backend.py 57 KB

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