mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Release v1.62.0-beta.4 candidate
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
|
||||
+158
-144
File diff suppressed because one or more lines are too long
+22
-1
@@ -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'){
|
||||
|
||||
Vendored
+158
-144
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 и управления шторами объединены в одно
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -138,6 +138,13 @@ Lovelace **пересоздаёт элемент карточки** — выбр
|
||||
`_restoreZoom()` — центрирующее восстановление осталось только для настоящей
|
||||
навигации (хеш/`LS_NAV` привели на другое пространство).
|
||||
|
||||
Явная команда пользователя всегда сильнее ещё не применённой памятки. Пока
|
||||
backend уточняет `can_write`, старый DOM редактора может оставаться видимым, а
|
||||
новый экземпляр уже fail-closed находится в Просмотре и держит режим только в
|
||||
`_pendingNavMode`. Любой вызов `_setMode()`, включая повторный `view` от
|
||||
видимого крестика, сначала отменяет этот pending: поздний ответ сервера не
|
||||
имеет права снова открыть уже закрытый редактор (#95).
|
||||
|
||||
Диплинк `#space=<id>` — явная навигация и по-прежнему сильнее памятки. Он
|
||||
выбирает пространство, но, как и обычный возврат на карточку, не включает
|
||||
редактор.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 перечисляет пропущенные цели |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
## Правило актуализации
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
`;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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'] } };
|
||||
|
||||
Reference in New Issue
Block a user