fix: restore one child of a deleted device

Issue: #262
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-23 16:32:12 +03:00
parent a0b39b626f
commit 8cdf6b4894
15 changed files with 283 additions and 34 deletions
File diff suppressed because one or more lines are too long
+62 -10
View File
@@ -35,15 +35,18 @@ Object.assign(out, await page.evaluate(async () => {
await c.updateComplete;
window.confirm = nativeConfirm;
};
const tombstones = () => (c._serverCfg.markers || []).filter((m) => m.removed === true);
const markers = () => c._serverCfg.markers || [];
const tombstones = () => markers().filter((m) => m.removed === true);
const liveMarkers = (binding) => markers().filter((m) => m.removed !== true && m.binding === binding);
// --- 1. удалили устройство — оно снова предлагается ----------------------
const device = c._devices.find((d) => d.bindingKind === 'device' && d.bindingRef && !d.hidden);
o.standHasDeviceMarker = !!device;
const deviceBinding = `device:${device.bindingRef}`;
const childEntity = Object.entries(c._planHass.entities)
.find(([, reg]) => reg.device_id === device.bindingRef)?.[0];
.find(([, reg]) => reg.device_id === device.bindingRef && !reg.hidden)?.[0];
o.standHasChildEntity = !!childEntity;
const childBinding = `entity:${childEntity}`;
o.placedDeviceNotOffered = !(await offered(false)).includes(deviceBinding);
await remove(device);
@@ -67,16 +70,62 @@ Object.assign(out, await page.evaluate(async () => {
return c._markerDialog.showEntities;
})()) === false;
// --- 3. известный дефект #262 -------------------------------------------
// Надгробие ключуется по точной строке привязки, поэтому `device:<id>`
// разблокирует только само устройство: `isRemovedPlanEntity` продолжает
// выкидывать из списка все его дочерние сущности. Здесь закреплено ТЕКУЩЕЕ
// поведение — починят #262, и смок покраснеет, потребовав перевернуть
// проверку. Молча пройти фикс мимо покрытия не сможет.
o.knownDefect262ChildEntityBlocked =
!(await offered(true)).includes(`entity:${childEntity}`);
// --- 3. #262: из удалённого parent возвращается одна exact entity --------
// Parent tombstone открывает переход в picker, но сохраняется после Save:
// выбранная X оживает, siblings и автоматический device:D — нет.
o.deletedDeviceChildHiddenWithoutCheckbox =
!(await offered(false)).includes(childBinding);
o.deletedDeviceChildOfferedWithCheckbox =
(await offered(true)).includes(childBinding);
const saveChild = async () => {
await openAdd(true);
const reg = c._planHass.entities[childEntity];
const state = c._planHass.states[childEntity];
c._markerDialog = {
...c._markerDialog,
bindingMode: 'ha',
binding: childBinding,
name: reg?.name || state?.attributes?.friendly_name || childEntity,
};
await c._saveMarker();
await c.updateComplete;
return liveMarkers(childBinding)[0];
};
const childMarker = await saveChild();
o.childSaveCreatesOneLiveMarker = liveMarkers(childBinding).length === 1
&& !tombstones().some((m) => m.binding === childBinding);
o.childSaveKeepsParentTombstone = tombstones().some((m) => m.binding === deviceBinding);
const childDevice = () => c._devices.find((d) =>
d.bindingKind === 'entity' && d.bindingRef === childEntity);
o.childBuiltWithoutAutoParent = !!childDevice()
&& !c._devices.some((d) => d.bindingKind === 'device' && d.bindingRef === device.bindingRef);
const childPos = childMarker && c._layout[childMarker.id];
o.childGetsFreshLayoutPosition = !!childPos
&& Number.isFinite(childPos.x) && Number.isFinite(childPos.y) && !!childPos.s;
c._setMode('view');
await c.updateComplete;
o.childDrawnInView = !!c.renderRoot.querySelector(
`.dev[data-id="${CSS.escape(childMarker.id)}"]`,
);
o.parentStillOfferedAfterChildSave = (await offered(false)).includes(deviceBinding);
o.liveChildRemovedFromPicker = !(await offered(true)).includes(childBinding);
// Повторный Delete → Add той же X обязан быть идемпотентным и не затрагивать
// соседний parent tombstone.
await remove(childDevice());
o.childDeleteLeavesBothTombstones = tombstones().some((m) => m.binding === deviceBinding)
&& tombstones().some((m) => m.binding === childBinding);
o.deletedChildOfferedAgain = (await offered(true)).includes(childBinding);
await saveChild();
o.secondChildSaveIsIdempotent = liveMarkers(childBinding).length === 1
&& !tombstones().some((m) => m.binding === childBinding)
&& tombstones().some((m) => m.binding === deviceBinding);
// --- 4. повторное добавление возвращает устройство в список размещённых --
// Явные device:D + entity:X сосуществуют по принятому контракту #226.
await openAdd(false);
c._markerDialog = { ...c._markerDialog, bindingMode: 'ha', binding: deviceBinding,
name: device.name };
@@ -85,6 +134,9 @@ Object.assign(out, await page.evaluate(async () => {
o.readdReplacesTombstone = !tombstones().some((m) => m.binding === deviceBinding)
&& (c._serverCfg.markers || []).some((m) => m.binding === deviceBinding);
o.readdRemovesFromPicker = !(await offered(false)).includes(deviceBinding);
o.explicitParentAndChildCoexist = c._devices.some((d) =>
d.bindingKind === 'device' && d.bindingRef === device.bindingRef)
&& !!childDevice();
c._markerDialog = null;
c._setMode('view');
+4 -4
View File
File diff suppressed because one or more lines are too long
+11 -5
View File
@@ -306,16 +306,22 @@ references and duplicates by name|area. Manual files: transactional HTTP upload
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` and live text remain persisted and become active again after
re-add. Exact `opening.contact` / `opening.lock` fields are a separate
the same entity out of a still-live parent device. A live exact `entity:X`
marker is the one narrow override: it may coexist with a `device:D` tombstone,
restoring X while the parent claim continues to suppress D and every sibling
without its own live exact marker. The Add picker exposes active children of a
device tombstone only behind **Show entities**, so that combination is reachable
without weakening ordinary runtime deletion. Runtime-filtered references such
as `controls` and live text remain persisted and become active again after
exact 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.
Re-adding the same binding replaces its tombstone. Re-adding a child entity of
a tombstoned device preserves the parent tombstone instead; virtual markers
need no tombstone because they have no discovery source.
## Server-side configuration (current shape, v1.51+)
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- After deleting a complete HA device from the plan, any one of its active
entities can now be added back separately through **Add → Show entities**.
The selected entity returns as a normal live marker with a fresh position,
while the parent device and unselected siblings stay deleted; the complete
device remains available for an intentional later re-add
([#262](https://github.com/Matysh/houseplan-card/issues/262)).
- Thick T-junctions no longer show white triangular gaps on the exterior side
when three or more walls of different thickness meet. The bounded bevel now
preserves valid masonry and paper up to the same limit on both sides of the
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- После удаления целого HA-устройства с плана теперь можно отдельно вернуть
любую его активную сущность через **Добавить → Показывать сущности**.
Выбранная сущность появляется как обычный живой маркер на свежей позиции, а
родительское устройство и невыбранные siblings остаются удалёнными; само
устройство по-прежнему доступно для осознанного последующего возврата
([#262](https://github.com/Matysh/houseplan-card/issues/262)).
- В толстых T-стыках больше не появляются белые треугольные щели с наружной
стороны, когда сходятся три и более стены разной толщины. Ограниченная фаска
теперь сохраняет допустимую кладку и подложку до одного предела по обе стороны
+12 -4
View File
@@ -26,6 +26,12 @@ as the SEEDER of initial hidden flags.
automatic discovery cannot immediately recreate the deleted device. The
same binding remains available in Add; saving it again replaces the
tombstone and starts with a fresh position.
- A `device:D` tombstone also exposes D's active child entities in Add when
**Show entities** is enabled. Saving one `entity:X` keeps the parent
tombstone and restores only X: the live exact entity binding overrides the
tombstone for X, while D and every sibling without its own live marker stay
deleted. Adding D later is still an explicit exact re-add and may coexist
with X under the rule below.
- A live `entity:X` marker owns X inside its automatic parent `device:D`. A
residual auto-device contains only active, HA-visible siblings not owned by
other entity markers and disappears when that set is empty. A user-hidden
@@ -110,10 +116,12 @@ the old behaviour until an editing client materialises it.
server while the tombstone exists.
- A deleted **device binding** is excluded from room LQI, light-source
resolution, room light statistics/fill/Glow, registry-wide climate averages
and explicit room sources. An **entity binding** tombstone suppresses that
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.
and explicit room sources, except for an exact child entity restored as a
live marker as described above. That exception is exact: it restores X's
normal marker-level state and aggregates, never the parent or its siblings.
An **entity binding** tombstone suppresses that 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.
- 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
+6
View File
@@ -1573,6 +1573,12 @@ separately promised workflows:
return on rebuild/reload, but its binding is offered by Add. Re-add it:
one marker only, fresh centred/grid position, no tombstone
[auto: smoke_hidden_flag; unit + manual]
- [ ] Delete `device:D`, enable Show entities in Add and restore only child
`entity:X`: X receives one live marker and a fresh position; the parent
tombstone remains, so D and sibling Y do not return or contribute to
aggregates. Delete/re-add X is idempotent; explicitly re-adding D later
leaves the intentional D + X pair from #226
[auto: smoke_binding_picker; unit: devices.test.mjs; mutation x4]
- [ ] Delete an entity marker and a virtual marker: the entity is offered by
Add (with Show entities when applicable); the virtual marker is gone and
can be recreated manually. The exact deleted entity remains offered even
+6
View File
@@ -401,6 +401,12 @@ as an intentional configuration. Deleting an entity marker returns the entity
to automatic parent discovery; its binding tombstone does not remove registry
data from the live HA device.
After deleting a complete HA device, you can restore only one of its entities:
open **Add**, enable **Show entities**, and select that entity. House Plan
returns the selected marker with a fresh position while the complete device
and its other entities remain deleted. The complete device stays available in
Add if you later decide to restore it explicitly as well.
### Device editor
- drag a marker to save its server-side position;
+6
View File
@@ -669,6 +669,12 @@ House Plan читает реестры устройств, сущностей и
удаления entity-marker сущность снова доступна автоматическому родителю:
binding tombstone не вырезает её из живого устройства HA.
После удаления целого HA-устройства можно вернуть только одну его сущность:
откройте **Добавить**, включите **Показывать сущности** и выберите нужную
сущность. House Plan вернёт выбранный маркер на свежую позицию, а всё устройство
и остальные его сущности останутся удалёнными. Само устройство продолжит
предлагаться в **Добавить**, если позднее вы решите явно вернуть и его.
### Редактор устройств
| Действие | Результат |
@@ -2,7 +2,9 @@
- Issue: [#262](https://github.com/Matysh/houseplan-card/issues/262)
- Ветка: `issue/262-readd-child-entity`
- Статус: `S3-spec`
- Статус: `S6-in-progress`, ТЗ одобрено
- Ревью ТЗ: [`docs/reviews/SPEC-REVIEW-262-r1.md`](../reviews/SPEC-REVIEW-262-r1.md),
зелёный вердикт
- Приоритет: `P2`
- Тип: `bug`
- Трек: обычный
@@ -439,4 +441,3 @@ records schema-valid и не удаляются старой версией, э
координат D → X нет.
5. Полноценное добавление D после X оставляет оба explicit marker по уже
утверждённому #226; скрытое автоматическое объединение было бы новым UX.
+55
View File
@@ -40,6 +40,61 @@ import { fileURLToPath } from 'node:url';
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
export const MUTANTS = [
{
id: 'device-tombstone-blocks-child-picker',
guard: 'node demo/smoke_binding_picker.mjs',
because: 'a device tombstone must expose an active child in Add when Show entities is on; '
+ 'restoring only the exact device reproduces the user-visible dead end from #262',
patches: [{
file: 'src/houseplan-card.ts',
find: ' if (isRemovedPlanEntity(h, eid, removed)\n'
+ ' && !removedBindings.has(v) && !childOfRemovedDevice) continue;',
replace: ' if (isRemovedPlanEntity(h, eid, removed)\n'
+ ' && !removedBindings.has(v)) continue;',
}],
},
{
id: 'live-child-still-suppressed-by-parent-tombstone',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 262" test/devices.test.mjs',
because: 'fixing only the picker lets the person select X but buildDevices and every '
+ 'availability consumer still discard it after Save; a live exact child must win (#262)',
patches: [{
file: 'src/devices.ts',
find: ' if (removed.liveEntities.has(eid)) return false;\n',
replace: '',
}],
},
{
id: 'parent-tombstone-restores-all-siblings',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 262" test/devices.test.mjs',
because: 'one restored child is an exact override, not permission for every sibling and '
+ 'room aggregate of the deleted parent device to return (#262)',
patches: [{
file: 'src/devices.ts',
find: ' if (removed.liveEntities.has(eid)) return false;\n',
replace: ' if (removed.liveEntities.size) return false;\n',
}],
},
{
id: 'child-readd-clears-parent-tombstone',
guard: 'node demo/smoke_binding_picker.mjs',
because: 'saving one child must preserve the device tombstone; dropping all tombstones '
+ 'resurrects the automatic parent and the siblings the person deliberately removed (#262)',
patches: [{
file: 'src/houseplan-card.ts',
find: ` cfg.markers = cfg.markers.filter(
(m) => m.id !== id && m.id !== oldId
&& (marker.binding === 'virtual' || m.binding !== marker.binding),
);`,
replace: ` cfg.markers = cfg.markers.filter(
(m) => m.id !== id && m.id !== oldId
&& (marker.binding === 'virtual' || m.binding !== marker.binding)
&& (!marker.binding.startsWith('entity:') || m.removed !== true),
);`,
}],
},
{
id: 'orphan-space-detach-disabled',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+15 -4
View File
@@ -840,6 +840,9 @@ export function resolveIcon(hass: any, name: string, model: string | undefined,
export interface RemovedPlanBindings {
devices: Set<string>;
entities: Set<string>;
/** Exact live entity markers that intentionally override an entity or
* parent-device tombstone without restoring the rest of that device. */
liveEntities: Set<string>;
}
export interface DeletePlanMarkerResult {
@@ -934,23 +937,31 @@ export function deletePlanMarkerRecords(
export function removedPlanBindings(markers?: readonly Marker[] | null): RemovedPlanBindings {
const devices = new Set<string>();
const entities = new Set<string>();
const liveEntities = new Set<string>();
for (const m of markers || []) {
if (m?.removed !== true) continue;
const i = String(m.binding || '').indexOf(':');
if (i < 1) continue;
const kind = m.binding.slice(0, i);
const ref = m.binding.slice(i + 1);
if (!ref) continue;
if (m?.removed !== true) {
if (kind === 'entity') liveEntities.add(ref);
continue;
}
if (kind === 'device') devices.add(ref);
else if (kind === 'entity') entities.add(ref);
}
return { devices, entities };
return { devices, entities, liveEntities };
}
/** Whether an HA entity is suppressed by an entity or whole-device tombstone. */
export function isRemovedPlanEntity(
hass: any, eid: string, removed: RemovedPlanBindings,
): boolean {
// A person may restore one exact child without restoring its deleted parent.
// This exception must stay binding-scoped: siblings remain suppressed by the
// device tombstone until they receive their own live markers (#262).
if (removed.liveEntities.has(eid)) return false;
if (removed.entities.has(eid)) return true;
const deviceId = hass?.entities?.[eid]?.device_id;
return !!deviceId && removed.devices.has(deviceId);
@@ -1393,8 +1404,8 @@ export function areaClimateMap(
// A device tombstone suppresses all of its data. An entity tombstone only
// suppresses a standalone entity; inside its live parent device it remains
// available to device state, cards and room aggregates.
if ((reg.device_id && removed.devices.has(reg.device_id))
|| (!reg.device_id && removed.entities.has(eid))) continue;
if ((reg.device_id && removed.devices.has(reg.device_id) && !removed.liveEntities.has(eid))
|| (!reg.device_id && removed.entities.has(eid) && !removed.liveEntities.has(eid))) continue;
const dev = reg.device_id ? hass.devices?.[reg.device_id] : null;
const area = reg.area_id || dev?.area_id || null;
if (!area) continue;
+7 -1
View File
@@ -13518,7 +13518,13 @@ class HouseplanCard extends LitElement {
for (const [eid, reg] of Object.entries<any>(h.entities)) {
const v = 'entity:' + eid;
if (taken.has(v) || seen.has(v) || (reg.hidden && !removedBindings.has(v))) continue;
if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(v)) continue;
// A deleted device remains offered as a whole, but the person may also
// restore just one of its children. The runtime override begins only
// after that exact entity marker is saved; this picker-only exception
// is what makes the intentional transition possible (#262).
const childOfRemovedDevice = !!reg.device_id && removed.devices.has(reg.device_id);
if (isRemovedPlanEntity(h, eid, removed)
&& !removedBindings.has(v) && !childOfRemovedDevice) continue;
const stt = h.states[eid];
const label = reg.name || stt?.attributes?.friendly_name || eid;
const dev = reg.device_id ? h.devices[reg.device_id] : null;
+80
View File
@@ -10,6 +10,7 @@ import {
removeMarkerControlReferences,
rewriteMarkerControlReferences, markerControlWouldCycle, resolveDeviceLightSettings,
incomingLightControls,
removedPlanBindings, isRemovedPlanEntity, isRemovedPlanSource,
} from '../test-build/devices.js';
import { compileIconRules, iconFor } from '../test-build/rules.js';
@@ -51,6 +52,36 @@ test('deletePlanMarkerRecords deduplicates HA binding and leaves old-card-safe t
assert.deepEqual(new Set(result.cleanupIds), new Set(['old', 'new']));
});
test('issue 262 live child overrides a parent tombstone without restoring siblings', () => {
const h = mkHass({
devices: { hub: dev('hub', 'Voice satellite', 'Satellite', 'living') },
entities: {
'sensor.voice_level': { entity_id: 'sensor.voice_level', device_id: 'hub' },
'switch.mic_mute': { entity_id: 'switch.mic_mute', device_id: 'hub' },
},
});
const removed = removedPlanBindings([
{ id: 'hub', binding: 'device:hub', removed: true },
{ id: 'stale-x', binding: 'entity:sensor.voice_level', removed: true },
{ id: 'live-x', binding: 'entity:sensor.voice_level' },
]);
assert.equal(isRemovedPlanEntity(h, 'sensor.voice_level', removed), false);
assert.equal(isRemovedPlanEntity(h, 'switch.mic_mute', removed), true);
assert.equal(isRemovedPlanSource(h, 'entity:sensor.voice_level', [
{ id: 'hub', binding: 'device:hub', removed: true },
{ id: 'live-x', binding: 'entity:sensor.voice_level' },
]), false);
assert.equal(isRemovedPlanSource(h, 'entity:switch.mic_mute', [
{ id: 'hub', binding: 'device:hub', removed: true },
{ id: 'live-x', binding: 'entity:sensor.voice_level' },
]), true);
assert.equal(isRemovedPlanSource(h, 'device:hub', [
{ id: 'hub', binding: 'device:hub', removed: true },
{ id: 'live-x', binding: 'entity:sensor.voice_level' },
]), true);
});
test('buildDevices: devices outside bound areas are dropped', () => {
const h = mkHass({ devices: {
a: dev('a', 'Lamp', 'Bulb', 'living'),
@@ -468,6 +499,36 @@ test('entity tombstone does not strip that entity from a live parent device', ()
assert.equal(parent.primary, 'sensor.box_temp');
});
test('issue 262 parent tombstone builds only the explicitly restored child entity', () => {
const h = mkHass({
devices: { hub: dev('hub', 'Voice satellite', 'Satellite', 'living') },
entities: {
'sensor.voice_level': { entity_id: 'sensor.voice_level', device_id: 'hub', platform: 'demo' },
'switch.mic_mute': { entity_id: 'switch.mic_mute', device_id: 'hub', platform: 'demo' },
},
states: {
'sensor.voice_level': { state: '42', attributes: { friendly_name: 'Voice level' } },
'switch.mic_mute': { state: 'off', attributes: { friendly_name: 'Mic mute' } },
},
});
const child = { id: 'lg_sensor.voice_level', binding: 'entity:sensor.voice_level', hidden: false };
const withParentTombstone = buildDevices(baseCtx(h, { markers: [
{ id: 'hub', binding: 'device:hub', removed: true, hidden: true },
child,
], settings: { filter_seeded: true } }));
assert.deepEqual(withParentTombstone.map((item) => item.bindingKind + ':' + item.bindingRef), [
'entity:sensor.voice_level',
]);
const bothExplicit = buildDevices(baseCtx(h, { markers: [
{ id: 'hub', binding: 'device:hub', hidden: false },
child,
], settings: { filter_seeded: true } }));
assert.deepEqual(new Set(bothExplicit.map((item) => item.bindingKind + ':' + item.bindingRef)), new Set([
'device:hub', 'entity:sensor.voice_level',
]));
});
test('buildDevices: virtual marker lands in its room; entity marker resolves name from state', () => {
const h = mkHass({
entities: { 'sensor.avg': { entity_id: 'sensor.avg', platform: 'min_max', area_id: 'kitchen' } },
@@ -1302,6 +1363,25 @@ test('areaClimateMap: whole device tombstone wins; entity tombstone stays bindin
assert.deepEqual(areaClimateMap(hass, undefined, markers).get('living'), { temp: 30, hum: null });
});
test('issue 262 room climate restores only the exact live child of a tombstoned device', () => {
const hass = {
devices: { hub: { id: 'hub', name: 'Climate hub', area_id: 'living' } },
entities: {
'sensor.room_temperature': { device_id: 'hub', platform: 'demo' },
'sensor.sibling_temperature': { device_id: 'hub', platform: 'demo' },
},
states: {
'sensor.room_temperature': { state: '20', attributes: { device_class: 'temperature' } },
'sensor.sibling_temperature': { state: '40', attributes: { device_class: 'temperature' } },
},
};
const markers = [
{ id: 'hub', binding: 'device:hub', removed: true },
{ id: 'room-temp', binding: 'entity:sensor.room_temperature' },
];
assert.deepEqual(areaClimateMap(hass, undefined, markers).get('living'), { temp: 20, hum: null });
});
test('resolvedLightSources: one source set feeds room fill, card, glow and controls', () => {
const hass = { states: {
'light.auto': { state: 'on' },