pattern_manager.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  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. CLEAR_PATTERNS = {
  19. "clear_from_in": "./patterns/clear_from_in.thr",
  20. "clear_from_out": "./patterns/clear_from_out.thr",
  21. "clear_sideway": "./patterns/clear_sideway.thr"
  22. }
  23. os.makedirs(THETA_RHO_DIR, exist_ok=True)
  24. # Create an asyncio Event for pause/resume
  25. pause_event = asyncio.Event()
  26. pause_event.set() # Initially not paused
  27. # Create an asyncio Lock for pattern execution
  28. pattern_lock = asyncio.Lock()
  29. # Progress update task
  30. progress_update_task = None
  31. async def cleanup_pattern_manager():
  32. """Clean up pattern manager resources"""
  33. global progress_update_task, pattern_lock, pause_event
  34. try:
  35. # Cancel progress update task if running
  36. if progress_update_task and not progress_update_task.done():
  37. try:
  38. progress_update_task.cancel()
  39. # Wait for task to actually cancel
  40. try:
  41. await progress_update_task
  42. except asyncio.CancelledError:
  43. pass
  44. except Exception as e:
  45. logger.error(f"Error cancelling progress update task: {e}")
  46. # Clean up pattern lock
  47. if pattern_lock:
  48. try:
  49. if pattern_lock.locked():
  50. pattern_lock.release()
  51. pattern_lock = None
  52. except Exception as e:
  53. logger.error(f"Error cleaning up pattern lock: {e}")
  54. # Clean up pause event
  55. if pause_event:
  56. try:
  57. pause_event.set() # Wake up any waiting tasks
  58. pause_event = None
  59. except Exception as e:
  60. logger.error(f"Error cleaning up pause event: {e}")
  61. # Clean up pause condition from state
  62. if state.pause_condition:
  63. try:
  64. with state.pause_condition:
  65. state.pause_condition.notify_all()
  66. state.pause_condition = threading.Condition()
  67. except Exception as e:
  68. logger.error(f"Error cleaning up pause condition: {e}")
  69. # Clear all state variables
  70. state.current_playing_file = None
  71. state.execution_progress = 0
  72. state.is_running = False
  73. state.pause_requested = False
  74. state.stop_requested = True
  75. state.is_clearing = False
  76. # Reset machine position
  77. await connection_manager.update_machine_position()
  78. logger.info("Pattern manager resources cleaned up")
  79. except Exception as e:
  80. logger.error(f"Error during pattern manager cleanup: {e}")
  81. finally:
  82. # Ensure we always reset these
  83. progress_update_task = None
  84. pattern_lock = None
  85. pause_event = None
  86. def list_theta_rho_files():
  87. files = []
  88. for root, _, filenames in os.walk(THETA_RHO_DIR):
  89. for file in filenames:
  90. relative_path = os.path.relpath(os.path.join(root, file), THETA_RHO_DIR)
  91. files.append(relative_path)
  92. logger.debug(f"Found {len(files)} theta-rho files")
  93. return files
  94. def parse_theta_rho_file(file_path):
  95. """Parse a theta-rho file and return a list of (theta, rho) pairs."""
  96. coordinates = []
  97. try:
  98. logger.debug(f"Parsing theta-rho file: {file_path}")
  99. with open(file_path, 'r') as file:
  100. for line in file:
  101. line = line.strip()
  102. if not line or line.startswith("#"):
  103. continue
  104. try:
  105. theta, rho = map(float, line.split())
  106. coordinates.append((theta, rho))
  107. except ValueError:
  108. logger.warning(f"Skipping invalid line: {line}")
  109. continue
  110. except Exception as e:
  111. logger.error(f"Error reading file: {e}")
  112. return coordinates
  113. # Normalization Step
  114. if coordinates:
  115. first_theta = coordinates[0][0]
  116. normalized = [(theta - first_theta, rho) for theta, rho in coordinates]
  117. coordinates = normalized
  118. logger.debug(f"Parsed {len(coordinates)} coordinates from {file_path}")
  119. return coordinates
  120. def get_clear_pattern_file(clear_pattern_mode, path=None):
  121. """Return a .thr file path based on pattern_name."""
  122. if not clear_pattern_mode or clear_pattern_mode == 'none':
  123. return
  124. logger.debug("Clear pattern mode: " + clear_pattern_mode)
  125. if clear_pattern_mode == "random":
  126. return random.choice(list(CLEAR_PATTERNS.values()))
  127. if clear_pattern_mode == 'adaptive':
  128. if not path:
  129. logger.warning("No path provided for adaptive clear pattern")
  130. return random.choice(list(CLEAR_PATTERNS.values()))
  131. coordinates = parse_theta_rho_file(path)
  132. if not coordinates:
  133. logger.warning("No valid coordinates found in file for adaptive clear pattern")
  134. return random.choice(list(CLEAR_PATTERNS.values()))
  135. first_rho = coordinates[0][1]
  136. if first_rho < 0.5:
  137. return CLEAR_PATTERNS['clear_from_out']
  138. else:
  139. return random.choice([CLEAR_PATTERNS['clear_from_in'], CLEAR_PATTERNS['clear_sideway']])
  140. else:
  141. if clear_pattern_mode not in CLEAR_PATTERNS:
  142. logger.warning(f"Invalid clear pattern mode: {clear_pattern_mode}")
  143. return random.choice(list(CLEAR_PATTERNS.values()))
  144. return CLEAR_PATTERNS[clear_pattern_mode]
  145. async def run_theta_rho_file(file_path, is_playlist=False):
  146. """Run a theta-rho file by sending data in optimized batches with tqdm ETA tracking."""
  147. if pattern_lock.locked():
  148. logger.warning("Another pattern is already running. Cannot start a new one.")
  149. return
  150. async with pattern_lock: # This ensures only one pattern can run at a time
  151. # Start progress update task only if not part of a playlist
  152. global progress_update_task
  153. if not is_playlist and not progress_update_task:
  154. progress_update_task = asyncio.create_task(broadcast_progress())
  155. coordinates = parse_theta_rho_file(file_path)
  156. total_coordinates = len(coordinates)
  157. if total_coordinates < 2:
  158. logger.warning("Not enough coordinates for interpolation")
  159. if not is_playlist:
  160. state.current_playing_file = None
  161. state.execution_progress = None
  162. return
  163. state.execution_progress = (0, total_coordinates, None, 0)
  164. # stop actions without resetting the playlist
  165. stop_actions(clear_playlist=False)
  166. state.current_playing_file = file_path
  167. state.stop_requested = False
  168. logger.info(f"Starting pattern execution: {file_path}")
  169. logger.info(f"t: {state.current_theta}, r: {state.current_rho}")
  170. reset_theta()
  171. start_time = time.time()
  172. if state.led_controller:
  173. effect_playing(state.led_controller)
  174. with tqdm(
  175. total=total_coordinates,
  176. unit="coords",
  177. desc=f"Executing Pattern {file_path}",
  178. dynamic_ncols=True,
  179. disable=False,
  180. mininterval=1.0
  181. ) as pbar:
  182. for i, coordinate in enumerate(coordinates):
  183. theta, rho = coordinate
  184. if state.stop_requested:
  185. logger.info("Execution stopped by user")
  186. if state.led_controller:
  187. effect_idle(state.led_controller)
  188. break
  189. if state.skip_requested:
  190. logger.info("Skipping pattern...")
  191. connection_manager.check_idle()
  192. if state.led_controller:
  193. effect_idle(state.led_controller)
  194. break
  195. # Wait for resume if paused
  196. if state.pause_requested:
  197. logger.info("Execution paused...")
  198. if state.led_controller:
  199. effect_idle(state.led_controller)
  200. await pause_event.wait()
  201. logger.info("Execution resumed...")
  202. if state.led_controller:
  203. effect_playing(state.led_controller)
  204. move_polar(theta, rho)
  205. # Update progress for all coordinates including the first one
  206. pbar.update(1)
  207. elapsed_time = time.time() - start_time
  208. estimated_remaining_time = (total_coordinates - (i + 1)) / pbar.format_dict['rate'] if pbar.format_dict['rate'] and total_coordinates else 0
  209. state.execution_progress = (i + 1, total_coordinates, estimated_remaining_time, elapsed_time)
  210. # Add a small delay to allow other async operations
  211. await asyncio.sleep(0.001)
  212. # Update progress one last time to show 100%
  213. elapsed_time = time.time() - start_time
  214. state.execution_progress = (total_coordinates, total_coordinates, 0, elapsed_time)
  215. # Give WebSocket a chance to send the final update
  216. await asyncio.sleep(0.1)
  217. connection_manager.check_idle()
  218. # Only clear state if not part of a playlist
  219. if not is_playlist:
  220. state.current_playing_file = None
  221. state.execution_progress = None
  222. logger.info("Pattern execution completed and state cleared")
  223. else:
  224. logger.info("Pattern execution completed, maintaining state for playlist")
  225. # Only cancel progress update task if not part of a playlist
  226. if not is_playlist and progress_update_task:
  227. progress_update_task.cancel()
  228. try:
  229. await progress_update_task
  230. except asyncio.CancelledError:
  231. pass
  232. progress_update_task = None
  233. async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
  234. """Run multiple .thr files in sequence with options."""
  235. state.stop_requested = False
  236. # Set initial playlist state
  237. state.playlist_mode = run_mode
  238. state.current_playlist_index = 0
  239. # Start progress update task for the playlist
  240. global progress_update_task
  241. if not progress_update_task:
  242. progress_update_task = asyncio.create_task(broadcast_progress())
  243. if shuffle:
  244. random.shuffle(file_paths)
  245. logger.info("Playlist shuffled")
  246. if shuffle:
  247. random.shuffle(file_paths)
  248. logger.info("Playlist shuffled")
  249. try:
  250. while True:
  251. # Construct the complete pattern sequence
  252. pattern_sequence = []
  253. for path in file_paths:
  254. # Add clear pattern if specified
  255. if clear_pattern and clear_pattern != 'none':
  256. clear_file_path = get_clear_pattern_file(clear_pattern, path)
  257. if clear_file_path:
  258. pattern_sequence.append(clear_file_path)
  259. # Add main pattern
  260. pattern_sequence.append(path)
  261. # Shuffle if requested
  262. if shuffle:
  263. # Get pairs of patterns (clear + main) to keep them together
  264. pairs = [pattern_sequence[i:i+2] for i in range(0, len(pattern_sequence), 2)]
  265. random.shuffle(pairs)
  266. # Flatten the pairs back into a single list
  267. pattern_sequence = [pattern for pair in pairs for pattern in pair]
  268. logger.info("Playlist shuffled")
  269. # Set the playlist to the first pattern
  270. state.current_playlist = pattern_sequence
  271. # Execute the pattern sequence
  272. for idx, file_path in enumerate(pattern_sequence):
  273. state.current_playlist_index = idx
  274. if state.stop_requested:
  275. logger.info("Execution stopped")
  276. return
  277. # Update state for main patterns only
  278. logger.info(f"Running pattern {file_path}")
  279. # Execute the pattern
  280. await run_theta_rho_file(file_path, is_playlist=True)
  281. # Handle pause between patterns
  282. if idx < len(pattern_sequence) - 1 and not state.stop_requested and pause_time > 0 and not state.skip_requested:
  283. logger.info(f"Pausing for {pause_time} seconds")
  284. pause_start = time.time()
  285. while time.time() - pause_start < pause_time:
  286. if state.skip_requested:
  287. logger.info("Pause interrupted by stop/skip request")
  288. break
  289. await asyncio.sleep(1) # Check every 100ms
  290. state.skip_requested = False
  291. if run_mode == "indefinite":
  292. logger.info("Playlist completed. Restarting as per 'indefinite' run mode")
  293. if pause_time > 0:
  294. logger.debug(f"Pausing for {pause_time} seconds before restarting")
  295. await asyncio.sleep(pause_time)
  296. continue
  297. else:
  298. logger.info("Playlist completed")
  299. break
  300. finally:
  301. # Clean up progress update task
  302. if progress_update_task:
  303. progress_update_task.cancel()
  304. try:
  305. await progress_update_task
  306. except asyncio.CancelledError:
  307. pass
  308. progress_update_task = None
  309. # Clear all state variables
  310. state.current_playing_file = None
  311. state.execution_progress = None
  312. state.current_playlist = None
  313. state.current_playlist_index = None
  314. state.playlist_mode = None
  315. if state.led_controller:
  316. effect_idle(state.led_controller)
  317. logger.info("All requested patterns completed (or stopped) and state cleared")
  318. def stop_actions(clear_playlist = True):
  319. """Stop all current actions."""
  320. try:
  321. with state.pause_condition:
  322. state.pause_requested = False
  323. state.stop_requested = True
  324. state.current_playing_file = None
  325. state.execution_progress = None
  326. state.is_clearing = False
  327. if clear_playlist:
  328. # Clear playlist state
  329. state.current_playlist = None
  330. state.current_playlist_index = None
  331. state.playlist_mode = None
  332. # Cancel progress update task if we're clearing the playlist
  333. global progress_update_task
  334. if progress_update_task and not progress_update_task.done():
  335. progress_update_task.cancel()
  336. state.pause_condition.notify_all()
  337. connection_manager.update_machine_position()
  338. except Exception as e:
  339. logger.error(f"Error during stop_actions: {e}")
  340. # Ensure we still update machine position even if there's an error
  341. connection_manager.update_machine_position()
  342. def move_polar(theta, rho):
  343. """
  344. This functions take in a pair of theta rho coordinate, compute the distance to travel based on current theta, rho,
  345. and translate the motion to gcode jog command and sent to grbl.
  346. Since having similar steps_per_mm will make x and y axis moves at around the same speed, we have to scale the
  347. x_steps_per_mm and y_steps_per_mm so that they are roughly the same. Here's the range of motion:
  348. X axis (angular): 50mm = 1 revolution
  349. Y axis (radial): 0 => 20mm = theta 0 (center) => 1 (perimeter)
  350. Args:
  351. theta (_type_): _description_
  352. rho (_type_): _description_
  353. """
  354. # Adding soft limit to reduce hardware sound
  355. soft_limit_inner = 0.01
  356. if rho < soft_limit_inner:
  357. rho = soft_limit_inner
  358. soft_limit_outter = 0.015
  359. if rho > (1-soft_limit_outter):
  360. rho = (1-soft_limit_outter)
  361. if state.gear_ratio == 6.25:
  362. x_scaling_factor = 2
  363. y_scaling_factor = 3.7
  364. else:
  365. x_scaling_factor = 2
  366. y_scaling_factor = 5
  367. delta_theta = theta - state.current_theta
  368. delta_rho = rho - state.current_rho
  369. x_increment = delta_theta * 100 / (2 * pi * x_scaling_factor) # Added -1 to reverse direction
  370. y_increment = delta_rho * 100 / y_scaling_factor
  371. x_total_steps = state.x_steps_per_mm * (100/x_scaling_factor)
  372. y_total_steps = state.y_steps_per_mm * (100/y_scaling_factor)
  373. offset = x_increment * (x_total_steps * x_scaling_factor / (state.gear_ratio * y_total_steps * y_scaling_factor))
  374. if state.gear_ratio == 6.25:
  375. y_increment -= offset
  376. else:
  377. y_increment += offset
  378. new_x_abs = state.machine_x + x_increment
  379. new_y_abs = state.machine_y + y_increment
  380. # dynamic_speed = compute_dynamic_speed(rho, max_speed=state.speed)
  381. connection_manager.send_grbl_coordinates(round(new_x_abs, 3), round(new_y_abs,3), state.speed)
  382. state.current_theta = theta
  383. state.current_rho = rho
  384. state.machine_x = new_x_abs
  385. state.machine_y = new_y_abs
  386. def pause_execution():
  387. """Pause pattern execution using asyncio Event."""
  388. logger.info("Pausing pattern execution")
  389. state.pause_requested = True
  390. pause_event.clear() # Clear the event to pause execution
  391. return True
  392. def resume_execution():
  393. """Resume pattern execution using asyncio Event."""
  394. logger.info("Resuming pattern execution")
  395. state.pause_requested = False
  396. pause_event.set() # Set the event to resume execution
  397. return True
  398. def reset_theta():
  399. logger.info('Resetting Theta')
  400. state.current_theta = 0
  401. connection_manager.update_machine_position()
  402. def set_speed(new_speed):
  403. state.speed = new_speed
  404. logger.info(f'Set new state.speed {new_speed}')
  405. def get_status():
  406. """Get the current status of pattern execution."""
  407. status = {
  408. "current_file": state.current_playing_file,
  409. "is_paused": state.pause_requested,
  410. "is_running": bool(state.current_playing_file and not state.stop_requested),
  411. "progress": None,
  412. "playlist": None,
  413. "speed": state.speed
  414. }
  415. # Add playlist information if available
  416. if state.current_playlist and state.current_playlist_index is not None:
  417. next_index = state.current_playlist_index + 1
  418. status["playlist"] = {
  419. "current_index": state.current_playlist_index,
  420. "total_files": len(state.current_playlist),
  421. "mode": state.playlist_mode,
  422. "next_file": state.current_playlist[next_index] if next_index < len(state.current_playlist) else None
  423. }
  424. if state.execution_progress:
  425. current, total, remaining_time, elapsed_time = state.execution_progress
  426. status["progress"] = {
  427. "current": current,
  428. "total": total,
  429. "remaining_time": remaining_time,
  430. "elapsed_time": elapsed_time,
  431. "percentage": (current / total * 100) if total > 0 else 0
  432. }
  433. return status
  434. async def broadcast_progress():
  435. """Background task to broadcast progress updates."""
  436. from app import active_status_connections
  437. while True:
  438. # Send status updates regardless of pattern_lock state
  439. status = get_status()
  440. disconnected = set()
  441. # Create a copy of the set for iteration
  442. active_connections = active_status_connections.copy()
  443. for websocket in active_connections:
  444. try:
  445. await websocket.send_json(status)
  446. except Exception as e:
  447. logger.warning(f"Failed to send status update: {e}")
  448. disconnected.add(websocket)
  449. # Clean up disconnected clients
  450. if disconnected:
  451. active_status_connections.difference_update(disconnected)
  452. # Check if we should stop broadcasting
  453. if not state.current_playlist:
  454. # If no playlist, only stop if no pattern is being executed
  455. if not pattern_lock.locked():
  456. logger.info("No playlist or pattern running, stopping broadcast")
  457. break
  458. # Wait before next update
  459. await asyncio.sleep(1)