pattern_manager.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  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. state.current_playing_file = file_path
  174. coordinates = parse_theta_rho_file(file_path)
  175. total_coordinates = len(coordinates)
  176. if total_coordinates < 2:
  177. logger.warning("Not enough coordinates for interpolation")
  178. state.current_playing_file = None
  179. state.execution_progress = None
  180. return
  181. # stop actions without resetting the playlist
  182. state.execution_progress = (0, total_coordinates, None, 0)
  183. state.stop_requested = False
  184. logger.info(f"Starting pattern execution: {file_path}")
  185. logger.info(f"t: {state.current_theta}, r: {state.current_rho}")
  186. reset_theta()
  187. if state.led_controller:
  188. effect_playing(state.led_controller)
  189. # Track last status update time for time-based updates
  190. last_status_update = time.time()
  191. status_update_interval = 0.5 # Update status every 0.5 seconds
  192. with tqdm(
  193. total=total_coordinates,
  194. unit="coords",
  195. desc=f"Executing Pattern {file_path}",
  196. dynamic_ncols=True,
  197. disable=False, # Force enable the progress bar
  198. mininterval=1.0 # Optional: reduce update frequency to prevent flooding
  199. ) as pbar:
  200. for i, coordinate in enumerate(coordinates):
  201. theta, rho = coordinate
  202. if state.stop_requested:
  203. logger.info("Execution stopped by user")
  204. if state.led_controller:
  205. effect_idle(state.led_controller)
  206. # Make sure to clear current_playing_file when stopping
  207. state.current_playing_file = None
  208. break
  209. if state.skip_requested:
  210. logger.info("Skipping pattern...")
  211. connection_manager.check_idle()
  212. if state.led_controller:
  213. effect_idle(state.led_controller)
  214. # Make sure to clear current_playing_file when skipping
  215. state.current_playing_file = None
  216. break
  217. # Wait for resume if paused
  218. if state.pause_requested:
  219. logger.info("Execution paused...")
  220. if state.led_controller:
  221. effect_idle(state.led_controller)
  222. pause_event.wait()
  223. logger.info("Execution resumed...")
  224. if state.led_controller:
  225. effect_playing(state.led_controller)
  226. move_polar(theta, rho)
  227. if i != 0:
  228. pbar.update(1)
  229. estimated_remaining_time = (total_coordinates - i) / pbar.format_dict['rate'] if pbar.format_dict['rate'] and total_coordinates else 0
  230. elapsed_time = pbar.format_dict['elapsed']
  231. state.execution_progress = (i, total_coordinates, estimated_remaining_time, elapsed_time)
  232. # Send status updates based on time interval
  233. current_time = time.time()
  234. if current_time - last_status_update >= status_update_interval:
  235. last_status_update = current_time
  236. connection_manager.check_idle()
  237. # Clear pattern state atomically
  238. state.current_playing_file = None
  239. state.execution_progress = None
  240. logger.info("Pattern execution completed")
  241. def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
  242. """Run multiple .thr files in sequence with options."""
  243. state.stop_requested = False
  244. # Set initial playlist state
  245. state.playlist_mode = run_mode
  246. state.current_playlist_index = 0
  247. try:
  248. while True:
  249. # Construct the complete pattern sequence
  250. pattern_sequence = []
  251. for path in file_paths:
  252. # Add clear pattern if specified
  253. if clear_pattern and clear_pattern != 'none':
  254. clear_file_path = get_clear_pattern_file(clear_pattern, path)
  255. if clear_file_path:
  256. pattern_sequence.append(clear_file_path)
  257. # Add main pattern
  258. pattern_sequence.append(path)
  259. # Shuffle if requested
  260. if shuffle:
  261. # Get pairs of patterns (clear + main) to keep them together
  262. pairs = [pattern_sequence[i:i+2] for i in range(0, len(pattern_sequence), 2)]
  263. random.shuffle(pairs)
  264. # Flatten the pairs back into a single list
  265. pattern_sequence = [pattern for pair in pairs for pattern in pair]
  266. logger.info("Playlist shuffled")
  267. # Set the playlist to the first pattern
  268. state.current_playlist = pattern_sequence
  269. # Execute the pattern sequence
  270. for idx, file_path in enumerate(pattern_sequence):
  271. state.current_playlist_index = idx
  272. if state.stop_requested:
  273. logger.info("Execution stopped")
  274. return
  275. # Update state for main patterns only
  276. logger.info(f"Running pattern {file_path}")
  277. # Execute the pattern
  278. run_theta_rho_file(file_path)
  279. # Handle pause between patterns
  280. if idx < len(pattern_sequence) - 1 and not state.stop_requested and pause_time > 0 and not state.skip_requested:
  281. logger.info(f"Pausing for {pause_time} seconds")
  282. pause_start = time.time()
  283. last_status_update = time.time()
  284. while time.time() - pause_start < pause_time:
  285. if state.skip_requested:
  286. logger.info("Pause interrupted by stop/skip request")
  287. break
  288. # Periodically send status updates during long pauses
  289. current_time = time.time()
  290. if current_time - last_status_update >= 0.5: # Update every 0.5 seconds
  291. last_status_update = current_time
  292. time.sleep(0.1) # Use shorter sleep to check for skip more frequently
  293. state.skip_requested = False
  294. if run_mode == "indefinite":
  295. logger.info("Playlist completed. Restarting as per 'indefinite' run mode")
  296. if pause_time > 0:
  297. logger.debug(f"Pausing for {pause_time} seconds before restarting")
  298. time.sleep(pause_time)
  299. continue
  300. else:
  301. logger.info("Playlist completed")
  302. break
  303. finally:
  304. state.current_playing_file = None
  305. state.execution_progress = None
  306. state.current_playlist = None
  307. state.current_playlist_index = None
  308. state.playlist_mode = None
  309. state.current_playlist_name = None # Clear the playlist name in MQTT state
  310. if state.led_controller:
  311. effect_idle(state.led_controller)
  312. logger.info("All requested patterns completed (or stopped) and state cleared")
  313. def stop_actions(clear_playlist = True):
  314. """Stop all current actions."""
  315. with state.pause_condition:
  316. state.pause_requested = False
  317. state.stop_requested = True
  318. state.current_playing_file = None
  319. state.execution_progress = None
  320. state.is_clearing = False
  321. if clear_playlist:
  322. # Clear playlist state
  323. state.current_playlist = None
  324. state.current_playlist_index = None
  325. state.playlist_mode = None
  326. state.current_playlist_name = None # Also clear the playlist name for MQTT updates
  327. state.pause_condition.notify_all()
  328. connection_manager.update_machine_position()
  329. def get_status():
  330. """Get the current status of pattern execution."""
  331. status = {
  332. "current_file": state.current_playing_file,
  333. "is_paused": state.pause_requested,
  334. "is_running": bool(state.current_playing_file and not state.stop_requested),
  335. "progress": None,
  336. "playlist": None,
  337. "speed": state.speed
  338. }
  339. # Add playlist information if available
  340. if state.current_playlist and state.current_playlist_index is not None:
  341. next_index = state.current_playlist_index + 1
  342. status["playlist"] = {
  343. "current_index": state.current_playlist_index,
  344. "total_files": len(state.current_playlist),
  345. "mode": state.playlist_mode,
  346. "next_file": state.current_playlist[next_index] if next_index < len(state.current_playlist) else None
  347. }
  348. # Only include progress information if a file is actually playing
  349. if state.execution_progress and state.current_playing_file:
  350. current, total, remaining_time, elapsed_time = state.execution_progress
  351. status["progress"] = {
  352. "current": current,
  353. "total": total,
  354. "remaining_time": remaining_time,
  355. "elapsed_time": elapsed_time,
  356. "percentage": (current / total * 100) if total > 0 else 0
  357. }
  358. return status