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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:

- name: Check out Hypercolor client
env:
HYPERCOLOR_REF: ${{ vars.HYPERCOLOR_REF || '25067d621c9756388fe13e850fcd2a9f90389870' }}
HYPERCOLOR_REF: ${{ vars.HYPERCOLOR_REF || '978096e614695777d3cd13ea0b184877e46afd84' }}
HYPERCOLOR_TOKEN: ${{ secrets.HYPERCOLOR_TOKEN }}
run: |
set -euo pipefail
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ jobs:

- name: Check out Hypercolor client
env:
HYPERCOLOR_REF: ${{ vars.HYPERCOLOR_REF || '25067d621c9756388fe13e850fcd2a9f90389870' }}
HYPERCOLOR_REF: ${{ vars.HYPERCOLOR_REF || '978096e614695777d3cd13ea0b184877e46afd84' }}
HYPERCOLOR_TOKEN: ${{ secrets.HYPERCOLOR_TOKEN }}
run: |
set -euo pipefail
Expand Down
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ the device tree reads naturally.
| `light.hypercolor` | light | master power, brightness, effect picker |
| `binary_sensor.hypercolor_connected` | binary_sensor | live connectivity to the daemon |
| `sensor.hypercolor_active_effect` | sensor | display name of the running effect |
| `sensor.hypercolor_fps` | sensor | render loop FPS |
| `select.hypercolor_scene` | select | activate a scene |
| `select.hypercolor_profile` | select | apply a profile |
| `select.hypercolor_layout` | select | switch spatial layouts |
Expand All @@ -142,7 +141,8 @@ Toggle these in the integration's options panel:
- 🌊 **Audio entities** (`channels.audio`) — adds `binary_sensor.hypercolor_audio_beat`,
`binary_sensor.hypercolor_audio_reactive_active`, `sensor.hypercolor_audio_energy`,
`select.hypercolor_audio_device`, and `switch.hypercolor_audio_reactive`.
- 🧪 **Metrics entity** (`channels.metrics`) — adds `sensor.hypercolor_render_time`.
- 🧪 **Metrics entities** (`channels.metrics`): adds `sensor.hypercolor_fps` and
`sensor.hypercolor_render_time`.
- 🦋 **Per-device entities** (`per_device_entities`) — opt specific device ids in to get
their own light, identify button, and enabled switch.

Expand Down Expand Up @@ -261,10 +261,11 @@ that becomes the integration's unique id. That means the same daemon keeps the s
entry across IP changes, container restarts, and network re-shuffles.

The integration also runs a background WebSocket session against the daemon. Events
trigger immediate coordinator refreshes; metrics, device metrics, and audio spectrum are
opt-in channels that ride the same socket. If the WebSocket drops, the integration
backs off exponentially and retries forever, so HA's connectivity sensor reflects reality
without needing per-tick polling.
patch authoritative state immediately and refresh only the affected coordinator; metrics
and audio spectrum are opt-in channels that ride the same socket. If the WebSocket drops,
the integration backs off exponentially and retries forever, so HA's connectivity sensor
reflects reality without needing per-tick polling. Periodic reconciliation is disabled by
default and remains available as an explicit fallback.

## 🧪 Development

Expand Down
100 changes: 58 additions & 42 deletions custom_components/hypercolor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,22 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.httpx_client import get_async_client

from .api import CannotConnectError, InvalidAuthError, async_validate_daemon
from .client import create_hypercolor_client
from hypercolor import HypercolorClient

from .api import (
CannotConnectError,
InvalidAuthError,
UnsupportedDaemonError,
async_validate_daemon,
)
from .const import (
CONF_API_KEY,
CONF_RECONCILE_INTERVAL_S,
Expand All @@ -32,7 +38,7 @@
reconcile_loop,
websocket_loop,
)
from .entity import child_device_identifier, device_slug, read_field
from .entity import child_device_identifier, read_field
from .runtime_data import HypercolorRuntimeData
from .services import async_setup_services

Expand All @@ -59,8 +65,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HypercolorConfigEntry) -
raise ConfigEntryNotReady from exc
except InvalidAuthError as exc:
raise ConfigEntryAuthFailed from exc
except UnsupportedDaemonError as exc:
raise ConfigEntryError(
"The Hypercolor daemon does not support persistent output pause"
) from exc

client = create_hypercolor_client(
client = HypercolorClient(
host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
api_key=entry.data.get(CONF_API_KEY),
Expand Down Expand Up @@ -126,17 +136,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: HypercolorConfigEntry) -
entry.async_on_unload(entry.add_update_listener(_async_update_listener))

_register_child_devices(hass, entry, devices.data)
_cleanup_opted_out_entities(hass, entry, devices.data)
_cleanup_opted_out_entities(hass, entry)
_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])
)
runtime_data.reconcile_task = entry.async_create_background_task(
hass,
reconcile_loop([state, catalog, devices], reconcile_interval_s),
name="hypercolor.reconcile",
)
if reconcile_interval_s > 0:
runtime_data.reconcile_task = entry.async_create_background_task(
hass,
reconcile_loop([state, catalog, devices], reconcile_interval_s),
name="hypercolor.reconcile",
)
runtime_data.ws_task = entry.async_create_background_task(
hass,
websocket_loop(runtime_data, {**OPTIONS_DEFAULTS, **entry.options}),
Expand All @@ -154,13 +165,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: HypercolorConfigEntry)

runtime = entry.runtime_data

tasks = [task for task in (runtime.ws_task, runtime.reconcile_task) if task is not None]
tasks = [
task
for task in (runtime.ws_task, runtime.reconcile_task, runtime.unavailable_task)
if task is not None
]
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)

if hasattr(runtime.client, "aclose"):
await runtime.client.aclose()
await runtime.client.aclose()

return unload_ok

Expand All @@ -170,11 +184,15 @@ async def _async_update_listener(hass: HomeAssistant, entry: HypercolorConfigEnt


async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if entry.version == 1 and entry.minor_version < 1:
if entry.version == 1 and entry.minor_version < 2:
options = {**OPTIONS_DEFAULTS, **entry.options}
if options[CONF_RECONCILE_INTERVAL_S] == 60:
options[CONF_RECONCILE_INTERVAL_S] = 0
options.pop("channels.device_metrics", None)
hass.config_entries.async_update_entry(
entry,
minor_version=1,
options={**OPTIONS_DEFAULTS, **entry.options},
minor_version=2,
options=options,
)
return True

Expand Down Expand Up @@ -204,6 +222,15 @@ def _register_child_devices(
) -> None:
device_registry = dr.async_get(hass)
runtime = entry.runtime_data
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, runtime.server.instance_id)},
name=runtime.server.instance_name,
manufacturer="Hypercolor",
model="Daemon",
sw_version=runtime.server.version,
configuration_url=(f"http://{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}"),
)
for device in devices or []:
device_id = str(read_field(device, "id"))
if not device_id:
Expand All @@ -222,28 +249,25 @@ def _register_child_devices(
def _cleanup_opted_out_entities(
hass: HomeAssistant,
entry: HypercolorConfigEntry,
devices: list[Any],
) -> None:
entity_registry = er.async_get(hass)
runtime = entry.runtime_data
opted_in = set(entry.options.get("per_device_entities", []))
for device in devices or []:
device_id = str(read_field(device, "id"))
if not device_id or device_id in opted_in:
prefix = f"{runtime.server.instance_id}:device:"
suffixes = (":light", ":identify", ":enabled")
for registry_entry in er.async_entries_for_config_entry(entity_registry, entry.entry_id):
if not registry_entry.unique_id.startswith(prefix):
continue
suffix = next(
(candidate for candidate in suffixes if registry_entry.unique_id.endswith(candidate)),
None,
)
if suffix is None:
continue
device_id = registry_entry.unique_id[len(prefix) : -len(suffix)]
if device_id in opted_in:
continue
slug = device_slug(device_id)
for unique_id in (
f"{runtime.server.instance_id}:device:{device_id}:light",
f"{runtime.server.instance_id}:device:{device_id}:identify",
f"{runtime.server.instance_id}:device:{device_id}:enabled",
):
if entity_id := entity_registry.async_get_entity_id(
_domain_for_unique_id(unique_id),
DOMAIN,
unique_id,
):
entity_registry.async_remove(entity_id)
runtime.per_device_entity_ids.discard(slug)
entity_registry.async_remove(registry_entry.entity_id)


def _cleanup_stale_zone_entities(
Expand All @@ -270,11 +294,3 @@ def _cleanup_stale_zone_entities(
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"
if unique_id.endswith(":identify"):
return "button"
return "switch"
34 changes: 24 additions & 10 deletions custom_components/hypercolor/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ class InvalidAuthError(Exception):
pass


class UnsupportedDaemonError(Exception):
pass


@dataclass(frozen=True, slots=True)
class ServerInfo:
instance_id: str
Expand Down Expand Up @@ -48,27 +52,37 @@ async def async_validate_daemon(
except (KeyError, TypeError, ValueError) as exc:
raise CannotConnectError from exc

headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
try:
output_probe = await httpx_client.get(
f"{root_url}/api/v1/output/power",
headers=headers,
)
except httpx.HTTPError as exc:
raise CannotConnectError from exc
if output_probe.status_code in {httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN}:
raise InvalidAuthError
if output_probe.status_code == httpx.codes.NOT_FOUND:
raise UnsupportedDaemonError
if output_probe.status_code >= httpx.codes.BAD_REQUEST:
raise CannotConnectError

if server_info.auth_required:
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
try:
auth_probe = await httpx_client.get(f"{root_url}/api/v1/effects", headers=headers)
control_probe = await httpx_client.patch(
f"{root_url}/api/v1/effects/current/controls",
control_probe = await httpx_client.post(
f"{root_url}/api/v1/diagnose",
headers=headers,
json={"controls": {}},
json={"checks": []},
)
except httpx.HTTPError as exc:
raise CannotConnectError from exc

if auth_probe.status_code == httpx.codes.UNAUTHORIZED or control_probe.status_code in {
if control_probe.status_code in {
httpx.codes.UNAUTHORIZED,
httpx.codes.FORBIDDEN,
}:
raise InvalidAuthError
if (
auth_probe.status_code >= httpx.codes.BAD_REQUEST
or control_probe.status_code >= httpx.codes.INTERNAL_SERVER_ERROR
):
if control_probe.status_code >= httpx.codes.BAD_REQUEST:
raise CannotConnectError

return server_info
Expand Down
61 changes: 56 additions & 5 deletions custom_components/hypercolor/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@
CONF_CHANNELS_AUDIO,
DEFAULT_AUDIO_BEAT_HOLD_MS,
)
from .entity import catalog_items, hub_device_info, item_id, item_name, read_field
from .entity import (
MultiCoordinatorEntity,
catalog_items,
hub_device_info,
item_id,
item_name,
read_field,
)
from .runtime_data import HypercolorRuntimeData


Expand Down Expand Up @@ -44,6 +51,41 @@ def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None:
self._entry = entry
self._attr_device_info = hub_device_info(runtime, entry.data)
self._attr_unique_id = f"{runtime.server.instance_id}:connected"
self._remove_timer: CALLBACK_TYPE | None = None

async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self.async_on_remove(self._cancel_timer)
self.async_on_remove(
self._entry.runtime_data.connection_state.async_add_listener(
self._handle_connection_update
)
)
self._handle_connection_update()

@callback
def _handle_connection_update(self) -> None:
self._cancel_timer()
if not self._entry.runtime_data.connection_state.connected:
grace_s = int(self._entry.options.get("disconnect_grace_s", 5))
if grace_s > 0:
self._remove_timer = async_call_later(
self.hass,
grace_s,
self._disconnect_grace_expired,
)
self.async_write_ha_state()

@callback
def _disconnect_grace_expired(self, *_: object) -> None:
self._remove_timer = None
self.async_write_ha_state()

@callback
def _cancel_timer(self) -> None:
if self._remove_timer is not None:
self._remove_timer()
self._remove_timer = None

@property
def is_on(self) -> bool:
Expand All @@ -70,6 +112,10 @@ def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None:
self._attr_device_info = hub_device_info(runtime, entry.data)
self._attr_unique_id = f"{runtime.server.instance_id}:audio_beat"

async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self.async_on_remove(self._cancel_timer)

@property
def is_on(self) -> bool:
spectrum = (self.coordinator.data or {}).get("spectrum") or {}
Expand All @@ -81,8 +127,7 @@ def is_on(self) -> bool:
@callback
def _handle_coordinator_update(self) -> None:
if self.is_on:
if self._remove_timer is not None:
self._remove_timer()
self._cancel_timer()
hold_ms = int(
self._entry.options.get(
CONF_AUDIO_BEAT_HOLD_MS,
Expand All @@ -101,14 +146,20 @@ def _beat_expired(self, *_: object) -> None:
self._remove_timer = None
self.async_write_ha_state()

@callback
def _cancel_timer(self) -> None:
if self._remove_timer is not None:
self._remove_timer()
self._remove_timer = None


class HypercolorAudioReactiveBinarySensor(CoordinatorEntity, BinarySensorEntity):
class HypercolorAudioReactiveBinarySensor(MultiCoordinatorEntity, BinarySensorEntity):
_attr_has_entity_name = True
_attr_name = "Audio reactive active"

def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None:
runtime = entry.runtime_data
super().__init__(runtime.coordinators["state"])
super().__init__(runtime.coordinators["state"], runtime.coordinators["catalog"])
self._catalog = runtime.coordinators["catalog"]
self._attr_device_info = hub_device_info(runtime, entry.data)
self._attr_unique_id = f"{runtime.server.instance_id}:audio_reactive_active"
Expand Down
Loading
Loading