backend.py 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914
  1. from PySide6.QtCore import QObject, Signal, Property, Slot, QTimer
  2. from PySide6.QtQml import QmlElement
  3. from PySide6.QtWebSockets import QWebSocket
  4. import aiohttp
  5. import asyncio
  6. import json
  7. import subprocess
  8. import threading
  9. import time
  10. from pathlib import Path
  11. QML_IMPORT_NAME = "DuneWeaver"
  12. QML_IMPORT_MAJOR_VERSION = 1
  13. @QmlElement
  14. class Backend(QObject):
  15. """Backend controller for API and WebSocket communication"""
  16. # Signals
  17. statusChanged = Signal()
  18. progressChanged = Signal()
  19. connectionChanged = Signal()
  20. executionStarted = Signal(str, str) # patternName, patternPreview
  21. executionStopped = Signal()
  22. errorOccurred = Signal(str)
  23. serialPortsUpdated = Signal(list)
  24. serialConnectionChanged = Signal(bool)
  25. currentPortChanged = Signal(str)
  26. speedChanged = Signal(int)
  27. settingsLoaded = Signal()
  28. screenStateChanged = Signal(bool) # True = on, False = off
  29. def __init__(self):
  30. super().__init__()
  31. self.base_url = "http://localhost:8080"
  32. # WebSocket for status
  33. self.ws = QWebSocket()
  34. self.ws.connected.connect(self._on_ws_connected)
  35. self.ws.textMessageReceived.connect(self._on_ws_message)
  36. self.ws.open("ws://localhost:8080/ws/status")
  37. # Status properties
  38. self._current_file = ""
  39. self._progress = 0
  40. self._is_running = False
  41. self._is_connected = False
  42. self._serial_ports = []
  43. self._serial_connected = False
  44. self._current_port = ""
  45. self._current_speed = 130
  46. self._auto_play_on_boot = False
  47. # Screen management
  48. self._screen_on = True
  49. self._screen_timeout = 30 # 30 seconds for testing (change back to 300 for production)
  50. self._last_activity = time.time()
  51. self._touch_monitor_thread = None
  52. self._screen_transition_lock = threading.Lock() # Prevent rapid state changes
  53. self._last_screen_change = 0 # Track last state change time
  54. self._use_touch_script = False # Disable external touch-monitor script (too sensitive)
  55. self._screen_timer = QTimer()
  56. self._screen_timer.timeout.connect(self._check_screen_timeout)
  57. self._screen_timer.start(1000) # Check every second
  58. print(f"🖥️ Screen management initialized: timeout={self._screen_timeout}s, timer started")
  59. # HTTP session - initialize lazily
  60. self.session = None
  61. self._session_initialized = False
  62. # Use QTimer to defer session initialization until event loop is running
  63. QTimer.singleShot(100, self._delayed_init)
  64. @Slot()
  65. def _delayed_init(self):
  66. """Initialize session after Qt event loop is running"""
  67. if not self._session_initialized:
  68. try:
  69. loop = asyncio.get_event_loop()
  70. if loop.is_running():
  71. asyncio.create_task(self._init_session())
  72. else:
  73. # If no loop is running, try again later
  74. QTimer.singleShot(500, self._delayed_init)
  75. except RuntimeError:
  76. # No event loop yet, try again
  77. QTimer.singleShot(500, self._delayed_init)
  78. async def _init_session(self):
  79. """Initialize aiohttp session"""
  80. if not self._session_initialized:
  81. self.session = aiohttp.ClientSession()
  82. self._session_initialized = True
  83. # Properties
  84. @Property(str, notify=statusChanged)
  85. def currentFile(self):
  86. return self._current_file
  87. @Property(float, notify=progressChanged)
  88. def progress(self):
  89. return self._progress
  90. @Property(bool, notify=statusChanged)
  91. def isRunning(self):
  92. return self._is_running
  93. @Property(bool, notify=connectionChanged)
  94. def isConnected(self):
  95. return self._is_connected
  96. @Property(list, notify=serialPortsUpdated)
  97. def serialPorts(self):
  98. return self._serial_ports
  99. @Property(bool, notify=serialConnectionChanged)
  100. def serialConnected(self):
  101. return self._serial_connected
  102. @Property(str, notify=currentPortChanged)
  103. def currentPort(self):
  104. return self._current_port
  105. @Property(int, notify=speedChanged)
  106. def currentSpeed(self):
  107. return self._current_speed
  108. @Property(bool, notify=settingsLoaded)
  109. def autoPlayOnBoot(self):
  110. return self._auto_play_on_boot
  111. # WebSocket handlers
  112. @Slot()
  113. def _on_ws_connected(self):
  114. print("WebSocket connected")
  115. self._is_connected = True
  116. self.connectionChanged.emit()
  117. @Slot(str)
  118. def _on_ws_message(self, message):
  119. try:
  120. data = json.loads(message)
  121. if data.get("type") == "status_update":
  122. status = data.get("data", {})
  123. self._current_file = status.get("current_file", "")
  124. self._is_running = status.get("is_running", False)
  125. # Handle serial connection status from WebSocket
  126. ws_connection_status = status.get("connection_status", False)
  127. if ws_connection_status != self._serial_connected:
  128. print(f"🔌 WebSocket serial connection status changed: {ws_connection_status}")
  129. self._serial_connected = ws_connection_status
  130. self.serialConnectionChanged.emit(ws_connection_status)
  131. # If we're connected, we need to get the current port
  132. if ws_connection_status:
  133. # We'll need to fetch the current port via HTTP since WS doesn't include port info
  134. asyncio.create_task(self._get_current_port())
  135. else:
  136. self._current_port = ""
  137. self.currentPortChanged.emit("")
  138. # Handle speed updates from WebSocket
  139. ws_speed = status.get("speed", None)
  140. if ws_speed and ws_speed != self._current_speed:
  141. print(f"⚡ WebSocket speed changed: {ws_speed}")
  142. self._current_speed = ws_speed
  143. self.speedChanged.emit(ws_speed)
  144. if status.get("progress"):
  145. self._progress = status["progress"].get("percentage", 0)
  146. self.statusChanged.emit()
  147. self.progressChanged.emit()
  148. except json.JSONDecodeError:
  149. pass
  150. async def _get_current_port(self):
  151. """Fetch the current port when we detect a connection via WebSocket"""
  152. if not self.session:
  153. return
  154. try:
  155. async with self.session.get(f"{self.base_url}/serial_status") as resp:
  156. if resp.status == 200:
  157. data = await resp.json()
  158. current_port = data.get("port", "")
  159. if current_port:
  160. self._current_port = current_port
  161. self.currentPortChanged.emit(current_port)
  162. print(f"🔌 Updated current port from WebSocket trigger: {current_port}")
  163. except Exception as e:
  164. print(f"💥 Exception getting current port: {e}")
  165. # API Methods
  166. @Slot(str, str)
  167. def executePattern(self, fileName, preExecution="adaptive"):
  168. print(f"🎯 ExecutePattern called: fileName='{fileName}', preExecution='{preExecution}'")
  169. asyncio.create_task(self._execute_pattern(fileName, preExecution))
  170. async def _execute_pattern(self, fileName, preExecution):
  171. if not self.session:
  172. print("❌ Backend session not ready")
  173. self.errorOccurred.emit("Backend not ready, please try again")
  174. return
  175. try:
  176. request_data = {"file_name": fileName, "pre_execution": preExecution}
  177. print(f"🔄 Making HTTP POST to: {self.base_url}/run_theta_rho")
  178. print(f"📝 Request payload: {request_data}")
  179. async with self.session.post(
  180. f"{self.base_url}/run_theta_rho",
  181. json=request_data
  182. ) as resp:
  183. print(f"📡 Response status: {resp.status}")
  184. print(f"📋 Response headers: {dict(resp.headers)}")
  185. response_text = await resp.text()
  186. print(f"📄 Response body: {response_text}")
  187. if resp.status == 200:
  188. print("✅ Pattern execution request successful")
  189. # Find preview image for the pattern
  190. preview_path = self._find_pattern_preview(fileName)
  191. print(f"🖼️ Pattern preview path: {preview_path}")
  192. print(f"📡 About to emit executionStarted signal with: fileName='{fileName}', preview='{preview_path}'")
  193. try:
  194. self.executionStarted.emit(fileName, preview_path)
  195. print("✅ ExecutionStarted signal emitted successfully")
  196. except Exception as e:
  197. print(f"❌ Error emitting executionStarted signal: {e}")
  198. else:
  199. print(f"❌ Pattern execution failed with status {resp.status}")
  200. self.errorOccurred.emit(f"Failed to execute: {resp.status} - {response_text}")
  201. except Exception as e:
  202. print(f"💥 Exception in _execute_pattern: {e}")
  203. self.errorOccurred.emit(str(e))
  204. def _find_pattern_preview(self, fileName):
  205. """Find the preview image for a pattern"""
  206. try:
  207. # Extract just the filename from the path (remove any directory prefixes)
  208. clean_filename = fileName.split('/')[-1] # Get last part of path
  209. print(f"🔍 Original fileName: {fileName}, clean filename: {clean_filename}")
  210. # Check multiple possible locations for patterns directory
  211. # Use relative paths that work across different environments
  212. possible_dirs = [
  213. Path("../patterns"), # One level up (for when running from touch subdirectory)
  214. Path("patterns"), # Same level (for when running from main directory)
  215. Path(__file__).parent.parent / "patterns" # Dynamic path relative to backend.py
  216. ]
  217. for patterns_dir in possible_dirs:
  218. cache_dir = patterns_dir / "cached_images"
  219. if cache_dir.exists():
  220. print(f"🔍 Checking preview cache directory: {cache_dir}")
  221. # Try different preview image extensions - PNG first for kiosk
  222. # First try with .thr suffix (e.g., pattern.thr.png)
  223. for ext in [".png", ".webp", ".jpg", ".jpeg"]:
  224. preview_file = cache_dir / (clean_filename + ext)
  225. print(f"🔍 Looking for preview: {preview_file}")
  226. if preview_file.exists():
  227. print(f"✅ Found preview: {preview_file}")
  228. return str(preview_file.absolute())
  229. # Then try without .thr suffix (e.g., pattern.png)
  230. base_name = clean_filename.replace(".thr", "")
  231. for ext in [".png", ".webp", ".jpg", ".jpeg"]:
  232. preview_file = cache_dir / (base_name + ext)
  233. print(f"🔍 Looking for preview (no .thr): {preview_file}")
  234. if preview_file.exists():
  235. print(f"✅ Found preview: {preview_file}")
  236. return str(preview_file.absolute())
  237. print("❌ No preview image found")
  238. return ""
  239. except Exception as e:
  240. print(f"💥 Exception finding preview: {e}")
  241. return ""
  242. @Slot()
  243. def stopExecution(self):
  244. asyncio.create_task(self._stop_execution())
  245. async def _stop_execution(self):
  246. if not self.session:
  247. self.errorOccurred.emit("Backend not ready")
  248. return
  249. try:
  250. print("🛑 Calling stop_execution endpoint...")
  251. # Add timeout to prevent hanging
  252. timeout = aiohttp.ClientTimeout(total=10) # 10 second timeout
  253. async with self.session.post(f"{self.base_url}/stop_execution", timeout=timeout) as resp:
  254. print(f"🛑 Stop execution response status: {resp.status}")
  255. if resp.status == 200:
  256. response_data = await resp.json()
  257. print(f"🛑 Stop execution response: {response_data}")
  258. self.executionStopped.emit()
  259. else:
  260. print(f"❌ Stop execution failed with status: {resp.status}")
  261. response_text = await resp.text()
  262. self.errorOccurred.emit(f"Stop failed: {resp.status} - {response_text}")
  263. except asyncio.TimeoutError:
  264. print("⏰ Stop execution request timed out")
  265. self.errorOccurred.emit("Stop execution request timed out")
  266. except Exception as e:
  267. print(f"💥 Exception in _stop_execution: {e}")
  268. self.errorOccurred.emit(str(e))
  269. @Slot()
  270. def pauseExecution(self):
  271. print("⏸️ Pausing execution...")
  272. asyncio.create_task(self._api_call("/pause_execution"))
  273. @Slot()
  274. def resumeExecution(self):
  275. print("▶️ Resuming execution...")
  276. asyncio.create_task(self._api_call("/resume_execution"))
  277. @Slot()
  278. def skipPattern(self):
  279. print("⏭️ Skipping pattern...")
  280. asyncio.create_task(self._api_call("/skip_pattern"))
  281. @Slot(str, float, str, str, bool)
  282. def executePlaylist(self, playlistName, pauseTime=0.0, clearPattern="adaptive", runMode="single", shuffle=False):
  283. print(f"🎵 ExecutePlaylist called: playlist='{playlistName}', pauseTime={pauseTime}, clearPattern='{clearPattern}', runMode='{runMode}', shuffle={shuffle}")
  284. asyncio.create_task(self._execute_playlist(playlistName, pauseTime, clearPattern, runMode, shuffle))
  285. async def _execute_playlist(self, playlistName, pauseTime, clearPattern, runMode, shuffle):
  286. if not self.session:
  287. print("❌ Backend session not ready")
  288. self.errorOccurred.emit("Backend not ready, please try again")
  289. return
  290. try:
  291. request_data = {
  292. "playlist_name": playlistName,
  293. "pause_time": pauseTime,
  294. "clear_pattern": clearPattern,
  295. "run_mode": runMode,
  296. "shuffle": shuffle
  297. }
  298. print(f"🔄 Making HTTP POST to: {self.base_url}/run_playlist")
  299. print(f"📝 Request payload: {request_data}")
  300. async with self.session.post(
  301. f"{self.base_url}/run_playlist",
  302. json=request_data
  303. ) as resp:
  304. print(f"📡 Response status: {resp.status}")
  305. response_text = await resp.text()
  306. print(f"📄 Response body: {response_text}")
  307. if resp.status == 200:
  308. print(f"✅ Playlist execution request successful: {playlistName}")
  309. # The playlist will start executing patterns automatically
  310. # Status updates will come through WebSocket
  311. else:
  312. print(f"❌ Playlist execution failed with status {resp.status}")
  313. self.errorOccurred.emit(f"Failed to execute playlist: {resp.status} - {response_text}")
  314. except Exception as e:
  315. print(f"💥 Exception in _execute_playlist: {e}")
  316. self.errorOccurred.emit(str(e))
  317. async def _api_call(self, endpoint):
  318. if not self.session:
  319. self.errorOccurred.emit("Backend not ready")
  320. return
  321. try:
  322. print(f"📡 Calling API endpoint: {endpoint}")
  323. # Add timeout to prevent hanging
  324. timeout = aiohttp.ClientTimeout(total=10) # 10 second timeout
  325. async with self.session.post(f"{self.base_url}{endpoint}", timeout=timeout) as resp:
  326. print(f"📡 API response status for {endpoint}: {resp.status}")
  327. if resp.status == 200:
  328. response_data = await resp.json()
  329. print(f"📡 API response for {endpoint}: {response_data}")
  330. else:
  331. print(f"❌ API call {endpoint} failed with status: {resp.status}")
  332. response_text = await resp.text()
  333. self.errorOccurred.emit(f"API call failed: {endpoint} - {resp.status} - {response_text}")
  334. except asyncio.TimeoutError:
  335. print(f"⏰ API call {endpoint} timed out")
  336. self.errorOccurred.emit(f"API call {endpoint} timed out")
  337. except Exception as e:
  338. print(f"💥 Exception in API call {endpoint}: {e}")
  339. self.errorOccurred.emit(str(e))
  340. # Serial Port Management
  341. @Slot()
  342. def refreshSerialPorts(self):
  343. print("🔌 Refreshing serial ports...")
  344. asyncio.create_task(self._refresh_serial_ports())
  345. async def _refresh_serial_ports(self):
  346. if not self.session:
  347. self.errorOccurred.emit("Backend not ready")
  348. return
  349. try:
  350. async with self.session.get(f"{self.base_url}/list_serial_ports") as resp:
  351. if resp.status == 200:
  352. # The endpoint returns a list directly, not a dictionary
  353. ports = await resp.json()
  354. self._serial_ports = ports if isinstance(ports, list) else []
  355. print(f"📡 Found serial ports: {self._serial_ports}")
  356. self.serialPortsUpdated.emit(self._serial_ports)
  357. else:
  358. print(f"❌ Failed to get serial ports: {resp.status}")
  359. except Exception as e:
  360. print(f"💥 Exception refreshing serial ports: {e}")
  361. self.errorOccurred.emit(str(e))
  362. @Slot(str)
  363. def connectSerial(self, port):
  364. print(f"🔗 Connecting to serial port: {port}")
  365. asyncio.create_task(self._connect_serial(port))
  366. async def _connect_serial(self, port):
  367. if not self.session:
  368. self.errorOccurred.emit("Backend not ready")
  369. return
  370. try:
  371. async with self.session.post(f"{self.base_url}/connect", json={"port": port}) as resp:
  372. if resp.status == 200:
  373. print(f"✅ Connected to {port}")
  374. self._serial_connected = True
  375. self._current_port = port
  376. self.serialConnectionChanged.emit(True)
  377. self.currentPortChanged.emit(port)
  378. else:
  379. response_text = await resp.text()
  380. print(f"❌ Failed to connect to {port}: {resp.status} - {response_text}")
  381. self.errorOccurred.emit(f"Failed to connect: {response_text}")
  382. except Exception as e:
  383. print(f"💥 Exception connecting to serial: {e}")
  384. self.errorOccurred.emit(str(e))
  385. @Slot()
  386. def disconnectSerial(self):
  387. print("🔌 Disconnecting serial...")
  388. asyncio.create_task(self._disconnect_serial())
  389. async def _disconnect_serial(self):
  390. if not self.session:
  391. self.errorOccurred.emit("Backend not ready")
  392. return
  393. try:
  394. async with self.session.post(f"{self.base_url}/disconnect") as resp:
  395. if resp.status == 200:
  396. print("✅ Disconnected from serial")
  397. self._serial_connected = False
  398. self._current_port = ""
  399. self.serialConnectionChanged.emit(False)
  400. self.currentPortChanged.emit("")
  401. else:
  402. response_text = await resp.text()
  403. print(f"❌ Failed to disconnect: {resp.status} - {response_text}")
  404. except Exception as e:
  405. print(f"💥 Exception disconnecting serial: {e}")
  406. self.errorOccurred.emit(str(e))
  407. # Hardware Movement Controls
  408. @Slot()
  409. def sendHome(self):
  410. print("🏠 Sending home command...")
  411. asyncio.create_task(self._api_call("/send_home"))
  412. @Slot()
  413. def moveToCenter(self):
  414. print("🎯 Moving to center...")
  415. asyncio.create_task(self._api_call("/move_to_center"))
  416. @Slot()
  417. def moveToPerimeter(self):
  418. print("⭕ Moving to perimeter...")
  419. asyncio.create_task(self._api_call("/move_to_perimeter"))
  420. # Speed Control
  421. @Slot(int)
  422. def setSpeed(self, speed):
  423. print(f"⚡ Setting speed to: {speed}")
  424. asyncio.create_task(self._set_speed(speed))
  425. async def _set_speed(self, speed):
  426. if not self.session:
  427. self.errorOccurred.emit("Backend not ready")
  428. return
  429. try:
  430. async with self.session.post(f"{self.base_url}/set_speed", json={"speed": speed}) as resp:
  431. if resp.status == 200:
  432. print(f"✅ Speed set to {speed}")
  433. self._current_speed = speed
  434. self.speedChanged.emit(speed)
  435. else:
  436. response_text = await resp.text()
  437. print(f"❌ Failed to set speed: {resp.status} - {response_text}")
  438. except Exception as e:
  439. print(f"💥 Exception setting speed: {e}")
  440. self.errorOccurred.emit(str(e))
  441. # Auto Play on Boot Setting
  442. @Slot(bool)
  443. def setAutoPlayOnBoot(self, enabled):
  444. print(f"🚀 Setting auto play on boot: {enabled}")
  445. asyncio.create_task(self._set_auto_play_on_boot(enabled))
  446. async def _set_auto_play_on_boot(self, enabled):
  447. if not self.session:
  448. self.errorOccurred.emit("Backend not ready")
  449. return
  450. try:
  451. # Use the kiosk mode API endpoint for auto-play on boot
  452. async with self.session.post(f"{self.base_url}/api/kiosk-mode", json={"enabled": enabled}) as resp:
  453. if resp.status == 200:
  454. print(f"✅ Auto play on boot set to {enabled}")
  455. self._auto_play_on_boot = enabled
  456. else:
  457. response_text = await resp.text()
  458. print(f"❌ Failed to set auto play: {resp.status} - {response_text}")
  459. except Exception as e:
  460. print(f"💥 Exception setting auto play: {e}")
  461. self.errorOccurred.emit(str(e))
  462. async def _save_screen_timeout_setting(self, timeout_seconds):
  463. if not self.session:
  464. self.errorOccurred.emit("Backend not ready")
  465. return
  466. try:
  467. # Convert seconds to minutes for the main application API
  468. timeout_minutes = timeout_seconds // 60
  469. # Use the kiosk mode API endpoint to save screen timeout
  470. async with self.session.post(f"{self.base_url}/api/kiosk-mode", json={
  471. "enabled": self._auto_play_on_boot,
  472. "screen_timeout": timeout_minutes
  473. }) as resp:
  474. if resp.status == 200:
  475. print(f"✅ Screen timeout saved: {timeout_minutes} minutes")
  476. else:
  477. response_text = await resp.text()
  478. print(f"❌ Failed to save screen timeout: {resp.status} - {response_text}")
  479. except Exception as e:
  480. print(f"💥 Exception saving screen timeout: {e}")
  481. self.errorOccurred.emit(str(e))
  482. # Load Settings
  483. @Slot()
  484. def loadControlSettings(self):
  485. print("📋 Loading control settings...")
  486. asyncio.create_task(self._load_settings())
  487. async def _load_settings(self):
  488. if not self.session:
  489. self.errorOccurred.emit("Backend not ready")
  490. return
  491. try:
  492. # Load kiosk mode settings
  493. async with self.session.get(f"{self.base_url}/api/kiosk-mode") as resp:
  494. if resp.status == 200:
  495. data = await resp.json()
  496. self._auto_play_on_boot = data.get("enabled", False)
  497. # Load screen timeout from kiosk settings (convert minutes to seconds)
  498. screen_timeout_minutes = data.get("screen_timeout", 0)
  499. if screen_timeout_minutes > 0:
  500. self._screen_timeout = screen_timeout_minutes * 60
  501. print(f"🚀 Loaded auto play setting: {self._auto_play_on_boot}")
  502. print(f"🖥️ Loaded screen timeout: {screen_timeout_minutes} minutes ({self._screen_timeout} seconds)")
  503. # Serial status will be handled by WebSocket updates automatically
  504. # But we still load the initial port info if connected
  505. async with self.session.get(f"{self.base_url}/serial_status") as resp:
  506. if resp.status == 200:
  507. data = await resp.json()
  508. initial_connected = data.get("connected", False)
  509. current_port = data.get("port", "")
  510. print(f"🔌 Initial serial status: connected={initial_connected}, port={current_port}")
  511. # Only update if WebSocket hasn't already set this
  512. if initial_connected and current_port and not self._current_port:
  513. self._current_port = current_port
  514. self.currentPortChanged.emit(current_port)
  515. # Set initial connection status (WebSocket will take over from here)
  516. if self._serial_connected != initial_connected:
  517. self._serial_connected = initial_connected
  518. self.serialConnectionChanged.emit(initial_connected)
  519. print("✅ Settings loaded - WebSocket will handle real-time updates")
  520. self.settingsLoaded.emit()
  521. except Exception as e:
  522. print(f"💥 Exception loading settings: {e}")
  523. self.errorOccurred.emit(str(e))
  524. # Screen Management Properties
  525. @Property(bool, notify=screenStateChanged)
  526. def screenOn(self):
  527. return self._screen_on
  528. @Property(int)
  529. def screenTimeout(self):
  530. return self._screen_timeout
  531. @screenTimeout.setter
  532. def setScreenTimeout(self, timeout):
  533. if self._screen_timeout != timeout:
  534. self._screen_timeout = timeout
  535. print(f"🖥️ Screen timeout set to {timeout} seconds")
  536. # Save to main application's kiosk settings
  537. asyncio.create_task(self._save_screen_timeout_setting(timeout))
  538. # Screen Control Methods
  539. @Slot()
  540. def turnScreenOn(self):
  541. """Turn the screen on and reset activity timer"""
  542. if not self._screen_on:
  543. self._turn_screen_on()
  544. self._reset_activity_timer()
  545. @Slot()
  546. def turnScreenOff(self):
  547. """Turn the screen off"""
  548. self._turn_screen_off()
  549. # Start touch monitoring after manual screen off
  550. QTimer.singleShot(1000, self._start_touch_monitoring) # 1 second delay
  551. @Slot()
  552. def resetActivityTimer(self):
  553. """Reset the activity timer (call on user interaction)"""
  554. self._reset_activity_timer()
  555. if not self._screen_on:
  556. self._turn_screen_on()
  557. def _turn_screen_on(self):
  558. """Internal method to turn screen on"""
  559. with self._screen_transition_lock:
  560. # Debounce: Don't turn on if we just changed state
  561. time_since_change = time.time() - self._last_screen_change
  562. if time_since_change < 2.0: # 2 second debounce
  563. print(f"🖥️ Screen state change blocked (debounce: {time_since_change:.1f}s < 2s)")
  564. return
  565. if self._screen_on:
  566. print("🖥️ Screen already ON, skipping")
  567. return
  568. try:
  569. # Use the working screen-on script if available
  570. screen_on_script = Path('/usr/local/bin/screen-on')
  571. if screen_on_script.exists():
  572. result = subprocess.run(['sudo', '/usr/local/bin/screen-on'],
  573. capture_output=True, text=True, timeout=5)
  574. if result.returncode == 0:
  575. print("🖥️ Screen turned ON (screen-on script)")
  576. else:
  577. print(f"⚠️ screen-on script failed: {result.stderr}")
  578. else:
  579. # Fallback: Manual control matching the script
  580. # Unblank framebuffer and restore backlight
  581. max_brightness = 255
  582. try:
  583. result = subprocess.run(['cat', '/sys/class/backlight/*/max_brightness'],
  584. shell=True, capture_output=True, text=True, timeout=2)
  585. if result.returncode == 0 and result.stdout.strip():
  586. max_brightness = int(result.stdout.strip())
  587. except:
  588. pass
  589. subprocess.run(['sudo', 'sh', '-c',
  590. f'echo 0 > /sys/class/graphics/fb0/blank && echo {max_brightness} > /sys/class/backlight/*/brightness'],
  591. check=False, timeout=5)
  592. print(f"🖥️ Screen turned ON (manual, brightness: {max_brightness})")
  593. self._screen_on = True
  594. self._last_screen_change = time.time()
  595. self.screenStateChanged.emit(True)
  596. except Exception as e:
  597. print(f"❌ Failed to turn screen on: {e}")
  598. def _turn_screen_off(self):
  599. """Internal method to turn screen off"""
  600. print("🖥️ _turn_screen_off() called")
  601. with self._screen_transition_lock:
  602. # Debounce: Don't turn off if we just changed state
  603. time_since_change = time.time() - self._last_screen_change
  604. if time_since_change < 2.0: # 2 second debounce
  605. print(f"🖥️ Screen state change blocked (debounce: {time_since_change:.1f}s < 2s)")
  606. return
  607. if not self._screen_on:
  608. print("🖥️ Screen already OFF, skipping")
  609. return
  610. try:
  611. # Use the working screen-off script if available
  612. screen_off_script = Path('/usr/local/bin/screen-off')
  613. print(f"🖥️ Checking for screen-off script at: {screen_off_script}")
  614. print(f"🖥️ Script exists: {screen_off_script.exists()}")
  615. if screen_off_script.exists():
  616. print("🖥️ Executing screen-off script...")
  617. result = subprocess.run(['sudo', '/usr/local/bin/screen-off'],
  618. capture_output=True, text=True, timeout=10)
  619. print(f"🖥️ Script return code: {result.returncode}")
  620. if result.stdout:
  621. print(f"🖥️ Script stdout: {result.stdout}")
  622. if result.stderr:
  623. print(f"🖥️ Script stderr: {result.stderr}")
  624. if result.returncode == 0:
  625. print("✅ Screen turned OFF (screen-off script)")
  626. else:
  627. print(f"⚠️ screen-off script failed: return code {result.returncode}")
  628. else:
  629. print("🖥️ Using manual screen control...")
  630. # Fallback: Manual control matching the script
  631. # Blank framebuffer and turn off backlight
  632. subprocess.run(['sudo', 'sh', '-c',
  633. 'echo 0 > /sys/class/backlight/*/brightness && echo 1 > /sys/class/graphics/fb0/blank'],
  634. check=False, timeout=5)
  635. print("🖥️ Screen turned OFF (manual)")
  636. self._screen_on = False
  637. self._last_screen_change = time.time()
  638. self.screenStateChanged.emit(False)
  639. print("🖥️ Screen state set to OFF, signal emitted")
  640. except Exception as e:
  641. print(f"❌ Failed to turn screen off: {e}")
  642. import traceback
  643. traceback.print_exc()
  644. def _reset_activity_timer(self):
  645. """Reset the last activity timestamp"""
  646. old_time = self._last_activity
  647. self._last_activity = time.time()
  648. time_since_last = self._last_activity - old_time
  649. if time_since_last > 1: # Only log if it's been more than 1 second
  650. print(f"🖥️ Activity detected - timer reset (was idle for {time_since_last:.1f}s)")
  651. def _check_screen_timeout(self):
  652. """Check if screen should be turned off due to inactivity"""
  653. if self._screen_on:
  654. idle_time = time.time() - self._last_activity
  655. # Log every 10 seconds when getting close to timeout
  656. if idle_time > self._screen_timeout - 10 and idle_time % 10 < 1:
  657. print(f"🖥️ Screen idle for {idle_time:.0f}s (timeout at {self._screen_timeout}s)")
  658. if idle_time > self._screen_timeout:
  659. print(f"🖥️ Screen timeout reached! Idle for {idle_time:.0f}s (timeout: {self._screen_timeout}s)")
  660. self._turn_screen_off()
  661. # Add delay before starting touch monitoring to avoid catching residual events
  662. QTimer.singleShot(1000, self._start_touch_monitoring) # 1 second delay
  663. def _start_touch_monitoring(self):
  664. """Start monitoring touch input for wake-up"""
  665. if self._touch_monitor_thread is None or not self._touch_monitor_thread.is_alive():
  666. self._touch_monitor_thread = threading.Thread(target=self._monitor_touch_input, daemon=True)
  667. self._touch_monitor_thread.start()
  668. def _monitor_touch_input(self):
  669. """Monitor touch input to wake up the screen"""
  670. print("👆 Starting touch monitoring for wake-up")
  671. # Add delay to let any residual touch events clear
  672. time.sleep(2)
  673. # Flush touch device to clear any buffered events
  674. try:
  675. # Find and flush touch device
  676. for i in range(5):
  677. device = f'/dev/input/event{i}'
  678. if Path(device).exists():
  679. try:
  680. # Read and discard any pending events
  681. with open(device, 'rb') as f:
  682. import fcntl
  683. import os
  684. fcntl.fcntl(f.fileno(), fcntl.F_SETFL, os.O_NONBLOCK)
  685. while True:
  686. try:
  687. f.read(24) # Standard input_event size
  688. except:
  689. break
  690. print(f"👆 Flushed touch device: {device}")
  691. break
  692. except:
  693. continue
  694. except Exception as e:
  695. print(f"👆 Could not flush touch device: {e}")
  696. print("👆 Touch monitoring active")
  697. try:
  698. # Use external touch monitor script if available - but only if not too sensitive
  699. touch_monitor_script = Path('/usr/local/bin/touch-monitor')
  700. use_script = touch_monitor_script.exists() and hasattr(self, '_use_touch_script') and self._use_touch_script
  701. if use_script:
  702. print("👆 Using touch-monitor script")
  703. # Add extra delay for script-based monitoring since it's more sensitive
  704. time.sleep(3)
  705. print("👆 Starting touch-monitor script after flush delay")
  706. process = subprocess.Popen(['sudo', '/usr/local/bin/touch-monitor'],
  707. stdout=subprocess.PIPE,
  708. stderr=subprocess.PIPE)
  709. # Wait for script to detect touch and wake screen
  710. while not self._screen_on:
  711. if process.poll() is not None: # Script exited (touch detected)
  712. print("👆 Touch detected by monitor script")
  713. self._turn_screen_on()
  714. self._reset_activity_timer()
  715. break
  716. time.sleep(0.1)
  717. if process.poll() is None:
  718. process.terminate()
  719. else:
  720. # Fallback: Direct monitoring
  721. # Find touch input device
  722. touch_device = None
  723. for i in range(5): # Check event0 through event4
  724. device = f'/dev/input/event{i}'
  725. if Path(device).exists():
  726. # Check if it's a touch device
  727. try:
  728. info = subprocess.run(['udevadm', 'info', '--query=all', f'--name={device}'],
  729. capture_output=True, text=True, timeout=2)
  730. if 'touch' in info.stdout.lower() or 'ft5406' in info.stdout.lower():
  731. touch_device = device
  732. break
  733. except:
  734. pass
  735. if not touch_device:
  736. touch_device = '/dev/input/event0' # Default fallback
  737. print(f"👆 Monitoring touch device: {touch_device}")
  738. # Try evtest first (more responsive to single taps)
  739. evtest_available = subprocess.run(['which', 'evtest'],
  740. capture_output=True).returncode == 0
  741. if evtest_available:
  742. # Use evtest which is more sensitive to single touches
  743. print("👆 Using evtest for touch detection")
  744. process = subprocess.Popen(['sudo', 'evtest', touch_device],
  745. stdout=subprocess.PIPE,
  746. stderr=subprocess.DEVNULL,
  747. text=True)
  748. # Wait for any event line
  749. while not self._screen_on:
  750. try:
  751. line = process.stdout.readline()
  752. if line and 'Event:' in line:
  753. print("👆 Touch detected via evtest - waking screen")
  754. process.terminate()
  755. self._turn_screen_on()
  756. self._reset_activity_timer()
  757. break
  758. except:
  759. pass
  760. if process.poll() is not None:
  761. break
  762. time.sleep(0.01) # Small sleep to prevent CPU spinning
  763. else:
  764. # Fallback: Use cat with single byte read (more responsive)
  765. print("👆 Using cat for touch detection")
  766. process = subprocess.Popen(['sudo', 'cat', touch_device],
  767. stdout=subprocess.PIPE,
  768. stderr=subprocess.DEVNULL)
  769. # Wait for any data (even 1 byte indicates touch)
  770. while not self._screen_on:
  771. try:
  772. # Non-blocking check for data
  773. import select
  774. ready, _, _ = select.select([process.stdout], [], [], 0.1)
  775. if ready:
  776. data = process.stdout.read(1) # Read just 1 byte
  777. if data:
  778. print("👆 Touch detected - waking screen")
  779. process.terminate()
  780. self._turn_screen_on()
  781. self._reset_activity_timer()
  782. break
  783. except:
  784. pass
  785. # Check if screen was turned on by other means
  786. if self._screen_on:
  787. process.terminate()
  788. break
  789. time.sleep(0.1)
  790. except Exception as e:
  791. print(f"❌ Error monitoring touch input: {e}")
  792. print("👆 Touch monitoring stopped")