backend.py 47 KB

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