pattern_manager.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859
  1. import os
  2. import threading
  3. import time
  4. import random
  5. import logging
  6. from datetime import datetime
  7. from tqdm import tqdm
  8. from modules.connection import connection_manager
  9. from modules.core.state import state
  10. from math import pi
  11. import asyncio
  12. import json
  13. from modules.led.led_controller import effect_playing, effect_idle
  14. import queue
  15. from dataclasses import dataclass
  16. from typing import Optional, Callable
  17. # Configure logging
  18. logger = logging.getLogger(__name__)
  19. # Global state
  20. THETA_RHO_DIR = './patterns'
  21. os.makedirs(THETA_RHO_DIR, exist_ok=True)
  22. # Create an asyncio Event for pause/resume
  23. pause_event = asyncio.Event()
  24. pause_event.set() # Initially not paused
  25. # Create an asyncio Lock for pattern execution
  26. pattern_lock = asyncio.Lock()
  27. # Progress update task
  28. progress_update_task = None
  29. # Motion Control Thread Infrastructure
  30. @dataclass
  31. class MotionCommand:
  32. """Represents a motion command for the motion control thread."""
  33. command_type: str # 'move', 'stop', 'pause', 'resume', 'shutdown'
  34. theta: Optional[float] = None
  35. rho: Optional[float] = None
  36. speed: Optional[float] = None
  37. callback: Optional[Callable] = None
  38. future: Optional[asyncio.Future] = None
  39. class MotionControlThread:
  40. """Dedicated thread for hardware motion control operations."""
  41. def __init__(self):
  42. self.command_queue = queue.Queue()
  43. self.thread = None
  44. self.running = False
  45. self.paused = False
  46. def start(self):
  47. """Start the motion control thread."""
  48. if self.thread and self.thread.is_alive():
  49. return
  50. self.running = True
  51. self.thread = threading.Thread(target=self._motion_loop, daemon=True)
  52. self.thread.start()
  53. logger.info("Motion control thread started")
  54. def stop(self):
  55. """Stop the motion control thread."""
  56. if not self.running:
  57. return
  58. self.running = False
  59. # Send shutdown command
  60. self.command_queue.put(MotionCommand('shutdown'))
  61. if self.thread and self.thread.is_alive():
  62. self.thread.join(timeout=5.0)
  63. logger.info("Motion control thread stopped")
  64. def _motion_loop(self):
  65. """Main loop for the motion control thread."""
  66. logger.info("Motion control thread loop started")
  67. while self.running:
  68. try:
  69. # Get command with timeout to allow periodic checks
  70. command = self.command_queue.get(timeout=1.0)
  71. if command.command_type == 'shutdown':
  72. break
  73. elif command.command_type == 'move':
  74. self._execute_move(command)
  75. elif command.command_type == 'pause':
  76. self.paused = True
  77. elif command.command_type == 'resume':
  78. self.paused = False
  79. elif command.command_type == 'stop':
  80. # Clear any pending commands
  81. while not self.command_queue.empty():
  82. try:
  83. self.command_queue.get_nowait()
  84. except queue.Empty:
  85. break
  86. self.command_queue.task_done()
  87. except queue.Empty:
  88. # Timeout - continue loop for shutdown check
  89. continue
  90. except Exception as e:
  91. logger.error(f"Error in motion control thread: {e}")
  92. logger.info("Motion control thread loop ended")
  93. def _execute_move(self, command: MotionCommand):
  94. """Execute a move command in the motion thread."""
  95. try:
  96. # Wait if paused
  97. while self.paused and self.running:
  98. time.sleep(0.1)
  99. if not self.running:
  100. return
  101. # Execute the actual motion using sync version
  102. self._move_polar_sync(command.theta, command.rho, command.speed)
  103. # Signal completion if future provided
  104. if command.future and not command.future.done():
  105. command.future.get_loop().call_soon_threadsafe(
  106. command.future.set_result, None
  107. )
  108. except Exception as e:
  109. logger.error(f"Error executing move command: {e}")
  110. if command.future and not command.future.done():
  111. command.future.get_loop().call_soon_threadsafe(
  112. command.future.set_exception, e
  113. )
  114. def _move_polar_sync(self, theta: float, rho: float, speed: Optional[float] = None):
  115. """Synchronous version of move_polar for use in motion thread."""
  116. # This is the original sync logic but running in dedicated thread
  117. if state.table_type == 'dune_weaver_mini':
  118. x_scaling_factor = 2
  119. y_scaling_factor = 3.7
  120. else:
  121. x_scaling_factor = 2
  122. y_scaling_factor = 5
  123. delta_theta = theta - state.current_theta
  124. delta_rho = rho - state.current_rho
  125. x_increment = delta_theta * 100 / (2 * pi * x_scaling_factor)
  126. y_increment = delta_rho * 100 / y_scaling_factor
  127. x_total_steps = state.x_steps_per_mm * (100/x_scaling_factor)
  128. y_total_steps = state.y_steps_per_mm * (100/y_scaling_factor)
  129. offset = x_increment * (x_total_steps * x_scaling_factor / (state.gear_ratio * y_total_steps * y_scaling_factor))
  130. if state.table_type == 'dune_weaver_mini' or state.y_steps_per_mm == 546:
  131. y_increment -= offset
  132. else:
  133. y_increment += offset
  134. new_x_abs = state.machine_x + x_increment
  135. new_y_abs = state.machine_y + y_increment
  136. # Use provided speed or fall back to state.speed
  137. actual_speed = speed if speed is not None else state.speed
  138. # Call sync version of send_grbl_coordinates in this thread
  139. self._send_grbl_coordinates_sync(round(new_x_abs, 3), round(new_y_abs, 3), actual_speed)
  140. # Update state
  141. state.current_theta = theta
  142. state.current_rho = rho
  143. state.machine_x = new_x_abs
  144. state.machine_y = new_y_abs
  145. def _send_grbl_coordinates_sync(self, x: float, y: float, speed: int = 600, timeout: int = 2, home: bool = False):
  146. """Synchronous version of send_grbl_coordinates for motion thread."""
  147. logger.debug(f"Motion thread sending G-code: X{x} Y{y} at F{speed}")
  148. # Track overall attempt time
  149. overall_start_time = time.time()
  150. while True:
  151. try:
  152. gcode = f"$J=G91 G21 Y{y} F{speed}" if home else f"G1 X{x} Y{y} F{speed}"
  153. state.conn.send(gcode + "\n")
  154. logger.debug(f"Motion thread sent command: {gcode}")
  155. start_time = time.time()
  156. while True:
  157. response = state.conn.readline()
  158. logger.debug(f"Motion thread response: {response}")
  159. if response.lower() == "ok":
  160. logger.debug("Motion thread: Command execution confirmed.")
  161. return
  162. except Exception as e:
  163. error_str = str(e)
  164. logger.warning(f"Motion thread error sending command: {error_str}")
  165. # Immediately return for device not configured errors
  166. if "Device not configured" in error_str or "Errno 6" in error_str:
  167. logger.error(f"Motion thread: Device configuration error detected: {error_str}")
  168. state.stop_requested = True
  169. state.conn = None
  170. state.is_connected = False
  171. logger.info("Connection marked as disconnected due to device error")
  172. return False
  173. logger.warning(f"Motion thread: No 'ok' received for X{x} Y{y}, speed {speed}. Retrying...")
  174. time.sleep(0.1)
  175. # Global motion control thread instance
  176. motion_controller = MotionControlThread()
  177. async def cleanup_pattern_manager():
  178. """Clean up pattern manager resources"""
  179. global progress_update_task, pattern_lock, pause_event
  180. try:
  181. # Stop motion control thread
  182. motion_controller.stop()
  183. # Cancel progress update task if running
  184. if progress_update_task and not progress_update_task.done():
  185. try:
  186. progress_update_task.cancel()
  187. # Wait for task to actually cancel
  188. try:
  189. await progress_update_task
  190. except asyncio.CancelledError:
  191. pass
  192. except Exception as e:
  193. logger.error(f"Error cancelling progress update task: {e}")
  194. # Clean up pattern lock
  195. if pattern_lock:
  196. try:
  197. if pattern_lock.locked():
  198. pattern_lock.release()
  199. pattern_lock = None
  200. except Exception as e:
  201. logger.error(f"Error cleaning up pattern lock: {e}")
  202. # Clean up pause event
  203. if pause_event:
  204. try:
  205. pause_event.set() # Wake up any waiting tasks
  206. pause_event = None
  207. except Exception as e:
  208. logger.error(f"Error cleaning up pause event: {e}")
  209. # Clean up pause condition from state
  210. if state.pause_condition:
  211. try:
  212. with state.pause_condition:
  213. state.pause_condition.notify_all()
  214. state.pause_condition = threading.Condition()
  215. except Exception as e:
  216. logger.error(f"Error cleaning up pause condition: {e}")
  217. # Clear all state variables
  218. state.current_playing_file = None
  219. state.execution_progress = 0
  220. state.is_running = False
  221. state.pause_requested = False
  222. state.stop_requested = True
  223. state.is_clearing = False
  224. # Reset machine position
  225. await connection_manager.update_machine_position()
  226. logger.info("Pattern manager resources cleaned up")
  227. except Exception as e:
  228. logger.error(f"Error during pattern manager cleanup: {e}")
  229. finally:
  230. # Ensure we always reset these
  231. progress_update_task = None
  232. pattern_lock = None
  233. pause_event = None
  234. def list_theta_rho_files():
  235. files = []
  236. for root, dirs, filenames in os.walk(THETA_RHO_DIR):
  237. # Skip cached_images directories to avoid scanning thousands of WebP files
  238. if 'cached_images' in dirs:
  239. dirs.remove('cached_images')
  240. # Filter .thr files during traversal for better performance
  241. thr_files = [f for f in filenames if f.endswith('.thr')]
  242. for file in thr_files:
  243. relative_path = os.path.relpath(os.path.join(root, file), THETA_RHO_DIR)
  244. # Normalize path separators to always use forward slashes for consistency across platforms
  245. relative_path = relative_path.replace(os.sep, '/')
  246. files.append(relative_path)
  247. logger.debug(f"Found {len(files)} theta-rho files")
  248. return files
  249. def parse_theta_rho_file(file_path):
  250. """Parse a theta-rho file and return a list of (theta, rho) pairs."""
  251. coordinates = []
  252. try:
  253. logger.debug(f"Parsing theta-rho file: {file_path}")
  254. with open(file_path, 'r', encoding='utf-8') as file:
  255. for line in file:
  256. line = line.strip()
  257. if not line or line.startswith("#"):
  258. continue
  259. try:
  260. theta, rho = map(float, line.split())
  261. coordinates.append((theta, rho))
  262. except ValueError:
  263. logger.warning(f"Skipping invalid line: {line}")
  264. continue
  265. except Exception as e:
  266. logger.error(f"Error reading file: {e}")
  267. return coordinates
  268. logger.debug(f"Parsed {len(coordinates)} coordinates from {file_path}")
  269. return coordinates
  270. def get_first_rho_from_cache(file_path):
  271. """Get the first rho value from cached metadata, falling back to file parsing if needed."""
  272. try:
  273. # Import cache_manager locally to avoid circular import
  274. from modules.core import cache_manager
  275. # Try to get from metadata cache first
  276. file_name = os.path.basename(file_path)
  277. metadata = cache_manager.get_pattern_metadata(file_name)
  278. if metadata and 'first_coordinate' in metadata:
  279. # In the cache, 'x' is theta and 'y' is rho
  280. return metadata['first_coordinate']['y']
  281. # Fallback to parsing the file if not in cache
  282. logger.debug(f"Metadata not cached for {file_name}, parsing file")
  283. coordinates = parse_theta_rho_file(file_path)
  284. if coordinates:
  285. return coordinates[0][1] # Return rho value
  286. return None
  287. except Exception as e:
  288. logger.warning(f"Error getting first rho from cache for {file_path}: {str(e)}")
  289. return None
  290. def get_clear_pattern_file(clear_pattern_mode, path=None):
  291. """Return a .thr file path based on pattern_name and table type."""
  292. if not clear_pattern_mode or clear_pattern_mode == 'none':
  293. return
  294. # Define patterns for each table type
  295. clear_patterns = {
  296. 'dune_weaver': {
  297. 'clear_from_out': './patterns/clear_from_out.thr',
  298. 'clear_from_in': './patterns/clear_from_in.thr',
  299. 'clear_sideway': './patterns/clear_sideway.thr'
  300. },
  301. 'dune_weaver_mini': {
  302. 'clear_from_out': './patterns/clear_from_out_mini.thr',
  303. 'clear_from_in': './patterns/clear_from_in_mini.thr',
  304. 'clear_sideway': './patterns/clear_sideway_mini.thr'
  305. },
  306. 'dune_weaver_pro': {
  307. 'clear_from_out': './patterns/clear_from_out_pro.thr',
  308. 'clear_from_out_Ultra': './patterns/clear_from_out_Ultra.thr',
  309. 'clear_from_in': './patterns/clear_from_in_pro.thr',
  310. 'clear_from_in_Ultra': './patterns/clear_from_in_Ultra.thr',
  311. 'clear_sideway': './patterns/clear_sideway_pro.thr'
  312. }
  313. }
  314. # Get patterns for current table type, fallback to standard patterns if type not found
  315. table_patterns = clear_patterns.get(state.table_type, clear_patterns['dune_weaver'])
  316. # Check for custom patterns first
  317. if state.custom_clear_from_out and clear_pattern_mode in ['clear_from_out', 'adaptive']:
  318. if clear_pattern_mode == 'adaptive':
  319. # For adaptive mode, use cached metadata to check first rho
  320. if path:
  321. first_rho = get_first_rho_from_cache(path)
  322. if first_rho is not None and first_rho < 0.5:
  323. # Use custom clear_from_out if set
  324. custom_path = os.path.join('./patterns', state.custom_clear_from_out)
  325. if os.path.exists(custom_path):
  326. logger.debug(f"Using custom clear_from_out: {custom_path}")
  327. return custom_path
  328. elif clear_pattern_mode == 'clear_from_out':
  329. custom_path = os.path.join('./patterns', state.custom_clear_from_out)
  330. if os.path.exists(custom_path):
  331. logger.debug(f"Using custom clear_from_out: {custom_path}")
  332. return custom_path
  333. if state.custom_clear_from_in and clear_pattern_mode in ['clear_from_in', 'adaptive']:
  334. if clear_pattern_mode == 'adaptive':
  335. # For adaptive mode, use cached metadata to check first rho
  336. if path:
  337. first_rho = get_first_rho_from_cache(path)
  338. if first_rho is not None and first_rho >= 0.5:
  339. # Use custom clear_from_in if set
  340. custom_path = os.path.join('./patterns', state.custom_clear_from_in)
  341. if os.path.exists(custom_path):
  342. logger.debug(f"Using custom clear_from_in: {custom_path}")
  343. return custom_path
  344. elif clear_pattern_mode == 'clear_from_in':
  345. custom_path = os.path.join('./patterns', state.custom_clear_from_in)
  346. if os.path.exists(custom_path):
  347. logger.debug(f"Using custom clear_from_in: {custom_path}")
  348. return custom_path
  349. logger.debug(f"Clear pattern mode: {clear_pattern_mode} for table type: {state.table_type}")
  350. if clear_pattern_mode == "random":
  351. return random.choice(list(table_patterns.values()))
  352. if clear_pattern_mode == 'adaptive':
  353. if not path:
  354. logger.warning("No path provided for adaptive clear pattern")
  355. return random.choice(list(table_patterns.values()))
  356. # Use cached metadata to get first rho value
  357. first_rho = get_first_rho_from_cache(path)
  358. if first_rho is None:
  359. logger.warning("Could not determine first rho value for adaptive clear pattern")
  360. return random.choice(list(table_patterns.values()))
  361. if first_rho < 0.5:
  362. return table_patterns['clear_from_out']
  363. else:
  364. return table_patterns['clear_from_in']
  365. else:
  366. if clear_pattern_mode not in table_patterns:
  367. return False
  368. return table_patterns[clear_pattern_mode]
  369. def is_clear_pattern(file_path):
  370. """Check if a file path is a clear pattern file."""
  371. # Get all possible clear pattern files for all table types
  372. clear_patterns = []
  373. for table_type in ['dune_weaver', 'dune_weaver_mini', 'dune_weaver_pro']:
  374. clear_patterns.extend([
  375. f'./patterns/clear_from_out{("_" + table_type.split("_")[-1]) if table_type != "dune_weaver" else ""}.thr',
  376. f'./patterns/clear_from_in{("_" + table_type.split("_")[-1]) if table_type != "dune_weaver" else ""}.thr',
  377. f'./patterns/clear_sideway{("_" + table_type.split("_")[-1]) if table_type != "dune_weaver" else ""}.thr'
  378. ])
  379. # Normalize paths for comparison
  380. normalized_path = os.path.normpath(file_path)
  381. normalized_clear_patterns = [os.path.normpath(p) for p in clear_patterns]
  382. # Check if the file path matches any clear pattern path
  383. return normalized_path in normalized_clear_patterns
  384. async def run_theta_rho_file(file_path, is_playlist=False):
  385. """Run a theta-rho file by sending data in optimized batches with tqdm ETA tracking."""
  386. if pattern_lock.locked():
  387. logger.warning("Another pattern is already running. Cannot start a new one.")
  388. return
  389. async with pattern_lock: # This ensures only one pattern can run at a time
  390. # Start progress update task only if not part of a playlist
  391. global progress_update_task
  392. if not is_playlist and not progress_update_task:
  393. progress_update_task = asyncio.create_task(broadcast_progress())
  394. coordinates = parse_theta_rho_file(file_path)
  395. total_coordinates = len(coordinates)
  396. if total_coordinates < 2:
  397. logger.warning("Not enough coordinates for interpolation")
  398. if not is_playlist:
  399. state.current_playing_file = None
  400. state.execution_progress = None
  401. return
  402. # Determine if this is a clearing pattern
  403. is_clear_file = is_clear_pattern(file_path)
  404. if is_clear_file:
  405. initial_speed = state.clear_pattern_speed if state.clear_pattern_speed is not None else state.speed
  406. logger.info(f"Running clearing pattern at initial speed {initial_speed}")
  407. else:
  408. logger.info(f"Running normal pattern at initial speed {state.speed}")
  409. state.execution_progress = (0, total_coordinates, None, 0)
  410. # stop actions without resetting the playlist
  411. await stop_actions(clear_playlist=False)
  412. state.current_playing_file = file_path
  413. state.stop_requested = False
  414. logger.info(f"Starting pattern execution: {file_path}")
  415. logger.info(f"t: {state.current_theta}, r: {state.current_rho}")
  416. await reset_theta()
  417. start_time = time.time()
  418. if state.led_controller:
  419. effect_playing(state.led_controller)
  420. with tqdm(
  421. total=total_coordinates,
  422. unit="coords",
  423. desc=f"Executing Pattern {file_path}",
  424. dynamic_ncols=True,
  425. disable=False,
  426. mininterval=1.0
  427. ) as pbar:
  428. for i, coordinate in enumerate(coordinates):
  429. theta, rho = coordinate
  430. if state.stop_requested:
  431. logger.info("Execution stopped by user")
  432. if state.led_controller:
  433. effect_idle(state.led_controller)
  434. break
  435. if state.skip_requested:
  436. logger.info("Skipping pattern...")
  437. connection_manager.check_idle()
  438. if state.led_controller:
  439. effect_idle(state.led_controller)
  440. break
  441. # Wait for resume if paused
  442. if state.pause_requested:
  443. logger.info("Execution paused...")
  444. if state.led_controller:
  445. effect_idle(state.led_controller)
  446. await pause_event.wait()
  447. logger.info("Execution resumed...")
  448. if state.led_controller:
  449. effect_playing(state.led_controller)
  450. # Dynamically determine the speed for each movement
  451. # Use clear_pattern_speed if it's set and this is a clear file, otherwise use state.speed
  452. if is_clear_file and state.clear_pattern_speed is not None:
  453. current_speed = state.clear_pattern_speed
  454. else:
  455. current_speed = state.speed
  456. await move_polar(theta, rho, current_speed)
  457. # Update progress for all coordinates including the first one
  458. pbar.update(1)
  459. elapsed_time = time.time() - start_time
  460. estimated_remaining_time = (total_coordinates - (i + 1)) / pbar.format_dict['rate'] if pbar.format_dict['rate'] and total_coordinates else 0
  461. state.execution_progress = (i + 1, total_coordinates, estimated_remaining_time, elapsed_time)
  462. # Add a small delay to allow other async operations
  463. await asyncio.sleep(0.001)
  464. # Update progress one last time to show 100%
  465. elapsed_time = time.time() - start_time
  466. state.execution_progress = (total_coordinates, total_coordinates, 0, elapsed_time)
  467. # Give WebSocket a chance to send the final update
  468. await asyncio.sleep(0.1)
  469. if not state.conn:
  470. logger.error("Device is not connected. Stopping pattern execution.")
  471. return
  472. connection_manager.check_idle()
  473. # Set LED back to idle when pattern completes normally (not stopped early)
  474. if state.led_controller and not state.stop_requested:
  475. effect_idle(state.led_controller)
  476. logger.debug("LED effect set to idle after pattern completion")
  477. # Only clear state if not part of a playlist
  478. if not is_playlist:
  479. state.current_playing_file = None
  480. state.execution_progress = None
  481. logger.info("Pattern execution completed and state cleared")
  482. else:
  483. logger.info("Pattern execution completed, maintaining state for playlist")
  484. # Only cancel progress update task if not part of a playlist
  485. if not is_playlist and progress_update_task:
  486. progress_update_task.cancel()
  487. try:
  488. await progress_update_task
  489. except asyncio.CancelledError:
  490. pass
  491. progress_update_task = None
  492. async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
  493. """Run multiple .thr files in sequence with options."""
  494. state.stop_requested = False
  495. # Set initial playlist state
  496. state.playlist_mode = run_mode
  497. state.current_playlist_index = 0
  498. # Start progress update task for the playlist
  499. global progress_update_task
  500. if not progress_update_task:
  501. progress_update_task = asyncio.create_task(broadcast_progress())
  502. if shuffle:
  503. random.shuffle(file_paths)
  504. logger.info("Playlist shuffled")
  505. if shuffle:
  506. random.shuffle(file_paths)
  507. logger.info("Playlist shuffled")
  508. try:
  509. while True:
  510. # Construct the complete pattern sequence
  511. pattern_sequence = []
  512. for path in file_paths:
  513. # Add clear pattern if specified
  514. if clear_pattern and clear_pattern != 'none':
  515. clear_file_path = get_clear_pattern_file(clear_pattern, path)
  516. if clear_file_path:
  517. pattern_sequence.append(clear_file_path)
  518. # Add main pattern
  519. pattern_sequence.append(path)
  520. # Shuffle if requested
  521. if shuffle:
  522. # Get pairs of patterns (clear + main) to keep them together
  523. pairs = [pattern_sequence[i:i+2] for i in range(0, len(pattern_sequence), 2)]
  524. random.shuffle(pairs)
  525. # Flatten the pairs back into a single list
  526. pattern_sequence = [pattern for pair in pairs for pattern in pair]
  527. logger.info("Playlist shuffled")
  528. # Set the playlist to the first pattern
  529. state.current_playlist = pattern_sequence
  530. # Execute the pattern sequence
  531. for idx, file_path in enumerate(pattern_sequence):
  532. state.current_playlist_index = idx
  533. if state.stop_requested:
  534. logger.info("Execution stopped")
  535. return
  536. # Update state for main patterns only
  537. logger.info(f"Running pattern {file_path}")
  538. # Execute the pattern
  539. await run_theta_rho_file(file_path, is_playlist=True)
  540. # Handle pause between patterns
  541. if idx < len(pattern_sequence) - 1 and not state.stop_requested and pause_time > 0 and not state.skip_requested:
  542. # Check if current pattern is a clear pattern
  543. if is_clear_pattern(file_path):
  544. logger.info("Skipping pause after clear pattern")
  545. else:
  546. logger.info(f"Pausing for {pause_time} seconds")
  547. state.original_pause_time = pause_time
  548. pause_start = time.time()
  549. while time.time() - pause_start < pause_time:
  550. state.pause_time_remaining = pause_start + pause_time - time.time()
  551. if state.skip_requested:
  552. logger.info("Pause interrupted by stop/skip request")
  553. break
  554. await asyncio.sleep(1)
  555. state.pause_time_remaining = 0
  556. state.skip_requested = False
  557. if run_mode == "indefinite":
  558. logger.info("Playlist completed. Restarting as per 'indefinite' run mode")
  559. if pause_time > 0:
  560. logger.debug(f"Pausing for {pause_time} seconds before restarting")
  561. pause_start = time.time()
  562. while time.time() - pause_start < pause_time:
  563. state.pause_time_remaining = pause_start + pause_time - time.time()
  564. if state.skip_requested:
  565. logger.info("Pause interrupted by stop/skip request")
  566. break
  567. await asyncio.sleep(1)
  568. state.pause_time_remaining = 0
  569. continue
  570. else:
  571. logger.info("Playlist completed")
  572. break
  573. finally:
  574. # Clean up progress update task
  575. if progress_update_task:
  576. progress_update_task.cancel()
  577. try:
  578. await progress_update_task
  579. except asyncio.CancelledError:
  580. pass
  581. progress_update_task = None
  582. # Clear all state variables
  583. state.current_playing_file = None
  584. state.execution_progress = None
  585. state.current_playlist = None
  586. state.current_playlist_index = None
  587. state.playlist_mode = None
  588. if state.led_controller:
  589. effect_idle(state.led_controller)
  590. logger.info("All requested patterns completed (or stopped) and state cleared")
  591. async def stop_actions(clear_playlist = True):
  592. """Stop all current actions."""
  593. try:
  594. with state.pause_condition:
  595. state.pause_requested = False
  596. state.stop_requested = True
  597. state.current_playing_file = None
  598. state.execution_progress = None
  599. state.is_clearing = False
  600. if clear_playlist:
  601. # Clear playlist state
  602. state.current_playlist = None
  603. state.current_playlist_index = None
  604. state.playlist_mode = None
  605. # Cancel progress update task if we're clearing the playlist
  606. global progress_update_task
  607. if progress_update_task and not progress_update_task.done():
  608. progress_update_task.cancel()
  609. state.pause_condition.notify_all()
  610. # Call async function directly since we're in async context
  611. await connection_manager.update_machine_position()
  612. except Exception as e:
  613. logger.error(f"Error during stop_actions: {e}")
  614. # Ensure we still update machine position even if there's an error
  615. try:
  616. await connection_manager.update_machine_position()
  617. except Exception as update_err:
  618. logger.error(f"Error updating machine position on error: {update_err}")
  619. async def move_polar(theta, rho, speed=None):
  620. """
  621. Queue a motion command to be executed in the dedicated motion control thread.
  622. This makes motion control non-blocking for API endpoints.
  623. Args:
  624. theta (float): Target theta coordinate
  625. rho (float): Target rho coordinate
  626. speed (int, optional): Speed override. If None, uses state.speed
  627. """
  628. # Ensure motion control thread is running
  629. if not motion_controller.running:
  630. motion_controller.start()
  631. # Create future for async/await pattern
  632. loop = asyncio.get_event_loop()
  633. future = loop.create_future()
  634. # Create and queue motion command
  635. command = MotionCommand(
  636. command_type='move',
  637. theta=theta,
  638. rho=rho,
  639. speed=speed,
  640. future=future
  641. )
  642. motion_controller.command_queue.put(command)
  643. logger.debug(f"Queued motion command: theta={theta}, rho={rho}, speed={speed}")
  644. # Wait for command completion
  645. await future
  646. def pause_execution():
  647. """Pause pattern execution using asyncio Event."""
  648. logger.info("Pausing pattern execution")
  649. state.pause_requested = True
  650. pause_event.clear() # Clear the event to pause execution
  651. return True
  652. def resume_execution():
  653. """Resume pattern execution using asyncio Event."""
  654. logger.info("Resuming pattern execution")
  655. state.pause_requested = False
  656. pause_event.set() # Set the event to resume execution
  657. return True
  658. async def reset_theta():
  659. logger.info('Resetting Theta')
  660. state.current_theta = state.current_theta % (2 * pi)
  661. # Call async function directly since we're in async context
  662. await connection_manager.update_machine_position()
  663. def set_speed(new_speed):
  664. state.speed = new_speed
  665. logger.info(f'Set new state.speed {new_speed}')
  666. def get_status():
  667. """Get the current status of pattern execution."""
  668. status = {
  669. "current_file": state.current_playing_file,
  670. "is_paused": state.pause_requested,
  671. "is_running": bool(state.current_playing_file and not state.stop_requested),
  672. "progress": None,
  673. "playlist": None,
  674. "speed": state.speed,
  675. "pause_time_remaining": state.pause_time_remaining,
  676. "original_pause_time": getattr(state, 'original_pause_time', None),
  677. "connection_status": state.conn.is_connected() if state.conn else False,
  678. "current_theta": state.current_theta,
  679. "current_rho": state.current_rho
  680. }
  681. # Add playlist information if available
  682. if state.current_playlist and state.current_playlist_index is not None:
  683. next_index = state.current_playlist_index + 1
  684. status["playlist"] = {
  685. "current_index": state.current_playlist_index,
  686. "total_files": len(state.current_playlist),
  687. "mode": state.playlist_mode,
  688. "next_file": state.current_playlist[next_index] if next_index < len(state.current_playlist) else None
  689. }
  690. if state.execution_progress:
  691. current, total, remaining_time, elapsed_time = state.execution_progress
  692. status["progress"] = {
  693. "current": current,
  694. "total": total,
  695. "remaining_time": remaining_time,
  696. "elapsed_time": elapsed_time,
  697. "percentage": (current / total * 100) if total > 0 else 0
  698. }
  699. return status
  700. async def broadcast_progress():
  701. """Background task to broadcast progress updates."""
  702. from main import broadcast_status_update
  703. while True:
  704. # Send status updates regardless of pattern_lock state
  705. status = get_status()
  706. # Use the existing broadcast function from main.py
  707. await broadcast_status_update(status)
  708. # Check if we should stop broadcasting
  709. if not state.current_playlist:
  710. # If no playlist, only stop if no pattern is being executed
  711. if not pattern_lock.locked():
  712. logger.info("No playlist or pattern running, stopping broadcast")
  713. break
  714. # Wait before next update
  715. await asyncio.sleep(1)