Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

| | |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
27 changes: 27 additions & 0 deletions custom_components/hypercolor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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"
Expand Down
18 changes: 17 additions & 1 deletion custom_components/hypercolor/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"),
Expand Down Expand Up @@ -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")


Expand Down
15 changes: 15 additions & 0 deletions custom_components/hypercolor/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
149 changes: 149 additions & 0 deletions custom_components/hypercolor/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
4 changes: 2 additions & 2 deletions custom_components/hypercolor/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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." }]
}
9 changes: 6 additions & 3 deletions custom_components/hypercolor/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
Loading
Loading