main.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. import sys
  2. import os
  3. import asyncio
  4. import logging
  5. import time
  6. import signal
  7. from pathlib import Path
  8. from PySide6.QtCore import QUrl, QTimer, QObject, QEvent
  9. from PySide6.QtGui import QGuiApplication, QFont, QFontDatabase
  10. from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterType
  11. from PySide6.QtQuickControls2 import QQuickStyle
  12. from qasync import QEventLoop
  13. from dotenv import load_dotenv
  14. from backend import Backend
  15. from models.pattern_model import PatternModel
  16. from models.playlist_model import PlaylistModel
  17. from png_cache_manager import ensure_png_cache_startup
  18. # Load environment variables from .env file if it exists
  19. load_dotenv(Path(__file__).parent / ".env")
  20. # Configure logging
  21. logging.basicConfig(
  22. level=logging.INFO,
  23. format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
  24. )
  25. logger = logging.getLogger(__name__)
  26. class FirstTouchFilter(QObject):
  27. """
  28. Event filter that ignores the first touch event after inactivity.
  29. Many capacitive touchscreens need the first touch to wake up or calibrate,
  30. and this touch often has incorrect coordinates.
  31. """
  32. def __init__(self, idle_threshold_seconds=2.0):
  33. super().__init__()
  34. self.idle_threshold = idle_threshold_seconds
  35. self.last_touch_time = 0
  36. self.ignore_next_touch = False
  37. logger.info(f"👆 First-touch filter initialized (idle threshold: {idle_threshold_seconds}s)")
  38. def eventFilter(self, obj, event):
  39. """Filter out the first touch after idle period"""
  40. try:
  41. event_type = event.type()
  42. # Handle touch events
  43. if event_type == QEvent.Type.TouchBegin:
  44. current_time = time.time()
  45. time_since_last_touch = current_time - self.last_touch_time
  46. # If it's been more than threshold since last touch, ignore this one
  47. if time_since_last_touch > self.idle_threshold:
  48. logger.debug(f"👆 Ignoring wake-up touch (idle for {time_since_last_touch:.1f}s)")
  49. self.last_touch_time = current_time
  50. return True # Filter out (ignore) this event
  51. self.last_touch_time = current_time
  52. elif event_type in (QEvent.Type.TouchUpdate, QEvent.Type.TouchEnd):
  53. # Update last touch time for any touch activity
  54. self.last_touch_time = time.time()
  55. # Pass through the event
  56. return False
  57. except KeyboardInterrupt:
  58. # Re-raise KeyboardInterrupt to allow clean shutdown
  59. raise
  60. except Exception as e:
  61. logger.error(f"Error in eventFilter: {e}")
  62. return False
  63. async def startup_tasks():
  64. """Run async startup tasks"""
  65. logger.info("🚀 Starting dune-weaver-touch async initialization...")
  66. # Ensure PNG cache is available for all WebP previews
  67. try:
  68. logger.info("🎨 Checking PNG preview cache...")
  69. png_cache_success = await ensure_png_cache_startup()
  70. if png_cache_success:
  71. logger.info("✅ PNG cache check completed successfully")
  72. else:
  73. logger.warning("⚠️ PNG cache check completed with warnings")
  74. except Exception as e:
  75. logger.error(f"❌ PNG cache check failed: {e}")
  76. logger.info("✨ dune-weaver-touch startup tasks completed")
  77. def is_pi5():
  78. """Check if running on Raspberry Pi 5"""
  79. try:
  80. with open('/proc/device-tree/model', 'r') as f:
  81. model = f.read()
  82. return 'Pi 5' in model
  83. except Exception:
  84. return False
  85. def main():
  86. # Enable virtual keyboard
  87. os.environ['QT_IM_MODULE'] = 'qtvirtualkeyboard'
  88. app = QGuiApplication(sys.argv)
  89. # Basic style everywhere: the custom control styling (DwSlider, DwSwitch,
  90. # TextField pills) is ignored by native styles (e.g. macOS in dev runs).
  91. QQuickStyle.setStyle("Basic")
  92. # Bundled fonts: Outfit (UI text) + Material Icons Round (icon glyphs).
  93. # The Pi image has no reliable emoji/symbol fonts, so all icons in QML go
  94. # through components/Icon.qml against this icon font.
  95. fonts_dir = Path(__file__).parent / "fonts"
  96. for font_file in sorted(fonts_dir.glob("*.ttf")) + sorted(fonts_dir.glob("*.otf")):
  97. if QFontDatabase.addApplicationFont(str(font_file)) == -1:
  98. logger.warning(f"Failed to load font {font_file.name}")
  99. app.setFont(QFont("Outfit", 10))
  100. # Install first-touch filter to ignore wake-up touches
  101. # Ignores the first touch after 2 seconds of inactivity
  102. first_touch_filter = FirstTouchFilter(idle_threshold_seconds=2.0)
  103. app.installEventFilter(first_touch_filter)
  104. logger.info("✅ First-touch filter installed on application")
  105. # Setup async event loop
  106. loop = QEventLoop(app)
  107. asyncio.set_event_loop(loop)
  108. # Register types
  109. qmlRegisterType(Backend, "DuneWeaver", 1, 0, "Backend")
  110. qmlRegisterType(PatternModel, "DuneWeaver", 1, 0, "PatternModel")
  111. qmlRegisterType(PlaylistModel, "DuneWeaver", 1, 0, "PlaylistModel")
  112. # Load QML
  113. engine = QQmlApplicationEngine()
  114. # Set rotation flag for Pi 5 (display needs 180° rotation via QML)
  115. # This applies regardless of Qt backend (eglfs or linuxfb)
  116. rotate_display = is_pi5()
  117. engine.rootContext().setContextProperty("rotateDisplay", rotate_display)
  118. if rotate_display:
  119. logger.info("🔄 Pi 5 detected - enabling QML rotation (180°)")
  120. qml_file = Path(__file__).parent / "qml" / "main.qml"
  121. engine.load(QUrl.fromLocalFile(str(qml_file)))
  122. if not engine.rootObjects():
  123. return -1
  124. # Schedule startup tasks after a brief delay to ensure event loop is running
  125. def schedule_startup():
  126. try:
  127. # Check if we're in an event loop context
  128. current_loop = asyncio.get_running_loop()
  129. current_loop.create_task(startup_tasks())
  130. except RuntimeError:
  131. # No running loop, create task directly
  132. asyncio.create_task(startup_tasks())
  133. # Use QTimer to delay startup tasks
  134. startup_timer = QTimer()
  135. startup_timer.timeout.connect(schedule_startup)
  136. startup_timer.setSingleShot(True)
  137. startup_timer.start(100) # 100ms delay
  138. # Setup signal handlers for clean shutdown
  139. def signal_handler(signum, frame):
  140. logger.info("🛑 Received shutdown signal, exiting...")
  141. loop.stop()
  142. app.quit()
  143. signal.signal(signal.SIGINT, signal_handler)
  144. signal.signal(signal.SIGTERM, signal_handler)
  145. try:
  146. with loop:
  147. loop.run_forever()
  148. except KeyboardInterrupt:
  149. logger.info("🛑 KeyboardInterrupt received, shutting down...")
  150. finally:
  151. loop.close()
  152. return 0
  153. if __name__ == "__main__":
  154. sys.exit(main())