fix: support registryless opening entities

Issue: #117
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-19 02:12:37 +03:00
parent 9baf533c90
commit 01fe48de00
14 changed files with 283 additions and 32 deletions
File diff suppressed because one or more lines are too long
+163
View File
@@ -0,0 +1,163 @@
// #117: exact YAML contact/lock references without Entity Registry rows must
// survive the immutable render projection without weakening disabled/security rules.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const settle = async () => {
card.requestUpdate();
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const contactId = 'binary_sensor.hp117_yaml_contact';
const lockId = 'lock.hp117_yaml_lock';
const space = card._serverCfg.spaces.find((item) => item.id === 'f1');
space.openings = [
{
id: 'hp117-window', type: 'window', x: 0.22, y: 0.14,
angle: 0, length: 0.09, contact: contactId,
},
{
id: 'hp117-door', type: 'door', x: 0.40, y: 0.14,
angle: 0, length: 0.09, contact: contactId, lock: lockId,
},
];
card._serverCfg = {
...card._serverCfg,
markers: [
{ id: 'hp117-contact-gone', binding: `entity:${contactId}`, removed: true, hidden: true },
{ id: 'hp117-lock-gone', binding: `entity:${lockId}`, removed: true, hidden: true },
],
};
card._cfgEpoch++;
card._setMode('view');
const serviceCalls = [];
card.hass = {
...card.hass,
states: {
...card.hass.states,
[contactId]: {
entity_id: contactId, state: 'off',
attributes: { friendly_name: 'YAML opening contact', device_class: 'window' },
},
[lockId]: {
entity_id: lockId, state: 'locked',
attributes: { friendly_name: 'YAML opening lock' },
},
},
callService: async (domain, service, data) => {
serviceCalls.push({ domain, service, entityId: data.entity_id });
},
};
await settle();
const rendered = () => card._openingsR;
const yamlWindow = () => rendered().find((opening) => opening.id === 'hp117-window');
const yamlDoor = () => rendered().find((opening) => opening.id === 'hp117-door');
result.noRegistryRowsExist = card.hass.entities?.[contactId] == null
&& card.hass.entities?.[lockId] == null;
result.pickerOffersExactYamlEntities = card._contactCandidates()
.some((item) => item.value === contactId)
&& card._lockCandidates().some((item) => item.value === lockId);
result.frozenProjectionKeepsYamlStates = card._renderPlanHass.entities?.[contactId] == null
&& card._renderPlanHass.entities?.[lockId] == null
&& card._renderPlanHass.states?.[contactId]?.state === 'off'
&& card._renderPlanHass.states?.[lockId]?.state === 'locked';
result.markerTombstonesDoNotBlockOpening = card._renderOpeningEntityAvailable(contactId)
&& card._renderOpeningEntityAvailable(lockId)
&& !card._planEntityAvailable(contactId)
&& !card._planEntityAvailable(lockId);
result.closedContactControlsPresentation = card._openingAmt(yamlWindow()) === 0
&& card._openingAmt(yamlDoor()) === 0;
result.yamlLockBadgeRendersLocked = root().querySelectorAll('.oplock.locked').length === 1;
const callsBeforePlanTap = serviceCalls.length;
root().querySelector('[data-hp="opening"][data-id="hp117-door"] .op-hit')
?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
root().querySelector('.oplock')?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
await settle();
result.planOpeningAndBadgeNeverActuate = serviceCalls.length === callsBeforePlanTap;
result.badgeOpensInfoWithBothRows = card._openingInfo?.id === 'hp117-door'
&& root().querySelectorAll('.oprow').length === 2
&& root().querySelectorAll('.lockact').length === 1;
window.confirm = () => true;
card._lockAction(lockId, 'unlock');
result.explicitInfoActionStillWorks = serviceCalls.at(-1)?.domain === 'lock'
&& serviceCalls.at(-1)?.service === 'unlock'
&& serviceCalls.at(-1)?.entityId === lockId;
card._openingInfo = null;
await settle();
card._physicalBodiesR();
const frameBefore = card._renderPlanHass;
const physicalBefore = card._physicalBodiesCache;
const epochBefore = card._cfgEpoch;
const configBefore = JSON.stringify(card._serverCfg);
card.hass = {
...card.hass,
states: {
...card.hass.states,
[contactId]: { ...card.hass.states[contactId], state: 'on' },
[lockId]: { ...card.hass.states[lockId], state: 'unlocked' },
},
};
const oldFrameHeldUntilUpdate = card._renderPlanHass === frameBefore
&& card._openingAmt(yamlWindow()) === 0;
await settle();
result.stateTickSwapsOneAtomicFrame = oldFrameHeldUntilUpdate
&& card._renderPlanHass !== frameBefore
&& card._openingAmt(yamlWindow()) === 1
&& card._openingAmt(yamlDoor()) === 1
&& root().querySelectorAll('.oplock.unlocked').length === 1;
result.stateTickDoesNotRebuildGeometryOrConfig = card._physicalBodiesCache === physicalBefore
&& card._cfgEpoch === epochBefore
&& JSON.stringify(card._serverCfg) === configBefore;
card.hass = {
...card.hass,
states: {
...card.hass.states,
[contactId]: { ...card.hass.states[contactId], state: 'unknown' },
[lockId]: { ...card.hass.states[lockId], state: 'unknown' },
},
};
await settle();
result.unknownKeepsExistingTypeSemantics = card._renderOpeningEntityAvailable(contactId)
&& card._openingAmt(yamlWindow()) === 0
&& card._openingAmt(yamlDoor()) === 1
&& root().querySelectorAll('.oplock.unknown').length === 1;
space.openings = [
{
id: 'hp117-disabled-window', type: 'window', x: 0.22, y: 0.14,
angle: 0, length: 0.09, contact: 'binary_sensor.window',
},
{
id: 'hp117-disabled-lock', type: 'door', x: 0.40, y: 0.14,
angle: 0, length: 0.09, lock: 'lock.front_door',
},
];
card._cfgEpoch++;
window.__setRegistryDisabled('entity', 'binary_sensor.window', 'user');
window.__setRegistryDisabled('entity', 'lock.front_door', 'user');
await new Promise((resolve) => setTimeout(resolve, 220));
await settle();
const disabledWindow = card._openingsR.find((opening) => opening.id === 'hp117-disabled-window');
result.explicitDisabledRowsRemoveStaleStates = card._renderPlanHass.states?.['binary_sensor.window'] == null
&& card._renderPlanHass.states?.['lock.front_door'] == null
&& !card._renderOpeningEntityAvailable('binary_sensor.window')
&& !card._renderOpeningEntityAvailable('lock.front_door')
&& card._openingAmt(disabledWindow) === 0
&& root().querySelectorAll('.oplock').length === 0;
return result;
});
checkAll(out);
await finish(browser, out);
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -519,8 +519,11 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
Contact and lock are exact HA references owned by the opening, not aliases of
standalone markers. Their candidate/action path follows HA binding status while
their render path follows the frozen active-registry projection; neither path
consults marker tombstones. Plan-level consumers keep the tombstone policy
described above.
consults marker tombstones. For that projection, the presence of an exact state
is sufficient: registry-less YAML entities have no row, while explicit
disabled/orphan rows have already been stripped together with their states.
The render helper must never receive raw live hass. Plan-level consumers keep
the tombstone policy described above.
For a wall with thickness, one `OpeningWallIndex` resolves the atomic wall
interval and adjacent room on each side of the centreline. Opening symbols,
+6
View File
@@ -69,6 +69,12 @@
([#166](https://github.com/Matysh/houseplan-card/issues/166)).
- Small fixes and improvements.
- Door, window and gate contacts and locks now keep working when they are live
YAML entities without a `unique_id` and therefore have no Entity Registry
row. The picker, View animation, lock badge and opening info card now follow
the same exact reference, while disabled, orphaned and missing entities
remain unavailable ([#117](https://github.com/Matysh/houseplan-card/issues/117)).
## v1.64.0-beta.3 — 2026-08-14
- Large plans no longer recompute an unused physical-wall union on every floor
+7
View File
@@ -80,6 +80,13 @@
([#166](https://github.com/Matysh/houseplan-card/issues/166)).
- Мелкие исправления и улучшения.
- Датчики и замки дверей, окон и ворот теперь работают и для живых
YAML-сущностей без `unique_id`, у которых поэтому нет строки в Entity
Registry. Список выбора, анимация в Просмотре, значок замка и карточка проёма
используют одну точную ссылку; отключённые, потерянные и отсутствующие
сущности по-прежнему недоступны
([#117](https://github.com/Matysh/houseplan-card/issues/117)).
## v1.64.0-beta.3 — 2026-08-14
- Большие планы больше не пересчитывают неиспользуемое объединение физических
+7
View File
@@ -1095,6 +1095,13 @@ separately promised workflows:
locked / Lock when unlocked; button calls the lock service; disabled while
locking/unlocking; hidden when unavailable; plan-icon tap still never
toggles a lock [auto: smoke_gear_tabs / smoke_gs_always]
- [ ] Registry-less opening binding (#117): a live YAML contact/lock without
`unique_id` is offered by the picker and drives the frozen View frame,
badge and info card; marker tombstones do not block it, an HA tick swaps
the frame without rebuilding geometry/config, and explicit disabled rows
with stale states remain hidden. Only the confirmed info-card lock action
may call a service [auto: ha-binding-status, render-device-snapshot,
smoke_registryless_opening, mutation-gate]
- [ ] New-device flag (v1.29.0): a device added to HA after install gets a big red
dot top-right of its icon (all clients); opening its editor clears it
everywhere; upgrade/first-run seeds the baseline silently — no dot flood [auto: smoke_new_device]
+4 -1
View File
@@ -472,7 +472,10 @@ Glow и солнечные лучи, но при Resize комнаты оста
датчика анимирует створку/полотно. Инверсия нужна, если интеграция отдаёт
противоположную логику. Контактный датчик и замок — самостоятельные точные
привязки проёма: удаление отдельного маркера той же сущности с плана не убирает
её из этих списков и не выключает уже сохранённую связь.
её из этих списков и не выключает уже сохранённую связь. Можно выбрать живую
YAML-сущность без `unique_id` и строки в Entity Registry: пока Home Assistant
передаёт её точное состояние, она так же управляет проёмом и значком замка в
Просмотре. Явно отключённая, потерянная или отсутствующая сущность не работает.
### Поведение в разных режимах
+11 -11
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"fixture": "synthetic-only",
"sourceFingerprint": "3523bebdb36912c484e50c1a31b37bcc12aec09a6eff8d9dd3558a6f037de4a0",
"sourceFingerprint": "2b4a6de3340e1c264c45724b79f9e618cda943509b8d4cb746f8ecace57d2bc5",
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -13,7 +13,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3523bebdb36912c484e50c1a31b37bcc12aec09a6eff8d9dd3558a6f037de4a0",
"sourceSha256": "2b4a6de3340e1c264c45724b79f9e618cda943509b8d4cb746f8ecace57d2bc5",
"imageSha256": "d36b6f9f8139f31ef73a780c6511a640a26055efd9d7a24c24fd48b1d8379bf0"
},
"view-touch": {
@@ -24,7 +24,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3523bebdb36912c484e50c1a31b37bcc12aec09a6eff8d9dd3558a6f037de4a0",
"sourceSha256": "2b4a6de3340e1c264c45724b79f9e618cda943509b8d4cb746f8ecace57d2bc5",
"imageSha256": "358e25ff9984d0fb0c03cfbb848df40c425fdfe64cd5f4f613b1e754ca9d4249"
},
"space-create": {
@@ -35,7 +35,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3523bebdb36912c484e50c1a31b37bcc12aec09a6eff8d9dd3558a6f037de4a0",
"sourceSha256": "2b4a6de3340e1c264c45724b79f9e618cda943509b8d4cb746f8ecace57d2bc5",
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
},
"room-contour-close": {
@@ -46,7 +46,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3523bebdb36912c484e50c1a31b37bcc12aec09a6eff8d9dd3558a6f037de4a0",
"sourceSha256": "2b4a6de3340e1c264c45724b79f9e618cda943509b8d4cb746f8ecace57d2bc5",
"imageSha256": "5ca7f24642926072ccbc5bf48341c5310329918631b147c116f9c29e20e4927e"
},
"plan-context-tray": {
@@ -57,7 +57,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3523bebdb36912c484e50c1a31b37bcc12aec09a6eff8d9dd3558a6f037de4a0",
"sourceSha256": "2b4a6de3340e1c264c45724b79f9e618cda943509b8d4cb746f8ecace57d2bc5",
"imageSha256": "164b79fd9075a1425cffe3c85c88b689dfda90b79856bb4ed6655507b166b3e0"
},
"device-editor": {
@@ -68,7 +68,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3523bebdb36912c484e50c1a31b37bcc12aec09a6eff8d9dd3558a6f037de4a0",
"sourceSha256": "2b4a6de3340e1c264c45724b79f9e618cda943509b8d4cb746f8ecace57d2bc5",
"imageSha256": "7a22a81e2abedf5a520bd36b7509d953cbfa9962b48c0ffbc233666a6215c04b"
},
"device-display-preview": {
@@ -79,7 +79,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3523bebdb36912c484e50c1a31b37bcc12aec09a6eff8d9dd3558a6f037de4a0",
"sourceSha256": "2b4a6de3340e1c264c45724b79f9e618cda943509b8d4cb746f8ecace57d2bc5",
"imageSha256": "f6014caed7b7d28790b8548996ba09d806a4e4d51fbb7e8d3fb3c582ebe49167"
},
"background-editor": {
@@ -90,7 +90,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3523bebdb36912c484e50c1a31b37bcc12aec09a6eff8d9dd3558a6f037de4a0",
"sourceSha256": "2b4a6de3340e1c264c45724b79f9e618cda943509b8d4cb746f8ecace57d2bc5",
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
},
"room-card": {
@@ -101,7 +101,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3523bebdb36912c484e50c1a31b37bcc12aec09a6eff8d9dd3558a6f037de4a0",
"sourceSha256": "2b4a6de3340e1c264c45724b79f9e618cda943509b8d4cb746f8ecace57d2bc5",
"imageSha256": "176abba71d41cfb045a33f82a794d9fbb2a5d3c48e6df66e6ec3a8e448311b11"
},
"device-info": {
@@ -112,7 +112,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3523bebdb36912c484e50c1a31b37bcc12aec09a6eff8d9dd3558a6f037de4a0",
"sourceSha256": "2b4a6de3340e1c264c45724b79f9e618cda943509b8d4cb746f8ecace57d2bc5",
"imageSha256": "2199ed88b215bf2bff63bf665028f78b2aa6a92c3032b803931790f2ef071893"
}
}
+12
View File
@@ -62,6 +62,18 @@ export const MUTANTS = [
replace: 'cuts.push([opening.x, opening.y, opening.x, opening.y]);',
}],
},
{
id: 'registryless-opening-requires-registry-row',
guard: 'node demo/smoke_registryless_opening.mjs',
because: 'возврат требования Entity Registry row снова делает выбранную YAML-сущность '
+ 'невидимой в painted frame; smoke обязан доказать contact, lock и frozen-snapshot parity',
patches: [{
file: 'src/ha-binding-status.ts',
find: ' return !!entityId\n && !!projectedHass?.states?.[entityId];',
replace: ' return !!entityId\n && !!projectedHass?.entities?.[entityId]\n'
+ ' && !!projectedHass?.states?.[entityId];',
}],
},
{
id: 'column-shadow-removed',
guard: 'node demo/smoke_glow.mjs',
+5 -3
View File
@@ -532,15 +532,17 @@ export function openingEntityAvailable(
/**
* Frame-local counterpart of `openingEntityAvailable`. The caller supplies an
* immutable active-registry projection, so both the registry row and state
* must belong to the same painted frame. Registry-less render parity is #117.
* immutable active-registry projection. State presence in that projection is
* sufficient evidence for an exact opening reference: YAML entities without
* unique_id have no registry row, while explicit disabled/orphan rows have
* already had their states removed by `activeRegistryHass`. Never call this
* helper with raw live hass; every read must belong to one painted frame.
*/
export function renderOpeningEntityAvailable(
projectedHass: any,
entityId: string | null | undefined,
): boolean {
return !!entityId
&& !!projectedHass?.entities?.[entityId]
&& !!projectedHass?.states?.[entityId];
}
+49 -9
View File
@@ -247,19 +247,59 @@ test('opening entity availability ignores marker lifecycle and follows exact HA
assert.equal(openingEntityAvailable(hass, null, snapshot), false);
});
test('opening render availability requires one frozen registry-backed frame', () => {
const frame = {
entities: {
'binary_sensor.door': { entity_id: 'binary_sensor.door' },
'lock.no_state': { entity_id: 'lock.no_state' },
test('opening render availability trusts one frozen active projection', () => {
const devices = {
active: { id: 'active', disabled_by: null },
disabled: { id: 'disabled', disabled_by: 'user' },
};
const entities = {
'binary_sensor.door': {
entity_id: 'binary_sensor.door', device_id: 'active', disabled_by: null,
},
states: {
'binary_sensor.door': { state: 'unknown' },
'binary_sensor.yaml_only': { state: 'on' },
'lock.no_state': { entity_id: 'lock.no_state', device_id: 'active', disabled_by: null },
'lock.disabled_entity': {
entity_id: 'lock.disabled_entity', device_id: 'active', disabled_by: 'user',
},
'lock.disabled_parent': {
entity_id: 'lock.disabled_parent', device_id: 'disabled', disabled_by: null,
},
'lock.orphan': {
entity_id: 'lock.orphan', device_id: 'missing-device', disabled_by: null,
},
};
const states = {
'binary_sensor.door': { entity_id: 'binary_sensor.door', state: 'unknown' },
'binary_sensor.yaml_only': { entity_id: 'binary_sensor.yaml_only', state: 'on' },
'lock.yaml_unknown': { entity_id: 'lock.yaml_unknown', state: 'unavailable' },
'lock.disabled_entity': { entity_id: 'lock.disabled_entity', state: 'locked' },
'lock.disabled_parent': { entity_id: 'lock.disabled_parent', state: 'locked' },
'lock.orphan': { entity_id: 'lock.orphan', state: 'locked' },
};
const frame = activeRegistryHass({ devices, entities, states }, full(devices, entities));
assert.equal(renderOpeningEntityAvailable(frame, 'binary_sensor.door'), true);
assert.equal(renderOpeningEntityAvailable(frame, 'lock.no_state'), false);
assert.equal(renderOpeningEntityAvailable(frame, 'binary_sensor.yaml_only'), false, '#117 owns YAML parity');
assert.equal(renderOpeningEntityAvailable(frame, 'binary_sensor.yaml_only'), true);
assert.equal(renderOpeningEntityAvailable(frame, 'lock.yaml_unknown'), true,
'unknown/unavailable remains an existing exact reference, not an open state');
assert.equal(renderOpeningEntityAvailable(frame, 'lock.disabled_entity'), false);
assert.equal(renderOpeningEntityAvailable(frame, 'lock.disabled_parent'), false);
assert.equal(renderOpeningEntityAvailable(frame, 'lock.orphan'), false);
assert.equal(renderOpeningEntityAvailable(frame, 'lock.missing'), false);
assert.equal(renderOpeningEntityAvailable(frame, ''), false);
const limitedFrame = activeRegistryHass({
devices: {}, entities: {},
states: { 'binary_sensor.limited_yaml': { state: 'off' } },
}, limited({}, {}));
assert.equal(renderOpeningEntityAvailable(limitedFrame, 'binary_sensor.limited_yaml'), true);
const markerTombstoneIsNotAnInput = {
...frame,
markers: [{ binding: 'entity:binary_sensor.yaml_only', removed: true }],
};
assert.equal(
renderOpeningEntityAvailable(markerTombstoneIsNotAnInput, 'binary_sensor.yaml_only'),
true,
);
});
+8
View File
@@ -114,6 +114,14 @@ test('opening references use their own availability policy without weakening pla
assert.match(renderAvailability, /isRemovedPlanEntity/);
assert.doesNotMatch(planAvailability, /openingEntityAvailable/);
assert.doesNotMatch(renderAvailability, /renderOpeningEntityAvailable/);
const openingRenderAvailability = methodBody(source, '_renderOpeningEntityAvailable');
assert.match(
openingRenderAvailability,
/renderOpeningEntityAvailable\(this\._renderPlanHass, eid\)/,
'opening render availability receives only the immutable painted-frame projection',
);
assert.doesNotMatch(openingRenderAvailability, /this\.hass\b/);
});
test('lock actuation remains guarded inside the one sanctioned opening-card method', () => {