Release v1.62.0-beta.4 candidate

This commit is contained in:
Matysh
2026-08-12 10:19:51 +03:00
parent 8a417539e0
commit 4dc3fdef36
30 changed files with 1459 additions and 545 deletions
+82
View File
@@ -0,0 +1,82 @@
# Code review — issue #68 (contextual help)
Date: 2026-08-12
Branch: `dev`
Scope: `hp-help`, Houseplan help factory, localization contract, dialog/overlay lifecycle,
keyboard and touch interaction, responsive placement, consumers and regression coverage.
## Outcome
The overlay, focus, Escape, outside-click, scroll, Popover/fallback and visual-viewport
paths are internally consistent. The shared dialog overlay registry is used correctly,
the trigger remains reachable in disabled fieldsets through `legend`, and all seven
current call sites have non-empty RU/EN body and ARIA strings.
Four hardening findings were accepted and fixed locally. No model, saved configuration
or user data contract changed.
## Findings and resolutions
### CR68-01 — dead trigger is rendered without help content (P1)
`hp-help` blocked `_openHelp()` when `text` was empty, but `render()` still returned a
focusable button. The result was the exact reported defect: a visible help glyph that
could not open any explanation.
Resolution: `hp-help` renders nothing unless both trimmed `text` and `ariaLabel` exist.
The same predicate guards opening and closes an already-open surface if either value is
removed dynamically.
### CR68-02 — missing translation could be displayed as a key (P1)
The card factory called `t()` directly. Its intended generic fallback returns the key
name when neither dictionary contains a value, so a future incomplete help pair could
produce a real trigger with implementation text such as `marker.foo.help`.
Resolution: the factory now checks the localized value and its derived `.aria` value
through `hasTranslation()` before it creates `hp-help`. English fallback remains valid;
a genuinely absent or whitespace-only pair produces no host and no layout gap.
### CR68-03 — accessible name had a hard-coded English fallback (P1)
Direct use without `ariaLabel` produced `aria-label="Help"`. This violated the issue
contract requiring a complete localized accessible name and made an incomplete component
look valid to keyboard and screen-reader users.
Resolution: the fallback was removed. Missing ARIA copy suppresses the affordance just
like missing visible copy.
### CR68-04 — icon did not follow the product icon system (P2)
The trigger used a font `?`, whose shape and optical alignment depended on the platform
font and did not visually mean “question in a circle”.
Resolution: the glyph is now the shared MDI `help-circle-outline` vector inside the same
32/40 px target. It remains decorative because the button already has a full ARIA label.
### CR68-05 — regression coverage missed incomplete content (P2)
The smoke covered all open/close and overlay paths but never instantiated an empty or
half-configured component, so CR68-01/03 could pass the release gate.
Resolution: the #68 smoke now asserts that empty body and empty ARIA copy create no
trigger, and that restoring a complete pair creates the circled-question SVG.
## Reviewed without changes
- Mouse hover timing, keyboard focus, touch click and the second-Escape dialog path.
- `aria-describedby` only while open; the visible bubble stays hidden from the
accessibility tree to avoid duplicate announcements.
- Exclusive transient-surface ownership with the colour/opacity picker and toast.
- Popover API path and dialog-owned fallback portal.
- Cached dialog scroll-listener cleanup and disconnect cleanup.
- Visual viewport placement, flipping and edge clamping.
- Existing call sites and RU/EN localization parity.
## Verification policy
Per project policy, no tests were run during this local edit. Static type checking,
syntax checking and whitespace validation are recorded in the handoff; the updated
targeted smoke is intended for the next prerelease gate.
+180
View File
@@ -0,0 +1,180 @@
# Код-ревью issue #94 — универсальное «Переключить состояние»
- **Дата:** 2026-08-12
- **Issue:** https://github.com/Matysh/houseplan-card/issues/94
- **Проверенная версия:** локальный `dev` после `v1.62.0-beta.3`, включая
незакоммиченные исправления #95–#97
- **Итог ревью до правок:** changes requested — 2 high, 4 medium, 1 minor
- **Итог после локальных правок:** замечания устранены; проверки отложены до
ближайшего pre-release по принятому правилу владельца
## 1. Охват
Проверены:
1. нормативный алгоритм и acceptance criteria в
`docs/specs/094-universal-state-toggle.md`;
2. pure resolver `src/device-toggle.ts`;
3. target selection через exact binding, device role и `controls`;
4. capability/security/service guards;
5. dialog projection, hint, lossless Save и preview;
6. обычный click, confirmation re-resolve и обработка ошибок;
7. общий cover target для действия и presentation;
8. backend schema и import/export round-trip;
9. unit/smoke-матрица и архитектурная документация;
10. совместимость с визуальной непрерывностью #73 и локальными правками
#95–#97.
## 2. Найденные и исправленные замечания
### CR94-01 — High: domain service ошибочно считался capability конкретной entity
**Было:** `POWER_DOMAINS` разрешал `climate`, `water_heater`, `siren` и
`camera`, если нужный service существовал на уровне domain. Но HA публикует
services для всего domain; неподдерживающая их конкретная entity всё равно
оставалась «исполняемой» в hint, а вызов затем отклонялся Home Assistant.
Это прямо противоречило §9.1 и mutation gate 6 ТЗ. Home Assistant Core
подтверждает entity-level guards:
- Climate `TURN_OFF=128`, `TURN_ON=256`:
https://github.com/home-assistant/core/blob/dev/homeassistant/components/climate/const.py
- Water heater `ON_OFF=8`:
https://github.com/home-assistant/core/blob/dev/homeassistant/components/water_heater/__init__.py
- Siren `TURN_ON=1`, `TURN_OFF=2`:
https://github.com/home-assistant/core/blob/dev/homeassistant/components/siren/const.py
- Camera `ON_OFF=1`:
https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py
**Исправлено:** введён декларативный `POWER_ADAPTERS` с state semantics,
unknown policy и точными feature masks. Feature-gated entity теперь получает
команду только при наличии требуемых bits; service catalog остаётся вторым
guard. Media player и legacy vacuum включены в тот же реестр.
**Покрытие:** параметрические unit-матрицы для всех базовых power adapters и
для climate, media player, siren, water heater, camera, legacy vacuum — как
разрешённые, так и запрещённые/missing-feature варианты; отдельная матрица
`unknown` проверяет полный/неполный capability mask.
### CR94-02 — High: click мог использовать target из сохранённого визуального frame
**Было:** #73 намеренно может некоторое время показывать последний цельный
`_renderDevices` snapshot, но `_clickDevice(ev, d)` разрешал action прямо по
переданному `d`. Если binding/controls изменились до атомарной смены frame,
нажатие без confirmation могло вызвать прежнюю цель. Confirmation уже делал
повторное разрешение, обычный click — нет.
**Исправлено:** в View действие сначала находит текущий `DevItem` в
`this._devices` по стабильному marker id. Action, binding, controls и command
разрешаются только из него; исчезнувший marker даёт no-op. Локальная
House Plan info-card по-прежнему может использовать видимый snapshot — это
безопасная read-only поверхность и намеренный контракт исправления #96.
**Покрытие:** smoke сохраняет старый `DevItem`, меняет controls, перестраивает
live devices и проверяет, что click вызывает только новую группу.
### CR94-03 — Medium: неизвестный persisted action расходил UI и runtime
**Было:** неизвестный token на light проецировался как default `toggle`, тогда
как `toggleOriginOf()` правильно не признавал его toggle-origin. Селектор мог
показать «Переключить состояние», hint оставался пустым, а click был no-op.
**Исправлено:** light default применяется только к действительно отсутствующему
token (`null`, `undefined`, пустая legacy-строка). Неизвестное значение fail-
closed проецируется в локальную карточку; backend по-прежнему отклоняет его при
записи.
### CR94-04 — Medium: legacy cover терял identity после disable в HA
**Было:** legacy `tap_action: cover` искал cover только в active
`device.entities`, если рядом оставался хотя бы один активный sibling. После
disable cover в HA старое явное намерение превращалось в анонимный no-target и
presentation переставал знать прежнюю cover entity.
**Исправлено:** legacy-cover origin сначала сохраняет приоритет активной cover,
а при её отсутствии ищет историческую цель в `allEntities`. Общий resolver
возвращает `ha-disabled` и сохраняет тот же cover identity для hint/presentation,
но более ранняя disabled registry row не может заслонить рабочую cover. Новый
device-role toggle по-прежнему исключает disabled rows.
### CR94-05 — Medium: пустой service catalog считался поддержкой всех services
**Было:** отсутствие/пустой `hass.services` давало optimistic `true` для любого
service. Это нарушало runtime guard из ТЗ и позволяло построить команду без
доказательства её существования.
**Исправлено:** отсутствующий catalog/domain/service теперь означает
`unsupported`. После появления актуального HA snapshot resolver автоматически
пересчитывает hint и command. Синтетический HA в `demo/srv/demo.html` теперь
публикует явный service catalog, поэтому smoke-среда проверяет тот же fail-closed
контракт и не создаёт ложные no-op.
### CR94-06 — Medium: device binding не выбирал первую действительно поддерживаемую entity роли
**Было:** resolver выбирал первую entity «подходящего domain», а затем мог
остановиться на `unsupported`, хотя следующая равноправная entity той же
functional role имела требуемую capability. Это не соответствовало формулировке
§8.1 «первая поддерживаемая entity».
**Исправлено:** проверка идёт по уже выбранной shared functional role.
Capability-unsupported peer можно пропустить только внутри неё; missing,
unavailable и secure identity сохраняются без retarget. Config/diagnostic
switch более слабой роли по-прежнему никогда не подставляется.
### CR94-07 — Minor: статус ТЗ оставался «готово к реализации»
**Исправлено:** ТЗ, specs index, архитектура, STATUS, TESTING и RU/EN changelog
актуализированы под опубликованную beta.3 и этот локальный hardening pass.
## 3. Проверенные инварианты без изменений
- exact `entity:` binding не ищет sibling при unsupported/missing/unavailable;
- raw external controls владеют tap только у explicit toggle и не дают fallback
на собственную entity контроллера;
- passive forced-light marker сохраняет единственное документированное driver-
исключение и дедупликацию;
- partial group вызывает только отображённое доступное подмножество;
- any-on/all-off group semantics соответствует ТЗ;
- lock, alarm и cover classes `garage`/`door`/`gate` остаются secure no-op;
- cover/valve open/close/stop используют одновременно feature bit и service;
- confirmation сравнивает target set, а направление намеренно пересчитывается
по текущему state;
- legacy `cover` и отсутствующий default-light action сохраняются lossless до
явного изменения select;
- backend принимает текущие actions и legacy `cover`, неизвестные tokens
отклоняет; import/export сохраняет action-поля без преобразования;
- отдельного `cover` в текущем UI нет;
- right-click, long press, touch/pinch и confirmation UX этим проходом не
менялись.
## 4. Изменённые файлы
- `src/device-toggle.ts`
- `src/houseplan-card.ts`
- `test/device-toggle.test.mjs`
- `demo/smoke_controls.mjs`
- `demo/srv/demo.html`
- `docs/specs/094-universal-state-toggle.md`
- `docs/specs/README.md`
- `docs/ARCHITECTURE.md`
- `docs/STATUS.md`
- `docs/TESTING.md`
- `docs/CHANGELOG.md`
- `docs/CHANGELOG.ru.md`
## 5. Проверка
Локально выполнены только read-only/static проверки ревью:
- `git diff --check`;
- `npm run typecheck`;
- `node --check test/device-toggle.test.mjs`;
- `node --check demo/smoke_controls.mjs`;
- поиск всех consumers `resolveToggleIntent`, `projectedTapAction`,
`toggleCoverEntity`, `sameToggleCommandTargets`;
- сверка backend schema/import-export;
- сверка capability flags с официальным Home Assistant Core.
Unit, browser smoke, backend tests, build и generated bundles **не запускались**
по правилу проекта: локальные правки делаются без тестов, минимальный целевой
прогон выполняется при следующем pre-release.
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.62.0-beta.3"
VERSION = "1.62.0-beta.4"
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.62.0-beta.3"
"version": "1.62.0-beta.4"
}
+32 -7
View File
@@ -59,8 +59,28 @@ const res = await page.evaluate(async () => {
out.aggregateActivityRuns = c._stateClass(dev2()).includes('activity-running');
await setSt({ [lights[0]]: 'off' });
out.stateOff = c._stateClass(dev2()) === '';
// 4) без tap_action=toggle клик НЕ переключает (инфо)
// #94/#73 boundary: a complete visual frame may temporarily retain the old
// DevItem while live config has already changed. The click must resolve the
// current marker by id and never call the controls captured by that frame.
const cfg = c._serverCfg.markers.find((m) => m.name === 'Выключатель');
const staleSource = dev2();
const visuallyStale = {
...staleSource,
controls: [...(staleSource.controls || [])],
marker: staleSource.marker ? {
...staleSource.marker,
controls: [...(staleSource.marker.controls || [])],
} : null,
};
cfg.controls = [lights[1]];
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
await setSt({ [lights[0]]: 'off', [lights[1]]: 'off' });
c._clickDevice(new MouseEvent('click'), visuallyStale);
out.staleFrameUsesCurrentControls = JSON.stringify(calls.at(-1))
=== JSON.stringify(['homeassistant', 'turn_on', [lights[1]]]);
cfg.controls = [...lights];
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
// 4) без tap_action=toggle клик НЕ переключает (инфо)
cfg.tap_action = 'info'; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
const n = calls.length;
c._clickDevice(new MouseEvent('click'), dev2());
@@ -96,12 +116,17 @@ const res = await page.evaluate(async () => {
// operates the entity, and an ON hood/fan still has working-state yellow.
const own = Object.keys(c.hass.states).find((eid) => eid.startsWith('switch.'));
out.hasStandaloneSwitch = !!own;
const self = own && {
id: 'self-control-regression', name: 'Hood', icon: 'mdi:fan',
space: c._space, area: room.area, entities: [own], primary: own,
bindingKind: 'entity', bindingRef: own, tapAction: 'toggle',
marker: { id: 'self-control-regression', binding: `entity:${own}`, controls: [own] },
};
const selfId = 'self-control-regression';
if (own) {
c._serverCfg.markers.push({
id: selfId, name: 'Hood', icon: 'mdi:fan', binding: `entity:${own}`,
space: c._space, area: room.area, tap_action: 'toggle', controls: [own],
});
c._regSignature = '';
c._maybeRebuildDevices();
await c.updateComplete;
}
const self = c._devices.find((device) => device.id === selfId);
if (self) {
await setSt({ [own]: 'off' });
c._clickDevice(new MouseEvent('click'), self);
+19 -1
View File
@@ -34,7 +34,7 @@ const out = await page.evaluate(async () => {
};
const coverState = (state, attrs = {}) => ({
entity_id: 'cover.office_curtain', state,
attributes: { friendly_name: 'Office curtain', ...attrs },
attributes: { friendly_name: 'Office curtain', supported_features: 11, ...attrs },
});
const push = async (state, attrs = {}) => {
c.hass = {
@@ -105,7 +105,12 @@ const out = await page.evaluate(async () => {
await new Promise((r) => setTimeout(r, 10));
};
o.tapActionSurvivedTheRebuild = curtain()?.tapAction === 'toggle';
o.coverServicesDeclared = ['open_cover', 'close_cover', 'stop_cover']
.every((service) => Object.hasOwn(c.hass.services?.cover || {}, service));
calls.length = 0;
const closedIntent = c._toggleIntent(curtain());
o.closedIntentResolvesExactTarget = closedIntent?.command?.service === 'open_cover'
&& closedIntent.command.data.entity_id === 'cover.office_curtain';
await tap();
o.closedOpensTheCover = JSON.stringify(calls[calls.length - 1] || [])
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
@@ -196,6 +201,19 @@ const out = await page.evaluate(async () => {
await setSwitch('on');
o.withoutTheActionResolvedCoverSpeaks = clsOf(curtain()).includes('activity-transition')
&& !clsOf(curtain()).includes('on');
calls.length = 0;
c._infoCard = null;
// A local card must not depend on a second registry read at click time. The
// already-rendered curtain remains a valid informational snapshot while HA's
// registry is transiently unavailable; service-backed actions stay gated.
const liveBindingStatus = c._bindingStatus;
c._bindingStatus = () => ({
kind: 'unverified', reason: 'registry_unavailable', enabledEntityIds: [], allEntityIds: [],
});
try { await tap(); } finally { c._bindingStatus = liveBindingStatus; }
o.infoActionOpensTheHousePlanCard = c._infoCard?.id === curtain()?.id;
o.infoActionCallsNoHaService = calls.length === 0;
c._infoCard = null;
savedMarker.tap_action = 'cover';
await push('closed', { device_class: 'curtain' });
await setSwitch('off');
+20
View File
@@ -48,6 +48,26 @@ const res = await page.evaluate(async () => {
|| root().querySelector('hp-dialog')?.shadowRoot?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
};
// Empty or incomplete content must not leave a dead focus target behind.
const incompleteHelp = document.createElement('hp-help');
incompleteHelp.text = ' ';
incompleteHelp.ariaLabel = 'Help: unavailable description';
document.body.append(incompleteHelp);
await incompleteHelp.updateComplete;
out.emptyTextHidesTrigger = !incompleteHelp.shadowRoot?.querySelector('.trigger')
&& getComputedStyle(incompleteHelp).display === 'none';
incompleteHelp.text = 'Available description';
incompleteHelp.ariaLabel = ' ';
await incompleteHelp.updateComplete;
out.emptyAriaHidesTrigger = !incompleteHelp.shadowRoot?.querySelector('.trigger')
&& getComputedStyle(incompleteHelp).display === 'none';
incompleteHelp.ariaLabel = 'Help: available description';
await incompleteHelp.updateComplete;
const restoredTrigger = incompleteHelp.shadowRoot?.querySelector('.trigger');
out.circledQuestionIcon = restoredTrigger?.querySelector('svg[viewBox="0 0 24 24"] path')
?.getAttribute('d')?.length > 0;
incompleteHelp.remove();
card._setMode('devices');
const marker = card._devices[0];
card._openMarkerDialog(marker);
+23
View File
@@ -18,6 +18,29 @@ const res = await page.evaluate(async () => {
const lampCall = calls.at(-1);
out.lampToggles = ['light', 'homeassistant'].includes(lampCall?.[0])
&& lampCall?.[1] === expectedService && lampCall?.[2] === lamp.primary;
// The untouched select is an effective projection, not a frozen draft.
// Reproduce the field race where a dialog was opened before HA identified
// the leading entity as light: runtime already sees light-default toggle,
// and the UI must catch up without materializing a stored tap_action.
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(lamp); await c.updateComplete;
c._markerDialog = {
...c._markerDialog,
tapAction: 'info', tapActionTouched: false,
originalHasTapAction: false, originalTapAction: undefined,
};
await c.updateComplete;
const tapSelect = c.renderRoot.querySelector('#marker-tap-action');
out.defaultLampDialogShowsEffectiveToggle = tapSelect?.value === 'toggle';
out.defaultLampActionRemainsUnmaterialized = !c._markerDialog.tapActionTouched
&& !c._markerDialog.originalHasTapAction;
tapSelect.value = 'info';
tapSelect.dispatchEvent(new Event('change'));
await c.updateComplete;
out.explicitDeviceCardStaysSelected = c.renderRoot.querySelector('#marker-tap-action')?.value === 'info'
&& c._markerDialog.tapActionTouched;
c._markerDialog = null;
c._setMode('view'); await c.updateComplete;
// устройство с не-light primary (сенсор) → клик = инфо
const sensorDev = c._devices.find((d) => d.primary?.startsWith('sensor.') && !d.tapAction);
const n = calls.length;
+10
View File
@@ -7,6 +7,16 @@ const res = await page.evaluate(async () => {
c._space = 'garden'; c._setMode('devices'); await c.updateComplete;
const nav = JSON.parse(localStorage.getItem('houseplan_card_nav_v1'));
out.saved = nav.space === 'garden' && !Object.hasOwn(nav, 'mode');
// #95: during a same-route remount Lit may still present the old editor DOM
// while the new instance fails closed to View and holds the editor only as a
// pending intent until can_write arrives. The visible close button is a
// newer user command and must cancel that pending intent even though the
// runtime field already equals View.
c._mode = 'view';
c._pendingNavMode = 'devices';
c._setMode('view');
out.sameModeCloseCancelsPendingEditor = c._mode === 'view' && c._pendingNavMode === null;
c._setMode('devices'); await c.updateComplete;
// пересоздать карточку (эмуляция закрытия вкладки; кэш конфига в LS уже есть)
const c2 = document.createElement('houseplan-card');
c2.setConfig({ type: 'custom:houseplan-card' });
File diff suppressed because one or more lines are too long
+22 -1
View File
@@ -128,13 +128,34 @@ let STATES={
'vacuum.mower':{entity_id:'vacuum.mower',state:'cleaning',attributes:{friendly_name:'Mower'}},
'cover.gate':{entity_id:'cover.gate',state:'closed',attributes:{friendly_name:'Gate'}},
};
// Mirror the service catalog exposed by a live Home Assistant frontend. Action
// resolution deliberately fails closed when a service is not registered, so
// the synthetic demo must declare every service it intends to exercise.
const SERVICES={
homeassistant:{turn_on:{},turn_off:{},toggle:{}},
light:{turn_on:{},turn_off:{},toggle:{}},
switch:{turn_on:{},turn_off:{},toggle:{}},
fan:{turn_on:{},turn_off:{},toggle:{}},
humidifier:{turn_on:{},turn_off:{},toggle:{}},
input_boolean:{turn_on:{},turn_off:{},toggle:{}},
automation:{turn_on:{},turn_off:{},toggle:{}},
remote:{turn_on:{},turn_off:{},toggle:{}},
climate:{turn_on:{},turn_off:{},toggle:{}},
media_player:{turn_on:{},turn_off:{},toggle:{}},
siren:{turn_on:{},turn_off:{},toggle:{}},
vacuum:{turn_on:{},turn_off:{},toggle:{}},
water_heater:{turn_on:{},turn_off:{},toggle:{}},
camera:{turn_on:{},turn_off:{}},
cover:{open_cover:{},close_cover:{},stop_cover:{},toggle:{}},
valve:{open_valve:{},close_valve:{},stop_valve:{},toggle:{}},
};
function mkHass(){
return {
language:'en', locale:{language:'en'},
// audit P0-4: the card fail-closes editors without an admin user /
// can_write flag — the demo admin mirrors a real HA admin session.
user:{id:'demo', name:'Demo', is_admin:true},
devices:DEVICES, entities:ENTITIES, areas:AREAS, states:STATES,
devices:DEVICES, entities:ENTITIES, areas:AREAS, states:STATES, services:SERVICES,
floors:{g:{floor_id:'g',name:'Ground floor',level:0},u:{floor_id:'u',name:'Upstairs',level:1}},
callWS:async (m)=>{
if(m.type==='config/device_registry/list'){
+158 -144
View File
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -100,7 +100,10 @@ the same profiler available between stable promotions.
and `floating-surface-controller.ts` fallback/portal lifecycle,
prefer the browser top-layer Popover API and use a real dialog-owned portal
when that API is unavailable. Help text is localized by the owning card so
two cards with different explicit languages remain independent.
two cards with different explicit languages remain independent. A help
affordance exists only when both its localized body and complete accessible
label are non-empty; the card factory and `hp-help` enforce this independently,
so incomplete content cannot leave a dead focus target or a layout gap.
## Coordinate system
@@ -793,6 +796,11 @@ hash falls back to the default.
action `cover` remains accepted and losslessly round-tripped as a legacy
origin until the user deliberately changes the selector. An absent action on
a primary `light.*` likewise stays absent on an untouched Open → Save.
`POWER_ADAPTERS` is the explicit domain allow-list and carries per-entity HA
feature masks where a domain-wide service is not capability proof. The
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.
- **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
+26
View File
@@ -2,6 +2,32 @@
## Unreleased
## v1.62.0-beta.4 — 2026-08-12
- Fixed contextual help showing a dead icon when its explanation was absent.
Incomplete help content is now omitted entirely, and the plain `?` glyph was
replaced with the consistent outlined circled-question icon
([#68](https://github.com/Matysh/houseplan-card/issues/68)).
- Hardened universal **Toggle state** after the beta code review. Climate,
water-heater, siren, camera, media-player and legacy-vacuum actions now
require the exact HA entity capability bits as well as a live service;
clicks resolve current controls instead of a retained visual frame, and a
disabled legacy cover keeps its original, explainable target
([#94](https://github.com/Matysh/houseplan-card/issues/94)).
- Fixed the device editor showing **Device card** for a lamp whose effective
default action is **Toggle state**. Untouched actions now follow the current
preview entity in real time, while explicit choices and lossless legacy
storage remain unchanged
([#97](https://github.com/Matysh/houseplan-card/issues/97)).
- Fixed **Device card** taps doing nothing on compound curtain/cover devices.
The local House Plan card now opens independently of a momentary HA registry
revalidation, while HA more-info and service actions retain their safety gate
([#96](https://github.com/Matysh/houseplan-card/issues/96)).
- Fixed an editor-close race after a same-route technical remount. A visible
editor now closes on the first press even while write permission is still
resolving; the late server response can no longer reopen it
([#95](https://github.com/Matysh/houseplan-card/issues/95)).
## v1.62.0-beta.3 — 2026-08-12
- Replaced the separate Toggle and cover actions with one universal **Toggle
+26
View File
@@ -8,6 +8,32 @@
## Unreleased
## v1.62.0-beta.4 — 2026-08-12
- Исправлено отображение неработающего значка подсказки, когда самого текста
подсказки нет. Неполная подсказка теперь целиком скрывается, а обычный символ
`?` заменён на единообразную контурную иконку «вопрос в кружке»
([#68](https://github.com/Matysh/houseplan-card/issues/68)).
- Усилена надёжность универсального **«Переключить состояние»** после код-
ревью беты. Для climate, бойлеров, сирен, камер, медиаплееров и legacy-
пылесосов теперь проверяются точные capability bits сущности и наличие
сервиса; клик использует актуальные controls, а не сохранённый визуальный
кадр, и деактивированная legacy-штора сохраняет прежнюю объяснимую цель
([#94](https://github.com/Matysh/houseplan-card/issues/94)).
- Исправлено отображение **«Карточка устройства»** у лампы, фактическим
действием которой по умолчанию является **«Переключить состояние»**.
Нетронутая настройка теперь следует за актуальной ведущей сущностью в
предпросмотре, не меняя явный выбор пользователя и lossless-хранение legacy
([#97](https://github.com/Matysh/houseplan-card/issues/97)).
- Исправлено бездействие пункта **«Карточка устройства»** у составных устройств
штор и жалюзи. Локальная карточка House Plan теперь открывается независимо от
кратковременной повторной проверки реестра HA, а HA more-info и сервисные
действия сохраняют защитную проверку ([#96](https://github.com/Matysh/houseplan-card/issues/96)).
- Исправлена гонка при закрытии редактора после технического ре-маунта на том
же маршруте. Видимый редактор теперь закрывается с первого нажатия, даже если
право записи ещё уточняется; поздний ответ сервера больше не открывает его
повторно ([#95](https://github.com/Matysh/houseplan-card/issues/95)).
## v1.62.0-beta.3 — 2026-08-12
- Отдельные действия Toggle и управления шторами объединены в одно
+9 -9
View File
@@ -1,18 +1,18 @@
<!-- release: v1.62.0-beta.3 -->
<!-- release: v1.62.0-beta.4 -->
## Основное
- Добавлено универсальное действие «Переключить состояние» с точным предпросмотром цели.
- При возвращении к карточке восстанавливается пространство, но всегда открывается режим просмотра.
- Исправлен Glow от источника внутри толстой стены или внешнего проёма.
- Редактор устройств теперь закрывается с первого нажатия.
- Исправлены действия ламп и составных устройств штор; универсальное переключение точнее учитывает возможности Home Assistant.
- Значок подсказки больше не показывается без текста и заменён на «вопрос в кружке».
- Мелкие исправления и улучшения.
## Highlights
- Added a universal Toggle state action with an exact target preview.
- Returning to the card restores the space but always opens View mode.
- Fixed Glow from a source embedded in a thick wall or exterior opening.
- The Device editor now closes on the first press.
- Fixed lamp and compound-cover actions; universal toggle now observes Home Assistant capabilities more precisely.
- Help is no longer shown without explanatory text and now uses the circled-question icon.
- Small fixes and improvements.
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.62.0-beta.3/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.62.0-beta.3/docs/CHANGELOG.md)
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.62.0-beta.4/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.62.0-beta.4/docs/CHANGELOG.md)
+2 -2
View File
@@ -21,8 +21,8 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Item | State |
|---|---|
| Version | **v1.62.0-beta.3** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate for issues #92–#94 |
| Current local cycle | The v1.62.0-beta.3 candidate makes Glow fail dark when a source is embedded inside final opaque thick-wall geometry, including exterior openings (#92); persists only the selected space across real HA navigation and always returns in View while preserving same-route technical remount continuity (#93); and replaces separate toggle/cover choices with one explained `Toggle state` resolver shared by preview, click, confirmation and cover presentation (#94). Exact bindings, partial groups, secure no-op and lossless legacy/default records are explicit contracts. Issue #89 remains specification-only. |
| Version | **v1.62.0-beta.4** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate for issues #95–#97 plus #68/#94 hardening |
| Current local cycle | The v1.62.0-beta.4 candidate fixes three interaction/UI races: explicit mode commands cancel an older `_pendingNavMode`, so the Device editor closes on the first press (#95); the local House Plan device card opens independently of transient HA registry revalidation for compound covers (#96); and an untouched tap-action selector follows the current preview primary, keeping a lamp's label aligned with its effective action (#97). The #94 review additionally makes domain adapters fail closed on exact HA capability bits and service availability, resolves clicks from live controls and preserves disabled legacy-cover identity. The #68 review suppresses incomplete help at both factory and component boundaries, removes the hard-coded English ARIA fallback and uses the outlined circled-question MDI icon. Targeted prerelease gates cover these surfaces; issue #89 remains specification-only. |
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records and the linked [Project v2](https://github.com/users/Matysh/projects/1) is the canonical priority/status view; both must stay current. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | Prerelease publication requires a green exact-SHA Validate: frontend/backend, smoke (including the #73 rAF frame sampler), golden, HACS/Hassfest and a short absolute-ceiling performance smoke. Obsolete same-ref Validate runs are cancelled. Full seven-sample base/candidate performance moved to `performance.yml` (`main` push, weekly, manual); stable release assets fail closed unless Validate and Full Performance are green for the exact tagged SHA and the stable-only CDP compositor screencast finds no empty/black presented frame. |
+30 -4
View File
@@ -416,9 +416,14 @@ separately promised workflows:
domain toggle. Confirmation re-resolves current state but cancels if the
target set changed. Opening and saving an untouched legacy `cover` or an
absent light default preserves the original token/absence; an intentional
selector edit writes `toggle`
selector edit writes `toggle`. Feature-gated climate/water-heater/siren/
camera/media-player/legacy-vacuum entities require their exact HA bits;
an empty service catalog is unsupported. If #73 retains an older visual
device while live controls change, click calls only the current controls
[auto: test/device-toggle.test.mjs + smoke_cover_tap +
smoke_cover_not_primary + backend action-schema parity]
smoke_cover_not_primary + smoke_controls + backend action-schema parity]
The synthetic HA fixture publishes its service catalog explicitly, so
browser checks exercise the same fail-closed resolver as production
- [ ] A cover is NEVER painted (dev, owner 2026-08-04): «у штор не должно быть
жёлтой подложки никогда, индикация открыто/закрыто за счёт морфинга
иконки». Walk one curtain through closed / open / ajar / opening /
@@ -723,7 +728,9 @@ separately promised workflows:
out of the box — no per-device setting needed; the device dialog shows
"Toggle" as its effective default; devices where light is a side
function (kettle: primary = sensor) keep the Device-card default;
explicit per-device "Device card" wins over the default [auto: smoke_light_default_tap]
an untouched open dialog follows a newly resolved light primary instead
of retaining a stale Device-card label (#97); explicit per-device
"Device card" wins over the default [auto: smoke_light_default_tap]
- [ ] Derived walls cut too (v1.38.4): in the Plan editor the derived wall
segments (.seg) no longer run solid through an open stretch — only the
dash remains there [auto: smoke_openwall]
@@ -735,11 +742,17 @@ separately promised workflows:
`mode` and is rewritten without it. A `#space=` deep link beats the saved
space but still opens View. A technical same-route remount preserves an
unfinished editor/dialog, while a real route departure clears both
[auto: smoke_nav_persist + smoke_warm_dialogs]
[auto: smoke_nav_persist + smoke_warm_dialogs]. During a pending
`can_write` warm restore, one press on the visible editor close button
cancels the deferred editor and a late response cannot reopen it (#95)
[auto: smoke_nav_persist]
- [ ] Tap action cleanup + right click (v1.38.1, #94): the per-device action
list has four options (Device card / HA more-info / Toggle state / Run),
no separate cover or "card default" option — the card editor's global
tap option is gone and ignored;
Device card opens locally for compound curtains/covers even during a
transient registry revalidation and calls no HA service (#96)
[auto: smoke_cover_not_primary];
right click on an icon in VIEW opens HA more-info (native menu kept in
editors; virtual w/o entity → device card) [auto: smoke_tap_ctx]
- [ ] Binding section redesign (v1.38.0): two radios — Virtual / Pick from
@@ -2031,6 +2044,19 @@ require hands on real hardware — they remain for the human pass.
config/space/source changes [auto: editor/preloader smokes; performance
profile for a dense plan].
## Contextual help (issue #68, v1.62)
- [ ] A setting with complete RU/EN help body and ARIA copy shows one 32 px
desktop / 40 px coarse-pointer button with the outlined circled-question
icon. Mouse hover, keyboard focus and tap open the same text surface
[auto: `smoke_help_affordance`].
- [ ] Empty or whitespace-only help body produces no trigger. A non-empty body
without a complete ARIA label also produces no trigger; neither case adds
a tab stop or reserves visible space [auto: `smoke_help_affordance`].
- [ ] Escape, outside pointer, owning-dialog scroll, toast and a competing colour
picker close help in the documented order. The Popover and portal fallback
paths stay inside the visual viewport [auto: `smoke_help_affordance`].
## Hiding layers: decor, openings, virtual walls (docs/UX-MODES.md, dev, unreleased)
- [ ] **«Скрыть декоративный слой»** (owner 2026-08-05): a space with lines,
+7
View File
@@ -138,6 +138,13 @@ Lovelace **пересоздаёт элемент карточки** — выбр
`_restoreZoom()` — центрирующее восстановление осталось только для настоящей
навигации (хеш/`LS_NAV` привели на другое пространство).
Явная команда пользователя всегда сильнее ещё не применённой памятки. Пока
backend уточняет `can_write`, старый DOM редактора может оставаться видимым, а
новый экземпляр уже fail-closed находится в Просмотре и держит режим только в
`_pendingNavMode`. Любой вызов `_setMode()`, включая повторный `view` от
видимого крестика, сначала отменяет этот pending: поздний ответ сервера не
имеет права снова открыть уже закрытый редактор (#95).
Диплинк `#space=<id>` — явная навигация и по-прежнему сильнее памятки. Он
выбирает пространство, но, как и обычный возврат на карточку, не включает
редактор.
+9 -2
View File
@@ -1,8 +1,8 @@
# 068 — Подсказки к настройкам: переиспользуемый «?» в любом диалоге
# 068 — Подсказки к настройкам: переиспользуемая кнопка помощи в любом диалоге
- Issue: **#68**
- Приоритет: P2, polish
- Статус ТЗ: **реализовано локально; проверки отложены до пре-релиза** (ревизия 4)
- Статус ТЗ: **выпущено в v1.62.0-beta.1; локально усилено после ревью** (ревизия 5)
- Связано: #30 (информационная архитектура диалогов), #31 (доступность),
#62 (инфраструктура i18n), #85 (тесты должны уметь падать)
- Область: общий компонент `hp-help`, фабрика вызова Houseplan Card, `hp-dialog`,
@@ -22,6 +22,13 @@ dialog-owned portal в composed focus traversal и запрещает Escape п
каждом попадании фокуса. Статический scanner дополнительно запрещает сочетать
`title=` и `_help()` на одном host-контроле.
Ревизия 5 по полному code review запрещает «мёртвый» affordance: кнопка помощи
не существует в DOM, если отсутствует или пуста хотя бы одна из двух обязательных
локализованных строк — текст подсказки либо полная ARIA-подпись. Фабрика проверяет
пару до создания host, а сам `hp-help` повторяет защиту для прямого и динамического
использования. Визуальный знак — MDI `help-circle-outline` («вопрос в кружке»), а не
зависящий от системного шрифта символ `?`.
---
## 0. Что изменено ревью 2026-08-11
+19 -2
View File
@@ -1,7 +1,7 @@
# Issue #94 — универсальное действие «Переключить состояние»
- **Статус:** ТЗ принято владельцем; редакция 2 после технического ревью
2026-08-12; готово к реализации
- **Статус:** реализовано в `v1.62.0-beta.3`; полный code-review и hardening
capability/live-target contracts включены в кандидат `v1.62.0-beta.4`
- **Issue:** https://github.com/Matysh/houseplan-card/issues/94
- **Область:** frontend, marker action model, HA service dispatch, backend
compatibility, i18n, документация и QA
@@ -216,6 +216,13 @@ unavailable/disabled/secure либо исчез service/adapter), действи
### 6.4. Save
Отображаемое значение select для нетронутого action является живой эффективной
проекцией, а не копией, зафиксированной при открытии диалога. Оно пересчитывается
из `originalTapAction` и актуального `previewDevice.primary` на каждом render:
если HA позднее уточнил ведущую сущность как `light.*`, UI сразу показывает
`Переключить состояние`, как и runtime. После явного изменения select значение
становится пользовательским и больше автоматически не меняется.
- `toggle` разрешено сохранять всегда, даже при `kind: none`.
- Save не требует target.
- После повторного открытия явно выбранный action остаётся `toggle`; legacy
@@ -443,6 +450,14 @@ siren, vacuum, water_heater и других domains. Появление ново
toggle только из-за наличия `turn_on`: их запуск остаётся action `run`.
Список adapters и state semantics не копируется по renderer/click paths.
Реализация использует один декларативный `POWER_ADAPTERS`. Для доменов, где
Home Assistant регистрирует service на весь domain, но ограничивает конкретную
entity через `supported_features`, adapter требует точные bits: climate
`TURN_OFF/TURN_ON`, water heater `ON_OFF`, siren `TURN_OFF/TURN_ON`, camera
`ON_OFF`, media player `TURN_OFF/TURN_ON` и legacy vacuum `TURN_OFF/TURN_ON`.
Отсутствующий или пустой `hass.services` не считается оптимистическим
разрешением.
Для automation hint обязан говорить «включить/выключить автоматизацию», а не
«запустить»: запуск остаётся отдельным action `run`.
@@ -582,9 +597,11 @@ cover indicator, который управляет morph/activity marker. Отд
| Весь device disabled | Marker скрыт по действующему contract; action/config не стираются |
| Friendly name изменён | Hint обновляет имя, entity id остаётся тем же |
| Integration добавила более сильную role entity | Auto-target device binding может измениться; hint отражает результат |
| Пока #73 показывает последний цельный visual frame | Click повторно находит текущий marker по id и разрешает action/controls только из live devices; исчезнувший marker даёт no-op |
| Explicit controls + own relay | Controls полностью выигрывают; relay не fallback |
| Default light + controls | Собственный light default сохраняется; controls не перехватывают tap |
| Legacy cover + controls | До явного изменения action сохраняется старая cover-цель; controls игнорируются |
| Legacy device содержит disabled и active cover | Active cover имеет приоритет; disabled cover используется только как историческая объяснимая цель, если активной больше нет |
| Group: one on, others off | Turn off all |
| Group: all off | Turn on all |
| Group: часть unavailable/missing | Команда для доступного подмножества; hint перечисляет пропущенные цели |
+2 -2
View File
@@ -70,10 +70,10 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#54](https://github.com/Matysh/houseplan-card/issues/54) Zigbee topology overlay | [054-zigbee-topology-overlay.md](054-zigbee-topology-overlay.md) | research + adapter contract |
| [#55](https://github.com/Matysh/houseplan-card/issues/55) Independent Glow overlay | [055-independent-glow-overlay.md](055-independent-glow-overlay.md) | реализовано в v1.61.0-beta.2; #61 — v1.61.0-beta.3 |
| [#56](https://github.com/Matysh/houseplan-card/issues/56) Static room color | [056-static-room-color.md](056-static-room-color.md) | реализовано в v1.61.0-beta.3 |
| [#68](https://github.com/Matysh/houseplan-card/issues/68) Подсказки к настройкам | [068-help-affordance.md](068-help-affordance.md) | реализовано локально; проверки на пре-релизе |
| [#68](https://github.com/Matysh/houseplan-card/issues/68) Подсказки к настройкам | [068-help-affordance.md](068-help-affordance.md) | выпущено в v1.62.0-beta.1; локально усилено после ревью |
| [#84](https://github.com/Matysh/houseplan-card/issues/84) Источник «Всегда» без сущности HA + [#88](https://github.com/Matysh/houseplan-card/issues/88) ведущая сущность | [084-passive-forced-light-sources.md](084-passive-forced-light-sources.md) | реализовано локально вместе; ожидает prerelease/CI gate |
| [#90](https://github.com/Matysh/houseplan-card/issues/90) Управляемый бейдж со значением | [090-device-value-badge.md](090-device-value-badge.md) | реализовано локально; ожидает prerelease/CI gate |
| [#94](https://github.com/Matysh/houseplan-card/issues/94) Универсальное действие «Переключить состояние» | [094-universal-state-toggle.md](094-universal-state-toggle.md) | реализовано в v1.62.0-beta.3 по редакции 2; следующий gate — beta feedback и code review |
| [#94](https://github.com/Matysh/houseplan-card/issues/94) Универсальное действие «Переключить состояние» | [094-universal-state-toggle.md](094-universal-state-toggle.md) | реализовано в v1.62.0-beta.3; review/hardening включены в кандидат v1.62.0-beta.4 |
## Правило актуализации
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.62.0-beta.3",
"version": "1.62.0-beta.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.62.0-beta.3",
"version": "1.62.0-beta.4",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.62.0-beta.3",
"version": "1.62.0-beta.4",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+136 -43
View File
@@ -75,17 +75,71 @@ export interface ResolveToggleOptions {
lightSources?: readonly ResolvedLightSource<DevItem>[];
}
const POWER_DOMAINS = new Set([
'light', 'switch', 'fan', 'humidifier', 'climate', 'media_player',
'input_boolean', 'automation', 'remote', 'siren', 'vacuum', 'water_heater',
'camera',
]);
type PowerService = 'turn_on' | 'turn_off' | 'toggle';
const TURN_FEATURES: Record<string, Partial<Record<'turn_on' | 'turn_off', number>>> = {
// Home Assistant VacuumEntityFeature.TURN_ON / TURN_OFF.
vacuum: { turn_on: 1, turn_off: 2 },
// Home Assistant MediaPlayerEntityFeature.TURN_ON / TURN_OFF.
media_player: { turn_on: 128, turn_off: 256 },
interface PowerToggleAdapter {
/** States which mean disabled. Every other known state is active/on. */
offStates: readonly string[];
/** Whether an indeterminate state may use the domain/generic toggle. */
unknownUsesToggle: boolean;
/**
* Per-entity capability bits required by Home Assistant for each command.
* An absent entry means that the entity domain itself guarantees the basic
* turn-on/turn-off contract (for example light, switch and fan).
*/
featureMasks?: Partial<Record<PowerService, number>>;
}
const BASIC_POWER_ADAPTER: PowerToggleAdapter = {
offStates: ['off'],
unknownUsesToggle: true,
};
/**
* Explicit domain adapters are the capability allow-list for issue #94.
* `hass.services` is only a second, runtime guard: a service registered for a
* domain says nothing about whether one particular entity implements it.
*/
const POWER_ADAPTERS: Readonly<Record<string, PowerToggleAdapter>> = {
light: BASIC_POWER_ADAPTER,
switch: BASIC_POWER_ADAPTER,
fan: BASIC_POWER_ADAPTER,
humidifier: BASIC_POWER_ADAPTER,
input_boolean: BASIC_POWER_ADAPTER,
automation: BASIC_POWER_ADAPTER,
remote: BASIC_POWER_ADAPTER,
climate: {
...BASIC_POWER_ADAPTER,
// Home Assistant ClimateEntityFeature.TURN_OFF / TURN_ON.
featureMasks: { turn_on: 256, turn_off: 128, toggle: 128 | 256 },
},
media_player: {
...BASIC_POWER_ADAPTER,
// Home Assistant MediaPlayerEntityFeature.TURN_ON / TURN_OFF.
featureMasks: { turn_on: 128, turn_off: 256, toggle: 128 | 256 },
},
siren: {
...BASIC_POWER_ADAPTER,
// Home Assistant SirenEntityFeature.TURN_ON / TURN_OFF.
featureMasks: { turn_on: 1, turn_off: 2, toggle: 1 | 2 },
},
vacuum: {
...BASIC_POWER_ADAPTER,
// Deprecated but still supported legacy VacuumEntityFeature bits. Modern
// StateVacuumEntity instances deliberately do not advertise these and are
// therefore not treated as a binary power toggle.
featureMasks: { turn_on: 1, turn_off: 2, toggle: 1 | 2 },
},
water_heater: {
...BASIC_POWER_ADAPTER,
// Home Assistant WaterHeaterEntityFeature.ON_OFF.
featureMasks: { turn_on: 8, turn_off: 8, toggle: 8 },
},
camera: {
...BASIC_POWER_ADAPTER,
// Home Assistant CameraEntityFeature.ON_OFF.
featureMasks: { turn_on: 1, turn_off: 1, toggle: 1 },
},
};
const FEATURE_OPEN = 1;
@@ -96,13 +150,8 @@ function domainOf(entityId: string): string {
return entityId.slice(0, entityId.indexOf('.'));
}
function serviceCatalogPresent(hass: any): boolean {
return !!hass?.services && typeof hass.services === 'object'
&& Object.keys(hass.services).length > 0;
}
function serviceExists(hass: any, domain: string, service: string): boolean {
if (!serviceCatalogPresent(hass)) return true;
if (!hass?.services || typeof hass.services !== 'object') return false;
const services = hass.services?.[domain];
return !!services && Object.prototype.hasOwnProperty.call(services, service);
}
@@ -134,9 +183,9 @@ function registryDisabled(registryHass: any, entityId: string): boolean {
function supportedFeature(state: any, mask: number): boolean {
const raw = state?.attributes?.supported_features;
if (raw == null || raw === '') return true;
if (raw == null || raw === '') return false;
const features = Number(raw);
return Number.isFinite(features) && (features & mask) !== 0;
return Number.isFinite(features) && (features & mask) === mask;
}
function secureEntity(hass: any, registryHass: any, entityId: string): boolean {
@@ -198,16 +247,20 @@ function resolvePowerEntity(
if (stateObject.state === 'unavailable') {
return unsupportedSingle(hass, registryHass, ref, entityId, 'unavailable', 'power');
}
if (!POWER_DOMAINS.has(domain)) {
const adapter = POWER_ADAPTERS[domain];
if (!adapter) {
return unsupportedSingle(hass, registryHass, ref, entityId, 'unsupported');
}
let nextEffect: ToggleNextEffect;
let requested: 'turn_on' | 'turn_off' | 'toggle';
let requested: PowerService;
if (stateObject.state === 'unknown' || stateObject.state === '') {
if (!adapter.unknownUsesToggle) {
return unsupportedSingle(hass, registryHass, ref, entityId, 'unsupported', 'power');
}
nextEffect = 'toggle';
requested = 'toggle';
} else if (stateObject.state === 'off') {
} else if (adapter.offStates.includes(String(stateObject.state))) {
nextEffect = 'turn-on';
requested = 'turn_on';
} else {
@@ -215,7 +268,7 @@ function resolvePowerEntity(
requested = 'turn_off';
}
const featureMask = TURN_FEATURES[domain]?.[requested as 'turn_on' | 'turn_off'];
const featureMask = adapter.featureMasks?.[requested];
if (featureMask && !supportedFeature(stateObject, featureMask)) {
return unsupportedSingle(hass, registryHass, ref, entityId, 'unsupported', 'power');
}
@@ -291,20 +344,54 @@ function resolveEntity(
};
}
function entityCanRepresentToggle(entityId: string): boolean {
const domain = domainOf(entityId);
return domain === 'cover' || domain === 'valve' || domain === 'lock'
|| domain === 'alarm_control_panel' || POWER_DOMAINS.has(domain);
}
function ownCandidate(device: DevItem, registryHass: any): { entityId: string; via: ToggleTargetVia } | null {
function ownRoleCandidates(device: DevItem, registryHass: any): string[] {
if (device.bindingKind === 'entity' && device.bindingRef) {
return { entityId: device.bindingRef, via: 'binding' };
return [device.bindingRef];
}
const candidates = device.entities.length ? device.entities : device.allEntities || [];
const role = resolvedDeviceStateEntities(registryHass, candidates);
const entityId = role.find(entityCanRepresentToggle) || role[0] || null;
return entityId ? { entityId, via: 'device-role' } : null;
return resolvedDeviceStateEntities(registryHass, candidates);
}
function ownControllableCandidate(
device: DevItem, registryHass: any,
): { entityId: string; via: ToggleTargetVia } | null {
const role = ownRoleCandidates(device, registryHass);
const entityId = role.find(isControllable) || null;
return entityId ? {
entityId,
via: device.bindingKind === 'entity' ? 'binding' : 'device-role',
} : null;
}
/**
* Resolve the first supported member of the already-selected functional role.
* Missing/unavailable/secure members remain the explained target and never
* retarget. Capability-unsupported peers may be skipped only within that same
* role; `resolvedDeviceStateEntities` has already excluded random sibling
* config switches from a stronger cover/climate/media role.
*/
function resolveOwnEntity(
hass: any, registryHass: any, device: DevItem,
): SingleResolution | null {
const role = ownRoleCandidates(device, registryHass);
if (!role.length) return null;
const via: ToggleTargetVia = device.bindingKind === 'entity' ? 'binding' : 'device-role';
if (via === 'binding') return resolveEntity(hass, registryHass, role[0], via);
let firstUnsupported: SingleResolution | null = null;
let firstDisabled: SingleResolution | null = null;
for (const entityId of role) {
const result = resolveEntity(hass, registryHass, entityId, via);
if (result.command) return result;
const reason = result.skipped?.reason;
if (reason === 'missing' || reason === 'unavailable' || reason === 'secure') return result;
if (reason === 'ha-disabled') {
firstDisabled ||= result;
continue;
}
firstUnsupported ||= result;
}
return firstUnsupported || firstDisabled;
}
function reasonForSingle(result: SingleResolution): ToggleNoneReason {
@@ -333,7 +420,12 @@ export function projectedTapAction(
): 'info' | 'more-info' | 'toggle' | 'run' {
if (persisted === 'cover' || persisted === 'toggle') return 'toggle';
if (persisted === 'more-info' || persisted === 'run' || persisted === 'info') return persisted;
return defaultDomain === 'light' ? 'toggle' : 'info';
// Only actual absence activates the light default. An unknown persisted
// token is invalid data, not an absent choice: fail closed to the local card
// so the UI projection and `toggleOriginOf()` cannot disagree.
return persisted == null || persisted === ''
? (defaultDomain === 'light' ? 'toggle' : 'info')
: 'info';
}
export function toggleOriginOf(device: DevItem): ToggleOrigin | null {
@@ -372,7 +464,7 @@ function resolveControls(options: ResolveToggleOptions): ResolvedToggleIntent {
list.push(source);
markerSources.set(source.key, list);
}
const own = ownCandidate(device, registryHass);
const own = ownControllableCandidate(device, registryHass);
const markerDevices = new Map<string, DevItem>();
for (const item of devices) {
const markerId = String(item.marker?.id || item.id || '');
@@ -485,22 +577,23 @@ export function resolveToggleIntent(options: ResolveToggleOptions): ResolvedTogg
}
if (origin === 'legacy-cover') {
const candidates = device.entities.length ? device.entities : device.allEntities || [];
const entityId = candidates.find((eid) => eid.startsWith('cover.'));
// Legacy cover is an explicit historical target choice. Keep its identity
// even if HA later disables that cover while other active siblings remain.
// An active cover still wins: `allEntities` may contain an older disabled
// peer before it, and registry order must not downgrade a working target.
const entityId = device.entities.find((eid) => eid.startsWith('cover.'))
|| device.allEntities?.find((eid) => eid.startsWith('cover.'));
return entityId
? singleIntent(origin, resolveEntity(hass, registryHass, entityId, 'device-role'))
: emptyIntent(origin, 'no-actionable-entity');
}
const candidate = ownCandidate(device, registryHass);
if (!candidate) {
const own = resolveOwnEntity(hass, registryHass, device);
if (!own) {
return emptyIntent(origin, device.virtual || device.bindingKind === 'virtual'
? 'no-actionable-entity' : 'no-binding');
}
return singleIntent(
origin,
resolveEntity(hass, registryHass, candidate.entityId, candidate.via),
);
return singleIntent(origin, own);
}
/** Entity which owns cover presentation, even while temporarily unavailable. */
+81 -28
View File
@@ -135,7 +135,7 @@ import {
clampCanvasR, clampCanvasN, type ContentItem, type Rect,
} from './space-geometry';
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
import { langOf, t, type I18nKey } from './i18n';
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
import { CommandStack } from './command-stack';
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
import {
@@ -179,7 +179,7 @@ import {
import { renderOpeningTunnelFills } from './render/opening-tunnels';
import { safeStoredColor } from './color';
const CARD_VERSION = '1.62.0-beta.3';
const CARD_VERSION = '1.62.0-beta.4';
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
const DECOR_TEXT_CM_MAX = 2000;
const CELL_CM_MIN = 0.1;
@@ -3150,7 +3150,11 @@ class HouseplanCard extends LitElement {
return this._renderDeviceSnapshot?.devices || this._devices;
}
/** Full registry metadata for dialogs/ghost labels; never use for actions. */
/**
* Full registry metadata for diagnostics and action safety checks. Entity
* states/services for actions always come from the active `_planHass`;
* disabled rows are exposed here only so the resolver can reject/explain.
*/
private get _fullRegistryHass(): any {
void this._planHass;
return this._planHassMemo?.full || this.hass;
@@ -3783,16 +3787,24 @@ class HouseplanCard extends LitElement {
this._openMarkerDialog(d);
return;
}
const action = projectedTapAction(d.tapAction, d.primary?.split('.')[0]);
// The renderer may deliberately keep an older, complete visual snapshot
// on screen while a new HA/config frame is being committed (#73). Actions
// must never inherit that visual staleness: resolve the current DevItem by
// stable marker id before reading tap_action, controls or the binding.
const actionDevice = this._devices.find((item) => item.id === d.id);
if (!actionDevice) return; // marker disappeared from the live config
const action = projectedTapAction(
actionDevice.tapAction, actionDevice.primary?.split('.')[0],
);
// the accidental-tap guard (owner's spec 2026-07-29): any state-changing
// action — toggle or run — may ask first. The dialog is ours, not the
// browser confirm(), so it works and looks right on a wall tablet.
const guarded = (text: string, exec: () => void): void => {
if (d.marker?.tap_confirm) this._tapConfirm = { text, exec };
if (actionDevice.marker?.tap_confirm) this._tapConfirm = { text, exec };
else exec();
};
if (action === 'toggle') {
const initial = this._toggleIntent(d);
const initial = this._toggleIntent(actionDevice);
if (!initial?.command) return; // configured no-target is an intentional, quiet no-op
const execute = (intent: ResolvedToggleIntent): void => {
const command = intent.command;
@@ -3800,12 +3812,12 @@ class HouseplanCard extends LitElement {
this.hass.callService(command.domain, command.service, command.data)
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
};
const name = toggleIntentName(initial) || d.name;
if (d.marker?.tap_confirm) {
const name = toggleIntentName(initial) || actionDevice.name;
if (actionDevice.marker?.tap_confirm) {
this._tapConfirm = {
text: this._t('confirm.tap_toggle', { name }),
exec: () => {
const currentDevice = this._devices.find((item) => item.id === d.id);
const currentDevice = this._devices.find((item) => item.id === actionDevice.id);
const current = currentDevice ? this._toggleIntent(currentDevice) : null;
if (!current?.command || !sameToggleCommandTargets(initial.command, current.command)) {
this._showToast(this._t('toast.tap_target_changed'));
@@ -3817,9 +3829,20 @@ class HouseplanCard extends LitElement {
} else execute(initial);
return;
}
if (!this._deviceBindingActive(d)) return;
// The House Plan device card is a local informational surface built from
// the DevItem that is already on screen. It does not call an HA service
// and must not disappear behind a second, momentary registry-status check
// (notably on compound cover/curtain devices). HA-backed actions below
// keep the active-binding gate. Closing the stage gesture first also
// prevents a pending pan/long-press lifecycle from swallowing the modal.
if (action === 'info') {
this._interruptViewGesture();
this._infoCard = d;
return;
}
if (!this._deviceBindingActive(actionDevice)) return;
if (action === 'run') {
const target = d.marker?.tap_target || '';
const target = actionDevice.marker?.tap_target || '';
const svc = runServiceFor(target);
const st = this.hass.states[target];
if (!svc || !st) {
@@ -3828,11 +3851,13 @@ class HouseplanCard extends LitElement {
}
const name = st.attributes?.friendly_name || target;
guarded(this._t('confirm.tap_run', { name }), () => {
if (!this._deviceBindingActive(d) || !this._planEntityAvailable(target)) return;
if (!this._deviceBindingActive(actionDevice) || !this._planEntityAvailable(target)) return;
this.hass
.callService(svc.domain, svc.service, { entity_id: target })
.then(() => {
this._stampActivity(d.id, 'event', this._activitySourceKey(d));
this._stampActivity(
actionDevice.id, 'event', this._activitySourceKey(actionDevice),
);
this.requestUpdate();
this._showToast(this._t('toast.run_started', { name }));
})
@@ -3840,8 +3865,8 @@ class HouseplanCard extends LitElement {
});
return;
}
if (action === 'more-info' && d.primary) {
this._openMoreInfo(d.primary);
if (action === 'more-info' && actionDevice.primary) {
this._openMoreInfo(actionDevice.primary);
return;
}
this._infoCard = d;
@@ -3853,10 +3878,12 @@ class HouseplanCard extends LitElement {
}
/** Localize both parts of a help affordance while hp-help stays presentation-only. */
private _help(key: Extract<I18nKey, `${string}.help`>): TemplateResult {
private _help(key: Extract<I18nKey, `${string}.help`>): TemplateResult | typeof nothing {
const ariaKey = `${key}.aria` as I18nKey;
const lang = langOf(this.hass, this._config?.language);
if (!hasTranslation(lang, key) || !hasTranslation(lang, ariaKey)) return nothing;
return html`<hp-help data-help-key=${key}
.text=${this._t(key)} .ariaLabel=${this._t(ariaKey)}></hp-help>`;
.text=${t(lang, key)} .ariaLabel=${t(lang, ariaKey)}></hp-help>`;
}
private get _stageEl(): HTMLElement | null {
@@ -4899,6 +4926,13 @@ class HouseplanCard extends LitElement {
private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor'): void {
if (this._kiosk && mode !== 'view') return; // wall devices never edit
// A mode command is newer than the editor remembered by a same-route warm
// remount. Clear it before the same-mode early return: while can_write is
// pending, Lit may still be presenting the previous editor DOM even though
// the new instance already fails closed to `view`. Its close button must
// cancel the deferred editor in one press, not let the server response
// reopen it and force a second press (#95).
this._pendingNavMode = null;
if (this._mode === mode) return;
this._bootSoftCancel(); // navigation owns its own short, bounded transition
if ((mode === 'plan' || mode === 'decor') && !this._norm) {
@@ -15904,16 +15938,26 @@ class HouseplanCard extends LitElement {
});
}
/** One projection for the untouched select, hints and draft transitions. */
private _effectiveMarkerTapAction(
d: NonNullable<HouseplanCard['_markerDialog']>,
preview = this._markerPreviewDevice(d),
): string {
return d.tapActionTouched
? d.tapAction
: projectedTapAction(
d.originalHasTapAction ? d.originalTapAction : null,
preview?.primary?.split('.')[0],
);
}
/** Store one user-triggered announcement; live HA state ticks do not mutate it. */
private _announceToggleDraft(
d: NonNullable<HouseplanCard['_markerDialog']>,
): NonNullable<HouseplanCard['_markerDialog']> {
const preview = this._markerPreviewDevice(d);
const next = !d.tapActionTouched
? { ...d, tapAction: projectedTapAction(
d.originalHasTapAction ? d.originalTapAction : null,
preview?.primary?.split('.')[0],
) }
? { ...d, tapAction: this._effectiveMarkerTapAction(d, preview) }
: d;
const text = next.tapAction === 'toggle'
? this._toggleHintLines(this._toggleIntentForDialog(next)).join(' ')
@@ -16093,13 +16137,20 @@ class HouseplanCard extends LitElement {
const bindingStatus = isVirtual ? null : this._bindingStatus(d.binding);
const canOpenBindingInHa = !isVirtual && this._bindingHasHaPage(d.binding);
const previewDevice = this._markerPreviewDevice(d);
// Untouched defaults are projections, not stored values. Re-resolve the
// select from the current preview on every render: HA may reveal a more
// meaningful leading entity (most visibly `light.*`) after the dialog was
// opened. Runtime already uses that current primary, so keeping the stale
// draft value here made the select say "Device card" while a tap toggled
// the lamp. An explicit user choice remains authoritative and stable.
const effectiveTapAction = this._effectiveMarkerTapAction(d, previewDevice);
const previewSpaceDisplay = previewDevice
? spaceDisplayOf(this._serverCfg?.spaces.find((space: any) => space.id === previewDevice.space))
: null;
const previewLightDevices = previewDevice
? [...this._devices.filter((item) => item.id !== previewDevice.id), previewDevice]
: this._devices;
const toggleIntent = d.tapAction === 'toggle' && previewDevice
const toggleIntent = effectiveTapAction === 'toggle' && previewDevice
? this._toggleIntent(previewDevice, previewLightDevices) : null;
const toggleHintLines = this._toggleHintLines(toggleIntent);
const previewPresentation = previewDevice
@@ -16269,8 +16320,8 @@ class HouseplanCard extends LitElement {
${this._renderVacSection(d)}
<label>${this._t('marker.tap_label')}</label>
<select class="areasel" .value=${d.tapAction}
aria-describedby=${d.tapAction === 'toggle' ? 'marker-toggle-hint' : nothing}
<select id="marker-tap-action" class="areasel"
aria-describedby=${effectiveTapAction === 'toggle' ? 'marker-toggle-hint' : nothing}
@change=${(e: Event) => {
const next = {
...d,
@@ -16280,16 +16331,18 @@ class HouseplanCard extends LitElement {
this._markerDialog = this._announceToggleDraft(next);
}}>
${TAP_ACTIONS.map((v) => [v, 'tap.' + v.replace('-', '_')] as const).map(
([v, k]) => html`<option value=${v}>${this._t(k as any)}</option>`,
([v, k]) => html`<option value=${v} ?selected=${v === effectiveTapAction}>
${this._t(k as any)}
</option>`,
)}
</select>
${d.tapAction === 'toggle'
${effectiveTapAction === 'toggle'
? html`<div id="marker-toggle-hint" class="rhint togglehint">
${toggleHintLines.map((line) => html`<div>${line}</div>`)}
</div>
<div class="sr-only" role="status" aria-live="polite">${d.tapHintAnnouncement}</div>`
: nothing}
${d.tapAction === 'run'
${effectiveTapAction === 'run'
? (() => {
const q = d.runFilter.trim().toLowerCase();
const cands = this._runCandidates().filter(
@@ -16318,7 +16371,7 @@ class HouseplanCard extends LitElement {
: nothing}`;
})()
: nothing}
${d.tapAction === 'run' || (d.tapAction === 'toggle' && !!toggleIntent?.command)
${effectiveTapAction === 'run' || (effectiveTapAction === 'toggle' && !!toggleIntent?.command)
? html`<label class="srcrow" title=${this._t('marker.tap_confirm_tip')}>
${this._boolInput(d.tapConfirm, (v) => (this._markerDialog = { ...d, tapConfirm: v }))}
<span>${this._t('marker.tap_confirm')}</span>
+29 -6
View File
@@ -1,4 +1,5 @@
import { LitElement, css, html, nothing, type PropertyValues, type TemplateResult } from 'lit';
import { mdiHelpCircleOutline } from '@mdi/js';
import { floatingViewport, placeFloatingSurface } from './floating-surface';
import { FloatingSurfaceController } from './floating-surface-controller';
import type { HpDialog, HpOverlayCloseReason } from './hp-dialog';
@@ -21,11 +22,15 @@ export class HpHelp extends LitElement {
static styles = css`
:host {
display: inline-flex;
display: none;
flex: none;
vertical-align: middle;
}
:host([data-has-content]) {
display: inline-flex;
}
.trigger {
width: 32px;
height: 32px;
@@ -39,11 +44,17 @@ export class HpHelp extends LitElement {
background: transparent;
border: 0;
border-radius: 50%;
font: 700 15px/1 system-ui, sans-serif;
cursor: help;
-webkit-tap-highlight-color: transparent;
}
.trigger svg {
display: block;
width: 18px;
height: 18px;
fill: currentColor;
}
.trigger:hover,
.trigger:focus-visible,
.trigger[aria-expanded='true'] {
@@ -146,6 +157,10 @@ export class HpHelp extends LitElement {
this.addEventListener('keydown', this._keyDown, true);
}
protected willUpdate(): void {
this.toggleAttribute('data-has-content', this._hasContent());
}
disconnectedCallback(): void {
this.ownerDocument.removeEventListener('pointerdown', this._outsidePointerDown, true);
const win = this.ownerDocument.defaultView;
@@ -164,7 +179,7 @@ export class HpHelp extends LitElement {
}
protected updated(changed: PropertyValues): void {
if (!this.text && this._open) {
if (!this._hasContent() && this._open) {
this._closeHelp();
return;
}
@@ -186,6 +201,11 @@ export class HpHelp extends LitElement {
return this._floating.usesPopover(this._forceFallback);
}
private _hasContent(): boolean {
return typeof this.text === 'string' && this.text.trim().length > 0
&& typeof this.ariaLabel === 'string' && this.ariaLabel.trim().length > 0;
}
private _clearTimers(): void {
const win = this._window();
if (this._openTimer) win?.clearTimeout(this._openTimer);
@@ -279,7 +299,7 @@ export class HpHelp extends LitElement {
private readonly _floating = new FloatingSurfaceController(this, 'help', this._keyDown);
private async _openHelp(): Promise<void> {
if (this._open || !this.text) return;
if (this._open || !this._hasContent()) return;
this._clearTimers();
this._forceFallback = false;
this._open = true;
@@ -382,7 +402,8 @@ export class HpHelp extends LitElement {
}
render() {
const label = this.ariaLabel || 'Help';
if (!this._hasContent()) return nothing;
const label = this.ariaLabel.trim();
return html`
<span id=${this._descriptionId} class="sr-only" role="tooltip" ?hidden=${!this._open}>${this.text}</span>
<button class="trigger" type="button" aria-label=${label}
@@ -390,7 +411,9 @@ export class HpHelp extends LitElement {
aria-expanded=${this._open ? 'true' : 'false'}
@pointerenter=${this._triggerPointerEnter} @pointerleave=${this._triggerPointerLeave}
@focus=${this._triggerFocus} @blur=${this._triggerBlur} @click=${this._triggerClick}>
<span aria-hidden="true">?</span>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d=${mdiHelpCircleOutline}></path>
</svg>
</button>
${this._usesPopover() ? this._tooltipTemplate(true) : nothing}
`;
+6
View File
@@ -26,4 +26,10 @@ export function t(lang: Lang, key: Key, vars?: Record<string, string | number>):
return subst(DICTS[lang][key] ?? en[key] ?? key, vars);
}
/** Whether a localized value exists and contains useful text after fallback. */
export function hasTranslation(lang: Lang, key: string): boolean {
const value = DICTS[lang][key] ?? DICTS.en[key];
return typeof value === 'string' && value.trim().length > 0;
}
export type { Key as I18nKey };
+201
View File
@@ -14,6 +14,15 @@ const services = {
light: { turn_on: {}, turn_off: {}, toggle: {} },
switch: { turn_on: {}, turn_off: {}, toggle: {} },
fan: { turn_on: {}, turn_off: {}, toggle: {} },
humidifier: { turn_on: {}, turn_off: {}, toggle: {} },
input_boolean: { turn_on: {}, turn_off: {}, toggle: {} },
automation: { turn_on: {}, turn_off: {}, toggle: {} },
remote: { turn_on: {}, turn_off: {}, toggle: {} },
climate: { turn_on: {}, turn_off: {}, toggle: {} },
media_player: { turn_on: {}, turn_off: {}, toggle: {} },
siren: { turn_on: {}, turn_off: {}, toggle: {} },
water_heater: { turn_on: {}, turn_off: {}, toggle: {} },
camera: { turn_on: {}, turn_off: {} },
cover: { open_cover: {}, close_cover: {}, stop_cover: {}, toggle: {} },
valve: { open_valve: {}, close_valve: {}, stop_valve: {}, toggle: {} },
vacuum: { turn_on: {}, turn_off: {}, toggle: {} },
@@ -43,7 +52,9 @@ function device(overrides = {}) {
test('action projection is universal while persisted legacy/default origins stay distinguishable', () => {
assert.equal(projectedTapAction('cover', 'cover'), 'toggle');
assert.equal(projectedTapAction(undefined, 'light'), 'toggle');
assert.equal(projectedTapAction('', 'light'), 'toggle');
assert.equal(projectedTapAction(undefined, 'switch'), 'info');
assert.equal(projectedTapAction('future-action', 'light'), 'info');
assert.equal(toggleOriginOf(device({ tapAction: 'toggle' })), 'explicit-toggle');
assert.equal(toggleOriginOf(device({ tapAction: 'cover' })), 'legacy-cover');
assert.equal(toggleOriginOf(device({ tapAction: null, primary: 'light.lamp' })), 'default-light');
@@ -166,6 +177,47 @@ test('legacy cover keeps cover priority, ignores controls and uses open/close/st
assert.equal(toggleCoverEntity(intent), 'cover.curtain');
});
test('legacy cover keeps a disabled cover identity when active siblings remain', () => {
const h = hass({
'switch.reverse': state('switch.reverse', 'off'),
'cover.curtain': state('cover.curtain', 'closed', {
device_class: 'curtain', supported_features: 3,
}),
}, {
'switch.reverse': { entity_id: 'switch.reverse', device_id: 'dev' },
'cover.curtain': {
entity_id: 'cover.curtain', device_id: 'dev', disabled_by: 'user', device_class: 'curtain',
},
});
const d = device({
tapAction: 'cover', bindingKind: 'device', bindingRef: 'dev', primary: 'switch.reverse',
entities: ['switch.reverse'], allEntities: ['switch.reverse', 'cover.curtain'],
marker: { id: 'marker', binding: 'device:dev' },
});
const intent = resolveToggleIntent({ hass: h, registryHass: h, devices: [d], device: d });
assert.equal(intent.command, null);
assert.equal(intent.noneReason, 'ha-disabled');
assert.equal(intent.skippedTargets[0].entityId, 'cover.curtain');
assert.equal(toggleCoverEntity(intent), 'cover.curtain');
});
test('legacy cover prefers an active cover over an earlier disabled peer', () => {
const h = hass({
'cover.active': state('cover.active', 'closed', { supported_features: 3 }),
}, {
'cover.disabled': { entity_id: 'cover.disabled', device_id: 'dev', disabled_by: 'user' },
'cover.active': { entity_id: 'cover.active', device_id: 'dev', disabled_by: null },
});
const d = device({
tapAction: 'cover', bindingKind: 'device', bindingRef: 'dev', primary: 'cover.active',
entities: ['cover.active'], allEntities: ['cover.disabled', 'cover.active'],
marker: { id: 'marker', binding: 'device:dev' },
});
const intent = resolveToggleIntent({ hass: h, registryHass: h, devices: [d], device: d });
assert.deepEqual(toggleCommandEntityIds(intent.command), ['cover.active']);
assert.equal(toggleCoverEntity(intent), 'cover.active');
});
test('cover and valve adapters resolve open, close, stop and unknown fallback', () => {
const matrix = [
['cover.blind', 'closed', 1, 'open_cover', 'open'],
@@ -280,6 +332,155 @@ test('missing services make an otherwise supported entity an explicit no-op', ()
assert.equal(intent.command, null);
});
test('an absent service catalog is not optimistic proof of a service', () => {
const entityId = 'switch.plug';
const h = hass({ [entityId]: state(entityId, 'off') }, {}, {});
const d = device({
bindingKind: 'entity', bindingRef: entityId, primary: entityId, entities: [entityId],
marker: { id: 'marker', binding: `entity:${entityId}` },
});
const intent = resolveToggleIntent({ hass: h, devices: [d], device: d });
assert.equal(intent.noneReason, 'unsupported');
assert.equal(intent.command, null);
});
test('basic power adapters expose the same off/on state contract', () => {
for (const domain of [
'light', 'switch', 'fan', 'humidifier', 'input_boolean', 'automation', 'remote',
]) {
const entityId = `${domain}.sample`;
const d = device({
bindingKind: 'entity', bindingRef: entityId, primary: entityId, entities: [entityId],
marker: { id: 'marker', binding: `entity:${entityId}` },
});
const off = resolveToggleIntent({
hass: hass({ [entityId]: state(entityId, 'off') }), devices: [d], device: d,
});
assert.equal(off.command?.service, 'turn_on', `${domain} off`);
const on = resolveToggleIntent({
hass: hass({ [entityId]: state(entityId, 'on') }), devices: [d], device: d,
});
assert.equal(on.command?.service, 'turn_off', `${domain} on`);
}
});
test('feature-gated HA domains require the exact entity capability bits', () => {
const matrix = [
['climate.room', 'off', 256, 'turn_on'],
['climate.room', 'heat', 128, 'turn_off'],
['media_player.tv', 'off', 128, 'turn_on'],
['media_player.tv', 'playing', 256, 'turn_off'],
['siren.alarm', 'off', 1, 'turn_on'],
['siren.alarm', 'on', 2, 'turn_off'],
['water_heater.boiler', 'off', 8, 'turn_on'],
['water_heater.boiler', 'eco', 8, 'turn_off'],
['camera.garden', 'off', 1, 'turn_on'],
['camera.garden', 'idle', 1, 'turn_off'],
['vacuum.legacy', 'off', 1, 'turn_on'],
['vacuum.legacy', 'on', 2, 'turn_off'],
];
for (const [entityId, value, features, expectedService] of matrix) {
const h = hass({
[entityId]: state(entityId, value, { supported_features: features }),
});
const d = device({
bindingKind: 'entity', bindingRef: entityId, primary: entityId, entities: [entityId],
marker: { id: 'marker', binding: `entity:${entityId}` },
});
const intent = resolveToggleIntent({ hass: h, devices: [d], device: d });
assert.equal(intent.command?.service, expectedService, `${entityId} ${value}`);
const withoutCapability = hass({
[entityId]: state(entityId, value, { supported_features: 0 }),
});
const blocked = resolveToggleIntent({
hass: withoutCapability, devices: [d], device: d,
});
assert.equal(blocked.command, null, `${entityId} without feature`);
assert.equal(blocked.noneReason, 'unsupported', `${entityId} without feature`);
const withoutFeatureAttribute = hass({ [entityId]: state(entityId, value) });
const missingCapability = resolveToggleIntent({
hass: withoutFeatureAttribute, devices: [d], device: d,
});
assert.equal(missingCapability.command, null, `${entityId} without feature attribute`);
assert.equal(
missingCapability.noneReason, 'unsupported', `${entityId} without feature attribute`,
);
}
});
test('unknown state uses toggle only when the complete capability is present', () => {
const lightId = 'light.unknown';
const lightDevice = device({
bindingKind: 'entity', bindingRef: lightId, primary: lightId, entities: [lightId],
marker: { id: 'marker', binding: `entity:${lightId}` },
});
const lightIntent = resolveToggleIntent({
hass: hass({ [lightId]: state(lightId, 'unknown') }),
devices: [lightDevice], device: lightDevice,
});
assert.equal(lightIntent.command?.service, 'toggle');
assert.equal(lightIntent.nextEffect, 'toggle');
const climateId = 'climate.unknown';
const climateDevice = device({
bindingKind: 'entity', bindingRef: climateId, primary: climateId, entities: [climateId],
marker: { id: 'marker', binding: `entity:${climateId}` },
});
const capable = resolveToggleIntent({
hass: hass({ [climateId]: state(climateId, 'unknown', { supported_features: 384 }) }),
devices: [climateDevice], device: climateDevice,
});
assert.equal(capable.command?.service, 'toggle');
assert.equal(capable.nextEffect, 'toggle');
const onlyTurnOn = resolveToggleIntent({
hass: hass({ [climateId]: state(climateId, 'unknown', { supported_features: 256 }) }),
devices: [climateDevice], device: climateDevice,
});
assert.equal(onlyTurnOn.command, null);
assert.equal(onlyTurnOn.noneReason, 'unsupported');
});
test('device binding chooses the first capable peer inside one functional role', () => {
const h = hass({
'camera.first': state('camera.first', 'idle', { supported_features: 0 }),
'camera.second': state('camera.second', 'idle', { supported_features: 1 }),
'switch.option': state('switch.option', 'off'),
}, {
'camera.first': { entity_id: 'camera.first', device_id: 'dev' },
'camera.second': { entity_id: 'camera.second', device_id: 'dev' },
'switch.option': { entity_id: 'switch.option', device_id: 'dev' },
});
const d = device({
bindingKind: 'device', bindingRef: 'dev', primary: 'camera.first',
entities: ['camera.first', 'camera.second', 'switch.option'],
marker: { id: 'marker', binding: 'device:dev' },
});
const intent = resolveToggleIntent({ hass: h, devices: [d], device: d });
assert.deepEqual(toggleCommandEntityIds(intent.command), ['camera.second']);
assert.equal(intent.targets[0].via, 'device-role');
});
test('device binding preserves an unavailable first role entity instead of retargeting', () => {
const h = hass({
'camera.first': state('camera.first', 'unavailable', { supported_features: 1 }),
'camera.second': state('camera.second', 'idle', { supported_features: 1 }),
}, {
'camera.first': { entity_id: 'camera.first', device_id: 'dev' },
'camera.second': { entity_id: 'camera.second', device_id: 'dev' },
});
const d = device({
bindingKind: 'device', bindingRef: 'dev', primary: 'camera.first',
entities: ['camera.first', 'camera.second'], marker: { id: 'marker', binding: 'device:dev' },
});
const intent = resolveToggleIntent({ hass: h, devices: [d], device: d });
assert.equal(intent.command, null);
assert.equal(intent.noneReason, 'unavailable');
assert.equal(intent.skippedTargets[0].entityId, 'camera.first');
});
test('confirmation target comparison ignores order but detects target-set changes', () => {
const a = { domain: 'homeassistant', service: 'turn_on', data: { entity_id: ['switch.b', 'light.a'] } };
const b = { domain: 'homeassistant', service: 'turn_off', data: { entity_id: ['light.a', 'switch.b'] } };