pattern_manager.py 87 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921
  1. import os
  2. from zoneinfo import ZoneInfo
  3. import threading
  4. import time
  5. import random
  6. import logging
  7. from datetime import datetime, time as datetime_time
  8. from tqdm import tqdm
  9. from modules.connection import connection_manager
  10. from modules.core.state import state
  11. from math import pi, isnan, isinf
  12. import asyncio
  13. import json
  14. # Import for legacy support, but we'll use LED interface through state
  15. from modules.led.led_controller import effect_playing, effect_idle
  16. from modules.led.idle_timeout_manager import idle_timeout_manager
  17. import queue
  18. from dataclasses import dataclass
  19. from typing import Optional, Callable, Literal
  20. # Configure logging
  21. logger = logging.getLogger(__name__)
  22. # Global state
  23. THETA_RHO_DIR = './patterns'
  24. os.makedirs(THETA_RHO_DIR, exist_ok=True)
  25. # Execution time log file (JSON Lines format - one JSON object per line)
  26. EXECUTION_LOG_FILE = './execution_times.jsonl'
  27. async def wait_with_interrupt(
  28. condition_fn: Callable[[], bool],
  29. check_stop: bool = True,
  30. check_skip: bool = True,
  31. interval: float = 1.0,
  32. ) -> Literal['completed', 'stopped', 'skipped']:
  33. """
  34. Wait while condition_fn() returns True, with instant interrupt support.
  35. Uses asyncio.Event for instant response to stop/skip requests rather than
  36. polling at fixed intervals. This ensures users get immediate feedback when
  37. pressing stop or skip buttons.
  38. Args:
  39. condition_fn: Function that returns True while waiting should continue
  40. check_stop: Whether to respond to stop requests (default True)
  41. check_skip: Whether to respond to skip requests (default True)
  42. interval: How often to re-check condition_fn in seconds (default 1.0)
  43. Returns:
  44. 'completed' - condition_fn() returned False (normal completion)
  45. 'stopped' - stop was requested
  46. 'skipped' - skip was requested
  47. Example:
  48. result = await wait_with_interrupt(
  49. lambda: state.pause_requested or is_in_scheduled_pause_period()
  50. )
  51. if result == 'stopped':
  52. return # Exit pattern execution
  53. if result == 'skipped':
  54. break # Skip to next pattern
  55. """
  56. while condition_fn():
  57. result = await state.wait_for_interrupt(
  58. timeout=interval,
  59. check_stop=check_stop,
  60. check_skip=check_skip,
  61. )
  62. if result == 'stopped':
  63. return 'stopped'
  64. if result == 'skipped':
  65. return 'skipped'
  66. # 'timeout' means we should re-check condition_fn
  67. return 'completed'
  68. def log_execution_time(pattern_name: str, table_type: str, speed: int, actual_time: float,
  69. total_coordinates: int, was_completed: bool):
  70. """Log pattern execution time to JSON Lines file for analysis.
  71. Args:
  72. pattern_name: Name of the pattern file
  73. table_type: Type of table (e.g., 'dune_weaver', 'dune_weaver_mini')
  74. speed: Speed setting used (0-255)
  75. actual_time: Actual execution time in seconds (excluding pauses)
  76. total_coordinates: Total number of coordinates in the pattern
  77. was_completed: Whether the pattern completed normally (not stopped/skipped)
  78. """
  79. # Format time as HH:MM:SS
  80. hours, remainder = divmod(int(actual_time), 3600)
  81. minutes, seconds = divmod(remainder, 60)
  82. time_formatted = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
  83. log_entry = {
  84. "timestamp": datetime.now().isoformat(),
  85. "pattern_name": pattern_name,
  86. "table_type": table_type or "unknown",
  87. "speed": speed,
  88. "actual_time_seconds": round(actual_time, 2),
  89. "actual_time_formatted": time_formatted,
  90. "total_coordinates": total_coordinates,
  91. "completed": was_completed
  92. }
  93. try:
  94. with open(EXECUTION_LOG_FILE, 'a') as f:
  95. f.write(json.dumps(log_entry) + '\n')
  96. logger.info(f"Execution time logged: {pattern_name} - {time_formatted} (speed: {speed}, table: {table_type})")
  97. except Exception as e:
  98. logger.error(f"Failed to log execution time: {e}")
  99. def get_last_completed_execution_time(pattern_name: str, speed: float) -> Optional[dict]:
  100. """Get the last completed execution time for a pattern at a specific speed.
  101. Args:
  102. pattern_name: Name of the pattern file (e.g., 'circle.thr')
  103. speed: Speed setting to match
  104. Returns:
  105. Dict with execution time info if found, None otherwise.
  106. Format: {"actual_time_seconds": float, "actual_time_formatted": str, "timestamp": str}
  107. """
  108. if not os.path.exists(EXECUTION_LOG_FILE):
  109. return None
  110. try:
  111. matching_entry = None
  112. with open(EXECUTION_LOG_FILE, 'r') as f:
  113. for line in f:
  114. line = line.strip()
  115. if not line:
  116. continue
  117. try:
  118. entry = json.loads(line)
  119. # Only consider fully completed patterns (100% finished)
  120. if (entry.get('completed', False) and
  121. entry.get('pattern_name') == pattern_name and
  122. entry.get('speed') == speed):
  123. # Keep the most recent match (last one in file)
  124. matching_entry = entry
  125. except json.JSONDecodeError:
  126. continue
  127. if matching_entry:
  128. return {
  129. "actual_time_seconds": matching_entry.get('actual_time_seconds'),
  130. "actual_time_formatted": matching_entry.get('actual_time_formatted'),
  131. "timestamp": matching_entry.get('timestamp')
  132. }
  133. return None
  134. except Exception as e:
  135. logger.error(f"Failed to read execution time log: {e}")
  136. return None
  137. def get_pattern_execution_history(pattern_name: str) -> Optional[dict]:
  138. """Get the most recent completed execution for a pattern (any speed).
  139. Args:
  140. pattern_name: Name of the pattern file (e.g., 'circle.thr')
  141. Returns:
  142. Dict with execution time info if found, None otherwise.
  143. Format: {"actual_time_seconds": float, "actual_time_formatted": str,
  144. "speed": int, "timestamp": str}
  145. """
  146. if not os.path.exists(EXECUTION_LOG_FILE):
  147. return None
  148. try:
  149. matching_entry = None
  150. with open(EXECUTION_LOG_FILE, 'r') as f:
  151. for line in f:
  152. line = line.strip()
  153. if not line:
  154. continue
  155. try:
  156. entry = json.loads(line)
  157. # Only consider fully completed patterns
  158. if (entry.get('completed', False) and
  159. entry.get('pattern_name') == pattern_name):
  160. # Keep the most recent match (last one in file)
  161. matching_entry = entry
  162. except json.JSONDecodeError:
  163. continue
  164. if matching_entry:
  165. return {
  166. "actual_time_seconds": matching_entry.get('actual_time_seconds'),
  167. "actual_time_formatted": matching_entry.get('actual_time_formatted'),
  168. "speed": matching_entry.get('speed'),
  169. "timestamp": matching_entry.get('timestamp')
  170. }
  171. return None
  172. except Exception as e:
  173. logger.error(f"Failed to read execution time log: {e}")
  174. return None
  175. # Asyncio primitives - initialized lazily to avoid event loop issues
  176. # These must be created in the context of the running event loop
  177. pause_event: Optional[asyncio.Event] = None
  178. pattern_lock: Optional[asyncio.Lock] = None
  179. progress_update_task = None
  180. def get_pause_event() -> asyncio.Event:
  181. """Get or create the pause event in the current event loop."""
  182. global pause_event
  183. if pause_event is None:
  184. pause_event = asyncio.Event()
  185. pause_event.set() # Initially not paused
  186. return pause_event
  187. def get_pattern_lock() -> asyncio.Lock:
  188. """Get or create the pattern lock in the current event loop."""
  189. global pattern_lock
  190. if pattern_lock is None:
  191. pattern_lock = asyncio.Lock()
  192. return pattern_lock
  193. # Cache timezone at module level - read once per session (cleared when user changes timezone)
  194. _cached_timezone = None
  195. _cached_zoneinfo = None
  196. def _get_timezone():
  197. """Get and cache the timezone for Still Sands. Uses user-selected timezone if set, otherwise system timezone."""
  198. global _cached_timezone, _cached_zoneinfo
  199. if _cached_timezone is not None:
  200. return _cached_zoneinfo
  201. user_tz = 'UTC' # Default fallback
  202. # First, check if user has selected a specific timezone in settings
  203. if state.scheduled_pause_timezone:
  204. user_tz = state.scheduled_pause_timezone
  205. logger.info(f"Still Sands using timezone: {user_tz} (user-selected)")
  206. else:
  207. # Fall back to system timezone detection
  208. try:
  209. if os.path.exists('/etc/host-timezone'):
  210. with open('/etc/host-timezone', 'r') as f:
  211. user_tz = f.read().strip()
  212. logger.info(f"Still Sands using timezone: {user_tz} (from host system)")
  213. # Fallback to /etc/timezone if host-timezone doesn't exist
  214. elif os.path.exists('/etc/timezone'):
  215. with open('/etc/timezone', 'r') as f:
  216. user_tz = f.read().strip()
  217. logger.info(f"Still Sands using timezone: {user_tz} (from container)")
  218. # Fallback to TZ environment variable
  219. elif os.environ.get('TZ'):
  220. user_tz = os.environ.get('TZ')
  221. logger.info(f"Still Sands using timezone: {user_tz} (from environment)")
  222. else:
  223. logger.info("Still Sands using timezone: UTC (system default)")
  224. except Exception as e:
  225. logger.debug(f"Could not read timezone: {e}")
  226. # Cache the timezone
  227. _cached_timezone = user_tz
  228. try:
  229. _cached_zoneinfo = ZoneInfo(user_tz)
  230. except Exception as e:
  231. logger.warning(f"Invalid timezone '{user_tz}', falling back to system time: {e}")
  232. _cached_zoneinfo = None
  233. return _cached_zoneinfo
  234. def is_in_scheduled_pause_period():
  235. """Check if current time falls within any scheduled pause period."""
  236. if not state.scheduled_pause_enabled or not state.scheduled_pause_time_slots:
  237. return False
  238. # Get cached timezone (user-selected or system default)
  239. tz_info = _get_timezone()
  240. try:
  241. # Get current time in user's timezone
  242. if tz_info:
  243. now = datetime.now(tz_info)
  244. else:
  245. now = datetime.now()
  246. except Exception as e:
  247. logger.warning(f"Error getting current time: {e}")
  248. now = datetime.now()
  249. current_time = now.time()
  250. current_weekday = now.strftime("%A").lower() # monday, tuesday, etc.
  251. for slot in state.scheduled_pause_time_slots:
  252. # Parse start and end times
  253. try:
  254. start_time = datetime_time.fromisoformat(slot['start_time'])
  255. end_time = datetime_time.fromisoformat(slot['end_time'])
  256. except (ValueError, KeyError):
  257. logger.warning(f"Invalid time format in scheduled pause slot: {slot}")
  258. continue
  259. # Check if this slot applies to today
  260. slot_applies_today = False
  261. days_setting = slot.get('days', 'daily')
  262. if days_setting == 'daily':
  263. slot_applies_today = True
  264. elif days_setting == 'weekdays':
  265. slot_applies_today = current_weekday in ['monday', 'tuesday', 'wednesday', 'thursday', 'friday']
  266. elif days_setting == 'weekends':
  267. slot_applies_today = current_weekday in ['saturday', 'sunday']
  268. elif days_setting == 'custom':
  269. custom_days = slot.get('custom_days', [])
  270. slot_applies_today = current_weekday in custom_days
  271. if not slot_applies_today:
  272. continue
  273. # Check if current time is within the pause period
  274. if start_time <= end_time:
  275. # Normal case: start and end are on the same day
  276. if start_time <= current_time <= end_time:
  277. return True
  278. else:
  279. # Time spans midnight: start is before midnight, end is after midnight
  280. if current_time >= start_time or current_time <= end_time:
  281. return True
  282. return False
  283. async def check_table_is_idle() -> bool:
  284. """
  285. Check if the table is currently idle by querying actual machine status.
  286. Returns True if idle, False if playing/moving.
  287. This checks the real machine state rather than relying on state variables,
  288. making it more reliable for detecting when table is truly idle.
  289. """
  290. # Use the connection_manager's is_machine_idle() function
  291. # Run it in a thread since it's a synchronous function
  292. return await asyncio.to_thread(connection_manager.is_machine_idle)
  293. async def start_idle_led_timeout(check_still_sands: bool = True):
  294. """
  295. Set LED to idle state and start timeout if enabled.
  296. Handles Still Sands: if in scheduled pause period with LED control enabled,
  297. turns off LEDs instead of showing idle effect.
  298. Should be called whenever the table goes idle.
  299. Args:
  300. check_still_sands: If True, checks Still Sands period and turns off LEDs if applicable.
  301. Set to False when caller already handles Still Sands logic
  302. (e.g., during pause with "finish pattern first" mode).
  303. """
  304. if not state.led_controller:
  305. return
  306. # Still Sands with LED control: turn off instead of idle effect
  307. if check_still_sands and is_in_scheduled_pause_period() and state.scheduled_pause_control_wled:
  308. logger.info("Turning off LED lights during Still Sands period")
  309. await state.led_controller.set_power_async(0)
  310. return
  311. # Normal flow: show idle effect
  312. await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
  313. # Start timeout if enabled
  314. if not state.dw_led_idle_timeout_enabled:
  315. logger.debug("Idle LED timeout not enabled")
  316. return
  317. timeout_minutes = state.dw_led_idle_timeout_minutes
  318. if timeout_minutes <= 0:
  319. logger.debug("Idle LED timeout not configured (timeout <= 0)")
  320. return
  321. logger.debug(f"Starting idle LED timeout: {timeout_minutes} minutes")
  322. idle_timeout_manager.start_idle_timeout(
  323. timeout_minutes=timeout_minutes,
  324. state=state,
  325. check_idle_callback=check_table_is_idle
  326. )
  327. # Motion Control Thread Infrastructure
  328. @dataclass
  329. class MotionCommand:
  330. """Represents a motion command for the motion control thread."""
  331. command_type: str # 'move', 'stop', 'pause', 'resume', 'shutdown'
  332. theta: Optional[float] = None
  333. rho: Optional[float] = None
  334. speed: Optional[float] = None
  335. callback: Optional[Callable] = None
  336. future: Optional[asyncio.Future] = None
  337. class MotionControlThread:
  338. """Dedicated thread for hardware motion control operations."""
  339. def __init__(self):
  340. self.command_queue = queue.Queue()
  341. self.thread = None
  342. self.running = False
  343. self.paused = False
  344. def start(self):
  345. """Start the motion control thread with elevated priority."""
  346. if self.thread and self.thread.is_alive():
  347. return
  348. self.running = True
  349. self.thread = threading.Thread(target=self._motion_loop, daemon=True)
  350. self.thread.start()
  351. logger.info("Motion control thread started")
  352. def stop(self):
  353. """Stop the motion control thread."""
  354. if not self.running:
  355. return
  356. self.running = False
  357. # Send shutdown command
  358. self.command_queue.put(MotionCommand('shutdown'))
  359. if self.thread and self.thread.is_alive():
  360. self.thread.join(timeout=5.0)
  361. logger.info("Motion control thread stopped")
  362. def _motion_loop(self):
  363. """Main loop for the motion control thread."""
  364. logger.info("Motion control thread loop started")
  365. while self.running:
  366. try:
  367. # Get command with timeout to allow periodic checks
  368. command = self.command_queue.get(timeout=1.0)
  369. if command.command_type == 'shutdown':
  370. break
  371. elif command.command_type == 'move':
  372. self._execute_move(command)
  373. elif command.command_type == 'pause':
  374. self.paused = True
  375. elif command.command_type == 'resume':
  376. self.paused = False
  377. elif command.command_type == 'stop':
  378. # Clear any pending commands
  379. while not self.command_queue.empty():
  380. try:
  381. self.command_queue.get_nowait()
  382. except queue.Empty:
  383. break
  384. self.command_queue.task_done()
  385. except queue.Empty:
  386. # Timeout - continue loop for shutdown check
  387. continue
  388. except Exception as e:
  389. logger.error(f"Error in motion control thread: {e}")
  390. logger.info("Motion control thread loop ended")
  391. def _execute_move(self, command: MotionCommand):
  392. """Execute a move command in the motion thread."""
  393. try:
  394. # Wait if paused
  395. while self.paused and self.running:
  396. time.sleep(0.1)
  397. if not self.running:
  398. return
  399. # Execute the actual motion using sync version
  400. self._move_polar_sync(command.theta, command.rho, command.speed)
  401. # Signal completion if future provided
  402. if command.future and not command.future.done():
  403. command.future.get_loop().call_soon_threadsafe(
  404. command.future.set_result, None
  405. )
  406. except Exception as e:
  407. logger.error(f"Error executing move command: {e}")
  408. if command.future and not command.future.done():
  409. command.future.get_loop().call_soon_threadsafe(
  410. command.future.set_exception, e
  411. )
  412. def _move_polar_sync(self, theta: float, rho: float, speed: Optional[float] = None):
  413. """Synchronous version of move_polar for use in motion thread."""
  414. # Check for valid machine position (can be None if homing failed)
  415. if state.machine_x is None or state.machine_y is None:
  416. logger.error("Cannot execute move: machine position unknown (homing may have failed)")
  417. logger.error("Please home the machine before running patterns")
  418. state.stop_requested = True
  419. return
  420. # This is the original sync logic but running in dedicated thread
  421. if state.table_type == 'dune_weaver_mini':
  422. x_scaling_factor = 2
  423. y_scaling_factor = 3.7
  424. else:
  425. x_scaling_factor = 2
  426. y_scaling_factor = 5
  427. delta_theta = theta - state.current_theta
  428. delta_rho = rho - state.current_rho
  429. x_increment = delta_theta * 100 / (2 * pi * x_scaling_factor)
  430. y_increment = delta_rho * 100 / y_scaling_factor
  431. x_total_steps = state.x_steps_per_mm * (100/x_scaling_factor)
  432. y_total_steps = state.y_steps_per_mm * (100/y_scaling_factor)
  433. offset = x_increment * (x_total_steps * x_scaling_factor / (state.gear_ratio * y_total_steps * y_scaling_factor))
  434. if state.table_type == 'dune_weaver_mini' or state.y_steps_per_mm == 546:
  435. y_increment -= offset
  436. else:
  437. y_increment += offset
  438. new_x_abs = state.machine_x + x_increment
  439. new_y_abs = state.machine_y + y_increment
  440. # Use provided speed or fall back to state.speed
  441. actual_speed = speed if speed is not None else state.speed
  442. # Validate coordinates before sending to prevent GRBL error:2
  443. if isnan(new_x_abs) or isnan(new_y_abs) or isinf(new_x_abs) or isinf(new_y_abs):
  444. logger.error(f"Motion thread: Invalid coordinates detected - X:{new_x_abs}, Y:{new_y_abs}")
  445. logger.error(f" theta:{theta}, rho:{rho}, current_theta:{state.current_theta}, current_rho:{state.current_rho}")
  446. logger.error(f" x_steps_per_mm:{state.x_steps_per_mm}, y_steps_per_mm:{state.y_steps_per_mm}, gear_ratio:{state.gear_ratio}")
  447. state.stop_requested = True
  448. return
  449. # Call sync version of send_grbl_coordinates in this thread
  450. # Use 2 decimal precision to reduce GRBL parsing overhead
  451. self._send_grbl_coordinates_sync(round(new_x_abs, 2), round(new_y_abs, 2), actual_speed)
  452. # Update state
  453. state.current_theta = theta
  454. state.current_rho = rho
  455. state.machine_x = new_x_abs
  456. state.machine_y = new_y_abs
  457. def _send_grbl_coordinates_sync(self, x: float, y: float, speed: int = 600, timeout: int = 2, home: bool = False):
  458. """Synchronous version of send_grbl_coordinates for motion thread.
  459. Waits for 'ok' with a timeout. GRBL sends 'ok' after the move completes,
  460. which can take many seconds at slow speeds. We use a generous timeout
  461. (120 seconds) to handle slow movements, but prevent indefinite hangs.
  462. Includes retry logic for serial corruption errors (common on Pi 3B+).
  463. """
  464. gcode = f"$J=G91 G21 Y{y:.2f} F{speed}" if home else f"G1 X{x:.2f} Y{y:.2f} F{speed}"
  465. max_wait_time = 120 # Maximum seconds to wait for 'ok' response
  466. max_corruption_retries = 10 # Max retries for corruption-type errors
  467. max_timeout_retries = 10 # Max retries for timeout (lost 'ok' response)
  468. corruption_retry_count = 0
  469. timeout_retry_count = 0
  470. # GRBL error codes that indicate likely serial corruption (syntax errors)
  471. # These are recoverable by resending the command
  472. corruption_error_codes = {
  473. 'error:1', # Expected command letter
  474. 'error:2', # Bad number format
  475. 'error:20', # Invalid gcode ID (e.g., G5s instead of G53)
  476. 'error:21', # Invalid gcode command value
  477. 'error:22', # Invalid gcode command value in negative
  478. 'error:23', # Invalid gcode command value in decimal
  479. }
  480. while True:
  481. # Check stop_requested at the start of each iteration
  482. if state.stop_requested:
  483. logger.debug("Motion thread: Stop requested, aborting command")
  484. return False
  485. try:
  486. # Clear any stale input data before sending to prevent interleaving
  487. # This helps with timing issues on slower UARTs like Pi 3B+
  488. if hasattr(state.conn, 'reset_input_buffer'):
  489. state.conn.reset_input_buffer()
  490. logger.debug(f"Motion thread sending G-code: {gcode}")
  491. state.conn.send(gcode + "\n")
  492. # Small delay for serial buffer to stabilize on slower UARTs
  493. # Prevents timing-related corruption on Pi 3B+
  494. time.sleep(0.005)
  495. # Wait for 'ok' with timeout
  496. wait_start = time.time()
  497. while True:
  498. # Check stop_requested while waiting
  499. if state.stop_requested:
  500. logger.debug("Motion thread: Stop requested while waiting for response")
  501. return False
  502. # Check for timeout
  503. elapsed = time.time() - wait_start
  504. if elapsed > max_wait_time:
  505. logger.warning(f"Motion thread: Timeout ({max_wait_time}s) waiting for 'ok' response")
  506. logger.warning(f"Motion thread: Failed command was: {gcode}")
  507. # Attempt to recover by checking machine status
  508. # The 'ok' might have been lost but command may have executed
  509. logger.info("Motion thread: Attempting timeout recovery - checking machine status")
  510. logger.info(f"Motion thread: Current retry counts - timeout: {timeout_retry_count}/{max_timeout_retries}, corruption: {corruption_retry_count}/{max_corruption_retries}")
  511. try:
  512. # Check connection state first
  513. conn_type = type(state.conn).__name__ if state.conn else "None"
  514. logger.info(f"Motion thread: Connection type: {conn_type}")
  515. if not state.conn:
  516. logger.error("Motion thread: Connection object is None!")
  517. raise Exception("Connection is None")
  518. # Clear buffer first
  519. if hasattr(state.conn, 'reset_input_buffer'):
  520. state.conn.reset_input_buffer()
  521. logger.info("Motion thread: Input buffer cleared")
  522. else:
  523. logger.warning("Motion thread: Connection has no reset_input_buffer method")
  524. # Check if there's data waiting before we send
  525. if hasattr(state.conn, 'in_waiting'):
  526. waiting = state.conn.in_waiting()
  527. logger.info(f"Motion thread: Bytes waiting in buffer after clear: {waiting}")
  528. # Send status query
  529. logger.info("Motion thread: Sending status query '?'...")
  530. state.conn.send("?\n")
  531. time.sleep(0.2)
  532. logger.info("Motion thread: Status query sent, reading responses...")
  533. # Try to read status response
  534. status_response = None
  535. responses_received = []
  536. for i in range(10):
  537. resp = state.conn.readline()
  538. if resp:
  539. responses_received.append(resp)
  540. logger.info(f"Motion thread: Recovery response [{i+1}/10]: '{resp}'")
  541. if '<' in resp or 'Idle' in resp or 'Run' in resp or 'Hold' in resp or 'Alarm' in resp:
  542. status_response = resp
  543. logger.info(f"Motion thread: Found valid status response: '{resp}'")
  544. break
  545. # Also check for 'ok' that might have been delayed
  546. if resp.lower() == 'ok':
  547. logger.info("Motion thread: Received delayed 'ok' during recovery - SUCCESS")
  548. return True
  549. else:
  550. logger.debug(f"Motion thread: Recovery read [{i+1}/10]: no data (timeout)")
  551. time.sleep(0.05)
  552. # Log summary of what we received
  553. if responses_received:
  554. logger.info(f"Motion thread: Total responses received during recovery: {len(responses_received)}")
  555. logger.info(f"Motion thread: All responses: {responses_received}")
  556. else:
  557. logger.warning("Motion thread: No responses received during recovery - connection may be dead")
  558. if status_response:
  559. if 'Idle' in status_response:
  560. # Machine is idle - command likely completed, 'ok' was lost
  561. logger.info("Motion thread: Machine is Idle - assuming command completed (ok was lost) - SUCCESS")
  562. return True
  563. elif 'Run' in status_response:
  564. # Machine still running - extend timeout
  565. logger.info("Motion thread: Machine still running, extending wait time")
  566. wait_start = time.time() # Reset timeout
  567. continue
  568. elif 'Hold' in status_response:
  569. # Machine is in Hold state - attempt to resume
  570. logger.warning(f"Motion thread: Machine in Hold state: '{status_response}'")
  571. logger.info("Motion thread: Sending cycle start command '~' to resume from Hold...")
  572. # Send cycle start command to resume
  573. state.conn.send("~\n")
  574. time.sleep(0.3) # Give time for resume to process
  575. # Re-check status after resume attempt
  576. state.conn.send("?\n")
  577. time.sleep(0.2)
  578. # Read new status
  579. resume_response = None
  580. for _ in range(5):
  581. resp = state.conn.readline()
  582. if resp:
  583. logger.info(f"Motion thread: Post-resume response: '{resp}'")
  584. if '<' in resp:
  585. resume_response = resp
  586. break
  587. time.sleep(0.05)
  588. if resume_response:
  589. if 'Idle' in resume_response:
  590. logger.info("Motion thread: Machine resumed and is now Idle - SUCCESS")
  591. return True
  592. elif 'Run' in resume_response:
  593. logger.info("Motion thread: Machine resumed and running, extending wait time")
  594. wait_start = time.time()
  595. continue
  596. elif 'Hold' in resume_response:
  597. # Still in Hold - may need user intervention
  598. logger.warning(f"Motion thread: Still in Hold after resume: '{resume_response}'")
  599. else:
  600. logger.warning("Motion thread: No response after resume attempt")
  601. elif 'Alarm' in status_response:
  602. # Machine is in Alarm state - attempt to unlock
  603. logger.warning(f"Motion thread: Machine in ALARM state: '{status_response}'")
  604. logger.info("Motion thread: Sending $X to unlock from Alarm...")
  605. # Send unlock command
  606. state.conn.send("$X\n")
  607. time.sleep(0.5) # Give time for unlock to process
  608. # Re-check status after unlock attempt
  609. state.conn.send("?\n")
  610. time.sleep(0.2)
  611. # Read new status
  612. unlock_response = None
  613. for _ in range(5):
  614. resp = state.conn.readline()
  615. if resp:
  616. logger.info(f"Motion thread: Post-unlock response: '{resp}'")
  617. if '<' in resp:
  618. unlock_response = resp
  619. break
  620. time.sleep(0.05)
  621. if unlock_response:
  622. if 'Idle' in unlock_response:
  623. logger.info("Motion thread: Machine unlocked and is now Idle - retrying command")
  624. # Don't return True - we need to resend the failed command
  625. break # Break inner loop to retry the command
  626. elif 'Alarm' in unlock_response:
  627. # Still in Alarm - underlying issue persists (e.g., sensor triggered)
  628. logger.error(f"Motion thread: Still in ALARM after unlock: '{unlock_response}'")
  629. logger.error("Motion thread: Machine may need physical attention")
  630. state.stop_requested = True
  631. return False
  632. else:
  633. logger.warning("Motion thread: No response after unlock attempt")
  634. else:
  635. logger.warning(f"Motion thread: Unrecognized status response: '{status_response}'")
  636. else:
  637. logger.warning("Motion thread: No valid status response found in any received data")
  638. # No valid status response - connection may be dead
  639. timeout_retry_count += 1
  640. if timeout_retry_count <= max_timeout_retries:
  641. logger.warning(f"Motion thread: Recovery failed, will retry command ({timeout_retry_count}/{max_timeout_retries})")
  642. time.sleep(0.1)
  643. break # Break inner loop to resend command
  644. else:
  645. logger.error(f"Motion thread: Max timeout retries ({max_timeout_retries}) exceeded")
  646. except Exception as e:
  647. logger.error(f"Motion thread: Error during timeout recovery: {e}")
  648. import traceback
  649. logger.error(f"Motion thread: Traceback: {traceback.format_exc()}")
  650. # Max retries exceeded or recovery failed
  651. logger.error("=" * 60)
  652. logger.error("Motion thread: TIMEOUT RECOVERY FAILED - STOPPING PATTERN")
  653. logger.error(f" Failed command: {gcode}")
  654. logger.error(f" Timeout retries used: {timeout_retry_count}/{max_timeout_retries}")
  655. logger.error(f" Corruption retries used: {corruption_retry_count}/{max_corruption_retries}")
  656. logger.error(" Possible causes:")
  657. logger.error(" - Serial connection lost or unstable")
  658. logger.error(" - Hardware controller unresponsive")
  659. logger.error(" - USB power issue (try powered hub)")
  660. logger.error("=" * 60)
  661. state.stop_requested = True
  662. return False
  663. response = state.conn.readline()
  664. if response:
  665. logger.debug(f"Motion thread response: {response}")
  666. if response.lower() == "ok":
  667. logger.debug("Motion thread: Command execution confirmed.")
  668. # Reset corruption retry count on success
  669. if corruption_retry_count > 0:
  670. logger.info(f"Motion thread: Command succeeded after {corruption_retry_count} corruption retry(ies)")
  671. return True
  672. # Handle GRBL errors
  673. if response.lower().startswith("error"):
  674. error_code = response.lower().split()[0] if response else ""
  675. # Check if this is a corruption-type error (recoverable)
  676. if error_code in corruption_error_codes:
  677. corruption_retry_count += 1
  678. if corruption_retry_count <= max_corruption_retries:
  679. logger.warning(f"Motion thread: Likely serial corruption detected ({response})")
  680. logger.warning(f"Motion thread: Retrying command ({corruption_retry_count}/{max_corruption_retries}): {gcode}")
  681. # Clear buffer and wait longer before retry
  682. if hasattr(state.conn, 'reset_input_buffer'):
  683. state.conn.reset_input_buffer()
  684. time.sleep(0.02) # 20ms delay before retry
  685. break # Break inner loop to retry send
  686. else:
  687. logger.error(f"Motion thread: Max corruption retries ({max_corruption_retries}) exceeded")
  688. logger.error(f"Motion thread: GRBL error received: {response}")
  689. logger.error(f"Failed command: {gcode}")
  690. logger.error("Stopping pattern due to persistent serial corruption")
  691. state.stop_requested = True
  692. return False
  693. else:
  694. # Non-corruption error - stop immediately
  695. logger.error(f"Motion thread: GRBL error received: {response}")
  696. logger.error(f"Failed command: {gcode}")
  697. logger.error("Stopping pattern due to GRBL error")
  698. state.stop_requested = True
  699. return False
  700. # Handle GRBL alarms - machine needs attention
  701. if "alarm" in response.lower():
  702. logger.error(f"Motion thread: GRBL ALARM: {response}")
  703. logger.error("Machine alarm triggered - stopping pattern")
  704. state.stop_requested = True
  705. return False
  706. # FluidNC may echo commands back before sending 'ok'
  707. # Silently ignore echoed G-code commands (G0, G1, $J, etc.)
  708. if response.startswith(('G0', 'G1', 'G2', 'G3', '$J', 'M')):
  709. logger.debug(f"Motion thread: Ignoring echoed command: {response}")
  710. continue # Read next line to get 'ok'
  711. # Check for corruption indicator in MSG:ERR responses
  712. if 'MSG:ERR' in response and 'Bad GCode' in response:
  713. corruption_retry_count += 1
  714. if corruption_retry_count <= max_corruption_retries:
  715. logger.warning(f"Motion thread: Corrupted command detected: {response}")
  716. logger.warning(f"Motion thread: Retrying command ({corruption_retry_count}/{max_corruption_retries}): {gcode}")
  717. # Don't break yet - wait for the error:XX that follows
  718. continue
  719. # If we've exceeded retries, the error:XX handler above will catch it
  720. # Log truly unexpected responses
  721. logger.warning(f"Motion thread: Unexpected response: '{response}'")
  722. else:
  723. # Log periodically when waiting for response (every 30s)
  724. if int(elapsed) > 0 and int(elapsed) % 30 == 0 and elapsed - int(elapsed) < 0.1:
  725. logger.warning(f"Motion thread: Still waiting for 'ok' after {int(elapsed)}s for command: {gcode}")
  726. else:
  727. # Inner while loop completed without break - shouldn't happen normally
  728. # This means we hit timeout, which is handled above
  729. continue
  730. except Exception as e:
  731. error_str = str(e)
  732. logger.warning(f"Motion thread error sending command: {error_str}")
  733. # Immediately return for device not configured errors
  734. if "Device not configured" in error_str or "Errno 6" in error_str:
  735. logger.error(f"Motion thread: Device configuration error detected: {error_str}")
  736. state.stop_requested = True
  737. state.conn = None
  738. state.is_connected = False
  739. logger.info("Connection marked as disconnected due to device error")
  740. return False
  741. # Retry on exception or corruption error
  742. logger.warning(f"Motion thread: Retrying {gcode}...")
  743. time.sleep(0.1)
  744. # Global motion control thread instance
  745. motion_controller = MotionControlThread()
  746. async def cleanup_pattern_manager():
  747. """Clean up pattern manager resources"""
  748. global progress_update_task, pattern_lock, pause_event
  749. try:
  750. # Signal stop to allow any running pattern to exit gracefully
  751. state.stop_requested = True
  752. # Stop motion control thread
  753. motion_controller.stop()
  754. # Cancel progress update task if running
  755. if progress_update_task and not progress_update_task.done():
  756. try:
  757. progress_update_task.cancel()
  758. # Wait for task to actually cancel
  759. try:
  760. await progress_update_task
  761. except asyncio.CancelledError:
  762. pass
  763. except Exception as e:
  764. logger.error(f"Error cancelling progress update task: {e}")
  765. # Clean up pattern lock - wait for it to be released naturally, don't force release
  766. # Force releasing an asyncio.Lock can corrupt internal state if held by another coroutine
  767. current_lock = pattern_lock
  768. if current_lock and current_lock.locked():
  769. logger.info("Pattern lock is held, waiting for release (max 5s)...")
  770. try:
  771. # Wait with timeout for the lock to become available
  772. # Use wait_for for Python 3.9 compatibility (asyncio.timeout is 3.11+)
  773. async def acquire_lock():
  774. async with current_lock:
  775. pass # Lock acquired means previous holder released it
  776. await asyncio.wait_for(acquire_lock(), timeout=5.0)
  777. logger.info("Pattern lock released normally")
  778. except asyncio.TimeoutError:
  779. logger.warning("Timed out waiting for pattern lock - creating fresh lock")
  780. except Exception as e:
  781. logger.error(f"Error waiting for pattern lock: {e}")
  782. # Clean up pause event - wake up any waiting tasks, then create fresh event
  783. current_event = pause_event
  784. if current_event:
  785. try:
  786. current_event.set() # Wake up any waiting tasks
  787. except Exception as e:
  788. logger.error(f"Error setting pause event: {e}")
  789. # Clean up pause condition from state
  790. if state.pause_condition:
  791. try:
  792. with state.pause_condition:
  793. state.pause_condition.notify_all()
  794. state.pause_condition = threading.Condition()
  795. except Exception as e:
  796. logger.error(f"Error cleaning up pause condition: {e}")
  797. # Clear all state variables
  798. state.current_playing_file = None
  799. state.execution_progress = 0
  800. state.is_running = False
  801. state.pause_requested = False
  802. state.stop_requested = True
  803. state.is_clearing = False
  804. # Reset machine position
  805. await connection_manager.update_machine_position()
  806. logger.info("Pattern manager resources cleaned up")
  807. except Exception as e:
  808. logger.error(f"Error during pattern manager cleanup: {e}")
  809. finally:
  810. # Reset to fresh instances instead of None to allow continued operation
  811. progress_update_task = None
  812. pattern_lock = asyncio.Lock() # Fresh lock instead of None
  813. pause_event = asyncio.Event() # Fresh event instead of None
  814. pause_event.set() # Initially not paused
  815. def list_theta_rho_files():
  816. files = []
  817. for root, dirs, filenames in os.walk(THETA_RHO_DIR):
  818. # Skip cached_images directories to avoid scanning thousands of WebP files
  819. if 'cached_images' in dirs:
  820. dirs.remove('cached_images')
  821. # Filter .thr files during traversal for better performance
  822. thr_files = [f for f in filenames if f.endswith('.thr')]
  823. for file in thr_files:
  824. relative_path = os.path.relpath(os.path.join(root, file), THETA_RHO_DIR)
  825. # Normalize path separators to always use forward slashes for consistency across platforms
  826. relative_path = relative_path.replace(os.sep, '/')
  827. files.append(relative_path)
  828. logger.debug(f"Found {len(files)} theta-rho files")
  829. return files
  830. def parse_theta_rho_file(file_path):
  831. """Parse a theta-rho file and return a list of (theta, rho) pairs."""
  832. coordinates = []
  833. try:
  834. logger.debug(f"Parsing theta-rho file: {file_path}")
  835. with open(file_path, 'r', encoding='utf-8') as file:
  836. for line in file:
  837. line = line.strip()
  838. if not line or line.startswith("#"):
  839. continue
  840. try:
  841. theta, rho = map(float, line.split())
  842. coordinates.append((theta, rho))
  843. except ValueError:
  844. logger.warning(f"Skipping invalid line: {line}")
  845. continue
  846. except Exception as e:
  847. logger.error(f"Error reading file: {e}")
  848. return coordinates
  849. logger.debug(f"Parsed {len(coordinates)} coordinates from {file_path}")
  850. return coordinates
  851. def get_first_rho_from_cache(file_path, cache_data=None):
  852. """Get the first rho value from cached metadata, falling back to file parsing if needed.
  853. Args:
  854. file_path: Path to the pattern file
  855. cache_data: Optional pre-loaded cache data dict to avoid repeated disk I/O
  856. """
  857. try:
  858. # Import cache_manager locally to avoid circular import
  859. from modules.core import cache_manager
  860. # Try to get from metadata cache first
  861. # Use relative path from THETA_RHO_DIR to match cache keys (which include subdirectories)
  862. file_name = os.path.relpath(file_path, THETA_RHO_DIR)
  863. # Use provided cache_data if available, otherwise load from disk
  864. if cache_data is not None:
  865. # Extract metadata directly from provided cache
  866. data_section = cache_data.get('data', {})
  867. if file_name in data_section:
  868. cached_entry = data_section[file_name]
  869. metadata = cached_entry.get('metadata')
  870. # When cache_data is provided, trust it without checking mtime
  871. # This significantly speeds up bulk operations (playlists with 1000+ patterns)
  872. # by avoiding 1000+ os.path.getmtime() calls on slow storage (e.g., Pi SD cards)
  873. if metadata and 'first_coordinate' in metadata:
  874. return metadata['first_coordinate']['y']
  875. else:
  876. # Fall back to loading cache from disk (original behavior)
  877. metadata = cache_manager.get_pattern_metadata(file_name)
  878. if metadata and 'first_coordinate' in metadata:
  879. # In the cache, 'x' is theta and 'y' is rho
  880. return metadata['first_coordinate']['y']
  881. # Fallback to parsing the file if not in cache
  882. logger.debug(f"Metadata not cached for {file_name}, parsing file")
  883. coordinates = parse_theta_rho_file(file_path)
  884. if coordinates:
  885. return coordinates[0][1] # Return rho value
  886. return None
  887. except Exception as e:
  888. logger.warning(f"Error getting first rho from cache for {file_path}: {str(e)}")
  889. return None
  890. def get_clear_pattern_file(clear_pattern_mode, path=None, cache_data=None):
  891. """Return a .thr file path based on pattern_name and table type.
  892. Args:
  893. clear_pattern_mode: The clear pattern mode to use
  894. path: Optional path to the pattern file for adaptive mode
  895. cache_data: Optional pre-loaded cache data dict to avoid repeated disk I/O
  896. """
  897. if not clear_pattern_mode or clear_pattern_mode == 'none':
  898. return
  899. # Define patterns for each table type
  900. clear_patterns = {
  901. 'dune_weaver': {
  902. 'clear_from_out': './patterns/clear_from_out.thr',
  903. 'clear_from_in': './patterns/clear_from_in.thr',
  904. 'clear_sideway': './patterns/clear_sideway.thr'
  905. },
  906. 'dune_weaver_mini': {
  907. 'clear_from_out': './patterns/clear_from_out_mini.thr',
  908. 'clear_from_in': './patterns/clear_from_in_mini.thr',
  909. 'clear_sideway': './patterns/clear_sideway_mini.thr'
  910. },
  911. 'dune_weaver_mini_pro': {
  912. 'clear_from_out': './patterns/clear_from_out_mini.thr',
  913. 'clear_from_in': './patterns/clear_from_in_mini.thr',
  914. 'clear_sideway': './patterns/clear_sideway_mini.thr'
  915. },
  916. 'dune_weaver_pro': {
  917. 'clear_from_out': './patterns/clear_from_out_pro.thr',
  918. 'clear_from_out_Ultra': './patterns/clear_from_out_Ultra.thr',
  919. 'clear_from_in': './patterns/clear_from_in_pro.thr',
  920. 'clear_from_in_Ultra': './patterns/clear_from_in_Ultra.thr',
  921. 'clear_sideway': './patterns/clear_sideway_pro.thr'
  922. }
  923. }
  924. # Get patterns for current table type, fallback to standard patterns if type not found
  925. table_patterns = clear_patterns.get(state.table_type, clear_patterns['dune_weaver'])
  926. # Check for custom patterns first
  927. if state.custom_clear_from_out and clear_pattern_mode in ['clear_from_out', 'adaptive']:
  928. if clear_pattern_mode == 'adaptive':
  929. # For adaptive mode, use cached metadata to check first rho
  930. if path:
  931. first_rho = get_first_rho_from_cache(path, cache_data)
  932. if first_rho is not None and first_rho < 0.5:
  933. # Use custom clear_from_out if set
  934. custom_path = os.path.join('./patterns', state.custom_clear_from_out)
  935. if os.path.exists(custom_path):
  936. logger.debug(f"Using custom clear_from_out: {custom_path}")
  937. return custom_path
  938. elif clear_pattern_mode == 'clear_from_out':
  939. custom_path = os.path.join('./patterns', state.custom_clear_from_out)
  940. if os.path.exists(custom_path):
  941. logger.debug(f"Using custom clear_from_out: {custom_path}")
  942. return custom_path
  943. if state.custom_clear_from_in and clear_pattern_mode in ['clear_from_in', 'adaptive']:
  944. if clear_pattern_mode == 'adaptive':
  945. # For adaptive mode, use cached metadata to check first rho
  946. if path:
  947. first_rho = get_first_rho_from_cache(path, cache_data)
  948. if first_rho is not None and first_rho >= 0.5:
  949. # Use custom clear_from_in if set
  950. custom_path = os.path.join('./patterns', state.custom_clear_from_in)
  951. if os.path.exists(custom_path):
  952. logger.debug(f"Using custom clear_from_in: {custom_path}")
  953. return custom_path
  954. elif clear_pattern_mode == 'clear_from_in':
  955. custom_path = os.path.join('./patterns', state.custom_clear_from_in)
  956. if os.path.exists(custom_path):
  957. logger.debug(f"Using custom clear_from_in: {custom_path}")
  958. return custom_path
  959. logger.debug(f"Clear pattern mode: {clear_pattern_mode} for table type: {state.table_type}")
  960. if clear_pattern_mode == "random":
  961. return random.choice(list(table_patterns.values()))
  962. if clear_pattern_mode == 'adaptive':
  963. if not path:
  964. logger.warning("No path provided for adaptive clear pattern")
  965. return random.choice(list(table_patterns.values()))
  966. # Use cached metadata to get first rho value
  967. first_rho = get_first_rho_from_cache(path, cache_data)
  968. if first_rho is None:
  969. logger.warning("Could not determine first rho value for adaptive clear pattern")
  970. return random.choice(list(table_patterns.values()))
  971. if first_rho < 0.5:
  972. return table_patterns['clear_from_out']
  973. else:
  974. return table_patterns['clear_from_in']
  975. else:
  976. if clear_pattern_mode not in table_patterns:
  977. return False
  978. return table_patterns[clear_pattern_mode]
  979. def is_clear_pattern(file_path):
  980. """Check if a file path is a clear pattern file."""
  981. # Get all possible clear pattern files for all table types
  982. clear_patterns = []
  983. for table_type in ['dune_weaver', 'dune_weaver_mini', 'dune_weaver_pro']:
  984. clear_patterns.extend([
  985. f'./patterns/clear_from_out{("_" + table_type.split("_")[-1]) if table_type != "dune_weaver" else ""}.thr',
  986. f'./patterns/clear_from_in{("_" + table_type.split("_")[-1]) if table_type != "dune_weaver" else ""}.thr',
  987. f'./patterns/clear_sideway{("_" + table_type.split("_")[-1]) if table_type != "dune_weaver" else ""}.thr'
  988. ])
  989. # Normalize paths for comparison
  990. normalized_path = os.path.normpath(file_path)
  991. normalized_clear_patterns = [os.path.normpath(p) for p in clear_patterns]
  992. # Check if the file path matches any clear pattern path
  993. return normalized_path in normalized_clear_patterns
  994. async def _execute_pattern_internal(file_path):
  995. """Internal function to execute a pattern file. Must be called with lock already held.
  996. Args:
  997. file_path: Path to the .thr file to execute
  998. Returns:
  999. True if pattern completed successfully, False if stopped/skipped
  1000. """
  1001. # Run file parsing in thread to avoid blocking the event loop
  1002. coordinates = await asyncio.to_thread(parse_theta_rho_file, file_path)
  1003. total_coordinates = len(coordinates)
  1004. # Cache coordinates in state for frontend preview (avoids re-parsing large files)
  1005. state._current_coordinates = coordinates
  1006. if total_coordinates < 2:
  1007. logger.warning("Not enough coordinates for interpolation")
  1008. return False
  1009. # Determine if this is a clearing pattern
  1010. is_clear_file = is_clear_pattern(file_path)
  1011. if is_clear_file:
  1012. initial_speed = state.clear_pattern_speed if state.clear_pattern_speed is not None else state.speed
  1013. logger.info(f"Running clearing pattern at initial speed {initial_speed}")
  1014. else:
  1015. logger.info(f"Running normal pattern at initial speed {state.speed}")
  1016. state.execution_progress = (0, total_coordinates, None, 0)
  1017. # stop actions without resetting the playlist, and don't wait for lock (we already have it)
  1018. # Preserve is_clearing flag since stop_actions resets it
  1019. was_clearing = state.is_clearing
  1020. await stop_actions(clear_playlist=False, wait_for_lock=False)
  1021. state.is_clearing = was_clearing
  1022. state.current_playing_file = file_path
  1023. state.stop_requested = False
  1024. # Reset LED idle timeout activity time when pattern starts
  1025. import time as time_module
  1026. state.dw_led_last_activity_time = time_module.time()
  1027. logger.info(f"Starting pattern execution: {file_path}")
  1028. logger.info(f"t: {state.current_theta}, r: {state.current_rho}")
  1029. await reset_theta()
  1030. start_time = time.time()
  1031. total_pause_time = 0 # Track total time spent paused (manual + scheduled)
  1032. if state.led_controller:
  1033. logger.info(f"Setting LED to playing effect: {state.dw_led_playing_effect}")
  1034. await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
  1035. # Cancel idle timeout when playing starts
  1036. idle_timeout_manager.cancel_timeout()
  1037. with tqdm(
  1038. total=total_coordinates,
  1039. unit="coords",
  1040. desc=f"Executing Pattern {file_path}",
  1041. dynamic_ncols=True,
  1042. disable=False,
  1043. mininterval=1.0
  1044. ) as pbar:
  1045. for i, coordinate in enumerate(coordinates):
  1046. theta, rho = coordinate
  1047. if state.stop_requested:
  1048. logger.info("Execution stopped by user")
  1049. await start_idle_led_timeout()
  1050. break
  1051. if state.skip_requested:
  1052. logger.info("Skipping pattern...")
  1053. await connection_manager.check_idle_async()
  1054. await start_idle_led_timeout()
  1055. break
  1056. # Wait for resume if paused (manual or scheduled)
  1057. manual_pause = state.pause_requested
  1058. # Only check scheduled pause during pattern if "finish pattern first" is NOT enabled
  1059. scheduled_pause = is_in_scheduled_pause_period() if not state.scheduled_pause_finish_pattern else False
  1060. if manual_pause or scheduled_pause:
  1061. pause_start = time.time() # Track when pause started
  1062. if manual_pause and scheduled_pause:
  1063. logger.info("Execution paused (manual + scheduled pause active)...")
  1064. elif manual_pause:
  1065. logger.info("Execution paused (manual)...")
  1066. else:
  1067. logger.info("Execution paused (scheduled pause period)...")
  1068. # Turn off LED controller if scheduled pause and control_wled is enabled
  1069. if state.scheduled_pause_control_wled and state.led_controller:
  1070. logger.info("Turning off LED lights during Still Sands period")
  1071. await state.led_controller.set_power_async(0)
  1072. # Show idle effect for manual pause or scheduled pause without LED control
  1073. # (skip Still Sands check since we handle it above with local scheduled_pause variable)
  1074. if not (scheduled_pause and state.scheduled_pause_control_wled):
  1075. await start_idle_led_timeout(check_still_sands=False)
  1076. # Remember if we turned off LED controller for scheduled pause
  1077. wled_was_off_for_scheduled = scheduled_pause and state.scheduled_pause_control_wled and not manual_pause
  1078. # Wait until both manual pause is released AND we're outside scheduled pause period
  1079. # Also check for stop/skip requests to allow immediate interruption
  1080. interrupted = False
  1081. while state.pause_requested or is_in_scheduled_pause_period():
  1082. # Check for stop/skip first
  1083. if state.stop_requested:
  1084. logger.info("Stop requested during pause, exiting")
  1085. interrupted = True
  1086. break
  1087. if state.skip_requested:
  1088. logger.info("Skip requested during pause, skipping pattern")
  1089. interrupted = True
  1090. break
  1091. if state.pause_requested:
  1092. # For manual pause, wait on multiple events for immediate response
  1093. # Wake on: resume, stop, skip, or timeout (for flag polling fallback)
  1094. pause_event = get_pause_event()
  1095. stop_event = state.get_stop_event()
  1096. skip_event = state.get_skip_event()
  1097. wait_tasks = [asyncio.create_task(pause_event.wait(), name='pause')]
  1098. if stop_event:
  1099. wait_tasks.append(asyncio.create_task(stop_event.wait(), name='stop'))
  1100. if skip_event:
  1101. wait_tasks.append(asyncio.create_task(skip_event.wait(), name='skip'))
  1102. # Add timeout to ensure we periodically check flags even if events aren't set
  1103. # This handles the case where stop is called from sync context (no event loop)
  1104. timeout_task = asyncio.create_task(asyncio.sleep(1.0), name='timeout')
  1105. wait_tasks.append(timeout_task)
  1106. try:
  1107. done, pending = await asyncio.wait(
  1108. wait_tasks, return_when=asyncio.FIRST_COMPLETED
  1109. )
  1110. finally:
  1111. for task in pending:
  1112. task.cancel()
  1113. for task in pending:
  1114. try:
  1115. await task
  1116. except asyncio.CancelledError:
  1117. pass
  1118. else:
  1119. # For scheduled pause, use wait_for_interrupt for instant response
  1120. result = await state.wait_for_interrupt(timeout=1.0)
  1121. if result in ('stopped', 'skipped'):
  1122. interrupted = True
  1123. break
  1124. total_pause_time += time.time() - pause_start # Add pause duration
  1125. if interrupted:
  1126. # Exit the coordinate loop if we were interrupted
  1127. break
  1128. logger.info("Execution resumed...")
  1129. if state.led_controller:
  1130. # Turn LED controller back on if it was turned off for scheduled pause
  1131. if wled_was_off_for_scheduled:
  1132. logger.info("Turning LED lights back on as Still Sands period ended")
  1133. await state.led_controller.set_power_async(1)
  1134. # CRITICAL: Give LED controller time to fully power on before sending more commands
  1135. # Without this delay, rapid-fire requests can crash controllers on resource-constrained Pis
  1136. await asyncio.sleep(0.5)
  1137. await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
  1138. # Cancel idle timeout when resuming from pause
  1139. idle_timeout_manager.cancel_timeout()
  1140. # Dynamically determine the speed for each movement
  1141. # Use clear_pattern_speed if it's set and this is a clear file, otherwise use state.speed
  1142. if is_clear_file and state.clear_pattern_speed is not None:
  1143. current_speed = state.clear_pattern_speed
  1144. else:
  1145. current_speed = state.speed
  1146. await move_polar(theta, rho, current_speed)
  1147. # Update progress for all coordinates including the first one
  1148. pbar.update(1)
  1149. elapsed_time = time.time() - start_time
  1150. estimated_remaining_time = (total_coordinates - (i + 1)) / pbar.format_dict['rate'] if pbar.format_dict['rate'] and total_coordinates else 0
  1151. state.execution_progress = (i + 1, total_coordinates, estimated_remaining_time, elapsed_time)
  1152. # Add a small delay to allow other async operations
  1153. await asyncio.sleep(0.001)
  1154. # Update progress one last time to show 100%
  1155. elapsed_time = time.time() - start_time
  1156. actual_execution_time = elapsed_time - total_pause_time
  1157. state.execution_progress = (total_coordinates, total_coordinates, 0, elapsed_time)
  1158. # Give WebSocket a chance to send the final update
  1159. await asyncio.sleep(0.1)
  1160. # Log execution time (only for completed patterns, not stopped/skipped)
  1161. was_completed = not state.stop_requested and not state.skip_requested
  1162. pattern_name = os.path.basename(file_path)
  1163. effective_speed = state.clear_pattern_speed if (is_clear_file and state.clear_pattern_speed is not None) else state.speed
  1164. log_execution_time(
  1165. pattern_name=pattern_name,
  1166. table_type=state.table_type,
  1167. speed=effective_speed,
  1168. actual_time=actual_execution_time,
  1169. total_coordinates=total_coordinates,
  1170. was_completed=was_completed
  1171. )
  1172. if not state.conn:
  1173. logger.error("Device is not connected. Stopping pattern execution.")
  1174. return False
  1175. await connection_manager.check_idle_async()
  1176. # Set LED back to idle when pattern completes normally (not stopped early)
  1177. # This also handles Still Sands: turns off LEDs if in scheduled pause period with LED control
  1178. if not state.stop_requested:
  1179. await start_idle_led_timeout()
  1180. return was_completed
  1181. async def run_theta_rho_file(file_path, is_playlist=False, clear_pattern=None, cache_data=None):
  1182. """Run a theta-rho file with optional pre-execution clear pattern.
  1183. Args:
  1184. file_path: Path to the main .thr file to execute
  1185. is_playlist: True if running as part of a playlist
  1186. clear_pattern: Clear pattern mode ('adaptive', 'clear_from_in', 'clear_from_out', 'none', or None)
  1187. cache_data: Pre-loaded metadata cache for adaptive clear pattern selection
  1188. """
  1189. lock = get_pattern_lock()
  1190. if lock.locked():
  1191. logger.warning("Another pattern is already running. Cannot start a new one.")
  1192. return
  1193. async with lock: # This ensures only one pattern can run at a time
  1194. # Clear any stale pause state from previous playlist
  1195. state.pause_time_remaining = 0
  1196. state.original_pause_time = None
  1197. # Start progress update task only if not part of a playlist
  1198. global progress_update_task
  1199. if not is_playlist and not progress_update_task:
  1200. progress_update_task = asyncio.create_task(broadcast_progress())
  1201. # Run clear pattern first if specified
  1202. if clear_pattern and clear_pattern != 'none':
  1203. clear_file_path = get_clear_pattern_file(clear_pattern, file_path, cache_data)
  1204. if clear_file_path:
  1205. logger.info(f"Running pre-execution clear pattern: {clear_file_path}")
  1206. state.is_clearing = True
  1207. await _execute_pattern_internal(clear_file_path)
  1208. state.is_clearing = False
  1209. # Reset skip flag after clear pattern (if user skipped clear, continue to main)
  1210. state.skip_requested = False
  1211. # Check if stopped during clear pattern
  1212. if state.stop_requested:
  1213. logger.info("Execution stopped during clear pattern")
  1214. if not is_playlist:
  1215. state.current_playing_file = None
  1216. state.execution_progress = None
  1217. return
  1218. # Run the main pattern
  1219. completed = await _execute_pattern_internal(file_path)
  1220. # Only clear state if not part of a playlist
  1221. if not is_playlist:
  1222. state.current_playing_file = None
  1223. state.execution_progress = None
  1224. logger.info("Pattern execution completed and state cleared")
  1225. # Only cancel progress update task if not part of a playlist
  1226. if progress_update_task:
  1227. progress_update_task.cancel()
  1228. try:
  1229. await progress_update_task
  1230. except asyncio.CancelledError:
  1231. pass
  1232. progress_update_task = None
  1233. else:
  1234. logger.info("Pattern execution completed, maintaining state for playlist")
  1235. async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
  1236. """Run multiple .thr files in sequence with options.
  1237. The playlist now stores only main patterns. Clear patterns are executed dynamically
  1238. before each main pattern based on the clear_pattern option.
  1239. """
  1240. state.stop_requested = False
  1241. # Track whether we actually started executing patterns.
  1242. # If cancelled before execution begins (e.g., by TestClient cleanup),
  1243. # we should NOT clear state that was set by the caller.
  1244. task_started_execution = False
  1245. # Reset LED idle timeout activity time when playlist starts
  1246. import time as time_module
  1247. state.dw_led_last_activity_time = time_module.time()
  1248. # Set initial playlist state only if not already set by caller (playlist_manager).
  1249. # This ensures backward compatibility when this function is called directly.
  1250. if state.playlist_mode is None:
  1251. state.playlist_mode = run_mode
  1252. if state.current_playlist_index is None:
  1253. state.current_playlist_index = 0
  1254. # Start progress update task for the playlist
  1255. global progress_update_task
  1256. if not progress_update_task:
  1257. progress_update_task = asyncio.create_task(broadcast_progress())
  1258. # Shuffle main patterns if requested (before starting)
  1259. if shuffle:
  1260. random.shuffle(file_paths)
  1261. logger.info("Playlist shuffled")
  1262. # Store patterns in state only if not already set by caller.
  1263. # The caller (playlist_manager.run_playlist) sets this before creating the task.
  1264. if state.current_playlist is None:
  1265. state.current_playlist = file_paths
  1266. try:
  1267. while True:
  1268. # Load metadata cache once per playlist iteration (for adaptive clear patterns)
  1269. cache_data = None
  1270. if clear_pattern and clear_pattern in ['adaptive', 'clear_from_in', 'clear_from_out']:
  1271. from modules.core import cache_manager
  1272. cache_data = await asyncio.to_thread(cache_manager.load_metadata_cache)
  1273. logger.info(f"Loaded metadata cache for {len(cache_data.get('data', {}))} patterns")
  1274. # Reset pattern counter at the start of the playlist
  1275. state.patterns_since_last_home = 0
  1276. # Execute main patterns using index-based access
  1277. # This allows the playlist to be reordered during execution
  1278. idx = 0
  1279. while state.current_playlist and idx < len(state.current_playlist):
  1280. state.current_playlist_index = idx
  1281. if state.stop_requested or not state.current_playlist:
  1282. logger.info("Execution stopped")
  1283. return
  1284. # Get the pattern at the current index (may have changed due to reordering)
  1285. file_path = state.current_playlist[idx]
  1286. logger.info(f"Running pattern {idx + 1}/{len(state.current_playlist)}: {file_path}")
  1287. # Mark that we've started actual execution (for cleanup logic)
  1288. task_started_execution = True
  1289. # Clear pause state when starting a new pattern (prevents stale "waiting" UI)
  1290. state.pause_time_remaining = 0
  1291. state.original_pause_time = None
  1292. # Execute the pattern with optional clear pattern
  1293. await run_theta_rho_file(
  1294. file_path,
  1295. is_playlist=True,
  1296. clear_pattern=clear_pattern,
  1297. cache_data=cache_data
  1298. )
  1299. # Increment pattern counter (auto-home check happens after pause time)
  1300. state.patterns_since_last_home += 1
  1301. logger.debug(f"Patterns since last home: {state.patterns_since_last_home}")
  1302. # Check for scheduled pause after pattern completes (when "finish pattern first" is enabled)
  1303. if state.scheduled_pause_finish_pattern and is_in_scheduled_pause_period() and not state.stop_requested and not state.skip_requested:
  1304. logger.info("Pattern completed. Entering Still Sands period (finish pattern first mode)...")
  1305. wled_was_off_for_scheduled = False
  1306. if state.scheduled_pause_control_wled and state.led_controller:
  1307. logger.info("Turning off LED lights during Still Sands period")
  1308. await state.led_controller.set_power_async(0)
  1309. wled_was_off_for_scheduled = True
  1310. else:
  1311. # Show idle effect (WLED control not enabled)
  1312. await start_idle_led_timeout(check_still_sands=False)
  1313. # Wait for scheduled pause to end, but allow stop/skip to interrupt
  1314. result = await wait_with_interrupt(
  1315. is_in_scheduled_pause_period,
  1316. check_stop=True,
  1317. check_skip=True,
  1318. )
  1319. if result == 'completed':
  1320. logger.info("Still Sands period ended. Resuming playlist...")
  1321. if state.led_controller:
  1322. if wled_was_off_for_scheduled:
  1323. logger.info("Turning LED lights back on as Still Sands period ended")
  1324. await state.led_controller.set_power_async(1)
  1325. await asyncio.sleep(0.5)
  1326. await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
  1327. idle_timeout_manager.cancel_timeout()
  1328. # Handle pause between patterns
  1329. if state.current_playlist and idx < len(state.current_playlist) - 1 and not state.stop_requested and pause_time > 0 and not state.skip_requested:
  1330. logger.info(f"Pausing for {pause_time} seconds")
  1331. state.original_pause_time = pause_time
  1332. pause_start = time.time()
  1333. while time.time() - pause_start < pause_time:
  1334. state.pause_time_remaining = pause_start + pause_time - time.time()
  1335. if state.skip_requested:
  1336. logger.info("Pause interrupted by skip request")
  1337. break
  1338. await asyncio.sleep(1)
  1339. # Clear both pause state vars immediately (so UI updates right away)
  1340. state.pause_time_remaining = 0
  1341. state.original_pause_time = None
  1342. # Auto-home after pause time, before next clear pattern starts
  1343. # Only home if there's a next pattern and we haven't been stopped
  1344. if (state.auto_home_enabled and
  1345. state.patterns_since_last_home >= state.auto_home_after_patterns and
  1346. state.current_playlist and idx < len(state.current_playlist) - 1 and
  1347. not state.stop_requested):
  1348. logger.info(f"Auto-homing triggered after {state.patterns_since_last_home} patterns (before next clear pattern)")
  1349. try:
  1350. success = await asyncio.to_thread(connection_manager.home)
  1351. if success:
  1352. logger.info("Auto-homing completed successfully")
  1353. state.patterns_since_last_home = 0
  1354. else:
  1355. logger.warning("Auto-homing failed, continuing with playlist")
  1356. except Exception as e:
  1357. logger.error(f"Error during auto-homing: {e}")
  1358. state.skip_requested = False
  1359. idx += 1
  1360. if run_mode == "indefinite":
  1361. logger.info("Playlist completed. Restarting as per 'indefinite' run mode")
  1362. if pause_time > 0:
  1363. pause_start = time.time()
  1364. while time.time() - pause_start < pause_time:
  1365. state.pause_time_remaining = pause_start + pause_time - time.time()
  1366. if state.skip_requested:
  1367. logger.info("Pause interrupted by skip request")
  1368. break
  1369. await asyncio.sleep(1)
  1370. # Clear both pause state vars immediately (so UI updates right away)
  1371. state.pause_time_remaining = 0
  1372. state.original_pause_time = None
  1373. continue
  1374. else:
  1375. logger.info("Playlist completed")
  1376. break
  1377. except asyncio.CancelledError:
  1378. # Task was cancelled externally (e.g., by TestClient cleanup, or explicit cancellation).
  1379. # Do NOT clear playlist state - preserve what the caller set.
  1380. logger.info("Playlist task was cancelled externally, preserving state")
  1381. if progress_update_task:
  1382. progress_update_task.cancel()
  1383. try:
  1384. await progress_update_task
  1385. except asyncio.CancelledError:
  1386. pass
  1387. progress_update_task = None
  1388. raise # Re-raise to signal cancellation
  1389. finally:
  1390. if progress_update_task:
  1391. progress_update_task.cancel()
  1392. try:
  1393. await progress_update_task
  1394. except asyncio.CancelledError:
  1395. pass
  1396. progress_update_task = None
  1397. # Check if we're exiting due to CancelledError - if so, don't clear state.
  1398. # State should only be cleared when:
  1399. # 1. Task completed normally (all patterns executed)
  1400. # 2. Task was stopped by user request (stop_requested)
  1401. # NOT when task was cancelled externally (CancelledError)
  1402. import sys
  1403. exc_type = sys.exc_info()[0]
  1404. if exc_type is asyncio.CancelledError:
  1405. logger.info("Task exiting due to cancellation, state preserved for caller")
  1406. else:
  1407. # Normal completion or user-requested stop - clear state
  1408. state.current_playing_file = None
  1409. state.execution_progress = None
  1410. state.current_playlist = None
  1411. state.current_playlist_index = None
  1412. state.playlist_mode = None
  1413. state.pause_time_remaining = 0
  1414. await start_idle_led_timeout()
  1415. logger.info("All requested patterns completed (or stopped) and state cleared")
  1416. async def stop_actions(clear_playlist = True, wait_for_lock = True):
  1417. """Stop all current actions and wait for pattern to fully release.
  1418. Args:
  1419. clear_playlist: Whether to clear playlist state
  1420. wait_for_lock: Whether to wait for pattern_lock to be released. Set to False when
  1421. called from within pattern execution to avoid deadlock.
  1422. Returns:
  1423. True if stopped cleanly, False if timed out waiting for pattern lock
  1424. """
  1425. timed_out = False
  1426. try:
  1427. with state.pause_condition:
  1428. state.pause_requested = False
  1429. state.stop_requested = True
  1430. state.is_clearing = False
  1431. # Always clear pause time between patterns on stop
  1432. state.pause_time_remaining = 0
  1433. state.original_pause_time = None
  1434. if clear_playlist:
  1435. # Clear playlist state
  1436. state.current_playlist = None
  1437. state.current_playlist_index = None
  1438. state.playlist_mode = None
  1439. # Cancel progress update task if we're clearing the playlist
  1440. global progress_update_task
  1441. if progress_update_task and not progress_update_task.done():
  1442. progress_update_task.cancel()
  1443. # Cancel the playlist task itself (late import to avoid circular dependency)
  1444. from modules.core import playlist_manager
  1445. await playlist_manager.cancel_current_playlist()
  1446. state.pause_condition.notify_all()
  1447. # Also set the pause event to wake up any paused patterns
  1448. get_pause_event().set()
  1449. # Send stop command to motion thread to clear its queue
  1450. if motion_controller.running:
  1451. motion_controller.command_queue.put(MotionCommand('stop'))
  1452. # Wait for the pattern lock to be released before continuing
  1453. # This ensures that when stop_actions completes, the pattern has fully stopped
  1454. # Skip this if called from within pattern execution to avoid deadlock
  1455. lock = get_pattern_lock()
  1456. if wait_for_lock and lock.locked():
  1457. logger.info("Waiting for pattern to fully stop...")
  1458. # Use a timeout to prevent hanging forever
  1459. # Use wait_for for Python 3.9 compatibility (asyncio.timeout is 3.11+)
  1460. try:
  1461. async def acquire_stop_lock():
  1462. async with lock:
  1463. logger.info("Pattern lock acquired - pattern has fully stopped")
  1464. await asyncio.wait_for(acquire_stop_lock(), timeout=10.0)
  1465. except asyncio.TimeoutError:
  1466. logger.warning("Timeout waiting for pattern to stop - forcing cleanup")
  1467. timed_out = True
  1468. # Force cleanup of state even if pattern didn't release lock gracefully
  1469. state.current_playing_file = None
  1470. state.execution_progress = None
  1471. state.is_running = False
  1472. # Clear current playing file only when clearing the entire playlist.
  1473. # When clear_playlist=False (called from within pattern execution), the caller
  1474. # will set current_playing_file to the new pattern immediately after.
  1475. if clear_playlist:
  1476. state.current_playing_file = None
  1477. state.execution_progress = None
  1478. # Clear stop_requested now that the pattern has stopped - this allows
  1479. # check_idle_async to work (it exits early if stop_requested is True)
  1480. state.stop_requested = False
  1481. # Wait for hardware to reach idle state before returning
  1482. # This ensures the machine has physically stopped moving
  1483. if not timed_out:
  1484. idle = await connection_manager.check_idle_async(timeout=30.0)
  1485. if not idle:
  1486. logger.warning("Machine did not reach idle after stop")
  1487. # Call async function directly since we're in async context
  1488. await connection_manager.update_machine_position()
  1489. return not timed_out
  1490. except Exception as e:
  1491. logger.error(f"Error during stop_actions: {e}")
  1492. # Force cleanup state on error
  1493. state.current_playing_file = None
  1494. state.execution_progress = None
  1495. state.is_running = False
  1496. # Ensure we still update machine position even if there's an error
  1497. try:
  1498. await connection_manager.update_machine_position()
  1499. except Exception as update_err:
  1500. logger.error(f"Error updating machine position on error: {update_err}")
  1501. return False
  1502. async def move_polar(theta, rho, speed=None):
  1503. """
  1504. Queue a motion command to be executed in the dedicated motion control thread.
  1505. This makes motion control non-blocking for API endpoints.
  1506. Args:
  1507. theta (float): Target theta coordinate
  1508. rho (float): Target rho coordinate
  1509. speed (int, optional): Speed override. If None, uses state.speed
  1510. """
  1511. # Note: stop_requested is cleared once at pattern start (execute_theta_rho_file line 890)
  1512. # Don't clear it here on every coordinate - causes performance issues with event system
  1513. # Ensure motion control thread is running
  1514. if not motion_controller.running:
  1515. motion_controller.start()
  1516. # Create future for async/await pattern
  1517. loop = asyncio.get_event_loop()
  1518. future = loop.create_future()
  1519. # Create and queue motion command
  1520. command = MotionCommand(
  1521. command_type='move',
  1522. theta=theta,
  1523. rho=rho,
  1524. speed=speed,
  1525. future=future
  1526. )
  1527. motion_controller.command_queue.put(command)
  1528. logger.debug(f"Queued motion command: theta={theta}, rho={rho}, speed={speed}")
  1529. # Wait for command completion
  1530. await future
  1531. def pause_execution():
  1532. """Pause pattern execution using asyncio Event."""
  1533. logger.info("Pausing pattern execution")
  1534. state.pause_requested = True
  1535. get_pause_event().clear() # Clear the event to pause execution
  1536. return True
  1537. def resume_execution():
  1538. """Resume pattern execution using asyncio Event."""
  1539. logger.info("Resuming pattern execution")
  1540. state.pause_requested = False
  1541. get_pause_event().set() # Set the event to resume execution
  1542. return True
  1543. async def reset_theta():
  1544. """
  1545. Reset theta to [0, 2π) range and hard reset machine position using $Bye.
  1546. $Bye sends a soft reset to FluidNC which resets the controller and clears
  1547. all position counters to 0. This is more reliable than G92 which only sets
  1548. a work coordinate offset without changing the actual machine position (MPos).
  1549. IMPORTANT: We wait for machine to be idle before sending $Bye to avoid
  1550. error:25 ("Feed rate not specified in block") which can occur if the
  1551. controller is still processing commands when reset is triggered.
  1552. """
  1553. logger.info('Resetting Theta')
  1554. # Wait for machine to be idle before reset to prevent error:25
  1555. if state.conn and state.conn.is_connected():
  1556. logger.info("Waiting for machine to be idle before reset...")
  1557. idle = await connection_manager.check_idle_async(timeout=30)
  1558. if not idle:
  1559. logger.warning("Machine not idle after 30s, proceeding with reset anyway")
  1560. state.current_theta = state.current_theta % (2 * pi)
  1561. # Hard reset machine position using $Bye via connection_manager
  1562. success = await connection_manager.perform_soft_reset()
  1563. if not success:
  1564. logger.error("Soft reset failed - theta reset may be unreliable")
  1565. def set_speed(new_speed):
  1566. state.speed = new_speed
  1567. logger.info(f'Set new state.speed {new_speed}')
  1568. def get_status():
  1569. """Get the current status of pattern execution."""
  1570. status = {
  1571. "current_file": state.current_playing_file,
  1572. "is_paused": state.pause_requested or is_in_scheduled_pause_period(),
  1573. "manual_pause": state.pause_requested,
  1574. "scheduled_pause": is_in_scheduled_pause_period(),
  1575. "is_running": bool(state.current_playing_file and not state.stop_requested),
  1576. "is_homing": state.is_homing,
  1577. "sensor_homing_failed": state.sensor_homing_failed,
  1578. "is_clearing": state.is_clearing,
  1579. "progress": None,
  1580. "playlist": None,
  1581. "speed": state.speed,
  1582. "pause_time_remaining": state.pause_time_remaining,
  1583. "original_pause_time": getattr(state, 'original_pause_time', None),
  1584. "connection_status": state.conn.is_connected() if state.conn else False,
  1585. "current_theta": state.current_theta,
  1586. "current_rho": state.current_rho
  1587. }
  1588. # Add playlist information if available
  1589. if state.current_playlist and state.current_playlist_index is not None:
  1590. # When a clear pattern is running, the "next" pattern is the current main pattern
  1591. # (since the clear pattern runs before the main pattern at current_playlist_index)
  1592. if state.is_clearing:
  1593. next_file = state.current_playlist[state.current_playlist_index]
  1594. else:
  1595. next_index = state.current_playlist_index + 1
  1596. next_file = state.current_playlist[next_index] if next_index < len(state.current_playlist) else None
  1597. status["playlist"] = {
  1598. "current_index": state.current_playlist_index,
  1599. "total_files": len(state.current_playlist),
  1600. "mode": state.playlist_mode,
  1601. "next_file": next_file,
  1602. "files": state.current_playlist,
  1603. "name": state.current_playlist_name
  1604. }
  1605. if state.execution_progress:
  1606. current, total, remaining_time, elapsed_time = state.execution_progress
  1607. status["progress"] = {
  1608. "current": current,
  1609. "total": total,
  1610. "remaining_time": remaining_time,
  1611. "elapsed_time": elapsed_time,
  1612. "percentage": (current / total * 100) if total > 0 else 0
  1613. }
  1614. # Add historical execution time if available for this pattern at current speed
  1615. if state.current_playing_file:
  1616. pattern_name = os.path.basename(state.current_playing_file)
  1617. historical_time = get_last_completed_execution_time(pattern_name, state.speed)
  1618. if historical_time:
  1619. status["progress"]["last_completed_time"] = historical_time
  1620. return status
  1621. async def broadcast_progress():
  1622. """Background task to broadcast progress updates."""
  1623. from main import broadcast_status_update
  1624. while True:
  1625. # Send status updates regardless of pattern_lock state
  1626. status = get_status()
  1627. # Use the existing broadcast function from main.py
  1628. await broadcast_status_update(status)
  1629. # Check if we should stop broadcasting
  1630. if not state.current_playlist:
  1631. # If no playlist, only stop if no pattern is being executed
  1632. if not get_pattern_lock().locked():
  1633. logger.info("No playlist or pattern running, stopping broadcast")
  1634. break
  1635. # Wait before next update
  1636. await asyncio.sleep(1)