pattern_manager.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851
  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, _, filenames in os.walk(THETA_RHO_DIR):
  237. for file in filenames:
  238. relative_path = os.path.relpath(os.path.join(root, file), THETA_RHO_DIR)
  239. # Normalize path separators to always use forward slashes for consistency across platforms
  240. relative_path = relative_path.replace(os.sep, '/')
  241. files.append(relative_path)
  242. logger.debug(f"Found {len(files)} theta-rho files")
  243. return [file for file in files if file.endswith('.thr')]
  244. def parse_theta_rho_file(file_path):
  245. """Parse a theta-rho file and return a list of (theta, rho) pairs."""
  246. coordinates = []
  247. try:
  248. logger.debug(f"Parsing theta-rho file: {file_path}")
  249. with open(file_path, 'r', encoding='utf-8') as file:
  250. for line in file:
  251. line = line.strip()
  252. if not line or line.startswith("#"):
  253. continue
  254. try:
  255. theta, rho = map(float, line.split())
  256. coordinates.append((theta, rho))
  257. except ValueError:
  258. logger.warning(f"Skipping invalid line: {line}")
  259. continue
  260. except Exception as e:
  261. logger.error(f"Error reading file: {e}")
  262. return coordinates
  263. logger.debug(f"Parsed {len(coordinates)} coordinates from {file_path}")
  264. return coordinates
  265. def get_first_rho_from_cache(file_path):
  266. """Get the first rho value from cached metadata, falling back to file parsing if needed."""
  267. try:
  268. # Import cache_manager locally to avoid circular import
  269. from modules.core import cache_manager
  270. # Try to get from metadata cache first
  271. file_name = os.path.basename(file_path)
  272. metadata = cache_manager.get_pattern_metadata(file_name)
  273. if metadata and 'first_coordinate' in metadata:
  274. # In the cache, 'x' is theta and 'y' is rho
  275. return metadata['first_coordinate']['y']
  276. # Fallback to parsing the file if not in cache
  277. logger.debug(f"Metadata not cached for {file_name}, parsing file")
  278. coordinates = parse_theta_rho_file(file_path)
  279. if coordinates:
  280. return coordinates[0][1] # Return rho value
  281. return None
  282. except Exception as e:
  283. logger.warning(f"Error getting first rho from cache for {file_path}: {str(e)}")
  284. return None
  285. def get_clear_pattern_file(clear_pattern_mode, path=None):
  286. """Return a .thr file path based on pattern_name and table type."""
  287. if not clear_pattern_mode or clear_pattern_mode == 'none':
  288. return
  289. # Define patterns for each table type
  290. clear_patterns = {
  291. 'dune_weaver': {
  292. 'clear_from_out': './patterns/clear_from_out.thr',
  293. 'clear_from_in': './patterns/clear_from_in.thr',
  294. 'clear_sideway': './patterns/clear_sideway.thr'
  295. },
  296. 'dune_weaver_mini': {
  297. 'clear_from_out': './patterns/clear_from_out_mini.thr',
  298. 'clear_from_in': './patterns/clear_from_in_mini.thr',
  299. 'clear_sideway': './patterns/clear_sideway_mini.thr'
  300. },
  301. 'dune_weaver_pro': {
  302. 'clear_from_out': './patterns/clear_from_out_pro.thr',
  303. 'clear_from_out_Ultra': './patterns/clear_from_out_Ultra.thr',
  304. 'clear_from_in': './patterns/clear_from_in_pro.thr',
  305. 'clear_from_in_Ultra': './patterns/clear_from_in_Ultra.thr',
  306. 'clear_sideway': './patterns/clear_sideway_pro.thr'
  307. }
  308. }
  309. # Get patterns for current table type, fallback to standard patterns if type not found
  310. table_patterns = clear_patterns.get(state.table_type, clear_patterns['dune_weaver'])
  311. # Check for custom patterns first
  312. if state.custom_clear_from_out and clear_pattern_mode in ['clear_from_out', 'adaptive']:
  313. if clear_pattern_mode == 'adaptive':
  314. # For adaptive mode, use cached metadata to check first rho
  315. if path:
  316. first_rho = get_first_rho_from_cache(path)
  317. if first_rho is not None and first_rho < 0.5:
  318. # Use custom clear_from_out if set
  319. custom_path = os.path.join('./patterns', state.custom_clear_from_out)
  320. if os.path.exists(custom_path):
  321. logger.debug(f"Using custom clear_from_out: {custom_path}")
  322. return custom_path
  323. elif clear_pattern_mode == 'clear_from_out':
  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. if state.custom_clear_from_in and clear_pattern_mode in ['clear_from_in', 'adaptive']:
  329. if clear_pattern_mode == 'adaptive':
  330. # For adaptive mode, use cached metadata to check first rho
  331. if path:
  332. first_rho = get_first_rho_from_cache(path)
  333. if first_rho is not None and first_rho >= 0.5:
  334. # Use custom clear_from_in if set
  335. custom_path = os.path.join('./patterns', state.custom_clear_from_in)
  336. if os.path.exists(custom_path):
  337. logger.debug(f"Using custom clear_from_in: {custom_path}")
  338. return custom_path
  339. elif clear_pattern_mode == 'clear_from_in':
  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. logger.debug(f"Clear pattern mode: {clear_pattern_mode} for table type: {state.table_type}")
  345. if clear_pattern_mode == "random":
  346. return random.choice(list(table_patterns.values()))
  347. if clear_pattern_mode == 'adaptive':
  348. if not path:
  349. logger.warning("No path provided for adaptive clear pattern")
  350. return random.choice(list(table_patterns.values()))
  351. # Use cached metadata to get first rho value
  352. first_rho = get_first_rho_from_cache(path)
  353. if first_rho is None:
  354. logger.warning("Could not determine first rho value for adaptive clear pattern")
  355. return random.choice(list(table_patterns.values()))
  356. if first_rho < 0.5:
  357. return table_patterns['clear_from_out']
  358. else:
  359. return table_patterns['clear_from_in']
  360. else:
  361. if clear_pattern_mode not in table_patterns:
  362. return False
  363. return table_patterns[clear_pattern_mode]
  364. def is_clear_pattern(file_path):
  365. """Check if a file path is a clear pattern file."""
  366. # Get all possible clear pattern files for all table types
  367. clear_patterns = []
  368. for table_type in ['dune_weaver', 'dune_weaver_mini', 'dune_weaver_pro']:
  369. clear_patterns.extend([
  370. f'./patterns/clear_from_out{("_" + table_type.split("_")[-1]) if table_type != "dune_weaver" else ""}.thr',
  371. f'./patterns/clear_from_in{("_" + table_type.split("_")[-1]) if table_type != "dune_weaver" else ""}.thr',
  372. f'./patterns/clear_sideway{("_" + table_type.split("_")[-1]) if table_type != "dune_weaver" else ""}.thr'
  373. ])
  374. # Normalize paths for comparison
  375. normalized_path = os.path.normpath(file_path)
  376. normalized_clear_patterns = [os.path.normpath(p) for p in clear_patterns]
  377. # Check if the file path matches any clear pattern path
  378. return normalized_path in normalized_clear_patterns
  379. async def run_theta_rho_file(file_path, is_playlist=False):
  380. """Run a theta-rho file by sending data in optimized batches with tqdm ETA tracking."""
  381. if pattern_lock.locked():
  382. logger.warning("Another pattern is already running. Cannot start a new one.")
  383. return
  384. async with pattern_lock: # This ensures only one pattern can run at a time
  385. # Start progress update task only if not part of a playlist
  386. global progress_update_task
  387. if not is_playlist and not progress_update_task:
  388. progress_update_task = asyncio.create_task(broadcast_progress())
  389. coordinates = parse_theta_rho_file(file_path)
  390. total_coordinates = len(coordinates)
  391. if total_coordinates < 2:
  392. logger.warning("Not enough coordinates for interpolation")
  393. if not is_playlist:
  394. state.current_playing_file = None
  395. state.execution_progress = None
  396. return
  397. # Determine if this is a clearing pattern
  398. is_clear_file = is_clear_pattern(file_path)
  399. if is_clear_file:
  400. initial_speed = state.clear_pattern_speed if state.clear_pattern_speed is not None else state.speed
  401. logger.info(f"Running clearing pattern at initial speed {initial_speed}")
  402. else:
  403. logger.info(f"Running normal pattern at initial speed {state.speed}")
  404. state.execution_progress = (0, total_coordinates, None, 0)
  405. # stop actions without resetting the playlist
  406. await stop_actions(clear_playlist=False)
  407. state.current_playing_file = file_path
  408. state.stop_requested = False
  409. logger.info(f"Starting pattern execution: {file_path}")
  410. logger.info(f"t: {state.current_theta}, r: {state.current_rho}")
  411. await reset_theta()
  412. start_time = time.time()
  413. if state.led_controller:
  414. effect_playing(state.led_controller)
  415. with tqdm(
  416. total=total_coordinates,
  417. unit="coords",
  418. desc=f"Executing Pattern {file_path}",
  419. dynamic_ncols=True,
  420. disable=False,
  421. mininterval=1.0
  422. ) as pbar:
  423. for i, coordinate in enumerate(coordinates):
  424. theta, rho = coordinate
  425. if state.stop_requested:
  426. logger.info("Execution stopped by user")
  427. if state.led_controller:
  428. effect_idle(state.led_controller)
  429. break
  430. if state.skip_requested:
  431. logger.info("Skipping pattern...")
  432. connection_manager.check_idle()
  433. if state.led_controller:
  434. effect_idle(state.led_controller)
  435. break
  436. # Wait for resume if paused
  437. if state.pause_requested:
  438. logger.info("Execution paused...")
  439. if state.led_controller:
  440. effect_idle(state.led_controller)
  441. await pause_event.wait()
  442. logger.info("Execution resumed...")
  443. if state.led_controller:
  444. effect_playing(state.led_controller)
  445. # Dynamically determine the speed for each movement
  446. # Use clear_pattern_speed if it's set and this is a clear file, otherwise use state.speed
  447. if is_clear_file and state.clear_pattern_speed is not None:
  448. current_speed = state.clear_pattern_speed
  449. else:
  450. current_speed = state.speed
  451. await move_polar(theta, rho, current_speed)
  452. # Update progress for all coordinates including the first one
  453. pbar.update(1)
  454. elapsed_time = time.time() - start_time
  455. estimated_remaining_time = (total_coordinates - (i + 1)) / pbar.format_dict['rate'] if pbar.format_dict['rate'] and total_coordinates else 0
  456. state.execution_progress = (i + 1, total_coordinates, estimated_remaining_time, elapsed_time)
  457. # Add a small delay to allow other async operations
  458. await asyncio.sleep(0.001)
  459. # Update progress one last time to show 100%
  460. elapsed_time = time.time() - start_time
  461. state.execution_progress = (total_coordinates, total_coordinates, 0, elapsed_time)
  462. # Give WebSocket a chance to send the final update
  463. await asyncio.sleep(0.1)
  464. if not state.conn:
  465. logger.error("Device is not connected. Stopping pattern execution.")
  466. return
  467. connection_manager.check_idle()
  468. # Set LED back to idle when pattern completes normally (not stopped early)
  469. if state.led_controller and not state.stop_requested:
  470. effect_idle(state.led_controller)
  471. logger.debug("LED effect set to idle after pattern completion")
  472. # Only clear state if not part of a playlist
  473. if not is_playlist:
  474. state.current_playing_file = None
  475. state.execution_progress = None
  476. logger.info("Pattern execution completed and state cleared")
  477. else:
  478. logger.info("Pattern execution completed, maintaining state for playlist")
  479. # Only cancel progress update task if not part of a playlist
  480. if not is_playlist and progress_update_task:
  481. progress_update_task.cancel()
  482. try:
  483. await progress_update_task
  484. except asyncio.CancelledError:
  485. pass
  486. progress_update_task = None
  487. async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
  488. """Run multiple .thr files in sequence with options."""
  489. state.stop_requested = False
  490. # Set initial playlist state
  491. state.playlist_mode = run_mode
  492. state.current_playlist_index = 0
  493. # Start progress update task for the playlist
  494. global progress_update_task
  495. if not progress_update_task:
  496. progress_update_task = asyncio.create_task(broadcast_progress())
  497. if shuffle:
  498. random.shuffle(file_paths)
  499. logger.info("Playlist shuffled")
  500. if shuffle:
  501. random.shuffle(file_paths)
  502. logger.info("Playlist shuffled")
  503. try:
  504. while True:
  505. # Construct the complete pattern sequence
  506. pattern_sequence = []
  507. for path in file_paths:
  508. # Add clear pattern if specified
  509. if clear_pattern and clear_pattern != 'none':
  510. clear_file_path = get_clear_pattern_file(clear_pattern, path)
  511. if clear_file_path:
  512. pattern_sequence.append(clear_file_path)
  513. # Add main pattern
  514. pattern_sequence.append(path)
  515. # Shuffle if requested
  516. if shuffle:
  517. # Get pairs of patterns (clear + main) to keep them together
  518. pairs = [pattern_sequence[i:i+2] for i in range(0, len(pattern_sequence), 2)]
  519. random.shuffle(pairs)
  520. # Flatten the pairs back into a single list
  521. pattern_sequence = [pattern for pair in pairs for pattern in pair]
  522. logger.info("Playlist shuffled")
  523. # Set the playlist to the first pattern
  524. state.current_playlist = pattern_sequence
  525. # Execute the pattern sequence
  526. for idx, file_path in enumerate(pattern_sequence):
  527. state.current_playlist_index = idx
  528. if state.stop_requested:
  529. logger.info("Execution stopped")
  530. return
  531. # Update state for main patterns only
  532. logger.info(f"Running pattern {file_path}")
  533. # Execute the pattern
  534. await run_theta_rho_file(file_path, is_playlist=True)
  535. # Handle pause between patterns
  536. if idx < len(pattern_sequence) - 1 and not state.stop_requested and pause_time > 0 and not state.skip_requested:
  537. # Check if current pattern is a clear pattern
  538. if is_clear_pattern(file_path):
  539. logger.info("Skipping pause after clear pattern")
  540. else:
  541. logger.info(f"Pausing for {pause_time} seconds")
  542. state.original_pause_time = pause_time
  543. pause_start = time.time()
  544. while time.time() - pause_start < pause_time:
  545. state.pause_time_remaining = pause_start + pause_time - time.time()
  546. if state.skip_requested:
  547. logger.info("Pause interrupted by stop/skip request")
  548. break
  549. await asyncio.sleep(1)
  550. state.pause_time_remaining = 0
  551. state.skip_requested = False
  552. if run_mode == "indefinite":
  553. logger.info("Playlist completed. Restarting as per 'indefinite' run mode")
  554. if pause_time > 0:
  555. logger.debug(f"Pausing for {pause_time} seconds before restarting")
  556. pause_start = time.time()
  557. while time.time() - pause_start < pause_time:
  558. state.pause_time_remaining = pause_start + pause_time - time.time()
  559. if state.skip_requested:
  560. logger.info("Pause interrupted by stop/skip request")
  561. break
  562. await asyncio.sleep(1)
  563. state.pause_time_remaining = 0
  564. continue
  565. else:
  566. logger.info("Playlist completed")
  567. break
  568. finally:
  569. # Clean up progress update task
  570. if progress_update_task:
  571. progress_update_task.cancel()
  572. try:
  573. await progress_update_task
  574. except asyncio.CancelledError:
  575. pass
  576. progress_update_task = None
  577. # Clear all state variables
  578. state.current_playing_file = None
  579. state.execution_progress = None
  580. state.current_playlist = None
  581. state.current_playlist_index = None
  582. state.playlist_mode = None
  583. if state.led_controller:
  584. effect_idle(state.led_controller)
  585. logger.info("All requested patterns completed (or stopped) and state cleared")
  586. async def stop_actions(clear_playlist = True):
  587. """Stop all current actions."""
  588. try:
  589. with state.pause_condition:
  590. state.pause_requested = False
  591. state.stop_requested = True
  592. state.current_playing_file = None
  593. state.execution_progress = None
  594. state.is_clearing = False
  595. if clear_playlist:
  596. # Clear playlist state
  597. state.current_playlist = None
  598. state.current_playlist_index = None
  599. state.playlist_mode = None
  600. # Cancel progress update task if we're clearing the playlist
  601. global progress_update_task
  602. if progress_update_task and not progress_update_task.done():
  603. progress_update_task.cancel()
  604. state.pause_condition.notify_all()
  605. # Call async function directly since we're in async context
  606. await connection_manager.update_machine_position()
  607. except Exception as e:
  608. logger.error(f"Error during stop_actions: {e}")
  609. # Ensure we still update machine position even if there's an error
  610. try:
  611. await connection_manager.update_machine_position()
  612. except Exception as update_err:
  613. logger.error(f"Error updating machine position on error: {update_err}")
  614. async def move_polar(theta, rho, speed=None):
  615. """
  616. Queue a motion command to be executed in the dedicated motion control thread.
  617. This makes motion control non-blocking for API endpoints.
  618. Args:
  619. theta (float): Target theta coordinate
  620. rho (float): Target rho coordinate
  621. speed (int, optional): Speed override. If None, uses state.speed
  622. """
  623. # Ensure motion control thread is running
  624. if not motion_controller.running:
  625. motion_controller.start()
  626. # Create future for async/await pattern
  627. loop = asyncio.get_event_loop()
  628. future = loop.create_future()
  629. # Create and queue motion command
  630. command = MotionCommand(
  631. command_type='move',
  632. theta=theta,
  633. rho=rho,
  634. speed=speed,
  635. future=future
  636. )
  637. motion_controller.command_queue.put(command)
  638. logger.debug(f"Queued motion command: theta={theta}, rho={rho}, speed={speed}")
  639. # Wait for command completion
  640. await future
  641. def pause_execution():
  642. """Pause pattern execution using asyncio Event."""
  643. logger.info("Pausing pattern execution")
  644. state.pause_requested = True
  645. pause_event.clear() # Clear the event to pause execution
  646. return True
  647. def resume_execution():
  648. """Resume pattern execution using asyncio Event."""
  649. logger.info("Resuming pattern execution")
  650. state.pause_requested = False
  651. pause_event.set() # Set the event to resume execution
  652. return True
  653. async def reset_theta():
  654. logger.info('Resetting Theta')
  655. state.current_theta = state.current_theta % (2 * pi)
  656. # Call async function directly since we're in async context
  657. await connection_manager.update_machine_position()
  658. def set_speed(new_speed):
  659. state.speed = new_speed
  660. logger.info(f'Set new state.speed {new_speed}')
  661. def get_status():
  662. """Get the current status of pattern execution."""
  663. status = {
  664. "current_file": state.current_playing_file,
  665. "is_paused": state.pause_requested,
  666. "is_running": bool(state.current_playing_file and not state.stop_requested),
  667. "progress": None,
  668. "playlist": None,
  669. "speed": state.speed,
  670. "pause_time_remaining": state.pause_time_remaining,
  671. "original_pause_time": getattr(state, 'original_pause_time', None),
  672. "connection_status": state.conn.is_connected() if state.conn else False,
  673. "current_theta": state.current_theta,
  674. "current_rho": state.current_rho
  675. }
  676. # Add playlist information if available
  677. if state.current_playlist and state.current_playlist_index is not None:
  678. next_index = state.current_playlist_index + 1
  679. status["playlist"] = {
  680. "current_index": state.current_playlist_index,
  681. "total_files": len(state.current_playlist),
  682. "mode": state.playlist_mode,
  683. "next_file": state.current_playlist[next_index] if next_index < len(state.current_playlist) else None
  684. }
  685. if state.execution_progress:
  686. current, total, remaining_time, elapsed_time = state.execution_progress
  687. status["progress"] = {
  688. "current": current,
  689. "total": total,
  690. "remaining_time": remaining_time,
  691. "elapsed_time": elapsed_time,
  692. "percentage": (current / total * 100) if total > 0 else 0
  693. }
  694. return status
  695. async def broadcast_progress():
  696. """Background task to broadcast progress updates."""
  697. from main import broadcast_status_update
  698. while True:
  699. # Send status updates regardless of pattern_lock state
  700. status = get_status()
  701. # Use the existing broadcast function from main.py
  702. await broadcast_status_update(status)
  703. # Check if we should stop broadcasting
  704. if not state.current_playlist:
  705. # If no playlist, only stop if no pattern is being executed
  706. if not pattern_lock.locked():
  707. logger.info("No playlist or pattern running, stopping broadcast")
  708. break
  709. # Wait before next update
  710. await asyncio.sleep(1)