feat: select exact toggle entity

Issue: #178
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-18 21:31:04 +03:00
parent 034eb3f5f3
commit 8086399aa6
29 changed files with 938 additions and 308 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -894,7 +894,7 @@ def build_space_merge(
for field in (
"area", "controls", "tap_action", "tap_target", "tap_confirm",
"vacuum", "is_light", "use_climate_temp", "glow_color",
"glow_radius_cm", "light_entity", "hidden", "removed",
"glow_radius_cm", "light_entity", "toggle_entity", "hidden", "removed",
"value_badge",
):
marker.pop(field, None)
+24 -11
View File
@@ -189,7 +189,7 @@ def validate_marker_value_badges(
def validate_marker_light_entities(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate new/changed leading-light choices without rejecting dormant data.
"""Validate new/changed light/switch choices without rejecting dormant data.
The top-level schema must stay lossless: an old or future literal that the
current frontend cannot edit may round-trip unchanged. Imports validate the
@@ -206,16 +206,26 @@ def validate_marker_light_entities(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
value = marker.get("light_entity")
old_value = None if validate_all else (old_marker or {}).get("light_entity")
if not validate_all and value == old_value:
continue
if value is None:
continue
if not isinstance(value, str) or not _LIGHT_ENTITY_RE.fullmatch(value):
raise MarkerControlError(
"invalid_light_entity", "Leading light entity must be light.* or switch.*"
)
for field, code, message in (
(
"light_entity",
"invalid_light_entity",
"Leading light entity must be light.* or switch.*",
),
(
"toggle_entity",
"invalid_toggle_entity",
"Toggle entity must be light.* or switch.*",
),
):
value = marker.get(field)
old_value = None if validate_all else (old_marker or {}).get(field)
if not validate_all and value == old_value:
continue
if value is None:
continue
if not isinstance(value, str) or not _LIGHT_ENTITY_RE.fullmatch(value):
raise MarkerControlError(code, message)
def validate_marker_controls(
@@ -939,6 +949,9 @@ MARKER_SCHEMA = vol.Schema(
# Semantic delta validation below the schema preserves unknown/future
# literals until that exact field is edited (lossless config doctrine).
vol.Optional("light_entity"): object,
# Exact own entity selected for Toggle. Delta validation preserves an
# untouched future literal while bounding every new/changed value.
vol.Optional("toggle_entity"): object,
vol.Optional("value_badge"): vol.Any(
None,
vol.Schema(
+19
View File
@@ -580,6 +580,25 @@ export async function prepareGoldenScenario(page, scenario) {
if (visibleRole.top < visibleBody.top - 1 || visibleRadius.bottom > visibleBody.bottom + 1)
throw new Error('golden viewport does not show the complete device light-source controls');
}
if (scenario.deviceToggleEntity) {
const dialog = card.renderRoot.querySelector('hp-dialog');
const body = dialog?.querySelector('.body');
const select = dialog?.querySelector('#marker-toggle-entity');
const warning = dialog?.querySelector('.markertoggleentity [role="status"]');
const childLock = 'switch.golden_washer_child_lock';
if (!body || !select || select.options.length !== 3)
throw new Error('golden toggle-entity selector is incomplete');
if (scenario.deviceToggleEntity === 'selected'
&& (select.value !== childLock || warning))
throw new Error('golden selected toggle entity is not projected');
if (scenario.deviceToggleEntity === 'stale'
&& (select.value !== '' || !warning?.textContent?.includes('switch.golden_washer_removed')))
throw new Error('golden stale toggle entity warning is missing');
const bodyRect = body.getBoundingClientRect();
const selectRect = select.getBoundingClientRect();
body.scrollTop += selectRect.top - bodyRect.top - 12;
await frame();
}
if (scenario.openHelp) {
const help = card.renderRoot.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`);
await help?.updateComplete;
+15 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 25;
export const GOLDEN_MATRIX_VERSION = 26;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -248,6 +248,20 @@ export const GOLDEN_SCENARIOS = Object.freeze([
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
deviceName: 'Контроллер освещения гостиной с намеренно очень длинным названием',
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
{ id: 'toggle-entity-dialog-desktop-en', fixture: 'visual', applianceLifecycle: true,
space: 'golden-appliance', dialog: 'device', deviceId: 'golden-washer',
deviceToggleEntity: 'selected', markerOverrides: [{
id: 'golden-washer', binding: 'device:golden-washer', tap_action: 'toggle',
toggle_entity: 'switch.golden_washer_child_lock',
}],
language: 'en', theme: 'light', viewport: { width: 1180, height: 1100 }, ...page },
{ id: 'toggle-entity-dialog-mobile-ru', fixture: 'visual', applianceLifecycle: true,
space: 'golden-appliance', dialog: 'device', deviceId: 'golden-washer',
deviceToggleEntity: 'stale', markerOverrides: [{
id: 'golden-washer', binding: 'device:golden-washer', tap_action: 'toggle',
toggle_entity: 'switch.golden_washer_removed',
}],
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
{ id: 'device-help-popover-light-ru', fixture: 'visual', space: 'golden-lighting',
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
openHelp: 'marker.glow_mode.help', helpTextRegion: { key: 'marker.glow_mode.help', minPixels: 30 },
+133
View File
@@ -0,0 +1,133 @@
// #178: production-bundle contract for the exact Toggle-entity selector.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1180, height: 1000 });
const result = await page.evaluate(async () => {
const out = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const base = card._devices.find((device) =>
device.bindingKind === 'device' && device.primary?.startsWith('light.'));
const primary = base.primary;
const secondary = 'switch.issue178_child_lock';
card.hass = {
...card.hass,
states: {
...card.hass.states,
[secondary]: {
entity_id: secondary,
state: 'off',
attributes: { friendly_name: 'Child lock' },
},
},
entities: {
...card.hass.entities,
[secondary]: {
entity_id: secondary,
device_id: base.bindingRef,
platform: 'issue178',
disabled_by: null,
},
},
};
await card.updateComplete;
// Keep the browser smoke independent from registry loading order while the
// production resolver, dialog and persistence helpers remain unmodified.
const realPreview = card._markerPreviewDevice.bind(card);
card._markerPreviewDevice = (draft) => draft.devId === base.id ? {
...base,
tapAction: draft.tapActionTouched ? draft.tapAction : 'toggle',
entities: [primary, secondary],
marker: {
...(base.marker || {}),
id: base.id,
binding: `device:${base.bindingRef}`,
tap_action: 'toggle',
toggle_entity: draft.toggleEntity || null,
},
} : realPreview(draft);
card._openMarkerDialog({
...base,
tapAction: 'toggle',
entities: [primary, secondary],
marker: {
...(base.marker || {}), id: base.id, binding: `device:${base.bindingRef}`,
tap_action: 'toggle', toggle_entity: primary,
},
});
await card.updateComplete;
let select = root().querySelector('#marker-toggle-entity');
if (!select) throw new Error(JSON.stringify({
dialog: card._markerDialog,
effectiveTapAction: card._effectiveMarkerTapAction(card._markerDialog),
preview: card._markerPreviewDevice(card._markerDialog),
selectIds: [...root().querySelectorAll('select')].map((item) => item.id),
}));
out.selectorForMultiple = !!select && select.options.length === 3;
out.savedSelectionProjected = select?.value === primary;
select.value = secondary;
select.dispatchEvent(new Event('change', { bubbles: true }));
await card.updateComplete;
select = root().querySelector('#marker-toggle-entity');
const hint = root().querySelector('#marker-toggle-hint')?.textContent || '';
out.selectionUpdatesDraft = card._markerDialog.toggleEntity === secondary
&& select?.value === secondary;
out.selectionUpdatesHintBeforeSave = hint.includes(secondary)
&& card._markerDialog.tapHintAnnouncement.includes(secondary);
out.writerPersistsSelection = card._markerToggleEntityFields(card._markerDialog)
.toggle_entity === secondary;
// Re-open the persisted shape, then simulate a temporarily missing entity.
card._markerDialog = null;
card._openMarkerDialog({
...base,
tapAction: 'toggle',
entities: [primary, secondary],
marker: {
...(base.marker || {}), id: base.id, binding: `device:${base.bindingRef}`,
tap_action: 'toggle', toggle_entity: secondary,
},
});
await card.updateComplete;
out.reopenRestoresSelection = root().querySelector('#marker-toggle-entity')?.value === secondary;
card._markerDialog = {
...card._markerDialog,
toggleEntity: 'switch.issue178_removed',
toggleEntityTouched: false,
originalHasToggleEntity: true,
originalToggleEntity: 'switch.issue178_removed',
};
await card.updateComplete;
const warning = root().querySelector('.markertoggleentity [role="status"]');
out.staleWarnsAndShowsAuto = root().querySelector('#marker-toggle-entity')?.value === ''
&& warning?.textContent?.includes('switch.issue178_removed');
out.staleLiteralSurvives = card._markerToggleEntityFields(card._markerDialog)
.toggle_entity === 'switch.issue178_removed';
card._markerDialog = null;
card._markerPreviewDevice = realPreview;
const single = card._devices.find((device) =>
device.bindingKind === 'device' && device.entities.filter((eid) =>
eid.startsWith('light.') || eid.startsWith('switch.')).length === 1);
card._openMarkerDialog({
...single,
tapAction: 'toggle',
marker: {
...(single.marker || {}), id: single.id, binding: `device:${single.bindingRef}`,
tap_action: 'toggle',
},
});
await card.updateComplete;
out.singleEntityHidesSelector = !root().querySelector('#marker-toggle-entity');
card._markerDialog = null;
await card.updateComplete;
return out;
});
checkAll(result, {});
await finish(browser, result);
File diff suppressed because one or more lines are too long
+116 -96
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -957,6 +957,11 @@ hash falls back to the default.
service catalog is a second fail-closed guard. A click resolves the current
marker by id rather than using a retained #73 visual snapshot; the snapshot
remains valid only for read-only presentation.
Optional `marker.toggle_entity` is an exact own `light.*`/`switch.*` override
layered before the legacy own-role resolver. Its absence leaves legacy
single/group membership unchanged; an active explicit choice also joins an
explicit controls group, while stale values fall back without being erased.
It is deliberately independent from visual `marker.light_entity`.
- **Resolved device state** (2026-08-06): HA provides states per entity, not
one state per device. `resolvedDeviceStateEntities` therefore starts from
uncategorised registry entities, resolves one functional role (whole-device
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Composite Home Assistant devices with several light/switch entities now let
you choose the exact entity operated by **Toggle state**. The dialog previews
the selected target immediately, preserves missing choices with a warning,
and can include the selected own entity in an explicitly configured group
without changing existing plans ([#178](https://github.com/Matysh/houseplan-card/issues/178)).
- A virtual Always-light linked to a real relay now follows that relay instead
of keeping an independent manual state. Tapping either marker operates the
real Home Assistant device, while Glow, Light fill, room counts and both
+6
View File
@@ -8,6 +8,12 @@
## Unreleased
- У составных устройств Home Assistant с несколькими сущностями света/реле
теперь можно выбрать точную сущность для действия **«Переключить состояние»**.
Диалог сразу показывает новую цель, сохраняет пропавший выбор с
предупреждением и может включить выбранную собственную сущность в явно
настроенную группу без изменения старых планов
([#178](https://github.com/Matysh/houseplan-card/issues/178)).
- Виртуальный источник с ролью **«Всегда»**, связанный с реальным реле, теперь
следует состоянию реле вместо отдельного ручного состояния. Нажатие на любой
из двух маркеров управляет реальным устройством Home Assistant, а Glow,
+12
View File
@@ -181,6 +181,18 @@ and temporarily fall back to the normal deterministic selection; merely
opening or saving another field does not erase them. Older frontends ignore the
unknown field and may erase it only if they reconstruct that marker.
`marker.toggle_entity` optionally stores the exact own `light.*`/`switch.*`
operated by Toggle. Absence/null keeps the historical single-target resolver
and external-only controls groups bit-for-bit. A present active choice is exact:
temporary missing/unavailable/secure state never retargets it to a sibling. A
choice no longer belonging to the marker remains stored, warns in the dialog
and temporarily uses the historical fallback. New/changed values are
domain-bounded by lossless delta validation; an untouched future literal can
round-trip. Full and space transfer copy the entity id literally, while a
duplicate marker virtualised during space import drops the HA-dependent field.
Older frontends ignore it and may erase it if they reconstruct the marker.
`light_entity`, `toggle_entity` and `tap_target` are independent.
`marker.controls[]` additionally accepts `marker:<marker_id>` links to forced
plan sources. Runtime and old frontends continue to filter those strings out of
HA service calls. New writes validate target existence, forced-source role,
+9
View File
@@ -352,6 +352,15 @@ A light defaults to Toggle; other devices default to the House Plan card. An
unsupported Toggle remains a visible no-op and is never changed into another
action behind the user's back.
When a device-bound marker has two or more own `light.*`/`switch.*` entities,
**Entity to toggle** appears below Toggle. It selects the exact own channel and
updates the target hint before Save. **Automatic** keeps the previous binding /
functional-role rules. A missing saved entity stays configured, shows a
warning and temporarily falls back; returning the same entity restores the
choice. This setting is independent from **Leading light entity**. With an
explicit external controls group, an explicitly selected own entity joins the
group; without a selection existing groups remain external-only.
![House Plan device card with state and safe actions](images/09-device-info.png)
<!-- docs-section: visual-states -->
+10
View File
@@ -647,6 +647,16 @@ HA. «Показать» заблокировано до активации. П
У устройства, чья основная функция — `light.*`, действием по умолчанию уже является **Переключить состояние**. У остальных устройств по умолчанию открывается внутренняя карточка.
Если у маркера с привязкой к устройству есть две или больше собственных
сущностей `light.*`/`switch.*`, под действием появляется **«Переключаемая
сущность»**. Она выбирает точный канал и меняет подсказку цели ещё до
сохранения. **«Автоматически»** сохраняет прежние правила binding /
функциональной роли. Исчезнувший сохранённый ID не стирается: диалог показывает
предупреждение и временный fallback, а возврат того же ID восстанавливает выбор.
Эта настройка не зависит от **«Ведущей сущности света»**. При явно настроенной
внешней группе выбранная собственная сущность входит в группу; без явного
выбора старые группы остаются только внешними.
Вариант **Переключить состояние** доступен для любого маркера, в том числе
виртуального. Выбор всегда сохраняется. Если у маркера нет переключаемой цели,
под настройкой прямо написано, что по нажатию ничего не произойдёт; House Plan
+3
View File
@@ -339,6 +339,9 @@ RU и EN получают полный паритет для:
одинаково на desktop и touch: используется native `<select>` с обычным tap,
связанным `<label>` и без hover-only affordance. Селектор не добавляет жестов
на плане, drag/pinch/pan, long-press либо собственную touch-геометрию.
Runtime-эффект сохранённого выбора одинаков для pointer и touch: тап по marker
в обычном View/kiosk проходит через один `resolveToggleIntent()` и не имеет
отдельной ветки по типу устройства ввода.
На узкой ширине label, option text, help и stale-warning не должны выходить за
границы диалога или перекрывать footer. Длинный friendly name может быть
+39 -3
View File
@@ -7,6 +7,7 @@
*/
import {
forcedLightEntityOf,
ownControllableEntities,
incomingLightControls,
persistedExternalControls,
resolvedDeviceStateEntities,
@@ -391,6 +392,23 @@ function ownRoleCandidates(device: DevItem, registryHass: any): string[] {
].filter((eid): eid is string => !!eid))];
}
/** Own light/switch choices exposed by the Toggle selector. An entity binding
* is already an exact user choice and must not silently expand to registry
* siblings; a device binding keeps the established deterministic order. */
export function toggleEntityCandidates(device: DevItem): string[] {
if (device.bindingKind === 'entity') {
return device.bindingRef && isControllable(device.bindingRef) ? [device.bindingRef] : [];
}
if (device.bindingKind === 'virtual') return [];
return ownControllableEntities(device);
}
/** A stale explicit choice remains persisted but is never sent to a service. */
function selectedToggleEntity(device: DevItem): string | null {
const selected = device.marker?.toggle_entity;
return selected && toggleEntityCandidates(device).includes(selected) ? selected : null;
}
/**
* Resolve the first supported member of the already-selected functional role.
* Missing/unavailable/secure members remain the explained target and never
@@ -570,7 +588,9 @@ function resolveIncomingControllers(
);
}
function resolveControls(options: ResolveToggleOptions): ResolvedToggleIntent {
function resolveControls(
options: ResolveToggleOptions, selectedOwnEntity: string | null = null,
): ResolvedToggleIntent {
const { hass, device, devices } = options;
const registryHass = options.registryHass || hass;
const refs = persistedExternalControls(
@@ -594,6 +614,14 @@ function resolveControls(options: ResolveToggleOptions): ResolvedToggleIntent {
const entries: GroupEntityRef[] = [];
const skippedTargets: SkippedToggleTarget[] = [];
if (selectedOwnEntity) {
entries.push({
entityId: selectedOwnEntity,
via: device.bindingKind === 'entity' ? 'binding' : 'device-role',
ref: selectedOwnEntity,
});
}
for (const ref of refs) {
if (!ref.startsWith('marker:')) {
if (!isControllable(ref)) {
@@ -683,7 +711,7 @@ export function resolveToggleIntent(options: ResolveToggleOptions): ResolvedTogg
const refs = persistedExternalControls(
device.marker?.binding, device.marker?.controls ?? device.controls, device.entities,
);
if (refs.length) return resolveControls(options);
if (refs.length) return resolveControls(options, selectedToggleEntity(device));
}
if (origin === 'legacy-cover') {
@@ -698,7 +726,15 @@ export function resolveToggleIntent(options: ResolveToggleOptions): ResolvedTogg
: emptyIntent(origin, 'no-actionable-entity');
}
const own = resolveOwnEntity(hass, registryHass, device);
const selected = selectedToggleEntity(device);
const own = selected
? resolveEntity(
hass,
registryHass,
selected,
device.bindingKind === 'entity' ? 'binding' : 'device-role',
)
: resolveOwnEntity(hass, registryHass, device);
if (!own) {
return emptyIntent(origin, device.virtual || device.bindingKind === 'virtual'
? 'no-actionable-entity' : 'no-binding');
+1
View File
@@ -216,6 +216,7 @@ export interface LightSourceDevice {
room_id?: string | null;
is_light?: boolean | null;
light_entity?: string | null;
toggle_entity?: string | null;
controls?: string[] | null;
glow_radius_cm?: number | null;
glow_color?: { c: string; bri?: number | null } | null;
+74
View File
@@ -109,9 +109,11 @@ import {
import {
formatToggleIntent, projectedTapAction, resolveToggleIntent,
sameToggleOperationTargets, toggleCoverEntity, toggleIntentName, toggleOperation,
toggleEntityCandidates,
type ResolvedToggleIntent, type ToggleNextEffect, type ToggleNoneReason,
type ToggleSkipReason,
} from './device-toggle';
import { toggleEntityWriteFields } from './marker-toggle-entity';
import {
adoptVirtualLightServerSnapshot,
applyVirtualLightEvent,
@@ -1485,6 +1487,10 @@ class HouseplanCard extends LitElement {
originalTapAction: string | null | undefined;
/** Snapshot announced only after a user edit; live HA ticks do not rewrite it. */
tapHintAnnouncement: string;
toggleEntity: string;
toggleEntityTouched: boolean;
originalHasToggleEntity: boolean;
originalToggleEntity: string | null | undefined;
tapTarget: string; // 'run': automation./script./scene. entity id
tapConfirm: boolean; // ask before toggle/run
runFilter: string;
@@ -11925,6 +11931,7 @@ class HouseplanCard extends LitElement {
const hasGlowColor = Object.prototype.hasOwnProperty.call(marker || {}, 'glow_color');
const hasValueBadge = Object.prototype.hasOwnProperty.call(marker || {}, 'value_badge');
const hasTapAction = Object.prototype.hasOwnProperty.call(marker || {}, 'tap_action');
const hasToggleEntity = Object.prototype.hasOwnProperty.call(marker || {}, 'toggle_entity');
const glowOverride = normalizeGlowColorOverride(marker?.glow_color);
const currentBadge = this._devicePresentation(d, true).valueBadge;
const badgeCandidates = valueBadgeCandidates(this._planHass, d, this._devices);
@@ -11950,6 +11957,10 @@ class HouseplanCard extends LitElement {
originalHasTapAction: hasTapAction,
originalTapAction: d.marker?.tap_action,
tapHintAnnouncement: '',
toggleEntity: marker?.toggle_entity || '',
toggleEntityTouched: false,
originalHasToggleEntity: hasToggleEntity,
originalToggleEntity: marker?.toggle_entity,
tapTarget: d.marker?.tap_target || '',
tapConfirm: d.marker?.tap_confirm === true,
runFilter: '',
@@ -12003,6 +12014,8 @@ class HouseplanCard extends LitElement {
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
tapAction: 'info', tapActionTouched: false,
originalHasTapAction: false, originalTapAction: undefined, tapHintAnnouncement: '',
toggleEntity: '', toggleEntityTouched: false,
originalHasToggleEntity: false, originalToggleEntity: undefined,
tapTarget: '', tapConfirm: false, runFilter: '',
controls: [], controlsFilter: '',
lightRole: 'auto', lightRoleTouched: false,
@@ -12382,6 +12395,18 @@ class HouseplanCard extends LitElement {
return { tap_action: d.tapAction || null };
}
/** Preserve absence and stale/future literals until this exact selector is edited. */
private _markerToggleEntityFields(
d: NonNullable<HouseplanCard['_markerDialog']>,
): Pick<Marker, 'toggle_entity'> | Record<string, never> {
return toggleEntityWriteFields({
touched: d.toggleEntityTouched,
originalHas: d.originalHasToggleEntity,
original: d.originalToggleEntity,
value: d.toggleEntity,
});
}
private async _saveMarker(): Promise<void> {
const dlg = this._markerDialog;
if (!dlg || dlg.busy) return;
@@ -12456,6 +12481,7 @@ class HouseplanCard extends LitElement {
size: dlg.size !== 1 ? dlg.size : null,
angle: dlg.angle ? dlg.angle : null,
...this._markerTapActionFields(dlg),
...this._markerToggleEntityFields(dlg),
tap_target: effectiveTapAction === 'run' ? dlg.tapTarget || null : null,
tap_confirm: dlg.tapConfirm ? true : null,
controls: controls.length ? controls : null,
@@ -17752,6 +17778,7 @@ class HouseplanCard extends LitElement {
size: d.size !== 1 ? d.size : null,
angle: d.angle || null,
...this._markerTapActionFields(d),
...this._markerToggleEntityFields(d),
tap_target: effectiveTapAction === 'run' ? d.tapTarget || null : null,
tap_confirm: d.tapConfirm ? true : null,
controls: controls.length ? controls : null,
@@ -18168,6 +18195,17 @@ class HouseplanCard extends LitElement {
// registry order used for the remaining candidates.
const effectiveLeading = previewDevice ? forcedLightEntityOf(previewDevice) || '' : '';
const staleLeading = !!d.lightEntity && !leadingEntities.includes(d.lightEntity);
const toggleEntities = previewDevice ? toggleEntityCandidates(previewDevice) : [];
const staleToggleEntity = !!d.toggleEntity && !toggleEntities.includes(d.toggleEntity);
const automaticToggleIntent = effectiveTapAction === 'toggle'
? this._toggleIntentForDialog({ ...d, toggleEntity: '', toggleEntityTouched: true })
: null;
const automaticToggleTarget = automaticToggleIntent
? [...automaticToggleIntent.targets, ...automaticToggleIntent.skippedTargets]
.map((target) => target.entityId || ('ref' in target ? target.ref : ''))
.filter(Boolean)
.join(', ')
: '';
const curLabel = (() => {
if (isVirtual) return null;
const found = cands.find((c) => c.value === d.binding);
@@ -18302,6 +18340,42 @@ class HouseplanCard extends LitElement {
</option>`,
)}
</select>
${effectiveTapAction === 'toggle'
&& (toggleEntities.length > 1 || staleToggleEntity)
? html`<div class="markerhelpfield markertoggleentity">
<div class="markerhelplabel">
<label for="marker-toggle-entity">${this._t('marker.toggle_entity_label')}</label>
${this._help('marker.toggle_entity.help')}
</div>
<select id="marker-toggle-entity" class="areasel"
@change=${(e: Event) => {
const next = {
...d,
toggleEntity: (e.target as HTMLSelectElement).value,
toggleEntityTouched: true,
};
this._markerDialog = this._announceToggleDraft(next);
}}>
<option value="" ?selected=${staleToggleEntity || !d.toggleEntity}>
${this._t('marker.toggle_entity_auto', {
entity: automaticToggleTarget || this._t('marker.toggle_entity_none'),
})}
</option>
${toggleEntities.map((eid) => html`<option value=${eid}
?selected=${!staleToggleEntity && eid === d.toggleEntity}>
${this.hass.states[eid]?.attributes?.friendly_name
|| this._fullRegistryHass.entities[eid]?.name || eid} · ${eid}
</option>`)}
</select>
${staleToggleEntity ? html`<p class="muted markerlightwarning" role="status">
<ha-icon icon="mdi:alert-outline"></ha-icon>
${this._t('marker.toggle_entity_missing', {
entity: d.toggleEntity,
fallback: automaticToggleTarget || this._t('marker.toggle_entity_none'),
})}
</p>` : nothing}
</div>`
: nothing}
${effectiveTapAction === 'toggle'
? html`<div id="marker-toggle-hint" class="rhint togglehint">
${toggleHintLines.map((line) => html`<div>${line}</div>`)}
+6
View File
@@ -588,6 +588,12 @@
"marker.light_entity_auto": "Automatic ({entity})",
"marker.light_entity_none": "no controllable entity",
"marker.light_entity_missing": "The saved entity {entity} is unavailable. House Plan temporarily uses {fallback}; the saved choice will be restored if it returns.",
"marker.toggle_entity_label": "Entity to toggle",
"marker.toggle_entity.help": "For a composite device, choose the exact own light or switch operated by a tap. Automatic keeps the previous target rules and is independent from the leading light entity.",
"marker.toggle_entity.help.aria": "Help: entity to toggle",
"marker.toggle_entity_auto": "Automatic ({entity})",
"marker.toggle_entity_none": "no own controllable entity",
"marker.toggle_entity_missing": "The saved entity {entity} is no longer among this marker’s selectable channels. House Plan temporarily uses {fallback}; the saved choice will be restored if it returns.",
"marker.glow_color_label": "Glow colour and brightness",
"marker.glow_mode.help": "Use live source values, override only its colour, or fix both colour and brightness. The minimum is 1%; to disable the source, choose “Never”.",
"marker.glow_mode.help.aria": "Help: glow colour and brightness",
+6
View File
@@ -588,6 +588,12 @@
"marker.light_entity_auto": "Автоматически ({entity})",
"marker.light_entity_none": "нет управляемой сущности",
"marker.light_entity_missing": "Сохранённая сущность {entity} недоступна. Временно используется {fallback}; выбор восстановится, если сущность вернётся.",
"marker.toggle_entity_label": "Переключаемая сущность",
"marker.toggle_entity.help": "Для составного устройства выберите собственную лампу или переключатель, которой управляет нажатие. Автоматический вариант сохраняет прежние правила цели и не зависит от ведущей сущности света.",
"marker.toggle_entity.help.aria": "Подсказка: переключаемая сущность",
"marker.toggle_entity_auto": "Автоматически ({entity})",
"marker.toggle_entity_none": "нет собственной управляемой сущности",
"marker.toggle_entity_missing": "Сохранённой сущности {entity} больше нет среди выбираемых каналов этого маркера. Временно используется {fallback}; выбор восстановится, если сущность вернётся.",
"marker.glow_color_label": "Цвет и яркость свечения",
"marker.glow_mode.help": "Используйте данные источника, задайте только цвет или зафиксируйте цвет и яркость. Минимум — 1%; чтобы отключить источник, выберите «Никогда».",
"marker.glow_mode.help.aria": "Подсказка: цвет и яркость свечения",
+18
View File
@@ -0,0 +1,18 @@
import type { Marker } from './types';
export interface ToggleEntityWriteState {
touched: boolean;
originalHas: boolean;
original: string | null | undefined;
value: string;
}
/** Lossless transactional write policy for marker.toggle_entity. */
export function toggleEntityWriteFields(
state: ToggleEntityWriteState,
): Pick<Marker, 'toggle_entity'> | Record<string, never> {
if (!state.touched) {
return state.originalHas ? { toggle_entity: state.original ?? null } : {};
}
return state.value ? { toggle_entity: state.value } : {};
}
+3
View File
@@ -150,6 +150,9 @@ export interface Marker {
/** Explicit leading controllable entity for an Always source. Missing keeps
* the compatibility fallback (entity binding -> primary -> first control). */
light_entity?: string | null;
/** Exact own light/switch selected for Toggle. Missing/null keeps the
* historical action resolver and external-only controls groups. */
toggle_entity?: string | null;
/** Optional, user-controlled value satellite around the device face.
* Missing keeps the legacy temperature/humidity compatibility heuristic. */
value_badge?: MarkerValueBadge | null;
+114
View File
@@ -474,6 +474,120 @@ test('explicit leading light entity drives the same entity used by the presentat
assert.deepEqual(toggleCommandEntityIds(intent.command), ['switch.power']);
});
test('issue 178: an explicit toggle entity selects one exact composite-device channel', () => {
const h = hass({
'switch.power': state('switch.power', 'on'),
'switch.child_lock': state('switch.child_lock', 'off'),
});
const base = device({
bindingKind: 'device', bindingRef: 'washer', primary: 'switch.power',
entities: ['switch.power', 'switch.child_lock'],
marker: { id: 'marker', binding: 'device:washer', tap_action: 'toggle' },
});
const childLock = {
...base, marker: { ...base.marker, toggle_entity: 'switch.child_lock' },
};
assert.deepEqual(toggleCommandEntityIds(resolveToggleIntent({
hass: h, devices: [childLock], device: childLock,
}).command), ['switch.child_lock']);
const power = { ...base, marker: { ...base.marker, toggle_entity: 'switch.power' } };
assert.deepEqual(toggleCommandEntityIds(resolveToggleIntent({
hass: h, devices: [power], device: power,
}).command), ['switch.power']);
});
test('issue 178: stale selection falls back while an active missing target never retargets', () => {
const h = hass({ 'switch.power': state('switch.power', 'off') });
h.entities['switch.child_lock'] = {
entity_id: 'switch.child_lock', platform: 'test', disabled_by: null,
};
const base = device({
bindingKind: 'device', bindingRef: 'washer', primary: 'switch.power',
entities: ['switch.power', 'switch.child_lock'],
marker: { id: 'marker', binding: 'device:washer', tap_action: 'toggle' },
});
const activeMissing = {
...base, marker: { ...base.marker, toggle_entity: 'switch.child_lock' },
};
const exact = resolveToggleIntent({ hass: h, devices: [activeMissing], device: activeMissing });
assert.equal(exact.command, null);
assert.equal(exact.noneReason, 'unavailable');
assert.equal(exact.skippedTargets[0].entityId, 'switch.child_lock');
const stale = {
...base, marker: { ...base.marker, toggle_entity: 'switch.removed' },
};
const fallback = resolveToggleIntent({ hass: h, devices: [stale], device: stale });
assert.deepEqual(toggleCommandEntityIds(fallback.command), ['switch.power']);
});
test('issue 178: an entity binding stays exact even with a sibling toggle selection', () => {
const h = hass({
'switch.bound': state('switch.bound', 'off'),
'switch.sibling': state('switch.sibling', 'on'),
});
const d = device({
bindingKind: 'entity', bindingRef: 'switch.bound', primary: 'switch.bound',
entities: ['switch.bound', 'switch.sibling'],
marker: {
id: 'marker', binding: 'entity:switch.bound', tap_action: 'toggle',
toggle_entity: 'switch.sibling',
},
});
assert.deepEqual(toggleCommandEntityIds(resolveToggleIntent({
hass: h, devices: [d], device: d,
}).command), ['switch.bound']);
});
test('issue 178: explicit own selection joins controls without changing legacy groups', () => {
const h = hass({
'switch.power': state('switch.power', 'off'),
'switch.child_lock': state('switch.child_lock', 'on'),
'light.external': state('light.external', 'off'),
});
const base = device({
bindingKind: 'device', bindingRef: 'washer', primary: 'switch.power',
entities: ['switch.power', 'switch.child_lock'], controls: ['light.external'],
marker: {
id: 'marker', binding: 'device:washer', tap_action: 'toggle',
controls: ['light.external'],
},
});
const legacy = resolveToggleIntent({ hass: h, devices: [base], device: base });
assert.deepEqual(toggleCommandEntityIds(legacy.command), ['light.external']);
assert.equal(legacy.command.service, 'turn_on');
const selected = {
...base, marker: { ...base.marker, toggle_entity: 'switch.child_lock' },
};
const group = resolveToggleIntent({ hass: h, devices: [selected], device: selected });
assert.deepEqual(toggleCommandEntityIds(group.command), ['light.external', 'switch.child_lock']);
assert.equal(group.command.service, 'turn_off');
});
test('issue 178: unavailable selected own group member is skipped without replacing it', () => {
const h = hass({
'switch.power': state('switch.power', 'on'),
'switch.child_lock': state('switch.child_lock', 'unavailable'),
'light.external': state('light.external', 'off'),
});
const d = device({
bindingKind: 'device', bindingRef: 'washer', primary: 'switch.power',
entities: ['switch.power', 'switch.child_lock'], controls: ['light.external'],
marker: {
id: 'marker', binding: 'device:washer', tap_action: 'toggle',
toggle_entity: 'switch.child_lock', controls: ['light.external'],
},
});
const intent = resolveToggleIntent({ hass: h, devices: [d], device: d });
assert.deepEqual(toggleCommandEntityIds(intent.command), ['light.external']);
assert.equal(intent.command.service, 'turn_on');
assert.deepEqual(intent.skippedTargets.map((target) => [target.entityId, target.reason]), [
['switch.child_lock', 'unavailable'],
]);
});
test('group domain remains a universal power target', () => {
const entityId = 'group.downstairs';
const h = hass({ [entityId]: state(entityId, 'on') });
+13 -1
View File
@@ -209,7 +209,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 25);
assert.equal(GOLDEN_MATRIX_VERSION, 26);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -378,6 +378,18 @@ test('device dialog goldens expose the complete light-source controls at desktop
'the harness must activate the declared light-controls scenario flag');
});
test('toggle-entity dialog goldens cover selected and stale states across themes and widths', () => {
const dialogs = GOLDEN_SCENARIOS.filter((scenario) =>
scenario.id.startsWith('toggle-entity-dialog-'));
assert.equal(dialogs.length, 2);
assert.deepEqual(new Set(dialogs.map((scenario) => scenario.deviceToggleEntity)),
new Set(['selected', 'stale']));
assert.deepEqual(new Set(dialogs.map((scenario) => scenario.language)), new Set(['en', 'ru']));
assert.deepEqual(new Set(dialogs.map((scenario) => scenario.theme)), new Set(['light', 'dark']));
assert.equal(dialogs.some((scenario) => scenario.viewport.width < 600), true);
assert.equal(dialogs.some((scenario) => scenario.viewport.width >= 1000), true);
});
test('help affordance golden covers an open text-bearing surface in both themes', () => {
const help = GOLDEN_SCENARIOS.filter((scenario) => scenario.openHelp);
assert.equal(help.length >= 2, true);
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { toggleEntityWriteFields } from '../test-build/marker-toggle-entity.js';
test('issue 178: untouched toggle entity preserves absence, null and stale literals', () => {
assert.deepEqual(toggleEntityWriteFields({
touched: false, originalHas: false, original: undefined, value: '',
}), {});
assert.deepEqual(toggleEntityWriteFields({
touched: false, originalHas: true, original: null, value: '',
}), { toggle_entity: null });
assert.deepEqual(toggleEntityWriteFields({
touched: false, originalHas: true, original: 'switch.removed', value: 'switch.removed',
}), { toggle_entity: 'switch.removed' });
});
test('issue 178: touched selection writes an exact id and Auto restores absence', () => {
assert.deepEqual(toggleEntityWriteFields({
touched: true, originalHas: false, original: undefined, value: 'switch.child_lock',
}), { toggle_entity: 'switch.child_lock' });
assert.deepEqual(toggleEntityWriteFields({
touched: true, originalHas: true, original: 'switch.child_lock', value: '',
}), {});
});
+1 -1
View File
@@ -36,7 +36,7 @@ test('persisted dynamic marker selects project their selected option explicitly'
block.match(/\bid="([^"]+)"/)?.[1] || '', block,
]));
for (const id of [
'marker-room', 'marker-tap-action', 'marker-light-entity', 'marker-display',
'marker-room', 'marker-tap-action', 'marker-toggle-entity', 'marker-light-entity', 'marker-display',
'marker-value-badge-source', 'marker-value-badge-position',
]) {
const block = byId.get(id);
+18 -1
View File
@@ -685,8 +685,23 @@ def test_export_rejects_boolean_stored_model_before_schema_coercion(tmp_path: Pa
assert invalid.value.code == "invalid_config"
@pytest.mark.parametrize("kind", ["full", "space"])
def test_export_copies_toggle_entity_literal(tmp_path: Path, kind: str) -> None:
config = _config()
config["markers"][0]["toggle_entity"] = "switch.channel_2"
document, _ = create_export(
SimpleNamespace(instance_id="instance-a"),
{"config": config}, {"layout": {}}, kind=kind,
space_id="ground" if kind == "space" else None,
card_version="1.65.0", config_root=tmp_path,
)
marker = document["payload"]["config"]["markers"][0]
assert marker["toggle_entity"] == "switch.channel_2"
def test_space_import_remaps_owned_ids_and_duplicate_policy(tmp_path: Path) -> None:
document = _document(tmp_path, "space")
document["payload"]["config"]["markers"][0]["toggle_entity"] = "switch.channel_2"
current = _config()
current["spaces"][0]["id"] = "existing"
current["spaces"][0]["title"] = "Ground"
@@ -704,7 +719,9 @@ def test_space_import_remaps_owned_ids_and_duplicate_policy(tmp_path: Path) -> N
copied = virtual["markers"][-1]
assert copied["binding"] == "virtual"
assert copied["display"] == "static_icon"
assert "controls" not in copied and vdetails["virtualized"] == 1
assert "controls" not in copied
assert "toggle_entity" not in copied
assert vdetails["virtualized"] == 1
def test_preview_is_owner_bound_and_foreign_full_drops_discovery_lists(tmp_path: Path) -> None:
+30
View File
@@ -1829,6 +1829,36 @@ def test_light_entity_is_domain_bounded_only_when_new_or_changed():
v.validate_marker_light_entities(old, validate_all=True)
def test_toggle_entity_is_domain_bounded_only_when_new_or_changed():
cfg = v.CONFIG_SCHEMA({"spaces": [], "markers": [
{"id": "washer", "binding": "device:washer",
"toggle_entity": "switch.child_lock"},
]})
assert cfg["markers"][0]["toggle_entity"] == "switch.child_lock"
old = {"markers": [
{"id": "washer", "binding": "device:washer", "toggle_entity": "sensor.future"},
]}
v.validate_marker_light_entities(old, old)
with pytest.raises(v.MarkerControlError) as changed:
v.validate_marker_light_entities({"markers": [
{"id": "washer", "binding": "device:washer", "toggle_entity": "sensor.changed"},
]}, old)
assert changed.value.code == "invalid_toggle_entity"
for invalid in ("light.UPPER", "switch.bad-id", "marker:other", 7):
with pytest.raises(v.MarkerControlError) as imported:
v.validate_marker_light_entities({"markers": [
{"id": "washer", "binding": "device:washer", "toggle_entity": invalid},
]}, validate_all=True)
assert imported.value.code == "invalid_toggle_entity"
with pytest.raises(v.MarkerControlError) as full_import:
v.validate_marker_light_entities(old, validate_all=True)
assert full_import.value.code == "invalid_toggle_entity"
def test_issue_90_value_badge_validation_is_strict_only_when_changed():
valid = {
"enabled": True,
+1 -1
View File
@@ -9,7 +9,7 @@
},
"include": [
"src/color.ts", "src/logic.ts", "src/glow-blend.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts",
"src/device-face.ts", "src/device-toggle.ts", "src/activity-runtime.ts",
"src/device-face.ts", "src/device-toggle.ts", "src/marker-toggle-entity.ts", "src/activity-runtime.ts",
"src/ha-binding-status.ts",
"src/integration-provider.ts", "src/vacuum.ts",
"src/sun.ts",