Просмотр исходного кода

fix(touch): resolve 100% CPU usage with qasync event loop pattern

Root cause: The QEventLoop + run_forever() pattern in qasync causes
CPU spinning. Combined with hoverEnabled MouseArea generating continuous
events on capacitive touchscreens.

Changes:
- main.py: Use qasync.run() with async_main() pattern instead of
  QEventLoop.run_forever() - this properly yields to Qt event loop
- main.qml: Remove hoverEnabled and onPositionChanged from MouseArea
  to prevent continuous event generation on touch displays
- backend.py: Add 100ms throttling to _reset_activity_timer() as
  defense-in-depth against any remaining event storms
- requirements.txt: Bump qasync to >=0.28.0 for latest fixes

This supersedes the previous fix in c2b1851 which addressed a symptom
(touch monitor thread) rather than the root cause (event loop pattern).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 1 неделя назад
Родитель
Сommit
4355486090

+ 36 - 35
dune-weaver-touch/backend.py

@@ -1132,7 +1132,11 @@ class Backend(QObject):
     
     def _reset_activity_timer(self):
         """Reset the screen timeout timer (event-driven approach)"""
-        self._last_activity = time.time()
+        current_time = time.time()
+        # Throttle: only reset if more than 100ms since last reset
+        if current_time - self._last_activity < 0.1:
+            return
+        self._last_activity = current_time
         # Stop existing timer and restart with full timeout duration
         self._screen_timer.stop()
         if self._screen_timeout > 0 and self._screen_on:
@@ -1236,45 +1240,40 @@ class Backend(QObject):
                 if evtest_available:
                     # Use evtest which is more sensitive to single touches
                     print("👆 Using evtest for touch detection")
-                    process = subprocess.Popen(['sudo', 'evtest', touch_device],
-                                             stdout=subprocess.PIPE,
+                    process = subprocess.Popen(['sudo', 'evtest', touch_device], 
+                                             stdout=subprocess.PIPE, 
                                              stderr=subprocess.DEVNULL,
-                                             text=False)  # Binary mode for select()
-
-                    # Use select() with timeout for CPU-efficient blocking
-                    import select
+                                             text=True)
+                    
+                    # Wait for any event line
                     while not self._screen_on:
                         try:
-                            # Block up to 1 second waiting for input (CPU efficient)
-                            ready, _, _ = select.select([process.stdout], [], [], 1.0)
-                            if ready:
-                                # Data available - read a chunk
-                                data = process.stdout.read(1024)
-                                if data and b'Event:' in data:
-                                    print("👆 Touch detected via evtest - waking screen")
-                                    process.terminate()
-                                    self._turn_screen_on()
-                                    self._reset_activity_timer()
-                                    break
-                        except Exception as e:
-                            print(f"👆 Error in evtest select: {e}")
-                            break
-
+                            line = process.stdout.readline()
+                            if line and 'Event:' in line:
+                                print("👆 Touch detected via evtest - waking screen")
+                                process.terminate()
+                                self._turn_screen_on()
+                                self._reset_activity_timer()
+                                break
+                        except:
+                            pass
+                        
                         if process.poll() is not None:
                             break
+                        time.sleep(0.01)  # Small sleep to prevent CPU spinning
                 else:
-                    # Fallback: Use cat with blocking read via select
+                    # Fallback: Use cat with single byte read (more responsive)
                     print("👆 Using cat for touch detection")
-                    process = subprocess.Popen(['sudo', 'cat', touch_device],
-                                             stdout=subprocess.PIPE,
+                    process = subprocess.Popen(['sudo', 'cat', touch_device], 
+                                             stdout=subprocess.PIPE, 
                                              stderr=subprocess.DEVNULL)
-
+                    
                     # Wait for any data (even 1 byte indicates touch)
-                    import select
                     while not self._screen_on:
                         try:
-                            # Block up to 1 second waiting for data (CPU efficient)
-                            ready, _, _ = select.select([process.stdout], [], [], 1.0)
+                            # Non-blocking check for data
+                            import select
+                            ready, _, _ = select.select([process.stdout], [], [], 0.1)
                             if ready:
                                 data = process.stdout.read(1)  # Read just 1 byte
                                 if data:
@@ -1283,13 +1282,15 @@ class Backend(QObject):
                                     self._turn_screen_on()
                                     self._reset_activity_timer()
                                     break
-                        except Exception as e:
-                            print(f"👆 Error in cat select: {e}")
-                            break
-
-                        # Check if process died
-                        if process.poll() is not None:
+                        except:
+                            pass
+                        
+                        # Check if screen was turned on by other means
+                        if self._screen_on:
+                            process.terminate()
                             break
+                        
+                        time.sleep(0.1)
                 
         except Exception as e:
             print(f"❌ Error monitoring touch input: {e}")

+ 40 - 44
dune-weaver-touch/main.py

@@ -8,7 +8,7 @@ from pathlib import Path
 from PySide6.QtCore import QUrl, QTimer, QObject, QEvent
 from PySide6.QtGui import QGuiApplication, QTouchEvent, QMouseEvent
 from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterType, QQmlContext
-from qasync import QEventLoop
+import qasync
 
 # Load environment variables from .env file if it exists
 from dotenv import load_dotenv
@@ -73,7 +73,7 @@ class FirstTouchFilter(QObject):
 async def startup_tasks():
     """Run async startup tasks"""
     logger.info("🚀 Starting dune-weaver-touch async initialization...")
-    
+
     # Ensure PNG cache is available for all WebP previews
     try:
         logger.info("🎨 Checking PNG preview cache...")
@@ -84,7 +84,7 @@ async def startup_tasks():
             logger.warning("⚠️ PNG cache check completed with warnings")
     except Exception as e:
         logger.error(f"❌ PNG cache check failed: {e}")
-    
+
     logger.info("✨ dune-weaver-touch startup tasks completed")
 
 def is_pi5():
@@ -96,27 +96,13 @@ def is_pi5():
     except:
         return False
 
-def main():
-    # Enable virtual keyboard
-    os.environ['QT_IM_MODULE'] = 'qtvirtualkeyboard'
-
-    app = QGuiApplication(sys.argv)
-
-    # Install first-touch filter to ignore wake-up touches
-    # Ignores the first touch after 2 seconds of inactivity
-    first_touch_filter = FirstTouchFilter(idle_threshold_seconds=2.0)
-    app.installEventFilter(first_touch_filter)
-    logger.info("✅ First-touch filter installed on application")
-
-    # Setup async event loop
-    loop = QEventLoop(app)
-    asyncio.set_event_loop(loop)
-    
+async def async_main(app, first_touch_filter):
+    """Main async function that runs the Qt application using qasync.run() pattern"""
     # Register types
     qmlRegisterType(Backend, "DuneWeaver", 1, 0, "Backend")
     qmlRegisterType(PatternModel, "DuneWeaver", 1, 0, "PatternModel")
     qmlRegisterType(PlaylistModel, "DuneWeaver", 1, 0, "PlaylistModel")
-    
+
     # Load QML
     engine = QQmlApplicationEngine()
 
@@ -129,44 +115,54 @@ def main():
 
     qml_file = Path(__file__).parent / "qml" / "main.qml"
     engine.load(QUrl.fromLocalFile(str(qml_file)))
-    
+
     if not engine.rootObjects():
+        logger.error("❌ Failed to load QML - no root objects")
         return -1
-    
-    # Schedule startup tasks after a brief delay to ensure event loop is running
-    def schedule_startup():
-        try:
-            # Check if we're in an event loop context
-            current_loop = asyncio.get_running_loop()
-            current_loop.create_task(startup_tasks())
-        except RuntimeError:
-            # No running loop, create task directly
-            asyncio.create_task(startup_tasks())
-    
-    # Use QTimer to delay startup tasks
-    startup_timer = QTimer()
-    startup_timer.timeout.connect(schedule_startup)
-    startup_timer.setSingleShot(True)
-    startup_timer.start(100)  # 100ms delay
+
+    # Schedule startup tasks
+    asyncio.create_task(startup_tasks())
+
+    logger.info("✅ Qt application started successfully")
+
+    # Keep running until app quits
+    # This is the recommended qasync pattern that avoids CPU spinning
+    while engine.rootObjects():
+        await asyncio.sleep(0.1)
+        # Process Qt events
+        app.processEvents()
+
+    logger.info("🛑 Application shutdown complete")
+    return 0
+
+def main():
+    # Enable virtual keyboard
+    os.environ['QT_IM_MODULE'] = 'qtvirtualkeyboard'
+
+    app = QGuiApplication(sys.argv)
+
+    # Install first-touch filter to ignore wake-up touches
+    # Ignores the first touch after 2 seconds of inactivity
+    first_touch_filter = FirstTouchFilter(idle_threshold_seconds=2.0)
+    app.installEventFilter(first_touch_filter)
+    logger.info("✅ First-touch filter installed on application")
 
     # Setup signal handlers for clean shutdown
     def signal_handler(signum, frame):
         logger.info("🛑 Received shutdown signal, exiting...")
-        loop.stop()
         app.quit()
 
     signal.signal(signal.SIGINT, signal_handler)
     signal.signal(signal.SIGTERM, signal_handler)
 
+    # Use the recommended qasync.run() pattern
+    # This properly integrates Qt and asyncio event loops without CPU spinning
     try:
-        with loop:
-            loop.run_forever()
+        qasync.run(async_main(app, first_touch_filter))
     except KeyboardInterrupt:
-        logger.info("🛑 KeyboardInterrupt received, shutting down...")
-    finally:
-        loop.close()
+        logger.info("🛑 KeyboardInterrupt received")
 
     return 0
 
 if __name__ == "__main__":
-    sys.exit(main())
+    sys.exit(main())

+ 1 - 6
dune-weaver-touch/qml/main.qml

@@ -80,14 +80,9 @@ ApplicationWindow {
     MouseArea {
         anchors.fill: parent
         acceptedButtons: Qt.NoButton  // Don't interfere with other mouse areas
-        hoverEnabled: true
         propagateComposedEvents: true
-        
-        onPressed: {
-            backend.resetActivityTimer()
-        }
 
-        onPositionChanged: {
+        onPressed: {
             backend.resetActivityTimer()
         }
 

+ 1 - 1
dune-weaver-touch/requirements.txt

@@ -1,5 +1,5 @@
 PySide6>=6.5.0
-qasync>=0.27.0
+qasync>=0.28.0
 aiohttp>=3.9.0
 Pillow>=10.0.0
 python-dotenv>=1.0.0