@@ -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,6 +127,24 @@ const lightingSpace = {
|
||||
decor: [],
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
const devices = {};
|
||||
const entities = {};
|
||||
@@ -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, ...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,12 +195,48 @@ const runtime = () => {
|
||||
add('golden-right-linkquality', 'sensor', 'golden_light_right', 0.66, 0.70, '190', {
|
||||
unit_of_measurement: 'lqi',
|
||||
});
|
||||
|
||||
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 };
|
||||
};
|
||||
|
||||
export const VISUAL_MATRIX_COUNTS = Object.freeze({
|
||||
spaces: 2,
|
||||
rooms: geometryRooms.length + lightingRooms.length,
|
||||
spaces: 3,
|
||||
rooms: geometryRooms.length + lightingRooms.length + applianceRooms.length,
|
||||
openings: geometrySpace.openings.length + lightingSpace.openings.length,
|
||||
partitions: geometrySpace.partitions.length + lightingSpace.partitions.length,
|
||||
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
|
||||
@@ -184,7 +244,10 @@ export const VISUAL_MATRIX_COUNTS = Object.freeze({
|
||||
|
||||
export const makeVisualMatrixFixture = () => ({
|
||||
config: {
|
||||
spaces: [structuredClone(geometrySpace), structuredClone(lightingSpace)],
|
||||
spaces: [
|
||||
structuredClone(geometrySpace), structuredClone(lightingSpace),
|
||||
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.
|
||||
|
||||
@@ -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,12 @@ 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', 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', 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 } } },
|
||||
|
||||
@@ -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. Заливки комнат и свет
|
||||
|
||||
### Режимы заливки пространства
|
||||
|
||||
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 342 KiB After Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 330 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"fixture": "synthetic-only",
|
||||
"sourceFingerprint": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"sourceFingerprint": "c21b9b9c8e62502d59b35e7d5c6a6327b5e93243a35f4b00b0e4f048a1174d2f",
|
||||
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
@@ -13,8 +13,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "d36b6f9f8139f31ef73a780c6511a640a26055efd9d7a24c24fd48b1d8379bf0"
|
||||
"sourceSha256": "c21b9b9c8e62502d59b35e7d5c6a6327b5e93243a35f4b00b0e4f048a1174d2f",
|
||||
"imageSha256": "d78868e4a9866ce624ddc7ecb73da3de0ea69889a5b2555304e3071d0d384277"
|
||||
},
|
||||
"view-touch": {
|
||||
"file": "02-view-touch.png",
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"sourceSha256": "c21b9b9c8e62502d59b35e7d5c6a6327b5e93243a35f4b00b0e4f048a1174d2f",
|
||||
"imageSha256": "358e25ff9984d0fb0c03cfbb848df40c425fdfe64cd5f4f613b1e754ca9d4249"
|
||||
},
|
||||
"space-create": {
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"sourceSha256": "c21b9b9c8e62502d59b35e7d5c6a6327b5e93243a35f4b00b0e4f048a1174d2f",
|
||||
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
|
||||
},
|
||||
"room-contour-close": {
|
||||
@@ -46,8 +46,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "2f4770869f04c8d7f1e8ff33af1e8bd79f45ad6d97be6d5e6135b3a82fec9e4c"
|
||||
"sourceSha256": "c21b9b9c8e62502d59b35e7d5c6a6327b5e93243a35f4b00b0e4f048a1174d2f",
|
||||
"imageSha256": "f8196be86dca7b39580b23ced0fce67c18b1fb30ddc2320232da7a7caa372129"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
"file": "05-plan-context-tray.png",
|
||||
@@ -57,8 +57,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "47d8fc2ac1c14cb699e990ca9c00d89b9ac57f6cc76b40420ceb26b4d7f50df4"
|
||||
"sourceSha256": "c21b9b9c8e62502d59b35e7d5c6a6327b5e93243a35f4b00b0e4f048a1174d2f",
|
||||
"imageSha256": "14bef4778e0927956a4dc0d132bc814c8fd1ea427f42d98c0aec9699a7918d7f"
|
||||
},
|
||||
"device-editor": {
|
||||
"file": "06-device-editor.png",
|
||||
@@ -68,8 +68,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "7c3e25534fc819c45431907616c3d523961505859ee68af27cce2daec9f04fb0"
|
||||
"sourceSha256": "c21b9b9c8e62502d59b35e7d5c6a6327b5e93243a35f4b00b0e4f048a1174d2f",
|
||||
"imageSha256": "b3bfcf1249a9228c538760fe59852c99aaaca4f33355ce3dfa554d2e7c54ed5b"
|
||||
},
|
||||
"device-display-preview": {
|
||||
"file": "06-device-display-preview.png",
|
||||
@@ -79,8 +79,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "df3a54395c83bffd7a161f1fb3de0143e12e76567535aedf9883873ead74df91"
|
||||
"sourceSha256": "c21b9b9c8e62502d59b35e7d5c6a6327b5e93243a35f4b00b0e4f048a1174d2f",
|
||||
"imageSha256": "e47d1bb71c58c9f9cc62792ca53d1db9c3bf989e13275c25b5dd25e0427d5717"
|
||||
},
|
||||
"background-editor": {
|
||||
"file": "07-background-editor.png",
|
||||
@@ -90,8 +90,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "4e7b3b1220a3fe104cf11e5b32a31b343dc95c45fd2cbe3365d145f0a66a560d"
|
||||
"sourceSha256": "c21b9b9c8e62502d59b35e7d5c6a6327b5e93243a35f4b00b0e4f048a1174d2f",
|
||||
"imageSha256": "d957c01e5261d7c264faed8a157011606b373f5026c7154e49511ae4864260e0"
|
||||
},
|
||||
"room-card": {
|
||||
"file": "08-room-card.png",
|
||||
@@ -101,8 +101,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "176abba71d41cfb045a33f82a794d9fbb2a5d3c48e6df66e6ec3a8e448311b11"
|
||||
"sourceSha256": "c21b9b9c8e62502d59b35e7d5c6a6327b5e93243a35f4b00b0e4f048a1174d2f",
|
||||
"imageSha256": "5d4bd13ec234a58e9ec12ff0e73ff1c8d96cb16b5426850f63e4195c03a96f6f"
|
||||
},
|
||||
"device-info": {
|
||||
"file": "09-device-info.png",
|
||||
@@ -112,8 +112,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "2199ed88b215bf2bff63bf665028f78b2aa6a92c3032b803931790f2ef071893"
|
||||
"sourceSha256": "c21b9b9c8e62502d59b35e7d5c6a6327b5e93243a35f4b00b0e4f048a1174d2f",
|
||||
"imageSha256": "b0557546e1d2075b62eea36dbd105a5c8ee59be4c0e972b5aef0a07e38776115"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Issue #164 — активный цикл стиральной машины должен быть жёлтым
|
||||
|
||||
Статус: **вторая редакция после ревью, S3-spec; повторно на ревью не отправлено**
|
||||
Редакция: **вторая; замечания ревью учтены, вердикт зелёный**
|
||||
|
||||
Дата: 2026-08-16
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,24 @@ 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.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), [
|
||||
|
||||