diff --git a/README.md b/README.md index 969dedc..970a2a2 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 @@ -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/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..7da2955 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, @@ -311,44 +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 hubIds = hubEntityIds(ctx.hass, mainEntity); - - // 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. - // - // 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. + + const registryScope = hypercolorRegistryScope(ctx.hass, mainEntity); + 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 = {}; @@ -374,61 +351,65 @@ export const hypercolorBackend: LightBackend = { if (found) patch.random_effect_entity = found; } + const configuredExtra = addenda(ctx.config); 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 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, + }; + 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; - - // 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) + if (discoveredLiveControl) extra.live_control_entities = liveControls; + + const childLights = [...registryScope.childEntities.keys()].filter( + id => id.startsWith('light.') && id !== mainEntity ); - 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')) + 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] : [] ); - 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; + 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; + patch.hypercolor = { ...configuredExtra, ...extra }; } return patch; }, @@ -472,19 +453,17 @@ 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 { - const registries = hass as unknown as { - entities?: Record; - devices?: Record; - }; - const entities = registries.entities; - const devices = registries.devices; +function hypercolorRegistryScope( + hass: HomeAssistant, + lightEntityId: string +): { + hubEntities: Map; + childEntities: Map; +} | null { + const { entities, devices } = hypercolorRegistries(hass); if (!entities || !devices) return null; const deviceId = entities[lightEntityId]?.device_id; if (!deviceId) return null; @@ -494,24 +473,49 @@ 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 hubEntities = new Map(); + const childEntities = new Map(); + for (const [id, entity] of Object.entries(entities)) { + 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 hubEntities.size > 0 ? { hubEntities, childEntities } : null; +} + +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; + }; } /** - * 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 master slug used only to match manually configured Identify + * buttons when Home Assistant's registries are unavailable. */ 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 +537,19 @@ function identifyEntityFor( return identifyButtons.find(id => candidates.has(id)) ?? null; } +function siblingEntityOnDevice( + hass: HomeAssistant, + entityId: string, + candidates: string[] +): string | null | undefined { + const { entities } = hypercolorRegistries(hass); + 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 +593,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' @@ -780,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 e77f934..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); @@ -38,9 +38,12 @@ 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; + private _discoveryEntities: unknown; + private _discoveryDevices: unknown; constructor() { super(); @@ -61,8 +64,10 @@ export class HyperLightCard extends LitElement { } this._autoDiscovered = false; + 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. @@ -82,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); } @@ -103,9 +109,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 +678,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,11 +937,26 @@ export class HyperLightCard extends LitElement { } private _runAutoDiscovery() { - if (!this.hass || !this.config || this._autoDiscovered) return; - const ctx: BackendContext = { hass: this.hass, config: this.config }; + if (!this.hass || !this.config || !this._sourceConfig) 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._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 (!structurallyEqual(nextConfig, this.config)) { + this.config = nextConfig; this.stateManager.cleanup(); this.stateManager = new StateManager(this.config, this.state); this.stateManager.hass = this.hass; @@ -945,6 +964,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/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/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..0fc0335 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('waits for registry roles even when helper names match', () => { const ctx = { hass: hassWith([ 'light.hypercolor', @@ -66,68 +66,144 @@ 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).toEqual([ - 'light.hypercolor_living_room', - 'light.hypercolor_living_room_lamp_1', - ]); + expect(hypercolorBackend.autoDiscover?.(ctx)).toEqual({}); }); - 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 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.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', - ]), - config: { entity: 'light.hyperia' } as Config, + hass, + 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.lamp', + 'button.find_lamp', + 'light.other_lamp', + 'button.find_other_lamp', + ]); + 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', translation_key: 'layout' }, + 'light.hypercolor_hyperia_default_zone': { device_id: 'hub' }, + 'light.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', translation_key: 'identify' }, + }; + (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 +218,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 +234,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 +245,24 @@ 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', 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', + translation_key: 'layout', + }, }; (hass as unknown as { devices: Record }).devices = { hub: { via_device_id: null }, @@ -193,10 +272,11 @@ 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'); + expect(patch.hypercolor?.zone_lights).toBeUndefined(); }); it('does not fall back to names when the registry answers but lacks the helper', () => { @@ -204,17 +284,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,39 +304,43 @@ 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(); }); - 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. - 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).toBeUndefined(); }); 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' }, - 'select.custom_layout': { device_id: 'hub' }, - 'select.spare_layout': { device_id: 'hub' }, + 'light.hypercolor_hyperia': { 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 }, @@ -264,51 +348,42 @@ 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', () => { + 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', () => { @@ -358,22 +433,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', () => { @@ -385,6 +463,24 @@ 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', + 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 }, + studio: { via_device_id: 'hub' }, + }; const patch = hypercolorBackend.autoDiscover?.({ hass, @@ -401,21 +497,48 @@ 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', - ]), + hass, 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' + ); + 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..0d4f84f 100644 --- a/tests/hyper-light-card.test.ts +++ b/tests/hyper-light-card.test.ts @@ -121,6 +121,102 @@ 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', translation_key: 'layout' }, + 'light.lamp': { device_id: 'lamp' }, + 'button.find_lamp': { device_id: 'lamp', translation_key: 'identify' }, + }, + 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']); + + 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', + ]); + }); + }); + describe('render', () => { it('renders without errors', () => { const renderResult = card.render(); 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); + }); });