diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 20b627eb..be6108f7 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -245,7 +245,13 @@ HA binding against automatic discovery while intentionally exposing that same
binding to the Add picker. A device tombstone excludes all data of that device;
an entity tombstone excludes the standalone entity binding but does not mutate
the same entity out of a still-live parent device. Runtime-filtered references
-such as `controls` remain persisted and become active again after re-add.
+such as `controls` and live text remain persisted and become active again after
+re-add. Exact `opening.contact` / `opening.lock` fields are a separate
+architectural-object role: their HA availability ignores marker tombstones but
+still uses `resolveHaBindingStatus()` to reject disabled, orphaned or unverified
+entities. Their painted state comes from the immutable active-registry frame,
+not directly from live `hass`. Re-adding a marker therefore cannot duplicate or
+rewrite an opening reference.
Re-adding replaces the tombstone; virtual markers need no tombstone because
they have no discovery source.
@@ -413,6 +419,12 @@ Its default width in the editor is 300 cm. `openingAmount` (pure) maps the conta
drag along walls (continuous re-snap, saved on release), click → status card (250 ms timer),
double click → properties dialog. In markup mode the "Opening" tool handles clicks instead.
+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.
+
For a wall with thickness, one `OpeningWallIndex` resolves the atomic wall
interval and adjacent room on each side of the centreline. Opening symbols,
wall cuts and room-coloured tunnel patches all consume this association; none
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 8233917d..498baf8d 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+- Deleting a standalone sensor or lock marker no longer breaks its explicit
+ door, window or gate binding: the entity stays available in opening settings
+ and continues to drive its state without restoring the marker on the plan
+ ([#104](https://github.com/Matysh/houseplan-card/issues/104)).
- Fixed a crash after deleting the last space: the supported empty state now
stays usable so a new space can be added ([#111](https://github.com/Matysh/houseplan-card/issues/111)).
diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md
index 68d69458..ffbaf9aa 100755
--- a/docs/CHANGELOG.ru.md
+++ b/docs/CHANGELOG.ru.md
@@ -8,6 +8,10 @@
## Unreleased
+- Удаление самостоятельного маркера датчика или замка больше не разрывает его
+ явную привязку к двери, окну или воротам: сущность остаётся доступной в
+ настройках проёма и продолжает показывать состояние, не возвращая маркер на
+ план ([#104](https://github.com/Matysh/houseplan-card/issues/104)).
- Исправлено падение после удаления последнего пространства: предусмотренное
пустое состояние остаётся рабочим, и из него можно добавить новое пространство
([#111](https://github.com/Matysh/houseplan-card/issues/111)).
diff --git a/docs/FILTERING.md b/docs/FILTERING.md
index 265f8e7f..34c00570 100644
--- a/docs/FILTERING.md
+++ b/docs/FILTERING.md
@@ -104,10 +104,15 @@ the old behaviour until an editing client materialises it.
standalone plan object; it does not remove the same entity from the data of
a still-live parent HA device. Tombstones are binding-scoped, not mutations
of the HA registry.
-- References in openings, live text and another marker's persisted `controls`
- are retained but become inactive (no lock/contact badge, `—`, or control
- action respectively). Adding the binding again restores them without
- reconstructing configuration or performing an unrelated Save first.
+- Exact `opening.contact` and `opening.lock` references are independent of a
+ standalone plan marker: deleting that marker does not remove the entity from
+ the opening picker and does not stop the saved opening from following its HA
+ state. The tombstone still suppresses every marker-level contribution and is
+ not removed or rewritten by choosing the entity in an opening.
+- References in live text and another marker's persisted `controls` are
+ retained but become inactive (`—` or no control action respectively).
+ Adding the binding again restores those references without reconstructing
+ configuration or performing an unrelated Save first.
- The same inactive rule applies while a saved binding is HA-disabled. The
marker, room metrics, Glow/light fill, registry-wide climate, live text,
openings, controls, vacuum puck and trails all ignore it. Reactivation of the
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 3bd44579..ca1435a1 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -98,8 +98,10 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
- **True plan deletion** (v1.60.0-beta.1, 2026-08-07): confirmed Delete beside Hide/Show;
a minimal `marker.removed` tombstone prevents auto-rediscovery but exposes
the binding to Add. Deleted devices are absent from every plan aggregate and
- linked presentation; layout/files/trails are cleaned and stale layout writes
- are rejected.
+ linked marker presentation; layout/files/trails are cleaned and stale layout
+ writes are rejected. Exact contact/lock references owned by architectural
+ openings remain active without restoring the standalone marker; live text
+ and other marker controls retain the older re-add-to-reactivate contract.
- **Yellow = working right now** (v1.51.0): climate uses `hvac_action` when
available and falls back to a current advertised non-off HVAC mode only when
the integration omits the action; service switches can no longer become
diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md
index 7edb73ac..3d21b4b3 100644
--- a/docs/USER-GUIDE.ru.md
+++ b/docs/USER-GUIDE.ru.md
@@ -392,7 +392,11 @@ Glow и солнечные лучи, но при Resize комнаты оста
| Открывается в другую сторону | Да | Да | Да; по умолчанию — наружу |
| Замок `lock.*` | Да | Нет | Да |
-Контакт выбирается из подходящих `binary_sensor` и дверных `cover`. Состояние датчика анимирует створку/полотно. Инверсия нужна, если интеграция отдаёт противоположную логику.
+Контакт выбирается из подходящих `binary_sensor` и дверных `cover`. Состояние
+датчика анимирует створку/полотно. Инверсия нужна, если интеграция отдаёт
+противоположную логику. Контактный датчик и замок — самостоятельные точные
+привязки проёма: удаление отдельного маркера той же сущности с плана не убирает
+её из этих списков и не выключает уже сохранённую связь.
### Поведение в разных режимах
@@ -491,10 +495,13 @@ House Plan читает реестры устройств, сущностей и
Удаление и скрытие — разные операции. Скрытый маркер можно показать через
**Скрытые и деактивированные**, и он продолжает участвовать в предусмотренных для скрытых
устройств данных. Удалённый маркер там не показывается, не участвует в LQI,
-температуре, влажности, свете, Glow, карточке комнаты, управлении и живых
-ссылках плана. Его привязка снова появляется в списке **Добавить**. Повторное
-добавление создаёт новую позицию; сохранённая конфигурация проёмов и текстовых
-переменных снова оживает. Позиция, вложения и след пылесоса удаляются.
+температуре, влажности, свете, Glow, карточке комнаты, управлении, текстовых
+переменных и внешних controls других маркеров. Его привязка снова появляется в
+списке **Добавить**. Контактный датчик или замок, явно выбранный в свойствах
+проёма, остаётся отдельной рабочей связью и не восстанавливает удалённый маркер.
+Повторное добавление создаёт маркеру новую позицию и снова оживляет сохранённые
+текстовые переменные и controls, но не дублирует и не переписывает связь проёма.
+Позиция, вложения и след пылесоса при удалении маркера удаляются.
### Скрытые устройства
diff --git a/src/ha-binding-status.ts b/src/ha-binding-status.ts
index 0b45338a..b57c7f6c 100644
--- a/src/ha-binding-status.ts
+++ b/src/ha-binding-status.ts
@@ -515,6 +515,35 @@ export function resolveHaBindingStatus(
return { kind: 'unverified', reason: 'registry_unavailable', enabledEntityIds: [], allEntityIds: [] };
}
+/**
+ * Availability of an exact entity explicitly assigned to an architectural
+ * opening. This is deliberately independent of plan-marker tombstones: the
+ * opening is its own configured consumer of the HA entity. Registry status
+ * still rejects disabled, orphaned and unverified references.
+ */
+export function openingEntityAvailable(
+ hass: any,
+ entityId: string | null | undefined,
+ snapshot = haRegistrySnapshot(hass),
+): boolean {
+ return !!entityId
+ && resolveHaBindingStatus(hass, `entity:${entityId}`, snapshot).kind === 'active';
+}
+
+/**
+ * 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.
+ */
+export function renderOpeningEntityAvailable(
+ projectedHass: any,
+ entityId: string | null | undefined,
+): boolean {
+ return !!entityId
+ && !!projectedHass?.entities?.[entityId]
+ && !!projectedHass?.states?.[entityId];
+}
+
export function haRegistryDiagnostics(hass: any): {
access: HaRegistrySnapshot['access']; authoritative: boolean; revision: number;
lastSuccess: number; error?: string;
diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index 7a03f479..324f2ec6 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -175,7 +175,8 @@ import {
import {
acquireHaRegistries, activeRegistryHass, cacheHaBindingStatuses,
fullRegistryHass, haRegistryBuildSignature, haRegistryDiagnostics, haRegistrySnapshot,
- refreshHaRegistries, resolveHaBindingStatus,
+ openingEntityAvailable, refreshHaRegistries, renderOpeningEntityAvailable,
+ resolveHaBindingStatus,
type HaBindingStatus, type HaRegistrySnapshot,
} from './ha-binding-status';
import type { DecorShape, DecorStyle } from './editors/decor/types';
@@ -10362,7 +10363,7 @@ class HouseplanCard extends LitElement {
private _contactCandidates(): { value: string; label: string }[] {
const out: [string, string, number][] = [];
for (const eid of Object.keys(this.hass.states)) {
- if (!this._planEntityAvailable(eid)) continue;
+ if (!this._openingEntityAvailable(eid)) continue;
const dom = eid.split('.')[0];
if (dom !== 'binary_sensor' && dom !== 'cover') continue;
const st = this.hass.states[eid];
@@ -10378,7 +10379,7 @@ class HouseplanCard extends LitElement {
private _lockCandidates(): { value: string; label: string }[] {
return Object.keys(this.hass.states)
- .filter((eid) => eid.startsWith('lock.') && this._planEntityAvailable(eid))
+ .filter((eid) => eid.startsWith('lock.') && this._openingEntityAvailable(eid))
.map((eid) => ({ value: eid, label: this.hass.states[eid]?.attributes?.friendly_name || eid }))
.sort((a, b) => a.label.localeCompare(b.label));
}
@@ -15512,7 +15513,7 @@ class HouseplanCard extends LitElement {
/** Live state of an opening's contact, 0..1 drawn amount. */
private _openingAmt(o: OpeningCfg): number {
- const st = o.contact && this._renderEntityAvailable(o.contact)
+ const st = o.contact && this._renderOpeningEntityAvailable(o.contact)
? this._renderPlanHass.states[o.contact]?.state
: null;
return openingAmount(o.type, st, !!o.invert);
@@ -15532,6 +15533,16 @@ class HouseplanCard extends LitElement {
return !!this._renderPlanHass.entities?.[eid] && !!this._renderPlanHass.states?.[eid];
}
+ /** Exact opening references ignore marker tombstones but keep HA status. */
+ private _openingEntityAvailable(eid: string | null | undefined): boolean {
+ return openingEntityAvailable(this.hass, eid, this._haRegistry);
+ }
+
+ /** Opening availability from the same immutable projection as the frame. */
+ private _renderOpeningEntityAvailable(eid: string | null | undefined): boolean {
+ return renderOpeningEntityAvailable(this._renderPlanHass, eid);
+ }
+
/**
* Doors, windows and gates, drawn in plan (SVG) coordinates so they scale and
* pan with the plan. Doors/windows follow the easy-floorplan (MIT) symbol
@@ -15571,7 +15582,7 @@ class HouseplanCard extends LitElement {
const openingWallIndex = this._openingWallIndexFor(this._spaceModel(), openCuts).value;
return svg`${items.map((o) => {
const amt = this._openingAmt(o);
- const active = amt > 0 && !!o.contact && this._renderEntityAvailable(o.contact);
+ const active = amt > 0 && !!o.contact && this._renderOpeningEntityAvailable(o.contact);
const tone = active ? 'var(--hp-open)' : base;
// A normal door/window uses the selected room-side face. A gate's
// architectural symbol sits and opens on the opposite (exterior) face;
@@ -15617,7 +15628,7 @@ class HouseplanCard extends LitElement {
/** Padlock badges for door-like openings with a lock entity. */
private _renderOpeningLocks(view: { x: number; y: number; w: number; h: number }): TemplateResult {
const items = this._openingsR.filter(
- (o) => o.type !== 'window' && o.lock && this._renderEntityAvailable(o.lock),
+ (o) => o.type !== 'window' && o.lock && this._renderOpeningEntityAvailable(o.lock),
);
if (!items.length) return html``;
const openCuts = this._openPairs().flatMap((pair) => pair.segs);
@@ -15662,7 +15673,7 @@ class HouseplanCard extends LitElement {
// button is a deliberate, labeled control inside an opened card, the same
// contract as Home Assistant's own more-info dialog. Unlocking additionally
// asks for confirmation; locking does not (locking is never destructive).
- if (!this._planEntityAvailable(entityId)) return;
+ if (!this._openingEntityAvailable(entityId)) return;
if (action === 'unlock') {
const name = this.hass?.states?.[entityId]?.attributes?.friendly_name || entityId;
if (!confirm(this._t('confirm.unlock', { name }))) return;
@@ -15672,8 +15683,8 @@ class HouseplanCard extends LitElement {
private _renderOpeningInfoCard(): TemplateResult {
const o = this._openingInfo!;
- const contact = o.contact && this._planEntityAvailable(o.contact) ? o.contact : null;
- const lock = o.lock && this._planEntityAvailable(o.lock) ? o.lock : null;
+ const contact = o.contact && this._openingEntityAvailable(o.contact) ? o.contact : null;
+ const lock = o.lock && this._openingEntityAvailable(o.lock) ? o.lock : null;
const cSt = contact ? this.hass.states[contact]?.state : null;
const amt = this._openingAmt(o);
const lSt = lock ? this.hass.states[lock]?.state : null;
diff --git a/test/ha-binding-status.test.mjs b/test/ha-binding-status.test.mjs
index a6f3e789..05c7062c 100644
--- a/test/ha-binding-status.test.mjs
+++ b/test/ha-binding-status.test.mjs
@@ -6,6 +6,8 @@ import {
activeRegistryHass,
cacheHaBindingStatuses,
haRegistrySnapshot,
+ openingEntityAvailable,
+ renderOpeningEntityAvailable,
resolveHaBindingStatus,
} from '../test-build/ha-binding-status.js';
@@ -61,6 +63,8 @@ test('limited registry never guesses missing rows are disabled or orphaned', ()
assert.equal(resolveHaBindingStatus(hass, 'entity:switch.live_only', snapshot).kind, 'active');
assert.equal(resolveHaBindingStatus(hass, 'entity:switch.unknown', snapshot).kind, 'unverified');
assert.equal(resolveHaBindingStatus(hass, 'device:unknown', snapshot).kind, 'unverified');
+ assert.equal(openingEntityAvailable(hass, 'switch.live_only', snapshot), true);
+ assert.equal(openingEntityAvailable(hass, 'switch.unknown', snapshot), false);
});
test('authoritative registry accepts an exact live YAML entity without a registry row', () => {
@@ -170,9 +174,14 @@ test('limited live rows cannot override the last authoritative disabled result',
getItem: () => '{}',
setItem: () => undefined,
};
- cacheHaBindingStatuses(new Map([['device:cached-disabled', {
- kind: 'ha_disabled', reason: 'all_entities', enabledEntityIds: [], allEntityIds: ['switch.cached'],
- }]]));
+ cacheHaBindingStatuses(new Map([
+ ['device:cached-disabled', {
+ kind: 'ha_disabled', reason: 'all_entities', enabledEntityIds: [], allEntityIds: ['switch.cached'],
+ }],
+ ['entity:switch.cached', {
+ kind: 'ha_disabled', reason: 'entity', enabledEntityIds: [], allEntityIds: ['switch.cached'],
+ }],
+ ]));
const devices = { 'cached-disabled': { id: 'cached-disabled', disabled_by: null } };
const entities = {
'switch.cached': { entity_id: 'switch.cached', device_id: 'cached-disabled', disabled_by: null },
@@ -181,6 +190,7 @@ test('limited live rows cannot override the last authoritative disabled result',
assert.deepEqual(resolveHaBindingStatus(hass, 'device:cached-disabled', limited(devices, entities)), {
kind: 'ha_disabled', reason: 'all_entities', enabledEntityIds: [], allEntityIds: ['switch.cached'],
});
+ assert.equal(openingEntityAvailable(hass, 'switch.cached', limited(devices, entities)), false);
globalThis.localStorage = previousStorage;
});
@@ -193,3 +203,63 @@ test('entity with a missing parent reports device_missing', () => {
'device_missing',
);
});
+
+test('opening entity availability ignores marker lifecycle and follows exact HA status', () => {
+ 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,
+ },
+ 'lock.front': { entity_id: 'lock.front', 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', disabled_by: null },
+ };
+ const hass = {
+ devices, entities,
+ states: {
+ 'binary_sensor.door': { state: 'unavailable' },
+ 'lock.front': { state: 'locked' },
+ 'lock.disabled_entity': { state: 'locked' },
+ 'lock.disabled_parent': { state: 'locked' },
+ 'lock.orphan': { state: 'locked' },
+ },
+ };
+ const snapshot = full(devices, entities);
+
+ // Marker lifecycle is intentionally not an input: the opening is an exact
+ // independent consumer before deletion, after either tombstone and re-add.
+ for (const _markerLifecycle of ['present', 'entity-tombstone', 'device-tombstone', 're-added']) {
+ assert.equal(openingEntityAvailable(hass, 'binary_sensor.door', snapshot), true);
+ assert.equal(openingEntityAvailable(hass, 'lock.front', snapshot), true);
+ }
+ assert.equal(openingEntityAvailable(hass, 'lock.disabled_entity', snapshot), false);
+ assert.equal(openingEntityAvailable(hass, 'lock.disabled_parent', snapshot), false);
+ assert.equal(openingEntityAvailable(hass, 'lock.orphan', snapshot), false);
+ assert.equal(openingEntityAvailable(hass, 'lock.missing', snapshot), false);
+ 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' },
+ },
+ states: {
+ 'binary_sensor.door': { state: 'unknown' },
+ 'binary_sensor.yaml_only': { state: 'on' },
+ },
+ };
+ 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, ''), false);
+});
diff --git a/test/render-device-snapshot.test.mjs b/test/render-device-snapshot.test.mjs
index accd16d7..f7893438 100644
--- a/test/render-device-snapshot.test.mjs
+++ b/test/render-device-snapshot.test.mjs
@@ -63,7 +63,7 @@ test('RenderDeviceSnapshot keeps immutable facts and excludes live HA capabiliti
});
const methodBody = (source, name) => {
- const start = source.indexOf(`private ${name}(`);
+ const start = source.search(new RegExp(`private\\s+(?:async\\s+)?${name}\\(`));
assert.notEqual(start, -1, `${name} exists`);
const tail = source.slice(start + 1);
const next = tail.search(/\n (?:private|protected|public)\s/);
@@ -95,3 +95,44 @@ test('the card gates snapshot positions on the render model', () => {
'the previous unconditional position path must not return',
);
});
+
+test('opening references use their own availability policy without weakening plan tombstones', () => {
+ const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
+ for (const name of [
+ '_contactCandidates', '_lockCandidates', '_openingAmt', '_renderOpenings',
+ '_renderOpeningLocks', '_renderOpeningInfoCard', '_lockAction',
+ ]) {
+ assert.match(
+ methodBody(source, name),
+ /_openingEntityAvailable|_renderOpeningEntityAvailable/,
+ `${name} uses the explicit opening-reference policy`,
+ );
+ }
+ const planAvailability = methodBody(source, '_planEntityAvailable');
+ const renderAvailability = methodBody(source, '_renderEntityAvailable');
+ assert.match(planAvailability, /isRemovedPlanEntity/);
+ assert.match(renderAvailability, /isRemovedPlanEntity/);
+ assert.doesNotMatch(planAvailability, /openingEntityAvailable/);
+ assert.doesNotMatch(renderAvailability, /renderOpeningEntityAvailable/);
+});
+
+test('lock actuation remains guarded inside the one sanctioned opening-card method', () => {
+ const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
+ const action = methodBody(source, '_lockAction');
+ const guardAt = action.indexOf('_openingEntityAvailable(entityId)');
+ const confirmAt = action.indexOf('confirm(');
+ const serviceAt = action.indexOf("callService?.('lock'");
+ assert.ok(guardAt >= 0 && guardAt < confirmAt && confirmAt < serviceAt);
+ assert.equal(source.match(/callService\?\.\('lock'/g)?.length, 1);
+ assert.equal(source.match(/this\._lockAction\(/g)?.length, 1, 'only the opening card button calls it');
+});
+
+test('marker delete/re-add and opening save remain separate config transactions', () => {
+ const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
+ const saveOpening = methodBody(source, '_saveOpening');
+ const saveMarker = methodBody(source, '_saveMarker');
+ assert.match(saveOpening, /sp\.openings/);
+ assert.doesNotMatch(saveOpening, /cfg\.markers|this\._markers/);
+ assert.match(saveMarker, /cfg\.markers/);
+ assert.doesNotMatch(saveMarker, /\.openings/);
+});