backend.py 49 KB

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