feat: roadmap round two (2026-06-23) — per-pixel smart-lights + integrations

A) Per-pixel smart-lights
- LIFX multizone (SetExtendedColorZones msg 510, <=82 zones) + Tile
  (SetTileState64 715), auto-detected on connect with single-colour
  fallback; lifx_per_zone threaded like nanoleaf_per_panel
- Hue gradient-lightstrip mapping: Entertainment v2 frame now keyed by
  channel id (was 1 light=1 LED), channels discovered on connect;
  hue_gradient_mode toggle (default on)

B) Integrations bundle
- Outbound webhook automation action (Discord/IFTTT/Zapier/Node-RED),
  SSRF-gated via validate_polling_url at both save and fire time; fires
  on activate/deactivate, best-effort, audited
- Home Assistant MQTT auto-discovery: read-only binary_sensors per
  automation + connectivity, availability via birth/will, cleanup on
  disable/delete, live state from the engine

Shared: pixel_reduce.resample_to_n nearest-neighbour helper.
57 new tests (lifx_multizone, hue_segment, webhook_action, ha_discovery).
Gate: ruff + tsc + build clean, pytest 2719 passed / 2 skipped.
This commit is contained in:
2026-06-23 00:50:22 +03:00
parent 6745e25b20
commit 39b0554444
40 changed files with 1962 additions and 40 deletions
@@ -12,6 +12,7 @@ from ledgrab.api.dependencies import (
get_scene_preset_store, get_scene_preset_store,
) )
from ledgrab.api.schemas.automations import ( from ledgrab.api.schemas.automations import (
ActionSchema,
AutomationCreate, AutomationCreate,
AutomationListResponse, AutomationListResponse,
AutomationResponse, AutomationResponse,
@@ -21,6 +22,7 @@ from ledgrab.api.schemas.automations import (
) )
from ledgrab.core.automations.automation_engine import AutomationEngine from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.storage.automation import ( from ledgrab.storage.automation import (
Action,
ApplicationRule, ApplicationRule,
DisplayStateRule, DisplayStateRule,
HomeAssistantRule, HomeAssistantRule,
@@ -32,11 +34,13 @@ from ledgrab.storage.automation import (
StartupRule, StartupRule,
SystemIdleRule, SystemIdleRule,
TimeOfDayRule, TimeOfDayRule,
WebhookAction,
WebhookRule, WebhookRule,
) )
from ledgrab.storage.automation_store import AutomationStore from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset_store import ScenePresetStore from ledgrab.storage.scene_preset_store import ScenePresetStore
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
from ledgrab.utils.safe_source import validate_polling_url
from ledgrab.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -113,6 +117,38 @@ def _rule_to_schema(r: Rule) -> RuleSchema:
return RuleSchema(**d) 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( def _automation_to_response(
automation, engine: AutomationEngine, request: Request = None automation, engine: AutomationEngine, request: Request = None
) -> AutomationResponse: ) -> AutomationResponse:
@@ -148,6 +184,7 @@ def _automation_to_response(
last_activated_at=state.get("last_activated_at"), last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"), last_deactivated_at=state.get("last_deactivated_at"),
tags=automation.tags, tags=automation.tags,
actions=[_action_to_schema(a) for a in getattr(automation, "actions", [])],
icon=getattr(automation, "icon", "") or "", icon=getattr(automation, "icon", "") or "",
icon_color=getattr(automation, "icon_color", "") or "", icon_color=getattr(automation, "icon_color", "") or "",
created_at=automation.created_at, created_at=automation.created_at,
@@ -204,6 +241,7 @@ async def create_automation(
try: try:
rules = [_rule_from_schema(r) for r in data.rules] rules = [_rule_from_schema(r) for r in data.rules]
actions = [_action_from_schema(a) for a in data.actions]
except EntityNotFoundError as e: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@@ -219,6 +257,7 @@ async def create_automation(
deactivation_mode=data.deactivation_mode, deactivation_mode=data.deactivation_mode,
deactivation_scene_preset_id=data.deactivation_scene_preset_id, deactivation_scene_preset_id=data.deactivation_scene_preset_id,
tags=data.tags, tags=data.tags,
actions=actions,
icon=data.icon, icon=data.icon,
icon_color=data.icon_color, icon_color=data.icon_color,
) )
@@ -301,6 +340,13 @@ async def update_automation(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(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: try:
# If disabling, deactivate first # If disabling, deactivate first
if data.enabled is False: if data.enabled is False:
@@ -315,6 +361,7 @@ async def update_automation(
rules=rules, rules=rules,
deactivation_mode=data.deactivation_mode, deactivation_mode=data.deactivation_mode,
tags=data.tags, tags=data.tags,
actions=actions,
icon=data.icon, icon=data.icon,
icon_color=data.icon_color, icon_color=data.icon_color,
) )
+8
View File
@@ -96,9 +96,11 @@ def _device_to_response(device) -> DeviceResponse:
espnow_channel=device.espnow_channel, espnow_channel=device.espnow_channel,
hue_paired=bool(device.hue_username and device.hue_client_key), hue_paired=bool(device.hue_username and device.hue_client_key),
hue_entertainment_group_id=device.hue_entertainment_group_id, 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, yeelight_min_interval_ms=device.yeelight_min_interval_ms,
wiz_min_interval_ms=device.wiz_min_interval_ms, wiz_min_interval_ms=device.wiz_min_interval_ms,
lifx_min_interval_ms=device.lifx_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, govee_min_interval_ms=device.govee_min_interval_ms,
opc_channel=device.opc_channel, opc_channel=device.opc_channel,
nanoleaf_paired=bool(device.nanoleaf_token), nanoleaf_paired=bool(device.nanoleaf_token),
@@ -262,6 +264,9 @@ async def create_device(
hue_username=device_data.hue_username or "", hue_username=device_data.hue_username or "",
hue_client_key=device_data.hue_client_key or "", hue_client_key=device_data.hue_client_key or "",
hue_entertainment_group_id=device_data.hue_entertainment_group_id 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=( yeelight_min_interval_ms=(
device_data.yeelight_min_interval_ms device_data.yeelight_min_interval_ms
if device_data.yeelight_min_interval_ms is not None 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 if device_data.lifx_min_interval_ms is not None
else 50 else 50
), ),
lifx_per_zone=bool(device_data.lifx_per_zone),
govee_min_interval_ms=( govee_min_interval_ms=(
device_data.govee_min_interval_ms device_data.govee_min_interval_ms
if device_data.govee_min_interval_ms is not None 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_username=update_data.hue_username,
hue_client_key=update_data.hue_client_key, hue_client_key=update_data.hue_client_key,
hue_entertainment_group_id=update_data.hue_entertainment_group_id, 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, yeelight_min_interval_ms=update_data.yeelight_min_interval_ms,
wiz_min_interval_ms=update_data.wiz_min_interval_ms, wiz_min_interval_ms=update_data.wiz_min_interval_ms,
lifx_min_interval_ms=update_data.lifx_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, govee_min_interval_ms=update_data.govee_min_interval_ms,
opc_channel=update_data.opc_channel, opc_channel=update_data.opc_channel,
nanoleaf_token=update_data.nanoleaf_token, nanoleaf_token=update_data.nanoleaf_token,
+13
View File
@@ -42,6 +42,8 @@ def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse
password_set=bool(source.password), password_set=bool(source.password),
client_id=source.client_id, client_id=source.client_id,
base_topic=source.base_topic, 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, connected=runtime.is_connected if runtime else False,
description=source.description, description=source.description,
tags=source.tags, tags=source.tags,
@@ -90,6 +92,8 @@ async def create_mqtt_source(
password=data.password, password=data.password,
client_id=data.client_id, client_id=data.client_id,
base_topic=data.base_topic, base_topic=data.base_topic,
publish_ha_discovery=data.publish_ha_discovery,
discovery_prefix=data.discovery_prefix,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon, icon=data.icon,
@@ -97,6 +101,8 @@ async def create_mqtt_source(
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(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) fire_entity_event("mqtt_source", "created", source.id)
return _to_response(source, manager) return _to_response(source, manager)
@@ -141,6 +147,8 @@ async def update_mqtt_source(
password=data.password, password=data.password,
client_id=data.client_id, client_id=data.client_id,
base_topic=data.base_topic, base_topic=data.base_topic,
publish_ha_discovery=data.publish_ha_discovery,
discovery_prefix=data.discovery_prefix,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon, icon=data.icon,
@@ -151,6 +159,8 @@ async def update_mqtt_source(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
await manager.update_source(source_id) 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) fire_entity_event("mqtt_source", "updated", source.id)
return _to_response(source, manager) return _to_response(source, manager)
@@ -162,6 +172,9 @@ async def delete_mqtt_source(
store: MQTTSourceStore = Depends(get_mqtt_store), store: MQTTSourceStore = Depends(get_mqtt_store),
manager: MQTTManager = Depends(get_mqtt_manager), 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: try:
store.delete_source(source_id) store.delete_source(source_id)
except EntityNotFoundError: except EntityNotFoundError:
@@ -108,6 +108,31 @@ class RuleSchema(BaseModel):
ConditionSchema = RuleSchema 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): class AutomationCreate(BaseModel):
"""Request to create an automation.""" """Request to create an automation."""
@@ -123,6 +148,9 @@ class AutomationCreate(BaseModel):
None, description="Scene preset for fallback deactivation" None, description="Scene preset for fallback deactivation"
) )
tags: List[str] = Field(default_factory=list, description="User-defined tags") 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
@@ -148,6 +176,7 @@ class AutomationUpdate(BaseModel):
None, description="Scene preset for fallback deactivation" None, description="Scene preset for fallback deactivation"
) )
tags: List[str] | None = None tags: List[str] | None = None
actions: List[ActionSchema] | None = Field(None, description="Outbound actions (e.g. webhooks)")
icon: str | None = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
@@ -172,6 +201,9 @@ class AutomationResponse(BaseModel):
deactivation_mode: str = Field(default="none", description="Deactivation behavior") deactivation_mode: str = Field(default="none", description="Deactivation behavior")
deactivation_scene_preset_id: str | None = Field(None, description="Fallback scene preset") deactivation_scene_preset_id: str | None = Field(None, description="Fallback scene preset")
tags: List[str] = Field(default_factory=list, description="User-defined tags") 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( webhook_url: str | None = Field(
None, description="Webhook URL for the first webhook rule (if any)" None, description="Webhook URL for the first webhook rule (if any)"
) )
+23
View File
@@ -59,6 +59,10 @@ class DeviceCreate(BaseModel):
hue_entertainment_group_id: str | None = Field( hue_entertainment_group_id: str | None = Field(
None, description="Hue entertainment group/zone ID" 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 fields
yeelight_min_interval_ms: int | None = Field( yeelight_min_interval_ms: int | None = Field(
None, None,
@@ -80,6 +84,10 @@ class DeviceCreate(BaseModel):
le=10000, le=10000,
description="LIFX client-side rate limit between commands in ms (default 50)", 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 fields
govee_min_interval_ms: int | None = Field( govee_min_interval_ms: int | None = Field(
None, None,
@@ -198,6 +206,10 @@ class DeviceUpdate(BaseModel):
hue_username: str | None = Field(None, description="Hue bridge username") hue_username: str | None = Field(None, description="Hue bridge username")
hue_client_key: str | None = Field(None, description="Hue entertainment client key") 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_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( yeelight_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Yeelight client-side rate limit in ms" 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( lifx_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="LIFX client-side rate limit in ms" 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( govee_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Govee client-side rate limit in ms" 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_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( yeelight_min_interval_ms: int = Field(
default=500, description="Yeelight client-side rate limit in ms" 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") 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_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") 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)") opc_channel: int = Field(default=0, description="OPC channel (0 = broadcast to all)")
nanoleaf_paired: bool = Field( nanoleaf_paired: bool = Field(
+12
View File
@@ -16,6 +16,12 @@ class MQTTSourceCreate(BaseModel):
password: str = Field(default="", description="Broker password (optional)") password: str = Field(default="", description="Broker password (optional)")
client_id: str = Field(default="ledgrab", description="MQTT client ID") client_id: str = Field(default="ledgrab", description="MQTT client ID")
base_topic: str = Field(default="ledgrab", description="Base topic prefix") 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) description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: str | None = Field( icon: str | None = Field(
@@ -40,6 +46,10 @@ class MQTTSourceUpdate(BaseModel):
password: str | None = Field(None, description="Broker password") password: str | None = Field(None, description="Broker password")
client_id: str | None = Field(None, description="MQTT client ID") client_id: str | None = Field(None, description="MQTT client ID")
base_topic: str | None = Field(None, description="Base topic prefix") 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) description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None tags: List[str] | None = None
icon: str | None = Field( icon: str | None = Field(
@@ -65,6 +75,8 @@ class MQTTSourceResponse(BaseModel):
password_set: bool = Field(default=False, description="Whether a password is configured") password_set: bool = Field(default=False, description="Whether a password is configured")
client_id: str = Field(description="MQTT client ID") client_id: str = Field(description="MQTT client ID")
base_topic: str = Field(description="Base topic prefix") 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") connected: bool = Field(default=False, description="Whether the broker connection is active")
description: str | None = Field(None, description="Description") description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -812,6 +812,8 @@ class AutomationEngine:
# Record the activation too — a no-scene activation is still a # Record the activation too — a no-scene activation is still a
# successful activation and must appear in the audit log. # successful activation and must appear in the audit log.
self._audit_activation(automation) self._audit_activation(automation)
await self._fire_actions(automation, "activate")
await self._publish_mqtt_state(automation.id, True)
return return
if not self._scene_preset_store or not self._target_store or not self._device_store: 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). # Audit record — best-effort (shared helper, also used by no-scene path).
self._audit_activation(automation) 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]]: async def _apply_manual_scene(self, automation: Automation) -> tuple[str, list[str]]:
"""Apply the automation's scene once for a manual trigger. """Apply the automation's scene once for a manual trigger.
@@ -987,6 +1043,22 @@ class AutomationEngine:
except Exception: except Exception:
pass 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: async def _deactivate_revert(self, automation_id: str) -> None:
"""Revert to pre-activation snapshot.""" """Revert to pre-activation snapshot."""
snapshot = self._pre_activation_snapshots.pop(automation_id, None) snapshot = self._pre_activation_snapshots.pop(automation_id, None)
@@ -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
@@ -79,6 +79,9 @@ class HueConfig(BaseDeviceConfig):
hue_username: str = "" hue_username: str = ""
hue_client_key: str = "" hue_client_key: str = ""
hue_entertainment_group_id: 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) @dataclass(frozen=True)
@@ -115,6 +118,8 @@ class LIFXConfig(BaseDeviceConfig):
device_type: Literal["lifx"] = "lifx" device_type: Literal["lifx"] = "lifx"
lifx_min_interval_ms: int = 50 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) @dataclass(frozen=True)
+91 -13
View File
@@ -9,6 +9,7 @@ from typing import List, Tuple
import numpy as np import numpy as np
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient 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 from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -24,15 +25,40 @@ COLOR_SPACE_RGB = 0x00
HEADER_SIZE = 16 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( def _build_entertainment_frame(
lights: List[Tuple[int, int, int]], colors: List[Tuple[int, int, int]],
brightness: int = 255, brightness: int = 255,
sequence: int = 0, sequence: int = 0,
channel_ids: List[int] | None = None,
) -> bytes: ) -> bytes:
"""Build a Hue Entertainment API v2 UDP frame. """Build a Hue Entertainment API v2 UDP frame.
Each light gets 7 bytes: [light_id(2B)][R(2B)][G(2B)][B(2B)] Each record is 7 bytes: [channel_id(1B)][R(2B)][G(2B)][B(2B)]. Colors are
Colors are 16-bit (0-65535). We scale 8-bit RGB + brightness. 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
header = bytearray(HEADER_SIZE) header = bytearray(HEADER_SIZE)
@@ -45,15 +71,15 @@ def _build_entertainment_frame(
header[14] = COLOR_SPACE_RGB header[14] = COLOR_SPACE_RGB
header[15] = 0x00 # reserved header[15] = 0x00 # reserved
# Light data # Channel data
# Note: brightness already applied by processor loop (_cached_brightness) # Note: brightness already applied by processor loop (_cached_brightness)
data = bytearray() data = bytearray()
for idx, (r, g, b) in enumerate(lights): for idx, (r, g, b) in enumerate(colors):
light_id = idx # 0-based light index in entertainment group 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 r16 = int(r * 257) # scale 0-255 to 0-65535
g16 = int(g * 257) g16 = int(g * 257)
b16 = int(b * 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) return bytes(header) + bytes(data)
@@ -62,7 +88,11 @@ class HueClient(LEDClient):
"""LED client for Philips Hue Entertainment API streaming. """LED client for Philips Hue Entertainment API streaming.
Uses UDP (optionally DTLS) to stream color data at ~25 fps to a Hue 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__( def __init__(
@@ -72,6 +102,7 @@ class HueClient(LEDClient):
hue_username: str = "", hue_username: str = "",
hue_client_key: str = "", hue_client_key: str = "",
hue_entertainment_group_id: str = "", hue_entertainment_group_id: str = "",
gradient_mode: bool = True,
**kwargs, **kwargs,
): ):
self._bridge_ip = url.replace("hue://", "").rstrip("/") self._bridge_ip = url.replace("hue://", "").rstrip("/")
@@ -79,15 +110,55 @@ class HueClient(LEDClient):
self._username = hue_username self._username = hue_username
self._client_key = hue_client_key self._client_key = hue_client_key
self._group_id = hue_entertainment_group_id self._group_id = hue_entertainment_group_id
self._gradient_mode = gradient_mode
self._channel_ids: List[int] = []
self._sock: socket.socket | None = None self._sock: socket.socket | None = None
self._connected = False self._connected = False
self._sequence = 0 self._sequence = 0
self._dtls_sock = None 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: async def connect(self) -> bool:
# Activate entertainment streaming via REST API # Activate entertainment streaming via REST API
await self._activate_streaming(True) 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 # Open UDP socket for entertainment streaming
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._sock.setblocking(False) self._sock.setblocking(False)
@@ -179,12 +250,19 @@ class HueClient(LEDClient):
if not self._connected: if not self._connected:
return return
if isinstance(pixels, np.ndarray): # Resample the strip to the number of addressable elements: the
light_colors = [tuple(pixels[i]) for i in range(min(len(pixels), self._led_count))] # discovered channel count in gradient mode, else the configured
else: # light count. ``resample_to_n`` spreads the strip spatially and
light_colors = pixels[: self._led_count] # 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 self._sequence = (self._sequence + 1) & 0xFF
try: try:
@@ -50,6 +50,7 @@ class HueDeviceProvider(LEDDeviceProvider):
hue_username=config.hue_username, hue_username=config.hue_username,
hue_client_key=config.hue_client_key, hue_client_key=config.hue_client_key,
hue_entertainment_group_id=config.hue_entertainment_group_id, 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: async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
+238 -22
View File
@@ -29,6 +29,7 @@ import numpy as np
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient 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 average_color as _average_color
from ledgrab.core.devices.pixel_reduce import resample_to_n
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -41,6 +42,21 @@ MSG_GET_SERVICE = 2
MSG_STATE_SERVICE = 3 MSG_STATE_SERVICE = 3
MSG_SET_POWER = 21 MSG_SET_POWER = 21
MSG_SET_COLOR = 102 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 field byte 0 of the protocol header: tagged=1, addressable=1, protocol=1024
_FRAME_TAGGED = 0x3400 _FRAME_TAGGED = 0x3400
@@ -142,6 +158,105 @@ def _build_set_power_payload(on: bool, duration_ms: int = 0) -> bytes:
return struct.pack("<HI", 65535 if on else 0, duration_ms & 0xFFFFFFFF) return struct.pack("<HI", 65535 if on else 0, duration_ms & 0xFFFFFFFF)
def _build_get_color_zones_payload(start: int = 0, end: int = 255) -> bytes:
"""GetColorZones (502) payload: start_index(1) | end_index(1)."""
return struct.pack("<BB", start & 0xFF, end & 0xFF)
def _pack_hsbk_array(hsbk_list: List[Tuple[int, int, int, int]], slots: int) -> 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("<HHHH", h & 0xFFFF, s & 0xFFFF, b & 0xFFFF, k & 0xFFFF)
used = min(len(hsbk_list), slots)
body += b"\x00" * (8 * (slots - used))
return bytes(body)
def _build_set_extended_color_zones_payload(
hsbk_list: List[Tuple[int, int, int, int]],
*,
duration_ms: int = 0,
apply: int = _ZONE_APPLY,
zone_index: int = 0,
) -> 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(
"<IBHB", duration_ms & 0xFFFFFFFF, apply & 0xFF, zone_index & 0xFFFF, count & 0xFF
)
return header + _pack_hsbk_array(hsbk_list, _EXT_ZONE_MAX)
def _build_set_tile_state64_payload(
hsbk_list: List[Tuple[int, int, int, int]],
*,
tile_index: int = 0,
x: int = 0,
y: int = 0,
width: int = 8,
duration_ms: int = 0,
) -> bytes:
"""SetTileState64 (715): tile_index|length|reserved|x|y|width|duration(4)|64×HSBK."""
header = struct.pack(
"<BBBBBBI",
tile_index & 0xFF,
1, # length: number of tiles this packet addresses
0, # reserved
x & 0xFF,
y & 0xFF,
width & 0xFF,
duration_ms & 0xFFFFFFFF,
)
return header + _pack_hsbk_array(hsbk_list, _TILE_PIXELS)
def _parse_multizone_reply(raw: bytes) -> 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("<H", raw, 32)[0]
if msg_type not in (MSG_STATE_ZONE, MSG_STATE_MULTIZONE):
return None
count, index = struct.unpack_from("<BB", raw, 36)
return {"count": int(count), "index": int(index)}
def _parse_state_device_chain(raw: bytes) -> 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("<H", raw, 32)[0]
if msg_type != MSG_STATE_DEVICE_CHAIN:
return None
payload_off = 36
start_index = raw[payload_off]
count_off = payload_off + 1 + _TILE_STRUCT_SIZE * _TILE_CHAIN_MAX
total = min(raw[count_off], _TILE_CHAIN_MAX)
tiles: list[tuple[int, int]] = []
for i in range(total):
base = payload_off + 1 + i * _TILE_STRUCT_SIZE
width = raw[base + 16]
height = raw[base + 17]
tiles.append((int(width), int(height)))
return {"start_index": int(start_index), "tiles": tiles}
def _parse_state_service_reply(raw: bytes) -> dict | None: def _parse_state_service_reply(raw: bytes) -> dict | None:
"""Parse a LIFX StateService (discovery) reply. """Parse a LIFX StateService (discovery) reply.
@@ -163,15 +278,25 @@ def _parse_state_service_reply(raw: bytes) -> dict | None:
class _LIFXProtocol(asyncio.DatagramProtocol): 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): def connection_made(self, transport):
self.transport = transport self.transport = transport
def datagram_received(self, data, addr): def datagram_received(self, data, addr):
# LIFX bulbs sometimes echo back state on broadcast. We don't need it if len(self.received) < self._MAX_BUFFER:
# for streaming ambilight — discard. self.received.append(bytes(data))
pass
def error_received(self, exc): def error_received(self, exc):
logger.debug("LIFX UDP error: %s", exc) logger.debug("LIFX UDP error: %s", exc)
@@ -186,6 +311,7 @@ class LIFXClient(LEDClient):
led_count: int = 1, led_count: int = 1,
*, *,
min_interval_s: float = DEFAULT_MIN_INTERVAL_S, min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
per_zone: bool = False,
): ):
host, port = parse_lifx_url(url) host, port = parse_lifx_url(url)
self._host = host self._host = host
@@ -197,6 +323,12 @@ class LIFXClient(LEDClient):
self._connected = False self._connected = False
self._next_tx_at: float = 0.0 self._next_tx_at: float = 0.0
self._sequence: int = 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 @property
def host(self) -> str: def host(self) -> str:
@@ -212,6 +344,12 @@ class LIFXClient(LEDClient):
@property @property
def device_led_count(self) -> int | None: 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 return self._led_count or None
async def connect(self) -> bool: async def connect(self) -> bool:
@@ -227,9 +365,56 @@ class LIFXClient(LEDClient):
self._transport = transport self._transport = transport
self._protocol = protocol # type: ignore[assignment] self._protocol = protocol # type: ignore[assignment]
self._connected = True 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 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: async def close(self) -> None:
if self._transport is not None: if self._transport is not None:
try: try:
@@ -255,25 +440,63 @@ class LIFXClient(LEDClient):
) )
self._transport.sendto(packet) 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( async def send_pixels(
self, self,
pixels: List[Tuple[int, int, int]] | np.ndarray, pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
"""Average the strip → HSBK → SetColor.""" """Stream per-zone/tile when detected, else average the strip → SetColor."""
if not self.is_connected: if not self.is_connected:
raise RuntimeError("LIFXClient not connected") raise RuntimeError("LIFXClient not connected")
now = time.monotonic() now = time.monotonic()
if now < self._next_tx_at: if now < self._next_tx_at:
return True return True
r, g, b = _average_color(pixels) self._emit_pixels(pixels, brightness)
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._next_tx_at = now + self._min_interval_s self._next_tx_at = now + self._min_interval_s
return True return True
@@ -288,14 +511,7 @@ class LIFXClient(LEDClient):
now = time.monotonic() now = time.monotonic()
if now < self._next_tx_at: if now < self._next_tx_at:
return return
r, g, b = _average_color(pixels) self._emit_pixels(pixels, brightness)
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._next_tx_at = now + self._min_interval_s self._next_tx_at = now + self._min_interval_s
@property @property
@@ -51,6 +51,7 @@ class LIFXDeviceProvider(LEDDeviceProvider):
config.device_url, config.device_url,
led_count=config.led_count, led_count=config.led_count,
min_interval_s=max(0.0, config.lifx_min_interval_ms / 1000.0), 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: async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
@@ -40,3 +40,29 @@ def average_color(
total_b += b total_b += b
n = len(pixels) n = len(pixels)
return total_r // n, total_g // n, total_b // n 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
@@ -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
``<discovery_prefix>/<component>/<node_id>/<object_id>/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 ``<base_topic>/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)
+91 -1
View File
@@ -22,10 +22,20 @@ class MQTTManager:
Multiple consumers share the same runtime via acquire/release. 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 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) # source_id -> (runtime, ref_count)
self._runtimes: Dict[str, tuple] = {} self._runtimes: Dict[str, tuple] = {}
# Sources for which we hold a discovery acquire() reference.
self._discovery_sources: set[str] = set()
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
async def acquire(self, source_id: str) -> MQTTRuntime: async def acquire(self, source_id: str) -> MQTTRuntime:
@@ -100,6 +110,86 @@ class MQTTManager:
except Exception as e: except Exception as e:
logger.warning("Failed to update MQTT runtime %s: %s", source_id, 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]]: def get_connection_status(self) -> List[Dict[str, Any]]:
"""Get status of all active MQTT connections (for dashboard indicators).""" """Get status of all active MQTT connections (for dashboard indicators)."""
result = [] result = []
+6 -1
View File
@@ -180,7 +180,9 @@ weather_manager = WeatherManager(weather_source_store)
ha_store = HomeAssistantStore(db) ha_store = HomeAssistantStore(db)
ha_manager = HomeAssistantManager(ha_store) ha_manager = HomeAssistantManager(ha_store)
mqtt_source_store = MQTTSourceStore(db) 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) http_endpoint_store = HTTPEndpointStore(db)
audio_processing_template_store = AudioProcessingTemplateStore(db) audio_processing_template_store = AudioProcessingTemplateStore(db)
game_integration_store = GameIntegrationStore(db) game_integration_store = GameIntegrationStore(db)
@@ -424,6 +426,9 @@ async def lifespan(app: FastAPI):
# Start automation engine (evaluates conditions and activates scenes) # Start automation engine (evaluates conditions and activates scenes)
await automation_engine.start() 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) # Start auto-backup engine (periodic configuration backups)
await auto_backup_engine.start() await auto_backup_engine.start()
@@ -126,6 +126,10 @@ class AutomationEditorModal extends Modal {
deactivationMode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value, deactivationMode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
deactivationScenePresetId: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value, deactivationScenePresetId: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value,
tags: JSON.stringify(_automationTagsInput ? _automationTagsInput.getValue() : []), 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 = `<div class="mod-chips">${ruleChain}${_chainArrow('→')}${sceneChipHtml}${deactivationHtml}</div>`; // ── 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 = `<div class="mod-chips">${ruleChain}${_chainArrow('→')}${sceneChipHtml}${deactivationHtml}${actionHtml}</div>`;
// ── State surfaces: LED + patch indicator ── // ── State surfaces: LED + patch indicator ──
// Active = blink (live signal); Enabled-but-idle = off (waiting); // Active = blink (live signal); Enabled-but-idle = off (waiting);
@@ -633,6 +648,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
_ensureRuleLogicIconSelect(); _ensureRuleLogicIconSelect();
_ensureDeactivationModeIconSelect(); _ensureDeactivationModeIconSelect();
_ensureActionIconSelects();
// Fetch scenes for selector // Fetch scenes for selector
try { try {
@@ -643,6 +659,8 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
(document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value = 'none'; (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value = 'none';
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none'); if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none');
(document.getElementById('automation-fallback-scene-group') as HTMLElement).style.display = '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[] = []; let _editorTags: any[] = [];
@@ -670,6 +688,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode); if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode);
_onDeactivationModeChange(); _onDeactivationModeChange();
_initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id); _initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id);
_loadAutomationAction(automation.actions || []);
_editorTags = automation.tags || []; _editorTags = automation.tags || [];
} catch (e: any) { } catch (e: any) {
showToast(e.message, 'error'); showToast(e.message, 'error');
@@ -698,6 +717,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode); if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode);
_onDeactivationModeChange(); _onDeactivationModeChange();
_initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id); _initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id);
_loadAutomationAction(cloneData.actions || []);
_editorTags = cloneData.tags || []; _editorTags = cloneData.tags || [];
} else { } else {
titleEl!.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`; titleEl!.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
@@ -712,6 +732,8 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
// Wire up deactivation mode change // Wire up deactivation mode change
(document.getElementById('automation-deactivation-mode') as HTMLSelectElement).onchange = _onDeactivationModeChange; (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 // Auto-name wiring
_autoNameManuallyEdited = !!(automationId || cloneData); _autoNameManuallyEdited = !!(automationId || cloneData);
@@ -805,6 +827,56 @@ function _ensureDeactivationModeIconSelect() {
_deactivationModeIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any); _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 ===== // ===== Condition editor =====
export function addAutomationRule() { export function addAutomationRule() {
@@ -1562,6 +1634,15 @@ export async function saveAutomationEditor() {
return; 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 = { const body = {
name, name,
enabled: enabledInput.checked, enabled: enabledInput.checked,
@@ -1571,6 +1652,7 @@ export async function saveAutomationEditor() {
deactivation_mode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value, deactivation_mode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
deactivation_scene_preset_id: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value || null, deactivation_scene_preset_id: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value || null,
tags: _automationTagsInput ? _automationTagsInput.getValue() : [], tags: _automationTagsInput ? _automationTagsInput.getValue() : [],
actions,
}; };
const automationId = idInput.value; const automationId = idInput.value;
@@ -1010,6 +1010,8 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) {
if (lmi && cloneData.lifx_min_interval_ms != null) { if (lmi && cloneData.lifx_min_interval_ms != null) {
lmi.value = String(cloneData.lifx_min_interval_ms); 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 // Prefill Nanoleaf fields (clone only carries the rate limit — the
// token is not exposed in /devices responses, so a cloned device // 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_username = (document.getElementById('device-hue-username') as HTMLInputElement)?.value || '';
body.hue_client_key = (document.getElementById('device-hue-client-key') 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_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)) { if (isYeelightDevice(deviceType)) {
const raw = (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value; 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 raw = (document.getElementById('device-lifx-min-interval') as HTMLInputElement)?.value;
const parsed = parseInt(raw || '50', 10); const parsed = parseInt(raw || '50', 10);
body.lifx_min_interval_ms = Number.isFinite(parsed) ? parsed : 50; 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)) { if (isGoveeDevice(deviceType)) {
const raw = (document.getElementById('device-govee-min-interval') as HTMLInputElement)?.value; const raw = (document.getElementById('device-govee-min-interval') as HTMLInputElement)?.value;
@@ -1614,7 +1618,7 @@ function _showEspnowFields(show: boolean) {
} }
function _showHueFields(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 => { ids.forEach(id => {
const el = document.getElementById(id) as HTMLElement; const el = document.getElementById(id) as HTMLElement;
if (el) el.style.display = show ? '' : 'none'; if (el) el.style.display = show ? '' : 'none';
@@ -1639,6 +1643,8 @@ function _showWizFields(show: boolean) {
function _showLifxFields(show: boolean) { function _showLifxFields(show: boolean) {
const el = document.getElementById('device-lifx-min-interval-group') as HTMLElement | null; const el = document.getElementById('device-lifx-min-interval-group') as HTMLElement | null;
if (el) el.style.display = show ? '' : 'none'; 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) { function _showGoveeFields(show: boolean) {
@@ -701,10 +701,14 @@ export async function showSettings(deviceId: any) {
// (HSBK averaged from the strip). LIFX recommends ≤20 cmd/sec per // (HSBK averaged from the strip). LIFX recommends ≤20 cmd/sec per
// device; default 50 ms matches that ceiling. // device; default 50 ms matches that ceiling.
const lifxMinIntervalGroup = document.getElementById('settings-lifx-min-interval-group'); const lifxMinIntervalGroup = document.getElementById('settings-lifx-min-interval-group');
const lifxPerZoneGroup = document.getElementById('settings-lifx-per-zone-group');
if (isLifxDevice(device.device_type)) { if (isLifxDevice(device.device_type)) {
if (lifxMinIntervalGroup) (lifxMinIntervalGroup as HTMLElement).style.display = ''; if (lifxMinIntervalGroup) (lifxMinIntervalGroup as HTMLElement).style.display = '';
const lmi = device.lifx_min_interval_ms ?? 50; const lmi = device.lifx_min_interval_ms ?? 50;
(document.getElementById('settings-lifx-min-interval') as HTMLInputElement).value = String(lmi); (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) // 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 urlLabel6 = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null;
const urlHint6 = urlGroup.querySelector('.input-hint') 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'; urlInput.placeholder = t('device.lifx.url.placeholder') || '192.168.1.50';
} else { } else {
if (lifxMinIntervalGroup) (lifxMinIntervalGroup as HTMLElement).style.display = 'none'; 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 // 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 raw = (document.getElementById('settings-lifx-min-interval') as HTMLInputElement | null)?.value;
const parsed = parseInt(raw || '50', 10); const parsed = parseInt(raw || '50', 10);
body.lifx_min_interval_ms = Number.isFinite(parsed) ? parsed : 50; 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)) { if (isGoveeDevice(settingsModal.deviceType)) {
const raw = (document.getElementById('settings-govee-min-interval') as HTMLInputElement | null)?.value; const raw = (document.getElementById('settings-govee-min-interval') as HTMLInputElement | null)?.value;
@@ -56,6 +56,8 @@ class MQTTSourceModal extends Modal {
base_topic: (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value, base_topic: (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value,
description: (document.getElementById('mqtt-source-description') as HTMLInputElement).value, description: (document.getElementById('mqtt-source-description') as HTMLInputElement).value,
tags: JSON.stringify(_mqttTagsInput ? _mqttTagsInput.getValue() : []), 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-password') as HTMLInputElement).value = ''; // never expose
(document.getElementById('mqtt-source-client-id') as HTMLInputElement).value = editData.client_id || 'ledgrab'; (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-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 || ''; (document.getElementById('mqtt-source-description') as HTMLInputElement).value = editData.description || '';
} else { } else {
(document.getElementById('mqtt-source-name') as HTMLInputElement).value = ''; (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-password') as HTMLInputElement).value = '';
(document.getElementById('mqtt-source-client-id') as HTMLInputElement).value = 'ledgrab'; (document.getElementById('mqtt-source-client-id') as HTMLInputElement).value = 'ledgrab';
(document.getElementById('mqtt-source-base-topic') 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 = ''; (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 // Tags
if (_mqttTagsInput) { _mqttTagsInput.destroy(); _mqttTagsInput = null; } if (_mqttTagsInput) { _mqttTagsInput.destroy(); _mqttTagsInput = null; }
_mqttTagsInput = new TagInput(document.getElementById('mqtt-source-tags-container'), { placeholder: t('tags.placeholder') }); _mqttTagsInput = new TagInput(document.getElementById('mqtt-source-tags-container'), { placeholder: t('tags.placeholder') });
@@ -125,6 +140,8 @@ export async function saveMQTTSource(): Promise<void> {
const password = (document.getElementById('mqtt-source-password') as HTMLInputElement).value; 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 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 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; const description = (document.getElementById('mqtt-source-description') as HTMLInputElement).value.trim() || null;
if (!name) { if (!name) {
@@ -138,6 +155,7 @@ export async function saveMQTTSource(): Promise<void> {
const payload: Record<string, any> = { const payload: Record<string, any> = {
name, broker_port, username, client_id, base_topic, description, name, broker_port, username, client_id, base_topic, description,
publish_ha_discovery, discovery_prefix,
tags: _mqttTagsInput ? _mqttTagsInput.getValue() : [], tags: _mqttTagsInput ? _mqttTagsInput.getValue() : [],
}; };
if (broker_host) payload.broker_host = broker_host; if (broker_host) payload.broker_host = broker_host;
@@ -48,6 +48,15 @@ export interface AutomationRule {
value?: string; 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 { export interface Automation {
id: string; id: string;
name: string; name: string;
@@ -58,6 +67,7 @@ export interface Automation {
deactivation_mode: 'none' | 'revert' | 'fallback_scene'; deactivation_mode: 'none' | 'revert' | 'fallback_scene';
deactivation_scene_preset_id?: string; deactivation_scene_preset_id?: string;
tags: string[]; tags: string[];
actions?: AutomationAction[];
webhook_url?: string; webhook_url?: string;
is_active: boolean; is_active: boolean;
last_activated_at?: string; last_activated_at?: string;
@@ -38,9 +38,11 @@ export interface Device {
espnow_channel: number; espnow_channel: number;
hue_paired: boolean; hue_paired: boolean;
hue_entertainment_group_id: string; hue_entertainment_group_id: string;
hue_gradient_mode?: boolean;
yeelight_min_interval_ms: number; yeelight_min_interval_ms: number;
wiz_min_interval_ms: number; wiz_min_interval_ms: number;
lifx_min_interval_ms: number; lifx_min_interval_ms: number;
lifx_per_zone?: boolean;
govee_min_interval_ms: number; govee_min_interval_ms: number;
nanoleaf_paired: boolean; nanoleaf_paired: boolean;
nanoleaf_min_interval_ms: number; nanoleaf_min_interval_ms: number;
@@ -12,6 +12,8 @@ export interface MQTTSource {
password_set: boolean; password_set: boolean;
client_id: string; client_id: string;
base_topic: string; base_topic: string;
publish_ha_discovery?: boolean;
discovery_prefix?: string;
connected: boolean; connected: boolean;
description?: string; description?: string;
tags: string[]; tags: string[];
+20
View File
@@ -75,6 +75,7 @@
"activity_log.msg.audit_log.disabled": "Activity logging disabled", "activity_log.msg.audit_log.disabled": "Activity logging disabled",
"activity_log.msg.automation.activated": "Automation '{name}' activated", "activity_log.msg.automation.activated": "Automation '{name}' activated",
"activity_log.msg.automation.deactivated": "Automation '{name}' deactivated", "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.automation.triggered": "Automation '{name}' manually triggered",
"activity_log.msg.server.shutting_down": "Server shutting down", "activity_log.msg.server.shutting_down": "Server shutting down",
"activity_log.msg.server.restarting": "Server restart requested", "activity_log.msg.server.restarting": "Server restart requested",
@@ -498,6 +499,18 @@
"automations.scene.search_placeholder": "Search scenes...", "automations.scene.search_placeholder": "Search scenes...",
"automations.section.action": "Action", "automations.section.action": "Action",
"automations.section.deactivation": "Deactivation", "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.section.triggers": "Triggers",
"automations.status.active": "Active", "automations.status.active": "Active",
"automations.status.disabled": "Disabled", "automations.status.disabled": "Disabled",
@@ -1364,6 +1377,8 @@
"device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)", "device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)",
"device.hue.group_id": "Entertainment Group:", "device.hue.group_id": "Entertainment Group:",
"device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge", "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": "Bridge IP:",
"device.hue.url.hint": "IP address of your Hue bridge", "device.hue.url.hint": "IP address of your Hue bridge",
"device.hue.username": "Bridge Username:", "device.hue.username": "Bridge Username:",
@@ -1513,6 +1528,8 @@
"device.lifx.url.placeholder": "192.168.1.50", "device.lifx.url.placeholder": "192.168.1.50",
"device.lifx_min_interval": "Min Update Interval:", "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_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.actual_fps": "Actual FPS",
"device.metrics.current_fps": "Current FPS", "device.metrics.current_fps": "Current FPS",
"device.metrics.device_fps": "Device refresh rate", "device.metrics.device_fps": "Device refresh rate",
@@ -2089,6 +2106,9 @@
"mqtt_source.add": "Add MQTT Source", "mqtt_source.add": "Add MQTT Source",
"mqtt_source.base_topic": "Base Topic:", "mqtt_source.base_topic": "Base Topic:",
"mqtt_source.base_topic.hint": "Prefix for status and state topics, e.g. ledgrab/status", "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": "Broker Host:",
"mqtt_source.broker_host.hint": "MQTT broker hostname or IP address, e.g. 192.168.1.100", "mqtt_source.broker_host.hint": "MQTT broker hostname or IP address, e.g. 192.168.1.100",
"mqtt_source.broker_port": "Port:", "mqtt_source.broker_port": "Port:",
+20
View File
@@ -78,6 +78,7 @@
"activity_log.msg.audit_log.disabled": "Запись активности отключена", "activity_log.msg.audit_log.disabled": "Запись активности отключена",
"activity_log.msg.automation.activated": "Автоматизация '{name}' активирована", "activity_log.msg.automation.activated": "Автоматизация '{name}' активирована",
"activity_log.msg.automation.deactivated": "Автоматизация '{name}' деактивирована", "activity_log.msg.automation.deactivated": "Автоматизация '{name}' деактивирована",
"activity_log.msg.automation.webhook_fired": "Вебхук для '{name}' отправлен",
"activity_log.msg.automation.triggered": "Автоматизация '{name}' запущена вручную", "activity_log.msg.automation.triggered": "Автоматизация '{name}' запущена вручную",
"activity_log.msg.server.shutting_down": "Сервер выключается", "activity_log.msg.server.shutting_down": "Сервер выключается",
"activity_log.msg.server.restarting": "Запрошен перезапуск сервера", "activity_log.msg.server.restarting": "Запрошен перезапуск сервера",
@@ -491,6 +492,18 @@
"automations.scene.search_placeholder": "Поиск сцен...", "automations.scene.search_placeholder": "Поиск сцен...",
"automations.section.action": "Действие", "automations.section.action": "Действие",
"automations.section.deactivation": "Деактивация", "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.section.triggers": "Триггеры",
"automations.status.active": "Активна", "automations.status.active": "Активна",
"automations.status.disabled": "Отключена", "automations.status.disabled": "Отключена",
@@ -1286,6 +1299,11 @@
"device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)", "device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)",
"device.hue.group_id": "Entertainment Group:", "device.hue.group_id": "Entertainment Group:",
"device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge", "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": "Bridge IP:",
"device.hue.url.hint": "IP address of your Hue bridge", "device.hue.url.hint": "IP address of your Hue bridge",
"device.hue.username": "Bridge Username:", "device.hue.username": "Bridge Username:",
@@ -1435,6 +1453,8 @@
"device.lifx.url.placeholder": "192.168.1.50", "device.lifx.url.placeholder": "192.168.1.50",
"device.lifx_min_interval": "Мин. интервал обновления:", "device.lifx_min_interval": "Мин. интервал обновления:",
"device.lifx_min_interval.hint": "Локальный лимит частоты команд (мс). LIFX рекомендует ≤20 команд/сек; по умолчанию 50 мс соответствует этому потолку.", "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.actual_fps": "Факт. FPS",
"device.metrics.current_fps": "Текущ. FPS", "device.metrics.current_fps": "Текущ. FPS",
"device.metrics.device_fps": "Частота обновления устройства", "device.metrics.device_fps": "Частота обновления устройства",
+20
View File
@@ -77,6 +77,7 @@
"activity_log.msg.audit_log.disabled": "活动记录已禁用", "activity_log.msg.audit_log.disabled": "活动记录已禁用",
"activity_log.msg.automation.activated": "自动化 '{name}' 已激活", "activity_log.msg.automation.activated": "自动化 '{name}' 已激活",
"activity_log.msg.automation.deactivated": "自动化 '{name}' 已停用", "activity_log.msg.automation.deactivated": "自动化 '{name}' 已停用",
"activity_log.msg.automation.webhook_fired": "已为 '{name}' 发送 webhook",
"activity_log.msg.automation.triggered": "已手动触发自动化 '{name}'", "activity_log.msg.automation.triggered": "已手动触发自动化 '{name}'",
"activity_log.msg.server.shutting_down": "服务器正在关闭", "activity_log.msg.server.shutting_down": "服务器正在关闭",
"activity_log.msg.server.restarting": "已请求服务器重启", "activity_log.msg.server.restarting": "已请求服务器重启",
@@ -490,6 +491,18 @@
"automations.scene.search_placeholder": "搜索场景...", "automations.scene.search_placeholder": "搜索场景...",
"automations.section.action": "动作", "automations.section.action": "动作",
"automations.section.deactivation": "停用", "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.section.triggers": "触发器",
"automations.status.active": "活动", "automations.status.active": "活动",
"automations.status.disabled": "已禁用", "automations.status.disabled": "已禁用",
@@ -1283,6 +1296,11 @@
"device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)", "device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)",
"device.hue.group_id": "Entertainment Group:", "device.hue.group_id": "Entertainment Group:",
"device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge", "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": "Bridge IP:",
"device.hue.url.hint": "IP address of your Hue bridge", "device.hue.url.hint": "IP address of your Hue bridge",
"device.hue.username": "Bridge Username:", "device.hue.username": "Bridge Username:",
@@ -1432,6 +1450,8 @@
"device.lifx.url.placeholder": "192.168.1.50", "device.lifx.url.placeholder": "192.168.1.50",
"device.lifx_min_interval": "最小更新间隔:", "device.lifx_min_interval": "最小更新间隔:",
"device.lifx_min_interval.hint": "客户端命令速率限制(毫秒)。LIFX 建议 ≤20 cmd/sec;默认 50 毫秒符合该上限。", "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.actual_fps": "实际 FPS",
"device.metrics.current_fps": "当前 FPS", "device.metrics.current_fps": "当前 FPS",
"device.metrics.device_fps": "设备刷新率", "device.metrics.device_fps": "设备刷新率",
+89
View File
@@ -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) ── # ── Backward-compatible aliases (for imports in other modules during transition) ──
Condition = Rule Condition = Rule
ApplicationCondition = ApplicationRule ApplicationCondition = ApplicationRule
@@ -406,6 +481,8 @@ class Automation:
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
tags: List[str] = field(default_factory=list) 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) # Custom card icon (frontend display only)
icon: str = "" icon: str = ""
icon_color: str = "" icon_color: str = ""
@@ -441,6 +518,10 @@ class Automation:
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_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: if self.icon:
d["icon"] = self.icon d["icon"] = self.icon
if self.icon_color: if self.icon_color:
@@ -463,6 +544,13 @@ class Automation:
except ValueError as e: except ValueError as e:
logger.warning("Skipping unknown rule type on load: %s", 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( return cls(
id=data["id"], id=data["id"],
name=data["name"], name=data["name"],
@@ -473,6 +561,7 @@ class Automation:
deactivation_mode=data.get("deactivation_mode", "none"), deactivation_mode=data.get("deactivation_mode", "none"),
deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"), deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"),
tags=data.get("tags", []), tags=data.get("tags", []),
actions=actions,
icon=data.get("icon", ""), icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""), icon_color=data.get("icon_color", ""),
created_at=datetime.fromisoformat( created_at=datetime.fromisoformat(
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List 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.base_sqlite_store import BaseSqliteStore
from ledgrab.storage.database import Database from ledgrab.storage.database import Database
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
@@ -34,6 +34,7 @@ class AutomationStore(BaseSqliteStore[Automation]):
deactivation_mode: str = "none", deactivation_mode: str = "none",
deactivation_scene_preset_id: str | None = None, deactivation_scene_preset_id: str | None = None,
tags: List[str] | None = None, tags: List[str] | None = None,
actions: List[Action] | None = None,
icon: str | None = None, icon: str | None = None,
icon_color: str | None = None, icon_color: str | None = None,
# Legacy parameter aliases # Legacy parameter aliases
@@ -65,6 +66,7 @@ class AutomationStore(BaseSqliteStore[Automation]):
created_at=now, created_at=now,
updated_at=now, updated_at=now,
tags=tags or [], tags=tags or [],
actions=actions or [],
icon=icon or "", icon=icon or "",
icon_color=icon_color or "", icon_color=icon_color or "",
) )
@@ -85,6 +87,7 @@ class AutomationStore(BaseSqliteStore[Automation]):
deactivation_mode: str | None = None, deactivation_mode: str | None = None,
deactivation_scene_preset_id: str = "__unset__", deactivation_scene_preset_id: str = "__unset__",
tags: List[str] | None = None, tags: List[str] | None = None,
actions: List[Action] | None = None,
icon: str | None = None, icon: str | None = None,
icon_color: str | None = None, icon_color: str | None = None,
# Legacy parameter aliases # Legacy parameter aliases
@@ -118,6 +121,8 @@ class AutomationStore(BaseSqliteStore[Automation]):
) )
if tags is not None: if tags is not None:
automation.tags = tags automation.tags = tags
if actions is not None:
automation.actions = actions
if icon is not None: if icon is not None:
automation.icon = icon or "" automation.icon = icon or ""
if icon_color is not None: if icon_color is not None:
@@ -81,12 +81,14 @@ class Device:
hue_username: str = "", hue_username: str = "",
hue_client_key: str = "", hue_client_key: str = "",
hue_entertainment_group_id: str = "", hue_entertainment_group_id: str = "",
hue_gradient_mode: bool = True,
# Yeelight fields # Yeelight fields
yeelight_min_interval_ms: int = 500, yeelight_min_interval_ms: int = 500,
# WiZ fields # WiZ fields
wiz_min_interval_ms: int = 50, wiz_min_interval_ms: int = 50,
# LIFX fields # LIFX fields
lifx_min_interval_ms: int = 50, lifx_min_interval_ms: int = 50,
lifx_per_zone: bool = False,
# Govee fields # Govee fields
govee_min_interval_ms: int = 50, govee_min_interval_ms: int = 50,
# OPC fields # OPC fields
@@ -142,9 +144,11 @@ class Device:
self.hue_username = hue_username self.hue_username = hue_username
self.hue_client_key = hue_client_key self.hue_client_key = hue_client_key
self.hue_entertainment_group_id = hue_entertainment_group_id 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.yeelight_min_interval_ms = yeelight_min_interval_ms
self.wiz_min_interval_ms = wiz_min_interval_ms self.wiz_min_interval_ms = wiz_min_interval_ms
self.lifx_min_interval_ms = lifx_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.govee_min_interval_ms = govee_min_interval_ms
self.opc_channel = opc_channel self.opc_channel = opc_channel
self.nanoleaf_token = nanoleaf_token self.nanoleaf_token = nanoleaf_token
@@ -241,6 +245,7 @@ class Device:
hue_username=self.hue_username, hue_username=self.hue_username,
hue_client_key=self.hue_client_key, hue_client_key=self.hue_client_key,
hue_entertainment_group_id=self.hue_entertainment_group_id, hue_entertainment_group_id=self.hue_entertainment_group_id,
hue_gradient_mode=self.hue_gradient_mode,
) )
if dt == "yeelight": if dt == "yeelight":
return YeelightConfig( return YeelightConfig(
@@ -256,6 +261,7 @@ class Device:
return LIFXConfig( return LIFXConfig(
**base, **base,
lifx_min_interval_ms=self.lifx_min_interval_ms, lifx_min_interval_ms=self.lifx_min_interval_ms,
lifx_per_zone=self.lifx_per_zone,
) )
if dt == "govee": if dt == "govee":
return GoveeConfig( return GoveeConfig(
@@ -352,12 +358,17 @@ class Device:
d["hue_client_key"] = _enc(self.hue_client_key) d["hue_client_key"] = _enc(self.hue_client_key)
if self.hue_entertainment_group_id: if self.hue_entertainment_group_id:
d["hue_entertainment_group_id"] = 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: if self.yeelight_min_interval_ms != 500:
d["yeelight_min_interval_ms"] = self.yeelight_min_interval_ms d["yeelight_min_interval_ms"] = self.yeelight_min_interval_ms
if self.wiz_min_interval_ms != 50: if self.wiz_min_interval_ms != 50:
d["wiz_min_interval_ms"] = self.wiz_min_interval_ms d["wiz_min_interval_ms"] = self.wiz_min_interval_ms
if self.lifx_min_interval_ms != 50: if self.lifx_min_interval_ms != 50:
d["lifx_min_interval_ms"] = self.lifx_min_interval_ms 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: if self.govee_min_interval_ms != 50:
d["govee_min_interval_ms"] = self.govee_min_interval_ms d["govee_min_interval_ms"] = self.govee_min_interval_ms
if self.opc_channel: if self.opc_channel:
@@ -424,9 +435,11 @@ class Device:
hue_username=_dec(data.get("hue_username", "")), hue_username=_dec(data.get("hue_username", "")),
hue_client_key=_dec(data.get("hue_client_key", "")), hue_client_key=_dec(data.get("hue_client_key", "")),
hue_entertainment_group_id=data.get("hue_entertainment_group_id", ""), 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), yeelight_min_interval_ms=data.get("yeelight_min_interval_ms", 500),
wiz_min_interval_ms=data.get("wiz_min_interval_ms", 50), wiz_min_interval_ms=data.get("wiz_min_interval_ms", 50),
lifx_min_interval_ms=data.get("lifx_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), govee_min_interval_ms=data.get("govee_min_interval_ms", 50),
opc_channel=data.get("opc_channel", 0), opc_channel=data.get("opc_channel", 0),
nanoleaf_token=_dec(data.get("nanoleaf_token", "")), nanoleaf_token=_dec(data.get("nanoleaf_token", "")),
@@ -479,9 +492,11 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset(
"hue_username", "hue_username",
"hue_client_key", "hue_client_key",
"hue_entertainment_group_id", "hue_entertainment_group_id",
"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",
"govee_min_interval_ms", "govee_min_interval_ms",
"opc_channel", "opc_channel",
"nanoleaf_token", "nanoleaf_token",
@@ -587,9 +602,11 @@ class DeviceStore(BaseSqliteStore[Device]):
hue_username: str = "", hue_username: str = "",
hue_client_key: str = "", hue_client_key: str = "",
hue_entertainment_group_id: str = "", hue_entertainment_group_id: str = "",
hue_gradient_mode: bool = True,
yeelight_min_interval_ms: int = 500, yeelight_min_interval_ms: int = 500,
wiz_min_interval_ms: int = 50, wiz_min_interval_ms: int = 50,
lifx_min_interval_ms: int = 50, lifx_min_interval_ms: int = 50,
lifx_per_zone: bool = False,
govee_min_interval_ms: int = 50, govee_min_interval_ms: int = 50,
opc_channel: int = 0, opc_channel: int = 0,
nanoleaf_token: str = "", nanoleaf_token: str = "",
@@ -638,9 +655,11 @@ class DeviceStore(BaseSqliteStore[Device]):
hue_username=hue_username, hue_username=hue_username,
hue_client_key=hue_client_key, hue_client_key=hue_client_key,
hue_entertainment_group_id=hue_entertainment_group_id, hue_entertainment_group_id=hue_entertainment_group_id,
hue_gradient_mode=hue_gradient_mode,
yeelight_min_interval_ms=yeelight_min_interval_ms, yeelight_min_interval_ms=yeelight_min_interval_ms,
wiz_min_interval_ms=wiz_min_interval_ms, wiz_min_interval_ms=wiz_min_interval_ms,
lifx_min_interval_ms=lifx_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, govee_min_interval_ms=govee_min_interval_ms,
opc_channel=opc_channel, opc_channel=opc_channel,
nanoleaf_token=nanoleaf_token, nanoleaf_token=nanoleaf_token,
@@ -48,6 +48,10 @@ class MQTTSource:
password: str = "" password: str = ""
client_id: str = "ledgrab" client_id: str = "ledgrab"
base_topic: 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 description: str | None = None
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = "" icon: str = ""
@@ -66,6 +70,8 @@ class MQTTSource:
"password": stored_password, "password": stored_password,
"client_id": self.client_id, "client_id": self.client_id,
"base_topic": self.base_topic, "base_topic": self.base_topic,
"publish_ha_discovery": self.publish_ha_discovery,
"discovery_prefix": self.discovery_prefix,
"description": self.description, "description": self.description,
"tags": self.tags, "tags": self.tags,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
@@ -93,6 +99,8 @@ class MQTTSource:
password=password, password=password,
client_id=data.get("client_id", "ledgrab"), client_id=data.get("client_id", "ledgrab"),
base_topic=data.get("base_topic", "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=data.get("icon", ""),
icon_color=data.get("icon_color", ""), icon_color=data.get("icon_color", ""),
) )
@@ -70,6 +70,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
password: str = "", password: str = "",
client_id: str = "ledgrab", client_id: str = "ledgrab",
base_topic: str = "ledgrab", base_topic: str = "ledgrab",
publish_ha_discovery: bool = False,
discovery_prefix: str = "homeassistant",
description: str | None = None, description: str | None = None,
tags: List[str] | None = None, tags: List[str] | None = None,
icon: str | None = None, icon: str | None = None,
@@ -94,6 +96,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
password=password, password=password,
client_id=client_id, client_id=client_id,
base_topic=base_topic, base_topic=base_topic,
publish_ha_discovery=publish_ha_discovery,
discovery_prefix=discovery_prefix or "homeassistant",
description=description, description=description,
tags=tags or [], tags=tags or [],
icon=icon or "", icon=icon or "",
@@ -115,6 +119,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
password: str | None = None, password: str | None = None,
client_id: str | None = None, client_id: str | None = None,
base_topic: str | None = None, base_topic: str | None = None,
publish_ha_discovery: bool | None = None,
discovery_prefix: str | None = None,
description: str | None = None, description: str | None = None,
tags: List[str] | None = None, tags: List[str] | None = None,
icon: 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, password=password if password is not None else existing.password,
client_id=client_id if client_id is not None else existing.client_id, 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, 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, description=description if description is not None else existing.description,
tags=tags if tags is not None else existing.tags, tags=tags if tags is not None else existing.tags,
icon=icon if icon is not None else existing.icon, icon=icon if icon is not None else existing.icon,
@@ -265,6 +265,17 @@
<small class="input-hint" style="display:none" data-i18n="device.lifx_min_interval.hint">Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.</small> <small class="input-hint" style="display:none" data-i18n="device.lifx_min_interval.hint">Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.</small>
<input type="number" id="device-lifx-min-interval" min="0" max="10000" step="10" value="50"> <input type="number" id="device-lifx-min-interval" min="0" max="10000" step="10" value="50">
</div> </div>
<div class="form-group" id="device-lifx-per-zone-group" style="display: none;">
<div class="label-row">
<label for="device-lifx-per-zone" data-i18n="device.lifx_per_zone">Per-zone streaming:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="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.</small>
<label class="settings-toggle">
<input type="checkbox" id="device-lifx-per-zone">
<span class="settings-toggle-slider"></span>
</label>
</div>
<!-- Govee fields --> <!-- Govee fields -->
<div class="form-group" id="device-govee-min-interval-group" style="display: none;"> <div class="form-group" id="device-govee-min-interval-group" style="display: none;">
<div class="label-row"> <div class="label-row">
@@ -336,6 +347,17 @@
<small class="input-hint" style="display:none" data-i18n="device.hue.group_id.hint">Entertainment configuration ID from your Hue bridge</small> <small class="input-hint" style="display:none" data-i18n="device.hue.group_id.hint">Entertainment configuration ID from your Hue bridge</small>
<input type="text" id="device-hue-group-id" placeholder="Entertainment group ID"> <input type="text" id="device-hue-group-id" placeholder="Entertainment group ID">
</div> </div>
<div class="form-group" id="device-hue-gradient-mode-group" style="display: none;">
<div class="label-row">
<label for="device-hue-gradient-mode" data-i18n="device.hue_gradient_mode">Map across segments:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="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.</small>
<label class="settings-toggle">
<input type="checkbox" id="device-hue-gradient-mode" checked>
<span class="settings-toggle-slider"></span>
</label>
</div>
<!-- BLE LED Controller fields --> <!-- BLE LED Controller fields -->
<div class="form-group" id="device-ble-family-group" style="display: none;"> <div class="form-group" id="device-ble-family-group" style="display: none;">
<div class="label-row"> <div class="label-row">
@@ -132,6 +132,48 @@
</div> </div>
</section> </section>
<section class="ds-section" data-ds-key="action" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="automations.section.action">Webhook</span>
<span class="ds-section-index" aria-hidden="true">05</span>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="automation-action-webhook-url" data-i18n="automations.action.webhook_url">Webhook URL:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="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.</small>
<input type="text" id="automation-action-webhook-url" placeholder="https://discord.com/api/webhooks/...">
</div>
<div class="form-group" id="automation-action-fields" style="display:none">
<div class="label-row">
<label for="automation-action-method" data-i18n="automations.action.method">Method:</label>
</div>
<select id="automation-action-method">
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="GET">GET</option>
</select>
<div class="label-row" style="margin-top:0.5rem">
<label for="automation-action-fire-on" data-i18n="automations.action.fire_on">Fire on:</label>
</div>
<select id="automation-action-fire-on">
<option value="activate" data-i18n="automations.action.fire_on.activate">When activated</option>
<option value="deactivate" data-i18n="automations.action.fire_on.deactivate">When deactivated</option>
<option value="both" data-i18n="automations.action.fire_on.both">Both</option>
</select>
<div class="label-row" style="margin-top:0.5rem">
<label for="automation-action-body" data-i18n="automations.action.body_template">Body template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.action.body_template.hint">JSON body for POST/PUT. Tokens: {{automation_name}}, {{automation_id}}, {{event}}, {{timestamp}}.</small>
<textarea id="automation-action-body" rows="3" placeholder='{"content": "LedGrab: {{automation_name}} {{event}}"}'></textarea>
</div>
</div>
</section>
<div id="automation-editor-error" class="error-message" style="display: none;"></div> <div id="automation-editor-error" class="error-message" style="display: none;"></div>
</form> </form>
</div> </div>
@@ -294,6 +294,17 @@
<small class="input-hint" style="display:none" data-i18n="device.lifx_min_interval.hint">Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.</small> <small class="input-hint" style="display:none" data-i18n="device.lifx_min_interval.hint">Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.</small>
<input type="number" id="settings-lifx-min-interval" min="0" max="10000" step="10" value="50"> <input type="number" id="settings-lifx-min-interval" min="0" max="10000" step="10" value="50">
</div> </div>
<div class="form-group" id="settings-lifx-per-zone-group" style="display: none;">
<div class="label-row">
<label for="settings-lifx-per-zone" data-i18n="device.lifx_per_zone">Per-zone streaming:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="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.</small>
<label class="settings-toggle">
<input type="checkbox" id="settings-lifx-per-zone">
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="form-group" id="settings-govee-min-interval-group" style="display: none;"> <div class="form-group" id="settings-govee-min-interval-group" style="display: none;">
<div class="label-row"> <div class="label-row">
@@ -107,6 +107,24 @@
<small class="input-hint" style="display:none" data-i18n="mqtt_source.base_topic.hint">Prefix for status and state topics, e.g. ledgrab/status</small> <small class="input-hint" style="display:none" data-i18n="mqtt_source.base_topic.hint">Prefix for status and state topics, e.g. ledgrab/status</small>
<input type="text" id="mqtt-source-base-topic" value="ledgrab" placeholder="ledgrab"> <input type="text" id="mqtt-source-base-topic" value="ledgrab" placeholder="ledgrab">
</div> </div>
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-ha-discovery" data-i18n="mqtt_source.ha_discovery">Home Assistant discovery:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.ha_discovery.hint">Publish homeassistant/.../config topics so MQTT-only Home Assistant installs get LedGrab automation + connectivity entities automatically.</small>
<label class="settings-toggle">
<input type="checkbox" id="mqtt-source-ha-discovery">
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="form-group" id="mqtt-source-discovery-prefix-group" style="display:none">
<div class="label-row">
<label for="mqtt-source-discovery-prefix" data-i18n="mqtt_source.discovery_prefix">Discovery prefix:</label>
</div>
<input type="text" id="mqtt-source-discovery-prefix" value="homeassistant" placeholder="homeassistant">
</div>
</div> </div>
</section> </section>
+145
View File
@@ -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"])
+140
View File
@@ -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"])
+215
View File
@@ -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("<H", packet, 32)[0]
class TestResampleToN:
def test_nearest_neighbour_downsample(self):
pixels = [[10, 10, 10], [20, 20, 20], [30, 30, 30], [40, 40, 40]]
assert resample_to_n(pixels, 2) == [(10, 10, 10), (30, 30, 30)]
def test_empty_strip_is_black(self):
assert resample_to_n([], 3) == [(0, 0, 0), (0, 0, 0), (0, 0, 0)]
def test_upsample_repeats(self):
assert resample_to_n([[255, 0, 0]], 3) == [(255, 0, 0), (255, 0, 0), (255, 0, 0)]
def test_zero_n_is_empty(self):
assert resample_to_n([[1, 2, 3]], 0) == []
class TestExtendedColorZonesPayload:
def test_framing_is_byte_exact(self):
hsbk = [(100, 200, 300, 3500), (400, 500, 600, 3500)]
payload = _build_set_extended_color_zones_payload(hsbk, duration_ms=0, zone_index=0)
# header(8) + 82 fixed HSBK slots * 8 bytes
assert len(payload) == 8 + _EXT_ZONE_MAX * 8
duration, apply, zone_index, count = struct.unpack_from("<IBHB", payload, 0)
assert (duration, apply, zone_index, count) == (0, 1, 0, 2)
h, s, b, k = struct.unpack_from("<HHHH", payload, 8)
assert (h, s, b, k) == (100, 200, 300, 3500)
def test_unused_slots_are_zero_padded(self):
payload = _build_set_extended_color_zones_payload([(1, 2, 3, 4)])
# Third slot (index 2) onward must be zero.
assert payload[8 + 8 : 8 + _EXT_ZONE_MAX * 8] == b"\x00" * (8 * (_EXT_ZONE_MAX - 1))
def test_overflow_is_capped_at_82(self):
payload = _build_set_extended_color_zones_payload([(1, 1, 1, 1)] * 200)
_, _, _, count = struct.unpack_from("<IBHB", payload, 0)
assert count == _EXT_ZONE_MAX
assert len(payload) == 8 + _EXT_ZONE_MAX * 8
class TestTileState64Payload:
def test_framing_is_byte_exact(self):
hsbk = [(11, 22, 33, 3500)]
payload = _build_set_tile_state64_payload(hsbk, tile_index=2, x=0, y=0, width=8)
# header(10) + 64 fixed HSBK slots * 8 bytes
assert len(payload) == 10 + _TILE_PIXELS * 8
tile_index, length, reserved, x, y, width, duration = struct.unpack_from(
"<BBBBBBI", payload, 0
)
assert (tile_index, length, reserved, x, y, width, duration) == (2, 1, 0, 0, 0, 8, 0)
h, s, b, k = struct.unpack_from("<HHHH", payload, 10)
assert (h, s, b, k) == (11, 22, 33, 3500)
class TestMultizoneReplyParser:
def test_parses_state_multizone(self):
body = struct.pack("<BB", 16, 0) + b"\x00" * (8 * 8) # count=16, index=0, 8 HSBK
raw = _build_packet(msg_type=MSG_STATE_MULTIZONE, payload=body)
parsed = _parse_multizone_reply(raw)
assert parsed == {"count": 16, "index": 0}
def test_other_message_type_returns_none(self):
raw = _build_packet(msg_type=MSG_SET_COLOR, payload=b"\x00" * 16)
assert _parse_multizone_reply(raw) is None
class TestDeviceChainParser:
def _chain_packet(self, tile_specs: list[tuple[int, int]]) -> 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("<HHHH", payload, 8)
scale = 128 / 255.0
exp_h, exp_s, exp_b, exp_k = rgb_to_hsbk(
int(200 * scale), int(100 * scale), int(50 * scale)
)
assert (h, s, b) == (exp_h, exp_s, exp_b)
class TestConfigRoundTrip:
def _device(self, per_zone: bool) -> 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"])
+136
View File
@@ -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"])