pattern_manager.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  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. def list_theta_rho_files():
  18. files = []
  19. for root, _, filenames in os.walk(THETA_RHO_DIR):
  20. for file in filenames:
  21. relative_path = os.path.relpath(os.path.join(root, file), THETA_RHO_DIR)
  22. files.append(relative_path)
  23. logger.debug(f"Found {len(files)} theta-rho files")
  24. return files
  25. def parse_theta_rho_file(file_path):
  26. """Parse a theta-rho file and return a list of (theta, rho) pairs."""
  27. coordinates = []
  28. try:
  29. logger.debug(f"Parsing theta-rho file: {file_path}")
  30. with open(file_path, 'r') as file:
  31. for line in file:
  32. line = line.strip()
  33. if not line or line.startswith("#"):
  34. continue
  35. try:
  36. theta, rho = map(float, line.split())
  37. coordinates.append((theta, rho))
  38. except ValueError:
  39. logger.warning(f"Skipping invalid line: {line}")
  40. continue
  41. except Exception as e:
  42. logger.error(f"Error reading file: {e}")
  43. return coordinates
  44. # Normalization Step
  45. if coordinates:
  46. first_theta = coordinates[0][0]
  47. normalized = [(theta - first_theta, rho) for theta, rho in coordinates]
  48. coordinates = normalized
  49. logger.debug(f"Parsed {len(coordinates)} coordinates from {file_path}")
  50. return coordinates
  51. def get_clear_pattern_file(clear_pattern_mode, path=None):
  52. """Return a .thr file path based on pattern_name and table type."""
  53. if not clear_pattern_mode or clear_pattern_mode == 'none':
  54. return
  55. # Define patterns for each table type
  56. clear_patterns = {
  57. 'dune_weaver': {
  58. 'clear_from_out': './patterns/clear_from_out.thr',
  59. 'clear_from_in': './patterns/clear_from_in.thr',
  60. 'clear_sideway': './patterns/clear_sideway.thr'
  61. },
  62. 'dune_weaver_mini': {
  63. 'clear_from_out': './patterns/clear_from_out_mini.thr',
  64. 'clear_from_in': './patterns/clear_from_in_mini.thr',
  65. 'clear_sideway': './patterns/clear_sideway_mini.thr'
  66. },
  67. 'dune_weaver_pro': {
  68. 'clear_from_out': './patterns/clear_from_out_pro.thr',
  69. 'clear_from_in': './patterns/clear_from_in_pro.thr',
  70. 'clear_sideway': './patterns/clear_sideway_pro.thr'
  71. }
  72. }
  73. # Get patterns for current table type, fallback to standard patterns if type not found
  74. table_patterns = clear_patterns.get(state.table_type, clear_patterns['dune_weaver'])
  75. logger.debug(f"Clear pattern mode: {clear_pattern_mode} for table type: {state.table_type}")
  76. if clear_pattern_mode == "random":
  77. return random.choice(list(table_patterns.values()))
  78. if clear_pattern_mode == 'adaptive':
  79. if not path:
  80. logger.warning("No path provided for adaptive clear pattern")
  81. return random.choice(list(table_patterns.values()))
  82. coordinates = parse_theta_rho_file(path)
  83. if not coordinates:
  84. logger.warning("No valid coordinates found in file for adaptive clear pattern")
  85. return random.choice(list(table_patterns.values()))
  86. first_rho = coordinates[0][1]
  87. if first_rho < 0.5:
  88. return table_patterns['clear_from_out']
  89. else:
  90. return random.choice([table_patterns['clear_from_in'], table_patterns['clear_sideway']])
  91. else:
  92. if clear_pattern_mode not in table_patterns:
  93. return False
  94. return table_patterns[clear_pattern_mode]
  95. def move_polar(theta, rho):
  96. """
  97. This functions take in a pair of theta rho coordinate, compute the distance to travel based on current theta, rho,
  98. and translate the motion to gcode jog command and sent to grbl.
  99. Since having similar steps_per_mm will make x and y axis moves at around the same speed, we have to scale the
  100. x_steps_per_mm and y_steps_per_mm so that they are roughly the same. Here's the range of motion:
  101. X axis (angular): 50mm = 1 revolution
  102. Y axis (radial): 0 => 20mm = theta 0 (center) => 1 (perimeter)
  103. Args:
  104. theta (_type_): _description_
  105. rho (_type_): _description_
  106. """
  107. # Adding soft limit to reduce hardware sound
  108. soft_limit_inner = 0.01
  109. if rho < soft_limit_inner:
  110. rho = soft_limit_inner
  111. soft_limit_outter = 0.015
  112. if rho > (1-soft_limit_outter):
  113. rho = (1-soft_limit_outter)
  114. if state.gear_ratio == 6.25:
  115. x_scaling_factor = 2
  116. y_scaling_factor = 3.7
  117. else:
  118. x_scaling_factor = 2
  119. y_scaling_factor = 5
  120. delta_theta = theta - state.current_theta
  121. delta_rho = rho - state.current_rho
  122. x_increment = delta_theta * 100 / (2 * pi * x_scaling_factor) # Added -1 to reverse direction
  123. y_increment = delta_rho * 100 / y_scaling_factor
  124. x_total_steps = state.x_steps_per_mm * (100/x_scaling_factor)
  125. y_total_steps = state.y_steps_per_mm * (100/y_scaling_factor)
  126. offset = x_increment * (x_total_steps * x_scaling_factor / (state.gear_ratio * y_total_steps * y_scaling_factor))
  127. if state.gear_ratio == 6.25:
  128. y_increment -= offset
  129. else:
  130. y_increment += offset
  131. new_x_abs = state.machine_x + x_increment
  132. new_y_abs = state.machine_y + y_increment
  133. # dynamic_speed = compute_dynamic_speed(rho, max_speed=state.speed)
  134. connection_manager.send_grbl_coordinates(round(new_x_abs, 3), round(new_y_abs,3), state.speed)
  135. state.current_theta = theta
  136. state.current_rho = rho
  137. state.machine_x = new_x_abs
  138. state.machine_y = new_y_abs
  139. def pause_execution():
  140. logger.info("Pausing pattern execution")
  141. with state.pause_condition:
  142. state.pause_requested = True
  143. return True
  144. def resume_execution():
  145. logger.info("Resuming pattern execution")
  146. with state.pause_condition:
  147. state.pause_requested = False
  148. state.pause_condition.notify_all()
  149. return True
  150. def reset_theta():
  151. logger.info('Resetting Theta')
  152. state.current_theta = 0
  153. connection_manager.update_machine_position()
  154. def set_speed(new_speed):
  155. state.speed = new_speed
  156. logger.info(f'Set new state.speed {new_speed}')
  157. def run_theta_rho_file(file_path):
  158. """Run a theta-rho file by sending data in optimized batches with tqdm ETA tracking."""
  159. # Check if connection is still valid, if not, restart
  160. # if not connection_manager.get_status_response() and isinstance(state.conn, connection_manager.WebSocketConnection):
  161. # logger.info('Cannot get status response, restarting connection')
  162. # connection_manager.restart_connection(home=False)
  163. # if (state.conn.is_connected() if state.conn else False):
  164. # logger.error('Connection not established')
  165. # return
  166. # if not file_path:
  167. # return
  168. coordinates = parse_theta_rho_file(file_path)
  169. total_coordinates = len(coordinates)
  170. if total_coordinates < 2:
  171. logger.warning("Not enough coordinates for interpolation")
  172. state.current_playing_file = None
  173. state.execution_progress = None
  174. return
  175. state.execution_progress = (0, total_coordinates, None)
  176. # stop actions without resetting the playlist
  177. stop_actions(clear_playlist=False)
  178. state.current_playing_file = file_path
  179. state.execution_progress = (0, 0, None)
  180. state.stop_requested = False
  181. logger.info(f"Starting pattern execution: {file_path}")
  182. logger.info(f"t: {state.current_theta}, r: {state.current_rho}")
  183. reset_theta()
  184. if state.led_controller:
  185. effect_playing(state.led_controller)
  186. with tqdm(
  187. total=total_coordinates,
  188. unit="coords",
  189. desc=f"Executing Pattern {file_path}",
  190. dynamic_ncols=True,
  191. disable=False, # Force enable the progress bar
  192. mininterval=1.0 # Optional: reduce update frequency to prevent flooding
  193. ) as pbar:
  194. for i, coordinate in enumerate(coordinates):
  195. theta, rho = coordinate
  196. if state.stop_requested:
  197. logger.info("Execution stopped by user")
  198. if state.led_controller:
  199. effect_idle(state.led_controller)
  200. break
  201. if state.skip_requested:
  202. logger.info("Skipping pattern...")
  203. connection_manager.check_idle()
  204. if state.led_controller:
  205. effect_idle(state.led_controller)
  206. break
  207. # Wait for resume if paused
  208. if state.pause_requested:
  209. logger.info("Execution paused...")
  210. if state.led_controller:
  211. effect_idle(state.led_controller)
  212. pause_event.wait()
  213. logger.info("Execution resumed...")
  214. if state.led_controller:
  215. effect_playing(state.led_controller)
  216. move_polar(theta, rho)
  217. if i != 0:
  218. pbar.update(1)
  219. estimated_remaining_time = (total_coordinates - i) / pbar.format_dict['rate'] if pbar.format_dict['rate'] and total_coordinates else 0
  220. elapsed_time = pbar.format_dict['elapsed']
  221. state.execution_progress = (i, total_coordinates, estimated_remaining_time, elapsed_time)
  222. connection_manager.check_idle()
  223. state.current_playing_file = None
  224. state.execution_progress = None
  225. logger.info("Pattern execution completed")
  226. def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
  227. """Run multiple .thr files in sequence with options."""
  228. state.stop_requested = False
  229. # Set initial playlist state
  230. state.playlist_mode = run_mode
  231. state.current_playlist_index = 0
  232. try:
  233. while True:
  234. # Construct the complete pattern sequence
  235. pattern_sequence = []
  236. for path in file_paths:
  237. # Add clear pattern if specified
  238. if clear_pattern and clear_pattern != 'none':
  239. clear_file_path = get_clear_pattern_file(clear_pattern, path)
  240. if clear_file_path:
  241. pattern_sequence.append(clear_file_path)
  242. # Add main pattern
  243. pattern_sequence.append(path)
  244. # Shuffle if requested
  245. if shuffle:
  246. # Get pairs of patterns (clear + main) to keep them together
  247. pairs = [pattern_sequence[i:i+2] for i in range(0, len(pattern_sequence), 2)]
  248. random.shuffle(pairs)
  249. # Flatten the pairs back into a single list
  250. pattern_sequence = [pattern for pair in pairs for pattern in pair]
  251. logger.info("Playlist shuffled")
  252. # Set the playlist to the first pattern
  253. state.current_playlist = pattern_sequence
  254. # Execute the pattern sequence
  255. for idx, file_path in enumerate(pattern_sequence):
  256. state.current_playlist_index = idx
  257. if state.stop_requested:
  258. logger.info("Execution stopped")
  259. return
  260. # Update state for main patterns only
  261. logger.info(f"Running pattern {file_path}")
  262. # Execute the pattern
  263. run_theta_rho_file(file_path)
  264. # Handle pause between patterns
  265. if idx < len(pattern_sequence) - 1 and not state.stop_requested and pause_time > 0 and not state.skip_requested:
  266. logger.info(f"Pausing for {pause_time} seconds")
  267. pause_start = time.time()
  268. while time.time() - pause_start < pause_time:
  269. if state.skip_requested:
  270. logger.info("Pause interrupted by stop/skip request")
  271. break
  272. time.sleep(1)
  273. state.skip_requested = False
  274. if run_mode == "indefinite":
  275. logger.info("Playlist completed. Restarting as per 'indefinite' run mode")
  276. if pause_time > 0:
  277. logger.debug(f"Pausing for {pause_time} seconds before restarting")
  278. time.sleep(pause_time)
  279. continue
  280. else:
  281. logger.info("Playlist completed")
  282. break
  283. finally:
  284. # Clean up progress update task
  285. # if progress_update_task:
  286. # progress_update_task.cancel()
  287. # try:
  288. # await progress_update_task
  289. # except asyncio.CancelledError:
  290. # pass
  291. # progress_update_task = None
  292. # Clear all state variables
  293. state.current_playing_file = None
  294. state.execution_progress = None
  295. state.current_playlist = None
  296. state.current_playlist_index = None
  297. state.playlist_mode = None
  298. if state.led_controller:
  299. effect_idle(state.led_controller)
  300. logger.info("All requested patterns completed (or stopped) and state cleared")
  301. def stop_actions(clear_playlist = True):
  302. """Stop all current actions."""
  303. with state.pause_condition:
  304. state.pause_requested = False
  305. state.stop_requested = True
  306. state.current_playing_file = None
  307. state.execution_progress = None
  308. state.is_clearing = False
  309. if clear_playlist:
  310. # Clear playlist state
  311. state.current_playlist = None
  312. state.current_playlist_index = None
  313. state.playlist_mode = None
  314. state.pause_condition.notify_all()
  315. connection_manager.update_machine_position()
  316. def get_status():
  317. """Get the current execution status."""
  318. # Update state.is_clearing based on current file
  319. if state.current_playing_file in CLEAR_PATTERNS.values():
  320. state.is_clearing = True
  321. else:
  322. state.is_clearing = False
  323. return {
  324. "ser_port": state.port,
  325. "stop_requested": state.stop_requested,
  326. "pause_requested": state.pause_requested,
  327. "current_playing_file": state.current_playing_file,
  328. "execution_progress": state.execution_progress,
  329. "current_playing_index": state.current_playlist_index,
  330. "current_playlist": state.current_playlist,
  331. "is_clearing": state.is_clearing,
  332. "current_speed": state.speed
  333. }