diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e565a8f..716bac6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ on: default: false permissions: + actions: write contents: write concurrency: @@ -130,6 +131,48 @@ jobs: git push origin HEAD:${{ github.ref_name }} git push origin "v${{ steps.version.outputs.version }}" + # A tag pushed with GITHUB_TOKEN never fires cicd.yml's push trigger + # (GitHub suppresses workflow runs caused by that token), so the build + # and release job is dispatched by hand on the tag ref and watched + # here until it finishes. + - name: Dispatch and watch the build and release + id: publish + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: v${{ steps.version.outputs.version }} + run: | + set -euo pipefail + candidate_sha="$(git rev-parse HEAD)" + before_id="$( + gh run list --workflow cicd.yml --event workflow_dispatch \ + --branch "${TAG}" --commit "${candidate_sha}" --limit 1 \ + --json databaseId --jq '.[0].databaseId // empty' + )" + gh workflow run cicd.yml --ref "${TAG}" -f release_run_id="${GITHUB_RUN_ID}" + + run_id="" + for _ in {1..30}; do + candidate_id="$( + gh run list --workflow cicd.yml --event workflow_dispatch \ + --branch "${TAG}" --commit "${candidate_sha}" --limit 1 \ + --json databaseId --jq '.[0].databaseId // empty' + )" + if [[ -n "${candidate_id}" && "${candidate_id}" != "${before_id}" ]]; then + run_id="${candidate_id}" + break + fi + sleep 2 + done + if [[ -z "${run_id}" ]]; then + echo "::error::Timed out locating the cicd.yml run for ${TAG}" + exit 1 + fi + + run_url="$(gh run view "${run_id}" --json url --jq '.url')" + echo "run_url=${run_url}" >> "${GITHUB_OUTPUT}" + gh run watch "${run_id}" --exit-status + - name: Summary run: | echo "## Release ${{ inputs.dry_run && '[DRY RUN] ' || '' }}v${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY @@ -139,5 +182,5 @@ jobs: else echo "Version bumped: ${{ steps.version.outputs.current }} -> ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY echo "Tag pushed: v${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "The tag push will run cicd.yml and attach the release artifact." >> $GITHUB_STEP_SUMMARY + echo "Build and release run: ${{ steps.publish.outputs.run_url }}" >> $GITHUB_STEP_SUMMARY fi diff --git a/README.md b/README.md index 970a2a2..1f3a5f4 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ _A dazzling custom card for controlling SignalRGB and Hypercolor lights through - πŸ”€ Effect switching with an intelligent dropdown menu that highlights and scrolls to the running effect - ⌨️ Keyboard-navigable selectors with visible focus rings - ℹ️ Rich effect info: description, publisher, and tag chips -- πŸŽ›οΈ Full effect controls: sliders, toggles, palette pickers, and color swatches +- πŸŽ›οΈ Live effect controls: sliders, toggles, and read-only palette and color swatches - πŸ“Š Layout and preset selection for SignalRGB -- 🎬 Scenes and profiles for Hypercolor, with live runtime controls +- 🎬 Scenes for Hypercolor, with live runtime controls - πŸ—ΊοΈ Scene zone controls for tuning each render group's brightness and power - 🎧 Audio controls for toggling reactivity and picking the input device - πŸ›°οΈ Status chips for FPS, audio reactivity, and connectivity (Hypercolor) @@ -140,7 +140,6 @@ name: 'Living Room' show_status_chips: true show_live_controls: true show_scene_select: true -show_profile_select: true show_audio_controls: true show_zones: true show_per_device: false @@ -163,7 +162,6 @@ entity: light.hypercolor_hyperia backend: hypercolor hypercolor: 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 @@ -221,8 +219,7 @@ These default to the device-scoped helpers exposed by the SignalRGB integration, | Option | Type | Default | Description | | --------------------- | ------- | ------- | -------------------------------------------------------- | | `show_scene_select` | boolean | `true` | Show the scene selector dropdown | -| `show_profile_select` | boolean | `false` | Show the profile selector dropdown | -| `show_live_controls` | boolean | `true` | Show effect controls (sliders, toggles, palette + color pickers) | +| `show_live_controls` | boolean | `true` | Show effect controls (sliders, toggles, palette and color swatches) | | `show_audio_controls` | boolean | `true` | Show the audio-reactive toggle and input device selector (when present) | | `show_zones` | boolean | `true` | Show scene zone controls for per-group brightness and power (when present) | | `show_status_chips` | boolean | `true` | Show FPS, audio reactivity, and connectivity chips | @@ -242,7 +239,6 @@ discovery. | Option | Type | Description | | ------------------------------ | -------- | ------------------------------------------------------- | | `scene_entity` | string | Scene `select` entity | -| `profile_entity` | string | Profile `select` entity | | `stop_effect_entity` | string | Stop-effect `button` entity | | `fps_entity` | string | Render-rate `sensor` for the FPS chip | | `connected_entity` | string | Daemon connectivity `binary_sensor` | @@ -264,13 +260,13 @@ hyper-light-card adapts to whichever backend it detects: - **Power Toggle**: Click the light icon to turn the light on or off. - **Effect Selection**: Use the dropdown to choose from available effects. -- **Layout & Preset / Scene & Profile**: SignalRGB exposes layouts and presets; Hypercolor exposes scenes and (optionally) profiles. +- **Layout & Preset / Scene**: SignalRGB exposes layouts and presets; Hypercolor exposes scenes. - **Effect Navigation**: Cycle through effects with next, previous, and random buttons. - **Brightness Control**: Adjust brightness with the slider. - **Live Controls**: Drag Hypercolor's intensity, speed, and parameter sliders without flooding the bus. Input is coalesced while you drag and committed on release. - **Keyboard**: Tab to any selector, then Enter or Space to open it and to pick a row. - **Status Chips**: Hypercolor surfaces FPS, audio reactivity, and connectivity at a glance. -- **Per-Device Drilldown**: Expand grouped Hypercolor installs to see and toggle each child light. +- **Per-Device Drilldown**: Expand grouped Hypercolor installs to see each child light's brightness and flash it with Identify. - **Effect Info & Parameters**: Description, publisher, and current parameter values for the running effect. The card pulls its accent palette from the running effect image, giving each effect its own coherent look. The extracted background is measured against the WCAG relative-luminance threshold to pick black or white foreground text, so labels stay readable on any cover art. diff --git a/package.json b/package.json index 53db10f..6d13491 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hyper-light-card", "version": "1.0.0", - "description": "A custom Lovelace card for Home Assistant to control SignalRGB", + "description": "A custom Lovelace card for Home Assistant that controls SignalRGB and Hypercolor lighting", "author": "Stefanie Jane ", "license": "Apache 2.0", "keywords": [ diff --git a/src/backends/hypercolor.ts b/src/backends/hypercolor.ts index 7da2955..88e1909 100644 --- a/src/backends/hypercolor.ts +++ b/src/backends/hypercolor.ts @@ -89,10 +89,6 @@ export const hypercolorBackend: LightBackend = { return readSelectModel(ctx.hass, addenda(ctx.config).scene_entity); }, - profiles(ctx) { - return readSelectModel(ctx.hass, addenda(ctx.config).profile_entity); - }, - navigation(ctx) { const extra = addenda(ctx.config); return { @@ -255,12 +251,6 @@ export const hypercolorBackend: LightBackend = { await ctx.hass.callService('select', 'select_option', { entity_id: entityId, option: value }); }, - async setProfile(ctx, value) { - const entityId = addenda(ctx.config).profile_entity; - if (!entityId) return; - await ctx.hass.callService('select', 'select_option', { entity_id: entityId, option: value }); - }, - async setLiveControl(ctx, id, value) { const entityId = addenda(ctx.config).live_control_entities?.[id as HypercolorLiveControlId]; // The four canonical controls have smooth `number.*` entities; use them so @@ -363,7 +353,6 @@ export const hypercolorBackend: LightBackend = { 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'); @@ -431,7 +420,6 @@ export const hypercolorBackend: LightBackend = { show_preset_select: true, show_effect_controls: true, show_scene_select: true, - show_profile_select: false, show_live_controls: true, show_status_chips: true, show_per_device: false, @@ -550,12 +538,22 @@ function siblingEntityOnDevice( return matches.length === 1 ? matches[0] : null; } +/** + * A select whose entity reports `unknown` or `unavailable` has no current + * option. Home Assistant uses those sentinel states when `current_option` + * is None, and they must not leak into the header as literal text. + */ +function selectCurrent(state: string | undefined): string { + if (!state || state === 'unknown' || state === 'unavailable') return ''; + return state; +} + function readSelectModel(hass: HomeAssistant, entityId?: string): SelectModel | null { if (!entityId) return null; const stateObj = hass.states[entityId]; if (!stateObj) return { current: '', options: [], available: false }; return { - current: stateObj.state ?? '', + current: selectCurrent(stateObj.state), options: Array.isArray(stateObj.attributes.options) ? (stateObj.attributes.options as string[]) : [], diff --git a/src/backends/signalrgb.ts b/src/backends/signalrgb.ts index 6771af4..25a368b 100644 --- a/src/backends/signalrgb.ts +++ b/src/backends/signalrgb.ts @@ -198,12 +198,22 @@ export const signalRgbBackend: LightBackend = { }, }; +/** + * A select whose entity reports `unknown` or `unavailable` has no current + * option. Home Assistant uses those sentinel states when `current_option` + * is None, and they must not leak into the header as literal text. + */ +function selectCurrent(state: string | undefined): string { + if (!state || state === 'unknown' || state === 'unavailable') return ''; + return state; +} + function readSelectModel(hass: HomeAssistant, entityId?: string): SelectModel | null { if (!entityId) return null; const stateObj = hass.states[entityId]; if (!stateObj) return { current: '', options: [], available: false }; return { - current: stateObj.state ?? '', + current: selectCurrent(stateObj.state), options: Array.isArray(stateObj.attributes.options) ? (stateObj.attributes.options as string[]) : [], diff --git a/src/backends/types.ts b/src/backends/types.ts index ffe8d06..4cd3f96 100644 --- a/src/backends/types.ts +++ b/src/backends/types.ts @@ -152,7 +152,7 @@ export interface ZoneModel { * (SignalRGB, Hypercolor, future ones) behind view models. Render code * consumes the view models and never reads `stateObj.attributes` directly. * - * Optional methods (`scenes`, `profiles`, `liveControls`, etc.) signal that + * Optional methods (`scenes`, `liveControls`, etc.) signal that * the backend exposes a richer surface; the card gates UI accordingly. */ export interface LightBackend { @@ -166,7 +166,6 @@ export interface LightBackend { layouts(ctx: BackendContext): SelectModel | null; presets(ctx: BackendContext): SelectModel | null; scenes?(ctx: BackendContext): SelectModel | null; - profiles?(ctx: BackendContext): SelectModel | null; navigation(ctx: BackendContext): NavigationModel; liveControls?(ctx: BackendContext): LiveControlModel[]; @@ -183,7 +182,6 @@ export interface LightBackend { setLayout(ctx: BackendContext, value: string): Promise; setPreset(ctx: BackendContext, value: string): Promise; setScene?(ctx: BackendContext, value: string): Promise; - setProfile?(ctx: BackendContext, value: string): Promise; setLiveControl?(ctx: BackendContext, id: string, value: number | string | boolean): Promise; /** Toggle audio-reactive processing on/off. */ setAudioReactive?(ctx: BackendContext, on: boolean): Promise; diff --git a/src/config.ts b/src/config.ts index b2e00ab..0f840ba 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,6 @@ export type HypercolorLiveControlId = 'brightness' | 'speed' | 'hue_shift' | 'in export interface HypercolorConfigAddenda { scene_entity?: string; - profile_entity?: string; stop_effect_entity?: string; fps_entity?: string; connected_entity?: string; @@ -45,7 +44,6 @@ export interface Config { /** Hypercolor-only feature toggles (Phase 4). */ show_scene_select?: boolean; - show_profile_select?: boolean; show_live_controls?: boolean; show_status_chips?: boolean; show_per_device?: boolean; diff --git a/src/hyper-light-card-editor.ts b/src/hyper-light-card-editor.ts index e36ed71..a401279 100644 --- a/src/hyper-light-card-editor.ts +++ b/src/hyper-light-card-editor.ts @@ -16,6 +16,28 @@ interface FormSchemaEntry { default?: unknown; } +/** + * Sentinel the backend dropdown uses for "no override". The card config + * never stores it: an absent `backend` key means auto-detect, and the form + * needs a concrete value to show that state as selected. + */ +export const AUTO_BACKEND = 'auto'; + +type BackendChoice = NonNullable | typeof AUTO_BACKEND; +type FormData = Omit & { backend: BackendChoice }; + +/** Shape the stored config into what ha-form displays. */ +export function toFormData(config: Config): FormData { + return { ...config, backend: config.backend || AUTO_BACKEND }; +} + +/** Turn a form value back into a card config, dropping the auto sentinel. */ +export function fromFormData(value: Omit & { backend?: string }): Config { + const { backend, ...rest } = value; + if (!backend || backend === AUTO_BACKEND) return rest as Config; + return { ...rest, backend: backend as Config['backend'] }; +} + export class HyperLightCardEditor extends LitElement implements LovelaceCardEditor { @property({ attribute: false }) public hass!: HomeAssistant; @state() private _config: Config = { @@ -47,7 +69,6 @@ export class HyperLightCardEditor extends LitElement implements LovelaceCardEdit show_preset_select: true, show_effect_controls: true, show_scene_select: true, - show_profile_select: false, show_live_controls: true, show_status_chips: true, show_per_device: false, @@ -67,7 +88,7 @@ export class HyperLightCardEditor extends LitElement implements LovelaceCardEdit return html` = { entity: 'Light entity', name: 'Card name (optional)', icon: 'Icon (optional)', - backend: 'Backend (auto-detected)', + backend: 'Backend', layout_entity: 'Layout select entity', preset_entity: 'Preset select entity', next_effect_entity: 'Next-effect button', @@ -220,7 +241,6 @@ const LABELS: Record = { show_preset_select: 'Show preset selector', show_effect_controls: 'Show effect navigation', show_scene_select: 'Show scene selector', - show_profile_select: 'Show profile selector', show_live_controls: 'Show live effect controls', show_audio_controls: 'Show audio controls (reactive toggle, input device)', show_zones: 'Show scene zones', @@ -235,7 +255,7 @@ function computeLabel(schema: FormSchemaEntry): string { } const HELPERS: Record = { - backend: 'Override backend detection. Leave on the auto value unless something is off.', + backend: 'Auto-detect picks the backend from the entity. Override only if detection is wrong.', show_per_device: 'Adds an expandable list of child lights below the main card. Off by default.', allowed_effects: 'Limit the effect dropdown. Empty means all available effects.', }; diff --git a/src/hyper-light-card-styles.css b/src/hyper-light-card-styles.css index c45ca92..b142d90 100644 --- a/src/hyper-light-card-styles.css +++ b/src/hyper-light-card-styles.css @@ -493,6 +493,11 @@ ha-card { background-color: rgba(var(--rgb-primary-text-color, 225, 225, 225), 0.2); } +.attributes-toggle:focus-visible { + outline: 2px solid var(--accent-color, var(--primary-color, #03a9f4)); + outline-offset: 2px; +} + .attributes-toggle ha-icon { display: block; transition: transform 0.3s ease; @@ -1018,15 +1023,13 @@ ha-switch { cursor: not-allowed; } -/* Scene + profile selector wrappers reuse the existing dropdown styles */ -.scene-select-wrapper, -.profile-select-wrapper { +/* The scene selector wrapper reuses the existing dropdown styles */ +.scene-select-wrapper { position: relative; z-index: 5; } -.scene-select-wrapper .dropdown-header, -.profile-select-wrapper .dropdown-header { +.scene-select-wrapper .dropdown-header { border-width: 1px; padding: 6px 10px; background-color: rgba(var(--background-color-rgb, 255, 255, 255), 0.5); diff --git a/src/hyper-light-card.ts b/src/hyper-light-card.ts index 277c3d3..83d57a9 100644 --- a/src/hyper-light-card.ts +++ b/src/hyper-light-card.ts @@ -113,7 +113,7 @@ export class HyperLightCard extends LitElement { } // Scroll each newly-opened list to its active row exactly once, and re-arm - // when it closes. Scene and profile were previously left out entirely. + // when it closes. for (const [isOpen, selector] of this._dropdownScrollTargets()) { if (isOpen && !this._scrolledDropdowns.has(selector)) { this._scrolledDropdowns.add(selector); @@ -130,7 +130,6 @@ export class HyperLightCard extends LitElement { [this.state.isLayoutDropdownOpen, '.layout-select-wrapper'], [this.state.isPresetDropdownOpen, '.preset-select-wrapper'], [this.state.isSceneDropdownOpen, '.scene-select-wrapper'], - [this.state.isProfileDropdownOpen, '.profile-select-wrapper'], ]; } @@ -156,7 +155,6 @@ export class HyperLightCard extends LitElement { const layouts = backend.layouts(ctx); const presets = backend.presets(ctx); const scenes = backend.scenes?.(ctx) ?? null; - const profiles = backend.profiles?.(ctx) ?? null; const navigation = backend.navigation(ctx); const liveControls = backend.liveControls?.(ctx) ?? []; const connectivity = backend.connectivity?.(ctx) ?? null; @@ -168,7 +166,6 @@ export class HyperLightCard extends LitElement { const showStatusChips = this.config.show_status_chips !== false; const showSceneSelect = this.config.show_scene_select !== false && scenes !== null; - const showProfileSelect = this.config.show_profile_select === true && profiles !== null; const showLiveControls = this.config.show_live_controls !== false && liveControls.length > 0; const showAudioControls = this.config.show_audio_controls !== false && audioControls !== null; const showPerDevice = this.config.show_per_device === true && devices.length > 0; @@ -193,10 +190,8 @@ export class HyperLightCard extends LitElement { ${this.state.showEffectParameters ? this._renderAttributes(effect, layouts, presets, { scenes, - profiles, liveControls, showSceneSelect, - showProfileSelect, showLiveControls, }) : ''} @@ -336,7 +331,7 @@ export class HyperLightCard extends LitElement { /** * One dropdown row. Every selector renders through this so selection * highlighting, keyboard activation, and ARIA stay identical across the - * effect, layout, preset, scene, and profile pickers. + * effect, layout, preset, and scene pickers. */ private _renderDropdownItem(label: string, selected: boolean, activate: () => void) { return html` @@ -459,10 +454,6 @@ export class HyperLightCard extends LitElement {
@@ -502,10 +495,8 @@ export class HyperLightCard extends LitElement { presets: SelectModel | null, extras: { scenes: SelectModel | null; - profiles: SelectModel | null; liveControls: LiveControlModel[]; showSceneSelect: boolean; - showProfileSelect: boolean; showLiveControls: boolean; } ) { @@ -513,17 +504,8 @@ export class HyperLightCard extends LitElement { const hasLayouts = Boolean(layouts?.options.length) && this.state.showLayoutSelect; const hasPresets = Boolean(presets?.options.length) && this.state.showPresetSelect; const hasScenes = extras.scenes && extras.scenes.options.length > 0 && extras.showSceneSelect; - const hasProfiles = - extras.profiles && extras.profiles.options.length > 0 && extras.showProfileSelect; const hasLiveControls = extras.showLiveControls && extras.liveControls.length > 0; - if ( - !hasParameters && - !hasLayouts && - !hasPresets && - !hasScenes && - !hasProfiles && - !hasLiveControls - ) { + if (!hasParameters && !hasLayouts && !hasPresets && !hasScenes && !hasLiveControls) { return html``; } @@ -533,8 +515,7 @@ export class HyperLightCard extends LitElement { const nestedDropdownOpen = this.state.isLayoutDropdownOpen || this.state.isPresetDropdownOpen || - this.state.isSceneDropdownOpen || - this.state.isProfileDropdownOpen; + this.state.isSceneDropdownOpen; return html`
${this._renderLayoutSelect(layouts)} ${this._renderPresetSelect(presets)} ${hasScenes ? this._renderSceneSelect(extras.scenes!) : ''} - ${hasProfiles ? this._renderProfileSelect(extras.profiles!) : ''}
${hasLiveControls ? this._renderLiveControls(extras.liveControls) @@ -676,14 +656,14 @@ 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 + * Shared renderer for the compact selectors (layout, preset, scene). + * A selector whose entity exposes no options renders nothing at * 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. */ private _renderSelect( - kind: 'layout' | 'preset' | 'scene' | 'profile', + kind: 'layout' | 'preset' | 'scene', model: SelectModel | null, spec: { icon: string; @@ -733,16 +713,6 @@ export class HyperLightCard extends LitElement { }); } - private _renderProfileSelect(profiles: SelectModel) { - return this._renderSelect('profile', profiles, { - icon: 'mdi:account-cog', - title: 'Profile', - empty: 'No active profile', - isOpen: this.state.isProfileDropdownOpen, - select: profile => this._selectProfile(profile), - }); - } - private _renderAudioControls(audioControls: AudioControlsModel) { const { reactive, device } = audioControls; const deviceOptions = device?.options ?? []; @@ -1000,7 +970,7 @@ export class HyperLightCard extends LitElement { this.stateManager.toggleDropdown(); } - private _toggleSelectDropdown(e: Event, kind: 'layout' | 'preset' | 'scene' | 'profile') { + private _toggleSelectDropdown(e: Event, kind: 'layout' | 'preset' | 'scene') { e.stopPropagation(); switch (kind) { case 'layout': @@ -1009,8 +979,6 @@ export class HyperLightCard extends LitElement { return this.stateManager.togglePresetDropdown(); case 'scene': return this.stateManager.toggleSceneDropdown(); - case 'profile': - return this.stateManager.toggleProfileDropdown(); } } @@ -1024,10 +992,6 @@ export class HyperLightCard extends LitElement { await this.stateManager.setScene(scene); } - private async _selectProfile(profile: string) { - await this.stateManager.setProfile(profile); - } - private async _identifyDevice(entityId: string) { if (!this.hass) return; await this.hass.callService('button', 'press', { entity_id: entityId }); @@ -1099,11 +1063,6 @@ export class HyperLightCard extends LitElement { '.scene-select-wrapper .dropdown', () => this.stateManager.toggleSceneDropdown(), ], - [ - this.state.isProfileDropdownOpen, - '.profile-select-wrapper .dropdown', - () => this.stateManager.toggleProfileDropdown(), - ], ]; for (const [isOpen, selector, close] of dropdowns) { if (!isOpen) continue; diff --git a/src/state-manager.ts b/src/state-manager.ts index d626a8f..16dd10f 100644 --- a/src/state-manager.ts +++ b/src/state-manager.ts @@ -148,27 +148,18 @@ export class StateManager { } } - toggleProfileDropdown() { - this._state.isProfileDropdownOpen = !this._state.isProfileDropdownOpen; - if (this._state.isProfileDropdownOpen) { - this._closeOtherDropdowns('profile'); - } - } - closeAllDropdowns() { this._state.isDropdownOpen = false; this._state.isLayoutDropdownOpen = false; this._state.isPresetDropdownOpen = false; this._state.isSceneDropdownOpen = false; - this._state.isProfileDropdownOpen = false; } - private _closeOtherDropdowns(keep: 'effect' | 'layout' | 'preset' | 'scene' | 'profile') { + private _closeOtherDropdowns(keep: 'effect' | 'layout' | 'preset' | 'scene') { if (keep !== 'effect') this._state.isDropdownOpen = false; if (keep !== 'layout') this._state.isLayoutDropdownOpen = false; if (keep !== 'preset') this._state.isPresetDropdownOpen = false; if (keep !== 'scene') this._state.isSceneDropdownOpen = false; - if (keep !== 'profile') this._state.isProfileDropdownOpen = false; } async setScene(value: string) { @@ -178,13 +169,6 @@ export class StateManager { this._state.isSceneDropdownOpen = false; } - async setProfile(value: string) { - const ctx = this._ctx; - if (!ctx) return; - await this._backend.setProfile?.(ctx, value); - this._state.isProfileDropdownOpen = false; - } - /** * Update a live control value. Coalesces rapid drag input into one * service call per ~50ms window; the final value always lands on diff --git a/src/state.ts b/src/state.ts index 8dfd7a6..25922eb 100644 --- a/src/state.ts +++ b/src/state.ts @@ -21,7 +21,6 @@ export class State implements ReactiveController { private _showPresetSelect = true; private _isSceneDropdownOpen = false; - private _isProfileDropdownOpen = false; // Effect navigation controls private _showEffectControls = true; @@ -185,13 +184,4 @@ export class State implements ReactiveController { this._isSceneDropdownOpen = value; this._host.requestUpdate(); } - - get isProfileDropdownOpen() { - return this._isProfileDropdownOpen; - } - - set isProfileDropdownOpen(value: boolean) { - this._isProfileDropdownOpen = value; - this._host.requestUpdate(); - } } diff --git a/tests/backends/hypercolor.test.ts b/tests/backends/hypercolor.test.ts index 0fc0335..816da85 100644 --- a/tests/backends/hypercolor.test.ts +++ b/tests/backends/hypercolor.test.ts @@ -542,6 +542,52 @@ describe('hypercolorBackend.autoDiscover', () => { }); }); +describe('hypercolorBackend select models', () => { + const config = { + entity: 'light.hypercolor', + preset_entity: 'select.hypercolor_preset', + hypercolor: { scene_entity: 'select.hypercolor_scene' }, + } as Config; + + it('treats an unknown scene state as no current option', () => { + const { hass } = hassWithStates({ + 'select.hypercolor_scene': { + state: 'unknown', + attributes: { options: ['Studio Session', 'Movie Night'] }, + }, + }); + expect(hypercolorBackend.scenes?.({ hass, config })).toEqual({ + current: '', + options: ['Studio Session', 'Movie Night'], + available: true, + }); + }); + + it('keeps the options of an unavailable preset select but drops its state', () => { + const { hass } = hassWithStates({ + 'select.hypercolor_preset': { + state: 'unavailable', + attributes: { options: ['Hyperkandi'] }, + }, + }); + expect(hypercolorBackend.presets({ hass, config })).toEqual({ + current: '', + options: ['Hyperkandi'], + available: false, + }); + }); + + it('passes a real option through untouched', () => { + const { hass } = hassWithStates({ + 'select.hypercolor_scene': { + state: 'Movie Night', + attributes: { options: ['Studio Session', 'Movie Night'] }, + }, + }); + expect(hypercolorBackend.scenes?.({ hass, config })?.current).toBe('Movie Night'); + }); +}); + describe('hypercolorBackend.liveControls', () => { it('labels effect brightness distinctly from the card brightness slider', () => { const hass = hassWith(['light.hypercolor', 'number.hypercolor_brightness']); diff --git a/tests/backends/signalrgb.test.ts b/tests/backends/signalrgb.test.ts new file mode 100644 index 0000000..93fb3ed --- /dev/null +++ b/tests/backends/signalrgb.test.ts @@ -0,0 +1,37 @@ +import type { HomeAssistant } from 'custom-card-helpers'; +import { describe, expect, it } from 'vitest'; +import { signalRgbBackend } from '@/backends/signalrgb'; +import type { Config } from '@/config'; + +function hassWithSelect(state: string, options: string[]): HomeAssistant { + return { + states: { + 'select.signalrgb_layout': { + entity_id: 'select.signalrgb_layout', + state, + attributes: { options }, + }, + }, + } as unknown as HomeAssistant; +} + +const config = { + entity: 'light.signalrgb_home', + layout_entity: 'select.signalrgb_layout', +} as Config; + +describe('signalRgbBackend.layouts', () => { + it('treats an unknown layout state as no current option', () => { + const hass = hassWithSelect('unknown', ['Desk', 'Wall']); + expect(signalRgbBackend.layouts({ hass, config })).toEqual({ + current: '', + options: ['Desk', 'Wall'], + available: true, + }); + }); + + it('passes a real option through untouched', () => { + const hass = hassWithSelect('Wall', ['Desk', 'Wall']); + expect(signalRgbBackend.layouts({ hass, config })?.current).toBe('Wall'); + }); +}); diff --git a/tests/hyper-light-card-editor.test.ts b/tests/hyper-light-card-editor.test.ts new file mode 100644 index 0000000..ec20d01 --- /dev/null +++ b/tests/hyper-light-card-editor.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import type { Config } from '@/config'; +import { AUTO_BACKEND, fromFormData, toFormData } from '@/hyper-light-card-editor'; + +describe('editor backend choice', () => { + it('shows an absent backend as auto-detect', () => { + const config = { entity: 'light.hyperia' } as Config; + expect(toFormData(config).backend).toBe(AUTO_BACKEND); + }); + + it('shows a legacy empty backend as auto-detect', () => { + const config = { entity: 'light.hypercolor', backend: '' } as unknown as Config; + expect(toFormData(config).backend).toBe(AUTO_BACKEND); + }); + + it('shows an explicit override as itself', () => { + const config = { entity: 'light.hyperia', backend: 'hypercolor' } as Config; + expect(toFormData(config).backend).toBe('hypercolor'); + }); + + it('drops the backend key when auto-detect is chosen', () => { + const next = fromFormData({ entity: 'light.hyperia', backend: AUTO_BACKEND }); + expect('backend' in next).toBe(false); + expect(next.entity).toBe('light.hyperia'); + }); + + it('drops an empty backend the same way', () => { + const next = fromFormData({ entity: 'light.hyperia', backend: '' }); + expect('backend' in next).toBe(false); + }); + + it('keeps a real override', () => { + const next = fromFormData({ entity: 'light.hyperia', backend: 'signalrgb' }); + expect(next.backend).toBe('signalrgb'); + }); +});