diff --git a/server/src/ledgrab/api/routes/automations.py b/server/src/ledgrab/api/routes/automations.py index 4556877..e8f3142 100644 --- a/server/src/ledgrab/api/routes/automations.py +++ b/server/src/ledgrab/api/routes/automations.py @@ -12,6 +12,7 @@ from ledgrab.api.dependencies import ( get_scene_preset_store, ) from ledgrab.api.schemas.automations import ( + ActionSchema, AutomationCreate, AutomationListResponse, AutomationResponse, @@ -21,6 +22,7 @@ from ledgrab.api.schemas.automations import ( ) from ledgrab.core.automations.automation_engine import AutomationEngine from ledgrab.storage.automation import ( + Action, ApplicationRule, DisplayStateRule, HomeAssistantRule, @@ -32,11 +34,13 @@ from ledgrab.storage.automation import ( StartupRule, SystemIdleRule, TimeOfDayRule, + WebhookAction, WebhookRule, ) from ledgrab.storage.automation_store import AutomationStore from ledgrab.storage.scene_preset_store import ScenePresetStore from ledgrab.utils import get_logger +from ledgrab.utils.safe_source import validate_polling_url from ledgrab.storage.base_store import EntityNotFoundError logger = get_logger(__name__) @@ -113,6 +117,38 @@ def _rule_to_schema(r: Rule) -> RuleSchema: return RuleSchema(**d) +def _action_from_schema(s: ActionSchema) -> Action: + """Build a domain Action from its request schema, validating the webhook URL. + + The SSRF gate runs here (save time) AND again at fire time, closing the + DNS-rebinding window. A bad/blocked URL rejects the whole save with 400. + """ + if s.action_type != "webhook": + raise ValueError(f"Unknown action type: {s.action_type}") + url = (s.webhook_url or "").strip() + if not url: + raise ValueError("webhook action requires a webhook_url") + method = (s.method or "POST").upper() + if method not in ("POST", "PUT", "GET"): + raise ValueError(f"Invalid webhook method: {method}. Must be POST, PUT or GET.") + fire_on = s.fire_on or "activate" + if fire_on not in ("activate", "deactivate", "both"): + raise ValueError(f"Invalid fire_on: {fire_on}. Must be activate, deactivate or both.") + # Raises HTTPException(400) on a blocked/loopback/metadata target. + validate_polling_url(url) + return WebhookAction( + webhook_url=url, + method=method, + body_template=s.body_template or "", + content_type=s.content_type or "application/json", + fire_on=fire_on, + ) + + +def _action_to_schema(a: Action) -> ActionSchema: + return ActionSchema(**a.to_dict()) + + def _automation_to_response( automation, engine: AutomationEngine, request: Request = None ) -> AutomationResponse: @@ -148,6 +184,7 @@ def _automation_to_response( last_activated_at=state.get("last_activated_at"), last_deactivated_at=state.get("last_deactivated_at"), tags=automation.tags, + actions=[_action_to_schema(a) for a in getattr(automation, "actions", [])], icon=getattr(automation, "icon", "") or "", icon_color=getattr(automation, "icon_color", "") or "", created_at=automation.created_at, @@ -204,6 +241,7 @@ async def create_automation( try: rules = [_rule_from_schema(r) for r in data.rules] + actions = [_action_from_schema(a) for a in data.actions] except EntityNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -219,6 +257,7 @@ async def create_automation( deactivation_mode=data.deactivation_mode, deactivation_scene_preset_id=data.deactivation_scene_preset_id, tags=data.tags, + actions=actions, icon=data.icon, icon_color=data.icon_color, ) @@ -301,6 +340,13 @@ async def update_automation( except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) + actions = None + if data.actions is not None: + try: + actions = [_action_from_schema(a) for a in data.actions] + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + try: # If disabling, deactivate first if data.enabled is False: @@ -315,6 +361,7 @@ async def update_automation( rules=rules, deactivation_mode=data.deactivation_mode, tags=data.tags, + actions=actions, icon=data.icon, icon_color=data.icon_color, ) diff --git a/server/src/ledgrab/api/routes/devices.py b/server/src/ledgrab/api/routes/devices.py index 910ece8..fae0bb2 100644 --- a/server/src/ledgrab/api/routes/devices.py +++ b/server/src/ledgrab/api/routes/devices.py @@ -96,9 +96,11 @@ def _device_to_response(device) -> DeviceResponse: espnow_channel=device.espnow_channel, hue_paired=bool(device.hue_username and device.hue_client_key), hue_entertainment_group_id=device.hue_entertainment_group_id, + hue_gradient_mode=device.hue_gradient_mode, yeelight_min_interval_ms=device.yeelight_min_interval_ms, wiz_min_interval_ms=device.wiz_min_interval_ms, lifx_min_interval_ms=device.lifx_min_interval_ms, + lifx_per_zone=device.lifx_per_zone, govee_min_interval_ms=device.govee_min_interval_ms, opc_channel=device.opc_channel, nanoleaf_paired=bool(device.nanoleaf_token), @@ -262,6 +264,9 @@ async def create_device( hue_username=device_data.hue_username or "", hue_client_key=device_data.hue_client_key or "", hue_entertainment_group_id=device_data.hue_entertainment_group_id or "", + hue_gradient_mode=( + device_data.hue_gradient_mode if device_data.hue_gradient_mode is not None else True + ), yeelight_min_interval_ms=( device_data.yeelight_min_interval_ms if device_data.yeelight_min_interval_ms is not None @@ -277,6 +282,7 @@ async def create_device( if device_data.lifx_min_interval_ms is not None else 50 ), + lifx_per_zone=bool(device_data.lifx_per_zone), govee_min_interval_ms=( device_data.govee_min_interval_ms if device_data.govee_min_interval_ms is not None @@ -633,9 +639,11 @@ async def update_device( hue_username=update_data.hue_username, hue_client_key=update_data.hue_client_key, hue_entertainment_group_id=update_data.hue_entertainment_group_id, + hue_gradient_mode=update_data.hue_gradient_mode, yeelight_min_interval_ms=update_data.yeelight_min_interval_ms, wiz_min_interval_ms=update_data.wiz_min_interval_ms, lifx_min_interval_ms=update_data.lifx_min_interval_ms, + lifx_per_zone=update_data.lifx_per_zone, govee_min_interval_ms=update_data.govee_min_interval_ms, opc_channel=update_data.opc_channel, nanoleaf_token=update_data.nanoleaf_token, diff --git a/server/src/ledgrab/api/routes/mqtt.py b/server/src/ledgrab/api/routes/mqtt.py index 347d21f..0c1cda4 100644 --- a/server/src/ledgrab/api/routes/mqtt.py +++ b/server/src/ledgrab/api/routes/mqtt.py @@ -42,6 +42,8 @@ def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse password_set=bool(source.password), client_id=source.client_id, base_topic=source.base_topic, + publish_ha_discovery=getattr(source, "publish_ha_discovery", False), + discovery_prefix=getattr(source, "discovery_prefix", "homeassistant"), connected=runtime.is_connected if runtime else False, description=source.description, tags=source.tags, @@ -90,6 +92,8 @@ async def create_mqtt_source( password=data.password, client_id=data.client_id, base_topic=data.base_topic, + publish_ha_discovery=data.publish_ha_discovery, + discovery_prefix=data.discovery_prefix, description=data.description, tags=data.tags, icon=data.icon, @@ -97,6 +101,8 @@ async def create_mqtt_source( ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) + # Publish HA discovery if the new source opted in. + await manager.sync_discovery(source.id) fire_entity_event("mqtt_source", "created", source.id) return _to_response(source, manager) @@ -141,6 +147,8 @@ async def update_mqtt_source( password=data.password, client_id=data.client_id, base_topic=data.base_topic, + publish_ha_discovery=data.publish_ha_discovery, + discovery_prefix=data.discovery_prefix, description=data.description, tags=data.tags, icon=data.icon, @@ -151,6 +159,8 @@ async def update_mqtt_source( except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) await manager.update_source(source_id) + # Reconcile HA discovery (publish if enabled, clear if turned off). + await manager.sync_discovery(source_id) fire_entity_event("mqtt_source", "updated", source.id) return _to_response(source, manager) @@ -162,6 +172,9 @@ async def delete_mqtt_source( store: MQTTSourceStore = Depends(get_mqtt_store), manager: MQTTManager = Depends(get_mqtt_manager), ): + # Clear any HA discovery configs (needs the source still present to build + # the exact retained topics) before deleting the row. + await manager.disable_discovery(source_id) try: store.delete_source(source_id) except EntityNotFoundError: diff --git a/server/src/ledgrab/api/schemas/automations.py b/server/src/ledgrab/api/schemas/automations.py index c484ac4..4853cc4 100644 --- a/server/src/ledgrab/api/schemas/automations.py +++ b/server/src/ledgrab/api/schemas/automations.py @@ -108,6 +108,31 @@ class RuleSchema(BaseModel): ConditionSchema = RuleSchema +class ActionSchema(BaseModel): + """A single outbound action fired alongside scene activation/deactivation.""" + + action_type: str = Field(description="Action type discriminator (e.g. 'webhook')") + # Webhook action fields + webhook_url: str | None = Field( + None, max_length=2048, description="Target URL for the webhook action" + ) + method: str | None = Field(None, description="'POST', 'PUT', or 'GET' (for webhook action)") + body_template: str | None = Field( + None, + max_length=8192, + description=( + "Request body template (for webhook action). Tokens: {{automation_name}}, " + "{{automation_id}}, {{event}}, {{timestamp}}." + ), + ) + content_type: str | None = Field( + None, description="Content-Type header for the webhook body (default application/json)" + ) + fire_on: str | None = Field( + None, description="'activate', 'deactivate', or 'both' (for webhook action)" + ) + + class AutomationCreate(BaseModel): """Request to create an automation.""" @@ -123,6 +148,9 @@ class AutomationCreate(BaseModel): None, description="Scene preset for fallback deactivation" ) tags: List[str] = Field(default_factory=list, description="User-defined tags") + actions: List[ActionSchema] = Field( + default_factory=list, description="Outbound actions (e.g. webhooks)" + ) icon: str | None = Field( None, max_length=64, @@ -148,6 +176,7 @@ class AutomationUpdate(BaseModel): None, description="Scene preset for fallback deactivation" ) tags: List[str] | None = None + actions: List[ActionSchema] | None = Field(None, description="Outbound actions (e.g. webhooks)") icon: str | None = Field( None, max_length=64, @@ -172,6 +201,9 @@ class AutomationResponse(BaseModel): deactivation_mode: str = Field(default="none", description="Deactivation behavior") deactivation_scene_preset_id: str | None = Field(None, description="Fallback scene preset") tags: List[str] = Field(default_factory=list, description="User-defined tags") + actions: List[ActionSchema] = Field( + default_factory=list, description="Outbound actions (e.g. webhooks)" + ) webhook_url: str | None = Field( None, description="Webhook URL for the first webhook rule (if any)" ) diff --git a/server/src/ledgrab/api/schemas/devices.py b/server/src/ledgrab/api/schemas/devices.py index d84d75c..6540f2b 100644 --- a/server/src/ledgrab/api/schemas/devices.py +++ b/server/src/ledgrab/api/schemas/devices.py @@ -59,6 +59,10 @@ class DeviceCreate(BaseModel): hue_entertainment_group_id: str | None = Field( None, description="Hue entertainment group/zone ID" ) + hue_gradient_mode: bool | None = Field( + None, + description="Map the strip across gradient-lightstrip channels vs one record per light", + ) # Yeelight fields yeelight_min_interval_ms: int | None = Field( None, @@ -80,6 +84,10 @@ class DeviceCreate(BaseModel): le=10000, description="LIFX client-side rate limit between commands in ms (default 50)", ) + lifx_per_zone: bool | None = Field( + None, + description="Stream individual zones/tiles (multizone Z/Beam, Tile/Canvas) vs single colour", + ) # Govee fields govee_min_interval_ms: int | None = Field( None, @@ -198,6 +206,10 @@ class DeviceUpdate(BaseModel): hue_username: str | None = Field(None, description="Hue bridge username") hue_client_key: str | None = Field(None, description="Hue entertainment client key") hue_entertainment_group_id: str | None = Field(None, description="Hue entertainment group ID") + hue_gradient_mode: bool | None = Field( + None, + description="Map the strip across gradient-lightstrip channels vs one record per light", + ) yeelight_min_interval_ms: int | None = Field( None, ge=0, le=10000, description="Yeelight client-side rate limit in ms" ) @@ -207,6 +219,9 @@ class DeviceUpdate(BaseModel): lifx_min_interval_ms: int | None = Field( None, ge=0, le=10000, description="LIFX client-side rate limit in ms" ) + lifx_per_zone: bool | None = Field( + None, description="Stream individual zones/tiles (multizone/matrix) vs single colour" + ) govee_min_interval_ms: int | None = Field( None, ge=0, le=10000, description="Govee client-side rate limit in ms" ) @@ -442,11 +457,19 @@ class DeviceResponse(BaseModel): ), ) hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID") + hue_gradient_mode: bool = Field( + default=True, + description="Map the strip across gradient-lightstrip channels vs one record per light", + ) yeelight_min_interval_ms: int = Field( default=500, description="Yeelight client-side rate limit in ms" ) wiz_min_interval_ms: int = Field(default=50, description="WiZ client-side rate limit in ms") lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms") + lifx_per_zone: bool = Field( + default=False, + description="Stream individual zones/tiles (multizone/matrix) vs single colour", + ) govee_min_interval_ms: int = Field(default=50, description="Govee client-side rate limit in ms") opc_channel: int = Field(default=0, description="OPC channel (0 = broadcast to all)") nanoleaf_paired: bool = Field( diff --git a/server/src/ledgrab/api/schemas/mqtt.py b/server/src/ledgrab/api/schemas/mqtt.py index 268135a..ad2006d 100644 --- a/server/src/ledgrab/api/schemas/mqtt.py +++ b/server/src/ledgrab/api/schemas/mqtt.py @@ -16,6 +16,12 @@ class MQTTSourceCreate(BaseModel): password: str = Field(default="", description="Broker password (optional)") client_id: str = Field(default="ledgrab", description="MQTT client ID") base_topic: str = Field(default="ledgrab", description="Base topic prefix") + publish_ha_discovery: bool = Field( + default=False, description="Publish Home Assistant MQTT auto-discovery configs" + ) + discovery_prefix: str = Field( + default="homeassistant", description="HA MQTT discovery prefix (default 'homeassistant')" + ) description: str | None = Field(None, description="Optional description", max_length=500) tags: List[str] = Field(default_factory=list, description="User-defined tags") icon: str | None = Field( @@ -40,6 +46,10 @@ class MQTTSourceUpdate(BaseModel): password: str | None = Field(None, description="Broker password") client_id: str | None = Field(None, description="MQTT client ID") base_topic: str | None = Field(None, description="Base topic prefix") + publish_ha_discovery: bool | None = Field( + None, description="Publish Home Assistant MQTT auto-discovery configs" + ) + discovery_prefix: str | None = Field(None, description="HA MQTT discovery prefix") description: str | None = Field(None, description="Optional description", max_length=500) tags: List[str] | None = None icon: str | None = Field( @@ -65,6 +75,8 @@ class MQTTSourceResponse(BaseModel): password_set: bool = Field(default=False, description="Whether a password is configured") client_id: str = Field(description="MQTT client ID") base_topic: str = Field(description="Base topic prefix") + publish_ha_discovery: bool = Field(default=False, description="HA MQTT discovery enabled") + discovery_prefix: str = Field(default="homeassistant", description="HA MQTT discovery prefix") connected: bool = Field(default=False, description="Whether the broker connection is active") description: str | None = Field(None, description="Description") tags: List[str] = Field(default_factory=list, description="User-defined tags") diff --git a/server/src/ledgrab/core/automations/automation_engine.py b/server/src/ledgrab/core/automations/automation_engine.py index b454490..4ba63f5 100644 --- a/server/src/ledgrab/core/automations/automation_engine.py +++ b/server/src/ledgrab/core/automations/automation_engine.py @@ -812,6 +812,8 @@ class AutomationEngine: # Record the activation too — a no-scene activation is still a # successful activation and must appear in the audit log. self._audit_activation(automation) + await self._fire_actions(automation, "activate") + await self._publish_mqtt_state(automation.id, True) return if not self._scene_preset_store or not self._target_store or not self._device_store: @@ -858,6 +860,60 @@ class AutomationEngine: # Audit record — best-effort (shared helper, also used by no-scene path). self._audit_activation(automation) + await self._fire_actions(automation, "activate") + await self._publish_mqtt_state(automation.id, True) + + async def _fire_actions(self, automation: Automation, event: str) -> None: + """Fire any outbound actions (e.g. webhooks) for this transition. + + Best-effort and never raises into the activation path: a hung or + failing endpoint is logged/audited but must not stall the evaluation + loop or abort scene activation. + """ + actions = getattr(automation, "actions", None) + if not actions: + return + from ledgrab.storage.automation import WebhookAction + from ledgrab.core.automations.webhook_action import fire_webhook_action, should_fire + + for action in actions: + if not isinstance(action, WebhookAction) or not should_fire(action, event): + continue + try: + ok, err = await fire_webhook_action(action, automation, event) + except Exception as exc: # noqa: BLE001 — defensive; fire is already best-effort + logger.warning( + "Action fire raised for '%s': %s", automation.name, type(exc).__name__ + ) + ok, err = False, type(exc).__name__ + self._audit_webhook(automation, event, ok, err) + + def _audit_webhook(self, automation: Automation, event: str, ok: bool, err: str | None) -> None: + """Best-effort audit entry for a webhook fire (success or failure).""" + try: + from ledgrab.core.activity_log.recorder import get_module_recorder + from ledgrab.core.activity_log.sanitize import sanitize_display + from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity + + rec = get_module_recorder() + if rec is None: + return + safe_name = sanitize_display(automation.name) if automation.name else automation.id + rec.record( + category=ActivityCategory.CAPTURE, + action="automation.webhook_fired", + severity=ActivitySeverity.INFO if ok else ActivitySeverity.WARNING, + actor="system", + entity_type="automation", + entity_id=automation.id, + entity_name=safe_name, + message=( + f"Webhook for '{safe_name}' {'fired' if ok else 'failed'} on {event}" + + ("" if ok else f" ({sanitize_display(err) if err else 'error'})") + ), + ) + except Exception: + pass async def _apply_manual_scene(self, automation: Automation) -> tuple[str, list[str]]: """Apply the automation's scene once for a manual trigger. @@ -987,6 +1043,22 @@ class AutomationEngine: except Exception: pass + # Fire any outbound deactivate actions (best-effort). Skipped when the + # automation was since-deleted (no actions to read). + if automation is not None: + await self._fire_actions(automation, "deactivate") + await self._publish_mqtt_state(automation_id, False) + + async def _publish_mqtt_state(self, automation_id: str, active: bool) -> None: + """Best-effort publish of the automation's active state to HA discovery.""" + mgr = self._mqtt_manager + if mgr is None or not hasattr(mgr, "publish_automation_state_all"): + return + try: + await mgr.publish_automation_state_all(automation_id, active) + except Exception: # noqa: BLE001 — never raise into the engine + pass + async def _deactivate_revert(self, automation_id: str) -> None: """Revert to pre-activation snapshot.""" snapshot = self._pre_activation_snapshots.pop(automation_id, None) diff --git a/server/src/ledgrab/core/automations/webhook_action.py b/server/src/ledgrab/core/automations/webhook_action.py new file mode 100644 index 0000000..90212f4 --- /dev/null +++ b/server/src/ledgrab/core/automations/webhook_action.py @@ -0,0 +1,102 @@ +"""Outbound webhook action firing for the automation engine. + +When an automation activates or deactivates, any attached +:class:`~ledgrab.storage.automation.WebhookAction` performs a best-effort +outbound HTTP request (Discord / IFTTT / Zapier / Node-RED / Home Assistant +webhooks). Firing is fire-and-forget: a hung or failing endpoint is logged and +audited but never raises into the activation path. + +Security: the target URL is SSRF-gated via :func:`validate_polling_url` (LAN +allowed so users can hit Node-RED / HA on their own network; loopback, +link-local / cloud-metadata, multicast and reserved ranges blocked) at **both** +save time (in the route) and fire time (here) — re-validating at fire time +closes the DNS-rebinding window. Redirects are not followed. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import httpx +from fastapi import HTTPException + +from ledgrab.storage.automation import Automation, WebhookAction +from ledgrab.utils import get_logger +from ledgrab.utils.safe_source import validate_polling_url + +logger = get_logger(__name__) + +# A webhook must never stall the ~1 Hz evaluation loop. +_WEBHOOK_TIMEOUT_S = 5.0 + + +def render_template(template: str, automation: Automation, event: str) -> str: + """Substitute the supported ``{{token}}`` placeholders in *template*. + + Tokens: ``{{automation_name}}``, ``{{automation_id}}``, ``{{event}}`` + (``activate``/``deactivate``), ``{{timestamp}}`` (ISO-8601 UTC). Unknown + tokens are left untouched. + """ + replacements = { + "{{automation_name}}": automation.name, + "{{automation_id}}": automation.id, + "{{event}}": event, + "{{timestamp}}": datetime.now(timezone.utc).isoformat(), + } + out = template + for token, value in replacements.items(): + out = out.replace(token, value) + return out + + +def should_fire(action: WebhookAction, event: str) -> bool: + """Whether *action* fires for this transition (``activate``/``deactivate``).""" + return action.fire_on == event or action.fire_on == "both" + + +async def fire_webhook_action( + action: WebhookAction, + automation: Automation, + event: str, +) -> tuple[bool, str | None]: + """Fire a single webhook action. Best-effort: never raises. + + Returns ``(ok, error)`` where ``ok`` is True on a 2xx response and + ``error`` is a short, secret-free reason on failure. + """ + url = action.webhook_url.strip() + if not url: + return False, "no URL configured" + + # Re-validate at fire time (DNS-rebinding window). HTTPException carries a + # 4xx detail; surface a short reason rather than raising into the engine. + try: + validate_polling_url(url) + except HTTPException as exc: + logger.warning("Webhook for '%s' blocked by SSRF policy: %s", automation.name, exc.detail) + return False, "blocked by SSRF policy" + + body = render_template(action.body_template, automation, event) + headers = {"Content-Type": action.content_type or "application/json"} + + try: + async with httpx.AsyncClient(timeout=_WEBHOOK_TIMEOUT_S, follow_redirects=False) as client: + kwargs: dict = {"headers": headers} + # Only attach a body for write methods with content to send. + if action.method in ("POST", "PUT") and body: + kwargs["content"] = body.encode("utf-8") + response = await client.request(action.method, url, **kwargs) + except Exception as exc: # noqa: BLE001 — never propagate into activation + # Never log the rendered body or the exception repr (may carry the URL + # with embedded secrets) — the type name is enough to diagnose. + logger.warning("Webhook for '%s' failed: %s", automation.name, type(exc).__name__) + return False, f"request failed: {type(exc).__name__}" + + ok = 200 <= response.status_code < 300 + if not ok: + logger.warning("Webhook for '%s' returned HTTP %d", automation.name, response.status_code) + return False, f"HTTP {response.status_code}" + logger.info( + "Webhook for '%s' fired on %s (HTTP %d)", automation.name, event, response.status_code + ) + return True, None diff --git a/server/src/ledgrab/core/devices/device_config.py b/server/src/ledgrab/core/devices/device_config.py index b9bc49f..f1a5670 100644 --- a/server/src/ledgrab/core/devices/device_config.py +++ b/server/src/ledgrab/core/devices/device_config.py @@ -79,6 +79,9 @@ class HueConfig(BaseDeviceConfig): hue_username: str = "" hue_client_key: str = "" hue_entertainment_group_id: str = "" + # Map the strip across the entertainment configuration's channels + # (gradient-lightstrip segments) instead of one record per light. + hue_gradient_mode: bool = True @dataclass(frozen=True) @@ -115,6 +118,8 @@ class LIFXConfig(BaseDeviceConfig): device_type: Literal["lifx"] = "lifx" lifx_min_interval_ms: int = 50 + # Per-zone/tile streaming (Z/Beam multizone, Tile/Canvas matrix) vs single colour. + lifx_per_zone: bool = False @dataclass(frozen=True) diff --git a/server/src/ledgrab/core/devices/hue_client.py b/server/src/ledgrab/core/devices/hue_client.py index b126c41..79c78e8 100644 --- a/server/src/ledgrab/core/devices/hue_client.py +++ b/server/src/ledgrab/core/devices/hue_client.py @@ -9,6 +9,7 @@ from typing import List, Tuple import numpy as np from ledgrab.core.devices.led_client import DeviceHealth, LEDClient +from ledgrab.core.devices.pixel_reduce import resample_to_n from ledgrab.utils import get_logger logger = get_logger(__name__) @@ -24,15 +25,40 @@ COLOR_SPACE_RGB = 0x00 HEADER_SIZE = 16 +def parse_entertainment_channels(config_json: dict) -> List[int]: + """Extract ordered channel ids from an entertainment_configuration GET. + + Entertainment API v2 keys stream records by *channel*, not by light. A + plain bulb contributes one channel; a gradient lightstrip contributes up + to five (one per segment). We order channels left-to-right by their + spatial ``position`` (x then y) so a strip maps across the segments in + physical order. Channels without a position fall back to channel-id order. + """ + data = config_json.get("data") or [] + if not data: + return [] + channels = data[0].get("channels") or [] + + def _key(c: dict) -> tuple: + pos = c.get("position") or {} + return (pos.get("x", 0.0), pos.get("y", 0.0), c.get("channel_id", 0)) + + ordered = sorted(channels, key=_key) + return [int(c["channel_id"]) for c in ordered if "channel_id" in c] + + def _build_entertainment_frame( - lights: List[Tuple[int, int, int]], + colors: List[Tuple[int, int, int]], brightness: int = 255, sequence: int = 0, + channel_ids: List[int] | None = None, ) -> bytes: """Build a Hue Entertainment API v2 UDP frame. - Each light gets 7 bytes: [light_id(2B)][R(2B)][G(2B)][B(2B)] - Colors are 16-bit (0-65535). We scale 8-bit RGB + brightness. + Each record is 7 bytes: [channel_id(1B)][R(2B)][G(2B)][B(2B)]. Colors are + 16-bit (0-65535). Record ``i`` is keyed by ``channel_ids[i]`` when a + channel map is supplied (gradient-segment mode); otherwise it falls back + to the 0-based index (one light = one channel, the legacy behaviour). """ # Header header = bytearray(HEADER_SIZE) @@ -45,15 +71,15 @@ def _build_entertainment_frame( header[14] = COLOR_SPACE_RGB header[15] = 0x00 # reserved - # Light data + # Channel data # Note: brightness already applied by processor loop (_cached_brightness) data = bytearray() - for idx, (r, g, b) in enumerate(lights): - light_id = idx # 0-based light index in entertainment group + for idx, (r, g, b) in enumerate(colors): + channel_id = channel_ids[idx] if channel_ids and idx < len(channel_ids) else idx r16 = int(r * 257) # scale 0-255 to 0-65535 g16 = int(g * 257) b16 = int(b * 257) - data += struct.pack(">BHHH", light_id, r16, g16, b16) + data += struct.pack(">BHHH", channel_id & 0xFF, r16, g16, b16) return bytes(header) + bytes(data) @@ -62,7 +88,11 @@ class HueClient(LEDClient): """LED client for Philips Hue Entertainment API streaming. Uses UDP (optionally DTLS) to stream color data at ~25 fps to a Hue - entertainment group. Each light in the group is treated as one "LED". + entertainment group. In ``gradient_mode`` (the default) the client + discovers the entertainment configuration's *channels* on connect and + maps the strip across them, so a gradient lightstrip shows a gradient + instead of a single averaged colour. With gradient mode off (or if + discovery fails) it falls back to one record per light by index. """ def __init__( @@ -72,6 +102,7 @@ class HueClient(LEDClient): hue_username: str = "", hue_client_key: str = "", hue_entertainment_group_id: str = "", + gradient_mode: bool = True, **kwargs, ): self._bridge_ip = url.replace("hue://", "").rstrip("/") @@ -79,15 +110,55 @@ class HueClient(LEDClient): self._username = hue_username self._client_key = hue_client_key self._group_id = hue_entertainment_group_id + self._gradient_mode = gradient_mode + self._channel_ids: List[int] = [] self._sock: socket.socket | None = None self._connected = False self._sequence = 0 self._dtls_sock = None + @property + def device_led_count(self) -> int | None: + # In gradient mode the discovered channel count is authoritative. + if self._gradient_mode and self._channel_ids: + return len(self._channel_ids) + return self._led_count or None + + async def _fetch_channels(self) -> None: + """Best-effort discovery of the entertainment configuration channels.""" + import httpx + + url = ( + f"https://{self._bridge_ip}/clip/v2/resource/" + f"entertainment_configuration/{self._group_id}" + ) + headers = {"hue-application-key": self._username} + async with httpx.AsyncClient(verify=False, timeout=5.0) as client: + resp = await client.get(url, headers=headers) + resp.raise_for_status() + self._channel_ids = parse_entertainment_channels(resp.json()) + if self._channel_ids: + logger.info( + "Hue %s: mapped strip across %d channels", self._bridge_ip, len(self._channel_ids) + ) + async def connect(self) -> bool: # Activate entertainment streaming via REST API await self._activate_streaming(True) + # Best-effort channel discovery for gradient-segment mapping. On any + # failure (old bridge, network) we degrade to legacy 1-light-1-record. + if self._gradient_mode: + try: + await self._fetch_channels() + except Exception as exc: # noqa: BLE001 — degrade, never fail connect + logger.warning( + "Hue %s: channel discovery failed, using per-light mapping (%s)", + self._bridge_ip, + exc, + ) + self._channel_ids = [] + # Open UDP socket for entertainment streaming self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self._sock.setblocking(False) @@ -179,12 +250,19 @@ class HueClient(LEDClient): if not self._connected: return - if isinstance(pixels, np.ndarray): - light_colors = [tuple(pixels[i]) for i in range(min(len(pixels), self._led_count))] - else: - light_colors = pixels[: self._led_count] + # Resample the strip to the number of addressable elements: the + # discovered channel count in gradient mode, else the configured + # light count. ``resample_to_n`` spreads the strip spatially and + # handles both the np.ndarray and list-of-tuples inputs. + target = len(self._channel_ids) if self._channel_ids else self._led_count + colors = resample_to_n(pixels, target) - frame = _build_entertainment_frame(light_colors, brightness, self._sequence) + frame = _build_entertainment_frame( + colors, + brightness, + self._sequence, + channel_ids=self._channel_ids or None, + ) self._sequence = (self._sequence + 1) & 0xFF try: diff --git a/server/src/ledgrab/core/devices/hue_provider.py b/server/src/ledgrab/core/devices/hue_provider.py index e7fa21c..96dabae 100644 --- a/server/src/ledgrab/core/devices/hue_provider.py +++ b/server/src/ledgrab/core/devices/hue_provider.py @@ -50,6 +50,7 @@ class HueDeviceProvider(LEDDeviceProvider): hue_username=config.hue_username, hue_client_key=config.hue_client_key, hue_entertainment_group_id=config.hue_entertainment_group_id, + gradient_mode=getattr(config, "hue_gradient_mode", True), ) async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: diff --git a/server/src/ledgrab/core/devices/lifx_client.py b/server/src/ledgrab/core/devices/lifx_client.py index 46f5d2b..07d8c98 100644 --- a/server/src/ledgrab/core/devices/lifx_client.py +++ b/server/src/ledgrab/core/devices/lifx_client.py @@ -29,6 +29,7 @@ import numpy as np from ledgrab.core.devices.led_client import DeviceHealth, LEDClient from ledgrab.core.devices.pixel_reduce import average_color as _average_color +from ledgrab.core.devices.pixel_reduce import resample_to_n from ledgrab.utils import get_logger logger = get_logger(__name__) @@ -41,6 +42,21 @@ MSG_GET_SERVICE = 2 MSG_STATE_SERVICE = 3 MSG_SET_POWER = 21 MSG_SET_COLOR = 102 +# Multizone (Z / Beam) and matrix (Tile / Canvas) per-pixel messages. +MSG_GET_COLOR_ZONES = 502 +MSG_STATE_ZONE = 503 +MSG_STATE_MULTIZONE = 506 +MSG_SET_EXTENDED_COLOR_ZONES = 510 +MSG_GET_DEVICE_CHAIN = 701 +MSG_STATE_DEVICE_CHAIN = 702 +MSG_SET_TILE_STATE_64 = 715 + +_EXT_ZONE_MAX = 82 # SetExtendedColorZones carries a fixed 82-slot HSBK array +_TILE_PIXELS = 64 # SetTileState64 always carries 64 HSBK +_TILE_STRUCT_SIZE = 55 # bytes per Tile struct in StateDeviceChain +_TILE_CHAIN_MAX = 16 # StateDeviceChain always lists 16 tile slots +# Apply field for zone writes: 0=NO_APPLY (buffer), 1=APPLY, 2=APPLY_ONLY +_ZONE_APPLY = 1 # Frame field byte 0 of the protocol header: tagged=1, addressable=1, protocol=1024 _FRAME_TAGGED = 0x3400 @@ -142,6 +158,105 @@ def _build_set_power_payload(on: bool, duration_ms: int = 0) -> bytes: return struct.pack(" bytes: + """GetColorZones (502) payload: start_index(1) | end_index(1).""" + return struct.pack(" bytes: + """Pack a fixed-length HSBK array, zero-padding unused slots. + + Both SetExtendedColorZones (82 slots) and SetTileState64 (64 slots) + carry a fixed-size color array regardless of how many are in use. + """ + body = bytearray() + for h, s, b, k in hsbk_list[:slots]: + body += struct.pack(" bytes: + """SetExtendedColorZones (510): duration(4)|apply(1)|zone_index(2)|count(1)|82×HSBK.""" + count = min(len(hsbk_list), _EXT_ZONE_MAX) + header = struct.pack( + " bytes: + """SetTileState64 (715): tile_index|length|reserved|x|y|width|duration(4)|64×HSBK.""" + header = struct.pack( + " dict | None: + """Parse StateZone (503) / StateMultiZone (506) → ``{"count", "index"}``. + + ``count`` is the device's *total* zone count; ``index`` is where this + packet's run starts. Returns ``None`` for any other message type. + """ + if len(raw) < 36 + 2: + return None + msg_type = struct.unpack_from(" dict | None: + """Parse StateDeviceChain (702) → ``{"start_index", "tiles": [(w, h), ...]}``. + + Payload: start_index(1) | 16 × Tile(55 bytes) | tile_devices_count(1). + Within each 55-byte Tile struct, ``width`` is byte 16 and ``height`` + byte 17 (after accel int16×4 + user_x/user_y float32×2). Returns ``None`` + for any other message type. + """ + min_len = 36 + 1 + _TILE_STRUCT_SIZE * _TILE_CHAIN_MAX + 1 + if len(raw) < min_len: + return None + msg_type = struct.unpack_from(" dict | None: """Parse a LIFX StateService (discovery) reply. @@ -163,15 +278,25 @@ def _parse_state_service_reply(raw: bytes) -> dict | None: class _LIFXProtocol(asyncio.DatagramProtocol): - """Write-only datagram protocol. Inbound replies dropped silently.""" + """Datagram protocol that buffers inbound replies for zone/tile discovery. + + The streaming hot path never reads replies, but per-zone setup needs the + StateMultiZone / StateDeviceChain answers. The buffer is bounded so a + chatty bulb can't grow it without limit during steady-state streaming. + """ + + _MAX_BUFFER = 64 + + def __init__(self) -> None: + self.transport: asyncio.DatagramTransport | None = None + self.received: list[bytes] = [] def connection_made(self, transport): self.transport = transport def datagram_received(self, data, addr): - # LIFX bulbs sometimes echo back state on broadcast. We don't need it - # for streaming ambilight — discard. - pass + if len(self.received) < self._MAX_BUFFER: + self.received.append(bytes(data)) def error_received(self, exc): logger.debug("LIFX UDP error: %s", exc) @@ -186,6 +311,7 @@ class LIFXClient(LEDClient): led_count: int = 1, *, min_interval_s: float = DEFAULT_MIN_INTERVAL_S, + per_zone: bool = False, ): host, port = parse_lifx_url(url) self._host = host @@ -197,6 +323,12 @@ class LIFXClient(LEDClient): self._connected = False self._next_tx_at: float = 0.0 self._sequence: int = 0 + # Per-pixel state. ``_mode`` is "single" until connect() probes the + # device and finds multizone (Z/Beam) or tile (matrix) support. + self._per_zone = per_zone + self._mode = "single" # "single" | "multizone" | "tile" + self._zone_count = 0 + self._tiles: list[tuple[int, int]] = [] @property def host(self) -> str: @@ -212,6 +344,12 @@ class LIFXClient(LEDClient): @property def device_led_count(self) -> int | None: + # In per-zone streaming the device's addressable element count is + # authoritative (zones for multizone, total pixels for tiles). + if self._mode == "multizone" and self._zone_count: + return self._zone_count + if self._mode == "tile" and self._tiles: + return sum(w * h for w, h in self._tiles) return self._led_count or None async def connect(self) -> bool: @@ -227,9 +365,56 @@ class LIFXClient(LEDClient): self._transport = transport self._protocol = protocol # type: ignore[assignment] self._connected = True - logger.info("LIFXClient connected to %s:%d", self._host, self._port) + if self._per_zone: + # Best-effort: probe for multizone / tile support. On timeout or + # an old single-colour bulb we silently stay in single-colour mode. + try: + await self._setup_zones() + except Exception as exc: # noqa: BLE001 — degrade, never fail connect + logger.warning( + "LIFX %s: per-zone setup failed, using single colour (%s)", self._host, exc + ) + self._mode = "single" + logger.info( + "LIFXClient connected to %s:%d (per_zone=%s, mode=%s)", + self._host, + self._port, + self._per_zone, + self._mode, + ) return True + async def _setup_zones(self) -> None: + """Probe the device for multizone / tile support and pick a mode. + + Sends GetColorZones + GetDeviceChain and waits briefly for whichever + reply the device returns (a strip answers StateMultiZone; a tile chain + answers StateDeviceChain). Leaves ``_mode`` at "single" if neither. + """ + if self._protocol is None: + return + self._protocol.received.clear() + self._send(MSG_GET_DEVICE_CHAIN, b"") + self._send(MSG_GET_COLOR_ZONES, _build_get_color_zones_payload()) + loop = asyncio.get_event_loop() + deadline = loop.time() + 0.6 + while loop.time() < deadline: + await asyncio.sleep(0.05) + for raw in list(self._protocol.received): + chain = _parse_state_device_chain(raw) + if chain and chain["tiles"]: + self._tiles = chain["tiles"] + self._mode = "tile" + logger.info("LIFX %s: tile chain (%d tiles)", self._host, len(self._tiles)) + return + zones = _parse_multizone_reply(raw) + if zones and zones["count"] > 1: + self._zone_count = min(zones["count"], _EXT_ZONE_MAX) + self._mode = "multizone" + logger.info("LIFX %s: multizone (%d zones)", self._host, self._zone_count) + return + self._mode = "single" + async def close(self) -> None: if self._transport is not None: try: @@ -255,25 +440,63 @@ class LIFXClient(LEDClient): ) self._transport.sendto(packet) + def _hsbk_list( + self, + pixels: List[Tuple[int, int, int]] | np.ndarray, + n: int, + scale: float, + ) -> List[Tuple[int, int, int, int]]: + """Resample the strip to ``n`` pixels and convert each to HSBK.""" + out: List[Tuple[int, int, int, int]] = [] + for r, g, b in resample_to_n(pixels, n): + if scale != 1.0: + r, g, b = int(r * scale), int(g * scale), int(b * scale) + out.append(rgb_to_hsbk(r, g, b)) + return out + + def _emit_pixels( + self, + pixels: List[Tuple[int, int, int]] | np.ndarray, + brightness: int, + ) -> None: + """Build and send the packet(s) for the current mode (single/zone/tile).""" + scale = max(0, min(255, brightness)) / 255.0 if brightness < 255 else 1.0 + if self._mode == "multizone" and self._zone_count > 0: + hsbk = self._hsbk_list(pixels, self._zone_count, scale) + self._send(MSG_SET_EXTENDED_COLOR_ZONES, _build_set_extended_color_zones_payload(hsbk)) + return + if self._mode == "tile" and self._tiles: + total = sum(w * h for w, h in self._tiles) + full = self._hsbk_list(pixels, total, scale) + offset = 0 + for ti, (w, h) in enumerate(self._tiles): + n = w * h + chunk = full[offset : offset + n] + offset += n + self._send( + MSG_SET_TILE_STATE_64, + _build_set_tile_state64_payload(chunk, tile_index=ti, width=w), + ) + return + # Single-colour fallback (every non-multizone/tile bulb). + r, g, b = _average_color(pixels) + if scale != 1.0: + r, g, b = int(r * scale), int(g * scale), int(b * scale) + h, s, br, k = rgb_to_hsbk(r, g, b) + self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0)) + async def send_pixels( self, pixels: List[Tuple[int, int, int]] | np.ndarray, brightness: int = 255, ) -> bool: - """Average the strip → HSBK → SetColor.""" + """Stream per-zone/tile when detected, else average the strip → SetColor.""" if not self.is_connected: raise RuntimeError("LIFXClient not connected") now = time.monotonic() if now < self._next_tx_at: return True - r, g, b = _average_color(pixels) - if brightness < 255: - scale = max(0, min(255, brightness)) / 255.0 - r = int(r * scale) - g = int(g * scale) - b = int(b * scale) - h, s, br, k = rgb_to_hsbk(r, g, b) - self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0)) + self._emit_pixels(pixels, brightness) self._next_tx_at = now + self._min_interval_s return True @@ -288,14 +511,7 @@ class LIFXClient(LEDClient): now = time.monotonic() if now < self._next_tx_at: return - r, g, b = _average_color(pixels) - if brightness < 255: - scale = max(0, min(255, brightness)) / 255.0 - r = int(r * scale) - g = int(g * scale) - b = int(b * scale) - h, s, br, k = rgb_to_hsbk(r, g, b) - self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0)) + self._emit_pixels(pixels, brightness) self._next_tx_at = now + self._min_interval_s @property diff --git a/server/src/ledgrab/core/devices/lifx_provider.py b/server/src/ledgrab/core/devices/lifx_provider.py index d0019e5..4a763c2 100644 --- a/server/src/ledgrab/core/devices/lifx_provider.py +++ b/server/src/ledgrab/core/devices/lifx_provider.py @@ -51,6 +51,7 @@ class LIFXDeviceProvider(LEDDeviceProvider): config.device_url, led_count=config.led_count, min_interval_s=max(0.0, config.lifx_min_interval_ms / 1000.0), + per_zone=getattr(config, "lifx_per_zone", False), ) async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: diff --git a/server/src/ledgrab/core/devices/pixel_reduce.py b/server/src/ledgrab/core/devices/pixel_reduce.py index 8f22d56..f3a4245 100644 --- a/server/src/ledgrab/core/devices/pixel_reduce.py +++ b/server/src/ledgrab/core/devices/pixel_reduce.py @@ -40,3 +40,29 @@ def average_color( total_b += b n = len(pixels) return total_r // n, total_g // n, total_b // n + + +def resample_to_n( + pixels: List[Tuple[int, int, int]] | np.ndarray, + n: int, +) -> List[Tuple[int, int, int]]: + """Nearest-neighbour resample an N-pixel strip to exactly ``n`` pixels. + + Output pixel ``i`` of ``n`` samples input pixel ``floor(i * N / n)``, so + the strip spreads spatially across a multi-element device (LIFX zones/ + tiles, Nanoleaf panels, Hue segments). Black is returned for an empty + strip; ``n <= 0`` yields an empty list. + """ + if n <= 0: + return [] + arr = np.asarray(pixels, dtype=np.uint8).reshape(-1, 3) + n_pix = len(arr) + out: List[Tuple[int, int, int]] = [] + for i in range(n): + if n_pix == 0: + out.append((0, 0, 0)) + continue + idx = min(n_pix - 1, (i * n_pix) // n) + px = arr[idx] + out.append((int(px[0]), int(px[1]), int(px[2]))) + return out diff --git a/server/src/ledgrab/core/mqtt/ha_discovery.py b/server/src/ledgrab/core/mqtt/ha_discovery.py new file mode 100644 index 0000000..db08132 --- /dev/null +++ b/server/src/ledgrab/core/mqtt/ha_discovery.py @@ -0,0 +1,141 @@ +"""Home Assistant MQTT auto-discovery publisher. + +When an :class:`~ledgrab.storage.mqtt_source.MQTTSource` has +``publish_ha_discovery`` enabled, this publishes retained +``////config`` topics so an +MQTT-only Home Assistant install gets LedGrab entities automatically — no YAML. + +Scope (read-only telemetry): a ``binary_sensor`` per automation (active / +inactive) plus a connectivity ``binary_sensor`` for LedGrab itself, all tied to +the broker's birth/will ``/status`` availability topic. This is +deliberately one-way (LedGrab → HA): there is no inbound command surface, so it +adds no new attack surface. Controllable ``light``/``switch`` entities (HA → +LedGrab) are a documented follow-up. + +Cleanup: disabling discovery or deleting the source publishes an empty retained +payload to every previously-published config topic, so HA drops the entities +rather than leaving orphans behind forever. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from ledgrab.utils import get_logger + +if TYPE_CHECKING: + from ledgrab.core.mqtt.mqtt_runtime import MQTTRuntime + from ledgrab.storage.automation_store import AutomationStore + from ledgrab.storage.mqtt_source import MQTTSource + +logger = get_logger(__name__) + + +def _node_id(source_id: str) -> str: + """Namespace every entity by source so multiple brokers don't collide.""" + return f"ledgrab_{source_id}" + + +class HADiscoveryPublisher: + """Builds and publishes HA discovery configs for one MQTT runtime.""" + + def __init__( + self, + runtime: "MQTTRuntime", + source: "MQTTSource", + automation_store: "AutomationStore", + version: str = "", + ) -> None: + self._runtime = runtime + self._source = source + self._automation_store = automation_store + self._version = version + self._base = source.base_topic + self._prefix = source.discovery_prefix or "homeassistant" + # Config topics we have published, so remove_all() can clear exactly them. + self._published: set[str] = set() + + # ── Config builders (pure — unit tested) ────────────────────── + + def _device_block(self) -> dict: + return { + "identifiers": [_node_id(self._source.id)], + "name": "LedGrab", + "manufacturer": "LedGrab", + "model": "Ambient", + "sw_version": self._version or "unknown", + } + + def _config_topic(self, component: str, object_id: str) -> str: + return f"{self._prefix}/{component}/{_node_id(self._source.id)}/{object_id}/config" + + def build_connectivity_config(self) -> tuple[str, dict]: + """A connectivity ``binary_sensor`` reflecting LedGrab's birth/will.""" + sid = self._source.id + topic = self._config_topic("binary_sensor", "connectivity") + payload = { + "unique_id": f"ledgrab_{sid}_connectivity", + "name": "LedGrab", + "device_class": "connectivity", + "state_topic": f"{self._base}/status", + "payload_on": "online", + "payload_off": "offline", + "device": self._device_block(), + } + return topic, payload + + def build_automation_config(self, automation) -> tuple[str, dict]: + """A ``binary_sensor`` reflecting an automation's active state.""" + sid = self._source.id + topic = self._config_topic("binary_sensor", f"automation_{automation.id}") + payload = { + "unique_id": f"ledgrab_{sid}_automation_{automation.id}", + "name": automation.name, + "state_topic": f"{self._base}/automation/{automation.id}/state", + "value_template": "{{ value_json.action }}", + "payload_on": "active", + "payload_off": "inactive", + "availability_topic": f"{self._base}/status", + "payload_available": "online", + "payload_not_available": "offline", + "device": self._device_block(), + } + return topic, payload + + # ── Publish / remove ────────────────────────────────────────── + + async def publish_all(self) -> None: + """Publish (retained) every discovery config + an initial state snapshot.""" + topic, payload = self.build_connectivity_config() + await self._runtime.publish(topic, json.dumps(payload), retain=True) + self._published.add(topic) + + count = 0 + for automation in self._automation_store.get_all(): + topic, payload = self.build_automation_config(automation) + await self._runtime.publish(topic, json.dumps(payload), retain=True) + self._published.add(topic) + # Seed an initial "inactive" state; the engine flips it live on the + # next activate/deactivate transition. + await self._runtime.publish_automation_state(automation.id, "inactive") + count += 1 + logger.info( + "HA discovery published for source %s: %d automation sensor(s) + connectivity", + self._source.id, + count, + ) + + async def remove_all(self) -> None: + """Clear every previously-published config (empty retained payload).""" + for topic in list(self._published): + await self._runtime.publish(topic, "", retain=True) + # Also clear automations that may have been published in a prior run but + # since deleted — recompute the current set and clear those too. + topic, _ = self.build_connectivity_config() + await self._runtime.publish(topic, "", retain=True) + for automation in self._automation_store.get_all(): + topic, _ = self.build_automation_config(automation) + await self._runtime.publish(topic, "", retain=True) + self._published.clear() + logger.info("HA discovery removed for source %s", self._source.id) diff --git a/server/src/ledgrab/core/mqtt/mqtt_manager.py b/server/src/ledgrab/core/mqtt/mqtt_manager.py index 1c6ba3e..8c48d04 100644 --- a/server/src/ledgrab/core/mqtt/mqtt_manager.py +++ b/server/src/ledgrab/core/mqtt/mqtt_manager.py @@ -22,10 +22,20 @@ class MQTTManager: Multiple consumers share the same runtime via acquire/release. """ - def __init__(self, store: MQTTSourceStore) -> None: + def __init__( + self, + store: MQTTSourceStore, + automation_store=None, + version: str = "", + ) -> None: self._store = store + # Optional deps for HA discovery publishing (injected from main.py). + self._automation_store = automation_store + self._version = version # source_id -> (runtime, ref_count) self._runtimes: Dict[str, tuple] = {} + # Sources for which we hold a discovery acquire() reference. + self._discovery_sources: set[str] = set() self._lock = asyncio.Lock() async def acquire(self, source_id: str) -> MQTTRuntime: @@ -100,6 +110,86 @@ class MQTTManager: except Exception as e: logger.warning("Failed to update MQTT runtime %s: %s", source_id, e) + # ===== Home Assistant MQTT discovery ===== + + def _make_publisher(self, runtime: MQTTRuntime, source): + from ledgrab.core.mqtt.ha_discovery import HADiscoveryPublisher + + return HADiscoveryPublisher(runtime, source, self._automation_store, version=self._version) + + async def bootstrap_discovery(self) -> None: + """On startup, ensure a runtime + publish discovery for every enabled source.""" + if self._automation_store is None: + return + for source in self._store.get_all(): + if getattr(source, "publish_ha_discovery", False): + try: + await self.ensure_discovery(source.id) + except Exception as exc: # noqa: BLE001 — best-effort bootstrap + logger.warning("HA discovery bootstrap failed for %s: %s", source.id, exc) + + async def ensure_discovery(self, source_id: str) -> None: + """Hold a runtime open for *source_id* and publish its discovery configs. + + Idempotent: a second call re-publishes (configs are retained) without + leaking a second acquire reference. + """ + if self._automation_store is None: + return + try: + source = self._store.get(source_id) + except Exception: + return + if not getattr(source, "publish_ha_discovery", False): + return + first_time = source_id not in self._discovery_sources + if first_time: + runtime = await self.acquire(source_id) + self._discovery_sources.add(source_id) + else: + runtime = self.get_runtime(source_id) + if runtime is None: + return + await self._make_publisher(runtime, source).publish_all() + + async def disable_discovery(self, source_id: str) -> None: + """Clear a source's discovery configs and drop our runtime reference.""" + if source_id not in self._discovery_sources: + return + runtime = self.get_runtime(source_id) + if runtime is not None: + try: + source = self._store.get(source_id) + await self._make_publisher(runtime, source).remove_all() + except Exception as exc: # noqa: BLE001 — best-effort cleanup + logger.warning("HA discovery cleanup failed for %s: %s", source_id, exc) + self._discovery_sources.discard(source_id) + await self.release(source_id) + + async def sync_discovery(self, source_id: str) -> None: + """Reconcile discovery state after a source is created/updated.""" + try: + source = self._store.get(source_id) + except Exception: + return + if getattr(source, "publish_ha_discovery", False): + await self.ensure_discovery(source_id) + else: + await self.disable_discovery(source_id) + + async def publish_automation_state_all(self, automation_id: str, active: bool) -> None: + """Fan an automation's active state out to every discovery-enabled runtime.""" + if not self._discovery_sources: + return + action = "active" if active else "inactive" + for source_id in list(self._discovery_sources): + runtime = self.get_runtime(source_id) + if runtime is not None: + try: + await runtime.publish_automation_state(automation_id, action) + except Exception: # noqa: BLE001 — never raise into the engine + pass + def get_connection_status(self) -> List[Dict[str, Any]]: """Get status of all active MQTT connections (for dashboard indicators).""" result = [] diff --git a/server/src/ledgrab/main.py b/server/src/ledgrab/main.py index 9696550..48db2a7 100644 --- a/server/src/ledgrab/main.py +++ b/server/src/ledgrab/main.py @@ -180,7 +180,9 @@ weather_manager = WeatherManager(weather_source_store) ha_store = HomeAssistantStore(db) ha_manager = HomeAssistantManager(ha_store) mqtt_source_store = MQTTSourceStore(db) -mqtt_manager = MQTTManager(mqtt_source_store) +mqtt_manager = MQTTManager( + mqtt_source_store, automation_store=automation_store, version=__version__ +) http_endpoint_store = HTTPEndpointStore(db) audio_processing_template_store = AudioProcessingTemplateStore(db) game_integration_store = GameIntegrationStore(db) @@ -424,6 +426,9 @@ async def lifespan(app: FastAPI): # Start automation engine (evaluates conditions and activates scenes) await automation_engine.start() + # Publish Home Assistant MQTT discovery for any source that opted in. + await mqtt_manager.bootstrap_discovery() + # Start auto-backup engine (periodic configuration backups) await auto_backup_engine.start() diff --git a/server/src/ledgrab/static/js/features/automations.ts b/server/src/ledgrab/static/js/features/automations.ts index 6b6b13b..92c751f 100644 --- a/server/src/ledgrab/static/js/features/automations.ts +++ b/server/src/ledgrab/static/js/features/automations.ts @@ -126,6 +126,10 @@ class AutomationEditorModal extends Modal { deactivationMode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value, deactivationScenePresetId: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value, tags: JSON.stringify(_automationTagsInput ? _automationTagsInput.getValue() : []), + actionUrl: (document.getElementById('automation-action-webhook-url') as HTMLInputElement)?.value || '', + actionMethod: (document.getElementById('automation-action-method') as HTMLSelectElement)?.value || '', + actionFireOn: (document.getElementById('automation-action-fire-on') as HTMLSelectElement)?.value || '', + actionBody: (document.getElementById('automation-action-body') as HTMLTextAreaElement)?.value || '', }; } } @@ -533,7 +537,18 @@ function createAutomationCard(automation: Automation, sceneMap = new Map()) { }); } - const chipsHtml = `
${ruleChain}${_chainArrow('→')}${sceneChipHtml}${deactivationHtml}
`; + // ── Optional webhook-action chip — shows when an outbound webhook fires. ── + let actionHtml = ''; + const _webhook = (automation.actions || []).find((a: any) => a.action_type === 'webhook'); + if (_webhook && _webhook.webhook_url) { + actionHtml = _chainArrow('→') + _chipHtml({ + icon: ICON_WEB, + text: t('automations.action.webhook'), + title: t(`automations.action.fire_on.${_webhook.fire_on || 'activate'}`), + }); + } + + const chipsHtml = `
${ruleChain}${_chainArrow('→')}${sceneChipHtml}${deactivationHtml}${actionHtml}
`; // ── State surfaces: LED + patch indicator ── // Active = blink (live signal); Enabled-but-idle = off (waiting); @@ -633,6 +648,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any) _ensureRuleLogicIconSelect(); _ensureDeactivationModeIconSelect(); + _ensureActionIconSelects(); // Fetch scenes for selector try { @@ -643,6 +659,8 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any) (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value = 'none'; if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none'); (document.getElementById('automation-fallback-scene-group') as HTMLElement).style.display = 'none'; + // Reset webhook action fields (overwritten below for edit/clone). + _loadAutomationAction([]); let _editorTags: any[] = []; @@ -670,6 +688,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any) if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode); _onDeactivationModeChange(); _initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id); + _loadAutomationAction(automation.actions || []); _editorTags = automation.tags || []; } catch (e: any) { showToast(e.message, 'error'); @@ -698,6 +717,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any) if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode); _onDeactivationModeChange(); _initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id); + _loadAutomationAction(cloneData.actions || []); _editorTags = cloneData.tags || []; } else { titleEl!.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`; @@ -712,6 +732,8 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any) // Wire up deactivation mode change (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).onchange = _onDeactivationModeChange; + // Wire up webhook URL → show/hide the rest of the action fields + (document.getElementById('automation-action-webhook-url') as HTMLInputElement).oninput = _onActionUrlChange; // Auto-name wiring _autoNameManuallyEdited = !!(automationId || cloneData); @@ -805,6 +827,56 @@ function _ensureDeactivationModeIconSelect() { _deactivationModeIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any); } +// ── Webhook action selectors (method + fire_on) ── +let _actionMethodIconSelect: any = null; +let _actionFireOnIconSelect: any = null; +const _ACTION_METHOD_ICONS: any = { POST: P.send, PUT: P.code, GET: P.globe }; +const _ACTION_FIRE_ON_ICONS: any = { activate: P.play, deactivate: P.undo2, both: P.zap }; + +function _ensureActionIconSelects() { + const methodSel = document.getElementById('automation-action-method'); + if (methodSel && !_actionMethodIconSelect) { + const items = ['POST', 'PUT', 'GET'].map(k => ({ + value: k, + icon: _icon(_ACTION_METHOD_ICONS[k]), + label: k, + })); + _actionMethodIconSelect = new IconSelect({ target: methodSel, items, columns: 3 } as any); + } + const fireSel = document.getElementById('automation-action-fire-on'); + if (fireSel && !_actionFireOnIconSelect) { + const items = ['activate', 'deactivate', 'both'].map(k => ({ + value: k, + icon: _icon(_ACTION_FIRE_ON_ICONS[k]), + label: t(`automations.action.fire_on.${k}`), + })); + _actionFireOnIconSelect = new IconSelect({ target: fireSel, items, columns: 3 } as any); + } +} + +// Show the method/fire_on/body fields only once a webhook URL is present. +function _onActionUrlChange() { + const url = (document.getElementById('automation-action-webhook-url') as HTMLInputElement)?.value.trim() || ''; + const fields = document.getElementById('automation-action-fields'); + if (fields) (fields as HTMLElement).style.display = url ? '' : 'none'; +} + +// Populate the webhook action fields from an automation's first webhook action. +function _loadAutomationAction(actions: any[]) { + const webhook = (actions || []).find((a: any) => a.action_type === 'webhook') || null; + const urlEl = document.getElementById('automation-action-webhook-url') as HTMLInputElement; + const methodEl = document.getElementById('automation-action-method') as HTMLSelectElement; + const fireEl = document.getElementById('automation-action-fire-on') as HTMLSelectElement; + const bodyEl = document.getElementById('automation-action-body') as HTMLTextAreaElement; + if (urlEl) urlEl.value = webhook?.webhook_url || ''; + if (methodEl) methodEl.value = webhook?.method || 'POST'; + if (_actionMethodIconSelect) _actionMethodIconSelect.setValue(webhook?.method || 'POST'); + if (fireEl) fireEl.value = webhook?.fire_on || 'activate'; + if (_actionFireOnIconSelect) _actionFireOnIconSelect.setValue(webhook?.fire_on || 'activate'); + if (bodyEl) bodyEl.value = webhook?.body_template || ''; + _onActionUrlChange(); +} + // ===== Condition editor ===== export function addAutomationRule() { @@ -1562,6 +1634,15 @@ export async function saveAutomationEditor() { return; } + const actionUrl = (document.getElementById('automation-action-webhook-url') as HTMLInputElement)?.value.trim() || ''; + const actions = actionUrl ? [{ + action_type: 'webhook', + webhook_url: actionUrl, + method: (document.getElementById('automation-action-method') as HTMLSelectElement)?.value || 'POST', + fire_on: (document.getElementById('automation-action-fire-on') as HTMLSelectElement)?.value || 'activate', + body_template: (document.getElementById('automation-action-body') as HTMLTextAreaElement)?.value || '', + }] : []; + const body = { name, enabled: enabledInput.checked, @@ -1571,6 +1652,7 @@ export async function saveAutomationEditor() { deactivation_mode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value, deactivation_scene_preset_id: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value || null, tags: _automationTagsInput ? _automationTagsInput.getValue() : [], + actions, }; const automationId = idInput.value; diff --git a/server/src/ledgrab/static/js/features/device-discovery.ts b/server/src/ledgrab/static/js/features/device-discovery.ts index 053e590..5ca6c27 100644 --- a/server/src/ledgrab/static/js/features/device-discovery.ts +++ b/server/src/ledgrab/static/js/features/device-discovery.ts @@ -1010,6 +1010,8 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) { if (lmi && cloneData.lifx_min_interval_ms != null) { lmi.value = String(cloneData.lifx_min_interval_ms); } + const lpz = document.getElementById('device-lifx-per-zone') as HTMLInputElement | null; + if (lpz) lpz.checked = !!cloneData.lifx_per_zone; } // Prefill Nanoleaf fields (clone only carries the rate limit — the // token is not exposed in /devices responses, so a cloned device @@ -1231,6 +1233,7 @@ export async function handleAddDevice(event: any) { body.hue_username = (document.getElementById('device-hue-username') as HTMLInputElement)?.value || ''; body.hue_client_key = (document.getElementById('device-hue-client-key') as HTMLInputElement)?.value || ''; body.hue_entertainment_group_id = (document.getElementById('device-hue-group-id') as HTMLInputElement)?.value || ''; + body.hue_gradient_mode = (document.getElementById('device-hue-gradient-mode') as HTMLInputElement | null)?.checked ?? true; } if (isYeelightDevice(deviceType)) { const raw = (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value; @@ -1246,6 +1249,7 @@ export async function handleAddDevice(event: any) { const raw = (document.getElementById('device-lifx-min-interval') as HTMLInputElement)?.value; const parsed = parseInt(raw || '50', 10); body.lifx_min_interval_ms = Number.isFinite(parsed) ? parsed : 50; + body.lifx_per_zone = (document.getElementById('device-lifx-per-zone') as HTMLInputElement | null)?.checked || false; } if (isGoveeDevice(deviceType)) { const raw = (document.getElementById('device-govee-min-interval') as HTMLInputElement)?.value; @@ -1614,7 +1618,7 @@ function _showEspnowFields(show: boolean) { } function _showHueFields(show: boolean) { - const ids = ['device-hue-username-group', 'device-hue-client-key-group', 'device-hue-group-id-group']; + const ids = ['device-hue-username-group', 'device-hue-client-key-group', 'device-hue-group-id-group', 'device-hue-gradient-mode-group']; ids.forEach(id => { const el = document.getElementById(id) as HTMLElement; if (el) el.style.display = show ? '' : 'none'; @@ -1639,6 +1643,8 @@ function _showWizFields(show: boolean) { function _showLifxFields(show: boolean) { const el = document.getElementById('device-lifx-min-interval-group') as HTMLElement | null; if (el) el.style.display = show ? '' : 'none'; + const pz = document.getElementById('device-lifx-per-zone-group') as HTMLElement | null; + if (pz) pz.style.display = show ? '' : 'none'; } function _showGoveeFields(show: boolean) { diff --git a/server/src/ledgrab/static/js/features/devices.ts b/server/src/ledgrab/static/js/features/devices.ts index 1c6f301..8209212 100644 --- a/server/src/ledgrab/static/js/features/devices.ts +++ b/server/src/ledgrab/static/js/features/devices.ts @@ -701,10 +701,14 @@ export async function showSettings(deviceId: any) { // (HSBK averaged from the strip). LIFX recommends ≤20 cmd/sec per // device; default 50 ms matches that ceiling. const lifxMinIntervalGroup = document.getElementById('settings-lifx-min-interval-group'); + const lifxPerZoneGroup = document.getElementById('settings-lifx-per-zone-group'); if (isLifxDevice(device.device_type)) { if (lifxMinIntervalGroup) (lifxMinIntervalGroup as HTMLElement).style.display = ''; const lmi = device.lifx_min_interval_ms ?? 50; (document.getElementById('settings-lifx-min-interval') as HTMLInputElement).value = String(lmi); + if (lifxPerZoneGroup) (lifxPerZoneGroup as HTMLElement).style.display = ''; + const lpzEl = document.getElementById('settings-lifx-per-zone') as HTMLInputElement | null; + if (lpzEl) lpzEl.checked = !!device.lifx_per_zone; // Relabel URL field as IP Address (same pattern as WiZ/Yeelight/DMX/DDP) const urlLabel6 = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null; const urlHint6 = urlGroup.querySelector('.input-hint') as HTMLElement | null; @@ -713,6 +717,7 @@ export async function showSettings(deviceId: any) { urlInput.placeholder = t('device.lifx.url.placeholder') || '192.168.1.50'; } else { if (lifxMinIntervalGroup) (lifxMinIntervalGroup as HTMLElement).style.display = 'none'; + if (lifxPerZoneGroup) (lifxPerZoneGroup as HTMLElement).style.display = 'none'; } // Govee-specific fields — 2023+ LAN API over UDP fire-and-forget @@ -927,6 +932,7 @@ export async function saveDeviceSettings() { const raw = (document.getElementById('settings-lifx-min-interval') as HTMLInputElement | null)?.value; const parsed = parseInt(raw || '50', 10); body.lifx_min_interval_ms = Number.isFinite(parsed) ? parsed : 50; + body.lifx_per_zone = (document.getElementById('settings-lifx-per-zone') as HTMLInputElement | null)?.checked || false; } if (isGoveeDevice(settingsModal.deviceType)) { const raw = (document.getElementById('settings-govee-min-interval') as HTMLInputElement | null)?.value; diff --git a/server/src/ledgrab/static/js/features/mqtt-sources.ts b/server/src/ledgrab/static/js/features/mqtt-sources.ts index 2fdce24..70c74eb 100644 --- a/server/src/ledgrab/static/js/features/mqtt-sources.ts +++ b/server/src/ledgrab/static/js/features/mqtt-sources.ts @@ -56,6 +56,8 @@ class MQTTSourceModal extends Modal { base_topic: (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value, description: (document.getElementById('mqtt-source-description') as HTMLInputElement).value, tags: JSON.stringify(_mqttTagsInput ? _mqttTagsInput.getValue() : []), + haDiscovery: (document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement)?.checked.toString() || 'false', + discoveryPrefix: (document.getElementById('mqtt-source-discovery-prefix') as HTMLInputElement)?.value || '', }; } } @@ -79,6 +81,8 @@ export async function showMQTTSourceModal(editData: MQTTSource | null = null): P (document.getElementById('mqtt-source-password') as HTMLInputElement).value = ''; // never expose (document.getElementById('mqtt-source-client-id') as HTMLInputElement).value = editData.client_id || 'ledgrab'; (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value = editData.base_topic || 'ledgrab'; + (document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement).checked = !!editData.publish_ha_discovery; + (document.getElementById('mqtt-source-discovery-prefix') as HTMLInputElement).value = editData.discovery_prefix || 'homeassistant'; (document.getElementById('mqtt-source-description') as HTMLInputElement).value = editData.description || ''; } else { (document.getElementById('mqtt-source-name') as HTMLInputElement).value = ''; @@ -88,9 +92,20 @@ export async function showMQTTSourceModal(editData: MQTTSource | null = null): P (document.getElementById('mqtt-source-password') as HTMLInputElement).value = ''; (document.getElementById('mqtt-source-client-id') as HTMLInputElement).value = 'ledgrab'; (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value = 'ledgrab'; + (document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement).checked = false; + (document.getElementById('mqtt-source-discovery-prefix') as HTMLInputElement).value = 'homeassistant'; (document.getElementById('mqtt-source-description') as HTMLInputElement).value = ''; } + // Show the discovery-prefix field only while discovery is enabled. + const _discoveryToggle = document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement; + const _syncDiscoveryPrefix = () => { + const grp = document.getElementById('mqtt-source-discovery-prefix-group'); + if (grp) (grp as HTMLElement).style.display = _discoveryToggle?.checked ? '' : 'none'; + }; + if (_discoveryToggle) _discoveryToggle.onchange = _syncDiscoveryPrefix; + _syncDiscoveryPrefix(); + // Tags if (_mqttTagsInput) { _mqttTagsInput.destroy(); _mqttTagsInput = null; } _mqttTagsInput = new TagInput(document.getElementById('mqtt-source-tags-container'), { placeholder: t('tags.placeholder') }); @@ -125,6 +140,8 @@ export async function saveMQTTSource(): Promise { const password = (document.getElementById('mqtt-source-password') as HTMLInputElement).value; const client_id = (document.getElementById('mqtt-source-client-id') as HTMLInputElement).value.trim() || 'ledgrab'; const base_topic = (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value.trim() || 'ledgrab'; + const publish_ha_discovery = (document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement).checked; + const discovery_prefix = (document.getElementById('mqtt-source-discovery-prefix') as HTMLInputElement).value.trim() || 'homeassistant'; const description = (document.getElementById('mqtt-source-description') as HTMLInputElement).value.trim() || null; if (!name) { @@ -138,6 +155,7 @@ export async function saveMQTTSource(): Promise { const payload: Record = { name, broker_port, username, client_id, base_topic, description, + publish_ha_discovery, discovery_prefix, tags: _mqttTagsInput ? _mqttTagsInput.getValue() : [], }; if (broker_host) payload.broker_host = broker_host; diff --git a/server/src/ledgrab/static/js/types/automation.ts b/server/src/ledgrab/static/js/types/automation.ts index b793efd..86f3617 100644 --- a/server/src/ledgrab/static/js/types/automation.ts +++ b/server/src/ledgrab/static/js/types/automation.ts @@ -48,6 +48,15 @@ export interface AutomationRule { value?: string; } +export interface AutomationAction { + action_type: 'webhook'; + webhook_url?: string; + method?: 'POST' | 'PUT' | 'GET'; + body_template?: string; + content_type?: string; + fire_on?: 'activate' | 'deactivate' | 'both'; +} + export interface Automation { id: string; name: string; @@ -58,6 +67,7 @@ export interface Automation { deactivation_mode: 'none' | 'revert' | 'fallback_scene'; deactivation_scene_preset_id?: string; tags: string[]; + actions?: AutomationAction[]; webhook_url?: string; is_active: boolean; last_activated_at?: string; diff --git a/server/src/ledgrab/static/js/types/device.ts b/server/src/ledgrab/static/js/types/device.ts index 02dae37..485489d 100644 --- a/server/src/ledgrab/static/js/types/device.ts +++ b/server/src/ledgrab/static/js/types/device.ts @@ -38,9 +38,11 @@ export interface Device { espnow_channel: number; hue_paired: boolean; hue_entertainment_group_id: string; + hue_gradient_mode?: boolean; yeelight_min_interval_ms: number; wiz_min_interval_ms: number; lifx_min_interval_ms: number; + lifx_per_zone?: boolean; govee_min_interval_ms: number; nanoleaf_paired: boolean; nanoleaf_min_interval_ms: number; diff --git a/server/src/ledgrab/static/js/types/mqtt.ts b/server/src/ledgrab/static/js/types/mqtt.ts index 6d1b18f..576fa57 100644 --- a/server/src/ledgrab/static/js/types/mqtt.ts +++ b/server/src/ledgrab/static/js/types/mqtt.ts @@ -12,6 +12,8 @@ export interface MQTTSource { password_set: boolean; client_id: string; base_topic: string; + publish_ha_discovery?: boolean; + discovery_prefix?: string; connected: boolean; description?: string; tags: string[]; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 0f1a169..d97e12e 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -75,6 +75,7 @@ "activity_log.msg.audit_log.disabled": "Activity logging disabled", "activity_log.msg.automation.activated": "Automation '{name}' activated", "activity_log.msg.automation.deactivated": "Automation '{name}' deactivated", + "activity_log.msg.automation.webhook_fired": "Webhook for '{name}' fired", "activity_log.msg.automation.triggered": "Automation '{name}' manually triggered", "activity_log.msg.server.shutting_down": "Server shutting down", "activity_log.msg.server.restarting": "Server restart requested", @@ -498,6 +499,18 @@ "automations.scene.search_placeholder": "Search scenes...", "automations.section.action": "Action", "automations.section.deactivation": "Deactivation", + "automations.section.action": "Webhook", + "automations.action.webhook": "Webhook", + "automations.action.webhook_url": "Webhook URL:", + "automations.action.webhook_url.hint": "Optional. POST to a Discord / IFTTT / Zapier / Node-RED URL when this automation fires. LAN addresses are allowed; loopback and cloud-metadata are blocked. Leave empty for no webhook.", + "automations.action.method": "Method:", + "automations.action.fire_on": "Fire on:", + "automations.action.fire_on.activate": "When activated", + "automations.action.fire_on.deactivate": "When deactivated", + "automations.action.fire_on.both": "Both", + "automations.action.body_template": "Body template:", + "automations.action.body_template.hint": "JSON body for POST/PUT. Tokens: {{automation_name}}, {{automation_id}}, {{event}}, {{timestamp}}.", + "automations.error.invalid_webhook_url": "Invalid or blocked webhook URL.", "automations.section.triggers": "Triggers", "automations.status.active": "Active", "automations.status.disabled": "Disabled", @@ -1364,6 +1377,8 @@ "device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)", "device.hue.group_id": "Entertainment Group:", "device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge", + "device.hue_gradient_mode": "Map across segments:", + "device.hue_gradient_mode.hint": "Spread the strip across a gradient lightstrip's segments (channels) instead of one averaged colour per light. Auto-detected on connect; plain bulbs are unaffected.", "device.hue.url": "Bridge IP:", "device.hue.url.hint": "IP address of your Hue bridge", "device.hue.username": "Bridge Username:", @@ -1513,6 +1528,8 @@ "device.lifx.url.placeholder": "192.168.1.50", "device.lifx_min_interval": "Min Update Interval:", "device.lifx_min_interval.hint": "Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.", + "device.lifx_per_zone": "Per-zone streaming:", + "device.lifx_per_zone.hint": "Address individual zones (Z/Beam multizone) or pixels (Tile/Canvas matrix) instead of one averaged colour. Auto-detected on connect; older bulbs fall back to single colour.", "device.metrics.actual_fps": "Actual FPS", "device.metrics.current_fps": "Current FPS", "device.metrics.device_fps": "Device refresh rate", @@ -2089,6 +2106,9 @@ "mqtt_source.add": "Add MQTT Source", "mqtt_source.base_topic": "Base Topic:", "mqtt_source.base_topic.hint": "Prefix for status and state topics, e.g. ledgrab/status", + "mqtt_source.ha_discovery": "Home Assistant discovery:", + "mqtt_source.ha_discovery.hint": "Publish homeassistant/.../config topics so MQTT-only Home Assistant installs get LedGrab automation + connectivity entities automatically.", + "mqtt_source.discovery_prefix": "Discovery prefix:", "mqtt_source.broker_host": "Broker Host:", "mqtt_source.broker_host.hint": "MQTT broker hostname or IP address, e.g. 192.168.1.100", "mqtt_source.broker_port": "Port:", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 5734709..b96b3d5 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -78,6 +78,7 @@ "activity_log.msg.audit_log.disabled": "Запись активности отключена", "activity_log.msg.automation.activated": "Автоматизация '{name}' активирована", "activity_log.msg.automation.deactivated": "Автоматизация '{name}' деактивирована", + "activity_log.msg.automation.webhook_fired": "Вебхук для '{name}' отправлен", "activity_log.msg.automation.triggered": "Автоматизация '{name}' запущена вручную", "activity_log.msg.server.shutting_down": "Сервер выключается", "activity_log.msg.server.restarting": "Запрошен перезапуск сервера", @@ -491,6 +492,18 @@ "automations.scene.search_placeholder": "Поиск сцен...", "automations.section.action": "Действие", "automations.section.deactivation": "Деактивация", + "automations.section.action": "Вебхук", + "automations.action.webhook": "Вебхук", + "automations.action.webhook_url": "URL вебхука:", + "automations.action.webhook_url.hint": "Необязательно. Отправлять POST-запрос на URL Discord / IFTTT / Zapier / Node-RED при срабатывании автоматизации. Локальные (LAN) адреса разрешены; loopback и метаданные облака заблокированы. Оставьте пустым, чтобы отключить вебхук.", + "automations.action.method": "Метод:", + "automations.action.fire_on": "Срабатывать при:", + "automations.action.fire_on.activate": "При активации", + "automations.action.fire_on.deactivate": "При деактивации", + "automations.action.fire_on.both": "В обоих случаях", + "automations.action.body_template": "Шаблон тела:", + "automations.action.body_template.hint": "Тело JSON для POST/PUT. Токены: {{automation_name}}, {{automation_id}}, {{event}}, {{timestamp}}.", + "automations.error.invalid_webhook_url": "Недопустимый или заблокированный URL вебхука.", "automations.section.triggers": "Триггеры", "automations.status.active": "Активна", "automations.status.disabled": "Отключена", @@ -1286,6 +1299,11 @@ "device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)", "device.hue.group_id": "Entertainment Group:", "device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge", + "device.hue_gradient_mode": "Распределить по сегментам:", + "device.hue_gradient_mode.hint": "Распределить ленту по сегментам (каналам) градиентной ленты вместо одного усреднённого цвета на лампу. Определяется автоматически при подключении; обычные лампы не затрагиваются.", + "mqtt_source.ha_discovery": "Обнаружение Home Assistant:", + "mqtt_source.ha_discovery.hint": "Публиковать топики homeassistant/.../config, чтобы установки Home Assistant только с MQTT автоматически получали сущности автоматизаций и подключения LedGrab.", + "mqtt_source.discovery_prefix": "Префикс обнаружения:", "device.hue.url": "Bridge IP:", "device.hue.url.hint": "IP address of your Hue bridge", "device.hue.username": "Bridge Username:", @@ -1435,6 +1453,8 @@ "device.lifx.url.placeholder": "192.168.1.50", "device.lifx_min_interval": "Мин. интервал обновления:", "device.lifx_min_interval.hint": "Локальный лимит частоты команд (мс). LIFX рекомендует ≤20 команд/сек; по умолчанию 50 мс соответствует этому потолку.", + "device.lifx_per_zone": "Потоковая передача по зонам:", + "device.lifx_per_zone.hint": "Адресовать отдельные зоны (мультизона Z/Beam) или пиксели (матрица Tile/Canvas) вместо одного усреднённого цвета. Определяется автоматически при подключении; старые лампы переходят на одиночный цвет.", "device.metrics.actual_fps": "Факт. FPS", "device.metrics.current_fps": "Текущ. FPS", "device.metrics.device_fps": "Частота обновления устройства", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index fa1baf3..0239000 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -77,6 +77,7 @@ "activity_log.msg.audit_log.disabled": "活动记录已禁用", "activity_log.msg.automation.activated": "自动化 '{name}' 已激活", "activity_log.msg.automation.deactivated": "自动化 '{name}' 已停用", + "activity_log.msg.automation.webhook_fired": "已为 '{name}' 发送 webhook", "activity_log.msg.automation.triggered": "已手动触发自动化 '{name}'", "activity_log.msg.server.shutting_down": "服务器正在关闭", "activity_log.msg.server.restarting": "已请求服务器重启", @@ -490,6 +491,18 @@ "automations.scene.search_placeholder": "搜索场景...", "automations.section.action": "动作", "automations.section.deactivation": "停用", + "automations.section.action": "Webhook", + "automations.action.webhook": "Webhook", + "automations.action.webhook_url": "Webhook URL:", + "automations.action.webhook_url.hint": "可选。当此自动化触发时向 Discord / IFTTT / Zapier / Node-RED 的 URL 发送 POST。允许局域网地址;回环和云元数据被阻止。留空则不发送 webhook。", + "automations.action.method": "方法:", + "automations.action.fire_on": "触发时机:", + "automations.action.fire_on.activate": "激活时", + "automations.action.fire_on.deactivate": "停用时", + "automations.action.fire_on.both": "两者", + "automations.action.body_template": "正文模板:", + "automations.action.body_template.hint": "POST/PUT 的 JSON 正文。令牌:{{automation_name}}、{{automation_id}}、{{event}}、{{timestamp}}。", + "automations.error.invalid_webhook_url": "无效或被阻止的 webhook URL。", "automations.section.triggers": "触发器", "automations.status.active": "活动", "automations.status.disabled": "已禁用", @@ -1283,6 +1296,11 @@ "device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)", "device.hue.group_id": "Entertainment Group:", "device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge", + "device.hue_gradient_mode": "跨分段映射:", + "device.hue_gradient_mode.hint": "将灯带分布到渐变灯带的各分段(通道),而不是每个灯具一个平均色。连接时自动检测;普通灯泡不受影响。", + "mqtt_source.ha_discovery": "Home Assistant 发现:", + "mqtt_source.ha_discovery.hint": "发布 homeassistant/.../config 主题,使仅使用 MQTT 的 Home Assistant 安装自动获得 LedGrab 的自动化和连接实体。", + "mqtt_source.discovery_prefix": "发现前缀:", "device.hue.url": "Bridge IP:", "device.hue.url.hint": "IP address of your Hue bridge", "device.hue.username": "Bridge Username:", @@ -1432,6 +1450,8 @@ "device.lifx.url.placeholder": "192.168.1.50", "device.lifx_min_interval": "最小更新间隔:", "device.lifx_min_interval.hint": "客户端命令速率限制(毫秒)。LIFX 建议 ≤20 cmd/sec;默认 50 毫秒符合该上限。", + "device.lifx_per_zone": "逐区流式传输:", + "device.lifx_per_zone.hint": "单独寻址各个分区(Z/Beam 多区)或像素(Tile/Canvas 矩阵),而不是单一平均色。连接时自动检测;较旧的灯具回退为单色。", "device.metrics.actual_fps": "实际 FPS", "device.metrics.current_fps": "当前 FPS", "device.metrics.device_fps": "设备刷新率", diff --git a/server/src/ledgrab/storage/automation.py b/server/src/ledgrab/storage/automation.py index 5ee141b..f00426f 100644 --- a/server/src/ledgrab/storage/automation.py +++ b/server/src/ledgrab/storage/automation.py @@ -378,6 +378,81 @@ _RULE_MAP: Dict[str, Type[Rule]] = { } +# ── Actions ────────────────────────────────────────────────────────────── +# Rules decide WHEN an automation is active; actions are extra side effects +# fired alongside scene activation (e.g. an outbound webhook to Discord / +# IFTTT / Zapier / Node-RED). Polymorphic via the same registry pattern as +# Rule, so new action types can be added without touching the engine wiring. + + +@dataclass +class Action: + """Base action — polymorphic via the ``action_type`` discriminator.""" + + action_type: str + + def to_dict(self) -> dict: + return {"action_type": self.action_type} + + @classmethod + def from_dict(cls, data: dict) -> "Action": + at = data.get("action_type", "") + subcls = _ACTION_MAP.get(at) + if subcls is None: + raise ValueError(f"Unknown action type: {at}") + return subcls.from_dict(data) + + +@dataclass +class WebhookAction(Action): + """POST/PUT/GET an outbound HTTP request when the automation fires. + + ``fire_on`` selects which transition triggers the call: ``"activate"``, + ``"deactivate"``, or ``"both"``. ``body_template`` supports the tokens + ``{{automation_name}}``, ``{{automation_id}}``, ``{{event}}`` and + ``{{timestamp}}``, substituted server-side at fire time. The URL is + SSRF-gated (LAN allowed, loopback / cloud-metadata / link-local blocked) + at both save and fire time. + """ + + action_type: str = "webhook" + webhook_url: str = "" + method: str = "POST" # POST | PUT | GET + body_template: str = "" + content_type: str = "application/json" + fire_on: str = "activate" # activate | deactivate | both + + def to_dict(self) -> dict: + d = super().to_dict() + d["webhook_url"] = self.webhook_url + d["method"] = self.method + d["body_template"] = self.body_template + d["content_type"] = self.content_type + d["fire_on"] = self.fire_on + return d + + @classmethod + def from_dict(cls, data: dict) -> "WebhookAction": + method = str(data.get("method", "POST")).upper() + if method not in ("POST", "PUT", "GET"): + method = "POST" + fire_on = data.get("fire_on", "activate") + if fire_on not in ("activate", "deactivate", "both"): + fire_on = "activate" + return cls( + webhook_url=data.get("webhook_url", ""), + method=method, + body_template=data.get("body_template", ""), + content_type=data.get("content_type", "") or "application/json", + fire_on=fire_on, + ) + + +_ACTION_MAP: Dict[str, Type[Action]] = { + "webhook": WebhookAction, +} + + # ── Backward-compatible aliases (for imports in other modules during transition) ── Condition = Rule ApplicationCondition = ApplicationRule @@ -406,6 +481,8 @@ class Automation: created_at: datetime updated_at: datetime tags: List[str] = field(default_factory=list) + # Outbound side-effects fired alongside scene activation/deactivation. + actions: List[Action] = field(default_factory=list) # Custom card icon (frontend display only) icon: str = "" icon_color: str = "" @@ -441,6 +518,10 @@ class Automation: "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), } + # Only persist actions when present — keeps existing rows byte-identical + # and tolerates loading automations saved before actions existed. + if self.actions: + d["actions"] = [a.to_dict() for a in self.actions] if self.icon: d["icon"] = self.icon if self.icon_color: @@ -463,6 +544,13 @@ class Automation: except ValueError as e: logger.warning("Skipping unknown rule type on load: %s", e) + actions: List[Action] = [] + for a_data in data.get("actions") or []: + try: + actions.append(Action.from_dict(a_data)) + except ValueError as e: + logger.warning("Skipping unknown action type on load: %s", e) + return cls( id=data["id"], name=data["name"], @@ -473,6 +561,7 @@ class Automation: deactivation_mode=data.get("deactivation_mode", "none"), deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"), tags=data.get("tags", []), + actions=actions, icon=data.get("icon", ""), icon_color=data.get("icon_color", ""), created_at=datetime.fromisoformat( diff --git a/server/src/ledgrab/storage/automation_store.py b/server/src/ledgrab/storage/automation_store.py index 9564d88..a6671ec 100644 --- a/server/src/ledgrab/storage/automation_store.py +++ b/server/src/ledgrab/storage/automation_store.py @@ -4,7 +4,7 @@ import uuid from datetime import datetime, timezone from typing import List -from ledgrab.storage.automation import Automation, Rule +from ledgrab.storage.automation import Action, Automation, Rule from ledgrab.storage.base_sqlite_store import BaseSqliteStore from ledgrab.storage.database import Database from ledgrab.utils import get_logger @@ -34,6 +34,7 @@ class AutomationStore(BaseSqliteStore[Automation]): deactivation_mode: str = "none", deactivation_scene_preset_id: str | None = None, tags: List[str] | None = None, + actions: List[Action] | None = None, icon: str | None = None, icon_color: str | None = None, # Legacy parameter aliases @@ -65,6 +66,7 @@ class AutomationStore(BaseSqliteStore[Automation]): created_at=now, updated_at=now, tags=tags or [], + actions=actions or [], icon=icon or "", icon_color=icon_color or "", ) @@ -85,6 +87,7 @@ class AutomationStore(BaseSqliteStore[Automation]): deactivation_mode: str | None = None, deactivation_scene_preset_id: str = "__unset__", tags: List[str] | None = None, + actions: List[Action] | None = None, icon: str | None = None, icon_color: str | None = None, # Legacy parameter aliases @@ -118,6 +121,8 @@ class AutomationStore(BaseSqliteStore[Automation]): ) if tags is not None: automation.tags = tags + if actions is not None: + automation.actions = actions if icon is not None: automation.icon = icon or "" if icon_color is not None: diff --git a/server/src/ledgrab/storage/device_store.py b/server/src/ledgrab/storage/device_store.py index 5c826c1..f139459 100644 --- a/server/src/ledgrab/storage/device_store.py +++ b/server/src/ledgrab/storage/device_store.py @@ -81,12 +81,14 @@ class Device: hue_username: str = "", hue_client_key: str = "", hue_entertainment_group_id: str = "", + hue_gradient_mode: bool = True, # Yeelight fields yeelight_min_interval_ms: int = 500, # WiZ fields wiz_min_interval_ms: int = 50, # LIFX fields lifx_min_interval_ms: int = 50, + lifx_per_zone: bool = False, # Govee fields govee_min_interval_ms: int = 50, # OPC fields @@ -142,9 +144,11 @@ class Device: self.hue_username = hue_username self.hue_client_key = hue_client_key self.hue_entertainment_group_id = hue_entertainment_group_id + self.hue_gradient_mode = hue_gradient_mode self.yeelight_min_interval_ms = yeelight_min_interval_ms self.wiz_min_interval_ms = wiz_min_interval_ms self.lifx_min_interval_ms = lifx_min_interval_ms + self.lifx_per_zone = lifx_per_zone self.govee_min_interval_ms = govee_min_interval_ms self.opc_channel = opc_channel self.nanoleaf_token = nanoleaf_token @@ -241,6 +245,7 @@ class Device: hue_username=self.hue_username, hue_client_key=self.hue_client_key, hue_entertainment_group_id=self.hue_entertainment_group_id, + hue_gradient_mode=self.hue_gradient_mode, ) if dt == "yeelight": return YeelightConfig( @@ -256,6 +261,7 @@ class Device: return LIFXConfig( **base, lifx_min_interval_ms=self.lifx_min_interval_ms, + lifx_per_zone=self.lifx_per_zone, ) if dt == "govee": return GoveeConfig( @@ -352,12 +358,17 @@ class Device: d["hue_client_key"] = _enc(self.hue_client_key) if self.hue_entertainment_group_id: d["hue_entertainment_group_id"] = self.hue_entertainment_group_id + # Gradient mode defaults ON — only persist the opt-out. + if not self.hue_gradient_mode: + d["hue_gradient_mode"] = False if self.yeelight_min_interval_ms != 500: d["yeelight_min_interval_ms"] = self.yeelight_min_interval_ms if self.wiz_min_interval_ms != 50: d["wiz_min_interval_ms"] = self.wiz_min_interval_ms if self.lifx_min_interval_ms != 50: d["lifx_min_interval_ms"] = self.lifx_min_interval_ms + if self.lifx_per_zone: + d["lifx_per_zone"] = True if self.govee_min_interval_ms != 50: d["govee_min_interval_ms"] = self.govee_min_interval_ms if self.opc_channel: @@ -424,9 +435,11 @@ class Device: hue_username=_dec(data.get("hue_username", "")), hue_client_key=_dec(data.get("hue_client_key", "")), hue_entertainment_group_id=data.get("hue_entertainment_group_id", ""), + hue_gradient_mode=bool(data.get("hue_gradient_mode", True)), yeelight_min_interval_ms=data.get("yeelight_min_interval_ms", 500), wiz_min_interval_ms=data.get("wiz_min_interval_ms", 50), lifx_min_interval_ms=data.get("lifx_min_interval_ms", 50), + lifx_per_zone=bool(data.get("lifx_per_zone", False)), govee_min_interval_ms=data.get("govee_min_interval_ms", 50), opc_channel=data.get("opc_channel", 0), nanoleaf_token=_dec(data.get("nanoleaf_token", "")), @@ -479,9 +492,11 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset( "hue_username", "hue_client_key", "hue_entertainment_group_id", + "hue_gradient_mode", "yeelight_min_interval_ms", "wiz_min_interval_ms", "lifx_min_interval_ms", + "lifx_per_zone", "govee_min_interval_ms", "opc_channel", "nanoleaf_token", @@ -587,9 +602,11 @@ class DeviceStore(BaseSqliteStore[Device]): hue_username: str = "", hue_client_key: str = "", hue_entertainment_group_id: str = "", + hue_gradient_mode: bool = True, yeelight_min_interval_ms: int = 500, wiz_min_interval_ms: int = 50, lifx_min_interval_ms: int = 50, + lifx_per_zone: bool = False, govee_min_interval_ms: int = 50, opc_channel: int = 0, nanoleaf_token: str = "", @@ -638,9 +655,11 @@ class DeviceStore(BaseSqliteStore[Device]): hue_username=hue_username, hue_client_key=hue_client_key, hue_entertainment_group_id=hue_entertainment_group_id, + hue_gradient_mode=hue_gradient_mode, yeelight_min_interval_ms=yeelight_min_interval_ms, wiz_min_interval_ms=wiz_min_interval_ms, lifx_min_interval_ms=lifx_min_interval_ms, + lifx_per_zone=lifx_per_zone, govee_min_interval_ms=govee_min_interval_ms, opc_channel=opc_channel, nanoleaf_token=nanoleaf_token, diff --git a/server/src/ledgrab/storage/mqtt_source.py b/server/src/ledgrab/storage/mqtt_source.py index 5b8b3a8..071c154 100644 --- a/server/src/ledgrab/storage/mqtt_source.py +++ b/server/src/ledgrab/storage/mqtt_source.py @@ -48,6 +48,10 @@ class MQTTSource: password: str = "" client_id: str = "ledgrab" base_topic: str = "ledgrab" + # Home Assistant MQTT auto-discovery: publish homeassistant/.../config so + # MQTT-only HA installs get LedGrab entities automatically. + publish_ha_discovery: bool = False + discovery_prefix: str = "homeassistant" description: str | None = None tags: List[str] = field(default_factory=list) icon: str = "" @@ -66,6 +70,8 @@ class MQTTSource: "password": stored_password, "client_id": self.client_id, "base_topic": self.base_topic, + "publish_ha_discovery": self.publish_ha_discovery, + "discovery_prefix": self.discovery_prefix, "description": self.description, "tags": self.tags, "created_at": self.created_at.isoformat(), @@ -93,6 +99,8 @@ class MQTTSource: password=password, client_id=data.get("client_id", "ledgrab"), base_topic=data.get("base_topic", "ledgrab"), + publish_ha_discovery=bool(data.get("publish_ha_discovery", False)), + discovery_prefix=data.get("discovery_prefix", "homeassistant") or "homeassistant", icon=data.get("icon", ""), icon_color=data.get("icon_color", ""), ) diff --git a/server/src/ledgrab/storage/mqtt_source_store.py b/server/src/ledgrab/storage/mqtt_source_store.py index 43ef769..a66510a 100644 --- a/server/src/ledgrab/storage/mqtt_source_store.py +++ b/server/src/ledgrab/storage/mqtt_source_store.py @@ -70,6 +70,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]): password: str = "", client_id: str = "ledgrab", base_topic: str = "ledgrab", + publish_ha_discovery: bool = False, + discovery_prefix: str = "homeassistant", description: str | None = None, tags: List[str] | None = None, icon: str | None = None, @@ -94,6 +96,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]): password=password, client_id=client_id, base_topic=base_topic, + publish_ha_discovery=publish_ha_discovery, + discovery_prefix=discovery_prefix or "homeassistant", description=description, tags=tags or [], icon=icon or "", @@ -115,6 +119,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]): password: str | None = None, client_id: str | None = None, base_topic: str | None = None, + publish_ha_discovery: bool | None = None, + discovery_prefix: str | None = None, description: str | None = None, tags: List[str] | None = None, icon: str | None = None, @@ -136,6 +142,14 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]): password=password if password is not None else existing.password, client_id=client_id if client_id is not None else existing.client_id, base_topic=base_topic if base_topic is not None else existing.base_topic, + publish_ha_discovery=( + publish_ha_discovery + if publish_ha_discovery is not None + else existing.publish_ha_discovery + ), + discovery_prefix=( + discovery_prefix if discovery_prefix is not None else existing.discovery_prefix + ), description=description if description is not None else existing.description, tags=tags if tags is not None else existing.tags, icon=icon if icon is not None else existing.icon, diff --git a/server/src/ledgrab/templates/modals/add-device.html b/server/src/ledgrab/templates/modals/add-device.html index 71b5878..64c632e 100644 --- a/server/src/ledgrab/templates/modals/add-device.html +++ b/server/src/ledgrab/templates/modals/add-device.html @@ -265,6 +265,17 @@ + + diff --git a/server/tests/test_ha_discovery.py b/server/tests/test_ha_discovery.py new file mode 100644 index 0000000..9207894 --- /dev/null +++ b/server/tests/test_ha_discovery.py @@ -0,0 +1,145 @@ +"""Tests for Home Assistant MQTT auto-discovery. + +The end-to-end entity appearance, retained-config survival across HA restart, +and availability flips need a live broker + HA. Here we lock down the parts +that DON'T: the discovery config payloads, publish_all / remove_all topic sets, +the MQTTSource field round-trip, and the manager's state fan-out. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from ledgrab.core.mqtt.ha_discovery import HADiscoveryPublisher +from ledgrab.core.mqtt.mqtt_manager import MQTTManager +from ledgrab.storage.mqtt_source import MQTTSource + + +class _FakeRuntime: + def __init__(self) -> None: + self.published: list[tuple[str, str, bool]] = [] + self.states: list[tuple[str, str]] = [] + + async def publish(self, topic: str, payload: str, retain: bool = False, qos: int = 0) -> None: + self.published.append((topic, payload, retain)) + + async def publish_automation_state(self, automation_id: str, action: str) -> None: + self.states.append((automation_id, action)) + + +class _Auto: + def __init__(self, id: str, name: str) -> None: + self.id = id + self.name = name + + +class _FakeAutomationStore: + def __init__(self, autos) -> None: + self._autos = autos + + def get_all(self): + return self._autos + + +def _source(**kw) -> MQTTSource: + now = datetime.now(timezone.utc) + defaults = dict(id="mqs_1", name="Broker", created_at=now, updated_at=now) + defaults.update(kw) + return MQTTSource(**defaults) + + +def _publisher(autos=None, **src_kw): + runtime = _FakeRuntime() + source = _source(**src_kw) + store = _FakeAutomationStore(autos if autos is not None else [_Auto("auto_1", "Movie Night")]) + return HADiscoveryPublisher(runtime, source, store, version="9.9.9"), runtime + + +class TestConfigBuilders: + def test_connectivity_config_shape(self): + pub, _ = _publisher(base_topic="ledgrab") + topic, payload = pub.build_connectivity_config() + assert topic == "homeassistant/binary_sensor/ledgrab_mqs_1/connectivity/config" + assert payload["device_class"] == "connectivity" + assert payload["state_topic"] == "ledgrab/status" + assert payload["unique_id"] == "ledgrab_mqs_1_connectivity" + assert payload["device"]["identifiers"] == ["ledgrab_mqs_1"] + assert payload["device"]["sw_version"] == "9.9.9" + + def test_automation_config_shape(self): + pub, _ = _publisher(base_topic="lg") + topic, payload = pub.build_automation_config(_Auto("auto_7", "Night")) + assert topic == "homeassistant/binary_sensor/ledgrab_mqs_1/automation_auto_7/config" + assert payload["state_topic"] == "lg/automation/auto_7/state" + assert payload["value_template"] == "{{ value_json.action }}" + assert payload["payload_on"] == "active" and payload["payload_off"] == "inactive" + assert payload["availability_topic"] == "lg/status" + assert payload["name"] == "Night" + + def test_custom_discovery_prefix(self): + pub, _ = _publisher(discovery_prefix="ha") + topic, _ = pub.build_connectivity_config() + assert topic.startswith("ha/binary_sensor/") + + +class TestPublishRemove: + @pytest.mark.asyncio + async def test_publish_all_publishes_retained_configs_and_state(self): + pub, runtime = _publisher(autos=[_Auto("a1", "One"), _Auto("a2", "Two")]) + await pub.publish_all() + config_topics = [t for (t, p, r) in runtime.published if t.endswith("/config")] + assert any("connectivity" in t for t in config_topics) + assert sum("automation_" in t for t in config_topics) == 2 + assert all(r for (_t, _p, r) in runtime.published) # all retained + # Seeded initial states. + assert ("a1", "inactive") in runtime.states + + @pytest.mark.asyncio + async def test_remove_all_clears_with_empty_payload(self): + pub, runtime = _publisher(autos=[_Auto("a1", "One")]) + await pub.publish_all() + runtime.published.clear() + await pub.remove_all() + # Every clear is an empty retained payload to a config topic. + assert runtime.published + assert all(p == "" and r for (_t, p, r) in runtime.published) + assert all(t.endswith("/config") for (t, _p, _r) in runtime.published) + + +class TestSourceRoundTrip: + def test_fields_round_trip(self): + s = _source(publish_ha_discovery=True, discovery_prefix="ha") + back = MQTTSource.from_dict(s.to_dict()) + assert back.publish_ha_discovery is True + assert back.discovery_prefix == "ha" + + def test_defaults_when_absent(self): + back = MQTTSource.from_dict({"id": "x", "name": "n", "created_at": "", "updated_at": ""}) + assert back.publish_ha_discovery is False + assert back.discovery_prefix == "homeassistant" + + +class TestManagerStateFanout: + @pytest.mark.asyncio + async def test_publish_state_only_to_discovery_sources(self): + mgr = MQTTManager(store=None, automation_store=None) + runtime = _FakeRuntime() + # Inject a fake runtime + mark it discovery-enabled. + mgr._runtimes["mqs_1"] = (runtime, 1) + mgr._discovery_sources.add("mqs_1") + await mgr.publish_automation_state_all("auto_1", True) + assert runtime.states == [("auto_1", "active")] + await mgr.publish_automation_state_all("auto_1", False) + assert runtime.states[-1] == ("auto_1", "inactive") + + @pytest.mark.asyncio + async def test_no_discovery_sources_is_noop(self): + mgr = MQTTManager(store=None, automation_store=None) + # Should not raise with no discovery sources. + await mgr.publish_automation_state_all("auto_1", True) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/server/tests/test_hue_segment.py b/server/tests/test_hue_segment.py new file mode 100644 index 0000000..541710b --- /dev/null +++ b/server/tests/test_hue_segment.py @@ -0,0 +1,140 @@ +"""Tests for Hue gradient-lightstrip segment (channel) mapping. + +The DTLS handshake and the actual gradient rendering need a real bridge + +gradient strip to validate; here we lock down the parts that DON'T: channel +discovery/ordering from an entertainment_configuration payload, the v2 frame +builder's channel-id keying (vs the legacy per-light index), the resample on +send, and the ``hue_gradient_mode`` config round-trip through the store. +""" + +from __future__ import annotations + +import struct + +import pytest + +from ledgrab.core.devices.hue_client import ( + HEADER_SIZE, + PROTOCOL_NAME, + HueClient, + _build_entertainment_frame, + parse_entertainment_channels, +) +from ledgrab.storage.device_store import Device + + +class _FakeSock: + def __init__(self) -> None: + self.sent: list[bytes] = [] + + def sendto(self, data: bytes, addr) -> None: + self.sent.append(bytes(data)) + + +class TestParseChannels: + def test_orders_by_position_x_then_y(self): + cfg = { + "data": [ + { + "channels": [ + {"channel_id": 2, "position": {"x": 0.5, "y": 0.0}}, + {"channel_id": 0, "position": {"x": -0.5, "y": 0.0}}, + {"channel_id": 1, "position": {"x": 0.0, "y": 0.0}}, + ] + } + ] + } + assert parse_entertainment_channels(cfg) == [0, 1, 2] + + def test_gradient_strip_contributes_multiple_channels(self): + # A 5-segment gradient strip → five channels, all from one light. + channels = [{"channel_id": i, "position": {"x": i * 0.1, "y": 0.0}} for i in range(5)] + cfg = {"data": [{"channels": channels}]} + assert parse_entertainment_channels(cfg) == [0, 1, 2, 3, 4] + + def test_empty_payload_is_empty(self): + assert parse_entertainment_channels({}) == [] + assert parse_entertainment_channels({"data": []}) == [] + + +class TestFrameBuilder: + def test_header_is_huestream_v2(self): + frame = _build_entertainment_frame([(255, 0, 0)], sequence=7) + assert frame[0:9] == PROTOCOL_NAME + assert frame[9] == 2 and frame[10] == 0 # version 2.0 + assert frame[11] == 7 # sequence + + def test_record_is_seven_bytes_keyed_by_channel(self): + colors = [(255, 0, 0), (0, 255, 0)] + frame = _build_entertainment_frame(colors, channel_ids=[3, 1]) + assert len(frame) == HEADER_SIZE + 7 * 2 + cid0, r0, g0, b0 = struct.unpack_from(">BHHH", frame, HEADER_SIZE) + assert (cid0, r0, g0, b0) == (3, 255 * 257, 0, 0) + cid1, r1, g1, b1 = struct.unpack_from(">BHHH", frame, HEADER_SIZE + 7) + assert (cid1, r1, g1, b1) == (1, 0, 255 * 257, 0) + + def test_falls_back_to_index_without_channel_map(self): + frame = _build_entertainment_frame([(1, 2, 3), (4, 5, 6)]) + cid0 = struct.unpack_from(">B", frame, HEADER_SIZE)[0] + cid1 = struct.unpack_from(">B", frame, HEADER_SIZE + 7)[0] + assert (cid0, cid1) == (0, 1) + + +class TestSendAndCount: + def _client(self, *, channel_ids, led_count, gradient_mode=True) -> HueClient: + c = HueClient("hue://1.2.3.4", led_count=led_count, gradient_mode=gradient_mode) + c._connected = True + c._sock = _FakeSock() # type: ignore[assignment] + c._dtls_sock = None + c._channel_ids = channel_ids + return c + + def test_resamples_strip_across_channels(self): + c = self._client(channel_ids=[0, 1, 2, 3, 4], led_count=1) + c.send_pixels_fast([(10, 0, 0)] * 20) + frame = c._sock.sent[0] # type: ignore[attr-defined] + # 5 channels → 5 records. + assert len(frame) == HEADER_SIZE + 7 * 5 + + def test_legacy_path_uses_led_count(self): + c = self._client(channel_ids=[], led_count=3) + c.send_pixels_fast([(5, 5, 5)] * 8) + frame = c._sock.sent[0] # type: ignore[attr-defined] + assert len(frame) == HEADER_SIZE + 7 * 3 + + def test_device_led_count_reflects_channels(self): + assert self._client(channel_ids=[0, 1, 2, 3, 4], led_count=1).device_led_count == 5 + assert self._client(channel_ids=[], led_count=3).device_led_count == 3 + # Gradient mode off → channel count ignored even if present. + off = self._client(channel_ids=[0, 1], led_count=2, gradient_mode=False) + assert off.device_led_count == 2 + + +class TestConfigRoundTrip: + def _device(self, gradient_mode: bool) -> Device: + return Device( + device_id="d1", + name="Gradient strip", + url="hue://1.2.3.4", + led_count=5, + device_type="hue", + hue_gradient_mode=gradient_mode, + ) + + def test_default_on_is_omitted(self): + d = self._device(True) + assert "hue_gradient_mode" not in d.to_dict() + back = Device.from_dict(d.to_dict()) + assert back.hue_gradient_mode is True + assert back.to_config().hue_gradient_mode is True + + def test_opt_out_round_trips(self): + d = self._device(False) + assert d.to_dict().get("hue_gradient_mode") is False + back = Device.from_dict(d.to_dict()) + assert back.hue_gradient_mode is False + assert back.to_config().hue_gradient_mode is False + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/server/tests/test_lifx_multizone.py b/server/tests/test_lifx_multizone.py new file mode 100644 index 0000000..785084a --- /dev/null +++ b/server/tests/test_lifx_multizone.py @@ -0,0 +1,215 @@ +"""Tests for LIFX multizone (Z/Beam) + tile (Canvas) per-pixel streaming. + +The device-side handshake (zone/tile auto-detection over UDP, firmware +fallback) needs real hardware to validate; here we lock down the parts that +DON'T: the exact packet framing for SetExtendedColorZones (510) and +SetTileState64 (715), the StateMultiZone / StateDeviceChain reply parsers, +the strip→element resample, the per-mode emit path, and the ``lifx_per_zone`` +config round-trip through the device store. +""" + +from __future__ import annotations + +import struct + +import pytest + +from ledgrab.core.devices.lifx_client import ( + MSG_SET_COLOR, + MSG_SET_EXTENDED_COLOR_ZONES, + MSG_SET_TILE_STATE_64, + MSG_STATE_DEVICE_CHAIN, + MSG_STATE_MULTIZONE, + LIFXClient, + _build_packet, + _build_set_extended_color_zones_payload, + _build_set_tile_state64_payload, + _parse_multizone_reply, + _parse_state_device_chain, + rgb_to_hsbk, +) +from ledgrab.core.devices.pixel_reduce import resample_to_n +from ledgrab.storage.device_store import Device + +_EXT_ZONE_MAX = 82 +_TILE_PIXELS = 64 +_TILE_STRUCT_SIZE = 55 +_TILE_CHAIN_MAX = 16 + + +class _FakeTransport: + """Captures every datagram the client emits via ``_send``.""" + + def __init__(self) -> None: + self.sent: list[bytes] = [] + + def sendto(self, data: bytes) -> None: + self.sent.append(bytes(data)) + + +def _client(mode: str = "single", *, zone_count: int = 0, tiles=None) -> LIFXClient: + c = LIFXClient("lifx://1.2.3.4", led_count=10, per_zone=True) + c._connected = True + c._transport = _FakeTransport() # type: ignore[assignment] + c._mode = mode + c._zone_count = zone_count + c._tiles = tiles or [] + return c + + +def _msg_type(packet: bytes) -> int: + """LIFX protocol-header message type lives at byte offset 32.""" + return struct.unpack_from(" bytes: + payload = bytearray(1 + _TILE_STRUCT_SIZE * _TILE_CHAIN_MAX + 1) + payload[0] = 0 # start_index + for i, (w, h) in enumerate(tile_specs): + base = 1 + i * _TILE_STRUCT_SIZE + payload[base + 16] = w + payload[base + 17] = h + payload[1 + _TILE_STRUCT_SIZE * _TILE_CHAIN_MAX] = len(tile_specs) # tile count + return _build_packet(msg_type=MSG_STATE_DEVICE_CHAIN, payload=bytes(payload)) + + def test_parses_tile_widths_and_heights(self): + raw = self._chain_packet([(8, 8), (8, 8)]) + parsed = _parse_state_device_chain(raw) + assert parsed == {"start_index": 0, "tiles": [(8, 8), (8, 8)]} + + def test_short_packet_returns_none(self): + assert _parse_state_device_chain(b"\x00" * 40) is None + + +class TestEmitPixels: + def test_multizone_emits_one_extended_packet(self): + c = _client("multizone", zone_count=4) + c._emit_pixels([(255, 0, 0)] * 8, 255) + sent = c._transport.sent # type: ignore[attr-defined] + assert len(sent) == 1 + assert _msg_type(sent[0]) == MSG_SET_EXTENDED_COLOR_ZONES + + def test_tile_emits_one_packet_per_tile(self): + c = _client("tile", tiles=[(2, 2), (2, 2)]) + c._emit_pixels([(0, 255, 0)] * 8, 255) + sent = c._transport.sent # type: ignore[attr-defined] + assert len(sent) == 2 + assert all(_msg_type(p) == MSG_SET_TILE_STATE_64 for p in sent) + + def test_single_mode_falls_back_to_set_color(self): + c = _client("single") + c._emit_pixels([(10, 20, 30)] * 4, 255) + sent = c._transport.sent # type: ignore[attr-defined] + assert len(sent) == 1 + assert _msg_type(sent[0]) == MSG_SET_COLOR + + def test_device_led_count_reflects_mode(self): + assert _client("multizone", zone_count=16).device_led_count == 16 + assert _client("tile", tiles=[(8, 8), (8, 8)]).device_led_count == 128 + assert _client("single").device_led_count == 10 + + +class TestBrightnessScaling: + def test_brightness_scales_zone_colours(self): + c = _client("multizone", zone_count=1) + c._emit_pixels([(200, 100, 50)], 128) + payload = c._transport.sent[0][36:] # type: ignore[index] + h, s, b, k = struct.unpack_from(" Device: + return Device( + device_id="d1", + name="Beam", + url="lifx://1.2.3.4", + led_count=10, + device_type="lifx", + lifx_per_zone=per_zone, + ) + + def test_per_zone_round_trips_through_store(self): + d = self._device(True) + assert d.to_dict().get("lifx_per_zone") is True + back = Device.from_dict(d.to_dict()) + assert back.lifx_per_zone is True + assert back.to_config().lifx_per_zone is True + + def test_default_off_is_omitted(self): + d = self._device(False) + assert "lifx_per_zone" not in d.to_dict() + assert d.to_config().lifx_per_zone is False + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/server/tests/test_webhook_action.py b/server/tests/test_webhook_action.py new file mode 100644 index 0000000..057fa89 --- /dev/null +++ b/server/tests/test_webhook_action.py @@ -0,0 +1,136 @@ +"""Tests for the outbound webhook automation action. + +Covers the pure logic (template rendering, fire_on filtering, model +round-trip) and the fire path with a mocked transport (success, non-2xx, +SSRF-blocked). Live delivery to a real Discord/Zapier endpoint is out of +scope for CI and is verified manually. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import httpx +import pytest +import respx + +from ledgrab.core.automations.webhook_action import ( + fire_webhook_action, + render_template, + should_fire, +) +from ledgrab.storage.automation import Action, Automation, WebhookAction + + +def _automation(actions=None) -> Automation: + now = datetime.now(timezone.utc) + return Automation( + id="auto_1", + name="Movie Night", + enabled=True, + rule_logic="or", + rules=[], + scene_preset_id=None, + deactivation_mode="none", + deactivation_scene_preset_id=None, + created_at=now, + updated_at=now, + actions=actions or [], + ) + + +class TestRenderTemplate: + def test_substitutes_all_tokens(self): + out = render_template( + '{"name":"{{automation_name}}","id":"{{automation_id}}","ev":"{{event}}"}', + _automation(), + "activate", + ) + assert '"name":"Movie Night"' in out + assert '"id":"auto_1"' in out + assert '"ev":"activate"' in out + + def test_leaves_unknown_tokens(self): + assert render_template("{{unknown}}", _automation(), "activate") == "{{unknown}}" + + +class TestShouldFire: + def test_matches_event_or_both(self): + assert should_fire(WebhookAction(fire_on="activate"), "activate") + assert not should_fire(WebhookAction(fire_on="activate"), "deactivate") + assert should_fire(WebhookAction(fire_on="both"), "activate") + assert should_fire(WebhookAction(fire_on="both"), "deactivate") + + +class TestModelRoundTrip: + def test_webhook_action_round_trips(self): + a = WebhookAction( + webhook_url="https://example.com/hook", + method="PUT", + body_template="hi {{event}}", + content_type="text/plain", + fire_on="both", + ) + back = Action.from_dict(a.to_dict()) + assert isinstance(back, WebhookAction) + assert back.webhook_url == "https://example.com/hook" + assert back.method == "PUT" + assert back.fire_on == "both" + + def test_unknown_action_type_raises(self): + with pytest.raises(ValueError): + Action.from_dict({"action_type": "nope"}) + + def test_from_dict_normalises_bad_method_and_fire_on(self): + a = WebhookAction.from_dict({"method": "delete", "fire_on": "whenever"}) + assert a.method == "POST" + assert a.fire_on == "activate" + + def test_automation_actions_survive_serialization(self): + auto = _automation([WebhookAction(webhook_url="https://x.test/h", fire_on="both")]) + back = Automation.from_dict(auto.to_dict()) + assert len(back.actions) == 1 + assert isinstance(back.actions[0], WebhookAction) + assert back.actions[0].webhook_url == "https://x.test/h" + + def test_no_actions_omitted_from_dict(self): + assert "actions" not in _automation().to_dict() + + +class TestFire: + @respx.mock + @pytest.mark.asyncio + async def test_success_returns_true(self): + route = respx.post("http://93.184.216.34/hook").mock(return_value=httpx.Response(204)) + action = WebhookAction(webhook_url="http://93.184.216.34/hook", body_template="{{event}}") + ok, err = await fire_webhook_action(action, _automation(), "activate") + assert ok is True and err is None + assert route.called + # Body template was rendered and sent. + assert route.calls.last.request.content == b"activate" + + @respx.mock + @pytest.mark.asyncio + async def test_non_2xx_returns_error(self): + respx.post("http://93.184.216.34/hook").mock(return_value=httpx.Response(500)) + action = WebhookAction(webhook_url="http://93.184.216.34/hook") + ok, err = await fire_webhook_action(action, _automation(), "activate") + assert ok is False and err == "HTTP 500" + + @pytest.mark.asyncio + async def test_ssrf_blocked_loopback(self): + # validate_polling_url must reject loopback — no HTTP call is made. + action = WebhookAction(webhook_url="http://127.0.0.1:8080/admin") + ok, err = await fire_webhook_action(action, _automation(), "activate") + assert ok is False and "SSRF" in (err or "") + + @pytest.mark.asyncio + async def test_empty_url_returns_error(self): + ok, err = await fire_webhook_action( + WebhookAction(webhook_url=""), _automation(), "activate" + ) + assert ok is False and err == "no URL configured" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])