From 0d71919da0647e1c3546a40698eaec94dbf904f0 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 16 Aug 2026 11:40:24 -0700 Subject: [PATCH 1/4] feat(hypercolor): discover entities through device registry Treat Home Assistant's hub and child device relationships as the authority for Hypercolor companion discovery. This keeps renamed physical entities scoped to the right daemon, preserves explicit nested options, and updates the documented Hyperia namespace. Co-Authored-By: Nova (OpenAI Codex) --- README.md | 60 ++++---- src/backends/detect.ts | 8 +- src/backends/hypercolor.ts | 187 +++++++++++++---------- tests/backends/detect.test.ts | 6 +- tests/backends/hypercolor.test.ts | 237 +++++++++++++++++++----------- 5 files changed, 300 insertions(+), 198 deletions(-) diff --git a/README.md b/README.md index 969dedc..4bc9731 100644 --- a/README.md +++ b/README.md @@ -127,14 +127,15 @@ allowed_effects: ### Hypercolor example -The Hypercolor integration names its entities after the **daemon instance**, not -after the integration. A daemon called `Hyperia` produces `light.hyperia` -alongside `select.hyperia_layout`, `button.hyperia_next_effect`, and so on. Use -whatever your own instance is called: +The Hypercolor integration namespaces each hub with the product and daemon +instance name. A daemon called `Hyperia` appears as **Hypercolor Hyperia** and +produces `light.hypercolor_hyperia` alongside +`select.hypercolor_hyperia_layout`, `button.hypercolor_hyperia_next_effect`, +and the rest of its hub entities: ```yaml type: custom:hyper-light-card -entity: light.hyperia +entity: light.hypercolor_hyperia name: 'Living Room' show_status_chips: true show_live_controls: true @@ -146,43 +147,42 @@ show_per_device: false background_opacity: 0.7 ``` -> Companion entities (scene/preset/layout selects, live-control numbers, audio -> switch/device selects, status sensors, scene-zone lights, and per-device -> children) are auto-discovered by deriving the sibling name from the entity you -> configure. Point the card at `light.hyperia` and it finds the `hyperia_*` -> helpers on its own. Effect description, publisher, tags, and the full control -> set are read straight from the master light's attributes (published by -> hypercolor-hass), so the info panel and controls populate automatically. +> Companion entities are auto-discovered through Home Assistant's device and +> entity registries. Hub controls, scene zones, and physical child devices stay +> attached to the correct Hypercolor instance even when their entity ids are +> renamed. Effect description, publisher, tags, and the full control set come +> from the master light's attributes, so the info panel and controls populate +> automatically. Discovery only fills in options you haven't set, so any helper that lives somewhere unexpected can be pinned by hand under `hypercolor`: ```yaml type: custom:hyper-light-card -entity: light.hyperia +entity: light.hypercolor_hyperia backend: hypercolor hypercolor: - scene_entity: select.hyperia_scene - profile_entity: select.hyperia_profile - stop_effect_entity: button.hyperia_stop_effect - fps_entity: sensor.hyperia_fps - connected_entity: binary_sensor.hyperia_connected - audio_beat_entity: binary_sensor.hyperia_audio_beat - audio_reactive_active_entity: binary_sensor.hyperia_audio_reactive_active - audio_energy_entity: sensor.hyperia_audio_energy - audio_reactive_switch_entity: switch.hyperia_audio_reactive - audio_device_entity: select.hyperia_audio_device + scene_entity: select.hypercolor_hyperia_scene + profile_entity: select.hypercolor_hyperia_profile + stop_effect_entity: button.hypercolor_hyperia_stop_effect + fps_entity: sensor.hypercolor_hyperia_fps + connected_entity: binary_sensor.hypercolor_hyperia_connected + audio_beat_entity: binary_sensor.hypercolor_hyperia_audio_beat + audio_reactive_active_entity: binary_sensor.hypercolor_hyperia_audio_reactive_active + audio_energy_entity: sensor.hypercolor_hyperia_audio_energy + audio_reactive_switch_entity: switch.hypercolor_hyperia_audio_reactive + audio_device_entity: select.hypercolor_hyperia_audio_device live_control_entities: - brightness: number.hyperia_brightness - speed: number.hyperia_speed - hue_shift: number.hyperia_hue_shift - intensity: number.hyperia_intensity + brightness: number.hypercolor_hyperia_brightness + speed: number.hypercolor_hyperia_speed + hue_shift: number.hypercolor_hyperia_hue_shift + intensity: number.hypercolor_hyperia_intensity zone_lights: - - light.hyperia_default_zone + - light.hypercolor_hyperia_default_zone per_device_lights: - - light.hyperia_lamp_1 + - light.lamp_1 per_device_identify_buttons: - - button.hyperia_identify_lamp_1 + - button.lamp_1_identify ``` ### Configuration Options diff --git a/src/backends/detect.ts b/src/backends/detect.ts index 7fafa86..3f5235a 100644 --- a/src/backends/detect.ts +++ b/src/backends/detect.ts @@ -10,10 +10,10 @@ const REGISTRY: Record = { }; /** - * Attributes only Hypercolor's light entity publishes. The integration names - * entities after the daemon instance (`light.hyperia`, not - * `light.hypercolor_*`), so an entity-id prefix alone cannot identify it and - * these are the reliable signature. + * Attributes only Hypercolor's light entity publishes. Home Assistant lets + * users rename entity ids, so the default `light.hypercolor_*` namespace is + * not sufficient on its own and these attributes remain the reliable + * signature. */ const HYPERCOLOR_ATTRIBUTES = ['effect_controls', 'active_effect_id', 'zone_count'] as const; diff --git a/src/backends/hypercolor.ts b/src/backends/hypercolor.ts index 771c902..9fad881 100644 --- a/src/backends/hypercolor.ts +++ b/src/backends/hypercolor.ts @@ -194,9 +194,12 @@ export const hypercolorBackend: LightBackend = { const stateObj = ctx.hass.states[entityId]; if (!stateObj) return []; const haBrightness = stateObj.attributes.brightness; - // Find an identify button under the same device_id slug pattern. const slug = entityId.replace(/^light\./, ''); - const identifyEntity = identifyEntityFor(slug, slugs, identifyButtons); + const registryIdentify = siblingEntityOnDevice(ctx.hass, entityId, identifyButtons); + const identifyEntity = + registryIdentify === undefined + ? identifyEntityFor(slug, slugs, identifyButtons) + : registryIdentify; return [ { id: stateObj.entity_id, @@ -315,21 +318,18 @@ export const hypercolorBackend: LightBackend = { const slugs = instanceSlugs(mainEntity); const known = new Set(entities); - const hubIds = hubEntityIds(ctx.hass, mainEntity); + const registryScope = hypercolorRegistryScope(ctx.hass, mainEntity); + const hubIds = registryScope?.hubEntityIds ?? null; // The device registry states which entities share this card's hub, which - // entity ids alone cannot: a `hyperia_living` hub and a - // `light.hyperia_living_room` belonging to a `hyperia` hub are - // indistinguishable by name. So when the registry answers, it is the only - // authority — falling through to name matching after a registry miss would - // reintroduce exactly the cross-hub binding it exists to prevent. A miss - // means the hub genuinely has no such helper. + // entity ids alone cannot. When the registry answers, it is the only + // authority. Falling through to name matching after a registry miss would + // reintroduce cross-hub binding. A miss means the hub has no such helper. // // Name matching runs only when the registries aren't populated. There, // exact ids only, against slugs derived from this card's own entity id: a - // prefix match would bind a collision-renamed neighbour such as - // `select.hyperia_layout_2`, and a hard-coded `hypercolor` fallback would - // let a Hyperia card adopt a different instance's helpers. + // prefix match would bind a collision-renamed neighbour, and a hard-coded + // fallback would let one instance adopt another instance's helpers. const findOne = (domain: string, suffix: string) => { if (hubIds) { for (const candidate of slugs) { @@ -374,58 +374,71 @@ export const hypercolorBackend: LightBackend = { if (found) patch.random_effect_entity = found; } - const extra: HypercolorConfigAddenda = {}; - const scene = findOne('select', 'scene'); - if (scene) extra.scene_entity = scene; - const profile = findOne('select', 'profile'); - if (profile) extra.profile_entity = profile; - const stop = findOne('button', 'stop_effect'); - if (stop) extra.stop_effect_entity = stop; - const fps = findOne('sensor', 'fps'); - if (fps) extra.fps_entity = fps; - const connected = findOne('binary_sensor', 'connected'); - if (connected) extra.connected_entity = connected; - const beat = findOne('binary_sensor', 'audio_beat'); - if (beat) extra.audio_beat_entity = beat; - const reactive = findOne('binary_sensor', 'audio_reactive_active'); - if (reactive) extra.audio_reactive_active_entity = reactive; - const energy = findOne('sensor', 'audio_energy'); - if (energy) extra.audio_energy_entity = energy; - const audioReactiveSwitch = findOne('switch', 'audio_reactive'); - if (audioReactiveSwitch) extra.audio_reactive_switch_entity = audioReactiveSwitch; - const audioDevice = findOne('select', 'audio_device'); - if (audioDevice) extra.audio_device_entity = audioDevice; - - const liveControls: Partial> = {}; + const configuredExtra = addenda(ctx.config); + const extra: HypercolorConfigAddenda = { ...configuredExtra }; + const discoverExtra = ( + key: K, + domain: string, + suffix: string + ) => { + if (configuredExtra[key] !== undefined) return; + const found = findOne(domain, suffix); + if (found) extra[key] = found as HypercolorConfigAddenda[K]; + }; + discoverExtra('scene_entity', 'select', 'scene'); + discoverExtra('profile_entity', 'select', 'profile'); + discoverExtra('stop_effect_entity', 'button', 'stop_effect'); + discoverExtra('fps_entity', 'sensor', 'fps'); + discoverExtra('connected_entity', 'binary_sensor', 'connected'); + discoverExtra('audio_beat_entity', 'binary_sensor', 'audio_beat'); + discoverExtra('audio_reactive_active_entity', 'binary_sensor', 'audio_reactive_active'); + discoverExtra('audio_energy_entity', 'sensor', 'audio_energy'); + discoverExtra('audio_reactive_switch_entity', 'switch', 'audio_reactive'); + discoverExtra('audio_device_entity', 'select', 'audio_device'); + + const liveControls: Partial> = { + ...configuredExtra.live_control_entities, + }; for (const id of LIVE_CONTROL_IDS) { + if (liveControls[id] !== undefined) continue; const found = findOne('number', id); if (found) liveControls[id] = found; } if (Object.keys(liveControls).length > 0) extra.live_control_entities = liveControls; - // Scope per-device discovery to the card's own group. A non-root card - // must not pick up children from unrelated groups; only a root hub card - // sees every light under its own prefix. - const childPrefix = `${mainEntity}_`; - // Zone lights share the hub prefix but carry a `zone_id` attribute; they - // are scene render-groups, not physical devices, so split them out and - // keep them from polluting the per-device drilldown. const isZoneLight = (id: string) => ctx.hass.states[id]?.attributes.zone_id != null; - const childLights = entities.filter( - id => id.startsWith(childPrefix) && id !== mainEntity && !isZoneLight(id) - ); - const zoneLights = entities.filter(id => id.startsWith(childPrefix) && isZoneLight(id)); - // Identify buttons appear as `button._identify_` for - // hub-managed children, plus the conventional `_identify` pattern - // HA generates from `_attr_name = "Identify"` on a child entity. - const identifyButtons = entities.filter( - id => - slugs.some(candidate => id.startsWith(`button.${candidate}_identify_`)) || - (id.startsWith('button.') && id.endsWith('_identify')) - ); - if (childLights.length > 0) extra.per_device_lights = childLights; - if (zoneLights.length > 0) extra.zone_lights = zoneLights; - if (identifyButtons.length > 0) extra.per_device_identify_buttons = identifyButtons; + let childLights: string[]; + let zoneLights: string[]; + let identifyButtons: string[]; + if (registryScope) { + childLights = [...registryScope.childEntityIds].filter( + id => id.startsWith('light.') && id !== mainEntity && !isZoneLight(id) + ); + zoneLights = [...registryScope.hubEntityIds].filter( + id => id.startsWith('light.') && id !== mainEntity && isZoneLight(id) + ); + identifyButtons = [...registryScope.childEntityIds].filter(id => id.startsWith('button.')); + } else { + const childPrefix = `${mainEntity}_`; + childLights = entities.filter( + id => id.startsWith(childPrefix) && id !== mainEntity && !isZoneLight(id) + ); + zoneLights = entities.filter(id => id.startsWith(childPrefix) && isZoneLight(id)); + identifyButtons = entities.filter( + id => + slugs.some(candidate => id.startsWith(`button.${candidate}_identify_`)) || + (id.startsWith('button.') && id.endsWith('_identify')) + ); + } + if (configuredExtra.per_device_lights === undefined && childLights.length > 0) { + extra.per_device_lights = childLights; + } + if (configuredExtra.zone_lights === undefined && zoneLights.length > 0) { + extra.zone_lights = zoneLights; + } + if (configuredExtra.per_device_identify_buttons === undefined && identifyButtons.length > 0) { + extra.per_device_identify_buttons = identifyButtons; + } if (Object.keys(extra).length > 0) { patch.hypercolor = extra; @@ -472,13 +485,13 @@ function liveControlLabel(id: string): string { } /** - * Entity ids that share the card light's Hypercolor hub, via the Home - * Assistant device registry. Child lights sit on their own device linked to - * the hub by `via_device_id`, so walk up one level before collecting. Returns - * null when the registries aren't populated (they often aren't at first - * paint), leaving the caller to fall back to name matching. + * Entity ids attached to the card light's Hypercolor hub and child devices. + * Returns null while Home Assistant's registries are not fully populated. */ -function hubEntityIds(hass: HomeAssistant, lightEntityId: string): Set | null { +function hypercolorRegistryScope( + hass: HomeAssistant, + lightEntityId: string +): { hubEntityIds: Set; childEntityIds: Set } | null { const registries = hass as unknown as { entities?: Record; devices?: Record; @@ -494,24 +507,27 @@ function hubEntityIds(hass: HomeAssistant, lightEntityId: string): Set | const device = devices[deviceId]; if (!device) return null; const hubId = device.via_device_id ?? deviceId; - const ids = Object.keys(entities).filter(id => entities[id]?.device_id === hubId); - return ids.length > 0 ? new Set(ids) : null; + const childDeviceIds = new Set( + Object.entries(devices).flatMap(([id, candidate]) => + candidate?.via_device_id === hubId ? [id] : [] + ) + ); + const hubEntityIds = new Set(); + const childEntityIds = new Set(); + for (const [id, entity] of Object.entries(entities)) { + if (entity?.device_id === hubId) hubEntityIds.add(id); + if (entity?.device_id && childDeviceIds.has(entity.device_id)) childEntityIds.add(id); + } + return hubEntityIds.size > 0 ? { hubEntityIds, childEntityIds } : null; } /** - * Instance-slug candidates for a light entity, longest first: `light.hyperia` - * yields `[hyperia]`, and a child card `light.hyperia_living_room` yields - * `[hyperia_living_room, hyperia_living, hyperia]` so it can still reach its - * own hub's helpers. Every candidate is a prefix of the card's own entity id, - * so discovery can never wander into a differently-named instance. + * The complete entity slug used for name matching before Home Assistant's + * registries are populated. Partial prefixes are never candidates because + * they can belong to another Hypercolor instance. */ function instanceSlugs(entityId: string): string[] { - const parts = entityId.slice('light.'.length).split('_'); - const slugs: string[] = []; - for (let i = parts.length; i > 0; i--) { - slugs.push(parts.slice(0, i).join('_')); - } - return slugs; + return [entityId.slice('light.'.length)]; } function identifyEntityFor( @@ -533,6 +549,23 @@ function identifyEntityFor( return identifyButtons.find(id => candidates.has(id)) ?? null; } +function siblingEntityOnDevice( + hass: HomeAssistant, + entityId: string, + candidates: string[] +): string | null | undefined { + const entities = ( + hass as unknown as { + entities?: Record; + } + ).entities; + if (!entities) return undefined; + const deviceId = entities[entityId]?.device_id; + if (!deviceId) return undefined; + const matches = candidates.filter(candidate => entities[candidate]?.device_id === deviceId); + return matches.length === 1 ? matches[0] : null; +} + function readSelectModel(hass: HomeAssistant, entityId?: string): SelectModel | null { if (!entityId) return null; const stateObj = hass.states[entityId]; @@ -576,7 +609,7 @@ function liveControlsFromAttribute(ctx: BackendContext): LiveControlModel[] { const stateObj = ctx.hass.states[ctx.config.entity]; const raw = stateObj?.attributes.effect_controls; if (!Array.isArray(raw)) return []; - // Controls inherit the master light's availability — a stale attribute on an + // Controls inherit the master light's availability. A stale attribute on an // unavailable light must not render as interactive. const available = stateObj ? stateObj.state !== 'unavailable' && stateObj.state !== 'unknown' diff --git a/tests/backends/detect.test.ts b/tests/backends/detect.test.ts index 86c98ca..a7ce155 100644 --- a/tests/backends/detect.test.ts +++ b/tests/backends/detect.test.ts @@ -17,15 +17,13 @@ describe('detectBackend', () => { }); it('detects hypercolor from its attribute signature regardless of entity name', () => { - // The integration names the light after the daemon instance, so the entity - // id carries no "hypercolor" marker to key off. - const hass = hassWith('light.hyperia', { + const hass = hassWith('light.rgb_queen', { effect: 'Borealis', effect_image: 'http://hyperia.local:7777/cover.png', effect_controls: [{ id: 'speed', kind: 'number', min: 0, max: 100, value: 50 }], active_effect_id: 'borealis', }); - expect(detectBackend(hass, { entity: 'light.hyperia' } as Config).id).toBe('hypercolor'); + expect(detectBackend(hass, { entity: 'light.rgb_queen' } as Config).id).toBe('hypercolor'); }); it('still detects hypercolor from the default entity prefix', () => { diff --git a/tests/backends/hypercolor.test.ts b/tests/backends/hypercolor.test.ts index c795b7d..e0727c0 100644 --- a/tests/backends/hypercolor.test.ts +++ b/tests/backends/hypercolor.test.ts @@ -82,52 +82,105 @@ describe('hypercolorBackend.autoDiscover', () => { ]); }); - it('discovers siblings named after the daemon instance, not the integration', () => { - // The integration names entities after the hub device, so an instance - // called "Hyperia" produces light.hyperia / select.hyperia_layout. Keying - // discovery off the literal string "hypercolor" found nothing here and - // forced users to hand-write every entity id in YAML. + it('discovers the product and instance namespace before registries load', () => { const ctx = { hass: hassWith([ - 'light.hyperia', - 'select.hyperia_layout', - 'select.hyperia_preset', - 'select.hyperia_scene', - 'button.hyperia_next_effect', - 'button.hyperia_previous_effect', - 'button.hyperia_random_effect', - 'button.hyperia_stop_effect', - 'sensor.hyperia_fps', - 'binary_sensor.hyperia_connected', - 'switch.hyperia_audio_reactive', - 'number.hyperia_brightness', - 'number.hyperia_speed', + 'light.hypercolor_hyperia', + 'select.hypercolor_hyperia_layout', + 'select.hypercolor_hyperia_preset', + 'select.hypercolor_hyperia_scene', + 'button.hypercolor_hyperia_next_effect', + 'button.hypercolor_hyperia_previous_effect', + 'button.hypercolor_hyperia_random_effect', + 'button.hypercolor_hyperia_stop_effect', + 'sensor.hypercolor_hyperia_fps', + 'binary_sensor.hypercolor_hyperia_connected', + 'switch.hypercolor_hyperia_audio_reactive', + 'number.hypercolor_hyperia_brightness', + 'number.hypercolor_hyperia_speed', ]), - config: { entity: 'light.hyperia' } as Config, + config: { entity: 'light.hypercolor_hyperia' } as Config, }; const patch = hypercolorBackend.autoDiscover?.(ctx) as DiscoveredPatch; - expect(patch.layout_entity).toBe('select.hyperia_layout'); - expect(patch.preset_entity).toBe('select.hyperia_preset'); - expect(patch.next_effect_entity).toBe('button.hyperia_next_effect'); - expect(patch.previous_effect_entity).toBe('button.hyperia_previous_effect'); - expect(patch.random_effect_entity).toBe('button.hyperia_random_effect'); - expect(patch.hypercolor?.scene_entity).toBe('select.hyperia_scene'); - expect(patch.hypercolor?.stop_effect_entity).toBe('button.hyperia_stop_effect'); - expect(patch.hypercolor?.fps_entity).toBe('sensor.hyperia_fps'); - expect(patch.hypercolor?.connected_entity).toBe('binary_sensor.hyperia_connected'); - expect(patch.hypercolor?.audio_reactive_switch_entity).toBe('switch.hyperia_audio_reactive'); + expect(patch.layout_entity).toBe('select.hypercolor_hyperia_layout'); + expect(patch.preset_entity).toBe('select.hypercolor_hyperia_preset'); + expect(patch.next_effect_entity).toBe('button.hypercolor_hyperia_next_effect'); + expect(patch.previous_effect_entity).toBe('button.hypercolor_hyperia_previous_effect'); + expect(patch.random_effect_entity).toBe('button.hypercolor_hyperia_random_effect'); + expect(patch.hypercolor?.scene_entity).toBe('select.hypercolor_hyperia_scene'); + expect(patch.hypercolor?.stop_effect_entity).toBe('button.hypercolor_hyperia_stop_effect'); + expect(patch.hypercolor?.fps_entity).toBe('sensor.hypercolor_hyperia_fps'); + expect(patch.hypercolor?.connected_entity).toBe('binary_sensor.hypercolor_hyperia_connected'); + expect(patch.hypercolor?.audio_reactive_switch_entity).toBe( + 'switch.hypercolor_hyperia_audio_reactive' + ); expect(patch.hypercolor?.live_control_entities).toEqual({ - brightness: 'number.hyperia_brightness', - speed: 'number.hyperia_speed', + brightness: 'number.hypercolor_hyperia_brightness', + speed: 'number.hypercolor_hyperia_speed', + }); + }); + + it('discovers hub, zone, and physical entities through registry relationships', () => { + const hass = hassWith([ + 'light.hypercolor_hyperia', + 'select.custom_layout', + 'light.hypercolor_hyperia_default_zone', + 'light.lamp', + 'button.find_lamp', + 'light.other_lamp', + 'button.find_other_lamp', + ]); + hass.states['light.hypercolor_hyperia_default_zone'].attributes = { + zone_id: 'zone_default', + }; + hass.states['light.lamp'].state = 'on'; + hass.states['light.lamp'].attributes = { friendly_name: 'Lamp' }; + (hass as unknown as { entities: Record }).entities = { + 'light.hypercolor_hyperia': { device_id: 'hub' }, + 'select.custom_layout': { device_id: 'hub' }, + 'light.hypercolor_hyperia_default_zone': { device_id: 'hub' }, + 'light.lamp': { device_id: 'lamp' }, + 'button.find_lamp': { device_id: 'lamp' }, + 'light.other_lamp': { device_id: 'other_lamp' }, + 'button.find_other_lamp': { device_id: 'other_lamp' }, + }; + (hass as unknown as { devices: Record }).devices = { + hub: { via_device_id: null }, + lamp: { via_device_id: 'hub' }, + other_hub: { via_device_id: null }, + other_lamp: { via_device_id: 'other_hub' }, + }; + + const patch = hypercolorBackend.autoDiscover?.({ + hass, + config: { entity: 'light.hypercolor_hyperia' } as Config, + }) as DiscoveredPatch; + + expect(patch.layout_entity).toBe('select.custom_layout'); + expect(patch.hypercolor?.zone_lights).toEqual(['light.hypercolor_hyperia_default_zone']); + expect(patch.hypercolor?.per_device_lights).toEqual(['light.lamp']); + expect(patch.hypercolor?.per_device_identify_buttons).toEqual(['button.find_lamp']); + + const devices = hypercolorBackend.perDevice?.({ + hass, + config: { + entity: 'light.hypercolor_hyperia', + hypercolor: patch.hypercolor, + } as Config, }); + expect(devices?.[0]?.identifyEntity).toBe('button.find_lamp'); }); it('does not borrow another instance entities when the slug does not match', () => { const ctx = { - hass: hassWith(['light.hyperia', 'select.otherbox_layout', 'button.otherbox_next_effect']), - config: { entity: 'light.hyperia' } as Config, + hass: hassWith([ + 'light.hypercolor_hyperia', + 'select.otherbox_layout', + 'button.otherbox_next_effect', + ]), + config: { entity: 'light.hypercolor_hyperia' } as Config, }; const patch = hypercolorBackend.autoDiscover?.(ctx) as DiscoveredPatch; @@ -142,12 +195,12 @@ describe('hypercolorBackend.autoDiscover', () => { // are absent. const ctx = { hass: hassWith([ - 'light.hyperia', + 'light.hypercolor_hyperia', 'light.hypercolor', 'select.hypercolor_layout', 'button.hypercolor_next_effect', ]), - config: { entity: 'light.hyperia' } as Config, + config: { entity: 'light.hypercolor_hyperia' } as Config, }; const patch = hypercolorBackend.autoDiscover?.(ctx) as DiscoveredPatch; @@ -158,8 +211,8 @@ describe('hypercolorBackend.autoDiscover', () => { it('does not bind a collision-renamed neighbour by prefix', () => { const ctx = { - hass: hassWith(['light.hyperia', 'select.hyperia_layout_2']), - config: { entity: 'light.hyperia' } as Config, + hass: hassWith(['light.hypercolor_hyperia', 'select.hypercolor_hyperia_layout_2']), + config: { entity: 'light.hypercolor_hyperia' } as Config, }; expect( @@ -169,21 +222,21 @@ describe('hypercolorBackend.autoDiscover', () => { it('uses the device registry to pick the right hub when names overlap', () => { // Two instances whose names collide by prefix: a `hyperia_living` hub - // alongside this card's `light.hyperia_living_room`. Names alone cannot + // alongside this card's `light.hypercolor_hyperia_living_room`. Names alone cannot // disambiguate, so discovery follows the registry instead. const hass = hassWith([ - 'light.hyperia', - 'light.hyperia_living_room', - 'select.hyperia_layout', - 'light.hyperia_living', - 'select.hyperia_living_layout', + 'light.hypercolor_hyperia', + 'light.hypercolor_hyperia_living_room', + 'select.hypercolor_hyperia_layout', + 'light.hypercolor_hyperia_living', + 'select.hypercolor_hyperia_living_layout', ]); (hass as unknown as { entities: Record }).entities = { - 'light.hyperia': { device_id: 'hub' }, - 'select.hyperia_layout': { device_id: 'hub' }, - 'light.hyperia_living_room': { device_id: 'child' }, - 'light.hyperia_living': { device_id: 'other_hub' }, - 'select.hyperia_living_layout': { device_id: 'other_hub' }, + 'light.hypercolor_hyperia': { device_id: 'hub' }, + 'select.hypercolor_hyperia_layout': { device_id: 'hub' }, + 'light.hypercolor_hyperia_living_room': { device_id: 'child' }, + 'light.hypercolor_hyperia_living': { device_id: 'other_hub' }, + 'select.hypercolor_hyperia_living_layout': { device_id: 'other_hub' }, }; (hass as unknown as { devices: Record }).devices = { hub: { via_device_id: null }, @@ -193,10 +246,10 @@ describe('hypercolorBackend.autoDiscover', () => { const patch = hypercolorBackend.autoDiscover?.({ hass, - config: { entity: 'light.hyperia_living_room' } as Config, + config: { entity: 'light.hypercolor_hyperia_living_room' } as Config, }) as DiscoveredPatch; - expect(patch.layout_entity).toBe('select.hyperia_layout'); + expect(patch.layout_entity).toBe('select.hypercolor_hyperia_layout'); }); it('does not fall back to names when the registry answers but lacks the helper', () => { @@ -204,17 +257,17 @@ describe('hypercolorBackend.autoDiscover', () => { // select, so discovery must report nothing rather than reaching for a // same-named entity that belongs to a different hub. const hass = hassWith([ - 'light.hyperia', - 'light.hyperia_living_room', - 'light.hyperia_living', - 'select.hyperia_living_layout', + 'light.hypercolor_hyperia', + 'light.hypercolor_hyperia_living_room', + 'light.hypercolor_hyperia_living', + 'select.hypercolor_hyperia_living_layout', ]); (hass as unknown as { entities: Record }).entities = { // The hub is present and registered, it simply exposes no layout select. - 'light.hyperia': { device_id: 'hub' }, - 'light.hyperia_living_room': { device_id: 'child' }, - 'light.hyperia_living': { device_id: 'other_hub' }, - 'select.hyperia_living_layout': { device_id: 'other_hub' }, + 'light.hypercolor_hyperia': { device_id: 'hub' }, + 'light.hypercolor_hyperia_living_room': { device_id: 'child' }, + 'light.hypercolor_hyperia_living': { device_id: 'other_hub' }, + 'select.hypercolor_hyperia_living_layout': { device_id: 'other_hub' }, }; (hass as unknown as { devices: Record }).devices = { hub: { via_device_id: null }, @@ -224,7 +277,7 @@ describe('hypercolorBackend.autoDiscover', () => { const patch = hypercolorBackend.autoDiscover?.({ hass, - config: { entity: 'light.hyperia_living_room' } as Config, + config: { entity: 'light.hypercolor_hyperia_living_room' } as Config, }) as DiscoveredPatch; expect(patch.layout_entity).toBeUndefined(); @@ -234,27 +287,31 @@ describe('hypercolorBackend.autoDiscover', () => { // `entities` knows the light but `devices` has not caught up. Treating the // child device as a hub would return a one-entity set and, since the // registry answer wins, suppress discovery entirely. - const hass = hassWith(['light.hyperia', 'select.hyperia_layout']); + const hass = hassWith(['light.hypercolor_hyperia', 'select.hypercolor_hyperia_layout']); (hass as unknown as { entities: Record }).entities = { - 'light.hyperia': { device_id: 'hub' }, - 'select.hyperia_layout': { device_id: 'hub' }, + 'light.hypercolor_hyperia': { device_id: 'hub' }, + 'select.hypercolor_hyperia_layout': { device_id: 'hub' }, }; (hass as unknown as { devices: Record }).devices = {}; const patch = hypercolorBackend.autoDiscover?.({ hass, - config: { entity: 'light.hyperia' } as Config, + config: { entity: 'light.hypercolor_hyperia' } as Config, }) as DiscoveredPatch; - expect(patch.layout_entity).toBe('select.hyperia_layout'); + expect(patch.layout_entity).toBe('select.hypercolor_hyperia_layout'); }); it('ignores an ambiguous suffix match within the hub', () => { // Two same-device entities end in `_layout`; picking one would make the // result depend on registry key order. - const hass = hassWith(['light.hyperia', 'select.custom_layout', 'select.spare_layout']); + const hass = hassWith([ + 'light.hypercolor_hyperia', + 'select.custom_layout', + 'select.spare_layout', + ]); (hass as unknown as { entities: Record }).entities = { - 'light.hyperia': { device_id: 'hub' }, + 'light.hypercolor_hyperia': { device_id: 'hub' }, 'select.custom_layout': { device_id: 'hub' }, 'select.spare_layout': { device_id: 'hub' }, }; @@ -264,29 +321,26 @@ describe('hypercolorBackend.autoDiscover', () => { const patch = hypercolorBackend.autoDiscover?.({ hass, - config: { entity: 'light.hyperia' } as Config, + config: { entity: 'light.hypercolor_hyperia' } as Config, }) as DiscoveredPatch; expect(patch.layout_entity).toBeUndefined(); }); - it('lets a child card fall back to its own hub helpers', () => { - // `light.hyperia_living_room` has no `select.hyperia_living_room_layout`, - // so it must resolve its hub's `select.hyperia_layout` by trimming its own - // slug, without reaching for an unrelated instance. + it('does not infer a parent namespace from a child name without registries', () => { const ctx = { hass: hassWith([ - 'light.hyperia', - 'light.hyperia_living_room', - 'select.hyperia_layout', + 'light.hypercolor_hyperia', + 'light.hypercolor_hyperia_living_room', + 'select.hypercolor_hyperia_layout', 'select.hypercolor_layout', ]), - config: { entity: 'light.hyperia_living_room' } as Config, + config: { entity: 'light.hypercolor_hyperia_living_room' } as Config, }; const patch = hypercolorBackend.autoDiscover?.(ctx) as DiscoveredPatch; - expect(patch.layout_entity).toBe('select.hyperia_layout'); + expect(patch.layout_entity).toBeUndefined(); }); it('scopes per-device children to the configured group, not the whole install', () => { @@ -358,22 +412,25 @@ describe('hypercolorBackend.autoDiscover', () => { it('matches hub identify buttons for a custom instance name', () => { // Discovery collects `button._identify_`, so the matcher // has to build that shape too or the Identify control silently vanishes. - const hass = hassWith(['light.hyperia_lamp', 'button.hyperia_identify_lamp']); - hass.states['light.hyperia_lamp'].state = 'on'; - hass.states['light.hyperia_lamp'].attributes = { friendly_name: 'Lamp' }; + const hass = hassWith([ + 'light.hypercolor_hyperia_lamp', + 'button.hypercolor_hyperia_identify_lamp', + ]); + hass.states['light.hypercolor_hyperia_lamp'].state = 'on'; + hass.states['light.hypercolor_hyperia_lamp'].attributes = { friendly_name: 'Lamp' }; const devices = hypercolorBackend.perDevice?.({ hass, config: { - entity: 'light.hyperia', + entity: 'light.hypercolor_hyperia', hypercolor: { - per_device_lights: ['light.hyperia_lamp'], - per_device_identify_buttons: ['button.hyperia_identify_lamp'], + per_device_lights: ['light.hypercolor_hyperia_lamp'], + per_device_identify_buttons: ['button.hypercolor_hyperia_identify_lamp'], }, }, }); - expect(devices?.[0]?.identifyEntity).toBe('button.hyperia_identify_lamp'); + expect(devices?.[0]?.identifyEntity).toBe('button.hypercolor_hyperia_identify_lamp'); }); it('separates zone lights from device lights and finds audio + render entities', () => { @@ -406,16 +463,30 @@ describe('hypercolorBackend.autoDiscover', () => { 'light.hypercolor', 'select.hypercolor_layout', 'select.hypercolor_custom_layout', + 'select.hypercolor_scene', + 'select.hypercolor_custom_scene', + 'number.hypercolor_brightness', + 'number.hypercolor_custom_brightness', ]), config: { entity: 'light.hypercolor', layout_entity: 'select.hypercolor_custom_layout', + hypercolor: { + scene_entity: 'select.hypercolor_custom_scene', + live_control_entities: { + brightness: 'number.hypercolor_custom_brightness', + }, + }, } as Config, }; const patch = hypercolorBackend.autoDiscover?.(ctx) as Partial; expect(patch.layout_entity).toBeUndefined(); + expect(patch.hypercolor?.scene_entity).toBe('select.hypercolor_custom_scene'); + expect(patch.hypercolor?.live_control_entities?.brightness).toBe( + 'number.hypercolor_custom_brightness' + ); }); }); From 3e5e3623e121a552d7f5172e2924cdbddbd961f3 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 16 Aug 2026 11:48:34 -0700 Subject: [PATCH 2/4] fix(hypercolor): retry registry-backed discovery Retry discovery when Home Assistant replaces its entity or device registry so a half-loaded first paint cannot hide renamed helpers and child devices. Keep topology registry-only to prevent name collisions from becoming sticky auto-discovered configuration. Co-Authored-By: Nova (OpenAI Codex) --- src/backends/hypercolor.ts | 25 +++++++-------- src/hyper-light-card.ts | 22 ++++++++++--- tests/backends/hypercolor.test.ts | 41 +++++++++++++----------- tests/hyper-light-card.test.ts | 53 +++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 37 deletions(-) diff --git a/src/backends/hypercolor.ts b/src/backends/hypercolor.ts index 9fad881..0389b12 100644 --- a/src/backends/hypercolor.ts +++ b/src/backends/hypercolor.ts @@ -375,7 +375,7 @@ export const hypercolorBackend: LightBackend = { } const configuredExtra = addenda(ctx.config); - const extra: HypercolorConfigAddenda = { ...configuredExtra }; + const extra: HypercolorConfigAddenda = {}; const discoverExtra = ( key: K, domain: string, @@ -399,12 +399,16 @@ export const hypercolorBackend: LightBackend = { const liveControls: Partial> = { ...configuredExtra.live_control_entities, }; + let discoveredLiveControl = false; for (const id of LIVE_CONTROL_IDS) { if (liveControls[id] !== undefined) continue; const found = findOne('number', id); - if (found) liveControls[id] = found; + if (found) { + liveControls[id] = found; + discoveredLiveControl = true; + } } - if (Object.keys(liveControls).length > 0) extra.live_control_entities = liveControls; + if (discoveredLiveControl) extra.live_control_entities = liveControls; const isZoneLight = (id: string) => ctx.hass.states[id]?.attributes.zone_id != null; let childLights: string[]; @@ -419,16 +423,9 @@ export const hypercolorBackend: LightBackend = { ); identifyButtons = [...registryScope.childEntityIds].filter(id => id.startsWith('button.')); } else { - const childPrefix = `${mainEntity}_`; - childLights = entities.filter( - id => id.startsWith(childPrefix) && id !== mainEntity && !isZoneLight(id) - ); - zoneLights = entities.filter(id => id.startsWith(childPrefix) && isZoneLight(id)); - identifyButtons = entities.filter( - id => - slugs.some(candidate => id.startsWith(`button.${candidate}_identify_`)) || - (id.startsWith('button.') && id.endsWith('_identify')) - ); + childLights = []; + zoneLights = []; + identifyButtons = []; } if (configuredExtra.per_device_lights === undefined && childLights.length > 0) { extra.per_device_lights = childLights; @@ -441,7 +438,7 @@ export const hypercolorBackend: LightBackend = { } if (Object.keys(extra).length > 0) { - patch.hypercolor = extra; + patch.hypercolor = { ...configuredExtra, ...extra }; } return patch; }, diff --git a/src/hyper-light-card.ts b/src/hyper-light-card.ts index e77f934..14571db 100644 --- a/src/hyper-light-card.ts +++ b/src/hyper-light-card.ts @@ -41,6 +41,8 @@ export class HyperLightCard extends LitElement { private _clickOutsideHandler: (event: Event) => void; private _scrolledDropdowns = new Set(); private _autoDiscovered = false; + private _discoveryEntities: unknown; + private _discoveryDevices: unknown; constructor() { super(); @@ -61,6 +63,8 @@ export class HyperLightCard extends LitElement { } this._autoDiscovered = false; + this._discoveryEntities = undefined; + this._discoveryDevices = undefined; this.config = { name: config.name, @@ -103,9 +107,7 @@ export class HyperLightCard extends LitElement { if (changedProperties.has('hass') && this.hass && this.config) { this.stateManager.hass = this.hass; - if (!this._autoDiscovered) { - this._runAutoDiscovery(); - } + this._runAutoDiscovery(); } // Scroll each newly-opened list to its active row exactly once, and re-arm @@ -674,7 +676,7 @@ export class HyperLightCard extends LitElement { /** * Shared renderer for the compact selectors (layout, preset, scene, * profile). A selector whose entity exposes no options renders nothing at - * all rather than a dead "No X available" row — Hypercolor's preset entity + * all rather than a dead "No X available" row. Hypercolor's preset entity * legitimately has an empty option list for most effects, and a permanently * disabled control reads as breakage. */ @@ -933,7 +935,15 @@ export class HyperLightCard extends LitElement { } private _runAutoDiscovery() { - if (!this.hass || !this.config || this._autoDiscovered) return; + if (!this.hass || !this.config) return; + const registries = this.hass as unknown as { entities?: unknown; devices?: unknown }; + if ( + this._autoDiscovered && + registries.entities === this._discoveryEntities && + registries.devices === this._discoveryDevices + ) { + return; + } const ctx: BackendContext = { hass: this.hass, config: this.config }; const patch = this.stateManager.backend.autoDiscover?.(ctx); if (patch && Object.keys(patch).length > 0) { @@ -945,6 +955,8 @@ export class HyperLightCard extends LitElement { this.requestUpdate(); } this._autoDiscovered = true; + this._discoveryEntities = registries.entities; + this._discoveryDevices = registries.devices; } private _scrollDropdownToSelected(wrapperSelector: string) { diff --git a/tests/backends/hypercolor.test.ts b/tests/backends/hypercolor.test.ts index e0727c0..1616578 100644 --- a/tests/backends/hypercolor.test.ts +++ b/tests/backends/hypercolor.test.ts @@ -50,7 +50,7 @@ describe('hypercolorBackend.autoDiscover', () => { expect(hypercolorBackend.autoDiscover?.(ctx)).toEqual({}); }); - it('discovers core entities and per-device children for the root card', () => { + it('discovers core helpers before registries load', () => { const ctx = { hass: hassWith([ 'light.hypercolor', @@ -76,10 +76,7 @@ describe('hypercolorBackend.autoDiscover', () => { expect(patch.hypercolor?.live_control_entities?.brightness).toBe( 'number.hypercolor_brightness' ); - expect(patch.hypercolor?.per_device_lights).toEqual([ - 'light.hypercolor_living_room', - 'light.hypercolor_living_room_lamp_1', - ]); + expect(patch.hypercolor?.per_device_lights).toBeUndefined(); }); it('discovers the product and instance namespace before registries load', () => { @@ -343,26 +340,20 @@ describe('hypercolorBackend.autoDiscover', () => { expect(patch.layout_entity).toBeUndefined(); }); - it('scopes per-device children to the configured group, not the whole install', () => { + it('does not infer topology from overlapping names before registries load', () => { const ctx = { hass: hassWith([ - 'light.hypercolor_living_room', - 'light.hypercolor_living_room_lamp_1', - 'light.hypercolor_living_room_lamp_2', - 'light.hypercolor_kitchen', - 'light.hypercolor_kitchen_strip_1', + 'light.hypercolor_hyperia', + 'light.hypercolor_hyperia_living', + 'button.hypercolor_hyperia_living_identify', ]), - config: { entity: 'light.hypercolor_living_room' } as Config, + config: { entity: 'light.hypercolor_hyperia' } as Config, }; const patch = hypercolorBackend.autoDiscover?.(ctx) as DiscoveredPatch; - expect(patch.hypercolor?.per_device_lights).toEqual([ - 'light.hypercolor_living_room_lamp_1', - 'light.hypercolor_living_room_lamp_2', - ]); - expect(patch.hypercolor?.per_device_lights).not.toContain('light.hypercolor_kitchen'); - expect(patch.hypercolor?.per_device_lights).not.toContain('light.hypercolor_kitchen_strip_1'); + expect(patch.hypercolor?.per_device_lights).toBeUndefined(); + expect(patch.hypercolor?.per_device_identify_buttons).toBeUndefined(); }); it('returns no per-device entry when the group has no children', () => { @@ -442,6 +433,18 @@ describe('hypercolorBackend.autoDiscover', () => { 'switch.hypercolor_audio_reactive': {}, 'select.hypercolor_audio_device': {}, }); + (hass as unknown as { entities: Record }).entities = { + 'light.hypercolor': { device_id: 'hub' }, + 'light.hypercolor_studio': { device_id: 'studio' }, + 'light.hypercolor_main': { device_id: 'hub' }, + 'light.hypercolor_accent': { device_id: 'hub' }, + 'switch.hypercolor_audio_reactive': { device_id: 'hub' }, + 'select.hypercolor_audio_device': { device_id: 'hub' }, + }; + (hass as unknown as { devices: Record }).devices = { + hub: { via_device_id: null }, + studio: { via_device_id: 'hub' }, + }; const patch = hypercolorBackend.autoDiscover?.({ hass, @@ -467,6 +470,7 @@ describe('hypercolorBackend.autoDiscover', () => { 'select.hypercolor_custom_scene', 'number.hypercolor_brightness', 'number.hypercolor_custom_brightness', + 'button.hypercolor_stop_effect', ]), config: { entity: 'light.hypercolor', @@ -487,6 +491,7 @@ describe('hypercolorBackend.autoDiscover', () => { expect(patch.hypercolor?.live_control_entities?.brightness).toBe( 'number.hypercolor_custom_brightness' ); + expect(patch.hypercolor?.stop_effect_entity).toBe('button.hypercolor_stop_effect'); }); }); diff --git a/tests/hyper-light-card.test.ts b/tests/hyper-light-card.test.ts index dbb7af7..23d3046 100644 --- a/tests/hyper-light-card.test.ts +++ b/tests/hyper-light-card.test.ts @@ -121,6 +121,59 @@ describe('HyperLightCard', () => { }); }); + describe('auto-discovery', () => { + it('retries when Home Assistant publishes its registries after first paint', async () => { + const states = { + 'light.hypercolor_hyperia': { + entity_id: 'light.hypercolor_hyperia', + state: 'on', + attributes: { active_effect_id: 'aurora' }, + }, + 'select.custom_layout': { + entity_id: 'select.custom_layout', + state: 'default', + attributes: { options: ['default'] }, + }, + 'light.lamp': { + entity_id: 'light.lamp', + state: 'on', + attributes: { friendly_name: 'Lamp' }, + }, + 'button.find_lamp': { + entity_id: 'button.find_lamp', + state: 'unknown', + attributes: {}, + }, + }; + const firstPaint = { states, callService: vi.fn() } as unknown as HomeAssistant; + card.setConfig({ entity: 'light.hypercolor_hyperia', backend: 'hypercolor' }); + card.hass = firstPaint; + card['_runAutoDiscovery'](); + + expect(card.config?.layout_entity).toBeUndefined(); + expect(card.config?.hypercolor?.per_device_lights).toBeUndefined(); + + card.hass = { + ...firstPaint, + entities: { + 'light.hypercolor_hyperia': { device_id: 'hub' }, + 'select.custom_layout': { device_id: 'hub' }, + 'light.lamp': { device_id: 'lamp' }, + 'button.find_lamp': { device_id: 'lamp' }, + }, + devices: { + hub: { via_device_id: null }, + lamp: { via_device_id: 'hub' }, + }, + } as unknown as HomeAssistant; + await card.updateComplete; + + expect(card.config?.layout_entity).toBe('select.custom_layout'); + expect(card.config?.hypercolor?.per_device_lights).toEqual(['light.lamp']); + expect(card.config?.hypercolor?.per_device_identify_buttons).toEqual(['button.find_lamp']); + }); + }); + describe('render', () => { it('renders without errors', () => { const renderResult = card.render(); From 91f5fbb6d291f722342fd4cf6453dd23348b4a52 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 16 Aug 2026 11:58:18 -0700 Subject: [PATCH 3/4] refactor(hypercolor): make discovery role driven Recompute the discovery overlay from the normalized source configuration on every registry revision. Match hub helpers and child actions by stable translation keys so renamed entities and dynamic devices converge without losing explicit user settings. Co-Authored-By: Nova (OpenAI Codex) --- src/backends/hypercolor.ts | 94 ++++++++----------- src/hyper-light-card.ts | 19 +++- tests/backends/hypercolor.test.ts | 148 ++++++++++++++++++++---------- tests/hyper-light-card.test.ts | 47 +++++++++- 4 files changed, 194 insertions(+), 114 deletions(-) diff --git a/src/backends/hypercolor.ts b/src/backends/hypercolor.ts index 0389b12..ff3e393 100644 --- a/src/backends/hypercolor.ts +++ b/src/backends/hypercolor.ts @@ -314,41 +314,18 @@ export const hypercolorBackend: LightBackend = { autoDiscover(ctx) { const mainEntity = ctx.config.entity; if (!mainEntity?.startsWith('light.')) return {}; - const entities = Object.keys(ctx.hass.states); - const slugs = instanceSlugs(mainEntity); - const known = new Set(entities); const registryScope = hypercolorRegistryScope(ctx.hass, mainEntity); - const hubIds = registryScope?.hubEntityIds ?? null; - - // The device registry states which entities share this card's hub, which - // entity ids alone cannot. When the registry answers, it is the only - // authority. Falling through to name matching after a registry miss would - // reintroduce cross-hub binding. A miss means the hub has no such helper. - // - // Name matching runs only when the registries aren't populated. There, - // exact ids only, against slugs derived from this card's own entity id: a - // prefix match would bind a collision-renamed neighbour, and a hard-coded - // fallback would let one instance adopt another instance's helpers. + if (!registryScope) return {}; + + // The device registry is the authority for hub membership. Entity ids can + // be renamed and can collide across integrations, so discovery never + // infers ownership from a name. const findOne = (domain: string, suffix: string) => { - if (hubIds) { - for (const candidate of slugs) { - const id = `${domain}.${candidate}_${suffix}`; - if (hubIds.has(id)) return id; - } - // Renamed helpers no longer match the slug, so fall back to the - // suffix, but only when it is unambiguous. Two candidates would make - // the pick depend on registry key order. - const matches = [...hubIds].filter( - id => id.startsWith(`${domain}.`) && id.endsWith(`_${suffix}`) - ); - return matches.length === 1 ? matches[0] : undefined; - } - for (const candidate of slugs) { - const id = `${domain}.${candidate}_${suffix}`; - if (known.has(id)) return id; - } - return undefined; + const matches = [...registryScope.hubEntities].flatMap(([id, entity]) => + id.startsWith(`${domain}.`) && entity.translation_key === suffix ? [id] : [] + ); + return matches.length === 1 ? matches[0] : undefined; }; const patch: Partial = {}; @@ -411,22 +388,15 @@ export const hypercolorBackend: LightBackend = { if (discoveredLiveControl) extra.live_control_entities = liveControls; const isZoneLight = (id: string) => ctx.hass.states[id]?.attributes.zone_id != null; - let childLights: string[]; - let zoneLights: string[]; - let identifyButtons: string[]; - if (registryScope) { - childLights = [...registryScope.childEntityIds].filter( - id => id.startsWith('light.') && id !== mainEntity && !isZoneLight(id) - ); - zoneLights = [...registryScope.hubEntityIds].filter( - id => id.startsWith('light.') && id !== mainEntity && isZoneLight(id) - ); - identifyButtons = [...registryScope.childEntityIds].filter(id => id.startsWith('button.')); - } else { - childLights = []; - zoneLights = []; - identifyButtons = []; - } + const childLights = [...registryScope.childEntities.keys()].filter( + id => id.startsWith('light.') && id !== mainEntity && !isZoneLight(id) + ); + const zoneLights = [...registryScope.hubEntities.keys()].filter( + id => id.startsWith('light.') && id !== mainEntity && isZoneLight(id) + ); + const identifyButtons = [...registryScope.childEntities].flatMap(([id, entity]) => + id.startsWith('button.') && entity.translation_key === 'identify' ? [id] : [] + ); if (configuredExtra.per_device_lights === undefined && childLights.length > 0) { extra.per_device_lights = childLights; } @@ -488,9 +458,12 @@ function liveControlLabel(id: string): string { function hypercolorRegistryScope( hass: HomeAssistant, lightEntityId: string -): { hubEntityIds: Set; childEntityIds: Set } | null { +): { + hubEntities: Map; + childEntities: Map; +} | null { const registries = hass as unknown as { - entities?: Record; + entities?: Record; devices?: Record; }; const entities = registries.entities; @@ -509,19 +482,24 @@ function hypercolorRegistryScope( candidate?.via_device_id === hubId ? [id] : [] ) ); - const hubEntityIds = new Set(); - const childEntityIds = new Set(); + const hubEntities = new Map(); + const childEntities = new Map(); for (const [id, entity] of Object.entries(entities)) { - if (entity?.device_id === hubId) hubEntityIds.add(id); - if (entity?.device_id && childDeviceIds.has(entity.device_id)) childEntityIds.add(id); + if (!entity) continue; + if (entity.device_id === hubId) hubEntities.set(id, entity); + if (entity.device_id && childDeviceIds.has(entity.device_id)) childEntities.set(id, entity); } - return hubEntityIds.size > 0 ? { hubEntityIds, childEntityIds } : null; + return hubEntities.size > 0 ? { hubEntities, childEntities } : null; +} + +interface HypercolorRegistryEntity { + device_id?: string; + translation_key?: string | null; } /** - * The complete entity slug used for name matching before Home Assistant's - * registries are populated. Partial prefixes are never candidates because - * they can belong to another Hypercolor instance. + * The complete master slug used only to match manually configured Identify + * buttons when Home Assistant's registries are unavailable. */ function instanceSlugs(entityId: string): string[] { return [entityId.slice('light.'.length)]; diff --git a/src/hyper-light-card.ts b/src/hyper-light-card.ts index 14571db..1beb768 100644 --- a/src/hyper-light-card.ts +++ b/src/hyper-light-card.ts @@ -38,6 +38,7 @@ export class HyperLightCard extends LitElement { @property({ type: Object }) config?: Config; @state() private state: State; private stateManager: StateManager; + private _sourceConfig?: Config; private _clickOutsideHandler: (event: Event) => void; private _scrolledDropdowns = new Set(); private _autoDiscovered = false; @@ -66,7 +67,7 @@ export class HyperLightCard extends LitElement { this._discoveryEntities = undefined; this._discoveryDevices = undefined; - this.config = { + this._sourceConfig = { name: config.name, // Icon defaults are backend-specific; describeCard fills in the // right one when the user has not supplied an explicit override. @@ -86,6 +87,7 @@ export class HyperLightCard extends LitElement { random_effect_entity: config.random_effect_entity, ...config, }; + this.config = this._sourceConfig; this.stateManager.cleanup(); this.stateManager = new StateManager(this.config, this.state); } @@ -935,7 +937,7 @@ export class HyperLightCard extends LitElement { } private _runAutoDiscovery() { - if (!this.hass || !this.config) return; + if (!this.hass || !this.config || !this._sourceConfig) return; const registries = this.hass as unknown as { entities?: unknown; devices?: unknown }; if ( this._autoDiscovered && @@ -944,10 +946,17 @@ export class HyperLightCard extends LitElement { ) { return; } - const ctx: BackendContext = { hass: this.hass, config: this.config }; + const ctx: BackendContext = { hass: this.hass, config: this._sourceConfig }; const patch = this.stateManager.backend.autoDiscover?.(ctx); - if (patch && Object.keys(patch).length > 0) { - this.config = { ...this.config, ...patch }; + const nextConfig: Config = { + ...this._sourceConfig, + ...patch, + hypercolor: patch?.hypercolor + ? { ...this._sourceConfig.hypercolor, ...patch.hypercolor } + : this._sourceConfig.hypercolor, + }; + if (JSON.stringify(nextConfig) !== JSON.stringify(this.config)) { + this.config = nextConfig; this.stateManager.cleanup(); this.stateManager = new StateManager(this.config, this.state); this.stateManager.hass = this.hass; diff --git a/tests/backends/hypercolor.test.ts b/tests/backends/hypercolor.test.ts index 1616578..b7bc730 100644 --- a/tests/backends/hypercolor.test.ts +++ b/tests/backends/hypercolor.test.ts @@ -50,7 +50,7 @@ describe('hypercolorBackend.autoDiscover', () => { expect(hypercolorBackend.autoDiscover?.(ctx)).toEqual({}); }); - it('discovers core helpers before registries load', () => { + it('waits for registry roles even when helper names match', () => { const ctx = { hass: hassWith([ 'light.hypercolor', @@ -66,36 +66,66 @@ describe('hypercolorBackend.autoDiscover', () => { config: { entity: 'light.hypercolor' } as Config, }; - const patch = hypercolorBackend.autoDiscover?.(ctx) as DiscoveredPatch; - - expect(patch.layout_entity).toBe('select.hypercolor_layout'); - expect(patch.preset_entity).toBe('select.hypercolor_preset'); - expect(patch.next_effect_entity).toBe('button.hypercolor_next_effect'); - expect(patch.hypercolor?.fps_entity).toBe('sensor.hypercolor_fps'); - expect(patch.hypercolor?.connected_entity).toBe('binary_sensor.hypercolor_connected'); - expect(patch.hypercolor?.live_control_entities?.brightness).toBe( - 'number.hypercolor_brightness' - ); - expect(patch.hypercolor?.per_device_lights).toBeUndefined(); + expect(hypercolorBackend.autoDiscover?.(ctx)).toEqual({}); }); - it('discovers the product and instance namespace before registries load', () => { + it('discovers the full hub surface from stable registry roles', () => { + const hass = hassWith([ + 'light.hypercolor_hyperia', + 'select.hypercolor_hyperia_layout', + 'select.hypercolor_hyperia_preset', + 'select.hypercolor_hyperia_scene', + 'button.hypercolor_hyperia_next_effect', + 'button.hypercolor_hyperia_previous_effect', + 'button.hypercolor_hyperia_random_effect', + 'button.hypercolor_hyperia_stop_effect', + 'sensor.hypercolor_hyperia_fps', + 'binary_sensor.hypercolor_hyperia_connected', + 'switch.hypercolor_hyperia_audio_reactive', + 'number.hypercolor_hyperia_brightness', + 'number.hypercolor_hyperia_speed', + ]); + (hass as unknown as { entities: Record }).entities = { + 'light.hypercolor_hyperia': { device_id: 'hub' }, + 'select.hypercolor_hyperia_layout': { device_id: 'hub', translation_key: 'layout' }, + 'select.hypercolor_hyperia_preset': { device_id: 'hub', translation_key: 'preset' }, + 'select.hypercolor_hyperia_scene': { device_id: 'hub', translation_key: 'scene' }, + 'button.hypercolor_hyperia_next_effect': { + device_id: 'hub', + translation_key: 'next_effect', + }, + 'button.hypercolor_hyperia_previous_effect': { + device_id: 'hub', + translation_key: 'previous_effect', + }, + 'button.hypercolor_hyperia_random_effect': { + device_id: 'hub', + translation_key: 'random_effect', + }, + 'button.hypercolor_hyperia_stop_effect': { + device_id: 'hub', + translation_key: 'stop_effect', + }, + 'sensor.hypercolor_hyperia_fps': { device_id: 'hub', translation_key: 'fps' }, + 'binary_sensor.hypercolor_hyperia_connected': { + device_id: 'hub', + translation_key: 'connected', + }, + 'switch.hypercolor_hyperia_audio_reactive': { + device_id: 'hub', + translation_key: 'audio_reactive', + }, + 'number.hypercolor_hyperia_brightness': { + device_id: 'hub', + translation_key: 'brightness', + }, + 'number.hypercolor_hyperia_speed': { device_id: 'hub', translation_key: 'speed' }, + }; + (hass as unknown as { devices: Record }).devices = { + hub: { via_device_id: null }, + }; const ctx = { - hass: hassWith([ - 'light.hypercolor_hyperia', - 'select.hypercolor_hyperia_layout', - 'select.hypercolor_hyperia_preset', - 'select.hypercolor_hyperia_scene', - 'button.hypercolor_hyperia_next_effect', - 'button.hypercolor_hyperia_previous_effect', - 'button.hypercolor_hyperia_random_effect', - 'button.hypercolor_hyperia_stop_effect', - 'sensor.hypercolor_hyperia_fps', - 'binary_sensor.hypercolor_hyperia_connected', - 'switch.hypercolor_hyperia_audio_reactive', - 'number.hypercolor_hyperia_brightness', - 'number.hypercolor_hyperia_speed', - ]), + hass, config: { entity: 'light.hypercolor_hyperia' } as Config, }; @@ -136,12 +166,12 @@ describe('hypercolorBackend.autoDiscover', () => { hass.states['light.lamp'].attributes = { friendly_name: 'Lamp' }; (hass as unknown as { entities: Record }).entities = { 'light.hypercolor_hyperia': { device_id: 'hub' }, - 'select.custom_layout': { device_id: 'hub' }, + 'select.custom_layout': { device_id: 'hub', translation_key: 'layout' }, 'light.hypercolor_hyperia_default_zone': { device_id: 'hub' }, 'light.lamp': { device_id: 'lamp' }, - 'button.find_lamp': { device_id: 'lamp' }, + 'button.find_lamp': { device_id: 'lamp', translation_key: 'identify' }, 'light.other_lamp': { device_id: 'other_lamp' }, - 'button.find_other_lamp': { device_id: 'other_lamp' }, + 'button.find_other_lamp': { device_id: 'other_lamp', translation_key: 'identify' }, }; (hass as unknown as { devices: Record }).devices = { hub: { via_device_id: null }, @@ -230,10 +260,13 @@ describe('hypercolorBackend.autoDiscover', () => { ]); (hass as unknown as { entities: Record }).entities = { 'light.hypercolor_hyperia': { device_id: 'hub' }, - 'select.hypercolor_hyperia_layout': { device_id: 'hub' }, + 'select.hypercolor_hyperia_layout': { device_id: 'hub', translation_key: 'layout' }, 'light.hypercolor_hyperia_living_room': { device_id: 'child' }, 'light.hypercolor_hyperia_living': { device_id: 'other_hub' }, - 'select.hypercolor_hyperia_living_layout': { device_id: 'other_hub' }, + 'select.hypercolor_hyperia_living_layout': { + device_id: 'other_hub', + translation_key: 'layout', + }, }; (hass as unknown as { devices: Record }).devices = { hub: { via_device_id: null }, @@ -280,7 +313,7 @@ describe('hypercolorBackend.autoDiscover', () => { expect(patch.layout_entity).toBeUndefined(); }); - it('keeps name matching when the device registry is only half loaded', () => { + it('waits when the device registry is only half loaded', () => { // `entities` knows the light but `devices` has not caught up. Treating the // child device as a hub would return a one-entity set and, since the // registry answer wins, suppress discovery entirely. @@ -296,7 +329,7 @@ describe('hypercolorBackend.autoDiscover', () => { config: { entity: 'light.hypercolor_hyperia' } as Config, }) as DiscoveredPatch; - expect(patch.layout_entity).toBe('select.hypercolor_hyperia_layout'); + expect(patch.layout_entity).toBeUndefined(); }); it('ignores an ambiguous suffix match within the hub', () => { @@ -309,8 +342,8 @@ describe('hypercolorBackend.autoDiscover', () => { ]); (hass as unknown as { entities: Record }).entities = { 'light.hypercolor_hyperia': { device_id: 'hub' }, - 'select.custom_layout': { device_id: 'hub' }, - 'select.spare_layout': { device_id: 'hub' }, + 'select.custom_layout': { device_id: 'hub', translation_key: 'layout' }, + 'select.spare_layout': { device_id: 'hub', translation_key: 'layout' }, }; (hass as unknown as { devices: Record }).devices = { hub: { via_device_id: null }, @@ -438,8 +471,14 @@ describe('hypercolorBackend.autoDiscover', () => { 'light.hypercolor_studio': { device_id: 'studio' }, 'light.hypercolor_main': { device_id: 'hub' }, 'light.hypercolor_accent': { device_id: 'hub' }, - 'switch.hypercolor_audio_reactive': { device_id: 'hub' }, - 'select.hypercolor_audio_device': { device_id: 'hub' }, + 'switch.hypercolor_audio_reactive': { + device_id: 'hub', + translation_key: 'audio_reactive', + }, + 'select.hypercolor_audio_device': { + device_id: 'hub', + translation_key: 'audio_device', + }, }; (hass as unknown as { devices: Record }).devices = { hub: { via_device_id: null }, @@ -461,17 +500,28 @@ describe('hypercolorBackend.autoDiscover', () => { }); it('does not overwrite explicitly configured entities', () => { + const hass = hassWith([ + 'light.hypercolor', + 'select.hypercolor_layout', + 'select.hypercolor_custom_layout', + 'select.hypercolor_scene', + 'select.hypercolor_custom_scene', + 'number.hypercolor_brightness', + 'number.hypercolor_custom_brightness', + 'button.hypercolor_stop_effect', + ]); + (hass as unknown as { entities: Record }).entities = { + 'light.hypercolor': { device_id: 'hub' }, + 'button.hypercolor_stop_effect': { + device_id: 'hub', + translation_key: 'stop_effect', + }, + }; + (hass as unknown as { devices: Record }).devices = { + hub: { via_device_id: null }, + }; const ctx = { - hass: hassWith([ - 'light.hypercolor', - 'select.hypercolor_layout', - 'select.hypercolor_custom_layout', - 'select.hypercolor_scene', - 'select.hypercolor_custom_scene', - 'number.hypercolor_brightness', - 'number.hypercolor_custom_brightness', - 'button.hypercolor_stop_effect', - ]), + hass, config: { entity: 'light.hypercolor', layout_entity: 'select.hypercolor_custom_layout', diff --git a/tests/hyper-light-card.test.ts b/tests/hyper-light-card.test.ts index 23d3046..0d4f84f 100644 --- a/tests/hyper-light-card.test.ts +++ b/tests/hyper-light-card.test.ts @@ -157,9 +157,9 @@ describe('HyperLightCard', () => { ...firstPaint, entities: { 'light.hypercolor_hyperia': { device_id: 'hub' }, - 'select.custom_layout': { device_id: 'hub' }, + 'select.custom_layout': { device_id: 'hub', translation_key: 'layout' }, 'light.lamp': { device_id: 'lamp' }, - 'button.find_lamp': { device_id: 'lamp' }, + 'button.find_lamp': { device_id: 'lamp', translation_key: 'identify' }, }, devices: { hub: { via_device_id: null }, @@ -171,6 +171,49 @@ describe('HyperLightCard', () => { expect(card.config?.layout_entity).toBe('select.custom_layout'); expect(card.config?.hypercolor?.per_device_lights).toEqual(['light.lamp']); expect(card.config?.hypercolor?.per_device_identify_buttons).toEqual(['button.find_lamp']); + + card.hass = { + ...firstPaint, + states: { + ...states, + 'select.spatial_picker': { + entity_id: 'select.spatial_picker', + state: 'default', + attributes: { options: ['default'] }, + }, + 'light.new_strip': { + entity_id: 'light.new_strip', + state: 'on', + attributes: { friendly_name: 'New strip' }, + }, + 'button.locate_strip': { + entity_id: 'button.locate_strip', + state: 'unknown', + attributes: {}, + }, + }, + entities: { + 'light.hypercolor_hyperia': { device_id: 'hub' }, + 'select.spatial_picker': { device_id: 'hub', translation_key: 'layout' }, + 'light.lamp': { device_id: 'lamp' }, + 'button.find_lamp': { device_id: 'lamp', translation_key: 'identify' }, + 'light.new_strip': { device_id: 'strip' }, + 'button.locate_strip': { device_id: 'strip', translation_key: 'identify' }, + }, + devices: { + hub: { via_device_id: null }, + lamp: { via_device_id: 'hub' }, + strip: { via_device_id: 'hub' }, + }, + } as unknown as HomeAssistant; + await card.updateComplete; + + expect(card.config?.layout_entity).toBe('select.spatial_picker'); + expect(card.config?.hypercolor?.per_device_lights).toEqual(['light.lamp', 'light.new_strip']); + expect(card.config?.hypercolor?.per_device_identify_buttons).toEqual([ + 'button.find_lamp', + 'button.locate_strip', + ]); }); }); From 4bfc119789429de44e2b3224cc9b0f070fa09a52 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 16 Aug 2026 13:25:39 -0700 Subject: [PATCH 4/4] fix(hypercolor): harden registry discovery Zone membership now comes from the hub registry topology. Cards configured for a child device leave zones undiscovered instead of treating the master light as one. Registry access uses one typed seam. Config refreshes use structural equality, and the documentation describes the registry lifecycle accurately. Co-Authored-By: Nova (OpenAI Codex) --- README.md | 12 ++++--- src/backends/hypercolor.ts | 53 ++++++++++++++++--------------- src/hyper-light-card.ts | 4 +-- src/utils.ts | 26 +++++++++++++++ tests/backends/hypercolor.test.ts | 5 +-- tests/utils.test.ts | 20 +++++++++++- 6 files changed, 83 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 4bc9731..970a2a2 100644 --- a/README.md +++ b/README.md @@ -235,8 +235,9 @@ for those. #### Hypercolor helper entities -All of these live under the `hypercolor:` key and are auto-discovered from your -instance name. Set one only when you need to override discovery. +All of these live under the `hypercolor:` key and are auto-discovered from Home +Assistant's device and entity registries. Set one only when you need to override +discovery. | Option | Type | Description | | ------------------------------ | -------- | ------------------------------------------------------- | @@ -302,9 +303,10 @@ Detection uses attributes the Hypercolor light publishes (`effect_controls`, not expose them yet. Set `backend: hypercolor` to pin it. **Companion entities aren't discovered.** -Discovery derives sibling names from the entity you configure, so it expects -`select._layout` next to `light.`. If you have renamed -entity IDs away from that pattern, wire them explicitly under `hypercolor:`. +Discovery waits for Home Assistant's device and entity registries, so helpers +may appear after the card's first paint. A helper attached to a different device +cannot be associated with the Hypercolor hub automatically. Pin that helper +explicitly under `hypercolor:`. ## 👩‍💻 Development diff --git a/src/backends/hypercolor.ts b/src/backends/hypercolor.ts index ff3e393..7da2955 100644 --- a/src/backends/hypercolor.ts +++ b/src/backends/hypercolor.ts @@ -387,13 +387,14 @@ export const hypercolorBackend: LightBackend = { } if (discoveredLiveControl) extra.live_control_entities = liveControls; - const isZoneLight = (id: string) => ctx.hass.states[id]?.attributes.zone_id != null; const childLights = [...registryScope.childEntities.keys()].filter( - id => id.startsWith('light.') && id !== mainEntity && !isZoneLight(id) - ); - const zoneLights = [...registryScope.hubEntities.keys()].filter( - id => id.startsWith('light.') && id !== mainEntity && isZoneLight(id) + id => id.startsWith('light.') && id !== mainEntity ); + const zoneLights = registryScope.hubEntities.has(mainEntity) + ? [...registryScope.hubEntities.keys()].filter( + id => id.startsWith('light.') && id !== mainEntity + ) + : []; const identifyButtons = [...registryScope.childEntities].flatMap(([id, entity]) => id.startsWith('button.') && entity.translation_key === 'identify' ? [id] : [] ); @@ -462,12 +463,7 @@ function hypercolorRegistryScope( hubEntities: Map; childEntities: Map; } | null { - const registries = hass as unknown as { - entities?: Record; - devices?: Record; - }; - const entities = registries.entities; - const devices = registries.devices; + const { entities, devices } = hypercolorRegistries(hass); if (!entities || !devices) return null; const deviceId = entities[lightEntityId]?.device_id; if (!deviceId) return null; @@ -493,10 +489,27 @@ function hypercolorRegistryScope( } interface HypercolorRegistryEntity { + config_entry_id?: string; device_id?: string; translation_key?: string | null; } +interface HypercolorRegistryDevice { + config_entries?: string[]; + primary_config_entry?: string; + via_device_id?: string | null; +} + +function hypercolorRegistries(hass: HomeAssistant): { + entities?: Record; + devices?: Record; +} { + return hass as unknown as { + entities?: Record; + devices?: Record; + }; +} + /** * The complete master slug used only to match manually configured Identify * buttons when Home Assistant's registries are unavailable. @@ -529,11 +542,7 @@ function siblingEntityOnDevice( entityId: string, candidates: string[] ): string | null | undefined { - const entities = ( - hass as unknown as { - entities?: Record; - } - ).entities; + const { entities } = hypercolorRegistries(hass); if (!entities) return undefined; const deviceId = entities[entityId]?.device_id; if (!deviceId) return undefined; @@ -788,18 +797,12 @@ function colorToHex(value: unknown): string { * caller degrades gracefully (the control simply won't commit via service). */ function configEntryId(hass: HomeAssistant, entityId: string): string | undefined { - const registries = hass as unknown as { - entities?: Record; - devices?: Record< - string, - { primary_config_entry?: string; config_entries?: string[] } | undefined - >; - }; - const entity = registries.entities?.[entityId]; + const { entities, devices } = hypercolorRegistries(hass); + const entity = entities?.[entityId]; if (entity?.config_entry_id) return entity.config_entry_id; const deviceId = entity?.device_id; if (deviceId) { - const device = registries.devices?.[deviceId]; + const device = devices?.[deviceId]; return device?.primary_config_entry ?? device?.config_entries?.[0]; } return undefined; diff --git a/src/hyper-light-card.ts b/src/hyper-light-card.ts index 1beb768..277c3d3 100644 --- a/src/hyper-light-card.ts +++ b/src/hyper-light-card.ts @@ -27,7 +27,7 @@ import { HyperLightCardEditor } from './hyper-light-card-editor'; import styleText from './hyper-light-card-styles.css?inline'; import { State } from './state'; import { StateManager } from './state-manager'; -import { formatAttributeKey, formatAttributeValue } from './utils'; +import { formatAttributeKey, formatAttributeValue, structurallyEqual } from './utils'; if (!customElements.get('hyper-light-card-editor')) { customElements.define('hyper-light-card-editor', HyperLightCardEditor); @@ -955,7 +955,7 @@ export class HyperLightCard extends LitElement { ? { ...this._sourceConfig.hypercolor, ...patch.hypercolor } : this._sourceConfig.hypercolor, }; - if (JSON.stringify(nextConfig) !== JSON.stringify(this.config)) { + if (!structurallyEqual(nextConfig, this.config)) { this.config = nextConfig; this.stateManager.cleanup(); this.stateManager = new StateManager(this.config, this.state); diff --git a/src/utils.ts b/src/utils.ts index 8326236..6f127f6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -88,6 +88,32 @@ export function convertCardBrightnessToHA(cardBrightness: number): number { return Math.round((cardBrightness / 100) * 252) + 3; } +export function structurallyEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + return left.every((value, index) => structurallyEqual(value, right[index])); + } + if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') { + return false; + } + if (!isPlainRecord(left) || !isPlainRecord(right)) return false; + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) return false; + return leftKeys.every( + key => Object.hasOwn(right, key) && structurallyEqual(left[key], right[key]) + ); +} + +function isPlainRecord(value: object): value is Record { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + // Logging utility // This will be replaced at build time with a literal true/false by vite's define plugin declare const __IS_LOGGING_ENABLED__: boolean; diff --git a/tests/backends/hypercolor.test.ts b/tests/backends/hypercolor.test.ts index b7bc730..0fc0335 100644 --- a/tests/backends/hypercolor.test.ts +++ b/tests/backends/hypercolor.test.ts @@ -153,15 +153,11 @@ describe('hypercolorBackend.autoDiscover', () => { const hass = hassWith([ 'light.hypercolor_hyperia', 'select.custom_layout', - 'light.hypercolor_hyperia_default_zone', 'light.lamp', 'button.find_lamp', 'light.other_lamp', 'button.find_other_lamp', ]); - hass.states['light.hypercolor_hyperia_default_zone'].attributes = { - zone_id: 'zone_default', - }; hass.states['light.lamp'].state = 'on'; hass.states['light.lamp'].attributes = { friendly_name: 'Lamp' }; (hass as unknown as { entities: Record }).entities = { @@ -280,6 +276,7 @@ describe('hypercolorBackend.autoDiscover', () => { }) as DiscoveredPatch; expect(patch.layout_entity).toBe('select.hypercolor_hyperia_layout'); + expect(patch.hypercolor?.zone_lights).toBeUndefined(); }); it('does not fall back to names when the registry answers but lacks the helper', () => { diff --git a/tests/utils.test.ts b/tests/utils.test.ts index a57114d..fc9d068 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,7 +1,12 @@ import chroma from 'chroma-js'; import { render } from 'lit'; import { describe, expect, test } from 'vitest'; -import { formatAttributeKey, formatAttributeValue, getAccessibleTextColors } from '@/utils'; +import { + formatAttributeKey, + formatAttributeValue, + getAccessibleTextColors, + structurallyEqual, +} from '@/utils'; describe('getAccessibleTextColors', () => { test.each([ @@ -50,4 +55,17 @@ describe('Formatting Functions', () => { expect(formatAttributeValue('Single Color', 'combobox')).toBe('Single Color'); }); + + test('structurallyEqual ignores object key order without dropping undefined fields', () => { + expect( + structurallyEqual( + { hypercolor: { zone_lights: ['light.zone'] }, layout_entity: 'select.layout' }, + { layout_entity: 'select.layout', hypercolor: { zone_lights: ['light.zone'] } } + ) + ).toBe(true); + expect(structurallyEqual({ layout_entity: undefined }, {})).toBe(false); + expect(structurallyEqual(new Date(0), new Date(1))).toBe(false); + expect(structurallyEqual(new Map([['zone', 1]]), new Map([['zone', 2]]))).toBe(false); + expect(structurallyEqual(/zone/, /device/)).toBe(false); + }); });