1
0

pattern_manager.py 97 KB

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