pattern_manager.py 21 KB

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