backend.py 82 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877
  1. from PySide6.QtCore import QObject, Signal, Property, Slot, QTimer
  2. from PySide6.QtQml import QmlElement
  3. from PySide6.QtWebSockets import QWebSocket
  4. from PySide6.QtNetwork import QAbstractSocket
  5. import aiohttp
  6. import asyncio
  7. import json
  8. import logging
  9. import subprocess
  10. import threading
  11. import time
  12. from pathlib import Path
  13. import os
  14. # Configure logging
  15. logging.basicConfig(
  16. level=logging.INFO,
  17. format='%(asctime)s [%(levelname)s] %(message)s',
  18. datefmt='%H:%M:%S'
  19. )
  20. logger = logging.getLogger("DuneWeaver")
  21. QML_IMPORT_NAME = "DuneWeaver"
  22. QML_IMPORT_MAJOR_VERSION = 1
  23. @QmlElement
  24. class Backend(QObject):
  25. """Backend controller for API and WebSocket communication"""
  26. # Constants
  27. SETTINGS_FILE = "touch_settings.json"
  28. DEFAULT_SCREEN_TIMEOUT = 300 # 5 minutes in seconds
  29. # Predefined timeout options (in seconds)
  30. TIMEOUT_OPTIONS = {
  31. "30 seconds": 30,
  32. "1 minute": 60,
  33. "5 minutes": 300,
  34. "10 minutes": 600,
  35. "Never": 0 # 0 means never timeout
  36. }
  37. # Predefined speed options
  38. SPEED_OPTIONS = {
  39. "50": 50,
  40. "100": 100,
  41. "150": 150,
  42. "200": 200,
  43. "300": 300,
  44. "500": 500
  45. }
  46. # Predefined pause between patterns options (in seconds)
  47. PAUSE_OPTIONS = {
  48. "0s": 0, # No pause
  49. "1 min": 60, # 1 minute
  50. "5 min": 300, # 5 minutes
  51. "15 min": 900, # 15 minutes
  52. "30 min": 1800, # 30 minutes
  53. "1 hour": 3600, # 1 hour
  54. "2 hour": 7200, # 2 hours
  55. "3 hour": 10800, # 3 hours
  56. "4 hour": 14400, # 4 hours
  57. "5 hour": 18000, # 5 hours
  58. "6 hour": 21600, # 6 hours
  59. "12 hour": 43200 # 12 hours
  60. }
  61. # Signals
  62. statusChanged = Signal()
  63. progressChanged = Signal()
  64. connectionChanged = Signal()
  65. executionStarted = Signal(str, str) # patternName, patternPreview
  66. executionStopped = Signal()
  67. errorOccurred = Signal(str)
  68. serialPortsUpdated = Signal(list)
  69. serialConnectionChanged = Signal(bool)
  70. currentPortChanged = Signal(str)
  71. speedChanged = Signal(int)
  72. settingsLoaded = Signal()
  73. screenStateChanged = Signal(bool) # True = on, False = off
  74. screenTimeoutChanged = Signal(int) # New signal for timeout changes
  75. pauseBetweenPatternsChanged = Signal(int) # New signal for pause changes
  76. pausedChanged = Signal(bool) # Signal when pause state changes
  77. playlistSettingsChanged = Signal() # Signal when any playlist setting changes
  78. patternsRefreshCompleted = Signal(bool, str) # (success, message) for pattern refresh
  79. # Playlist management signals
  80. playlistCreated = Signal(bool, str) # (success, message)
  81. playlistDeleted = Signal(bool, str) # (success, message)
  82. patternAddedToPlaylist = Signal(bool, str) # (success, message)
  83. playlistModified = Signal(bool, str) # (success, message)
  84. # Backend connection status signals
  85. backendConnectionChanged = Signal(bool) # True = backend reachable, False = unreachable
  86. reconnectStatusChanged = Signal(str) # Current reconnection status message
  87. # LED control signals
  88. ledStatusChanged = Signal()
  89. ledEffectsLoaded = Signal(list) # List of available effects
  90. ledPalettesLoaded = Signal(list) # List of available palettes
  91. # LCD brightness signals
  92. lcdBrightnessChanged = Signal()
  93. def __init__(self):
  94. super().__init__()
  95. # Load base URL from environment variable, default to localhost
  96. self.base_url = os.environ.get("DUNE_WEAVER_URL", "http://localhost:8080")
  97. # Initialize all status properties first
  98. self._current_file = ""
  99. self._progress = 0
  100. self._is_running = False
  101. self._is_paused = False # Track pause state separately
  102. self._is_connected = False
  103. self._serial_ports = []
  104. self._serial_connected = False
  105. self._current_port = ""
  106. self._current_speed = 130
  107. self._auto_play_on_boot = False
  108. self._pause_between_patterns = 10800 # Default: 3 hours (10800 seconds)
  109. # Playlist settings (persisted locally)
  110. self._playlist_shuffle = True # Default: shuffle on
  111. self._playlist_run_mode = "loop" # Default: loop mode
  112. self._playlist_clear_pattern = "adaptive" # Default: adaptive
  113. # Backend connection status
  114. self._backend_connected = False
  115. self._reconnect_status = "Connecting to backend..."
  116. # LCD brightness state
  117. self._lcd_brightness = 255
  118. self._lcd_brightness_path = "" # Empty = auto-detect
  119. self._lcd_max_brightness = 0 # 0 = read from sysfs
  120. # LED control state
  121. self._led_provider = "none" # "none", "wled", or "dw_leds"
  122. self._led_connected = False
  123. self._led_power_on = False
  124. self._led_brightness = 100
  125. self._led_effects = []
  126. self._led_palettes = []
  127. self._led_current_effect = 0
  128. self._led_current_palette = 0
  129. self._led_color = "#ffffff"
  130. # WebSocket for status with reconnection
  131. self.ws = QWebSocket()
  132. self.ws.connected.connect(self._on_ws_connected)
  133. self.ws.disconnected.connect(self._on_ws_disconnected)
  134. self.ws.errorOccurred.connect(self._on_ws_error)
  135. self.ws.textMessageReceived.connect(self._on_ws_message)
  136. # WebSocket reconnection management
  137. self._reconnect_timer = QTimer()
  138. self._reconnect_timer.timeout.connect(self._attempt_ws_reconnect)
  139. self._reconnect_timer.setSingleShot(True)
  140. self._reconnect_attempts = 0
  141. self._reconnect_delay = 1000 # Fixed 1 second delay between retries
  142. # Screen management
  143. self._screen_on = True
  144. self._screen_timeout = self.DEFAULT_SCREEN_TIMEOUT # Will be loaded from settings
  145. self._last_activity = time.time()
  146. self._screen_transition_lock = threading.Lock() # Prevent rapid state changes
  147. self._last_screen_change = 0 # Track last state change time
  148. self._screen_timer = QTimer()
  149. self._screen_timer.timeout.connect(self._check_screen_timeout)
  150. self._screen_timer.start(1000) # Check every second
  151. # Load local settings first
  152. self._load_local_settings()
  153. logger.debug(f"Screen management initialized: timeout={self._screen_timeout}s, timer started")
  154. # Detect and initialize LCD backlight
  155. self._detect_backlight()
  156. # HTTP session - initialize lazily
  157. self.session = None
  158. self._session_initialized = False
  159. # Use QTimer to defer session initialization until event loop is running
  160. QTimer.singleShot(100, self._delayed_init)
  161. # Start initial WebSocket connection (after all attributes are initialized)
  162. # Use QTimer to ensure it happens after constructor completes
  163. QTimer.singleShot(200, self._attempt_ws_reconnect)
  164. @Slot()
  165. def _delayed_init(self):
  166. """Initialize session after Qt event loop is running"""
  167. if not self._session_initialized:
  168. try:
  169. loop = asyncio.get_event_loop()
  170. if loop.is_running():
  171. asyncio.create_task(self._init_session())
  172. else:
  173. # If no loop is running, try again later
  174. QTimer.singleShot(500, self._delayed_init)
  175. except RuntimeError:
  176. # No event loop yet, try again
  177. QTimer.singleShot(500, self._delayed_init)
  178. async def _init_session(self):
  179. """Initialize aiohttp session"""
  180. if not self._session_initialized:
  181. # Create connector with SSL disabled for localhost
  182. connector = aiohttp.TCPConnector(ssl=False)
  183. self.session = aiohttp.ClientSession(connector=connector)
  184. self._session_initialized = True
  185. # Properties
  186. @Property(str, notify=statusChanged)
  187. def currentFile(self):
  188. return self._current_file
  189. @Property(float, notify=progressChanged)
  190. def progress(self):
  191. return self._progress
  192. @Property(bool, notify=statusChanged)
  193. def isRunning(self):
  194. return self._is_running
  195. @Property(bool, notify=pausedChanged)
  196. def isPaused(self):
  197. return self._is_paused
  198. @Property(bool, notify=connectionChanged)
  199. def isConnected(self):
  200. return self._is_connected
  201. @Property(list, notify=serialPortsUpdated)
  202. def serialPorts(self):
  203. return self._serial_ports
  204. @Property(bool, notify=serialConnectionChanged)
  205. def serialConnected(self):
  206. return self._serial_connected
  207. @Property(str, notify=currentPortChanged)
  208. def currentPort(self):
  209. return self._current_port
  210. @Property(int, notify=speedChanged)
  211. def currentSpeed(self):
  212. return self._current_speed
  213. @Property(bool, notify=settingsLoaded)
  214. def autoPlayOnBoot(self):
  215. return self._auto_play_on_boot
  216. @Property(bool, notify=backendConnectionChanged)
  217. def backendConnected(self):
  218. return self._backend_connected
  219. @Property(str, notify=reconnectStatusChanged)
  220. def reconnectStatus(self):
  221. return self._reconnect_status
  222. # WebSocket handlers
  223. @Slot()
  224. def _on_ws_connected(self):
  225. logger.info("WebSocket connected successfully")
  226. self._is_connected = True
  227. self._backend_connected = True
  228. self._reconnect_attempts = 0 # Reset reconnection counter
  229. self._reconnect_status = "Connected to backend"
  230. self.connectionChanged.emit()
  231. self.backendConnectionChanged.emit(True)
  232. self.reconnectStatusChanged.emit("Connected to backend")
  233. # Load initial settings when we connect
  234. self.loadControlSettings()
  235. # Also load LED config automatically
  236. self.loadLedConfig()
  237. @Slot()
  238. def _on_ws_disconnected(self):
  239. logger.error("WebSocket disconnected")
  240. self._is_connected = False
  241. self._backend_connected = False
  242. self._reconnect_status = "Backend connection lost..."
  243. self.connectionChanged.emit()
  244. self.backendConnectionChanged.emit(False)
  245. self.reconnectStatusChanged.emit("Backend connection lost...")
  246. # Start reconnection attempts
  247. self._schedule_reconnect()
  248. @Slot()
  249. def _on_ws_error(self, error):
  250. logger.error(f"WebSocket error: {error}")
  251. self._is_connected = False
  252. self._backend_connected = False
  253. self._reconnect_status = f"Backend error: {error}"
  254. self.connectionChanged.emit()
  255. self.backendConnectionChanged.emit(False)
  256. self.reconnectStatusChanged.emit(f"Backend error: {error}")
  257. # Start reconnection attempts
  258. self._schedule_reconnect()
  259. def _schedule_reconnect(self):
  260. """Schedule a reconnection attempt with fixed 1-second delay."""
  261. # Always retry - no maximum attempts for touch interface
  262. status_msg = f"Reconnecting in 1s... (attempt {self._reconnect_attempts + 1})"
  263. logger.debug(f"{status_msg}")
  264. self._reconnect_status = status_msg
  265. self.reconnectStatusChanged.emit(status_msg)
  266. self._reconnect_timer.start(self._reconnect_delay) # Always 1 second
  267. @Slot()
  268. def _attempt_ws_reconnect(self):
  269. """Attempt to reconnect WebSocket."""
  270. if self.ws.state() == QAbstractSocket.SocketState.ConnectedState:
  271. logger.info("WebSocket already connected")
  272. return
  273. self._reconnect_attempts += 1
  274. status_msg = f"Connecting to backend... (attempt {self._reconnect_attempts})"
  275. logger.debug(f"{status_msg}")
  276. self._reconnect_status = status_msg
  277. self.reconnectStatusChanged.emit(status_msg)
  278. # Close existing connection if any
  279. if self.ws.state() != QAbstractSocket.SocketState.UnconnectedState:
  280. self.ws.close()
  281. # Attempt new connection - derive WebSocket URL from base URL
  282. ws_url = self.base_url.replace("http://", "ws://").replace("https://", "wss://") + "/ws/status"
  283. self.ws.open(ws_url)
  284. @Slot()
  285. def retryConnection(self):
  286. """Manually retry connection (reset attempts and try again)."""
  287. logger.debug("Manual connection retry requested")
  288. self._reconnect_attempts = 0
  289. self._reconnect_timer.stop() # Stop any scheduled reconnect
  290. self._attempt_ws_reconnect()
  291. @Slot(str)
  292. def _on_ws_message(self, message):
  293. try:
  294. data = json.loads(message)
  295. if data.get("type") == "status_update":
  296. status = data.get("data", {})
  297. new_file = status.get("current_file", "")
  298. # Detect pattern change and emit executionStarted signal
  299. if new_file and new_file != self._current_file:
  300. logger.info(f"Pattern changed from '{self._current_file}' to '{new_file}'")
  301. # Find preview for the new pattern
  302. preview_path = self._find_pattern_preview(new_file)
  303. logger.debug(f"Preview path for new pattern: {preview_path}")
  304. # Emit signal so UI can update
  305. self.executionStarted.emit(new_file, preview_path)
  306. self._current_file = new_file
  307. self._is_running = status.get("is_running", False)
  308. # Handle pause state from WebSocket
  309. new_paused = status.get("is_paused", False)
  310. if new_paused != self._is_paused:
  311. logger.info(f"Pause state changed: {self._is_paused} -> {new_paused}")
  312. self._is_paused = new_paused
  313. self.pausedChanged.emit(new_paused)
  314. # Handle serial connection status from WebSocket
  315. ws_connection_status = status.get("connection_status", False)
  316. if ws_connection_status != self._serial_connected:
  317. logger.info(f"WebSocket serial connection status changed: {ws_connection_status}")
  318. self._serial_connected = ws_connection_status
  319. self.serialConnectionChanged.emit(ws_connection_status)
  320. # If we're connected, we need to get the current port
  321. if ws_connection_status:
  322. # We'll need to fetch the current port via HTTP since WS doesn't include port info
  323. asyncio.create_task(self._get_current_port())
  324. else:
  325. self._current_port = ""
  326. self.currentPortChanged.emit("")
  327. # Handle speed updates from WebSocket
  328. ws_speed = status.get("speed", None)
  329. if ws_speed and ws_speed != self._current_speed:
  330. logger.debug(f"WebSocket speed changed: {ws_speed}")
  331. self._current_speed = ws_speed
  332. self.speedChanged.emit(ws_speed)
  333. if status.get("progress"):
  334. self._progress = status["progress"].get("percentage", 0)
  335. self.statusChanged.emit()
  336. self.progressChanged.emit()
  337. except json.JSONDecodeError:
  338. pass
  339. async def _get_current_port(self):
  340. """Fetch the current port when we detect a connection via WebSocket"""
  341. if not self.session:
  342. return
  343. try:
  344. async with self.session.get(f"{self.base_url}/serial_status") as resp:
  345. if resp.status == 200:
  346. data = await resp.json()
  347. current_port = data.get("port", "")
  348. if current_port:
  349. self._current_port = current_port
  350. self.currentPortChanged.emit(current_port)
  351. logger.info(f"Updated current port from WebSocket trigger: {current_port}")
  352. except Exception as e:
  353. logger.error(f"Exception getting current port: {e}")
  354. # API Methods
  355. @Slot(str, str)
  356. def executePattern(self, fileName, preExecution="adaptive"):
  357. logger.info(f"ExecutePattern called: fileName='{fileName}', preExecution='{preExecution}'")
  358. asyncio.create_task(self._execute_pattern(fileName, preExecution))
  359. async def _execute_pattern(self, fileName, preExecution):
  360. if not self.session:
  361. logger.error("Backend session not ready")
  362. self.errorOccurred.emit("Backend not ready, please try again")
  363. return
  364. try:
  365. request_data = {"file_name": fileName, "pre_execution": preExecution}
  366. logger.debug(f"Making HTTP POST to: {self.base_url}/run_theta_rho")
  367. logger.debug(f"Request payload: {request_data}")
  368. async with self.session.post(
  369. f"{self.base_url}/run_theta_rho",
  370. json=request_data
  371. ) as resp:
  372. logger.debug(f"Response status: {resp.status}")
  373. logger.debug(f"Response headers: {dict(resp.headers)}")
  374. response_text = await resp.text()
  375. logger.debug(f"Response body: {response_text}")
  376. if resp.status == 200:
  377. logger.info("Pattern execution request successful")
  378. # Find preview image for the pattern
  379. preview_path = self._find_pattern_preview(fileName)
  380. logger.debug(f"Pattern preview path: {preview_path}")
  381. logger.debug(f"About to emit executionStarted signal with: fileName='{fileName}', preview='{preview_path}'")
  382. try:
  383. self.executionStarted.emit(fileName, preview_path)
  384. logger.info("ExecutionStarted signal emitted successfully")
  385. except Exception as e:
  386. logger.error(f"Error emitting executionStarted signal: {e}")
  387. else:
  388. logger.error(f"Pattern execution failed with status {resp.status}")
  389. self.errorOccurred.emit(f"Failed to execute: {resp.status} - {response_text}")
  390. except Exception as e:
  391. logger.error(f"Exception in _execute_pattern: {e}")
  392. self.errorOccurred.emit(str(e))
  393. def _find_pattern_preview(self, fileName):
  394. """Find the preview image for a pattern"""
  395. try:
  396. # Extract just the filename from the path (remove any directory prefixes)
  397. clean_filename = fileName.split('/')[-1] # Get last part of path
  398. logger.debug(f"Original fileName: {fileName}, clean filename: {clean_filename}")
  399. # Check multiple possible locations for patterns directory
  400. # Use relative paths that work across different environments
  401. possible_dirs = [
  402. Path("../patterns"), # One level up (for when running from touch subdirectory)
  403. Path("patterns"), # Same level (for when running from main directory)
  404. Path(__file__).parent.parent / "patterns" # Dynamic path relative to backend.py
  405. ]
  406. for patterns_dir in possible_dirs:
  407. cache_dir = patterns_dir / "cached_images"
  408. if cache_dir.exists():
  409. logger.debug(f"Searching for preview in cache directory: {cache_dir}")
  410. # Extensions to try - PNG first for better kiosk compatibility
  411. extensions = [".png", ".webp", ".jpg", ".jpeg"]
  412. # Filenames to try - with and without .thr suffix
  413. base_name = clean_filename.replace(".thr", "")
  414. filenames_to_try = [clean_filename, base_name]
  415. # Try direct path in cache_dir first (fastest)
  416. for filename in filenames_to_try:
  417. for ext in extensions:
  418. preview_file = cache_dir / (filename + ext)
  419. if preview_file.exists():
  420. logger.info(f"Found preview (direct): {preview_file}")
  421. return str(preview_file.absolute())
  422. # If not found directly, search recursively through subdirectories
  423. logger.debug(f"Searching recursively in {cache_dir}...")
  424. for filename in filenames_to_try:
  425. for ext in extensions:
  426. target_name = filename + ext
  427. # Use rglob to search recursively
  428. matches = list(cache_dir.rglob(target_name))
  429. if matches:
  430. # Return the first match found
  431. preview_file = matches[0]
  432. logger.info(f"Found preview (recursive): {preview_file}")
  433. return str(preview_file.absolute())
  434. logger.error("No preview image found")
  435. return ""
  436. except Exception as e:
  437. logger.error(f"Exception finding preview: {e}")
  438. return ""
  439. @Slot()
  440. def stopExecution(self):
  441. asyncio.create_task(self._stop_execution())
  442. async def _stop_execution(self):
  443. if not self.session:
  444. self.errorOccurred.emit("Backend not ready")
  445. return
  446. try:
  447. logger.info("Calling stop_execution endpoint...")
  448. # Backend stop_actions() waits up to 10s for pattern lock + 30s for idle check
  449. # Use 45s timeout to accommodate worst-case scenario
  450. timeout = aiohttp.ClientTimeout(total=45)
  451. async with self.session.post(f"{self.base_url}/stop_execution", timeout=timeout) as resp:
  452. logger.info(f"Stop execution response status: {resp.status}")
  453. if resp.status == 200:
  454. response_data = await resp.json()
  455. logger.info(f"Stop execution response: {response_data}")
  456. self.executionStopped.emit()
  457. else:
  458. logger.error(f"Stop execution failed with status: {resp.status}")
  459. response_text = await resp.text()
  460. self.errorOccurred.emit(f"Stop failed: {resp.status} - {response_text}")
  461. except asyncio.TimeoutError:
  462. logger.warning("Stop execution request timed out")
  463. self.errorOccurred.emit("Stop execution request timed out")
  464. except Exception as e:
  465. logger.error(f"Exception in _stop_execution: {e}")
  466. self.errorOccurred.emit(str(e))
  467. @Slot()
  468. def pauseExecution(self):
  469. logger.info("Pausing execution...")
  470. asyncio.create_task(self._api_call("/pause_execution"))
  471. @Slot()
  472. def resumeExecution(self):
  473. logger.info("Resuming execution...")
  474. asyncio.create_task(self._api_call("/resume_execution"))
  475. @Slot()
  476. def skipPattern(self):
  477. logger.info("Skipping pattern...")
  478. asyncio.create_task(self._api_call("/skip_pattern"))
  479. @Slot(str, float, str, str, bool)
  480. def executePlaylist(self, playlistName, pauseTime=0.0, clearPattern="adaptive", runMode="single", shuffle=False):
  481. logger.info(f"ExecutePlaylist called: playlist='{playlistName}', pauseTime={pauseTime}, clearPattern='{clearPattern}', runMode='{runMode}', shuffle={shuffle}")
  482. asyncio.create_task(self._execute_playlist(playlistName, pauseTime, clearPattern, runMode, shuffle))
  483. async def _execute_playlist(self, playlistName, pauseTime, clearPattern, runMode, shuffle):
  484. if not self.session:
  485. logger.error("Backend session not ready")
  486. self.errorOccurred.emit("Backend not ready, please try again")
  487. return
  488. try:
  489. request_data = {
  490. "playlist_name": playlistName,
  491. "pause_time": pauseTime,
  492. "clear_pattern": clearPattern,
  493. "run_mode": runMode,
  494. "shuffle": shuffle
  495. }
  496. logger.debug(f"Making HTTP POST to: {self.base_url}/run_playlist")
  497. logger.debug(f"Request payload: {request_data}")
  498. async with self.session.post(
  499. f"{self.base_url}/run_playlist",
  500. json=request_data
  501. ) as resp:
  502. logger.debug(f"Response status: {resp.status}")
  503. response_text = await resp.text()
  504. logger.debug(f"Response body: {response_text}")
  505. if resp.status == 200:
  506. logger.info(f"Playlist execution request successful: {playlistName}")
  507. # The playlist will start executing patterns automatically
  508. # Status updates will come through WebSocket
  509. else:
  510. logger.error(f"Playlist execution failed with status {resp.status}")
  511. self.errorOccurred.emit(f"Failed to execute playlist: {resp.status} - {response_text}")
  512. except Exception as e:
  513. logger.error(f"Exception in _execute_playlist: {e}")
  514. self.errorOccurred.emit(str(e))
  515. async def _api_call(self, endpoint):
  516. if not self.session:
  517. self.errorOccurred.emit("Backend not ready")
  518. return
  519. try:
  520. logger.debug(f"Calling API endpoint: {endpoint}")
  521. # Add timeout to prevent hanging
  522. timeout = aiohttp.ClientTimeout(total=10) # 10 second timeout
  523. async with self.session.post(f"{self.base_url}{endpoint}", timeout=timeout) as resp:
  524. logger.debug(f"API response status for {endpoint}: {resp.status}")
  525. if resp.status == 200:
  526. response_data = await resp.json()
  527. logger.debug(f"API response for {endpoint}: {response_data}")
  528. else:
  529. logger.error(f"API call {endpoint} failed with status: {resp.status}")
  530. response_text = await resp.text()
  531. self.errorOccurred.emit(f"API call failed: {endpoint} - {resp.status} - {response_text}")
  532. except asyncio.TimeoutError:
  533. logger.warning(f"API call {endpoint} timed out")
  534. self.errorOccurred.emit(f"API call {endpoint} timed out")
  535. except Exception as e:
  536. logger.error(f"Exception in API call {endpoint}: {e}")
  537. self.errorOccurred.emit(str(e))
  538. # Serial Port Management
  539. @Slot()
  540. def refreshSerialPorts(self):
  541. logger.info("Refreshing serial ports...")
  542. asyncio.create_task(self._refresh_serial_ports())
  543. async def _refresh_serial_ports(self):
  544. if not self.session:
  545. self.errorOccurred.emit("Backend not ready")
  546. return
  547. try:
  548. async with self.session.get(f"{self.base_url}/list_serial_ports") as resp:
  549. if resp.status == 200:
  550. # The endpoint returns a list directly, not a dictionary
  551. ports = await resp.json()
  552. self._serial_ports = ports if isinstance(ports, list) else []
  553. logger.debug(f"Found serial ports: {self._serial_ports}")
  554. self.serialPortsUpdated.emit(self._serial_ports)
  555. else:
  556. logger.error(f"Failed to get serial ports: {resp.status}")
  557. except Exception as e:
  558. logger.error(f"Exception refreshing serial ports: {e}")
  559. self.errorOccurred.emit(str(e))
  560. @Slot(str)
  561. def connectSerial(self, port):
  562. logger.info(f"Connecting to serial port: {port}")
  563. asyncio.create_task(self._connect_serial(port))
  564. async def _connect_serial(self, port):
  565. if not self.session:
  566. self.errorOccurred.emit("Backend not ready")
  567. return
  568. try:
  569. async with self.session.post(f"{self.base_url}/connect", json={"port": port}) as resp:
  570. if resp.status == 200:
  571. logger.info(f"Connected to {port}")
  572. self._serial_connected = True
  573. self._current_port = port
  574. self.serialConnectionChanged.emit(True)
  575. self.currentPortChanged.emit(port)
  576. else:
  577. response_text = await resp.text()
  578. logger.error(f"Failed to connect to {port}: {resp.status} - {response_text}")
  579. self.errorOccurred.emit(f"Failed to connect: {response_text}")
  580. except Exception as e:
  581. logger.error(f"Exception connecting to serial: {e}")
  582. self.errorOccurred.emit(str(e))
  583. @Slot()
  584. def disconnectSerial(self):
  585. logger.info("Disconnecting serial...")
  586. asyncio.create_task(self._disconnect_serial())
  587. async def _disconnect_serial(self):
  588. if not self.session:
  589. self.errorOccurred.emit("Backend not ready")
  590. return
  591. try:
  592. async with self.session.post(f"{self.base_url}/disconnect") as resp:
  593. if resp.status == 200:
  594. logger.info("Disconnected from serial")
  595. self._serial_connected = False
  596. self._current_port = ""
  597. self.serialConnectionChanged.emit(False)
  598. self.currentPortChanged.emit("")
  599. else:
  600. response_text = await resp.text()
  601. logger.error(f"Failed to disconnect: {resp.status} - {response_text}")
  602. except Exception as e:
  603. logger.error(f"Exception disconnecting serial: {e}")
  604. self.errorOccurred.emit(str(e))
  605. # Hardware Movement Controls
  606. @Slot()
  607. def sendHome(self):
  608. logger.debug("Sending home command...")
  609. asyncio.create_task(self._send_home())
  610. async def _send_home(self):
  611. """Send home command without timeout - homing can take up to 90 seconds."""
  612. if not self.session:
  613. self.errorOccurred.emit("Backend not ready")
  614. return
  615. try:
  616. logger.debug("Calling /send_home (no timeout - homing can take up to 90s)...")
  617. async with self.session.post(f"{self.base_url}/send_home") as resp:
  618. logger.debug(f"Home command response status: {resp.status}")
  619. if resp.status == 200:
  620. response_data = await resp.json()
  621. logger.info(f"Home command successful: {response_data}")
  622. else:
  623. logger.error(f"Home command failed with status: {resp.status}")
  624. response_text = await resp.text()
  625. self.errorOccurred.emit(f"Home failed: {resp.status} - {response_text}")
  626. except Exception as e:
  627. logger.error(f"Exception in home command: {e}")
  628. self.errorOccurred.emit(str(e))
  629. @Slot()
  630. def moveToCenter(self):
  631. logger.info("Moving to center...")
  632. asyncio.create_task(self._move_to("/move_to_center"))
  633. @Slot()
  634. def moveToPerimeter(self):
  635. logger.info("Moving to perimeter...")
  636. asyncio.create_task(self._move_to("/move_to_perimeter"))
  637. async def _move_to(self, endpoint):
  638. """Move to center/perimeter with extended timeout.
  639. Backend calls reset_theta() (up to 30s idle check if hard_reset_theta
  640. enabled) then move_polar() + check_idle_async(timeout=60). Use 95s
  641. timeout to accommodate the worst-case scenario.
  642. """
  643. if not self.session:
  644. self.errorOccurred.emit("Backend not ready")
  645. return
  646. try:
  647. logger.debug(f"Calling {endpoint} (95s timeout for idle checks)...")
  648. timeout = aiohttp.ClientTimeout(total=95)
  649. async with self.session.post(f"{self.base_url}{endpoint}", timeout=timeout) as resp:
  650. logger.debug(f"Move response status for {endpoint}: {resp.status}")
  651. if resp.status == 200:
  652. response_data = await resp.json()
  653. logger.info(f"Move command {endpoint} successful: {response_data}")
  654. else:
  655. logger.error(f"Move command {endpoint} failed with status: {resp.status}")
  656. response_text = await resp.text()
  657. self.errorOccurred.emit(f"Move failed: {endpoint} - {resp.status} - {response_text}")
  658. except asyncio.TimeoutError:
  659. logger.warning(f"Move command {endpoint} timed out after 95s")
  660. self.errorOccurred.emit(f"Move command {endpoint} timed out")
  661. except Exception as e:
  662. logger.error(f"Exception in move command {endpoint}: {e}")
  663. self.errorOccurred.emit(str(e))
  664. # Speed Control
  665. @Slot(int)
  666. def setSpeed(self, speed):
  667. logger.debug(f"Setting speed to: {speed}")
  668. asyncio.create_task(self._set_speed(speed))
  669. async def _set_speed(self, speed):
  670. if not self.session:
  671. self.errorOccurred.emit("Backend not ready")
  672. return
  673. try:
  674. async with self.session.post(f"{self.base_url}/set_speed", json={"speed": speed}) as resp:
  675. if resp.status == 200:
  676. logger.info(f"Speed set to {speed}")
  677. self._current_speed = speed
  678. self.speedChanged.emit(speed)
  679. else:
  680. response_text = await resp.text()
  681. logger.error(f"Failed to set speed: {resp.status} - {response_text}")
  682. except Exception as e:
  683. logger.error(f"Exception setting speed: {e}")
  684. self.errorOccurred.emit(str(e))
  685. # Auto Play on Boot Setting
  686. @Slot(bool)
  687. def setAutoPlayOnBoot(self, enabled):
  688. logger.info(f"Setting auto play on boot: {enabled}")
  689. asyncio.create_task(self._set_auto_play_on_boot(enabled))
  690. async def _set_auto_play_on_boot(self, enabled):
  691. if not self.session:
  692. self.errorOccurred.emit("Backend not ready")
  693. return
  694. try:
  695. # Use the kiosk mode API endpoint for auto-play on boot
  696. async with self.session.post(f"{self.base_url}/api/kiosk-mode", json={"enabled": enabled}) as resp:
  697. if resp.status == 200:
  698. logger.info(f"Auto play on boot set to {enabled}")
  699. self._auto_play_on_boot = enabled
  700. else:
  701. response_text = await resp.text()
  702. logger.error(f"Failed to set auto play: {resp.status} - {response_text}")
  703. except Exception as e:
  704. logger.error(f"Exception setting auto play: {e}")
  705. self.errorOccurred.emit(str(e))
  706. # Note: Screen timeout is now managed locally in touch_settings.json
  707. # The main application doesn't have a kiosk-mode endpoint, so we manage this locally
  708. # Load Settings
  709. def _load_local_settings(self):
  710. """Load settings from local JSON file"""
  711. try:
  712. if os.path.exists(self.SETTINGS_FILE):
  713. with open(self.SETTINGS_FILE, 'r') as f:
  714. settings = json.load(f)
  715. screen_timeout = settings.get('screen_timeout', self.DEFAULT_SCREEN_TIMEOUT)
  716. if isinstance(screen_timeout, (int, float)) and screen_timeout >= 0:
  717. self._screen_timeout = int(screen_timeout)
  718. if screen_timeout == 0:
  719. logger.debug("Loaded screen timeout from local settings: Never (0s)")
  720. else:
  721. logger.debug(f"Loaded screen timeout from local settings: {self._screen_timeout}s")
  722. else:
  723. logger.warning(f"Invalid screen timeout in settings, using default: {self.DEFAULT_SCREEN_TIMEOUT}s")
  724. # Load LCD brightness settings
  725. self._lcd_brightness = settings.get('lcd_brightness', 255)
  726. self._lcd_brightness_path = settings.get('lcd_brightness_path', "")
  727. self._lcd_max_brightness = settings.get('lcd_max_brightness', 0)
  728. logger.debug(f"Loaded LCD settings: brightness={self._lcd_brightness}, path='{self._lcd_brightness_path}', max={self._lcd_max_brightness}")
  729. # Load playlist settings
  730. self._pause_between_patterns = settings.get('pause_between_patterns', 10800) # Default 3h
  731. self._playlist_shuffle = settings.get('playlist_shuffle', True)
  732. self._playlist_run_mode = settings.get('playlist_run_mode', "loop")
  733. self._playlist_clear_pattern = settings.get('playlist_clear_pattern', "adaptive")
  734. logger.info(f"Loaded playlist settings: pause={self._pause_between_patterns}s, shuffle={self._playlist_shuffle}, mode={self._playlist_run_mode}, clear={self._playlist_clear_pattern}")
  735. else:
  736. logger.debug("No local settings file found, creating with defaults")
  737. self._save_local_settings()
  738. except Exception as e:
  739. logger.error(f"Error loading local settings: {e}, using defaults")
  740. self._screen_timeout = self.DEFAULT_SCREEN_TIMEOUT
  741. def _save_local_settings(self):
  742. """Save settings to local JSON file"""
  743. try:
  744. settings = {
  745. 'screen_timeout': self._screen_timeout,
  746. 'lcd_brightness': self._lcd_brightness,
  747. 'lcd_brightness_path': self._lcd_brightness_path,
  748. 'lcd_max_brightness': self._lcd_max_brightness,
  749. 'pause_between_patterns': self._pause_between_patterns,
  750. 'playlist_shuffle': self._playlist_shuffle,
  751. 'playlist_run_mode': self._playlist_run_mode,
  752. 'playlist_clear_pattern': self._playlist_clear_pattern,
  753. 'version': '1.2'
  754. }
  755. with open(self.SETTINGS_FILE, 'w') as f:
  756. json.dump(settings, f, indent=2)
  757. logger.debug(f"Saved local settings: screen_timeout={self._screen_timeout}s, playlist settings saved")
  758. except Exception as e:
  759. logger.error(f"Error saving local settings: {e}")
  760. @Slot()
  761. def loadControlSettings(self):
  762. logger.debug("Loading control settings...")
  763. asyncio.create_task(self._load_settings())
  764. async def _load_settings(self):
  765. if not self.session:
  766. logger.warning("Session not ready for loading settings")
  767. return
  768. try:
  769. # Load auto play setting from the working endpoint
  770. timeout = aiohttp.ClientTimeout(total=5) # 5 second timeout
  771. async with self.session.get(f"{self.base_url}/api/auto_play-mode", timeout=timeout) as resp:
  772. if resp.status == 200:
  773. data = await resp.json()
  774. self._auto_play_on_boot = data.get("enabled", False)
  775. logger.info(f"Loaded auto play setting: {self._auto_play_on_boot}")
  776. # Note: Screen timeout is managed locally, not from server
  777. # Serial status will be handled by WebSocket updates automatically
  778. # But we still load the initial port info if connected
  779. async with self.session.get(f"{self.base_url}/serial_status", timeout=timeout) as resp:
  780. if resp.status == 200:
  781. data = await resp.json()
  782. initial_connected = data.get("connected", False)
  783. current_port = data.get("port", "")
  784. logger.info(f"Initial serial status: connected={initial_connected}, port={current_port}")
  785. # Only update if WebSocket hasn't already set this
  786. if initial_connected and current_port and not self._current_port:
  787. self._current_port = current_port
  788. self.currentPortChanged.emit(current_port)
  789. # Set initial connection status (WebSocket will take over from here)
  790. if self._serial_connected != initial_connected:
  791. self._serial_connected = initial_connected
  792. self.serialConnectionChanged.emit(initial_connected)
  793. logger.info("Settings loaded - WebSocket will handle real-time updates")
  794. self.settingsLoaded.emit()
  795. except aiohttp.ClientConnectorError as e:
  796. logger.warning(f"Cannot connect to backend at {self.base_url}: {e}")
  797. # Don't emit error - this is expected when backend is down
  798. # WebSocket will handle reconnection
  799. except asyncio.TimeoutError:
  800. logger.warning(f"Timeout loading settings from {self.base_url}")
  801. # Don't emit error - expected when backend is slow/down
  802. except Exception as e:
  803. logger.error(f"Unexpected error loading settings: {e}")
  804. # Only emit error for unexpected issues
  805. if "ssl" not in str(e).lower():
  806. self.errorOccurred.emit(str(e))
  807. # Screen Management Properties
  808. @Property(bool, notify=screenStateChanged)
  809. def screenOn(self):
  810. return self._screen_on
  811. @Property(int, notify=screenTimeoutChanged)
  812. def screenTimeout(self):
  813. return self._screen_timeout
  814. @screenTimeout.setter
  815. def setScreenTimeout(self, timeout):
  816. if self._screen_timeout != timeout:
  817. old_timeout = self._screen_timeout
  818. self._screen_timeout = timeout
  819. logger.debug(f"Screen timeout changed from {old_timeout}s to {timeout}s")
  820. # Save to local settings
  821. self._save_local_settings()
  822. # Emit change signal for QML
  823. self.screenTimeoutChanged.emit(timeout)
  824. @Slot(result='QStringList')
  825. def getScreenTimeoutOptions(self):
  826. """Get list of screen timeout options for QML"""
  827. return list(self.TIMEOUT_OPTIONS.keys())
  828. @Slot(result=str)
  829. def getCurrentScreenTimeoutOption(self):
  830. """Get current screen timeout as option string"""
  831. current_timeout = self._screen_timeout
  832. for option, value in self.TIMEOUT_OPTIONS.items():
  833. if value == current_timeout:
  834. return option
  835. # If custom value, return closest match or custom description
  836. if current_timeout == 0:
  837. return "Never"
  838. elif current_timeout < 60:
  839. return f"{current_timeout} seconds"
  840. elif current_timeout < 3600:
  841. minutes = current_timeout // 60
  842. return f"{minutes} minute{'s' if minutes != 1 else ''}"
  843. else:
  844. hours = current_timeout // 3600
  845. return f"{hours} hour{'s' if hours != 1 else ''}"
  846. @Slot(str)
  847. def setScreenTimeoutByOption(self, option):
  848. """Set screen timeout by option string"""
  849. if option in self.TIMEOUT_OPTIONS:
  850. timeout_value = self.TIMEOUT_OPTIONS[option]
  851. # Don't call the setter method, just assign to trigger the property setter
  852. if self._screen_timeout != timeout_value:
  853. old_timeout = self._screen_timeout
  854. self._screen_timeout = timeout_value
  855. logger.debug(f"Screen timeout changed from {old_timeout}s to {timeout_value}s ({option})")
  856. # Save to local settings
  857. self._save_local_settings()
  858. # Emit change signal for QML
  859. self.screenTimeoutChanged.emit(timeout_value)
  860. else:
  861. logger.warning(f"Unknown timeout option: {option}")
  862. @Slot(result='QStringList')
  863. def getSpeedOptions(self):
  864. """Get list of speed options for QML"""
  865. return list(self.SPEED_OPTIONS.keys())
  866. @Slot(result=str)
  867. def getCurrentSpeedOption(self):
  868. """Get current speed as option string"""
  869. current_speed = self._current_speed
  870. for option, value in self.SPEED_OPTIONS.items():
  871. if value == current_speed:
  872. return option
  873. # If custom value, return as string
  874. return str(current_speed)
  875. @Slot(str)
  876. def setSpeedByOption(self, option):
  877. """Set speed by option string"""
  878. if option in self.SPEED_OPTIONS:
  879. speed_value = self.SPEED_OPTIONS[option]
  880. # Don't call setter method, just assign directly
  881. if self._current_speed != speed_value:
  882. old_speed = self._current_speed
  883. self._current_speed = speed_value
  884. logger.debug(f"Speed changed from {old_speed} to {speed_value} ({option})")
  885. # Send to main application
  886. asyncio.create_task(self._set_speed_async(speed_value))
  887. # Emit change signal for QML
  888. self.speedChanged.emit(speed_value)
  889. else:
  890. logger.warning(f"Unknown speed option: {option}")
  891. async def _set_speed_async(self, speed):
  892. """Send speed to main application asynchronously"""
  893. if not self.session:
  894. return
  895. try:
  896. async with self.session.post(f"{self.base_url}/set_speed", json={"speed": speed}) as resp:
  897. if resp.status == 200:
  898. logger.info(f"Speed set successfully: {speed}")
  899. else:
  900. logger.error(f"Failed to set speed: {resp.status}")
  901. except Exception as e:
  902. logger.error(f"Exception setting speed: {e}")
  903. # Pause Between Patterns Methods
  904. @Slot(result='QStringList')
  905. def getPauseOptions(self):
  906. """Get list of pause between patterns options for QML"""
  907. return list(self.PAUSE_OPTIONS.keys())
  908. @Slot(result=str)
  909. def getCurrentPauseOption(self):
  910. """Get current pause between patterns as option string"""
  911. current_pause = self._pause_between_patterns
  912. for option, value in self.PAUSE_OPTIONS.items():
  913. if value == current_pause:
  914. return option
  915. # If custom value, return descriptive string
  916. if current_pause == 0:
  917. return "0s"
  918. elif current_pause < 60:
  919. return f"{current_pause}s"
  920. elif current_pause < 3600:
  921. minutes = current_pause // 60
  922. return f"{minutes} min"
  923. else:
  924. hours = current_pause // 3600
  925. return f"{hours} hour"
  926. @Slot(str)
  927. def setPauseByOption(self, option):
  928. """Set pause between patterns by option string"""
  929. if option in self.PAUSE_OPTIONS:
  930. pause_value = self.PAUSE_OPTIONS[option]
  931. if self._pause_between_patterns != pause_value:
  932. old_pause = self._pause_between_patterns
  933. self._pause_between_patterns = pause_value
  934. logger.info(f"Pause between patterns changed from {old_pause}s to {pause_value}s ({option})")
  935. # Save to local settings
  936. self._save_local_settings()
  937. # Emit change signal for QML
  938. self.pauseBetweenPatternsChanged.emit(pause_value)
  939. else:
  940. logger.warning(f"Unknown pause option: {option}")
  941. # Property for pause between patterns
  942. @Property(int, notify=pauseBetweenPatternsChanged)
  943. def pauseBetweenPatterns(self):
  944. """Get current pause between patterns in seconds"""
  945. return self._pause_between_patterns
  946. # Playlist Settings Properties and Slots
  947. @Property(bool, notify=playlistSettingsChanged)
  948. def playlistShuffle(self):
  949. """Get playlist shuffle setting"""
  950. return self._playlist_shuffle
  951. @Slot(bool)
  952. def setPlaylistShuffle(self, enabled):
  953. """Set playlist shuffle setting"""
  954. if self._playlist_shuffle != enabled:
  955. self._playlist_shuffle = enabled
  956. logger.info(f"Playlist shuffle changed to: {enabled}")
  957. self._save_local_settings()
  958. self.playlistSettingsChanged.emit()
  959. @Property(str, notify=playlistSettingsChanged)
  960. def playlistRunMode(self):
  961. """Get playlist run mode (single/loop)"""
  962. return self._playlist_run_mode
  963. @Slot(str)
  964. def setPlaylistRunMode(self, mode):
  965. """Set playlist run mode"""
  966. if mode in ["single", "loop"] and self._playlist_run_mode != mode:
  967. self._playlist_run_mode = mode
  968. logger.info(f"Playlist run mode changed to: {mode}")
  969. self._save_local_settings()
  970. self.playlistSettingsChanged.emit()
  971. @Property(str, notify=playlistSettingsChanged)
  972. def playlistClearPattern(self):
  973. """Get playlist clear pattern setting"""
  974. return self._playlist_clear_pattern
  975. @Slot(str)
  976. def setPlaylistClearPattern(self, pattern):
  977. """Set playlist clear pattern"""
  978. valid_patterns = ["adaptive", "clear_center", "clear_perimeter", "none"]
  979. if pattern in valid_patterns and self._playlist_clear_pattern != pattern:
  980. self._playlist_clear_pattern = pattern
  981. logger.info(f"Playlist clear pattern changed to: {pattern}")
  982. self._save_local_settings()
  983. self.playlistSettingsChanged.emit()
  984. # Screen Control Methods
  985. @Slot()
  986. def turnScreenOn(self):
  987. """Turn the screen on and reset activity timer"""
  988. if not self._screen_on:
  989. self._turn_screen_on()
  990. self._reset_activity_timer()
  991. @Slot()
  992. def turnScreenOff(self):
  993. """Turn the screen off (QML MouseArea handles wake-on-touch)"""
  994. self._turn_screen_off()
  995. @Slot()
  996. def resetActivityTimer(self):
  997. """Reset the activity timer (call on user interaction)"""
  998. self._reset_activity_timer()
  999. if not self._screen_on:
  1000. self._turn_screen_on()
  1001. def _turn_screen_on(self):
  1002. """Internal method to turn screen on"""
  1003. with self._screen_transition_lock:
  1004. # Debounce: Don't turn on if we just changed state
  1005. time_since_change = time.time() - self._last_screen_change
  1006. if time_since_change < 2.0: # 2 second debounce
  1007. logger.debug(f"Screen state change blocked (debounce: {time_since_change:.1f}s < 2s)")
  1008. return
  1009. if self._screen_on:
  1010. logger.debug("Screen already ON, skipping")
  1011. return
  1012. try:
  1013. # Determine the brightness to restore (saved value, not max)
  1014. restore_brightness = self._lcd_brightness if self._lcd_brightness > 0 else self._lcd_max_brightness or 255
  1015. # Use the working screen-on script if available
  1016. screen_on_script = Path('/usr/local/bin/screen-on')
  1017. if screen_on_script.exists():
  1018. result = subprocess.run(['sudo', '/usr/local/bin/screen-on'],
  1019. capture_output=True, text=True, timeout=5)
  1020. if result.returncode == 0:
  1021. logger.debug("Screen turned ON (screen-on script)")
  1022. else:
  1023. logger.warning(f"screen-on script failed: {result.stderr}")
  1024. # Restore saved brightness (script sets max, we want saved level)
  1025. if self._lcd_brightness_path and restore_brightness < (self._lcd_max_brightness or 255):
  1026. subprocess.run(
  1027. ['sudo', 'sh', '-c', f'echo {restore_brightness} > {self._lcd_brightness_path}'],
  1028. check=False, timeout=5
  1029. )
  1030. logger.debug(f"Restored saved brightness: {restore_brightness}")
  1031. else:
  1032. # Fallback: Manual control — unblank framebuffer and restore backlight
  1033. if self._lcd_brightness_path:
  1034. subprocess.run(['sudo', 'sh', '-c',
  1035. f'echo 0 > /sys/class/graphics/fb0/blank && echo {restore_brightness} > {self._lcd_brightness_path}'],
  1036. check=False, timeout=5)
  1037. else:
  1038. subprocess.run(['sudo', 'sh', '-c',
  1039. f'echo 0 > /sys/class/graphics/fb0/blank && echo {restore_brightness} > /sys/class/backlight/*/brightness'],
  1040. check=False, timeout=5)
  1041. logger.debug(f"Screen turned ON (manual, brightness: {restore_brightness})")
  1042. self._screen_on = True
  1043. self._last_screen_change = time.time()
  1044. self.screenStateChanged.emit(True)
  1045. except Exception as e:
  1046. logger.error(f"Failed to turn screen on: {e}")
  1047. def _turn_screen_off(self):
  1048. """Internal method to turn screen off"""
  1049. logger.debug("_turn_screen_off() called")
  1050. with self._screen_transition_lock:
  1051. # Debounce: Don't turn off if we just changed state
  1052. time_since_change = time.time() - self._last_screen_change
  1053. if time_since_change < 2.0: # 2 second debounce
  1054. logger.debug(f"Screen state change blocked (debounce: {time_since_change:.1f}s < 2s)")
  1055. return
  1056. if not self._screen_on:
  1057. logger.debug("Screen already OFF, skipping")
  1058. return
  1059. try:
  1060. # Use the working screen-off script if available
  1061. screen_off_script = Path('/usr/local/bin/screen-off')
  1062. logger.debug(f"Checking for screen-off script at: {screen_off_script}")
  1063. logger.debug(f"Script exists: {screen_off_script.exists()}")
  1064. if screen_off_script.exists():
  1065. logger.debug("Executing screen-off script...")
  1066. result = subprocess.run(['sudo', '/usr/local/bin/screen-off'],
  1067. capture_output=True, text=True, timeout=10)
  1068. logger.debug(f"Script return code: {result.returncode}")
  1069. if result.stdout:
  1070. logger.debug(f"Script stdout: {result.stdout}")
  1071. if result.stderr:
  1072. logger.debug(f"Script stderr: {result.stderr}")
  1073. if result.returncode == 0:
  1074. logger.info("Screen turned OFF (screen-off script)")
  1075. else:
  1076. logger.warning(f"screen-off script failed: return code {result.returncode}")
  1077. else:
  1078. logger.debug("Using manual screen control...")
  1079. # Fallback: Manual control matching the script
  1080. # Blank framebuffer and turn off backlight
  1081. subprocess.run(['sudo', 'sh', '-c',
  1082. 'echo 0 > /sys/class/backlight/*/brightness && echo 1 > /sys/class/graphics/fb0/blank'],
  1083. check=False, timeout=5)
  1084. logger.debug("Screen turned OFF (manual)")
  1085. self._screen_on = False
  1086. self._last_screen_change = time.time()
  1087. self.screenStateChanged.emit(False)
  1088. logger.debug("Screen state set to OFF, signal emitted")
  1089. except Exception as e:
  1090. logger.error(f"Failed to turn screen off: {e}")
  1091. import traceback
  1092. traceback.print_exc()
  1093. def _reset_activity_timer(self):
  1094. """Reset the last activity timestamp"""
  1095. old_time = self._last_activity
  1096. self._last_activity = time.time()
  1097. time_since_last = self._last_activity - old_time
  1098. if time_since_last > 1: # Only log if it's been more than 1 second
  1099. logger.debug(f"Activity detected - timer reset (was idle for {time_since_last:.1f}s)")
  1100. def _check_screen_timeout(self):
  1101. """Check if screen should be turned off due to inactivity.
  1102. Wake-on-touch is handled by QML's global MouseArea which calls
  1103. resetActivityTimer() on any touch event, even when screen is off.
  1104. """
  1105. if self._screen_on and self._screen_timeout > 0: # Only check if timeout is enabled
  1106. idle_time = time.time() - self._last_activity
  1107. # Log every 10 seconds when getting close to timeout
  1108. if idle_time > self._screen_timeout - 10 and idle_time % 10 < 1:
  1109. logger.debug(f"Screen idle for {idle_time:.0f}s (timeout at {self._screen_timeout}s)")
  1110. if idle_time > self._screen_timeout:
  1111. logger.debug(f"Screen timeout reached! Idle for {idle_time:.0f}s (timeout: {self._screen_timeout}s)")
  1112. self._turn_screen_off()
  1113. # If timeout is 0 (Never), screen stays on indefinitely
  1114. # ==================== LED Control Methods ====================
  1115. # LED Properties
  1116. @Property(str, notify=ledStatusChanged)
  1117. def ledProvider(self):
  1118. return self._led_provider
  1119. @Property(bool, notify=ledStatusChanged)
  1120. def ledConnected(self):
  1121. return self._led_connected
  1122. @Property(bool, notify=ledStatusChanged)
  1123. def ledPowerOn(self):
  1124. return self._led_power_on
  1125. @Property(int, notify=ledStatusChanged)
  1126. def ledBrightness(self):
  1127. return self._led_brightness
  1128. @Property(list, notify=ledEffectsLoaded)
  1129. def ledEffects(self):
  1130. return self._led_effects
  1131. @Property(list, notify=ledPalettesLoaded)
  1132. def ledPalettes(self):
  1133. return self._led_palettes
  1134. @Property(int, notify=ledStatusChanged)
  1135. def ledCurrentEffect(self):
  1136. return self._led_current_effect
  1137. @Property(int, notify=ledStatusChanged)
  1138. def ledCurrentPalette(self):
  1139. return self._led_current_palette
  1140. @Property(str, notify=ledStatusChanged)
  1141. def ledColor(self):
  1142. return self._led_color
  1143. # ==================== LCD Brightness Methods ====================
  1144. def _detect_backlight(self):
  1145. """Auto-detect the sysfs backlight path and max brightness on startup."""
  1146. # Auto-detect path if not configured
  1147. if not self._lcd_brightness_path:
  1148. try:
  1149. result = subprocess.run(
  1150. ['ls', '/sys/class/backlight'],
  1151. capture_output=True, text=True, timeout=2
  1152. )
  1153. if result.returncode == 0 and result.stdout.strip():
  1154. device = result.stdout.strip().split('\n')[0]
  1155. self._lcd_brightness_path = f"/sys/class/backlight/{device}/brightness"
  1156. logger.info(f"Auto-detected backlight path: {self._lcd_brightness_path}")
  1157. else:
  1158. logger.warning("No backlight device found in /sys/class/backlight/")
  1159. return
  1160. except Exception as e:
  1161. logger.warning(f"Failed to detect backlight path: {e}")
  1162. return
  1163. # Derive the directory from the brightness path
  1164. backlight_dir = str(Path(self._lcd_brightness_path).parent)
  1165. # Read max_brightness if not configured (0 = auto-detect)
  1166. if self._lcd_max_brightness == 0:
  1167. try:
  1168. max_path = f"{backlight_dir}/max_brightness"
  1169. result = subprocess.run(
  1170. ['cat', max_path],
  1171. capture_output=True, text=True, timeout=2
  1172. )
  1173. if result.returncode == 0 and result.stdout.strip():
  1174. self._lcd_max_brightness = int(result.stdout.strip())
  1175. logger.info(f"Auto-detected max brightness: {self._lcd_max_brightness}")
  1176. else:
  1177. self._lcd_max_brightness = 255 # Sensible fallback
  1178. logger.warning(f"Could not read max_brightness, defaulting to 255")
  1179. except Exception as e:
  1180. self._lcd_max_brightness = 255
  1181. logger.warning(f"Failed to read max_brightness: {e}, defaulting to 255")
  1182. # Read current brightness from sysfs
  1183. try:
  1184. result = subprocess.run(
  1185. ['cat', self._lcd_brightness_path],
  1186. capture_output=True, text=True, timeout=2
  1187. )
  1188. if result.returncode == 0 and result.stdout.strip():
  1189. current = int(result.stdout.strip())
  1190. self._lcd_brightness = current
  1191. logger.info(f"Current LCD brightness: {current}/{self._lcd_max_brightness}")
  1192. except Exception as e:
  1193. logger.warning(f"Failed to read current brightness: {e}")
  1194. @Property(int, notify=lcdBrightnessChanged)
  1195. def lcdBrightness(self):
  1196. return self._lcd_brightness
  1197. @Property(int, notify=lcdBrightnessChanged)
  1198. def lcdMaxBrightness(self):
  1199. return self._lcd_max_brightness
  1200. @Slot(int)
  1201. def setLcdBrightness(self, value):
  1202. """Set LCD screen brightness by writing to sysfs backlight."""
  1203. value = max(0, min(value, self._lcd_max_brightness))
  1204. if not self._lcd_brightness_path:
  1205. logger.warning("No backlight path configured, cannot set brightness")
  1206. return
  1207. try:
  1208. subprocess.run(
  1209. ['sudo', 'sh', '-c', f'echo {value} > {self._lcd_brightness_path}'],
  1210. check=False, timeout=5
  1211. )
  1212. self._lcd_brightness = value
  1213. logger.debug(f"LCD brightness set to {value}/{self._lcd_max_brightness}")
  1214. self._save_local_settings()
  1215. self.lcdBrightnessChanged.emit()
  1216. except Exception as e:
  1217. logger.error(f"Failed to set LCD brightness: {e}")
  1218. @Slot()
  1219. def loadLedConfig(self):
  1220. """Load LED configuration from the server"""
  1221. logger.debug("Loading LED configuration...")
  1222. asyncio.create_task(self._load_led_config())
  1223. async def _load_led_config(self):
  1224. if not self.session:
  1225. logger.warning("Session not ready for LED config")
  1226. return
  1227. try:
  1228. timeout = aiohttp.ClientTimeout(total=5)
  1229. async with self.session.get(f"{self.base_url}/get_led_config", timeout=timeout) as resp:
  1230. if resp.status == 200:
  1231. data = await resp.json()
  1232. self._led_provider = data.get("provider", "none")
  1233. logger.debug(f"LED provider: {self._led_provider}")
  1234. if self._led_provider == "dw_leds":
  1235. # Load DW LEDs status
  1236. await self._load_led_status()
  1237. await self._load_led_effects()
  1238. await self._load_led_palettes()
  1239. self.ledStatusChanged.emit()
  1240. else:
  1241. logger.error(f"Failed to get LED config: {resp.status}")
  1242. except Exception as e:
  1243. logger.error(f"Exception loading LED config: {e}")
  1244. async def _load_led_status(self):
  1245. """Load current LED status"""
  1246. if not self.session:
  1247. return
  1248. try:
  1249. timeout = aiohttp.ClientTimeout(total=5)
  1250. async with self.session.get(f"{self.base_url}/api/dw_leds/status", timeout=timeout) as resp:
  1251. if resp.status == 200:
  1252. data = await resp.json()
  1253. self._led_connected = data.get("connected", False)
  1254. self._led_power_on = data.get("power_on", False)
  1255. self._led_brightness = data.get("brightness", 100)
  1256. self._led_current_effect = data.get("current_effect", 0)
  1257. self._led_current_palette = data.get("current_palette", 0)
  1258. logger.debug(f"LED status: connected={self._led_connected}, power={self._led_power_on}, brightness={self._led_brightness}")
  1259. self.ledStatusChanged.emit()
  1260. except Exception as e:
  1261. logger.error(f"Exception loading LED status: {e}")
  1262. async def _load_led_effects(self):
  1263. """Load available LED effects"""
  1264. if not self.session:
  1265. return
  1266. try:
  1267. timeout = aiohttp.ClientTimeout(total=5)
  1268. async with self.session.get(f"{self.base_url}/api/dw_leds/effects", timeout=timeout) as resp:
  1269. if resp.status == 200:
  1270. data = await resp.json()
  1271. # API returns effects as [[id, name], ...] arrays
  1272. raw_effects = data.get("effects", [])
  1273. # Convert to list of dicts for easier use in QML
  1274. self._led_effects = [{"id": e[0], "name": e[1]} for e in raw_effects if len(e) >= 2]
  1275. logger.debug(f"Loaded {len(self._led_effects)} LED effects")
  1276. self.ledEffectsLoaded.emit(self._led_effects)
  1277. except Exception as e:
  1278. logger.error(f"Exception loading LED effects: {e}")
  1279. async def _load_led_palettes(self):
  1280. """Load available LED palettes"""
  1281. if not self.session:
  1282. return
  1283. try:
  1284. timeout = aiohttp.ClientTimeout(total=5)
  1285. async with self.session.get(f"{self.base_url}/api/dw_leds/palettes", timeout=timeout) as resp:
  1286. if resp.status == 200:
  1287. data = await resp.json()
  1288. # API returns palettes as [[id, name], ...] arrays
  1289. raw_palettes = data.get("palettes", [])
  1290. # Convert to list of dicts for easier use in QML
  1291. self._led_palettes = [{"id": p[0], "name": p[1]} for p in raw_palettes if len(p) >= 2]
  1292. logger.debug(f"Loaded {len(self._led_palettes)} LED palettes")
  1293. self.ledPalettesLoaded.emit(self._led_palettes)
  1294. except Exception as e:
  1295. logger.error(f"Exception loading LED palettes: {e}")
  1296. @Slot()
  1297. def refreshLedStatus(self):
  1298. """Refresh LED status from server"""
  1299. logger.debug("Refreshing LED status...")
  1300. asyncio.create_task(self._load_led_status())
  1301. @Slot()
  1302. def toggleLedPower(self):
  1303. """Toggle LED power on/off"""
  1304. logger.debug("Toggling LED power...")
  1305. asyncio.create_task(self._toggle_led_power())
  1306. async def _toggle_led_power(self):
  1307. if not self.session:
  1308. self.errorOccurred.emit("Backend not ready")
  1309. return
  1310. try:
  1311. async with self.session.post(
  1312. f"{self.base_url}/api/dw_leds/power",
  1313. json={"state": 2} # Toggle
  1314. ) as resp:
  1315. if resp.status == 200:
  1316. data = await resp.json()
  1317. self._led_power_on = data.get("power_on", False)
  1318. self._led_connected = data.get("connected", False)
  1319. logger.debug(f"LED power toggled: {self._led_power_on}")
  1320. self.ledStatusChanged.emit()
  1321. else:
  1322. self.errorOccurred.emit(f"Failed to toggle LED power: {resp.status}")
  1323. except Exception as e:
  1324. logger.error(f"Exception toggling LED power: {e}")
  1325. self.errorOccurred.emit(str(e))
  1326. @Slot(bool)
  1327. def setLedPower(self, on):
  1328. """Set LED power state (True=on, False=off)"""
  1329. logger.debug(f"Setting LED power: {on}")
  1330. asyncio.create_task(self._set_led_power(on))
  1331. async def _set_led_power(self, on):
  1332. if not self.session:
  1333. self.errorOccurred.emit("Backend not ready")
  1334. return
  1335. try:
  1336. async with self.session.post(
  1337. f"{self.base_url}/api/dw_leds/power",
  1338. json={"state": 1 if on else 0}
  1339. ) as resp:
  1340. if resp.status == 200:
  1341. data = await resp.json()
  1342. self._led_power_on = data.get("power_on", False)
  1343. self._led_connected = data.get("connected", False)
  1344. logger.debug(f"LED power set: {self._led_power_on}")
  1345. self.ledStatusChanged.emit()
  1346. else:
  1347. self.errorOccurred.emit(f"Failed to set LED power: {resp.status}")
  1348. except Exception as e:
  1349. logger.error(f"Exception setting LED power: {e}")
  1350. self.errorOccurred.emit(str(e))
  1351. @Slot(int)
  1352. def setLedBrightness(self, value):
  1353. """Set LED brightness (0-100)"""
  1354. logger.debug(f"Setting LED brightness: {value}")
  1355. asyncio.create_task(self._set_led_brightness(value))
  1356. async def _set_led_brightness(self, value):
  1357. if not self.session:
  1358. self.errorOccurred.emit("Backend not ready")
  1359. return
  1360. try:
  1361. async with self.session.post(
  1362. f"{self.base_url}/api/dw_leds/brightness",
  1363. json={"value": value}
  1364. ) as resp:
  1365. if resp.status == 200:
  1366. self._led_brightness = value
  1367. logger.debug(f"LED brightness set: {value}")
  1368. self.ledStatusChanged.emit()
  1369. else:
  1370. self.errorOccurred.emit(f"Failed to set brightness: {resp.status}")
  1371. except Exception as e:
  1372. logger.error(f"Exception setting LED brightness: {e}")
  1373. self.errorOccurred.emit(str(e))
  1374. @Slot(int, int, int)
  1375. def setLedColor(self, r, g, b):
  1376. """Set LED color using RGB values"""
  1377. logger.debug(f"Setting LED color: RGB({r}, {g}, {b})")
  1378. asyncio.create_task(self._set_led_color(r, g, b))
  1379. async def _set_led_color(self, r, g, b):
  1380. if not self.session:
  1381. self.errorOccurred.emit("Backend not ready")
  1382. return
  1383. try:
  1384. async with self.session.post(
  1385. f"{self.base_url}/api/dw_leds/color",
  1386. json={"color": [r, g, b]}
  1387. ) as resp:
  1388. if resp.status == 200:
  1389. self._led_color = f"#{r:02x}{g:02x}{b:02x}"
  1390. logger.debug(f"LED color set: {self._led_color}")
  1391. self.ledStatusChanged.emit()
  1392. else:
  1393. self.errorOccurred.emit(f"Failed to set color: {resp.status}")
  1394. except Exception as e:
  1395. logger.error(f"Exception setting LED color: {e}")
  1396. self.errorOccurred.emit(str(e))
  1397. @Slot(str)
  1398. def setLedColorHex(self, hexColor):
  1399. """Set LED color using hex string (e.g., '#ff0000')"""
  1400. # Parse hex color
  1401. hexColor = hexColor.lstrip('#')
  1402. if len(hexColor) == 6:
  1403. r = int(hexColor[0:2], 16)
  1404. g = int(hexColor[2:4], 16)
  1405. b = int(hexColor[4:6], 16)
  1406. self.setLedColor(r, g, b)
  1407. else:
  1408. logger.warning(f"Invalid hex color: {hexColor}")
  1409. @Slot(int)
  1410. def setLedEffect(self, effectId):
  1411. """Set LED effect by ID"""
  1412. logger.debug(f"Setting LED effect: {effectId}")
  1413. asyncio.create_task(self._set_led_effect(effectId))
  1414. async def _set_led_effect(self, effectId):
  1415. if not self.session:
  1416. self.errorOccurred.emit("Backend not ready")
  1417. return
  1418. try:
  1419. async with self.session.post(
  1420. f"{self.base_url}/api/dw_leds/effect",
  1421. json={"effect_id": effectId}
  1422. ) as resp:
  1423. if resp.status == 200:
  1424. self._led_current_effect = effectId
  1425. logger.debug(f"LED effect set: {effectId}")
  1426. self.ledStatusChanged.emit()
  1427. else:
  1428. self.errorOccurred.emit(f"Failed to set effect: {resp.status}")
  1429. except Exception as e:
  1430. logger.error(f"Exception setting LED effect: {e}")
  1431. self.errorOccurred.emit(str(e))
  1432. @Slot(int)
  1433. def setLedPalette(self, paletteId):
  1434. """Set LED palette by ID"""
  1435. logger.debug(f"Setting LED palette: {paletteId}")
  1436. asyncio.create_task(self._set_led_palette(paletteId))
  1437. async def _set_led_palette(self, paletteId):
  1438. if not self.session:
  1439. self.errorOccurred.emit("Backend not ready")
  1440. return
  1441. try:
  1442. async with self.session.post(
  1443. f"{self.base_url}/api/dw_leds/palette",
  1444. json={"palette_id": paletteId}
  1445. ) as resp:
  1446. if resp.status == 200:
  1447. self._led_current_palette = paletteId
  1448. logger.debug(f"LED palette set: {paletteId}")
  1449. self.ledStatusChanged.emit()
  1450. else:
  1451. self.errorOccurred.emit(f"Failed to set palette: {resp.status}")
  1452. except Exception as e:
  1453. logger.error(f"Exception setting LED palette: {e}")
  1454. self.errorOccurred.emit(str(e))
  1455. # ==================== Pattern Refresh Methods ====================
  1456. @Slot()
  1457. def refreshPatterns(self):
  1458. """Refresh pattern cache - converts new WebPs to PNG and rescans patterns"""
  1459. logger.debug("Refreshing patterns...")
  1460. asyncio.create_task(self._refresh_patterns())
  1461. async def _refresh_patterns(self):
  1462. """Async implementation of pattern refresh"""
  1463. try:
  1464. from png_cache_manager import PngCacheManager
  1465. cache_manager = PngCacheManager()
  1466. success = await cache_manager.ensure_png_cache_available()
  1467. message = "Patterns refreshed" if success else "Refreshed with warnings"
  1468. logger.info(f"Pattern refresh completed: {message}")
  1469. self.patternsRefreshCompleted.emit(True, message)
  1470. except Exception as e:
  1471. logger.error(f"Pattern refresh failed: {e}")
  1472. self.patternsRefreshCompleted.emit(False, str(e))
  1473. # ==================== System Control Methods ====================
  1474. @Slot()
  1475. def restartBackend(self):
  1476. """Restart the dune-weaver backend via API"""
  1477. logger.debug("Requesting backend restart via API...")
  1478. asyncio.create_task(self._restart_backend())
  1479. async def _restart_backend(self):
  1480. """Async implementation of backend restart"""
  1481. if not self.session:
  1482. self.errorOccurred.emit("Backend not ready")
  1483. return
  1484. try:
  1485. async with self.session.post(f"{self.base_url}/api/system/restart") as resp:
  1486. if resp.status == 200:
  1487. logger.info("Backend restart initiated via API")
  1488. else:
  1489. response_text = await resp.text()
  1490. logger.error(f"Failed to restart backend: {resp.status} - {response_text}")
  1491. self.errorOccurred.emit(f"Failed to restart: {response_text}")
  1492. except Exception as e:
  1493. logger.error(f"Exception restarting backend: {e}")
  1494. self.errorOccurred.emit(str(e))
  1495. @Slot()
  1496. def shutdownPi(self):
  1497. """Shutdown the Raspberry Pi via API"""
  1498. logger.info("Requesting Pi shutdown via API...")
  1499. asyncio.create_task(self._shutdown_pi())
  1500. async def _shutdown_pi(self):
  1501. """Async implementation of Pi shutdown"""
  1502. if not self.session:
  1503. self.errorOccurred.emit("Backend not ready")
  1504. return
  1505. try:
  1506. async with self.session.post(f"{self.base_url}/api/system/shutdown") as resp:
  1507. if resp.status == 200:
  1508. logger.info("Shutdown initiated via API")
  1509. else:
  1510. response_text = await resp.text()
  1511. logger.error(f"Failed to shutdown: {resp.status} - {response_text}")
  1512. self.errorOccurred.emit(f"Failed to shutdown: {response_text}")
  1513. except Exception as e:
  1514. logger.error(f"Exception during shutdown: {e}")
  1515. self.errorOccurred.emit(str(e))
  1516. # ==================== Playlist Management Methods ====================
  1517. @Slot(str)
  1518. def createPlaylist(self, playlistName):
  1519. """Create a new empty playlist"""
  1520. logger.debug(f"Creating playlist: {playlistName}")
  1521. asyncio.create_task(self._create_playlist(playlistName))
  1522. async def _create_playlist(self, playlistName):
  1523. """Async implementation of playlist creation"""
  1524. if not self.session:
  1525. self.playlistCreated.emit(False, "Backend not ready")
  1526. return
  1527. try:
  1528. async with self.session.post(
  1529. f"{self.base_url}/create_playlist",
  1530. json={"playlist_name": playlistName, "files": []}
  1531. ) as resp:
  1532. if resp.status == 200:
  1533. logger.info(f"Playlist created: {playlistName}")
  1534. self.playlistCreated.emit(True, f"Created: {playlistName}")
  1535. else:
  1536. response_text = await resp.text()
  1537. logger.error(f"Failed to create playlist: {resp.status} - {response_text}")
  1538. self.playlistCreated.emit(False, f"Failed: {response_text}")
  1539. except Exception as e:
  1540. logger.error(f"Exception creating playlist: {e}")
  1541. self.playlistCreated.emit(False, str(e))
  1542. @Slot(str)
  1543. def deletePlaylist(self, playlistName):
  1544. """Delete a playlist"""
  1545. logger.info(f"Deleting playlist: {playlistName}")
  1546. asyncio.create_task(self._delete_playlist(playlistName))
  1547. async def _delete_playlist(self, playlistName):
  1548. """Async implementation of playlist deletion"""
  1549. if not self.session:
  1550. self.playlistDeleted.emit(False, "Backend not ready")
  1551. return
  1552. try:
  1553. async with self.session.request(
  1554. "DELETE",
  1555. f"{self.base_url}/delete_playlist",
  1556. json={"playlist_name": playlistName}
  1557. ) as resp:
  1558. if resp.status == 200:
  1559. logger.info(f"Playlist deleted: {playlistName}")
  1560. self.playlistDeleted.emit(True, f"Deleted: {playlistName}")
  1561. else:
  1562. response_text = await resp.text()
  1563. logger.error(f"Failed to delete playlist: {resp.status} - {response_text}")
  1564. self.playlistDeleted.emit(False, f"Failed: {response_text}")
  1565. except Exception as e:
  1566. logger.error(f"Exception deleting playlist: {e}")
  1567. self.playlistDeleted.emit(False, str(e))
  1568. @Slot(str, str)
  1569. def addPatternToPlaylist(self, playlistName, patternPath):
  1570. """Add a pattern to an existing playlist"""
  1571. logger.info(f"Adding pattern to playlist: {patternPath} -> {playlistName}")
  1572. asyncio.create_task(self._add_pattern_to_playlist(playlistName, patternPath))
  1573. async def _add_pattern_to_playlist(self, playlistName, patternPath):
  1574. """Async implementation of adding pattern to playlist"""
  1575. if not self.session:
  1576. self.patternAddedToPlaylist.emit(False, "Backend not ready")
  1577. return
  1578. try:
  1579. async with self.session.post(
  1580. f"{self.base_url}/add_to_playlist",
  1581. json={"playlist_name": playlistName, "pattern": patternPath}
  1582. ) as resp:
  1583. if resp.status == 200:
  1584. logger.info(f"Pattern added to {playlistName}")
  1585. self.patternAddedToPlaylist.emit(True, f"Added to {playlistName}")
  1586. else:
  1587. response_text = await resp.text()
  1588. logger.error(f"Failed to add pattern: {resp.status} - {response_text}")
  1589. self.patternAddedToPlaylist.emit(False, f"Failed: {response_text}")
  1590. except Exception as e:
  1591. logger.error(f"Exception adding pattern: {e}")
  1592. self.patternAddedToPlaylist.emit(False, str(e))
  1593. @Slot(str, list)
  1594. def updatePlaylistPatterns(self, playlistName, patterns):
  1595. """Update a playlist with a new list of patterns (used for removing patterns)"""
  1596. logger.debug(f"Updating playlist patterns: {playlistName} -> {len(patterns)} patterns")
  1597. asyncio.create_task(self._update_playlist_patterns(playlistName, patterns))
  1598. async def _update_playlist_patterns(self, playlistName, patterns):
  1599. """Async implementation of playlist pattern update"""
  1600. if not self.session:
  1601. self.playlistModified.emit(False, "Backend not ready")
  1602. return
  1603. try:
  1604. async with self.session.post(
  1605. f"{self.base_url}/modify_playlist",
  1606. json={"playlist_name": playlistName, "files": patterns}
  1607. ) as resp:
  1608. if resp.status == 200:
  1609. logger.info(f"Playlist updated: {playlistName}")
  1610. self.playlistModified.emit(True, f"Updated: {playlistName}")
  1611. else:
  1612. response_text = await resp.text()
  1613. logger.error(f"Failed to update playlist: {resp.status} - {response_text}")
  1614. self.playlistModified.emit(False, f"Failed: {response_text}")
  1615. except Exception as e:
  1616. logger.error(f"Exception updating playlist: {e}")
  1617. self.playlistModified.emit(False, str(e))
  1618. @Slot(result=list)
  1619. def getPlaylistNames(self):
  1620. """Get list of all playlist names (synchronous, reads from local file)"""
  1621. try:
  1622. playlists_file = Path("../playlists.json")
  1623. if playlists_file.exists():
  1624. with open(playlists_file, 'r') as f:
  1625. data = json.load(f)
  1626. return sorted(list(data.keys()))
  1627. except Exception as e:
  1628. logger.error(f"Error reading playlists: {e}")
  1629. return []