From 93e0ae7e0eb5a4567fdae5514884bdf8ead7a129 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Fri, 8 May 2026 01:03:30 -0700 Subject: [PATCH 1/6] docs(readme): add hyper-light-card references to overview and recipes Mention the companion Lovelace card in the overview section so new users discover it immediately. Add a lead-in to the dashboard recipe that links hyper-light-card before the stock entities example, and update the related-projects bullet to describe it as a companion to this integration rather than a generic catalog card. --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 From bb2c26d1362179186d9a8b1e6906aa8434bde979 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Thu, 11 Jun 2026 23:30:36 -0700 Subject: [PATCH 2/6] chore(deps): adopt hypercolor 0.2.0 and its real wire shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client realigned with the daemon's scene/multi-zone API: device payloads carry origin/presentation/connection, control definitions are name/control_type/default_value, and control values arrive externally tagged ({"float": 12.0}). Pin hypercolor>=0.2.0,<0.3.0; the local dev source stays at ../hypercolor/python, the client's home inside the main repo. The live-control numbers were reading bare scalars out of control_values, which a real daemon never sends — they showed unknown against live hardware. A control_scalar helper unwraps tagged values (and tolerates the bare form older payloads used), and the control matcher also accepts the new name field. The e2e fake daemon now serves the real control wire shape so this can't drift silently again. Co-Authored-By: Nova (Claude Fable 5) --- custom_components/hypercolor/entity.py | 15 ++++++++++++++ custom_components/hypercolor/manifest.json | 4 ++-- custom_components/hypercolor/number.py | 9 ++++++--- pyproject.toml | 2 +- tests/test_hass_e2e.py | 23 +++++++++++++--------- uv.lock | 2 +- 6 files changed, 39 insertions(+), 16 deletions(-) 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/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/pyproject.toml b/pyproject.toml index 1693b26..3065cdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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_hass_e2e.py b/tests/test_hass_e2e.py index 0023363..c489bc6 100644 --- a/tests/test_hass_e2e.py +++ b/tests/test_hass_e2e.py @@ -333,27 +333,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" diff --git a/uv.lock b/uv.lock index 8510bd4..b412923 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" }, From 4df1041bd84087b88907dab8eef1c5a17c912b6c Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Thu, 11 Jun 2026 23:30:36 -0700 Subject: [PATCH 3/6] feat(zones): per-zone light entities from the active scene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The state coordinator now carries the active scene's render groups: zones, groups_revision, and active_scene_name flow from GET /scenes/active into coordinator data, and the WebSocket refresh triggers cover the zone-world events (render_group_changed, active_scene_changed, layer_*, brightness_changed) that previously slipped through the prefix filter. Each non-display zone becomes a light entity: brightness maps the zone's 0..1 multiplier, effects apply with render_group targeting so one zone changes without touching its siblings, and turn_off disables the zone. Zones are scene-scoped, so entities appear dynamically as scenes introduce new zone ids and go unavailable when the active scene no longer carries theirs. Display-face zones are excluded — they drive LCDs, not LEDs, and have their own service surface. The e2e fake daemon grew a full active-scene fixture with a primary zone and a zone PATCH route; the test drives brightness, zone-targeted effect apply, and disable round-trips through a real config entry. Co-Authored-By: Nova (Claude Fable 5) --- custom_components/hypercolor/coordinator.py | 18 ++- custom_components/hypercolor/light.py | 149 ++++++++++++++++++++ tests/test_coordinator.py | 15 +- tests/test_hass_e2e.py | 120 +++++++++++++++- tests/test_light.py | 34 ++++- 5 files changed, 331 insertions(+), 5 deletions(-) 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/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/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 c489bc6..db5dfc3 100644 --- a/tests/test_hass_e2e.py +++ b/tests/test_hass_e2e.py @@ -38,6 +38,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 +109,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) @@ -186,6 +226,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 +260,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 +276,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 +308,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", @@ -404,6 +461,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"}) == [] From 76da340fe48e96ac0b61a8be6c289ac56288797d Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Thu, 11 Jun 2026 23:30:36 -0700 Subject: [PATCH 4/6] feat(services): zone-aware service surface apply_effect (and its preset routing) accepts zone_id for render-group targeting. New services: set_zone patches zone name/brightness/enabled/ make_primary (brightness as 0-100 percent, scaled to the daemon's 0..1 multiplier), list_zones returns the zone set with its groups_revision for automations, deactivate_scene returns to the synthesized default, and set_unassigned_behavior sets the off/hold/fallback policy for unclaimed device outputs. Zone services default to the active scene when no scene_id is given. Co-Authored-By: Nova (Claude Fable 5) --- custom_components/hypercolor/services.py | 110 ++++++++++++++++++++- custom_components/hypercolor/services.yaml | 74 ++++++++++++++ tests/test_services.py | 73 +++++++++++++- 3 files changed, 255 insertions(+), 2 deletions(-) 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/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( From f96d7944a4e4543a15336dd930f627241e7e0e02 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Thu, 11 Jun 2026 23:31:11 -0700 Subject: [PATCH 5/6] chore(release): bump integration to 0.2.0 Matches the manifest version and the hypercolor 0.2.0 client adoption that brought the scene/multi-zone surface. Co-Authored-By: Nova (Claude Fable 5) --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3065cdf..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" diff --git a/uv.lock b/uv.lock index b412923..5e8de2c 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, From 01979c543b9ebf08883d8a7a310b075a62030442 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Thu, 11 Jun 2026 23:41:20 -0700 Subject: [PATCH 6/6] fix(zones): prune stale zone entities at setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zone ids are per-scene UUIDs, so zone churn (deleting zones, cycling through scenes) would grow the entity registry without bound — every vanished zone left a permanently-unavailable light behind. Setup now removes registry entries under the instance's zone namespace whose zone no longer exists in the active scene. Pruning is deliberately setup-only: mid-session scene switches leave entities unavailable instead of yanking them out from under dashboards and automations; the registry gets reconciled on the next reload. Found by adversarial review of the zone adaptation. Co-Authored-By: Nova (Claude Fable 5) --- custom_components/hypercolor/__init__.py | 27 +++++++++++++++++++ tests/test_hass_e2e.py | 33 +++++++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) 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/tests/test_hass_e2e.py b/tests/test_hass_e2e.py index db5dfc3..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 ( @@ -173,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, @@ -198,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(