1
0

backend.py 57 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473
  1. from PySide6.QtCore import QObject, Signal, Property, Slot, QTimer
  2. from PySide6.QtQml import QmlElement
  3. import asyncio
  4. import base64
  5. import json
  6. import logging
  7. import subprocess
  8. import threading
  9. import time
  10. from pathlib import Path
  11. import os
  12. from firmware_client import (FirmwareClient, LED_EFFECTS, LED_PALETTES,
  13. friendly_error, posix_tz)
  14. import discovery
  15. # Configure logging
  16. logging.basicConfig(
  17. level=logging.INFO,
  18. format='%(asctime)s [%(levelname)s] %(message)s',
  19. datefmt='%H:%M:%S'
  20. )
  21. logger = logging.getLogger("DuneWeaver")
  22. QML_IMPORT_NAME = "DuneWeaver"
  23. QML_IMPORT_MAJOR_VERSION = 1
  24. # Firmware clear-mode -> touch UI pre-execution option (reverse of CLEAR_MODE_MAP)
  25. _FW_TO_UI_CLEAR = {
  26. "in": "clear_center", "out": "clear_perimeter",
  27. "adaptive": "adaptive", "none": "none",
  28. "sideway": "adaptive", "random": "adaptive",
  29. }
  30. def _run(coro):
  31. """Schedule a coroutine on the running (qasync) event loop, if any."""
  32. try:
  33. asyncio.get_event_loop().create_task(coro)
  34. except RuntimeError:
  35. logger.warning("No running event loop to schedule task")
  36. def _clamp_int(value, default, lo, hi):
  37. """Coerce ``value`` to an int within [lo, hi], falling back to ``default``."""
  38. try:
  39. return max(lo, min(hi, int(value)))
  40. except (TypeError, ValueError):
  41. return default
  42. @QmlElement
  43. class Backend(QObject):
  44. """Backend controller: drives a headless FluidNC sand table over HTTP."""
  45. # Constants
  46. SETTINGS_FILE = "touch_settings.json"
  47. DEFAULT_SCREEN_TIMEOUT = 300 # 5 minutes in seconds
  48. STATUS_POLL_MS = 1000 # /sand_status poll interval
  49. # Consecutive poll failures before the UI flips to "disconnected". The
  50. # board's single-threaded web server stalls status reads for seconds while
  51. # it serves file transfers, so one slow/failed poll means "busy", not
  52. # "gone". A hard-down board fails fast (connection refused), so real
  53. # disconnects are still detected within a few seconds.
  54. STATUS_FAIL_THRESHOLD = 3
  55. # Predefined timeout options (in seconds)
  56. TIMEOUT_OPTIONS = {
  57. "30 seconds": 30,
  58. "1 minute": 60,
  59. "5 minutes": 300,
  60. "10 minutes": 600,
  61. "Never": 0 # 0 means never timeout
  62. }
  63. # Predefined speed options (motor feed, mm/min)
  64. SPEED_OPTIONS = {
  65. "50": 50,
  66. "100": 100,
  67. "150": 150,
  68. "200": 200,
  69. "300": 300,
  70. "500": 500
  71. }
  72. # Predefined pause between patterns options (in seconds)
  73. PAUSE_OPTIONS = {
  74. "0s": 0, "1 min": 60, "5 min": 300, "15 min": 900, "30 min": 1800,
  75. "1 hour": 3600, "2 hour": 7200, "3 hour": 10800, "4 hour": 14400,
  76. "5 hour": 18000, "6 hour": 21600, "12 hour": 43200
  77. }
  78. # Signals
  79. statusChanged = Signal()
  80. progressChanged = Signal()
  81. connectionChanged = Signal()
  82. executionStarted = Signal(str, str) # patternName, patternPreview
  83. patternPreviewReady = Signal(str, str) # patternName, preview rendered late
  84. executionStopped = Signal()
  85. errorOccurred = Signal(str)
  86. serialPortsUpdated = Signal(list) # now: list of discovered table URLs
  87. discoveredTablesUpdated = Signal(list) # [{name, url}] from mDNS discovery
  88. serialConnectionChanged = Signal(bool) # now: table reachable
  89. currentPortChanged = Signal(str) # now: current table URL
  90. speedChanged = Signal(int)
  91. settingsLoaded = Signal()
  92. screenStateChanged = Signal(bool)
  93. screenTimeoutChanged = Signal(int)
  94. pauseBetweenPatternsChanged = Signal(int)
  95. pausedChanged = Signal(bool)
  96. playlistSettingsChanged = Signal()
  97. patternsRefreshCompleted = Signal(bool, str)
  98. # Playlist management signals
  99. playlistCreated = Signal(bool, str)
  100. playlistDeleted = Signal(bool, str)
  101. patternAddedToPlaylist = Signal(bool, str)
  102. playlistModified = Signal(bool, str)
  103. # Backend/table connection status signals
  104. backendConnectionChanged = Signal(bool)
  105. reconnectStatusChanged = Signal(str)
  106. # LED control signals
  107. ledStatusChanged = Signal()
  108. ledEffectsLoaded = Signal(list)
  109. ledPalettesLoaded = Signal(list)
  110. # LCD brightness signals
  111. lcdBrightnessChanged = Signal()
  112. def __init__(self):
  113. super().__init__()
  114. self.client = FirmwareClient.instance()
  115. # Status properties
  116. self._current_file = ""
  117. self._progress = 0
  118. self._is_running = False
  119. self._is_paused = False
  120. self._is_connected = False
  121. self._serial_ports = []
  122. self._serial_connected = False
  123. self._current_port = ""
  124. self._current_speed = 130
  125. self._auto_play_on_boot = False
  126. self._pause_between_patterns = 10800
  127. # Playlist settings (mirror the table's NVS; also persisted locally)
  128. self._playlist_shuffle = True
  129. self._playlist_run_mode = "loop"
  130. self._playlist_clear_pattern = "adaptive"
  131. # Connection status
  132. self._backend_connected = False
  133. self._reconnect_status = "Looking for table..."
  134. self._saved_table_url = ""
  135. # LCD brightness state
  136. self._lcd_brightness = 255
  137. self._lcd_brightness_path = ""
  138. self._lcd_max_brightness = 0
  139. # LED control state
  140. self._led_provider = "none" # "none" or "dw_leds"
  141. self._led_connected = False
  142. self._led_power_on = False
  143. self._led_brightness = 100 # 0..100 for QML (firmware is 0..255)
  144. self._led_effects = []
  145. self._led_palettes = []
  146. self._led_current_effect = 0
  147. self._led_current_palette = 0
  148. self._led_color = "#ffffff"
  149. self._led_last_effect = 2 # remembered on power-off (default rainbow)
  150. # 'ball' tracker state (firmware-native effect id 38; the blob follows
  151. # the sand ball). Mirrors the board's NVS; written live via /sand_led.
  152. self._led_color2 = "#000000" # background colour when bg == "static"
  153. self._led_ball_fgbright = 255 # blob brightness (0..255)
  154. self._led_ball_bgbright = 255 # background brightness (0..255)
  155. self._led_ball_size = 3 # glow size in LEDs (1..30 in the UI)
  156. self._led_ball_bg = "static" # "static" | "off" | any effect name
  157. self._led_ball_direction = "cw" # "cw" | "ccw"
  158. self._led_ball_align = 0 # rotate the blob onto the ball (0..359)
  159. self._led_last_non_ball_effect = 2 # restored when the ball toggle is off
  160. # Screen management
  161. self._screen_on = True
  162. self._screen_timeout = self.DEFAULT_SCREEN_TIMEOUT
  163. self._last_activity = time.time()
  164. self._screen_transition_lock = threading.Lock()
  165. self._last_screen_change = 0
  166. self._screen_timer = QTimer()
  167. self._screen_timer.timeout.connect(self._check_screen_timeout)
  168. self._screen_timer.start(1000)
  169. # Load local settings first
  170. self._load_local_settings()
  171. self._detect_backlight()
  172. # Apply the saved table password ($Sand/Password key) to the client.
  173. self.client.set_api_key(self._decode_password(self._table_password_b64))
  174. # Point the shared client at the saved / env-configured table.
  175. env_url = os.environ.get("DUNE_WEAVER_URL", "")
  176. initial_url = env_url or self._saved_table_url
  177. if initial_url:
  178. self.client.set_base_url(initial_url)
  179. self._current_port = self.client.base_url
  180. # Status polling replaces the old WebSocket status stream.
  181. self._status_timer = QTimer()
  182. self._status_timer.timeout.connect(self._tick_status)
  183. self._time_synced = False
  184. self._poll_inflight = False # never stack polls on the busy board
  185. self._poll_failures = 0 # consecutive failures (see threshold)
  186. self._discovered_tables = [] # [{name, url}] from the last mDNS browse
  187. self._table_name = "" # firmware hostname from /sand_status
  188. self._table_password_b64 = "" # $Sand/Password, base64 like the backend
  189. # Playlist run state from /sand_status's playlist object (firmware
  190. # sequences playlists; this is read-only telemetry, like the
  191. # backend's translate_status).
  192. self._playlist_active = False
  193. self._playlist_index = 0 # 0-based position in the playlist
  194. self._playlist_total = 0
  195. self._playlist_name = ""
  196. self._next_pattern = "" # next pattern (display name, no path/.thr)
  197. self._playlist_clearing = False
  198. self._pause_remaining = -1 # seconds until next pattern; -1 = not pausing
  199. self._pause_total = -1
  200. # Kick everything off once the event loop is running.
  201. QTimer.singleShot(200, self._start)
  202. @Slot()
  203. def _start(self):
  204. self._status_timer.start(self.STATUS_POLL_MS)
  205. if not self.client.base_url:
  206. # No configured table -> try to discover one automatically.
  207. _run(self._discover(auto_connect=True))
  208. async def _discover(self, auto_connect=False):
  209. """mDNS-browse for tables; auto_connect picks one on startup.
  210. Preference order: the last-connected table (saved URL) if it's on the
  211. network, otherwise the first table found — the touch panel should
  212. come up connected without being asked.
  213. """
  214. self._set_reconnect_status("Searching for tables (mDNS)...")
  215. try:
  216. tables = await discovery.discover_tables(timeout=3.0)
  217. except Exception as exc:
  218. logger.warning(f"Discovery failed: {exc}")
  219. tables = []
  220. self._serial_ports = [t.base_url for t in tables]
  221. self._discovered_tables = [{"name": t.name, "url": t.base_url} for t in tables]
  222. self.serialPortsUpdated.emit(self._serial_ports)
  223. self.discoveredTablesUpdated.emit(self._discovered_tables)
  224. if not auto_connect:
  225. return
  226. if not tables:
  227. self._set_reconnect_status("No table found. Enter the table address.")
  228. return
  229. target = next((t for t in tables if t.base_url == self._saved_table_url),
  230. tables[0])
  231. which = "last-connected" if target.base_url == self._saved_table_url else "first discovered"
  232. logger.info(f"Auto-connecting to the {which} table: {target.base_url}")
  233. self._connect_to(target.base_url)
  234. # ==================== Status polling ====================
  235. @Slot()
  236. def _tick_status(self):
  237. _run(self._poll_status())
  238. async def _poll_status(self):
  239. if not self.client.base_url or self._poll_inflight:
  240. return
  241. self._poll_inflight = True
  242. try:
  243. data = await self.client.status()
  244. except Exception as exc:
  245. self._poll_failures += 1
  246. reason = str(exc) or type(exc).__name__
  247. # 401 = board is password-locked; deterministic, so say so
  248. # instead of the generic "retrying" message.
  249. if getattr(exc, "status", None) == 401:
  250. self._on_unreachable(reason)
  251. self._set_reconnect_status(
  252. "Table requires a password — set it below.")
  253. elif self._poll_failures >= self.STATUS_FAIL_THRESHOLD:
  254. self._on_unreachable(reason)
  255. else:
  256. logger.debug(f"Status poll failed ({self._poll_failures}/"
  257. f"{self.STATUS_FAIL_THRESHOLD}): {reason}")
  258. return
  259. finally:
  260. self._poll_inflight = False
  261. self._poll_failures = 0
  262. self._on_status(data)
  263. def _on_status(self, status):
  264. # Reachability
  265. if not self._backend_connected:
  266. self._backend_connected = True
  267. self._is_connected = True
  268. self._serial_connected = True
  269. self._current_port = self.client.base_url
  270. self._reconnect_status = "Connected"
  271. self.connectionChanged.emit()
  272. self.backendConnectionChanged.emit(True)
  273. self.serialConnectionChanged.emit(True)
  274. self.currentPortChanged.emit(self._current_port)
  275. self.reconnectStatusChanged.emit("Connected")
  276. # Load settings + LED config on (re)connect.
  277. self.loadControlSettings()
  278. self.loadLedConfig()
  279. _run(self._sync_time_once())
  280. # GRBL states can carry a substate suffix ("Hold:0") — strip it, like
  281. # the backend's execution._state() does, or pause is never detected.
  282. state = (status.get("state") or "").split(":", 1)[0]
  283. self._table_name = status.get("hostname", "") or self._table_name
  284. # Current pattern / execution start detection
  285. raw_file = status.get("file", "") or ""
  286. new_file = raw_file
  287. for prefix in ("/sd/patterns/", "/patterns/", "/sd/", "/"):
  288. if new_file.startswith(prefix):
  289. new_file = new_file[len(prefix):]
  290. break
  291. if new_file and new_file != self._current_file:
  292. logger.info(f"Pattern changed to '{new_file}'")
  293. preview = self._preview_path(new_file)
  294. self.executionStarted.emit(new_file, preview)
  295. if not preview:
  296. _run(self._render_preview_late(new_file))
  297. self._current_file = new_file
  298. self._is_running = bool(status.get("running", False))
  299. # Playlist telemetry (between-patterns pause countdown + position)
  300. pl = status.get("playlist") or {}
  301. self._playlist_active = bool(pl.get("active"))
  302. self._playlist_index = int(pl.get("index", 0) or 0)
  303. self._playlist_total = int(pl.get("total", 0) or 0)
  304. self._playlist_name = str(pl.get("name") or "")
  305. nxt = str(pl.get("next") or "").rsplit("/", 1)[-1]
  306. self._next_pattern = nxt[:-4] if nxt.endswith(".thr") else nxt
  307. self._playlist_clearing = bool(pl.get("clearing"))
  308. def _secs(v):
  309. return int(v) if isinstance(v, (int, float)) and v >= 0 else -1
  310. self._pause_remaining = _secs(pl.get("pause_remaining", -1))
  311. self._pause_total = _secs(pl.get("pause_total", -1))
  312. new_paused = (state == "Hold")
  313. if new_paused != self._is_paused:
  314. self._is_paused = new_paused
  315. self.pausedChanged.emit(new_paused)
  316. feed = status.get("feed")
  317. if feed and int(feed) != self._current_speed:
  318. self._current_speed = int(feed)
  319. self.speedChanged.emit(self._current_speed)
  320. prog = status.get("progress")
  321. if prog is not None:
  322. self._progress = 0 if prog < 0 else float(prog) * 100.0
  323. # Live LED snapshot (effect/brightness) from status
  324. led = status.get("led")
  325. if isinstance(led, dict):
  326. self._ingest_led_status(led)
  327. self.statusChanged.emit()
  328. self.progressChanged.emit()
  329. def _on_unreachable(self, reason):
  330. if self._backend_connected or self._is_connected:
  331. logger.warning(f"Table unreachable: {reason}")
  332. self._backend_connected = False
  333. self._is_connected = False
  334. if self._serial_connected:
  335. self._serial_connected = False
  336. self.serialConnectionChanged.emit(False)
  337. self._reconnect_status = "Table connection lost, retrying..."
  338. self.connectionChanged.emit()
  339. self.backendConnectionChanged.emit(False)
  340. self.reconnectStatusChanged.emit(self._reconnect_status)
  341. def _set_reconnect_status(self, msg):
  342. self._reconnect_status = msg
  343. self.reconnectStatusChanged.emit(msg)
  344. async def _sync_time_once(self):
  345. if self._time_synced:
  346. return
  347. try:
  348. # epoch + POSIX tz, like the backend — board-side quiet-hours and
  349. # autostart schedules run on the board's local time.
  350. await self.client.sync_time(int(time.time()), tz=posix_tz())
  351. self._time_synced = True
  352. except Exception as exc:
  353. logger.debug(f"time sync failed: {exc}")
  354. # ==================== Table password ($Sand/Password) ====================
  355. @staticmethod
  356. def _decode_password(b64: str):
  357. if not b64:
  358. return None
  359. try:
  360. return base64.b64decode(b64).decode("utf-8")
  361. except Exception:
  362. return None
  363. @Slot(str)
  364. def setTablePassword(self, password):
  365. """Store the table's API password (empty string clears it)."""
  366. password = (password or "").strip()
  367. self._table_password_b64 = (
  368. base64.b64encode(password.encode("utf-8")).decode("ascii")
  369. if password else "")
  370. self.client.set_api_key(password or None)
  371. self._save_local_settings()
  372. logger.info("Table password %s", "set" if password else "cleared")
  373. _run(self._poll_status())
  374. @Property(bool, notify=settingsLoaded)
  375. def hasTablePassword(self):
  376. return bool(self._table_password_b64)
  377. def _preview_path(self, rel_name):
  378. """Best-effort cached preview path for a pattern (may be empty)."""
  379. try:
  380. import thr_preview
  381. return thr_preview.cached_preview(self.client.base_url, rel_name)
  382. except Exception:
  383. return ""
  384. async def _render_preview_late(self, rel_name):
  385. """Render an uncached preview for the executing pattern, then notify.
  386. The Execution page otherwise only ever sees whatever was cached at
  387. the moment the pattern started."""
  388. try:
  389. import thr_preview
  390. path = await thr_preview.render_preview(
  391. self.client, self.client.base_url, rel_name)
  392. except Exception as exc:
  393. logger.debug(f"late preview render failed for {rel_name}: {exc}")
  394. return
  395. if path:
  396. self.patternPreviewReady.emit(rel_name, path)
  397. # ==================== Properties ====================
  398. @Property(str, notify=statusChanged)
  399. def currentFile(self):
  400. return self._current_file
  401. @Property(float, notify=progressChanged)
  402. def progress(self):
  403. return self._progress
  404. @Property(bool, notify=statusChanged)
  405. def isRunning(self):
  406. return self._is_running
  407. @Property(bool, notify=pausedChanged)
  408. def isPaused(self):
  409. return self._is_paused
  410. @Property(bool, notify=connectionChanged)
  411. def isConnected(self):
  412. return self._is_connected
  413. @Property(str, notify=statusChanged)
  414. def tableName(self):
  415. return self._table_name
  416. @Property(bool, notify=statusChanged)
  417. def playlistActive(self):
  418. return self._playlist_active
  419. @Property(int, notify=statusChanged)
  420. def playlistIndex(self):
  421. return self._playlist_index
  422. @Property(int, notify=statusChanged)
  423. def playlistTotal(self):
  424. return self._playlist_total
  425. @Property(str, notify=statusChanged)
  426. def playlistName(self):
  427. return self._playlist_name
  428. @Property(str, notify=statusChanged)
  429. def nextPattern(self):
  430. return self._next_pattern
  431. @Property(bool, notify=statusChanged)
  432. def playlistClearing(self):
  433. return self._playlist_clearing
  434. @Property(int, notify=statusChanged)
  435. def pauseRemaining(self):
  436. return self._pause_remaining
  437. @Property(int, notify=statusChanged)
  438. def pauseTotal(self):
  439. return self._pause_total
  440. @Property(list, notify=discoveredTablesUpdated)
  441. def discoveredTables(self):
  442. return self._discovered_tables
  443. @Property(list, notify=serialPortsUpdated)
  444. def serialPorts(self):
  445. return self._serial_ports
  446. @Property(bool, notify=serialConnectionChanged)
  447. def serialConnected(self):
  448. return self._serial_connected
  449. @Property(str, notify=currentPortChanged)
  450. def currentPort(self):
  451. return self._current_port
  452. @Property(int, notify=speedChanged)
  453. def currentSpeed(self):
  454. return self._current_speed
  455. @Property(bool, notify=settingsLoaded)
  456. def autoPlayOnBoot(self):
  457. return self._auto_play_on_boot
  458. @Property(bool, notify=backendConnectionChanged)
  459. def backendConnected(self):
  460. return self._backend_connected
  461. @Property(str, notify=reconnectStatusChanged)
  462. def reconnectStatus(self):
  463. return self._reconnect_status
  464. # ==================== Connection management ====================
  465. @Slot()
  466. def retryConnection(self):
  467. logger.debug("Manual connection retry requested")
  468. if self.client.base_url:
  469. _run(self._poll_status())
  470. else:
  471. _run(self._discover(auto_connect=True))
  472. @Slot()
  473. def refreshSerialPorts(self):
  474. """Re-run mDNS discovery to populate the table picker (list only —
  475. never switches tables; connecting is an explicit user tap)."""
  476. logger.info("Discovering tables...")
  477. _run(self._discover())
  478. @Slot(str)
  479. def connectSerial(self, port):
  480. """Point the app at a table (URL or host)."""
  481. logger.info(f"Connecting to table: {port}")
  482. self._connect_to(port)
  483. def _connect_to(self, url):
  484. self.client.set_base_url(url)
  485. self._current_port = self.client.base_url
  486. self._saved_table_url = self.client.base_url
  487. self._time_synced = False
  488. self._poll_failures = 0 # fresh table, fresh streak
  489. self._save_local_settings()
  490. self.currentPortChanged.emit(self._current_port)
  491. self._set_reconnect_status(f"Connecting to {self._current_port}...")
  492. _run(self._poll_status())
  493. @Slot()
  494. def disconnectSerial(self):
  495. logger.info("Disconnecting from table...")
  496. self.client.set_base_url("")
  497. self._current_port = ""
  498. self._table_name = ""
  499. self._serial_connected = False
  500. self._backend_connected = False
  501. self._is_connected = False
  502. self.serialConnectionChanged.emit(False)
  503. self.currentPortChanged.emit("")
  504. self.backendConnectionChanged.emit(False)
  505. self.connectionChanged.emit()
  506. # ==================== Pattern execution ====================
  507. @Slot(str, str)
  508. def executePattern(self, fileName, preExecution="adaptive"):
  509. logger.info(f"ExecutePattern: '{fileName}' (clear={preExecution})")
  510. _run(self._execute_pattern(fileName, preExecution))
  511. async def _execute_pattern(self, fileName, preExecution):
  512. try:
  513. await self.client.run_pattern(fileName, preExecution)
  514. preview = self._preview_path(fileName)
  515. self.executionStarted.emit(fileName, preview)
  516. if not preview:
  517. _run(self._render_preview_late(fileName))
  518. except Exception as exc:
  519. logger.error(f"executePattern failed: {exc}")
  520. self.errorOccurred.emit("Couldn't start the pattern. " + friendly_error(exc))
  521. @Slot()
  522. def stopExecution(self):
  523. _run(self._simple_action(self.client.stop, on_ok=self.executionStopped.emit,
  524. label="stop"))
  525. @Slot()
  526. def pauseExecution(self):
  527. logger.info("Pausing execution...")
  528. _run(self._simple_action(self.client.pause, label="pause"))
  529. @Slot()
  530. def resumeExecution(self):
  531. logger.info("Resuming execution...")
  532. _run(self._simple_action(self.client.resume, label="resume"))
  533. @Slot()
  534. def skipPattern(self):
  535. logger.info("Skipping pattern...")
  536. _run(self._simple_action(self.client.playlist_skip, label="skip"))
  537. async def _simple_action(self, coro_fn, *, on_ok=None, label="action"):
  538. try:
  539. await coro_fn()
  540. if on_ok:
  541. on_ok()
  542. except Exception as exc:
  543. logger.error(f"{label} failed: {exc}")
  544. self.errorOccurred.emit(f"Couldn't {label}. " + friendly_error(exc))
  545. @Slot(str, float, str, str, bool)
  546. def executePlaylist(self, playlistName, pauseTime=0.0, clearPattern="adaptive",
  547. runMode="single", shuffle=False):
  548. logger.info(f"ExecutePlaylist: '{playlistName}' pause={pauseTime} "
  549. f"clear={clearPattern} mode={runMode} shuffle={shuffle}")
  550. _run(self._execute_playlist(playlistName, pauseTime, clearPattern, runMode, shuffle))
  551. async def _execute_playlist(self, name, pauseTime, clearPattern, runMode, shuffle):
  552. try:
  553. await self.client.run_playlist(
  554. name, pause_time=pauseTime, clear_pattern=clearPattern,
  555. run_mode=runMode, shuffle=shuffle)
  556. except Exception as exc:
  557. logger.error(f"executePlaylist failed: {exc}")
  558. self.errorOccurred.emit("Couldn't start the playlist. " + friendly_error(exc))
  559. # ==================== Hardware movement ====================
  560. @Slot()
  561. def sendHome(self):
  562. logger.debug("Sending home command...")
  563. _run(self._simple_action(self.client.home, label="home"))
  564. @Slot()
  565. def moveToCenter(self):
  566. logger.info("Moving to center...")
  567. _run(self._simple_action(lambda: self.client.goto(rho=0), label="move to center"))
  568. @Slot()
  569. def moveToPerimeter(self):
  570. logger.info("Moving to perimeter...")
  571. _run(self._simple_action(lambda: self.client.goto(rho=1), label="move to perimeter"))
  572. # ==================== Speed ====================
  573. @Slot(int)
  574. def setSpeed(self, speed):
  575. logger.debug(f"Setting speed (feed) to: {speed}")
  576. _run(self._set_speed(speed))
  577. async def _set_speed(self, speed):
  578. try:
  579. await self.client.set_feed_mm(speed)
  580. self._current_speed = speed
  581. self.speedChanged.emit(speed)
  582. except Exception as exc:
  583. logger.error(f"set speed failed: {exc}")
  584. self.errorOccurred.emit("Couldn't change the speed. " + friendly_error(exc))
  585. @Slot(result='QStringList')
  586. def getSpeedOptions(self):
  587. return list(self.SPEED_OPTIONS.keys())
  588. @Slot(result=str)
  589. def getCurrentSpeedOption(self):
  590. for option, value in self.SPEED_OPTIONS.items():
  591. if value == self._current_speed:
  592. return option
  593. return str(self._current_speed)
  594. @Slot(str)
  595. def setSpeedByOption(self, option):
  596. if option in self.SPEED_OPTIONS:
  597. self.setSpeed(self.SPEED_OPTIONS[option])
  598. else:
  599. logger.warning(f"Unknown speed option: {option}")
  600. # ==================== Auto play on boot ====================
  601. @Slot(bool)
  602. def setAutoPlayOnBoot(self, enabled):
  603. logger.info(f"Setting auto play on boot: {enabled}")
  604. # The firmware auto-plays a *named* playlist ($Playlist/Autostart). We
  605. # can reliably disable it here; enabling requires choosing a playlist
  606. # (done from the playlist page), so True is best-effort.
  607. if not enabled:
  608. _run(self._simple_action(lambda: self.client.set_autostart(""),
  609. label="clear autostart"))
  610. else:
  611. logger.info("Enable auto-play: select a playlist to autostart on the playlist page")
  612. self._auto_play_on_boot = enabled
  613. self.settingsLoaded.emit()
  614. # ==================== Load settings from the table ====================
  615. @Slot()
  616. def loadControlSettings(self):
  617. logger.debug("Loading control settings from table...")
  618. _run(self._load_settings())
  619. async def _load_settings(self):
  620. try:
  621. settings = await self.client.settings()
  622. except Exception as exc:
  623. logger.debug(f"Could not load settings: {exc}")
  624. return
  625. def as_int(key, default):
  626. try:
  627. return int(float(settings.get(key, default)))
  628. except (TypeError, ValueError):
  629. return default
  630. # Speed / feed
  631. feed = as_int("THR/Feed", self._current_speed)
  632. if feed and feed != self._current_speed:
  633. self._current_speed = feed
  634. self.speedChanged.emit(feed)
  635. # Playlist settings (mirror the table)
  636. mode = settings.get("Playlist/Mode", self._playlist_run_mode)
  637. if mode in ("single", "loop"):
  638. self._playlist_run_mode = mode
  639. self._playlist_shuffle = str(settings.get("Playlist/Shuffle", "ON")).upper() == "ON"
  640. self._pause_between_patterns = as_int("Playlist/PauseTime", self._pause_between_patterns)
  641. fw_clear = settings.get("Playlist/ClearPattern", "adaptive")
  642. self._playlist_clear_pattern = _FW_TO_UI_CLEAR.get(fw_clear, "adaptive")
  643. self._auto_play_on_boot = bool(settings.get("Playlist/Autostart", "").strip())
  644. self.playlistSettingsChanged.emit()
  645. self.pauseBetweenPatternsChanged.emit(self._pause_between_patterns)
  646. self.settingsLoaded.emit()
  647. logger.info("Settings loaded from table")
  648. # ==================== Screen management ====================
  649. @Property(bool, notify=screenStateChanged)
  650. def screenOn(self):
  651. return self._screen_on
  652. @Property(int, notify=screenTimeoutChanged)
  653. def screenTimeout(self):
  654. return self._screen_timeout
  655. @screenTimeout.setter
  656. def setScreenTimeout(self, timeout):
  657. if self._screen_timeout != timeout:
  658. self._screen_timeout = timeout
  659. self._save_local_settings()
  660. self.screenTimeoutChanged.emit(timeout)
  661. @Slot(result='QStringList')
  662. def getScreenTimeoutOptions(self):
  663. return list(self.TIMEOUT_OPTIONS.keys())
  664. @Slot(result=str)
  665. def getCurrentScreenTimeoutOption(self):
  666. current_timeout = self._screen_timeout
  667. for option, value in self.TIMEOUT_OPTIONS.items():
  668. if value == current_timeout:
  669. return option
  670. if current_timeout == 0:
  671. return "Never"
  672. elif current_timeout < 60:
  673. return f"{current_timeout} seconds"
  674. elif current_timeout < 3600:
  675. minutes = current_timeout // 60
  676. return f"{minutes} minute{'s' if minutes != 1 else ''}"
  677. else:
  678. hours = current_timeout // 3600
  679. return f"{hours} hour{'s' if hours != 1 else ''}"
  680. @Slot(str)
  681. def setScreenTimeoutByOption(self, option):
  682. if option in self.TIMEOUT_OPTIONS:
  683. timeout_value = self.TIMEOUT_OPTIONS[option]
  684. if self._screen_timeout != timeout_value:
  685. self._screen_timeout = timeout_value
  686. self._save_local_settings()
  687. self.screenTimeoutChanged.emit(timeout_value)
  688. else:
  689. logger.warning(f"Unknown timeout option: {option}")
  690. @Slot()
  691. def turnScreenOn(self):
  692. if not self._screen_on:
  693. self._turn_screen_on()
  694. self._reset_activity_timer()
  695. @Slot()
  696. def turnScreenOff(self):
  697. self._turn_screen_off()
  698. @Slot()
  699. def resetActivityTimer(self):
  700. self._reset_activity_timer()
  701. if not self._screen_on:
  702. self._turn_screen_on()
  703. def _turn_screen_on(self):
  704. with self._screen_transition_lock:
  705. time_since_change = time.time() - self._last_screen_change
  706. if time_since_change < 2.0:
  707. return
  708. if self._screen_on:
  709. return
  710. try:
  711. restore_brightness = self._lcd_brightness if self._lcd_brightness > 0 else self._lcd_max_brightness or 255
  712. screen_on_script = Path('/usr/local/bin/screen-on')
  713. if screen_on_script.exists():
  714. result = subprocess.run(['sudo', '/usr/local/bin/screen-on'],
  715. capture_output=True, text=True, timeout=5)
  716. if result.returncode != 0:
  717. logger.warning(f"screen-on script failed: {result.stderr}")
  718. if self._lcd_brightness_path and restore_brightness < (self._lcd_max_brightness or 255):
  719. subprocess.run(
  720. ['sudo', 'sh', '-c', f'echo {restore_brightness} > {self._lcd_brightness_path}'],
  721. check=False, timeout=5)
  722. else:
  723. if self._lcd_brightness_path:
  724. subprocess.run(['sudo', 'sh', '-c',
  725. f'echo 0 > /sys/class/graphics/fb0/blank && echo {restore_brightness} > {self._lcd_brightness_path}'],
  726. check=False, timeout=5)
  727. else:
  728. subprocess.run(['sudo', 'sh', '-c',
  729. f'echo 0 > /sys/class/graphics/fb0/blank && echo {restore_brightness} > /sys/class/backlight/*/brightness'],
  730. check=False, timeout=5)
  731. self._screen_on = True
  732. self._last_screen_change = time.time()
  733. self.screenStateChanged.emit(True)
  734. except Exception as e:
  735. logger.error(f"Failed to turn screen on: {e}")
  736. def _turn_screen_off(self):
  737. with self._screen_transition_lock:
  738. time_since_change = time.time() - self._last_screen_change
  739. if time_since_change < 2.0:
  740. return
  741. if not self._screen_on:
  742. return
  743. try:
  744. screen_off_script = Path('/usr/local/bin/screen-off')
  745. if screen_off_script.exists():
  746. result = subprocess.run(['sudo', '/usr/local/bin/screen-off'],
  747. capture_output=True, text=True, timeout=10)
  748. if result.returncode == 0:
  749. logger.info("Screen turned OFF (screen-off script)")
  750. else:
  751. logger.warning(f"screen-off script failed: return code {result.returncode}")
  752. else:
  753. subprocess.run(['sudo', 'sh', '-c',
  754. 'echo 0 > /sys/class/backlight/*/brightness && echo 1 > /sys/class/graphics/fb0/blank'],
  755. check=False, timeout=5)
  756. self._screen_on = False
  757. self._last_screen_change = time.time()
  758. self.screenStateChanged.emit(False)
  759. except Exception as e:
  760. logger.error(f"Failed to turn screen off: {e}")
  761. def _reset_activity_timer(self):
  762. self._last_activity = time.time()
  763. def _check_screen_timeout(self):
  764. if self._screen_on and self._screen_timeout > 0:
  765. idle_time = time.time() - self._last_activity
  766. if idle_time > self._screen_timeout:
  767. self._turn_screen_off()
  768. # ==================== Local settings persistence ====================
  769. def _load_local_settings(self):
  770. try:
  771. if os.path.exists(self.SETTINGS_FILE):
  772. with open(self.SETTINGS_FILE, 'r') as f:
  773. settings = json.load(f)
  774. screen_timeout = settings.get('screen_timeout', self.DEFAULT_SCREEN_TIMEOUT)
  775. if isinstance(screen_timeout, (int, float)) and screen_timeout >= 0:
  776. self._screen_timeout = int(screen_timeout)
  777. self._lcd_brightness = settings.get('lcd_brightness', 255)
  778. self._lcd_brightness_path = settings.get('lcd_brightness_path', "")
  779. self._lcd_max_brightness = settings.get('lcd_max_brightness', 0)
  780. self._pause_between_patterns = settings.get('pause_between_patterns', 10800)
  781. self._playlist_shuffle = settings.get('playlist_shuffle', True)
  782. self._playlist_run_mode = settings.get('playlist_run_mode', "loop")
  783. self._playlist_clear_pattern = settings.get('playlist_clear_pattern', "adaptive")
  784. self._saved_table_url = settings.get('table_url', "")
  785. self._table_password_b64 = settings.get('table_password', "")
  786. else:
  787. self._save_local_settings()
  788. except Exception as e:
  789. logger.error(f"Error loading local settings: {e}, using defaults")
  790. self._screen_timeout = self.DEFAULT_SCREEN_TIMEOUT
  791. def _save_local_settings(self):
  792. try:
  793. settings = {
  794. 'screen_timeout': self._screen_timeout,
  795. 'lcd_brightness': self._lcd_brightness,
  796. 'lcd_brightness_path': self._lcd_brightness_path,
  797. 'lcd_max_brightness': self._lcd_max_brightness,
  798. 'pause_between_patterns': self._pause_between_patterns,
  799. 'playlist_shuffle': self._playlist_shuffle,
  800. 'playlist_run_mode': self._playlist_run_mode,
  801. 'playlist_clear_pattern': self._playlist_clear_pattern,
  802. 'table_url': self._saved_table_url,
  803. 'table_password': self._table_password_b64,
  804. 'version': '2.0'
  805. }
  806. with open(self.SETTINGS_FILE, 'w') as f:
  807. json.dump(settings, f, indent=2)
  808. except Exception as e:
  809. logger.error(f"Error saving local settings: {e}")
  810. # ==================== Pause between patterns ====================
  811. @Slot(result='QStringList')
  812. def getPauseOptions(self):
  813. return list(self.PAUSE_OPTIONS.keys())
  814. @Slot(result=str)
  815. def getCurrentPauseOption(self):
  816. current_pause = self._pause_between_patterns
  817. for option, value in self.PAUSE_OPTIONS.items():
  818. if value == current_pause:
  819. return option
  820. if current_pause == 0:
  821. return "0s"
  822. elif current_pause < 60:
  823. return f"{current_pause}s"
  824. elif current_pause < 3600:
  825. return f"{current_pause // 60} min"
  826. else:
  827. return f"{current_pause // 3600} hour"
  828. @Slot(str)
  829. def setPauseByOption(self, option):
  830. if option in self.PAUSE_OPTIONS:
  831. pause_value = self.PAUSE_OPTIONS[option]
  832. if self._pause_between_patterns != pause_value:
  833. self._pause_between_patterns = pause_value
  834. self._save_local_settings()
  835. _run(self._simple_action(
  836. lambda: self.client.command(f"$Playlist/PauseTime={pause_value}"),
  837. label="set pause"))
  838. self.pauseBetweenPatternsChanged.emit(pause_value)
  839. else:
  840. logger.warning(f"Unknown pause option: {option}")
  841. @Property(int, notify=pauseBetweenPatternsChanged)
  842. def pauseBetweenPatterns(self):
  843. return self._pause_between_patterns
  844. # ==================== Playlist settings ====================
  845. @Property(bool, notify=playlistSettingsChanged)
  846. def playlistShuffle(self):
  847. return self._playlist_shuffle
  848. @Slot(bool)
  849. def setPlaylistShuffle(self, enabled):
  850. if self._playlist_shuffle != enabled:
  851. self._playlist_shuffle = enabled
  852. self._save_local_settings()
  853. _run(self._simple_action(
  854. lambda: self.client.command(f"$Playlist/Shuffle={'ON' if enabled else 'OFF'}"),
  855. label="set shuffle"))
  856. self.playlistSettingsChanged.emit()
  857. @Property(str, notify=playlistSettingsChanged)
  858. def playlistRunMode(self):
  859. return self._playlist_run_mode
  860. @Slot(str)
  861. def setPlaylistRunMode(self, mode):
  862. if mode in ("single", "loop") and self._playlist_run_mode != mode:
  863. self._playlist_run_mode = mode
  864. self._save_local_settings()
  865. _run(self._simple_action(
  866. lambda: self.client.command(f"$Playlist/Mode={mode}"),
  867. label="set run mode"))
  868. self.playlistSettingsChanged.emit()
  869. @Property(str, notify=playlistSettingsChanged)
  870. def playlistClearPattern(self):
  871. return self._playlist_clear_pattern
  872. @Slot(str)
  873. def setPlaylistClearPattern(self, pattern):
  874. valid = ["adaptive", "clear_center", "clear_perimeter", "none"]
  875. if pattern in valid and self._playlist_clear_pattern != pattern:
  876. self._playlist_clear_pattern = pattern
  877. self._save_local_settings()
  878. from firmware_client import CLEAR_MODE_MAP
  879. fw = CLEAR_MODE_MAP.get(pattern, "adaptive")
  880. _run(self._simple_action(
  881. lambda: self.client.command(f"$Playlist/ClearPattern={fw}"),
  882. label="set clear pattern"))
  883. self.playlistSettingsChanged.emit()
  884. # ==================== LED control ====================
  885. @Property(str, notify=ledStatusChanged)
  886. def ledProvider(self):
  887. return self._led_provider
  888. @Property(bool, notify=ledStatusChanged)
  889. def ledConnected(self):
  890. return self._led_connected
  891. @Property(bool, notify=ledStatusChanged)
  892. def ledPowerOn(self):
  893. return self._led_power_on
  894. @Property(int, notify=ledStatusChanged)
  895. def ledBrightness(self):
  896. return self._led_brightness
  897. @Property(list, notify=ledEffectsLoaded)
  898. def ledEffects(self):
  899. return self._led_effects
  900. @Property(list, notify=ledPalettesLoaded)
  901. def ledPalettes(self):
  902. return self._led_palettes
  903. @Property(int, notify=ledStatusChanged)
  904. def ledCurrentEffect(self):
  905. return self._led_current_effect
  906. @Property(int, notify=ledStatusChanged)
  907. def ledCurrentPalette(self):
  908. return self._led_current_palette
  909. @Property(str, notify=ledStatusChanged)
  910. def ledColor(self):
  911. return self._led_color
  912. # -- 'ball' tracker properties (firmware effect id 38) -----------------
  913. @Property(str, notify=ledStatusChanged)
  914. def ledColor2(self):
  915. return self._led_color2
  916. @Property(int, notify=ledStatusChanged)
  917. def ledBallFgBright(self):
  918. return self._led_ball_fgbright
  919. @Property(int, notify=ledStatusChanged)
  920. def ledBallBgBright(self):
  921. return self._led_ball_bgbright
  922. @Property(int, notify=ledStatusChanged)
  923. def ledBallSize(self):
  924. return self._led_ball_size
  925. @Property(str, notify=ledStatusChanged)
  926. def ledBallBg(self):
  927. return self._led_ball_bg
  928. @Property(str, notify=ledStatusChanged)
  929. def ledBallDirection(self):
  930. return self._led_ball_direction
  931. @Property(int, notify=ledStatusChanged)
  932. def ledBallAlign(self):
  933. return self._led_ball_align
  934. @Slot()
  935. def loadLedConfig(self):
  936. logger.debug("Loading LED configuration...")
  937. _run(self._load_led_config())
  938. async def _load_led_config(self):
  939. try:
  940. settings = await self.client.settings()
  941. except Exception as exc:
  942. logger.debug(f"LED config load failed: {exc}")
  943. return
  944. has_leds = any(k.startswith("LED/") for k in settings)
  945. self._led_provider = "dw_leds" if has_leds else "none"
  946. self._led_connected = has_leds
  947. if has_leds:
  948. # Expose the fixed catalogues as {id, name} lists for the QML page.
  949. self._led_effects = [{"id": i, "name": n} for i, n in enumerate(LED_EFFECTS)]
  950. self._led_palettes = [{"id": i, "name": n} for i, n in enumerate(LED_PALETTES)]
  951. self.ledEffectsLoaded.emit(self._led_effects)
  952. self.ledPalettesLoaded.emit(self._led_palettes)
  953. effect = settings.get("LED/Effect", "off")
  954. if effect in LED_EFFECTS:
  955. self._led_current_effect = LED_EFFECTS.index(effect)
  956. palette = settings.get("LED/Palette", "rainbow")
  957. if palette in LED_PALETTES:
  958. self._led_current_palette = LED_PALETTES.index(palette)
  959. self._led_power_on = (effect != "off")
  960. if self._led_power_on:
  961. self._led_last_effect = self._led_current_effect
  962. try:
  963. self._led_brightness = round(int(settings.get("LED/Brightness", 255)) * 100 / 255)
  964. except (TypeError, ValueError):
  965. self._led_brightness = 100
  966. color = settings.get("LED/Color", "ffffff")
  967. self._led_color = f"#{color.lstrip('#')}"
  968. # 'ball' tracker params (read NVS names, written live via /sand_led).
  969. color2 = settings.get("LED/Color2", "000000")
  970. self._led_color2 = f"#{color2.lstrip('#')}"
  971. self._led_ball_fgbright = _clamp_int(settings.get("LED/BallBright"), 255, 0, 255)
  972. self._led_ball_bgbright = _clamp_int(settings.get("LED/BallBgBright"), 255, 0, 255)
  973. self._led_ball_size = _clamp_int(settings.get("LED/BallSize"), 3, 1, 200)
  974. self._led_ball_align = _clamp_int(settings.get("LED/Align"), 0, 0, 359)
  975. self._led_ball_bg = (settings.get("LED/BallBg") or "static").lower()
  976. direction = (settings.get("LED/Direction") or "cw").lower()
  977. self._led_ball_direction = direction if direction in ("cw", "ccw") else "cw"
  978. if effect != "ball":
  979. self._led_last_non_ball_effect = self._led_current_effect
  980. self.ledStatusChanged.emit()
  981. def _ingest_led_status(self, led):
  982. """Update live LED effect/brightness from a /sand_status snapshot."""
  983. changed = False
  984. effect = led.get("effect")
  985. if effect in LED_EFFECTS:
  986. idx = LED_EFFECTS.index(effect)
  987. if idx != self._led_current_effect:
  988. self._led_current_effect = idx
  989. changed = True
  990. power = (effect != "off")
  991. if power != self._led_power_on:
  992. self._led_power_on = power
  993. changed = True
  994. if power:
  995. self._led_last_effect = idx
  996. if effect not in ("ball", "off"):
  997. self._led_last_non_ball_effect = idx
  998. b = led.get("brightness")
  999. if b is not None:
  1000. scaled = round(int(b) * 100 / 255)
  1001. if scaled != self._led_brightness:
  1002. self._led_brightness = scaled
  1003. changed = True
  1004. if changed:
  1005. self.ledStatusChanged.emit()
  1006. @Slot()
  1007. def refreshLedStatus(self):
  1008. logger.debug("Refreshing LED status...")
  1009. _run(self._load_led_config())
  1010. @Slot()
  1011. def toggleLedPower(self):
  1012. self.setLedPower(not self._led_power_on)
  1013. @Slot(bool)
  1014. def setLedPower(self, on):
  1015. logger.debug(f"Setting LED power: {on}")
  1016. if on:
  1017. effect = LED_EFFECTS[self._led_last_effect] if self._led_last_effect else "rainbow"
  1018. if effect == "off":
  1019. effect = "rainbow"
  1020. _run(self._apply_led(effect=effect))
  1021. self._led_power_on = True
  1022. self._led_current_effect = LED_EFFECTS.index(effect)
  1023. else:
  1024. if self._led_current_effect:
  1025. self._led_last_effect = self._led_current_effect
  1026. _run(self._apply_led(effect="off"))
  1027. self._led_power_on = False
  1028. self.ledStatusChanged.emit()
  1029. async def _apply_led(self, **kwargs):
  1030. try:
  1031. await self.client.set_led(**kwargs)
  1032. except Exception as exc:
  1033. logger.error(f"LED update failed: {exc}")
  1034. self.errorOccurred.emit("Couldn't update the light. " + friendly_error(exc))
  1035. @Slot(int)
  1036. def setLedBrightness(self, value):
  1037. logger.debug(f"Setting LED brightness: {value}")
  1038. value = max(0, min(100, value))
  1039. self._led_brightness = value
  1040. _run(self._apply_led(brightness=round(value * 255 / 100)))
  1041. self.ledStatusChanged.emit()
  1042. @Slot(int, int, int)
  1043. def setLedColor(self, r, g, b):
  1044. self.setLedColorHex(f"#{r:02x}{g:02x}{b:02x}")
  1045. @Slot(str)
  1046. def setLedColorHex(self, hexColor):
  1047. hexColor = hexColor.lstrip('#')
  1048. if len(hexColor) != 6:
  1049. logger.warning(f"Invalid hex color: {hexColor}")
  1050. return
  1051. self._led_color = f"#{hexColor}"
  1052. _run(self._apply_led(color=hexColor))
  1053. self.ledStatusChanged.emit()
  1054. @Slot(int)
  1055. def setLedEffect(self, effectId):
  1056. logger.debug(f"Setting LED effect: {effectId}")
  1057. if 0 <= effectId < len(LED_EFFECTS):
  1058. name = LED_EFFECTS[effectId]
  1059. self._led_current_effect = effectId
  1060. self._led_power_on = (name != "off")
  1061. if self._led_power_on:
  1062. self._led_last_effect = effectId
  1063. if name not in ("ball", "off"):
  1064. self._led_last_non_ball_effect = effectId
  1065. _run(self._apply_led(effect=name))
  1066. self.ledStatusChanged.emit()
  1067. @Slot(int)
  1068. def setLedPalette(self, paletteId):
  1069. logger.debug(f"Setting LED palette: {paletteId}")
  1070. if 0 <= paletteId < len(LED_PALETTES):
  1071. self._led_current_palette = paletteId
  1072. _run(self._apply_led(palette=LED_PALETTES[paletteId]))
  1073. self.ledStatusChanged.emit()
  1074. # -- 'ball' tracker control (firmware-native effect id 38) -------------
  1075. BALL_EFFECT_ID = LED_EFFECTS.index("ball")
  1076. @Slot(bool)
  1077. def setBallTracker(self, on):
  1078. """Enable/disable the ball tracker by toggling the 'ball' effect.
  1079. Mirrors the mobile app's ball switch: remembers the previous non-ball
  1080. effect and restores it when turned off."""
  1081. if on:
  1082. if self._led_current_effect != self.BALL_EFFECT_ID:
  1083. self._led_last_non_ball_effect = self._led_current_effect
  1084. self.setLedEffect(self.BALL_EFFECT_ID)
  1085. else:
  1086. restore = self._led_last_non_ball_effect or LED_EFFECTS.index("rainbow")
  1087. self.setLedEffect(restore)
  1088. @Slot(str)
  1089. def setLedColor2Hex(self, hexColor):
  1090. """Background colour for the ball tracker (used when bg == 'static')."""
  1091. hexColor = hexColor.lstrip('#')
  1092. if len(hexColor) != 6:
  1093. logger.warning(f"Invalid hex color2: {hexColor}")
  1094. return
  1095. self._led_color2 = f"#{hexColor}"
  1096. _run(self._apply_led(color2=hexColor))
  1097. self.ledStatusChanged.emit()
  1098. @Slot(int)
  1099. def setLedBallFgBright(self, value):
  1100. self._led_ball_fgbright = _clamp_int(value, 255, 0, 255)
  1101. _run(self._apply_led(fgbright=self._led_ball_fgbright))
  1102. self.ledStatusChanged.emit()
  1103. @Slot(int)
  1104. def setLedBallBgBright(self, value):
  1105. self._led_ball_bgbright = _clamp_int(value, 255, 0, 255)
  1106. _run(self._apply_led(bgbright=self._led_ball_bgbright))
  1107. self.ledStatusChanged.emit()
  1108. @Slot(int)
  1109. def setLedBallSize(self, value):
  1110. self._led_ball_size = _clamp_int(value, 3, 1, 200)
  1111. _run(self._apply_led(size=self._led_ball_size))
  1112. self.ledStatusChanged.emit()
  1113. @Slot(int)
  1114. def setLedBallAlign(self, value):
  1115. self._led_ball_align = _clamp_int(value, 0, 0, 359)
  1116. _run(self._apply_led(align=self._led_ball_align))
  1117. self.ledStatusChanged.emit()
  1118. @Slot(str)
  1119. def setLedBallDirection(self, direction):
  1120. if direction not in ("cw", "ccw"):
  1121. return
  1122. self._led_ball_direction = direction
  1123. _run(self._apply_led(direction=direction))
  1124. self.ledStatusChanged.emit()
  1125. @Slot(str)
  1126. def setLedBallBg(self, bg):
  1127. if not bg:
  1128. return
  1129. self._led_ball_bg = str(bg)
  1130. _run(self._apply_led(bg=self._led_ball_bg))
  1131. self.ledStatusChanged.emit()
  1132. # ==================== LCD brightness ====================
  1133. def _detect_backlight(self):
  1134. if not self._lcd_brightness_path:
  1135. try:
  1136. result = subprocess.run(['ls', '/sys/class/backlight'],
  1137. capture_output=True, text=True, timeout=2)
  1138. if result.returncode == 0 and result.stdout.strip():
  1139. device = result.stdout.strip().split('\n')[0]
  1140. self._lcd_brightness_path = f"/sys/class/backlight/{device}/brightness"
  1141. logger.info(f"Auto-detected backlight path: {self._lcd_brightness_path}")
  1142. else:
  1143. logger.warning("No backlight device found")
  1144. return
  1145. except Exception as e:
  1146. logger.warning(f"Failed to detect backlight path: {e}")
  1147. return
  1148. backlight_dir = str(Path(self._lcd_brightness_path).parent)
  1149. if self._lcd_max_brightness == 0:
  1150. try:
  1151. result = subprocess.run(['cat', f"{backlight_dir}/max_brightness"],
  1152. capture_output=True, text=True, timeout=2)
  1153. if result.returncode == 0 and result.stdout.strip():
  1154. self._lcd_max_brightness = int(result.stdout.strip())
  1155. else:
  1156. self._lcd_max_brightness = 255
  1157. except Exception:
  1158. self._lcd_max_brightness = 255
  1159. try:
  1160. result = subprocess.run(['cat', self._lcd_brightness_path],
  1161. capture_output=True, text=True, timeout=2)
  1162. if result.returncode == 0 and result.stdout.strip():
  1163. self._lcd_brightness = int(result.stdout.strip())
  1164. except Exception as e:
  1165. logger.warning(f"Failed to read current brightness: {e}")
  1166. @Property(int, notify=lcdBrightnessChanged)
  1167. def lcdBrightness(self):
  1168. return self._lcd_brightness
  1169. @Property(int, notify=lcdBrightnessChanged)
  1170. def lcdMaxBrightness(self):
  1171. return self._lcd_max_brightness
  1172. @Slot(int)
  1173. def setLcdBrightness(self, value):
  1174. value = max(0, min(value, self._lcd_max_brightness))
  1175. if not self._lcd_brightness_path:
  1176. logger.warning("No backlight path configured")
  1177. return
  1178. try:
  1179. subprocess.run(['sudo', 'sh', '-c', f'echo {value} > {self._lcd_brightness_path}'],
  1180. check=False, timeout=5)
  1181. self._lcd_brightness = value
  1182. self._save_local_settings()
  1183. self.lcdBrightnessChanged.emit()
  1184. except Exception as e:
  1185. logger.error(f"Failed to set LCD brightness: {e}")
  1186. # ==================== Pattern refresh ====================
  1187. @Slot()
  1188. def refreshPatterns(self):
  1189. """Re-fetch the pattern catalogue from the table (models reload)."""
  1190. logger.debug("Refreshing patterns...")
  1191. # onPatternsRefreshCompleted in QML triggers patternModel.refresh().
  1192. self.patternsRefreshCompleted.emit(True, "Patterns refreshed")
  1193. # ==================== System control ====================
  1194. @Slot()
  1195. def restartBackend(self):
  1196. """Reboot the table controller (closest analog to the old backend restart)."""
  1197. logger.info("Rebooting the table...")
  1198. _run(self._simple_action(self.client.reboot, label="reboot table"))
  1199. @Slot()
  1200. def shutdownPi(self):
  1201. """Shut down the local touch host (Raspberry Pi)."""
  1202. logger.info("Shutting down the Pi...")
  1203. try:
  1204. subprocess.run(['sudo', 'shutdown', '-h', 'now'], check=False, timeout=5)
  1205. except Exception as e:
  1206. logger.error(f"Shutdown failed: {e}")
  1207. self.errorOccurred.emit("Couldn't shut down the Pi. " + friendly_error(e))
  1208. # ==================== Playlist management ====================
  1209. @Slot(str)
  1210. def createPlaylist(self, playlistName):
  1211. logger.debug(f"Creating playlist: {playlistName}")
  1212. _run(self._create_playlist(playlistName))
  1213. async def _create_playlist(self, name):
  1214. try:
  1215. content = f"# {name}\n".encode("utf-8")
  1216. await self.client.upload_file(f"{name}.txt", content, path="/playlists")
  1217. self.playlistCreated.emit(True, f"Created: {name}")
  1218. except Exception as exc:
  1219. logger.error(f"create playlist failed: {exc}")
  1220. self.playlistCreated.emit(False, f"Failed: {exc}")
  1221. @Slot(str)
  1222. def deletePlaylist(self, playlistName):
  1223. logger.info(f"Deleting playlist: {playlistName}")
  1224. _run(self._delete_playlist(playlistName))
  1225. async def _delete_playlist(self, name):
  1226. try:
  1227. await self.client.delete_file(f"{name}.txt", path="/playlists")
  1228. self.playlistDeleted.emit(True, f"Deleted: {name}")
  1229. except Exception as exc:
  1230. logger.error(f"delete playlist failed: {exc}")
  1231. self.playlistDeleted.emit(False, f"Failed: {exc}")
  1232. @Slot(str, str)
  1233. def addPatternToPlaylist(self, playlistName, patternPath):
  1234. logger.info(f"Adding pattern to playlist: {patternPath} -> {playlistName}")
  1235. _run(self._add_pattern_to_playlist(playlistName, patternPath))
  1236. async def _add_pattern_to_playlist(self, name, patternPath):
  1237. try:
  1238. # Fetch current contents, append the SD path, re-upload.
  1239. try:
  1240. raw = await self.client.fetch_sd_file(f"/playlists/{name}.txt")
  1241. lines = raw.decode("utf-8", errors="ignore").splitlines()
  1242. except Exception:
  1243. lines = [f"# {name}"]
  1244. sd_path = self._to_sd_pattern_path(patternPath)
  1245. lines.append(sd_path)
  1246. content = ("\n".join(lines) + "\n").encode("utf-8")
  1247. await self.client.upload_file(f"{name}.txt", content, path="/playlists")
  1248. self.patternAddedToPlaylist.emit(True, f"Added to {name}")
  1249. except Exception as exc:
  1250. logger.error(f"add pattern failed: {exc}")
  1251. self.patternAddedToPlaylist.emit(False, f"Failed: {exc}")
  1252. @Slot(str, list)
  1253. def updatePlaylistPatterns(self, playlistName, patterns):
  1254. logger.debug(f"Updating playlist {playlistName} -> {len(patterns)} patterns")
  1255. _run(self._update_playlist_patterns(playlistName, patterns))
  1256. async def _update_playlist_patterns(self, name, patterns):
  1257. try:
  1258. lines = [f"# {name}"]
  1259. lines += [self._to_sd_pattern_path(p) for p in patterns]
  1260. content = ("\n".join(lines) + "\n").encode("utf-8")
  1261. await self.client.upload_file(f"{name}.txt", content, path="/playlists")
  1262. self.playlistModified.emit(True, f"Updated: {name}")
  1263. except Exception as exc:
  1264. logger.error(f"update playlist failed: {exc}")
  1265. self.playlistModified.emit(False, f"Failed: {exc}")
  1266. @staticmethod
  1267. def _to_sd_pattern_path(pattern):
  1268. """Normalize a pattern reference to an SD-absolute /patterns path."""
  1269. p = str(pattern).strip()
  1270. if p.startswith("/patterns/") or p.startswith("/sd/"):
  1271. return p.replace("/sd/", "/", 1) if p.startswith("/sd/") else p
  1272. return "/patterns/" + p.lstrip("/")
  1273. @Slot(result=list)
  1274. def getPlaylistNames(self):
  1275. """Synchronous accessor kept for QML compatibility (best-effort)."""
  1276. # Playlists now live on the table; the PlaylistModel is the source of
  1277. # truth. This returns an empty list rather than reading a local file.
  1278. return []