pattern_manager.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  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. from modules.led.led_controller import effect_playing, effect_idle
  12. # Configure logging
  13. logger = logging.getLogger(__name__)
  14. # Global state
  15. THETA_RHO_DIR = './patterns'
  16. os.makedirs(THETA_RHO_DIR, exist_ok=True)
  17. # Threading events
  18. pause_event = threading.Event()
  19. pause_event.set() # Initially not paused
  20. def list_theta_rho_files():
  21. files = []
  22. for root, _, filenames in os.walk(THETA_RHO_DIR):
  23. for file in filenames:
  24. relative_path = os.path.relpath(os.path.join(root, file), THETA_RHO_DIR)
  25. files.append(relative_path)
  26. logger.debug(f"Found {len(files)} theta-rho files")
  27. return files
  28. def parse_theta_rho_file(file_path):
  29. """Parse a theta-rho file and return a list of (theta, rho) pairs."""
  30. coordinates = []
  31. try:
  32. logger.debug(f"Parsing theta-rho file: {file_path}")
  33. with open(file_path, 'r') as file:
  34. for line in file:
  35. line = line.strip()
  36. if not line or line.startswith("#"):
  37. continue
  38. try:
  39. theta, rho = map(float, line.split())
  40. coordinates.append((theta, rho))
  41. except ValueError:
  42. logger.warning(f"Skipping invalid line: {line}")
  43. continue
  44. except Exception as e:
  45. logger.error(f"Error reading file: {e}")
  46. return coordinates
  47. # Normalization Step
  48. if coordinates:
  49. first_theta = coordinates[0][0]
  50. normalized = [(theta - first_theta, rho) for theta, rho in coordinates]
  51. coordinates = normalized
  52. logger.debug(f"Parsed {len(coordinates)} coordinates from {file_path}")
  53. return coordinates
  54. def get_clear_pattern_file(clear_pattern_mode, path=None):
  55. """Return a .thr file path based on pattern_name and table type."""
  56. if not clear_pattern_mode or clear_pattern_mode == 'none':
  57. return
  58. # Define patterns for each table type
  59. clear_patterns = {
  60. 'dune_weaver': {
  61. 'clear_from_out': './patterns/clear_from_out.thr',
  62. 'clear_from_in': './patterns/clear_from_in.thr',
  63. 'clear_sideway': './patterns/clear_sideway.thr'
  64. },
  65. 'dune_weaver_mini': {
  66. 'clear_from_out': './patterns/clear_from_out_mini.thr',
  67. 'clear_from_in': './patterns/clear_from_in_mini.thr',
  68. 'clear_sideway': './patterns/clear_sideway_mini.thr'
  69. },
  70. 'dune_weaver_pro': {
  71. 'clear_from_out': './patterns/clear_from_out_pro.thr',
  72. 'clear_from_in': './patterns/clear_from_in_pro.thr',
  73. 'clear_sideway': './patterns/clear_sideway_pro.thr'
  74. }
  75. }
  76. # Get patterns for current table type, fallback to standard patterns if type not found
  77. table_patterns = clear_patterns.get(state.table_type, clear_patterns['dune_weaver'])
  78. logger.debug(f"Clear pattern mode: {clear_pattern_mode} for table type: {state.table_type}")
  79. if clear_pattern_mode == "random":
  80. return random.choice(list(table_patterns.values()))
  81. if clear_pattern_mode == 'adaptive':
  82. if not path:
  83. logger.warning("No path provided for adaptive clear pattern")
  84. return random.choice(list(table_patterns.values()))
  85. coordinates = parse_theta_rho_file(path)
  86. if not coordinates:
  87. logger.warning("No valid coordinates found in file for adaptive clear pattern")
  88. return random.choice(list(table_patterns.values()))
  89. first_rho = coordinates[0][1]
  90. if first_rho < 0.5:
  91. return table_patterns['clear_from_out']
  92. else:
  93. return random.choice([table_patterns['clear_from_in'], table_patterns['clear_sideway']])
  94. else:
  95. if clear_pattern_mode not in table_patterns:
  96. return False
  97. return table_patterns[clear_pattern_mode]
  98. def move_polar(theta, rho):
  99. """
  100. This functions take in a pair of theta rho coordinate, compute the distance to travel based on current theta, rho,
  101. and translate the motion to gcode jog command and sent to grbl.
  102. Since having similar steps_per_mm will make x and y axis moves at around the same speed, we have to scale the
  103. x_steps_per_mm and y_steps_per_mm so that they are roughly the same. Here's the range of motion:
  104. X axis (angular): 50mm = 1 revolution
  105. Y axis (radial): 0 => 20mm = theta 0 (center) => 1 (perimeter)
  106. Args:
  107. theta (_type_): _description_
  108. rho (_type_): _description_
  109. """
  110. # Adding soft limit to reduce hardware sound
  111. soft_limit_inner = 0.01
  112. if rho < soft_limit_inner:
  113. rho = soft_limit_inner
  114. soft_limit_outter = 0.015
  115. if rho > (1-soft_limit_outter):
  116. rho = (1-soft_limit_outter)
  117. if state.gear_ratio == 6.25:
  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) # Added -1 to reverse direction
  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.gear_ratio == 6.25:
  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. # dynamic_speed = compute_dynamic_speed(rho, max_speed=state.speed)
  137. connection_manager.send_grbl_coordinates(round(new_x_abs, 3), round(new_y_abs,3), state.speed)
  138. state.current_theta = theta
  139. state.current_rho = rho
  140. state.machine_x = new_x_abs
  141. state.machine_y = new_y_abs
  142. def pause_execution():
  143. logger.info("Pausing pattern execution")
  144. with state.pause_condition:
  145. state.pause_requested = True
  146. pause_event.clear() # Clear event to block execution
  147. return True
  148. def resume_execution():
  149. logger.info("Resuming pattern execution")
  150. with state.pause_condition:
  151. state.pause_requested = False
  152. state.pause_condition.notify_all()
  153. pause_event.set() # Set event to allow execution to continue
  154. return True
  155. def reset_theta():
  156. logger.info('Resetting Theta')
  157. state.current_theta = 0
  158. connection_manager.update_machine_position()
  159. def set_speed(new_speed):
  160. state.speed = new_speed
  161. logger.info(f'Set new state.speed {new_speed}')
  162. def run_theta_rho_file(file_path):
  163. """Run a theta-rho file by sending data in optimized batches with tqdm ETA tracking."""
  164. # Check if connection is still valid, if not, restart
  165. # if not connection_manager.get_status_response() and isinstance(state.conn, connection_manager.WebSocketConnection):
  166. # logger.info('Cannot get status response, restarting connection')
  167. # connection_manager.restart_connection(home=False)
  168. # if (state.conn.is_connected() if state.conn else False):
  169. # logger.error('Connection not established')
  170. # return
  171. # if not file_path:
  172. # return
  173. try:
  174. state.current_playing_file = file_path
  175. coordinates = parse_theta_rho_file(file_path)
  176. total_coordinates = len(coordinates)
  177. if total_coordinates < 2:
  178. logger.warning("Not enough coordinates for interpolation")
  179. state.current_playing_file = None
  180. state.execution_progress = None
  181. return
  182. # stop actions without resetting the playlist
  183. state.execution_progress = (0, total_coordinates, None, 0)
  184. state.stop_requested = False
  185. logger.info(f"Starting pattern execution: {file_path}")
  186. logger.info(f"t: {state.current_theta}, r: {state.current_rho}")
  187. reset_theta()
  188. if state.led_controller:
  189. effect_playing(state.led_controller)
  190. # Track last status update time for time-based updates
  191. last_status_update = time.time()
  192. status_update_interval = 0.5 # Update status every 0.5 seconds
  193. with tqdm(
  194. total=total_coordinates,
  195. unit="coords",
  196. desc=f"Executing Pattern {file_path}",
  197. dynamic_ncols=True,
  198. disable=False, # Force enable the progress bar
  199. mininterval=1.0 # Optional: reduce update frequency to prevent flooding
  200. ) as pbar:
  201. for i, coordinate in enumerate(coordinates):
  202. theta, rho = coordinate
  203. if state.stop_requested:
  204. logger.info("Execution stopped by user")
  205. if state.led_controller:
  206. effect_idle(state.led_controller)
  207. # Make sure to clear current_playing_file when stopping
  208. state.current_playing_file = None
  209. break
  210. if state.skip_requested:
  211. logger.info("Skipping pattern...")
  212. connection_manager.check_idle()
  213. if state.led_controller:
  214. effect_idle(state.led_controller)
  215. # Make sure to clear current_playing_file when skipping
  216. state.current_playing_file = None
  217. break
  218. # Wait for resume if paused
  219. if state.pause_requested:
  220. logger.info("Execution paused...")
  221. if state.led_controller:
  222. effect_idle(state.led_controller)
  223. pause_event.wait()
  224. logger.info("Execution resumed...")
  225. if state.led_controller:
  226. effect_playing(state.led_controller)
  227. move_polar(theta, rho)
  228. if i != 0:
  229. pbar.update(1)
  230. estimated_remaining_time = (total_coordinates - i) / pbar.format_dict['rate'] if pbar.format_dict['rate'] and total_coordinates else 0
  231. elapsed_time = pbar.format_dict['elapsed']
  232. state.execution_progress = (i, total_coordinates, estimated_remaining_time, elapsed_time)
  233. # Send status updates based on time interval
  234. current_time = time.time()
  235. if current_time - last_status_update >= status_update_interval:
  236. last_status_update = current_time
  237. connection_manager.check_idle()
  238. except Exception as e:
  239. logger.error(f"Error in pattern execution: {str(e)}")
  240. finally:
  241. # Clear pattern state atomically - ensure this is always called
  242. state.current_playing_file = None
  243. state.execution_progress = None
  244. if state.led_controller:
  245. effect_idle(state.led_controller)
  246. logger.info("Pattern execution completed")
  247. 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. try:
  254. while True:
  255. # Construct the complete pattern sequence
  256. pattern_sequence = []
  257. for path in file_paths:
  258. # Add clear pattern if specified
  259. if clear_pattern and clear_pattern != 'none':
  260. clear_file_path = get_clear_pattern_file(clear_pattern, path)
  261. if clear_file_path:
  262. pattern_sequence.append(clear_file_path)
  263. # Add main pattern
  264. pattern_sequence.append(path)
  265. # Shuffle if requested
  266. if shuffle:
  267. # Get pairs of patterns (clear + main) to keep them together
  268. pairs = [pattern_sequence[i:i+2] for i in range(0, len(pattern_sequence), 2)]
  269. random.shuffle(pairs)
  270. # Flatten the pairs back into a single list
  271. pattern_sequence = [pattern for pair in pairs for pattern in pair]
  272. logger.info("Playlist shuffled")
  273. # Set the playlist to the first pattern
  274. state.current_playlist = pattern_sequence
  275. # Execute the pattern sequence
  276. for idx, file_path in enumerate(pattern_sequence):
  277. state.current_playlist_index = idx
  278. if state.stop_requested:
  279. logger.info("Execution stopped")
  280. return
  281. # Update state for main patterns only
  282. logger.info(f"Running pattern {file_path}")
  283. # Execute the pattern
  284. run_theta_rho_file(file_path)
  285. # Handle pause between patterns
  286. if idx < len(pattern_sequence) - 1 and not state.stop_requested and pause_time > 0 and not state.skip_requested:
  287. logger.info(f"Pausing for {pause_time} seconds")
  288. pause_start = time.time()
  289. last_status_update = time.time()
  290. while time.time() - pause_start < pause_time:
  291. if state.skip_requested:
  292. logger.info("Pause interrupted by stop/skip request")
  293. break
  294. # Periodically send status updates during long pauses
  295. current_time = time.time()
  296. if current_time - last_status_update >= 0.5: # Update every 0.5 seconds
  297. last_status_update = current_time
  298. time.sleep(0.1) # Use shorter sleep to check for skip more frequently
  299. state.skip_requested = False
  300. if run_mode == "indefinite":
  301. logger.info("Playlist completed. Restarting as per 'indefinite' run mode")
  302. if pause_time > 0:
  303. logger.debug(f"Pausing for {pause_time} seconds before restarting")
  304. time.sleep(pause_time)
  305. continue
  306. else:
  307. logger.info("Playlist completed")
  308. break
  309. finally:
  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. state.current_playlist_name = None # Clear the playlist name in MQTT state
  316. if state.led_controller:
  317. effect_idle(state.led_controller)
  318. logger.info("All requested patterns completed (or stopped) and state cleared")
  319. def stop_actions(clear_playlist = True):
  320. """Stop all current actions."""
  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. state.current_playlist_name = None # Also clear the playlist name for MQTT updates
  333. state.pause_condition.notify_all()
  334. connection_manager.update_machine_position()
  335. def get_status():
  336. """Get the current status of pattern execution."""
  337. status = {
  338. "current_file": state.current_playing_file,
  339. "is_paused": state.pause_requested,
  340. "is_running": bool(state.current_playing_file and not state.stop_requested),
  341. "progress": None,
  342. "playlist": None,
  343. "speed": state.speed
  344. }
  345. # Add playlist information if available
  346. if state.current_playlist and state.current_playlist_index is not None:
  347. next_index = state.current_playlist_index + 1
  348. status["playlist"] = {
  349. "current_index": state.current_playlist_index,
  350. "total_files": len(state.current_playlist),
  351. "mode": state.playlist_mode,
  352. "next_file": state.current_playlist[next_index] if next_index < len(state.current_playlist) else None
  353. }
  354. # Only include progress information if a file is actually playing
  355. if state.execution_progress and state.current_playing_file:
  356. current, total, remaining_time, elapsed_time = state.execution_progress
  357. status["progress"] = {
  358. "current": current,
  359. "total": total,
  360. "remaining_time": remaining_time,
  361. "elapsed_time": elapsed_time,
  362. "percentage": (current / total * 100) if total > 0 else 0
  363. }
  364. return status