1
0

backend.py 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172
  1. from PySide6.QtCore import QObject, Signal, Property, Slot, QTimer
  2. from PySide6.QtQml import QmlElement
  3. import asyncio
  4. import json
  5. import logging
  6. import subprocess
  7. import threading
  8. import time
  9. from pathlib import Path
  10. import os
  11. from firmware_client import FirmwareClient, LED_EFFECTS, LED_PALETTES
  12. import discovery
  13. # Configure logging
  14. logging.basicConfig(
  15. level=logging.INFO,
  16. format='%(asctime)s [%(levelname)s] %(message)s',
  17. datefmt='%H:%M:%S'
  18. )
  19. logger = logging.getLogger("DuneWeaver")
  20. QML_IMPORT_NAME = "DuneWeaver"
  21. QML_IMPORT_MAJOR_VERSION = 1
  22. # Firmware clear-mode -> touch UI pre-execution option (reverse of CLEAR_MODE_MAP)
  23. _FW_TO_UI_CLEAR = {
  24. "in": "clear_center", "out": "clear_perimeter",
  25. "adaptive": "adaptive", "none": "none",
  26. "sideway": "adaptive", "random": "adaptive",
  27. }
  28. def _run(coro):
  29. """Schedule a coroutine on the running (qasync) event loop, if any."""
  30. try:
  31. asyncio.get_event_loop().create_task(coro)
  32. except RuntimeError:
  33. logger.warning("No running event loop to schedule task")
  34. @QmlElement
  35. class Backend(QObject):
  36. """Backend controller: drives a headless FluidNC sand table over HTTP."""
  37. # Constants
  38. SETTINGS_FILE = "touch_settings.json"
  39. DEFAULT_SCREEN_TIMEOUT = 300 # 5 minutes in seconds
  40. STATUS_POLL_MS = 1000 # /sand_status poll interval
  41. # Predefined timeout options (in seconds)
  42. TIMEOUT_OPTIONS = {
  43. "30 seconds": 30,
  44. "1 minute": 60,
  45. "5 minutes": 300,
  46. "10 minutes": 600,
  47. "Never": 0 # 0 means never timeout
  48. }
  49. # Predefined speed options (motor feed, mm/min)
  50. SPEED_OPTIONS = {
  51. "50": 50,
  52. "100": 100,
  53. "150": 150,
  54. "200": 200,
  55. "300": 300,
  56. "500": 500
  57. }
  58. # Predefined pause between patterns options (in seconds)
  59. PAUSE_OPTIONS = {
  60. "0s": 0, "1 min": 60, "5 min": 300, "15 min": 900, "30 min": 1800,
  61. "1 hour": 3600, "2 hour": 7200, "3 hour": 10800, "4 hour": 14400,
  62. "5 hour": 18000, "6 hour": 21600, "12 hour": 43200
  63. }
  64. # Signals
  65. statusChanged = Signal()
  66. progressChanged = Signal()
  67. connectionChanged = Signal()
  68. executionStarted = Signal(str, str) # patternName, patternPreview
  69. executionStopped = Signal()
  70. errorOccurred = Signal(str)
  71. serialPortsUpdated = Signal(list) # now: list of discovered table URLs
  72. serialConnectionChanged = Signal(bool) # now: table reachable
  73. currentPortChanged = Signal(str) # now: current table URL
  74. speedChanged = Signal(int)
  75. settingsLoaded = Signal()
  76. screenStateChanged = Signal(bool)
  77. screenTimeoutChanged = Signal(int)
  78. pauseBetweenPatternsChanged = Signal(int)
  79. pausedChanged = Signal(bool)
  80. playlistSettingsChanged = Signal()
  81. patternsRefreshCompleted = Signal(bool, str)
  82. # Playlist management signals
  83. playlistCreated = Signal(bool, str)
  84. playlistDeleted = Signal(bool, str)
  85. patternAddedToPlaylist = Signal(bool, str)
  86. playlistModified = Signal(bool, str)
  87. # Backend/table connection status signals
  88. backendConnectionChanged = Signal(bool)
  89. reconnectStatusChanged = Signal(str)
  90. # LED control signals
  91. ledStatusChanged = Signal()
  92. ledEffectsLoaded = Signal(list)
  93. ledPalettesLoaded = Signal(list)
  94. # LCD brightness signals
  95. lcdBrightnessChanged = Signal()
  96. def __init__(self):
  97. super().__init__()
  98. self.client = FirmwareClient.instance()
  99. # Status properties
  100. self._current_file = ""
  101. self._progress = 0
  102. self._is_running = False
  103. self._is_paused = False
  104. self._is_connected = False
  105. self._serial_ports = []
  106. self._serial_connected = False
  107. self._current_port = ""
  108. self._current_speed = 130
  109. self._auto_play_on_boot = False
  110. self._pause_between_patterns = 10800
  111. # Playlist settings (mirror the table's NVS; also persisted locally)
  112. self._playlist_shuffle = True
  113. self._playlist_run_mode = "loop"
  114. self._playlist_clear_pattern = "adaptive"
  115. # Connection status
  116. self._backend_connected = False
  117. self._reconnect_status = "Looking for table..."
  118. self._saved_table_url = ""
  119. # LCD brightness state
  120. self._lcd_brightness = 255
  121. self._lcd_brightness_path = ""
  122. self._lcd_max_brightness = 0
  123. # LED control state
  124. self._led_provider = "none" # "none" or "dw_leds"
  125. self._led_connected = False
  126. self._led_power_on = False
  127. self._led_brightness = 100 # 0..100 for QML (firmware is 0..255)
  128. self._led_effects = []
  129. self._led_palettes = []
  130. self._led_current_effect = 0
  131. self._led_current_palette = 0
  132. self._led_color = "#ffffff"
  133. self._led_last_effect = 2 # remembered on power-off (default rainbow)
  134. # Screen management
  135. self._screen_on = True
  136. self._screen_timeout = self.DEFAULT_SCREEN_TIMEOUT
  137. self._last_activity = time.time()
  138. self._screen_transition_lock = threading.Lock()
  139. self._last_screen_change = 0
  140. self._screen_timer = QTimer()
  141. self._screen_timer.timeout.connect(self._check_screen_timeout)
  142. self._screen_timer.start(1000)
  143. # Load local settings first
  144. self._load_local_settings()
  145. self._detect_backlight()
  146. # Point the shared client at the saved / env-configured table.
  147. env_url = os.environ.get("DUNE_WEAVER_URL", "")
  148. initial_url = env_url or self._saved_table_url
  149. if initial_url:
  150. self.client.set_base_url(initial_url)
  151. self._current_port = self.client.base_url
  152. # Status polling replaces the old WebSocket status stream.
  153. self._status_timer = QTimer()
  154. self._status_timer.timeout.connect(self._tick_status)
  155. self._time_synced = False
  156. # Kick everything off once the event loop is running.
  157. QTimer.singleShot(200, self._start)
  158. @Slot()
  159. def _start(self):
  160. self._status_timer.start(self.STATUS_POLL_MS)
  161. if not self.client.base_url:
  162. # No configured table -> try to discover one automatically.
  163. _run(self._auto_discover())
  164. async def _auto_discover(self):
  165. self._set_reconnect_status("Searching for tables (mDNS)...")
  166. try:
  167. tables = await discovery.discover_tables(timeout=3.0)
  168. except Exception as exc:
  169. logger.warning(f"Discovery failed: {exc}")
  170. tables = []
  171. self._serial_ports = [t.base_url for t in tables]
  172. self.serialPortsUpdated.emit(self._serial_ports)
  173. if len(tables) == 1:
  174. logger.info(f"Auto-connecting to the only discovered table: {tables[0].base_url}")
  175. self._connect_to(tables[0].base_url)
  176. elif not tables:
  177. self._set_reconnect_status("No table found. Enter the table address.")
  178. else:
  179. self._set_reconnect_status(f"{len(tables)} tables found. Pick one.")
  180. # ==================== Status polling ====================
  181. @Slot()
  182. def _tick_status(self):
  183. _run(self._poll_status())
  184. async def _poll_status(self):
  185. if not self.client.base_url:
  186. return
  187. try:
  188. data = await self.client.status()
  189. except Exception as exc:
  190. self._on_unreachable(str(exc))
  191. return
  192. self._on_status(data)
  193. def _on_status(self, status):
  194. # Reachability
  195. if not self._backend_connected:
  196. self._backend_connected = True
  197. self._is_connected = True
  198. self._serial_connected = True
  199. self._current_port = self.client.base_url
  200. self._reconnect_status = "Connected"
  201. self.connectionChanged.emit()
  202. self.backendConnectionChanged.emit(True)
  203. self.serialConnectionChanged.emit(True)
  204. self.currentPortChanged.emit(self._current_port)
  205. self.reconnectStatusChanged.emit("Connected")
  206. # Load settings + LED config on (re)connect.
  207. self.loadControlSettings()
  208. self.loadLedConfig()
  209. _run(self._sync_time_once())
  210. state = status.get("state", "")
  211. # Current pattern / execution start detection
  212. raw_file = status.get("file", "") or ""
  213. new_file = raw_file
  214. for prefix in ("/sd/patterns/", "/patterns/", "/sd/", "/"):
  215. if new_file.startswith(prefix):
  216. new_file = new_file[len(prefix):]
  217. break
  218. if new_file and new_file != self._current_file:
  219. logger.info(f"Pattern changed to '{new_file}'")
  220. self.executionStarted.emit(new_file, self._preview_path(new_file))
  221. self._current_file = new_file
  222. self._is_running = bool(status.get("running", False))
  223. new_paused = (state == "Hold")
  224. if new_paused != self._is_paused:
  225. self._is_paused = new_paused
  226. self.pausedChanged.emit(new_paused)
  227. feed = status.get("feed")
  228. if feed and int(feed) != self._current_speed:
  229. self._current_speed = int(feed)
  230. self.speedChanged.emit(self._current_speed)
  231. prog = status.get("progress")
  232. if prog is not None:
  233. self._progress = 0 if prog < 0 else float(prog) * 100.0
  234. # Live LED snapshot (effect/brightness) from status
  235. led = status.get("led")
  236. if isinstance(led, dict):
  237. self._ingest_led_status(led)
  238. self.statusChanged.emit()
  239. self.progressChanged.emit()
  240. def _on_unreachable(self, reason):
  241. if self._backend_connected or self._is_connected:
  242. logger.warning(f"Table unreachable: {reason}")
  243. self._backend_connected = False
  244. self._is_connected = False
  245. if self._serial_connected:
  246. self._serial_connected = False
  247. self.serialConnectionChanged.emit(False)
  248. self._reconnect_status = "Table connection lost, retrying..."
  249. self.connectionChanged.emit()
  250. self.backendConnectionChanged.emit(False)
  251. self.reconnectStatusChanged.emit(self._reconnect_status)
  252. def _set_reconnect_status(self, msg):
  253. self._reconnect_status = msg
  254. self.reconnectStatusChanged.emit(msg)
  255. async def _sync_time_once(self):
  256. if self._time_synced:
  257. return
  258. try:
  259. await self.client.sync_time(int(time.time()))
  260. self._time_synced = True
  261. except Exception as exc:
  262. logger.debug(f"time sync failed: {exc}")
  263. def _preview_path(self, rel_name):
  264. """Best-effort cached preview path for a pattern (may be empty)."""
  265. try:
  266. import thr_preview
  267. return thr_preview.cached_preview(self.client.base_url, rel_name)
  268. except Exception:
  269. return ""
  270. # ==================== Properties ====================
  271. @Property(str, notify=statusChanged)
  272. def currentFile(self):
  273. return self._current_file
  274. @Property(float, notify=progressChanged)
  275. def progress(self):
  276. return self._progress
  277. @Property(bool, notify=statusChanged)
  278. def isRunning(self):
  279. return self._is_running
  280. @Property(bool, notify=pausedChanged)
  281. def isPaused(self):
  282. return self._is_paused
  283. @Property(bool, notify=connectionChanged)
  284. def isConnected(self):
  285. return self._is_connected
  286. @Property(list, notify=serialPortsUpdated)
  287. def serialPorts(self):
  288. return self._serial_ports
  289. @Property(bool, notify=serialConnectionChanged)
  290. def serialConnected(self):
  291. return self._serial_connected
  292. @Property(str, notify=currentPortChanged)
  293. def currentPort(self):
  294. return self._current_port
  295. @Property(int, notify=speedChanged)
  296. def currentSpeed(self):
  297. return self._current_speed
  298. @Property(bool, notify=settingsLoaded)
  299. def autoPlayOnBoot(self):
  300. return self._auto_play_on_boot
  301. @Property(bool, notify=backendConnectionChanged)
  302. def backendConnected(self):
  303. return self._backend_connected
  304. @Property(str, notify=reconnectStatusChanged)
  305. def reconnectStatus(self):
  306. return self._reconnect_status
  307. # ==================== Connection management ====================
  308. @Slot()
  309. def retryConnection(self):
  310. logger.debug("Manual connection retry requested")
  311. if self.client.base_url:
  312. _run(self._poll_status())
  313. else:
  314. _run(self._auto_discover())
  315. @Slot()
  316. def refreshSerialPorts(self):
  317. """Re-run mDNS discovery to populate the table picker."""
  318. logger.info("Discovering tables...")
  319. _run(self._auto_discover())
  320. @Slot(str)
  321. def connectSerial(self, port):
  322. """Point the app at a table (URL or host)."""
  323. logger.info(f"Connecting to table: {port}")
  324. self._connect_to(port)
  325. def _connect_to(self, url):
  326. self.client.set_base_url(url)
  327. self._current_port = self.client.base_url
  328. self._saved_table_url = self.client.base_url
  329. self._time_synced = False
  330. self._save_local_settings()
  331. self.currentPortChanged.emit(self._current_port)
  332. self._set_reconnect_status(f"Connecting to {self._current_port}...")
  333. _run(self._poll_status())
  334. @Slot()
  335. def disconnectSerial(self):
  336. logger.info("Disconnecting from table...")
  337. self.client.set_base_url("")
  338. self._current_port = ""
  339. self._serial_connected = False
  340. self._backend_connected = False
  341. self._is_connected = False
  342. self.serialConnectionChanged.emit(False)
  343. self.currentPortChanged.emit("")
  344. self.backendConnectionChanged.emit(False)
  345. self.connectionChanged.emit()
  346. # ==================== Pattern execution ====================
  347. @Slot(str, str)
  348. def executePattern(self, fileName, preExecution="adaptive"):
  349. logger.info(f"ExecutePattern: '{fileName}' (clear={preExecution})")
  350. _run(self._execute_pattern(fileName, preExecution))
  351. async def _execute_pattern(self, fileName, preExecution):
  352. try:
  353. await self.client.run_pattern(fileName, preExecution)
  354. self.executionStarted.emit(fileName, self._preview_path(fileName))
  355. except Exception as exc:
  356. logger.error(f"executePattern failed: {exc}")
  357. self.errorOccurred.emit(str(exc))
  358. @Slot()
  359. def stopExecution(self):
  360. _run(self._simple_action(self.client.stop, on_ok=self.executionStopped.emit,
  361. label="stop"))
  362. @Slot()
  363. def pauseExecution(self):
  364. logger.info("Pausing execution...")
  365. _run(self._simple_action(self.client.pause, label="pause"))
  366. @Slot()
  367. def resumeExecution(self):
  368. logger.info("Resuming execution...")
  369. _run(self._simple_action(self.client.resume, label="resume"))
  370. @Slot()
  371. def skipPattern(self):
  372. logger.info("Skipping pattern...")
  373. _run(self._simple_action(self.client.playlist_skip, label="skip"))
  374. async def _simple_action(self, coro_fn, *, on_ok=None, label="action"):
  375. try:
  376. await coro_fn()
  377. if on_ok:
  378. on_ok()
  379. except Exception as exc:
  380. logger.error(f"{label} failed: {exc}")
  381. self.errorOccurred.emit(f"{label} failed: {exc}")
  382. @Slot(str, float, str, str, bool)
  383. def executePlaylist(self, playlistName, pauseTime=0.0, clearPattern="adaptive",
  384. runMode="single", shuffle=False):
  385. logger.info(f"ExecutePlaylist: '{playlistName}' pause={pauseTime} "
  386. f"clear={clearPattern} mode={runMode} shuffle={shuffle}")
  387. _run(self._execute_playlist(playlistName, pauseTime, clearPattern, runMode, shuffle))
  388. async def _execute_playlist(self, name, pauseTime, clearPattern, runMode, shuffle):
  389. try:
  390. await self.client.run_playlist(
  391. name, pause_time=pauseTime, clear_pattern=clearPattern,
  392. run_mode=runMode, shuffle=shuffle)
  393. except Exception as exc:
  394. logger.error(f"executePlaylist failed: {exc}")
  395. self.errorOccurred.emit(str(exc))
  396. # ==================== Hardware movement ====================
  397. @Slot()
  398. def sendHome(self):
  399. logger.debug("Sending home command...")
  400. _run(self._simple_action(self.client.home, label="home"))
  401. @Slot()
  402. def moveToCenter(self):
  403. logger.info("Moving to center...")
  404. _run(self._simple_action(lambda: self.client.goto(rho=0), label="move to center"))
  405. @Slot()
  406. def moveToPerimeter(self):
  407. logger.info("Moving to perimeter...")
  408. _run(self._simple_action(lambda: self.client.goto(rho=1), label="move to perimeter"))
  409. # ==================== Speed ====================
  410. @Slot(int)
  411. def setSpeed(self, speed):
  412. logger.debug(f"Setting speed (feed) to: {speed}")
  413. _run(self._set_speed(speed))
  414. async def _set_speed(self, speed):
  415. try:
  416. await self.client.set_feed_mm(speed)
  417. self._current_speed = speed
  418. self.speedChanged.emit(speed)
  419. except Exception as exc:
  420. logger.error(f"set speed failed: {exc}")
  421. self.errorOccurred.emit(str(exc))
  422. @Slot(result='QStringList')
  423. def getSpeedOptions(self):
  424. return list(self.SPEED_OPTIONS.keys())
  425. @Slot(result=str)
  426. def getCurrentSpeedOption(self):
  427. for option, value in self.SPEED_OPTIONS.items():
  428. if value == self._current_speed:
  429. return option
  430. return str(self._current_speed)
  431. @Slot(str)
  432. def setSpeedByOption(self, option):
  433. if option in self.SPEED_OPTIONS:
  434. self.setSpeed(self.SPEED_OPTIONS[option])
  435. else:
  436. logger.warning(f"Unknown speed option: {option}")
  437. # ==================== Auto play on boot ====================
  438. @Slot(bool)
  439. def setAutoPlayOnBoot(self, enabled):
  440. logger.info(f"Setting auto play on boot: {enabled}")
  441. # The firmware auto-plays a *named* playlist ($Playlist/Autostart). We
  442. # can reliably disable it here; enabling requires choosing a playlist
  443. # (done from the playlist page), so True is best-effort.
  444. if not enabled:
  445. _run(self._simple_action(lambda: self.client.set_autostart(""),
  446. label="clear autostart"))
  447. else:
  448. logger.info("Enable auto-play: select a playlist to autostart on the playlist page")
  449. self._auto_play_on_boot = enabled
  450. self.settingsLoaded.emit()
  451. # ==================== Load settings from the table ====================
  452. @Slot()
  453. def loadControlSettings(self):
  454. logger.debug("Loading control settings from table...")
  455. _run(self._load_settings())
  456. async def _load_settings(self):
  457. try:
  458. settings = await self.client.settings()
  459. except Exception as exc:
  460. logger.debug(f"Could not load settings: {exc}")
  461. return
  462. def as_int(key, default):
  463. try:
  464. return int(float(settings.get(key, default)))
  465. except (TypeError, ValueError):
  466. return default
  467. # Speed / feed
  468. feed = as_int("THR/Feed", self._current_speed)
  469. if feed and feed != self._current_speed:
  470. self._current_speed = feed
  471. self.speedChanged.emit(feed)
  472. # Playlist settings (mirror the table)
  473. mode = settings.get("Playlist/Mode", self._playlist_run_mode)
  474. if mode in ("single", "loop"):
  475. self._playlist_run_mode = mode
  476. self._playlist_shuffle = str(settings.get("Playlist/Shuffle", "ON")).upper() == "ON"
  477. self._pause_between_patterns = as_int("Playlist/PauseTime", self._pause_between_patterns)
  478. fw_clear = settings.get("Playlist/ClearPattern", "adaptive")
  479. self._playlist_clear_pattern = _FW_TO_UI_CLEAR.get(fw_clear, "adaptive")
  480. self._auto_play_on_boot = bool(settings.get("Playlist/Autostart", "").strip())
  481. self.playlistSettingsChanged.emit()
  482. self.pauseBetweenPatternsChanged.emit(self._pause_between_patterns)
  483. self.settingsLoaded.emit()
  484. logger.info("Settings loaded from table")
  485. # ==================== Screen management ====================
  486. @Property(bool, notify=screenStateChanged)
  487. def screenOn(self):
  488. return self._screen_on
  489. @Property(int, notify=screenTimeoutChanged)
  490. def screenTimeout(self):
  491. return self._screen_timeout
  492. @screenTimeout.setter
  493. def setScreenTimeout(self, timeout):
  494. if self._screen_timeout != timeout:
  495. self._screen_timeout = timeout
  496. self._save_local_settings()
  497. self.screenTimeoutChanged.emit(timeout)
  498. @Slot(result='QStringList')
  499. def getScreenTimeoutOptions(self):
  500. return list(self.TIMEOUT_OPTIONS.keys())
  501. @Slot(result=str)
  502. def getCurrentScreenTimeoutOption(self):
  503. current_timeout = self._screen_timeout
  504. for option, value in self.TIMEOUT_OPTIONS.items():
  505. if value == current_timeout:
  506. return option
  507. if current_timeout == 0:
  508. return "Never"
  509. elif current_timeout < 60:
  510. return f"{current_timeout} seconds"
  511. elif current_timeout < 3600:
  512. minutes = current_timeout // 60
  513. return f"{minutes} minute{'s' if minutes != 1 else ''}"
  514. else:
  515. hours = current_timeout // 3600
  516. return f"{hours} hour{'s' if hours != 1 else ''}"
  517. @Slot(str)
  518. def setScreenTimeoutByOption(self, option):
  519. if option in self.TIMEOUT_OPTIONS:
  520. timeout_value = self.TIMEOUT_OPTIONS[option]
  521. if self._screen_timeout != timeout_value:
  522. self._screen_timeout = timeout_value
  523. self._save_local_settings()
  524. self.screenTimeoutChanged.emit(timeout_value)
  525. else:
  526. logger.warning(f"Unknown timeout option: {option}")
  527. @Slot()
  528. def turnScreenOn(self):
  529. if not self._screen_on:
  530. self._turn_screen_on()
  531. self._reset_activity_timer()
  532. @Slot()
  533. def turnScreenOff(self):
  534. self._turn_screen_off()
  535. @Slot()
  536. def resetActivityTimer(self):
  537. self._reset_activity_timer()
  538. if not self._screen_on:
  539. self._turn_screen_on()
  540. def _turn_screen_on(self):
  541. with self._screen_transition_lock:
  542. time_since_change = time.time() - self._last_screen_change
  543. if time_since_change < 2.0:
  544. return
  545. if self._screen_on:
  546. return
  547. try:
  548. restore_brightness = self._lcd_brightness if self._lcd_brightness > 0 else self._lcd_max_brightness or 255
  549. screen_on_script = Path('/usr/local/bin/screen-on')
  550. if screen_on_script.exists():
  551. result = subprocess.run(['sudo', '/usr/local/bin/screen-on'],
  552. capture_output=True, text=True, timeout=5)
  553. if result.returncode != 0:
  554. logger.warning(f"screen-on script failed: {result.stderr}")
  555. if self._lcd_brightness_path and restore_brightness < (self._lcd_max_brightness or 255):
  556. subprocess.run(
  557. ['sudo', 'sh', '-c', f'echo {restore_brightness} > {self._lcd_brightness_path}'],
  558. check=False, timeout=5)
  559. else:
  560. if self._lcd_brightness_path:
  561. subprocess.run(['sudo', 'sh', '-c',
  562. f'echo 0 > /sys/class/graphics/fb0/blank && echo {restore_brightness} > {self._lcd_brightness_path}'],
  563. check=False, timeout=5)
  564. else:
  565. subprocess.run(['sudo', 'sh', '-c',
  566. f'echo 0 > /sys/class/graphics/fb0/blank && echo {restore_brightness} > /sys/class/backlight/*/brightness'],
  567. check=False, timeout=5)
  568. self._screen_on = True
  569. self._last_screen_change = time.time()
  570. self.screenStateChanged.emit(True)
  571. except Exception as e:
  572. logger.error(f"Failed to turn screen on: {e}")
  573. def _turn_screen_off(self):
  574. with self._screen_transition_lock:
  575. time_since_change = time.time() - self._last_screen_change
  576. if time_since_change < 2.0:
  577. return
  578. if not self._screen_on:
  579. return
  580. try:
  581. screen_off_script = Path('/usr/local/bin/screen-off')
  582. if screen_off_script.exists():
  583. result = subprocess.run(['sudo', '/usr/local/bin/screen-off'],
  584. capture_output=True, text=True, timeout=10)
  585. if result.returncode == 0:
  586. logger.info("Screen turned OFF (screen-off script)")
  587. else:
  588. logger.warning(f"screen-off script failed: return code {result.returncode}")
  589. else:
  590. subprocess.run(['sudo', 'sh', '-c',
  591. 'echo 0 > /sys/class/backlight/*/brightness && echo 1 > /sys/class/graphics/fb0/blank'],
  592. check=False, timeout=5)
  593. self._screen_on = False
  594. self._last_screen_change = time.time()
  595. self.screenStateChanged.emit(False)
  596. except Exception as e:
  597. logger.error(f"Failed to turn screen off: {e}")
  598. def _reset_activity_timer(self):
  599. self._last_activity = time.time()
  600. def _check_screen_timeout(self):
  601. if self._screen_on and self._screen_timeout > 0:
  602. idle_time = time.time() - self._last_activity
  603. if idle_time > self._screen_timeout:
  604. self._turn_screen_off()
  605. # ==================== Local settings persistence ====================
  606. def _load_local_settings(self):
  607. try:
  608. if os.path.exists(self.SETTINGS_FILE):
  609. with open(self.SETTINGS_FILE, 'r') as f:
  610. settings = json.load(f)
  611. screen_timeout = settings.get('screen_timeout', self.DEFAULT_SCREEN_TIMEOUT)
  612. if isinstance(screen_timeout, (int, float)) and screen_timeout >= 0:
  613. self._screen_timeout = int(screen_timeout)
  614. self._lcd_brightness = settings.get('lcd_brightness', 255)
  615. self._lcd_brightness_path = settings.get('lcd_brightness_path', "")
  616. self._lcd_max_brightness = settings.get('lcd_max_brightness', 0)
  617. self._pause_between_patterns = settings.get('pause_between_patterns', 10800)
  618. self._playlist_shuffle = settings.get('playlist_shuffle', True)
  619. self._playlist_run_mode = settings.get('playlist_run_mode', "loop")
  620. self._playlist_clear_pattern = settings.get('playlist_clear_pattern', "adaptive")
  621. self._saved_table_url = settings.get('table_url', "")
  622. else:
  623. self._save_local_settings()
  624. except Exception as e:
  625. logger.error(f"Error loading local settings: {e}, using defaults")
  626. self._screen_timeout = self.DEFAULT_SCREEN_TIMEOUT
  627. def _save_local_settings(self):
  628. try:
  629. settings = {
  630. 'screen_timeout': self._screen_timeout,
  631. 'lcd_brightness': self._lcd_brightness,
  632. 'lcd_brightness_path': self._lcd_brightness_path,
  633. 'lcd_max_brightness': self._lcd_max_brightness,
  634. 'pause_between_patterns': self._pause_between_patterns,
  635. 'playlist_shuffle': self._playlist_shuffle,
  636. 'playlist_run_mode': self._playlist_run_mode,
  637. 'playlist_clear_pattern': self._playlist_clear_pattern,
  638. 'table_url': self._saved_table_url,
  639. 'version': '2.0'
  640. }
  641. with open(self.SETTINGS_FILE, 'w') as f:
  642. json.dump(settings, f, indent=2)
  643. except Exception as e:
  644. logger.error(f"Error saving local settings: {e}")
  645. # ==================== Pause between patterns ====================
  646. @Slot(result='QStringList')
  647. def getPauseOptions(self):
  648. return list(self.PAUSE_OPTIONS.keys())
  649. @Slot(result=str)
  650. def getCurrentPauseOption(self):
  651. current_pause = self._pause_between_patterns
  652. for option, value in self.PAUSE_OPTIONS.items():
  653. if value == current_pause:
  654. return option
  655. if current_pause == 0:
  656. return "0s"
  657. elif current_pause < 60:
  658. return f"{current_pause}s"
  659. elif current_pause < 3600:
  660. return f"{current_pause // 60} min"
  661. else:
  662. return f"{current_pause // 3600} hour"
  663. @Slot(str)
  664. def setPauseByOption(self, option):
  665. if option in self.PAUSE_OPTIONS:
  666. pause_value = self.PAUSE_OPTIONS[option]
  667. if self._pause_between_patterns != pause_value:
  668. self._pause_between_patterns = pause_value
  669. self._save_local_settings()
  670. _run(self._simple_action(
  671. lambda: self.client.command(f"$Playlist/PauseTime={pause_value}"),
  672. label="set pause"))
  673. self.pauseBetweenPatternsChanged.emit(pause_value)
  674. else:
  675. logger.warning(f"Unknown pause option: {option}")
  676. @Property(int, notify=pauseBetweenPatternsChanged)
  677. def pauseBetweenPatterns(self):
  678. return self._pause_between_patterns
  679. # ==================== Playlist settings ====================
  680. @Property(bool, notify=playlistSettingsChanged)
  681. def playlistShuffle(self):
  682. return self._playlist_shuffle
  683. @Slot(bool)
  684. def setPlaylistShuffle(self, enabled):
  685. if self._playlist_shuffle != enabled:
  686. self._playlist_shuffle = enabled
  687. self._save_local_settings()
  688. _run(self._simple_action(
  689. lambda: self.client.command(f"$Playlist/Shuffle={'ON' if enabled else 'OFF'}"),
  690. label="set shuffle"))
  691. self.playlistSettingsChanged.emit()
  692. @Property(str, notify=playlistSettingsChanged)
  693. def playlistRunMode(self):
  694. return self._playlist_run_mode
  695. @Slot(str)
  696. def setPlaylistRunMode(self, mode):
  697. if mode in ("single", "loop") and self._playlist_run_mode != mode:
  698. self._playlist_run_mode = mode
  699. self._save_local_settings()
  700. _run(self._simple_action(
  701. lambda: self.client.command(f"$Playlist/Mode={mode}"),
  702. label="set run mode"))
  703. self.playlistSettingsChanged.emit()
  704. @Property(str, notify=playlistSettingsChanged)
  705. def playlistClearPattern(self):
  706. return self._playlist_clear_pattern
  707. @Slot(str)
  708. def setPlaylistClearPattern(self, pattern):
  709. valid = ["adaptive", "clear_center", "clear_perimeter", "none"]
  710. if pattern in valid and self._playlist_clear_pattern != pattern:
  711. self._playlist_clear_pattern = pattern
  712. self._save_local_settings()
  713. from firmware_client import CLEAR_MODE_MAP
  714. fw = CLEAR_MODE_MAP.get(pattern, "adaptive")
  715. _run(self._simple_action(
  716. lambda: self.client.command(f"$Playlist/ClearPattern={fw}"),
  717. label="set clear pattern"))
  718. self.playlistSettingsChanged.emit()
  719. # ==================== LED control ====================
  720. @Property(str, notify=ledStatusChanged)
  721. def ledProvider(self):
  722. return self._led_provider
  723. @Property(bool, notify=ledStatusChanged)
  724. def ledConnected(self):
  725. return self._led_connected
  726. @Property(bool, notify=ledStatusChanged)
  727. def ledPowerOn(self):
  728. return self._led_power_on
  729. @Property(int, notify=ledStatusChanged)
  730. def ledBrightness(self):
  731. return self._led_brightness
  732. @Property(list, notify=ledEffectsLoaded)
  733. def ledEffects(self):
  734. return self._led_effects
  735. @Property(list, notify=ledPalettesLoaded)
  736. def ledPalettes(self):
  737. return self._led_palettes
  738. @Property(int, notify=ledStatusChanged)
  739. def ledCurrentEffect(self):
  740. return self._led_current_effect
  741. @Property(int, notify=ledStatusChanged)
  742. def ledCurrentPalette(self):
  743. return self._led_current_palette
  744. @Property(str, notify=ledStatusChanged)
  745. def ledColor(self):
  746. return self._led_color
  747. @Slot()
  748. def loadLedConfig(self):
  749. logger.debug("Loading LED configuration...")
  750. _run(self._load_led_config())
  751. async def _load_led_config(self):
  752. try:
  753. settings = await self.client.settings()
  754. except Exception as exc:
  755. logger.debug(f"LED config load failed: {exc}")
  756. return
  757. has_leds = any(k.startswith("LED/") for k in settings)
  758. self._led_provider = "dw_leds" if has_leds else "none"
  759. self._led_connected = has_leds
  760. if has_leds:
  761. # Expose the fixed catalogues as {id, name} lists for the QML page.
  762. self._led_effects = [{"id": i, "name": n} for i, n in enumerate(LED_EFFECTS)]
  763. self._led_palettes = [{"id": i, "name": n} for i, n in enumerate(LED_PALETTES)]
  764. self.ledEffectsLoaded.emit(self._led_effects)
  765. self.ledPalettesLoaded.emit(self._led_palettes)
  766. effect = settings.get("LED/Effect", "off")
  767. if effect in LED_EFFECTS:
  768. self._led_current_effect = LED_EFFECTS.index(effect)
  769. palette = settings.get("LED/Palette", "rainbow")
  770. if palette in LED_PALETTES:
  771. self._led_current_palette = LED_PALETTES.index(palette)
  772. self._led_power_on = (effect != "off")
  773. if self._led_power_on:
  774. self._led_last_effect = self._led_current_effect
  775. try:
  776. self._led_brightness = round(int(settings.get("LED/Brightness", 255)) * 100 / 255)
  777. except (TypeError, ValueError):
  778. self._led_brightness = 100
  779. color = settings.get("LED/Color", "ffffff")
  780. self._led_color = f"#{color.lstrip('#')}"
  781. self.ledStatusChanged.emit()
  782. def _ingest_led_status(self, led):
  783. """Update live LED effect/brightness from a /sand_status snapshot."""
  784. changed = False
  785. effect = led.get("effect")
  786. if effect in LED_EFFECTS:
  787. idx = LED_EFFECTS.index(effect)
  788. if idx != self._led_current_effect:
  789. self._led_current_effect = idx
  790. changed = True
  791. power = (effect != "off")
  792. if power != self._led_power_on:
  793. self._led_power_on = power
  794. changed = True
  795. if power:
  796. self._led_last_effect = idx
  797. b = led.get("brightness")
  798. if b is not None:
  799. scaled = round(int(b) * 100 / 255)
  800. if scaled != self._led_brightness:
  801. self._led_brightness = scaled
  802. changed = True
  803. if changed:
  804. self.ledStatusChanged.emit()
  805. @Slot()
  806. def refreshLedStatus(self):
  807. logger.debug("Refreshing LED status...")
  808. _run(self._load_led_config())
  809. @Slot()
  810. def toggleLedPower(self):
  811. self.setLedPower(not self._led_power_on)
  812. @Slot(bool)
  813. def setLedPower(self, on):
  814. logger.debug(f"Setting LED power: {on}")
  815. if on:
  816. effect = LED_EFFECTS[self._led_last_effect] if self._led_last_effect else "rainbow"
  817. if effect == "off":
  818. effect = "rainbow"
  819. _run(self._apply_led(effect=effect))
  820. self._led_power_on = True
  821. self._led_current_effect = LED_EFFECTS.index(effect)
  822. else:
  823. if self._led_current_effect:
  824. self._led_last_effect = self._led_current_effect
  825. _run(self._apply_led(effect="off"))
  826. self._led_power_on = False
  827. self.ledStatusChanged.emit()
  828. async def _apply_led(self, **kwargs):
  829. try:
  830. await self.client.set_led(**kwargs)
  831. except Exception as exc:
  832. logger.error(f"LED update failed: {exc}")
  833. self.errorOccurred.emit(str(exc))
  834. @Slot(int)
  835. def setLedBrightness(self, value):
  836. logger.debug(f"Setting LED brightness: {value}")
  837. value = max(0, min(100, value))
  838. self._led_brightness = value
  839. _run(self._apply_led(brightness=round(value * 255 / 100)))
  840. self.ledStatusChanged.emit()
  841. @Slot(int, int, int)
  842. def setLedColor(self, r, g, b):
  843. self.setLedColorHex(f"#{r:02x}{g:02x}{b:02x}")
  844. @Slot(str)
  845. def setLedColorHex(self, hexColor):
  846. hexColor = hexColor.lstrip('#')
  847. if len(hexColor) != 6:
  848. logger.warning(f"Invalid hex color: {hexColor}")
  849. return
  850. self._led_color = f"#{hexColor}"
  851. _run(self._apply_led(color=hexColor))
  852. self.ledStatusChanged.emit()
  853. @Slot(int)
  854. def setLedEffect(self, effectId):
  855. logger.debug(f"Setting LED effect: {effectId}")
  856. if 0 <= effectId < len(LED_EFFECTS):
  857. name = LED_EFFECTS[effectId]
  858. self._led_current_effect = effectId
  859. self._led_power_on = (name != "off")
  860. if self._led_power_on:
  861. self._led_last_effect = effectId
  862. _run(self._apply_led(effect=name))
  863. self.ledStatusChanged.emit()
  864. @Slot(int)
  865. def setLedPalette(self, paletteId):
  866. logger.debug(f"Setting LED palette: {paletteId}")
  867. if 0 <= paletteId < len(LED_PALETTES):
  868. self._led_current_palette = paletteId
  869. _run(self._apply_led(palette=LED_PALETTES[paletteId]))
  870. self.ledStatusChanged.emit()
  871. # ==================== LCD brightness ====================
  872. def _detect_backlight(self):
  873. if not self._lcd_brightness_path:
  874. try:
  875. result = subprocess.run(['ls', '/sys/class/backlight'],
  876. capture_output=True, text=True, timeout=2)
  877. if result.returncode == 0 and result.stdout.strip():
  878. device = result.stdout.strip().split('\n')[0]
  879. self._lcd_brightness_path = f"/sys/class/backlight/{device}/brightness"
  880. logger.info(f"Auto-detected backlight path: {self._lcd_brightness_path}")
  881. else:
  882. logger.warning("No backlight device found")
  883. return
  884. except Exception as e:
  885. logger.warning(f"Failed to detect backlight path: {e}")
  886. return
  887. backlight_dir = str(Path(self._lcd_brightness_path).parent)
  888. if self._lcd_max_brightness == 0:
  889. try:
  890. result = subprocess.run(['cat', f"{backlight_dir}/max_brightness"],
  891. capture_output=True, text=True, timeout=2)
  892. if result.returncode == 0 and result.stdout.strip():
  893. self._lcd_max_brightness = int(result.stdout.strip())
  894. else:
  895. self._lcd_max_brightness = 255
  896. except Exception:
  897. self._lcd_max_brightness = 255
  898. try:
  899. result = subprocess.run(['cat', self._lcd_brightness_path],
  900. capture_output=True, text=True, timeout=2)
  901. if result.returncode == 0 and result.stdout.strip():
  902. self._lcd_brightness = int(result.stdout.strip())
  903. except Exception as e:
  904. logger.warning(f"Failed to read current brightness: {e}")
  905. @Property(int, notify=lcdBrightnessChanged)
  906. def lcdBrightness(self):
  907. return self._lcd_brightness
  908. @Property(int, notify=lcdBrightnessChanged)
  909. def lcdMaxBrightness(self):
  910. return self._lcd_max_brightness
  911. @Slot(int)
  912. def setLcdBrightness(self, value):
  913. value = max(0, min(value, self._lcd_max_brightness))
  914. if not self._lcd_brightness_path:
  915. logger.warning("No backlight path configured")
  916. return
  917. try:
  918. subprocess.run(['sudo', 'sh', '-c', f'echo {value} > {self._lcd_brightness_path}'],
  919. check=False, timeout=5)
  920. self._lcd_brightness = value
  921. self._save_local_settings()
  922. self.lcdBrightnessChanged.emit()
  923. except Exception as e:
  924. logger.error(f"Failed to set LCD brightness: {e}")
  925. # ==================== Pattern refresh ====================
  926. @Slot()
  927. def refreshPatterns(self):
  928. """Re-fetch the pattern catalogue from the table (models reload)."""
  929. logger.debug("Refreshing patterns...")
  930. # onPatternsRefreshCompleted in QML triggers patternModel.refresh().
  931. self.patternsRefreshCompleted.emit(True, "Patterns refreshed")
  932. # ==================== System control ====================
  933. @Slot()
  934. def restartBackend(self):
  935. """Reboot the table controller (closest analog to the old backend restart)."""
  936. logger.info("Rebooting the table...")
  937. _run(self._simple_action(self.client.reboot, label="reboot table"))
  938. @Slot()
  939. def shutdownPi(self):
  940. """Shut down the local touch host (Raspberry Pi)."""
  941. logger.info("Shutting down the Pi...")
  942. try:
  943. subprocess.run(['sudo', 'shutdown', '-h', 'now'], check=False, timeout=5)
  944. except Exception as e:
  945. logger.error(f"Shutdown failed: {e}")
  946. self.errorOccurred.emit(str(e))
  947. # ==================== Playlist management ====================
  948. @Slot(str)
  949. def createPlaylist(self, playlistName):
  950. logger.debug(f"Creating playlist: {playlistName}")
  951. _run(self._create_playlist(playlistName))
  952. async def _create_playlist(self, name):
  953. try:
  954. content = f"# {name}\n".encode("utf-8")
  955. await self.client.upload_file(f"{name}.txt", content, path="/playlists")
  956. self.playlistCreated.emit(True, f"Created: {name}")
  957. except Exception as exc:
  958. logger.error(f"create playlist failed: {exc}")
  959. self.playlistCreated.emit(False, f"Failed: {exc}")
  960. @Slot(str)
  961. def deletePlaylist(self, playlistName):
  962. logger.info(f"Deleting playlist: {playlistName}")
  963. _run(self._delete_playlist(playlistName))
  964. async def _delete_playlist(self, name):
  965. try:
  966. await self.client.delete_file(f"{name}.txt", path="/playlists")
  967. self.playlistDeleted.emit(True, f"Deleted: {name}")
  968. except Exception as exc:
  969. logger.error(f"delete playlist failed: {exc}")
  970. self.playlistDeleted.emit(False, f"Failed: {exc}")
  971. @Slot(str, str)
  972. def addPatternToPlaylist(self, playlistName, patternPath):
  973. logger.info(f"Adding pattern to playlist: {patternPath} -> {playlistName}")
  974. _run(self._add_pattern_to_playlist(playlistName, patternPath))
  975. async def _add_pattern_to_playlist(self, name, patternPath):
  976. try:
  977. # Fetch current contents, append the SD path, re-upload.
  978. try:
  979. raw = await self.client.fetch_sd_file(f"/playlists/{name}.txt")
  980. lines = raw.decode("utf-8", errors="ignore").splitlines()
  981. except Exception:
  982. lines = [f"# {name}"]
  983. sd_path = self._to_sd_pattern_path(patternPath)
  984. lines.append(sd_path)
  985. content = ("\n".join(lines) + "\n").encode("utf-8")
  986. await self.client.upload_file(f"{name}.txt", content, path="/playlists")
  987. self.patternAddedToPlaylist.emit(True, f"Added to {name}")
  988. except Exception as exc:
  989. logger.error(f"add pattern failed: {exc}")
  990. self.patternAddedToPlaylist.emit(False, f"Failed: {exc}")
  991. @Slot(str, list)
  992. def updatePlaylistPatterns(self, playlistName, patterns):
  993. logger.debug(f"Updating playlist {playlistName} -> {len(patterns)} patterns")
  994. _run(self._update_playlist_patterns(playlistName, patterns))
  995. async def _update_playlist_patterns(self, name, patterns):
  996. try:
  997. lines = [f"# {name}"]
  998. lines += [self._to_sd_pattern_path(p) for p in patterns]
  999. content = ("\n".join(lines) + "\n").encode("utf-8")
  1000. await self.client.upload_file(f"{name}.txt", content, path="/playlists")
  1001. self.playlistModified.emit(True, f"Updated: {name}")
  1002. except Exception as exc:
  1003. logger.error(f"update playlist failed: {exc}")
  1004. self.playlistModified.emit(False, f"Failed: {exc}")
  1005. @staticmethod
  1006. def _to_sd_pattern_path(pattern):
  1007. """Normalize a pattern reference to an SD-absolute /patterns path."""
  1008. p = str(pattern).strip()
  1009. if p.startswith("/patterns/") or p.startswith("/sd/"):
  1010. return p.replace("/sd/", "/", 1) if p.startswith("/sd/") else p
  1011. return "/patterns/" + p.lstrip("/")
  1012. @Slot(result=list)
  1013. def getPlaylistNames(self):
  1014. """Synchronous accessor kept for QML compatibility (best-effort)."""
  1015. # Playlists now live on the table; the PlaylistModel is the source of
  1016. # truth. This returns an empty list rather than reading a local file.
  1017. return []