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:
@@ -12,6 +12,7 @@ from ledgrab.api.dependencies import (
|
||||
get_scene_preset_store,
|
||||
)
|
||||
from ledgrab.api.schemas.automations import (
|
||||
ActionSchema,
|
||||
AutomationCreate,
|
||||
AutomationListResponse,
|
||||
AutomationResponse,
|
||||
@@ -21,6 +22,7 @@ from ledgrab.api.schemas.automations import (
|
||||
)
|
||||
from ledgrab.core.automations.automation_engine import AutomationEngine
|
||||
from ledgrab.storage.automation import (
|
||||
Action,
|
||||
ApplicationRule,
|
||||
DisplayStateRule,
|
||||
HomeAssistantRule,
|
||||
@@ -32,11 +34,13 @@ from ledgrab.storage.automation import (
|
||||
StartupRule,
|
||||
SystemIdleRule,
|
||||
TimeOfDayRule,
|
||||
WebhookAction,
|
||||
WebhookRule,
|
||||
)
|
||||
from ledgrab.storage.automation_store import AutomationStore
|
||||
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.safe_source import validate_polling_url
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -113,6 +117,38 @@ def _rule_to_schema(r: Rule) -> RuleSchema:
|
||||
return RuleSchema(**d)
|
||||
|
||||
|
||||
def _action_from_schema(s: ActionSchema) -> Action:
|
||||
"""Build a domain Action from its request schema, validating the webhook URL.
|
||||
|
||||
The SSRF gate runs here (save time) AND again at fire time, closing the
|
||||
DNS-rebinding window. A bad/blocked URL rejects the whole save with 400.
|
||||
"""
|
||||
if s.action_type != "webhook":
|
||||
raise ValueError(f"Unknown action type: {s.action_type}")
|
||||
url = (s.webhook_url or "").strip()
|
||||
if not url:
|
||||
raise ValueError("webhook action requires a webhook_url")
|
||||
method = (s.method or "POST").upper()
|
||||
if method not in ("POST", "PUT", "GET"):
|
||||
raise ValueError(f"Invalid webhook method: {method}. Must be POST, PUT or GET.")
|
||||
fire_on = s.fire_on or "activate"
|
||||
if fire_on not in ("activate", "deactivate", "both"):
|
||||
raise ValueError(f"Invalid fire_on: {fire_on}. Must be activate, deactivate or both.")
|
||||
# Raises HTTPException(400) on a blocked/loopback/metadata target.
|
||||
validate_polling_url(url)
|
||||
return WebhookAction(
|
||||
webhook_url=url,
|
||||
method=method,
|
||||
body_template=s.body_template or "",
|
||||
content_type=s.content_type or "application/json",
|
||||
fire_on=fire_on,
|
||||
)
|
||||
|
||||
|
||||
def _action_to_schema(a: Action) -> ActionSchema:
|
||||
return ActionSchema(**a.to_dict())
|
||||
|
||||
|
||||
def _automation_to_response(
|
||||
automation, engine: AutomationEngine, request: Request = None
|
||||
) -> AutomationResponse:
|
||||
@@ -148,6 +184,7 @@ def _automation_to_response(
|
||||
last_activated_at=state.get("last_activated_at"),
|
||||
last_deactivated_at=state.get("last_deactivated_at"),
|
||||
tags=automation.tags,
|
||||
actions=[_action_to_schema(a) for a in getattr(automation, "actions", [])],
|
||||
icon=getattr(automation, "icon", "") or "",
|
||||
icon_color=getattr(automation, "icon_color", "") or "",
|
||||
created_at=automation.created_at,
|
||||
@@ -204,6 +241,7 @@ async def create_automation(
|
||||
|
||||
try:
|
||||
rules = [_rule_from_schema(r) for r in data.rules]
|
||||
actions = [_action_from_schema(a) for a in data.actions]
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
@@ -219,6 +257,7 @@ async def create_automation(
|
||||
deactivation_mode=data.deactivation_mode,
|
||||
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
|
||||
tags=data.tags,
|
||||
actions=actions,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
@@ -301,6 +340,13 @@ async def update_automation(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
actions = None
|
||||
if data.actions is not None:
|
||||
try:
|
||||
actions = [_action_from_schema(a) for a in data.actions]
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
try:
|
||||
# If disabling, deactivate first
|
||||
if data.enabled is False:
|
||||
@@ -315,6 +361,7 @@ async def update_automation(
|
||||
rules=rules,
|
||||
deactivation_mode=data.deactivation_mode,
|
||||
tags=data.tags,
|
||||
actions=actions,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
|
||||
@@ -96,9 +96,11 @@ def _device_to_response(device) -> DeviceResponse:
|
||||
espnow_channel=device.espnow_channel,
|
||||
hue_paired=bool(device.hue_username and device.hue_client_key),
|
||||
hue_entertainment_group_id=device.hue_entertainment_group_id,
|
||||
hue_gradient_mode=device.hue_gradient_mode,
|
||||
yeelight_min_interval_ms=device.yeelight_min_interval_ms,
|
||||
wiz_min_interval_ms=device.wiz_min_interval_ms,
|
||||
lifx_min_interval_ms=device.lifx_min_interval_ms,
|
||||
lifx_per_zone=device.lifx_per_zone,
|
||||
govee_min_interval_ms=device.govee_min_interval_ms,
|
||||
opc_channel=device.opc_channel,
|
||||
nanoleaf_paired=bool(device.nanoleaf_token),
|
||||
@@ -262,6 +264,9 @@ async def create_device(
|
||||
hue_username=device_data.hue_username or "",
|
||||
hue_client_key=device_data.hue_client_key or "",
|
||||
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
|
||||
hue_gradient_mode=(
|
||||
device_data.hue_gradient_mode if device_data.hue_gradient_mode is not None else True
|
||||
),
|
||||
yeelight_min_interval_ms=(
|
||||
device_data.yeelight_min_interval_ms
|
||||
if device_data.yeelight_min_interval_ms is not None
|
||||
@@ -277,6 +282,7 @@ async def create_device(
|
||||
if device_data.lifx_min_interval_ms is not None
|
||||
else 50
|
||||
),
|
||||
lifx_per_zone=bool(device_data.lifx_per_zone),
|
||||
govee_min_interval_ms=(
|
||||
device_data.govee_min_interval_ms
|
||||
if device_data.govee_min_interval_ms is not None
|
||||
@@ -633,9 +639,11 @@ async def update_device(
|
||||
hue_username=update_data.hue_username,
|
||||
hue_client_key=update_data.hue_client_key,
|
||||
hue_entertainment_group_id=update_data.hue_entertainment_group_id,
|
||||
hue_gradient_mode=update_data.hue_gradient_mode,
|
||||
yeelight_min_interval_ms=update_data.yeelight_min_interval_ms,
|
||||
wiz_min_interval_ms=update_data.wiz_min_interval_ms,
|
||||
lifx_min_interval_ms=update_data.lifx_min_interval_ms,
|
||||
lifx_per_zone=update_data.lifx_per_zone,
|
||||
govee_min_interval_ms=update_data.govee_min_interval_ms,
|
||||
opc_channel=update_data.opc_channel,
|
||||
nanoleaf_token=update_data.nanoleaf_token,
|
||||
|
||||
@@ -42,6 +42,8 @@ def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse
|
||||
password_set=bool(source.password),
|
||||
client_id=source.client_id,
|
||||
base_topic=source.base_topic,
|
||||
publish_ha_discovery=getattr(source, "publish_ha_discovery", False),
|
||||
discovery_prefix=getattr(source, "discovery_prefix", "homeassistant"),
|
||||
connected=runtime.is_connected if runtime else False,
|
||||
description=source.description,
|
||||
tags=source.tags,
|
||||
@@ -90,6 +92,8 @@ async def create_mqtt_source(
|
||||
password=data.password,
|
||||
client_id=data.client_id,
|
||||
base_topic=data.base_topic,
|
||||
publish_ha_discovery=data.publish_ha_discovery,
|
||||
discovery_prefix=data.discovery_prefix,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
@@ -97,6 +101,8 @@ async def create_mqtt_source(
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
# Publish HA discovery if the new source opted in.
|
||||
await manager.sync_discovery(source.id)
|
||||
fire_entity_event("mqtt_source", "created", source.id)
|
||||
return _to_response(source, manager)
|
||||
|
||||
@@ -141,6 +147,8 @@ async def update_mqtt_source(
|
||||
password=data.password,
|
||||
client_id=data.client_id,
|
||||
base_topic=data.base_topic,
|
||||
publish_ha_discovery=data.publish_ha_discovery,
|
||||
discovery_prefix=data.discovery_prefix,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
@@ -151,6 +159,8 @@ async def update_mqtt_source(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
await manager.update_source(source_id)
|
||||
# Reconcile HA discovery (publish if enabled, clear if turned off).
|
||||
await manager.sync_discovery(source_id)
|
||||
fire_entity_event("mqtt_source", "updated", source.id)
|
||||
return _to_response(source, manager)
|
||||
|
||||
@@ -162,6 +172,9 @@ async def delete_mqtt_source(
|
||||
store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||
manager: MQTTManager = Depends(get_mqtt_manager),
|
||||
):
|
||||
# Clear any HA discovery configs (needs the source still present to build
|
||||
# the exact retained topics) before deleting the row.
|
||||
await manager.disable_discovery(source_id)
|
||||
try:
|
||||
store.delete_source(source_id)
|
||||
except EntityNotFoundError:
|
||||
|
||||
@@ -108,6 +108,31 @@ class RuleSchema(BaseModel):
|
||||
ConditionSchema = RuleSchema
|
||||
|
||||
|
||||
class ActionSchema(BaseModel):
|
||||
"""A single outbound action fired alongside scene activation/deactivation."""
|
||||
|
||||
action_type: str = Field(description="Action type discriminator (e.g. 'webhook')")
|
||||
# Webhook action fields
|
||||
webhook_url: str | None = Field(
|
||||
None, max_length=2048, description="Target URL for the webhook action"
|
||||
)
|
||||
method: str | None = Field(None, description="'POST', 'PUT', or 'GET' (for webhook action)")
|
||||
body_template: str | None = Field(
|
||||
None,
|
||||
max_length=8192,
|
||||
description=(
|
||||
"Request body template (for webhook action). Tokens: {{automation_name}}, "
|
||||
"{{automation_id}}, {{event}}, {{timestamp}}."
|
||||
),
|
||||
)
|
||||
content_type: str | None = Field(
|
||||
None, description="Content-Type header for the webhook body (default application/json)"
|
||||
)
|
||||
fire_on: str | None = Field(
|
||||
None, description="'activate', 'deactivate', or 'both' (for webhook action)"
|
||||
)
|
||||
|
||||
|
||||
class AutomationCreate(BaseModel):
|
||||
"""Request to create an automation."""
|
||||
|
||||
@@ -123,6 +148,9 @@ class AutomationCreate(BaseModel):
|
||||
None, description="Scene preset for fallback deactivation"
|
||||
)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
actions: List[ActionSchema] = Field(
|
||||
default_factory=list, description="Outbound actions (e.g. webhooks)"
|
||||
)
|
||||
icon: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
@@ -148,6 +176,7 @@ class AutomationUpdate(BaseModel):
|
||||
None, description="Scene preset for fallback deactivation"
|
||||
)
|
||||
tags: List[str] | None = None
|
||||
actions: List[ActionSchema] | None = Field(None, description="Outbound actions (e.g. webhooks)")
|
||||
icon: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
@@ -172,6 +201,9 @@ class AutomationResponse(BaseModel):
|
||||
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
|
||||
deactivation_scene_preset_id: str | None = Field(None, description="Fallback scene preset")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
actions: List[ActionSchema] = Field(
|
||||
default_factory=list, description="Outbound actions (e.g. webhooks)"
|
||||
)
|
||||
webhook_url: str | None = Field(
|
||||
None, description="Webhook URL for the first webhook rule (if any)"
|
||||
)
|
||||
|
||||
@@ -59,6 +59,10 @@ class DeviceCreate(BaseModel):
|
||||
hue_entertainment_group_id: str | None = Field(
|
||||
None, description="Hue entertainment group/zone ID"
|
||||
)
|
||||
hue_gradient_mode: bool | None = Field(
|
||||
None,
|
||||
description="Map the strip across gradient-lightstrip channels vs one record per light",
|
||||
)
|
||||
# Yeelight fields
|
||||
yeelight_min_interval_ms: int | None = Field(
|
||||
None,
|
||||
@@ -80,6 +84,10 @@ class DeviceCreate(BaseModel):
|
||||
le=10000,
|
||||
description="LIFX client-side rate limit between commands in ms (default 50)",
|
||||
)
|
||||
lifx_per_zone: bool | None = Field(
|
||||
None,
|
||||
description="Stream individual zones/tiles (multizone Z/Beam, Tile/Canvas) vs single colour",
|
||||
)
|
||||
# Govee fields
|
||||
govee_min_interval_ms: int | None = Field(
|
||||
None,
|
||||
@@ -198,6 +206,10 @@ class DeviceUpdate(BaseModel):
|
||||
hue_username: str | None = Field(None, description="Hue bridge username")
|
||||
hue_client_key: str | None = Field(None, description="Hue entertainment client key")
|
||||
hue_entertainment_group_id: str | None = Field(None, description="Hue entertainment group ID")
|
||||
hue_gradient_mode: bool | None = Field(
|
||||
None,
|
||||
description="Map the strip across gradient-lightstrip channels vs one record per light",
|
||||
)
|
||||
yeelight_min_interval_ms: int | None = Field(
|
||||
None, ge=0, le=10000, description="Yeelight client-side rate limit in ms"
|
||||
)
|
||||
@@ -207,6 +219,9 @@ class DeviceUpdate(BaseModel):
|
||||
lifx_min_interval_ms: int | None = Field(
|
||||
None, ge=0, le=10000, description="LIFX client-side rate limit in ms"
|
||||
)
|
||||
lifx_per_zone: bool | None = Field(
|
||||
None, description="Stream individual zones/tiles (multizone/matrix) vs single colour"
|
||||
)
|
||||
govee_min_interval_ms: int | None = Field(
|
||||
None, ge=0, le=10000, description="Govee client-side rate limit in ms"
|
||||
)
|
||||
@@ -442,11 +457,19 @@ class DeviceResponse(BaseModel):
|
||||
),
|
||||
)
|
||||
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
|
||||
hue_gradient_mode: bool = Field(
|
||||
default=True,
|
||||
description="Map the strip across gradient-lightstrip channels vs one record per light",
|
||||
)
|
||||
yeelight_min_interval_ms: int = Field(
|
||||
default=500, description="Yeelight client-side rate limit in ms"
|
||||
)
|
||||
wiz_min_interval_ms: int = Field(default=50, description="WiZ client-side rate limit in ms")
|
||||
lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms")
|
||||
lifx_per_zone: bool = Field(
|
||||
default=False,
|
||||
description="Stream individual zones/tiles (multizone/matrix) vs single colour",
|
||||
)
|
||||
govee_min_interval_ms: int = Field(default=50, description="Govee client-side rate limit in ms")
|
||||
opc_channel: int = Field(default=0, description="OPC channel (0 = broadcast to all)")
|
||||
nanoleaf_paired: bool = Field(
|
||||
|
||||
@@ -16,6 +16,12 @@ class MQTTSourceCreate(BaseModel):
|
||||
password: str = Field(default="", description="Broker password (optional)")
|
||||
client_id: str = Field(default="ledgrab", description="MQTT client ID")
|
||||
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
|
||||
publish_ha_discovery: bool = Field(
|
||||
default=False, description="Publish Home Assistant MQTT auto-discovery configs"
|
||||
)
|
||||
discovery_prefix: str = Field(
|
||||
default="homeassistant", description="HA MQTT discovery prefix (default 'homeassistant')"
|
||||
)
|
||||
description: str | None = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: str | None = Field(
|
||||
@@ -40,6 +46,10 @@ class MQTTSourceUpdate(BaseModel):
|
||||
password: str | None = Field(None, description="Broker password")
|
||||
client_id: str | None = Field(None, description="MQTT client ID")
|
||||
base_topic: str | None = Field(None, description="Base topic prefix")
|
||||
publish_ha_discovery: bool | None = Field(
|
||||
None, description="Publish Home Assistant MQTT auto-discovery configs"
|
||||
)
|
||||
discovery_prefix: str | None = Field(None, description="HA MQTT discovery prefix")
|
||||
description: str | None = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] | None = None
|
||||
icon: str | None = Field(
|
||||
@@ -65,6 +75,8 @@ class MQTTSourceResponse(BaseModel):
|
||||
password_set: bool = Field(default=False, description="Whether a password is configured")
|
||||
client_id: str = Field(description="MQTT client ID")
|
||||
base_topic: str = Field(description="Base topic prefix")
|
||||
publish_ha_discovery: bool = Field(default=False, description="HA MQTT discovery enabled")
|
||||
discovery_prefix: str = Field(default="homeassistant", description="HA MQTT discovery prefix")
|
||||
connected: bool = Field(default=False, description="Whether the broker connection is active")
|
||||
description: str | None = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
@@ -812,6 +812,8 @@ class AutomationEngine:
|
||||
# Record the activation too — a no-scene activation is still a
|
||||
# successful activation and must appear in the audit log.
|
||||
self._audit_activation(automation)
|
||||
await self._fire_actions(automation, "activate")
|
||||
await self._publish_mqtt_state(automation.id, True)
|
||||
return
|
||||
|
||||
if not self._scene_preset_store or not self._target_store or not self._device_store:
|
||||
@@ -858,6 +860,60 @@ class AutomationEngine:
|
||||
|
||||
# Audit record — best-effort (shared helper, also used by no-scene path).
|
||||
self._audit_activation(automation)
|
||||
await self._fire_actions(automation, "activate")
|
||||
await self._publish_mqtt_state(automation.id, True)
|
||||
|
||||
async def _fire_actions(self, automation: Automation, event: str) -> None:
|
||||
"""Fire any outbound actions (e.g. webhooks) for this transition.
|
||||
|
||||
Best-effort and never raises into the activation path: a hung or
|
||||
failing endpoint is logged/audited but must not stall the evaluation
|
||||
loop or abort scene activation.
|
||||
"""
|
||||
actions = getattr(automation, "actions", None)
|
||||
if not actions:
|
||||
return
|
||||
from ledgrab.storage.automation import WebhookAction
|
||||
from ledgrab.core.automations.webhook_action import fire_webhook_action, should_fire
|
||||
|
||||
for action in actions:
|
||||
if not isinstance(action, WebhookAction) or not should_fire(action, event):
|
||||
continue
|
||||
try:
|
||||
ok, err = await fire_webhook_action(action, automation, event)
|
||||
except Exception as exc: # noqa: BLE001 — defensive; fire is already best-effort
|
||||
logger.warning(
|
||||
"Action fire raised for '%s': %s", automation.name, type(exc).__name__
|
||||
)
|
||||
ok, err = False, type(exc).__name__
|
||||
self._audit_webhook(automation, event, ok, err)
|
||||
|
||||
def _audit_webhook(self, automation: Automation, event: str, ok: bool, err: str | None) -> None:
|
||||
"""Best-effort audit entry for a webhook fire (success or failure)."""
|
||||
try:
|
||||
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||
|
||||
rec = get_module_recorder()
|
||||
if rec is None:
|
||||
return
|
||||
safe_name = sanitize_display(automation.name) if automation.name else automation.id
|
||||
rec.record(
|
||||
category=ActivityCategory.CAPTURE,
|
||||
action="automation.webhook_fired",
|
||||
severity=ActivitySeverity.INFO if ok else ActivitySeverity.WARNING,
|
||||
actor="system",
|
||||
entity_type="automation",
|
||||
entity_id=automation.id,
|
||||
entity_name=safe_name,
|
||||
message=(
|
||||
f"Webhook for '{safe_name}' {'fired' if ok else 'failed'} on {event}"
|
||||
+ ("" if ok else f" ({sanitize_display(err) if err else 'error'})")
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _apply_manual_scene(self, automation: Automation) -> tuple[str, list[str]]:
|
||||
"""Apply the automation's scene once for a manual trigger.
|
||||
@@ -987,6 +1043,22 @@ class AutomationEngine:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fire any outbound deactivate actions (best-effort). Skipped when the
|
||||
# automation was since-deleted (no actions to read).
|
||||
if automation is not None:
|
||||
await self._fire_actions(automation, "deactivate")
|
||||
await self._publish_mqtt_state(automation_id, False)
|
||||
|
||||
async def _publish_mqtt_state(self, automation_id: str, active: bool) -> None:
|
||||
"""Best-effort publish of the automation's active state to HA discovery."""
|
||||
mgr = self._mqtt_manager
|
||||
if mgr is None or not hasattr(mgr, "publish_automation_state_all"):
|
||||
return
|
||||
try:
|
||||
await mgr.publish_automation_state_all(automation_id, active)
|
||||
except Exception: # noqa: BLE001 — never raise into the engine
|
||||
pass
|
||||
|
||||
async def _deactivate_revert(self, automation_id: str) -> None:
|
||||
"""Revert to pre-activation snapshot."""
|
||||
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
|
||||
|
||||
@@ -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_client_key: str = ""
|
||||
hue_entertainment_group_id: str = ""
|
||||
# Map the strip across the entertainment configuration's channels
|
||||
# (gradient-lightstrip segments) instead of one record per light.
|
||||
hue_gradient_mode: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -115,6 +118,8 @@ class LIFXConfig(BaseDeviceConfig):
|
||||
|
||||
device_type: Literal["lifx"] = "lifx"
|
||||
lifx_min_interval_ms: int = 50
|
||||
# Per-zone/tile streaming (Z/Beam multizone, Tile/Canvas matrix) vs single colour.
|
||||
lifx_per_zone: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import List, Tuple
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from ledgrab.core.devices.pixel_reduce import resample_to_n
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -24,15 +25,40 @@ COLOR_SPACE_RGB = 0x00
|
||||
HEADER_SIZE = 16
|
||||
|
||||
|
||||
def parse_entertainment_channels(config_json: dict) -> List[int]:
|
||||
"""Extract ordered channel ids from an entertainment_configuration GET.
|
||||
|
||||
Entertainment API v2 keys stream records by *channel*, not by light. A
|
||||
plain bulb contributes one channel; a gradient lightstrip contributes up
|
||||
to five (one per segment). We order channels left-to-right by their
|
||||
spatial ``position`` (x then y) so a strip maps across the segments in
|
||||
physical order. Channels without a position fall back to channel-id order.
|
||||
"""
|
||||
data = config_json.get("data") or []
|
||||
if not data:
|
||||
return []
|
||||
channels = data[0].get("channels") or []
|
||||
|
||||
def _key(c: dict) -> tuple:
|
||||
pos = c.get("position") or {}
|
||||
return (pos.get("x", 0.0), pos.get("y", 0.0), c.get("channel_id", 0))
|
||||
|
||||
ordered = sorted(channels, key=_key)
|
||||
return [int(c["channel_id"]) for c in ordered if "channel_id" in c]
|
||||
|
||||
|
||||
def _build_entertainment_frame(
|
||||
lights: List[Tuple[int, int, int]],
|
||||
colors: List[Tuple[int, int, int]],
|
||||
brightness: int = 255,
|
||||
sequence: int = 0,
|
||||
channel_ids: List[int] | None = None,
|
||||
) -> bytes:
|
||||
"""Build a Hue Entertainment API v2 UDP frame.
|
||||
|
||||
Each light gets 7 bytes: [light_id(2B)][R(2B)][G(2B)][B(2B)]
|
||||
Colors are 16-bit (0-65535). We scale 8-bit RGB + brightness.
|
||||
Each record is 7 bytes: [channel_id(1B)][R(2B)][G(2B)][B(2B)]. Colors are
|
||||
16-bit (0-65535). Record ``i`` is keyed by ``channel_ids[i]`` when a
|
||||
channel map is supplied (gradient-segment mode); otherwise it falls back
|
||||
to the 0-based index (one light = one channel, the legacy behaviour).
|
||||
"""
|
||||
# Header
|
||||
header = bytearray(HEADER_SIZE)
|
||||
@@ -45,15 +71,15 @@ def _build_entertainment_frame(
|
||||
header[14] = COLOR_SPACE_RGB
|
||||
header[15] = 0x00 # reserved
|
||||
|
||||
# Light data
|
||||
# Channel data
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
data = bytearray()
|
||||
for idx, (r, g, b) in enumerate(lights):
|
||||
light_id = idx # 0-based light index in entertainment group
|
||||
for idx, (r, g, b) in enumerate(colors):
|
||||
channel_id = channel_ids[idx] if channel_ids and idx < len(channel_ids) else idx
|
||||
r16 = int(r * 257) # scale 0-255 to 0-65535
|
||||
g16 = int(g * 257)
|
||||
b16 = int(b * 257)
|
||||
data += struct.pack(">BHHH", light_id, r16, g16, b16)
|
||||
data += struct.pack(">BHHH", channel_id & 0xFF, r16, g16, b16)
|
||||
|
||||
return bytes(header) + bytes(data)
|
||||
|
||||
@@ -62,7 +88,11 @@ class HueClient(LEDClient):
|
||||
"""LED client for Philips Hue Entertainment API streaming.
|
||||
|
||||
Uses UDP (optionally DTLS) to stream color data at ~25 fps to a Hue
|
||||
entertainment group. Each light in the group is treated as one "LED".
|
||||
entertainment group. In ``gradient_mode`` (the default) the client
|
||||
discovers the entertainment configuration's *channels* on connect and
|
||||
maps the strip across them, so a gradient lightstrip shows a gradient
|
||||
instead of a single averaged colour. With gradient mode off (or if
|
||||
discovery fails) it falls back to one record per light by index.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -72,6 +102,7 @@ class HueClient(LEDClient):
|
||||
hue_username: str = "",
|
||||
hue_client_key: str = "",
|
||||
hue_entertainment_group_id: str = "",
|
||||
gradient_mode: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
self._bridge_ip = url.replace("hue://", "").rstrip("/")
|
||||
@@ -79,15 +110,55 @@ class HueClient(LEDClient):
|
||||
self._username = hue_username
|
||||
self._client_key = hue_client_key
|
||||
self._group_id = hue_entertainment_group_id
|
||||
self._gradient_mode = gradient_mode
|
||||
self._channel_ids: List[int] = []
|
||||
self._sock: socket.socket | None = None
|
||||
self._connected = False
|
||||
self._sequence = 0
|
||||
self._dtls_sock = None
|
||||
|
||||
@property
|
||||
def device_led_count(self) -> int | None:
|
||||
# In gradient mode the discovered channel count is authoritative.
|
||||
if self._gradient_mode and self._channel_ids:
|
||||
return len(self._channel_ids)
|
||||
return self._led_count or None
|
||||
|
||||
async def _fetch_channels(self) -> None:
|
||||
"""Best-effort discovery of the entertainment configuration channels."""
|
||||
import httpx
|
||||
|
||||
url = (
|
||||
f"https://{self._bridge_ip}/clip/v2/resource/"
|
||||
f"entertainment_configuration/{self._group_id}"
|
||||
)
|
||||
headers = {"hue-application-key": self._username}
|
||||
async with httpx.AsyncClient(verify=False, timeout=5.0) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
self._channel_ids = parse_entertainment_channels(resp.json())
|
||||
if self._channel_ids:
|
||||
logger.info(
|
||||
"Hue %s: mapped strip across %d channels", self._bridge_ip, len(self._channel_ids)
|
||||
)
|
||||
|
||||
async def connect(self) -> bool:
|
||||
# Activate entertainment streaming via REST API
|
||||
await self._activate_streaming(True)
|
||||
|
||||
# Best-effort channel discovery for gradient-segment mapping. On any
|
||||
# failure (old bridge, network) we degrade to legacy 1-light-1-record.
|
||||
if self._gradient_mode:
|
||||
try:
|
||||
await self._fetch_channels()
|
||||
except Exception as exc: # noqa: BLE001 — degrade, never fail connect
|
||||
logger.warning(
|
||||
"Hue %s: channel discovery failed, using per-light mapping (%s)",
|
||||
self._bridge_ip,
|
||||
exc,
|
||||
)
|
||||
self._channel_ids = []
|
||||
|
||||
# Open UDP socket for entertainment streaming
|
||||
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self._sock.setblocking(False)
|
||||
@@ -179,12 +250,19 @@ class HueClient(LEDClient):
|
||||
if not self._connected:
|
||||
return
|
||||
|
||||
if isinstance(pixels, np.ndarray):
|
||||
light_colors = [tuple(pixels[i]) for i in range(min(len(pixels), self._led_count))]
|
||||
else:
|
||||
light_colors = pixels[: self._led_count]
|
||||
# Resample the strip to the number of addressable elements: the
|
||||
# discovered channel count in gradient mode, else the configured
|
||||
# light count. ``resample_to_n`` spreads the strip spatially and
|
||||
# handles both the np.ndarray and list-of-tuples inputs.
|
||||
target = len(self._channel_ids) if self._channel_ids else self._led_count
|
||||
colors = resample_to_n(pixels, target)
|
||||
|
||||
frame = _build_entertainment_frame(light_colors, brightness, self._sequence)
|
||||
frame = _build_entertainment_frame(
|
||||
colors,
|
||||
brightness,
|
||||
self._sequence,
|
||||
channel_ids=self._channel_ids or None,
|
||||
)
|
||||
self._sequence = (self._sequence + 1) & 0xFF
|
||||
|
||||
try:
|
||||
|
||||
@@ -50,6 +50,7 @@ class HueDeviceProvider(LEDDeviceProvider):
|
||||
hue_username=config.hue_username,
|
||||
hue_client_key=config.hue_client_key,
|
||||
hue_entertainment_group_id=config.hue_entertainment_group_id,
|
||||
gradient_mode=getattr(config, "hue_gradient_mode", True),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
|
||||
@@ -29,6 +29,7 @@ import numpy as np
|
||||
|
||||
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from ledgrab.core.devices.pixel_reduce import average_color as _average_color
|
||||
from ledgrab.core.devices.pixel_reduce import resample_to_n
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -41,6 +42,21 @@ MSG_GET_SERVICE = 2
|
||||
MSG_STATE_SERVICE = 3
|
||||
MSG_SET_POWER = 21
|
||||
MSG_SET_COLOR = 102
|
||||
# Multizone (Z / Beam) and matrix (Tile / Canvas) per-pixel messages.
|
||||
MSG_GET_COLOR_ZONES = 502
|
||||
MSG_STATE_ZONE = 503
|
||||
MSG_STATE_MULTIZONE = 506
|
||||
MSG_SET_EXTENDED_COLOR_ZONES = 510
|
||||
MSG_GET_DEVICE_CHAIN = 701
|
||||
MSG_STATE_DEVICE_CHAIN = 702
|
||||
MSG_SET_TILE_STATE_64 = 715
|
||||
|
||||
_EXT_ZONE_MAX = 82 # SetExtendedColorZones carries a fixed 82-slot HSBK array
|
||||
_TILE_PIXELS = 64 # SetTileState64 always carries 64 HSBK
|
||||
_TILE_STRUCT_SIZE = 55 # bytes per Tile struct in StateDeviceChain
|
||||
_TILE_CHAIN_MAX = 16 # StateDeviceChain always lists 16 tile slots
|
||||
# Apply field for zone writes: 0=NO_APPLY (buffer), 1=APPLY, 2=APPLY_ONLY
|
||||
_ZONE_APPLY = 1
|
||||
|
||||
# Frame field byte 0 of the protocol header: tagged=1, addressable=1, protocol=1024
|
||||
_FRAME_TAGGED = 0x3400
|
||||
@@ -142,6 +158,105 @@ def _build_set_power_payload(on: bool, duration_ms: int = 0) -> bytes:
|
||||
return struct.pack("<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:
|
||||
"""Parse a LIFX StateService (discovery) reply.
|
||||
|
||||
@@ -163,15 +278,25 @@ def _parse_state_service_reply(raw: bytes) -> dict | None:
|
||||
|
||||
|
||||
class _LIFXProtocol(asyncio.DatagramProtocol):
|
||||
"""Write-only datagram protocol. Inbound replies dropped silently."""
|
||||
"""Datagram protocol that buffers inbound replies for zone/tile discovery.
|
||||
|
||||
The streaming hot path never reads replies, but per-zone setup needs the
|
||||
StateMultiZone / StateDeviceChain answers. The buffer is bounded so a
|
||||
chatty bulb can't grow it without limit during steady-state streaming.
|
||||
"""
|
||||
|
||||
_MAX_BUFFER = 64
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.transport: asyncio.DatagramTransport | None = None
|
||||
self.received: list[bytes] = []
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
# LIFX bulbs sometimes echo back state on broadcast. We don't need it
|
||||
# for streaming ambilight — discard.
|
||||
pass
|
||||
if len(self.received) < self._MAX_BUFFER:
|
||||
self.received.append(bytes(data))
|
||||
|
||||
def error_received(self, exc):
|
||||
logger.debug("LIFX UDP error: %s", exc)
|
||||
@@ -186,6 +311,7 @@ class LIFXClient(LEDClient):
|
||||
led_count: int = 1,
|
||||
*,
|
||||
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
|
||||
per_zone: bool = False,
|
||||
):
|
||||
host, port = parse_lifx_url(url)
|
||||
self._host = host
|
||||
@@ -197,6 +323,12 @@ class LIFXClient(LEDClient):
|
||||
self._connected = False
|
||||
self._next_tx_at: float = 0.0
|
||||
self._sequence: int = 0
|
||||
# Per-pixel state. ``_mode`` is "single" until connect() probes the
|
||||
# device and finds multizone (Z/Beam) or tile (matrix) support.
|
||||
self._per_zone = per_zone
|
||||
self._mode = "single" # "single" | "multizone" | "tile"
|
||||
self._zone_count = 0
|
||||
self._tiles: list[tuple[int, int]] = []
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
@@ -212,6 +344,12 @@ class LIFXClient(LEDClient):
|
||||
|
||||
@property
|
||||
def device_led_count(self) -> int | None:
|
||||
# In per-zone streaming the device's addressable element count is
|
||||
# authoritative (zones for multizone, total pixels for tiles).
|
||||
if self._mode == "multizone" and self._zone_count:
|
||||
return self._zone_count
|
||||
if self._mode == "tile" and self._tiles:
|
||||
return sum(w * h for w, h in self._tiles)
|
||||
return self._led_count or None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
@@ -227,9 +365,56 @@ class LIFXClient(LEDClient):
|
||||
self._transport = transport
|
||||
self._protocol = protocol # type: ignore[assignment]
|
||||
self._connected = True
|
||||
logger.info("LIFXClient connected to %s:%d", self._host, self._port)
|
||||
if self._per_zone:
|
||||
# Best-effort: probe for multizone / tile support. On timeout or
|
||||
# an old single-colour bulb we silently stay in single-colour mode.
|
||||
try:
|
||||
await self._setup_zones()
|
||||
except Exception as exc: # noqa: BLE001 — degrade, never fail connect
|
||||
logger.warning(
|
||||
"LIFX %s: per-zone setup failed, using single colour (%s)", self._host, exc
|
||||
)
|
||||
self._mode = "single"
|
||||
logger.info(
|
||||
"LIFXClient connected to %s:%d (per_zone=%s, mode=%s)",
|
||||
self._host,
|
||||
self._port,
|
||||
self._per_zone,
|
||||
self._mode,
|
||||
)
|
||||
return True
|
||||
|
||||
async def _setup_zones(self) -> None:
|
||||
"""Probe the device for multizone / tile support and pick a mode.
|
||||
|
||||
Sends GetColorZones + GetDeviceChain and waits briefly for whichever
|
||||
reply the device returns (a strip answers StateMultiZone; a tile chain
|
||||
answers StateDeviceChain). Leaves ``_mode`` at "single" if neither.
|
||||
"""
|
||||
if self._protocol is None:
|
||||
return
|
||||
self._protocol.received.clear()
|
||||
self._send(MSG_GET_DEVICE_CHAIN, b"")
|
||||
self._send(MSG_GET_COLOR_ZONES, _build_get_color_zones_payload())
|
||||
loop = asyncio.get_event_loop()
|
||||
deadline = loop.time() + 0.6
|
||||
while loop.time() < deadline:
|
||||
await asyncio.sleep(0.05)
|
||||
for raw in list(self._protocol.received):
|
||||
chain = _parse_state_device_chain(raw)
|
||||
if chain and chain["tiles"]:
|
||||
self._tiles = chain["tiles"]
|
||||
self._mode = "tile"
|
||||
logger.info("LIFX %s: tile chain (%d tiles)", self._host, len(self._tiles))
|
||||
return
|
||||
zones = _parse_multizone_reply(raw)
|
||||
if zones and zones["count"] > 1:
|
||||
self._zone_count = min(zones["count"], _EXT_ZONE_MAX)
|
||||
self._mode = "multizone"
|
||||
logger.info("LIFX %s: multizone (%d zones)", self._host, self._zone_count)
|
||||
return
|
||||
self._mode = "single"
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._transport is not None:
|
||||
try:
|
||||
@@ -255,25 +440,63 @@ class LIFXClient(LEDClient):
|
||||
)
|
||||
self._transport.sendto(packet)
|
||||
|
||||
def _hsbk_list(
|
||||
self,
|
||||
pixels: List[Tuple[int, int, int]] | np.ndarray,
|
||||
n: int,
|
||||
scale: float,
|
||||
) -> List[Tuple[int, int, int, int]]:
|
||||
"""Resample the strip to ``n`` pixels and convert each to HSBK."""
|
||||
out: List[Tuple[int, int, int, int]] = []
|
||||
for r, g, b in resample_to_n(pixels, n):
|
||||
if scale != 1.0:
|
||||
r, g, b = int(r * scale), int(g * scale), int(b * scale)
|
||||
out.append(rgb_to_hsbk(r, g, b))
|
||||
return out
|
||||
|
||||
def _emit_pixels(
|
||||
self,
|
||||
pixels: List[Tuple[int, int, int]] | np.ndarray,
|
||||
brightness: int,
|
||||
) -> None:
|
||||
"""Build and send the packet(s) for the current mode (single/zone/tile)."""
|
||||
scale = max(0, min(255, brightness)) / 255.0 if brightness < 255 else 1.0
|
||||
if self._mode == "multizone" and self._zone_count > 0:
|
||||
hsbk = self._hsbk_list(pixels, self._zone_count, scale)
|
||||
self._send(MSG_SET_EXTENDED_COLOR_ZONES, _build_set_extended_color_zones_payload(hsbk))
|
||||
return
|
||||
if self._mode == "tile" and self._tiles:
|
||||
total = sum(w * h for w, h in self._tiles)
|
||||
full = self._hsbk_list(pixels, total, scale)
|
||||
offset = 0
|
||||
for ti, (w, h) in enumerate(self._tiles):
|
||||
n = w * h
|
||||
chunk = full[offset : offset + n]
|
||||
offset += n
|
||||
self._send(
|
||||
MSG_SET_TILE_STATE_64,
|
||||
_build_set_tile_state64_payload(chunk, tile_index=ti, width=w),
|
||||
)
|
||||
return
|
||||
# Single-colour fallback (every non-multizone/tile bulb).
|
||||
r, g, b = _average_color(pixels)
|
||||
if scale != 1.0:
|
||||
r, g, b = int(r * scale), int(g * scale), int(b * scale)
|
||||
h, s, br, k = rgb_to_hsbk(r, g, b)
|
||||
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: List[Tuple[int, int, int]] | np.ndarray,
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
"""Average the strip → HSBK → SetColor."""
|
||||
"""Stream per-zone/tile when detected, else average the strip → SetColor."""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("LIFXClient not connected")
|
||||
now = time.monotonic()
|
||||
if now < self._next_tx_at:
|
||||
return True
|
||||
r, g, b = _average_color(pixels)
|
||||
if brightness < 255:
|
||||
scale = max(0, min(255, brightness)) / 255.0
|
||||
r = int(r * scale)
|
||||
g = int(g * scale)
|
||||
b = int(b * scale)
|
||||
h, s, br, k = rgb_to_hsbk(r, g, b)
|
||||
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
|
||||
self._emit_pixels(pixels, brightness)
|
||||
self._next_tx_at = now + self._min_interval_s
|
||||
return True
|
||||
|
||||
@@ -288,14 +511,7 @@ class LIFXClient(LEDClient):
|
||||
now = time.monotonic()
|
||||
if now < self._next_tx_at:
|
||||
return
|
||||
r, g, b = _average_color(pixels)
|
||||
if brightness < 255:
|
||||
scale = max(0, min(255, brightness)) / 255.0
|
||||
r = int(r * scale)
|
||||
g = int(g * scale)
|
||||
b = int(b * scale)
|
||||
h, s, br, k = rgb_to_hsbk(r, g, b)
|
||||
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
|
||||
self._emit_pixels(pixels, brightness)
|
||||
self._next_tx_at = now + self._min_interval_s
|
||||
|
||||
@property
|
||||
|
||||
@@ -51,6 +51,7 @@ class LIFXDeviceProvider(LEDDeviceProvider):
|
||||
config.device_url,
|
||||
led_count=config.led_count,
|
||||
min_interval_s=max(0.0, config.lifx_min_interval_ms / 1000.0),
|
||||
per_zone=getattr(config, "lifx_per_zone", False),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
|
||||
@@ -40,3 +40,29 @@ def average_color(
|
||||
total_b += b
|
||||
n = len(pixels)
|
||||
return total_r // n, total_g // n, total_b // n
|
||||
|
||||
|
||||
def resample_to_n(
|
||||
pixels: List[Tuple[int, int, int]] | np.ndarray,
|
||||
n: int,
|
||||
) -> List[Tuple[int, int, int]]:
|
||||
"""Nearest-neighbour resample an N-pixel strip to exactly ``n`` pixels.
|
||||
|
||||
Output pixel ``i`` of ``n`` samples input pixel ``floor(i * N / n)``, so
|
||||
the strip spreads spatially across a multi-element device (LIFX zones/
|
||||
tiles, Nanoleaf panels, Hue segments). Black is returned for an empty
|
||||
strip; ``n <= 0`` yields an empty list.
|
||||
"""
|
||||
if n <= 0:
|
||||
return []
|
||||
arr = np.asarray(pixels, dtype=np.uint8).reshape(-1, 3)
|
||||
n_pix = len(arr)
|
||||
out: List[Tuple[int, int, int]] = []
|
||||
for i in range(n):
|
||||
if n_pix == 0:
|
||||
out.append((0, 0, 0))
|
||||
continue
|
||||
idx = min(n_pix - 1, (i * n_pix) // n)
|
||||
px = arr[idx]
|
||||
out.append((int(px[0]), int(px[1]), int(px[2])))
|
||||
return out
|
||||
|
||||
@@ -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)
|
||||
@@ -22,10 +22,20 @@ class MQTTManager:
|
||||
Multiple consumers share the same runtime via acquire/release.
|
||||
"""
|
||||
|
||||
def __init__(self, store: MQTTSourceStore) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
store: MQTTSourceStore,
|
||||
automation_store=None,
|
||||
version: str = "",
|
||||
) -> None:
|
||||
self._store = store
|
||||
# Optional deps for HA discovery publishing (injected from main.py).
|
||||
self._automation_store = automation_store
|
||||
self._version = version
|
||||
# source_id -> (runtime, ref_count)
|
||||
self._runtimes: Dict[str, tuple] = {}
|
||||
# Sources for which we hold a discovery acquire() reference.
|
||||
self._discovery_sources: set[str] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def acquire(self, source_id: str) -> MQTTRuntime:
|
||||
@@ -100,6 +110,86 @@ class MQTTManager:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to update MQTT runtime %s: %s", source_id, e)
|
||||
|
||||
# ===== Home Assistant MQTT discovery =====
|
||||
|
||||
def _make_publisher(self, runtime: MQTTRuntime, source):
|
||||
from ledgrab.core.mqtt.ha_discovery import HADiscoveryPublisher
|
||||
|
||||
return HADiscoveryPublisher(runtime, source, self._automation_store, version=self._version)
|
||||
|
||||
async def bootstrap_discovery(self) -> None:
|
||||
"""On startup, ensure a runtime + publish discovery for every enabled source."""
|
||||
if self._automation_store is None:
|
||||
return
|
||||
for source in self._store.get_all():
|
||||
if getattr(source, "publish_ha_discovery", False):
|
||||
try:
|
||||
await self.ensure_discovery(source.id)
|
||||
except Exception as exc: # noqa: BLE001 — best-effort bootstrap
|
||||
logger.warning("HA discovery bootstrap failed for %s: %s", source.id, exc)
|
||||
|
||||
async def ensure_discovery(self, source_id: str) -> None:
|
||||
"""Hold a runtime open for *source_id* and publish its discovery configs.
|
||||
|
||||
Idempotent: a second call re-publishes (configs are retained) without
|
||||
leaking a second acquire reference.
|
||||
"""
|
||||
if self._automation_store is None:
|
||||
return
|
||||
try:
|
||||
source = self._store.get(source_id)
|
||||
except Exception:
|
||||
return
|
||||
if not getattr(source, "publish_ha_discovery", False):
|
||||
return
|
||||
first_time = source_id not in self._discovery_sources
|
||||
if first_time:
|
||||
runtime = await self.acquire(source_id)
|
||||
self._discovery_sources.add(source_id)
|
||||
else:
|
||||
runtime = self.get_runtime(source_id)
|
||||
if runtime is None:
|
||||
return
|
||||
await self._make_publisher(runtime, source).publish_all()
|
||||
|
||||
async def disable_discovery(self, source_id: str) -> None:
|
||||
"""Clear a source's discovery configs and drop our runtime reference."""
|
||||
if source_id not in self._discovery_sources:
|
||||
return
|
||||
runtime = self.get_runtime(source_id)
|
||||
if runtime is not None:
|
||||
try:
|
||||
source = self._store.get(source_id)
|
||||
await self._make_publisher(runtime, source).remove_all()
|
||||
except Exception as exc: # noqa: BLE001 — best-effort cleanup
|
||||
logger.warning("HA discovery cleanup failed for %s: %s", source_id, exc)
|
||||
self._discovery_sources.discard(source_id)
|
||||
await self.release(source_id)
|
||||
|
||||
async def sync_discovery(self, source_id: str) -> None:
|
||||
"""Reconcile discovery state after a source is created/updated."""
|
||||
try:
|
||||
source = self._store.get(source_id)
|
||||
except Exception:
|
||||
return
|
||||
if getattr(source, "publish_ha_discovery", False):
|
||||
await self.ensure_discovery(source_id)
|
||||
else:
|
||||
await self.disable_discovery(source_id)
|
||||
|
||||
async def publish_automation_state_all(self, automation_id: str, active: bool) -> None:
|
||||
"""Fan an automation's active state out to every discovery-enabled runtime."""
|
||||
if not self._discovery_sources:
|
||||
return
|
||||
action = "active" if active else "inactive"
|
||||
for source_id in list(self._discovery_sources):
|
||||
runtime = self.get_runtime(source_id)
|
||||
if runtime is not None:
|
||||
try:
|
||||
await runtime.publish_automation_state(automation_id, action)
|
||||
except Exception: # noqa: BLE001 — never raise into the engine
|
||||
pass
|
||||
|
||||
def get_connection_status(self) -> List[Dict[str, Any]]:
|
||||
"""Get status of all active MQTT connections (for dashboard indicators)."""
|
||||
result = []
|
||||
|
||||
@@ -180,7 +180,9 @@ weather_manager = WeatherManager(weather_source_store)
|
||||
ha_store = HomeAssistantStore(db)
|
||||
ha_manager = HomeAssistantManager(ha_store)
|
||||
mqtt_source_store = MQTTSourceStore(db)
|
||||
mqtt_manager = MQTTManager(mqtt_source_store)
|
||||
mqtt_manager = MQTTManager(
|
||||
mqtt_source_store, automation_store=automation_store, version=__version__
|
||||
)
|
||||
http_endpoint_store = HTTPEndpointStore(db)
|
||||
audio_processing_template_store = AudioProcessingTemplateStore(db)
|
||||
game_integration_store = GameIntegrationStore(db)
|
||||
@@ -424,6 +426,9 @@ async def lifespan(app: FastAPI):
|
||||
# Start automation engine (evaluates conditions and activates scenes)
|
||||
await automation_engine.start()
|
||||
|
||||
# Publish Home Assistant MQTT discovery for any source that opted in.
|
||||
await mqtt_manager.bootstrap_discovery()
|
||||
|
||||
# Start auto-backup engine (periodic configuration backups)
|
||||
await auto_backup_engine.start()
|
||||
|
||||
|
||||
@@ -126,6 +126,10 @@ class AutomationEditorModal extends Modal {
|
||||
deactivationMode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
|
||||
deactivationScenePresetId: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value,
|
||||
tags: JSON.stringify(_automationTagsInput ? _automationTagsInput.getValue() : []),
|
||||
actionUrl: (document.getElementById('automation-action-webhook-url') as HTMLInputElement)?.value || '',
|
||||
actionMethod: (document.getElementById('automation-action-method') as HTMLSelectElement)?.value || '',
|
||||
actionFireOn: (document.getElementById('automation-action-fire-on') as HTMLSelectElement)?.value || '',
|
||||
actionBody: (document.getElementById('automation-action-body') as HTMLTextAreaElement)?.value || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -533,7 +537,18 @@ function createAutomationCard(automation: Automation, sceneMap = new Map()) {
|
||||
});
|
||||
}
|
||||
|
||||
const chipsHtml = `<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 ──
|
||||
// Active = blink (live signal); Enabled-but-idle = off (waiting);
|
||||
@@ -633,6 +648,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
|
||||
|
||||
_ensureRuleLogicIconSelect();
|
||||
_ensureDeactivationModeIconSelect();
|
||||
_ensureActionIconSelects();
|
||||
|
||||
// Fetch scenes for selector
|
||||
try {
|
||||
@@ -643,6 +659,8 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
|
||||
(document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value = 'none';
|
||||
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none');
|
||||
(document.getElementById('automation-fallback-scene-group') as HTMLElement).style.display = 'none';
|
||||
// Reset webhook action fields (overwritten below for edit/clone).
|
||||
_loadAutomationAction([]);
|
||||
|
||||
let _editorTags: any[] = [];
|
||||
|
||||
@@ -670,6 +688,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
|
||||
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode);
|
||||
_onDeactivationModeChange();
|
||||
_initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id);
|
||||
_loadAutomationAction(automation.actions || []);
|
||||
_editorTags = automation.tags || [];
|
||||
} catch (e: any) {
|
||||
showToast(e.message, 'error');
|
||||
@@ -698,6 +717,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
|
||||
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode);
|
||||
_onDeactivationModeChange();
|
||||
_initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id);
|
||||
_loadAutomationAction(cloneData.actions || []);
|
||||
_editorTags = cloneData.tags || [];
|
||||
} else {
|
||||
titleEl!.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
|
||||
@@ -712,6 +732,8 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
|
||||
|
||||
// Wire up deactivation mode change
|
||||
(document.getElementById('automation-deactivation-mode') as HTMLSelectElement).onchange = _onDeactivationModeChange;
|
||||
// Wire up webhook URL → show/hide the rest of the action fields
|
||||
(document.getElementById('automation-action-webhook-url') as HTMLInputElement).oninput = _onActionUrlChange;
|
||||
|
||||
// Auto-name wiring
|
||||
_autoNameManuallyEdited = !!(automationId || cloneData);
|
||||
@@ -805,6 +827,56 @@ function _ensureDeactivationModeIconSelect() {
|
||||
_deactivationModeIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any);
|
||||
}
|
||||
|
||||
// ── Webhook action selectors (method + fire_on) ──
|
||||
let _actionMethodIconSelect: any = null;
|
||||
let _actionFireOnIconSelect: any = null;
|
||||
const _ACTION_METHOD_ICONS: any = { POST: P.send, PUT: P.code, GET: P.globe };
|
||||
const _ACTION_FIRE_ON_ICONS: any = { activate: P.play, deactivate: P.undo2, both: P.zap };
|
||||
|
||||
function _ensureActionIconSelects() {
|
||||
const methodSel = document.getElementById('automation-action-method');
|
||||
if (methodSel && !_actionMethodIconSelect) {
|
||||
const items = ['POST', 'PUT', 'GET'].map(k => ({
|
||||
value: k,
|
||||
icon: _icon(_ACTION_METHOD_ICONS[k]),
|
||||
label: k,
|
||||
}));
|
||||
_actionMethodIconSelect = new IconSelect({ target: methodSel, items, columns: 3 } as any);
|
||||
}
|
||||
const fireSel = document.getElementById('automation-action-fire-on');
|
||||
if (fireSel && !_actionFireOnIconSelect) {
|
||||
const items = ['activate', 'deactivate', 'both'].map(k => ({
|
||||
value: k,
|
||||
icon: _icon(_ACTION_FIRE_ON_ICONS[k]),
|
||||
label: t(`automations.action.fire_on.${k}`),
|
||||
}));
|
||||
_actionFireOnIconSelect = new IconSelect({ target: fireSel, items, columns: 3 } as any);
|
||||
}
|
||||
}
|
||||
|
||||
// Show the method/fire_on/body fields only once a webhook URL is present.
|
||||
function _onActionUrlChange() {
|
||||
const url = (document.getElementById('automation-action-webhook-url') as HTMLInputElement)?.value.trim() || '';
|
||||
const fields = document.getElementById('automation-action-fields');
|
||||
if (fields) (fields as HTMLElement).style.display = url ? '' : 'none';
|
||||
}
|
||||
|
||||
// Populate the webhook action fields from an automation's first webhook action.
|
||||
function _loadAutomationAction(actions: any[]) {
|
||||
const webhook = (actions || []).find((a: any) => a.action_type === 'webhook') || null;
|
||||
const urlEl = document.getElementById('automation-action-webhook-url') as HTMLInputElement;
|
||||
const methodEl = document.getElementById('automation-action-method') as HTMLSelectElement;
|
||||
const fireEl = document.getElementById('automation-action-fire-on') as HTMLSelectElement;
|
||||
const bodyEl = document.getElementById('automation-action-body') as HTMLTextAreaElement;
|
||||
if (urlEl) urlEl.value = webhook?.webhook_url || '';
|
||||
if (methodEl) methodEl.value = webhook?.method || 'POST';
|
||||
if (_actionMethodIconSelect) _actionMethodIconSelect.setValue(webhook?.method || 'POST');
|
||||
if (fireEl) fireEl.value = webhook?.fire_on || 'activate';
|
||||
if (_actionFireOnIconSelect) _actionFireOnIconSelect.setValue(webhook?.fire_on || 'activate');
|
||||
if (bodyEl) bodyEl.value = webhook?.body_template || '';
|
||||
_onActionUrlChange();
|
||||
}
|
||||
|
||||
// ===== Condition editor =====
|
||||
|
||||
export function addAutomationRule() {
|
||||
@@ -1562,6 +1634,15 @@ export async function saveAutomationEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
const actionUrl = (document.getElementById('automation-action-webhook-url') as HTMLInputElement)?.value.trim() || '';
|
||||
const actions = actionUrl ? [{
|
||||
action_type: 'webhook',
|
||||
webhook_url: actionUrl,
|
||||
method: (document.getElementById('automation-action-method') as HTMLSelectElement)?.value || 'POST',
|
||||
fire_on: (document.getElementById('automation-action-fire-on') as HTMLSelectElement)?.value || 'activate',
|
||||
body_template: (document.getElementById('automation-action-body') as HTMLTextAreaElement)?.value || '',
|
||||
}] : [];
|
||||
|
||||
const body = {
|
||||
name,
|
||||
enabled: enabledInput.checked,
|
||||
@@ -1571,6 +1652,7 @@ export async function saveAutomationEditor() {
|
||||
deactivation_mode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
|
||||
deactivation_scene_preset_id: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value || null,
|
||||
tags: _automationTagsInput ? _automationTagsInput.getValue() : [],
|
||||
actions,
|
||||
};
|
||||
|
||||
const automationId = idInput.value;
|
||||
|
||||
@@ -1010,6 +1010,8 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) {
|
||||
if (lmi && cloneData.lifx_min_interval_ms != null) {
|
||||
lmi.value = String(cloneData.lifx_min_interval_ms);
|
||||
}
|
||||
const lpz = document.getElementById('device-lifx-per-zone') as HTMLInputElement | null;
|
||||
if (lpz) lpz.checked = !!cloneData.lifx_per_zone;
|
||||
}
|
||||
// Prefill Nanoleaf fields (clone only carries the rate limit — the
|
||||
// token is not exposed in /devices responses, so a cloned device
|
||||
@@ -1231,6 +1233,7 @@ export async function handleAddDevice(event: any) {
|
||||
body.hue_username = (document.getElementById('device-hue-username') as HTMLInputElement)?.value || '';
|
||||
body.hue_client_key = (document.getElementById('device-hue-client-key') as HTMLInputElement)?.value || '';
|
||||
body.hue_entertainment_group_id = (document.getElementById('device-hue-group-id') as HTMLInputElement)?.value || '';
|
||||
body.hue_gradient_mode = (document.getElementById('device-hue-gradient-mode') as HTMLInputElement | null)?.checked ?? true;
|
||||
}
|
||||
if (isYeelightDevice(deviceType)) {
|
||||
const raw = (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value;
|
||||
@@ -1246,6 +1249,7 @@ export async function handleAddDevice(event: any) {
|
||||
const raw = (document.getElementById('device-lifx-min-interval') as HTMLInputElement)?.value;
|
||||
const parsed = parseInt(raw || '50', 10);
|
||||
body.lifx_min_interval_ms = Number.isFinite(parsed) ? parsed : 50;
|
||||
body.lifx_per_zone = (document.getElementById('device-lifx-per-zone') as HTMLInputElement | null)?.checked || false;
|
||||
}
|
||||
if (isGoveeDevice(deviceType)) {
|
||||
const raw = (document.getElementById('device-govee-min-interval') as HTMLInputElement)?.value;
|
||||
@@ -1614,7 +1618,7 @@ function _showEspnowFields(show: boolean) {
|
||||
}
|
||||
|
||||
function _showHueFields(show: boolean) {
|
||||
const ids = ['device-hue-username-group', 'device-hue-client-key-group', 'device-hue-group-id-group'];
|
||||
const ids = ['device-hue-username-group', 'device-hue-client-key-group', 'device-hue-group-id-group', 'device-hue-gradient-mode-group'];
|
||||
ids.forEach(id => {
|
||||
const el = document.getElementById(id) as HTMLElement;
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
@@ -1639,6 +1643,8 @@ function _showWizFields(show: boolean) {
|
||||
function _showLifxFields(show: boolean) {
|
||||
const el = document.getElementById('device-lifx-min-interval-group') as HTMLElement | null;
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
const pz = document.getElementById('device-lifx-per-zone-group') as HTMLElement | null;
|
||||
if (pz) pz.style.display = show ? '' : 'none';
|
||||
}
|
||||
|
||||
function _showGoveeFields(show: boolean) {
|
||||
|
||||
@@ -701,10 +701,14 @@ export async function showSettings(deviceId: any) {
|
||||
// (HSBK averaged from the strip). LIFX recommends ≤20 cmd/sec per
|
||||
// device; default 50 ms matches that ceiling.
|
||||
const lifxMinIntervalGroup = document.getElementById('settings-lifx-min-interval-group');
|
||||
const lifxPerZoneGroup = document.getElementById('settings-lifx-per-zone-group');
|
||||
if (isLifxDevice(device.device_type)) {
|
||||
if (lifxMinIntervalGroup) (lifxMinIntervalGroup as HTMLElement).style.display = '';
|
||||
const lmi = device.lifx_min_interval_ms ?? 50;
|
||||
(document.getElementById('settings-lifx-min-interval') as HTMLInputElement).value = String(lmi);
|
||||
if (lifxPerZoneGroup) (lifxPerZoneGroup as HTMLElement).style.display = '';
|
||||
const lpzEl = document.getElementById('settings-lifx-per-zone') as HTMLInputElement | null;
|
||||
if (lpzEl) lpzEl.checked = !!device.lifx_per_zone;
|
||||
// Relabel URL field as IP Address (same pattern as WiZ/Yeelight/DMX/DDP)
|
||||
const urlLabel6 = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null;
|
||||
const urlHint6 = urlGroup.querySelector('.input-hint') as HTMLElement | null;
|
||||
@@ -713,6 +717,7 @@ export async function showSettings(deviceId: any) {
|
||||
urlInput.placeholder = t('device.lifx.url.placeholder') || '192.168.1.50';
|
||||
} else {
|
||||
if (lifxMinIntervalGroup) (lifxMinIntervalGroup as HTMLElement).style.display = 'none';
|
||||
if (lifxPerZoneGroup) (lifxPerZoneGroup as HTMLElement).style.display = 'none';
|
||||
}
|
||||
|
||||
// Govee-specific fields — 2023+ LAN API over UDP fire-and-forget
|
||||
@@ -927,6 +932,7 @@ export async function saveDeviceSettings() {
|
||||
const raw = (document.getElementById('settings-lifx-min-interval') as HTMLInputElement | null)?.value;
|
||||
const parsed = parseInt(raw || '50', 10);
|
||||
body.lifx_min_interval_ms = Number.isFinite(parsed) ? parsed : 50;
|
||||
body.lifx_per_zone = (document.getElementById('settings-lifx-per-zone') as HTMLInputElement | null)?.checked || false;
|
||||
}
|
||||
if (isGoveeDevice(settingsModal.deviceType)) {
|
||||
const raw = (document.getElementById('settings-govee-min-interval') as HTMLInputElement | null)?.value;
|
||||
|
||||
@@ -56,6 +56,8 @@ class MQTTSourceModal extends Modal {
|
||||
base_topic: (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value,
|
||||
description: (document.getElementById('mqtt-source-description') as HTMLInputElement).value,
|
||||
tags: JSON.stringify(_mqttTagsInput ? _mqttTagsInput.getValue() : []),
|
||||
haDiscovery: (document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement)?.checked.toString() || 'false',
|
||||
discoveryPrefix: (document.getElementById('mqtt-source-discovery-prefix') as HTMLInputElement)?.value || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -79,6 +81,8 @@ export async function showMQTTSourceModal(editData: MQTTSource | null = null): P
|
||||
(document.getElementById('mqtt-source-password') as HTMLInputElement).value = ''; // never expose
|
||||
(document.getElementById('mqtt-source-client-id') as HTMLInputElement).value = editData.client_id || 'ledgrab';
|
||||
(document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value = editData.base_topic || 'ledgrab';
|
||||
(document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement).checked = !!editData.publish_ha_discovery;
|
||||
(document.getElementById('mqtt-source-discovery-prefix') as HTMLInputElement).value = editData.discovery_prefix || 'homeassistant';
|
||||
(document.getElementById('mqtt-source-description') as HTMLInputElement).value = editData.description || '';
|
||||
} else {
|
||||
(document.getElementById('mqtt-source-name') as HTMLInputElement).value = '';
|
||||
@@ -88,9 +92,20 @@ export async function showMQTTSourceModal(editData: MQTTSource | null = null): P
|
||||
(document.getElementById('mqtt-source-password') as HTMLInputElement).value = '';
|
||||
(document.getElementById('mqtt-source-client-id') as HTMLInputElement).value = 'ledgrab';
|
||||
(document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value = 'ledgrab';
|
||||
(document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement).checked = false;
|
||||
(document.getElementById('mqtt-source-discovery-prefix') as HTMLInputElement).value = 'homeassistant';
|
||||
(document.getElementById('mqtt-source-description') as HTMLInputElement).value = '';
|
||||
}
|
||||
|
||||
// Show the discovery-prefix field only while discovery is enabled.
|
||||
const _discoveryToggle = document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement;
|
||||
const _syncDiscoveryPrefix = () => {
|
||||
const grp = document.getElementById('mqtt-source-discovery-prefix-group');
|
||||
if (grp) (grp as HTMLElement).style.display = _discoveryToggle?.checked ? '' : 'none';
|
||||
};
|
||||
if (_discoveryToggle) _discoveryToggle.onchange = _syncDiscoveryPrefix;
|
||||
_syncDiscoveryPrefix();
|
||||
|
||||
// Tags
|
||||
if (_mqttTagsInput) { _mqttTagsInput.destroy(); _mqttTagsInput = null; }
|
||||
_mqttTagsInput = new TagInput(document.getElementById('mqtt-source-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
@@ -125,6 +140,8 @@ export async function saveMQTTSource(): Promise<void> {
|
||||
const password = (document.getElementById('mqtt-source-password') as HTMLInputElement).value;
|
||||
const client_id = (document.getElementById('mqtt-source-client-id') as HTMLInputElement).value.trim() || 'ledgrab';
|
||||
const base_topic = (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value.trim() || 'ledgrab';
|
||||
const publish_ha_discovery = (document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement).checked;
|
||||
const discovery_prefix = (document.getElementById('mqtt-source-discovery-prefix') as HTMLInputElement).value.trim() || 'homeassistant';
|
||||
const description = (document.getElementById('mqtt-source-description') as HTMLInputElement).value.trim() || null;
|
||||
|
||||
if (!name) {
|
||||
@@ -138,6 +155,7 @@ export async function saveMQTTSource(): Promise<void> {
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
name, broker_port, username, client_id, base_topic, description,
|
||||
publish_ha_discovery, discovery_prefix,
|
||||
tags: _mqttTagsInput ? _mqttTagsInput.getValue() : [],
|
||||
};
|
||||
if (broker_host) payload.broker_host = broker_host;
|
||||
|
||||
@@ -48,6 +48,15 @@ export interface AutomationRule {
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface AutomationAction {
|
||||
action_type: 'webhook';
|
||||
webhook_url?: string;
|
||||
method?: 'POST' | 'PUT' | 'GET';
|
||||
body_template?: string;
|
||||
content_type?: string;
|
||||
fire_on?: 'activate' | 'deactivate' | 'both';
|
||||
}
|
||||
|
||||
export interface Automation {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -58,6 +67,7 @@ export interface Automation {
|
||||
deactivation_mode: 'none' | 'revert' | 'fallback_scene';
|
||||
deactivation_scene_preset_id?: string;
|
||||
tags: string[];
|
||||
actions?: AutomationAction[];
|
||||
webhook_url?: string;
|
||||
is_active: boolean;
|
||||
last_activated_at?: string;
|
||||
|
||||
@@ -38,9 +38,11 @@ export interface Device {
|
||||
espnow_channel: number;
|
||||
hue_paired: boolean;
|
||||
hue_entertainment_group_id: string;
|
||||
hue_gradient_mode?: boolean;
|
||||
yeelight_min_interval_ms: number;
|
||||
wiz_min_interval_ms: number;
|
||||
lifx_min_interval_ms: number;
|
||||
lifx_per_zone?: boolean;
|
||||
govee_min_interval_ms: number;
|
||||
nanoleaf_paired: boolean;
|
||||
nanoleaf_min_interval_ms: number;
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface MQTTSource {
|
||||
password_set: boolean;
|
||||
client_id: string;
|
||||
base_topic: string;
|
||||
publish_ha_discovery?: boolean;
|
||||
discovery_prefix?: string;
|
||||
connected: boolean;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"activity_log.msg.audit_log.disabled": "Activity logging disabled",
|
||||
"activity_log.msg.automation.activated": "Automation '{name}' activated",
|
||||
"activity_log.msg.automation.deactivated": "Automation '{name}' deactivated",
|
||||
"activity_log.msg.automation.webhook_fired": "Webhook for '{name}' fired",
|
||||
"activity_log.msg.automation.triggered": "Automation '{name}' manually triggered",
|
||||
"activity_log.msg.server.shutting_down": "Server shutting down",
|
||||
"activity_log.msg.server.restarting": "Server restart requested",
|
||||
@@ -498,6 +499,18 @@
|
||||
"automations.scene.search_placeholder": "Search scenes...",
|
||||
"automations.section.action": "Action",
|
||||
"automations.section.deactivation": "Deactivation",
|
||||
"automations.section.action": "Webhook",
|
||||
"automations.action.webhook": "Webhook",
|
||||
"automations.action.webhook_url": "Webhook URL:",
|
||||
"automations.action.webhook_url.hint": "Optional. POST to a Discord / IFTTT / Zapier / Node-RED URL when this automation fires. LAN addresses are allowed; loopback and cloud-metadata are blocked. Leave empty for no webhook.",
|
||||
"automations.action.method": "Method:",
|
||||
"automations.action.fire_on": "Fire on:",
|
||||
"automations.action.fire_on.activate": "When activated",
|
||||
"automations.action.fire_on.deactivate": "When deactivated",
|
||||
"automations.action.fire_on.both": "Both",
|
||||
"automations.action.body_template": "Body template:",
|
||||
"automations.action.body_template.hint": "JSON body for POST/PUT. Tokens: {{automation_name}}, {{automation_id}}, {{event}}, {{timestamp}}.",
|
||||
"automations.error.invalid_webhook_url": "Invalid or blocked webhook URL.",
|
||||
"automations.section.triggers": "Triggers",
|
||||
"automations.status.active": "Active",
|
||||
"automations.status.disabled": "Disabled",
|
||||
@@ -1364,6 +1377,8 @@
|
||||
"device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)",
|
||||
"device.hue.group_id": "Entertainment Group:",
|
||||
"device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge",
|
||||
"device.hue_gradient_mode": "Map across segments:",
|
||||
"device.hue_gradient_mode.hint": "Spread the strip across a gradient lightstrip's segments (channels) instead of one averaged colour per light. Auto-detected on connect; plain bulbs are unaffected.",
|
||||
"device.hue.url": "Bridge IP:",
|
||||
"device.hue.url.hint": "IP address of your Hue bridge",
|
||||
"device.hue.username": "Bridge Username:",
|
||||
@@ -1513,6 +1528,8 @@
|
||||
"device.lifx.url.placeholder": "192.168.1.50",
|
||||
"device.lifx_min_interval": "Min Update Interval:",
|
||||
"device.lifx_min_interval.hint": "Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.",
|
||||
"device.lifx_per_zone": "Per-zone streaming:",
|
||||
"device.lifx_per_zone.hint": "Address individual zones (Z/Beam multizone) or pixels (Tile/Canvas matrix) instead of one averaged colour. Auto-detected on connect; older bulbs fall back to single colour.",
|
||||
"device.metrics.actual_fps": "Actual FPS",
|
||||
"device.metrics.current_fps": "Current FPS",
|
||||
"device.metrics.device_fps": "Device refresh rate",
|
||||
@@ -2089,6 +2106,9 @@
|
||||
"mqtt_source.add": "Add MQTT Source",
|
||||
"mqtt_source.base_topic": "Base Topic:",
|
||||
"mqtt_source.base_topic.hint": "Prefix for status and state topics, e.g. ledgrab/status",
|
||||
"mqtt_source.ha_discovery": "Home Assistant discovery:",
|
||||
"mqtt_source.ha_discovery.hint": "Publish homeassistant/.../config topics so MQTT-only Home Assistant installs get LedGrab automation + connectivity entities automatically.",
|
||||
"mqtt_source.discovery_prefix": "Discovery prefix:",
|
||||
"mqtt_source.broker_host": "Broker Host:",
|
||||
"mqtt_source.broker_host.hint": "MQTT broker hostname or IP address, e.g. 192.168.1.100",
|
||||
"mqtt_source.broker_port": "Port:",
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"activity_log.msg.audit_log.disabled": "Запись активности отключена",
|
||||
"activity_log.msg.automation.activated": "Автоматизация '{name}' активирована",
|
||||
"activity_log.msg.automation.deactivated": "Автоматизация '{name}' деактивирована",
|
||||
"activity_log.msg.automation.webhook_fired": "Вебхук для '{name}' отправлен",
|
||||
"activity_log.msg.automation.triggered": "Автоматизация '{name}' запущена вручную",
|
||||
"activity_log.msg.server.shutting_down": "Сервер выключается",
|
||||
"activity_log.msg.server.restarting": "Запрошен перезапуск сервера",
|
||||
@@ -491,6 +492,18 @@
|
||||
"automations.scene.search_placeholder": "Поиск сцен...",
|
||||
"automations.section.action": "Действие",
|
||||
"automations.section.deactivation": "Деактивация",
|
||||
"automations.section.action": "Вебхук",
|
||||
"automations.action.webhook": "Вебхук",
|
||||
"automations.action.webhook_url": "URL вебхука:",
|
||||
"automations.action.webhook_url.hint": "Необязательно. Отправлять POST-запрос на URL Discord / IFTTT / Zapier / Node-RED при срабатывании автоматизации. Локальные (LAN) адреса разрешены; loopback и метаданные облака заблокированы. Оставьте пустым, чтобы отключить вебхук.",
|
||||
"automations.action.method": "Метод:",
|
||||
"automations.action.fire_on": "Срабатывать при:",
|
||||
"automations.action.fire_on.activate": "При активации",
|
||||
"automations.action.fire_on.deactivate": "При деактивации",
|
||||
"automations.action.fire_on.both": "В обоих случаях",
|
||||
"automations.action.body_template": "Шаблон тела:",
|
||||
"automations.action.body_template.hint": "Тело JSON для POST/PUT. Токены: {{automation_name}}, {{automation_id}}, {{event}}, {{timestamp}}.",
|
||||
"automations.error.invalid_webhook_url": "Недопустимый или заблокированный URL вебхука.",
|
||||
"automations.section.triggers": "Триггеры",
|
||||
"automations.status.active": "Активна",
|
||||
"automations.status.disabled": "Отключена",
|
||||
@@ -1286,6 +1299,11 @@
|
||||
"device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)",
|
||||
"device.hue.group_id": "Entertainment Group:",
|
||||
"device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge",
|
||||
"device.hue_gradient_mode": "Распределить по сегментам:",
|
||||
"device.hue_gradient_mode.hint": "Распределить ленту по сегментам (каналам) градиентной ленты вместо одного усреднённого цвета на лампу. Определяется автоматически при подключении; обычные лампы не затрагиваются.",
|
||||
"mqtt_source.ha_discovery": "Обнаружение Home Assistant:",
|
||||
"mqtt_source.ha_discovery.hint": "Публиковать топики homeassistant/.../config, чтобы установки Home Assistant только с MQTT автоматически получали сущности автоматизаций и подключения LedGrab.",
|
||||
"mqtt_source.discovery_prefix": "Префикс обнаружения:",
|
||||
"device.hue.url": "Bridge IP:",
|
||||
"device.hue.url.hint": "IP address of your Hue bridge",
|
||||
"device.hue.username": "Bridge Username:",
|
||||
@@ -1435,6 +1453,8 @@
|
||||
"device.lifx.url.placeholder": "192.168.1.50",
|
||||
"device.lifx_min_interval": "Мин. интервал обновления:",
|
||||
"device.lifx_min_interval.hint": "Локальный лимит частоты команд (мс). LIFX рекомендует ≤20 команд/сек; по умолчанию 50 мс соответствует этому потолку.",
|
||||
"device.lifx_per_zone": "Потоковая передача по зонам:",
|
||||
"device.lifx_per_zone.hint": "Адресовать отдельные зоны (мультизона Z/Beam) или пиксели (матрица Tile/Canvas) вместо одного усреднённого цвета. Определяется автоматически при подключении; старые лампы переходят на одиночный цвет.",
|
||||
"device.metrics.actual_fps": "Факт. FPS",
|
||||
"device.metrics.current_fps": "Текущ. FPS",
|
||||
"device.metrics.device_fps": "Частота обновления устройства",
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"activity_log.msg.audit_log.disabled": "活动记录已禁用",
|
||||
"activity_log.msg.automation.activated": "自动化 '{name}' 已激活",
|
||||
"activity_log.msg.automation.deactivated": "自动化 '{name}' 已停用",
|
||||
"activity_log.msg.automation.webhook_fired": "已为 '{name}' 发送 webhook",
|
||||
"activity_log.msg.automation.triggered": "已手动触发自动化 '{name}'",
|
||||
"activity_log.msg.server.shutting_down": "服务器正在关闭",
|
||||
"activity_log.msg.server.restarting": "已请求服务器重启",
|
||||
@@ -490,6 +491,18 @@
|
||||
"automations.scene.search_placeholder": "搜索场景...",
|
||||
"automations.section.action": "动作",
|
||||
"automations.section.deactivation": "停用",
|
||||
"automations.section.action": "Webhook",
|
||||
"automations.action.webhook": "Webhook",
|
||||
"automations.action.webhook_url": "Webhook URL:",
|
||||
"automations.action.webhook_url.hint": "可选。当此自动化触发时向 Discord / IFTTT / Zapier / Node-RED 的 URL 发送 POST。允许局域网地址;回环和云元数据被阻止。留空则不发送 webhook。",
|
||||
"automations.action.method": "方法:",
|
||||
"automations.action.fire_on": "触发时机:",
|
||||
"automations.action.fire_on.activate": "激活时",
|
||||
"automations.action.fire_on.deactivate": "停用时",
|
||||
"automations.action.fire_on.both": "两者",
|
||||
"automations.action.body_template": "正文模板:",
|
||||
"automations.action.body_template.hint": "POST/PUT 的 JSON 正文。令牌:{{automation_name}}、{{automation_id}}、{{event}}、{{timestamp}}。",
|
||||
"automations.error.invalid_webhook_url": "无效或被阻止的 webhook URL。",
|
||||
"automations.section.triggers": "触发器",
|
||||
"automations.status.active": "活动",
|
||||
"automations.status.disabled": "已禁用",
|
||||
@@ -1283,6 +1296,11 @@
|
||||
"device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)",
|
||||
"device.hue.group_id": "Entertainment Group:",
|
||||
"device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge",
|
||||
"device.hue_gradient_mode": "跨分段映射:",
|
||||
"device.hue_gradient_mode.hint": "将灯带分布到渐变灯带的各分段(通道),而不是每个灯具一个平均色。连接时自动检测;普通灯泡不受影响。",
|
||||
"mqtt_source.ha_discovery": "Home Assistant 发现:",
|
||||
"mqtt_source.ha_discovery.hint": "发布 homeassistant/.../config 主题,使仅使用 MQTT 的 Home Assistant 安装自动获得 LedGrab 的自动化和连接实体。",
|
||||
"mqtt_source.discovery_prefix": "发现前缀:",
|
||||
"device.hue.url": "Bridge IP:",
|
||||
"device.hue.url.hint": "IP address of your Hue bridge",
|
||||
"device.hue.username": "Bridge Username:",
|
||||
@@ -1432,6 +1450,8 @@
|
||||
"device.lifx.url.placeholder": "192.168.1.50",
|
||||
"device.lifx_min_interval": "最小更新间隔:",
|
||||
"device.lifx_min_interval.hint": "客户端命令速率限制(毫秒)。LIFX 建议 ≤20 cmd/sec;默认 50 毫秒符合该上限。",
|
||||
"device.lifx_per_zone": "逐区流式传输:",
|
||||
"device.lifx_per_zone.hint": "单独寻址各个分区(Z/Beam 多区)或像素(Tile/Canvas 矩阵),而不是单一平均色。连接时自动检测;较旧的灯具回退为单色。",
|
||||
"device.metrics.actual_fps": "实际 FPS",
|
||||
"device.metrics.current_fps": "当前 FPS",
|
||||
"device.metrics.device_fps": "设备刷新率",
|
||||
|
||||
@@ -378,6 +378,81 @@ _RULE_MAP: Dict[str, Type[Rule]] = {
|
||||
}
|
||||
|
||||
|
||||
# ── Actions ──────────────────────────────────────────────────────────────
|
||||
# Rules decide WHEN an automation is active; actions are extra side effects
|
||||
# fired alongside scene activation (e.g. an outbound webhook to Discord /
|
||||
# IFTTT / Zapier / Node-RED). Polymorphic via the same registry pattern as
|
||||
# Rule, so new action types can be added without touching the engine wiring.
|
||||
|
||||
|
||||
@dataclass
|
||||
class Action:
|
||||
"""Base action — polymorphic via the ``action_type`` discriminator."""
|
||||
|
||||
action_type: str
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"action_type": self.action_type}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Action":
|
||||
at = data.get("action_type", "")
|
||||
subcls = _ACTION_MAP.get(at)
|
||||
if subcls is None:
|
||||
raise ValueError(f"Unknown action type: {at}")
|
||||
return subcls.from_dict(data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebhookAction(Action):
|
||||
"""POST/PUT/GET an outbound HTTP request when the automation fires.
|
||||
|
||||
``fire_on`` selects which transition triggers the call: ``"activate"``,
|
||||
``"deactivate"``, or ``"both"``. ``body_template`` supports the tokens
|
||||
``{{automation_name}}``, ``{{automation_id}}``, ``{{event}}`` and
|
||||
``{{timestamp}}``, substituted server-side at fire time. The URL is
|
||||
SSRF-gated (LAN allowed, loopback / cloud-metadata / link-local blocked)
|
||||
at both save and fire time.
|
||||
"""
|
||||
|
||||
action_type: str = "webhook"
|
||||
webhook_url: str = ""
|
||||
method: str = "POST" # POST | PUT | GET
|
||||
body_template: str = ""
|
||||
content_type: str = "application/json"
|
||||
fire_on: str = "activate" # activate | deactivate | both
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["webhook_url"] = self.webhook_url
|
||||
d["method"] = self.method
|
||||
d["body_template"] = self.body_template
|
||||
d["content_type"] = self.content_type
|
||||
d["fire_on"] = self.fire_on
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "WebhookAction":
|
||||
method = str(data.get("method", "POST")).upper()
|
||||
if method not in ("POST", "PUT", "GET"):
|
||||
method = "POST"
|
||||
fire_on = data.get("fire_on", "activate")
|
||||
if fire_on not in ("activate", "deactivate", "both"):
|
||||
fire_on = "activate"
|
||||
return cls(
|
||||
webhook_url=data.get("webhook_url", ""),
|
||||
method=method,
|
||||
body_template=data.get("body_template", ""),
|
||||
content_type=data.get("content_type", "") or "application/json",
|
||||
fire_on=fire_on,
|
||||
)
|
||||
|
||||
|
||||
_ACTION_MAP: Dict[str, Type[Action]] = {
|
||||
"webhook": WebhookAction,
|
||||
}
|
||||
|
||||
|
||||
# ── Backward-compatible aliases (for imports in other modules during transition) ──
|
||||
Condition = Rule
|
||||
ApplicationCondition = ApplicationRule
|
||||
@@ -406,6 +481,8 @@ class Automation:
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
tags: List[str] = field(default_factory=list)
|
||||
# Outbound side-effects fired alongside scene activation/deactivation.
|
||||
actions: List[Action] = field(default_factory=list)
|
||||
# Custom card icon (frontend display only)
|
||||
icon: str = ""
|
||||
icon_color: str = ""
|
||||
@@ -441,6 +518,10 @@ class Automation:
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
# Only persist actions when present — keeps existing rows byte-identical
|
||||
# and tolerates loading automations saved before actions existed.
|
||||
if self.actions:
|
||||
d["actions"] = [a.to_dict() for a in self.actions]
|
||||
if self.icon:
|
||||
d["icon"] = self.icon
|
||||
if self.icon_color:
|
||||
@@ -463,6 +544,13 @@ class Automation:
|
||||
except ValueError as e:
|
||||
logger.warning("Skipping unknown rule type on load: %s", e)
|
||||
|
||||
actions: List[Action] = []
|
||||
for a_data in data.get("actions") or []:
|
||||
try:
|
||||
actions.append(Action.from_dict(a_data))
|
||||
except ValueError as e:
|
||||
logger.warning("Skipping unknown action type on load: %s", e)
|
||||
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
@@ -473,6 +561,7 @@ class Automation:
|
||||
deactivation_mode=data.get("deactivation_mode", "none"),
|
||||
deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"),
|
||||
tags=data.get("tags", []),
|
||||
actions=actions,
|
||||
icon=data.get("icon", ""),
|
||||
icon_color=data.get("icon_color", ""),
|
||||
created_at=datetime.fromisoformat(
|
||||
|
||||
@@ -4,7 +4,7 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from ledgrab.storage.automation import Automation, Rule
|
||||
from ledgrab.storage.automation import Action, Automation, Rule
|
||||
from ledgrab.storage.base_sqlite_store import BaseSqliteStore
|
||||
from ledgrab.storage.database import Database
|
||||
from ledgrab.utils import get_logger
|
||||
@@ -34,6 +34,7 @@ class AutomationStore(BaseSqliteStore[Automation]):
|
||||
deactivation_mode: str = "none",
|
||||
deactivation_scene_preset_id: str | None = None,
|
||||
tags: List[str] | None = None,
|
||||
actions: List[Action] | None = None,
|
||||
icon: str | None = None,
|
||||
icon_color: str | None = None,
|
||||
# Legacy parameter aliases
|
||||
@@ -65,6 +66,7 @@ class AutomationStore(BaseSqliteStore[Automation]):
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
tags=tags or [],
|
||||
actions=actions or [],
|
||||
icon=icon or "",
|
||||
icon_color=icon_color or "",
|
||||
)
|
||||
@@ -85,6 +87,7 @@ class AutomationStore(BaseSqliteStore[Automation]):
|
||||
deactivation_mode: str | None = None,
|
||||
deactivation_scene_preset_id: str = "__unset__",
|
||||
tags: List[str] | None = None,
|
||||
actions: List[Action] | None = None,
|
||||
icon: str | None = None,
|
||||
icon_color: str | None = None,
|
||||
# Legacy parameter aliases
|
||||
@@ -118,6 +121,8 @@ class AutomationStore(BaseSqliteStore[Automation]):
|
||||
)
|
||||
if tags is not None:
|
||||
automation.tags = tags
|
||||
if actions is not None:
|
||||
automation.actions = actions
|
||||
if icon is not None:
|
||||
automation.icon = icon or ""
|
||||
if icon_color is not None:
|
||||
|
||||
@@ -81,12 +81,14 @@ class Device:
|
||||
hue_username: str = "",
|
||||
hue_client_key: str = "",
|
||||
hue_entertainment_group_id: str = "",
|
||||
hue_gradient_mode: bool = True,
|
||||
# Yeelight fields
|
||||
yeelight_min_interval_ms: int = 500,
|
||||
# WiZ fields
|
||||
wiz_min_interval_ms: int = 50,
|
||||
# LIFX fields
|
||||
lifx_min_interval_ms: int = 50,
|
||||
lifx_per_zone: bool = False,
|
||||
# Govee fields
|
||||
govee_min_interval_ms: int = 50,
|
||||
# OPC fields
|
||||
@@ -142,9 +144,11 @@ class Device:
|
||||
self.hue_username = hue_username
|
||||
self.hue_client_key = hue_client_key
|
||||
self.hue_entertainment_group_id = hue_entertainment_group_id
|
||||
self.hue_gradient_mode = hue_gradient_mode
|
||||
self.yeelight_min_interval_ms = yeelight_min_interval_ms
|
||||
self.wiz_min_interval_ms = wiz_min_interval_ms
|
||||
self.lifx_min_interval_ms = lifx_min_interval_ms
|
||||
self.lifx_per_zone = lifx_per_zone
|
||||
self.govee_min_interval_ms = govee_min_interval_ms
|
||||
self.opc_channel = opc_channel
|
||||
self.nanoleaf_token = nanoleaf_token
|
||||
@@ -241,6 +245,7 @@ class Device:
|
||||
hue_username=self.hue_username,
|
||||
hue_client_key=self.hue_client_key,
|
||||
hue_entertainment_group_id=self.hue_entertainment_group_id,
|
||||
hue_gradient_mode=self.hue_gradient_mode,
|
||||
)
|
||||
if dt == "yeelight":
|
||||
return YeelightConfig(
|
||||
@@ -256,6 +261,7 @@ class Device:
|
||||
return LIFXConfig(
|
||||
**base,
|
||||
lifx_min_interval_ms=self.lifx_min_interval_ms,
|
||||
lifx_per_zone=self.lifx_per_zone,
|
||||
)
|
||||
if dt == "govee":
|
||||
return GoveeConfig(
|
||||
@@ -352,12 +358,17 @@ class Device:
|
||||
d["hue_client_key"] = _enc(self.hue_client_key)
|
||||
if self.hue_entertainment_group_id:
|
||||
d["hue_entertainment_group_id"] = self.hue_entertainment_group_id
|
||||
# Gradient mode defaults ON — only persist the opt-out.
|
||||
if not self.hue_gradient_mode:
|
||||
d["hue_gradient_mode"] = False
|
||||
if self.yeelight_min_interval_ms != 500:
|
||||
d["yeelight_min_interval_ms"] = self.yeelight_min_interval_ms
|
||||
if self.wiz_min_interval_ms != 50:
|
||||
d["wiz_min_interval_ms"] = self.wiz_min_interval_ms
|
||||
if self.lifx_min_interval_ms != 50:
|
||||
d["lifx_min_interval_ms"] = self.lifx_min_interval_ms
|
||||
if self.lifx_per_zone:
|
||||
d["lifx_per_zone"] = True
|
||||
if self.govee_min_interval_ms != 50:
|
||||
d["govee_min_interval_ms"] = self.govee_min_interval_ms
|
||||
if self.opc_channel:
|
||||
@@ -424,9 +435,11 @@ class Device:
|
||||
hue_username=_dec(data.get("hue_username", "")),
|
||||
hue_client_key=_dec(data.get("hue_client_key", "")),
|
||||
hue_entertainment_group_id=data.get("hue_entertainment_group_id", ""),
|
||||
hue_gradient_mode=bool(data.get("hue_gradient_mode", True)),
|
||||
yeelight_min_interval_ms=data.get("yeelight_min_interval_ms", 500),
|
||||
wiz_min_interval_ms=data.get("wiz_min_interval_ms", 50),
|
||||
lifx_min_interval_ms=data.get("lifx_min_interval_ms", 50),
|
||||
lifx_per_zone=bool(data.get("lifx_per_zone", False)),
|
||||
govee_min_interval_ms=data.get("govee_min_interval_ms", 50),
|
||||
opc_channel=data.get("opc_channel", 0),
|
||||
nanoleaf_token=_dec(data.get("nanoleaf_token", "")),
|
||||
@@ -479,9 +492,11 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset(
|
||||
"hue_username",
|
||||
"hue_client_key",
|
||||
"hue_entertainment_group_id",
|
||||
"hue_gradient_mode",
|
||||
"yeelight_min_interval_ms",
|
||||
"wiz_min_interval_ms",
|
||||
"lifx_min_interval_ms",
|
||||
"lifx_per_zone",
|
||||
"govee_min_interval_ms",
|
||||
"opc_channel",
|
||||
"nanoleaf_token",
|
||||
@@ -587,9 +602,11 @@ class DeviceStore(BaseSqliteStore[Device]):
|
||||
hue_username: str = "",
|
||||
hue_client_key: str = "",
|
||||
hue_entertainment_group_id: str = "",
|
||||
hue_gradient_mode: bool = True,
|
||||
yeelight_min_interval_ms: int = 500,
|
||||
wiz_min_interval_ms: int = 50,
|
||||
lifx_min_interval_ms: int = 50,
|
||||
lifx_per_zone: bool = False,
|
||||
govee_min_interval_ms: int = 50,
|
||||
opc_channel: int = 0,
|
||||
nanoleaf_token: str = "",
|
||||
@@ -638,9 +655,11 @@ class DeviceStore(BaseSqliteStore[Device]):
|
||||
hue_username=hue_username,
|
||||
hue_client_key=hue_client_key,
|
||||
hue_entertainment_group_id=hue_entertainment_group_id,
|
||||
hue_gradient_mode=hue_gradient_mode,
|
||||
yeelight_min_interval_ms=yeelight_min_interval_ms,
|
||||
wiz_min_interval_ms=wiz_min_interval_ms,
|
||||
lifx_min_interval_ms=lifx_min_interval_ms,
|
||||
lifx_per_zone=lifx_per_zone,
|
||||
govee_min_interval_ms=govee_min_interval_ms,
|
||||
opc_channel=opc_channel,
|
||||
nanoleaf_token=nanoleaf_token,
|
||||
|
||||
@@ -48,6 +48,10 @@ class MQTTSource:
|
||||
password: str = ""
|
||||
client_id: str = "ledgrab"
|
||||
base_topic: str = "ledgrab"
|
||||
# Home Assistant MQTT auto-discovery: publish homeassistant/.../config so
|
||||
# MQTT-only HA installs get LedGrab entities automatically.
|
||||
publish_ha_discovery: bool = False
|
||||
discovery_prefix: str = "homeassistant"
|
||||
description: str | None = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
icon: str = ""
|
||||
@@ -66,6 +70,8 @@ class MQTTSource:
|
||||
"password": stored_password,
|
||||
"client_id": self.client_id,
|
||||
"base_topic": self.base_topic,
|
||||
"publish_ha_discovery": self.publish_ha_discovery,
|
||||
"discovery_prefix": self.discovery_prefix,
|
||||
"description": self.description,
|
||||
"tags": self.tags,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
@@ -93,6 +99,8 @@ class MQTTSource:
|
||||
password=password,
|
||||
client_id=data.get("client_id", "ledgrab"),
|
||||
base_topic=data.get("base_topic", "ledgrab"),
|
||||
publish_ha_discovery=bool(data.get("publish_ha_discovery", False)),
|
||||
discovery_prefix=data.get("discovery_prefix", "homeassistant") or "homeassistant",
|
||||
icon=data.get("icon", ""),
|
||||
icon_color=data.get("icon_color", ""),
|
||||
)
|
||||
|
||||
@@ -70,6 +70,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
|
||||
password: str = "",
|
||||
client_id: str = "ledgrab",
|
||||
base_topic: str = "ledgrab",
|
||||
publish_ha_discovery: bool = False,
|
||||
discovery_prefix: str = "homeassistant",
|
||||
description: str | None = None,
|
||||
tags: List[str] | None = None,
|
||||
icon: str | None = None,
|
||||
@@ -94,6 +96,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
|
||||
password=password,
|
||||
client_id=client_id,
|
||||
base_topic=base_topic,
|
||||
publish_ha_discovery=publish_ha_discovery,
|
||||
discovery_prefix=discovery_prefix or "homeassistant",
|
||||
description=description,
|
||||
tags=tags or [],
|
||||
icon=icon or "",
|
||||
@@ -115,6 +119,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
|
||||
password: str | None = None,
|
||||
client_id: str | None = None,
|
||||
base_topic: str | None = None,
|
||||
publish_ha_discovery: bool | None = None,
|
||||
discovery_prefix: str | None = None,
|
||||
description: str | None = None,
|
||||
tags: List[str] | None = None,
|
||||
icon: str | None = None,
|
||||
@@ -136,6 +142,14 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
|
||||
password=password if password is not None else existing.password,
|
||||
client_id=client_id if client_id is not None else existing.client_id,
|
||||
base_topic=base_topic if base_topic is not None else existing.base_topic,
|
||||
publish_ha_discovery=(
|
||||
publish_ha_discovery
|
||||
if publish_ha_discovery is not None
|
||||
else existing.publish_ha_discovery
|
||||
),
|
||||
discovery_prefix=(
|
||||
discovery_prefix if discovery_prefix is not None else existing.discovery_prefix
|
||||
),
|
||||
description=description if description is not None else existing.description,
|
||||
tags=tags if tags is not None else existing.tags,
|
||||
icon=icon if icon is not None else existing.icon,
|
||||
|
||||
@@ -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>
|
||||
<input type="number" id="device-lifx-min-interval" min="0" max="10000" step="10" value="50">
|
||||
</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 -->
|
||||
<div class="form-group" id="device-govee-min-interval-group" style="display: none;">
|
||||
<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>
|
||||
<input type="text" id="device-hue-group-id" placeholder="Entertainment group ID">
|
||||
</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 -->
|
||||
<div class="form-group" id="device-ble-family-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
|
||||
@@ -132,6 +132,48 @@
|
||||
</div>
|
||||
</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>
|
||||
</form>
|
||||
</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>
|
||||
<input type="number" id="settings-lifx-min-interval" min="0" max="10000" step="10" value="50">
|
||||
</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="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>
|
||||
<input type="text" id="mqtt-source-base-topic" value="ledgrab" placeholder="ledgrab">
|
||||
</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>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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"])
|
||||
@@ -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"])
|
||||
@@ -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"])
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user