v1.45.3: 'value instead of an icon' could never be saved (issue #3)

The device editor has offered display='value' since v1.26.0; MARKER_SCHEMA
accepted only badge/ripple/icon_ripple. Picking it produced

  not a valid value for dictionary value @ data['config']['markers'][n]['display']

and since one rejected marker fails the whole config write, the user could not
save the plan at all until the setting was undone. Reported by @RemyRoux with
the exact error text, 2026-07-27 — a year and a half after the feature shipped.

The schema now accepts it, and the class of bug is closed rather than the
instance: DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES and ROOM_FILL_MODES are
exported from src/logic.ts, the editors render their options from them, and a
backend test parses those lists out of the TypeScript source and asserts the
schema accepts every one (and rejects a bogus value). Reverting the one-word
schema fix fails that test, which is the check that was missing.

Plus an HA-harness test saving a config that contains a value-display marker —
the exact call the user's card was making.
Docs: CHANGELOG.md + CHANGELOG.ru.md + TESTING.md + STATUS.md.
This commit is contained in:
Matysh
2026-07-28 00:23:37 +03:00
parent 2e2d353b04
commit 3d41fe16b8
16 changed files with 274 additions and 116 deletions
+6 -5
View File
@@ -22,6 +22,7 @@ import {
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
referencedContentUrls,
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
} from './logic';
import { ContentSigner } from './signing';
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, type AreaClimate } from './devices';
@@ -34,7 +35,7 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.45.2';
const CARD_VERSION = '1.45.3';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -4824,7 +4825,7 @@ class HouseplanCard extends LitElement {
<label>${this._t('marker.tap_label')}</label>
<select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, tapAction: (e.target as HTMLSelectElement).value })}>
${[['info', 'tap.info'], ['more-info', 'tap.more_info'], ['toggle', 'tap.toggle']].map(
${TAP_ACTIONS.map((v) => [v, 'tap.' + v.replace('-', '_')] as const).map(
([v, k]) => html`<option value=${v} ?selected=${(d.tapAction || d.defaultTap) === v}>${this._t(k as any)}</option>`,
)}
</select>
@@ -4895,7 +4896,7 @@ class HouseplanCard extends LitElement {
<label>${this._t('marker.display_label')}</label>
<select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, display: (e.target as HTMLSelectElement).value as any })}>
${[['badge', 'display.badge'], ['ripple', 'display.ripple'], ['icon_ripple', 'display.icon_ripple'], ['value', 'display.value']].map(
${DISPLAY_MODES.map((v) => [v, 'display.' + v] as const).map(
([v, k]) => html`<option value=${v} ?selected=${d.display === v}>${this._t(k as any)}</option>`,
)}
</select>
@@ -5072,7 +5073,7 @@ class HouseplanCard extends LitElement {
<span class="opv">${Math.round(d.roomOpacity * 100)}%</span>
</div>
<label>${this._t('space.fill_label')}</label>
${[['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp'], ['glow', 'fill.glow']].map(
${SPACE_FILL_MODES.map((v) => [v, 'fill.' + v] as const).map(
([v, k]) => html`<label class="srcrow">
<input type="radio" name="fillmode" .checked=${d.fillMode === v}
@change=${() => (this._spaceDialog = { ...d, fillMode: v as any })} />
@@ -5243,7 +5244,7 @@ class HouseplanCard extends LitElement {
<label class="dispsection">${this._t('room.settings_section')}</label>
<label>${this._t('room.fill_label')}</label>
${([['', 'fill.inherit'], ['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp']] as const).map(
${([['', 'fill.inherit'], ...ROOM_FILL_MODES.map((v) => [v, 'fill.' + v])] as const).map(
([v, k]) => html`<label class="srcrow inline">
<input type="radio" name="rfill" .checked=${this._roomFill === v}
@change=${() => { this._roomFill = v as any; this.requestUpdate(); }} />
+18
View File
@@ -531,6 +531,24 @@ export function safeUrl(url: string | null | undefined): string | null {
export type TapAction = 'info' | 'more-info' | 'toggle';
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
/**
* The option lists the editors offer, in one place and the reason they are
* here rather than inline in the templates.
*
* `display` gained 'value' in v1.26.0 ("show the measurement instead of the
* icon") but the backend schema still only accepted badge/ripple/icon_ripple,
* so saving any marker configured that way was rejected outright the feature
* was unusable for a year and a half and only surfaced through a user's error
* message (issue #3, 2026-07-27). The lists are exported so a backend test can
* read them and assert the schema accepts every value a user can pick.
* Adding an option here and forgetting the schema now fails the test suite.
*/
export const DISPLAY_MODES = ['badge', 'ripple', 'icon_ripple', 'value'] as const;
export const TAP_ACTIONS = ['info', 'more-info', 'toggle'] as const;
/** Space-level fill: 'glow' is a whole-space light model, not a per-room one. */
export const SPACE_FILL_MODES = ['none', 'lqi', 'light', 'temp', 'glow'] as const;
export const ROOM_FILL_MODES = ['none', 'lqi', 'light', 'temp'] as const;
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier']);
/**