fix: clean orphaned Optimize positions

Issue: #252
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-23 10:21:56 +03:00
parent a322db4860
commit da719f5f06
24 changed files with 999 additions and 223 deletions
File diff suppressed because one or more lines are too long
+7 -1
View File
@@ -727,10 +727,16 @@ export async function prepareGoldenScenario(page, scenario) {
const dialog = card.renderRoot.querySelector('hp-dialog');
const report = card._alignDialog?.report;
const body = dialog?.querySelector('.body');
if (!body || report?.markersDetached !== 1 || report?.positionsUnresolved !== 1
if (!body || report?.markersDetached !== 1
|| report?.orphanRoomLabelsRemoved !== 0
|| report?.orphanDevicePositionsRemoved !== 1
|| report?.liveMissingPositions.length !== 2
|| !body.textContent.includes(card._t('gs.optimize_references', {
spaces: '0', rooms: '0', positions: '0', detached: '1',
}))
|| !body.textContent.includes(card._t('gs.optimize_orphans_removed', {
total: '1', rooms: '0', devices: '1', groups: '0',
}))
|| !body.textContent.includes('unresolved-floor')) {
throw new Error('golden orphan-reference Optimize dialog is incomplete');
}
+24 -1
View File
@@ -583,13 +583,36 @@ export const GOLDEN_SCENARIOS = Object.freeze([
dialog: 'optimize-orphan-references', language: 'en', theme: 'dark',
markerOverrides: [{
id: 'golden-light-two', binding: 'virtual', space: 'removed-floor',
room_id: 'removed-room', icon: 'mdi:lightbulb',
room_id: 'removed-room', icon: 'mdi:lightbulb', name: 'Living-room lamp',
}, {
id: 'golden-light-one', binding: 'virtual', name: 'Hall lamp',
}, {
id: 'golden-presence', binding: 'virtual', removed: true,
space: 'removed-floor', name: 'Retired marker',
}],
layoutOverrides: {
'golden-light-two': { s: 'removed-floor', x: 0.35, y: 0.72 },
'golden-light-one': { s: 'unresolved-floor', x: 0.20, y: 0.34 },
'golden-presence': { s: 'removed-floor', x: 0.45, y: 0.64 },
},
viewport: { width: 900, height: 760 }, ...page },
{ id: 'optimize-orphan-references-light-ru', fixture: 'visual', space: 'golden-geometry',
dialog: 'optimize-orphan-references', language: 'ru', theme: 'light',
markerOverrides: [{
id: 'golden-light-two', binding: 'virtual', space: 'removed-floor',
room_id: 'removed-room', icon: 'mdi:lightbulb', name: 'Лампа в гостиной',
}, {
id: 'golden-light-one', binding: 'virtual', name: 'Лампа в холле',
}, {
id: 'golden-presence', binding: 'virtual', removed: true,
space: 'removed-floor', name: 'Старый маркер',
}],
layoutOverrides: {
'golden-light-two': { s: 'removed-floor', x: 0.35, y: 0.72 },
'golden-light-one': { s: 'unresolved-floor', x: 0.20, y: 0.34 },
'golden-presence': { s: 'removed-floor', x: 0.45, y: 0.64 },
},
viewport: { width: 900, height: 880 }, ...page },
{ id: 'card-editor-invalid-default-floor-light-en', fixture: 'visual',
cardEditorInvalidDefaultFloor: 'removed-floor', language: 'en', theme: 'light',
viewport: { width: 900, height: 760 }, ...page },
+45 -13
View File
@@ -1,5 +1,5 @@
// #244: production-bundle coverage for Optimize reference preview/apply/undo,
// remaining-only warning, space-delete blocker and invalid default_floor UI.
// #244/#252: production-bundle coverage for safe owner-aware Optimize
// preview/opt-in/apply/undo, plus the existing space-delete/default-floor UI.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 920, height: 900 });
@@ -27,12 +27,17 @@ const out = await page.evaluate(async () => {
// dependency of the later attempt to delete `home`.
id: 'home-blocker', binding: 'virtual', space: 'home', name: 'Home blocker',
},
{ id: 'removed-marker', binding: 'virtual', removed: true, space: 'gone', name: 'Old marker' },
],
settings: {},
settings: { known_devices: ['removed-auto-device'] },
};
const originalLayout = {
orphan: { s: 'gone', x: 0.25, y: 0.5, k: 1.1 },
opaque_owner: { s: 'gone', x: 0.6, y: 0.7 },
rl_removed_room: { s: 'gone', x: 0.2, y: 0.3 },
'removed-marker': { s: 'gone', x: 0.3, y: 0.4 },
'removed-auto-device': { s: 'gone', x: 0.4, y: 0.5 },
'lg_light.removed_group': { s: 'gone', x: 0.5, y: 0.6 },
};
let serverConfig = clone(original);
let serverLayout = clone(originalLayout);
@@ -84,16 +89,34 @@ const out = await page.evaluate(async () => {
card._openAlignDialog();
await card.updateComplete;
const preview = card._alignDialog;
const previewText = card.renderRoot.querySelector('hp-dialog .body')?.textContent || '';
result.previewSeparatesRepairAndDebt = preview?.report.markersDetached === 1
&& preview.report.positionsUnresolved === 1
&& preview.report.deadSpaceIds.join(',') === 'gone'
&& previewText.includes('devices detached from missing spaces — 1')
&& previewText.includes('unresolved positions — 1');
const previewBody = card.renderRoot.querySelector('hp-dialog .body');
const previewMainText = [...(previewBody?.children || [])]
.filter((element) => element.tagName !== 'DETAILS')
.map((element) => element.textContent || '').join(' ');
result.previewSeparatesRepairCleanupAndDebt = preview?.report.markersDetached === 1
&& preview.report.orphanRoomLabelsRemoved === 1
&& preview.report.orphanDevicePositionsRemoved === 2
&& preview.report.orphanGroupPositionsRemoved === 1
&& preview.report.liveMissingPositions.length === 1
&& preview.report.unverifiedPositions.length === 1
&& preview.report.positionsUnresolved === 2
&& previewMainText.includes('devices detached from missing spaces — 1')
&& previewMainText.includes('Forgotten records removed: 4')
&& previewMainText.includes('Kept marker')
&& previewMainText.includes('Could not safely verify positions: 1')
&& !previewMainText.includes('opaque_owner')
&& !previewMainText.includes('gone');
result.previewPreservesMarkerSettings = preview?.config.markers[0].description === 'must survive'
&& preview.config.markers[0].space === undefined
&& preview.layout.orphan === undefined
&& preview.layout.opaque_owner.s === 'gone';
&& preview.layout.orphan.s === 'gone'
&& preview.layout.opaque_owner.s === 'gone'
&& preview.layout.rl_removed_room === undefined
&& preview.layout['removed-marker'] === undefined
&& preview.layout['removed-auto-device'] === undefined
&& preview.layout['lg_light.removed_group'] === undefined;
const details = previewBody?.querySelector('details.optimize-details');
result.idsExistOnlyInClosedDetails = details && details.open === false
&& details.textContent.includes('opaque_owner') && details.textContent.includes('gone');
result.previewOffersOneApplyWithoutWriting = !!card.renderRoot.querySelector('hp-dialog .btn.on')
&& calls.length === 0;
@@ -103,6 +126,13 @@ const out = await page.evaluate(async () => {
card._openAlignDialog();
await card.updateComplete;
card._toggleOptimizeLivePositions();
await card.updateComplete;
result.explicitCleanupRebuildsPreviewWithoutWriting = calls.length === 0
&& card._alignDialog?.removeLiveMissingPositions === true
&& card._alignDialog.report.liveMissingPositionsRemoved === 1
&& card._alignDialog.layout.orphan === undefined
&& card.renderRoot.querySelector('.optimize-cleanup')?.getAttribute('aria-pressed') === 'true';
await card._runAlignToGrid();
await card.updateComplete;
result.applyUsesExactAtomicEndpoint = calls.filter((type) => type === 'houseplan/plan/optimize').length === 1;
@@ -115,8 +145,8 @@ const out = await page.evaluate(async () => {
await card.updateComplete;
const noOpText = card.renderRoot.querySelector('hp-dialog .body')?.textContent || '';
result.remainingOnlyWarningHasNoApply = card._alignDialog?.changed === false
&& noOpText.includes('All plans already use the current optimized data model.')
&& noOpText.includes('unresolved positions — 1')
&& noOpText.includes('There are no automatic changes to apply.')
&& noOpText.includes('Could not safely verify positions: 1')
&& !card.renderRoot.querySelector('hp-dialog .btn.on');
card._alignDialog = null;
await card.updateComplete;
@@ -125,6 +155,8 @@ const out = await page.evaluate(async () => {
await card.updateComplete;
result.undoRestoresDeadRefs = card._serverCfg.markers[0].space === 'gone'
&& card._layout.orphan.s === 'gone'
&& card._layout.rl_removed_room.s === 'gone'
&& card._layout['removed-marker'].s === 'gone'
&& !card.renderRoot.querySelector('.dev[data-id="orphan"]');
let nativeConfirmCalls = 0;
+113 -61
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -690,6 +690,17 @@ check. A red result means zero WS calls. Python deliberately does not duplicate
and crash-resumable atomicity boundary, not a consumer-supplied preflight
attestation.
`src/space-reference-repair.ts` keeps orphan-layout classification pure. The
card builds a runtime-only owner roster from the complete HA device/entity
registries, current states and config names, and marks absence authoritative
only after the registry load succeeds. The repair pass may then distinguish a
proven-absent room label/device/group position from a live owner in a deleted
space and from an unverified future or registry-limited owner. The first enters
the default candidate, the second only an explicit secondary opt-in, and the
third never a destructive candidate. No registry data or classification status
is persisted; Apply still sends only the exact ordinary config/layout pair that
was previewed.
Manual attachments upload over HTTP (streaming, transactional staging), not WS —
the old `houseplan/file/set` was removed in v1.10.0.
+9 -4
View File
@@ -491,10 +491,15 @@ touching virtual spans per room pair, compacts consecutive real-wall
intervals of equal thickness and stamps `model_version`. Unknown fields
are preserved and every pass is idempotent.
The optimizer deliberately does **not** alter backdrop calibration or
saved view boxes, delete unattached layout entries (a device may only be
temporarily unavailable), deduplicate markers, or delete files. File
collection remains the backend's reference-aware scheduled job.
The optimizer deliberately does **not** alter backdrop calibration or saved
view boxes, deduplicate markers, or delete files. It may delete an unattached
layout entry only after classifying its owner against current rooms, marker
tombstones and an authoritative HA device/entity roster. Proven-absent room
labels, devices and group markers are cleaned; live owners are preserved unless
the administrator explicitly opts into removing their old positions, and an
incomplete registry or unknown namespace always fails closed. The cleanup is
part of the pure candidate, Undo and idempotence contract. File collection
remains the backend's reference-aware scheduled job.
`alignAllToGrid(spaces, layout)` (`src/align-grid.ts`) is pure: it
copies its input, never mutates it, and returns the new spaces, the new
+10
View File
@@ -2,6 +2,16 @@
## Unreleased
- “Optimize plans” now removes layout positions only when their room label,
device or light-group owner is proven to no longer exist. The preview reports
those cleanups in plain-language categories, keeps technical IDs inside
collapsed Details, and preserves live or unverifiable owners. A separate
opt-in can remove old positions of live objects before the main Apply; Cancel
writes nothing and maintenance Undo restores the complete previous layout.
This narrows the v1.59 promise to preserve every unattached entry: temporarily
unavailable and unverified objects remain protected, while proven orphans no
longer accumulate indefinitely
([#252](https://github.com/Matysh/houseplan-card/issues/252)).
- Wall junctions where three or more physical walls meet now use a bounded
straight bevel instead of producing long hatched spikes, including mixed wall
thicknesses. The node stays filled and matches across Plan, View, kiosk,
+11
View File
@@ -8,6 +8,17 @@
## Не выпущено
- «Оптимизировать планы» теперь удаляет сохранённую позицию, только когда
доказано, что её подписи комнаты, устройства или групповой метки больше не
существует. Предпросмотр описывает очистку понятными категориями, прячет
технические id в свёрнутые «Подробности» и сохраняет живых либо непроверенных
владельцев. Отдельная явная опция позволяет включить старые позиции живых
объектов в основной Apply; Cancel ничего не записывает, а служебная отмена
восстанавливает прежнюю раскладку целиком. Это уточняет обещание v1.59
сохранять все непривязанные записи: временно недоступные и непроверенные
объекты по-прежнему защищены, а доказанно забытые позиции больше не
накапливаются
([#252](https://github.com/Matysh/houseplan-card/issues/252)).
- В узлах, где сходятся три и более физические стены, длинные выступающие
клинья штриховки теперь заменяются ограниченной прямой фаской, в том числе
при разной толщине стен. Сам узел остаётся заполненным и одинаковым в Plan,
+14 -4
View File
@@ -193,10 +193,20 @@ Missing `marker.space`, `marker.room_id`, `vacuum.segment_map` and layout
ownership remain readable by the permissive persisted schemas. They are never
rewritten during load or an unrelated Save. Explicit Optimize may map an
untruncated exact `space_<old>_<8 hex>` / `room_<old>_<8 hex>` import signature,
or use the production HA Area placement for an active real marker. Without
proof it removes only the active marker's missing placement and stale position;
unknown layout and nested vacuum mappings remain stored and reported. The pass
is data-driven, idempotent and runs even when `model_version` is already 7.
or use the production HA Area placement for an active real marker. Without a
valid target it removes the marker's missing placement but preserves its old
position for the owner-aware cleanup decision.
The same explicit Optimize candidate automatically removes layout entries only
for owners proven absent: missing room labels, removed marker tombstones, and
known devices or `lg_` entities absent from an authoritative HA registry/state
roster. A live owner in a deleted space is preserved unless the administrator
explicitly opts into removing its old position. A limited or unavailable
registry and an unknown/future layout namespace always preserve the entry;
nested vacuum mappings likewise remain stored and reported. This is a runtime
read-model decision, not a persisted migration: schemas, store/model versions
and the layout shape are unchanged. The pass is data-driven, undoable,
idempotent and runs even when `model_version` is already 7.
A one-space import uses its known id map (not a heuristic) to repair matching
orphan target references when the original space id is absent. Full restore is
+18 -3
View File
@@ -2248,9 +2248,10 @@ require hands on real hardware — they remain for the human pass.
`optimize-preflight-renders-apply-on-failure`].
- [ ] **Missing space references recover without losing a marker (#244)**:
exact import signatures remap space, room, marker/room-label positions
and vacuum segments; Area remap and detach delete stale coordinates but
preserve the marker record; ambiguous/truncated signatures and opaque
layout are never guessed. Preview/Apply/Undo use one exact candidate and
and vacuum segments; Area remap and detach preserve the marker record and
leave an old position for the owner-aware #252 decision rather than
guessing. Ambiguous/truncated signatures and opaque layout are never
guessed. Preview/Apply/Undo use one exact candidate and
show remaining debt even for a no-op. Space import repairs target refs by
its known map. With another space present, space delete deduplicates active
marker blockers; deleting the sole remaining space instead preserves every
@@ -2264,6 +2265,20 @@ require hands on real hardware — they remain for the human pass.
card-editor-validation; backend: test_ha_import_export,
test_ha_websocket; smoke: orphan-space-references + optional-space-model;
pre-release: targeted browser smoke and light/dark golden].
- [ ] **Optimize explains and safely cleans forgotten positions (#252)**:
the owner matrix covers room labels, marker tombstones, known HA devices,
`lg_` entities and unknown namespaces across authoritative and limited
registries. Only proven-absent owners enter the default candidate; live
owners are named and preserved until the secondary opt-in, and unverified
owners never receive a destructive action. The main RU/EN report contains
bounded human categories rather than IDs; closed Details contains at most
ten technical entries plus the remainder, and vacuum mappings remain a
separate warning. Cancel and the secondary toggle write nothing; Apply
writes the exact preview once, reload is a no-op, and Undo restores all
removed positions [unit: space-reference-repair + plan-optimizer + i18n;
auto: smoke_orphan_space_references; golden: dark EN + light RU;
mutations: `orphan-cleanup-proven-owners-kept`,
`orphan-cleanup-partial-registry-deletes`].
- [ ] **Every write prevents new ULP coordinate noise (#224)**: config/layout
schema, import, direct storage writers, startup recovery and maintenance
Undo produce the same nine-decimal allow-listed geometry as the frontend.
+17 -7
View File
@@ -621,14 +621,24 @@ Optimization compacts old off-grid geometry and repairs the plan's reference
graph while preserving rooms, bindings and supported settings. An exact
independent-import signature restores the copied space, room and positions. If
there is no exact copy, an active real device follows its unambiguous HA Area;
otherwise only its missing placement and stale coordinates are detached, so
the marker becomes available on a valid plan without losing its settings.
Unknown layout and vacuum mappings are preserved and reported for manual
attention. It does not delete plan images or attachments merely because
nothing currently references them.
otherwise only its missing placement is detached, so the marker becomes
available on a valid plan without losing its settings.
Optimization creates one server-side undo point. Any later edit makes that undo
stale, so create a Home Assistant backup before a large maintenance operation.
Old positions are classified before Apply. A position whose room label, device
or light-group owner is proven absent is removed automatically and counted by a
plain-language category. A live owner in a deleted space is named and preserved
by default; **Remove old positions** explicitly adds only those entries to the
same Apply candidate. An owner that cannot be checked against a complete HA
registry is preserved without a destructive action. Raw IDs appear only inside
collapsed **Details**, and vacuum room mappings remain a separate warning for
manual review. Preview, the secondary option and Cancel do not write anything.
Plan images and attachments are never deleted merely because nothing currently
references them.
Optimization creates one server-side undo point which restores automatically
and explicitly removed positions with the rest of the previous layout. Any
later edit makes that undo stale, so create a Home Assistant backup before a
large maintenance operation.
<!-- docs-section: multiple-cards -->
+11 -6
View File
@@ -1385,7 +1385,8 @@ show_signal: true
| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, только если с обеих сторон находятся участки одной толщины и на его концах нет вершины комнаты или границы проёма |
| Перегородки | Коллинеарные соседние отрезки одинаковой толщины сращиваются в одну запись; узел сохраняется, если в нём сходится третья стена, стена комнаты, колонна или конец черновика. Проёмы остаются на своих местах |
| Виртуальные стены | Соседние/перекрывающиеся участки объединяются и приводятся к общей границе |
| Ссылки устройств | Точная подпись независимого импорта восстанавливает пространство, комнату и позицию. Иначе реальное устройство следует однозначной Area HA либо теряет только мёртвую привязку и старые координаты; настройки маркера сохраняются |
| Ссылки устройств | Точная подпись независимого импорта восстанавливает пространство, комнату и позицию. Иначе реальное устройство следует однозначной Area HA либо теряет только мёртвую привязку; настройки маркера сохраняются |
| Забытые позиции | Позиции доказанно отсутствующих подписей комнат, устройств и групповых меток удаляются и считаются по понятным категориям |
Перед записью диалог показывает количество затрагиваемых элементов,
максимальный сдвиг в сантиметрах и пространство с этим сдвигом. Отдельная
@@ -1394,9 +1395,13 @@ show_signal: true
честный предпросмотр «сдвинуто элементов — 0» с ненулевым числом очищенных
координат. Повторный Optimize над результатом ничего не предлагает — ни сразу,
ни после серверного события обновления или перезагрузки страницы.
Неразрешённые позиции и вложенные сопоставления робота не удаляются: диалог
показывает их отдельным предупреждением даже тогда, когда применять больше
нечего.
Позиция живого объекта в удалённом пространстве по умолчанию сохраняется и
показывается с его именем. Вторичная опция «Убрать старые позиции» добавляет
только такие записи в тот же кандидат Apply; сама опция, предпросмотр и Cancel
ничего не записывают. Если полного реестра HA недостаточно для безопасной
проверки владельца, позиция сохраняется без кнопки удаления. Технические id
доступны только в свёрнутых «Подробностях». Сопоставления комнат пылесоса также
сохраняются и показываются отдельным предупреждением для ручной проверки.
Перед подтверждением House Plan дополнительно строит точную геометрию кандидата
для **всех** пространств — тем же способом, которым затем рисуются стены,
@@ -1412,12 +1417,12 @@ show_signal: true
- неизвестные поля новых версий;
- положение и масштаб изображения подложки;
- сохранённые виды/границы холста;
- неиспользуемые записи раскладки;
- позиции живых объектов по умолчанию и все непроверенные записи раскладки;
- файлы подложек и вложения.
### Риск и отмена
Старые и импортированные объекты между узлами будут привязаны к сетке. Новые редакторы такие координаты не создают. После оптимизации доступна одна серверная отмена, но только до следующего изменения плана. Отмена возвращает прежнюю геометрию и неизвестные поля в чистом числовом представлении, не восстанавливая невидимый floating-point шум. Новый edit делает резервную копию оптимизации устаревшей.
Старые и импортированные объекты между узлами будут привязаны к сетке. Новые редакторы такие координаты не создают. После оптимизации доступна одна серверная отмена, но только до следующего изменения плана. Отмена возвращает прежнюю геометрию, автоматически и явно удалённые позиции и неизвестные поля в чистом числовом представлении, не восстанавливая невидимый floating-point шум. Новый edit делает резервную копию оптимизации устаревшей.
Обычное открытие и редактирование плана не удаляет даже очень короткие точные
границы толщины. Описанное схлопывание выполняется только после явного
+20 -8
View File
@@ -43,7 +43,7 @@ export const MUTANTS = [
{
id: 'orphan-space-detach-disabled',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="detaches only dead active placement" '
+ '&& node --test --test-name-pattern="detaches a live marker" '
+ 'test/space-reference-repair.test.mjs',
because: 'an active marker with no exact copy or usable Area must lose only its dead space '
+ 'field; retaining that one field is the original invisibility bug and must be caught '
@@ -70,17 +70,29 @@ export const MUTANTS = [
}],
},
{
id: 'orphan-space-area-keeps-stale-position',
id: 'orphan-cleanup-partial-registry-deletes',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="Area remap uses a unique production Area" '
+ '&& node --test --test-name-pattern="fails closed for limited registry" '
+ 'test/space-reference-repair.test.mjs',
because: 'an Area proves the destination room but not a coordinate transform between plans; '
+ 'keeping the deleted-plan x/y silently teleports the restored device (#244)',
because: 'limited registry access is not proof that an HA device or group was deleted; '
+ 'treating it as authoritative destroys positions on permission/network failures (#252)',
patches: [{
file: 'src/space-reference-repair.ts',
find: ' delete layout[markerId];\n handledLayout.add(markerId);',
replace: ' layout[markerId] = { ...position, s: targetSpace || positionSpace };\n'
+ ' handledLayout.add(markerId);',
find: ' const rosterAuthoritative = roster?.authoritative === true;',
replace: ' const rosterAuthoritative = true;',
}],
},
{
id: 'orphan-cleanup-proven-owners-kept',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="removes only proven room, device and group" '
+ 'test/space-reference-repair.test.mjs',
because: 'proof without deletion leaves the exact maintenance debt #252 exists to remove; '
+ 'all three supported owner categories must change the candidate once and only once',
patches: [{
file: 'src/space-reference-repair.ts',
find: " if (status === 'absent') {\n delete layout[key];\n countRemoval(owner.kind);",
replace: " if (status === 'absent') {\n countRemoval(owner.kind);",
}],
},
{
+188 -24
View File
@@ -187,6 +187,7 @@ import {
clampCanvasR, clampCanvasN, type ContentItem, type Rect,
} from './space-geometry';
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
import type { SpaceReferenceRepairContext } from './space-reference-repair';
import { collectSpaceMarkerDependencies } from './space-deletion';
import {
checkOptimizeGeometry,
@@ -1773,6 +1774,8 @@ class HouseplanCard extends LitElement {
where: string;
changed: boolean;
busy: boolean;
/** False by default; true only after the secondary preview action. */
removeLiveMissingPositions: boolean;
} | null = null;
private _settingsDialog: {
@@ -15003,15 +15006,74 @@ class HouseplanCard extends LitElement {
});
}
private _openAlignDialog = (): void => {
private _optimizeReferenceContext(
removeLiveMissingPositions: boolean,
): SpaceReferenceRepairContext {
const registry = this._haRegistry;
const full = this._fullRegistryHass;
const names: Record<string, string> = {};
const humanName = (...values: unknown[]): string => {
for (const value of values) {
const name = String(value || '').trim();
if (name) return name;
}
return '';
};
for (const [deviceId, device] of Object.entries<any>(registry.devices || {})) {
names[deviceId] = humanName(device?.name_by_user, device?.name, device?.model);
}
for (const [entityId, entity] of Object.entries<any>(registry.entities || {})) {
names[`lg_${entityId}`] = humanName(
this.hass?.states?.[entityId]?.attributes?.friendly_name,
entity?.name,
entity?.original_name,
);
}
for (const device of this._devices) names[device.id] = humanName(device.name, names[device.id]);
for (const marker of this._serverCfg?.markers || []) {
const separator = String(marker.binding || '').indexOf(':');
const kind = separator > 0 ? marker.binding.slice(0, separator) : '';
const ref = separator > 0 ? marker.binding.slice(separator + 1) : '';
const bound = kind === 'device'
? full?.devices?.[ref]
: kind === 'entity' ? full?.entities?.[ref] : null;
names[marker.id] = humanName(
marker.name,
kind === 'device' ? bound?.name_by_user : null,
bound?.name,
kind === 'entity' ? this.hass?.states?.[ref]?.attributes?.friendly_name : null,
names[marker.id],
);
}
return {
effectiveAreaByMarker: Object.fromEntries(
this._devices
.filter((device) => !device.virtual && !!device.area)
.map((device) => [device.id, device.area]),
),
ownerRoster: {
authoritative: registry.authoritative,
deviceIds: Object.keys(registry.devices || {}),
// State-only YAML entities are positive existence evidence even though
// an authoritative Entity Registry cannot list them.
entityIds: [...new Set([
...Object.keys(registry.entities || {}),
...Object.keys(this.hass?.states || {}),
])],
names,
},
removeLiveMissingPositions,
};
}
private _previewAlignDialog(removeLiveMissingPositions: boolean): void {
if (!this._norm || !this._serverCfg) return;
const spaces = this._serverCfg.spaces || [];
const effectiveAreaByMarker = Object.fromEntries(
this._devices
.filter((device) => !device.virtual && !!device.area)
.map((device) => [device.id, device.area]),
const r = optimizePlans(
this._serverCfg,
this._layout || {},
this._optimizeReferenceContext(removeLiveMissingPositions),
);
const r = optimizePlans(this._serverCfg, this._layout || {}, { effectiveAreaByMarker });
const preflight = r.changed ? this._checkOptimizeGeometry(r.config) : null;
// The maximum geometry shift is an UPPER BOUND, not a sample. The run
// measured every element in the centimetres of ITS OWN space — converting
@@ -15023,8 +15085,16 @@ class HouseplanCard extends LitElement {
const where = spaces.length > 1 && sp ? String(sp.title || sp.id) : '';
this._alignDialog = {
report: r.report, config: r.config, layout: r.layout, cm, where,
preflight, changed: r.changed, busy: false,
preflight, changed: r.changed, busy: false, removeLiveMissingPositions,
};
}
private _openAlignDialog = (): void => this._previewAlignDialog(false);
private _toggleOptimizeLivePositions = (): void => {
const dialog = this._alignDialog;
if (!dialog || dialog.busy || !dialog.report.liveMissingPositions.length) return;
this._previewAlignDialog(!dialog.removeLiveMissingPositions);
};
/**
@@ -15076,7 +15146,9 @@ class HouseplanCard extends LitElement {
+ d.report.coordsCanonicalized + d.report.wallsMerged + d.report.spansMerged
+ d.report.partitionsMerged),
r: String(d.report.spaceRefsRemapped + d.report.roomRefsRemapped
+ d.report.positionsRemapped + d.report.markersDetached),
+ d.report.positionsRemapped + d.report.markersDetached
+ d.report.orphanRoomLabelsRemoved + d.report.orphanDevicePositionsRemoved
+ d.report.orphanGroupPositionsRemoved),
}));
} catch (e: any) {
if (this._alignDialog) this._alignDialog = { ...this._alignDialog, busy: false };
@@ -16077,9 +16149,48 @@ class HouseplanCard extends LitElement {
: '';
const repaired = r.spaceRefsRemapped + r.roomRefsRemapped
+ r.positionsRemapped + r.markersDetached;
const referenceWarnings = r.positionsUnresolved + r.nestedRefsUnresolved;
const visibleDeadIds = r.deadSpaceIds.slice(0, 10).join(', ');
const remainingDeadIds = Math.max(0, r.deadSpaceIds.length - 10);
const modelMaintenance = r.migrated + r.canonicalized + r.coordsCanonicalized
+ r.wallsMerged + r.spansMerged + r.partitionsMerged;
const gridWarning = r.moved + r.rotated + r.removedDrafts + r.coordsCanonicalized;
const removed = r.orphanRoomLabelsRemoved + r.orphanDevicePositionsRemoved
+ r.orphanGroupPositionsRemoved;
const liveNames = r.liveMissingPositions.map((item) => item.name).filter(Boolean);
const visibleLiveNames = liveNames.slice(0, 3).join(', ');
const remainingLiveNames = Math.max(0, liveNames.length - 3);
const liveNamesText = visibleLiveNames
? this._t('gs.optimize_live_names', {
names: visibleLiveNames,
more: remainingLiveNames
? this._t('gs.optimize_reference_more', { n: String(remainingLiveNames) }) : '',
})
: '';
const registryLimited = r.unverifiedPositions.some(
(item) => item.reason === 'registry_unavailable',
);
const detailStatus = (item: typeof r.removedPositions[number]): string => {
if (r.removedPositions.some((removedItem) => removedItem.id === item.id)) {
return this._t('gs.optimize_detail_removed');
}
if (r.liveMissingPositions.some((liveItem) => liveItem.id === item.id)) {
return this._t('gs.optimize_detail_live');
}
return this._t('gs.optimize_detail_unverified');
};
const detailKind = (kind: typeof r.removedPositions[number]['kind']): string => this._t(
kind === 'room_label' ? 'gs.optimize_detail_room_label'
: kind === 'group' ? 'gs.optimize_detail_group'
: kind === 'device' ? 'gs.optimize_detail_device'
: 'gs.optimize_detail_unknown',
);
const referenceDetails = [
...r.removedPositions,
...r.liveMissingPositions.filter((item) => (
!r.removedPositions.some((removedItem) => removedItem.id === item.id)
)),
...r.unverifiedPositions,
];
const visibleDetails = referenceDetails.slice(0, 10);
const remainingDetails = Math.max(0, referenceDetails.length - visibleDetails.length);
return html`<hp-dialog .hass=${this.hass} .title=${this._t('gs.align_title')} icon="mdi:broom"
dismiss-on-scrim @hp-close=${() => (this._alignDialog = null)}>
<div class="body">
@@ -16088,7 +16199,11 @@ class HouseplanCard extends LitElement {
<p class="alignmsg">${this._t('gs.align_preflight_failed', { spaces, more })}</p>
<div class="rhint">${this._t('gs.align_preflight_hint')}</div>`
: !d.changed
? html`<p class="alignmsg">${this._t('gs.align_none')}</p>`
? html`<p class="alignmsg">${this._t(
r.liveMissingPositions.length || r.unverifiedPositions.length
|| r.nestedRefsUnresolved
? 'gs.optimize_no_automatic_changes' : 'gs.align_none',
)}</p>`
: html`
${r.moved ? html`<p class="alignmsg">${this._t('gs.align_count', {
n: String(r.moved), total: String(r.total), cm: String(d.cm),
@@ -16104,32 +16219,81 @@ class HouseplanCard extends LitElement {
n: String(r.removedDrafts),
})}</p>`
: nothing}
<p class="alignmsg">${this._t('gs.optimize_changes', {
m: String(r.migrated), c: String(r.canonicalized),
p: String(r.coordsCanonicalized), w: String(r.wallsMerged),
s: String(r.spansMerged), i: String(r.partitionsMerged),
})}</p>
${modelMaintenance ? html`<p class="alignmsg">${this._t('gs.optimize_changes', {
m: String(r.migrated), c: String(r.canonicalized),
p: String(r.coordsCanonicalized), w: String(r.wallsMerged),
s: String(r.spansMerged), i: String(r.partitionsMerged),
})}</p>` : nothing}
${r.glowSpacesMigrated || r.glowRoomsMigrated
? html`<p class="alignmsg">${this._t('gs.optimize_glow_migration', {
spaces: String(r.glowSpacesMigrated),
rooms: String(r.glowRoomsMigrated),
})}</p>`
: nothing}
<div class="rhint">${this._t('gs.align_warn')}</div>`}
${gridWarning ? html`<div class="rhint">${this._t('gs.align_warn')}</div>` : nothing}`}
${repaired
? html`<p class="alignmsg">${this._t('gs.optimize_references', {
spaces: String(r.spaceRefsRemapped), rooms: String(r.roomRefsRemapped),
positions: String(r.positionsRemapped), detached: String(r.markersDetached),
})}</p>`
: nothing}
${referenceWarnings || r.deadSpaceIds.length
? html`<div class="rhint" role="alert">${this._t('gs.optimize_reference_warning', {
positions: String(r.positionsUnresolved), nested: String(r.nestedRefsUnresolved),
ids: visibleDeadIds || '—',
more: remainingDeadIds
? this._t('gs.optimize_reference_more', { n: String(remainingDeadIds) }) : '',
${removed
? html`<p class="alignmsg">${this._t('gs.optimize_orphans_removed', {
total: String(removed),
rooms: String(r.orphanRoomLabelsRemoved),
devices: String(r.orphanDevicePositionsRemoved),
groups: String(r.orphanGroupPositionsRemoved),
})}</p>`
: nothing}
${r.liveMissingPositions.length
? html`<div class="optimize-live">
<p class="alignmsg">${this._t(d.removeLiveMissingPositions
? 'gs.optimize_live_positions_remove' : 'gs.optimize_live_positions', {
n: String(r.liveMissingPositions.length), names: liveNamesText,
})}</p>
<button class="btn ghost optimize-cleanup" type="button"
aria-pressed=${d.removeLiveMissingPositions ? 'true' : 'false'}
@click=${this._toggleOptimizeLivePositions} ?disabled=${d.busy}>
<ha-icon icon=${d.removeLiveMissingPositions ? 'mdi:undo' : 'mdi:map-marker-remove-outline'}></ha-icon>
${this._t(d.removeLiveMissingPositions
? 'gs.optimize_live_keep' : 'gs.optimize_live_remove')}
</button>
${d.removeLiveMissingPositions
? html`<div class="rhint optimize-selected" role="status">
${this._t('gs.optimize_live_selected')}
</div>`
: nothing}
</div>`
: nothing}
${r.unverifiedPositions.length
? html`<div class="rhint" role="alert">
${this._t('gs.optimize_unverified', {
n: String(r.unverifiedPositions.length),
})}
${registryLimited ? ` ${this._t('gs.optimize_registry_limited')}` : ''}
</div>`
: nothing}
${r.nestedRefsUnresolved
? html`<div class="rhint" role="alert">${this._t('gs.optimize_vacuum_warning', {
n: String(r.nestedRefsUnresolved),
})}</div>`
: nothing}
${referenceDetails.length
? html`<details class="optimize-details">
<summary>${this._t('gs.optimize_details')}</summary>
<ul>
${visibleDetails.map((item) => html`<li>${this._t('gs.optimize_detail_item', {
status: detailStatus(item), kind: detailKind(item.kind),
id: item.id, space: item.spaceId,
})}</li>`)}
</ul>
${remainingDetails
? html`<div class="rhint">${this._t('gs.optimize_details_more', {
n: String(remainingDetails),
})}</div>`
: nothing}
</details>`
: nothing}
</div>
<div class="row" slot="footer">
<span class="spacer"></span>
+21
View File
@@ -794,6 +794,7 @@
"gs.align_all": "Optimize plans",
"gs.align_title": "Optimize plans",
"gs.align_none": "All plans already use the current optimized data model.",
"gs.optimize_no_automatic_changes": "There are no automatic changes to apply. Review the items below.",
"gs.align_count": "{n} of {total} elements will move, by at most {cm} cm.",
"gs.align_where": "The largest shift is in “{s}”.",
"gs.align_turned": "Openings whose angle is corrected: {n}.",
@@ -807,6 +808,26 @@
"gs.optimize_references": "References repaired: spaces — {spaces}; rooms — {rooms}; positions — {positions}; devices detached from missing spaces — {detached}.",
"gs.optimize_reference_warning": "Some references still need attention: unresolved positions — {positions}; nested mappings — {nested}. Missing space IDs: {ids}{more}.",
"gs.optimize_reference_more": ", and {n} more",
"gs.optimize_orphans_removed": "Forgotten records removed: {total} — room labels: {rooms}; devices: {devices}; group markers: {groups}. They belonged to spaces deleted earlier.",
"gs.optimize_live_positions": "Old positions in deleted spaces belong to existing objects: {n}{names}. They will be kept.",
"gs.optimize_live_positions_remove": "Old positions in deleted spaces belong to existing objects: {n}{names}. They are selected for removal.",
"gs.optimize_live_names": ": {names}{more}",
"gs.optimize_live_remove": "Remove old positions",
"gs.optimize_live_keep": "Keep old positions",
"gs.optimize_live_selected": "Old positions will be removed after Optimize is applied.",
"gs.optimize_unverified": "Could not safely verify positions: {n}. They were left unchanged.",
"gs.optimize_registry_limited": "Full administrator access to the Home Assistant registries is required for a safe check.",
"gs.optimize_vacuum_warning": "Vacuum room mappings that still need review: {n}.",
"gs.optimize_details": "Details",
"gs.optimize_details_more": "And {n} more records.",
"gs.optimize_detail_removed": "will be removed",
"gs.optimize_detail_live": "will be kept",
"gs.optimize_detail_unverified": "not verified",
"gs.optimize_detail_room_label": "room label",
"gs.optimize_detail_device": "device",
"gs.optimize_detail_group": "group marker",
"gs.optimize_detail_unknown": "unknown owner",
"gs.optimize_detail_item": "{status}: {kind} {id}; old space {space}",
"gs.align_warn": "Elements deliberately placed between grid nodes will move. One undo is available after the operation, only until the next plan edit.",
"gs.align_run": "Optimize",
"gs.align_done": "Plans optimized: {n} elements moved, {m} records maintained, {r} references repaired",
+21
View File
@@ -794,6 +794,7 @@
"gs.align_all": "Оптимизировать планы",
"gs.align_title": "Оптимизировать планы",
"gs.align_none": "Все планы уже используют актуальную и оптимальную модель данных.",
"gs.optimize_no_automatic_changes": "Автоматических изменений нет. Проверьте пункты ниже.",
"gs.align_count": "Сдвинется элементов: {n} из {total}, максимум на {cm} см.",
"gs.align_where": "Наибольший сдвиг — в пространстве «{s}».",
"gs.align_turned": "Проёмов с исправлением угла: {n}.",
@@ -807,6 +808,26 @@
"gs.optimize_references": "Исправлено ссылок: пространства — {spaces}; комнаты — {rooms}; позиции — {positions}; устройств отвязано от отсутствующих пространств — {detached}.",
"gs.optimize_reference_warning": "Некоторые ссылки требуют внимания: неразрешённых позиций — {positions}; вложенных сопоставлений — {nested}. Отсутствующие id пространств: {ids}{more}.",
"gs.optimize_reference_more": ", и ещё {n}",
"gs.optimize_orphans_removed": "Убрано забытых записей: {total} — подписи комнат: {rooms}; устройства: {devices}; групповые метки: {groups}. Все они принадлежали пространствам, удалённым ранее.",
"gs.optimize_live_positions": "Старые позиции в удалённых пространствах принадлежат существующим объектам: {n}{names}. Они будут сохранены.",
"gs.optimize_live_positions_remove": "Старые позиции в удалённых пространствах принадлежат существующим объектам: {n}{names}. Они выбраны для удаления.",
"gs.optimize_live_names": ": {names}{more}",
"gs.optimize_live_remove": "Убрать старые позиции",
"gs.optimize_live_keep": "Сохранить старые позиции",
"gs.optimize_live_selected": "Старые позиции будут убраны после применения оптимизации.",
"gs.optimize_unverified": "Не удалось безопасно проверить позиций: {n}. Они оставлены без изменений.",
"gs.optimize_registry_limited": "Для безопасной проверки нужен полный доступ администратора к реестрам Home Assistant.",
"gs.optimize_vacuum_warning": "Сопоставления комнат пылесоса требуют проверки: {n}.",
"gs.optimize_details": "Подробности",
"gs.optimize_details_more": "И ещё записей: {n}.",
"gs.optimize_detail_removed": "будет удалено",
"gs.optimize_detail_live": "будет сохранено",
"gs.optimize_detail_unverified": "не проверено",
"gs.optimize_detail_room_label": "подпись комнаты",
"gs.optimize_detail_device": "устройство",
"gs.optimize_detail_group": "групповая метка",
"gs.optimize_detail_unknown": "неизвестный владелец",
"gs.optimize_detail_item": "{status}: {kind} {id}; прежнее пространство {space}",
"gs.align_warn": "Элементы, намеренно поставленные между узлами, будут сдвинуты. После операции доступна одна отмена — только до следующего изменения плана.",
"gs.align_run": "Оптимизировать",
"gs.align_done": "Планы оптимизированы: сдвинуто элементов — {n}, обслужено записей — {m}, исправлено ссылок — {r}",
+4
View File
@@ -575,6 +575,10 @@ export function optimizePlans(
roomRefsRemapped: 0,
positionsRemapped: 0,
markersDetached: 0,
orphanRoomLabelsRemoved: 0,
orphanDevicePositionsRemoved: 0,
orphanGroupPositionsRemoved: 0,
liveMissingPositionsRemoved: 0,
};
return {
config: persistedConfig,
+121 -14
View File
@@ -10,6 +10,27 @@
export interface SpaceReferenceRepairContext {
/** Effective Area from the same buildDevices snapshot used by View. */
effectiveAreaByMarker?: Readonly<Record<string, string>>;
/** Full HA owner evidence. Absence is meaningful only when authoritative. */
ownerRoster?: {
authoritative: boolean;
deviceIds?: readonly string[];
entityIds?: readonly string[];
/** User-facing names keyed by the persisted layout owner id. */
names?: Readonly<Record<string, string>>;
};
/** Explicit preview opt-in; the default candidate preserves live owners. */
removeLiveMissingPositions?: boolean;
}
export type SpaceReferenceOwnerKind = 'room_label' | 'device' | 'group' | 'unknown';
export interface SpaceReferencePositionDetail {
id: string;
spaceId: string;
kind: SpaceReferenceOwnerKind;
/** Empty when no safe human-readable name exists. */
name: string;
reason?: 'registry_unavailable' | 'unknown_owner';
}
export interface SpaceReferenceReport {
@@ -20,6 +41,13 @@ export interface SpaceReferenceReport {
positionsUnresolved: number;
nestedRefsUnresolved: number;
deadSpaceIds: string[];
orphanRoomLabelsRemoved: number;
orphanDevicePositionsRemoved: number;
orphanGroupPositionsRemoved: number;
liveMissingPositionsRemoved: number;
removedPositions: SpaceReferencePositionDetail[];
liveMissingPositions: SpaceReferencePositionDetail[];
unverifiedPositions: SpaceReferencePositionDetail[];
}
export interface SpaceReferenceRepairResult {
@@ -71,6 +99,7 @@ export function repairSpaceReferences(
}
const roomSignaturesBySpace = new Map<string, Map<string, string[]>>();
const roomOwner = new Map<string, string>();
const roomNames = new Map<string, string>();
const roomsByArea = new Map<string, { spaceId: string; roomId: string }[]>();
for (const space of spaces) {
const spaceId = typeof space?.id === 'string' ? space.id : '';
@@ -82,6 +111,7 @@ export function repairSpaceReferences(
const signature = /^room_(.+)_([0-9a-f]{8})$/.exec(roomId);
if (signature) addCandidate(roomSignatures, signature[1], roomId);
if (!roomOwner.has(roomId)) roomOwner.set(roomId, spaceId);
if (!roomNames.has(roomId)) roomNames.set(roomId, String(room.name || ''));
const area = typeof room.area === 'string' ? room.area : '';
if (area) {
const values = roomsByArea.get(area) || [];
@@ -92,11 +122,15 @@ export function repairSpaceReferences(
roomSignaturesBySpace.set(spaceId, roomSignatures);
}
const existingRoomIds = new Set(roomOwner.keys());
const activeMarkerIds = new Set<string>();
const removedMarkerIds = new Set<string>();
const activeMarkers = new Map<string, any>();
const removedMarkers = new Map<string, any>();
for (const marker of markers) {
if (typeof marker?.id !== 'string' || !marker.id) continue;
(marker.removed === true ? removedMarkerIds : activeMarkerIds).add(marker.id);
if (marker.removed === true) {
removedMarkers.set(marker.id, marker);
} else {
activeMarkers.set(marker.id, marker);
}
}
const report: SpaceReferenceReport = {
@@ -107,6 +141,13 @@ export function repairSpaceReferences(
positionsUnresolved: 0,
nestedRefsUnresolved: 0,
deadSpaceIds: [],
orphanRoomLabelsRemoved: 0,
orphanDevicePositionsRemoved: 0,
orphanGroupPositionsRemoved: 0,
liveMissingPositionsRemoved: 0,
removedPositions: [],
liveMissingPositions: [],
unverifiedPositions: [],
};
const handledLayout = new Set<string>();
@@ -204,10 +245,6 @@ export function repairSpaceReferences(
layout[markerId] = { ...position, s: targetSpace };
report.positionsRemapped++;
handledLayout.add(markerId);
} else if (!isRemoved) {
// Coordinates from an unrelated/deleted plan must not be transplanted.
delete layout[markerId];
handledLayout.add(markerId);
}
}
}
@@ -238,18 +275,88 @@ export function repairSpaceReferences(
report.positionsRemapped++;
}
const roster = context.ownerRoster;
const rosterAuthoritative = roster?.authoritative === true;
const liveDeviceIds = new Set(roster?.deviceIds || []);
const liveEntityIds = new Set(roster?.entityIds || []);
const knownDeviceIds = new Set(
Array.isArray(config.settings?.known_devices)
? config.settings.known_devices.filter((value: unknown) => typeof value === 'string')
: [],
);
const ownerName = (id: string, fallback = ''): string => {
const value = String(roster?.names?.[id] || fallback || '').trim();
return value && value !== id ? value : '';
};
const detail = (
id: string, spaceId: string, kind: SpaceReferenceOwnerKind, fallback = '',
reason?: SpaceReferencePositionDetail['reason'],
): SpaceReferencePositionDetail => ({
id, spaceId, kind, name: ownerName(id, fallback), ...(reason ? { reason } : {}),
});
const countRemoval = (kind: SpaceReferenceOwnerKind): void => {
if (kind === 'room_label') report.orphanRoomLabelsRemoved++;
else if (kind === 'group') report.orphanGroupPositionsRemoved++;
else report.orphanDevicePositionsRemoved++;
};
const remainingDead = new Set<string>();
for (const marker of markers) {
const value = typeof marker?.space === 'string' ? marker.space : '';
if (value && !existingSpaceIds.has(value)) remainingDead.add(value);
}
for (const [key, position] of Object.entries(layout)) {
const value = typeof (position as any)?.s === 'string' ? (position as any).s : '';
if (!value || existingSpaceIds.has(value)) continue;
let owner: SpaceReferencePositionDetail;
let status: 'absent' | 'live' | 'unverified';
const activeMarker = activeMarkers.get(key);
const removedMarker = removedMarkers.get(key);
if (activeMarker) {
owner = detail(key, value, 'device', String(activeMarker.name || ''));
status = 'live';
} else if (removedMarker) {
owner = detail(key, value, 'device', String(removedMarker.name || ''));
status = 'absent';
} else if (key.startsWith('rl_')) {
const roomId = key.slice(3);
owner = detail(key, value, 'room_label', roomNames.get(roomId) || '');
status = existingRoomIds.has(roomId) ? 'live' : 'absent';
} else if (key.startsWith('lg_')) {
const entityId = key.slice(3);
owner = detail(key, value, 'group');
status = liveEntityIds.has(entityId)
? 'live'
: rosterAuthoritative ? 'absent' : 'unverified';
if (status === 'unverified') owner.reason = 'registry_unavailable';
} else if (liveDeviceIds.has(key)) {
owner = detail(key, value, 'device');
status = 'live';
} else if (knownDeviceIds.has(key)) {
owner = detail(key, value, 'device');
status = rosterAuthoritative ? 'absent' : 'unverified';
if (status === 'unverified') owner.reason = 'registry_unavailable';
} else {
owner = detail(key, value, 'unknown', '', 'unknown_owner');
status = 'unverified';
}
if (status === 'absent') {
delete layout[key];
countRemoval(owner.kind);
report.removedPositions.push(owner);
continue;
}
if (status === 'live') {
report.liveMissingPositions.push(owner);
if (context.removeLiveMissingPositions === true) {
delete layout[key];
report.liveMissingPositionsRemoved++;
countRemoval(owner.kind);
report.removedPositions.push(owner);
continue;
}
} else {
report.unverifiedPositions.push(owner);
}
report.positionsUnresolved++;
remainingDead.add(value);
// Active marker positions should have been deleted above. Count every
// preserved opaque/removed owner once in the remaining warning.
if (!activeMarkerIds.has(key) || removedMarkerIds.has(key)) report.positionsUnresolved++;
}
report.deadSpaceIds = [...remainingDead].sort((a, b) => a.localeCompare(b));
+28
View File
@@ -3225,6 +3225,34 @@ export const cardStyles = css`
color: var(--hp-muted);
}
.alignmsg { margin: 0 0 8px; font-size: 13px; line-height: 1.45; }
.optimize-live {
display: grid;
justify-items: start;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
}
.optimize-live .alignmsg, .optimize-live .rhint { margin-bottom: 0; }
.optimize-cleanup { min-height: 44px; }
.optimize-selected { color: var(--hp-txt); }
.optimize-details {
margin-top: var(--sp-3);
color: var(--hp-muted);
font-size: var(--fs-s);
overflow-wrap: anywhere;
}
.optimize-details > summary {
width: fit-content;
color: var(--hp-txt);
cursor: pointer;
font-weight: 600;
}
.optimize-details > summary:focus-visible {
outline: 2px solid var(--hp-accent);
outline-offset: 3px;
border-radius: var(--rad-s);
}
.optimize-details ul { margin: var(--sp-3) 0; padding-inline-start: 22px; }
.optimize-details li + li { margin-top: var(--sp-1); }
.btn.alignall { width: 100%; justify-content: center; }
.backupactions {
display: grid;
+15 -7
View File
@@ -339,15 +339,23 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.equal(scenario.sunRayPixels.minChannelDelta >= 4, true);
});
test('issue 244 golden matrix covers orphan repair and invalid default_floor themes', () => {
const optimize = GOLDEN_SCENARIOS.find(
(item) => item.id === 'optimize-orphan-references-dark-en',
test('issue 252 golden matrix covers owner-aware cleanup in both themes and languages', () => {
const optimize = GOLDEN_SCENARIOS.filter(
(item) => item.dialog === 'optimize-orphan-references',
);
assert.ok(optimize);
assert.equal(optimize.dialog, 'optimize-orphan-references');
assert.equal(optimize.markerOverrides[0].space, 'removed-floor');
assert.equal(optimize.layoutOverrides['golden-light-one'].s, 'unresolved-floor');
assert.deepEqual(optimize.map((item) => item.theme).sort(), ['dark', 'light']);
assert.deepEqual(optimize.map((item) => item.language).sort(), ['en', 'ru']);
for (const scenario of optimize) {
const live = scenario.markerOverrides.find((marker) => marker.id === 'golden-light-two');
const removed = scenario.markerOverrides.find((marker) => marker.id === 'golden-presence');
assert.equal(live.space, 'removed-floor');
assert.equal(!!live.name, true);
assert.equal(removed.removed, true);
assert.equal(scenario.layoutOverrides['golden-light-one'].s, 'unresolved-floor');
assert.equal(scenario.layoutOverrides['golden-presence'].s, 'removed-floor');
}
// The existing #244 default-floor warning remains paired light/dark as well.
const editors = GOLDEN_SCENARIOS.filter(
(item) => item.cardEditorInvalidDefaultFloor === 'removed-floor',
);
+21
View File
@@ -63,6 +63,27 @@ test('Optimize distinguishes updated spaces from cleaned coordinate noise', () =
assert.match(cardSource, /d\.report\.coordsCanonicalized \+ d\.report\.wallsMerged/);
});
test('issue 252 Optimize keeps internal ids out of the main orphan report', () => {
assert.equal(
en['gs.optimize_orphans_removed'],
'Forgotten records removed: {total} — room labels: {rooms}; devices: {devices}; group markers: {groups}. They belonged to spaces deleted earlier.',
);
assert.equal(
ru['gs.optimize_orphans_removed'],
'Убрано забытых записей: {total} — подписи комнат: {rooms}; устройства: {devices}; групповые метки: {groups}. Все они принадлежали пространствам, удалённым ранее.',
);
assert.match(cardSource, /gs\.optimize_orphans_removed/);
assert.match(cardSource, /<details class="optimize-details">/);
assert.doesNotMatch(cardSource, /this\._t\('gs\.optimize_reference_warning'/);
for (const key of [
'gs.optimize_orphans_removed', 'gs.optimize_live_positions',
'gs.optimize_unverified', 'gs.optimize_vacuum_warning',
]) {
assert.doesNotMatch(en[key], /\b(?:layout|owner|nested mapping|space id|marker id)\b/i);
assert.doesNotMatch(ru[key], /\b(?:layout|owner|id|вложенн)/i);
}
});
test('i18n: every literal help call has body and full aria keys in both languages', () => {
const allCalls = cardSource.match(/this\._help\(/g) || [];
const helpKeys = [...cardSource.matchAll(/this\._help\('([^']+\.help)'\)/g)].map((match) => match[1]);
+7 -3
View File
@@ -37,7 +37,9 @@ const assertNoPersistedChanges = (result) => {
'moved', 'coordsCanonicalized', 'rotated', 'removedDrafts', 'migrated',
'glowSpacesMigrated', 'glowRoomsMigrated', 'canonicalized', 'wallsMerged',
'spansMerged', 'partitionsMerged', 'spaceRefsRemapped', 'roomRefsRemapped',
'positionsRemapped', 'markersDetached',
'positionsRemapped', 'markersDetached', 'orphanRoomLabelsRemoved',
'orphanDevicePositionsRemoved', 'orphanGroupPositionsRemoved',
'liveMissingPositionsRemoved',
]) assert.equal(result.report[field], 0, `${field} must describe the persisted delta`);
assert.equal(result.report.maxShift, 0);
assert.equal(result.report.maxShiftCm, 0);
@@ -374,7 +376,7 @@ test('model version bookkeeping does not claim a data migration', () => {
assert.equal(result.report.migrated, 0);
});
test('issue 244 reference repair is part of exact Optimize candidate and bumps model version', () => {
test('issue 252 Optimize detaches a live marker without silently deleting its old position', () => {
const input = {
model_version: PLAN_MODEL_VERSION - 1,
spaces: [{ id: 'home', title: 'Home', view_box: [0, 0, 1, 1], rooms: [] }],
@@ -386,8 +388,10 @@ test('issue 244 reference repair is part of exact Optimize candidate and bumps m
assert.equal(result.config.model_version, PLAN_MODEL_VERSION);
assert.equal(result.config.markers[0].space, undefined);
assert.equal(result.config.markers[0].icon, 'mdi:sofa');
assert.equal(result.layout.m, undefined);
assert.deepEqual(result.layout.m, { s: 'gone', x: 0.25, y: 0.5 });
assert.equal(result.report.markersDetached, 1);
assert.equal(result.report.positionsUnresolved, 1);
assert.equal(result.report.liveMissingPositions.length, 1);
assert.equal(result.report.migrated, 0, 'reference counters stay separate from migration');
});
+150 -6
View File
@@ -7,8 +7,17 @@ const space = (id, rooms = []) => ({
id, title: id, cell_cm: 5, view_box: [0, 0, 1, 1], rooms,
});
const room = (id, area = null) => ({ id, name: id, area, x: 0, y: 0, w: 1, h: 1 });
const emptyOwnerReport = {
orphanRoomLabelsRemoved: 0,
orphanDevicePositionsRemoved: 0,
orphanGroupPositionsRemoved: 0,
liveMissingPositionsRemoved: 0,
removedPositions: [],
liveMissingPositions: [],
unverifiedPositions: [],
};
test('issue 244 detaches only dead active placement and drops its stale coordinates', () => {
test('issue 252 detaches a live marker but preserves its stale coordinates until explicit cleanup', () => {
const marker = {
id: 'washer', binding: 'entity:sensor.washer', space: 'gone', room_id: 'gone_room',
name: 'Washer', icon: 'mdi:washing-machine', tap_action: 'more-info',
@@ -23,7 +32,11 @@ test('issue 244 detaches only dead active placement and drops its stale coordina
assert.equal(result.report.spaceRefsRemapped, 0);
assert.equal(result.config.markers[0].space, undefined);
assert.equal(result.config.markers[0].room_id, undefined);
assert.equal(result.layout.washer, undefined);
assert.deepEqual(result.layout.washer, { s: 'gone', x: 0.25, y: 0.5, k: 1.2 });
assert.equal(result.report.positionsUnresolved, 1);
assert.deepEqual(result.report.liveMissingPositions, [{
id: 'washer', spaceId: 'gone', kind: 'device', name: 'Washer',
}]);
assert.equal(result.config.markers[0].binding, marker.binding);
assert.equal(result.config.markers[0].icon, marker.icon);
assert.deepEqual(result.config.markers[0].pdfs, marker.pdfs);
@@ -62,6 +75,7 @@ test('issue 244 exact import signatures repair the full reference graph and are
positionsUnresolved: 0,
nestedRefsUnresolved: 0,
deadSpaceIds: [],
...emptyOwnerReport,
});
const again = repairSpaceReferences(result.config, result.layout);
@@ -74,10 +88,11 @@ test('issue 244 exact import signatures repair the full reference graph and are
positionsUnresolved: 0,
nestedRefsUnresolved: 0,
deadSpaceIds: [],
...emptyOwnerReport,
});
});
test('issue 244 Area remap uses a unique production Area and never transplants coordinates', () => {
test('issue 252 Area remap never transplants or silently deletes old coordinates', () => {
const input = {
spaces: [space('ground', [room('utility', 'utility_area')])],
markers: [{
@@ -91,7 +106,9 @@ test('issue 244 Area remap uses a unique production Area and never transplants c
assert.equal(result.config.markers[0].space, 'ground');
assert.equal(result.config.markers[0].room_id, 'utility');
assert.equal(result.layout.washer, undefined);
assert.deepEqual(result.layout.washer, { s: 'gone', x: 0.9, y: 0.9 });
assert.equal(result.report.positionsUnresolved, 1);
assert.equal(result.report.liveMissingPositions.length, 1);
assert.equal(result.report.spaceRefsRemapped, 1);
assert.equal(result.report.roomRefsRemapped, 1);
assert.equal(result.report.markersDetached, 0);
@@ -121,7 +138,7 @@ test('issue 244 ambiguous, truncated and malformed signatures are never guessed'
assert.equal(result.config.markers[2].space, undefined);
assert.equal(result.config.markers[3].space, 'f1', 'removed tombstone is preserved');
assert.equal(result.report.markersDetached, 3);
assert.deepEqual(result.report.deadSpaceIds, ['f1']);
assert.deepEqual(result.report.deadSpaceIds, []);
});
test('issue 244 preserves unresolved layout and nested calibration while reporting it', () => {
@@ -141,6 +158,9 @@ test('issue 244 preserves unresolved layout and nested calibration while reporti
assert.deepEqual(result.layout, layout);
assert.equal(result.report.nestedRefsUnresolved, 1);
assert.equal(result.report.positionsUnresolved, 1);
assert.deepEqual(result.report.unverifiedPositions, [{
id: 'opaque_owner', spaceId: 'gone', kind: 'unknown', name: '', reason: 'unknown_owner',
}]);
assert.deepEqual(result.report.deadSpaceIds, ['gone']);
});
@@ -175,7 +195,8 @@ test('issue 244 removed tombstones accept only an exact signature map', () => {
assert.deepEqual(result.layout.exact, { s: 'space_f1_deadbeef', x: 0.2, y: 0.3 });
assert.equal(result.config.markers[1].space, 'other');
assert.equal(result.config.markers[1].room_id, 'other-room');
assert.deepEqual(result.layout.guess, { s: 'other', x: 0.4, y: 0.5 });
assert.equal(result.layout.guess, undefined);
assert.equal(result.report.orphanDevicePositionsRemoved, 1);
});
test('issue 244 large valid reference graph stays unchanged in one indexed pass', () => {
@@ -200,3 +221,126 @@ test('issue 244 large valid reference graph stays unchanged in one indexed pass'
assert.equal(result.config.markers.length, markers.length);
assert.equal(Object.keys(result.layout).length, markers.length);
});
test('issue 252 removes only proven room, device and group orphans and is idempotent', () => {
const removedMarkers = Array.from({ length: 3 }, (_, index) => ({
id: `removed-device-${index}`, binding: 'virtual', removed: true, space: 'deleted-floor',
}));
const roomLabels = Object.fromEntries(Array.from({ length: 32 }, (_, index) => [
`rl_deleted-room-${index}`, { s: 'deleted-floor', x: 0.1, y: 0.2 },
]));
const devicePositions = Object.fromEntries(removedMarkers.map((marker) => [
marker.id, { s: 'deleted-floor', x: 0.3, y: 0.4 },
]));
const layout = {
...roomLabels,
...devicePositions,
'lg_light.deleted_one': { s: 'deleted-floor', x: 0.5, y: 0.6 },
'lg_light.deleted_two': { s: 'deleted-floor', x: 0.7, y: 0.8 },
};
const config = {
spaces: [space('home', [room('living')])], markers: removedMarkers, settings: {},
};
const result = repairSpaceReferences(config, layout, {
ownerRoster: { authoritative: true, deviceIds: [], entityIds: [] },
});
assert.equal(result.changed, true);
assert.equal(Object.keys(result.layout).length, 0);
assert.equal(result.report.orphanRoomLabelsRemoved, 32);
assert.equal(result.report.orphanDevicePositionsRemoved, 3);
assert.equal(result.report.orphanGroupPositionsRemoved, 2);
assert.equal(result.report.positionsUnresolved, 0);
assert.deepEqual(result.report.deadSpaceIds, []);
assert.deepEqual(layout, {
...roomLabels,
...devicePositions,
'lg_light.deleted_one': { s: 'deleted-floor', x: 0.5, y: 0.6 },
'lg_light.deleted_two': { s: 'deleted-floor', x: 0.7, y: 0.8 },
}, 'preview must not mutate the source layout');
const again = repairSpaceReferences(result.config, result.layout, {
ownerRoster: { authoritative: true, deviceIds: [], entityIds: [] },
});
assert.equal(again.changed, false);
assert.equal(again.report.orphanRoomLabelsRemoved, 0);
assert.equal(again.report.orphanDevicePositionsRemoved, 0);
assert.equal(again.report.orphanGroupPositionsRemoved, 0);
});
test('issue 252 preserves live owners by default and removes only them after explicit opt-in', () => {
const config = {
spaces: [space('home', [room('living')])],
markers: [{ id: 'virtual-live', binding: 'virtual', name: 'Hall note' }],
settings: { known_devices: ['auto-live'] },
};
const layout = {
'virtual-live': { s: 'gone', x: 0.1, y: 0.2 },
'auto-live': { s: 'gone', x: 0.3, y: 0.4 },
'lg_light.live': { s: 'gone', x: 0.5, y: 0.6 },
rl_living: { s: 'gone', x: 0.7, y: 0.8 },
};
const context = {
ownerRoster: {
authoritative: true,
deviceIds: ['auto-live'],
entityIds: ['light.live'],
names: {
'virtual-live': 'Hall note',
'auto-live': 'Washing machine',
'lg_light.live': 'Living lights',
},
},
};
const kept = repairSpaceReferences(config, layout, context);
assert.deepEqual(kept.layout, layout);
assert.equal(kept.report.positionsUnresolved, 4);
assert.equal(kept.report.liveMissingPositions.length, 4);
assert.deepEqual(kept.report.liveMissingPositions.map((item) => item.name).sort(), [
'Hall note', 'Living lights', 'Washing machine', 'living',
].sort());
assert.equal(kept.report.orphanDevicePositionsRemoved, 0);
const removed = repairSpaceReferences(config, layout, {
...context, removeLiveMissingPositions: true,
});
assert.deepEqual(removed.layout, {});
assert.equal(removed.report.liveMissingPositionsRemoved, 4);
assert.equal(removed.report.orphanRoomLabelsRemoved, 1);
assert.equal(removed.report.orphanDevicePositionsRemoved, 2);
assert.equal(removed.report.orphanGroupPositionsRemoved, 1);
assert.equal(removed.report.positionsUnresolved, 0);
});
test('issue 252 fails closed for limited registry and unknown future layout owners', () => {
const config = {
spaces: [space('home')], markers: [], settings: { known_devices: ['known-old-device'] },
};
const layout = {
'known-old-device': { s: 'gone', x: 0.1, y: 0.2 },
'lg_light.maybe': { s: 'gone', x: 0.3, y: 0.4 },
'future_widget:one': { s: 'gone', x: 0.5, y: 0.6 },
};
const limited = repairSpaceReferences(config, layout, {
ownerRoster: { authoritative: false, deviceIds: [], entityIds: [] },
removeLiveMissingPositions: true,
});
assert.deepEqual(limited.layout, layout);
assert.equal(limited.report.unverifiedPositions.length, 3);
assert.equal(limited.report.positionsUnresolved, 3);
assert.deepEqual(limited.report.unverifiedPositions.map((item) => item.reason).sort(), [
'registry_unavailable', 'registry_unavailable', 'unknown_owner',
].sort());
const authoritative = repairSpaceReferences(config, layout, {
ownerRoster: { authoritative: true, deviceIds: [], entityIds: [] },
});
assert.equal(authoritative.layout['known-old-device'], undefined);
assert.equal(authoritative.layout['lg_light.maybe'], undefined);
assert.deepEqual(authoritative.layout['future_widget:one'], layout['future_widget:one']);
assert.equal(authoritative.report.orphanDevicePositionsRemoved, 1);
assert.equal(authoritative.report.orphanGroupPositionsRemoved, 1);
assert.equal(authoritative.report.unverifiedPositions.length, 1);
});