pattern_manager.py 81 KB

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