fix: detect active appliance lifecycle

Issue: #164
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-16 22:35:54 +03:00
parent 8ce7d1f0bb
commit f7c8609ac5
26 changed files with 935 additions and 422 deletions
File diff suppressed because one or more lines are too long
+67 -4
View File
@@ -57,6 +57,11 @@ const lightingRooms = [
poly: [[0.50, 0.10], [0.93, 0.10], [0.93, 0.88], [0.50, 0.88]] },
];
const applianceRooms = [
{ id: 'appliance-room', name: 'Laundry', area: 'golden_appliance',
poly: [[0.08, 0.10], [0.92, 0.10], [0.92, 0.90], [0.08, 0.90]] },
];
const geometrySpace = {
id: 'golden-geometry',
title: 'Geometry matrix',
@@ -122,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.
+7 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 22;
export const GOLDEN_MATRIX_VERSION = 23;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -35,6 +35,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 } } },
File diff suppressed because one or more lines are too long
+124 -124
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- Composite appliances such as washing machines now use an explicit Home
Assistant Status/Run state/Job state to show the yellow working marker during
an active cycle. Power-on alone remains neutral, Power-off still fades stale
activity, and ordinary lone relays keep their existing behaviour
([#164](https://github.com/Matysh/houseplan-card/issues/164)).
## v1.64.0-beta.3 — 2026-08-14
- Large plans no longer recompute an unused physical-wall union on every floor
+6
View File
@@ -8,6 +8,12 @@
## Unreleased
- Составная техника, например стиральная машина, теперь использует явный
Status/Run state/Job state Home Assistant и получает жёлтую подложку во время
активного цикла. Одного Power=`on` по-прежнему недостаточно, Power=`off`
подавляет устаревший активный статус, а обычные одиночные реле сохраняют
прежнее поведение ([#164](https://github.com/Matysh/houseplan-card/issues/164)).
## v1.64.0-beta.3 — 2026-08-14
- Большие планы больше не пересчитывают неиспользуемое объединение физических
+9
View File
@@ -369,6 +369,15 @@ neutral**.
| Faded | Data unavailable | All relevant entities unknown, unavailable or absent |
| Neutral | No alarm, work or open condition | Off, closed, idle, standby, docked |
For a composite appliance with a dedicated Power switch, Power=`on` alone
remains neutral. If Home Assistant also exposes a strict lifecycle entity such
as Status/Run state/Job state, active values (`start`, `running`, `washing`,
`rinse`, and similar work states) make the marker yellow; idle, paused and
terminal values remove it. Power=`off` or unavailable still fades the marker
even if the lifecycle value is stale. Mode, Program, Stage and remaining time
are not treated as independent proof of work, and an ordinary lone relay keeps
its existing yellow-on behaviour.
Activity may be a finite three-wave event, persistent presence, a travelling
transition, or persistent work. `prefers-reduced-motion` replaces ordinary
motion with a compact indicator while the alarm remains clear.
+6 -1
View File
@@ -757,7 +757,7 @@ marker на плане при этом не меняются.
|---|---|---|---|---|
| `light.*` | Жёлтый, пока `on` | Работа, пока `on` | Стандартная лампочка off/on | Toggle |
| `switch.*`, `fan.*`, `humidifier.*` | Жёлтый, пока `on` | Работа, пока `on` | Обычно нет | Внутренняя карточка, если Toggle не выбран явно |
| Составное switch-only устройство с отдельной сущностью Power | Нейтральное при Power=`on`; приглушённое при Power=`off`/`unknown`/`unavailable` | Нет: соседние переключатели режимов и функций не считаются работой всего устройства | Обычно нет | Внутренняя карточка |
| Составное switch-only устройство с отдельной сущностью Power | При одном Power=`on` нейтральное; жёлтое при явном активном Status/Run state/Job state (`start`, `running`, `washing`, `rinse` и т. п.); приглушённое при Power=`off`/`unknown`/`unavailable` | Постоянная работа только по явному lifecycle; соседние переключатели режимов и функций не считаются работой всего устройства | Обычно нет | Внутренняя карточка |
| Motion/vibration/sound binary sensor | Нейтральный | Событие около 3.3 с при переходе off→on | Нет | Внутренняя карточка |
| Occupancy/presence binary sensor | Нейтральный | Пока присутствие активно | Нет | Внутренняя карточка |
| Door/window/garage contact | Оранжевый, пока `on` | Короткое событие при открытии | Закрыто/открыто для известных классов | Внутренняя карточка |
@@ -774,6 +774,11 @@ marker на плане при этом не меняются.
| Smoke/gas/CO/moisture/safety/tamper/problem, siren, triggered alarm | Красная авария | Кольцо не требуется: авария имеет высший приоритет | Зависит от базовой иконки | Карточка; опасный Toggle запрещён |
| Виртуальный маркер | Нейтральный | Нет без HA-источника | Нет | Внутренняя карточка или явный Run |
У составной техники `Mode`, `Program`, `Stage` и оставшееся время сами по себе
не доказывают работу: они могут сохранять последнее значение после цикла.
Power=`off`/`unavailable` подавляет даже устаревший активный Status. Обычное
одиночное реле не считается составной техникой и по-прежнему жёлтое при `on`.
## 13. Заливки комнат и свет
### Режимы заливки пространства
Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

+19 -19
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
# Issue #164 — активный цикл стиральной машины должен быть жёлтым
Статус: **вторая редакция после ревью, S3-spec; повторно на ревью не отправлено**
Редакция: **вторая; замечания ревью учтены, вердикт зелёный**
Дата: 2026-08-16
+25 -6
View File
@@ -12,7 +12,7 @@ import {
} from './devices';
import {
combineVisualSamples, entityVisualSample, entityVisualSamplesForDevice,
isDevicePowerSwitch,
isApplianceLifecycleEntity, isDevicePowerSwitch,
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
} from './device-visual';
import {
@@ -30,7 +30,8 @@ export type PresentationSourceKind =
| 'cover' | 'light' | 'controls' | 'device_role' | 'primary' | 'none';
export type PresentationSourceRole =
| 'cover' | 'light' | 'control' | 'forced_light' | 'device_role' | 'primary' | 'critical';
| 'cover' | 'light' | 'control' | 'forced_light' | 'device_role'
| 'lifecycle' | 'power_gate' | 'primary' | 'critical';
export type ValueFallbackReason =
| 'value_no_state' | 'value_ambiguous_sources' | 'value_non_scalar' | 'value_virtual';
@@ -315,8 +316,20 @@ export function resolvePresentationSources(
: d.entities.filter((eid) => !!hass?.states?.[eid]);
if (ids.length) {
sourceKind = 'device_role';
const samples = entityVisualSamplesForDevice(hass, ids, d.entities);
visualSources = ids.map((eid, index) => sourceOf(hass, eid, 'device_role', samples[index]));
// Registry metadata chooses the role, while the live hass snapshot owns
// its state. Keep those authorities together for composite lifecycle
// classification even during a frozen/partial registry projection.
const visualHass = registryHass === hass
? hass : { ...registryHass, states: hass?.states || {} };
const samples = entityVisualSamplesForDevice(visualHass, ids, d.entities);
const hasLifecycle = ids.some((eid) => isApplianceLifecycleEntity(registryHass, eid));
visualSources = samples.map((sample) => {
const role: PresentationSourceRole = hasLifecycle
? isDevicePowerSwitch(registryHass, sample.eid) ? 'power_gate'
: isApplianceLifecycleEntity(registryHass, sample.eid) ? 'lifecycle' : 'device_role'
: 'device_role';
return sourceOf(hass, sample.eid, role, sample);
});
} else if (d.primary) {
sourceKind = 'primary';
visualSources = [sourceOf(hass, d.primary, 'primary')];
@@ -418,6 +431,11 @@ function resolveValue(
// `hass.states['marker:*']` or turning a sensor into an empty value.
const realVisualSources = sources.visualSources.filter((source) => !source.eid.startsWith('marker:'));
let valueEids = realVisualSources.map((source) => source.eid);
const powerGate = realVisualSources.find((source) => source.role === 'power_gate');
// Before #164 a composite appliance value face displayed its selected Power
// entity. The added lifecycle source must not turn that existing value into
// an ambiguous fallback.
if (powerGate) valueEids = [powerGate.eid];
if (!valueEids.length && sources.visualSources.some((source) => source.eid.startsWith('marker:'))) {
valueEids = resolvedDeviceStateEntities(hass, d.entities);
if (!valueEids.length && d.primary && hass?.states?.[d.primary]) valueEids = [d.primary];
@@ -634,8 +652,9 @@ export function resolveDevicePresentation(
const notices: PresentationReason[] = [];
if (options.designPreview && userHidden) notices.push('hidden_design_preview');
if (!staticIcon && d.marker?.vacuum?.live === true) notices.push('vacuum_live_plan_only');
const powerSource = sources.visualSources.length === 1 && !!realVisualSource
&& isDevicePowerSwitch(hass, realVisualSource.eid);
const powerSource = sources.visualSources.some((source) =>
source.role === 'power_gate' || isDevicePowerSwitch(hass, source.eid),
);
const uncategorisedSwitches = d.entities.filter((eid) =>
eid.startsWith('switch.') && !hass?.entities?.[eid]?.entity_category,
).length;
+94 -11
View File
@@ -67,9 +67,19 @@ const WORKING_STATES = new Set([
'irrigating', 'humidifying', 'dehumidifying', 'fan', 'preheating', 'defrosting',
]);
/** Extra active verbs are safe only after the device resolver has selected a
* strict appliance lifecycle role. Keeping them out of WORKING_STATES prevents
* generic sensors such as a dry leak sensor or an active mesh node from
* turning an unrelated marker yellow. */
const LIFECYCLE_WORKING_STATES = new Set([
...WORKING_STATES,
'start', 'started', 'run', 'active', 'in_progress',
'wash', 'rinse', 'spin', 'dry',
]);
const IDLE_STATES = new Set([
'off', 'idle', 'paused', 'standby', 'docked', 'finished', 'complete',
'completed', 'stopped', 'ready', 'sleeping',
'completed', 'stopped', 'ready', 'sleeping', 'stop', 'end', 'done', 'inactive',
]);
/** Standard HA HVAC modes which mean the climate entity is enabled. The
@@ -84,6 +94,46 @@ const unavailable = (state: string): boolean =>
const lower = (v: unknown): string => String(v ?? '').trim().toLowerCase();
const lifecycleToken = (v: unknown): string => lower(v)
.replace(/[\s-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
const LIFECYCLE_ROLE_RANK = new Map<string, number>([
['run_state', 0], ['job_state', 0], ['operation_state', 0], ['activity_state', 0],
['machine_state', 1], ['running_state', 1],
['status', 2], ['device_status', 2], ['machine_status', 2],
]);
const LIFECYCLE_CONNECTIVITY_SEGMENTS = new Set(['wifi', 'connection', 'signal', 'battery']);
/** Rank a strict appliance lifecycle entity from generic HA metadata.
* Russian/localised display text is deliberately not authority in #164. */
export function applianceLifecycleRoleRank(hass: any, eid: string): number | null {
const reg = hass?.entities?.[eid] || {};
if (reg.entity_category === 'config') return null;
const objectId = String(eid || '').split('.').slice(1).join('.');
const registryEvidence = [reg.translation_key, reg.original_name, reg.name];
const evidence = [...registryEvidence, objectId];
if (!registryEvidence.some((value) => lower(value))) {
evidence.push(hass?.states?.[eid]?.attributes?.friendly_name);
}
const normalized = evidence.map(lifecycleToken).filter(Boolean);
if (normalized.some((value) => value.split('_').some(
(segment) => LIFECYCLE_CONNECTIVITY_SEGMENTS.has(segment),
))) return null;
let best: number | null = null;
for (const value of normalized) {
for (const [role, rank] of LIFECYCLE_ROLE_RANK) {
if (value !== role && !value.endsWith(`_${role}`)) continue;
best = best == null ? rank : Math.min(best, rank);
}
}
return best;
}
export const isApplianceLifecycleEntity = (hass: any, eid: string): boolean =>
applianceLifecycleRoleRank(hass, eid) != null;
/**
* A dedicated whole-device power switch, as opposed to a relay whose `on`
* state is the useful work itself. This uses generic HA registry/state
@@ -115,6 +165,25 @@ function workAction(attrs: any): string {
return '';
}
/** Classify the state of an already-selected appliance lifecycle role. The
* generic classifier intentionally never sees the extra lifecycle verbs. */
function applianceLifecycleVisualSample(hass: any, eid: string): EntityVisualSample {
const st = hass?.states?.[eid];
const state = st ? lower(st.state) : '__missing__';
const base: EntityVisualSample = {
eid, state,
availability: unavailable(state) ? 'unavailable' : 'available',
status: 'neutral', activity: 'none', edge: 'none',
};
if (base.availability === 'unavailable') return base;
const action = workAction(st?.attributes);
if (WORKING_STATES.has(action)
|| (LIFECYCLE_WORKING_STATES.has(state) && !IDLE_STATES.has(state))) {
return { ...base, status: 'working', activity: 'running' };
}
return base;
}
/** Classify one entity without looking at previous state. */
export function entityVisualSample(hass: any, eid: string): EntityVisualSample {
const st = hass?.states?.[eid];
@@ -249,19 +318,33 @@ export function entityVisualSamplesForDevice(
resolvedEids: readonly string[],
allEids: readonly string[],
): EntityVisualSample[] {
const samples = resolvedEids.map((eid) => entityVisualSample(hass, eid));
const uncategorisedSwitches = allEids.filter((eid) =>
eid.startsWith('switch.') && !hass?.entities?.[eid]?.entity_category,
);
const lifecycle = resolvedEids.length === 1
&& uncategorisedSwitches.length > 1
&& isDevicePowerSwitch(hass, resolvedEids[0]);
if (!lifecycle) return samples;
return samples.map((sample) => {
if (sample.availability === 'unavailable') return sample;
return sample.state === 'off'
? { ...sample, availability: 'unavailable', status: 'neutral', activity: 'none', edge: 'none' }
: { ...sample, status: 'neutral', activity: 'none', edge: 'none' };
const powerEid = resolvedEids.find((eid) => isDevicePowerSwitch(hass, eid));
const compositePower = uncategorisedSwitches.length > 1 && !!powerEid;
if (!compositePower) return resolvedEids.map((eid) => entityVisualSample(hass, eid));
const lifecycleEid = resolvedEids.find((eid) =>
eid !== powerEid && isApplianceLifecycleEntity(hass, eid),
);
const powerSample = entityVisualSample(hass, powerEid);
const powerUnavailable = powerSample.availability === 'unavailable' || powerSample.state === 'off';
if (powerUnavailable) {
return resolvedEids.map((eid) => ({
eid,
state: hass?.states?.[eid] ? lower(hass.states[eid].state) : '__missing__',
availability: 'unavailable',
status: 'neutral',
activity: 'none',
edge: 'none',
}));
}
return resolvedEids.map((eid) => {
if (eid === lifecycleEid) return applianceLifecycleVisualSample(hass, eid);
const sample = eid === powerEid ? powerSample : entityVisualSample(hass, eid);
return { ...sample, status: 'neutral', activity: 'none', edge: 'none' };
});
}
+32 -6
View File
@@ -4,7 +4,9 @@
*/
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule, EXCLUDED_DOMAINS } from './rules';
import { averageLqi, isControllable } from './logic';
import { isDevicePowerSwitch, isSemanticBinaryEntity } from './device-visual';
import {
applianceLifecycleRoleRank, isDevicePowerSwitch, isSemanticBinaryEntity,
} from './device-visual';
import type { DevItem, Marker, ServerConfig } from './types';
import {
isManualVirtualLightMarker,
@@ -108,9 +110,10 @@ const visibleFirst = (items: DeviceEntityCandidate[]): DeviceEntityCandidate[] =
* 1) uncategorised HA entities (all entities only as a fallback);
* 2) a whole-device state domain, if present;
* 3) semantic binary signals (presence/contact/motion/safety/running);
* 4) one representative switch when the device has no stronger
* state-bearing role;
* 5) passive entities together, so one unavailable sensor does not make the
* 4) for a recognised composite Power topology, a strict appliance
* lifecycle entity plus the Power availability gate;
* 5) one representative switch when the device has no stronger role;
* 6) passive entities together, so one unavailable sensor does not make the
* whole marker unavailable while another reading is alive.
*/
export function resolvedDeviceStateEntities(hass: any, entIds: readonly string[]): string[] {
@@ -140,7 +143,26 @@ export function resolvedDeviceStateEntities(hass: any, entIds: readonly string[]
// use the same single representative that primaryEntity/actions use.
// Entity-bound markers remain exact because their list has one member.
const ordered = visibleFirst(switches);
return [(ordered.find((item) => isDevicePowerSwitch(hass, item.eid)) || ordered[0]).eid];
const power = ordered.find((item) => isDevicePowerSwitch(hass, item.eid));
if (switches.length > 1 && power) {
// Lifecycle metadata is allowed to take over only inside the same
// composite-Power topology that already neutralises Power=on. This keeps
// a lone relay authoritative even when it has a diagnostic Status peer.
const eligible = all.filter((item) =>
item.eid !== power.eid
&& item.reg?.entity_category !== 'config'
&& applianceLifecycleRoleRank(hass, item.eid) != null,
);
const uncategorised = eligible.filter((item) => !item.reg?.entity_category);
const lifecyclePool = uncategorised.length ? uncategorised : eligible;
for (const rank of [0, 1, 2]) {
const role = visibleFirst(lifecyclePool.filter(
(item) => applianceLifecycleRoleRank(hass, item.eid) === rank,
));
if (role.length) return [role[0].eid, power.eid];
}
}
return [(power || ordered[0]).eid];
}
// Passive/fallback entities are all useful for aggregate availability. Keep
@@ -172,7 +194,11 @@ export function primaryEntity(hass: any, entIds: string[], icon: string): string
if (t) return t.eid;
}
}
return resolvedDeviceStateEntities(hass, entIds)[0];
const role = resolvedDeviceStateEntities(hass, entIds);
// A composite appliance exposes [lifecycle, Power gate]. Keep the
// controllable Power entity as the historical primary/action/value target;
// presentation still consumes the complete resolved role.
return role.find(isControllable) || role[0];
}
/** Minimal device shape accepted by the shared light-source resolver. */
+55
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
activitySourceSignature,
presentationSourceSignature,
@@ -46,6 +47,60 @@ const options = {
showSignal: true,
};
test('shares one lifecycle presentation between full and space cards', () => {
const h = hass({
'sensor.washer_status': state('sensor.washer_status', 'start'),
'switch.washer_power': state('switch.washer_power', 'on'),
'switch.washer_child_lock': state('switch.washer_child_lock', 'off'),
}, {
'sensor.washer_status': {
entity_id: 'sensor.washer_status', device_id: 'd1', platform: 'demo',
translation_key: 'status',
},
'switch.washer_power': {
entity_id: 'switch.washer_power', device_id: 'd1', platform: 'demo',
original_name: 'Power',
},
'switch.washer_child_lock': {
entity_id: 'switch.washer_child_lock', device_id: 'd1', platform: 'demo',
},
});
const washer = device({
name: 'Washer',
icon: 'mdi:washing-machine',
entities: ['switch.washer_child_lock', 'sensor.washer_status', 'switch.washer_power'],
allEntities: ['switch.washer_child_lock', 'sensor.washer_status', 'switch.washer_power'],
primary: 'switch.washer_power',
marker: { id: 'd1', binding: 'device:d1', display: 'value' },
});
const full = resolveDevicePresentation(h, washer, options);
const space = resolveDevicePresentation(h, washer, { ...options });
const projection = (value) => ({
sources: value.visualSources.map((source) => [source.eid, source.role]),
visual: value.visual,
reason: value.explanation.reason,
valueText: value.valueText,
});
assert.deepEqual(projection(full), projection(space));
assert.deepEqual(full.visualSources.map((source) => [source.eid, source.role]), [
['sensor.washer_status', 'lifecycle'],
['switch.washer_power', 'power_gate'],
]);
assert.equal(full.visual.status, 'working');
assert.equal(full.explanation.reason, 'working');
assert.equal(full.valueText, 'On', 'the historical Power value remains unambiguous');
const deviceVisual = readFileSync(new URL('../src/device-visual.ts', import.meta.url), 'utf8');
const presentation = readFileSync(new URL('../src/device-presentation.ts', import.meta.url), 'utf8');
const fullCard = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const spaceCard = readFileSync(new URL('../src/space-card.ts', import.meta.url), 'utf8');
assert.match(deviceVisual, /const LIFECYCLE_WORKING_STATES/);
assert.doesNotMatch(presentation, /const LIFECYCLE_WORKING_STATES/);
assert.doesNotMatch(spaceCard, /const LIFECYCLE_WORKING_STATES/);
assert.match(fullCard, /resolveDevicePresentation\(/);
assert.match(spaceCard, /resolveDevicePresentation\(/);
});
test('passive source and its controller share the plan-wide derived state', () => {
const h = hass({
'switch.wall': state('switch.wall', 'on'),
+98
View File
@@ -108,6 +108,104 @@ test('a composite Power switch is a neutral lifecycle, while a lone relay still
);
});
test('keeps lifecycle-only active tokens neutral outside lifecycle role', () => {
const h = hass({
'sensor.leak_dry': { state: 'dry' },
'sensor.zone_active': { state: 'active' },
'sensor.mesh_start': { state: 'start' },
});
for (const eid of Object.keys(h.states)) {
assert.deepEqual(
(({ status, activity }) => ({ status, activity }))(entityVisualSample(h, eid)),
{ status: 'neutral', activity: 'none' },
eid,
);
}
});
test('composite appliance lifecycle classifies scoped active and terminal states', () => {
const h = {
...hass({
'sensor.washer_status': { state: 'start' },
'switch.washer_power': { state: 'on' },
'switch.washer_child_lock': { state: 'off' },
}),
entities: {
'sensor.washer_status': { translation_key: 'status' },
'switch.washer_power': { original_name: 'Power' },
'switch.washer_child_lock': {},
},
};
const role = ['sensor.washer_status', 'switch.washer_power'];
const all = [...role, 'switch.washer_child_lock'];
for (const active of ['start', 'started', 'run', 'active', 'in_progress', 'wash', 'rinse', 'spin', 'dry', 'washing']) {
h.states['sensor.washer_status'].state = ` ${active.toUpperCase()} `;
assert.deepEqual(
combineVisualSamples(entityVisualSamplesForDevice(h, role, all)),
{ availability: 'available', status: 'working', activity: 'running' },
active,
);
}
for (const idle of ['idle', 'paused', 'stop', 'end', 'done', 'inactive', 'finished']) {
h.states['sensor.washer_status'].state = idle;
assert.deepEqual(
combineVisualSamples(entityVisualSamplesForDevice(h, role, all)),
{ availability: 'available', status: 'neutral', activity: 'none' },
idle,
);
}
});
test('suppresses stale active lifecycle when composite Power is off', () => {
const h = {
...hass({
'sensor.washer_status': { state: 'start' },
'switch.washer_power': { state: 'off' },
'switch.washer_child_lock': { state: 'on' },
}),
entities: {
'sensor.washer_status': { translation_key: 'status' },
'switch.washer_power': {},
'switch.washer_child_lock': {},
},
};
const role = ['sensor.washer_status', 'switch.washer_power'];
const all = [...role, 'switch.washer_child_lock'];
for (const power of ['off', 'unknown', 'unavailable']) {
h.states['switch.washer_power'].state = power;
assert.deepEqual(
combineVisualSamples(entityVisualSamplesForDevice(h, role, all)),
{ availability: 'unavailable', status: 'neutral', activity: 'none' },
power,
);
}
h.states['switch.washer_power'].state = 'on';
h.states['sensor.washer_status'].state = 'unavailable';
assert.deepEqual(
combineVisualSamples(entityVisualSamplesForDevice(h, role, all)),
{ availability: 'available', status: 'neutral', activity: 'none' },
);
});
test('lone relay stays working beside a neutral lifecycle-like sensor', () => {
const h = {
...hass({
'switch.coffee': { state: 'on' },
'sensor.coffee_status': { state: 'connected' },
}),
entities: {
'switch.coffee': {},
'sensor.coffee_status': { translation_key: 'status' },
},
};
assert.deepEqual(
combineVisualSamples(entityVisualSamplesForDevice(
h, ['switch.coffee'], ['switch.coffee', 'sensor.coffee_status'],
)),
{ availability: 'available', status: 'working', activity: 'running' },
);
});
test('off media sources share unavailable styling without hiding an active peer', () => {
const off = entityVisualSample(hass({ 'media_player.one': { state: 'off' } }), 'media_player.one');
const on = entityVisualSample(hass({ 'media_player.two': { state: 'on' } }), 'media_player.two');
+118
View File
@@ -388,6 +388,124 @@ test('switch-only resolver prefers a dedicated Power entity over registry order'
);
});
test('keeps lifecycle role identity stable while live state is unavailable', () => {
const hass = mkHass({
entities: {
'switch.washer_child_lock': { entity_id: 'switch.washer_child_lock' },
'sensor.washer_job_state': {
entity_id: 'sensor.washer_job_state', entity_category: 'diagnostic',
translation_key: 'job_state',
},
'sensor.washer_status': {
entity_id: 'sensor.washer_status', translation_key: 'status',
},
'switch.washer_power': {
entity_id: 'switch.washer_power', original_name: 'Power',
},
'sensor.washer_stage': { entity_id: 'sensor.washer_stage', original_name: 'Stage' },
},
states: {
'switch.washer_child_lock': { state: 'off', attributes: {} },
'sensor.washer_job_state': { state: 'idle', attributes: {} },
'sensor.washer_status': { state: 'start', attributes: { friendly_name: 'Статус' } },
'switch.washer_power': { state: 'on', attributes: { friendly_name: 'Питание' } },
'sensor.washer_stage': { state: 'Rinse', attributes: {} },
},
});
const entities = [
'switch.washer_child_lock',
'sensor.washer_job_state',
'sensor.washer_status',
'switch.washer_power',
'sensor.washer_stage',
];
assert.deepEqual(
resolvedDeviceStateEntities(hass, entities),
['sensor.washer_status', 'switch.washer_power'],
'an uncategorised Status must beat a diagnostic job_state',
);
assert.equal(primaryEntity(hass, entities, 'mdi:washing-machine'), 'switch.washer_power');
hass.states['sensor.washer_status'].state = 'unavailable';
assert.deepEqual(
resolvedDeviceStateEntities(hass, [...entities].reverse()),
['sensor.washer_status', 'switch.washer_power'],
'live state and registry order must not retarget the selected lifecycle role',
);
});
test('resolvedDeviceStateEntities: lifecycle matching rejects connectivity and localised-name guesses', () => {
const hass = mkHass({
entities: {
'switch.washer_power': { entity_id: 'switch.washer_power' },
'switch.washer_child_lock': { entity_id: 'switch.washer_child_lock' },
'sensor.washer_wifi_status': {
entity_id: 'sensor.washer_wifi_status', original_name: 'WiFi Status',
},
'sensor.washer_localised': { entity_id: 'sensor.washer_localised' },
'sensor.washer_run_state': {
entity_id: 'sensor.washer_run_state', entity_category: 'config',
translation_key: 'run_state',
},
'sensor.washer_stage': { entity_id: 'sensor.washer_stage', original_name: 'Stage' },
'select.washer_program': { entity_id: 'select.washer_program', original_name: 'Program' },
'sensor.washer_remaining_time': {
entity_id: 'sensor.washer_remaining_time', original_name: 'Remaining time',
},
},
states: {
'switch.washer_power': { state: 'on', attributes: {} },
'switch.washer_child_lock': { state: 'off', attributes: {} },
'sensor.washer_wifi_status': { state: 'connected', attributes: {} },
'sensor.washer_localised': { state: 'start', attributes: { friendly_name: 'Статус' } },
'sensor.washer_run_state': { state: 'start', attributes: {} },
'sensor.washer_stage': { state: 'Rinse', attributes: {} },
'select.washer_program': { state: 'mixed_wash', attributes: {} },
'sensor.washer_remaining_time': { state: '17', attributes: { unit_of_measurement: 'min' } },
},
});
assert.deepEqual(
resolvedDeviceStateEntities(hass, Object.keys(hass.entities)),
['switch.washer_power'],
);
});
test('keeps semantic binary ahead of appliance lifecycle and preserves lone relay', () => {
const semantic = mkHass({
entities: {
'switch.washer_power': { entity_id: 'switch.washer_power' },
'switch.washer_child_lock': { entity_id: 'switch.washer_child_lock' },
'sensor.washer_status': { entity_id: 'sensor.washer_status', translation_key: 'status' },
'binary_sensor.washer_running': { entity_id: 'binary_sensor.washer_running' },
},
states: {
'switch.washer_power': { state: 'on', attributes: {} },
'switch.washer_child_lock': { state: 'off', attributes: {} },
'sensor.washer_status': { state: 'idle', attributes: {} },
'binary_sensor.washer_running': { state: 'on', attributes: { device_class: 'running' } },
},
});
assert.deepEqual(
resolvedDeviceStateEntities(semantic, Object.keys(semantic.entities)),
['binary_sensor.washer_running'],
);
const lone = mkHass({
entities: {
'switch.coffee': { entity_id: 'switch.coffee' },
'sensor.coffee_status': { entity_id: 'sensor.coffee_status', translation_key: 'status' },
},
states: {
'switch.coffee': { state: 'on', attributes: {} },
'sensor.coffee_status': { state: 'connected', attributes: {} },
},
});
assert.deepEqual(
resolvedDeviceStateEntities(lone, Object.keys(lone.entities)),
['switch.coffee'],
);
});
test('resolvedDeviceStateEntities: passive readings aggregate availability instead of picking the first', () => {
const hass = mkHass({
entities: {
+20 -1
View File
@@ -84,7 +84,8 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
'backup-full', 'backup-space', 'value-badge-positions', 'isometric-geometry',
'isometric-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
'isometric-large-warm-remount', 'split-corner-wall', 'plan-snap-endpoint',
'plan-snap-line-gaps', 'wall-junctions', 'isometric-wall-junctions'])
'plan-snap-line-gaps', 'wall-junctions', 'isometric-wall-junctions',
'washer-active-cycle', 'washer-idle-cycle'])
assert.equal(ids.includes(token), true, token);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('plan'), true);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('devices'), true);
@@ -93,6 +94,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), [