Merge issue #164 implementation into dev

The previous merge (6beb404) took a stale local ref and brought only the
specification; this one brings the implementation and the error-channel fix.

Issue: #164
User-Visible: no
This commit is contained in:
Matysh
2026-08-17 12:06:51 +03:00
19 changed files with 1080 additions and 476 deletions
File diff suppressed because one or more lines are too long
+75 -6
View File
@@ -57,6 +57,11 @@ const lightingRooms = [
poly: [[0.50, 0.10], [0.93, 0.10], [0.93, 0.88], [0.50, 0.88]] },
];
const applianceRooms = [
{ id: 'appliance-room', name: 'Laundry', area: 'golden_appliance',
poly: [[0.08, 0.10], [0.92, 0.10], [0.92, 0.90], [0.08, 0.90]] },
];
const geometrySpace = {
id: 'golden-geometry',
title: 'Geometry matrix',
@@ -122,7 +127,25 @@ const lightingSpace = {
decor: [],
};
const runtime = () => {
const applianceSpace = {
id: 'golden-appliance',
title: 'Appliance lifecycle',
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: {
fill_mode: 'none', glow_enabled: false, show_borders: true, show_names: true,
sun_rays: false, bg_mode: 'static',
},
rooms: applianceRooms,
walls: wallsFor('appliance', applianceRooms, 15),
openings: [],
partitions: [],
wall_columns: [],
decor: [],
};
const runtime = (includeAppliance = false) => {
const devices = {};
const entities = {};
const states = {
@@ -136,7 +159,8 @@ const runtime = () => {
// The production projection must preserve them.
const layout = {};
const areas = Object.fromEntries(
[...geometryRooms, ...lightingRooms].map((room) => [room.area, { area_id: room.area, name: room.name }]),
[...geometryRooms, ...lightingRooms, ...(includeAppliance ? applianceRooms : [])]
.map((room) => [room.area, { area_id: room.area, name: room.name }]),
);
const add = (id, domain, area, x, y, state, attributes = {}) => {
const entityId = `${domain}.${id.replaceAll('-', '_')}`;
@@ -171,6 +195,44 @@ const runtime = () => {
add('golden-right-linkquality', 'sensor', 'golden_light_right', 0.66, 0.70, '190', {
unit_of_measurement: 'lqi',
});
if (includeAppliance) {
const washerId = 'golden-washer';
devices[washerId] = {
id: washerId,
name: 'Golden washing machine',
model: 'GOLDEN-WASHER-COMPOSITE',
area_id: 'golden_appliance',
identifiers: [['houseplan_golden', washerId]],
config_entries: ['golden_entry'],
entry_type: null,
via_device_id: null,
disabled_by: null,
};
const addWasherEntity = (entityId, state, attributes = {}, registry = {}) => {
entities[entityId] = {
entity_id: entityId,
device_id: washerId,
platform: 'houseplan_golden',
config_entry_id: 'golden_entry',
disabled_by: null,
...registry,
};
states[entityId] = {
entity_id: entityId,
state,
attributes: { friendly_name: registry.original_name || entityId, ...attributes },
};
};
addWasherEntity('switch.golden_washer_power', 'on', {}, { original_name: 'Power' });
addWasherEntity('switch.golden_washer_child_lock', 'off', {}, { original_name: 'Child lock' });
addWasherEntity('sensor.golden_washer_status', 'done', {}, {
original_name: 'Status', translation_key: 'status',
});
addWasherEntity('sensor.golden_washer_stage', 'Rinse', {}, { original_name: 'Stage' });
addWasherEntity('sensor.golden_washer_program', 'mixed_wash', {}, { original_name: 'Program' });
layout[washerId] = { s: 'golden-appliance', x: 0.5, y: 0.5 };
}
return { devices, entities, states, layout, areas };
};
@@ -182,9 +244,12 @@ export const VISUAL_MATRIX_COUNTS = Object.freeze({
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
});
export const makeVisualMatrixFixture = () => ({
export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) => ({
config: {
spaces: [structuredClone(geometrySpace), structuredClone(lightingSpace)],
spaces: [
structuredClone(geometrySpace), structuredClone(lightingSpace),
...(applianceLifecycle ? [structuredClone(applianceSpace)] : []),
],
// A persisted marker is part of the fixture contract for scenarios that
// override per-source Glow controls. The device/layout alone are not a
// saved marker configuration and must not be silently treated as one.
@@ -201,6 +266,10 @@ export const makeVisualMatrixFixture = () => ({
},
},
},
...runtime(),
counts: VISUAL_MATRIX_COUNTS,
...runtime(applianceLifecycle),
counts: applianceLifecycle ? {
...VISUAL_MATRIX_COUNTS,
spaces: VISUAL_MATRIX_COUNTS.spaces + 1,
rooms: VISUAL_MATRIX_COUNTS.rooms + applianceRooms.length,
} : VISUAL_MATRIX_COUNTS,
});
+4 -2
View File
@@ -1,7 +1,9 @@
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
const fixtureFor = (scenario) => scenario.fixture === 'large'
? makeLargeHouseFixture()
: makeVisualMatrixFixture({ applianceLifecycle: !!scenario.applianceLifecycle });
const themeVars = {
dark: {
@@ -50,7 +52,7 @@ async function stableEnvironment(page, scenario) {
/** Apply every data-only scenario override before the fixture crosses into the browser. */
export function prepareGoldenFixture(scenario) {
const fixture = fixtureFor(scenario.fixture);
const fixture = fixtureFor(scenario);
if (scenario.cornerSplitWall) {
const stage = scenario.cornerSplitWall;
if (!['before', 'thin', 'thick'].includes(stage))
+9 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 22;
export const GOLDEN_MATRIX_VERSION = 23;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -35,6 +35,14 @@ export const GOLDEN_SCENARIOS = Object.freeze([
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-view-light-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'washer-active-cycle-dark', fixture: 'visual', applianceLifecycle: true,
space: 'golden-appliance', mode: 'view',
stateOverrides: { 'sensor.golden_washer_status': { state: 'start' } },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'washer-idle-cycle-dark', fixture: 'visual', applianceLifecycle: true,
space: 'golden-appliance', mode: 'view',
stateOverrides: { 'sensor.golden_washer_status': { state: 'done' } },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'day-cycle-dawn-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
bgMode: 'daynight', dayCycle: { phase: 'dawn', top: '#aabdd1' },
stateOverrides: { 'sun.sun': { attributes: { azimuth: 95, elevation: -2, rising: true } } },
File diff suppressed because one or more lines are too long
+124 -124
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- Composite appliances such as washing machines now use an explicit Home
Assistant Status/Run state/Job state to show the yellow working marker during
an active cycle. Power-on alone remains neutral, Power-off still fades stale
activity, and ordinary lone relays keep their existing behaviour
([#164](https://github.com/Matysh/houseplan-card/issues/164)).
## v1.64.0-beta.3 — 2026-08-14
- Large plans no longer recompute an unused physical-wall union on every floor
+6
View File
@@ -8,6 +8,12 @@
## Unreleased
- Составная техника, например стиральная машина, теперь использует явный
Status/Run state/Job state Home Assistant и получает жёлтую подложку во время
активного цикла. Одного Power=`on` по-прежнему недостаточно, Power=`off`
подавляет устаревший активный статус, а обычные одиночные реле сохраняют
прежнее поведение ([#164](https://github.com/Matysh/houseplan-card/issues/164)).
## v1.64.0-beta.3 — 2026-08-14
- Большие планы больше не пересчитывают неиспользуемое объединение физических
+9
View File
@@ -369,6 +369,15 @@ neutral**.
| Faded | Data unavailable | All relevant entities unknown, unavailable or absent |
| Neutral | No alarm, work or open condition | Off, closed, idle, standby, docked |
For a composite appliance with a dedicated Power switch, Power=`on` alone
remains neutral. If Home Assistant also exposes a strict lifecycle entity such
as Status/Run state/Job state, active values (`start`, `running`, `washing`,
`rinse`, and similar work states) make the marker yellow; idle, paused and
terminal values remove it. Power=`off` or unavailable still fades the marker
even if the lifecycle value is stale. Mode, Program, Stage and remaining time
are not treated as independent proof of work, and an ordinary lone relay keeps
its existing yellow-on behaviour.
Activity may be a finite three-wave event, persistent presence, a travelling
transition, or persistent work. `prefers-reduced-motion` replaces ordinary
motion with a compact indicator while the alarm remains clear.
+6 -1
View File
@@ -757,7 +757,7 @@ marker на плане при этом не меняются.
|---|---|---|---|---|
| `light.*` | Жёлтый, пока `on` | Работа, пока `on` | Стандартная лампочка off/on | Toggle |
| `switch.*`, `fan.*`, `humidifier.*` | Жёлтый, пока `on` | Работа, пока `on` | Обычно нет | Внутренняя карточка, если Toggle не выбран явно |
| Составное switch-only устройство с отдельной сущностью Power | Нейтральное при Power=`on`; приглушённое при Power=`off`/`unknown`/`unavailable` | Нет: соседние переключатели режимов и функций не считаются работой всего устройства | Обычно нет | Внутренняя карточка |
| Составное switch-only устройство с отдельной сущностью Power | При одном Power=`on` нейтральное; жёлтое при явном активном Status/Run state/Job state (`start`, `running`, `washing`, `rinse` и т. п.); приглушённое при Power=`off`/`unknown`/`unavailable` | Постоянная работа только по явному lifecycle; соседние переключатели режимов и функций не считаются работой всего устройства | Обычно нет | Внутренняя карточка |
| Motion/vibration/sound binary sensor | Нейтральный | Событие около 3.3 с при переходе off→on | Нет | Внутренняя карточка |
| Occupancy/presence binary sensor | Нейтральный | Пока присутствие активно | Нет | Внутренняя карточка |
| Door/window/garage contact | Оранжевый, пока `on` | Короткое событие при открытии | Закрыто/открыто для известных классов | Внутренняя карточка |
@@ -774,6 +774,11 @@ marker на плане при этом не меняются.
| Smoke/gas/CO/moisture/safety/tamper/problem, siren, triggered alarm | Красная авария | Кольцо не требуется: авария имеет высший приоритет | Зависит от базовой иконки | Карточка; опасный Toggle запрещён |
| Виртуальный маркер | Нейтральный | Нет без HA-источника | Нет | Внутренняя карточка или явный Run |
У составной техники `Mode`, `Program`, `Stage` и оставшееся время сами по себе
не доказывают работу: они могут сохранять последнее значение после цикла.
Power=`off`/`unavailable` подавляет даже устаревший активный Status. Обычное
одиночное реле не считается составной техникой и по-прежнему жёлтое при `on`.
## 13. Заливки комнат и свет
### Режимы заливки пространства
+11 -11
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"fixture": "synthetic-only",
"sourceFingerprint": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceFingerprint": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -13,7 +13,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
"imageSha256": "d36b6f9f8139f31ef73a780c6511a640a26055efd9d7a24c24fd48b1d8379bf0"
},
"view-touch": {
@@ -24,7 +24,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
"imageSha256": "358e25ff9984d0fb0c03cfbb848df40c425fdfe64cd5f4f613b1e754ca9d4249"
},
"space-create": {
@@ -35,7 +35,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
},
"room-contour-close": {
@@ -46,7 +46,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
"imageSha256": "2f4770869f04c8d7f1e8ff33af1e8bd79f45ad6d97be6d5e6135b3a82fec9e4c"
},
"plan-context-tray": {
@@ -57,7 +57,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
"imageSha256": "47d8fc2ac1c14cb699e990ca9c00d89b9ac57f6cc76b40420ceb26b4d7f50df4"
},
"device-editor": {
@@ -68,7 +68,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
"imageSha256": "7c3e25534fc819c45431907616c3d523961505859ee68af27cce2daec9f04fb0"
},
"device-display-preview": {
@@ -79,7 +79,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
"imageSha256": "df3a54395c83bffd7a161f1fb3de0143e12e76567535aedf9883873ead74df91"
},
"background-editor": {
@@ -90,7 +90,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
"imageSha256": "4e7b3b1220a3fe104cf11e5b32a31b343dc95c45fd2cbe3365d145f0a66a560d"
},
"room-card": {
@@ -101,7 +101,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
"imageSha256": "176abba71d41cfb045a33f82a794d9fbb2a5d3c48e6df66e6ec3a8e448311b11"
},
"device-info": {
@@ -112,7 +112,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
"imageSha256": "2199ed88b215bf2bff63bf665028f78b2aa6a92c3032b803931790f2ef071893"
}
}
+138 -59
View File
@@ -1,6 +1,6 @@
# Issue #164 — активный цикл стиральной машины должен быть жёлтым
Статус: **первая редакция, S3-spec; на ревью не отправлено**
Редакция: **вторая; замечания ревью учтены, вердикт зелёный**
Дата: 2026-08-16
@@ -63,10 +63,13 @@ paused, stopped или terminal, жёлтая подложка снимаетс
В задачу входят:
1. строгий generic resolver явной appliance lifecycle-сущности;
2. её приоритет над auxiliary switches и над нейтральным `Power=on`;
3. дополнение actual-work и terminal/idle словарей минимальными значениями,
необходимыми для отчёта и симметричного завершения цикла;
1. строгий generic resolver явной appliance lifecycle-сущности в уже
распознанной составной топологии с выделенным Power;
2. её приоритет над auxiliary switches и над нейтральным composite `Power=on`
без изменения семантики lone relay;
3. отдельное lifecycle-расширение actual-work словаря и дополнение общего
terminal/idle словаря минимальными значениями, необходимыми для отчёта и
симметричного завершения цикла;
4. явная матрица совместной работы lifecycle и выделенного Power;
5. одинаковый результат в full card, `houseplan-space-card`, desktop View,
touch View и kiosk;
@@ -103,18 +106,24 @@ paused, stopped или terminal, жёлтая подложка снимаетс
### 6.1. Порядок ролей
Действующий resolver сохраняет приоритеты whole-device доменов и semantic
binary. Новая appliance lifecycle-роль вставляется **после** них и **до**
switch fallback:
Действующий resolver сохраняет приоритеты whole-device доменов, semantic
binary и lone relay. Новая appliance lifecycle-роль включается только тогда,
когда существующее топологическое правило уже распознало составной прибор с
выделенным Power: среди entities больше одного uncategorised switch, а
выбранный switch подтверждён `isDevicePowerSwitch()`. В этой ветке порядок
такой:
1. canonical whole-device domain (`vacuum`, `climate`, `cover`, ...);
2. semantic binary (`running`, `power`, presence/contact/alarm, ...);
3. строгая appliance lifecycle-сущность;
4. representative switch / выделенный Power;
3. строгая appliance lifecycle-сущность + выделенный Power как gate;
4. действующий composite Power fallback;
5. passive fallback.
Так lifecycle не перехватывает устройство, для которого HA уже предоставляет
более сильную стандартную state machine.
более сильную стандартную state machine. Если топология не распознана как
composite Power, существующий representative switch остаётся источником:
одиночное реле `on` не теряет working из-за соседнего `Status=connected`,
`Status=idle` или другого lifecycle-похожего sensor.
### 6.2. Что считается явной lifecycle-сущностью
@@ -136,9 +145,16 @@ Power не должно скрывать от resolver точный `Status` т
- `run_state` / `running_state` / `machine_state`;
- `job_state` / `operation_state` / `activity_state`.
Русская локализация отображаемого имени (`Статус`, `Состояние работы`) может
поддерживаться тем же строгим словарём, но английский `translation_key`/
`original_name`, когда он есть, остаётся предпочтительным доказательством.
Русские отображаемые имена (`Статус`, `Состояние работы`) в первой итерации не
являются доказательством lifecycle-роли. Локализованный `friendly_name` не
мешает выбору, если canonical `translation_key`, `original_name`, registry name
или object id уже доказывает роль, но сам по себе роль не создаёт.
Конечный сегмент `_status` матчится, поэтому до allowlist применяется явный
connectivity stop-list. Кандидаты, чьи нормализованные metadata/object-id
сегменты содержат `wifi`, `connection`, `signal` или `battery`, исключаются:
`wifi_status`, `connection_status`, `signal_status` и `battery_status` не
являются lifecycle прибора.
Общий токен `state` без уточнения, substring внутри произвольного слова и
эвристика по имени всего устройства запрещены. `mode`, `program`, `stage`,
@@ -157,46 +173,62 @@ Live state не меняет выбранную сущность: временн
## 7. Контракт actual-work значений
Сравнение state и поддерживаемых action attributes остаётся case-insensitive и
whitespace-insensitive. Существующий `WORKING_STATES` сохраняется и получает
минимальный набор lifecycle-глаголов:
whitespace-insensitive. В одном модуле хранятся два уровня working vocabulary:
1. базовый `WORKING_STATES` без изменений — для generic entity projection и
действующих action attributes;
2. `LIFECYCLE_WORKING_STATES` = базовый набор плюс минимальный набор
lifecycle-глаголов, применяемый **только** к выбранной appliance
lifecycle-роли:
- `start`, `started`, `run`, `active`, `in_progress`;
- короткие формы физических этапов, уже представленных длительными формами:
`wash`, `rinse`, `spin`, `dry`.
Существующий `IDLE_STATES` сохраняется и получает terminal-пары:
Существующий общий `IDLE_STATES` сохраняется и получает terminal-пары:
- `stop`, `end`, `done`, `inactive`.
`paused` остаётся неработающим состоянием. `unknown`, `unavailable`, пустое и
missing не становятся working. Новые значения применяются только к уже
выбранной lifecycle-роли либо к действующим action attributes; они не дают
права сканировать произвольные Mode/Program/Stage sensors.
`paused` остаётся неработающим состоянием. Глобальное расширение idle-набора
допустимо и применяется по действующим веткам, включая climate; глобальное
расширение базового working-набора запрещено. `unknown`, `unavailable`, пустое
и missing не становятся working.
Новые active-значения применяются только к уже выбранной lifecycle-роли и не
расширяют классификацию generic sensors либо action attributes. Поэтому
`dry`, `start` или `active` у устройства без lifecycle-роли остаются neutral.
`wash`, `rinse`, `spin` и `dry` описывают допустимое **значение выбранной
lifecycle-роли**; сущность Stage/Phase сама по себе по-прежнему не является
authority и не сканируется.
Неоднозначные значения вроде `normal`, `auto`, `eco`, `mixed_wash`, чисел и
температур остаются neutral.
## 8. Матрица lifecycle + Power
Для составного прибора выделенный Power остаётся availability/lifecycle gate,
но не источником actual work:
Новая матрица применяется только к топологии, которую существующее правило
распознало как составной прибор с выделенным Power. В ней Power остаётся
availability/lifecycle gate, но не источником actual work:
| Power | Явный lifecycle | Итог |
|---|---|---|
| `on` | active (`start`, `washing`, ...) | available + yellow working/running |
| `on` | idle/terminal (`idle`, `stop`, `done`, ...) | available + neutral |
| `on` | unknown/unavailable/missing | available + neutral |
| `on` | lifecycle отсутствует | действующий composite Power: available + neutral |
| `on` | lifecycle отсутствует | действующий composite Power fallback: available + neutral |
| `off` | любое, включая stale active | действующий Power-off вид: unavailable/faded + neutral |
| unavailable/missing | любое | unavailable/faded + neutral |
| Power отсутствует | active lifecycle | available + yellow working/running |
| Power отсутствует | idle lifecycle | available + neutral |
| Power отсутствует | unavailable lifecycle | unavailable/faded + neutral |
Auxiliary switches не могут сделать прибор working и не перебивают выбранный
lifecycle. Alarm critical sources сохраняют действующий высший визуальный
приоритет над working.
За пределами распознанной composite Power топологии новая роль не меняет
источник состояния. В частности, lone relay `on` остаётся working даже при
соседнем lifecycle-похожем sensor с neutral/unknown значением; устройство без
выделенного Power следует прежнему resolver и не получает новых
lifecycle-specific active tokens по контракту #164.
## 9. Presentation и UX
- Результат использует существующий `status: working` и существующую жёлтую
@@ -248,14 +280,18 @@ neutral результат, что desktop View. Новых жестов или
Обязательные инварианты:
1. один classifier владеет working/idle vocabulary;
1. один модуль classifier владеет базовым working-набором, его scoped
lifecycle-расширением и общим idle-набором; второй список в presentation
запрещён;
2. один role resolver используется full и static cards;
3. выбор lifecycle не зависит от текущей доступности/значения и не скачет между
peers на state update;
4. анализ выполняется по уже построенному списку entities устройства, без
полного сканирования `hass.states` для каждого marker;
5. integration/model/device-name исключения запрещены;
6. Power-off и alarm precedence не размножаются отдельными render-ветками.
6. Power-off и alarm precedence не размножаются отдельными render-ветками;
7. lifecycle override существует только внутри уже распознанной composite
Power топологии и не меняет lone relay/обычный switch fallback.
## 13. Критерии приёмки
@@ -264,7 +300,9 @@ neutral результат, что desktop View. Новых жестов или
switches) детерминированно выбирает lifecycle `Status` плюс Power gate, а не
auxiliary switch; uncategorised Status выигрывает у diagnostic, config
исключается, а registry order и локализованный friendly name не меняют
результат при наличии canonical metadata.
результат при наличии canonical metadata. Без canonical metadata русские
`Статус`/`Состояние работы` роль не создают; `wifi_status`,
`connection_status`, `signal_status` и `battery_status` всегда исключаются.
- **AC2 (`unit`):** `Status=start` при `Power=on` даёт
`{ availability: available, status: working, activity: running }`; те же
assertions проходят для существующих длительных и новых коротких active
@@ -272,21 +310,29 @@ neutral результат, что desktop View. Новых жестов или
- **AC3 (`unit`):** при `Power=on` значения `idle`, `paused`, `stop`, `done`,
`finished` дают available + neutral; `Power=off/unavailable` подавляет даже
stale active lifecycle и сохраняет действующий faded neutral вид.
- **AC4 (`unit`):** Power-on без явной lifecycle-сущности остаётся neutral;
Mode/Program/Stage/positive remaining time и auxiliary switches сами по себе
не создают working.
- **AC5 (`unit`):** без Power активный lifecycle даёт working, idle — neutral,
unavailable — unavailable; live availability не ретаргетит выбранную роль.
- **AC6 (`unit`):** регрессии сохраняют действующий статус lone relay, climate
actual action/fallback, passive media, vacuum cleaning/returning, cover
transition, automation enabled, semantic binary, alarm priority и static
display suppression.
- **AC4 (`unit`):** в распознанной composite Power топологии `Power=on` без
явной lifecycle-сущности остаётся neutral; Mode/Program/Stage/positive
remaining time и auxiliary switches сами по себе не создают working.
Generic sensor со state `dry`, `start` или `active` у устройства без
выбранной lifecycle-роли также остаётся neutral.
- **AC5 (`unit`):** при `Power=on` временно unavailable lifecycle остаётся
выбранной ролью и даёт neutral, не ретаргетясь на peer. Топологии без
выделенного composite Power следуют прежнему resolver: новые
lifecycle-specific tokens их состояние не меняют.
- **AC6 (`unit`):** lone relay `on` плюс lifecycle-похожий sensor со значением
`connected`, `idle` или unavailable по-прежнему даёт working от relay;
semantic binary остаётся выше lifecycle. Остальные регрессии сохраняют
climate actual action/fallback, passive media, vacuum cleaning/returning,
cover transition, automation enabled, alarm priority и static display
suppression.
- **AC7 (`unit` + `ревью кода`):** full card и `houseplan-space-card` получают
один и тот же resolved presentation для active/idle washer snapshot; preview
использует тот же reason без нового classifier.
- **AC8 (`golden`):** synthetic composite washer в active snapshot визуально
имеет существующую жёлтую подложку, а парный idle snapshot — нейтральный;
эталон не содержит реальных пользовательских данных и проверяется по обычному
- **AC8 (`golden`):** synthetic composite washer добавлен в
`demo/fixtures/visual-matrix.mjs`; active snapshot визуально имеет
существующую жёлтую подложку, а парный idle snapshot — нейтральный. Сцены
перечислены в `GOLDEN_SCENARIOS`, `GOLDEN_MATRIX_VERSION` увеличен; эталон не
содержит реальных пользовательских данных и проверяется по обычному
baseline-review процессу.
- **AC9 (`ревью кода`):** desktop View, touch View и kiosk не имеют отдельных
state branches; поведение не зависит от pointer/viewport и не меняет editor
@@ -308,15 +354,40 @@ neutral результат, что desktop View. Новых жестов или
1. Добавить device fixture, повторяющий состав сущностей пользовательского
отчёта, с canonical registry metadata и локализованными display names.
2. Покрыть role priority и deterministic tie-break независимо от registry order.
3. Таблично покрыть active/idle/unknown словари и Power-матрицу из раздела 8.
4. Оставить негативную матрицу Mode/Program/Stage/time/aux switches.
5. Расширить regression tests существующих device roles и presentation modes.
3. Таблично покрыть базовый working-набор, scoped lifecycle-расширение, общий
idle-набор и Power-матрицу из раздела 8.
4. Оставить негативную матрицу Mode/Program/Stage/time/aux switches и добавить
generic `dry`/`start`/`active` без lifecycle-роли.
5. Добавить lone relay `on` с neutral lifecycle-похожим peer и connectivity
`_status` candidates.
6. Расширить regression tests существующих device roles и presentation modes.
### Mutation gate (#85)
До код-ревью исполнитель вручную вносит каждый мутант по одному и фиксирует,
что указанный тест краснеет; после возврата корректного кода весь unit suite
снова зелёный:
1. lifecycle-specific tokens добавлены в базовый working-набор вместо scoped
набора → краснеет
`keeps lifecycle-only active tokens neutral outside lifecycle role` (AC4);
2. снят Power-off gate для stale `Status=start` → краснеет
`suppresses stale active lifecycle when composite Power is off` (AC3);
3. lifecycle-роль поднята выше semantic binary → краснеет
`keeps semantic binary ahead of appliance lifecycle` (AC6);
4. unavailable lifecycle ретаргетится на соседнюю сущность → краснеет
`keeps lifecycle role identity stable while live state is unavailable`
(AC5);
5. в `device-presentation`/space-card добавлен второй vocabulary/classifier →
краснеет
`shares one lifecycle presentation between full and space cards` (AC7).
### Golden
Targeted synthetic scenario показывает рядом active и idle варианты одного
составного прибора либо делает два детерминированных capture. Меняется только
уже существующая working-подложка; новые visual tokens не принимаются.
В `demo/fixtures/visual-matrix.mjs` добавляются synthetic active и idle варианты
одного составного прибора. Оба capture регистрируются в `GOLDEN_SCENARIOS`, а
`GOLDEN_MATRIX_VERSION` увеличивается. Меняется только уже существующая
working-подложка; новые visual tokens не принимаются.
### Цикл реализации и pre-beta
@@ -339,19 +410,26 @@ registry/state snapshots. Новых service calls, permissions, URLs и HTML н
## 16. Риски
1. **Ложный yellow от режима/программы.** Закрывается строгим role allowlist и
1. **Ложный yellow от lifecycle-глагола у generic sensor.** Базовый working
vocabulary не расширяется, lifecycle tokens scoped выбранной ролью; AC4 и
первый мутант защищают границу.
2. **Ложный yellow от режима/программы.** Закрывается строгим role allowlist и
AC4.
2. **Stale stage после завершения.** Stage не является authority в #164;
3. **Stale stage после завершения.** Stage не является authority в #164;
terminal Status и Power-off закрывают цикл.
3. **Ложный neutral из-за слишком узкого metadata matcher.** Fixture покрывает
object id, translation key, original name и локализованный display name.
4. **Power-off проигрывает stale Status=start.** Явная матрица и AC3 задают
4. **Ложный neutral из-за слишком узкого metadata matcher.** Fixture покрывает
object id, translation key и original name. Русское отображаемое имя без
canonical metadata намеренно не поддерживается первой итерацией.
5. **Connectivity status ошибочно принят за lifecycle.** Stop-list и AC1
исключают wifi/connection/signal/battery candidates.
6. **Power-off проигрывает stale Status=start.** Явная матрица и AC3 задают
жёсткий gate.
5. **Регрессия soundbar/composite switches.** Существующий Power-on neutral
сохраняется и защищён AC4/AC6.
6. **Retarget на state update.** Выбор роли опирается на metadata, не live
7. **Регрессия lone relay или soundbar/composite switches.** Lifecycle override
ограничен распознанной composite Power топологией; AC4/AC6 защищают обе
стороны границы.
8. **Retarget на state update.** Выбор роли опирается на metadata, не live
значение; AC5 защищает identity.
7. **Расхождение full/static card.** Запрещён второй classifier, AC7 проверяет
9. **Расхождение full/static card.** Запрещён второй classifier, AC7 проверяет
один resolved presentation.
## 17. Release-артефакты
@@ -392,5 +470,6 @@ registry/state snapshots. Новых service calls, permissions, URLs и HTML н
не поводом ослаблять matcher без доказательства.
4. Lifecycle и Power могут быть представлены двумя resolved samples либо одним
составным result; наблюдаемая матрица раздела 8 обязательна, форма — нет.
5. Golden может расширить существующую visual-matrix fixture либо добавить
узкую новую; реальные screenshot/user data в репозиторий не попадают.
5. Golden расширяет существующую `demo/fixtures/visual-matrix.mjs`, регистрирует
обе сцены в `GOLDEN_SCENARIOS` и увеличивает `GOLDEN_MATRIX_VERSION`; реальные
screenshot/user data в репозиторий не попадают.
+25 -6
View File
@@ -12,7 +12,7 @@ import {
} from './devices';
import {
combineVisualSamples, entityVisualSample, entityVisualSamplesForDevice,
isDevicePowerSwitch,
isApplianceLifecycleEntity, isDevicePowerSwitch,
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
} from './device-visual';
import {
@@ -30,7 +30,8 @@ export type PresentationSourceKind =
| 'cover' | 'light' | 'controls' | 'device_role' | 'primary' | 'none';
export type PresentationSourceRole =
| 'cover' | 'light' | 'control' | 'forced_light' | 'device_role' | 'primary' | 'critical';
| 'cover' | 'light' | 'control' | 'forced_light' | 'device_role'
| 'lifecycle' | 'power_gate' | 'primary' | 'critical';
export type ValueFallbackReason =
| 'value_no_state' | 'value_ambiguous_sources' | 'value_non_scalar' | 'value_virtual';
@@ -315,8 +316,20 @@ export function resolvePresentationSources(
: d.entities.filter((eid) => !!hass?.states?.[eid]);
if (ids.length) {
sourceKind = 'device_role';
const samples = entityVisualSamplesForDevice(hass, ids, d.entities);
visualSources = ids.map((eid, index) => sourceOf(hass, eid, 'device_role', samples[index]));
// Registry metadata chooses the role, while the live hass snapshot owns
// its state. Keep those authorities together for composite lifecycle
// classification even during a frozen/partial registry projection.
const visualHass = registryHass === hass
? hass : { ...registryHass, states: hass?.states || {} };
const samples = entityVisualSamplesForDevice(visualHass, ids, d.entities);
const hasLifecycle = ids.some((eid) => isApplianceLifecycleEntity(registryHass, eid));
visualSources = samples.map((sample) => {
const role: PresentationSourceRole = hasLifecycle
? isDevicePowerSwitch(registryHass, sample.eid) ? 'power_gate'
: isApplianceLifecycleEntity(registryHass, sample.eid) ? 'lifecycle' : 'device_role'
: 'device_role';
return sourceOf(hass, sample.eid, role, sample);
});
} else if (d.primary) {
sourceKind = 'primary';
visualSources = [sourceOf(hass, d.primary, 'primary')];
@@ -418,6 +431,11 @@ function resolveValue(
// `hass.states['marker:*']` or turning a sensor into an empty value.
const realVisualSources = sources.visualSources.filter((source) => !source.eid.startsWith('marker:'));
let valueEids = realVisualSources.map((source) => source.eid);
const powerGate = realVisualSources.find((source) => source.role === 'power_gate');
// Before #164 a composite appliance value face displayed its selected Power
// entity. The added lifecycle source must not turn that existing value into
// an ambiguous fallback.
if (powerGate) valueEids = [powerGate.eid];
if (!valueEids.length && sources.visualSources.some((source) => source.eid.startsWith('marker:'))) {
valueEids = resolvedDeviceStateEntities(hass, d.entities);
if (!valueEids.length && d.primary && hass?.states?.[d.primary]) valueEids = [d.primary];
@@ -634,8 +652,9 @@ export function resolveDevicePresentation(
const notices: PresentationReason[] = [];
if (options.designPreview && userHidden) notices.push('hidden_design_preview');
if (!staticIcon && d.marker?.vacuum?.live === true) notices.push('vacuum_live_plan_only');
const powerSource = sources.visualSources.length === 1 && !!realVisualSource
&& isDevicePowerSwitch(hass, realVisualSource.eid);
const powerSource = sources.visualSources.some((source) =>
source.role === 'power_gate' || isDevicePowerSwitch(hass, source.eid),
);
const uncategorisedSwitches = d.entities.filter((eid) =>
eid.startsWith('switch.') && !hass?.entities?.[eid]?.entity_category,
).length;
+94 -11
View File
@@ -67,9 +67,19 @@ const WORKING_STATES = new Set([
'irrigating', 'humidifying', 'dehumidifying', 'fan', 'preheating', 'defrosting',
]);
/** Extra active verbs are safe only after the device resolver has selected a
* strict appliance lifecycle role. Keeping them out of WORKING_STATES prevents
* generic sensors such as a dry leak sensor or an active mesh node from
* turning an unrelated marker yellow. */
const LIFECYCLE_WORKING_STATES = new Set([
...WORKING_STATES,
'start', 'started', 'run', 'active', 'in_progress',
'wash', 'rinse', 'spin', 'dry',
]);
const IDLE_STATES = new Set([
'off', 'idle', 'paused', 'standby', 'docked', 'finished', 'complete',
'completed', 'stopped', 'ready', 'sleeping',
'completed', 'stopped', 'ready', 'sleeping', 'stop', 'end', 'done', 'inactive',
]);
/** Standard HA HVAC modes which mean the climate entity is enabled. The
@@ -84,6 +94,46 @@ const unavailable = (state: string): boolean =>
const lower = (v: unknown): string => String(v ?? '').trim().toLowerCase();
const lifecycleToken = (v: unknown): string => lower(v)
.replace(/[\s-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
const LIFECYCLE_ROLE_RANK = new Map<string, number>([
['run_state', 0], ['job_state', 0], ['operation_state', 0], ['activity_state', 0],
['machine_state', 1], ['running_state', 1],
['status', 2], ['device_status', 2], ['machine_status', 2],
]);
const LIFECYCLE_CONNECTIVITY_SEGMENTS = new Set(['wifi', 'connection', 'signal', 'battery']);
/** Rank a strict appliance lifecycle entity from generic HA metadata.
* Russian/localised display text is deliberately not authority in #164. */
export function applianceLifecycleRoleRank(hass: any, eid: string): number | null {
const reg = hass?.entities?.[eid] || {};
if (reg.entity_category === 'config') return null;
const objectId = String(eid || '').split('.').slice(1).join('.');
const registryEvidence = [reg.translation_key, reg.original_name, reg.name];
const evidence = [...registryEvidence, objectId];
if (!registryEvidence.some((value) => lower(value))) {
evidence.push(hass?.states?.[eid]?.attributes?.friendly_name);
}
const normalized = evidence.map(lifecycleToken).filter(Boolean);
if (normalized.some((value) => value.split('_').some(
(segment) => LIFECYCLE_CONNECTIVITY_SEGMENTS.has(segment),
))) return null;
let best: number | null = null;
for (const value of normalized) {
for (const [role, rank] of LIFECYCLE_ROLE_RANK) {
if (value !== role && !value.endsWith(`_${role}`)) continue;
best = best == null ? rank : Math.min(best, rank);
}
}
return best;
}
export const isApplianceLifecycleEntity = (hass: any, eid: string): boolean =>
applianceLifecycleRoleRank(hass, eid) != null;
/**
* A dedicated whole-device power switch, as opposed to a relay whose `on`
* state is the useful work itself. This uses generic HA registry/state
@@ -115,6 +165,25 @@ function workAction(attrs: any): string {
return '';
}
/** Classify the state of an already-selected appliance lifecycle role. The
* generic classifier intentionally never sees the extra lifecycle verbs. */
function applianceLifecycleVisualSample(hass: any, eid: string): EntityVisualSample {
const st = hass?.states?.[eid];
const state = st ? lower(st.state) : '__missing__';
const base: EntityVisualSample = {
eid, state,
availability: unavailable(state) ? 'unavailable' : 'available',
status: 'neutral', activity: 'none', edge: 'none',
};
if (base.availability === 'unavailable') return base;
const action = workAction(st?.attributes);
if (WORKING_STATES.has(action)
|| (LIFECYCLE_WORKING_STATES.has(state) && !IDLE_STATES.has(state))) {
return { ...base, status: 'working', activity: 'running' };
}
return base;
}
/** Classify one entity without looking at previous state. */
export function entityVisualSample(hass: any, eid: string): EntityVisualSample {
const st = hass?.states?.[eid];
@@ -249,19 +318,33 @@ export function entityVisualSamplesForDevice(
resolvedEids: readonly string[],
allEids: readonly string[],
): EntityVisualSample[] {
const samples = resolvedEids.map((eid) => entityVisualSample(hass, eid));
const uncategorisedSwitches = allEids.filter((eid) =>
eid.startsWith('switch.') && !hass?.entities?.[eid]?.entity_category,
);
const lifecycle = resolvedEids.length === 1
&& uncategorisedSwitches.length > 1
&& isDevicePowerSwitch(hass, resolvedEids[0]);
if (!lifecycle) return samples;
return samples.map((sample) => {
if (sample.availability === 'unavailable') return sample;
return sample.state === 'off'
? { ...sample, availability: 'unavailable', status: 'neutral', activity: 'none', edge: 'none' }
: { ...sample, status: 'neutral', activity: 'none', edge: 'none' };
const powerEid = resolvedEids.find((eid) => isDevicePowerSwitch(hass, eid));
const compositePower = uncategorisedSwitches.length > 1 && !!powerEid;
if (!compositePower) return resolvedEids.map((eid) => entityVisualSample(hass, eid));
const lifecycleEid = resolvedEids.find((eid) =>
eid !== powerEid && isApplianceLifecycleEntity(hass, eid),
);
const powerSample = entityVisualSample(hass, powerEid);
const powerUnavailable = powerSample.availability === 'unavailable' || powerSample.state === 'off';
if (powerUnavailable) {
return resolvedEids.map((eid) => ({
eid,
state: hass?.states?.[eid] ? lower(hass.states[eid].state) : '__missing__',
availability: 'unavailable',
status: 'neutral',
activity: 'none',
edge: 'none',
}));
}
return resolvedEids.map((eid) => {
if (eid === lifecycleEid) return applianceLifecycleVisualSample(hass, eid);
const sample = eid === powerEid ? powerSample : entityVisualSample(hass, eid);
return { ...sample, status: 'neutral', activity: 'none', edge: 'none' };
});
}
+32 -6
View File
@@ -4,7 +4,9 @@
*/
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule, EXCLUDED_DOMAINS } from './rules';
import { averageLqi, isControllable } from './logic';
import { isDevicePowerSwitch, isSemanticBinaryEntity } from './device-visual';
import {
applianceLifecycleRoleRank, isDevicePowerSwitch, isSemanticBinaryEntity,
} from './device-visual';
import type { DevItem, Marker, ServerConfig } from './types';
import {
isManualVirtualLightMarker,
@@ -108,9 +110,10 @@ const visibleFirst = (items: DeviceEntityCandidate[]): DeviceEntityCandidate[] =
* 1) uncategorised HA entities (all entities only as a fallback);
* 2) a whole-device state domain, if present;
* 3) semantic binary signals (presence/contact/motion/safety/running);
* 4) one representative switch when the device has no stronger
* state-bearing role;
* 5) passive entities together, so one unavailable sensor does not make the
* 4) for a recognised composite Power topology, a strict appliance
* lifecycle entity plus the Power availability gate;
* 5) one representative switch when the device has no stronger role;
* 6) passive entities together, so one unavailable sensor does not make the
* whole marker unavailable while another reading is alive.
*/
export function resolvedDeviceStateEntities(hass: any, entIds: readonly string[]): string[] {
@@ -140,7 +143,26 @@ export function resolvedDeviceStateEntities(hass: any, entIds: readonly string[]
// use the same single representative that primaryEntity/actions use.
// Entity-bound markers remain exact because their list has one member.
const ordered = visibleFirst(switches);
return [(ordered.find((item) => isDevicePowerSwitch(hass, item.eid)) || ordered[0]).eid];
const power = ordered.find((item) => isDevicePowerSwitch(hass, item.eid));
if (switches.length > 1 && power) {
// Lifecycle metadata is allowed to take over only inside the same
// composite-Power topology that already neutralises Power=on. This keeps
// a lone relay authoritative even when it has a diagnostic Status peer.
const eligible = all.filter((item) =>
item.eid !== power.eid
&& item.reg?.entity_category !== 'config'
&& applianceLifecycleRoleRank(hass, item.eid) != null,
);
const uncategorised = eligible.filter((item) => !item.reg?.entity_category);
const lifecyclePool = uncategorised.length ? uncategorised : eligible;
for (const rank of [0, 1, 2]) {
const role = visibleFirst(lifecyclePool.filter(
(item) => applianceLifecycleRoleRank(hass, item.eid) === rank,
));
if (role.length) return [role[0].eid, power.eid];
}
}
return [(power || ordered[0]).eid];
}
// Passive/fallback entities are all useful for aggregate availability. Keep
@@ -172,7 +194,11 @@ export function primaryEntity(hass: any, entIds: string[], icon: string): string
if (t) return t.eid;
}
}
return resolvedDeviceStateEntities(hass, entIds)[0];
const role = resolvedDeviceStateEntities(hass, entIds);
// A composite appliance exposes [lifecycle, Power gate]. Keep the
// controllable Power entity as the historical primary/action/value target;
// presentation still consumes the complete resolved role.
return role.find(isControllable) || role[0];
}
/** Minimal device shape accepted by the shared light-source resolver. */
+55
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
activitySourceSignature,
presentationSourceSignature,
@@ -46,6 +47,60 @@ const options = {
showSignal: true,
};
test('shares one lifecycle presentation between full and space cards', () => {
const h = hass({
'sensor.washer_status': state('sensor.washer_status', 'start'),
'switch.washer_power': state('switch.washer_power', 'on'),
'switch.washer_child_lock': state('switch.washer_child_lock', 'off'),
}, {
'sensor.washer_status': {
entity_id: 'sensor.washer_status', device_id: 'd1', platform: 'demo',
translation_key: 'status',
},
'switch.washer_power': {
entity_id: 'switch.washer_power', device_id: 'd1', platform: 'demo',
original_name: 'Power',
},
'switch.washer_child_lock': {
entity_id: 'switch.washer_child_lock', device_id: 'd1', platform: 'demo',
},
});
const washer = device({
name: 'Washer',
icon: 'mdi:washing-machine',
entities: ['switch.washer_child_lock', 'sensor.washer_status', 'switch.washer_power'],
allEntities: ['switch.washer_child_lock', 'sensor.washer_status', 'switch.washer_power'],
primary: 'switch.washer_power',
marker: { id: 'd1', binding: 'device:d1', display: 'value' },
});
const full = resolveDevicePresentation(h, washer, options);
const space = resolveDevicePresentation(h, washer, { ...options });
const projection = (value) => ({
sources: value.visualSources.map((source) => [source.eid, source.role]),
visual: value.visual,
reason: value.explanation.reason,
valueText: value.valueText,
});
assert.deepEqual(projection(full), projection(space));
assert.deepEqual(full.visualSources.map((source) => [source.eid, source.role]), [
['sensor.washer_status', 'lifecycle'],
['switch.washer_power', 'power_gate'],
]);
assert.equal(full.visual.status, 'working');
assert.equal(full.explanation.reason, 'working');
assert.equal(full.valueText, 'On', 'the historical Power value remains unambiguous');
const deviceVisual = readFileSync(new URL('../src/device-visual.ts', import.meta.url), 'utf8');
const presentation = readFileSync(new URL('../src/device-presentation.ts', import.meta.url), 'utf8');
const fullCard = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const spaceCard = readFileSync(new URL('../src/space-card.ts', import.meta.url), 'utf8');
assert.match(deviceVisual, /const LIFECYCLE_WORKING_STATES/);
assert.doesNotMatch(presentation, /const LIFECYCLE_WORKING_STATES/);
assert.doesNotMatch(spaceCard, /const LIFECYCLE_WORKING_STATES/);
assert.match(fullCard, /resolveDevicePresentation\(/);
assert.match(spaceCard, /resolveDevicePresentation\(/);
});
test('passive source and its controller share the plan-wide derived state', () => {
const h = hass({
'switch.wall': state('switch.wall', 'on'),
+98
View File
@@ -108,6 +108,104 @@ test('a composite Power switch is a neutral lifecycle, while a lone relay still
);
});
test('keeps lifecycle-only active tokens neutral outside lifecycle role', () => {
const h = hass({
'sensor.leak_dry': { state: 'dry' },
'sensor.zone_active': { state: 'active' },
'sensor.mesh_start': { state: 'start' },
});
for (const eid of Object.keys(h.states)) {
assert.deepEqual(
(({ status, activity }) => ({ status, activity }))(entityVisualSample(h, eid)),
{ status: 'neutral', activity: 'none' },
eid,
);
}
});
test('composite appliance lifecycle classifies scoped active and terminal states', () => {
const h = {
...hass({
'sensor.washer_status': { state: 'start' },
'switch.washer_power': { state: 'on' },
'switch.washer_child_lock': { state: 'off' },
}),
entities: {
'sensor.washer_status': { translation_key: 'status' },
'switch.washer_power': { original_name: 'Power' },
'switch.washer_child_lock': {},
},
};
const role = ['sensor.washer_status', 'switch.washer_power'];
const all = [...role, 'switch.washer_child_lock'];
for (const active of ['start', 'started', 'run', 'active', 'in_progress', 'wash', 'rinse', 'spin', 'dry', 'washing']) {
h.states['sensor.washer_status'].state = ` ${active.toUpperCase()} `;
assert.deepEqual(
combineVisualSamples(entityVisualSamplesForDevice(h, role, all)),
{ availability: 'available', status: 'working', activity: 'running' },
active,
);
}
for (const idle of ['idle', 'paused', 'stop', 'end', 'done', 'inactive', 'finished']) {
h.states['sensor.washer_status'].state = idle;
assert.deepEqual(
combineVisualSamples(entityVisualSamplesForDevice(h, role, all)),
{ availability: 'available', status: 'neutral', activity: 'none' },
idle,
);
}
});
test('suppresses stale active lifecycle when composite Power is off', () => {
const h = {
...hass({
'sensor.washer_status': { state: 'start' },
'switch.washer_power': { state: 'off' },
'switch.washer_child_lock': { state: 'on' },
}),
entities: {
'sensor.washer_status': { translation_key: 'status' },
'switch.washer_power': {},
'switch.washer_child_lock': {},
},
};
const role = ['sensor.washer_status', 'switch.washer_power'];
const all = [...role, 'switch.washer_child_lock'];
for (const power of ['off', 'unknown', 'unavailable']) {
h.states['switch.washer_power'].state = power;
assert.deepEqual(
combineVisualSamples(entityVisualSamplesForDevice(h, role, all)),
{ availability: 'unavailable', status: 'neutral', activity: 'none' },
power,
);
}
h.states['switch.washer_power'].state = 'on';
h.states['sensor.washer_status'].state = 'unavailable';
assert.deepEqual(
combineVisualSamples(entityVisualSamplesForDevice(h, role, all)),
{ availability: 'available', status: 'neutral', activity: 'none' },
);
});
test('lone relay stays working beside a neutral lifecycle-like sensor', () => {
const h = {
...hass({
'switch.coffee': { state: 'on' },
'sensor.coffee_status': { state: 'connected' },
}),
entities: {
'switch.coffee': {},
'sensor.coffee_status': { translation_key: 'status' },
},
};
assert.deepEqual(
combineVisualSamples(entityVisualSamplesForDevice(
h, ['switch.coffee'], ['switch.coffee', 'sensor.coffee_status'],
)),
{ availability: 'available', status: 'working', activity: 'running' },
);
});
test('off media sources share unavailable styling without hiding an active peer', () => {
const off = entityVisualSample(hass({ 'media_player.one': { state: 'off' } }), 'media_player.one');
const on = entityVisualSample(hass({ 'media_player.two': { state: 'on' } }), 'media_player.two');
+118
View File
@@ -388,6 +388,124 @@ test('switch-only resolver prefers a dedicated Power entity over registry order'
);
});
test('keeps lifecycle role identity stable while live state is unavailable', () => {
const hass = mkHass({
entities: {
'switch.washer_child_lock': { entity_id: 'switch.washer_child_lock' },
'sensor.washer_job_state': {
entity_id: 'sensor.washer_job_state', entity_category: 'diagnostic',
translation_key: 'job_state',
},
'sensor.washer_status': {
entity_id: 'sensor.washer_status', translation_key: 'status',
},
'switch.washer_power': {
entity_id: 'switch.washer_power', original_name: 'Power',
},
'sensor.washer_stage': { entity_id: 'sensor.washer_stage', original_name: 'Stage' },
},
states: {
'switch.washer_child_lock': { state: 'off', attributes: {} },
'sensor.washer_job_state': { state: 'idle', attributes: {} },
'sensor.washer_status': { state: 'start', attributes: { friendly_name: 'Статус' } },
'switch.washer_power': { state: 'on', attributes: { friendly_name: 'Питание' } },
'sensor.washer_stage': { state: 'Rinse', attributes: {} },
},
});
const entities = [
'switch.washer_child_lock',
'sensor.washer_job_state',
'sensor.washer_status',
'switch.washer_power',
'sensor.washer_stage',
];
assert.deepEqual(
resolvedDeviceStateEntities(hass, entities),
['sensor.washer_status', 'switch.washer_power'],
'an uncategorised Status must beat a diagnostic job_state',
);
assert.equal(primaryEntity(hass, entities, 'mdi:washing-machine'), 'switch.washer_power');
hass.states['sensor.washer_status'].state = 'unavailable';
assert.deepEqual(
resolvedDeviceStateEntities(hass, [...entities].reverse()),
['sensor.washer_status', 'switch.washer_power'],
'live state and registry order must not retarget the selected lifecycle role',
);
});
test('resolvedDeviceStateEntities: lifecycle matching rejects connectivity and localised-name guesses', () => {
const hass = mkHass({
entities: {
'switch.washer_power': { entity_id: 'switch.washer_power' },
'switch.washer_child_lock': { entity_id: 'switch.washer_child_lock' },
'sensor.washer_wifi_status': {
entity_id: 'sensor.washer_wifi_status', original_name: 'WiFi Status',
},
'sensor.washer_localised': { entity_id: 'sensor.washer_localised' },
'sensor.washer_run_state': {
entity_id: 'sensor.washer_run_state', entity_category: 'config',
translation_key: 'run_state',
},
'sensor.washer_stage': { entity_id: 'sensor.washer_stage', original_name: 'Stage' },
'select.washer_program': { entity_id: 'select.washer_program', original_name: 'Program' },
'sensor.washer_remaining_time': {
entity_id: 'sensor.washer_remaining_time', original_name: 'Remaining time',
},
},
states: {
'switch.washer_power': { state: 'on', attributes: {} },
'switch.washer_child_lock': { state: 'off', attributes: {} },
'sensor.washer_wifi_status': { state: 'connected', attributes: {} },
'sensor.washer_localised': { state: 'start', attributes: { friendly_name: 'Статус' } },
'sensor.washer_run_state': { state: 'start', attributes: {} },
'sensor.washer_stage': { state: 'Rinse', attributes: {} },
'select.washer_program': { state: 'mixed_wash', attributes: {} },
'sensor.washer_remaining_time': { state: '17', attributes: { unit_of_measurement: 'min' } },
},
});
assert.deepEqual(
resolvedDeviceStateEntities(hass, Object.keys(hass.entities)),
['switch.washer_power'],
);
});
test('keeps semantic binary ahead of appliance lifecycle and preserves lone relay', () => {
const semantic = mkHass({
entities: {
'switch.washer_power': { entity_id: 'switch.washer_power' },
'switch.washer_child_lock': { entity_id: 'switch.washer_child_lock' },
'sensor.washer_status': { entity_id: 'sensor.washer_status', translation_key: 'status' },
'binary_sensor.washer_running': { entity_id: 'binary_sensor.washer_running' },
},
states: {
'switch.washer_power': { state: 'on', attributes: {} },
'switch.washer_child_lock': { state: 'off', attributes: {} },
'sensor.washer_status': { state: 'idle', attributes: {} },
'binary_sensor.washer_running': { state: 'on', attributes: { device_class: 'running' } },
},
});
assert.deepEqual(
resolvedDeviceStateEntities(semantic, Object.keys(semantic.entities)),
['binary_sensor.washer_running'],
);
const lone = mkHass({
entities: {
'switch.coffee': { entity_id: 'switch.coffee' },
'sensor.coffee_status': { entity_id: 'sensor.coffee_status', translation_key: 'status' },
},
states: {
'switch.coffee': { state: 'on', attributes: {} },
'sensor.coffee_status': { state: 'connected', attributes: {} },
},
});
assert.deepEqual(
resolvedDeviceStateEntities(lone, Object.keys(lone.entities)),
['switch.coffee'],
);
});
test('resolvedDeviceStateEntities: passive readings aggregate availability instead of picking the first', () => {
const hass = mkHass({
entities: {
+22 -1
View File
@@ -84,7 +84,8 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
'backup-full', 'backup-space', 'value-badge-positions', 'isometric-geometry',
'isometric-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
'isometric-large-warm-remount', 'split-corner-wall', 'plan-snap-endpoint',
'plan-snap-line-gaps', 'wall-junctions', 'isometric-wall-junctions'])
'plan-snap-line-gaps', 'wall-junctions', 'isometric-wall-junctions',
'washer-active-cycle', 'washer-idle-cycle'])
assert.equal(ids.includes(token), true, token);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('plan'), true);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('devices'), true);
@@ -93,6 +94,26 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.theme)).has('dark'), true);
});
test('washer lifecycle goldens pair active and idle snapshots of one composite fixture', () => {
const active = GOLDEN_SCENARIOS.find((scenario) => scenario.id === 'washer-active-cycle-dark');
const idle = GOLDEN_SCENARIOS.find((scenario) => scenario.id === 'washer-idle-cycle-dark');
assert.ok(active);
assert.ok(idle);
assert.equal(active.space, 'golden-appliance');
assert.equal(idle.space, active.space);
assert.equal(active.applianceLifecycle, true);
assert.equal(idle.applianceLifecycle, true);
assert.equal(active.stateOverrides['sensor.golden_washer_status'].state, 'start');
assert.equal(idle.stateOverrides['sensor.golden_washer_status'].state, 'done');
for (const scenario of [active, idle]) {
const fixture = prepareGoldenFixture(scenario);
assert.equal(fixture.states['switch.golden_washer_power'].state, 'on');
assert.equal(fixture.states['switch.golden_washer_child_lock'].state, 'off');
assert.equal(fixture.entities['sensor.golden_washer_status'].translation_key, 'status');
assert.equal(fixture.layout['golden-washer'].s, 'golden-appliance');
}
});
test('wall junction goldens cover live L/T previews plus saved flat and isometric bodies', () => {
const scenarios = GOLDEN_SCENARIOS.filter((scenario) => scenario.wallJunctions);
assert.deepEqual(scenarios.map((scenario) => scenario.id), [