pattern_manager.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680
  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. # Configure logging
  15. logger = logging.getLogger(__name__)
  16. # Global state
  17. THETA_RHO_DIR = './patterns'
  18. os.makedirs(THETA_RHO_DIR, exist_ok=True)
  19. # Create an asyncio Event for pause/resume
  20. pause_event = asyncio.Event()
  21. pause_event.set() # Initially not paused
  22. # Create an asyncio Lock for pattern execution
  23. pattern_lock = asyncio.Lock()
  24. # Progress update task
  25. progress_update_task = None
  26. async def cleanup_pattern_manager():
  27. """Clean up pattern manager resources"""
  28. global progress_update_task, pattern_lock, pause_event
  29. try:
  30. # Cancel progress update task if running
  31. if progress_update_task and not progress_update_task.done():
  32. try:
  33. progress_update_task.cancel()
  34. # Wait for task to actually cancel
  35. try:
  36. await progress_update_task
  37. except asyncio.CancelledError:
  38. pass
  39. except Exception as e:
  40. logger.error(f"Error cancelling progress update task: {e}")
  41. # Clean up pattern lock
  42. if pattern_lock:
  43. try:
  44. if pattern_lock.locked():
  45. pattern_lock.release()
  46. pattern_lock = None
  47. except Exception as e:
  48. logger.error(f"Error cleaning up pattern lock: {e}")
  49. # Clean up pause event
  50. if pause_event:
  51. try:
  52. pause_event.set() # Wake up any waiting tasks
  53. pause_event = None
  54. except Exception as e:
  55. logger.error(f"Error cleaning up pause event: {e}")
  56. # Clean up pause condition from state
  57. if state.pause_condition:
  58. try:
  59. with state.pause_condition:
  60. state.pause_condition.notify_all()
  61. state.pause_condition = threading.Condition()
  62. except Exception as e:
  63. logger.error(f"Error cleaning up pause condition: {e}")
  64. # Clear all state variables
  65. state.current_playing_file = None
  66. state.execution_progress = 0
  67. state.is_running = False
  68. state.pause_requested = False
  69. state.stop_requested = True
  70. state.is_clearing = False
  71. # Reset machine position
  72. await connection_manager.update_machine_position()
  73. logger.info("Pattern manager resources cleaned up")
  74. except Exception as e:
  75. logger.error(f"Error during pattern manager cleanup: {e}")
  76. finally:
  77. # Ensure we always reset these
  78. progress_update_task = None
  79. pattern_lock = None
  80. pause_event = None
  81. def list_theta_rho_files():
  82. files = []
  83. for root, _, filenames in os.walk(THETA_RHO_DIR):
  84. for file in filenames:
  85. relative_path = os.path.relpath(os.path.join(root, file), THETA_RHO_DIR)
  86. # Normalize path separators to always use forward slashes for consistency across platforms
  87. relative_path = relative_path.replace(os.sep, '/')
  88. files.append(relative_path)
  89. logger.debug(f"Found {len(files)} theta-rho files")
  90. return [file for file in files if file.endswith('.thr')]
  91. def parse_theta_rho_file(file_path):
  92. """Parse a theta-rho file and return a list of (theta, rho) pairs."""
  93. coordinates = []
  94. try:
  95. logger.debug(f"Parsing theta-rho file: {file_path}")
  96. with open(file_path, 'r', encoding='utf-8') as file:
  97. for line in file:
  98. line = line.strip()
  99. if not line or line.startswith("#"):
  100. continue
  101. try:
  102. theta, rho = map(float, line.split())
  103. coordinates.append((theta, rho))
  104. except ValueError:
  105. logger.warning(f"Skipping invalid line: {line}")
  106. continue
  107. except Exception as e:
  108. logger.error(f"Error reading file: {e}")
  109. return coordinates
  110. logger.debug(f"Parsed {len(coordinates)} coordinates from {file_path}")
  111. return coordinates
  112. def get_first_rho_from_cache(file_path):
  113. """Get the first rho value from cached metadata, falling back to file parsing if needed."""
  114. try:
  115. # Import cache_manager locally to avoid circular import
  116. from modules.core import cache_manager
  117. # Try to get from metadata cache first
  118. file_name = os.path.basename(file_path)
  119. metadata = cache_manager.get_pattern_metadata(file_name)
  120. if metadata and 'first_coordinate' in metadata:
  121. # In the cache, 'x' is theta and 'y' is rho
  122. return metadata['first_coordinate']['y']
  123. # Fallback to parsing the file if not in cache
  124. logger.debug(f"Metadata not cached for {file_name}, parsing file")
  125. coordinates = parse_theta_rho_file(file_path)
  126. if coordinates:
  127. return coordinates[0][1] # Return rho value
  128. return None
  129. except Exception as e:
  130. logger.warning(f"Error getting first rho from cache for {file_path}: {str(e)}")
  131. return None
  132. def get_clear_pattern_file(clear_pattern_mode, path=None):
  133. """Return a .thr file path based on pattern_name and table type."""
  134. if not clear_pattern_mode or clear_pattern_mode == 'none':
  135. return
  136. # Define patterns for each table type
  137. clear_patterns = {
  138. 'dune_weaver': {
  139. 'clear_from_out': './patterns/clear_from_out.thr',
  140. 'clear_from_in': './patterns/clear_from_in.thr',
  141. 'clear_sideway': './patterns/clear_sideway.thr'
  142. },
  143. 'dune_weaver_mini': {
  144. 'clear_from_out': './patterns/clear_from_out_mini.thr',
  145. 'clear_from_in': './patterns/clear_from_in_mini.thr',
  146. 'clear_sideway': './patterns/clear_sideway_mini.thr'
  147. },
  148. 'dune_weaver_pro': {
  149. 'clear_from_out': './patterns/clear_from_out_pro.thr',
  150. 'clear_from_out_Ultra': './patterns/clear_from_out_Ultra.thr',
  151. 'clear_from_in': './patterns/clear_from_in_pro.thr',
  152. 'clear_from_in_Ultra': './patterns/clear_from_in_Ultra.thr',
  153. 'clear_sideway': './patterns/clear_sideway_pro.thr'
  154. }
  155. }
  156. # Get patterns for current table type, fallback to standard patterns if type not found
  157. table_patterns = clear_patterns.get(state.table_type, clear_patterns['dune_weaver'])
  158. # Check for custom patterns first
  159. if state.custom_clear_from_out and clear_pattern_mode in ['clear_from_out', 'adaptive']:
  160. if clear_pattern_mode == 'adaptive':
  161. # For adaptive mode, use cached metadata to check first rho
  162. if path:
  163. first_rho = get_first_rho_from_cache(path)
  164. if first_rho is not None and first_rho < 0.5:
  165. # Use custom clear_from_out if set
  166. custom_path = os.path.join('./patterns', state.custom_clear_from_out)
  167. if os.path.exists(custom_path):
  168. logger.debug(f"Using custom clear_from_out: {custom_path}")
  169. return custom_path
  170. elif clear_pattern_mode == 'clear_from_out':
  171. custom_path = os.path.join('./patterns', state.custom_clear_from_out)
  172. if os.path.exists(custom_path):
  173. logger.debug(f"Using custom clear_from_out: {custom_path}")
  174. return custom_path
  175. if state.custom_clear_from_in and clear_pattern_mode in ['clear_from_in', 'adaptive']:
  176. if clear_pattern_mode == 'adaptive':
  177. # For adaptive mode, use cached metadata to check first rho
  178. if path:
  179. first_rho = get_first_rho_from_cache(path)
  180. if first_rho is not None and first_rho >= 0.5:
  181. # Use custom clear_from_in if set
  182. custom_path = os.path.join('./patterns', state.custom_clear_from_in)
  183. if os.path.exists(custom_path):
  184. logger.debug(f"Using custom clear_from_in: {custom_path}")
  185. return custom_path
  186. elif clear_pattern_mode == 'clear_from_in':
  187. custom_path = os.path.join('./patterns', state.custom_clear_from_in)
  188. if os.path.exists(custom_path):
  189. logger.debug(f"Using custom clear_from_in: {custom_path}")
  190. return custom_path
  191. logger.debug(f"Clear pattern mode: {clear_pattern_mode} for table type: {state.table_type}")
  192. if clear_pattern_mode == "random":
  193. return random.choice(list(table_patterns.values()))
  194. if clear_pattern_mode == 'adaptive':
  195. if not path:
  196. logger.warning("No path provided for adaptive clear pattern")
  197. return random.choice(list(table_patterns.values()))
  198. # Use cached metadata to get first rho value
  199. first_rho = get_first_rho_from_cache(path)
  200. if first_rho is None:
  201. logger.warning("Could not determine first rho value for adaptive clear pattern")
  202. return random.choice(list(table_patterns.values()))
  203. if first_rho < 0.5:
  204. return table_patterns['clear_from_out']
  205. else:
  206. return table_patterns['clear_from_in']
  207. else:
  208. if clear_pattern_mode not in table_patterns:
  209. return False
  210. return table_patterns[clear_pattern_mode]
  211. def is_clear_pattern(file_path):
  212. """Check if a file path is a clear pattern file."""
  213. # Get all possible clear pattern files for all table types
  214. clear_patterns = []
  215. for table_type in ['dune_weaver', 'dune_weaver_mini', 'dune_weaver_pro']:
  216. clear_patterns.extend([
  217. f'./patterns/clear_from_out{("_" + table_type.split("_")[-1]) if table_type != "dune_weaver" else ""}.thr',
  218. f'./patterns/clear_from_in{("_" + table_type.split("_")[-1]) if table_type != "dune_weaver" else ""}.thr',
  219. f'./patterns/clear_sideway{("_" + table_type.split("_")[-1]) if table_type != "dune_weaver" else ""}.thr'
  220. ])
  221. # Normalize paths for comparison
  222. normalized_path = os.path.normpath(file_path)
  223. normalized_clear_patterns = [os.path.normpath(p) for p in clear_patterns]
  224. # Check if the file path matches any clear pattern path
  225. return normalized_path in normalized_clear_patterns
  226. async def run_theta_rho_file(file_path, is_playlist=False):
  227. """Run a theta-rho file by sending data in optimized batches with tqdm ETA tracking."""
  228. if pattern_lock.locked():
  229. logger.warning("Another pattern is already running. Cannot start a new one.")
  230. return
  231. async with pattern_lock: # This ensures only one pattern can run at a time
  232. # Start progress update task only if not part of a playlist
  233. global progress_update_task
  234. if not is_playlist and not progress_update_task:
  235. progress_update_task = asyncio.create_task(broadcast_progress())
  236. coordinates = parse_theta_rho_file(file_path)
  237. total_coordinates = len(coordinates)
  238. if total_coordinates < 2:
  239. logger.warning("Not enough coordinates for interpolation")
  240. if not is_playlist:
  241. state.current_playing_file = None
  242. state.execution_progress = None
  243. return
  244. # Determine if this is a clearing pattern
  245. is_clear_file = is_clear_pattern(file_path)
  246. if is_clear_file:
  247. initial_speed = state.clear_pattern_speed if state.clear_pattern_speed is not None else state.speed
  248. logger.info(f"Running clearing pattern at initial speed {initial_speed}")
  249. else:
  250. logger.info(f"Running normal pattern at initial speed {state.speed}")
  251. state.execution_progress = (0, total_coordinates, None, 0)
  252. # stop actions without resetting the playlist
  253. stop_actions(clear_playlist=False)
  254. state.current_playing_file = file_path
  255. state.stop_requested = False
  256. logger.info(f"Starting pattern execution: {file_path}")
  257. logger.info(f"t: {state.current_theta}, r: {state.current_rho}")
  258. reset_theta()
  259. start_time = time.time()
  260. if state.led_controller:
  261. effect_playing(state.led_controller)
  262. with tqdm(
  263. total=total_coordinates,
  264. unit="coords",
  265. desc=f"Executing Pattern {file_path}",
  266. dynamic_ncols=True,
  267. disable=False,
  268. mininterval=1.0
  269. ) as pbar:
  270. for i, coordinate in enumerate(coordinates):
  271. theta, rho = coordinate
  272. if state.stop_requested:
  273. logger.info("Execution stopped by user")
  274. if state.led_controller:
  275. effect_idle(state.led_controller)
  276. break
  277. if state.skip_requested:
  278. logger.info("Skipping pattern...")
  279. connection_manager.check_idle()
  280. if state.led_controller:
  281. effect_idle(state.led_controller)
  282. break
  283. # Wait for resume if paused
  284. if state.pause_requested:
  285. logger.info("Execution paused...")
  286. if state.led_controller:
  287. effect_idle(state.led_controller)
  288. await pause_event.wait()
  289. logger.info("Execution resumed...")
  290. if state.led_controller:
  291. effect_playing(state.led_controller)
  292. # Dynamically determine the speed for each movement
  293. # Use clear_pattern_speed if it's set and this is a clear file, otherwise use state.speed
  294. if is_clear_file and state.clear_pattern_speed is not None:
  295. current_speed = state.clear_pattern_speed
  296. else:
  297. current_speed = state.speed
  298. move_polar(theta, rho, current_speed)
  299. # Update progress for all coordinates including the first one
  300. pbar.update(1)
  301. elapsed_time = time.time() - start_time
  302. estimated_remaining_time = (total_coordinates - (i + 1)) / pbar.format_dict['rate'] if pbar.format_dict['rate'] and total_coordinates else 0
  303. state.execution_progress = (i + 1, total_coordinates, estimated_remaining_time, elapsed_time)
  304. # Add a small delay to allow other async operations
  305. await asyncio.sleep(0.001)
  306. # Update progress one last time to show 100%
  307. elapsed_time = time.time() - start_time
  308. state.execution_progress = (total_coordinates, total_coordinates, 0, elapsed_time)
  309. # Give WebSocket a chance to send the final update
  310. await asyncio.sleep(0.1)
  311. if not state.conn:
  312. logger.error("Device is not connected. Stopping pattern execution.")
  313. return
  314. connection_manager.check_idle()
  315. # Set LED back to idle when pattern completes normally (not stopped early)
  316. if state.led_controller and not state.stop_requested:
  317. effect_idle(state.led_controller)
  318. logger.debug("LED effect set to idle after pattern completion")
  319. # Only clear state if not part of a playlist
  320. if not is_playlist:
  321. state.current_playing_file = None
  322. state.execution_progress = None
  323. logger.info("Pattern execution completed and state cleared")
  324. else:
  325. logger.info("Pattern execution completed, maintaining state for playlist")
  326. # Only cancel progress update task if not part of a playlist
  327. if not is_playlist and progress_update_task:
  328. progress_update_task.cancel()
  329. try:
  330. await progress_update_task
  331. except asyncio.CancelledError:
  332. pass
  333. progress_update_task = None
  334. async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
  335. """Run multiple .thr files in sequence with options."""
  336. state.stop_requested = False
  337. # Set initial playlist state
  338. state.playlist_mode = run_mode
  339. state.current_playlist_index = 0
  340. # Start progress update task for the playlist
  341. global progress_update_task
  342. if not progress_update_task:
  343. progress_update_task = asyncio.create_task(broadcast_progress())
  344. if shuffle:
  345. random.shuffle(file_paths)
  346. logger.info("Playlist shuffled")
  347. if shuffle:
  348. random.shuffle(file_paths)
  349. logger.info("Playlist shuffled")
  350. try:
  351. while True:
  352. # Construct the complete pattern sequence
  353. pattern_sequence = []
  354. for path in file_paths:
  355. # Add clear pattern if specified
  356. if clear_pattern and clear_pattern != 'none':
  357. clear_file_path = get_clear_pattern_file(clear_pattern, path)
  358. if clear_file_path:
  359. pattern_sequence.append(clear_file_path)
  360. # Add main pattern
  361. pattern_sequence.append(path)
  362. # Shuffle if requested
  363. if shuffle:
  364. # Get pairs of patterns (clear + main) to keep them together
  365. pairs = [pattern_sequence[i:i+2] for i in range(0, len(pattern_sequence), 2)]
  366. random.shuffle(pairs)
  367. # Flatten the pairs back into a single list
  368. pattern_sequence = [pattern for pair in pairs for pattern in pair]
  369. logger.info("Playlist shuffled")
  370. # Set the playlist to the first pattern
  371. state.current_playlist = pattern_sequence
  372. # Execute the pattern sequence
  373. for idx, file_path in enumerate(pattern_sequence):
  374. state.current_playlist_index = idx
  375. if state.stop_requested:
  376. logger.info("Execution stopped")
  377. return
  378. # Update state for main patterns only
  379. logger.info(f"Running pattern {file_path}")
  380. # Execute the pattern
  381. await run_theta_rho_file(file_path, is_playlist=True)
  382. # Handle pause between patterns
  383. if idx < len(pattern_sequence) - 1 and not state.stop_requested and pause_time > 0 and not state.skip_requested:
  384. # Check if current pattern is a clear pattern
  385. if is_clear_pattern(file_path):
  386. logger.info("Skipping pause after clear pattern")
  387. else:
  388. logger.info(f"Pausing for {pause_time} seconds")
  389. state.original_pause_time = pause_time
  390. pause_start = time.time()
  391. while time.time() - pause_start < pause_time:
  392. state.pause_time_remaining = pause_start + pause_time - time.time()
  393. if state.skip_requested:
  394. logger.info("Pause interrupted by stop/skip request")
  395. break
  396. await asyncio.sleep(1)
  397. state.pause_time_remaining = 0
  398. state.skip_requested = False
  399. if run_mode == "indefinite":
  400. logger.info("Playlist completed. Restarting as per 'indefinite' run mode")
  401. if pause_time > 0:
  402. logger.debug(f"Pausing for {pause_time} seconds before restarting")
  403. pause_start = time.time()
  404. while time.time() - pause_start < pause_time:
  405. state.pause_time_remaining = pause_start + pause_time - time.time()
  406. if state.skip_requested:
  407. logger.info("Pause interrupted by stop/skip request")
  408. break
  409. await asyncio.sleep(1)
  410. state.pause_time_remaining = 0
  411. continue
  412. else:
  413. logger.info("Playlist completed")
  414. break
  415. finally:
  416. # Clean up progress update task
  417. if progress_update_task:
  418. progress_update_task.cancel()
  419. try:
  420. await progress_update_task
  421. except asyncio.CancelledError:
  422. pass
  423. progress_update_task = None
  424. # Clear all state variables
  425. state.current_playing_file = None
  426. state.execution_progress = None
  427. state.current_playlist = None
  428. state.current_playlist_index = None
  429. state.playlist_mode = None
  430. if state.led_controller:
  431. effect_idle(state.led_controller)
  432. logger.info("All requested patterns completed (or stopped) and state cleared")
  433. def stop_actions(clear_playlist = True):
  434. """Stop all current actions."""
  435. try:
  436. with state.pause_condition:
  437. state.pause_requested = False
  438. state.stop_requested = True
  439. state.current_playing_file = None
  440. state.execution_progress = None
  441. state.is_clearing = False
  442. if clear_playlist:
  443. # Clear playlist state
  444. state.current_playlist = None
  445. state.current_playlist_index = None
  446. state.playlist_mode = None
  447. # Cancel progress update task if we're clearing the playlist
  448. global progress_update_task
  449. if progress_update_task and not progress_update_task.done():
  450. progress_update_task.cancel()
  451. state.pause_condition.notify_all()
  452. connection_manager.update_machine_position()
  453. except Exception as e:
  454. logger.error(f"Error during stop_actions: {e}")
  455. # Ensure we still update machine position even if there's an error
  456. connection_manager.update_machine_position()
  457. def move_polar(theta, rho, speed=None):
  458. """
  459. This functions take in a pair of theta rho coordinate, compute the distance to travel based on current theta, rho,
  460. and translate the motion to gcode jog command and sent to grbl.
  461. Since having similar steps_per_mm will make x and y axis moves at around the same speed, we have to scale the
  462. x_steps_per_mm and y_steps_per_mm so that they are roughly the same. Here's the range of motion:
  463. X axis (angular): 50mm = 1 revolution
  464. Y axis (radial): 0 => 20mm = theta 0 (center) => 1 (perimeter)
  465. Args:
  466. theta (_type_): _description_
  467. rho (_type_): _description_
  468. speed (int, optional): Speed override. If None, uses state.speed
  469. """
  470. # Adding soft limit to reduce hardware sound
  471. # soft_limit_inner = 0.01
  472. # if rho < soft_limit_inner:
  473. # rho = soft_limit_inner
  474. # soft_limit_outter = 0.015
  475. # if rho > (1-soft_limit_outter):
  476. # rho = (1-soft_limit_outter)
  477. if state.table_type == 'dune_weaver_mini':
  478. x_scaling_factor = 2
  479. y_scaling_factor = 3.7
  480. else:
  481. x_scaling_factor = 2
  482. y_scaling_factor = 5
  483. delta_theta = theta - state.current_theta
  484. delta_rho = rho - state.current_rho
  485. x_increment = delta_theta * 100 / (2 * pi * x_scaling_factor) # Added -1 to reverse direction
  486. y_increment = delta_rho * 100 / y_scaling_factor
  487. x_total_steps = state.x_steps_per_mm * (100/x_scaling_factor)
  488. y_total_steps = state.y_steps_per_mm * (100/y_scaling_factor)
  489. offset = x_increment * (x_total_steps * x_scaling_factor / (state.gear_ratio * y_total_steps * y_scaling_factor))
  490. if state.table_type == 'dune_weaver_mini':
  491. y_increment -= offset
  492. else:
  493. y_increment += offset
  494. new_x_abs = state.machine_x + x_increment
  495. new_y_abs = state.machine_y + y_increment
  496. # Use provided speed or fall back to state.speed
  497. actual_speed = speed if speed is not None else state.speed
  498. # dynamic_speed = compute_dynamic_speed(rho, max_speed=actual_speed)
  499. connection_manager.send_grbl_coordinates(round(new_x_abs, 3), round(new_y_abs,3), actual_speed)
  500. state.current_theta = theta
  501. state.current_rho = rho
  502. state.machine_x = new_x_abs
  503. state.machine_y = new_y_abs
  504. def pause_execution():
  505. """Pause pattern execution using asyncio Event."""
  506. logger.info("Pausing pattern execution")
  507. state.pause_requested = True
  508. pause_event.clear() # Clear the event to pause execution
  509. return True
  510. def resume_execution():
  511. """Resume pattern execution using asyncio Event."""
  512. logger.info("Resuming pattern execution")
  513. state.pause_requested = False
  514. pause_event.set() # Set the event to resume execution
  515. return True
  516. def reset_theta():
  517. logger.info('Resetting Theta')
  518. state.current_theta = state.current_theta % (2 * pi)
  519. connection_manager.update_machine_position()
  520. def set_speed(new_speed):
  521. state.speed = new_speed
  522. logger.info(f'Set new state.speed {new_speed}')
  523. def get_status():
  524. """Get the current status of pattern execution."""
  525. status = {
  526. "current_file": state.current_playing_file,
  527. "is_paused": state.pause_requested,
  528. "is_running": bool(state.current_playing_file and not state.stop_requested),
  529. "progress": None,
  530. "playlist": None,
  531. "speed": state.speed,
  532. "pause_time_remaining": state.pause_time_remaining,
  533. "original_pause_time": getattr(state, 'original_pause_time', None),
  534. "connection_status": state.conn.is_connected() if state.conn else False,
  535. "current_theta": state.current_theta,
  536. "current_rho": state.current_rho
  537. }
  538. # Add playlist information if available
  539. if state.current_playlist and state.current_playlist_index is not None:
  540. next_index = state.current_playlist_index + 1
  541. status["playlist"] = {
  542. "current_index": state.current_playlist_index,
  543. "total_files": len(state.current_playlist),
  544. "mode": state.playlist_mode,
  545. "next_file": state.current_playlist[next_index] if next_index < len(state.current_playlist) else None
  546. }
  547. if state.execution_progress:
  548. current, total, remaining_time, elapsed_time = state.execution_progress
  549. status["progress"] = {
  550. "current": current,
  551. "total": total,
  552. "remaining_time": remaining_time,
  553. "elapsed_time": elapsed_time,
  554. "percentage": (current / total * 100) if total > 0 else 0
  555. }
  556. return status
  557. async def broadcast_progress():
  558. """Background task to broadcast progress updates."""
  559. from main import broadcast_status_update
  560. while True:
  561. # Send status updates regardless of pattern_lock state
  562. status = get_status()
  563. # Use the existing broadcast function from main.py
  564. await broadcast_status_update(status)
  565. # Check if we should stop broadcasting
  566. if not state.current_playlist:
  567. # If no playlist, only stop if no pattern is being executed
  568. if not pattern_lock.locked():
  569. logger.info("No playlist or pattern running, stopping broadcast")
  570. break
  571. # Wait before next update
  572. await asyncio.sleep(1)