backend.py 82 KB

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