Compare commits

...
Author SHA1 Message Date
Claude 461ade7d19 docs(testing): browser guard total after the rebase over #821 (#829)
#821 and #829 each added one browser guard and each moved the inventory
total from 274 to 275, so the rebase merged both lists without a conflict
and left the total one short. The total is the sum of the category lists:
5 + 3 + 49 + 54 + 41 + 124 = 276.

Issue: #829
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-08 10:13:42 +03:00
Claude a4ca3ff2d5 fix(zigbee): arrowheads paint over the value badge of link endpoints (#829)
A marker that ends a shown Zigbee link rises above the route layer as a
whole (#464), its value badge included. Where the arrowhead of the link
stops at a device whose value badge faces the other end, the badge covered
the arrow completely and the direction of the link was unreadable.

The overlay now also draws a copy of the arrowheads alone in the #808 layer
(z 9, before the captions in DOM order), clipped to the value-badge frames
(.value-badge, the legacy second climate metric included) of the endpoint
markers. The frames are measured in the existing layout frame next to the
battery frames: no new observer and no measurement in render. Lines stay
under the badge; cores stay above routes, captions above routes and
batteries, and routes over batteries as before. Without a value badge the
clip is empty and the frame is unchanged. Parent-caption arrows keep
updating in every copy through their data-parent-index.

Tests: smoke_device_battery_zigbee gives the parent endpoint d_leak a value
badge facing its child d_light1, so the arrowhead of their local link lands
on it - the neighbour's badge while d_light1 is hovered, the hovered
device's own while d_leak is - Flat and 2.5D. Inside the shared area the
arrow pixels must cover the badge (routes-hidden control); the line beyond
the arrowhead must stay under it. On dev all four arrow probes are red
(arrowOver 0 of 18 Flat, 0 of 12 2.5D). New mutant
zigbee-arrow-copy-under-value-badge (manually red); browser guard inventory
+1. smoke-links ties the private clip method to that smoke, so smoke-select
picks it for changes there. smoke_zigbee_topology_hover counted one arrow
per drawn line across all layers; it now pins the layer set per route (line
and arrow in the base layer and the battery copy, the arrow alone in the
badge copy) and keeps checking the colour of every copy. The golden scene
device-battery-zigbee-overlap-dark has no value badge and is byte-identical
to dev.

Issue: #829
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-08 10:13:20 +03:00
claude[bot] 5380f7afa3 docs(reviews): индекс после сдвига каталога (#821)
Issue: #821
User-Visible: no
2026-10-08 06:48:43 +00:00
claude[bot] e7527cb76d docs: review document for #821
Issue: #821
User-Visible: no
2026-10-08 06:48:38 +00:00
Claude 6d048b1d8e test(resize): count the render snapshot on the editor runtime that renders (#821)
benchmark_safe_resize_render wrapped card._rszSnapshot, but the card only
delegates _renderResizeLayer to the editor runtime, whose render calls its own
_rszSnapshot. The counter saw 0 calls, so the standard run failed (exit 1)
without measuring the one-snapshot-per-frame contract of docs/RESIZE.md.

- The counter now wraps _rszSnapshot on the editor runtime instance and
  restores it afterwards; scenario, fixture, 3 warm-ups / 20 samples,
  20 rooms / 80 handles, one snapshot per frame and p95 <= 25 ms are unchanged.
- A zero count fails with an explicit "not on the render path" reason, and
  every failed check is named on stderr.
- Mutant resize-render-snapshot-per-handle drops the frame snapshot from
  _rszResolution in _renderResizeLayer; guard: this benchmark, so the nightly
  registry run now exercises it. Browser inventory: Performance threshold 5,
  total 275 / 200.

Evidence (sandbox, Chromium 141): dev 0 calls, exit 1; branch 20 calls,
p95 3.1 / 3.5 ms, exit 0 (2 runs); wrapper moved back to the card: exit 1 with
the zero reason; mutant: 1620 calls (81 per frame), p95 55.7 ms, exit 1.

Issue: #821
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-08 09:30:13 +03:00
claude[bot] 0bbaca24d4 docs(reviews): индекс после сдвига каталога (#818)
Issue: #818
User-Visible: no
2026-10-08 06:04:11 +00:00
claude[bot] 4c5808499d docs: review document for #818
Issue: #818
User-Visible: no
2026-10-08 06:04:04 +00:00
Claude a7397d1940 fix(mutation-gate): let the CLI end on its own so piped stdout drains (#818)
The entry point ended with process.exit(code) after main. On POSIX a piped
stdout is asynchronous: when the reader lags, console.log output waits in the
libuv queue, and process.exit() drops it. Under load the #811 AC7 unit lost
`WARN stale`, the warning count and plan-metrics with status 0; nightly shard
logs piped to tee are exposed the same way.

The entry now sets process.exitCode and lets the process end once
stdout/stderr have drained; nothing holds the event loop after main.

Witnesses in test/mutation-gate.test.mjs:
- #818 AC1 runs the real CLI with ~350 KB of membership warnings while the
  parent reads nothing; a preload reports the queued tail and, one macrotask
  later, that the process is still alive. The reader resumes on that signal or
  on exit, whichever comes first, so the verdict needs no timing window. Red
  3/3 on the old entry (resumed on exit, ~199k still queued, ~96 KB of
  353 KB read), green on the new one. Skipped on Windows, where stdout pipes
  are synchronous.
- #818 AC2 keeps the exit codes: unknown --id and a throwing main are 2.

Mutant mutation-gate-cli-exits-before-stdout-drains restores process.exit;
the AC1 guard catches it.

Issue: #818
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-08 08:50:37 +03:00
claude[bot] c36dca25cf docs: review document for #828
Issue: #828
User-Visible: no
2026-10-08 05:43:20 +00:00
claude[bot] a1d7c34cc2 docs: review document for #824
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Issue: #824
User-Visible: no
2026-10-07 22:39:05 +00:00
claude[bot] 91df175208 docs: review document for #826
Issue: #826
User-Visible: no
2026-10-07 22:39:02 +00:00
claude[bot] c748cf0d97 docs: review document for #827
Issue: #827
User-Visible: no
2026-10-07 22:38:35 +00:00
claude[bot] 6b45df6db6 docs: review document for #825
Issue: #825
User-Visible: no
2026-10-07 22:36:45 +00:00
Matysh f623903e40 Release v1.80.0-beta.8 candidate
Ship accepted wall-node editing, floor/resize geometry reuse, device tooltip,
space deletion and reinstall behaviour, plus review/test reliability work.
Refresh both committed bundles, release notes, dated RU/EN changelogs and
mechanical beta ratchets. Documentation screenshots have unchanged pixels.

Issue: #803
Issue: #805
Issue: #811
Issue: #812
Issue: #813
Issue: #814
Issue: #815
Issue: #816
Issue: #817
Issue: #819
Issue: #820
Issue: #822
User-Visible: yes
Release: v1.80.0-beta.8
2026-10-08 00:48:04 +03:00
claude[bot] 469789dc42 docs: accept derived artifacts on dev for v1.80.0-beta.8
Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697).
Прогон: https://github.com/Matysh/houseplan-card/actions/runs/37691340723
Скриншоты документации: отпечаток исходников; изменённые кадры: нет.

Issue: #697
User-Visible: no
2026-10-07 21:45:06 +00:00
claude[bot] 4df7d73b3b docs(reviews): индекс после сдвига каталога (#803)
Issue: #803
User-Visible: no
2026-10-07 21:40:35 +00:00
claude[bot] 0601e822ae docs: review document for #803
Issue: #803
User-Visible: no
2026-10-07 21:40:29 +00:00
Matysh f556c1e2b6 refactor(editor): share unchanged node ghost templates within bundle budget (#803)
Keep the accepted SVG mask geometry and input behavior while removing duplicate emitted node code after integrating #814.

Issue: #803
User-Visible: no
2026-10-08 00:09:36 +03:00
Matysh d45cc2660e fix(editor): scope retired node click tails to pointer stage activation (#803)
Do not swallow unrelated header/toolbar or keyboard clicks after a completed/cancelled drag. Add a red-before input witness and real Enter reentry followed by exact Undo/Redo.

Issue: #803
User-Visible: yes
2026-10-07 23:52:48 +03:00
Matysh 7597554c58 fix(backend): make node proof pairing explicitly strict (#803)
Match the CI B905 lint rule for paired original and copied spaces; no intended user-visible change.

Issue: #803
User-Visible: no
2026-10-07 23:52:48 +03:00
Matysh cf4a28f8d3 fix(backend): normalise legacy inputs for node host proof (#803)
Derive host identities from a read-compatible copy before the independent ledger, with a red-before legacy-room regression and pure registered guard. Durable source remains unchanged.

Issue: #803
User-Visible: yes
2026-10-07 23:52:48 +03:00
Matysh 1a6bd39ac0 fix(editor): retain exact axis and HV point intersections (#803)
Keep the winning axis guide without longitudinally rounding an exact compatible non-parallel H/V point; parallel-only goals still quantise. Add a red-before witness and registered pure guard.

Issue: #803
User-Visible: yes
2026-10-07 23:52:48 +03:00
Matysh 92c3daccc7 fix(editor): close noisy node, snap and host-proof review gaps (#803)
Use consistent owned-ray angular classification, stable snap ties and an independent original-interval host identity ledger. Add red/green regressions, finite-carrier/connectivity witnesses and canonical docs.

Issue: #803
User-Visible: yes
2026-10-07 23:52:48 +03:00
claude[bot]andMatysh 13cc59b923 docs: review document for #803
Issue: #803
User-Visible: no
2026-10-07 23:52:47 +03:00
Matysh 8972e44317 fix(build): guard all eager entry edges after node chunking (#803)
Issue: #803
User-Visible: yes
2026-10-07 23:52:32 +03:00
Matysh 310dd03311 test(editor): finalize node snap guards and typed polygon boundaries (#803)
Issue: #803
User-Visible: no
2026-10-07 23:52:32 +03:00
Matysh 562780400c fix(editor): bound node geometry work and refresh accepted history (#803)
Issue: #803
User-Visible: yes
2026-10-07 23:52:32 +03:00
Matysh d0d9548cb8 fix(editor): preserve retry API and cancel stale node paint (#803)
Issue: #803
User-Visible: yes
2026-10-07 23:52:32 +03:00
Matysh 398c7222c9 test(editor): complete node edit guards and lazy lifecycle (#803)
Issue: #803
User-Visible: no
2026-10-07 23:52:32 +03:00
Matysh 8a71cbc85e feat(editor): move wall nodes with reversible server proof (#803)
Issue: #803
User-Visible: yes
2026-10-07 23:52:15 +03:00
claude[bot] 02b1804525 docs(reviews): индекс после сдвига каталога (#814)
Issue: #814
User-Visible: no
2026-10-07 20:43:59 +00:00
claude[bot] 27cf8b5ea9 docs: review document for #814
Issue: #814
User-Visible: no
2026-10-07 20:43:55 +00:00
Claudeandclaude[bot] 64b7f2bfec test(geometry): wait out the mode transition before Resize drags (#814)
Validate 37666990080 (Chromium 151) crashed the floor-cache-reuse smoke
on `held.shift().reject` with no write parked: the held Resize commit
never happened. The facade's setMode resolves while the View->plan
transition is still busy (measured 3 of 3 locally), and the stage is
`inert` for the whole 220 ms tween: a real press on the Resize handle
then starts no session (4 of 4 locally while busy). A fast runner gets
from setMode through setTool to the press inside the tween; the
sandbox, with slower frames, does not. The old block also waited a
fixed 3 s inside one evaluate and dereferenced the slot unchecked.

- every drag waits for `_modeTransitionBusy` to clear and verifies
  that the press started a Resize session, else prints what it hit;
- the held write is awaited from Node by waitForFunction on the page
  slot (15 s); without it the AC1 checks fail with diagnostics
  (committed, history, _writesPending, the debounce, a replaced hass,
  the toast) instead of throwing, and parked writes are released so
  the rest of the smoke runs;
- the rejected write's settling and the warm remount's boot are polled
  from Node, not awaited inside an evaluate;
- the oracle card and the remount get `hass` before they connect: a
  warm slot freed over 15 s ago revalidates in connectedCallback, and
  under a 6x CPU throttle that read an unset hass (an uncaught
  TypeError that would have failed the run at finish).

The AC1 negative witnesses stay; all five smoke-guarded mutants are
still caught.

Issue: #814
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 20:19:14 +00:00
Claudeandclaude[bot] 4e525bd99a docs(changelog): note the reused floor geometry of #814 (#814)
The spec of #814 lists RU and EN changelog lines among its release artifacts.
The implementation commits stayed `User-Visible: no` because nothing on screen
changes, but Full Performance on 339946ae against its merge-base 0dcf4d98
shows the effect a large plan gets: the warm switch cycle builds 0 bodies and
0 opening tunnels instead of 12/12 (switchCycleMs -7.1 % large-house, -3.2 %
isometric, -6.5 % isometric-stage3) and the resize step's Long Task median is
131 -> 116 ms with its live labels 33 -> 23 ms. One line per language says
what is reused, with no percentage.

Issue: #814
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 20:19:14 +00:00
Claudeandclaude[bot] 859c7a224c docs(geometry): cache keys, pools, resize union and the F19/F27 report (#814)
ARCHITECTURE, WALL-THICKNESS, SUN and RESIZE describe the changed
contracts: the opening wall index and the sun wedges key by every input
they read instead of the global epoch, bodies and tunnels are pooled per
floor (eight, LRU), and Resize subtracts one union of the unchanged
bodies per gesture. The performance README records the `pooled`
judgement, the pool sizes and `keyCost` in the switch-cycle rows, and
what the Full Performance comparison publishes for AC5.

DEVELOPMENT.md carries the F19 measurement (source, rendered, minified
and gzip weight of bignumber.js, polyclip-ts and splaytree-ts, and of
the plan/chrome CSS left in the entry after #805, with the attribution
method and graph membership) and the F27 ratchet numbers of the final
build with SHA and toolchain. The dependencies stay: View itself needs
exact booleans, and their gzip share is at most 14.5 KB, not the 85 KB
of bignumber.js source.

Issue: #814
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 20:19:14 +00:00
Claudeandclaude[bot] 883376a043 test(geometry): smoke and mutants for floor cache reuse (#814)
demo/smoke_floor_cache_reuse.mjs judges the shown card against a fresh
card on the same config (no cache to reuse) and by its build counters:
- AC1: a Home Assistant update, a pushed edit of another floor and a
  pushed shared setting build no opening index, wedges, tunnels or
  bodies; a window moved in place without a new epoch rebuilds the
  wedges once; a Resize commit with its WS write held shows the commit
  before the ack, and the rejected write rolls back to the fresh card's
  geometry;
- AC2: three warm floors, the twelve-switch cycle judged with `pooled`
  builds no bodies or tunnels; full planted pools show the evicting
  miss by its counter with every size unchanged; a pushed partition
  builds the current bodies;
- AC3: one union per Resize gesture (cancel, commit, Undo, Redo,
  another floor, a server push mid-drag that ends the gesture), the
  live areas and the opening wall index of each held preview are the
  fresh card's on the preview record;
- AC7: config replacement, a mode round trip, disconnect/reconnect and
  a warm remount keep no stale geometry and bounded pools.
The fixture copies wall endpoints: a config from the server never
shares arrays, and the in-place adoption of a commit rewrites a room
vertex through an alias (recorded as a #814 finding).

Thirteen registry mutants, each checked by hand (patch, guard, revert):
the epoch back in either key, keys without wall or window geometry, a
single-slot pool, a bypassed bodies pool, an uncounted evicting tunnel
miss, a guard blind to pools, the union memo off, keyed by scale only,
a failed union read as empty, a union kept after a reset, and the live
Resize preflight skipped (AC4, smoke_room_resize). Six are browser
guards, recorded in the inventory (263 -> 269 of the 200 guideline).
The #577 origin mutant follows the moved sun key.

Issue: #814
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 20:19:14 +00:00
Claudeandclaude[bot] a02939cbb8 perf(resize): one union of the unchanged bodies per gesture (#814)
The live area labels of Resize compute every resized room as
floorMinusBodies(floor, physical), and floorMinusBodies unions all
physical bodies of the floor on each call: two rooms on a shared wall
meant two unions per accepted step, and every step repeated them
(#778 measured the labels at about a quarter of the resize Long Task).
A resize never moves a partition, a column or the host of their
openings, so the body set is the same for the whole gesture.

physical-geometry.ts splits the subtraction into bodyObstacles (the set
and its union, built once) and floorMinusObstacles (the floor minus
them). floorMinusBodies is now exactly that composition, byte for byte.
An empty set leaves a floor whole; a failed union (null for a non-empty
set) or a union the difference rejects takes the same lossless
sequential path as before, never "no obstacles".

BodyObstaclesMemo keeps the last union keyed by the bodies themselves
(partition opening cuts are cut into them) and the grid scale, not by
the gesture alone. The editor runtime clears it when a gesture begins,
finishes, is cancelled or reset (a server push mid-drag ends the
gesture), so a cancelled, repeated or other-floor gesture never reuses
it. The union is still built inside the first measured step, not on
pointerdown. The live physical preflight is untouched.

Issue: #814
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 20:19:14 +00:00
Claudeandclaude[bot] dd753f79d8 perf(geometry): pool physical bodies and opening tunnels per floor (#814)
Physical bodies and opening tunnels were single-slot caches: each warm
floor switch rebuilt both (12 of 12 in the #735 cycle, recorded by
#769). They now keep a bounded pool per card instance next to the
active entry, through one helper in src/floor-geometry-key.ts:
- at most eight entries (the union pool's bound), deterministic LRU;
  any read of a pooled key refreshes its recency, a miss that evicts
  still counts as a build;
- the active entry stays the editor's current cache
  (_physicalBodiesCache): its own key answers first, so the Resize
  preview that re-keys it in place stays a hit and never enters the
  pool; the caller gets a copy, so a re-key never renames a pooled
  record; setting the cache to null clears the pool as before.

The #735 switch-cycle guard judges both families at zero when the
target pools them (`pooled`, read from the target source by the
runner); a base before #814 keeps them reported. The large-house rows
report the pool sizes and the cost of a warm lookup
(switchCycleBuilds.keyCost) for the base/candidate comparison.

The win is deterministic (no build in the warm cycle) but small in
time: a local probe on the large-house fixture put one cold build of a
floor's bodies or tunnels at about 1 ms each, so no user-visible change
is claimed and no changelog line is added.

Issue: #814
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 20:19:14 +00:00
Claudeandclaude[bot] 9738fc6ee3 perf(geometry): key the opening index and sun wedges by their inputs (#814)
The opening wall index and the Flat/2.5D sun wedges carried the global
config epoch in their keys on top of a content fingerprint. Every save of
any floor, a shared setting or a server push bumps that epoch, so the
shown floor rebuilt both although none of their inputs had changed.

Both keys now live in src/floor-geometry-key.ts and list every input:
the opening index reads the floor, its rooms in order (id and the
polygon roomPoly reads, rect fallback included), its wall records, the
open cuts and the scale, afresh on every call, so an in-place edit is
seen without a new epoch. The sun key is that index key plus the
physical bodies key (the floor record the occluders, wall union and
inner contours are cached by), the exterior windows, the sun, the
compass, the ray origin and the zero walls. DEV-B701-01 still holds:
an own edit moves the key synchronously, before the WS ack.

The card counts sun builds (sunRays, isoSun) next to the floor-cache
families for the smoke; the #735 guard ignores them.

Issue: #814
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 20:19:14 +00:00
claude[bot] c267e9079a docs: accept derived artifacts on dev for v1.80.0-beta.8
Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697).
Прогон: https://github.com/Matysh/houseplan-card/actions/runs/37680512545
Скриншоты документации: отпечаток исходников; изменённые кадры: нет.
Golden: изменённые сцены: device-battery-zigbee-overlap-dark; новые: нет.

Release: v1.80.0-beta.8
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/37675448130
Issue: #697
User-Visible: no
2026-10-07 20:16:32 +00:00
claude[bot] 334e0c2378 docs(reviews): индекс после сдвига каталога (#822)
Issue: #822
User-Visible: no
2026-10-07 18:08:08 +00:00
claude[bot] e10bffb4de docs: review document for #822
Issue: #822
User-Visible: no
2026-10-07 18:08:02 +00:00
Matysh 02f12245d5 test(vacuum): cover paired device deletion (#822)
Issue: #822
User-Visible: no
2026-10-07 20:52:22 +03:00
Matysh 6c00fe6978 fix(vacuum): remove map routes with their space (#822)
Issue: #822
User-Visible: yes
2026-10-07 20:51:16 +03:00
claude[bot] 0dcf4d98a9 docs(reviews): индекс после сдвига каталога (#819)
Issue: #819
User-Visible: no
2026-10-07 17:49:38 +00:00
claude[bot] ef95ffd033 docs: review document for #819
Issue: #819
User-Visible: no
2026-10-07 17:49:34 +00:00
Matysh 48a37b8985 refactor(space): share deletion completion within bundle budget (#819)
Issue: #819
User-Visible: no
2026-10-07 20:23:56 +03:00
claude[bot]andMatysh 42f74dac29 docs(reviews): индекс после сдвига каталога (#819)
Issue: #819
User-Visible: no
2026-10-07 20:08:31 +03:00
claude[bot]andMatysh 563b921ccb docs: review document for #819
Issue: #819
User-Visible: no
2026-10-07 20:08:31 +03:00
ClaudeandMatysh a16510b6be feat(space): reveal the delete blocker and delete a space with its devices (#819)
The «still used by devices» warning is the last thing in a four-card dialog,
so a blocked Delete left it below the edge and looked like a dead button.
Every Delete that ends blocked — the first time, again after the body was
scrolled away, or after the server's `space_in_use` — now scrolls the
warning fully into view and moves focus to it (`tabindex="-1"`, outside the
Tab order). One helper serves both runtimes; the scroll is instant, so
reduced motion needs no branch.

The warning carries «Delete space with devices» in the danger style,
disabled while the dialog is busy. It reuses the same shared danger
confirmation as Delete: the space name, the existing body, «Its devices will
be deleted too: N», «Including hidden: K» when there are any, and the robot
map line. Cancel writes nothing. On accept the card re-checks the blocking
set: if it changed while the confirmation was open nothing is written and
the warning shows the current N; otherwise one `space/delete` with
`remove_markers: true` and the current revisions goes out, and the usual
authoritative re-read, toast and «Available again» follow. A space without
devices sends the old request without the new key.

New strings live in the lazy settings dictionary in all four languages.
USER-GUIDE §7 (ru/en) and both changelogs describe the new path; the new
smoke drives it at 1000×700 and 375×812 against a mock server, while the
server half is proven by pytest on the shared fixture.

Issue: #819
User-Visible: yes
Spec-Draft: sha256:bfea1e3ac13d4cfd584b4bd1dde99d260a3b6022a043f9127bcf85945bf40273
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 20:08:31 +03:00
ClaudeandMatysh 9ca3b649e2 feat(ws): delete a space together with the devices that block it (#819)
houseplan/space/delete gains an optional `remove_markers`. Without it the
server answers `space_in_use` exactly as before, so an older card keeps its
behaviour. With it every active marker that blocks the delete (visible,
hidden, by space, by room or by saved position) goes the way the device
dialog's «Delete» sends it — a real binding leaves a hidden tombstone of the
exact binding, a virtual marker goes, other markers' controls, LED strip
links, area snapshots and layout positions of the dropped ids go too — and
the space is then removed by the unchanged #244 candidate, all in the same
paired write. The expected revisions already guard the race: a plan that
moved after the confirmation deletes nothing. The last space keeps its own
rule (markers detached, never deleted), with or without the flag.

After the durable commit, under the write lock and best-effort, the server
removes the attachments of the deleted markers (the files/cleanup rule,
extracted into one helper) and purges their robot trails; the virtual-light
manual state is reconciled by the ordinary config save. The reply lists
`removed_markers`.

The card's `createSpaceDeletionCandidate` mirrors the server through the
product's own single-delete helpers; one shared fixture pins the two
(`test/fixtures/space-delete-with-markers.json`): hidden markers, the three
reference paths and tombstones count the same on both sides.

Issue: #819
User-Visible: no
Spec-Draft: sha256:bfea1e3ac13d4cfd584b4bd1dde99d260a3b6022a043f9127bcf85945bf40273
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 20:08:31 +03:00
claude[bot] 06f93d0850 docs(reviews): индекс после сдвига каталога (#820)
Issue: #820
User-Visible: no
2026-10-07 16:51:13 +00:00
claude[bot] f45003fdb5 docs: review document for #820
Issue: #820
User-Visible: no
2026-10-07 16:51:09 +00:00
Matysh 748b2f67fa feat(setup): restore or archive previous installation data (#820)
Issue: #820
User-Visible: yes
2026-10-07 19:31:35 +03:00
claude[bot] b13cc29134 docs(reviews): индекс после сдвига каталога (#817)
Issue: #817
User-Visible: no
2026-10-07 16:26:47 +00:00
claude[bot] be7a5287e0 docs: review document for #817
Issue: #817
User-Visible: no
2026-10-07 16:26:47 +00:00
Claudeandclaude[bot] 3c8f9af604 fix(tooltip): do not repeat a charge the value badge already shows (#817)
Code review r1 (Medium): when a device's value badge shows the very battery
sensor the charge row reads - which the badge editor recommends for a
battery-only device - the tooltip showed one number twice in two formats:
"37.6 %" from HA formatting in the meta row and "Battery 38%" below it.

Follow the existing LQI precedent (lqiText yields to an LQI badge): the
charge row is dropped only when the presented badge's explicit source is
entity_state of the source selected by the #792 reader. The reading now
carries that sourceEntityId; the comparison uses the badge source object,
never its text. A badge on another entity or attribute, and a disabled
badge, keep the row. The owner's "always" decision about plan indicator
settings is untouched.

Witnesses: unit cases for same/other/attribute/disabled badges and the
reviewer's reproduction through the real presentation resolver; the
tooltip smoke adds a separate marker (kept out of the shared #792 fixture)
whose badge shows its own battery sensor. Mutant
battery-tooltip-line-duplicates-same-badge removes the suppression and is
caught by the unit guard.

Issue: #817
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 16:26:47 +00:00
claude[bot] 897aa1dcc3 docs: review document for #817
Issue: #817
User-Visible: no
2026-10-07 16:26:47 +00:00
Claudeandclaude[bot] 52f2ad7463 feat(tooltip): battery charge line in the main device tooltip (#817)
A battery device's tooltip now carries its own charge row, so the owner can
read the value without opening more-info: "Battery N%" (rounded) for a
numeric source, "Battery normal" / "Low battery" for a binary one, in
en/ru/de/fr.

The row reads the same #792 source through a new pure reader,
deviceBatteryReading(); source selection is factored into batterySource() so
the indicator and the text cannot pick different sensors. The reader returns
the value instead of the band, and null for unknown, unavailable, empty,
non-numeric or out-of-range values and for disabled entities/devices, so
missing data is never shown as a normal charge.

Owner's decision: the row ignores the plan indicator settings (All / Low
only / None, per-device hiding, LED ownership). It therefore does not use
ResolvedDevicePresentation.battery; it builds a context from the frame's
active rows (_renderPlanHass) and the full registry, exactly as the card does
for the indicator, never from unfiltered hass.

Witnesses: test/device-battery-tip.test.mjs (every AC1/AC2 branch and the
real tooltip entry points with the production presentation resolver) and
demo/smoke_device_battery_tooltip.mjs (mouse and keyboard focus, Flat and
2.5D). Mutant battery-tooltip-line-follows-plan-indicator ties the row to
the indicator and is caught by the unit guard.

Issue: #817
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 16:26:47 +00:00
claude[bot] a5e7d79c69 docs: review document for #820
Issue: #820
User-Visible: no
2026-10-07 15:36:13 +00:00
claude[bot] 7ea49c4c26 docs(reviews): индекс после сдвига каталога (#816)
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Issue: #816
User-Visible: no
2026-10-07 14:03:09 +00:00
claude[bot] a80aefa028 docs: review document for #816
Issue: #816
User-Visible: no
2026-10-07 14:03:08 +00:00
Matyshandclaude[bot] 1a0024ea2b test(zigbee): explain incomplete-caption mutation guards (#816)
Issue: #816
User-Visible: no
2026-10-07 14:03:08 +00:00
Matyshandclaude[bot] 2e0f1a17a4 fix(zigbee): scope incomplete caption to unknown non-root uplinks (#816)
Issue: #816
User-Visible: yes
2026-10-07 14:03:08 +00:00
claude[bot] 98f3ab0c00 docs: review document for #819
Issue: #819
User-Visible: no
2026-10-07 13:23:31 +00:00
claude[bot] 98108151c1 docs(reviews): индекс после сдвига каталога (#813)
Issue: #813
User-Visible: no
2026-10-07 12:37:54 +00:00
claude[bot] b8a39efaa9 docs: review document for #813
Issue: #813
User-Visible: no
2026-10-07 12:37:54 +00:00
Claudeandclaude[bot] dd5e1b809b refactor: drop the dead kiosk dialog copy and dead dialog CSS (#813)
F12: the editor runtime still carried _saveKioskScale/_renderKioskDialog,
which wrote the pre-summary key houseplan_card_kiosk_v1 directly. Since #763
the core card draws the kiosk scale dialog and saves through the summary
runtime, and nothing called the runtime copy. The copy, its LS_KIOSK
constant and the port member _kioskDialog (read only by that copy) are gone;
the legacy key is still read once for migration and never rewritten. The
kiosk smoke now also checks that key stays unwritten.

F18: dialogs.styles.ts kept rules no markup ever matched: .curbind,
.btn.alignall, .backupactions (and its 520 px twin), .help-inline-label and
hp-dialog .body .temprange .tempin (the room dialog renders hpf-temprange;
the bare .tempin stays). No source, demo, test or doc builds these class
names dynamically. With the dead .backupactions gone, the .backupcounts pair
became editor-only, so the #805 ownership ratchet moves it to the lazy
editor sheet. The overridden supportbody gap was already removed by #805;
the effective gap stays 6 px. Computed styles of the View, the static card
and the settings, support, backup, rules and room dialogs are identical to
dev at 1100 and 360 px in both themes (scripts/dev/editor-styles-equivalence).

Issue: #813
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 12:37:54 +00:00
Claudeandclaude[bot] c46ccc506e fix(iso): keyboard focus lifts a marker over its 2.5D neighbours (#813)
The 2.5D View lays every marker on z-index 2 with a (0,4,0) selector
(plan.styles.ts); the focus layer `.dev:focus-visible { z-index: 5 }` is
(0,2,0), so a keyboard-focused marker stayed under the markers it overlaps
while Flat lifted it. Focus now repeats its layer at (0,5,0) for that View.
The rule sits before the #809 endpoint rules of the same weight, so a
focused Zigbee endpoint (the unhovered neighbour included) stays on 8; hover
and Flat are untouched, and focusing calls nothing.

Witnesses: smoke_iso_tiles moves real Shift+Tab focus onto the lower of two
overlapping ordinary markers and checks the computed layers and a
differential raster probe of the overlap in both themes, with hover and Flat
as controls; smoke_device_battery_zigbee checks focus with a hovered Zigbee
link (endpoint stays 8, an ordinary focused marker 5) and that focus starts
no scan, service or dialog. Two registry mutants. ISOMETRIC.md states the
layer.

Issue: #813
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 12:37:54 +00:00
Claudeandclaude[bot] 418b10e59b fix(continuity): paint a new opening with the projection of its own plan (#813)
Both cards keep the device projection of the last complete frame while a
candidate waits for its paint barrier, but adopt the new config at once. The
first frame after another client adds a door therefore painted the new door
with the projection captured for the OLD plan, which never held its contact:
a missing door state draws an open door, and the 0.6 s leaf transition then
"closed" a door nobody touched. The full card had the same defect, so the
shared code changes, not the space card alone.

A RenderDeviceSnapshot now records the geometry it was captured for (the
config fingerprint and space of the static card, the config epoch of the
full card), and selectRenderDeviceSnapshot paints the projection that
matches the frame's geometry; the held projection is still painted with the
old geometry, the staged/committed lifecycle and the token barrier are
unchanged, and the active-registry projection is still the only HA source.

Witnesses: smoke_visual_continuity samples every presented frame and the
transition events of both cards for a new closed door, new open, missing,
unknown and unavailable contacts, a real open/close (still animated), the
door already on the plan, a static-card space switch, a newer event inside
the waiting candidate and a warm remount; unit tests cover the selection.
Three registry mutants.

Issue: #813
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 12:37:54 +00:00
Claudeandclaude[bot] 107491d7ad fix(kiosk): end the stage gesture when the scale dialog takes over (#813)
The 3 s hold on an empty kiosk stage opened the scale dialog from a timer,
but the press stayed in the stage pointer map: a mouse has no implicit
capture, so its release is delivered to the modal dialog and the stage never
sees it. The next hold then read as a second finger (no dialog, and the
release became a room tap that zoomed the plan), and the live runtime kept
deferring every Home Assistant state change while a pointer looked active.

The hold now lives in a small controller (src/kiosk-hold.ts) that owns the
one timer and the pointer that armed it. Firing ends the whole stage gesture
through _interruptViewGesture before the dialog opens; release, pointercancel
(the cold kiosk has no editor runtime to clear the timer there), a lost
capture of the holding pointer, window blur and disconnect all end it as
well. No synthetic pointerup is created.

Witnesses: smoke_kiosk_scale_no_editor gains three real mouse holds in a
row (the release is observed on the dialog) and the presses that must not
open the dialog (tap, cancelled touch, lost capture, window blur, a press
across detach/reattach, pinch), with no service call and no editor request;
test/kiosk-hold.test.mjs covers the controller. Six registry mutants.

Issue: #813
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 12:37:54 +00:00
claude[bot] 9c86e62093 docs: review document for #820
Issue: #820
User-Visible: no
2026-10-07 12:35:27 +00:00
claude[bot] 8f11c9e90a docs: review document for #819
Issue: #819
User-Visible: no
2026-10-07 12:29:41 +00:00
claude[bot] 4789c3f636 docs(reviews): индекс после сдвига каталога (#805)
Issue: #805
User-Visible: no
2026-10-07 09:41:11 +00:00
claude[bot] 5e4cbf5bbf docs: review document for #805
Issue: #805
User-Visible: no
2026-10-07 09:41:08 +00:00
Claudeandclaude[bot] 3149a3d7f4 test(smoke): judge the first editor frame once the control is in the flat tree (#805)
Validate (Chromium 151) failed six first-frame checks of
smoke_editor_styles_lazy with an empty computed `display` while both editor
sheets were already adopted. The MutationObserver callback runs in the
microtask right after the card renders a fresh hp-dialog, before that dialog's
own first update renders its <slot>: the control is an unslotted light-DOM
child, outside the flat tree, and Chromium 151 gives such an element no
computed style at all (141, used locally, still computed one). An empty value
was taken for a verdict.

The judgment now waits for the element to take part in rendering: connected
and slotted by every shadow host on its way up, with a non-empty value. It is
taken at the update boundary - after updateComplete of the card and of every
Lit host above the element - which is still before any task, so an adoption
deferred to a setTimeout stays red; only if that is not enough it falls back
to the next animation frames. Every check reports the phase it was judged in.

The verdicts are parked in a page slot and polled with waitForFunction:
awaiting a long-pending promise inside page.evaluate was intermittently
reported by CDP as "Resulting promise was garbage collected" under CPU
throttling. The image-palette opener now waits for the render that shows the
image tool before using it (a plain capability field triggers no render).

Issue: #805
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 09:20:42 +00:00
Claudeandclaude[bot] 2f03167c30 docs(styles): computed-style equivalence probe and the lazy editor sheets (#805)
scripts/dev/editor-styles-equivalence.mjs is the reproducible AC4(b) proof:
it opens the same surfaces in the bundle of this tree and of a base tree and
compares every computed property (custom properties included) of every
element of the card root, the host and nested hp-device-preview roots - warm
editor surfaces and View at 1100/360 px in light and dark, plus cold View,
space card and onboarding. A rule multiset diff (styles-diff.mjs) cannot see
a winner flipping between two equally weighted rules; this can, and it is
how the three never-applied support declarations were found.

ARCHITECTURE §Styles names the lazy sheets, their adoption and guards.

Issue: #805
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 09:20:42 +00:00
Claudeandclaude[bot] e785b88a23 test(smoke): editor-only CSS stays off the cold View and styles the first editor frame (#805)
demo/smoke_editor_styles_lazy.mjs exercises the production bundle:

- AC2: a cold View at 1100 px and 360 px, a kiosk card, a space card and the
  onboarding form hold no editor sheet and no --hp-editor-tray-* on the host,
  request no editor chunk, and onboarding's shared rows keep their layout;
- AC3: on cold tabs every surface family (settings, support, plan tray,
  optimize, backup, room, wall thickness, catalog, rules, device dialog with
  radar and vacuum sections, radar wizard, vacuum fit, furniture and image
  palettes) is observed by a MutationObserver in the very first frame its
  control element exists - already styled, editor sheets in the canonical
  slots exactly once; back in View the editor sheets change no element;
- AC5: a second card adopts into its own root with the shared sheet objects,
  a warm remount revives the dialog styled, a disconnect during import and a
  failed first attempt adopt once, a module of another build adopts nothing;
- AC7: opening the editor requests only chunks of known graphs.

smoke-links ties the sheets and the adoption to it; the minifier witness now
names the lazy sheet. Mutants: the adoption call dropped from the runtime
constructor and a page-wide "adopted" flag (browser guards, inventory
updated), .btn moved into the lazy sheet (unit).

Issue: #805
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 09:20:42 +00:00
Claudeandclaude[bot] 2003959700 test(styles): guard ownership of editor-only CSS and its adoption (#805)
The move is only worth something while it holds. Two guards keep it:

- scripts/bundle-budget.mjs judges the built graphs by content
  (EDITOR_STYLE_MARKERS, after SUPPORT_LAZY_MARKERS): moved selectors and the
  tray's --hp-editor-tray-* are absent from initial View and onboarding and
  present in the lazy editor graph; .btn, .editorloading, .recoveryoverlay,
  .vaccalbar and .floorrow stay in the first frame. A rule back in the eager
  sheet, the tray back in static styles or the sheet behind its own import()
  turns the budget red; bundle-assets pins each of those.
- test/editor-dialog-styles.test.mjs judges the source tree with the spec's
  selection rule on a TypeScript import graph (import type and import() do
  not count): every lazy selector belongs to the editor runtime alone, also
  with hp-device-preview counted as View (own root with cardStyles); the
  tray sheet styles only the tray its renderer draws; no lazy selector
  exists in an eager sheet in or out of its at-rule; the reverse ratchet
  keeps any editor-only rule out of dialogs.styles.ts. Adoption runs on fake
  roots, with adoptedStyleSheets and through Lit's <style> fallback: canonical
  slots, once per root, one sheet per document, nothing without a root.

Issue: #805
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 09:20:42 +00:00
Claudeandclaude[bot] 19153ce4b1 perf(styles): adopt editor-only dialog CSS with the lazy editor runtime (#805)
The card's static styles carried 233 rules of dialogs.styles.ts and the
whole contextual-tray sheet that only the lazy editor runtime ever renders:
every cold View downloaded, parsed and matched about 25 KB of CSS it never
applies. By the selection rule of the spec (each selector carries a class
used in the editor runtime graph and in no source of the View graph or of
another lazy graph; rules shared with onboarding, backdrop-pick or the panel,
dynamic and dead classes stay eager; a selector moves with all its at-rule
twins or not at all) those rules now live in src/styles/editor-dialogs.styles.ts.

HouseplanEditorRuntime's constructor - the loader's create() step, after the
exact-build fingerprint check and before install - adopts that sheet right
after the eager dialogs sheet and the tray sheet right after cardStyles, the
slots they held before, once per shadow root, one sheet object per document,
with Lit's <style> fallback in the same order. No new loader, chunk or request.

Three declarations that an equally weighted later eager rule always overrode
are dropped instead of moved, so they cannot start winning from the later
sheet: gap of hp-dialog .supportbody and, at 520 px, padding-inline of
.supportbody and .supportfooter. Accepted frames stay as they are.

Measured on one toolchain, base a106b717 vs this tree: initial View
303 046 -> 298 552 B gzip (-4 494, -24 690 B raw), lazy editor 245 076 ->
250 460 B gzip (+5 384, owner-accepted). LAZY_EDITOR_GZIP_CEILING is
recalculated once to fact + 1 000; initial ceilings and budget unchanged.

Issue: #805
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 09:20:42 +00:00
claude[bot] a55085831f docs: review document for #803
Issue: #803
User-Visible: no
2026-10-07 09:20:29 +00:00
claude[bot] 60e03bd342 docs: review document for #803
Issue: #803
User-Visible: no
2026-10-07 09:10:21 +00:00
claude[bot] 11db092982 docs(reviews): индекс после сдвига каталога (#815)
Issue: #815
User-Visible: no
2026-10-07 08:18:08 +00:00
claude[bot] a92cf7b645 docs: review document for #815
Issue: #815
User-Visible: no
2026-10-07 08:18:04 +00:00
Matysh 6636328402 Strengthen battery pointer passivity witness
Issue: #815
User-Visible: no
2026-10-07 11:01:14 +03:00
claude[bot] 7661133f12 docs(reviews): индекс после сдвига каталога (#812)
Issue: #812
User-Visible: no
2026-10-07 07:54:14 +00:00
claude[bot] bafb6bce1c docs: review document for #812
Issue: #812
User-Visible: no
2026-10-07 07:54:10 +00:00
Matysh f01e627292 Fix dependency and regression-test reliability
Issue: #812
User-Visible: no
2026-10-07 10:33:11 +03:00
claude[bot] fd95054999 docs(reviews): индекс после сдвига каталога (#811)
Issue: #811
User-Visible: no
2026-10-07 07:22:59 +00:00
claude[bot] 083a491fb8 docs(reviews): индекс после сдвига каталога (#811)
Issue: #811
User-Visible: no
2026-10-07 07:10:19 +00:00
claude[bot] 3df9933efe docs: review document for #811
Issue: #811
User-Visible: no
2026-10-07 07:10:16 +00:00
Matysh 588ea8b27b fix(process): make review ranges and derived data reproducible
Validate rebased issue history without importing unrelated dev commits.
Keep verdicts, counts and findings inside the document's own review round.
Generate the final review index with the accepted tree while retaining the
trusted integration controller and its Git/CI policy. Reconcile only derived
browser-inventory numeric cells and share structural checks with the cheap gate.
Record the full catalogue audit and the bounded F30 acceptance investigation.

Issue: #811
User-Visible: no
2026-10-07 09:56:00 +03:00
claude[bot] 20b69d872c docs: review document for #814
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Issue: #814
User-Visible: no
2026-10-07 05:59:44 +00:00
claude[bot] a07f7493e2 docs: review document for #811
Issue: #811
User-Visible: no
2026-10-07 05:59:38 +00:00
claude[bot] 9628640a50 docs: review document for #813
Issue: #813
User-Visible: no
2026-10-07 05:58:56 +00:00
claude[bot] 42960896d4 docs: review document for #812
Issue: #812
User-Visible: no
2026-10-07 05:58:14 +00:00
Claude a106b71762 Release v1.80.0-beta.7 candidate
Ship the reviewed wave of 2026-10-06/07: Zigbee link snapshots turn stale
after an hour and link lines pass over battery indicators, with the far
endpoint lifted above the line in 2.5D (#808, #809); the radar on-plan setup
shows the room outline again (#774); the kiosk scale dialog opens without the
editor runtime (#763); space-card markers and opening symbols keep their own
drawing (#764); the summary panel recomputes the total area only for changed
floors (#769); plus the process, review and performance-harness fixes (#766,
#768, #770, #777, #778, #779, #810). Rebuild the committed bundle and tighten
the beta ratchets to the candidate's facts.

Issue: #763
Issue: #764
Issue: #766
Issue: #768
Issue: #769
Issue: #770
Issue: #774
Issue: #777
Issue: #778
Issue: #779
Issue: #808
Issue: #809
Issue: #810
User-Visible: yes
Release: v1.80.0-beta.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 07:56:24 +03:00
claude[bot] df69718cec docs: review document for #805
Issue: #805
User-Visible: no
2026-10-07 04:51:34 +00:00
claude[bot] 150b5a166b docs: accept derived artifacts on dev for v1.80.0-beta.7
Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697).
Прогон: https://github.com/Matysh/houseplan-card/actions/runs/37573076137
Скриншоты документации: отпечаток исходников; изменённые кадры: device-display-preview.

Issue: #697
User-Visible: no
2026-10-07 04:48:45 +00:00
claude[bot] aca88acf4d docs: ship review for v1.80.0-beta.7
Пакетное ревью задач track:ship перед бетой (PROCESS.md §11.7).
Задачи: 763. Итог: High 0 · Medium 0 · Low 0.

Issue: #696
User-Visible: no
2026-10-07 03:15:47 +00:00
claude[bot] 34a428711e docs(reviews): индекс после сдвига каталога (#769)
Issue: #769
User-Visible: no
2026-10-07 02:53:01 +00:00
claude[bot] 11ed68e4ba docs: review document for #769
Issue: #769
User-Visible: no
2026-10-07 02:52:56 +00:00
Claude d7b6bdfbf2 chore(monolith): raise bundleBytes for the per-floor area memo and build counters (#769)
bundleBytes 2 715 446 → 2 718 072 (+2 626, over the 2 000 band). The branch
base (dev f19087bf) already builds 2 717 213 (+1 767 over the baseline,
inside the band, from tasks merged since #807 raised it); #769 adds +859 by
measured rollup builds of each commit: +651 for the per-floor clean-area
memo of the summary panel and the shared floor record key, +546 for the
floor-cache build counters the #735 guard judges (nine miss branches and the
clean-floor build hook), −338 for the single key module that replaces the
copies in the resize runtime and drops its dead union seed. hostRefs 5 067
and portMembers 354 stay inside their bands and are not touched.

Issue: #769
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 05:21:53 +03:00
Claude 5a1b0c20d4 refactor(cache): one source for the wall-union and bodies keys and pool entry (#769)
The #744 key formats were copied: the wall-union key `${floorKey}|${rooms}`
in the card's miss branch and three times in the resize runtime, the
physical-bodies key `${floorKey}|${cellCm}|${gridPitch}` in the card, the
runtime twice and the LED editor, the pool bound 8 as a literal three times
and the `sourceFingerprint` attachment twice. A copy that drifts makes the
resize lookup and the cancel alias stop hitting without any error.

src/floor-geometry-key.ts now owns `wallUnionKey`, `physicalBodiesKey`,
`WALL_UNION_POOL_LIMIT`, the entry factory `wallUnionPoolEntry` (it attaches
`lightGeometryFingerprint` of the record the union was built from) and
`writeWallUnionPool`. The card's miss branch, `_rszEdgeDown`, the
`_rszCancelDrag` alias and both bodies re-keys use only them; the LED editor
keys its own bodies with the same helper. `_rszEdgeDown` now reads the key
of the shown floor's model, as the card does, instead of its own room list.

The union seed in `_rszAcceptPreview` is removed, not moved: since #451 the
live preflight publishes no artifact (`artifact: null`), so the seed never
ran. A host render mid-drag builds the preview union once through the card's
miss branch and every later render hits it. The bodies re-key stays: a
resize never moves partitions, columns or a partition opening's host, and
the smoke proves the re-keyed bodies equal a fresh card's on the preview
record, a door cut into a partition next to the moving wall included.

AC8 in demo/smoke_floor_geometry_cache.mjs on the held AC2c drag: (a) host
renders mid-drag build no union and the drawn union equals a fresh card's on
the preview record, paths and `sourceFingerprint`; (b) cancel and the way back
to View build no union, bodies, contours or clean floors, and the union and
bodies equal a fresh card's on the stored record; (c) the drag never rebuilds
the bodies. Unit: the key formats, the bound and the entry
(test/floor-geometry-key.test.mjs) and a source contract that no copy of the
formats, the pool write or the fingerprint attachment is left outside the
module (test/performance-contract.test.mjs, red on dev). Two registry mutants
(bodies re-key in its own format, union fingerprint from the stored record),
caught by the smoke and checked by hand, as were the two #744 mutants whose
anchor moved; browser inventory 247/200.

Budgets: initial View 303032 B gzip (ceiling 302803 + 2000), lazy editor
245094 B (ceiling 244872 + 2000); monolith ratchet unchanged.

Issue: #769
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 05:21:52 +03:00
Claude ff4c69ac28 test(perf): judge the #735 switch cycle by floor-cache build counters (#769)
The #735 guard compared cache sizes around the warmed twelve-switch cycle.
Once an LRU is full a cold build evicts one entry and every size stays: a
union planted cold in the full pool (8/8) changed no field of the snapshot and
the guard stayed green. Its `openingWallIndex` field read
`card._openingWallIndexCache ? 1 : 0` of a Map and was always 1.

The card now counts real builds of each floor-geometry cache in its miss
branch (`_floorCacheBuilds`: wall union, inner contours, clean floors via a
`cleanFloorForRoom` hook, opening wall index, light barriers, Glow clips,
physical bodies, opening tunnels, light physical bodies) — never on a hit, a
recency refresh, a resize seed or alias, or a clear. One pure decision
(demo/performance/switch-cycle-guard.mjs) judges the window for the benchmark,
the floor-cache smoke and the unit tests: any build of a pooled family fails
and names the family and its growth; the single-slot families may rebuild once
per switch; 2.5D keeps `_isoStructuralBuildCount`. A base bundle without the
counters reads null and is not judged; a candidate must expose them. Each row
reports `switchCycleBuilds` with the size snapshot as a diagnostic (the real
index size there). The budgeted cacheEntries/cacheGrowth keep the index as the
presence flag their `openingWallIndex: 1` ceilings were set on: no budget moves.

Counted run, one sample per profile on the dev base with the counters: every
large-house* profile and isometric-stage3-dense-v1 build 0 in every judged
family, 12/12 physical bodies and opening tunnels, nothing in 2.5D; no family
moved to the reported list. The same runner against a dev bundle reports
`supported: false` and passes.

AC7: demo/smoke_floor_geometry_cache.mjs fills the pool to 8 and drops one
floor's union; a four-switch cycle builds exactly that union (+1, an eviction,
every size the same) and the decision names "wall union" — the old size
comparison sees nothing. AC1 of the smoke also reads the counters. Registry
mutant: a counter that skips the evicting miss (caught by the smoke, checked
by hand); browser inventory 245/200.

Issue: #769
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 05:21:45 +03:00
Claude bf771c320b perf(summary): memoise the total area per floor by its record content (#769)
The summary panel's Total room area was memoised on the global config epoch:
any `_saveConfig()` or adopted server config — a rename on one floor, a
device or a setting — made the memo stale and the next View render ran
`cleanFloorAreaSteps` over every floor again (71 portions on large-house).

The area of a floor reads only its stored record, the model built from its
own record and constants, so each floor's value is now memoised by the
content key of those records. The key comes from the helper extracted out of
the #744 reader (`floorRecordKeyMemo`, src/floor-geometry-key.ts): one
fingerprint per floor and epoch, both records when a preview or a duplicate
id makes them differ, and independent of the shown floor. The total is the
sum of the floor values in `models` order, summed anew on every pass, so it
stays bitwise equal to a fresh `totalCleanFloorAreaM2`. A failed floor is
memoised as `null` until its record changes; the freshness check stays one
epoch comparison; the #509 portions, skeleton and the old value on screen
during a recomputation are unchanged; a lifecycle reset clears the memo.

The furniture and stairs magnet keeps its epoch key by measurement (its own
edits change the floor record anyway); the decision is recorded at the key.

Unit: test/summary-panel-area-memo.test.mjs (AC1–AC3 on a three-floor fixture
with walls, a door, a partition, a column and a stair; floors counted through
the injected geometry and portions through the step generator), and
test/floor-geometry-key.test.mjs for the shared key helper, now in the test
build. Two registry mutants (epoch key, rooms-only key); the #744 and #509
anchors follow the moved lines.

Issue: #769
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 05:21:35 +03:00
claude[bot] 805b435a04 docs(reviews): индекс после сдвига каталога (#809)
Issue: #809
User-Visible: no
2026-10-07 02:08:24 +00:00
claude[bot] 2c38e546e5 docs: review document for #809
Issue: #809
User-Visible: no
2026-10-07 02:08:21 +00:00
Claude 7fb609e48a fix(zigbee): keep the neighbour endpoint core above routes in 2.5D (#809)
In the 2.5D View every marker sits on z-index 2 through
`.stage.projection-iso.mode-view .dev` (0,4,0), which beats the #464
endpoint lift `.dev[data-hp-zigbee-topology-endpoint]` (0,2,0). The
hovered endpoint survived only through its higher-specificity hover
duplicate, so the unhovered neighbour endpoint stayed under the route
layer (z 7) and the line crossed its face. Flat was unaffected.

The endpoint contract is repeated for the 2.5D View at (0,5,0), next to
the #464 rules. Narrowing the 2.5D rule with :not(endpoint) was rejected:
it would reach (0,5,0) and beat the hovered non-endpoint's z-index 5.
No stylesheet order or new file changes; .oplock is untouched.

Witnesses in demo/smoke_device_battery_zigbee.mjs:
- {flat,iso}_neighbourCorePaintsOverRoutes: along the drawn local route,
  inside the neighbour core (outside its battery frame) the route layers
  change no pixels, outside both cores they do. Red on dev in 2.5D
  (16 of 16 core samples changed), green here (0 of 16).
- iso_nonEndpointKeepsBaseLayer / iso_hoveredNonEndpointKeepsHoverLayer:
  computed z-index 2 for unlinked markers and 5 for a hovered marker
  outside the topology; the :not(endpoint) variant turns the latter red.

Mutant zigbee-iso-neighbour-endpoint-under-routes removes the new rule;
the browser guard is listed in the inventory (paint 44, total 248).

Issue: #809
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 04:51:20 +03:00
claude[bot] 53d3c79065 docs(reviews): индекс после сдвига каталога (#778)
Issue: #778
User-Visible: no
2026-10-07 01:21:40 +00:00
claude[bot] c0fe5dd22c docs: review document for #778
Issue: #778
User-Visible: no
2026-10-07 01:21:37 +00:00
Claude 6a9fdee0be docs(perf): name the product causes of the resize Long Task (#778)
Code review r1 of #778 (Medium, in scope): point 3 of the brief asks to hand
the concrete product optimization to a product task, and the closing comment
pointed at a findings document that does not resolve from the repository.

The owner's directive for this wave forbids filing new issues, so the two
candidates are written down where the next reader of a failure looks: the
live-label area union in _rszEdgeLabels (about 24 % of the task) and the
whole-floor masonry union of the live preflight (about 70 %, BigNumber in
polyclip-ts about 29 % of the self time), with the measurement they come
from. The same entries sit in the owner's findings document (F23) for
triage. No product code, budget or ceiling changes.

Issue: #778
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 04:00:18 +03:00
claude[bot]andClaude 59527ee04a docs: review document for #778
Issue: #778
User-Visible: no
2026-10-07 04:00:18 +03:00
Claude 6b8a51a257 test(perf): attribute the resize Long Task of the interaction profile (#778)
Validate on #762 failed once on longTask.editorSeries.maxSingleMs (135/129/163
against 150 ms) and passed on the re-run (127/132/132) without a change. The
report carried only the task's maximum and the physical preflight time, so
nobody could tell whether the extra time was the preflight, the rest of the
resize step, or something else on the runner.

The investigation found no measurement defect. The resize part's single Long
Task is the task of the one accepted pointer move: ResizeController.move with
the live physical preflight inside project, publish and the live labels. No
Lit update, forced layout or paint is in it (Long Animation Frames and a
Chrome trace show the live paint and the frame's layout in the next rendering
step); pointerdown stays in an earlier task. Across 202 CI samples (14 Full
Performance runs and both Validate attempts) the task minus the preflight is
20-47 ms with a within-report sigma of at most 2.2 ms; the failed sample had a
normal preflight and +25 ms elsewhere, plus extra Long Tasks in its load and
space-switch windows: one slow sample of the whole page on top of a level that
sits 3-25 % under the ceiling.

Each interaction row now reports interactionDiagnostics.resizeLongTask: the
judged task split into preflight, projection, publish, labels, the rest of the
move, Lit updates and other time, plus the following frame's render and any
forced style/layout from Long Animation Frames. The split is computed by a
self-contained function from the entries of the very window the gate judges,
and the wrappers and observer are installed before the runner's yield, so
nothing new runs inside the measured task. A base without the
ResizeController.move callback shape reports supported: false with a reason
instead of zero shares; the contract declares _resize.move as an optional
method. The runner prints one line per sample for the job log. No budget,
window or product code changes; the README records the series, the method and
how to read the next failure.

Issue: #778
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 03:50:54 +03:00
claude[bot] 114c171ac3 docs(reviews): индекс после сдвига каталога (#770)
Issue: #770
User-Visible: no
2026-10-07 00:26:16 +00:00
claude[bot] 15c12b752c docs: review document for #770
Issue: #770
User-Visible: no
2026-10-07 00:26:16 +00:00
Claudeandclaude[bot] cb7faab3e2 test(perf): pull the Long Task ceilings of the switchCycle profiles down to the warmed level (#770)
longTasks.maxCountP95 / maxTotalP95Ms sum every window of a sample,
switchCycle included. Since #735 that window is warm, and the 30 tasks /
12000 ms of the cold cycle sat 1.25-3.2 times above the family maxima;
performance_smoke judges only these absolute ceilings.

Series (CI only, as #747): every Full Performance run 2026-10-01..10-05,
14 runs, each with a runner containing #735 (5e318168), both sides,
7 samples, one runner type (linux x64, Chromium 151.0.7922.34, Node
22.23.3) - 27 reports / 189 samples per profile. Point = what the ceiling
judges, the nearest-rank p95 of a report (its largest sample). The window
is warm in all of them: largest single Long Task in switchCycle 190 ms
against 427-1008 ms for the smallest cold floor switch; the #735 guard
fired in none.

                      count p50/p95/max   total p50/p95/max (ms)
  large-house-v1         10 / 12 / 13     2826 / 3140 / 3301
  plan-snap              14 / 14 / 15     3387 / 3610 / 3759
  interaction            12 / 13 / 13     3056 / 3242 / 3289
  isometric              20 / 21 / 22     4761 / 5101 / 5104
  stage3-dense           23 / 24 / 24     5516 / 5935 / 5935
  isometric-backdrop     21 / 22 / 22     4931 / 5286 / 5331

Rule (#747, per metric, one number per family): first step (one task,
50 ms) at or above 1.15 x M, no higher than 1.2 x M:
  flat (lh, plan-snap, interaction, interaction smoke)
    30 -> 18 (1.15 x 15 = 17.25, +20 %), 12000 -> 4350 (1.15 x 3759, +15.7 %)
  2.5D (isometric, dense, backdrop, isometric smoke)
    30 -> 28 (1.15 x 24 = 27.6, +16.7 %), 12000 -> 6850 (1.15 x 5935, +15.4 %)

Out of M: the base of the v1.79.0 stable comparison 37149329461 is
v1.78.0 (7d4d75bd, 09-28, before #735 and #694/#725/#739). On the same
runner as its candidate it reads 1.2-2 x higher - code, not noise - and
serves as the known slower variant: its dense sum of 8754 ms fails 6850;
its other points (isometric 24/6783, backdrop 22/5159, flat at most
17/3959) pass and stay the relative comparison's job. The one 3-sample
interaction smoke of the period (36910217188, 12/3047) passes. The
relative half (ratios, 3/5 tasks, 150 ms) is unchanged.

The test pins one pair per family in every file, replays all 162 points
and the smoke point through the --absolute-only evaluation, recomputes
the rule, requires doubling to fail on each metric and the v1.78.0
verdicts above, and keeps the relative fields; #507's count pin follows
to 28. Red on the previous budgets, and red with 12000 left in the
backdrop file.

The README records the series and the rule ("CI contracts") and, in
"Changing budgets", the recalibration method; it also drops the stale
#585 text: the 450/150/120 gesture allowances returned to 150/60/75 with
6f226a5b (v1.77.0-beta.2), and the backdrop twin carries the iso count
allowance too.

Issue: #770
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 00:26:16 +00:00
Claudeandclaude[bot] 283200c789 fix(perf): name the measured profile in the 2.5D runner contract errors (#770)
Three profiles walk the 2.5D candidate contract of the large-house
runner - large-house-isometric-v1, its Stage 3 dense twin and the
backdrop twin of #743 - but both of its errors said
"large-house-isometric-v1 candidate has no ...", so a dense or backdrop
failure pointed at the wrong profile and job.

The two checks move into assertIsometricCandidate(card, profile, stage)
in demo/performance/card-contract.mjs, self-contained like
assertCardContract and injected the same way with toString(); the
runner calls it with the profile it measures for the pre-#448 Labs hook
and for the renderer contract. Conditions and wording are otherwise
unchanged, so a base or candidate that passed before passes now.

The unit test runs both the export and its serialized copy for all three
2.5D profiles and requires "<profile> candidate has no Labs fixture hook"
and "<profile> candidate has no renderer contract"; it also pins that the
runner injects and calls it and no longer names one profile for all. Red
on the previous runner (the anchor), and red when the message is
hardcoded back to large-house-isometric-v1 (dense profile).

Issue: #770
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 00:26:16 +00:00
Claudeandclaude[bot] 9906f68173 ci(perf): budget and harness diffs select the perf-smoke profiles they judge (#770)
The isometric and interaction perf-smoke profiles joined Validate only on
a src/** diff. A change of their budgets alone - or of the shared
large-house runner, its fixture, the private card contract or the
evaluator - ran performance_smoke with Glow alone, so a new ceiling was
first judged by the beta candidate, and the profile set of the reuse key
stayed "glow" for a diff that changed what that set is judged by.

perf_iso and perf_interaction now also take their own budget files (the
smoke and the full profile's, which the smoke repeats, #473 AC4) and the
large-house harness, which brings in both. The Glow runner and
compare.mjs stay out: the Glow smoke always runs and exercises them; the
Stage 3 dense fixture belongs to a profile without a smoke. Tests still
select nothing.

The new unit test reads the profile of each smoke step from validate.yml
(its --budgets file), finds every budget of that profile in
demo/performance by its "profile" field, and requires each to select the
profile, change the profile set of the reuse key computed from the
workflow's own set lines, and be an input of the performance_smoke hash.
Budgets of profiles without a smoke select neither. On the previous
classifier both #770 tests are red:
"demo/performance/budgets-isometric-smoke.json судит
large-house-isometric-v1 - профиль обязан войти в смок".

Issue: #770
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 00:26:16 +00:00
claude[bot] 87ff8bd80d docs(reviews): индекс после сдвига каталога (#764)
Issue: #764
User-Visible: no
2026-10-07 00:16:21 +00:00
claude[bot] f6f0b96f6e docs: review document for #764
Issue: #764
User-Visible: no
2026-10-07 00:16:20 +00:00
Claudeandclaude[bot] 5fddc75514 fix(space-card): key markers and opening symbols by space and id (#764)
(iconDevs.map) and opening symbols (resolvedHosted.map) were still bare
map() calls. The card takes the full cardStyles, so `.device-core`
(background/color 0.15 s), `.device-shell-frame` (border-color) and the
door `.op-leaf`/`.op-arc` (0.6 s) transition, and Lit's positional reuse
turned a list change into an animation of something that never happened.

Measured on dev with a browser witness (one card element, doors on a
partition in both spaces, neighbours in different states):
- setConfig f1 -> garden: 4 nodes outlive the switch (closed door
  hp764-g-shut drawn in frame 0 at f1's open angle, matrix(0,-1,1,0); the
  same-id door hp764-shared in f1's closed state; mower and gate start in
  the ceiling light's orange), 16 transitionrun, 39/65 frames off-final.
- config push (a door inserted first, the first marker hidden): 11 nodes
  handed to a neighbour, 35 transitionrun, 38/68 frames off-final.

Both lists are now keyed(space.id, repeat(..., id, ...)), the shape of the
full card (#525/#534) and of the rooms (#745). The outer key makes a door
with the same id on another floor a new object; an id-less opening keys by
its index. The transitions stay: a real contact or light change on the same
object still animates on the same node, and the witness asserts it (catches
a `transition: none` fix). The #742/#745 note in plan.styles.ts now names
these lists too.

Witness: demo/smoke_space_card_identity.mjs (new, linked in smoke-links to
src/space-render.ts). It samples computed styles per frame and compares
each object to its settled look, not just DOM ids (#528). Red on dev as
above, green after: 0 transitions, 0 frames off-final, 0 reused nodes.
Mutants (checked by hand, each red on its own case): markers by map() and
openings by map() (config push), openings without the space key (same-id
door on setConfig). The marker space key has no observable case here, as
device ids are global; it is kept for parity with #534.

Issue: #764
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 00:16:20 +00:00
claude[bot] b2e98af692 docs(reviews): индекс после сдвига каталога (#810)
Issue: #810
User-Visible: no
2026-10-07 00:10:10 +00:00
claude[bot] 4dc0aed4a8 docs: review document for #810
Issue: #810
User-Visible: no
2026-10-07 00:10:02 +00:00
Claude 8f72115a49 fix(wait-verdict): tell a red Validate from a rebase conflict before review (#810)
Both pre-review comments of _process.yml start with «**Ревью не
запускалось:**»: the rebase conflict and the red or missing Validate on
the material. The waiter matched the shared prefix alone and printed
«конфликт разрешает автор» for both, sending the author to rebase a branch
that has no conflict instead of reading the Validate run.

PIPELINE_EVENTS now holds three signs instead of one: validate-red (its
own line: Validate on the material is red or missing, read the run, a
code change is not required), conflict (the rebase conflict, the old
line) and not-run (the family with an unrecognised continuation: read the
comment). All of them exit 3, as before. The two cause regexes used to
live in process-metrics.mjs as a copy of the templates; process-metrics
now takes them from PIPELINE_EVENTS by kind and re-exports them under the
same names, so the waiter and the K3 return reasons share one source.
The process-metrics contract test now finds the common prefix under kind
not-run, which is what the family entry is called now.

Tests build the bodies by running the two _process.yml steps as the
runner does (runStep, gh and git stubbed), for the conflict and for both
Validate kinds. The three new #810 cases are red on the original
scripts. The new mutant wait-verdict-not-run-validate-unrecognised was
checked by hand: its guard goes red.

Issue: #810
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 02:56:15 +03:00
claude[bot] f19087bfc1 docs(reviews): индекс после сдвига каталога (#779)
Issue: #779
User-Visible: no
2026-10-06 23:35:44 +00:00
claude[bot] bcfb6e1123 docs: review document for #779
Issue: #779
User-Visible: no
2026-10-06 23:35:44 +00:00
Claudeandclaude[bot] 9c5b6aca39 fix(reviews-index): pick verdict and counts from the document's own text (#779)
Review r1 M1: summaryParagraph() took the LAST paragraph starting with
«High: N», trusting «retellings first, conclusion last». The section order
is not fixed: «## Вердикт» comes before «## Унаследовано из r1» in
SPEC-REVIEW-728-r2 and after it in 774-r2/806-r2. An inherited section
quoting an old counter as its own «- High: 2, Medium: 1» paragraph gave the
previous round's numbers (reproduced by the reviewer).

The source is now chosen by structure, not position. ownText() blanks
retelling sections with their subsections and blockquotes (with lazy
continuation), keeping line numbers:
- a retelling section is «Закрытие раунда», «Унаследовано», «Inherited»,
  «Предыдущий раунд», or any level-2+ heading naming a round that is not the
  document's own (round from the file name, else from the «# …-rN» title);
  examples are «Вердикт по находкам r1» in r2 and «Дельта r1 → r2».
The own verdict line, the «## Вердикт» section and the summary paragraph are
searched only in that text. The summary is preferred from the section of the
own verdict line, else the last one in own text. Code blocks stay own text:
reviewers put their §7.2 comment template there (SPEC-REVIEW-288-r1). Only a
«#» inside a block is not a heading. The later fallbacks (verdict mention,
unique counter, headings) read the whole text as on dev. Moving them to own
text fixes some legacy rows but loses verdict recognition in others
(289-r2, 376-r2, 462-r2/r3), which is a separate decision.

Index against dev, every docs/reviews/*.md: still exactly one row changed
(#662 r3 → 🟡 0/2). In legacy/reviews (control, not indexed) there is one
more than before: SPEC-REVIEW-403-r2 yellow → green. Its first «Вердикт»
section is the retelling «## 1. Вердикт r1 и SHA…»; the own «## Итог» says
«Вердикт: зелёный».

The new test covers both layouts (verdict before/after the retelling) and a
§7.2-shaped retelling. All three are red on the branch before this commit
(2/1, 0/0, red 1/0) and green after. New mutant
reviews-index-retold-sections-own; anchors of four neighbours updated. All
five were checked by hand: their guards go red.

Issue: #779
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-06 23:35:44 +00:00
claude[bot] 8b8190b6d5 docs: review document for #779
Issue: #779
User-Visible: no
2026-10-06 23:35:44 +00:00
Claudeandclaude[bot] 6e42b91bc3 fix(reviews-index): a retold previous-round verdict is not the document's own (#779)
INDEX.md showed SPEC-REVIEW-662-r3 as green 0/0 while the document ends
with «**Вердикт: жёлтый.**» and «High: 0 · Medium: 2». The §2.10
«Закрытие раунда r2» section retells the previous round as «Вердикт r2 —
зелёный, … (High: 0, Medium: 0)»; that line also starts with «Вердикт», so
the own-line regex (any ≤60 chars before a colour) and the own-line counter
both took it.

Own verdict is now tiered:
- «Вердикт:»/«Verdict:» with the colon right after the word (markup and
  spaces around allowed, per the §7.2 template), plus the pipeline's own
  anchor line «Вердикт конвейера: `green`» written by review-doc-guard;
- then a line merely starting with the word («Вердикт зелёный; …» in old
  documents), never «Вердикт rN …»;
- the «вердикт … цвет» mention fallback also skips «Вердикт rN».
Counts: own line → «## Вердикт» section → the document's summary paragraph
starting with «High: N» (last one; a wrapped bullet continuation does not
count) → a verdict mention that is not a retelling.

Before/after over every docs/reviews/*.md: exactly one INDEX.md row
changed (#662 r3 → 🟡 0/2). Across legacy/reviews (not indexed, used as a
wider regression corpus) 15 rows changed, all the same class — «Вердикт
r1: жёлтый» / «Вердикт r2 — …» retellings no longer win over the round's
own verdict (or yield «—» instead of a foreign colour).

The colon-only rule taken literally regressed 9 docs/reviews rows whose
only own line is «Вердикт конвейера» and one legacy document with
«Вердикт зелёный;» — hence the pipeline line and the second tier, both
pinned by a test.

Mutant reviews-index-own-verdict-any-tail restores the old any-tail regex;
the guard is red on it (checked by hand). Anchors of three neighbouring
reviews-index mutants follow the changed lines.

Issue: #779
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-06 23:35:44 +00:00
claude[bot] 8e4343ee2e docs(reviews): индекс после сдвига каталога (#774)
Issue: #774
User-Visible: no
2026-10-06 23:34:51 +00:00
claude[bot] 5ecee099c3 docs: review document for #774
Issue: #774
User-Visible: no
2026-10-06 23:34:48 +00:00
Claudeandclaude[bot] e4b4b0e6cd fix(radar): show the room contour in the on-plan setup (#774)
The on-plan radar setup multiplied SpaceModel room contours by 1000 a
second time. They are render units already, so the outline was drawn at
40000… far outside the fixed "0 0 1000 1000" viewBox, and the
administrator placed mount, heading and references on an empty field.
Dropping the multiplier alone is not enough: the fixed board also cut
every room drawn outside the historical square, and the pointer mapping
ignored the letterbox and clamped presses to [0,1].

The setup now frames the plan with the main card's content frame
(docs/CANVAS.md §4, the same items through the editor host port). It
opens on core and switches to all when core would cut the configured
room; the stored view_box remains the empty-content fallback. The frame
is computed once per session. One pure projection serves every layer:
the contour is drawn as is, mount, heading, pending/reference marks and
the live trail go through plan units x NORM_W, and a press maps back
through xMidYMid meet to unclamped plan units, a press in the letterbox
placing nothing. Stored radar data, the calibration solve, Apply/Save
and View are unchanged.

Tests: a unit table for the projection and the frame (offset frame,
letterbox, negative and >1 plan units, outlier core -> all, empty-content
fallback) and an extended smoke_radar_setup (a room outside [0,1] with
view_box [0,0,1,1], vertices and bbox, known presses -> draft and DOM,
frame stability across presses, live trail and resize, Apply, Cancel,
Escape and reopen, a room without contour, an unknown room). Five
unit-guarded mutants are registered.

Issue: #774
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-06 23:16:55 +00:00
claude[bot] de6cc44b89 docs: review document for #805
Issue: #805
User-Visible: no
2026-10-06 23:10:44 +00:00
claude[bot] f9c56c28e4 docs: review document for #769
Issue: #769
User-Visible: no
2026-10-06 23:08:56 +00:00
claude[bot] 3a476697f1 docs(reviews): индекс после сдвига каталога (#766)
Issue: #766
User-Visible: no
2026-10-06 23:05:58 +00:00
claude[bot] 34349dfdbb docs: review document for #766
Issue: #766
User-Visible: no
2026-10-06 23:05:58 +00:00
Claudeandclaude[bot] b44bb82a3a fix(ci): build push and run links from GITHUB_SERVER_URL (#766)
night-red.mjs fell back to `https://github.com/<repo>/actions/runs/<id>` when
the API did not return html_url, and five publishing steps pushed to
`https://x-access-token:$TOKEN@github.com/<repo>`. On github.com nothing
breaks today; on any other server (GHES) the links and pushes would point to
the wrong host while GITHUB_API_URL is already honoured (#751).

- ci-proof.mjs: githubServerUrl(env) - GITHUB_SERVER_URL without a trailing
  slash, github.com when unset; night-red threads it as `server` into
  commentBody and nightRed, html_url from the API still wins.
- _beta-derived, _process (rebase and review document), _ship-review and
  release-review: `server="${GITHUB_SERVER_URL:-https://github.com}"` and
  push_url built from it; every token push uses "$push_url".

Tests: the night step executed on a non-standard server writes comment and
summary links to that host; every token push in every workflow takes its host
from GITHUB_SERVER_URL; the four publishing steps executed with
GITHUB_SERVER_URL=https://ghe.example.test push to that host, and to
github.com when it is empty. Checked: on the previous workflows and with the
hard-coded fallback restored in night-red these tests are red.

Issue: #766
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-06 23:05:58 +00:00
Claudeandclaude[bot] 0bf68d6dd2 fix(hooks): reject a malformed or repeated Spec-Draft trailer at commit-msg (#766)
The draft trailer of #729 (PROCESS.md 11.8) was checked only by rule 10 of
process-gate, on push or in CI: validateCommitMessage returned [] for
`sha256:wrong`. The commit-msg hook now refuses, at commit time, a draft
trailer that is repeated or not `sha256:<64 lowercase hex>`. An ordinary
commit carries no such trailer and is not asked for one; the S4 epoch, the
track and the green SPEC-REVIEW still need the network and history and stay
with rule 10.

The value format (SPEC_DRAFT_VALUE) and the parser (specDraftValues: a
`Spec-Draft` line anywhere in the message, key case-insensitive) now live in
validate-commit-provenance.mjs, and process-gate uses the same function for
rule 10, so the hook rejects exactly what rule 10 would. Editor comment lines
are ignored as before. The CI provenance job runs the same validator.

Tests: wrong, short, upper-case, prefix-less and empty values and a repeat are
refused, a valid draft and an ordinary commit pass; rule 10 and the hook read
the same values; the real .githooks/commit-msg in a temporary repository
refuses both bad commits and accepts the good ones. Checked by hand: with the
check removed from validateCommitMessage all three tests turn red.

Issue: #766
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-06 23:05:58 +00:00
Claudeandclaude[bot] 8c9c0089a4 test(hooks): derive the pre-push fixture from the hook's import tree (#766)
HOOK_FILES in test/pre-push-gate.test.mjs listed the hook's modules by hand.
After #729 process-gate pulled in review-doc-guard and friends, and #737
broke Validate with a new transitive import (model-usage.mjs) that the list
did not carry: the temporary repository lacked the module and the hook died
with ERR_MODULE_NOT_FOUND.

The list is now computed: the hook itself, the scripts it runs by path
(`$repo_root/scripts/...`, today process-gate.mjs and pre-push-gate.mjs), and
the transitive closure of their local imports (static import/export ... from,
side-effect imports, literal dynamic import() and new URL(..., import.meta.url)
reads). gate-small.mjs stays out: the test writes its stub. A referenced file
that does not exist fails loudly instead of shortening the list. On the
current tree the derived set equals the old manual one.

Witness: a copy of the hook tree with a new module imported from
model-usage.mjs (with its own import, a dynamic import and a data read) gets
all four files automatically. Checked by hand: the old manual list with such
an import makes the real-hook test fail with ERR_MODULE_NOT_FOUND, the
derived list passes. Module names in the witness are built from base names,
because check-inputs reads a `scripts/...` string literal as a data leaf and
stops following that module's imports.

Issue: #766
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-06 23:05:58 +00:00
Claudeandclaude[bot] cb97274ea7 test(harness): run workflow steps the way the runner does (#766)
Thirteen test harnesses executed workflow `run:` bodies with their own bash
flags. Four of them used `bash -eo pipefail` "as in Actions", but the runner
executes a step without `shell:` as `bash -e {0}`: pipefail comes only from an
explicit `shell: bash` or from `set -o pipefail` in the body. The harness
supplied protection the step did not have, so a step that lost its pipefail
stayed green in tests (#729, #737, #751); the reverse also happened - the
#793 guard test was red only because of the harness flag.

test/helpers/workflow-step.mjs resolves the shell like the runner (step ->
jobs.<id>.defaults.run.shell -> workflow defaults.run.shell -> unset), maps it
to the runner's command lines (unset -> `bash -e {0}`, bash -> `bash
--noprofile --norc -e -o pipefail {0}`, sh, python, custom templates with
{0}), writes the body to a file and executes it by path. Unsupported YAML or
shells are refused loudly instead of guessed. All step-executing tests now go
through runStep(findStep(...)).

Witnesses: a step whose left pipeline side fails is green without a shell
(negative test) and red with `shell: bash`, job defaults or `set -o pipefail`;
the #751 executed test now also shows that the same real steps without their
pipefail line stay green, i.e. the test sees a removed pipefail; a guard fails
on any test that runs a step with its own bash flags. TESTING.md rule 7 names
the helper.

Issue: #766
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-06 23:05:58 +00:00
claude[bot] 32334a173a docs(reviews): индекс после сдвига каталога (#768)
Issue: #768
User-Visible: no
2026-10-06 23:01:07 +00:00
claude[bot] 42b8004af3 docs(reviews): индекс после сдвига каталога (#768)
Issue: #768
User-Visible: no
2026-10-06 23:01:07 +00:00
claude[bot] 0615c38e5e docs: review document for #768
Issue: #768
User-Visible: no
2026-10-06 23:01:07 +00:00
Claudeandclaude[bot] 1ef639461e fix(wait-verdict): recognise merge outcomes through OUTCOME_SIGNS (#768)
The waiter knew two of the eleven merge-candidate outcomes, and those
through copied prefixes. A red or stuck candidate, a moving dev, a push
refusal (#705) or a failed merge step went unrecognised. In the window where
the comment is already posted but the label still reads S7, the author waited
until the timeout. Once the label moved to S6, only «S7 → S6» was printed and
the reason was lost.

Comments are now classified by merge-candidate's own catalog (outcomeOf)
first, then by the pipeline's own comments as before. The outcomes are
mapped by kind, not collapsed into one error:
- terminal refusals (reject-stale, conflict, validation-red, give-up,
  push-refused[-workflow] at merge and rebase stage, error) — exit 3 with
  their own reason even while S7 is unchanged;
- validation-missing (the merge stopped waiting for Validate) — exit 3,
  with «no code change needed, re-apply S7 after a green Validate»;
- rereview — printed, the wait goes on: the task is already back in S7 and
  the new round starts by itself;
- a green merge has no sign in the catalog — the S8 label reports it (0).
A label change keeps exit 0 and now carries the reason in its lines. The
round anchor (latest S4/S7), the baseline for older comments, blocked and
review-4 are unchanged. «stale» and «merge-conflict» stay in
PIPELINE_EVENTS: they are the fallback for pre-#752 bodies, and
process-metrics.mjs imports them by kind.

Tests use the actual bodies from commentFor/describePushRefusal; each of the
four new cases is red on the original wait-verdict.mjs. The new mutant
wait-verdict-merge-outcomes-unknown was checked by hand: its guard goes
red. AGENTS.md, where the exit codes are described, is updated to match.

Issue: #768
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-06 23:01:07 +00:00
claude[bot] 2561d80a1a docs(reviews): индекс после сдвига каталога (#777)
Issue: #777
User-Visible: no
2026-10-06 22:56:45 +00:00
claude[bot] d6703ac0c2 docs: review document for #777
Issue: #777
User-Visible: no
2026-10-06 22:56:45 +00:00
Claudeandclaude[bot] 2cb58dbab6 test(iso-overlay): match the virtual probe by TypeScript path key (#777)
On Windows the fixture-type test failed with "Cannot read properties of
undefined (reading 'text')". TypeScript normalizes root file names before
it asks the compiler host (C:\repo\test\probe.mjs becomes
C:/repo/test/probe.mjs), while the host compared that name with the
node:path string by ===. The probe was never served, the program had no
probe file, and the verdict read undefined.text. The module-resolution
filter compared dirname(containing) with TEST_DIR the same way, so on
Windows it let ../test-build/* and node:* imports resolve.

The host now compares names by the compiler's own key: '/' separators and
the host's getCanonicalFileName (case-insensitive where the file system
is). readFile serves the probe as well. A source file the check depends on
that is missing from the program now fails with its name, the compiler's
own diagnostic and a truncated list of the files the program knows,
instead of a TypeError.

Portability witness, runnable on Linux: the program builder takes the path
implementation, root and file system, and a new test builds the same
program over this checkout seen as C:\houseplan-card\ through path.win32
with case-insensitive names. It asserts that the probe is found, that the
dead-field verdict equals the native one, and that nothing under test-build
is looked up. With the old === comparisons it is red (probe missing,
test-build looked up); with the old lookup it reproduces the Windows
TypeError. The dead-field and direct-call witnesses are unchanged.

Issue: #777
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-06 22:56:45 +00:00
Claude 083d4d6e33 perf(kiosk): open the scale dialog without the editor runtime (#763)
A 3 s hold in kiosk mode opens the per-screen size dialog. The core card
draws it (`_renderKioskDialog`, `hp-dialog` from the core bundle, core
i18n keys) and the summary-panel runtime, which every card connects on
mount, saves it. Yet `_kioskDialog` was listed in `editorRuntimeRequested`,
so every hold on a wall tablet downloaded the whole editor chunk; with the
chunk unreachable the kiosk also showed "Could not load the editor" (#757
only capped the retries). Dropping it from the condition is the whole fix;
the loader and the gesture are untouched.

Proof: new smoke_kiosk_scale_no_editor runs a cold page, a real 3.4 s touch
hold (CDP synthesizeTapGesture: a long press, not a tap on the new backdrop),
sliders by keyboard, Reset/Close taps, a second hold to reopen, the chunk
refused by the network, and the ordinary editor entry as control. On dev it
is red in 3 network checks (chunk fetched; refused chunk = 2 requests and a
toast); with the fix all 20 checks pass. smoke_lazy_editor_chunk case (a)
pinned the old cycle and now asserts zero requests and zero notices; the
(checked by hand). New mutant kiosk-scale-dialog-loads-editor-runtime
(caught by hand), listed under browser lifecycle guards; the smoke is
linked in smoke-links because it names none of the touched members.

Issue: #763
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 00:49:47 +03:00
claude[bot] 7fbc97a296 docs(reviews): индекс после сдвига каталога (#808)
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Issue: #808
User-Visible: no
2026-10-06 21:27:11 +00:00
claude[bot] e10e482a63 docs: review document for #808
Issue: #808
User-Visible: no
2026-10-06 21:27:07 +00:00
Claude ec9462a457 chore(golden): accept the route arrowhead over the battery (#808)
device-battery-zigbee-overlap-dark: the parent-caption arrowhead now paints
over the battery indicator instead of disappearing under it. Reviewed against
the old baseline; the remaining 204 scenes passed in the same full run.

Issue: #808
User-Visible: no
Release: v1.80.0-beta.7
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/37526387243
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-06 23:49:06 +03:00
Claude ef47d4c5a1 fix(zigbee): stale after an hour; routes paint over battery indicators (#808)
A topology snapshot is labelled stale after one hour instead of five
minutes. Nothing refreshes the map on its own and a Zigbee2MQTT scan alone
can take more than ten minutes, so the old age marked almost every map. A
failed refresh still marks the retained snapshot stale at once.

Route lines and arrows now paint over battery indicators. Endpoint markers
rise above the route layer as a whole (#464), battery included, so the
overlay renders a copy of the routes one level higher (z 9, before the
captions in DOM order) clipped to the endpoint battery frames. The frames
are measured in the existing layout frame of the overlay; outside them the
picture is unchanged: endpoint cores stay above routes, captions above
routes and batteries (#792 AC7). The copy carries no data-hp, so route
counts and selectors stay as they were; parent-caption arrows update in both
layers.

Tests: smoke_device_battery_zigbee gains a local link whose route crosses a
whole battery frame — the hovered endpoint's and the neighbour endpoint's,
Flat and 2.5D, sampled along the drawn line. On dev three of the four probes
are red (2.5D neighbours are not raised today). New mutant
zigbee-route-copy-under-battery (manually red); the parent-LQI mutant anchor
follows the refactored route list. smoke_zigbee_topology_hover age fixtures
move to the one-hour boundary. The golden routes control hides both layers;
device-battery-zigbee-overlap-dark changes (arrowhead now over the battery)
and needs acceptance via ci:golden.

Issue: #808
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-06 23:49:06 +03:00
Sergey Matyunin bda25d26c2 test(smoke): follow segmented battery control
The volumetric settings smoke still expected the retired battery checkbox.
Assert the three radio segments in their real DOM order after the established
Display toggles.

Issue: #807
User-Visible: no
2026-10-06 22:53:06 +03:00
Sergey Matyunin e49083d6ec Release v1.80.0-beta.6 candidate
Ship the reviewed battery-indicator shadow and per-device opt-out together
with the global All / Low only / None display modes. Rebuild the committed
bundle, refresh the pixel-identical documentation fingerprint, and tighten
the beta ratchets.

Issue: #806
Issue: #807
User-Visible: yes
Release: v1.80.0-beta.6
2026-10-06 22:35:14 +03:00
claude[bot] 8cff278c78 docs(reviews): индекс после сдвига каталога (#807)
Issue: #807
User-Visible: no
2026-10-06 18:36:59 +00:00
claude[bot] a1a404e419 docs: review document for #807
Issue: #807
User-Visible: no
2026-10-06 18:36:55 +00:00
Claude 7dc5a69ec2 test(settings): find the background segment by its radio name (#807)
smoke_settings_dialog_cards took the first radiogroup of General settings as
the background segment. The battery choice of #807 is a segmented control in
the Display card above it, so the smoke now selects the group that owns the
gs-bg-mode radios.

Issue: #807
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-06 21:15:01 +03:00
Claude 2e1661b23d chore(monolith): raise bundleBytes for the low-only battery mode (#807)
bundleBytes 2 712 875 → 2 715 446 (+2 571, over the 2 000 band). Clean dev
after #806 already measures 2 714 174 (+1 299 for the per-device opt-out and
the shadow, inside the band); #807 adds +1 272: the three-way mode resolver
and writer, the segmented control replacing the toggle in General settings
with its three option labels, and the batteryLowOnly option passed by View,
static card, space-card render and the device dialog preview. hostRefs
5 072 stays inside its band and is not raised.

Issue: #807
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-06 21:08:26 +03:00
Claude 8c726d753e feat(battery): low-only option for the device battery indicator (#807)
General settings offer All / Low only / None for battery indicators as a
three-button segmented control, the same visual as the light-source choice
in the device dialog. Low only keeps just the red low state (below 20 %, or
a binary battery sensor that is on); normal, warning and unknown are hidden.

Storage keeps #792 compatibility: absent = All, false = None, the exact
string "low" = Low only. The backend accepts bool or "low" and refuses any
other value; old frontends read "low" as All, old backends refuse to save it
(documented in CONFIG-COMPATIBILITY). One presentation option
(batteryLowOnly) filters the resolved indicator, so View, kiosk, 2.5D,
space-card and the device dialog preview share the same rule.

Track raised ship -> show: new i18n keys, a new config value and Python.

Tests: unit (mode resolver, write, dirty, low-only presentation), backend
validator and support package, smoke_device_battery (low-only keeps only
red), smoke_general_settings_form (radios, save/reopen/clean, All removes
the key). New mutant battery-low-only-shows-every-state; the backend mutant
anchor moves to the new validator.

Issue: #807
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-06 20:59:11 +03:00
claude[bot] f398b50037 docs(reviews): индекс после сдвига каталога (#806)
Issue: #806
User-Visible: no
2026-10-06 17:51:30 +00:00
claude[bot] 16b4197409 docs: review document for #806
Issue: #806
User-Visible: no
2026-10-06 17:51:26 +00:00
Matysh 16f5a30f62 chore(golden): принимает средний фон индикаторов заряда
Эталон подтверждает увеличенную тень четырёх состояний и трёх размеров
непосредственно на нейтральной серой плоскости. Полная матрица: 205 сцен.

Issue: #806
User-Visible: no
Release: v1.80.0-beta.6
Baseline-Reviewed-Local: sha256:d5e785e772731545943419b719df10f952592b82bbfb65db61a6727892d8b61c
2026-10-06 20:25:09 +03:00
Matysh 3bfd3f4161 test(golden): фиксирует средний фон батарей
Issue: #806
User-Visible: no
Release: v1.80.0-beta.6
2026-10-06 20:14:58 +03:00
Matysh 1d2d23f104 test(golden): добавляет средний фон батарей
Issue: #806
User-Visible: no
Release: v1.80.0-beta.6
2026-10-06 20:02:02 +03:00
claude[bot] b83dd2021f docs: review document for #806
Issue: #806
User-Visible: no
2026-10-06 17:00:31 +00:00
Matysh 7812a26f18 fix(schema): синхронизирует поле скрытия заряда
Issue: #806
User-Visible: no
Release: v1.80.0-beta.6
2026-10-06 19:28:45 +03:00
Matysh 8a6f523b1a chore(golden): принимает тень индикаторов заряда
Проверены светлый, тёмный и мобильный масштабы, перекрытие Zigbee и
сцена диалога с новой локальной настройкой. Остальные 199 сцен совпали.

Issue: #806
User-Visible: no
Release: v1.80.0-beta.6
Baseline-Reviewed-Local: sha256:453d95ec28670c0e2b0d962acaf1fb2aa715178944b3796402cc7a4e50808d8a
2026-10-06 19:20:05 +03:00
Matysh ab9dc1fb46 feat(battery): добавляет тень и локальное скрытие индикатора
Issue: #806
User-Visible: yes
2026-10-06 18:59:09 +03:00
claude[bot] 705889cc37 docs: review document for #806
Issue: #806
User-Visible: no
2026-10-06 15:38:58 +00:00
claude[bot] e12c04568a docs: review document for #806
Issue: #806
User-Visible: no
2026-10-06 15:33:13 +00:00
Matysh d50efcb56e docs(release): refresh screenshot fingerprint for v1.80.0-beta.5
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
All eleven documentation frames are pixel-identical in the pinned Linux environment; update only their source fingerprint for the release candidate.

Issue: #792

Issue: #804

Issue: #776

User-Visible: no

Release: v1.80.0-beta.5
2026-10-06 17:30:47 +03:00
Matysh 25f463a933 Release v1.80.0-beta.5 candidate
Ship battery indicators, resilient room creation from partial wall faces, and
deterministic late smoke-error accounting. Rebuild the committed bundle and
tighten the beta ratchets.

Issue: #792
Issue: #804
Issue: #776
User-Visible: yes
Release: v1.80.0-beta.5
2026-10-06 17:22:40 +03:00
claude[bot] 63b6b48d03 docs(reviews): индекс после сдвига каталога (#776)
Issue: #776
User-Visible: no
2026-10-06 14:06:44 +00:00
claude[bot] 323fa70f96 docs: review document for #776
Issue: #776
User-Visible: no
2026-10-06 14:06:44 +00:00
Matyshandclaude[bot] 3e98ac4dde fix(test): include late page errors in smoke teardown verdict
Count delivered page errors after awaited browser close. Exercise the public
harness lifecycle with deterministic negative subprocess cases and retain the
unchanged Chromium probes for the real transport. Move the premature-snapshot
mutation witness to the deterministic Node suite.

Issue: #776
User-Visible: no
2026-10-06 14:06:44 +00:00
claude[bot] 3ee8663bfa docs: review document for #774
Issue: #774
User-Visible: no
2026-10-06 14:04:02 +00:00
claude[bot] 7015a2c454 docs: review document for #774
Issue: #774
User-Visible: no
2026-10-06 13:51:49 +00:00
claude[bot] aa62145621 docs(reviews): индекс после сдвига каталога (#792)
Issue: #792
User-Visible: no
2026-10-06 13:32:55 +00:00
claude[bot] 602305417b docs: review document for #792
Issue: #792
User-Visible: no
2026-10-06 13:32:51 +00:00
Matysh 97e6504e9c chore(golden): accept focused battery Zigbee overlap reference
Review the actual 420x140 frame from the complete attested WSL capture of
published 88bdd513e. The caption covers 70/70 sampled battery pixels;
coreChanged=0 and exposedRoute=49. All 203 existing scenes passed and their
PNG bytes remain untouched. Accept exactly the single declared new scene.

Issue: #792
User-Visible: no
Release: v1.80.0-beta.4
Baseline-Reviewed-Local: sha256:4918901746d50a9b8876c6683f9d4e71ce6782c2c02c680c836d0cd1af1b0765
2026-10-06 16:10:02 +03:00
Matysh 88bdd513ec test(golden): cover battery and Zigbee caption overlap
Address the in-scope Medium from CODE-REVIEW-792-r1 with a deterministic
golden scenario that uses actual topology rendering and overlapping battery
ink. Keep the existing battery boards and their reviewed images unchanged.
Align the English guide's setting name with the shipped locale (Low).

Issue: #792
User-Visible: no
2026-10-06 16:04:07 +03:00
claude[bot] b94112b8c4 docs: review document for #792
Issue: #792
User-Visible: no
2026-10-06 12:52:30 +00:00
Matysh 3c091e7ef8 fix(settings): preserve established Display option order
Append battery visibility after the existing tooltip, radar and volumetric
controls. The established 2.5D setting remains third. Extend the existing
browser oracle to allow exactly the new fourth option while preserving
its original first-three ordering requirement.

Full Validate 37461167422 exposed the ordering regression. It was reproduced
locally before the change; volumetric_setting, general_settings_form and
settings_dialog_cards all pass after it. Record the final gzip measurement.

Issue: #792
User-Visible: yes
2026-10-06 15:22:16 +03:00
Matysh 9951e6ba10 chore(golden): accept three MDI battery indicator scenes
Full 203-scene capture from published d4d48bb040
on pinned WSL/ext4 toolchain: 200 unchanged scenes and three new scenes;
105 environment witnesses passed (required floor: 10).
Reviewed light/dark boards and mobile framing against owner-approved MDI
glyphs and the designer's sizes, gaps and colors. Correct measured gzip
documentation by three bytes after the final source fingerprint settled.

Issue: #792
User-Visible: no
Release: v1.80.0-beta.4
Baseline-Reviewed-Local: sha256:7e238b1840401f791e00e53f5c77388006a0b04cc8d4e535775b3a5dc9f9db03
2026-10-06 15:04:00 +03:00
Matysh d4d48bb040 feat(devices): show own-device battery state with standard HA icons
Resolve one stable battery source independently of the functional device face,
include battery-only ticks in immutable render snapshots, and add a default-on
shared preference. Keep LED strips excluded and Zigbee captions above the
passive, out-of-flow indicator. Use the owner's accepted four MDI icons in the
designer's frame geometry and colours.

Include resolver/backend/browser/pixel contracts and three new golden scenes.
Recalibrate the owner-approved absolute/raw size budgets; preserve the existing
initial View rolling ceiling and band. Lazy extraction remains separate #805.

Issue: #792
User-Visible: yes
2026-10-06 14:43:19 +03:00
claude[bot] d787c02687 docs: review document for #792
Issue: #792
User-Visible: no
2026-10-06 10:56:30 +00:00
claude[bot] b9145d3e85 docs(reviews): индекс после сдвига каталога (#804)
Issue: #804
User-Visible: no
2026-10-06 10:14:27 +00:00
claude[bot] 6651d854d0 docs: review document for #804
Issue: #804
User-Visible: no
2026-10-06 10:14:23 +00:00
Matysh e8e5718ecd test(editor): verify room undo and canonical opening metadata
Issue: #804
User-Visible: no
2026-10-06 12:52:59 +03:00
Matysh b3f5aefa20 fix(editor): preserve residual wall identity when creating room
Issue: #804
User-Visible: yes
2026-10-06 12:52:59 +03:00
claude[bot] de2029b7f9 docs: review document for #804
Issue: #804
User-Visible: no
2026-10-06 09:47:47 +00:00
Matysh 370df0708c Release v1.80.0-beta.4 candidate after compatibility check
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Preserve room:false on pointer device tooltips. The prior candidate was not
published; task CI and WSL regression checks passed again after the repair.

Issue: #802
User-Visible: no
Release: v1.80.0-beta.4
2026-10-05 22:19:47 +03:00
claude[bot] bcc68f0da2 docs: accept derived artifacts on dev for v1.80.0-beta.4
Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697).
Прогон: https://github.com/Matysh/houseplan-card/actions/runs/37362267763
Скриншоты документации: отпечаток исходников; изменённые кадры: нет.

Issue: #697
User-Visible: no
2026-10-05 19:19:24 +00:00
Matysh 574ae94840 Merge issue/802-zigbee-hover-labels into dev
Owner-authorized local review; task CI and WSL acceptance are green.

Issue: #802
User-Visible: no
2026-10-05 22:16:38 +03:00
Matysh 90e8ea4685 docs(review): record local acceptance of Zigbee caption layout
Issue: #802
User-Visible: no
2026-10-05 22:05:55 +03:00
Matysh ee351dcb84 test(zigbee): retain projection fixture promise until completion
Use bounded Playwright polling for the RAF-backed facade operation, following
the existing preloader smoke precedent. No config retries or relaxed assertions.

Issue: #802
User-Visible: no
2026-10-05 22:05:04 +03:00
Matysh 2b841d94e4 fix(zigbee): preserve the device tooltip discriminator
Full release CI caught room becoming undefined instead of false after the
pointer-tooltip path stopped calling _showTip. Preserve the existing shape;
keep and strengthen the room-tooltip-toggle regression assertion.

Issue: #802
User-Visible: no
2026-10-05 22:00:23 +03:00
Matysh c175a2ab1a Release v1.80.0-beta.4 candidate
Zigbee destination names and readable, non-overlapping hover diagnostics.
Local self-review authorized by the owner; WSL acceptance is green.

Issue: #802
User-Visible: yes
Release: v1.80.0-beta.4
2026-10-05 21:45:34 +03:00
claude[bot] 020739ebca docs: accept derived artifacts on dev for v1.80.0-beta.4
Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697).
Прогон: https://github.com/Matysh/houseplan-card/actions/runs/37357972443
Скриншоты документации: отпечаток исходников; изменённые кадры: нет.

Issue: #697
User-Visible: no
2026-10-05 18:44:35 +00:00
Matysh ffdd69e277 Merge issue/802-zigbee-hover-labels into dev
Owner-authorized local review; task CI and WSL acceptance are green.

Issue: #802
User-Visible: no
2026-10-05 21:42:00 +03:00
Matysh 319ee70010 docs(review): record local acceptance of Zigbee caption layout
Issue: #802
User-Visible: no
2026-10-05 21:41:56 +03:00
Matysh 5c893c7ff3 docs(review): record local acceptance of Zigbee caption layout
Issue: #802
User-Visible: no
2026-10-05 21:39:44 +03:00
Matysh 077e9c1b1b test(zigbee): verify transformed cards and page scrolling
Issue: #802
User-Visible: no
2026-10-05 21:37:45 +03:00
Matysh 0471482c2e test(zigbee): isolate arrow raster probe from pointer tooltip
Keep outline pixel thresholds unchanged; real tooltip coexistence is tested separately.

Issue: #802
User-Visible: no
2026-10-05 21:34:27 +03:00
Matysh db887cd7f0 fix(zigbee): readable remote captions and collision-aware device tooltips
Keep diagnostic captions and the matching pointer tooltip readable, including
late overlay paint, camera/resize changes and narrow-card fallback. Preserve
focus, touch and device actions. Add pure placement and real-mouse witnesses.

Issue: #802
User-Visible: yes
2026-10-05 21:30:15 +03:00
Matysh 7feda5b6c8 Release v1.80.0-beta.3 candidate
Issue: #800
User-Visible: yes
Release: v1.80.0-beta.3
2026-10-05 20:24:52 +03:00
claude[bot] 285a6dc436 docs: accept derived artifacts on dev for v1.80.0-beta.3
Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697).
Прогон: https://github.com/Matysh/houseplan-card/actions/runs/37347936626
Скриншоты документации: отпечаток исходников; изменённые кадры: нет.

Issue: #697
User-Visible: no
2026-10-05 17:24:35 +00:00
Matysh 52876b0573 Merge issue #800: background Zigbee scans (owner-approved local review) 2026-10-05 20:22:18 +03:00
Matysh cdfe02e64d docs(review): record independent green #800 r3 2026-10-05 20:21:55 +03:00
Matysh 07c885435a test(zigbee): settle capability replies before age fixture (#800)
Issue: #800
User-Visible: no
2026-10-05 20:18:08 +03:00
Matysh 67af859e8c fix(zigbee): allow explicit retry after transient reload failure (#800)
Issue: #800
User-Visible: yes
2026-10-05 20:12:25 +03:00
Matysh 4a7abf531b fix(zigbee): restore observers after integration reload (#800)
Issue: #800
User-Visible: yes
2026-10-05 20:08:37 +03:00
Matysh 379de706d2 feat(zigbee): keep route scans in background HA jobs (#800)
Issue: #800
User-Visible: yes
2026-10-05 19:57:28 +03:00
528 changed files with 51453 additions and 13158 deletions
+4 -1
View File
@@ -214,7 +214,10 @@ jobs:
# перезапуск не лечит: причина и ответ git без токена — в журнале и в
# сводке шага.
push_err="$RUNNER_TEMP/beta-derived-push.stderr"
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev 2> "$push_err"; then
# #766: хост push — сервер раннера (GITHUB_SERVER_URL), не зашитый github.com.
server="${GITHUB_SERVER_URL:-https://github.com}"
push_url="${server%%://*}://x-access-token:$TOKEN@${server#*://}/${{ github.repository }}"
if ! git push -q "$push_url" HEAD:dev 2> "$push_err"; then
kind=$(node scripts/merge-candidate.mjs --push-refusal="$push_err" --ref=dev \
--stage=beta-derived --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
if [ "$kind" = "stale" ]; then
+12 -6
View File
@@ -584,8 +584,11 @@ jobs:
# прогона со своей причиной. #730: причина и ответ git без токена —
# ещё и в сводке шага (--summary; устаревший lease её не пишет).
push_err="$RUNNER_TEMP/rebase-push.stderr"
# #766: хост push — сервер раннера (GITHUB_SERVER_URL), не зашитый github.com.
server="${GITHUB_SERVER_URL:-https://github.com}"
push_url="${server%%://*}://x-access-token:$TOKEN@${server#*://}/${{ github.repository }}"
if ! git push -q --force-with-lease="refs/heads/$BRANCH:$before" \
"https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"$push_url" \
"HEAD:refs/heads/$BRANCH" 2> "$push_err"; then
refusal="$RUNNER_TEMP/push-refusal.md"
kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" \
@@ -1693,7 +1696,10 @@ jobs:
# сразу, причина и ответ git без токена — в журнале и в сводке шага;
# метка не меняется.
push_err="$RUNNER_TEMP/review-doc-push.stderr"
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
# #766: хост push — сервер раннера (GITHUB_SERVER_URL), не зашитый github.com.
server="${GITHUB_SERVER_URL:-https://github.com}"
push_url="${server%%://*}://x-access-token:$TOKEN@${server#*://}/${{ github.repository }}"
if ! git push -q "$push_url" \
"HEAD:$target" 2> "$push_err"; then
kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" --ref="$target" \
--stage=review-doc --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
@@ -1713,7 +1719,7 @@ jobs:
# После ребейза набор путей другой — проверяется заново. Форс здесь
# запрещён и не появляется: ветка двигается только вперёд.
git diff --name-only "origin/$target...HEAD" | node "$TOOLS/scripts/review-doc-guard.mjs"
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
if ! git push -q "$push_url" \
"HEAD:$target" 2> "$push_err"; then
kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" --ref="$target" \
--stage=review-doc --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
@@ -1925,9 +1931,9 @@ jobs:
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
# #696: show/ship сливаются по лёгкому Validate на кандидате.
MUTANTS: ${{ needs.prepare.outputs.mutants }}
# #749: из снимка dev — и суждение о Validate кандидата (ci-proof.mjs
# с validate.yml снимка), и reviews-index.mjs, который скрипт зовёт
# по пути от себя.
# #749/#811: policy, Git writes and CI proof stay in the dev snapshot.
# Only the accepted candidate's index generator runs from an exported
# credential-free tree; the trusted controller commits its output.
TOOLS: ${{ steps.tools.outputs.dir }}
run: |
if [ -z "$BRANCH" ]; then
+4 -1
View File
@@ -341,6 +341,9 @@ jobs:
fi
msg="$RUNNER_TEMP/ship-review-commit.txt"
push_err="$RUNNER_TEMP/ship-review-push.stderr"
# #766: хост push — сервер раннера (GITHUB_SERVER_URL), не зашитый github.com.
server="${GITHUB_SERVER_URL:-https://github.com}"
push_url="${server%%://*}://x-access-token:$TOKEN@${server#*://}/${{ github.repository }}"
for attempt in 1 2 3; do
git fetch -q origin dev
git reset -q --hard origin/dev
@@ -375,7 +378,7 @@ jobs:
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
commit -q -F "$msg"
git diff --name-only "origin/dev...HEAD" | node scripts/review-doc-guard.mjs
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev 2> "$push_err"; then
if git push -q "$push_url" HEAD:dev 2> "$push_err"; then
if [ "$MODE" = "nightly" ]; then
echo "### Ночное пакетное ревью ship" >> "$GITHUB_STEP_SUMMARY"
else
+4 -1
View File
@@ -309,6 +309,9 @@ jobs:
# Сообщение коммита — построчно в файл, без heredoc в `run:` (#723).
msg="$RUNNER_TEMP/release-review-commit.txt"
push_err="$RUNNER_TEMP/release-review-push.stderr"
# #766: хост push — сервер раннера (GITHUB_SERVER_URL), не зашитый github.com.
server="${GITHUB_SERVER_URL:-https://github.com}"
push_url="${server%%://*}://x-access-token:$TOKEN@${server#*://}/${{ github.repository }}"
for attempt in 1 2 3; do
git fetch -q origin dev
git reset -q --hard origin/dev
@@ -337,7 +340,7 @@ jobs:
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
commit -q -F "$msg"
git diff --name-only "origin/dev...HEAD" | node scripts/review-doc-guard.mjs
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev 2> "$push_err"; then
if git push -q "$push_url" HEAD:dev 2> "$push_err"; then
echo "### Независимое ревью $TAG" >> "$GITHUB_STEP_SUMMARY"
echo "Итог: $counts — \`$DOC\` в dev. Выпуск не блокируется." >> "$GITHUB_STEP_SUMMARY"
echo "::notice::$DOC опубликован: $counts"
+5 -2
View File
@@ -179,8 +179,11 @@ instead of ending the session.** The label starts the pipeline by itself. Poll
with `node scripts/wait-verdict.mjs --issue NN [--sha <tip>]` (#496): it watches
the label and the pipeline's comments every 90 s, at most 110 times, prints only
on a change and exits 0 on a new label, 3 on an event that needs a hand, 4 on
timeout. Watch the **label**, not the comment. Do not wait while `blocked` is
set.
timeout. A failed merge after a green verdict (outcomes of
`merge-candidate.mjs`, #768) exits 3 while the label still reads `S7`, and its
reason is printed when the label has already moved; «diff changed on rebase» is
printed and the wait goes on — the new review round starts by itself. Watch the
**label**, not the comment. Do not wait while `blocked` is set.
| Now reads | What happened | What you do |
|---|---|---|
+40 -6
View File
@@ -277,6 +277,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
`docs/testing-notes/mutation-browser-guards.md`. `mutation-gate --check`
показывает число browser guards против ориентира 200 и предупреждает — не
краснеет (#699) — при росте выше него и о любом id без browser-обоснования;
с #811 неверные Count/Total, повтор ID и нарушенная структура категорий
документа — ошибки дешёвой проверки; membership (пропущенный/устаревший ID)
остаётся предупреждением CLI и строгой проверкой юнита.
удалять чужой мутант ради числа не нужно; ревьюер проверяет
не только наличие строки, но и невозможность более дешёвого `node --test`.
@@ -398,14 +401,14 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
Файл генерируется `node scripts/reviews-index.mjs` и пересобирается **только
коммитами, идущими в `dev`** (#657, решение 1б): слиянием кандидата —
после ребейза, а если `dev` не двигался, то поверх материала перед
fast-forward (`--commit-if-stale`, коммит класса C) — и публикацией документа ревью ТЗ
fast-forward (коммит класса C) — и публикацией документа ревью ТЗ
прямо в `dev`. В ветке задачи индекс не пересобирается — ни при приведении к
dev, ни при публикации документа код-ревью: иначе две параллельные задачи
конфликтуют на нём по построению. Сам `INDEX.md` руками не правится никогда. Конфликт ребейза, в котором **все** пути —
`INDEX.md`, отказом не считается (#643): `scripts/rebase-generated.mjs`
пересобирает индекс по каталогу на остановке и продолжает ребейз — так делают
приведение к dev, слияние кандидата и авторский `rebase-on-dev.mjs`; индекс
вместе с любым другим путём — прежний отказ с перечнем файлов. Ещё два общих
вместе с любым ручным конфликтом — отказ с перечнем файлов. Ещё два общих
файла не конфликтуют по смыслу (#698): записи ченджлогов в `## Unreleased`
объединяет встроенный драйвер `merge=union` (`.gitattributes`), а на конфликте в
`scripts/monolith-baseline.json` ребейз берёт сторону `dev` — числа
@@ -414,7 +417,29 @@ Validate кандидата. Сегодня пять чисел исходник
с полосой 2 000 Б; поэтому сторона `dev` в базе краснеет, если задача сама
меняла эти числа, — это прежний возврат автору, только после Validate, а не до
ревью. Полоса у всех шести чисел — #699. Ни ченджлоги, ни база не входят в
patch-id кандидата слияния: вердикт к работе задачи остаётся в силе. Шаг Validate
patch-id кандидата слияния: вердикт к работе задачи остаётся в силе.
В `docs/testing-notes/mutation-browser-guards.md` производны только цифры
Count категорий и числитель Total (#811). Ребейз объединяет стороны после
нейтрализации этих цифр и пересчитывает их по объединённым спискам ID; после
чистого merge пересчёт тоже обязателен (иначе два одинаковых инкремента git
может склеить в один). ID, причины, названия категорий, знаменатель лимита и
прочая проза остаются ручными: смысловой конфликт или нарушенная структура
отменяет ребейз и восстанавливает исходное дерево. В patch-id нейтрализованы
только те же числовые ячейки, не весь Markdown.
Финальный индекс при слиянии строит генератор **из принимаемого дерева**
после зелёного код-ревью и, при ребейзе, после равенства patch-id (#811).
`candidate-reviews-index.mjs` из доверенного снимка `dev` экспортирует
закоммиченные `scripts/` и `docs/reviews/` без `.git`, symlink и credentials,
запускает только генерацию и `--check` с ограниченными правами Node, затем сам
добавляет/коммитит единственный `INDEX.md`. Это защита от случайных побочных
эффектов уже проверенного кода, не песочница для враждебного кода.
Управление ребейзом, CI proof, Git-запись и push остаются в снимке `dev`.
На промежуточной остановке ребейза используется доверенный генератор: ещё
не принятый дифф не исполняется ради индекса.
Шаг Validate
«индекс ревью совпадает с каталогом» красит push в `dev`, где `INDEX.md`
расходится с каталогом (на issue-ветках не судится: их переписывает конвейер).
Ручная правка каталога
@@ -976,6 +1001,9 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
при правке `src/live-*`, `src/render-*`, `houseplan-render-lifecycle.ts`,
`houseplan-card.ts` — `large-house-interaction-v1`, оба по три образца против
абсолютных потолков `hardMaxMs` полных профилей (`budgets-*-smoke.json`).
Профиль добавляет и правка того, что его меряет и судит без `src/**`: его
бюджетов и общего харнесса large-house — раннера, фикстуры, контракта карточки,
оценщика (#770).
Это гейт на «в разы», а не «на проценты»: регрессия #160 (первый кадр 9 870 мс
против потолка 3 500) ловится ещё в ревью, а не предрелизным гейтом под тегом.
Классификацию делает `scripts/classify-changes.mjs`, набор профилей входит в
@@ -1096,7 +1124,9 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
трогающих `demo/golden/baselines/**`, — `Release:` плюс ровно один источник:
`Baseline-Reviewed: <URL GitHub run>` либо
`Baseline-Reviewed-Local: sha256:<хеш аттестации>`. Локальный хеш обязан
совпадать с `localAttestation.sha256` в принятом индексе.
совпадать с `localAttestation.sha256` в принятом индексе. Трейлер черновика
`Spec-Draft`, если он есть, — ровно один, вида `sha256:<64 строчных hex>`
(#766, §11.8); обычный коммит его не несёт.
Реализация — `scripts/validate-commit-provenance.mjs`, тот же скрипт вызывается
job `provenance` в `validate.yml`.
- **`pre-push`** — есть, работает. Прогоняет `scripts/process-gate.mjs` по каждому
@@ -1567,11 +1597,13 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
конфликта. Шаг слияния поэтому:
- сверяет вершину ветки с материалом ревью (#312) — иначе `S6-in-progress`;
- если `dev` не двигался — push с `--force-with-lease` на текущую вершину;
- если `dev` не двигался — финальный индекс из принимаемого дерева (§2.10),
затем push с `--force-with-lease` на текущую вершину;
- если двигался — ребейз (конфликт — `S6-in-progress`, как раньше), сравнение
patch-id проверенного и получившегося диффа (различие — `S7-code-review`: вердикт
к другому диффу не применим, §2.10; метка снимается и ставится заново, чтобы
событие запустило новый заход, #706), публикация кандидата в ветку задачи и
событие запустило новый заход, #706); при равном patch-id финальный индекс
строится из принимаемого дерева, затем публикация кандидата в ветку задачи и
ожидание зелёного лёгкого Validate **на этом SHA** — его запускает сам push в
ветку, dispatch — только если прогона нет за три минуты (#696, #709), — и только
затем push в `dev` с lease на ту вершину, поверх которой кандидат собран.
@@ -1915,6 +1947,8 @@ Low в issue не пишутся. Цвет ночи — цвет Validate.
`S5-ready`, трек на момент написания — `ask`, и трейлер равен «Тело issue»
зелёного (High 0) `SPEC-REVIEW` этой эпохи из вершины диапазона или
`origin/dev`. Отказ называет причину; при устаревшем ТЗ — оба хеша и документ.
Повтор и неверный формат трейлера хук `commit-msg` отвергает раньше, при
коммите, тем же разбором (#766, §10.1); эпоху, трек и SPEC-REVIEW он не судит.
Комментарий «Черновик:» и учёт в WIP машиной не проверяются, как занятие `S6`.
Ревьюер ТЗ черновик не читает и доводом не считает: судится тело issue
+7
View File
@@ -49,6 +49,10 @@ async def _async_flush_runtime(
writers are independently guarded so one failed store cannot prevent the
other from reaching disk during shutdown.
"""
zigbee = getattr(entry.runtime_data, "zigbee_coordinator", None)
if zigbee is not None:
await zigbee.async_close()
recorder = hass.data.get(DOMAIN, {}).get("trail_recorder")
if recorder is not None:
try:
@@ -111,6 +115,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
_LOGGER.exception("House Plan: virtual-light storage is not readable; using default on")
entry.runtime_data = data
from .zigbee_topology import ZigbeeScanCoordinator
data.zigbee_coordinator = ZigbeeScanCoordinator(hass, entry)
from .radar import RadarCoordinator
data.radar_coordinator = RadarCoordinator(hass, data)
await data.radar_coordinator.async_setup()
+65 -2
View File
@@ -1,19 +1,53 @@
"""Config flow: a single entry with no parameters."""
"""Single-entry config flow with an explicit previous-installation choice."""
from __future__ import annotations
import logging
from datetime import UTC, datetime
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.helpers.translation import async_get_translations
from .const import CONF_ADMIN_ONLY, DOMAIN
from .previous_data import ArchiveError, archive_previous_data, inspect_previous_data
_LOGGER = logging.getLogger(__name__)
class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""One-step setup."""
"""Restore or archive only after the final user confirmation."""
VERSION = 1
def __init__(self):
self._previous_checked = False
self._start_fresh = False
async def async_step_user(self, user_input=None):
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
if self._async_in_progress(include_uninitialized=True):
return self.async_abort(reason="already_in_progress")
if not self._previous_checked:
try:
previous = await self.hass.async_add_executor_job(
inspect_previous_data, self.hass.config.config_dir
)
except OSError:
_LOGGER.exception("Cannot inspect previous House Plan data")
return self.async_abort(reason="previous_data_error")
self._previous_checked = True
if previous.found:
self._previous = previous
return await self.async_step_previous_data()
if user_input is not None:
if self._start_fresh:
try:
await self.hass.async_add_executor_job(
archive_previous_data, self.hass.config.config_dir
)
except ArchiveError:
return self.async_abort(reason="archive_failed")
return self.async_create_entry(
title="House Plan",
data={},
@@ -25,6 +59,35 @@ class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=True): bool}),
)
async def async_step_previous_data(self, user_input=None):
previous = self._previous
translations = await async_get_translations(
self.hass, self.hass.config.language, "config", {DOMAIN}
)
warning = ""
if previous.spaces is None:
warning = translations[f"component.{DOMAIN}.config.error.previous_data_unreadable"]
return self.async_show_menu(
step_id="previous_data",
menu_options=["restore", "start_fresh"],
description_placeholders={
"spaces": str(previous.spaces) if previous.spaces is not None else "—",
"files": str(previous.files),
"modified": datetime.fromtimestamp(previous.modified, UTC).strftime(
"%Y-%m-%d %H:%M:%S UTC"
) if previous.modified is not None else "—",
"warning": warning,
},
)
async def async_step_restore(self, user_input=None):
self._start_fresh = False
return await self.async_step_user()
async def async_step_start_fresh(self, user_input=None):
self._start_fresh = True
return await self.async_step_user()
@staticmethod
def async_get_options_flow(config_entry):
return HouseplanOptionsFlow()
+2 -1
View File
@@ -57,7 +57,7 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
ASSETS_DIR = "houseplan/assets"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.80.0-beta.2"
VERSION = "1.80.0-beta.8"
DECOR_ASSETS_API_VERSION = 1
SUMMARY_PANEL_API_VERSION = 1
@@ -131,6 +131,7 @@ ERROR_CODES: frozenset[str] = frozenset({
# served by the generic per-code fallback on the frontend.
ERROR_CODE_FAMILIES: tuple[str, ...] = (
"junction_limit_",
"node_move_",
"value_badge_",
"value_source_",
)
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";import{u as e,cn as o,A as t,aI as s,aJ as a,aK as i,p as l,n}from"./open-spans-Co-2W4Ja.js";import{l as r,U as c}from"./houseplan-card-BRGPGuZP.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return r(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],t=this._lang,s=[{value:"",label:c(t,"editor.floor_none")}],a=this._floorToken;a&&s.push({value:a,label:c(t,"editor.floor_index",{index:String(this._config?.floor)})});const i="string"==typeof this._config?.floor?this._config.floor:"";i&&!e.some(e=>e.value===i)&&s.push({value:i,label:i}),s.push(...e);const l="string"==typeof this._config?.default_floor?this._config.default_floor:"",n=[...e];return l&&!e.some(e=>e.value===l)&&n.unshift({value:l,label:l}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:s}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:n}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:o(c(t,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return t;const e=s(this,a,r(this.hass,this._config.language));if("cold"===e)return i();if("warm"===e)return l;this._loadSpaces();const o=this._lang,h={title:c(o,"editor.title"),floor:c(o,"editor.floor"),default_floor:c(o,"editor.default_floor"),language:c(o,"editor.language"),icon_size:c(o,"editor.icon_size"),show_temperature:c(o,"editor.show_temperature"),live_states:c(o,"editor.live_states"),show_signal:c(o,"editor.show_signal"),kiosk:c(o,"editor.kiosk"),cycle:c(o,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),u=e=>n`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return n`
${u(f.slice(0,3))}
${_?n`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${c(o,"editor.default_floor_missing",{id:_})}
</div>`:t}
${u(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
@@ -1,14 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";import{aG as e,l as o,aJ as t,eE as s,A as a,eF as i,eG as l,eH as n,E as r,c}from"./houseplan-card-DpMO8ZtV.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";var e={node_move_hint:"Drag the wall node. Original wall axes and horizontal/vertical walls snap. Esc cancels; Undo restores the whole move.",node_move_ambiguous:"These nodes are too close. Zoom in to choose one.",node_move_choose_axis:"Move along one of the original walls to choose the sliding axis.",node_move_invalid:"Cannot move this node: {reason}",node_move_opening_blocked:"A door or window would not fit or would cross the node. The move was cancelled.",node_move_update_required:"Update the House Plan integration to move wall nodes.",node_move_history:"Move wall node",node_move_unsupported_junction:"This complex through-junction cannot be moved. T and X junctions without extra branches are supported.","furn.title":"Furniture library","furn.symbol":"Symbol","furn.group_furniture":"Furniture","furn.group_appliance":"Appliances","furn.group_sanitary":"Plumbing","furn.group_other":"Other","furn.width":"Width","furn.depth":"Depth","furn.back_to_categories":"All categories","furn.pick_hint":"Choose a category, then a symbol.","furn.place_hint":"Click on the plan — the piece lands against the nearest wall. Shift places it free.","furn.cat_air_conditioner":"Air conditioners","furn.cat_armchair":"Armchairs","furn.cat_bathtub":"Bathtubs","furn.cat_bed":"Beds","furn.cat_bidet":"Bidets","furn.cat_boiler":"Water heaters","furn.cat_chair":"Chairs","furn.cat_coffee_table":"Coffee tables","furn.cat_computer":"Computer","furn.cat_cooktop":"Cooktops","furn.cat_dining_table":"Dining tables","furn.cat_dishwasher":"Dishwashers","furn.cat_dryer":"Dryers","furn.cat_exercise":"Exercise equipment","furn.cat_fireplace":"Fireplaces","furn.cat_fridge":"Refrigerators","furn.cat_hood":"Cooker hood","furn.cat_kitchen_cabinet":"Kitchen cabinets","furn.cat_kitchen_sink":"Kitchen sinks","furn.cat_nightstand":"Cabinets","furn.cat_oven":"Oven","furn.cat_plant":"Plants","furn.cat_rug":"Rugs","furn.cat_shelving":"Shelving","furn.cat_shower":"Showers","furn.cat_sink":"Sinks","furn.cat_sofa":"Sofas","furn.cat_stairs":"Stairs","furn.cat_toilet":"Toilets","furn.cat_tv":"Televisions","furn.cat_wardrobe":"Wardrobes","furn.cat_washer":"Washing machines","furn.cat_work_table":"Desks","furn.sym_exercise":"Exercise equipment","furn.sym_computer":"Computer","furn.sym_hood":"Cooker hood","furn.sym_oven":"Oven","furn.sym_sofa":"Two-seat sofa","furn.sym_sofa_three_seat":"Three-seat sofa","furn.sym_sofa_corner_right":"Right sectional sofa","furn.sym_armchair":"Soft armchair","furn.sym_armchair_office":"Office chair","furn.sym_coffee_table":"Rectangular coffee table","furn.sym_coffee_table_round":"Round coffee table","furn.sym_coffee_table_oval":"Oval coffee table","furn.sym_coffee_table_rounded":"Rounded coffee table","furn.sym_table_dining":"Rectangular dining table","furn.sym_table_round":"Round table","furn.sym_table_dining_oval":"Oval dining table","furn.sym_table_dining_rounded":"Rounded dining table","furn.sym_chair":"Chair","furn.sym_chair_bar":"Bar stool","furn.sym_desk":"Rectangular desk","furn.sym_desk_corner":"Corner desk","furn.sym_bed_double":"Double bed","furn.sym_bed_single":"Single bed","furn.sym_nightstand":"Bedside table","furn.sym_cabinet_tv":"TV cabinet","furn.sym_cabinet_shoe":"Shoe cabinet","furn.sym_cabinet_sink":"Sink cabinet","furn.sym_wardrobe":"Wardrobe","furn.sym_bookshelf":"Bookcase","furn.sym_wall_unit":"Wall unit","furn.sym_kitchen_floor":"Kitchen floor module","furn.sym_kitchen_floor_corner":"Kitchen floor corner module","furn.sym_kitchen_wall":"Kitchen wall module","furn.sym_kitchen_wall_corner":"Kitchen wall corner module","furn.sym_shelf_floor":"Floor shelving unit","furn.sym_shelf_wall":"Wall shelf","furn.sym_fridge":"Fridge","furn.sym_stove":"Four-burner cooktop","furn.sym_cooktop_two":"Two-burner cooktop","furn.sym_dishwasher":"Dishwasher","furn.sym_washer":"Washing machine","furn.sym_dryer":"Tumble dryer","furn.sym_tv":"TV on stand","furn.sym_tv_wall":"Wall-mounted TV","furn.sym_ac":"Air conditioner","furn.sym_water_heater":"Water heater","furn.sym_toilet":"Floor-standing toilet","furn.sym_toilet_built_in":"Built-in toilet","furn.sym_bathtub":"Rectangular bathtub","furn.sym_bathtub_corner":"Corner bathtub","furn.sym_shower":"Shower","furn.sym_sink":"Washbasin","furn.sym_kitchen_sink":"Single kitchen sink","furn.sym_kitchen_sink_double":"Double kitchen sink","furn.sym_bidet":"Floor-standing bidet","furn.sym_bidet_built_in":"Built-in bidet","furn.sym_stairs":"Stairs","furn.sym_fireplace":"Fireplace","furn.sym_plant":"Plant","furn.sym_rug":"Rug"};export{e};
@@ -0,0 +1 @@
function t(t){const a=t.visualViewport;return a&&Number.isFinite(a.width)&&Number.isFinite(a.height)?{left:Number(a.offsetLeft)||0,top:Number(a.offsetTop)||0,width:Math.max(0,Number(a.width)||0),height:Math.max(0,Number(a.height)||0)}:{left:0,top:0,width:t.innerWidth,height:t.innerHeight}}function a(t,a,h,e=7,i=8){const o=h.left+i,n=h.top+i,m=Math.max(o,h.left+h.width-i),r=Math.max(n,h.top+h.height-i),M=Math.max(0,m-o),f=Math.max(0,r-n),u=Math.min(Math.max(0,a.width),M),p=Math.min(Math.max(0,a.height),f);let x=t.left;x+u>m&&(x=t.right-u),x=Math.min(Math.max(o,x),Math.max(o,m-u));const d=t.bottom+e,s=t.top-e-p;let c="bottom",b=d;return d+p>r&&s>=n?(c="top",b=Math.min(Math.max(n,s),Math.max(n,r-p))):b=Math.min(Math.max(n,d),Math.max(n,r-p)),{left:Math.round(x),top:Math.round(b),side:c,maxWidth:Math.round(M),maxHeight:Math.round(f)}}function h(t){const a=t.HTMLElement?.prototype;return"function"==typeof a?.showPopover&&"function"==typeof a?.hidePopover}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";export{h as a,t as f,a as p};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";var e={"led.tool":"LED-Streifen","led.hint":"Zeichnen Sie den Streifen Punkt für Punkt entlang der Wandflächen oder über den Boden; er rastet am Raster und an Wandflächen ein. Danach wählen Sie das Gerät, das er darstellt.","led.start":"Klicken, um den Streifen zu beginnen","led.start_touch":"Tippen, um den Streifen zu beginnen","led.points":"Punkte: {n} · Umschalt — 45°-Schritte · Esc — fertig · Strg+Z — Punkt entfernen","led.points_touch":"Punkte: {n} · Werkzeug wechseln zum Beenden","led.finish":"Fertig","led.stopped":"Der Streifen stößt an eine Wand","led.unbound":"Streifen ist mit keinem Gerät verbunden","led.bind":"Gerät verbinden","led.change":"Gerät wechseln","led.later":"Später","led.unbind":"Trennen","led.delete":"Streifen löschen","led.settings":"Geräteeinstellungen","led.type_note":"LED-Streifen: Form im Geräte-Editor auf dem Plan bearbeiten","led.show_strip":"Als LED-Streifen zeigen","led.show_icon":"Als Symbol zeigen","led.cancel":"Abbrechen","led.taken":"Bereits mit einem anderen LED-Streifen verbunden","led.space":"Zuerst den LED-Streifen lösen: Er gehört zu seinem Bereich","led.limit_points":"Höchstens 50 Punkte pro Streifen","led.limit_strips":"Höchstens 50 Streifen pro Bereich","led.pick_title":"Gerät für den Streifen wählen","led.pick_new":"Neues Gerät…","led.pick_empty":"Auf diesem Plan gibt es noch keine freien Geräte.","led.saved":"LED-Streifen gespeichert","led.save_failed":"Streifen nicht gespeichert: {err}","led.history_draw":"LED-Streifen","led.history_shape":"Form des LED-Streifens","led.history_view":"Darstellung des Geräts","led.optimize_walls":"Streifen durch Wände: {n} ({spaces})"};const n="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";export{e as dictionary,n as fingerprint};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";var e={"led.tool":"LED-Streifen","led.hint":"Zeichnen Sie den Streifen Punkt für Punkt entlang der Wandflächen oder über den Boden; er rastet am Raster und an Wandflächen ein. Danach wählen Sie das Gerät, das er darstellt.","led.start":"Klicken, um den Streifen zu beginnen","led.start_touch":"Tippen, um den Streifen zu beginnen","led.points":"Punkte: {n} · Umschalt — 45°-Schritte · Esc — fertig · Strg+Z — Punkt entfernen","led.points_touch":"Punkte: {n} · Werkzeug wechseln zum Beenden","led.finish":"Fertig","led.stopped":"Der Streifen stößt an eine Wand","led.unbound":"Streifen ist mit keinem Gerät verbunden","led.bind":"Gerät verbinden","led.change":"Gerät wechseln","led.later":"Später","led.unbind":"Trennen","led.delete":"Streifen löschen","led.settings":"Geräteeinstellungen","led.type_note":"LED-Streifen: Form im Geräte-Editor auf dem Plan bearbeiten","led.show_strip":"Als LED-Streifen zeigen","led.show_icon":"Als Symbol zeigen","led.cancel":"Abbrechen","led.taken":"Bereits mit einem anderen LED-Streifen verbunden","led.space":"Zuerst den LED-Streifen lösen: Er gehört zu seinem Bereich","led.limit_points":"Höchstens 50 Punkte pro Streifen","led.limit_strips":"Höchstens 50 Streifen pro Bereich","led.pick_title":"Gerät für den Streifen wählen","led.pick_new":"Neues Gerät…","led.pick_empty":"Auf diesem Plan gibt es noch keine freien Geräte.","led.saved":"LED-Streifen gespeichert","led.save_failed":"Streifen nicht gespeichert: {err}","led.history_draw":"LED-Streifen","led.history_shape":"Form des LED-Streifens","led.history_view":"Darstellung des Geräts","led.optimize_walls":"Streifen durch Wände: {n} ({spaces})"};const n="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";export{e as dictionary,n as fingerprint};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";var e={"led.tool":"Ruban LED","led.hint":"Tracez le ruban point par point le long des faces des murs ou sur le sol ; il s’aimante à la grille et aux faces des murs. Ensuite, choisissez l’appareil qu’il représente.","led.start":"Cliquez pour commencer le ruban","led.start_touch":"Touchez pour commencer le ruban","led.points":"Points : {n} · Maj — pas de 45° · Échap — terminer · Ctrl+Z — retirer le point","led.points_touch":"Points : {n} · changez d’outil pour terminer","led.finish":"Terminer","led.stopped":"Le ruban bute contre un mur","led.unbound":"Ruban non lié à un appareil","led.bind":"Lier un appareil","led.change":"Changer d’appareil","led.later":"Plus tard","led.unbind":"Délier","led.delete":"Supprimer le ruban","led.settings":"Réglages de l’appareil","led.type_note":"Ruban LED : modifiez sa forme sur le plan dans l’éditeur d’appareils","led.show_strip":"Afficher en ruban LED","led.show_icon":"Afficher en icône","led.cancel":"Annuler","led.taken":"Déjà lié à un autre ruban LED","led.space":"Détachez d’abord le ruban LED : il appartient à son espace","led.limit_points":"50 points au maximum par ruban","led.limit_strips":"50 rubans au maximum par espace","led.pick_title":"Choisissez l’appareil du ruban","led.pick_new":"Nouvel appareil…","led.pick_empty":"Aucun appareil libre sur ce plan pour l’instant.","led.saved":"Ruban LED enregistré","led.save_failed":"Ruban non enregistré : {err}","led.history_draw":"Ruban LED","led.history_shape":"Forme du ruban LED","led.history_view":"Représentation de l’appareil","led.optimize_walls":"Rubans traversant des murs : {n} ({spaces})"};const a="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";export{e as dictionary,a as fingerprint};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";var e={"led.tool":"Ruban LED","led.hint":"Tracez le ruban point par point le long des faces des murs ou sur le sol ; il s’aimante à la grille et aux faces des murs. Ensuite, choisissez l’appareil qu’il représente.","led.start":"Cliquez pour commencer le ruban","led.start_touch":"Touchez pour commencer le ruban","led.points":"Points : {n} · Maj — pas de 45° · Échap — terminer · Ctrl+Z — retirer le point","led.points_touch":"Points : {n} · changez d’outil pour terminer","led.finish":"Terminer","led.stopped":"Le ruban bute contre un mur","led.unbound":"Ruban non lié à un appareil","led.bind":"Lier un appareil","led.change":"Changer d’appareil","led.later":"Plus tard","led.unbind":"Délier","led.delete":"Supprimer le ruban","led.settings":"Réglages de l’appareil","led.type_note":"Ruban LED : modifiez sa forme sur le plan dans l’éditeur d’appareils","led.show_strip":"Afficher en ruban LED","led.show_icon":"Afficher en icône","led.cancel":"Annuler","led.taken":"Déjà lié à un autre ruban LED","led.space":"Détachez d’abord le ruban LED : il appartient à son espace","led.limit_points":"50 points au maximum par ruban","led.limit_strips":"50 rubans au maximum par espace","led.pick_title":"Choisissez l’appareil du ruban","led.pick_new":"Nouvel appareil…","led.pick_empty":"Aucun appareil libre sur ce plan pour l’instant.","led.saved":"Ruban LED enregistré","led.save_failed":"Ruban non enregistré : {err}","led.history_draw":"Ruban LED","led.history_shape":"Forme du ruban LED","led.history_view":"Représentation de l’appareil","led.optimize_walls":"Rubans traversant des murs : {n} ({spaces})"};const a="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";export{e as dictionary,a as fingerprint};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";var e={"led.tool":"LED-лента","led.hint":"Нарисуйте ленту по точкам вдоль граней стен или по полу — она прилипает к сетке и граням. После завершения выберите устройство, которое она показывает.","led.start":"Нажмите, чтобы начать ленту","led.start_touch":"Коснитесь, чтобы начать ленту","led.points":"Точек: {n} · Shift — шаг 45° · Esc — завершить · Ctrl+Z — убрать точку","led.points_touch":"Точек: {n} · смените инструмент, чтобы завершить","led.finish":"Завершить","led.stopped":"Лента упёрлась в стену","led.unbound":"Лента не привязана к устройству","led.bind":"Привязать устройство","led.change":"Сменить устройство","led.later":"Позже","led.unbind":"Отвязать","led.delete":"Удалить ленту","led.settings":"Настройки устройства","led.type_note":"LED-лента: форма редактируется на плане в редакторе устройств","led.show_strip":"Показывать LED-лентой","led.show_icon":"Показывать значком","led.cancel":"Отмена","led.taken":"Уже привязано к другой LED-ленте","led.space":"Сначала отвяжите LED-ленту: она принадлежит своему пространству","led.limit_points":"Не более 50 точек в ленте","led.limit_strips":"Не более 50 лент в пространстве","led.pick_title":"Выберите устройство для ленты","led.pick_new":"Новое устройство…","led.pick_empty":"На этом плане пока нет свободных устройств.","led.saved":"LED-лента сохранена","led.save_failed":"Лента не сохранена: {err}","led.history_draw":"LED-лента","led.history_shape":"Форма LED-ленты","led.history_view":"Вид устройства","led.optimize_walls":"Лент, проходящих сквозь стены: {n} ({spaces})"};const d="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";export{e as dictionary,d as fingerprint};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";var e={"led.tool":"LED-лента","led.hint":"Нарисуйте ленту по точкам вдоль граней стен или по полу — она прилипает к сетке и граням. После завершения выберите устройство, которое она показывает.","led.start":"Нажмите, чтобы начать ленту","led.start_touch":"Коснитесь, чтобы начать ленту","led.points":"Точек: {n} · Shift — шаг 45° · Esc — завершить · Ctrl+Z — убрать точку","led.points_touch":"Точек: {n} · смените инструмент, чтобы завершить","led.finish":"Завершить","led.stopped":"Лента упёрлась в стену","led.unbound":"Лента не привязана к устройству","led.bind":"Привязать устройство","led.change":"Сменить устройство","led.later":"Позже","led.unbind":"Отвязать","led.delete":"Удалить ленту","led.settings":"Настройки устройства","led.type_note":"LED-лента: форма редактируется на плане в редакторе устройств","led.show_strip":"Показывать LED-лентой","led.show_icon":"Показывать значком","led.cancel":"Отмена","led.taken":"Уже привязано к другой LED-ленте","led.space":"Сначала отвяжите LED-ленту: она принадлежит своему пространству","led.limit_points":"Не более 50 точек в ленте","led.limit_strips":"Не более 50 лент в пространстве","led.pick_title":"Выберите устройство для ленты","led.pick_new":"Новое устройство…","led.pick_empty":"На этом плане пока нет свободных устройств.","led.saved":"LED-лента сохранена","led.save_failed":"Лента не сохранена: {err}","led.history_draw":"LED-лента","led.history_shape":"Форма LED-ленты","led.history_view":"Вид устройства","led.optimize_walls":"Лент, проходящих сквозь стены: {n} ({spaces})"};const l="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";export{e as dictionary,l as fingerprint};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";import{E as e,N as t,r as s,ax as o,f as i,A as n}from"./open-spans-Co-2W4Ja.js";import{cA as a,cH as r,aS as l,cL as c,cq as d,cr as p,Y as u,cM as h,cN as f,cO as m,cP as v}from"./houseplan-card-BRGPGuZP.js";import{L as g,v as w,s as $,a as _,b as y,p as k,c as b,d as C,e as P}from"./led-strip-geometry-BmgmliDq.js";const M="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";let F,L=null,U=null;function x(t,s){return L||U||F===t||""===F||(U=(void 0===F?import("./led-strip-field-BrWH5irR.js"):import(new URL(`./led-strip-field-BrWH5irR.js?${Date.now()}`,import.meta.url).href)).then(t=>{t.LED_FIELD_FINGERPRINT===e?L=t:F=""},()=>{F=t}).finally(()=>{U=null})),L||U?.then(()=>L&&s()),L}const D=e=>e.some(e=>e.glow&&"on"===e.state),R=e=>e.map(e=>[e[0],e[1]]),E="#383838";function A(e,t){if(!e)return null;const s=Array.isArray(e.masonryGeometry)?e.masonryGeometry:[],o=[...a(e.masonryGeometry),...e.opaqueBodies],i=[];for(const e of o)for(let t=0;t<e.length;t++){const s=e[t],o=e[(t+1)%e.length];s&&o&&i.push({a:[s[0],s[1]],b:[o[0],o[1]]})}const n=25,c=new Map;i.forEach((e,t)=>{const s=Math.floor(Math.min(e.a[0],e.b[0])/n),o=Math.floor(Math.max(e.a[0],e.b[0])/n),i=Math.floor(Math.min(e.a[1],e.b[1])/n),a=Math.floor(Math.max(e.a[1],e.b[1])/n);for(let e=s;e<=o;e++)for(let s=i;s<=a;s++){const o=`${e},${s}`,i=c.get(o);i?i.push(t):c.set(o,[t])}});const d=e=>{let t=1/0,s=1/0,o=-1/0,i=-1/0;for(const n of e)t=Math.min(t,n[0]),s=Math.min(s,n[1]),o=Math.max(o,n[0]),i=Math.max(i,n[1]);return[t,s,o,i]},p=(e,t)=>e[0]>=t[0]&&e[0]<=t[2]&&e[1]>=t[1]&&e[1]<=t[3],u=s.filter(e=>e?.[0]?.length).map(e=>({box:d(e[0]),poly:e})),h=e.opaqueBodies.map(e=>({box:d(e),body:e}));return{faces:i,inside:e=>u.some(({box:t,poly:s})=>p(e,t)&&r([e[0],e[1]],[s]))||h.some(({box:t,body:s})=>p(e,t)&&l([e[0],e[1]],s)),epsilon:t,near:e=>{const t=new Set;for(let s=Math.floor(e[0]/n);s<=Math.floor(e[2]/n);s++)for(let o=Math.floor(e[1]/n);o<=Math.floor(e[3]/n);o++)for(const e of c.get(`${s},${o}`)||[])t.add(e);return[...t].map(e=>i[e])}}}function j(e){const t=e.candidate?.sourceEid||e.device.primary||"",s=t?e.hass?.states?.[t]?.state:void 0,o="unavailable"===s||"unknown"===s||!!t&&!!e.hass&&!e.hass.states?.[t]&&!e.device.virtual,i=Number(e.device.marker?.glow_radius_cm),n=Number.isFinite(i)&&i>0?i/e.cellCm*e.gridPitch:e.defaultRadius,a=o?null:e.candidate?.appearance??null;return{strip:e.strip,device:e.device,state:o?"unavailable":a?"on":"off",glow:e.glow,appearance:a,radius:n}}const I=new WeakMap;function T(e,t,s){const o=P*s;const i=I.get(e);if(i&&i.faces===t&&i.epsilon===t?.epsilon&&i.value.t===o&&i.points.length===e.points.length&&i.points.every((t,s)=>t[0]===e.points[s][0]&&t[1]===e.points[s][1]))return i.value;const n=R(e.points),a=y(n,t,o/2),r={t:o,path:a,d:k(a)};return I.set(e,{faces:t,epsilon:t?.epsilon,points:n,value:r}),r}function H(e){if(!e.views.length)return i``;const t=e.handlers,s=[...e.views].sort((e,t)=>e.strip.id<t.strip.id?1:e.strip.id>t.strip.id?-1:0),o=e.iso;return i`<g class="led-strips" data-hp-led-strips="${s.length}"
data-hp-iso=${o?"raised":n}>
${o?i`<defs><filter id="hp-led-iso-shadow" filterUnits="userSpaceOnUse"
x="-100000" y="-100000" width="200000" height="200000">
<feGaussianBlur stdDeviation="${o.shadow.sigma}"></feGaussianBlur></filter></defs>`:n}
<style>
.led-strip .led-focus { fill: none; stroke: transparent; }
.led-strip:has(.led-hit:focus-visible) .led-focus { stroke: var(--primary-color, #03a9f4); }
.led-strip .led-hit { fill: none; stroke: transparent; cursor: pointer; outline: none; }
</style>
${v(s,e=>e.strip.id,s=>{const{t:a,path:r,d:l}=T(s.strip,e.faces,e.d),c="unavailable"===s.state,d=c?"#9e9e9e":"on"===s.state&&s.appearance?s.appearance.c:"#FFFFFF",p=2*b(a*e.perUnit)/(e.perUnit||1),u=s.device,h=t=>function(e,t,s){const o=e.currentTarget,i=o?.getScreenCTM?.();if(!i||s.views.length<2)return t;const n=i.inverse(),a=[n.a*e.clientX+n.c*e.clientY+n.e,n.b*e.clientX+n.d*e.clientY+n.f],r=s.views.map(e=>{const{t:t,path:o}=T(e.strip,s.faces,s.d);return{id:e.strip.id,points:o.points.map(e=>[e[0]*s.perUnit,e[1]*s.perUnit]),closed:o.closed,thicknessPx:t*s.perUnit}}),l=C([a[0]*s.perUnit,a[1]*s.perUnit],r);return s.views.find(e=>e.strip.id===l)||t}(t,s,e).device,f=o?`translate(0 ${-o.lift})`:n;return i`<g class="led-strip state-${s.state}" data-led-strip="${s.strip.id}"
data-marker="${u.id}" data-state="${s.state}" data-closed="${r.closed?"true":"false"}">
${o?i`<path class="led-iso-shadow" d="${l}" fill="none" stroke="black"
stroke-opacity="${o.shadow.opacity}" stroke-width="${a}" stroke-linecap="round"
stroke-linejoin="round" filter="url(#hp-led-iso-shadow)" pointer-events="none"
transform="translate(${o.shadow.dx} ${o.shadow.dy-o.lift})"></path>
${[1,.5].map(e=>i`<path class="led-iso-edge" d="${l}" fill="none" stroke="${o.edge}"
stroke-width="${a}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"
transform="translate(0 ${-o.lift+o.depth*e})"></path>`)}`:n}
<g transform=${f}>
<path class="led-outline" d="${l}" fill="none" stroke="${E}" stroke-width="${a}"
stroke-linecap="round" stroke-linejoin="round"
stroke-dasharray=${c?`${2*a} ${1.5*a}`:n}></path>
<path class="led-core" d="${l}" fill="none" stroke="${d}" stroke-width="${a/2}"
stroke-linecap="round" stroke-linejoin="round"
stroke-dasharray=${c?`${2*a} ${1.5*a}`:n}></path>
<path class="led-focus" d="${l}" stroke-width="${1.6*a}" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true"></path>
${t?i`<path class="led-hit" d="${l}" stroke-width="${p}"
stroke-linecap="round" stroke-linejoin="round" pointer-events="stroke"
role="button" tabindex="0" aria-label="${t.label(u)}"
data-hp="device" data-id="${u.id}"
@click=${e=>t.click(e,h(e))}
@keydown=${e=>t.keydown(e,u)}
@contextmenu=${e=>t.contextmenu(e,h(e))}
@pointerdown=${e=>t.pointerdown(e,h(e))}
@pointermove=${e=>t.pointermove(e,h(e))}
@pointerup=${e=>t.pointerup(e,h(e))}
@pointercancel=${e=>t.pointercancel(e,u)}
@lostpointercapture=${e=>t.pointercancel(e,u)}
@pointerover=${e=>t.pointerover(e,h(e))}
@pointerleave=${()=>t.pointerleave()}
@focus=${e=>t.focus(e,u)}
@blur=${()=>t.blur(u)}></path>`:n}
</g>
</g>`})}
</g>`}function N(e,t,s,o){return(t?e.find(e=>e.id===t):void 0)??(s?e.find(e=>o(s,e)):void 0)}function q(e){const s=new Map(e.devices.map(e=>[e.id,e])),o=g/e.cellCm*e.gridPitch,i=new Map(c({hass:e.hass,devices:e.devices,virtualLights:e.virtualLights,spaceId:e.space.id,defaultColor:e.defaultColor,paletteAlpha:e.paletteAlpha,defaultRadiusUnits:o,cellCm:e.cellCm,gridPitch:e.gridPitch,position:()=>({x:0,y:0})}).map(e=>[e.key,e])),n=[];for(const a of e.space.led_strips||[]){if(!1===a?.active||!a.marker||!w(a.points))continue;const r={...a,points:a.points.map(e=>[e[0]*t,e[1]*t])},l=s.get(r.marker);if(!l||l.space!==e.space.id)continue;if(l.hidden&&!e.showHidden)continue;const c=N(e.space.rooms,l.marker?.room_id,$(R(r.points)),e.inRoom);n.push(j({strip:r,device:l,candidate:i.get(`${e.space.id}|${l.id}`)??null,hass:e.hass,glow:!!c&&e.glowFor(c),defaultRadius:o,cellCm:e.cellCm,gridPitch:e.gridPitch}))}const a=_/e.cellCm*e.gridPitch;return{views:n,faces:A(e.scene,a),d:e.iconPct/100*d(e.space),scene:e.scene,polygons:e.polygons}}const B=new WeakMap;function G(e){for(const t of B.get(e)?.frame.views??[])I.delete(t.strip);B.delete(e),L?.releaseLedField(e)}function S(e){return{shapes:B.get(e)?.frame.views.length??0,...L?.ledFieldStats(e)??{visibility:0,sources:0,visibilityPaths:0,pathChars:0,recomputes:0}}}function O(e,t,i){const n=t.rooms.flatMap(e=>{const t=s(e);return t?[{room:e,poly:t}]:[]}),a=n.length?e._lightBarriers(t,n.map(({room:e,poly:t})=>({r:e,poly:t}))):null,r=[t,e._renderDevices,e._renderPlanHass,i,a,e._mode,e._showAll],l=B.get(e);if(l&&l.key.every((e,t)=>e===r[t]))return l.frame;for(const e of l?.frame.views??[])I.delete(e.strip);const c=e._config?.icon_size??2.5,d=q({space:t,devices:e._renderDevices,hass:e._renderPlanHass,virtualLights:e._virtualLights,defaultColor:e._fillColors.glow_light.c,paletteAlpha:e._fillColors.glow_light.a,cellCm:e._cellCm,gridPitch:e._gridPitch,iconPct:c>8?2.5:c,scene:a,polygons:n,glowFor:e=>o(i,e),inRoom:(t,s)=>e._pointInRoom(t,s),showHidden:"devices"===e._mode&&e._showAll});return!1!==e.isConnected&&B.set(e,{key:r,frame:d}),d}function W(e,t,s,o){const n=O(e,t,s),a=e._stageEl?.clientWidth,r="iso"===e._renderProjection&&"view"===e._mode,l=r?n.d*p:n.d,c="theme-dark"===u(e._renderPlanHass)?"dark":"light",d=!!e._isoLightFloors?.size,v=h(c,d),g=H({views:n.views,d:l,iso:r?{lift:m.lift*l,depth:m.depth*l,edge:f(E,c,d),shadow:{dx:v.dx*l,dy:v.dy*l,sigma:v.sigma*l,opacity:v.opacityWhite}}:null,faces:n.faces,perUnit:a&&o.w?a/o.w:1,handlers:"view"===e._mode?{click:(t,s)=>e._clickDevice(t,s),keydown:(t,s)=>e._keyDevice(t,s),contextmenu:(t,s)=>e._ctxDevice(t,s),pointerdown:(t,s)=>e._pointerDown(t,s),pointermove:(t,s)=>{const o=e._pointerMove(t,s);o&&e._showDeviceTip(t,o)},pointerup:(t,s)=>e._pointerUp(t,s),pointercancel:(t,s)=>e._pointerCancel(t,s),pointerover:(t,s)=>e._showDeviceTip(t,s),pointerleave:()=>e._clearPointerHover(),focus:(t,s)=>e._showDeviceFocusTip(t,s),blur:t=>e._hideDeviceFocusTip(t.id),label:e=>e.name}:null});return"plan"===e._mode||"decor"===e._mode?i`<g class="led-passive" opacity="0.45" pointer-events="none">${g}</g>`:g}function Y(e,t,s){if(!1===e.isConnected)return i``;const o=O(e,t,s),n=o.scene&&(D(o.views)||L?.hasLedField(e))?L||x(t.id,()=>!1!==e.isConnected&&e.requestUpdate()):null;return n?n.renderLedField({views:o.views,scene:o.scene,polygons:o.polygons,faces:o.faces,spaceId:t.id,owner:e,requestUpdate:()=>e.requestUpdate(),isConnected:()=>!1!==e.isConnected}):i``}function z(e){const t=e.space.rooms.flatMap(e=>{const t=s(e);return t?[{room:e,poly:t}]:[]}),o=q({space:e.space,devices:e.devices,hass:e.hass,virtualLights:e.virtualLights,defaultColor:e.defaultColor,paletteAlpha:e.paletteAlpha,cellCm:e.cellCm,gridPitch:e.gridPitch,iconPct:e.iconPct,scene:e.scene,polygons:t,glowFor:e.glowFor,inRoom:e.inRoom,showHidden:!1}),a=e.live?o.views:o.views.map(e=>({...e,state:"off",appearance:null,glow:!1})),r=A({masonryGeometry:e.bodies.masonryGeometry,opaqueBodies:e.bodies.opaqueBodies},_/e.cellCm*e.gridPitch),l=e.isConnected()&&e.scene&&e.live&&(D(a)||L?.hasLedField(e.owner))?x(e.space.id,()=>e.isConnected()&&e.ready()):null;return l||L?.releaseLedField(e.owner),i`${l?l.renderLedField({views:a,scene:e.scene,polygons:t,faces:r,spaceId:e.space.id,owner:e.owner,requestUpdate:e.ready,isConnected:e.isConnected}):n}${H({views:a,d:o.d,faces:r,perUnit:e.perUnit,handlers:null})}`}export{M as LED_RUNTIME_FINGERPRINT,A as faceContext,x as ledField,q as ledFrame,O as ledFrameFor,S as ledStats,j as ledStripView,T as ledStripePath,G as releaseLed,Y as renderLedFieldFor,W as renderLedLayerFor,H as renderLedStripes,z as renderStaticLed,N as stripRoom};
@@ -1,47 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";import{eu as e,eC as t,c6 as s,eJ as o,eK as i,ad as n,ee as a,O as r,eL as l,az as d,ef as c,aL as p,eM as u,eN as h,eO as f,A as m,eP as v}from"./houseplan-card-DpMO8ZtV.js";import{L as g,v as w,s as $,a as _,b as y,p as k,c as b,d as C,e as P}from"./led-strip-geometry-CbsAEceS.js";const M="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";let F,L=null,U=null;function x(e,t){return L||U||F===e||""===F||(U=(void 0===F?import("./led-strip-field-DftOG2Ox.js"):import(new URL(`./led-strip-field-DftOG2Ox.js?${Date.now()}`,import.meta.url).href)).then(e=>{e.LED_FIELD_FINGERPRINT===o?L=e:F=""},()=>{F=e}).finally(()=>{U=null})),L||U?.then(()=>L&&t()),L}const D=e=>e.some(e=>e.glow&&"on"===e.state),R=e=>e.map(e=>[e[0],e[1]]),E="#383838";function A(o,i){if(!o)return null;const n=Array.isArray(o.masonryGeometry)?o.masonryGeometry:[],a=[...e(o.masonryGeometry),...o.opaqueBodies],r=[];for(const e of a)for(let t=0;t<e.length;t++){const s=e[t],o=e[(t+1)%e.length];s&&o&&r.push({a:[s[0],s[1]],b:[o[0],o[1]]})}const l=25,d=new Map;r.forEach((e,t)=>{const s=Math.floor(Math.min(e.a[0],e.b[0])/l),o=Math.floor(Math.max(e.a[0],e.b[0])/l),i=Math.floor(Math.min(e.a[1],e.b[1])/l),n=Math.floor(Math.max(e.a[1],e.b[1])/l);for(let e=s;e<=o;e++)for(let s=i;s<=n;s++){const o=`${e},${s}`,i=d.get(o);i?i.push(t):d.set(o,[t])}});const c=e=>{let t=1/0,s=1/0,o=-1/0,i=-1/0;for(const n of e)t=Math.min(t,n[0]),s=Math.min(s,n[1]),o=Math.max(o,n[0]),i=Math.max(i,n[1]);return[t,s,o,i]},p=(e,t)=>e[0]>=t[0]&&e[0]<=t[2]&&e[1]>=t[1]&&e[1]<=t[3],u=n.filter(e=>e?.[0]?.length).map(e=>({box:c(e[0]),poly:e})),h=o.opaqueBodies.map(e=>({box:c(e),body:e}));return{faces:r,inside:e=>u.some(({box:s,poly:o})=>p(e,s)&&t([e[0],e[1]],[o]))||h.some(({box:t,body:o})=>p(e,t)&&s([e[0],e[1]],o)),epsilon:i,near:e=>{const t=new Set;for(let s=Math.floor(e[0]/l);s<=Math.floor(e[2]/l);s++)for(let o=Math.floor(e[1]/l);o<=Math.floor(e[3]/l);o++)for(const e of d.get(`${s},${o}`)||[])t.add(e);return[...t].map(e=>r[e])}}}function I(e){const t=e.candidate?.sourceEid||e.device.primary||"",s=t?e.hass?.states?.[t]?.state:void 0,o="unavailable"===s||"unknown"===s||!!t&&!!e.hass&&!e.hass.states?.[t]&&!e.device.virtual,i=Number(e.device.marker?.glow_radius_cm),n=Number.isFinite(i)&&i>0?i/e.cellCm*e.gridPitch:e.defaultRadius,a=o?null:e.candidate?.appearance??null;return{strip:e.strip,device:e.device,state:o?"unavailable":a?"on":"off",glow:e.glow,appearance:a,radius:n}}const T=new WeakMap;function j(e,t,s){const o=P*s;const i=T.get(e);if(i&&i.faces===t&&i.epsilon===t?.epsilon&&i.value.t===o&&i.points.length===e.points.length&&i.points.every((t,s)=>t[0]===e.points[s][0]&&t[1]===e.points[s][1]))return i.value;const n=R(e.points),a=y(n,t,o/2),r={t:o,path:a,d:k(a)};return T.set(e,{faces:t,epsilon:t?.epsilon,points:n,value:r}),r}function G(e){if(!e.views.length)return d``;const t=e.handlers,s=[...e.views].sort((e,t)=>e.strip.id<t.strip.id?1:e.strip.id>t.strip.id?-1:0),o=e.iso;return d`<g class="led-strips" data-hp-led-strips="${s.length}"
data-hp-iso=${o?"raised":m}>
${o?d`<defs><filter id="hp-led-iso-shadow" filterUnits="userSpaceOnUse"
x="-100000" y="-100000" width="200000" height="200000">
<feGaussianBlur stdDeviation="${o.shadow.sigma}"></feGaussianBlur></filter></defs>`:m}
<style>
.led-strip .led-focus { fill: none; stroke: transparent; }
.led-strip:has(.led-hit:focus-visible) .led-focus { stroke: var(--primary-color, #03a9f4); }
.led-strip .led-hit { fill: none; stroke: transparent; cursor: pointer; outline: none; }
</style>
${v(s,e=>e.strip.id,s=>{const{t:i,path:n,d:a}=j(s.strip,e.faces,e.d),r="unavailable"===s.state,l=r?"#9e9e9e":"on"===s.state&&s.appearance?s.appearance.c:"#FFFFFF",c=2*b(i*e.perUnit)/(e.perUnit||1),p=s.device,u=t=>function(e,t,s){const o=e.currentTarget,i=o?.getScreenCTM?.();if(!i||s.views.length<2)return t;const n=i.inverse(),a=[n.a*e.clientX+n.c*e.clientY+n.e,n.b*e.clientX+n.d*e.clientY+n.f],r=s.views.map(e=>{const{t:t,path:o}=j(e.strip,s.faces,s.d);return{id:e.strip.id,points:o.points.map(e=>[e[0]*s.perUnit,e[1]*s.perUnit]),closed:o.closed,thicknessPx:t*s.perUnit}}),l=C([a[0]*s.perUnit,a[1]*s.perUnit],r);return s.views.find(e=>e.strip.id===l)||t}(t,s,e).device,h=o?`translate(0 ${-o.lift})`:m;return d`<g class="led-strip state-${s.state}" data-led-strip="${s.strip.id}"
data-marker="${p.id}" data-state="${s.state}" data-closed="${n.closed?"true":"false"}">
${o?d`<path class="led-iso-shadow" d="${a}" fill="none" stroke="black"
stroke-opacity="${o.shadow.opacity}" stroke-width="${i}" stroke-linecap="round"
stroke-linejoin="round" filter="url(#hp-led-iso-shadow)" pointer-events="none"
transform="translate(${o.shadow.dx} ${o.shadow.dy-o.lift})"></path>
${[1,.5].map(e=>d`<path class="led-iso-edge" d="${a}" fill="none" stroke="${o.edge}"
stroke-width="${i}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"
transform="translate(0 ${-o.lift+o.depth*e})"></path>`)}`:m}
<g transform=${h}>
<path class="led-outline" d="${a}" fill="none" stroke="${E}" stroke-width="${i}"
stroke-linecap="round" stroke-linejoin="round"
stroke-dasharray=${r?`${2*i} ${1.5*i}`:m}></path>
<path class="led-core" d="${a}" fill="none" stroke="${l}" stroke-width="${i/2}"
stroke-linecap="round" stroke-linejoin="round"
stroke-dasharray=${r?`${2*i} ${1.5*i}`:m}></path>
<path class="led-focus" d="${a}" stroke-width="${1.6*i}" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true"></path>
${t?d`<path class="led-hit" d="${a}" stroke-width="${c}"
stroke-linecap="round" stroke-linejoin="round" pointer-events="stroke"
role="button" tabindex="0" aria-label="${t.label(p)}"
data-hp="device" data-id="${p.id}"
@click=${e=>t.click(e,u(e))}
@keydown=${e=>t.keydown(e,p)}
@contextmenu=${e=>t.contextmenu(e,u(e))}
@pointerdown=${e=>t.pointerdown(e,u(e))}
@pointermove=${e=>t.pointermove(e,u(e))}
@pointerup=${e=>t.pointerup(e,u(e))}
@pointercancel=${e=>t.pointercancel(e,p)}
@lostpointercapture=${e=>t.pointercancel(e,p)}
@pointerover=${e=>t.pointerover(e,u(e))}
@pointerleave=${()=>t.pointerleave()}
@focus=${e=>t.focus(e,p)}
@blur=${()=>t.blur(p)}></path>`:m}
</g>
</g>`})}
</g>`}function H(e,t,s,o){return(t?e.find(e=>e.id===t):void 0)??(s?e.find(e=>o(s,e)):void 0)}function N(e){const t=new Map(e.devices.map(e=>[e.id,e])),s=g/e.cellCm*e.gridPitch,o=new Map(i({hass:e.hass,devices:e.devices,virtualLights:e.virtualLights,spaceId:e.space.id,defaultColor:e.defaultColor,paletteAlpha:e.paletteAlpha,defaultRadiusUnits:s,cellCm:e.cellCm,gridPitch:e.gridPitch,position:()=>({x:0,y:0})}).map(e=>[e.key,e])),r=[];for(const i of e.space.led_strips||[]){if(!1===i?.active||!i.marker||!w(i.points))continue;const a={...i,points:i.points.map(e=>[e[0]*n,e[1]*n])},l=t.get(a.marker);if(!l||l.space!==e.space.id)continue;if(l.hidden&&!e.showHidden)continue;const d=H(e.space.rooms,l.marker?.room_id,$(R(a.points)),e.inRoom);r.push(I({strip:a,device:l,candidate:o.get(`${e.space.id}|${l.id}`)??null,hass:e.hass,glow:!!d&&e.glowFor(d),defaultRadius:s,cellCm:e.cellCm,gridPitch:e.gridPitch}))}const l=_/e.cellCm*e.gridPitch;return{views:r,faces:A(e.scene,l),d:e.iconPct/100*a(e.space),scene:e.scene,polygons:e.polygons}}const q=new WeakMap;function B(e){for(const t of q.get(e)?.frame.views??[])T.delete(t.strip);q.delete(e),L?.releaseLedField(e)}function S(e){return{shapes:q.get(e)?.frame.views.length??0,...L?.ledFieldStats(e)??{visibility:0,sources:0,visibilityPaths:0,pathChars:0,recomputes:0}}}function O(e,t,s){const o=t.rooms.flatMap(e=>{const t=r(e);return t?[{room:e,poly:t}]:[]}),i=o.length?e._lightBarriers(t,o.map(({room:e,poly:t})=>({r:e,poly:t}))):null,n=[t,e._renderDevices,e._renderPlanHass,s,i,e._mode,e._showAll],a=q.get(e);if(a&&a.key.every((e,t)=>e===n[t]))return a.frame;for(const e of a?.frame.views??[])T.delete(e.strip);const d=e._config?.icon_size??2.5,c=N({space:t,devices:e._renderDevices,hass:e._renderPlanHass,virtualLights:e._virtualLights,defaultColor:e._fillColors.glow_light.c,paletteAlpha:e._fillColors.glow_light.a,cellCm:e._cellCm,gridPitch:e._gridPitch,iconPct:d>8?2.5:d,scene:i,polygons:o,glowFor:e=>l(s,e),inRoom:(t,s)=>e._pointInRoom(t,s),showHidden:"devices"===e._mode&&e._showAll});return!1!==e.isConnected&&q.set(e,{key:n,frame:c}),c}function W(e,t,s,o){const i=O(e,t,s),n=e._stageEl?.clientWidth,a="iso"===e._renderProjection&&"view"===e._mode,r=a?i.d*c:i.d,l="theme-dark"===p(e._renderPlanHass)?"dark":"light",m=!!e._isoLightFloors?.size,v=u(l,m),g=G({views:i.views,d:r,iso:a?{lift:f.lift*r,depth:f.depth*r,edge:h(E,l,m),shadow:{dx:v.dx*r,dy:v.dy*r,sigma:v.sigma*r,opacity:v.opacityWhite}}:null,faces:i.faces,perUnit:n&&o.w?n/o.w:1,handlers:"view"===e._mode?{click:(t,s)=>e._clickDevice(t,s),keydown:(t,s)=>e._keyDevice(t,s),contextmenu:(t,s)=>e._ctxDevice(t,s),pointerdown:(t,s)=>e._pointerDown(t,s),pointermove:(t,s)=>{const o=e._pointerMove(t,s);o&&e._showDeviceTip(t,o)},pointerup:(t,s)=>e._pointerUp(t,s),pointercancel:(t,s)=>e._pointerCancel(t,s),pointerover:(t,s)=>e._showDeviceTip(t,s),pointerleave:()=>e._clearPointerHover(),focus:(t,s)=>e._showDeviceFocusTip(t,s),blur:t=>e._hideDeviceFocusTip(t.id),label:e=>e.name}:null});return"plan"===e._mode||"decor"===e._mode?d`<g class="led-passive" opacity="0.45" pointer-events="none">${g}</g>`:g}function z(e,t,s){if(!1===e.isConnected)return d``;const o=O(e,t,s),i=o.scene&&(D(o.views)||L?.hasLedField(e))?L||x(t.id,()=>!1!==e.isConnected&&e.requestUpdate()):null;return i?i.renderLedField({views:o.views,scene:o.scene,polygons:o.polygons,faces:o.faces,spaceId:t.id,owner:e,requestUpdate:()=>e.requestUpdate(),isConnected:()=>!1!==e.isConnected}):d``}function Y(e){const t=e.space.rooms.flatMap(e=>{const t=r(e);return t?[{room:e,poly:t}]:[]}),s=N({space:e.space,devices:e.devices,hass:e.hass,virtualLights:e.virtualLights,defaultColor:e.defaultColor,paletteAlpha:e.paletteAlpha,cellCm:e.cellCm,gridPitch:e.gridPitch,iconPct:e.iconPct,scene:e.scene,polygons:t,glowFor:e.glowFor,inRoom:e.inRoom,showHidden:!1}),o=e.live?s.views:s.views.map(e=>({...e,state:"off",appearance:null,glow:!1})),i=A({masonryGeometry:e.bodies.masonryGeometry,opaqueBodies:e.bodies.opaqueBodies},_/e.cellCm*e.gridPitch),n=e.isConnected()&&e.scene&&e.live&&(D(o)||L?.hasLedField(e.owner))?x(e.space.id,()=>e.isConnected()&&e.ready()):null;return n||L?.releaseLedField(e.owner),d`${n?n.renderLedField({views:o,scene:e.scene,polygons:t,faces:i,spaceId:e.space.id,owner:e.owner,requestUpdate:e.ready,isConnected:e.isConnected}):m}${G({views:o,d:s.d,faces:i,perUnit:e.perUnit,handlers:null})}`}export{M as LED_RUNTIME_FINGERPRINT,A as faceContext,x as ledField,N as ledFrame,O as ledFrameFor,S as ledStats,I as ledStripView,j as ledStripePath,B as releaseLed,z as renderLedFieldFor,W as renderLedLayerFor,G as renderLedStripes,Y as renderStaticLed,H as stripRoom};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";const t=t=>[t.left,t.top,t.right,t.bottom,t.width,t.height].every(Number.isFinite)&&t.right>=t.left&&t.bottom>=t.top&&t.width>=0&&t.height>=0;function o(o){const{preferred:e,size:i,bounds:h,blockers:r,gap:n=8,edge:f=8}=o;if(![e.x,e.y,i.width,i.height,n,f].every(Number.isFinite)||i.width<=0||i.height<=0||n<0||f<0||!t(h)||r.some(o=>!t(o)))return null;const s=h.left+f,l=h.right-f-i.width,d=h.top+f,b=h.bottom-f-i.height;if(![s,l,d,b].every(Number.isFinite)||l<s||b<d)return null;const g=r.filter(t=>t.right>t.left&&t.bottom>t.top).map(t=>({left:t.left-n-i.width,right:t.right+n,top:t.top-n-i.height,bottom:t.bottom+n})),a=(t,o,e)=>Math.max(o,Math.min(e,t)),c=new Set([s,l,a(e.x,s,l)]),m=new Set([d,b,a(e.y,d,b)]);for(const t of g)c.add(a(t.left,s,l)),c.add(a(t.right,s,l)),m.add(a(t.top,d,b)),m.add(a(t.bottom,d,b));const p=[...c].sort((t,o)=>t-o),u=[...m].sort((t,o)=>t-o);let w=null,y=1/0;for(const t of u)for(const o of p){if(g.some(e=>o>e.left&&o<e.right&&t>e.top&&t<e.bottom))continue;const i=Math.hypot(o-e.x,t-e.y);(!w||i<y)&&(w={left:o,top:t},y=i)}return w}export{o as p};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";import{eR as e,eS as n,eG as a}from"./houseplan-card-DpMO8ZtV.js";function o(a,o){return new e([{code:"en",dictionary:a},{code:"ru",loadDictionary:o.ru},{code:"de",loadDictionary:o.de},{code:"fr",loadDictionary:o.fr}],"1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f",console.warn,n)}function r(e){return function(e,n){return{state:a=>{const o=e.state(a);return"pending"!==o&&n.some(e=>"pending"===e.state(a))?"pending":o},dictionary:n=>e.dictionary(n),ensure:a=>Promise.all([e,...n].map(e=>e.ensure(a))).then(()=>{})}}(a,e)}export{o as n,r as s};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";import{co as n,cp as a,aJ as e}from"./open-spans-Co-2W4Ja.js";function o(e,o){return new n([{code:"en",dictionary:e},{code:"ru",loadDictionary:o.ru},{code:"de",loadDictionary:o.de},{code:"fr",loadDictionary:o.fr}],"f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342",console.warn,a)}function c(n){return function(n,a){return{state:e=>{const o=n.state(e);return"pending"!==o&&a.some(n=>"pending"===n.state(e))?"pending":o},dictionary:a=>n.dictionary(a),ensure:e=>Promise.all([n,...a].map(n=>n.ensure(e))).then(()=>{})}}(e,n)}export{o as n,c as s};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";import{aG as e,l as t,aJ as s,A as a,eF as o,eG as i,eH as n,E as l,eQ as c,c as r}from"./houseplan-card-DpMO8ZtV.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id}))}catch{this._spaces=[]}finally{this._spacesLoading=!1}}}get _lang(){return t(this.hass,this._config?.language)}get _schema(){const e=this._spaces||[];return[e.length?{name:"space",selector:{select:{mode:"dropdown",options:e}}}:{name:"space",selector:{text:{}}},{name:"title",selector:{text:{}}},{name:"fit",selector:{select:{mode:"dropdown",options:[{value:"content",label:s(this._lang,"editor.fit_content")},{value:"house",label:s(this._lang,"editor.fit_house")}]}}},{name:"show_button",selector:{boolean:{}}},{name:"button_label",selector:{text:{}}},{name:"button_target",selector:{text:{}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"light_pools",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}}]}render(){if(!this.hass||!this._config)return a;const e=o(this,i,t(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return l;this._loadSpaces();const h=this._lang,_={space:s(h,"editor.space"),title:s(h,"editor.title"),fit:s(h,"editor.framing"),show_button:s(h,"editor.show_button"),button_label:s(h,"editor.button_label"),button_target:s(h,"editor.button_target"),icon_size:s(h,"editor.icon_size"),show_temperature:s(h,"editor.show_temperature"),live_states:s(h,"editor.live_states"),light_pools:s(h,"editor.light_pools"),show_signal:s(h,"editor.show_signal")};return r`<ha-form
.hass=${this.hass}
.data=${{...this._config,fit:c(this._config.fit)}}
.schema=${this._schema}
.computeLabel=${e=>_[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`}_valueChanged(e){const t={...this._config||{},...e.detail.value};delete t.aspect_ratio;const s=new Event("config-changed",{bubbles:!0,composed:!0});s.detail={config:t},this.dispatchEvent(s)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-space-card-editor")||customElements.define("houseplan-space-card-editor",h);
@@ -0,0 +1,7 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";import{u as e,A as t,aI as s,aJ as a,aK as o,p as i,n}from"./open-spans-Co-2W4Ja.js";import{l,U as c,cQ as r}from"./houseplan-card-BRGPGuZP.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id}))}catch{this._spaces=[]}finally{this._spacesLoading=!1}}}get _lang(){return l(this.hass,this._config?.language)}get _schema(){const e=this._spaces||[];return[e.length?{name:"space",selector:{select:{mode:"dropdown",options:e}}}:{name:"space",selector:{text:{}}},{name:"title",selector:{text:{}}},{name:"fit",selector:{select:{mode:"dropdown",options:[{value:"content",label:c(this._lang,"editor.fit_content")},{value:"house",label:c(this._lang,"editor.fit_house")}]}}},{name:"show_button",selector:{boolean:{}}},{name:"button_label",selector:{text:{}}},{name:"button_target",selector:{text:{}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"light_pools",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}}]}render(){if(!this.hass||!this._config)return t;const e=s(this,a,l(this.hass,this._config.language));if("cold"===e)return o();if("warm"===e)return i;this._loadSpaces();const h=this._lang,_={space:c(h,"editor.space"),title:c(h,"editor.title"),fit:c(h,"editor.framing"),show_button:c(h,"editor.show_button"),button_label:c(h,"editor.button_label"),button_target:c(h,"editor.button_target"),icon_size:c(h,"editor.icon_size"),show_temperature:c(h,"editor.show_temperature"),live_states:c(h,"editor.live_states"),light_pools:c(h,"editor.light_pools"),show_signal:c(h,"editor.show_signal")};return n`<ha-form
.hass=${this.hass}
.data=${{...this._config,fit:r(this._config.fit)}}
.schema=${this._schema}
.computeLabel=${e=>_[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`}_valueChanged(e){const t={...this._config||{},...e.detail.value};delete t.aspect_ratio;const s=new Event("config-changed",{bubbles:!0,composed:!0});s.detail={config:t},this.dispatchEvent(s)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-space-card-editor")||customElements.define("houseplan-space-card-editor",h);
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";import{c as a,A as e}from"./houseplan-card-DpMO8ZtV.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-C7G0wFcw.js";import"./form-kit.styles-C2rEYmUr.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";import{n as a,A as s}from"./open-spans-Co-2W4Ja.js";import{s as e,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-D8WiQSym.js";import"./houseplan-card-BRGPGuZP.js";import"./form-kit.styles-DyFi9GJ3.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!e(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(s=>s.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
<span class="summary-switch-caption"><strong>${b("summary.show_local")}</strong>
<small id="summary-local-show-hint">${b("summary.show_local_hint")}</small></span>
<input id="summary-local-show" data-summary-local-show type="checkbox"
@@ -10,31 +10,31 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7
dismiss-on-scrim aria-busy=${String(d.busy)}
@hp-close=${k}>
<div class="body summary-editor" @click=${()=>m.closeSource()}>
${d.localOnly?a`<p class="summary-local-hint">${b(d.localOnlyHint)}</p>`:e}
${d.localOnly?a`<p class="summary-local-hint">${b(d.localOnlyHint)}</p>`:s}
<section class="summary-general" aria-labelledby="summary-general-title">
<h3 id="summary-general-title">${b("summary.general_settings")}</h3>
<div class="summary-general-grid">
${d.localOnly?e:a`<div class="summary-field">
${d.localOnly?s:a`<div class="summary-field">
<label for="summary-panel-title">${b("summary.panel_title")}</label>
<input id="summary-panel-title" type="text" .value=${d.draft.title}
?disabled=${d.busy}
data-summary-error=${String(d.attempted&&"title"===y?.path)}
aria-invalid=${"error"===v("title")?.kind?"true":"false"}
@input=${a=>m.mutate(e=>{e.title=a.target.value})} />
${v("title")?a`<div class="summary-problem error">${h(v("title"))}</div>`:e}
@input=${a=>m.mutate(s=>{s.title=a.target.value})} />
${v("title")?a`<div class="summary-problem error">${h(v("title"))}</div>`:s}
</div>`}
${g}
${d.localOnly?e:a`<label class="summary-switch" for="summary-mobile-show">
${d.localOnly?s:a`<label class="summary-switch" for="summary-mobile-show">
<span class="summary-switch-caption"><strong>${b("summary.show_mobile")}</strong>
<small id="summary-mobile-show-hint">${b("summary.show_mobile_hint")}</small></span>
<input id="summary-mobile-show" data-summary-mobile-show type="checkbox"
.checked=${d.draft.show_on_mobile} ?disabled=${!d.localShow||d.busy}
aria-label=${b("summary.show_mobile")} aria-describedby="summary-mobile-show-hint"
@change=${a=>m.mutate(e=>{e.show_on_mobile=a.target.checked})} /></label>`}
@change=${a=>m.mutate(s=>{s.show_on_mobile=a.target.checked})} /></label>`}
</div>
${m.storageUnavailable?a`<div class="summary-problem warning">${b("summary.storage_unavailable")}</div>`:e}
${m.storageUnavailable?a`<div class="summary-problem warning">${b("summary.storage_unavailable")}</div>`:s}
</section>
${d.localOnly?e:a`
${d.localOnly?s:a`
<section class="summary-blocks-card" aria-labelledby="summary-blocks-title">
<header class="summary-blocks-heading">
<div><h3 id="summary-blocks-title">${b("summary.blocks")}</h3>
@@ -42,7 +42,7 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7
<span class="summary-block-count">${b("summary.block_count").replace("{count}",String(d.draft.blocks.length)).replace("{limit}","10")}</span>
</header>
<div class="summary-editor-blocks">
${d.draft.blocks.map((s,t)=>{const u=`blocks.${t}`,n=v(`${u}.scope`);return a`<article class="summary-editor-block"
${d.draft.blocks.map((e,t)=>{const u=`blocks.${t}`,n=v(`${u}.scope`);return a`<article class="summary-editor-block"
@dragover=${a=>a.preventDefault()}
@drop=${a=>m.drop(a,`block:${t}`)}>
<div class="summary-block-head">
@@ -57,40 +57,40 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7
?disabled=${t===d.draft.blocks.length-1||d.busy}
@click=${()=>m.mutate(a=>{a.blocks=r(a.blocks,t,t+1)})}>${l("down")}</button>
</div>
<input class="summary-block-title" type="text" .value=${s.title}
<input class="summary-block-title" type="text" .value=${e.title}
placeholder=${b("summary.block_title")} aria-label=${b("summary.block_title")}
?disabled=${d.busy}
data-summary-error=${String(d.attempted&&y?.path===`${u}.title`)}
aria-invalid=${"error"===v(`${u}.title`)?.kind?"true":"false"}
@input=${a=>m.mutate(e=>{e.blocks[t].title=a.target.value})} />
@input=${a=>m.mutate(s=>{s.blocks[t].title=a.target.value})} />
<button class="summary-icon-button summary-visibility" type="button"
?disabled=${d.busy} aria-pressed=${String(s.visible)}
title=${b(s.visible?"summary.hide_block":"summary.show_block")}
aria-label=${b(s.visible?"summary.hide_block":"summary.show_block")}
@click=${()=>m.mutate(a=>{a.blocks[t].visible=!a.blocks[t].visible})}>${l(s.visible?"eye":"eyeOff")}</button>
${v(`${u}.title`)?a`<div class="summary-problem error summary-block-title-error">${h(v(`${u}.title`))}</div>`:e}
?disabled=${d.busy} aria-pressed=${String(e.visible)}
title=${b(e.visible?"summary.hide_block":"summary.show_block")}
aria-label=${b(e.visible?"summary.hide_block":"summary.show_block")}
@click=${()=>m.mutate(a=>{a.blocks[t].visible=!a.blocks[t].visible})}>${l(e.visible?"eye":"eyeOff")}</button>
${v(`${u}.title`)?a`<div class="summary-problem error summary-block-title-error">${h(v(`${u}.title`))}</div>`:s}
<div class="summary-scope">
<select .value=${s.scope.type} ?disabled=${d.busy}
<select .value=${e.scope.type} ?disabled=${d.busy}
aria-label=${b("summary.scope")}
@change=${a=>m.mutate(e=>{e.blocks[t].scope="space"===a.target.value?{type:"space",space_id:c._space}:{type:"all"}})}>
<option value="all" ?selected=${"all"===s.scope.type}>${b("summary.scope_all")}</option>
<option value="space" ?selected=${"space"===s.scope.type}>${b("summary.scope_space")}</option>
@change=${a=>m.mutate(s=>{s.blocks[t].scope="space"===a.target.value?{type:"space",space_id:c._space}:{type:"all"}})}>
<option value="all" ?selected=${"all"===e.scope.type}>${b("summary.scope_all")}</option>
<option value="space" ?selected=${"space"===e.scope.type}>${b("summary.scope_space")}</option>
</select>
${"space"===s.scope.type?a`<select .value=${s.scope.space_id}
${"space"===e.scope.type?a`<select .value=${e.scope.space_id}
?disabled=${d.busy} aria-label=${b("summary.scope_space")}
data-summary-error=${String(d.attempted&&y?.path===`${u}.scope`)}
aria-invalid=${"error"===n?.kind?"true":"false"}
@change=${a=>m.mutate(e=>{const s=e.blocks[t].scope;"space"===s.type&&(s.space_id=a.target.value)})}>
${c._model.some(a=>a.id===s.scope.space_id)?e:a`<option value=${s.scope.space_id} selected>${s.scope.space_id}</option>`}
${c._model.map(e=>a`<option value=${e.id}
?selected=${e.id===s.scope.space_id}>${e.title}</option>`)}
</select>`:e}
@change=${a=>m.mutate(s=>{const e=s.blocks[t].scope;"space"===e.type&&(e.space_id=a.target.value)})}>
${c._model.some(a=>a.id===e.scope.space_id)?s:a`<option value=${e.scope.space_id} selected>${e.scope.space_id}</option>`}
${c._model.map(s=>a`<option value=${s.id}
?selected=${s.id===e.scope.space_id}>${s.title}</option>`)}
</select>`:s}
</div>
${n?a`<div class="summary-problem summary-scope-problem ${n.kind}">${h(n)}</div>`:e}
${n?a`<div class="summary-problem summary-scope-problem ${n.kind}">${h(n)}</div>`:s}
</div>
<div class="summary-block-body">
<div class="summary-editor-values">
${s.values.map((i,c)=>{const n=`${u}.values.${c}`,$=v(`${n}.source`),p=m.sourceToken(i.source),k=d.activeSource?.blockId===s.id&&d.activeSource.valueId===i.id,g="system"===i.source.type?b(`summary.system.${i.source.key}`):i.source.entity_id?m.entityIndex.labels.get(i.source.entity_id)||i.source.entity_id:b("summary.select_source"),_="system"===i.source.type?b("summary.system_source"):i.source.entity_id,f=k?o(m.entityIndex,d.entityFilter):null,w="entity"===i.source.type&&!!i.source.entity_id&&!m.entityIndex.labels.has(i.source.entity_id),S=`${s.id}\n${i.id}`;return a`<div class="summary-editor-value"
${e.values.map((i,c)=>{const n=`${u}.values.${c}`,$=v(`${n}.source`),p=m.sourceToken(i.source),k=d.activeSource?.blockId===e.id&&d.activeSource.valueId===i.id,g="system"===i.source.type?b(`summary.system.${i.source.key}`):i.source.entity_id?m.entityIndex.labels.get(i.source.entity_id)||i.source.entity_id:b("summary.select_source"),_="system"===i.source.type?b("summary.system_source"):i.source.entity_id,f=k?o(m.entityIndex,d.entityFilter):null,w="entity"===i.source.type&&!!i.source.entity_id&&!m.entityIndex.labels.has(i.source.entity_id),S=`${e.id}\n${i.id}`;return a`<div class="summary-editor-value"
@dragover=${a=>{a.preventDefault(),a.stopPropagation()}}
@drop=${a=>m.drop(a,`value:${t}:${c}`)}>
<span class="summary-drag" draggable=${String(!d.busy)} title=${b("summary.drag")}
@@ -101,7 +101,7 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7
@click=${()=>m.mutate(a=>{a.blocks[t].values=r(a.blocks[t].values,c,c-1)})}>${l("up")}</button>
<button class="summary-icon-button" type="button" title=${b("summary.down")}
aria-label=${b("summary.down")}
?disabled=${c===s.values.length-1||d.busy}
?disabled=${c===e.values.length-1||d.busy}
@click=${()=>m.mutate(a=>{a.blocks[t].values=r(a.blocks[t].values,c,c+1)})}>${l("down")}</button>
</div>
<div class="summary-value-label">
@@ -109,8 +109,8 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7
placeholder=${b("summary.value_name")} aria-label=${b("summary.value_name")}
data-summary-error=${String(d.attempted&&y?.path===`${n}.label`)}
aria-invalid=${"error"===v(`${n}.label`)?.kind?"true":"false"}
@input=${a=>m.mutate(e=>{e.blocks[t].values[c].label=a.target.value})} />
${v(`${n}.label`)?a`<div class="summary-problem error">${h(v(`${n}.label`))}</div>`:e}
@input=${a=>m.mutate(s=>{s.blocks[t].values[c].label=a.target.value})} />
${v(`${n}.label`)?a`<div class="summary-problem error">${h(v(`${n}.label`))}</div>`:s}
</div>
<div class="summary-source-field">
<button class="summary-source" type="button"
@@ -119,9 +119,9 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7
data-summary-error=${String(d.attempted&&y?.path===`${n}.source`)}
aria-invalid=${"error"===$?.kind?"true":"false"}
aria-haspopup="listbox" aria-expanded=${k?"true":"false"}
@click=${a=>{a.stopPropagation(),k?m.closeSource():m.openSource(s.id,i.id)}}>
@click=${a=>{a.stopPropagation(),k?m.closeSource():m.openSource(e.id,i.id)}}>
<span class="summary-source-caption"><strong>${g}</strong>
${_?a`<small>${_}</small>`:e}</span>
${_?a`<small>${_}</small>`:s}</span>
${l("chevron")}
</button>
${k&&f?a`<div class="summary-source-picker"
@@ -135,39 +135,39 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7
<div class="summary-source-results" role="listbox"
aria-label=${b("summary.select_source")}>
<div class="summary-source-group">${b("summary.system_group")}</div>
${["device_count","total_area","datetime"].map(e=>a`
${["device_count","total_area","datetime"].map(s=>a`
<button type="button" role="option"
?disabled=${d.busy}
aria-selected=${p===`system:${e}`?"true":"false"}
@click=${()=>m.setSource(s.id,i.id,`system:${e}`)}>
${b(`summary.system.${e}`)}
aria-selected=${p===`system:${s}`?"true":"false"}
@click=${()=>m.setSource(e.id,i.id,`system:${s}`)}>
${b(`summary.system.${s}`)}
</button>`)}
${w?a`<div class="summary-source-group">${b("summary.current_source")}</div>
<button type="button" role="option" class="broken" aria-selected="true"
?disabled=${d.busy}
@click=${()=>m.setSource(s.id,i.id,p)}>
@click=${()=>m.setSource(e.id,i.id,p)}>
${g}
</button>`:e}
</button>`:s}
<div class="summary-source-group">${b("summary.entities_group")}</div>
${f.entries.map(e=>a`<button type="button" role="option"
${f.entries.map(s=>a`<button type="button" role="option"
?disabled=${d.busy}
aria-selected=${p===`entity:${e.id}`?"true":"false"}
@click=${()=>m.setSource(s.id,i.id,`entity:${e.id}`)}>
<span>${e.label}</span><small>${e.id}</small>
aria-selected=${p===`entity:${s.id}`?"true":"false"}
@click=${()=>m.setSource(e.id,i.id,`entity:${s.id}`)}>
<span>${s.label}</span><small>${s.id}</small>
</button>`)}
${f.total?e:a`<div class="summary-empty">${b("summary.no_search_results")}</div>`}
${f.truncated?a`<div class="summary-refine">${b("summary.refine_search")}</div>`:e}
${f.total?s:a`<div class="summary-empty">${b("summary.no_search_results")}</div>`}
${f.truncated?a`<div class="summary-refine">${b("summary.refine_search")}</div>`:s}
</div>
</div>`:e}
${$?a`<div class="summary-problem ${$.kind}">${h($)}</div>`:e}
</div>`:s}
${$?a`<div class="summary-problem ${$.kind}">${h($)}</div>`:s}
</div>
<button class="summary-icon-button summary-value-remove" type="button"
title=${b("btn.delete")} aria-label=${b("btn.delete")} ?disabled=${d.busy}
@click=${()=>m.mutate(a=>{a.blocks[t].values.splice(c,1)})}>${l("close")}</button>
</div>`})}
<button class="summary-add summary-add-value" type="button"
?disabled=${s.values.length>=20||d.busy}
title=${s.values.length>=20?b("summary.limit_values"):""}
?disabled=${e.values.length>=20||d.busy}
title=${e.values.length>=20?b("summary.limit_values"):""}
@click=${()=>m.mutate(a=>{a.blocks[t].values.push(i())})}>
${l("plus")}${b("summary.add_value")}
</button>
@@ -186,11 +186,11 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7
${l("plus")}${b("summary.add_block")}
</button>
</section>
${d.error?a`<div class="summary-problem error" role="alert">${d.error}</div>`:e}
${d.error?a`<div class="summary-problem error" role="alert">${d.error}</div>`:s}
${d.conflict?a`<button class="btn ghost summary-reload" type="button"
?disabled=${d.busy} @click=${()=>m.reload()}>
<ha-icon icon="mdi:reload"></ha-icon>${b("summary.reload_current")}
</button>`:e}
</button>`:s}
`}
</div>
<div class="row summary-editor-footer" slot="footer">
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";import{e$ as e,O as t,b5 as n,M as i,af as o,ad as r,cf as s,f0 as a,ce as l,b4 as c,b3 as d,b6 as u,f1 as f,f2 as m,ch as g,f3 as v}from"./houseplan-card-DpMO8ZtV.js";function y(e){if(!e.registry.authoritative)return null;const t=new Set,n=u(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const o=String(i.binding||"").indexOf(":");if(o<1)continue;const r=i.binding.slice(0,o),s=i.binding.slice(o+1),a="device"===r?e.registry.devices?.[s]:null,l="entity"===r?e.registry.entities?.[s]:null,c="device"===r?s:l?.device_id,d="device"===r?a?.area_id:l?.area_id||c&&e.registry.devices?.[c]?.area_id,u="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||d||"",f=u&&e.areaToSpace[u]||i.space||e.firstSpaceId;c&&e.registry.devices?.[c]&&e.spaceIds.has(f)&&(!n.devices.has(c)||"entity"===r&&n.liveEntities.has(s))&&t.add(c)}return t}function _(e,t){return e?.length?t?.length?v(e,t):e:t}function b(e,t){const n=c(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:d(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*h(c,d,u=b){try{let f=0;for(const m of d){const d=c.spaces.find(e=>String(e?.id)===m.id);if(!d)continue;const g=e(d,m),v=u(m,g);let y=null;for(const e of m.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const l=n(m.rooms,e.id,g.walls,g.openCuts,i,g.cellCm,o,r,v.roomGeom,v.multiWallNodes)||a;y=_(y,s(l,g.physicalBodies)),yield}const b=a(y||[],m.stairs);let h=b.next();for(;!h.done;)yield,h=b.next();y=h.value;const p=g.cellCm/o;f+=l(y)*p*p/1e4}return f}catch{return null}}function p(e,t,n=b){const i=h(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function S(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:g(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function C(e,t){const n=e?.states?.[t];if(!n)return null;const i=f(e,t);return i?m(i,String(n.attributes?.unit_of_measurement||"")):null}export{h as cleanFloorAreaSteps,y as representedHaDeviceIds,b as spaceWallGeometry,C as summaryEntityValue,S as summarySystemValue,p as totalCleanFloorAreaM2};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";import{af as e,N as t,R as n,G as i,bN as s,a7 as o,a8 as r,r as a,aE as l,J as c}from"./open-spans-Co-2W4Ja.js";import{a8 as u,b0 as d,cT as f,cl as m,d0 as v,aZ as y}from"./houseplan-card-BRGPGuZP.js";function g(e){if(!e.registry.authoritative)return null;const t=new Set,n=u(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const s=String(i.binding||"").indexOf(":");if(s<1)continue;const o=i.binding.slice(0,s),r=i.binding.slice(s+1),a="device"===o?e.registry.devices?.[r]:null,l="entity"===o?e.registry.entities?.[r]:null,c="device"===o?r:l?.device_id,u="device"===o?a?.area_id:l?.area_id||c&&e.registry.devices?.[c]?.area_id,d="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||u||"",f=d&&e.areaToSpace[d]||i.space||e.firstSpaceId;c&&e.registry.devices?.[c]&&e.spaceIds.has(f)&&(!n.devices.has(c)||"entity"===o&&n.liveEntities.has(r))&&t.add(c)}return t}function _(e,t){return e?.length?t?.length?c(e,t):e:t}function h(o,r){const a=e(o.rooms,r.walls,r.openCuts,[],i,r.cellCm,n,t);return{roomGeom:"ok"===a.status||"degraded-extra"===a.status?a.roomGeom:void 0,multiWallNodes:s(o.rooms,r.walls,r.openCuts,i,r.cellCm,n,t)}}function*p(e,s,o){const r=f(e,s),c=o(s,r);let u=null;for(const e of s.rooms){if(!e.id)continue;const o=a(e);if(!o)continue;const d=l(s.rooms,e.id,r.walls,r.openCuts,i,r.cellCm,n,t,c.roomGeom,c.multiWallNodes)||o;u=_(u,m(d,r.physicalBodies)),yield}const d=v(u||[],s.stairs);let g=d.next();for(;!g.done;)yield,g=d.next();u=g.value;const h=r.cellCm/n;return y(u)*h*h/1e4}function*S(e,t,n=h,i){try{let s=0,o=!1;const r=new Set;for(let a=0;a<t.length;a++){const l=t[a],c=e.spaces.find(e=>String(e?.id)===l.id);if(!c)continue;const u=i?.key(a,c,l);let d;if(i&&void 0!==u&&i.values.has(u))d=i.values.get(u)??null;else{try{d=yield*p(c,l,n)}catch{if(!i)return null;d=null}i&&void 0!==u&&i.values.set(u,d)}void 0!==u&&r.add(u),null===d?o=!0:s+=d}if(i)for(const e of[...i.values.keys()])r.has(e)||i.values.delete(e);return o?null:s}catch{return null}}function b(e,t,n=h){const i=S(e,t,n);let s=i.next();for(;!s.done;)s=i.next();return s.value}function C(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:d(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function w(e,t){const n=e?.states?.[t];if(!n)return null;const i=o(e,t);return i?r(i,String(n.attributes?.unit_of_measurement||"")):null}export{S as cleanFloorAreaSteps,g as representedHaDeviceIds,h as spaceWallGeometry,w as summaryEntityValue,C as summarySystemValue,b as totalCleanFloorAreaM2};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";var e={"furn.title":"Möbelbibliothek","furn.symbol":"Symbol","furn.group_furniture":"Möbel","furn.group_appliance":"Geräte","furn.group_sanitary":"Sanitär","furn.group_other":"Sonstiges","furn.width":"Breite","furn.depth":"Tiefe","furn.back_to_categories":"Alle Kategorien","furn.pick_hint":"Wählen Sie eine Kategorie und dann ein Symbol.","furn.place_hint":"Klicken Sie auf den Plan — das Element wird an der nächsten Wand platziert. Mit Shift wird es frei platziert.","furn.cat_air_conditioner":"Klimaanlagen","furn.cat_armchair":"Sessel","furn.cat_bathtub":"Badewannen","furn.cat_bed":"Betten","furn.cat_bidet":"Bidets","furn.cat_boiler":"Warmwasserbereiter","furn.cat_chair":"Stühle","furn.cat_coffee_table":"Couchtische","furn.cat_computer":"Computer","furn.cat_cooktop":"Kochfelder","furn.cat_dining_table":"Esstische","furn.cat_dishwasher":"Geschirrspüler","furn.cat_dryer":"Wäschetrockner","furn.cat_exercise":"Fitnessgerät","furn.cat_fireplace":"Kamine","furn.cat_fridge":"Kühlschränke","furn.cat_hood":"Dunstabzugshaube","furn.cat_kitchen_cabinet":"Küchenschränke","furn.cat_kitchen_sink":"Küchenspülen","furn.cat_nightstand":"Schränke","furn.cat_oven":"Backofen","furn.cat_plant":"Pflanzen","furn.cat_rug":"Teppiche","furn.cat_shelving":"Regale","furn.cat_shower":"Duschen","furn.cat_sink":"Waschbecken","furn.cat_sofa":"Sofas","furn.cat_stairs":"Treppen","furn.cat_toilet":"Toiletten","furn.cat_tv":"Fernseher","furn.cat_wardrobe":"Kleiderschränke","furn.cat_washer":"Waschmaschinen","furn.cat_work_table":"Schreibtische","furn.sym_exercise":"Fitnessgerät","furn.sym_computer":"Computer","furn.sym_hood":"Dunstabzugshaube","furn.sym_oven":"Backofen","furn.sym_sofa":"Zweisitzer-Sofa","furn.sym_sofa_three_seat":"Dreisitzer-Sofa","furn.sym_sofa_corner_right":"Ecksofa rechts","furn.sym_armchair":"Polstersessel","furn.sym_armchair_office":"Bürostuhl","furn.sym_coffee_table":"Rechteckiger Couchtisch","furn.sym_coffee_table_round":"Runder Couchtisch","furn.sym_coffee_table_oval":"Ovaler Couchtisch","furn.sym_coffee_table_rounded":"Abgerundeter Couchtisch","furn.sym_table_dining":"Rechteckiger Esstisch","furn.sym_table_round":"Rundtisch","furn.sym_table_dining_oval":"Ovaler Esstisch","furn.sym_table_dining_rounded":"Abgerundeter Esstisch","furn.sym_chair":"Stuhl","furn.sym_chair_bar":"Barhocker","furn.sym_desk":"Rechteckiger Schreibtisch","furn.sym_desk_corner":"Eckschreibtisch","furn.sym_bed_double":"Doppelbett","furn.sym_bed_single":"Einzelbett","furn.sym_nightstand":"Nachttisch","furn.sym_cabinet_tv":"TV-Schrank","furn.sym_cabinet_shoe":"Schuhschrank","furn.sym_cabinet_sink":"Waschtischunterschrank","furn.sym_wardrobe":"Kleiderschrank","furn.sym_bookshelf":"Bücherschrank","furn.sym_wall_unit":"Schrankwand","furn.sym_kitchen_floor":"Küchen-Unterschrank","furn.sym_kitchen_floor_corner":"Küchen-Eckunterschrank","furn.sym_kitchen_wall":"Küchen-Hängeschrank","furn.sym_kitchen_wall_corner":"Küchen-Eckhängeschrank","furn.sym_shelf_floor":"Standregal","furn.sym_shelf_wall":"Wandregal","furn.sym_fridge":"Kühlschrank","furn.sym_stove":"Vierflammiges Kochfeld","furn.sym_cooktop_two":"Zweiflammiges Kochfeld","furn.sym_dishwasher":"Geschirrspüler","furn.sym_washer":"Waschmaschine","furn.sym_dryer":"Wäschetrockner","furn.sym_tv":"Fernseher mit Standfuß","furn.sym_tv_wall":"Wandfernseher","furn.sym_ac":"Klimaanlage","furn.sym_water_heater":"Wassererhitzer","furn.sym_toilet":"Stand-WC","furn.sym_toilet_built_in":"Wand-WC","furn.sym_bathtub":"Rechteckige Badewanne","furn.sym_bathtub_corner":"Eckbadewanne","furn.sym_shower":"Dusche","furn.sym_sink":"Waschbecken","furn.sym_kitchen_sink":"Einzel-Küchenspüle","furn.sym_kitchen_sink_double":"Doppel-Küchenspüle","furn.sym_bidet":"Stand-Bidet","furn.sym_bidet_built_in":"Wand-Bidet","furn.sym_stairs":"Treppe","furn.sym_fireplace":"Kamin","furn.sym_plant":"Pflanze","furn.sym_rug":"Teppich"};const r="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";export{e as dictionary,r as fingerprint};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";var e={node_move_hint:"Wandknoten ziehen. Ursprüngliche Wandachsen und horizontale/vertikale Wände rasten ein. Esc bricht ab; Rückgängig stellt die gesamte Bewegung wieder her.",node_move_ambiguous:"Die Knoten liegen zu nahe beieinander. Zum Auswählen vergrößern.",node_move_choose_axis:"Entlang einer ursprünglichen Wand ziehen, um die Gleitachse zu wählen.",node_move_invalid:"Knoten kann nicht bewegt werden: {reason}",node_move_opening_blocked:"Eine Tür oder ein Fenster würde nicht passen oder den Knoten kreuzen. Bewegung abgebrochen.",node_move_update_required:"House-Plan-Integration aktualisieren, um Wandknoten zu bewegen.",node_move_history:"Wandknoten bewegen",node_move_unsupported_junction:"Dieser komplexe Durchgangsknoten kann nicht bewegt werden. T- und X-Knoten ohne zusätzliche Äste werden unterstützt.","furn.title":"Möbelbibliothek","furn.symbol":"Symbol","furn.group_furniture":"Möbel","furn.group_appliance":"Geräte","furn.group_sanitary":"Sanitär","furn.group_other":"Sonstiges","furn.width":"Breite","furn.depth":"Tiefe","furn.back_to_categories":"Alle Kategorien","furn.pick_hint":"Wählen Sie eine Kategorie und dann ein Symbol.","furn.place_hint":"Klicken Sie auf den Plan — das Element wird an der nächsten Wand platziert. Mit Shift wird es frei platziert.","furn.cat_air_conditioner":"Klimaanlagen","furn.cat_armchair":"Sessel","furn.cat_bathtub":"Badewannen","furn.cat_bed":"Betten","furn.cat_bidet":"Bidets","furn.cat_boiler":"Warmwasserbereiter","furn.cat_chair":"Stühle","furn.cat_coffee_table":"Couchtische","furn.cat_computer":"Computer","furn.cat_cooktop":"Kochfelder","furn.cat_dining_table":"Esstische","furn.cat_dishwasher":"Geschirrspüler","furn.cat_dryer":"Wäschetrockner","furn.cat_exercise":"Fitnessgerät","furn.cat_fireplace":"Kamine","furn.cat_fridge":"Kühlschränke","furn.cat_hood":"Dunstabzugshaube","furn.cat_kitchen_cabinet":"Küchenschränke","furn.cat_kitchen_sink":"Küchenspülen","furn.cat_nightstand":"Schränke","furn.cat_oven":"Backofen","furn.cat_plant":"Pflanzen","furn.cat_rug":"Teppiche","furn.cat_shelving":"Regale","furn.cat_shower":"Duschen","furn.cat_sink":"Waschbecken","furn.cat_sofa":"Sofas","furn.cat_stairs":"Treppen","furn.cat_toilet":"Toiletten","furn.cat_tv":"Fernseher","furn.cat_wardrobe":"Kleiderschränke","furn.cat_washer":"Waschmaschinen","furn.cat_work_table":"Schreibtische","furn.sym_exercise":"Fitnessgerät","furn.sym_computer":"Computer","furn.sym_hood":"Dunstabzugshaube","furn.sym_oven":"Backofen","furn.sym_sofa":"Zweisitzer-Sofa","furn.sym_sofa_three_seat":"Dreisitzer-Sofa","furn.sym_sofa_corner_right":"Ecksofa rechts","furn.sym_armchair":"Polstersessel","furn.sym_armchair_office":"Bürostuhl","furn.sym_coffee_table":"Rechteckiger Couchtisch","furn.sym_coffee_table_round":"Runder Couchtisch","furn.sym_coffee_table_oval":"Ovaler Couchtisch","furn.sym_coffee_table_rounded":"Abgerundeter Couchtisch","furn.sym_table_dining":"Rechteckiger Esstisch","furn.sym_table_round":"Rundtisch","furn.sym_table_dining_oval":"Ovaler Esstisch","furn.sym_table_dining_rounded":"Abgerundeter Esstisch","furn.sym_chair":"Stuhl","furn.sym_chair_bar":"Barhocker","furn.sym_desk":"Rechteckiger Schreibtisch","furn.sym_desk_corner":"Eckschreibtisch","furn.sym_bed_double":"Doppelbett","furn.sym_bed_single":"Einzelbett","furn.sym_nightstand":"Nachttisch","furn.sym_cabinet_tv":"TV-Schrank","furn.sym_cabinet_shoe":"Schuhschrank","furn.sym_cabinet_sink":"Waschtischunterschrank","furn.sym_wardrobe":"Kleiderschrank","furn.sym_bookshelf":"Bücherschrank","furn.sym_wall_unit":"Schrankwand","furn.sym_kitchen_floor":"Küchen-Unterschrank","furn.sym_kitchen_floor_corner":"Küchen-Eckunterschrank","furn.sym_kitchen_wall":"Küchen-Hängeschrank","furn.sym_kitchen_wall_corner":"Küchen-Eckhängeschrank","furn.sym_shelf_floor":"Standregal","furn.sym_shelf_wall":"Wandregal","furn.sym_fridge":"Kühlschrank","furn.sym_stove":"Vierflammiges Kochfeld","furn.sym_cooktop_two":"Zweiflammiges Kochfeld","furn.sym_dishwasher":"Geschirrspüler","furn.sym_washer":"Waschmaschine","furn.sym_dryer":"Wäschetrockner","furn.sym_tv":"Fernseher mit Standfuß","furn.sym_tv_wall":"Wandfernseher","furn.sym_ac":"Klimaanlage","furn.sym_water_heater":"Wassererhitzer","furn.sym_toilet":"Stand-WC","furn.sym_toilet_built_in":"Wand-WC","furn.sym_bathtub":"Rechteckige Badewanne","furn.sym_bathtub_corner":"Eckbadewanne","furn.sym_shower":"Dusche","furn.sym_sink":"Waschbecken","furn.sym_kitchen_sink":"Einzel-Küchenspüle","furn.sym_kitchen_sink_double":"Doppel-Küchenspüle","furn.sym_bidet":"Stand-Bidet","furn.sym_bidet_built_in":"Wand-Bidet","furn.sym_stairs":"Treppe","furn.sym_fireplace":"Kamin","furn.sym_plant":"Pflanze","furn.sym_rug":"Teppich"};const n="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";export{e as dictionary,n as fingerprint};
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";var e={"furn.title":"Bibliothèque de mobilier","furn.symbol":"Symbole","furn.group_furniture":"Mobilier","furn.group_appliance":"Électroménager","furn.group_sanitary":"Sanitaires","furn.group_other":"Autre","furn.width":"Largeur","furn.depth":"Profondeur","furn.back_to_categories":"Toutes les catégories","furn.pick_hint":"Choisissez un symbole, puis cliquez sur le plan.","furn.place_hint":"Cliquez sur le plan — le meuble se place contre le mur le plus proche. Maj permet un placement libre.","furn.cat_air_conditioner":"Climatiseurs","furn.cat_armchair":"Fauteuils","furn.cat_bathtub":"Baignoires","furn.cat_bed":"Lits","furn.cat_bidet":"Bidets","furn.cat_boiler":"Chauffe-eau","furn.cat_chair":"Chaises","furn.cat_coffee_table":"Tables basses","furn.cat_computer":"Ordinateur","furn.cat_cooktop":"Plaques de cuisson","furn.cat_dining_table":"Tables à manger","furn.cat_dishwasher":"Lave-vaisselle","furn.cat_dryer":"Sèche-linge","furn.cat_exercise":"Équipement de sport","furn.cat_fireplace":"Cheminées","furn.cat_fridge":"Réfrigérateurs","furn.cat_hood":"Hotte","furn.cat_kitchen_cabinet":"Meubles de cuisine","furn.cat_kitchen_sink":"Éviers de cuisine","furn.cat_nightstand":"Meubles","furn.cat_oven":"Four","furn.cat_plant":"Plantes","furn.cat_rug":"Tapis","furn.cat_shelving":"Étagères","furn.cat_shower":"Douches","furn.cat_sink":"Lavabos","furn.cat_sofa":"Canapés","furn.cat_stairs":"Escaliers","furn.cat_toilet":"Toilettes","furn.cat_tv":"Téléviseurs","furn.cat_wardrobe":"Armoires","furn.cat_washer":"Lave-linge","furn.cat_work_table":"Bureaux","furn.sym_exercise":"Équipement de sport","furn.sym_computer":"Ordinateur","furn.sym_hood":"Hotte","furn.sym_oven":"Four","furn.sym_sofa":"Canapé","furn.sym_sofa_three_seat":"Canapé trois places","furn.sym_sofa_corner_right":"Canapé d'angle droit","furn.sym_armchair":"Fauteuil","furn.sym_armchair_office":"Chaise de bureau","furn.sym_coffee_table":"Table basse","furn.sym_coffee_table_round":"Table basse ronde","furn.sym_coffee_table_oval":"Table basse ovale","furn.sym_coffee_table_rounded":"Table basse arrondie","furn.sym_table_dining":"Table à manger","furn.sym_table_round":"Table ronde","furn.sym_table_dining_oval":"Table à manger ovale","furn.sym_table_dining_rounded":"Table à manger arrondie","furn.sym_chair":"Chaise","furn.sym_chair_bar":"Tabouret de bar","furn.sym_desk":"Bureau","furn.sym_desk_corner":"Bureau d'angle","furn.sym_bed_double":"Lit double","furn.sym_bed_single":"Lit simple","furn.sym_nightstand":"Table de chevet","furn.sym_cabinet_tv":"Meuble TV","furn.sym_cabinet_shoe":"Meuble à chaussures","furn.sym_cabinet_sink":"Meuble sous lavabo","furn.sym_wardrobe":"Armoire","furn.sym_bookshelf":"Bibliothèque","furn.sym_wall_unit":"Élément mural","furn.sym_kitchen_floor":"Module de cuisine bas","furn.sym_kitchen_floor_corner":"Module de cuisine bas d'angle","furn.sym_kitchen_wall":"Module de cuisine haut","furn.sym_kitchen_wall_corner":"Module de cuisine haut d'angle","furn.sym_shelf_floor":"Étagère au sol","furn.sym_shelf_wall":"Étagère murale","furn.sym_fridge":"Réfrigérateur","furn.sym_stove":"Cuisinière","furn.sym_cooktop_two":"Plaque à deux feux","furn.sym_dishwasher":"Lave-vaisselle","furn.sym_washer":"Lave-linge","furn.sym_dryer":"Sèche-linge","furn.sym_tv":"Téléviseur","furn.sym_tv_wall":"Téléviseur mural","furn.sym_ac":"Climatiseur","furn.sym_water_heater":"Chauffe-eau","furn.sym_toilet":"Toilettes","furn.sym_toilet_built_in":"Toilettes suspendues","furn.sym_bathtub":"Baignoire","furn.sym_bathtub_corner":"Baignoire d'angle","furn.sym_shower":"Douche","furn.sym_sink":"Lavabo","furn.sym_kitchen_sink":"Évier de cuisine","furn.sym_kitchen_sink_double":"Évier de cuisine double","furn.sym_bidet":"Bidet","furn.sym_bidet_built_in":"Bidet suspendu","furn.sym_stairs":"Escalier","furn.sym_fireplace":"Cheminée","furn.sym_plant":"Plante","furn.sym_rug":"Tapis"};const r="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";export{e as dictionary,r as fingerprint};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";var e={node_move_hint:"Faites glisser le nœud du mur. Les axes initiaux et les murs horizontaux/verticaux s'aimantent. Échap annule ; Annuler restaure tout le déplacement.",node_move_ambiguous:"Ces nœuds sont trop proches. Zoomez pour en choisir un.",node_move_choose_axis:"Déplacez-vous le long d'un mur initial pour choisir l'axe de glissement.",node_move_invalid:"Impossible de déplacer ce nœud : {reason}",node_move_opening_blocked:"Une porte ou fenêtre ne tiendrait plus ou traverserait le nœud. Déplacement annulé.",node_move_update_required:"Mettez à jour l'intégration House Plan pour déplacer les nœuds des murs.",node_move_history:"Déplacer un nœud de mur",node_move_unsupported_junction:"Cette jonction traversante complexe ne peut pas être déplacée. Les jonctions T et X sans branche supplémentaire sont prises en charge.","furn.title":"Bibliothèque de mobilier","furn.symbol":"Symbole","furn.group_furniture":"Mobilier","furn.group_appliance":"Électroménager","furn.group_sanitary":"Sanitaires","furn.group_other":"Autre","furn.width":"Largeur","furn.depth":"Profondeur","furn.back_to_categories":"Toutes les catégories","furn.pick_hint":"Choisissez un symbole, puis cliquez sur le plan.","furn.place_hint":"Cliquez sur le plan — le meuble se place contre le mur le plus proche. Maj permet un placement libre.","furn.cat_air_conditioner":"Climatiseurs","furn.cat_armchair":"Fauteuils","furn.cat_bathtub":"Baignoires","furn.cat_bed":"Lits","furn.cat_bidet":"Bidets","furn.cat_boiler":"Chauffe-eau","furn.cat_chair":"Chaises","furn.cat_coffee_table":"Tables basses","furn.cat_computer":"Ordinateur","furn.cat_cooktop":"Plaques de cuisson","furn.cat_dining_table":"Tables à manger","furn.cat_dishwasher":"Lave-vaisselle","furn.cat_dryer":"Sèche-linge","furn.cat_exercise":"Équipement de sport","furn.cat_fireplace":"Cheminées","furn.cat_fridge":"Réfrigérateurs","furn.cat_hood":"Hotte","furn.cat_kitchen_cabinet":"Meubles de cuisine","furn.cat_kitchen_sink":"Éviers de cuisine","furn.cat_nightstand":"Meubles","furn.cat_oven":"Four","furn.cat_plant":"Plantes","furn.cat_rug":"Tapis","furn.cat_shelving":"Étagères","furn.cat_shower":"Douches","furn.cat_sink":"Lavabos","furn.cat_sofa":"Canapés","furn.cat_stairs":"Escaliers","furn.cat_toilet":"Toilettes","furn.cat_tv":"Téléviseurs","furn.cat_wardrobe":"Armoires","furn.cat_washer":"Lave-linge","furn.cat_work_table":"Bureaux","furn.sym_exercise":"Équipement de sport","furn.sym_computer":"Ordinateur","furn.sym_hood":"Hotte","furn.sym_oven":"Four","furn.sym_sofa":"Canapé","furn.sym_sofa_three_seat":"Canapé trois places","furn.sym_sofa_corner_right":"Canapé d'angle droit","furn.sym_armchair":"Fauteuil","furn.sym_armchair_office":"Chaise de bureau","furn.sym_coffee_table":"Table basse","furn.sym_coffee_table_round":"Table basse ronde","furn.sym_coffee_table_oval":"Table basse ovale","furn.sym_coffee_table_rounded":"Table basse arrondie","furn.sym_table_dining":"Table à manger","furn.sym_table_round":"Table ronde","furn.sym_table_dining_oval":"Table à manger ovale","furn.sym_table_dining_rounded":"Table à manger arrondie","furn.sym_chair":"Chaise","furn.sym_chair_bar":"Tabouret de bar","furn.sym_desk":"Bureau","furn.sym_desk_corner":"Bureau d'angle","furn.sym_bed_double":"Lit double","furn.sym_bed_single":"Lit simple","furn.sym_nightstand":"Table de chevet","furn.sym_cabinet_tv":"Meuble TV","furn.sym_cabinet_shoe":"Meuble à chaussures","furn.sym_cabinet_sink":"Meuble sous lavabo","furn.sym_wardrobe":"Armoire","furn.sym_bookshelf":"Bibliothèque","furn.sym_wall_unit":"Élément mural","furn.sym_kitchen_floor":"Module de cuisine bas","furn.sym_kitchen_floor_corner":"Module de cuisine bas d'angle","furn.sym_kitchen_wall":"Module de cuisine haut","furn.sym_kitchen_wall_corner":"Module de cuisine haut d'angle","furn.sym_shelf_floor":"Étagère au sol","furn.sym_shelf_wall":"Étagère murale","furn.sym_fridge":"Réfrigérateur","furn.sym_stove":"Cuisinière","furn.sym_cooktop_two":"Plaque à deux feux","furn.sym_dishwasher":"Lave-vaisselle","furn.sym_washer":"Lave-linge","furn.sym_dryer":"Sèche-linge","furn.sym_tv":"Téléviseur","furn.sym_tv_wall":"Téléviseur mural","furn.sym_ac":"Climatiseur","furn.sym_water_heater":"Chauffe-eau","furn.sym_toilet":"Toilettes","furn.sym_toilet_built_in":"Toilettes suspendues","furn.sym_bathtub":"Baignoire","furn.sym_bathtub_corner":"Baignoire d'angle","furn.sym_shower":"Douche","furn.sym_sink":"Lavabo","furn.sym_kitchen_sink":"Évier de cuisine","furn.sym_kitchen_sink_double":"Évier de cuisine double","furn.sym_bidet":"Bidet","furn.sym_bidet_built_in":"Bidet suspendu","furn.sym_stairs":"Escalier","furn.sym_fireplace":"Cheminée","furn.sym_plant":"Plante","furn.sym_rug":"Tapis"};const r="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";export{e as dictionary,r as fingerprint};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";var e={title:"Zigbee-Verbindungen",toggle:"Zigbee-Verbindungen beim Darüberfahren über ein Gerät anzeigen",hint:"Zeigt Eltern und nächste Hops laut Integration. Beim Darüberfahren werden keine Daten abgerufen.",help:"Zeigt den Elternknoten eines Endgeräts oder den aktiven nächsten Hop eines Routers zum Koordinator laut Integration. Es gibt höchstens einen ausgehenden Pfeil; eingehende Pfeile zeigen Geräte, die über diesen Knoten routen. Gewöhnliche Nachbarn werden ausgeblendet. Alle Linien sind durchgezogen. Linie und Pfeil verwenden LQI 0–255: rot bei 0, gelb bei 128, grün bei 255. Ohne LQI sind sie grau mit schwarzer Kontur. Eine Beschriftung am Pfeilende nennt ein Ziel in einem anderen Bereich oder außerhalb des Plans. Keine Routendaten bedeutet fehlende eindeutige Angaben, nicht dass das Gerät offline ist. Dies sind zuletzt empfangene Integrationsdaten, keine Live-Paketverfolgung oder Garantie des gesamten Pfads. Der ZHA-Cache kann beim Lesen bereits alt sein; veraltete und unvollständige Daten werden markiert. Verbindungen sind nur mit der Maus sichtbar.",help_aria:"Hilfe: Zigbee-Verbindungen",admin_only:"Topologiedaten sind nur für Home-Assistant-Administratoren verfügbar.",save_first:"Speichere diese Einstellung, bevor Topologiedaten geladen werden.",zha:"ZHA",zha_read:"ZHA-Daten lesen",zha_hint:"Liest den ZHA-Topologiecache; es wird kein Funkscan gestartet.",z2m:"Zigbee2MQTT",z2m_topics:"Basistopics (eines pro Zeile)",z2m_update:"Karte aktualisieren",z2m_warning:"Ein Netzwerkscan mit Routen kann länger als 10 Minuten dauern. House Plan wartet im Hintergrund weiter. Der Scan kann Zigbee vorübergehend verlangsamen.",scan_connecting:"Verbindung zu Zigbee2MQTT wird hergestellt",scan_waiting:"Warten auf die Karte von Zigbee2MQTT",scan_elapsed:"Vergangen: {time}",scan_background_hint:"Du kannst die Einstellungen schließen — das Warten wird im Hintergrund fortgesetzt.",scan_long_wait:"Zigbee2MQTT hat noch nicht geantwortet. Das Warten wird fortgesetzt.",scan_cancel:"Anfrage abbrechen",scan_cancel_hint:"Nur das Warten von House Plan wird beendet. Zigbee2MQTT kann den Scan fortsetzen.",scan_cancelled:"Warten abgebrochen. Der Scan von Zigbee2MQTT ist möglicherweise noch nicht beendet.",scan_backend_required:"Aktualisieren Sie die House-Plan-Integration und starten Sie Home Assistant neu, um Kartenscans im Hintergrund zu nutzen.",scan_connection_lost:"Die MQTT-Verbindung wurde unterbrochen; das Ergebnis ist möglicherweise nicht angekommen. Das Warten wurde beendet, Zigbee2MQTT scannt möglicherweise weiter.",scan_frontend_disconnected:"Die Verbindung zu Home Assistant wurde unterbrochen. Das Warten wird im Hintergrund fortgesetzt. Stelle die Verbindung wieder her, um den Status zu sehen.",scan_unavailable:"MQTT ist in Home Assistant nicht verfügbar oder wird nicht unterstützt.",status_idle:"Daten nicht geladen",status_loading:"Wird geladen…",status_ready:"Empfangen: {time}",status_stale:"Empfangen: {time} · veraltet",status_partial:"Empfangen: {time} · unvollständige Daten",status_no_links:"Empfangen: {time} · keine bestätigten Routen",error_permission:"Administratorrechte sind erforderlich.",error_unsupported:"Dieser Anbieter oder die Home-Assistant-API ist nicht verfügbar.",error_timeout:"Der Anbieter hat nicht rechtzeitig geantwortet.",error_invalid_topic:"Prüfe das Zigbee2MQTT-Basistopic.",error_invalid_payload:"Der Anbieter hat nicht unterstützte Topologiedaten geliefert.",error_provider:"Topologiedaten konnten nicht geladen werden.",remote_count:"+{n} in anderen Bereichen",route_device_not_on_plan:"Gerät ist nicht im Plan",route_coordinator_not_on_plan:"Koordinator ist nicht im Plan",route_other_space:"anderer Bereich",route_other_space_named:"{space} ({name})",route_device_not_on_plan_named:"Gerät ist nicht im Plan ({name})",route_unknown:"Keine Routendaten",route_stale:"Veraltete Daten",route_partial:"Unvollständige Daten",status_cache:"Integrationscache; Zeitpunkt des Funkscans unbekannt"};const n="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";export{e as dictionary,n as fingerprint};
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";var e={title:"Zigbee-Verbindungen",toggle:"Zigbee-Verbindungen beim Darüberfahren über ein Gerät anzeigen",hint:"Zeigt Eltern und nächste Hops laut Integration. Beim Darüberfahren werden keine Daten abgerufen.",help:"Zeigt den Elternknoten eines Endgeräts oder den aktiven nächsten Hop eines Routers zum Koordinator laut Integration. Es gibt höchstens einen ausgehenden Pfeil; eingehende Pfeile zeigen Geräte, die über diesen Knoten routen. Gewöhnliche Nachbarn werden ausgeblendet. Alle Linien sind durchgezogen. Linie und Pfeil verwenden LQI 0–255: rot bei 0, gelb bei 128, grün bei 255. Ohne LQI sind sie grau mit schwarzer Kontur. Eine Beschriftung am Pfeilende nennt ein Ziel in einem anderen Bereich oder außerhalb des Plans. Keine Routendaten bedeutet fehlende eindeutige Angaben, nicht dass das Gerät offline ist. Dies sind zuletzt empfangene Integrationsdaten, keine Live-Paketverfolgung oder Garantie des gesamten Pfads. Der ZHA-Cache kann beim Lesen bereits alt sein; veraltete und unvollständige Daten werden markiert. Verbindungen sind nur mit der Maus sichtbar.",help_aria:"Hilfe: Zigbee-Verbindungen",admin_only:"Topologiedaten sind nur für Home-Assistant-Administratoren verfügbar.",save_first:"Speichere diese Einstellung, bevor Topologiedaten geladen werden.",zha:"ZHA",zha_read:"ZHA-Daten lesen",zha_hint:"Liest den ZHA-Topologiecache; es wird kein Funkscan gestartet.",z2m:"Zigbee2MQTT",z2m_topics:"Basistopics (eines pro Zeile)",z2m_update:"Karte aktualisieren",z2m_warning:"Ein Netzwerkscan mit Routen kann mehrere Minuten dauern. House Plan wartet bis zu 10 Minuten. Der Scan kann Zigbee vorübergehend verlangsamen.",status_idle:"Daten nicht geladen",status_loading:"Wird geladen…",status_ready:"Empfangen: {time}",status_stale:"Empfangen: {time} · veraltet",status_partial:"Empfangen: {time} · unvollständige Daten",status_no_links:"Empfangen: {time} · keine bestätigten Routen",error_permission:"Administratorrechte sind erforderlich.",error_unsupported:"Dieser Anbieter oder die Home-Assistant-API ist nicht verfügbar.",error_timeout:"Der Anbieter hat nicht rechtzeitig geantwortet.",error_invalid_topic:"Prüfe das Zigbee2MQTT-Basistopic.",error_invalid_payload:"Der Anbieter hat nicht unterstützte Topologiedaten geliefert.",error_provider:"Topologiedaten konnten nicht geladen werden.",remote_count:"+{n} in anderen Bereichen",route_device_not_on_plan:"Gerät ist nicht im Plan",route_coordinator_not_on_plan:"Koordinator ist nicht im Plan",route_other_space:"anderer Bereich",route_device_not_on_plan_named:"Gerät ist nicht im Plan ({name})",route_unknown:"Keine Routendaten",route_stale:"Veraltete Daten",route_partial:"Unvollständige Daten",status_cache:"Integrationscache; Zeitpunkt des Funkscans unbekannt"};const n="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";export{e as dictionary,n as fingerprint};
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";var e={title:"Liens Zigbee",toggle:"Afficher les liens Zigbee au survol d’un appareil",hint:"Affiche les parents et prochains sauts signalés par l’intégration. Le survol ne lance aucune requête.",help:"Affiche le parent d’un appareil terminal ou le prochain saut actif d’un routeur vers le coordinateur selon l’intégration. Il y a au plus une flèche sortante ; les flèches entrantes indiquent les appareils passant par ce nœud. Les simples voisins sont masqués. Toutes les lignes sont continues. Ligne et flèche utilisent le LQI 0–255 : rouge à 0, jaune à 128, vert à 255. Sans LQI, elles sont grises avec un contour noir. Une étiquette au bout de la flèche nomme une cible dans un autre espace ou absente du plan. Aucune donnée de route indique l’absence d’information univoque, pas un appareil hors ligne. Il s’agit des derniers relevés de l’intégration, pas du trajet des paquets en direct ni d’une garantie du chemin entier. Le cache ZHA peut déjà être ancien à la lecture ; les données anciennes et incomplètes sont signalées. Les liens ne sont visibles qu’à la souris.",help_aria:"Aide : liens Zigbee",admin_only:"Les données de topologie sont réservées aux administrateurs Home Assistant.",save_first:"Enregistrez ce réglage avant de charger les données de topologie.",zha:"ZHA",zha_read:"Lire les données ZHA",zha_hint:"Lit la topologie en cache de ZHA sans lancer d’analyse radio.",z2m:"Zigbee2MQTT",z2m_topics:"Topics de base (un par ligne)",z2m_update:"Actualiser la carte",z2m_warning:"L’analyse de la carte réseau avec les routes peut prendre plusieurs minutes. House Plan attend jusqu’à 10 minutes. L’analyse peut réduire temporairement la réactivité de Zigbee.",status_idle:"Données non chargées",status_loading:"Chargement…",status_ready:"Reçu : {time}",status_stale:"Reçu : {time} · obsolète",status_partial:"Reçu : {time} · données incomplètes",status_no_links:"Reçu : {time} · aucune route confirmée",error_permission:"Les droits administrateur sont requis.",error_unsupported:"Ce fournisseur ou l’API Home Assistant n’est pas disponible.",error_timeout:"Le fournisseur n’a pas répondu à temps.",error_invalid_topic:"Vérifiez le topic de base Zigbee2MQTT.",error_invalid_payload:"Le fournisseur a renvoyé des données de topologie non prises en charge.",error_provider:"Impossible de charger les données de topologie.",remote_count:"+{n} dans d’autres espaces",route_device_not_on_plan:"appareil absent du plan",route_coordinator_not_on_plan:"coordinateur absent du plan",route_other_space:"autre espace",route_device_not_on_plan_named:"Appareil absent du plan ({name})",route_unknown:"Aucune donnée de route",route_stale:"Données anciennes",route_partial:"Données incomplètes",status_cache:"Cache de l’intégration ; date de l’analyse radio inconnue"};const n="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";export{e as dictionary,n as fingerprint};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";var e={title:"Liens Zigbee",toggle:"Afficher les liens Zigbee au survol d’un appareil",hint:"Affiche les parents et prochains sauts signalés par l’intégration. Le survol ne lance aucune requête.",help:"Affiche le parent d’un appareil terminal ou le prochain saut actif d’un routeur vers le coordinateur selon l’intégration. Il y a au plus une flèche sortante ; les flèches entrantes indiquent les appareils passant par ce nœud. Les simples voisins sont masqués. Toutes les lignes sont continues. Ligne et flèche utilisent le LQI 0–255 : rouge à 0, jaune à 128, vert à 255. Sans LQI, elles sont grises avec un contour noir. Une étiquette au bout de la flèche nomme une cible dans un autre espace ou absente du plan. Aucune donnée de route indique l’absence d’information univoque, pas un appareil hors ligne. Il s’agit des derniers relevés de l’intégration, pas du trajet des paquets en direct ni d’une garantie du chemin entier. Le cache ZHA peut déjà être ancien à la lecture ; les données anciennes et incomplètes sont signalées. Les liens ne sont visibles qu’à la souris.",help_aria:"Aide : liens Zigbee",admin_only:"Les données de topologie sont réservées aux administrateurs Home Assistant.",save_first:"Enregistrez ce réglage avant de charger les données de topologie.",zha:"ZHA",zha_read:"Lire les données ZHA",zha_hint:"Lit la topologie en cache de ZHA sans lancer d’analyse radio.",z2m:"Zigbee2MQTT",z2m_topics:"Topics de base (un par ligne)",z2m_update:"Actualiser la carte",z2m_warning:"L’analyse de la carte réseau avec les routes peut prendre plus de 10 minutes. House Plan continue d’attendre en arrière-plan. L’analyse peut réduire temporairement la réactivité de Zigbee.",scan_connecting:"Connexion à Zigbee2MQTT",scan_waiting:"En attente de la carte de Zigbee2MQTT",scan_elapsed:"Temps écoulé : {time}",scan_background_hint:"Vous pouvez fermer les réglages — l’attente continuera en arrière-plan.",scan_long_wait:"Zigbee2MQTT n’a pas encore répondu. L’attente continue.",scan_cancel:"Annuler la demande",scan_cancel_hint:"Seule l’attente de House Plan est annulée. L’analyse de Zigbee2MQTT peut continuer.",scan_cancelled:"Attente annulée. L’analyse de Zigbee2MQTT n’est peut-être pas encore terminée.",scan_backend_required:"Mettez à jour l’intégration House Plan et redémarrez Home Assistant pour utiliser les analyses de carte en arrière-plan.",scan_connection_lost:"La connexion MQTT a été perdue ; le résultat n’est peut-être pas arrivé. L’attente est terminée, mais Zigbee2MQTT peut encore analyser le réseau.",scan_frontend_disconnected:"La connexion à Home Assistant a été interrompue. L’attente continue en arrière-plan. Reconnectez-vous pour retrouver l’état de la demande.",scan_unavailable:"MQTT n’est pas disponible ou n’est pas pris en charge dans Home Assistant.",status_idle:"Données non chargées",status_loading:"Chargement…",status_ready:"Reçu : {time}",status_stale:"Reçu : {time} · obsolète",status_partial:"Reçu : {time} · données incomplètes",status_no_links:"Reçu : {time} · aucune route confirmée",error_permission:"Les droits administrateur sont requis.",error_unsupported:"Ce fournisseur ou l’API Home Assistant n’est pas disponible.",error_timeout:"Le fournisseur n’a pas répondu à temps.",error_invalid_topic:"Vérifiez le topic de base Zigbee2MQTT.",error_invalid_payload:"Le fournisseur a renvoyé des données de topologie non prises en charge.",error_provider:"Impossible de charger les données de topologie.",remote_count:"+{n} dans d’autres espaces",route_device_not_on_plan:"appareil absent du plan",route_coordinator_not_on_plan:"coordinateur absent du plan",route_other_space:"autre espace",route_other_space_named:"{space} ({name})",route_device_not_on_plan_named:"Appareil absent du plan ({name})",route_unknown:"Aucune donnée de route",route_stale:"Données anciennes",route_partial:"Données incomplètes",status_cache:"Cache de l’intégration ; date de l’analyse radio inconnue"};const n="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";export{e as dictionary,n as fingerprint};
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";var e={title:"Связи Zigbee",toggle:"Показывать связи Zigbee при наведении на устройство",hint:"Показываются родители и следующие узлы по сведениям интеграции. Наведение ничего не запрашивает.",help:"Показывает родителя конечного устройства или активный следующий узел роутера к координатору по данным интеграции. Исходящая стрелка не более одной, входящие — устройства, идущие через этот узел. Обычные соседи скрыты. Все линии сплошные. Цвет линии и стрелки — LQI 0–255: красный при 0, жёлтый при 128, зелёный при 255. Без LQI они серые с чёрной обводкой. Подпись на конце стрелки указывает цель на другом пространстве или не на плане. «Нет данных о маршруте» означает отсутствие однозначных сведений об исходящем маршруте, а не недоступность устройства. Это последние записи интеграции, не путь пакета в реальном времени и не гарантия всей цепочки. Кеш ZHA может быть старым уже при чтении; устаревшие и неполные данные отмечаются. Связи видны только при работе мышью.",help_aria:"Справка: связи Zigbee",admin_only:"Данные топологии доступны только администраторам Home Assistant.",save_first:"Сохраните настройку перед загрузкой данных топологии.",zha:"ZHA",zha_read:"Прочитать данные ZHA",zha_hint:"Читает сохранённую топологию ZHA и не запускает радио-сканирование.",z2m:"Zigbee2MQTT",z2m_topics:"Базовые топики (по одному в строке)",z2m_update:"Обновить карту",z2m_warning:"Сканирование карты сети с маршрутами может занять несколько минут. House Plan ждёт до 10 минут. Сканирование временно может снизить отзывчивость Zigbee.",status_idle:"Данные не загружены",status_loading:"Загрузка…",status_ready:"Получено: {time}",status_stale:"Получено: {time} · данные устарели",status_partial:"Получено: {time} · неполные данные",status_no_links:"Получено: {time} · подтверждённые маршруты не найдены",error_permission:"Нужны права администратора.",error_unsupported:"Провайдер или API Home Assistant недоступен.",error_timeout:"Провайдер не ответил вовремя.",error_invalid_topic:"Проверьте базовый топик Zigbee2MQTT.",error_invalid_payload:"Провайдер вернул неподдерживаемые данные топологии.",error_provider:"Не удалось загрузить данные топологии.",remote_count:"+{n} в других пространствах",route_device_not_on_plan:"Устройства нет на плане",route_coordinator_not_on_plan:"координатора нет на плане",route_other_space:"другое пространство",route_device_not_on_plan_named:"Устройства нет на плане ({name})",route_unknown:"Нет данных о маршруте",route_stale:"Данные устарели",route_partial:"Неполные данные",status_cache:"Кеш интеграции; время радиосканирования неизвестно"};const t="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";export{e as dictionary,t as fingerprint};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";import{n as t,d as n,e}from"./zigbee-topology-DkIGuBlK.js";import{aP as o}from"./houseplan-card-DpMO8ZtV.js";import"./namespace-language-B3Q_Y8Lh.js";const i=new WeakMap;function r(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=i.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},i.set(n,e)),e}function a(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function c(t){return Object.assign(new Error(t),{code:t})}function l(t,n,e){const o=t.inflight.get(n);if(o)return o;const i=t.states[n],r=void 0===i?.obtainedAt?{}:{obtainedAt:i.obtainedAt,partial:i.partial,stale:i.stale};a(t,n,{...r,phase:"loading"});const l=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw c("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],a(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{a(t,n,{...r,...void 0===r.obtainedAt?{}:{stale:!0},phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,l),l}function u(t){const n=r(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function f(t,n){const e=r(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw c("permission")}function d(n){const e=r(n);return e?l(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw c("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function h(t){return null!==t&&"object"==typeof t?t:null}function m(t){const n=h(t),o=n?.payload??t;if("string"!=typeof o)return o;if(o.length>e)return null;try{return JSON.parse(o)}catch{return null}}async function g(t,n){if(n<=0)throw t.catch(()=>{}),c("timeout");let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(c("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function w(t,e,i=6e5){const a=r(t);if(!a)return Promise.resolve();const s=o(e);return l(a,`z2m:${s||String(e)}`,async()=>{if(p(t),!s)throw c("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw c("unsupported");const r=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),a=Date.now()+Math.max(1,i);let l=!1,u=!1,f=null,d=null,w=null;const y=new Promise(t=>{f=t}),b=new Promise((t,n)=>{d=t,w=n});b.catch(()=>{});const v=[],_=t=>{try{t()}catch{}},S=()=>!l&&Date.now()<a,D=async(t,n)=>{if(!S())throw c("timeout");const i=o.call(e,n,{type:"mqtt/subscribe",topic:`${s}/${t}`}).then(t=>{"function"==typeof t&&(l?_(t):v.push(t))});await g(i,Math.min(1e4,a-Date.now()))};try{if(await D("bridge/info",t=>{S()&&!0===h(t)?.retain&&m(t)&&f?.()}),await D("bridge/response/networkmap",t=>{if(!S()||!u||!0===h(t)?.retain)return;const n=m(t);null!==n?n&&function(t){const n=h(t),e=n?.transaction??h(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===r&&d?.(n):w?.(c("invalid_payload"))}),await g(y,Math.min(4e3,a-Date.now())),!S())throw c("timeout");u=!0;const e=t.callService("mqtt","publish",{topic:`${s}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!0,transaction:r}),qos:0,retain:!1}),[,o]=await g(Promise.all([g(e,Math.min(1e4,a-Date.now())),b]),a-Date.now());if(!S())throw c("timeout");const i=h(o)?.status;if(i&&"ok"!==i)throw c("provider");return n(o,s)}finally{l=!0,u=!1;for(const t of v)_(t)}})}export{d as readZhaTopology,w as refreshZ2mTopology,f as subscribeZigbeeTopology,u as zigbeeTopologyRuntimeSnapshot};
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1d4220f7b40b863e575b7e27453db793fba7b358b97a6e20415d43d515b6166f";try{await import("./houseplan-assets/houseplan-card-DpMO8ZtV.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";try{await import("./houseplan-assets/open-spans-Co-2W4Ja.js");await import("./houseplan-assets/houseplan-card-BRGPGuZP.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -2,7 +2,8 @@
"domain": "houseplan",
"name": "House Plan",
"after_dependencies": [
"lovelace"
"lovelace",
"mqtt"
],
"codeowners": [
"@Matysh"
@@ -20,5 +21,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.80.0-beta.2"
"version": "1.80.0-beta.8"
}
@@ -0,0 +1,209 @@
"""Read-only discovery and reversible archival of a previous installation.
Only the config flow calls this module, in HA's executor. Never use Store here:
loading a Store may migrate/save data before the administrator makes a choice.
"""
from __future__ import annotations
import errno
import json
import logging
import os
import stat
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
from .const import (
ASSETS_DIR,
DOMAIN,
FILES_DIR,
PLANS_DIR,
STORAGE_CONFIG_KEY,
STORAGE_KEY,
STORAGE_MINOR_VERSION,
STORAGE_VERSION,
STORAGE_VIRTUAL_LIGHTS_KEY,
)
_LOGGER = logging.getLogger(__name__)
STORE_KEYS = (
STORAGE_CONFIG_KEY, STORAGE_KEY, STORAGE_VIRTUAL_LIGHTS_KEY, f"{DOMAIN}.trails"
)
DATA_DIRS = (PLANS_DIR, FILES_DIR, ASSETS_DIR)
@dataclass(frozen=True)
class PreviousData:
"""One summary, shared by all menu placeholders."""
found: bool
spaces: int | None
files: int
modified: float | None
unsafe: bool = False
@dataclass
class _Inventory:
sources: list[Path]
files: list[tuple[Path, os.stat_result]]
unsafe: bool = False
class ArchiveError(Exception):
"""Archive failed; a partial archive may need manual recovery."""
def __init__(self, archive_path: Path | None):
super().__init__(f"Previous data archival failed (archive: {archive_path})")
self.archive_path = archive_path
def _lstat(path: Path) -> os.stat_result | None:
try:
return path.lstat()
except FileNotFoundError:
return None
def _inventory(root: Path) -> _Inventory:
result = _Inventory([], [])
# Do not even enumerate a linked parent: that would access an external tree.
for parent in (root / ".storage", root / DOMAIN):
info = _lstat(parent)
if info is not None and not stat.S_ISDIR(info.st_mode):
result.unsafe = True
if result.unsafe:
return result
def visit(path: Path, info: os.stat_result) -> None:
if stat.S_ISDIR(info.st_mode):
with os.scandir(path) as children:
for child in children:
visit(Path(child.path), child.stat(follow_symlinks=False))
else:
result.files.append((path, info))
if not stat.S_ISREG(info.st_mode):
result.unsafe = True
for key in STORE_KEYS:
path = root / ".storage" / key
info = _lstat(path)
if info is not None:
result.sources.append(path)
result.files.append((path, info))
if not stat.S_ISREG(info.st_mode):
result.unsafe = True
for name in DATA_DIRS:
path = root / name
info = _lstat(path)
if info is not None:
result.sources.append(path)
visit(path, info)
if not stat.S_ISDIR(info.st_mode):
result.unsafe = True
return result
def _spaces(path: Path) -> int | None:
if _lstat(path) is None:
return 0
try:
with path.open(encoding="utf-8") as stream:
envelope = json.load(stream)
if not isinstance(envelope, dict):
return None
version = envelope.get("version")
minor = envelope.get("minor_version", 1)
if (type(version) is not int or version != STORAGE_VERSION
or type(minor) is not int or not 0 <= minor <= STORAGE_MINOR_VERSION):
return None
data = envelope.get("data")
config = data.get("config") if isinstance(data, dict) else None
spaces = config.get("spaces") if isinstance(config, dict) else None
if not isinstance(spaces, list) or not all(isinstance(s, dict) for s in spaces):
return None
return len(spaces)
except (OSError, ValueError, UnicodeError):
return None
def inspect_previous_data(config_dir: str) -> PreviousData:
"""Discover only fixed active paths, without writes or following symlinks."""
root = Path(config_dir)
inventory = _inventory(root)
return PreviousData(
found=bool(inventory.files) or inventory.unsafe,
spaces=None if inventory.unsafe else _spaces(root / ".storage" / STORAGE_CONFIG_KEY),
files=sum(path.parent != root / ".storage" for path, _ in inventory.files),
modified=max((info.st_mtime for _, info in inventory.files), default=None),
unsafe=inventory.unsafe,
)
def _archive_name() -> str:
return f"{datetime.now(UTC):%Y%m%dT%H%M%S.%fZ}-{uuid4().hex}"
def archive_previous_data(config_dir: str) -> Path | None:
"""Rename the complete old set; rollback handled failures without overwrites.
This is not a crash-atomic transaction. If rollback itself fails, all bytes
remain at their original paths or in the logged, retained partial archive.
"""
root = Path(config_dir)
created: list[Path] = []
moved: list[tuple[Path, Path]] = []
archive: Path | None = None
try:
inventory = _inventory(root)
archive_parent = root / DOMAIN / "archive"
parent_info = _lstat(archive_parent)
if inventory.unsafe or (parent_info is not None and not stat.S_ISDIR(parent_info.st_mode)):
raise OSError(errno.EPERM, "Unsafe previous data paths")
if not inventory.files:
return None
destination_parent = archive_parent
while _lstat(destination_parent) is None:
destination_parent = destination_parent.parent
device = destination_parent.stat().st_dev
if any(path.lstat().st_dev != device for path in inventory.sources) or any(
info.st_dev != device for _, info in inventory.files
):
raise OSError(errno.EXDEV, "Archive and previous data must be on one filesystem")
for directory in (root / DOMAIN, archive_parent):
if _lstat(directory) is None:
directory.mkdir()
created.append(directory)
# Exclusive creation: never reuse/overwrite an old archive, even on a collision.
archive = archive_parent / _archive_name()
archive.mkdir()
created.append(archive)
storage = archive / "storage"
storage.mkdir()
created.append(storage)
for source in inventory.sources:
destination = (storage if source.parent == root / ".storage" else archive) / source.name
source.rename(destination)
moved.append((source, destination))
return archive
except OSError as error:
rollback_failed = False
for source, destination in reversed(moved):
try:
if _lstat(source) is not None:
raise FileExistsError(source)
destination.rename(source)
except OSError:
rollback_failed = True
_LOGGER.exception("Cannot restore %s from partial House Plan archive %s", source, archive)
if not rollback_failed:
for directory in reversed(created):
try:
directory.rmdir() # Only our empty directories; never delete user files.
except OSError:
_LOGGER.exception("Cannot remove empty archive directory %s", directory)
_LOGGER.error("House Plan archival failed; retained archive if any: %s: %s", archive, error)
raise ArchiveError(archive) from error
+2
View File
@@ -112,6 +112,8 @@ class HouseplanData:
junction_baseline: tuple[int, dict[str, dict[str, int]]] | None = None
# Runtime-only #485 authority; never serialized by HouseplanStore.
radar_coordinator: Any | None = None
# #800: MQTT jobs and last-good maps live only for this loaded entry.
zigbee_coordinator: Any | None = None
HouseplanConfigEntry = ConfigEntry[HouseplanData]
+17 -1
View File
@@ -1,6 +1,14 @@
{
"config": {
"step": {
"previous_data": {
"title": "Previous installation found",
"description": "Found previous data: spaces — {spaces}, files — {files}, last modified — {modified}. {warning}\n\nStarting fresh moves all previous data to a permanent archive. No data is changed until you confirm the next form.",
"menu_options": {
"restore": "Restore previous plans",
"start_fresh": "Start fresh"
}
},
"user": {
"title": "House Plan",
"data": { "admin_only": "Only administrators may edit the layout" }
@@ -9,7 +17,15 @@
"create_entry": {
"panel_ready": "Open House Plan from the sidebar when the item is available. A desktop browser is recommended for editing. If the item is missing, use the House Plan dashboard card and check System Health."
},
"abort": { "single_instance_allowed": "Already configured — only one entry is allowed." }
"error": {
"previous_data_unreadable": "The previous configuration could not be read."
},
"abort": {
"single_instance_allowed": "Already configured — only one entry is allowed.",
"already_in_progress": "Another House Plan setup is already in progress.",
"previous_data_error": "Previous data could not be inspected. Check file permissions and the Home Assistant log; no data was changed.",
"archive_failed": "Previous data could not be archived. No entry was created. Check the Home Assistant log for the archive path and recovery details."
}
},
"options": {
"step": {
@@ -156,6 +156,9 @@ def _global_settings(value: object) -> dict[str, Any]:
show_room_tooltip = value.get("show_room_tooltip")
if isinstance(show_room_tooltip, bool):
out["show_room_tooltip"] = show_room_tooltip
show_device_battery = value.get("show_device_battery")
if isinstance(show_device_battery, bool) or show_device_battery == "low":
out["show_device_battery"] = show_device_battery
volumetric_view = value.get("volumetric_view")
if isinstance(volumetric_view, bool):
out["volumetric_view"] = volumetric_view
@@ -286,6 +289,9 @@ def _project_marker(ids: _Pseudonyms, marker: dict[str, Any]) -> dict[str, Any]:
"ripple_size", "size", "angle", "glow_radius_cm", "glow_color", "is_light",
"use_climate_temp",
)))
hide_battery = marker.get("hide_battery")
if isinstance(hide_battery, bool):
out["hide_battery"] = hide_battery
icon = marker.get("icon")
if isinstance(icon, str) and _SAFE_ICON.fullmatch(icon):
out["icon"] = icon
@@ -1,6 +1,14 @@
{
"config": {
"step": {
"previous_data": {
"title": "Frühere Installation gefunden",
"description": "Frühere Daten gefunden: Bereiche — {spaces}, Dateien — {files}, zuletzt geändert — {modified}. {warning}\n\nEin Neuanfang verschiebt alle früheren Daten in ein dauerhaftes Archiv. Vor der Bestätigung des nächsten Formulars werden keine Daten geändert.",
"menu_options": {
"restore": "Frühere Pläne wiederherstellen",
"start_fresh": "Neu beginnen"
}
},
"user": {
"title": "House Plan",
"data": {
@@ -11,8 +19,14 @@
"create_entry": {
"panel_ready": "Öffnen Sie House Plan über die Seitenleiste, wenn der Eintrag verfügbar ist. Für die Bearbeitung wird ein Desktop-Browser empfohlen. Fehlt der Eintrag, verwenden Sie die House-Plan-Dashboard-Karte und prüfen Sie die Systeminformationen."
},
"error": {
"previous_data_unreadable": "Die frühere Konfiguration konnte nicht gelesen werden."
},
"abort": {
"single_instance_allowed": "Bereits konfiguriert – es ist nur eine Instanz zulässig."
"single_instance_allowed": "Bereits konfiguriert – es ist nur eine Instanz zulässig.",
"already_in_progress": "Eine andere House-Plan-Einrichtung läuft bereits.",
"previous_data_error": "Frühere Daten konnten nicht geprüft werden. Prüfen Sie Dateiberechtigungen und das Home-Assistant-Protokoll; es wurden keine Daten geändert.",
"archive_failed": "Frühere Daten konnten nicht archiviert werden. Es wurde kein Eintrag erstellt. Archivpfad und Hinweise zur Wiederherstellung stehen im Home-Assistant-Protokoll."
}
},
"options": {
@@ -1,6 +1,14 @@
{
"config": {
"step": {
"previous_data": {
"title": "Previous installation found",
"description": "Found previous data: spaces — {spaces}, files — {files}, last modified — {modified}. {warning}\n\nStarting fresh moves all previous data to a permanent archive. No data is changed until you confirm the next form.",
"menu_options": {
"restore": "Restore previous plans",
"start_fresh": "Start fresh"
}
},
"user": {
"title": "House Plan",
"data": {
@@ -11,8 +19,14 @@
"create_entry": {
"panel_ready": "Open House Plan from the sidebar when the item is available. A desktop browser is recommended for editing. If the item is missing, use the House Plan dashboard card and check System Health."
},
"error": {
"previous_data_unreadable": "The previous configuration could not be read."
},
"abort": {
"single_instance_allowed": "Already configured — only one entry is allowed."
"single_instance_allowed": "Already configured — only one entry is allowed.",
"already_in_progress": "Another House Plan setup is already in progress.",
"previous_data_error": "Previous data could not be inspected. Check file permissions and the Home Assistant log; no data was changed.",
"archive_failed": "Previous data could not be archived. No entry was created. Check the Home Assistant log for the archive path and recovery details."
}
},
"options": {
@@ -1,6 +1,14 @@
{
"config": {
"step": {
"previous_data": {
"title": "Installation précédente trouvée",
"description": "Données précédentes trouvées : espaces — {spaces}, fichiers — {files}, dernière modification — {modified}. {warning}\n\nRepartir de zéro déplace les anciennes données dans une archive permanente. Aucune donnée ne change avant la confirmation du formulaire suivant.",
"menu_options": {
"restore": "Restaurer les plans précédents",
"start_fresh": "Repartir de zéro"
}
},
"user": {
"title": "House Plan",
"data": {
@@ -11,8 +19,14 @@
"create_entry": {
"panel_ready": "Ouvrez House Plan depuis la barre latérale lorsque l’entrée est disponible. Un navigateur sur ordinateur est recommandé pour la modification. Si l’entrée est absente, utilisez la carte House Plan du tableau de bord et consultez les informations système."
},
"error": {
"previous_data_unreadable": "La configuration précédente n’a pas pu être lue."
},
"abort": {
"single_instance_allowed": "Déjà configuré — une seule entrée est autorisée."
"single_instance_allowed": "Déjà configuré — une seule entrée est autorisée.",
"already_in_progress": "Une autre configuration House Plan est déjà en cours.",
"previous_data_error": "Impossible de vérifier les anciennes données. Vérifiez les permissions des fichiers et le journal Home Assistant ; aucune donnée n’a été modifiée.",
"archive_failed": "Impossible d’archiver les anciennes données. Aucune entrée n’a été créée. Consultez le journal Home Assistant pour le chemin de l’archive et les instructions de récupération."
}
},
"options": {
@@ -1,6 +1,14 @@
{
"config": {
"step": {
"previous_data": {
"title": "Найдены данные прежней установки",
"description": "Найдены прежние данные: пространств — {spaces}, файлов — {files}, последнее изменение — {modified}. {warning}\n\n«Начать с чистого листа» перенесёт прежние данные в постоянный архив. До подтверждения следующей формы данные не изменятся.",
"menu_options": {
"restore": "Восстановить прежние планы",
"start_fresh": "Начать с чистого листа"
}
},
"user": {
"title": "House Plan",
"data": {
@@ -11,8 +19,14 @@
"create_entry": {
"panel_ready": "Откройте House Plan в боковом меню, если пункт появился. Для редактирования рекомендуется браузер на компьютере. Если пункта нет, используйте карточку House Plan на панели и проверьте информацию о системе."
},
"error": {
"previous_data_unreadable": "Данные прежнего конфига не удалось прочитать."
},
"abort": {
"single_instance_allowed": "Уже настроено — допускается одна запись."
"single_instance_allowed": "Уже настроено — допускается одна запись.",
"already_in_progress": "Другой мастер установки House Plan уже открыт.",
"previous_data_error": "Не удалось проверить прежние данные. Проверьте права на файлы и журнал Home Assistant; данные не изменились.",
"archive_failed": "Не удалось перенести прежние данные в архив. Запись не создана. Путь архива и сведения для восстановления — в журнале Home Assistant."
}
},
"options": {
@@ -1402,6 +1402,9 @@ def _north_deg(value):
_BG_MODE = vol.In(["static", "daynight"])
_SUN_RAY_ORIGIN = vol.In(["inner", "outer"])
# #792/#807: absent = every battery device, exact False = none, "low" = only
# the red low state. Strings other than "low" and truthy numbers are refused.
_DEVICE_BATTERY = vol.Any(bool, "low")
SPACE_DISPLAY_SCHEMA = vol.Schema(
{
@@ -2000,6 +2003,8 @@ MARKER_SCHEMA = vol.Schema(
),
# climate current_temperature: badge + room-average vote (off unless True)
vol.Optional("use_climate_temp"): vol.Any(bool, None),
# Per-marker battery indicator override; absence/False keeps global policy.
vol.Optional("hide_battery"): bool,
vol.Optional("room_id"): vol.Any(str, None),
# Keep in sync with DISPLAY_MODES in src/logic.ts. `ripple` is no longer
# offered, but remains accepted while old stores migrate to icon_ripple.
@@ -2321,6 +2326,7 @@ CONFIG_SCHEMA = vol.All(
vol.Optional("sun_rays"): bool,
vol.Optional("sun_ray_origin"): _SUN_RAY_ORIGIN,
vol.Optional("show_room_tooltip"): bool,
vol.Optional("show_device_battery"): _DEVICE_BATTERY,
# #649: installation-wide 2.5D View; absent/false keeps Flat.
vol.Optional("volumetric_view"): bool,
# #661: the moon on the "Follow the Sun" background; absent/false = off.
@@ -0,0 +1,411 @@
"""#803: server-owned local node-move proof, independent of browser claims.
No arbitrary candidate, allow-rehost flag or persisted node graph is accepted.
Undo proves the same forward operation from its proposed baseline to the exact
current space before allowing the inverse (including a transverse X split).
"""
from __future__ import annotations
import copy
import math
from typing import Any
from .coordinate_canonicalization import canonicalize_config_geometry
from .wall_segment_model import _wall_key, commit_wall_segment_model
EPS = 1e-9
DIRECTION_EPS = math.sqrt(2 * EPS - EPS * EPS)
GRID = 1 / 240
class NodeMoveError(ValueError):
"""Stable fail-closed protocol error."""
def __init__(self, reason: str = "invalid") -> None:
self.code = f"node_move_{reason}"
super().__init__(self.code)
def _sub(a, b):
return [a[0] - b[0], a[1] - b[1]]
def _dot(a, b):
return a[0] * b[0] + a[1] * b[1]
def _cross(a, b):
return a[0] * b[1] - a[1] * b[0]
def _parallel(a, b):
"""Shared angular predicate, never the positional EPS."""
return abs(_cross(a, b)) <= DIRECTION_EPS
def _length(a, b):
return math.hypot(a[0] - b[0], a[1] - b[1])
def _same(a, b):
return _length(a, b) <= EPS
def _unit(a):
span = math.hypot(*a)
return [a[0] / span, a[1] / span]
def _fraction(p, a, b):
d = _sub(b, a)
return _dot(_sub(p, a), d) / _dot(d, d)
def _at(a, b, t):
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
def _on(p, w):
d = _sub(w["b"], w["a"])
t = _fraction(p, w["a"], w["b"])
return abs(_cross(_sub(p, w["a"]), d)) <= EPS * math.hypot(*d) and -EPS <= t <= 1 + EPS
def _ref(w):
return f'{w["kind"]}:{w["id"]}'
def _walls(space):
return [dict(w, kind=kind) for collection, kind in [("wall_segments", "wall"), ("partitions", "partition")]
for w in space.get(collection, []) if _length(w["a"], w["b"]) > EPS]
def _intersection(a, b):
da, db = _sub(a["b"], a["a"]), _sub(b["b"], b["a"])
den = _cross(da, db)
if abs(den) <= EPS * math.hypot(*da) * math.hypot(*db):
return None
t, u = _cross(_sub(b["a"], a["a"]), db) / den, _cross(_sub(b["a"], a["a"]), da) / den
return _at(a["a"], a["b"], t) if -EPS <= t <= 1 + EPS and -EPS <= u <= 1 + EPS else None
def _nodes(walls):
points = [p for w in walls for p in [w["a"], w["b"]]]
for i, a in enumerate(walls):
for b in walls[i + 1:]:
p = _intersection(a, b)
if p is not None:
points.append(p)
unique = {}
for p in points:
unique[(round(p[0], 9), round(p[1], 9))] = p
return list(unique.values())
def _classify(walls, p):
incident = sorted((w for w in walls if _on(p, w)), key=_ref)
axes = []
for w in incident:
d = _unit(_sub(w["b"], w["a"]))
if d[0] < -EPS or (abs(d[0]) <= EPS and d[1] < 0):
d = [-d[0], -d[1]]
axis = next((a for a in axes if _parallel(a["direction"], d)), None)
if axis is None:
axis = {"direction": d, "walls": [], "rays": []}
axes.append(axis)
axis["walls"].append(w)
for end in [w["a"], w["b"]]:
if not _same(end, p):
ray = _unit(_sub(end, p))
if not any(_parallel(r, ray) and _dot(r, ray) > 0 for r in axis["rays"]):
axis["rays"].append(ray)
for a in axes:
a["walls"].sort(key=_ref)
a["key"] = _ref(a["walls"][0])
# An axis cannot borrow an opposite ray from a different axis.
a["passing"] = any(_dot(r, a["direction"]) > 0 for r in a["rays"]) \
and any(_dot(r, a["direction"]) < 0 for r in a["rays"])
valence = sum(len(a["rays"]) for a in axes)
passing = sum(a["passing"] for a in axes)
branches = valence - 2 * passing
if not (valence <= 6 and ((passing == 0 and branches >= 1)
or (passing == 1 and branches <= 1) or (passing == 2 and branches == 0))):
raise NodeMoveError("unsupported_junction")
return incident, axes, passing
def _point(value):
if not isinstance(value, list) or len(value) != 2 \
or any(isinstance(v, bool) or not isinstance(v, (int, float))
or not math.isfinite(v) or abs(v) > 5000 for v in value):
raise NodeMoveError()
return value
def move_node_space(source: dict[str, Any], intent: dict[str, Any]) -> dict[str, Any]:
"""Apply only the proven incident edit. Foreign records are copied intact."""
if set(intent) != {"point", "target", "axis", "split_ids"}:
raise NodeMoveError()
p, target = _point(intent["point"]), _point(intent["target"])
if intent["axis"] is not None and not isinstance(intent["axis"], str):
raise NodeMoveError()
split_ids = intent["split_ids"]
if not isinstance(split_ids, dict) or len(split_ids) > 2 \
or any(not isinstance(k, str) or not isinstance(v, str) or not v or len(v) > 256
for k, v in split_ids.items()):
raise NodeMoveError()
walls = _walls(source)
nodes = _nodes(walls)
if not any(_same(p, n) for n in nodes):
raise NodeMoveError()
incident, axes, passing = _classify(walls, p)
if _same(p, target):
return copy.deepcopy(source)
on_grid = all(abs(v / GRID - math.floor(v / GRID + 0.5)) <= 0.0001 for v in target)
constrained = any(abs(_cross(_sub(target, p), a["direction"])) <= EPS for a in axes) \
or any(not _same(end, p) and (abs(end[0] - target[0]) <= EPS or abs(end[1] - target[1]) <= EPS)
for w in incident for end in [w["a"], w["b"]])
if not on_grid and not constrained:
raise NodeMoveError()
carrier = next((a for a in axes if a["passing"] and a["key"] == intent["axis"]), None) if passing else None
if passing and carrier is None:
raise NodeMoveError()
if carrier:
d = carrier["direction"]
if abs(_cross(_sub(target, p), d)) > EPS:
raise NodeMoveError()
positions = [_dot(_sub(end, p), d) for w in carrier["walls"] for end in [w["a"], w["b"]] if not _same(end, p)]
wanted = _dot(_sub(target, p), d)
lo, hi = max(v for v in positions if v < -EPS), min(v for v in positions if v > EPS)
if not lo + EPS < wanted < hi - EPS:
raise NodeMoveError()
for other in nodes:
if not _same(p, other) and abs(_cross(_sub(other, p), d)) <= EPS:
t = _dot(_sub(other, p), d)
if abs(t) > EPS and t * wanted > 0 and abs(t) <= abs(wanted) + EPS:
raise NodeMoveError()
if carrier:
swept_lo, swept_hi = min(0, wanted), max(0, wanted)
for opening in source.get("openings", []):
host = opening.get("host")
wall = next((w for w in carrier["walls"] if host and _ref(w) == f'{host["kind"]}:{host["id"]}'), None)
if wall is None:
continue
center = _dot(_sub(_at(wall["a"], wall["b"], host["t"]), p), d)
margin = opening["length"] / 2 + wall["cm"] / (source.get("cell_cm") or 5) * GRID / 2
if center + margin >= swept_lo - EPS and center - margin <= swept_hi + EPS:
raise NodeMoveError("opening_blocked")
out = copy.deepcopy(source)
replacements = {}
used = {item.get("id") for collection in ("rooms", "openings", "decor", "stairs", "wall_columns", "wall_segments", "partitions")
for item in source.get(collection, [])}
for old in incident:
ref = _ref(old)
stays = carrier and any(_ref(w) == ref for w in carrier["walls"])
a_moves, b_moves = _same(old["a"], p), _same(old["b"], p)
if not a_moves and not b_moves and stays:
replacements[ref] = [old]
elif a_moves or b_moves:
replacements[ref] = [dict(old, a=target[:] if a_moves else old["a"], b=target[:] if b_moves else old["b"])]
else:
new_id = split_ids.get(ref)
if new_id is None or new_id in used:
raise NodeMoveError()
used.add(new_id)
first_keeps = _fraction(p, old["a"], old["b"]) >= 0.5 - EPS
replacements[ref] = [dict(old, id=old["id"] if first_keeps else new_id, b=target[:]),
dict(old, id=new_id if first_keeps else old["id"], a=target[:])]
if any(_length(w["a"], w["b"]) <= EPS for children in replacements.values() for w in children):
raise NodeMoveError()
for collection, kind in [("wall_segments", "wall"), ("partitions", "partition")]:
if collection in out:
out[collection] = [{**w, "id": child["id"], "a": child["a"][:], "b": child["b"][:]}
for w in out[collection] for child in replacements.get(f'{kind}:{w["id"]}', [w])]
incident_by_ref = {_ref(w): w for w in incident}
for room in out.get("rooms", []):
if not room.get("poly") or len(room.get("wall_ids", [])) != len(room["poly"]):
raise NodeMoveError()
poly, ids = [], []
for i, old_point in enumerate(room["poly"]):
old_id = room["wall_ids"][i]
ref = f"wall:{old_id}"
children = replacements.get(ref)
if children is None:
poly.append(old_point)
ids.append(old_id)
continue
forward = _same(old_point, incident_by_ref[ref]["a"])
for child in children if forward else reversed(children):
poly.append(child["a"][:] if forward else child["b"][:])
ids.append(child["id"])
room["poly"], room["wall_ids"] = poly, ids
old_by_ref = {_ref(w): w for w in walls}
after = _walls(out)
changed_ids = {_ref(w) for children in replacements.values() for w in children}
parent_refs = {_ref(w): parent for parent, children in replacements.items() for w in children}
checked = set()
for a in after:
if _ref(a) not in changed_ids:
continue
for b in after:
pair = tuple(sorted([_ref(a), _ref(b)]))
if a is b or pair in checked:
continue
checked.add(pair)
old_a, old_b = old_by_ref.get(_ref(a)), old_by_ref.get(_ref(b))
hit = _intersection(a, b)
if hit is not None and _same(hit, target) and (
parent_refs.get(_ref(a), _ref(a)) not in incident_by_ref
or parent_refs.get(_ref(b), _ref(b)) not in incident_by_ref):
raise NodeMoveError()
if hit is not None and not _same(hit, target):
previous = _intersection(old_a, old_b) if old_a and old_b else None
if previous is None or not _same(hit, previous):
raise NodeMoveError()
if hit is None and abs(_cross(_sub(a["b"], a["a"]), _sub(b["b"], b["a"]))) <= EPS:
if any(_same(end, target) and _on(end, b) for end in [a["a"], a["b"]]) and (
parent_refs.get(_ref(a), _ref(a)) not in incident_by_ref
or parent_refs.get(_ref(b), _ref(b)) not in incident_by_ref):
raise NodeMoveError()
for end in [a["a"], a["b"]]:
if _on(end, b) and not _same(end, target) and (not old_a or not old_b
or not any(_same(q, end) and _on(q, old_b) for q in [old_a["a"], old_a["b"]])):
raise NodeMoveError()
for opening in out.get("openings", []):
host = opening.get("host")
if not host:
if any(_on([opening["x"], opening["y"]], w) for w in incident):
raise NodeMoveError("opening_blocked")
continue
ref = f'{host["kind"]}:{host["id"]}'
children = replacements.get(ref)
if children is None:
continue
old = incident_by_ref[ref]
if len(children) == 1 and _same(children[0]["a"], old["a"]) and _same(children[0]["b"], old["b"]):
continue
old_span, half, t = _length(old["a"], old["b"]), opening["length"] / 2, host["t"]
along, split_along = t * old_span, _fraction(p, old["a"], old["b"]) * old_span
child = children[0]
if len(children) == 2:
if along - half < split_along + EPS and along + half > split_along - EPS:
raise NodeMoveError("opening_blocked")
first = along + half <= split_along
child = children[0 if first else 1]
span = _length(child["a"], child["b"])
t = along / span if first else 1 - (old_span - along) / span
elif carrier and any(_ref(w) == ref for w in carrier["walls"]):
t = _fraction([opening["x"], opening["y"]], child["a"], child["b"])
else:
span = _length(child["a"], child["b"])
if span <= EPS:
raise NodeMoveError()
t = 1 - (1 - host["t"]) * old_span / span if _same(old["a"], p) else host["t"] * old_span / span
span = _length(child["a"], child["b"])
jamb = child["cm"] / (source.get("cell_cm") or 5) * GRID / 2
if span <= EPS or t * span - half < jamb - EPS or t * span + half > span - jamb + EPS:
raise NodeMoveError("opening_blocked")
opening["x"], opening["y"] = _at(child["a"], child["b"], t)
opening["host"] = {**host, "id": child["id"], "t": t}
angle = math.degrees(math.atan2(child["b"][1] - child["a"][1], child["b"][0] - child["a"][0]))
opening["angle"] = angle - 180 if angle >= 90 else angle + 180 if angle < -90 else angle
if "walls" in source:
out["walls"] = [{"key": _wall_key(w["a"], w["b"]), "cm": w["cm"], "a": w["a"][:], "b": w["b"][:]}
for w in out.get("wall_segments", []) if w["cm"] > 0]
return out
def node_move_host_baseline(current: dict[str, Any], candidate: dict[str, Any], space_id: str,
intent: dict[str, Any], direction: str = "apply",
before_space: dict[str, Any] | None = None) -> dict[str, Any]:
"""Independent identity ledger; candidate host IDs never authorize themselves.
Only an opening wholly on a transverse whole-X child may change its host ID.
Derive that child from ORIGINAL interval/midpoint lineage and frozen carrier,
not from move_node_space's replacements or returned opening. For Undo the
first proof establishes before_space; this ledger checks the inverse IDs.
Returning an identity-adjusted baseline leaves strict jamb validation intact.
"""
# The durable legacy record may not yet contain a structural catalogue.
# Normalize a COPY with the same read-compatible identity model as the first
# proof; raw legacy absence is not evidence of a missing node or new host.
current, _ = commit_wall_segment_model(current)
old_spaces = {s["id"]: s for s in current.get("spaces", [])}
new_spaces = {s["id"]: s for s in candidate.get("spaces", [])}
if old_spaces.keys() != new_spaces.keys() or direction not in {"apply", "undo"}:
raise NodeMoveError()
source = before_space if direction == "undo" else old_spaces.get(space_id)
if source is None or source.get("id") != space_id:
raise NodeMoveError()
p = intent["point"]
incident, axes, _ = _classify(_walls(source), p)
carrier = next((a for a in axes if a["passing"] and a["key"] == intent["axis"]), None)
carrier_refs = {_ref(w) for w in carrier["walls"]} if carrier else set()
transverse = {_ref(w): w for w in incident if _ref(w) not in carrier_refs
and not _same(w["a"], p) and not _same(w["b"], p)}
baseline = copy.deepcopy(current)
def identity(opening):
host = opening.get("host")
return (host["kind"], host["id"]) if host else None
for old_space, adjusted in zip(current.get("spaces", []), baseline.get("spaces", []), strict=True):
sid = old_space["id"]
original = source if sid == space_id else old_space
old = {o["id"]: o for o in old_space.get("openings", [])}
new = {o["id"]: o for o in new_spaces[sid].get("openings", [])}
originals = {o["id"]: o for o in original.get("openings", [])}
if old.keys() != new.keys() or old.keys() != originals.keys():
raise NodeMoveError()
adjusted_openings = {o["id"]: o for o in adjusted.get("openings", [])}
for oid, opening in originals.items():
initial = identity(opening)
forward = initial
wall = transverse.get(f'{initial[0]}:{initial[1]}') if initial and sid == space_id else None
if wall and not _same(p, intent["target"]):
span = _length(wall["a"], wall["b"])
split = _fraction(p, wall["a"], wall["b"])
along, half = opening["host"]["t"] * span, opening["length"] / 2
if along - half < split * span + EPS and along + half > split * span - EPS:
raise NodeMoveError("opening_blocked")
first = along + half <= split * span
keeps_parent = first == (split >= 0.5 - EPS)
child_id = wall["id"] if keeps_parent else intent["split_ids"].get(_ref(wall))
if not child_id:
raise NodeMoveError()
forward = (initial[0], child_id)
expected_old, expected_new = (forward, initial) if direction == "undo" else (initial, forward)
if identity(old[oid]) != expected_old or identity(new[oid]) != expected_new:
raise NodeMoveError()
if expected_new:
adjusted_openings[oid]["host"]["id"] = expected_new[1]
return baseline
def node_move_candidate(current: dict[str, Any], space_id: str, intent: dict[str, Any],
direction: str = "apply", before_space: dict[str, Any] | None = None) -> dict[str, Any]:
"""Independently prove the forward delta; inverse has no broader authority."""
baseline, _ = commit_wall_segment_model(current)
space = next((s for s in baseline.get("spaces", []) if s["id"] == space_id), None)
if space is None or direction not in {"apply", "undo"}:
raise NodeMoveError()
if direction == "undo":
if before_space is None or before_space.get("id") != space_id:
raise NodeMoveError()
proposed = copy.deepcopy(baseline)
proposed["spaces"] = [before_space if s["id"] == space_id else s for s in proposed["spaces"]]
# Proof has to reach exactly the current canonical document, including
# every foreign field. A forged baseline cannot move a distal endpoint,
# rehost another opening, alter metadata or change another floor.
forward = move_node_space(before_space, intent)
proof = copy.deepcopy(baseline)
proof["spaces"] = [forward if s["id"] == space_id else s for s in proof["spaces"]]
proof, _ = commit_wall_segment_model(proof)
if canonicalize_config_geometry(proof) != canonicalize_config_geometry(baseline):
raise NodeMoveError()
candidate, _ = commit_wall_segment_model(proposed)
return candidate
moved = move_node_space(space, intent)
baseline["spaces"] = [moved if s["id"] == space_id else s for s in baseline["spaces"]]
candidate, _ = commit_wall_segment_model(baseline)
return candidate
+266 -35
View File
@@ -137,6 +137,7 @@ from .virtual_lights import (
async_toggle_virtual_light,
async_virtual_light_snapshot,
)
from .wall_node_move import NodeMoveError, node_move_candidate, node_move_host_baseline
from .wall_segment_model import (
WALL_SEGMENT_MODEL_VERSION,
WallSegmentMigrationError,
@@ -258,6 +259,7 @@ async def _resolved_write_pair(
def async_register(hass: HomeAssistant) -> None:
"""Register the WS commands."""
from .radar_websocket import async_register as async_register_radar
from .zigbee_websocket import async_register as async_register_zigbee
websocket_api.async_register_command(hass, ws_layout_get)
websocket_api.async_register_command(hass, ws_trail_get)
@@ -269,6 +271,7 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_virtual_light_toggle)
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_wall_node_move)
websocket_api.async_register_command(hass, ws_plan_optimize)
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
websocket_api.async_register_command(hass, ws_space_delete)
@@ -288,6 +291,7 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_support_preview_discard)
websocket_api.async_register_command(hass, ws_support_submit)
async_register_radar(hass)
async_register_zigbee(hass)
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
@@ -1331,49 +1335,59 @@ async def ws_files_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any])
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
from .const import FILES_DIR
from .plans import attachment_refs
from .validation import sanitize_marker_id
mid = sanitize_marker_id(msg["marker_id"])
base = Path(hass.config.path(FILES_DIR)).resolve()
target = (base / mid).resolve() if mid else base
if not mid or not str(target).startswith(str(base)) or target == base:
connection.send_result(msg["id"], {"ok": True, "removed": 0, "kept": 0})
return
files_dir = Path(hass.config.path(FILES_DIR))
async with rt.write_lock:
stored = await rt.config_store.async_load() or {}
refs = attachment_refs(stored.get("config") or {})
removed, kept = await hass.async_add_executor_job(
_drop_unreferenced_marker_files, files_dir, msg["marker_id"], refs,
)
connection.send_result(msg["id"], {"ok": True, "removed": removed, "kept": kept})
def _rm() -> tuple[int, int]:
if not target.is_dir():
return 0, 0
removed = kept = 0
for item in sorted(target.iterdir()):
if not item.is_file():
continue
if f"{mid}/{item.name}" in refs:
kept += 1
continue
try:
item.unlink()
removed += 1
except OSError as err:
_LOGGER.warning("House Plan: could not remove %s: %s", item, err)
if not kept:
try:
target.rmdir()
except OSError:
pass
return removed, kept
removed, kept = await hass.async_add_executor_job(_rm)
if kept:
def _drop_unreferenced_marker_files(
files_dir: Path, marker_id: str, refs: set[str],
) -> tuple[int, int]:
"""Remove the files of ``<files>/<marker>`` the stored config does not name.
The one rule behind ``houseplan/files/cleanup`` and behind the markers a
space delete takes with it (#819). Runs in the executor; returns
``(removed, kept)``. An id that does not name a folder inside the files
directory touches nothing.
"""
from .validation import sanitize_marker_id
mid = sanitize_marker_id(marker_id)
base = files_dir.resolve()
target = (base / mid).resolve() if mid else base
if not mid or not str(target).startswith(str(base)) or target == base:
return 0, 0
if not target.is_dir():
return 0, 0
removed = kept = 0
for item in sorted(target.iterdir()):
if not item.is_file():
continue
if f"{mid}/{item.name}" in refs:
kept += 1
continue
try:
item.unlink()
removed += 1
except OSError as err:
_LOGGER.warning("House Plan: could not remove %s: %s", item, err)
if not kept:
try:
target.rmdir()
except OSError:
pass
else:
_LOGGER.info(
"House Plan: kept %s file(s) in %s — the configuration still references them", kept, mid
)
connection.send_result(msg["id"], {"ok": True, "removed": removed, "kept": kept})
return removed, kept
@websocket_api.websocket_command(
@@ -1478,9 +1492,12 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
"integration_version": VERSION,
# #423: protocol capability is independent from release skew.
"support_api": SUPPORT_API_VERSION,
"wall_node_move_api": 1,
"decor_assets_api": DECOR_ASSETS_API_VERSION,
"summary_panel_api": SUMMARY_PANEL_API_VERSION,
**({"radar_stage1_api": 1} if rt.radar_coordinator is not None else {}),
**({"zigbee_scan_api": 1} if rt.zigbee_coordinator is not None
and not rt.zigbee_coordinator.closed else {}),
},
)
@@ -1804,6 +1821,81 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
connection.send_result(msg["id"], result)
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/wall/node_move",
vol.Required("space_id"): str,
vol.Required("expected_rev"): int,
vol.Required("intent"): dict,
vol.Optional("direction", default="apply"): vol.In(["apply", "undo"]),
vol.Optional("before_space"): dict,
}
)
@websocket_api.async_response
async def ws_wall_node_move(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Narrow local node edit and its proved inverse; ordinary set stays strict."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Not allowed to edit the plan")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
if len(json.dumps(msg, separators=(",", ":"))) > MAX_CONFIG_BYTES:
connection.send_error(msg["id"], "too_large", "Node operation exceeds the configuration limit")
return
async with rt.write_lock:
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
if resolved is None:
return
data = resolved.config_data
rev = int(data.get("rev", 0))
if msg["expected_rev"] != rev:
connection.send_error(msg["id"], "conflict", "Plan revision changed; reload before moving a node")
return
previous = data.get("config", {})
def _validate_node_cpu():
candidate = node_move_candidate(previous, msg["space_id"], msg["intent"],
msg["direction"], msg.get("before_space"))
validate_wall_model_transition(candidate, previous)
candidate = CONFIG_SCHEMA(candidate)
validate_opening_passages(candidate, previous)
# A separate original-geometry ledger proves the allowed host IDs;
# it never copies authority from the candidate. Then strict jambs.
proved_baseline = node_move_host_baseline(previous, candidate, msg["space_id"],
msg["intent"], msg["direction"], msg.get("before_space"))
validate_partition_opening_hosts(candidate, proved_baseline)
counts = validate_junction_limits(candidate, previous)
return candidate, counts
try:
candidate, counts = await hass.async_add_executor_job(_validate_node_cpu)
except (NodeMoveError, JunctionLimitError, WallModelClientOutdatedError,
OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError) as err:
connection.send_error(msg["id"], err.code, str(err))
return
except (vol.Invalid, WallSegmentMigrationError, ValueError, KeyError,
TypeError, ZeroDivisionError) as err:
connection.send_error(msg["id"], NodeMoveError().code, str(err))
return
if candidate == previous:
connection.send_result(msg["id"], {"ok": True, "rev": rev, "config": candidate})
return
if len(json.dumps(candidate, separators=(",", ":"))) > MAX_CONFIG_BYTES:
connection.send_error(msg["id"], "too_large", "Result exceeds the configuration limit")
return
new_rev = rev + 1
await async_save_config_state(rt, candidate, new_rev, previous_rev=rev)
rt.junction_baseline = (new_rev, counts)
try:
await _discard_optimizer_snapshot(rt)
except Exception: # noqa: BLE001 - durable write already accepted
_LOGGER.exception("House Plan: discarding stale optimization backup failed")
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev, "config": candidate})
# ---------------- whole-plan maintenance ----------------
@@ -1865,6 +1957,16 @@ def _space_delete_candidate(
if isinstance(stair, dict) and stair.get("target_space_id") == space_id:
stair["target_space_id"] = None
for marker in candidate_config.get("markers") or []:
# #162/#822: fulfil the confirmation in the same authoritative pair.
vacuum = marker.get("vacuum")
routes = vacuum.get("map_routes") if isinstance(vacuum, dict) else None
if isinstance(routes, list):
kept_routes = [
route for route in routes
if not isinstance(route, dict) or route.get("space") != space_id
]
if len(kept_routes) != len(routes):
vacuum["map_routes"] = kept_routes
marker_id = str(marker.get("id")) if marker.get("id") is not None else None
marker_position = candidate_layout.get(marker_id) if marker_id is not None else None
references_deleted_space = (
@@ -1898,12 +2000,133 @@ def _space_delete_candidate(
return candidate_config, candidate_layout, dependencies, removed_layout
def _delete_plan_markers(
config: dict[str, Any], layout: dict[str, Any], marker_ids: list[str],
) -> list[str]:
"""Delete markers the way the device dialog's «Delete» does (#819).
Mirror of the card's ``deletePlanMarkerRecords`` and the cleanup around
it (``src/space-deletion.ts`` ``deletePlanMarkers``; the shared fixture
``test/fixtures/space-delete-with-markers.json`` pins both): a real binding
drops every marker of that binding and leaves a hidden tombstone of the
exact binding, a virtual marker simply goes; links to the dropped ids in
other markers' ``controls``, LED strips and area snapshots go with them,
and so do their layout positions. Mutates the given (already copied)
pair; returns the dropped ids, sorted.
"""
dropped: set[str] = set()
for marker_id in sorted(marker_ids):
markers = config.get("markers") or []
target = next(
(marker for marker in markers
if marker.get("id") == marker_id and marker.get("removed") is not True),
None,
)
if target is None:
continue
binding = target.get("binding")
virtual = binding == "virtual"
kept = []
for marker in markers:
if marker.get("id") == marker_id or (not virtual and marker.get("binding") == binding):
dropped.add(str(marker.get("id")))
else:
kept.append(marker)
if not virtual:
kept.append({"id": marker_id, "binding": binding, "removed": True, "hidden": True})
config["markers"] = kept
if not dropped:
return []
for marker in config.get("markers") or []:
controls = marker.get("controls")
if not isinstance(controls, list):
continue
links = [
ref for ref in controls
if not (isinstance(ref, str) and ref.startswith("marker:")
and ref[len("marker:"):] in dropped)
]
if len(links) != len(controls):
marker["controls"] = links or None
for space in config.get("spaces") or []:
for strip in space.get("led_strips") or []:
if isinstance(strip, dict) and strip.get("marker") in dropped:
strip["marker"] = None
strip["active"] = True
snapshot = (config.get("settings") or {}).get("marker_area_snapshot")
if isinstance(snapshot, dict):
for marker_id in dropped:
snapshot.pop(marker_id, None)
for marker_id in dropped:
layout.pop(marker_id, None)
return sorted(dropped)
def _space_delete_target(
config: dict[str, Any], layout: dict[str, Any], space_id: str,
*, remove_markers: bool = False,
) -> tuple[dict[str, Any], dict[str, Any], list[str], int, list[str]]:
"""The exact pair ``space/delete`` commits, and the markers it deleted.
Without ``remove_markers`` this is the #244 candidate unchanged. With it
(#819) every active marker that blocks the delete first goes the way
``_delete_plan_markers`` sends it, so the space is no longer in use. The
last space keeps its own rule: its markers are detached, never deleted.
"""
source_config, source_layout = config, layout
removed_markers: list[str] = []
spaces = config.get("spaces") or []
if remove_markers and not (len(spaces) == 1 and spaces[0].get("id") == space_id):
source_config = json.loads(json.dumps(config))
source_layout = json.loads(json.dumps(layout))
removed_markers = _delete_plan_markers(
source_config, source_layout,
_space_marker_dependencies(source_config, source_layout, space_id),
)
target_config, target_layout, dependencies, removed_layout = _space_delete_candidate(
source_config, source_layout, space_id,
)
removed_layout += len(layout) - len(source_layout)
return target_config, target_layout, dependencies, removed_layout, removed_markers
async def _forget_deleted_markers(
hass: HomeAssistant, config: dict[str, Any], marker_ids: list[str],
) -> None:
"""Attachments and robot trails of markers a space delete took (#819).
The same clean-up the card runs after the device dialog's «Delete»
(``files/cleanup`` and ``trail/delete`` per marker). It follows an already
durable commit, so it is best-effort: a failure is logged and never turns
the accepted delete into an error the client would retry.
"""
from .plans import attachment_refs
refs = attachment_refs(config)
files_dir = Path(hass.config.path(FILES_DIR))
def _drop_files() -> None:
for marker_id in marker_ids:
_drop_unreferenced_marker_files(files_dir, marker_id, refs)
try:
await hass.async_add_executor_job(_drop_files)
except Exception: # noqa: BLE001 - the delete is already durable
_LOGGER.exception("House Plan: removing attachments of deleted markers failed")
try:
await _purge_trail_recorder(hass, config)
except Exception: # noqa: BLE001 - the delete is already durable
_LOGGER.exception("House Plan: removing trails of deleted markers failed")
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/space/delete",
vol.Required("space_id"): str,
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
# #819: optional, so an older card keeps the `space_in_use` answer.
vol.Optional("remove_markers", default=False): bool,
}
)
@websocket_api.async_response
@@ -1940,8 +2163,11 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
):
connection.send_error(msg["id"], "space_not_found", "Space no longer exists")
return
target_config, target_layout, dependencies, removed_layout = (
_space_delete_candidate(current_config, current_layout, space_id)
(
target_config, target_layout, dependencies, removed_layout, removed_markers,
) = _space_delete_target(
current_config, current_layout, space_id,
remove_markers=msg["remove_markers"],
)
spaces = current_config.get("spaces") or []
deleting_last_space = (
@@ -1980,6 +2206,10 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
"final_metadata": original_metadata,
}
await _commit_pair(rt, pending, rollback)
if removed_markers:
# Under the lock, like config/set: a later write must not
# resurrect a marker between this commit and its clean-up.
await _forget_deleted_markers(hass, pending["config"], removed_markers)
except PairCommitFailure as err:
message = (
"Space delete failed; the previous plan is pending recovery"
@@ -2010,6 +2240,7 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"removed_layout": removed_layout,
"removed_markers": removed_markers,
})
@@ -0,0 +1,441 @@
"""Bounded, entry-owned Zigbee2MQTT requests; never a polling radio scanner."""
from __future__ import annotations
import asyncio
import json
import logging
import math
import re
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from time import monotonic, time
from typing import Any
from uuid import uuid4
from homeassistant.components import mqtt
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
MAX_TOPICS = 8
MAX_LISTENERS = 32
MAX_PAYLOAD_BYTES = 2 * 1024 * 1024
MAX_NODES = 1000
MAX_LINKS = 6000
CANCEL_AFTER_MS = 600_000
TRANSPORT_TIMEOUT = 10.0
INFO_TIMEOUT = 4.0
_LOGGER = logging.getLogger(__name__)
class ZigbeeScanError(Exception):
"""A stable, already localized WebSocket command refusal."""
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
def normalize_base_topic(value: object) -> str:
"""Match the frontend's exact-topic normalization, without a saved-topic gate."""
if not isinstance(value, str):
raise ZigbeeScanError("invalid_data")
topic = re.sub(r"/{2,}", "/", value.strip().strip("/"))
# JS counts UTF-16 code units; reject lone surrogates as invalid MQTT UTF-8.
try:
length = len(topic.encode("utf-16-le")) // 2
topic.encode("utf-8")
except UnicodeError as err:
raise ZigbeeScanError("invalid_data") from err
if not topic or length > 180 or re.search(r"[#+\x00-\x1f\x7f]", topic):
raise ZigbeeScanError("invalid_data")
return topic
def _parse_payload(payload: object) -> dict[str, Any] | None:
"""Bound bytes before decoding; uncorrelatable garbage must not end a job."""
try:
if isinstance(payload, str):
payload = payload.encode("utf-8")
if not isinstance(payload, bytes) or len(payload) > MAX_PAYLOAD_BYTES:
return None
value = json.loads(payload)
if not isinstance(value, dict):
return None
pending = [(value, 0)]
inspected = 0
while pending:
item, depth = pending.pop()
inspected += 1
if depth > 32 or inspected > 100_000:
return None
if isinstance(item, dict):
pending.extend((child, depth + 1) for child in item.values())
elif isinstance(item, list):
pending.extend((child, depth + 1) for child in item)
elif isinstance(item, float) and not math.isfinite(item):
return None
return value
except (ValueError, UnicodeError, RecursionError):
return None
def _valid_map(message: dict[str, Any]) -> bool:
"""Check the raw-map shape, not routing evidence (that remains TypeScript)."""
data = message.get("data")
value = data.get("value", data) if isinstance(data, dict) else data
if value is None:
value = message.get("value", message)
if isinstance(value, str):
value = _parse_payload(value)
if not isinstance(value, dict):
return False
nodes, links = value.get("nodes"), value.get("links")
if not isinstance(nodes, list) or len(nodes) > MAX_NODES:
return False
if not isinstance(links, list) or len(links) > MAX_LINKS:
return False
for node in nodes:
if not isinstance(node, dict):
return False
ieee = node.get("ieeeAddr", node.get("ieee_address", node.get("ieee")))
if not isinstance(ieee, str) or not re.fullmatch(
r"(?:0x)?[0-9a-fA-F]{16}|(?:[0-9a-fA-F]{2}[:-]){7}[0-9a-fA-F]{2}", ieee,
):
return False
routes_count = 0
for link in links:
if not isinstance(link, dict):
return False
for side in ("source", "target"):
endpoint = link.get(f"{side}IeeeAddr", link.get(side))
if isinstance(endpoint, bool) or not isinstance(endpoint, (dict, str, int)):
return False
if not endpoint and endpoint != 0:
return False
routes = link.get("routes", [])
if not isinstance(routes, list) or any(not isinstance(route, dict) for route in routes):
return False
routes_count += len(routes)
if routes_count > MAX_LINKS:
return False
return True
@dataclass
class _Job:
topic: str
job_id: str
started_at: int
started: float
info: asyncio.Future[None]
phase: str = "loading"
stage: str = "connecting"
finished: float | None = None
error: str | None = None
stale: bool = False
result: dict[str, Any] | None = None
obtained_at: int | None = None
response_active: bool = False
task: asyncio.Task[None] | None = None
cleanups: list[Callable[[], None]] = field(default_factory=list)
class ZigbeeScanCoordinator:
"""One in-memory slot per topic, shared by all browsers of the HA entry."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass
self.entry = entry
self.session_id = str(uuid4())
self.revision = 0
self.closed = False
self._jobs: dict[str, _Job] = {}
self._listeners: set[Callable[[dict[str, Any]], None]] = set()
self._operations: set[asyncio.Task] = set()
def _active(self, job: _Job) -> bool:
return not self.closed and self._jobs.get(job.topic) is job and job.phase == "loading"
def _state(self, job: _Job) -> dict[str, Any]:
provider: dict[str, Any] = {
"topic": job.topic, "job_id": job.job_id, "phase": job.phase,
"stage": job.stage, "started_at": job.started_at,
"elapsed_ms": max(0, int(((job.finished if job.finished is not None else monotonic()) - job.started) * 1000)),
"cancel_after_ms": CANCEL_AFTER_MS,
}
if job.error is not None:
provider["error"] = job.error
if job.result is not None:
provider["result"] = job.result
provider["obtained_at"] = job.obtained_at
provider["stale"] = job.stale
return {"kind": "state", "session_id": self.session_id,
"revision": self.revision, "provider": provider}
def snapshot(self, topic: str) -> dict[str, Any]:
"""Current public state; no MQTT work, no timestamp reset."""
if self.closed:
raise ZigbeeScanError("not_ready")
job = self._jobs.get(normalize_base_topic(topic))
if job is None:
raise ZigbeeScanError("invalid_data")
return self._state(job)
def initial_events(self) -> list[dict[str, Any]]:
"""Bound each event to one map; never aggregate eight maps into one frame."""
return [{"kind": "reset", "session_id": self.session_id,
"revision": self.revision, "topics": list(self._jobs)},
*(self._state(job) for job in self._jobs.values())]
def add_listener(self, listener: Callable[[dict[str, Any]], None]) -> Callable[[], None]:
if self.closed:
raise ZigbeeScanError("not_ready")
if len(self._listeners) >= MAX_LISTENERS:
raise ZigbeeScanError("capacity_exceeded")
self._listeners.add(listener)
return lambda: self._listeners.discard(listener)
def _emit(self, event: dict[str, Any]) -> None:
for listener in tuple(self._listeners):
try:
listener(event)
except Exception: # noqa: BLE001 - one disconnected UI must not interrupt a shared job
self._listeners.discard(listener)
def _changed(self, job: _Job) -> None:
self.revision += 1
self._emit(self._state(job))
def start(self, base_topic: str) -> dict[str, Any]:
"""Reserve before creating any task: concurrent WS clients join one job."""
if self.closed:
raise ZigbeeScanError("not_ready")
topic = normalize_base_topic(base_topic)
previous = self._jobs.get(topic)
if previous is not None and previous.phase == "loading":
return self._state(previous)
# A transport which acknowledges cancellation late still owns a bounded
# slot until its cleanup returns; repeated failed starts cannot grow it.
if len(self._operations) >= MAX_TOPICS:
raise ZigbeeScanError("capacity_exceeded")
if previous is None and len(self._jobs) >= MAX_TOPICS:
candidate = next((item for item in self._jobs.values() if item.phase != "loading"), None)
if candidate is None:
raise ZigbeeScanError("capacity_exceeded")
del self._jobs[candidate.topic]
self.revision += 1
self._emit({"kind": "removed", "session_id": self.session_id,
"revision": self.revision, "topic": candidate.topic})
job = _Job(topic, f"houseplan-{uuid4()}", int(time() * 1000), monotonic(),
self.hass.loop.create_future(),
stale=previous.stale if previous else False,
result=previous.result if previous else None,
obtained_at=previous.obtained_at if previous else None)
self._jobs.pop(topic, None)
self._jobs[topic] = job
job.task = self.entry.async_create_background_task(
self.hass, self._run(job), "House Plan Zigbee map", eager_start=False,
)
self._changed(job)
return self._state(job)
def cancel(self, base_topic: str, job_id: str) -> dict[str, Any]:
if self.closed:
raise ZigbeeScanError("not_ready")
topic = normalize_base_topic(base_topic)
job = self._jobs.get(topic)
if job is None or job.job_id != job_id:
raise ZigbeeScanError("conflict")
# A repeated cancellation or a success/cancel race never rolls back a final state.
if job.phase != "loading":
return self._state(job)
if (monotonic() - job.started) * 1000 < CANCEL_AFTER_MS:
raise ZigbeeScanError("conflict")
self._finish(job, "cancelled")
return self._state(job)
@staticmethod
def _cleanup(cleanup: Callable[[], None]) -> None:
try:
cleanup()
except Exception: # noqa: BLE001 - cleanup remains idempotent across unload and transport failure
_LOGGER.debug("House Plan Zigbee transport cleanup failed")
def _add_cleanup(self, job: _Job, cleanup: Callable[[], None]) -> None:
if self._active(job):
job.cleanups.append(cleanup)
else:
self._cleanup(cleanup)
def _release(self, job: _Job) -> None:
job.response_active = False
cleanups, job.cleanups = job.cleanups, []
for cleanup in cleanups:
self._cleanup(cleanup)
if not job.info.done():
job.info.cancel()
def _finish(self, job: _Job, phase: str, error: str | None = None,
result: dict[str, Any] | None = None) -> None:
if not self._active(job):
return
job.phase, job.error, job.finished = phase, error, monotonic()
if result is not None:
job.result, job.obtained_at = result, int(time() * 1000)
job.stale = False
elif job.result is not None:
job.stale = True
self._release(job)
# Including when called synchronously inside publish: a terminal provider
# reply must not wait another ten seconds for the publish acknowledgement.
if job.task is not None and not job.task.done():
job.task.cancel()
self._changed(job)
async def _operation(self, job: _Job, action: Callable[[], Awaitable],
seconds: float, *, subscription: bool = False):
"""Bound transport even if a cancelled subscribe later returns an unsubscribe."""
async def perform():
result = await action()
if subscription and callable(result):
self._add_cleanup(job, result)
return result
task = self.entry.async_create_background_task(
self.hass, perform(), "House Plan Zigbee transport", eager_start=False,
)
self._operations.add(task)
@callback
def settled(done: asyncio.Task) -> None:
self._operations.discard(done)
if not done.cancelled():
done.exception() # consume a late transport exception after the job ended
task.add_done_callback(settled)
try:
done, _pending = await asyncio.wait({task}, timeout=max(0, seconds))
if not done:
raise TimeoutError
return task.result()
finally:
if not task.done():
task.cancel()
async def _subscribe(self, job: _Job, topic: str, handler: Callable) -> None:
deadline = self.hass.loop.time() + TRANSPORT_TIMEOUT
await self._operation(job, lambda: mqtt.async_subscribe(
self.hass, topic, handler, qos=0, encoding=None,
), TRANSPORT_TIMEOUT, subscription=True)
# Optional on the declared HA floor (2024.6). This acknowledges HA's
# processing of subscriptions, not provider acceptance or radio progress.
processed = getattr(mqtt, "async_on_subscribe_done", None)
if callable(processed) and self._active(job):
ready = self.hass.loop.create_future()
@callback
def on_ready() -> None:
if not ready.done():
ready.set_result(None)
remove = processed(self.hass, topic, 0, on_ready)
self._add_cleanup(job, remove)
async with asyncio.timeout(max(0, deadline - self.hass.loop.time())):
await ready
async def _run(self, job: _Job) -> None:
try:
if not mqtt.mqtt_config_entry_enabled(self.hass):
self._finish(job, "error", "unsupported")
return
if not mqtt.is_connected(self.hass):
self._finish(job, "error", "connection")
return
@callback
def connection_changed(connected: bool) -> None:
if not connected:
self._finish(job, "error", "connection")
self._add_cleanup(job, mqtt.async_subscribe_connection_status(
self.hass, connection_changed,
))
response_topic = f"{job.topic}/bridge/response/networkmap"
info_topic = f"{job.topic}/bridge/info"
@callback
def receive_response(message) -> None:
if (not self._active(job) or not job.response_active
or message.topic != response_topic or message.retain):
return
value = _parse_payload(message.payload)
if value is None:
return
data = value.get("data")
transaction = value.get("transaction", data.get("transaction") if isinstance(data, dict) else None)
if transaction != job.job_id:
return
if value.get("status") not in (None, "ok"):
self._finish(job, "error", "provider")
elif not _valid_map(value):
self._finish(job, "error", "invalid_payload")
else:
self._finish(job, "ready", result=value)
@callback
def receive_info(message) -> None:
if (self._active(job) and message.topic == info_topic and message.retain
and not job.info.done() and _parse_payload(message.payload) is not None):
job.info.set_result(None)
# Response first also improves the old-HA retained-info preflight;
# retained info alone is not claimed to be a strict broker SUBACK.
await self._subscribe(job, response_topic, receive_response)
await self._subscribe(job, info_topic, receive_info)
async with asyncio.timeout(INFO_TIMEOUT):
await job.info
if not self._active(job):
return
job.response_active = True
await self._operation(job, lambda: mqtt.async_publish(
self.hass, f"{job.topic}/bridge/request/networkmap",
json.dumps({"type": "raw", "routes": True, "transaction": job.job_id}),
qos=0, retain=False,
), TRANSPORT_TIMEOUT)
if self._active(job):
job.stage = "waiting"
self._changed(job)
# Only MQTT, explicit cancel, transport loss or entry teardown
# ends this wait. 600 seconds is a cancel threshold, not a deadline.
await self.hass.loop.create_future()
except asyncio.CancelledError:
if self._active(job):
self._finish(job, "error", "connection")
raise
except TimeoutError:
self._finish(job, "error", "timeout")
except Exception: # noqa: BLE001 - contain optional MQTT errors without logging network payloads
self._finish(job, "error", "connection")
finally:
self._release(job)
async def async_close(self) -> None:
"""Stop entry-owned work, clear volatile maps, never restart a radio scan."""
if self.closed:
return
self.closed = True
# Reload does not disconnect HA WebSockets. Tell their observers that
# this session and its volatile cache are gone before dropping them.
self.revision += 1
self._emit({"kind": "closed", "session_id": self.session_id,
"revision": self.revision})
tasks = [job.task for job in self._jobs.values() if job.task is not None]
for job in self._jobs.values():
self._release(job)
for task in (*tasks, *self._operations):
if not task.done():
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._jobs.clear()
self._listeners.clear()

Some files were not shown because too many files have changed in this diff Show More