Procházet zdrojové kódy

Expose touch screen power and brightness to Home Assistant via MQTT

Add ScreenController module that auto-detects the Linux sysfs backlight
device and provides power/brightness control. Register as MQTT discovery
entities (switch + number slider) following the existing LED entity pattern.
Gracefully no-ops on machines without /sys/class/backlight.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tuanchris před 4 měsíci
rodič
revize
a1fc3ee0eb

+ 55 - 0
main.py

@@ -19,6 +19,7 @@ import signal
 import asyncio
 from contextlib import asynccontextmanager
 from modules.led.led_interface import LEDInterface
+from modules.screen.screen_controller import ScreenController
 from modules.led.idle_timeout_manager import idle_timeout_manager
 from modules.core.cache_manager import get_cache_path, generate_image_preview, get_pattern_metadata
 from modules.core.version_manager import version_manager
@@ -200,6 +201,17 @@ async def lifespan(app: FastAPI):
         logger.warning(f"Failed to initialize LED controller: {str(e)}")
         state.led_controller = None
 
+    # Initialize screen controller for LCD backlight control
+    try:
+        state.screen_controller = ScreenController()
+        if state.screen_controller.available:
+            logger.info("Screen controller initialized (backlight control available)")
+        else:
+            logger.info("Screen controller initialized (no backlight device found)")
+    except Exception as e:
+        logger.warning(f"Failed to initialize screen controller: {e}")
+        state.screen_controller = None
+
     # Note: auto_play is now handled in connect_and_home() after homing completes
 
     try:
@@ -3876,6 +3888,49 @@ async def dw_leds_get_idle_timeout():
         "remaining_minutes": remaining_minutes
     }
 
+# ── Screen (LCD backlight) control endpoints ──────────────────────
+
+@app.get("/api/screen/status")
+async def screen_status():
+    """Get screen controller status."""
+    if not state.screen_controller:
+        return {"available": False, "message": "Screen controller not initialized"}
+    return state.screen_controller.get_status()
+
+@app.post("/api/screen/power")
+async def screen_power(request: dict):
+    """Turn screen on/off. Body: {"on": true/false}"""
+    if not state.screen_controller or not state.screen_controller.available:
+        raise HTTPException(status_code=400, detail="Screen control not available")
+
+    on = request.get("on", True)
+    result = state.screen_controller.set_power(on)
+    if not result.get("success"):
+        raise HTTPException(status_code=500, detail=result.get("message", "Unknown error"))
+
+    # Publish updated state to MQTT
+    if state.mqtt_handler and state.mqtt_handler.is_enabled:
+        state.mqtt_handler._publish_screen_state()
+
+    return result
+
+@app.post("/api/screen/brightness")
+async def screen_brightness(request: dict):
+    """Set screen brightness. Body: {"value": 0-max_brightness}"""
+    if not state.screen_controller or not state.screen_controller.available:
+        raise HTTPException(status_code=400, detail="Screen control not available")
+
+    value = request.get("value", 128)
+    result = state.screen_controller.set_brightness(value)
+    if not result.get("success"):
+        raise HTTPException(status_code=500, detail=result.get("message", "Unknown error"))
+
+    # Publish updated state to MQTT
+    if state.mqtt_handler and state.mqtt_handler.is_enabled:
+        state.mqtt_handler._publish_screen_state()
+
+    return result
+
 @app.get("/table_control")
 async def table_control_page(request: Request):
     return get_redirect_response(request)

+ 1 - 0
modules/core/state.py

@@ -101,6 +101,7 @@ class AppState:
         self.wled_ip = None
         self.led_provider = "none"  # "wled", "dw_leds", or "none"
         self.led_controller = None
+        self.screen_controller = None
 
         # DW LED settings
         self.dw_led_num_leds = 60  # Number of LEDs in strip

+ 67 - 0
modules/mqtt/handler.py

@@ -51,6 +51,10 @@ class MQTTHandler(BaseMQTTHandler):
         self.completion_topic = f"{self.device_id}/state/completion"
         self.time_remaining_topic = f"{self.device_id}/state/time_remaining"
 
+        # Screen control topics
+        self.screen_power_topic = f"{self.device_id}/screen/power/set"
+        self.screen_brightness_topic = f"{self.device_id}/screen/brightness/set"
+
         # LED control topics
         self.led_power_topic = f"{self.device_id}/led/power/set"
         self.led_brightness_topic = f"{self.device_id}/led/brightness/set"
@@ -400,6 +404,38 @@ class MQTTHandler(BaseMQTTHandler):
             }
             self._publish_discovery("light", "led_color", led_color_config)
 
+        # Screen Control Entities (only if screen controller is available)
+        if state.screen_controller and state.screen_controller.available:
+            screen_status = state.screen_controller.get_status()
+
+            # Screen Power Switch
+            screen_power_config = {
+                "name": f"{self.device_name} Screen Power",
+                "unique_id": f"{self.device_id}_screen_power",
+                "command_topic": self.screen_power_topic,
+                "state_topic": f"{self.device_id}/screen/power/state",
+                "payload_on": "ON",
+                "payload_off": "OFF",
+                "device": base_device,
+                "icon": "mdi:monitor",
+                "optimistic": False
+            }
+            self._publish_discovery("switch", "screen_power", screen_power_config)
+
+            # Screen Brightness Number
+            screen_brightness_config = {
+                "name": f"{self.device_name} Screen Brightness",
+                "unique_id": f"{self.device_id}_screen_brightness",
+                "command_topic": self.screen_brightness_topic,
+                "state_topic": f"{self.device_id}/screen/brightness/state",
+                "device": base_device,
+                "icon": "mdi:brightness-6",
+                "min": 0,
+                "max": screen_status.get("max_brightness", 255),
+                "mode": "slider"
+            }
+            self._publish_discovery("number", "screen_brightness", screen_brightness_config)
+
     def _publish_discovery(self, component: str, config_type: str, config: dict):
         """Helper method to publish HA discovery configs."""
         if not self.is_enabled:
@@ -551,6 +587,19 @@ class MQTTHandler(BaseMQTTHandler):
         except Exception as e:
             logger.error(f"Error publishing LED state: {e}")
 
+    def _publish_screen_state(self):
+        """Helper to publish screen (LCD backlight) state to MQTT."""
+        if not state.screen_controller or not state.screen_controller.available:
+            return
+
+        try:
+            status = state.screen_controller.get_status()
+            power_state = "ON" if status["power_on"] else "OFF"
+            self.client.publish(f"{self.device_id}/screen/power/state", power_state, retain=True)
+            self.client.publish(f"{self.device_id}/screen/brightness/state", status["brightness"], retain=True)
+        except Exception as e:
+            logger.error(f"Error publishing screen state: {e}")
+
     def update_state(self, current_file=None, is_running=None, playlist=None, playlist_name=None):
         """Update state in Home Assistant. Only publishes the attributes that are explicitly passed."""
         if not self.is_enabled:
@@ -594,6 +643,8 @@ class MQTTHandler(BaseMQTTHandler):
                 (self.led_speed_topic, 0),
                 (self.led_intensity_topic, 0),
                 (self.led_color_topic, 0),
+                (self.screen_power_topic, 0),
+                (self.screen_brightness_topic, 0),
             ])
             # Publish discovery configurations
             self.setup_ha_discovery()
@@ -783,6 +834,18 @@ class MQTTHandler(BaseMQTTHandler):
                                               json.dumps({"r": r, "g": g, "b": b}), retain=True)
                 except json.JSONDecodeError:
                     logger.error(f"Invalid JSON for color command: {msg.payload}")
+            elif msg.topic == self.screen_power_topic:
+                # Handle screen power command
+                payload = msg.payload.decode()
+                if state.screen_controller and state.screen_controller.available:
+                    state.screen_controller.set_power(payload == "ON")
+                    self._publish_screen_state()
+            elif msg.topic == self.screen_brightness_topic:
+                # Handle screen brightness command
+                brightness = int(msg.payload.decode())
+                if state.screen_controller and state.screen_controller.available:
+                    state.screen_controller.set_brightness(brightness)
+                    self._publish_screen_state()
             else:
                 # Handle other commands
                 payload = json.loads(msg.payload.decode())
@@ -816,6 +879,9 @@ class MQTTHandler(BaseMQTTHandler):
                 # Update LED state
                 self._publish_led_state()
 
+                # Update screen state
+                self._publish_screen_state()
+
                 # Publish keepalive status
                 status = {
                     "timestamp": time.time(),
@@ -858,6 +924,7 @@ class MQTTHandler(BaseMQTTHandler):
             self._publish_progress_state()
             self._publish_playlist_settings_state()
             self._publish_led_state()
+            self._publish_screen_state()
 
             # Setup Home Assistant discovery
             self.setup_ha_discovery()

+ 0 - 0
modules/screen/__init__.py


+ 125 - 0
modules/screen/screen_controller.py

@@ -0,0 +1,125 @@
+"""Screen (LCD backlight) controller via Linux sysfs.
+
+Mirrors the sysfs approach used in dune-weaver-touch/backend.py so the main
+FastAPI backend can control the attached touchscreen independently. On dev
+machines without /sys/class/backlight the controller reports available=False
+and all commands no-op gracefully.
+"""
+import logging
+import subprocess
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+
+class ScreenController:
+    def __init__(self):
+        self.brightness_path: str = ""
+        self.max_brightness: int = 255
+        self._current_brightness: int = 0
+        self._power_on: bool = True
+        self.available: bool = False
+
+        self._detect_backlight()
+
+    # ── Detection ──────────────────────────────────────────────
+
+    def _detect_backlight(self):
+        """Auto-detect the sysfs backlight device, path, and max brightness."""
+        backlight_base = Path("/sys/class/backlight")
+        if not backlight_base.exists():
+            logger.info("No /sys/class/backlight found — screen control unavailable")
+            return
+
+        try:
+            devices = [d.name for d in backlight_base.iterdir() if d.is_dir()]
+        except Exception as e:
+            logger.warning(f"Failed to list backlight devices: {e}")
+            return
+
+        if not devices:
+            logger.info("No backlight devices found")
+            return
+
+        device = devices[0]
+        self.brightness_path = f"/sys/class/backlight/{device}/brightness"
+        logger.info(f"Auto-detected backlight device: {device}")
+
+        # Read max_brightness
+        max_path = f"/sys/class/backlight/{device}/max_brightness"
+        try:
+            self.max_brightness = int(Path(max_path).read_text().strip())
+            logger.info(f"Max brightness: {self.max_brightness}")
+        except Exception as e:
+            self.max_brightness = 255
+            logger.warning(f"Failed to read max_brightness, defaulting to 255: {e}")
+
+        # Read current brightness
+        try:
+            self._current_brightness = int(Path(self.brightness_path).read_text().strip())
+            self._power_on = self._current_brightness > 0
+            logger.info(f"Current brightness: {self._current_brightness}/{self.max_brightness}")
+        except Exception as e:
+            logger.warning(f"Failed to read current brightness: {e}")
+
+        self.available = True
+
+    # ── Public API ─────────────────────────────────────────────
+
+    def set_brightness(self, value: int) -> dict:
+        """Set backlight brightness (0 to max_brightness)."""
+        if not self.available:
+            return {"success": False, "message": "Screen control not available"}
+
+        value = max(0, min(value, self.max_brightness))
+        try:
+            subprocess.run(
+                ["sudo", "sh", "-c", f"echo {value} > {self.brightness_path}"],
+                check=True, timeout=5
+            )
+            self._current_brightness = value
+            if value > 0:
+                self._power_on = True
+            return {"success": True, "brightness": value}
+        except Exception as e:
+            logger.error(f"Failed to set brightness: {e}")
+            return {"success": False, "message": str(e)}
+
+    def set_power(self, on: bool) -> dict:
+        """Turn screen on or off via framebuffer blank + backlight."""
+        if not self.available:
+            return {"success": False, "message": "Screen control not available"}
+
+        try:
+            if on:
+                # Restore brightness + unblank framebuffer
+                restore = self._current_brightness if self._current_brightness > 0 else self.max_brightness
+                subprocess.run(
+                    ["sudo", "sh", "-c",
+                     f"echo 0 > /sys/class/graphics/fb0/blank && echo {restore} > {self.brightness_path}"],
+                    check=True, timeout=5
+                )
+                self._current_brightness = restore
+                self._power_on = True
+            else:
+                # Zero brightness + blank framebuffer
+                subprocess.run(
+                    ["sudo", "sh", "-c",
+                     f"echo 0 > {self.brightness_path} && echo 1 > /sys/class/graphics/fb0/blank"],
+                    check=True, timeout=5
+                )
+                self._power_on = False
+
+            return {"success": True, "power_on": self._power_on}
+        except Exception as e:
+            logger.error(f"Failed to set screen power: {e}")
+            return {"success": False, "message": str(e)}
+
+    def get_status(self) -> dict:
+        """Return current screen state."""
+        return {
+            "available": self.available,
+            "power_on": self._power_on,
+            "brightness": self._current_brightness,
+            "max_brightness": self.max_brightness,
+        }