1
0

pattern_manager.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. """
  2. Pattern-file and history utilities.
  3. Execution is firmware-delegated (see modules/core/execution.py): the board
  4. runs patterns and playlists itself; this module only keeps the host-side
  5. pattern catalog, the board-SD mirroring helpers, the play-history log, and
  6. the Still Sands time-window math used by the WLED quiet-hours watcher.
  7. """
  8. import os
  9. from zoneinfo import ZoneInfo
  10. import time
  11. import logging
  12. from datetime import datetime, time as datetime_time
  13. from modules.connection import connection_manager
  14. from modules.core.state import state
  15. from math import pi
  16. import asyncio
  17. import json
  18. from modules.led.idle_timeout_manager import idle_timeout_manager
  19. from typing import Optional
  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. def log_execution_time(pattern_name: str, table_type: str, speed: int, actual_time: float,
  28. total_coordinates: int, was_completed: bool):
  29. """Log pattern execution time to JSON Lines file for analysis.
  30. Args:
  31. pattern_name: Name of the pattern file
  32. table_type: Type of table (e.g., 'dune_weaver', 'dune_weaver_mini')
  33. speed: Speed setting used (0-255)
  34. actual_time: Actual execution time in seconds (excluding pauses)
  35. total_coordinates: Total number of coordinates in the pattern
  36. was_completed: Whether the pattern completed normally (not stopped/skipped)
  37. """
  38. # Format time as HH:MM:SS
  39. hours, remainder = divmod(int(actual_time), 3600)
  40. minutes, seconds = divmod(remainder, 60)
  41. time_formatted = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
  42. log_entry = {
  43. "timestamp": datetime.now().isoformat(),
  44. "pattern_name": pattern_name,
  45. "table_type": table_type or "unknown",
  46. "speed": speed,
  47. "actual_time_seconds": round(actual_time, 2),
  48. "actual_time_formatted": time_formatted,
  49. "total_coordinates": total_coordinates,
  50. "completed": was_completed
  51. }
  52. try:
  53. with open(EXECUTION_LOG_FILE, 'a') as f:
  54. f.write(json.dumps(log_entry) + '\n')
  55. logger.info(f"Execution time logged: {pattern_name} - {time_formatted} (speed: {speed}, table: {table_type})")
  56. except Exception as e:
  57. logger.error(f"Failed to log execution time: {e}")
  58. def get_last_completed_execution_time(pattern_name: str, speed: float) -> Optional[dict]:
  59. """Get the last completed execution time for a pattern at a specific speed.
  60. Args:
  61. pattern_name: Name of the pattern file (e.g., 'circle.thr')
  62. speed: Speed setting to match
  63. Returns:
  64. Dict with execution time info if found, None otherwise.
  65. Format: {"actual_time_seconds": float, "actual_time_formatted": str, "timestamp": str}
  66. """
  67. if not os.path.exists(EXECUTION_LOG_FILE):
  68. return None
  69. try:
  70. matching_entry = None
  71. with open(EXECUTION_LOG_FILE, 'r') as f:
  72. for line in f:
  73. line = line.strip()
  74. if not line:
  75. continue
  76. try:
  77. entry = json.loads(line)
  78. # Only consider fully completed patterns (100% finished)
  79. if (entry.get('completed', False) and
  80. entry.get('pattern_name') == pattern_name and
  81. entry.get('speed') == speed):
  82. # Keep the most recent match (last one in file)
  83. matching_entry = entry
  84. except json.JSONDecodeError:
  85. continue
  86. if matching_entry:
  87. return {
  88. "actual_time_seconds": matching_entry.get('actual_time_seconds'),
  89. "actual_time_formatted": matching_entry.get('actual_time_formatted'),
  90. "timestamp": matching_entry.get('timestamp')
  91. }
  92. return None
  93. except Exception as e:
  94. logger.error(f"Failed to read execution time log: {e}")
  95. return None
  96. def get_pattern_execution_history(pattern_name: str) -> Optional[dict]:
  97. """Get the most recent completed execution for a pattern (any speed).
  98. Args:
  99. pattern_name: Name of the pattern file (e.g., 'circle.thr')
  100. Returns:
  101. Dict with execution time info if found, None otherwise.
  102. Format: {"actual_time_seconds": float, "actual_time_formatted": str,
  103. "speed": int, "timestamp": str}
  104. """
  105. if not os.path.exists(EXECUTION_LOG_FILE):
  106. return None
  107. try:
  108. matching_entry = None
  109. with open(EXECUTION_LOG_FILE, 'r') as f:
  110. for line in f:
  111. line = line.strip()
  112. if not line:
  113. continue
  114. try:
  115. entry = json.loads(line)
  116. # Only consider fully completed patterns
  117. if (entry.get('completed', False) and
  118. entry.get('pattern_name') == pattern_name):
  119. # Keep the most recent match (last one in file)
  120. matching_entry = entry
  121. except json.JSONDecodeError:
  122. continue
  123. if matching_entry:
  124. return {
  125. "actual_time_seconds": matching_entry.get('actual_time_seconds'),
  126. "actual_time_formatted": matching_entry.get('actual_time_formatted'),
  127. "speed": matching_entry.get('speed'),
  128. "timestamp": matching_entry.get('timestamp')
  129. }
  130. return None
  131. except Exception as e:
  132. logger.error(f"Failed to read execution time log: {e}")
  133. return None
  134. _cached_timezone = None
  135. _cached_zoneinfo = None
  136. def _get_timezone():
  137. """Get and cache the timezone for Still Sands. Uses user-selected timezone if set, otherwise system timezone."""
  138. global _cached_timezone, _cached_zoneinfo
  139. if _cached_timezone is not None:
  140. return _cached_zoneinfo
  141. user_tz = 'UTC' # Default fallback
  142. # First, check if user has selected a specific timezone in settings
  143. if state.scheduled_pause_timezone:
  144. user_tz = state.scheduled_pause_timezone
  145. logger.info(f"Still Sands using timezone: {user_tz} (user-selected)")
  146. else:
  147. # Fall back to system timezone detection
  148. try:
  149. if os.path.exists('/etc/timezone'):
  150. with open('/etc/timezone', 'r') as f:
  151. user_tz = f.read().strip()
  152. logger.info(f"Still Sands using timezone: {user_tz} (from system)")
  153. # Fallback to TZ environment variable
  154. elif os.environ.get('TZ'):
  155. user_tz = os.environ.get('TZ')
  156. logger.info(f"Still Sands using timezone: {user_tz} (from environment)")
  157. else:
  158. logger.info("Still Sands using timezone: UTC (system default)")
  159. except Exception as e:
  160. logger.debug(f"Could not read timezone: {e}")
  161. # Cache the timezone
  162. _cached_timezone = user_tz
  163. try:
  164. _cached_zoneinfo = ZoneInfo(user_tz)
  165. except Exception as e:
  166. logger.warning(f"Invalid timezone '{user_tz}', falling back to system time: {e}")
  167. _cached_zoneinfo = None
  168. return _cached_zoneinfo
  169. def is_in_scheduled_pause_period():
  170. """Check if current time falls within any scheduled pause period."""
  171. if not state.scheduled_pause_enabled or not state.scheduled_pause_time_slots:
  172. return False
  173. # Get cached timezone (user-selected or system default)
  174. tz_info = _get_timezone()
  175. try:
  176. # Get current time in user's timezone
  177. if tz_info:
  178. now = datetime.now(tz_info)
  179. else:
  180. now = datetime.now()
  181. except Exception as e:
  182. logger.warning(f"Error getting current time: {e}")
  183. now = datetime.now()
  184. current_time = now.time()
  185. current_weekday = now.strftime("%A").lower() # monday, tuesday, etc.
  186. for slot in state.scheduled_pause_time_slots:
  187. # Parse start and end times
  188. try:
  189. start_time = datetime_time.fromisoformat(slot['start_time'])
  190. end_time = datetime_time.fromisoformat(slot['end_time'])
  191. except (ValueError, KeyError):
  192. logger.warning(f"Invalid time format in scheduled pause slot: {slot}")
  193. continue
  194. # Check if this slot applies to today
  195. slot_applies_today = False
  196. days_setting = slot.get('days', 'daily')
  197. if days_setting == 'daily':
  198. slot_applies_today = True
  199. elif days_setting == 'weekdays':
  200. slot_applies_today = current_weekday in ['monday', 'tuesday', 'wednesday', 'thursday', 'friday']
  201. elif days_setting == 'weekends':
  202. slot_applies_today = current_weekday in ['saturday', 'sunday']
  203. elif days_setting == 'custom':
  204. custom_days = slot.get('custom_days', [])
  205. slot_applies_today = current_weekday in custom_days
  206. if not slot_applies_today:
  207. continue
  208. # Check if current time is within the pause period
  209. if start_time <= end_time:
  210. # Normal case: start and end are on the same day
  211. if start_time <= current_time <= end_time:
  212. return True
  213. else:
  214. # Time spans midnight: start is before midnight, end is after midnight
  215. if current_time >= start_time or current_time <= end_time:
  216. return True
  217. return False
  218. async def check_table_is_idle() -> bool:
  219. """
  220. Check if the table is currently idle by querying actual machine status.
  221. Returns True if idle, False if playing/moving.
  222. This checks the real machine state rather than relying on state variables,
  223. making it more reliable for detecting when table is truly idle.
  224. """
  225. # Use the connection_manager's is_machine_idle() function
  226. # Run it in a thread since it's a synchronous function
  227. return await asyncio.to_thread(connection_manager.is_machine_idle)
  228. async def start_idle_led_timeout(check_still_sands: bool = True):
  229. """
  230. Set LED to idle state and start timeout if enabled.
  231. Handles Still Sands: if in scheduled pause period with LED control enabled,
  232. turns off LEDs instead of showing idle effect.
  233. Should be called whenever the table goes idle.
  234. Args:
  235. check_still_sands: If True, checks Still Sands period and turns off LEDs if applicable.
  236. Set to False when caller already handles Still Sands logic
  237. (e.g., during pause with "finish pattern first" mode).
  238. """
  239. if not state.led_controller:
  240. return
  241. if not state.led_automation_enabled:
  242. # Manual mode: Still Sands can still turn OFF, but skip idle effect + timeout
  243. if check_still_sands and is_in_scheduled_pause_period() and state.scheduled_pause_control_wled:
  244. logger.info("Manual mode: Turning off LEDs during Still Sands period")
  245. await state.led_controller.set_power_async(0)
  246. return
  247. # Still Sands with LED control: turn off instead of idle effect
  248. if check_still_sands and is_in_scheduled_pause_period() and state.scheduled_pause_control_wled:
  249. logger.info("Turning off LED lights during Still Sands period")
  250. await state.led_controller.set_power_async(0)
  251. return
  252. # Normal flow: show the idle effect. WLED uses its hardcoded preset; the
  253. # board provider is a deliberate no-op (the firmware's $LED/IdleEffect
  254. # switches by itself).
  255. await state.led_controller.effect_idle_async(None)
  256. # Start timeout if enabled
  257. if not state.dw_led_idle_timeout_enabled:
  258. logger.debug("Idle LED timeout not enabled")
  259. return
  260. timeout_minutes = state.dw_led_idle_timeout_minutes
  261. if timeout_minutes <= 0:
  262. logger.debug("Idle LED timeout not configured (timeout <= 0)")
  263. return
  264. logger.debug(f"Starting idle LED timeout: {timeout_minutes} minutes")
  265. idle_timeout_manager.start_idle_timeout(
  266. timeout_minutes=timeout_minutes,
  267. state=state,
  268. check_idle_callback=check_table_is_idle
  269. )
  270. def list_theta_rho_files():
  271. files = []
  272. for root, dirs, filenames in os.walk(THETA_RHO_DIR):
  273. # Skip cached_images directories to avoid scanning thousands of WebP files
  274. if 'cached_images' in dirs:
  275. dirs.remove('cached_images')
  276. # Filter .thr files during traversal for better performance
  277. thr_files = [f for f in filenames if f.endswith('.thr')]
  278. for file in thr_files:
  279. relative_path = os.path.relpath(os.path.join(root, file), THETA_RHO_DIR)
  280. # Normalize path separators to always use forward slashes for consistency across platforms
  281. relative_path = relative_path.replace(os.sep, '/')
  282. files.append(relative_path)
  283. logger.debug(f"Found {len(files)} theta-rho files")
  284. return files
  285. def parse_theta_rho_file(file_path):
  286. """Parse a theta-rho file and return a list of (theta, rho) pairs."""
  287. coordinates = []
  288. try:
  289. logger.debug(f"Parsing theta-rho file: {file_path}")
  290. with open(file_path, 'r', encoding='utf-8') as file:
  291. for line in file:
  292. line = line.strip()
  293. if not line or line.startswith("#"):
  294. continue
  295. try:
  296. theta, rho = map(float, line.split())
  297. coordinates.append((theta, rho))
  298. except ValueError:
  299. logger.warning(f"Skipping invalid line: {line}")
  300. continue
  301. except Exception as e:
  302. logger.error(f"Error reading file: {e}")
  303. return coordinates
  304. logger.debug(f"Parsed {len(coordinates)} coordinates from {file_path}")
  305. return coordinates
  306. def _to_sd_path(file_path):
  307. """Map a host pattern path (e.g. './patterns/star.thr') to its board SD path
  308. ('/patterns/star.thr'). The board SD mirrors the host's patterns/ tree."""
  309. p = file_path.replace("\\", "/").lstrip(".").lstrip("/")
  310. idx = p.find("patterns/")
  311. rel = p[idx:] if idx >= 0 else ("patterns/" + os.path.basename(p))
  312. return "/" + rel
  313. def _ensure_on_board(file_path, sd_path):
  314. """Upload the pattern to the board's SD card if it isn't already there."""
  315. try:
  316. if not state.conn:
  317. return
  318. if state.conn.file_exists(sd_path):
  319. return
  320. with open(file_path, "rb") as f:
  321. data = f.read()
  322. directory = sd_path.rsplit("/", 1)[0] or "/patterns"
  323. state.conn.upload_file(sd_path, data, directory)
  324. logger.info(f"Mirrored {file_path} to board at {sd_path}")
  325. except Exception as e:
  326. logger.warning(f"Could not mirror {file_path} to board: {e}")
  327. async def move_polar(theta, rho, speed=None):
  328. """
  329. Jog to an absolute (theta, rho) by delegating to the board's /sand_goto.
  330. Used by the manual move endpoints (center / perimeter / send_coordinate).
  331. Args:
  332. theta (float): Target theta in radians
  333. rho (float): Target rho (0..1)
  334. speed (int, optional): Feed override. If None, uses the board's current feed.
  335. """
  336. if not state.conn or not state.conn.is_connected():
  337. logger.warning("Cannot move: no board connection")
  338. return
  339. if speed is not None:
  340. try:
  341. await asyncio.to_thread(state.conn.set_feed, int(speed))
  342. except Exception as e:
  343. logger.warning(f"Could not set feed before jog: {e}")
  344. await asyncio.to_thread(state.conn.goto, theta, rho)
  345. async def reset_theta():
  346. """
  347. Normalize the host's tracked theta to [0, 2π).
  348. The board now owns machine position and theta accumulation, so this only keeps
  349. the host-side ``current_theta`` tidy for display. (The old $Bye hard-reset path
  350. was removed — rebooting the controller on a manual move is never what we want.)
  351. """
  352. state.current_theta = state.current_theta % (2 * pi)
  353. logger.info(f'Theta normalized to {state.current_theta:.4f} radians')
  354. def set_speed(new_speed):
  355. state.speed = new_speed
  356. logger.info(f'Set new state.speed {new_speed}')