diff --git a/README.md b/README.md index cbe16e2..480ccfb 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ This integration brings that engine into Home Assistant as a hub. Master light, profiles, layouts, live controls, audio-reactive primitives, and full device topology become first-class entities you can wire into automations, scripts, and dashboards. +The companion Lovelace card is [hyper-light-card](https://github.com/hyperb1iss/hyper-light-card), +built directly on this integration's catalog, live controls, and effect cover art. + ## 🌈 Features | | | @@ -214,6 +217,9 @@ brief beats actually trigger automations instead of bouncing too fast for HA to ### Live tweak from a dashboard +[hyper-light-card](https://github.com/hyperb1iss/hyper-light-card) is the dedicated Lovelace +card if you want a polished UI. For a stock entities card: + ```yaml type: entities title: Hypercolor @@ -307,7 +313,7 @@ code. Driver work, spatial topology, and effect authoring all live upstream in - 💜 [Hypercolor](https://github.com/hyperb1iss/hypercolor) — the engine and daemon - 🌌 [SignalRGB Home Assistant](https://github.com/hyperb1iss/signalrgb-homeassistant) — sister integration for SignalRGB on Windows -- 🪄 [hyper-light-card](https://github.com/hyperb1iss/hyper-light-card) — Lovelace card built around effect catalogs +- 🪄 [hyper-light-card](https://github.com/hyperb1iss/hyper-light-card) — companion Lovelace card for this integration ## 📄 License diff --git a/custom_components/hypercolor/__init__.py b/custom_components/hypercolor/__init__.py index d11dfcd..abd608b 100644 --- a/custom_components/hypercolor/__init__.py +++ b/custom_components/hypercolor/__init__.py @@ -127,6 +127,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HypercolorConfigEntry) - _register_child_devices(hass, entry, devices.data) _cleanup_opted_out_entities(hass, entry, devices.data) + _cleanup_stale_zone_entities(hass, entry, state.data) reconcile_interval_s = int( entry.options.get(CONF_RECONCILE_INTERVAL_S, OPTIONS_DEFAULTS[CONF_RECONCILE_INTERVAL_S]) @@ -245,6 +246,32 @@ def _cleanup_opted_out_entities( runtime.per_device_entity_ids.discard(slug) +def _cleanup_stale_zone_entities( + hass: HomeAssistant, + entry: HypercolorConfigEntry, + state: Any, +) -> None: + """Prune zone lights whose zones no longer exist. + + Zone ids are per-scene UUIDs, so zone churn would otherwise grow the + registry without bound. Pruning happens at setup only — mid-session + scene switches leave entities unavailable rather than yanking them + out from under dashboards. + """ + entity_registry = er.async_get(hass) + runtime = entry.runtime_data + current_zone_ids = { + str(read_field(zone, "id")) for zone in read_field(state, "zones", []) or [] + } + prefix = f"{runtime.server.instance_id}:zone:" + for registry_entry in er.async_entries_for_config_entry(entity_registry, entry.entry_id): + if not registry_entry.unique_id.startswith(prefix): + continue + zone_id = registry_entry.unique_id.removeprefix(prefix) + if zone_id not in current_zone_ids: + entity_registry.async_remove(registry_entry.entity_id) + + def _domain_for_unique_id(unique_id: str) -> str: if unique_id.endswith(":light"): return "light" diff --git a/custom_components/hypercolor/coordinator.py b/custom_components/hypercolor/coordinator.py index 50b6313..c113cef 100644 --- a/custom_components/hypercolor/coordinator.py +++ b/custom_components/hypercolor/coordinator.py @@ -89,6 +89,7 @@ async def load_state(client: Any) -> dict[str, Any]: active_effect_id = read_field(active_effect, "id", read_field(status, "active_effect")) active_effect_name = read_field(active_effect, "name", read_field(status, "active_effect")) active_effect_cover_image_url = _active_effect_cover_image_url(client, active_effect) + zones = read_field(active_scene, "groups", []) or [] return { "status": status, "active_effect_detail": active_effect, @@ -100,7 +101,10 @@ async def load_state(client: Any) -> dict[str, Any]: "active_effect_cover_image_url": active_effect_cover_image_url, "active_preset": read_field(active_effect, "active_preset_id"), "active_scene": read_field(active_scene, "id"), + "active_scene_name": read_field(active_scene, "name"), "active_layout": read_field(active_layout, "id"), + "zones": list(zones), + "groups_revision": read_field(active_scene, "groups_revision", 0), "global_brightness": read_field(status, "global_brightness"), "brightness": read_field(status, "brightness"), "device_count": read_field(status, "device_count"), @@ -258,7 +262,19 @@ def _handle_ws_message( _set_coordinator_data(runtime, "audio", current) elif message_name == "EventMessage": event = str(read_field(message, "event", "")) - if event.startswith(("effect", "scene", "profile", "layout", "device")): + if event.startswith( + ( + "effect", + "scene", + "active_scene", + "render_group", + "layer", + "profile", + "layout", + "device", + "brightness", + ) + ): _request_refresh(runtime, "state", "catalog", "devices") diff --git a/custom_components/hypercolor/entity.py b/custom_components/hypercolor/entity.py index b022159..528a518 100644 --- a/custom_components/hypercolor/entity.py +++ b/custom_components/hypercolor/entity.py @@ -67,3 +67,18 @@ def read_field(value: Any, field: str, default: Any = None) -> Any: if isinstance(value, dict): return value.get(field, default) return getattr(value, field, default) + + +def control_scalar(value: Any) -> Any: + """Unwrap a daemon control value to its scalar. + + The daemon serializes control values externally tagged, e.g. + ``{"float": 12.0}`` or ``{"enum": "Palette Blend"}``; older payloads + and the control patch path use bare scalars. Colors, gradients, and + rects stay as-is. + """ + if isinstance(value, dict) and len(value) == 1: + inner = next(iter(value.values())) + if isinstance(inner, (int, float, str, bool)): + return inner + return value diff --git a/custom_components/hypercolor/light.py b/custom_components/hypercolor/light.py index 237f5ee..5bd33f2 100644 --- a/custom_components/hypercolor/light.py +++ b/custom_components/hypercolor/light.py @@ -11,6 +11,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -47,6 +48,25 @@ async def async_setup_entry( ) async_add_entities(entities) + state = entry.runtime_data.coordinators["state"] + known_zone_ids: set[str] = set() + + def _sync_zone_entities() -> None: + fresh = [ + zone + for zone in renderable_zones(state.data) + if str(read_field(zone, "id")) not in known_zone_ids + ] + if not fresh: + return + known_zone_ids.update(str(read_field(zone, "id")) for zone in fresh) + async_add_entities( + HypercolorZoneLight(entry, str(read_field(zone, "id"))) for zone in fresh + ) + + _sync_zone_entities() + entry.async_on_unload(state.async_add_listener(_sync_zone_entities)) + class HypercolorMasterLight(CoordinatorEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS @@ -160,6 +180,125 @@ def _device(self) -> Any | None: return None +class HypercolorZoneLight(CoordinatorEntity, LightEntity): + """One zone (render group) of the active scene. + + Zones are scene-scoped: when the active scene changes, entities for + zones that no longer exist go unavailable, and new zones appear. + """ + + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_has_entity_name = True + _attr_supported_features = LightEntityFeature.EFFECT + + def __init__(self, entry: ConfigEntry[HypercolorRuntimeData], zone_id: str) -> None: + runtime = entry.runtime_data + super().__init__(runtime.coordinators["state"]) + self._entry = entry + self._zone_id = zone_id + self._catalog = runtime.coordinators["catalog"] + self._attr_device_info = hub_device_info(runtime, entry.data) + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + self._attr_unique_id = f"{runtime.server.instance_id}:zone:{zone_id}" + + @property + def available(self) -> bool: + return super().available and self._zone is not None + + @property + def name(self) -> str | None: + if zone := self._zone: + return str(read_field(zone, "name", self._zone_id)) + return f"Zone {self._zone_id}" + + @property + def brightness(self) -> int | None: + if zone := self._zone: + value = read_field(zone, "brightness") + if value is not None: + return max(0, min(255, round(float(value) * 255))) + return None + + @property + def is_on(self) -> bool | None: + if zone := self._zone: + return bool(read_field(zone, "enabled", True)) + return None + + @property + def effect(self) -> str | None: + zone = self._zone + if zone is None: + return None + effect_id = read_field(zone, "effect_id") + if not effect_id: + return None + return effect_name_for_id(self._catalog.data, str(effect_id)) + + @property + def effect_list(self) -> list[str] | None: + return effect_names(self._catalog.data) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + zone = self._zone + layout = read_field(zone, "layout") + outputs = read_field(layout, "zones", []) or [] + return { + "zone_id": self._zone_id, + "role": read_field(zone, "role"), + "effect_id": read_field(zone, "effect_id"), + "preset_id": read_field(zone, "preset_id"), + "output_count": len(outputs) if isinstance(outputs, list) else None, + "scene_id": read_field(self.coordinator.data, "active_scene"), + } + + async def async_turn_on(self, **kwargs: Any) -> None: + client = self._entry.runtime_data.client + scene_id = self._scene_id() + updates: dict[str, Any] = {} + if ATTR_BRIGHTNESS in kwargs: + updates["brightness"] = round(int(kwargs[ATTR_BRIGHTNESS]) / 255, 4) + if not self.is_on: + updates["enabled"] = True + if updates: + await client.update_zone(scene_id, self._zone_id, **updates) + + effect = kwargs.get(ATTR_EFFECT) + if effect: + await client.apply_effect( + effect_id_for_name(self._catalog.data, str(effect)), + render_group=self._zone_id, + ) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + client = self._entry.runtime_data.client + await client.update_zone(self._scene_id(), self._zone_id, enabled=False) + await self.coordinator.async_request_refresh() + + def _scene_id(self) -> str: + scene_id = read_field(self.coordinator.data, "active_scene") + if not scene_id: + raise HomeAssistantError("No active Hypercolor scene") + return str(scene_id) + + @property + def _zone(self) -> Any | None: + for zone in renderable_zones(self.coordinator.data): + if str(read_field(zone, "id")) == self._zone_id: + return zone + return None + + +def renderable_zones(state: Any) -> list[Any]: + """Zones of the active scene that render to LEDs (not display faces).""" + zones = read_field(state, "zones", []) or [] + if not isinstance(zones, list): + return [] + return [zone for zone in zones if read_field(zone, "role") != "display"] + + def effect_names(catalog: Any) -> list[str] | None: effects = _catalog_effects(catalog) if effects is None: @@ -177,6 +316,16 @@ def effect_id_for_name(catalog: Any, name: str) -> str: return name +def effect_name_for_id(catalog: Any, effect_id: str) -> str: + effects = _catalog_effects(catalog) + if effects is None: + return effect_id + for effect in effects: + if item_id(effect) == effect_id: + return item_name(effect) + return effect_id + + def _catalog_effects(catalog: Any) -> list[Any] | None: effects = catalog_items(catalog, "effects") return effects or None diff --git a/custom_components/hypercolor/manifest.json b/custom_components/hypercolor/manifest.json index b54390e..5f337a7 100644 --- a/custom_components/hypercolor/manifest.json +++ b/custom_components/hypercolor/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/hyperb1iss/hypercolor-hass/issues", "quality_scale": "bronze", - "requirements": ["hypercolor>=0.1.0,<0.2.0"], - "version": "0.1.0", + "requirements": ["hypercolor>=0.2.0,<0.3.0"], + "version": "0.2.0", "zeroconf": [{ "type": "_hypercolor._tcp.local." }] } diff --git a/custom_components/hypercolor/number.py b/custom_components/hypercolor/number.py index 6fa6526..f4801ca 100644 --- a/custom_components/hypercolor/number.py +++ b/custom_components/hypercolor/number.py @@ -10,7 +10,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_LIVE_CONTROLS_ENABLED, LIVE_CONTROL_IDS, OPTIONS_DEFAULTS -from .entity import hub_device_info, read_field +from .entity import control_scalar, hub_device_info, read_field from .runtime_data import HypercolorRuntimeData _DEFAULTS = { @@ -81,9 +81,11 @@ def native_value(self) -> float | None: return None active = read_field(self.coordinator.data, "active_effect_detail") values = read_field(active, "control_values", {}) - value = read_field(values, read_field(control, "id")) + value = control_scalar(read_field(values, read_field(control, "id"))) if value is None: - value = read_field(control, "value", read_field(control, "default")) + value = control_scalar(read_field(control, "value", read_field(control, "default"))) + if value is None: + value = control_scalar(read_field(control, "default_value")) return float(value) if isinstance(value, (int, float)) else None async def async_set_native_value(self, value: float) -> None: @@ -102,6 +104,7 @@ def _control(self) -> Any | None: names = { _normalize(str(read_field(control, "id", ""))), _normalize(str(read_field(control, "label", ""))), + _normalize(str(read_field(control, "name", ""))), } if _normalize(self._control_id) in names: return control diff --git a/custom_components/hypercolor/services.py b/custom_components/hypercolor/services.py index 9cecece..d0057b4 100644 --- a/custom_components/hypercolor/services.py +++ b/custom_components/hypercolor/services.py @@ -21,7 +21,11 @@ SERVICE_SET_COLOR = "set_color" SERVICE_SET_CONTROL = "set_control" SERVICE_ACTIVATE_SCENE = "activate_scene" +SERVICE_DEACTIVATE_SCENE = "deactivate_scene" SERVICE_CREATE_SCENE = "create_scene" +SERVICE_SET_ZONE = "set_zone" +SERVICE_LIST_ZONES = "list_zones" +SERVICE_SET_UNASSIGNED_BEHAVIOR = "set_unassigned_behavior" SERVICE_ACTIVATE_PROFILE = "activate_profile" SERVICE_SAVE_PROFILE = "save_profile" SERVICE_APPLY_LAYOUT = "apply_layout" @@ -46,6 +50,7 @@ def async_setup_services(hass: HomeAssistant) -> None: vol.Optional("controls"): dict, vol.Optional("transition"): dict, vol.Optional("preset_id"): cv.string, + vol.Optional("zone_id"): cv.string, } ), ) @@ -74,6 +79,46 @@ def async_setup_services(hass: HomeAssistant) -> None: _activate_scene, _schema({vol.Required("scene_id"): cv.string}), ) + _register( + hass, + SERVICE_DEACTIVATE_SCENE, + _deactivate_scene, + _schema({}), + ) + _register( + hass, + SERVICE_SET_ZONE, + _set_zone, + _schema( + { + vol.Required("zone_id"): cv.string, + vol.Optional("scene_id"): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional("brightness"): vol.All(int, vol.Range(min=0, max=100)), + vol.Optional("enabled"): bool, + vol.Optional("make_primary"): bool, + } + ), + ) + _register( + hass, + SERVICE_LIST_ZONES, + _list_zones, + _schema({vol.Optional("scene_id"): cv.string}), + supports_response=SupportsResponse.ONLY, + ) + _register( + hass, + SERVICE_SET_UNASSIGNED_BEHAVIOR, + _set_unassigned_behavior, + _schema( + { + vol.Required("behavior"): vol.In(["off", "hold", "fallback"]), + vol.Optional("fallback_zone_id"): cv.string, + vol.Optional("scene_id"): cv.string, + } + ), + ) _register( hass, SERVICE_CREATE_SCENE, @@ -224,8 +269,9 @@ def _schema(fields: dict[Any, Any]) -> vol.Schema: async def _apply_effect(call: ServiceCall) -> None: entry = _entry(call.hass, call) + zone_id = call.data.get("zone_id") if preset_id := call.data.get("preset_id"): - await entry.runtime_data.client.apply_preset(preset_id) + await entry.runtime_data.client.apply_preset(preset_id, render_group=zone_id) return effect_id = call.data.get("effect_id") if effect_id is None: @@ -234,6 +280,7 @@ async def _apply_effect(call: ServiceCall) -> None: effect_id, controls=call.data.get("controls"), transition=call.data.get("transition"), + render_group=zone_id, ) @@ -258,6 +305,67 @@ async def _activate_scene(call: ServiceCall) -> None: await entry.runtime_data.client.activate_scene(call.data["scene_id"]) +async def _deactivate_scene(call: ServiceCall) -> None: + entry = _entry(call.hass, call) + await entry.runtime_data.client.deactivate_scene() + + +async def _set_zone(call: ServiceCall) -> None: + entry = _entry(call.hass, call) + client = entry.runtime_data.client + scene_id = await _resolve_scene_id(entry, call.data.get("scene_id")) + updates: dict[str, Any] = {} + if (name := call.data.get(CONF_NAME)) is not None: + updates["name"] = name + if (brightness := call.data.get("brightness")) is not None: + updates["brightness"] = round(int(brightness) / 100, 4) + if (enabled := call.data.get("enabled")) is not None: + updates["enabled"] = enabled + if call.data.get("make_primary"): + updates["make_primary"] = True + if not updates: + raise HomeAssistantError("set_zone needs at least one field to change") + await client.update_zone(scene_id, call.data["zone_id"], **updates) + + +async def _list_zones(call: ServiceCall) -> dict[str, Any]: + entry = _entry(call.hass, call) + client = entry.runtime_data.client + scene_id = await _resolve_scene_id(entry, call.data.get("scene_id")) + result = await client.get_zones(scene_id) + return { + "scene_id": scene_id, + "groups_revision": _field(result, "groups_revision"), + "zones": [_jsonable(zone) for zone in _field(result, "items") or []], + } + + +async def _set_unassigned_behavior(call: ServiceCall) -> None: + entry = _entry(call.hass, call) + client = entry.runtime_data.client + scene_id = await _resolve_scene_id(entry, call.data.get("scene_id")) + behavior: str | dict[str, Any] = call.data["behavior"] + if behavior == "fallback": + fallback_zone_id = call.data.get("fallback_zone_id") + if not fallback_zone_id: + raise HomeAssistantError("fallback behavior requires fallback_zone_id") + behavior = {"fallback": fallback_zone_id} + await client.set_unassigned_behavior(scene_id, behavior) + + +async def _resolve_scene_id( + entry: ConfigEntry[HypercolorRuntimeData], + scene_id: str | None, +) -> str: + if scene_id: + return scene_id + active = await entry.runtime_data.client.get_active_scene() + resolved = _field(active, "id") + if not resolved: + raise HomeAssistantError("No active Hypercolor scene") + return str(resolved) + + async def _create_scene(call: ServiceCall) -> dict[str, Any]: entry = _entry(call.hass, call) scene = await entry.runtime_data.client.create_scene( diff --git a/custom_components/hypercolor/services.yaml b/custom_components/hypercolor/services.yaml index a915c36..449dd8d 100644 --- a/custom_components/hypercolor/services.yaml +++ b/custom_components/hypercolor/services.yaml @@ -15,6 +15,9 @@ apply_effect: preset_id: selector: text: + zone_id: + selector: + text: controls: selector: object: @@ -79,6 +82,77 @@ activate_scene: selector: text: +deactivate_scene: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: hypercolor + +set_zone: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: hypercolor + zone_id: + required: true + selector: + text: + scene_id: + selector: + text: + name: + selector: + text: + brightness: + selector: + number: + min: 0 + max: 100 + unit_of_measurement: "%" + enabled: + selector: + boolean: + make_primary: + selector: + boolean: + +list_zones: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: hypercolor + scene_id: + selector: + text: + +set_unassigned_behavior: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: hypercolor + behavior: + required: true + selector: + select: + options: + - "off" + - hold + - fallback + fallback_zone_id: + selector: + text: + scene_id: + selector: + text: + create_scene: fields: config_entry_id: diff --git a/pyproject.toml b/pyproject.toml index 1693b26..9edd13b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "hypercolor-hass" -version = "0.1.0" +version = "0.2.0" description = "Home Assistant integration for Hypercolor RGB lighting orchestration" readme = "README.md" license = "Apache-2.0" @@ -23,7 +23,7 @@ classifiers = [ "Private :: Do Not Upload", ] dependencies = [ - "hypercolor>=0.1.0,<0.2.0", + "hypercolor>=0.2.0,<0.3.0", ] [dependency-groups] diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index c864383..ba2f866 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -26,7 +26,17 @@ async def test_load_state_flattens_status_and_active_resources() -> None: cover_image_url="/api/v1/effects/aurora/cover", ) ), - get_active_scene=_async_value(SimpleNamespace(id="scene-1")), + get_active_scene=_async_value( + SimpleNamespace( + id="scene-1", + name="Battlestation", + groups=[ + SimpleNamespace(id="zone-1", name="Desk", role="primary"), + SimpleNamespace(id="zone-2", name="LCD", role="display"), + ], + groups_revision=7, + ) + ), get_active_layout=_async_value(SimpleNamespace(id="layout-1")), root_url="http://hyperia.test:9420", active_effect_cover_image_url=lambda: ( @@ -45,7 +55,10 @@ async def test_load_state_flattens_status_and_active_resources() -> None: assert state["active_preset"] == "soft" assert state["global_brightness"] == 66 assert state["active_scene"] == "scene-1" + assert state["active_scene_name"] == "Battlestation" assert state["active_layout"] == "layout-1" + assert [zone.id for zone in state["zones"]] == ["zone-1", "zone-2"] + assert state["groups_revision"] == 7 async def test_load_state_resolves_cover_image_without_client_helper() -> None: diff --git a/tests/test_hass_e2e.py b/tests/test_hass_e2e.py index 0023363..45a06bd 100644 --- a/tests/test_hass_e2e.py +++ b/tests/test_hass_e2e.py @@ -10,6 +10,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.hypercolor.const import ( @@ -38,6 +39,7 @@ async def fake_daemon( app.router.add_put("/api/v1/settings/brightness", daemon.set_brightness) app.router.add_put("/api/v1/devices/{device_id}", daemon.update_device) app.router.add_post("/api/v1/effects/stop", daemon.stop_effect) + app.router.add_patch("/api/v1/scenes/{scene_id}/zones/{zone_id}", daemon.update_zone) app.router.add_route("*", "/api/v1/{tail:.*}", daemon.handle_api) runner = web.AppRunner(app) await runner.setup() @@ -108,6 +110,45 @@ async def test_config_entry_boots_and_controls_fake_daemon( "effect_id": "solid_color", "controls": {"color": "#80ff00"}, } + + zone = _first_state( + hass, "light", lambda state: state.attributes.get("zone_id") == "zone-primary" + ) + assert zone.state == "on" + assert zone.attributes["role"] == "primary" + assert zone.attributes["scene_id"] == "default" + assert zone.attributes["output_count"] == 1 + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": zone.entity_id, "brightness": 128, "effect": "Rainbow"}, + blocking=True, + ) + + assert fake_daemon.zone_updates[-1] == { + "scene_id": "default", + "zone_id": "zone-primary", + "brightness": 0.502, + } + assert fake_daemon.applied_effects[-1] == { + "effect_id": "rainbow", + "controls": {}, + "render_group": "zone-primary", + } + + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": zone.entity_id}, + blocking=True, + ) + + assert fake_daemon.zone_updates[-1] == { + "scene_id": "default", + "zone_id": "zone-primary", + "enabled": False, + } assert await hass.config_entries.async_unload(entry.entry_id) @@ -133,11 +174,36 @@ async def test_real_daemon_config_entry_boots( assert await hass.config_entries.async_unload(entry.entry_id) +async def test_stale_zone_entities_are_pruned_at_setup( + hass: HomeAssistant, + enable_custom_integrations: None, + fake_daemon: _FakeHypercolorDaemon, +) -> None: + entry = await _setup_entry(hass, port=fake_daemon.port, setup=False) + entity_registry = er.async_get(hass) + stale = entity_registry.async_get_or_create( + "light", + DOMAIN, + "srv_e2e:zone:zone-deleted-long-ago", + config_entry=entry, + ) + + await _activate_entry(hass, entry) + + assert entity_registry.async_get(stale.entity_id) is None + assert ( + entity_registry.async_get_entity_id("light", DOMAIN, "srv_e2e:zone:zone-primary") + is not None + ) + assert await hass.config_entries.async_unload(entry.entry_id) + + async def _setup_entry( hass: HomeAssistant, *, host: str = "127.0.0.1", port: int, + setup: bool = True, ) -> MockConfigEntry: entry = MockConfigEntry( domain=DOMAIN, @@ -158,11 +224,16 @@ async def _setup_entry( }, ) entry.add_to_hass(hass) + if setup: + await _activate_entry(hass, entry) + return entry + + +async def _activate_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - return entry def _first_state( @@ -186,6 +257,7 @@ def __init__(self) -> None: self.control_updates: list[dict[str, Any]] = [] self.applied_effects: list[dict[str, Any]] = [] self.device_updates: list[dict[str, Any]] = [] + self.zone_updates: list[dict[str, Any]] = [] async def websocket(self, request: web.Request) -> web.WebSocketResponse: ws = web.WebSocketResponse(protocols=("hypercolor-v1",)) @@ -219,7 +291,7 @@ async def handle_api(self, request: web.Request) -> web.Response: "GET /effects/active": self._active_effect, "GET /devices": lambda: self._items([self._device()]), "GET /scenes": lambda: self._items([self._scene()]), - "GET /scenes/active": self._scene, + "GET /scenes/active": self._active_scene, "GET /profiles": lambda: self._items([self._profile()]), "GET /layouts": lambda: self._items([self._layout_summary()]), "GET /layouts/active": self._layout, @@ -235,7 +307,10 @@ async def apply_effect(self, request: web.Request) -> web.Response: self.active_effect_id = effect_id controls = dict(body.get("controls") or {}) self.control_values.update(controls) - self.applied_effects.append({"effect_id": effect_id, "controls": controls}) + applied = {"effect_id": effect_id, "controls": controls} + if body.get("render_group"): + applied["render_group"] = body["render_group"] + self.applied_effects.append(applied) return self._ok( { "effect": {"id": effect_id, "name": self._effect_name(effect_id)}, @@ -264,6 +339,19 @@ async def stop_effect(self, request: web.Request) -> web.Response: self.active_effect_id = "" return self._ok({"stopped": True}) + async def update_zone(self, request: web.Request) -> web.Response: + body = await _json_body(request) + self.zone_updates.append( + { + "scene_id": request.match_info["scene_id"], + "zone_id": request.match_info["zone_id"], + **body, + } + ) + zone = self._active_scene()["groups"][0] + zone.update(body) + return self._ok({"zone": zone, "groups_revision": 3}) + def _server(self) -> dict[str, Any]: return { "instance_id": "srv_e2e", @@ -333,27 +421,32 @@ def _active_effect(self) -> dict[str, Any]: "controls": [ { "id": "speed", - "label": "Speed", - "type": "number", + "name": "Speed", + "kind": "number", + "control_type": "slider", + "default_value": {"float": 50.0}, "min": 0, "max": 100, "step": 1, - "default": 50, - "value": self.control_values.get("speed"), }, { "id": "brightness", - "label": "Brightness", - "type": "number", + "name": "Brightness", + "kind": "number", + "control_type": "slider", + "default_value": {"float": 80.0}, "min": 0, "max": 100, "step": 1, - "default": 80, - "value": self.control_values.get("brightness"), }, ], - "control_values": self.control_values, + "control_values": { + key: {"float": float(value)} if isinstance(value, (int, float)) else value + for key, value in self.control_values.items() + }, "active_preset_id": "preset-rainbow", + "render_group_id": "zone-primary", + "controls_version": 1, } if self.active_effect_id: effect["cover_image_url"] = f"/api/v1/effects/{self.active_effect_id}/cover" @@ -399,6 +492,65 @@ def _device(self) -> dict[str, Any]: def _scene() -> dict[str, Any]: return {"id": "default", "name": "Default", "description": None, "enabled": True} + def _active_scene(self) -> dict[str, Any]: + return { + "id": "default", + "name": "Default", + "description": None, + "enabled": True, + "priority": 50, + "kind": "ephemeral", + "mutation_mode": "live", + "groups": [ + { + "id": "zone-primary", + "name": "Default zone", + "description": None, + "effect_id": self.active_effect_id, + "controls": {}, + "preset_id": None, + "layers": [], + "layout": { + "id": "zone-layout", + "name": "Default zone", + "description": None, + "canvas_width": 640, + "canvas_height": 480, + "zones": [ + { + "id": "wled-studio:zone_0", + "name": "WLED - Studio", + "device_id": "wled-studio", + "zone_name": "zone_0", + "position": {"x": 0.5, "y": 0.5}, + "size": {"x": 1.0, "y": 1.0}, + "rotation": 0.0, + "orientation": None, + "topology": { + "type": "strip", + "count": 275, + "direction": "left_to_right", + }, + "sampling_mode": None, + "edge_behavior": None, + "shape": None, + "shape_preset": None, + } + ], + "version": 1, + }, + "brightness": 1.0, + "enabled": True, + "color": None, + "role": "primary", + "controls_version": 1, + "layers_version": 0, + } + ], + "groups_revision": 2, + "unassigned_behavior": "off", + } + @staticmethod def _profile() -> dict[str, Any]: return { diff --git a/tests/test_light.py b/tests/test_light.py index 3633d6c..6156f0d 100644 --- a/tests/test_light.py +++ b/tests/test_light.py @@ -1,6 +1,11 @@ from __future__ import annotations -from custom_components.hypercolor.light import effect_id_for_name, effect_names +from custom_components.hypercolor.light import ( + effect_id_for_name, + effect_name_for_id, + effect_names, + renderable_zones, +) def test_effect_names_prefer_display_name() -> None: @@ -23,3 +28,30 @@ def test_effect_id_for_name_maps_home_assistant_choice_to_daemon_id() -> None: def test_effect_id_for_name_preserves_unknown_choice() -> None: assert effect_id_for_name([], "custom") == "custom" + + +def test_effect_name_for_id_maps_back_to_display_name() -> None: + catalog = [{"id": "neon_rain", "name": "Neon Rain"}] + + assert effect_name_for_id(catalog, "neon_rain") == "Neon Rain" + assert effect_name_for_id(catalog, "unknown") == "unknown" + + +def test_renderable_zones_excludes_display_faces() -> None: + state = { + "zones": [ + {"id": "zone-1", "name": "Desk", "role": "primary"}, + {"id": "zone-2", "name": "Room", "role": "custom"}, + {"id": "zone-3", "name": "LCD", "role": "display"}, + ] + } + + zones = renderable_zones(state) + + assert [zone["id"] for zone in zones] == ["zone-1", "zone-2"] + + +def test_renderable_zones_tolerates_missing_state() -> None: + assert renderable_zones(None) == [] + assert renderable_zones({}) == [] + assert renderable_zones({"zones": "bogus"}) == [] diff --git a/tests/test_services.py b/tests/test_services.py index 9f68d22..8b3b385 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -13,9 +13,12 @@ CONF_CONFIG_ENTRY_ID, _apply_effect, _list_presets, + _list_zones, _save_preset, _schema, _set_color, + _set_unassigned_behavior, + _set_zone, _upload_effect, ) @@ -33,7 +36,59 @@ async def test_apply_effect_can_route_to_preset() -> None: await _apply_effect(call) - assert client.calls == [("apply_preset", ("preset-1",), {})] + assert client.calls == [("apply_preset", ("preset-1",), {"render_group": None})] + + +async def test_apply_effect_targets_zone() -> None: + client = _FakeClient() + call = _call(client, {"effect_id": "aurora", "zone_id": "zone-1"}) + + await _apply_effect(call) + + assert client.calls == [ + ( + "apply_effect", + ("aurora",), + {"controls": None, "transition": None, "render_group": "zone-1"}, + ) + ] + + +async def test_set_zone_scales_brightness_and_resolves_active_scene() -> None: + client = _FakeClient() + call = _call(client, {"zone_id": "zone-1", "brightness": 50, "enabled": True}) + + await _set_zone(call) + + assert client.calls == [ + ("get_active_scene", (), {}), + ("update_zone", ("scene-active", "zone-1"), {"brightness": 0.5, "enabled": True}), + ] + + +async def test_set_unassigned_behavior_builds_fallback_payload() -> None: + client = _FakeClient() + call = _call( + client, + {"behavior": "fallback", "fallback_zone_id": "zone-2", "scene_id": "scene-9"}, + ) + + await _set_unassigned_behavior(call) + + assert client.calls == [("set_unassigned_behavior", ("scene-9", {"fallback": "zone-2"}), {})] + + +async def test_list_zones_returns_jsonable_payload() -> None: + client = _FakeClient() + call = _call(client, {"scene_id": "scene-9"}) + + result = await _list_zones(call) + + assert result == { + "scene_id": "scene-9", + "groups_revision": 4, + "zones": [{"id": "zone-1", "name": "Desk", "role": "primary"}], + } async def test_set_color_applies_solid_color_effect() -> None: @@ -116,6 +171,22 @@ async def upload_effect(self, *args: Any, **kwargs: Any) -> dict[str, str]: self.calls.append(("upload_effect", args, kwargs)) return {"id": "user:neon"} + async def get_active_scene(self) -> Any: + self.calls.append(("get_active_scene", (), {})) + return SimpleNamespace(id="scene-active") + + async def update_zone(self, *args: Any, **kwargs: Any) -> None: + self.calls.append(("update_zone", args, kwargs)) + + async def set_unassigned_behavior(self, *args: Any, **kwargs: Any) -> None: + self.calls.append(("set_unassigned_behavior", args, kwargs)) + + async def get_zones(self, scene_id: str) -> Any: + return SimpleNamespace( + groups_revision=4, + items=[{"id": "zone-1", "name": "Desk", "role": "primary"}], + ) + def _call(client: _FakeClient, data: dict[str, Any]) -> Any: entry = SimpleNamespace( diff --git a/uv.lock b/uv.lock index 8510bd4..5e8de2c 100644 --- a/uv.lock +++ b/uv.lock @@ -1047,7 +1047,7 @@ wheels = [ [[package]] name = "hypercolor" -version = "0.1.0" +version = "0.2.0" source = { editable = "../hypercolor/python" } dependencies = [ { name = "attrs" }, @@ -1085,7 +1085,7 @@ types = [{ name = "ty", specifier = ">=0.0.32" }] [[package]] name = "hypercolor-hass" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "hypercolor" },