backend.py 72 KB

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