mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
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:
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 } } },
|
||||
|
||||
+124
-124
File diff suppressed because one or more lines are too long
Vendored
+124
-124
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
- Большие планы больше не пересчитывают неиспользуемое объединение физических
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. Заливки комнат и свет
|
||||
|
||||
### Режимы заливки пространства
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 в репозиторий не попадают.
|
||||
|
||||
@@ -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
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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), [
|
||||
|
||||
Reference in New Issue
Block a user