1
0

backend.py 77 KB

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