Compare commits

...
Author SHA1 Message Date
claude[bot] 4b46a62503 docs: review document for #154
Validate / changes (push) Successful in 48s
Validate / provenance (push) Successful in 55s
Validate / smoke (push) Skipped
Validate / process-gate (push) Successful in 54s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Validate / frontend (push) Skipped
Validate / golden (push) Skipped
Issue: #154
User-Visible: no
2026-08-18 19:31:44 +00:00
Sergey Matyunin cd3c7752a8 docs: specify touch hover reset
Issue: #154
User-Visible: no
2026-08-15 03:55:30 +03:00
Sergey Matyunin 9f2c5f5ff4 build: promote v1.64.0 after beta.3
Validate / performance_smoke (push) Failing after 10m18s
Validate / provenance (push) Successful in 57s
Validate / process-gate (push) Failing after 1m6s
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 14m19s
Validate / golden (push) Failing after 12m25s
Validate / backend (push) Failing after 13m5s
Validate / smoke (push) Failing after 29m32s
Validate / changes (push) Successful in 44s
Full Performance / performance (push) Failing after 1h24m44s
Issue: #153
User-Visible: yes
2026-08-14 23:01:44 +03:00
Sergey Matyunin 20d7883699 Release v1.64.0-beta.3 candidate
Issue: #156
User-Visible: yes
2026-08-14 22:31:36 +03:00
Sergey Matyunin 6ebf12af1e docs: self-review performance repair
Issue: #156
User-Visible: no
2026-08-14 22:28:11 +03:00
Sergey Matyunin 09143e23a6 perf: remove v1.64 render regressions
Issue: #156
User-Visible: yes
2026-08-14 22:27:29 +03:00
Sergey Matyunin 0e6cb7570b docs: specify v1.64 performance repairs
Issue: #156
User-Visible: no
2026-08-14 22:19:50 +03:00
Sergey Matyunin 321d153c22 fix: ignore published main commits during dev reconciliation
Issue: #155
User-Visible: no
2026-08-14 21:25:45 +03:00
Sergey Matyunin 7f70b64f48 Merge main into dev for v1.64.0 2026-08-14 21:10:39 +03:00
Sergey Matyunin 6c37cd5f05 build: promote v1.64.0
Issue: #153
User-Visible: yes
2026-08-14 21:01:17 +03:00
Matysh 7642c484d2 feat: analysis proceeds on its own, questions are product-only and spec-stage
The analyst used to ask the owner to confirm every estimate and waited for an
answer on each point. Most issues are unambiguous, and most of that waiting
changed nothing — the owner's own measure is that seven issues in ten should
travel from S1-new to a finished spec without a single question.

Section 2.2 flips the default. Estimates, type, priority and track go on as
labels immediately; the analysis comment is a notification, not a request —
the owner's silence is consent, his disagreement is a label edit, and neither
stops the work. The analyst moves the issue to S3-spec himself. The only
questions that ever reach the owner are product questions, asked at the spec
stage in one batch with defaults and blocked, and only when the spec cannot be
written without the answer; anything that can wait for the spec waits, anything
that does not block it becomes a recorded assumption instead. The one full stop
left in analysis is a genuine SCOPE conflict, where the analyst proposes
rejection and the owner decides.

Issue: #114
User-Visible: no
2026-08-14 20:31:08 +03:00
Matysh ec7408f3d5 docs: the pre-1.62 "no tests, no commits" workflow line is dead
The author agent read STATUS.md, saw the owner's 2026-08-07 rule that ordinary
fixes are made locally without tests or commits, correctly ranked it below
AGENTS.md and PROCESS.md, and followed the canon instead. That is the trust
order doing its job — and the canon's second half says a divergence is not
ignored but fixed.

The line now says what replaced it: since release 1.62 every product change goes
through the process — an issue in S5-ready or later, a task branch, trailers on
every commit, the review pipeline. The release mechanics in the same cell were
still accurate and stay.

Issue: #114
User-Visible: no
2026-08-14 20:17:39 +03:00
Matysh 0f8d35f516 docs: run the AC-named smokes locally before S7-code-review
The owner's machine now carries Playwright with Chromium on Windows and a full
WSL environment — verified by execution: 34/34 smoke assertions, and 242 backend
tests passed where native Windows silently skips every test_ha_* file. A red
smoke that reaches the review costs a cycle of forty-five minutes plus the
return trip; run locally it costs a minute, and #89 already paid that price
once.

WSL runs of the full harness and golden verify are advisory. The canon does not
move: the beta gate is CI at the exact SHA, and baselines are accepted only via
golden:accept --reviewed on a complete Linux CI artefact.

Issue: #151
User-Visible: no
2026-08-14 19:50:47 +03:00
Sergey Matyunin 295257240d test: accept v1.64.0-beta.2 Linux golden baselines
Issue: #137
Issue: #141
Issue: #146
User-Visible: no
Release: v1.64.0-beta.2
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/31813468028
2026-08-14 19:04:08 +03:00
Sergey Matyunin f2fcf0d594 Release v1.64.0-beta.2 candidate
Validate / changes (push) Successful in 40s
Validate / provenance (push) Successful in 46s
Validate / process-gate (push) Failing after 45s
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 8m16s
Validate / backend (push) Failing after 8m22s
Validate / smoke (push) Failing after 3m27s
Validate / golden (push) Failing after 9m42s
Validate / performance_smoke (push) Failing after 13m13s
Issue: #146
User-Visible: yes
2026-08-14 18:14:02 +03:00
claude[bot] 3e4e549b56 docs: code review r2 for #146
Issue: #146
User-Visible: no
2026-08-14 14:52:20 +00:00
Sergey Matyunin 875b09cd8d fix: preserve zoom badge above sun background
Issue: #146
User-Visible: yes
2026-08-14 17:41:18 +03:00
claude[bot] 84cd5f9331 docs: review document for #146
Issue: #146
User-Visible: no
2026-08-14 14:38:19 +00:00
Sergey Matyunin debb13baa2 feat: add four-phase sun background
Issue: #146
User-Visible: yes
2026-08-14 17:17:25 +03:00
claude[bot] ab2a014568 docs: spec review r1 for #146
Issue: #146
User-Visible: no
2026-08-14 13:47:32 +00:00
Sergey Matyunin 558dae95cd docs: specify four-phase sun background
Issue: #146
User-Visible: no
2026-08-14 16:34:45 +03:00
claude[bot]andSergey Matyunin 1937c32572 docs: review document for #140
Issue: #140
User-Visible: no
2026-08-14 16:08:32 +03:00
Sergey Matyunin 5ed2821161 Fix editor property dialog footer width
Issue: #140
User-Visible: yes
2026-08-14 16:08:31 +03:00
Sergey Matyuninandclaude[bot] f66cf8ad4d fix: auto-close rooms along shared walls
Issue: #138
User-Visible: yes
2026-08-14 12:59:09 +00:00
claude[bot] d66cd2ebab docs: review document for #138
Issue: #138
User-Visible: no
2026-08-14 12:59:09 +00:00
Sergey Matyuninandclaude[bot] f0e7700805 docs(spec): guard autoclose minimum vertices
Issue: #138
User-Visible: no
2026-08-14 12:59:09 +00:00
claude[bot] aad625a84d docs: review document for #138
Issue: #138
User-Visible: no
2026-08-14 12:59:09 +00:00
Sergey Matyuninandclaude[bot] b57ea94cb8 docs(spec): define adjacent-room autoclose
Issue: #138
User-Visible: no
2026-08-14 12:59:09 +00:00
Matysh 737e7b62aa fix: the golden mutant guard runs capture, because verify forbids one scene
First real run of the gate failed before reaching a single mutant: the clean
run of the golden guard was red on untouched code. demo/golden/policy.mjs
refuses `verify --scenario=...` on purpose — a partial verify is the "make CI
green" loophole the policy exists to close. The gate built to catch dishonest
tests had reached for a dishonest shortcut, and the policy caught it.

capture keeps the whole check: a failed semantic assertion becomes status
error, and goldenRunFailed treats an error as failure in either mode. The scene
carries warmPixelRegion with minPixels 2500 over the receiving half, so a lamp
moved out of reach still fails it — which is exactly what this mutant asserts.

Issue: #85
User-Visible: no
2026-08-14 15:18:46 +03:00
claude[bot] f11a4e1085 docs: review document for #141
Issue: #141
User-Visible: no
2026-08-14 12:11:08 +00:00
Sergey Matyuninandclaude[bot] 548677a99c fix: preserve single wall previews
Issue: #141
User-Visible: yes
2026-08-14 12:11:08 +00:00
Sergey Matyuninandclaude[bot] 087f7cf381 feat: join independent wall junctions
Issue: #141
User-Visible: yes
2026-08-14 12:11:08 +00:00
claude[bot]andclaude[bot] b860ef4c43 docs: spec review for #141
Issue: #141
User-Visible: no
2026-08-14 12:11:08 +00:00
Sergey Matyuninandclaude[bot] 3b0b9eea50 docs: specify seamless wall junctions
Issue: #141
User-Visible: no
2026-08-14 12:11:08 +00:00
Matysh 0cf10613f2 ci: mutation-gate must live on the default branch to be dispatchable
Validate / hacs (push) Failing after 15s
Validate / hassfest (push) Failing after 13s
Validate / provenance (push) Successful in 43s
Validate / process-gate (push) Failing after 44s
Validate / frontend (push) Successful in 6m54s
Validate / backend (push) Failing after 10m31s
Validate / golden (push) Failing after 9m38s
Validate / performance_smoke (push) Failing after 10m29s
Validate / smoke (push) Failing after 29m50s
Full Performance / performance (push) Failing after 1h43m37s
gh workflow run answered 404: workflow_dispatch and schedule both resolve the
workflow file against the default branch, and the file sat only in dev. The
same trap as the process pipeline — even documented in that file's header — and
still stepped in a second time. The job itself checks out dev, so running from
main tests exactly the code it should.

Issue: #85
User-Visible: no
2026-08-14 15:08:31 +03:00
Matysh e894ce2986 docs: fix the working-tree layout in writing
Two agents sharing one checkout share one HEAD, and twice in an hour a commit
landed on someone else's task branch that way. The layout that ends it: the main
clone belongs to the author and its task branches, hp-dev is the owner's
permanent worktree on dev, and the reviewer and the infrastructure agent own no
local tree at all — one runs in CI on a fresh checkout, the other reads through
git show and publishes through the API, so it has no HEAD to collide with.

Also recorded: a worktree is only usable on the machine that created it, because
its .git file stores an absolute path in that machine's format. We hit this in
both directions within a day.

Issue: #115
User-Visible: no
2026-08-14 12:31:12 +03:00
Matysh ac30f8913d fix: build the gate CLI path with fileURLToPath, not URL.pathname
On Windows URL.pathname yields /C:/..., which spawnSync then reads as C:\C:\...
and the whole npm test run dies in this one test. Linux CI never caught it
because both spellings coincide there — which is exactly why the canonical gate
lives on Linux and the local run is advisory.

Issue: #133
User-Visible: no
2026-08-14 12:15:06 +03:00
Matysh de46db3343 docs: restore exact wording in the moved #89 spec review
One word was mistyped while transferring the file: "на каждый HA state
update" instead of "на каждом". The moved document must match the original
byte for byte.

Issue: #142
User-Visible: no
2026-08-14 11:50:54 +03:00
Matysh 782ff54e0f docs: remove originals after the move to docs/reviews and legacy
Issue: #142
User-Visible: no
2026-08-14 11:41:05 +03:00
Matysh b989c84b71 docs: remove originals after the move to docs/reviews and legacy
Issue: #142
User-Visible: no
2026-08-14 11:40:57 +03:00
Matysh 400ca7043e docs: remove originals after the move to docs/reviews and legacy
Issue: #142
User-Visible: no
2026-08-14 11:40:48 +03:00
Matysh 9f5d729538 docs: remove originals after the move to docs/reviews and legacy
Issue: #142
User-Visible: no
2026-08-14 11:40:36 +03:00
Matysh 8eb4bab7c6 docs: file reviews where reviews live, retire the #89 draft, honest markers
Three review documents sat in the repository root, committed before the pipeline
existed and before docs/reviews/ did. The directory exists now and the pipeline
writes into it, so they move there and the root stops being a second place to
look.

The #89 spec had a twin: the research draft next to the normative stage1
document, two files for one issue. The draft goes to legacy — it fed the
decisions and is worth keeping, but nothing should read it as current.

ROADMAP.md carried a live link to the Project v2 board that was dropped
yesterday; missed then because the sweep grepped for status-canon wording, not
for every link. And docs/README.ru.md said "verified against v1.60.0" as if
that were fresh — the line is now an explicit warning naming what to trust
instead: USER-GUIDE.ru.md and the changelogs.

Issue: #142
User-Visible: no
2026-08-14 11:40:26 +03:00
Sergey Matyuninandclaude[bot] 6c48c6d5c6 feat: add architectural snap overlay
Issue: #137
User-Visible: yes
2026-08-14 08:27:45 +00:00
claude[bot]andclaude[bot] 5bebc26aeb docs: review document for #137
Issue: #137
User-Visible: no
2026-08-14 08:27:45 +00:00
Sergey Matyuninandclaude[bot] f5c36da648 docs: specify plan snap overlay
Issue: #137
User-Visible: no
2026-08-14 08:27:45 +00:00
Matysh e9a148315a docs: restore the paragraph lost while publishing PROCESS.md
Three lines of section 10.4 and the trailing newline went missing in transit.
The lost paragraph is the one that says a label which did not change means the
run failed rather than the work — the sentence that tells a waiting author to
read the logs instead of polling for another forty-five minutes. Losing exactly
that one while copying a document about silent failures is a joke the situation
made on its own.

Caught by the byte comparison that follows every publish, which is the whole
reason it follows every publish.

Issue: #139
User-Visible: no
2026-08-14 11:11:41 +03:00
Matysh fb4096f67b chore: drop Project v2 from the process, the docs and the release script
The owner stopped using GitHub Projects. Most of this is wording, but one part
was not: release-prerelease.mjs talked to the Project in code. finishIssues
looked up the project id, listed its items and its Status=Done option, and threw
when an issue was missing from the board — so the first release that closed an
issue would have died on a step with nothing to do with publishing. Found by
reading rather than by releasing, which was luck.

Closing issues stays, and now strips the status label first. That order is not
cosmetic: the invariant that a closed issue carries no status label has broken
twice already, both times because a manual step did it the other way round. The
close-merged job already does it in this order.

The documents now say labels and only labels. The explicit "no longer used"
lines are kept on purpose, in PROCESS.md and next to the code that used to sync:
a decision that vanishes quietly gets reintroduced a month later by someone who
never knew it was made.

Issue: #139
User-Visible: no
2026-08-14 10:59:09 +03:00
Matysh e83da25085 chore: drop Project v2 from the process, the docs and the release script
The owner stopped using GitHub Projects. Most of this is wording, but one part
was not: release-prerelease.mjs talked to the Project in code. finishIssues
looked up the project id, listed its items and its Status=Done option, and threw
when an issue was missing from the board — so the first release that closed an
issue would have died on a step with nothing to do with publishing. Found by
reading rather than by releasing, which was luck.

Closing issues stays, and now strips the status label first. That order is not
cosmetic: the invariant that a closed issue carries no status label has broken
twice already, both times because a manual step did it the other way round. The
close-merged job already does it in this order.

The documents now say labels and only labels. The explicit "no longer used"
lines are kept on purpose, in PROCESS.md and next to the code that used to sync:
a decision that vanishes quietly gets reintroduced a month later by someone who
never knew it was made.

Issue: #139
User-Visible: no
2026-08-14 10:54:34 +03:00
Matysh ae10b2861b chore: drop Project v2 from the process, the docs and the release script
The owner stopped using GitHub Projects. Most of this is wording, but one part
was not: release-prerelease.mjs talked to the Project in code. finishIssues
looked up the project id, listed its items and its Status=Done option, and threw
when an issue was missing from the board — so the first release that closed an
issue would have died on a step with nothing to do with publishing. Found by
reading rather than by releasing, which was luck.

Closing issues stays, and now strips the status label first. That order is not
cosmetic: the invariant that a closed issue carries no status label has broken
twice already, both times because a manual step did it the other way round. The
close-merged job already does it in this order.

The documents now say labels and only labels. The explicit "no longer used"
lines are kept on purpose, in PROCESS.md and next to the code that used to sync:
a decision that vanishes quietly gets reintroduced a month later by someone who
never knew it was made.

Issue: #139
User-Visible: no
2026-08-14 10:50:56 +03:00
Matysh eef3634f23 chore: drop Project v2 from the process, the docs and the release script
The owner stopped using GitHub Projects. Most of this is wording, but one part
was not: release-prerelease.mjs talked to the Project in code. finishIssues
looked up the project id, listed its items and its Status=Done option, and threw
when an issue was missing from the board — so the first release that closed an
issue would have died on a step with nothing to do with publishing. Found by
reading rather than by releasing, which was luck.

Closing issues stays, and now strips the status label first. That order is not
cosmetic: the invariant that a closed issue carries no status label has broken
twice already, both times because a manual step did it the other way round. The
close-merged job already does it in this order.

The documents now say labels and only labels. The explicit "no longer used"
lines are kept on purpose, in PROCESS.md and next to the code that used to sync:
a decision that vanishes quietly gets reintroduced a month later by someone who
never knew it was made.

Issue: #139
User-Visible: no
2026-08-14 10:48:58 +03:00
Matysh 6ecbedfb85 ci: heavy Validate jobs run only where relevant paths changed
Validate / changes (push) Successful in 52s
Validate / process-gate (push) Failing after 1m17s
Validate / provenance (push) Successful in 1m20s
Validate / hacs (push) Failing after 16s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 7m30s
Validate / backend (push) Failing after 8m41s
Validate / performance_smoke (push) Failing after 9m45s
Validate / golden (push) Failing after 14m25s
Validate / smoke (push) Failing after 28m53s
Every push to every branch ran 128 browser smokes, 50 golden scenes, a Home
Assistant install and a performance pass — including a push that added one spec
file. The pipeline made such pushes routine: every spec revision and every
review document is a push to a task branch and used to cost the full suite.

A changes job classifies the push range; frontend, smoke, golden, performance
and backend now run only when their paths moved, and hacs and hassfest only for
manifests, translations or Python. provenance and process-gate always run — they
judge commits, not code.

The exception carries the design. On dev everything runs, always, unfiltered:
the beta gate accepts "green Validate at the exact SHA", and if the volume of a
run depends on the diff, green stops meaning one thing — a release candidate
touches manifests and changelogs, would skip the browser suites under filtering,
and a run with skipped jobs still concludes success. That would be the sixth
silent success of the week. Filters save time on task branches, where Validate is
an early signal and the real acceptance is the code review running gates itself.

A new branch with a zero before-sha is classified from the merge-base with dev,
not from the root of history.

Issue: #136
User-Visible: no
2026-08-14 10:16:18 +03:00
Matysh e6366b6548 fix: load virtual_lights by path so offline collection survives
The file declared itself pure but imported the module through the package, and
the package __init__ unconditionally imports homeassistant. Without homeassistant
installed pytest did not skip the file — it stopped collecting the whole
tests_backend directory, taking the previously working pure suite down with it.
test_validation.py had already established the by-path pattern; virtual_lights.py
imports nothing beyond the standard library, so it loads cleanly.

The async tests also dropped their pytest-asyncio dependency in favour of
asyncio.run: the offline environment does not carry the plugin, and without it
the two tests failed as unsupported async defs. The offline gate has to be green,
or nobody runs it.

Verified in both environments: pytest+voluptuous only — 129 passed where
collection previously stopped dead; with pytest-asyncio as in CI — 129 passed.

Issue: #135
User-Visible: no
2026-08-14 10:10:34 +03:00
Sergey Matyunin 159094cfec Fix pre-release smoke probes after merged contracts
Issue: #122
Issue: #131
Issue: #107
User-Visible: no
2026-08-14 04:29:21 +03:00
Sergey Matyunin 188a386cd8 Accept v1.64.0-beta.1 Linux golden baselines
Issue: #122
User-Visible: no
Release: v1.64.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/31759881579
2026-08-14 04:18:30 +03:00
Sergey Matyunin 5df8b723e7 Release v1.64.0-beta.1 candidate
Issue: #122
Issue: #131
Issue: #107
User-Visible: yes
2026-08-14 04:12:23 +03:00
Sergey Matyunin de0171dd02 fix: keep manual virtual light face canonical
Issue: #107
User-Visible: yes
2026-08-14 03:55:06 +03:00
claude[bot] f1e6cca3db docs: code review document for #107 (r1, red)
Issue: #107
User-Visible: no
2026-08-14 00:51:58 +00:00
Sergey Matyunin 1079cdfab2 feat: add persistent virtual light toggles
Issue: #107
User-Visible: yes
2026-08-14 03:32:53 +03:00
claude[bot] b7b28ee579 docs: review document for #107
Issue: #107
User-Visible: no
2026-08-14 00:02:41 +00:00
Sergey Matyunin af851cda85 docs: specify virtual light toggle
Issue: #107
User-Visible: no
2026-08-14 02:54:05 +03:00
Sergey Matyuninandclaude[bot] 0af095a6c4 fix: complete read-only cold start
Issue: #131
User-Visible: yes
2026-08-13 23:28:19 +00:00
claude[bot]andclaude[bot] 9ad2b3b4ef docs: spec review document for #131 (r1, green)
Issue: #131
User-Visible: no
2026-08-13 23:28:19 +00:00
Sergey Matyuninandclaude[bot] fea0d55c67 docs: specify readonly cold start behavior
Issue: #131
User-Visible: no
2026-08-13 23:28:19 +00:00
Matysh bc98116a31 test: a registry of known breakages that tests must catch
Five times in this project a green test meant nothing was checked. The
continuity smoke stayed green after the entire mechanism it guards was cut out.
The golden scene created to protect doorway light was empty — 1,177 warm pixels
against 107,119, all of them icons. The shadow smoke passed while no shadow was
drawn. Each time the test had been written alongside the code, went green at
once, and nobody ever asked whether it could go red.

The gate makes that question routine. Each mutant is a few lines of patch that
reproduce a known breakage, plus the name of the test that must fail on it. A
worktree is patched, the bundle rebuilt, the guard run — and a guard that stays
green fails the gate. Six mutants cover the holes documented in #85; the anchors
are exact strings from today's source, so the registry cannot silently drift —
a unit test that runs with the ordinary suite refuses a stale anchor.

The full run rebuilds the bundle per mutant, so it lives in its own workflow,
before a stable release and on a weekly schedule, not in Validate. The rules for
new tests are written at the top of docs/TESTING.md, and the sixth of them is
the cheapest: an assertion that reads back the property the code just set is
not written at all.

Issue: #85
User-Visible: no
2026-08-14 02:15:12 +03:00
Matysh 328ed7afc0 test: a registry of known breakages that tests must catch
Validate / provenance (push) Successful in 46s
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 12s
Validate / process-gate (push) Failing after 35s
Validate / frontend (push) Successful in 6m11s
Validate / backend (push) Failing after 9m24s
Validate / golden (push) Failing after 10m10s
Validate / performance_smoke (push) Failing after 12m46s
Validate / smoke (push) Failing after 30m15s
Five times in this project a green test meant nothing was checked. The
continuity smoke stayed green after the entire mechanism it guards was cut out.
The golden scene created to protect doorway light was empty — 1,177 warm pixels
against 107,119, all of them icons. The shadow smoke passed while no shadow was
drawn. Each time the test had been written alongside the code, went green at
once, and nobody ever asked whether it could go red.

The gate makes that question routine. Each mutant is a few lines of patch that
reproduce a known breakage, plus the name of the test that must fail on it. A
worktree is patched, the bundle rebuilt, the guard run — and a guard that stays
green fails the gate. Six mutants cover the holes documented in #85; the anchors
are exact strings from today's source, so the registry cannot silently drift —
a unit test that runs with the ordinary suite refuses a stale anchor.

The full run rebuilds the bundle per mutant, so it lives in its own workflow,
before a stable release and on a weekly schedule, not in Validate. The rules for
new tests are written at the top of docs/TESTING.md, and the sixth of them is
the cheapest: an assertion that reads back the property the code just set is
not written at all.

Issue: #85
User-Visible: no
2026-08-14 02:05:53 +03:00
claude[bot] 0e69c4a183 docs: code review document for #122 (r2, green)
Issue: #122
User-Visible: no
2026-08-13 22:27:31 +00:00
Sergey Matyunin ef3cc98d1c fix: preserve isometric fallback rendering
Issue: #122
User-Visible: no
2026-08-14 01:13:11 +03:00
claude[bot] e13215c02f docs: code review document for #122 (r1, red)
Issue: #122
User-Visible: no
2026-08-13 22:04:00 +00:00
Sergey Matyunin 42b3f44c4a feat: add hidden isometric stage 2
Issue: #122
User-Visible: no
2026-08-14 00:43:13 +03:00
claude[bot] 4c73e2ccdb docs: review document for #122
Issue: #122
User-Visible: no
2026-08-13 21:04:32 +00:00
Sergey Matyunin 76ce755742 docs: specify hidden isometric stage 2
Issue: #122
User-Visible: no
2026-08-13 23:55:45 +03:00
Sergey Matyunin 50099acc75 fix: allow stable promotion of published beta history
Validate / provenance (push) Successful in 5m46s
Validate / process-gate (push) Failing after 5m56s
Validate / hacs (push) Failing after 20s
Validate / hassfest (push) Failing after 14s
Validate / frontend (push) Successful in 13m53s
Validate / backend (push) Failing after 10m41s
Validate / golden (push) Failing after 9m40s
Validate / performance_smoke (push) Failing after 17m26s
Validate / smoke (push) Failing after 34m54s
Full Performance / performance (push) Failing after 1h54m13s
Issue: #130
User-Visible: no
2026-08-13 22:59:23 +03:00
Sergey Matyunin a282f850af build: promote v1.63.0
Issue: #129
User-Visible: yes
2026-08-13 22:47:48 +03:00
Sergey Matyunin d7f3bb8119 test: accept v1.63.0-beta.2 golden baselines
Issue: #123
User-Visible: no
Release: v1.63.0-beta.2
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/31734606270
2026-08-13 22:26:38 +03:00
Sergey Matyunin 5c6ab8ea9b Release v1.63.0-beta.2 candidate
Issue: #123
User-Visible: yes
2026-08-13 22:11:02 +03:00
Sergey Matyunin fda4893f0c Merge updated dev for v1.63.0 2026-08-13 22:10:28 +03:00
Matysh 888e90450a perf: make review scope and ceremony fit the size of the task
The owner's report: the process works but every stage takes a long time even on
simple bugs. Two causes, and neither was the one that first comes to mind.

The reviewer ran everything regardless. On #89 it installed Chromium, ran all 127
smoke files and a full golden capture — right for a task rated 10/10 for
complexity, absurd for a bug about a room divider. Full suites are the pre-beta
gate; the review now runs typecheck, unit and build always, and smokes, golden,
pytest or performance only where the diff and the AC call for them. The price of
narrowing it is honesty: the reviewer must list which gates it ran, which it did
not, and why, so a skipped gate is a visible decision rather than a silent one.

The reviewer also built its own environment out of model turns, with no npm cache
and no browser cache, paid for from the same forty-five minutes. The workflow now
installs dependencies and Chromium as ordinary cached steps, after switching to
the task branch so the lockfile is the branch's own.

Second, ceremony did not scale down. The light track makes a spec cheap; the new
trivial track does without one — S2-analysis straight to S5-ready, no spec review,
AC in the issue body. It is deliberately hard to qualify for: a bug on one surface,
no new UX contract, no migration, no i18n, no perf or touch effect, three checkable
AC at most, and expected behaviour already on record. Nothing left to decide is the
criterion that holds the whole thing up, and it cannot be met by feeling sure.

Code review is never skipped on either track. It is what stands in for testing
here, so it is the one stage speed may not buy.

Issue: #127
Issue: #128
User-Visible: no
2026-08-13 22:07:42 +03:00
Sergey Matyunin b2263a6551 Merge main into dev for v1.63.0 2026-08-13 22:05:35 +03:00
Matysh 565f518dcd perf: make review scope and ceremony fit the size of the task
The owner's report: the process works but every stage takes a long time even on
simple bugs. Two causes, and neither was the one that first comes to mind.

The reviewer ran everything regardless. On #89 it installed Chromium, ran all 127
smoke files and a full golden capture — right for a task rated 10/10 for
complexity, absurd for a bug about a room divider. Full suites are the pre-beta
gate; the review now runs typecheck, unit and build always, and smokes, golden,
pytest or performance only where the diff and the AC call for them. The price of
narrowing it is honesty: the reviewer must list which gates it ran, which it did
not, and why, so a skipped gate is a visible decision rather than a silent one.

The reviewer also built its own environment out of model turns, with no npm cache
and no browser cache, paid for from the same forty-five minutes. The workflow now
installs dependencies and Chromium as ordinary cached steps, after switching to
the task branch so the lockfile is the branch's own.

Second, ceremony did not scale down. The light track makes a spec cheap; the new
trivial track does without one — S2-analysis straight to S5-ready, no spec review,
AC in the issue body. It is deliberately hard to qualify for: a bug on one surface,
no new UX contract, no migration, no i18n, no perf or touch effect, three checkable
AC at most, and expected behaviour already on record. Nothing left to decide is the
criterion that holds the whole thing up, and it cannot be met by feeling sure.

Code review is never skipped on either track. It is what stands in for testing
here, so it is the one stage speed may not buy.

Issue: #127
Issue: #128
User-Visible: no
2026-08-13 21:59:55 +03:00
Sergey Matyuninandclaude[bot] 02e0c9801d Fix corner split smoke geometry input
Issue: #123
User-Visible: no
2026-08-13 18:55:49 +00:00
claude[bot]andclaude[bot] e93c405b13 docs: code review document for #123
Issue: #123
User-Visible: no
2026-08-13 18:55:49 +00:00
Sergey Matyuninandclaude[bot] 955de3e69c Fix corner split exterior walls
Issue: #123
User-Visible: yes
2026-08-13 18:55:49 +00:00
claude[bot]andclaude[bot] 7af4146614 docs: review document for #123
Issue: #123
User-Visible: no
2026-08-13 18:55:49 +00:00
Sergey Matyuninandclaude[bot] a43602934c Specify corner split wall geometry
Issue: #123
User-Visible: no
2026-08-13 18:55:49 +00:00
Matysh 516257e322 perf: make review scope and ceremony fit the size of the task
The owner's report: the process works but every stage takes a long time even on
simple bugs. Two causes, and neither was the one that first comes to mind.

The reviewer ran everything regardless. On #89 it installed Chromium, ran all 127
smoke files and a full golden capture — right for a task rated 10/10 for
complexity, absurd for a bug about a room divider. Full suites are the pre-beta
gate; the review now runs typecheck, unit and build always, and smokes, golden,
pytest or performance only where the diff and the AC call for them. The price of
narrowing it is honesty: the reviewer must list which gates it ran, which it did
not, and why, so a skipped gate is a visible decision rather than a silent one.

The reviewer also built its own environment out of model turns, with no npm cache
and no browser cache, paid for from the same forty-five minutes. The workflow now
installs dependencies and Chromium as ordinary cached steps, after switching to
the task branch so the lockfile is the branch's own.

Second, ceremony did not scale down. The light track makes a spec cheap; the new
trivial track does without one — S2-analysis straight to S5-ready, no spec review,
AC in the issue body. It is deliberately hard to qualify for: a bug on one surface,
no new UX contract, no migration, no i18n, no perf or touch effect, three checkable
AC at most, and expected behaviour already on record. Nothing left to decide is the
criterion that holds the whole thing up, and it cannot be met by feeling sure.

Code review is never skipped on either track. It is what stands in for testing
here, so it is the one stage speed may not buy.

Issue: #127
Issue: #128
User-Visible: no
2026-08-13 21:51:42 +03:00
Matysh 9177c9a944 perf: make review scope and ceremony fit the size of the task
The owner's report: the process works but every stage takes a long time even on
simple bugs. Two causes, and neither was the one that first comes to mind.

The reviewer ran everything regardless. On #89 it installed Chromium, ran all 127
smoke files and a full golden capture — right for a task rated 10/10 for
complexity, absurd for a bug about a room divider. Full suites are the pre-beta
gate; the review now runs typecheck, unit and build always, and smokes, golden,
pytest or performance only where the diff and the AC call for them. The price of
narrowing it is honesty: the reviewer must list which gates it ran, which it did
not, and why, so a skipped gate is a visible decision rather than a silent one.

The reviewer also built its own environment out of model turns, with no npm cache
and no browser cache, paid for from the same forty-five minutes. The workflow now
installs dependencies and Chromium as ordinary cached steps, after switching to
the task branch so the lockfile is the branch's own.

Second, ceremony did not scale down. The light track makes a spec cheap; the new
trivial track does without one — S2-analysis straight to S5-ready, no spec review,
AC in the issue body. It is deliberately hard to qualify for: a bug on one surface,
no new UX contract, no migration, no i18n, no perf or touch effect, three checkable
AC at most, and expected behaviour already on record. Nothing left to decide is the
criterion that holds the whole thing up, and it cannot be met by feeling sure.

Code review is never skipped on either track. It is what stands in for testing
here, so it is the one stage speed may not buy.

Issue: #127
Issue: #128
User-Visible: no
2026-08-13 21:33:36 +03:00
Matysh 8a3f6efa0a fix: the review document is published even without a task branch
Issues labelled before the pipeline existed keep their spec straight in dev and
have no issue/NN branch. The publish step quietly exited zero for them, so the
verdict would arrive as a comment and the analysis behind it would be thrown
away — the fifth instance today of a step reporting success by doing nothing.

The document now goes wherever the spec itself lives: the task branch when there
is one, dev otherwise. Publishing also survives dev moving on while the review
ran, which takes up to forty-five minutes, by rebasing once before it gives up.

Four issues are waiting on this — #12, #30, #44 and #52 — each with a spec in dev,
a status label applied during the bulk pass in August and a review that never ran
because nothing was there to raise the event.

Issue: #114
User-Visible: no
2026-08-13 21:11:41 +03:00
Matysh be7d6b9706 fix: the review document is published even without a task branch
Issues labelled before the pipeline existed keep their spec straight in dev and
have no issue/NN branch. The publish step quietly exited zero for them, so the
verdict would arrive as a comment and the analysis behind it would be thrown
away — the fifth instance today of a step reporting success by doing nothing.

The document now goes wherever the spec itself lives: the task branch when there
is one, dev otherwise. Publishing also survives dev moving on while the review
ran, which takes up to forty-five minutes, by rebasing once before it gives up.

Four issues are waiting on this — #12, #30, #44 and #52 — each with a spec in dev,
a status label applied during the bulk pass in August and a review that never ran
because nothing was there to raise the event.

Issue: #114
User-Visible: no
2026-08-13 21:05:17 +03:00
Matysh 7c1edbfa9b ci: realign the workflow copy in dev with main
The two copies of this file must match byte for byte; a comment line had drifted
by one character. main is the copy the issues event actually reads, so it is the
reference. Trivial in itself, and worth closing anyway: the file's own header
warns that a divergence between these two branches is one of the ways this
pipeline fails quietly.

Issue: #114
User-Visible: no
2026-08-13 20:53:35 +03:00
Matysh 024cdc0d94 feat: an outsider's issue is worked like any other once admitted
The guard refused to review any issue the owner had not filed himself. The rule
was meant to keep malformed outside reports out of the pipeline, but it checked at
every step instead of at the entrance, and it duplicated a guarantee the platform
already gives: only someone with write access can apply a label. Applying the
first status label is the owner's explicit decision, and it is the only place the
question belongs.

So the author check is gone. While an issue carries no status label it sits
outside the process and the invariants do not apply; once labelled, the task is in
flight and who filed it stops mattering.

The old rule also cost real work. On #123 an outside bug report had been analysed
and specified before the guard turned it away in nine seconds, and the remedy on
offer was to refile the same thing as the owner's own issue.

Issue: #114
User-Visible: no
2026-08-13 20:49:04 +03:00
Matysh 2fd042a7de feat: an outsider's issue is worked like any other once admitted
The guard refused to review any issue the owner had not filed himself. The rule
was meant to keep malformed outside reports out of the pipeline, but it checked at
every step instead of at the entrance, and it duplicated a guarantee the platform
already gives: only someone with write access can apply a label. Applying the
first status label is the owner's explicit decision, and it is the only place the
question belongs.

So the author check is gone. While an issue carries no status label it sits
outside the process and the invariants do not apply; once labelled, the task is in
flight and who filed it stops mattering.

The old rule also cost real work. On #123 an outside bug report had been analysed
and specified before the guard turned it away in nine seconds, and the remedy on
offer was to refile the same thing as the owner's own issue.

Issue: #114
User-Visible: no
2026-08-13 20:41:36 +03:00
Matysh 4e539b02df fix: the guard says why it refused, in the issue
A review label promises work. When the guard declined it wrote the reason to the
run log and nothing else, so the issue sat in a status nobody was acting on and
nobody could tell. #123 showed it: an outside reporter's issue was walked up to
S4-spec-review, the guard refused in nine seconds because only the owner's issues
enter the process, and the issue itself said not a word.

Refusals that a human can act on now become a comment: wrong author, blocked,
review-4. Only when a stage was actually recognised, so an unrelated label change
stays silent.

This is the same defect as the merge conflict that left the label untouched, seen
from the other side. The pattern is worth naming: doing nothing quietly is the
most expensive thing a pipeline can do.

Issue: #114
User-Visible: no
2026-08-13 20:36:25 +03:00
Matysh d7e2c4d4f0 fix: the guard says why it refused, in the issue
A review label promises work. When the guard declined it wrote the reason to the
run log and nothing else, so the issue sat in a status nobody was acting on and
nobody could tell. #123 showed it: an outside reporter's issue was walked up to
S4-spec-review, the guard refused in nine seconds because only the owner's issues
enter the process, and the issue itself said not a word.

Refusals that a human can act on now become a comment: wrong author, blocked,
review-4. Only when a stage was actually recognised, so an unrelated label change
stays silent.

This is the same defect as the merge conflict that left the label untouched, seen
from the other side. The pattern is worth naming: doing nothing quietly is the
most expensive thing a pipeline can do.

Issue: #114
User-Visible: no
2026-08-13 20:30:15 +03:00
Matysh 948f2848dd docs: a failed pre-release gate does not send the issue back to review
The implementation loop runs typecheck, unit and build. Golden, browser smokes,
performance and the full HA harness run before a beta — after the code review has
passed and the issue already sits in S8-merged. Some defects cannot surface any
earlier, and until now the process had nothing to say about them, so the honest
reading was a second full review cycle at the most expensive possible moment.

The owner's decision: fix it, re-run what failed, and a green run carries the
release on. The gate named the defect precisely and the same gate proves the fix,
so the check is objective and depends on nobody's judgement.

The boundary is written down with it, because "the gate found something" could
otherwise absorb an arbitrary amount of new work. A fix that changes a behaviour
contract, reaches an untouched subsystem or rivals the task in size goes through
the normal flow. Editing a test so it stops failing is concealment rather than
repair — the exception is a defect proven to be in the fixture, as on #89.

The rule also records what it costs: the author judges his own work here, which
the process refuses everywhere else. That is the price of speed at the one point
where a review cycle is dearest, and the compensation is that the re-run command
and its result are written into the issue where the release manager reads them.

Issue: #114
User-Visible: no
2026-08-13 19:36:39 +03:00
Sergey Matyunin 3270e039d8 test: accept v1.63.0-beta.1 golden baselines
Validate / provenance (push) Successful in 44s
Validate / process-gate (push) Failing after 2m46s
Validate / hassfest (push) Failing after 13s
Validate / hacs (push) Failing after 13m3s
Validate / frontend (push) Successful in 7m54s
Validate / backend (push) Failing after 8m33s
Validate / golden (push) Failing after 9m58s
Validate / performance_smoke (push) Failing after 9m24s
Validate / smoke (push) Failing after 26m9s
Issue: #89
User-Visible: no
Release: v1.63.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/31709903117
2026-08-13 17:30:50 +03:00
Sergey Matyunin 8e6b6c7ee3 Release v1.63.0-beta.1 candidate
Issue: #89
Issue: #104
Issue: #111
User-Visible: yes
2026-08-13 17:22:39 +03:00
Matysh dbe12f1a54 docs: state the invariant the pipeline was missing
A review run always moves the label. The rule is written down because its absence
cost a real stall: a green code review whose merge conflicted left the label alone,
the waiting author polled thirty times and reported the limit as exhausted, and a
verdict that existed reached nobody.

Both documents now say what S6-in-progress means when the verdict was green and
only the merge failed — rebase, not rework, and the verdict still stands. They also
say that a label which did not change means the run failed rather than the work, so
the answer is logs and the owner, not more polling. Cycles are counted per stage.

Issue: #114
User-Visible: no
2026-08-13 17:13:54 +03:00
Matysh 1e9952db35 fix: a review run always moves the label, conflict or not
A green code review whose merge conflicted used to leave the label where it was.
That is a dead end: the author waits for the label to change, so it polled thirty
times and reported the limit as exhausted — on a task the reviewer had already
passed. The verdict existed and nobody could act on it.

The merge step no longer fails the job. It reports whether it merged, and a green
review that did not merge sends the task back to S6-in-progress, because the work
did return to the author — a rebase rather than a code fix, and the comment says
so and says the verdict still stands.

The invariant is now stronger and worth stating plainly: after a review run the
label always changes. A pipeline whose state can stall silently is worse than one
that reports the wrong state loudly.

Issue: #114
User-Visible: no
2026-08-13 17:03:43 +03:00
Matysh d1be6891b2 fix: a review run always moves the label, conflict or not
Validate / hacs (push) Failing after 55s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 6m16s
Validate / backend (push) Failing after 8m39s
Validate / provenance (push) Successful in 37s
Validate / golden (push) Failing after 8m4s
Validate / smoke (push) Failing after 27m15s
Validate / performance_smoke (push) Failing after 14m5s
Full Performance / performance (push) Failing after 1h15m11s
A green code review whose merge conflicted used to leave the label where it was.
That is a dead end: the author waits for the label to change, so it polled thirty
times and reported the limit as exhausted — on a task the reviewer had already
passed. The verdict existed and nobody could act on it.

The merge step no longer fails the job. It reports whether it merged, and a green
review that did not merge sends the task back to S6-in-progress, because the work
did return to the author — a rebase rather than a code fix, and the comment says
so and says the verdict still stands.

The invariant is now stronger and worth stating plainly: after a review run the
label always changes. A pipeline whose state can stall silently is worse than one
that reports the wrong state loudly.

Issue: #114
User-Visible: no
2026-08-13 16:58:18 +03:00
Sergey Matyunin 39f5312f97 Merge issue #89 into dev
Issue: #89
User-Visible: no
2026-08-13 16:44:59 +03:00
claude[bot] cc3b0f12f2 docs: review document for #89
Issue: #89
User-Visible: no
2026-08-13 13:34:19 +00:00
Matysh 316ee76a29 fix: repair the line continuation in the failure handler
The step that comments on the issue when a review run dies carried a literal
backslash instead of a line continuation, so gh received four arguments and
--repo ran as a command of its own. The handler for failures would itself have
failed, silently, and only when something had already gone wrong.

bash -n does not catch this: the syntax is valid, the meaning is not. Checking
run blocks now also means looking for a doubled backslash at end of line.

Issue: #114
User-Visible: no
2026-08-13 16:21:33 +03:00
Matysh 9be81c1413 fix: repair the line continuation in the failure handler
The step that comments on the issue when a review run dies carried a literal
backslash instead of a line continuation, so gh received four arguments and
--repo ran as a command of its own. The handler for failures would itself have
failed, silently, and only when something had already gone wrong.

bash -n does not catch this: the syntax is valid, the meaning is not. Checking
run blocks now also means looking for a doubled backslash at end of line.

Issue: #114
User-Visible: no
2026-08-13 16:16:39 +03:00
Sergey Matyunin 7a2577dba0 test: align isometric sunlight fixture
Issue: #89
User-Visible: no
2026-08-13 16:14:16 +03:00
Matysh 869fe169d8 fix: count review cycles per stage, not across the whole issue
The guard counted every verdict comment on the issue, so a spec-review verdict
consumed a cycle from the code-review budget. On #89 the first code review came
out as r2/4. With two spec cycles the second code review would have hit review-4
after a single fix — the limit would have fired on a task nobody had reviewed
twice.

The stage is now resolved first and only its own verdicts are counted, recognised
by the review document named in the comment. If the document is missing the
verdict is not counted: undercounting grants an extra cycle, overcounting would
stop the work early, and of the two mistakes the recoverable one wins.

Issue: #114
User-Visible: no
2026-08-13 16:13:13 +03:00
Matysh fafeca4540 fix: count review cycles per stage, not across the whole issue
The guard counted every verdict comment on the issue, so a spec-review verdict
consumed a cycle from the code-review budget. On #89 the first code review came
out as r2/4. With two spec cycles the second code review would have hit review-4
after a single fix — the limit would have fired on a task nobody had reviewed
twice.

The stage is now resolved first and only its own verdicts are counted, recognised
by the review document named in the comment. If the document is missing the
verdict is not counted: undercounting grants an extra cycle, overcounting would
stop the work early, and of the two mistakes the recoverable one wins.

Issue: #114
User-Visible: no
2026-08-13 16:08:20 +03:00
claude[bot] e0ddbcd79e docs: review document for #89
Issue: #89
User-Visible: no
2026-08-13 12:53:16 +00:00
Sergey Matyunin 6ea3ebff17 test: complete isometric stage 1 gates
Issue: #89
User-Visible: no
2026-08-13 15:28:19 +03:00
Sergey Matyunin 22e98c5555 ci: mark the pre-push hook executable
Git skips a hook without the bit and says nothing about it, so the gate
would have reported success by being absent. The API cannot set the mode:
a file pushed that way arrives as 100644.

Issue: #121
User-Visible: no
2026-08-13 15:11:20 +03:00
Matysh d38a5be68b docs: drop the second status dictionary and the stale class note
docs/specs/README.md kept a "Статус ТЗ" column with its own vocabulary — draft,
in implementation, done — next to the labels that already hold the status. Two
dictionaries for one fact drift apart, and these had: the column still called
issues "in implementation" that were closed weeks ago. The table now says only
which issue a spec belongs to.

AGENTS.md was telling agents that PROCESS.md §1 does not cover package.json and
the rest of the configuration, and to report it as missing. It covers them now.
The same paragraph gained the rule that D beats A where paths overlap, which is
what keeps the built bundle under custom_components/houseplan/frontend/ from
reading as product source.

Issue: #119
User-Visible: no
2026-08-13 15:02:42 +03:00
Matysh 42335bc16d ci: add the pre-push gate and stop lying about it in the canon
Section 10.1 promised pre-push as the blocking gate that replaces pull requests.
The hook did not exist, so the document promised a check that was not there —
worse than saying nothing, because a promise like that gets relied on. Until now
process-gate ran only as the catch-up job in CI, which reports after the code is
already in dev.

The hook skips branch deletions and tags, and for a branch the remote has not
seen it measures from the merge-base with dev rather than from the root, or every
violation committed before the gate existed would make it impossible to pass. A
missing script does not block a push: old checkouts and worktrees have to stay
usable.

gh is optional on purpose. Reading issue status needs the network, and a hook
that cannot work on a train is a hook people switch off; offline it runs what it
can and CI does the strict pass.

The executable bit is the quiet part. Git skips a hook without +x and says
nothing — the gate reports success by being absent. Measured on a real push:
mode 644 produces zero lines from the gate and the push goes through, 755 stops
it. The API cannot set the bit, so install-hooks restores it on every install.

Issue: #121
User-Visible: no
2026-08-13 14:58:57 +03:00
Matysh a36b3129f6 ci: close the S8-merged queue when a beta is published
PROCESS.md 10.2 item 10 asks for this to happen because a beta shipped, not
because someone remembered. The manual cleanup was skipped twice and both times
it broke the invariant that a closed issue carries no status label — the one
thing `verify` leans on. A manual step that falls due right after a successful
release is the worst kind: the work already looks finished, which is precisely
why it gets forgotten.

The job comments the tag, removes the label, then closes. That order is
deliberate: dying between the two steps leaves an open issue without a status,
which is visible and fixable in the flow, where the reverse order would recreate
the breakage this exists to prevent. It ends by asserting that no closed issue
still carries S8-merged — aimed at the defect that actually recurs rather than at
the invariant in general.

The stock token is used on purpose. Events caused by GITHUB_TOKEN do not start
workflows, so stripping the label cannot wake the review pipeline; a PAT here
would turn bookkeeping into a cascade.

Issue: #120
User-Visible: no
2026-08-13 14:43:32 +03:00
Sergey Matyunin 0ef900a3ae feat: integrate isometric labs renderer
Issue: #89
User-Visible: no
2026-08-13 14:40:31 +03:00
Matysh 8cecaf2c5e fix: judge the branch rule only by the branch's own commits
Check 2 compared the Issue trailers against whatever branch the working tree
happened to be on, over whatever range it was given. Those two are not the same
set. After a rebase the CI range widens — `before` points at a discarded commit,
the merge-base slides back, and commits that belong to dev arrive carrying other
issue numbers. Every one of them then looks like a violation.

Running the gate over real history from issue/89 with a dev range produced 26
false refusals out of 26 commits, which would have reddened Validate on the next
force-push of any task branch.

The rule now reads origin/dev..HEAD for its own verdict and leaves the event
range to the other checks. A commit that genuinely carries the wrong trailer for
its branch is still caught; the integration test covers both directions.

Issue: #105
User-Visible: no
2026-08-13 14:30:13 +03:00
Matysh 5aa8771dc3 docs: bring the process canon back in line with what actually runs
The canon moved into the repository in #112 and then stood still while the
process kept moving. A document that lags is worse than no document: an agent
reading it as truth acts on rules that no longer exist. It promised a pre-push
hook that was never written, named labels in Russian that the repository has
never used, listed a status set the gate no longer applies, and said nothing at
all about the event-driven pipeline — the largest mechanism the process has.

Label names are now English throughout and S8-merged is documented. Section 1
covers the configuration files the gate kept reporting as unclassified, and
records that D beats A where paths overlap, since the built bundle lives inside
custom_components/houseplan/frontend/. Section 10.1 admits that pre-push does
not exist. Section 10.2 matches ALLOWED_STATUS in scripts/process-gate.mjs,
including the two caveats that only surfaced once the pipeline ran. Section 10.4
is new and documents the four silent-failure traps that cost a working day each.

The source-of-truth order now says that actual automation outranks its own
description — this document included.

Issue: #119
User-Visible: no
2026-08-13 14:24:50 +03:00
Sergey Matyunin 02502c990c feat: add labs and isometric geometry core
Issue: #89
User-Visible: no
2026-08-13 14:22:30 +03:00
Sergey Matyunin a841d85e40 docs: decide isometric renderer architecture
Issue: #89
User-Visible: no
2026-08-13 14:12:12 +03:00
claude[bot] 6d61529168 docs: review document for #89
Issue: #89
User-Visible: no
2026-08-13 11:05:29 +00:00
Sergey Matyunin f87d71ac18 docs: infrastructure-only work runs outside the product flow
Practice had already diverged from the documents: #105, #112, #114 and #116 were
all done without a spec and without review, and that was right. Nothing said it
was allowed.

The test is mechanical — not a single class A file — rather than left to the
executor's judgement, because a loose reading is exactly how product changes
would learn to skip review.

Issue: #118
User-Visible: no
2026-08-13 14:02:24 +03:00
Sergey Matyunin 7ba2de7c89 ci: process gate as a script and a Validate job
PROCESS.md 10.2 describes scripts/process-gate.mjs; the script never existed.
Commits go straight to dev without PRs and GitHub blocks nothing on its side, so
until now the only thing standing between the process and rule #1 was the good
faith of whoever was committing. Hooks catch a violation on the author's machine
but --no-verify walks past them; this job is the catch-up pass that cannot be
skipped locally.

Checks 1-7 offline, 8 through gh, plus the escalation of check 3: a class A
commit with neither a spec file nor the `small` label is a failure, not a
warning. Check 8 is fail closed — an unreachable or closed issue is a refusal,
never a silent pass.

Two things surfaced while wiring it up and are recorded in the script header.
S8-merged had to join the allowed statuses: the pipeline merges into dev before
it moves the label, so Validate reads the issue already advanced and a strict set
would redden every accepted task. And the status question now applies only to
class A/B commits — asking it of a review document would fail every time, since
that document lands while the issue sits in S4-spec-review or S7-code-review.

Issue: #105
User-Visible: no
2026-08-13 14:01:05 +03:00
Sergey Matyunin 74b08df88c docs: revise isometric stage 1 specification
Issue: #89
User-Visible: no
2026-08-13 13:53:54 +03:00
Sergey Matyunin 9f02d88b42 docs: the author waits for the verdict instead of ending the session
Review fires from the label and runs on its own, but nothing was picking the
result up: the author reported "handed over for review" and stopped, so the
conveyor stalled until the owner said a sentence. The author now polls the label
and continues from whatever it became.

Also drops the merge-into-dev standing permission: the pipeline does the merge
before setting S8-merged, so a hand merge would race it.

Issue: #114
User-Visible: no
2026-08-13 13:36:25 +03:00
Sergey Matyunin c18224cdd2 ci: sync the merge-before-label change into dev
Issue: #114
User-Visible: no
2026-08-13 13:24:39 +03:00
Matysh 3ade633538 ci: merge into dev before setting S8-merged
The label asserts the code is in dev. The workflow used to set it on a green
code review while the commits were still only on the task branch, so between
the verdict and the author's merge the state machine stated something untrue —
which is exactly what happened on #104.

The merge now runs inside the pipeline, before the label. A conflict leaves
the issue in S7-code-review and comments instead.

Issue: #114
User-Visible: no
2026-08-13 13:20:26 +03:00
Sergey Matyunin ee2357b914 fix: keep opening HA references after marker deletion
Issue: #104
User-Visible: yes
2026-08-13 13:11:57 +03:00
Sergey Matyunin 9e176aa1d7 docs: finalize opening reference review decisions
Issue: #104
User-Visible: no
2026-08-13 13:11:57 +03:00
Sergey Matyunin 7a76fb78fc docs: address first review of opening references
Issue: #104
User-Visible: no
2026-08-13 13:11:57 +03:00
Sergey Matyunin c585f0268d docs: specify opening references after marker deletion
Issue: #104
User-Visible: no
2026-08-13 13:11:57 +03:00
Matysh df5be154ee ci: sync the process workflow into dev
Keeps dev identical to main so the broken revision does not come back at the
next promotion. The workflow only fires from the default branch, but a stale
copy here would overwrite the working one.

Issue: #114
User-Visible: no
2026-08-13 13:05:13 +03:00
Matysh 68596a75a0 ci: the reviewer writes a review document to the task branch
PROCESS.md wants a review document in docs/reviews/; the CI reviewer could
only leave a comment, and flagged the gap itself. It may now write there.

What lands in the commit is decided by the workflow, not by the model: every
path outside docs/reviews/ is reverted before staging, and the commit carries
the usual trailers so the provenance gate accepts it.

Issue: #114
User-Visible: no
2026-08-13 13:04:26 +03:00
Matysh 65a86db122 fix(ci): repair the failure comment step
The multi-line --body started at column zero, which ends the YAML block
scalar. The parser silently truncated the run script and left an unclosed
double quote, so the whole workflow became unusable and blocked the code
review on #104.

The body now goes through a heredoc. Validating YAML alone did not catch
this; every run block is checked with bash -n from now on.

Issue: #114
User-Visible: no
2026-08-13 12:57:04 +03:00
Matysh 39dd5de857 fix: restore the executable bit on commit-msg, mirror the turn limit
Pushing the hook through the GitHub contents API dropped its mode to 100644.
assertHookMode caught it on the next commit, which is the gate working as
intended — a non-executable commit-msg would simply never run.

Also brings dev in line with the turn-limit fix already on main.

Issue: #116
User-Visible: no
2026-08-13 12:38:13 +03:00
Matysh a29df12e0b ci: raise the turn limit, bound the run by time instead
The r2 spec review on #104 produced a complete green verdict and then failed
on --max-turns 40 at turn 43, so the label step never ran and the transition
had to be reconciled by hand. Forty was a guess; a review that reads SCOPE,
AGENTS, PROCESS, the issue thread and the spec exceeds it routinely, and a
code review that also runs gates needs far more.

The real guard against a runaway run is the job timeout, not the turn count.

Issue: #114
User-Visible: no
2026-08-13 12:37:14 +03:00
Matysh 0509e1a008 docs: only product ambiguity goes to the owner
Agents were escalating technical calls. The owner answers what a person sees
or does and how much user-visible change belongs in an issue; storage, module
layout, naming, test strategy and migration mechanics are settled by the
agents, recorded as assumptions and challenged in review.

Issue: #114
User-Visible: no
2026-08-13 12:18:57 +03:00
Matysh e043974c44 docs: standing permission to merge a reviewed branch into dev
Without it S8-merged would be a lie: the label asserts the code is in dev,
while the branch-only push leaves it on the task branch. What lands is what
the reviewer just accepted, and dev is allowed to carry unreviewed code
anyway, so the merge adds no risk the branch did not already carry.

Issue: #114
User-Visible: no
2026-08-13 12:11:04 +03:00
Matysh 05b38e67c4 fix(hooks): drop basename from commit-msg
The hook parsed the message path with basename, an external command. Where
it is missing from PATH the substitution yields an empty string, set -e does
not trip on it, and the MERGE_MSG guard silently stops working — a generated
merge commit would then be rejected for missing trailers it cannot have.

POSIX parameter expansion needs no external command and behaves the same in
sh, dash, bash and Git Bash.

Issue: #116
User-Visible: no
2026-08-13 12:09:02 +03:00
Matysh b9062c1740 docs: standing permission to push the task branch
The reviewer runs in CI and can only read the remote, so an unpushed spec or
commit either stalls the review or points it at the wrong tree. Pushing
issue/<NN>-slug now needs no command; dev, main, merges, tags and releases
still do.

Issue: #114
User-Visible: no
2026-08-13 12:04:39 +03:00
Matysh 9146b4c357 ci: fix OIDC permission and review the issue branch
The first live run failed with "Could not fetch an OIDC token": the action
needs id-token: write to authenticate the GitHub App.

The reviewer also checked out dev, where the material under review does not
exist yet — specs and code are committed to issue/<NN>-slug. The job now
switches to that branch when it is pushed, and warns loudly when it is not.

Issue: #114
User-Visible: no
2026-08-13 12:02:31 +03:00
Matysh 97d932a384 ci: fix OIDC permission and review the issue branch
The first live run failed with "Could not fetch an OIDC token": the action
needs id-token: write to authenticate the GitHub App.

The reviewer also checked out dev, where the material under review does not
exist yet — specs and code are committed to issue/<NN>-slug. The job now
switches to that branch when it is pushed, and warns loudly when it is not.

Issue: #114
User-Visible: no
2026-08-13 12:02:28 +03:00
Sergey MatyuninandMatysh 30f71af200 Fix empty plan render snapshot
Issue: #111
User-Visible: yes
2026-08-13 11:41:23 +03:00
Matysh 9ad01be813 ci: event-driven process pipeline for spec and code review
Adds .github/workflows/process.yml. A status label change is the trigger:
S4-spec-review runs the spec review, S7-code-review runs the code review,
and the verdict decides the next label. Only a green verdict advances;
yellow and red return the task to its author. Cycle limits (4, or 2 on the
light track) are counted from the verdicts already posted on the issue.

Labels are moved with HP_PROCESS_TOKEN, not GITHUB_TOKEN, so the change
emits an event and the chain continues.

Issue: #114
User-Visible: no
2026-08-13 11:34:01 +03:00
Matysh 495a99872b ci: event-driven process pipeline for spec and code review
Adds .github/workflows/process.yml. A status label change is the trigger:
S4-spec-review runs the spec review, S7-code-review runs the code review,
and the verdict decides the next label. Only a green verdict advances;
yellow and red return the task to its author. Cycle limits (4, or 2 on the
light track) are counted from the verdicts already posted on the issue.

Labels are moved with HP_PROCESS_TOKEN, not GITHUB_TOKEN, so the change
emits an event and the chain continues.

Issue: #114
User-Visible: no
2026-08-13 11:33:21 +03:00
Matysh 53da8a1773 docs: align AGENTS.md and PROCESS.md with the actual process
Publishes the 538-line process canon into the repository, replacing the
51-line provenance stub that pointed at a non-existent .agents/PROTOCOL.md.
Rewrites AGENTS.md: product context first, labels as the canonical status,
rule #1 with the status check, change classes, trailers, push cadence,
Codex/Claude roles and review cycle limits.

Issue: #112
User-Visible: no
2026-08-13 10:30:00 +03:00
Matysh f339398f56 build: finalize v1.62.0
Validate / golden (push) Failing after 9m28s
Validate / provenance (push) Successful in 42s
Validate / hacs (push) Failing after 15s
Validate / hassfest (push) Failing after 16s
Validate / frontend (push) Successful in 7m39s
Validate / backend (push) Failing after 8m22s
Validate / performance_smoke (push) Failing after 12m22s
Validate / smoke (push) Failing after 23m26s
Full Performance / performance (push) Failing after 44m19s
User-Visible: no
Issue: #108
2026-08-13 01:05:25 +03:00
Matysh ab609ab165 test: honor baseline fingerprint contracts
User-Visible: no
Issue: #108
2026-08-13 01:05:19 +03:00
Matysh e2b0fbfc06 build: promote v1.62.0
User-Visible: yes
Issue: #108
2026-08-13 00:54:10 +03:00
Matysh ce40c57a3b build: prepare v1.62.0-rc.1
User-Visible: yes
Issue: #108
2026-08-13 00:35:24 +03:00
Matysh 31d81ad4ef test: accept v1.62.0-beta.10 golden matrix
Release: v1.62.0-beta.10
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/31641638463
User-Visible: no
Issue: #108
2026-08-13 00:23:00 +03:00
Matysh 37032203dd fix: harden v1.62.0-beta.10 candidate
User-Visible: yes
Issue: #108
2026-08-13 00:14:51 +03:00
Matysh cf77d7d2e1 test: accept beta.9 diagonal opening baseline
Issue: #108
User-Visible: no
Release: v1.62.0-beta.9
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/31629590600
2026-08-12 21:56:39 +03:00
Matysh 8e2973fa7a Release v1.62.0-beta.9 candidate
Issue: #108
User-Visible: yes
2026-08-12 21:49:29 +03:00
Matysh 9bb5f7c5a8 test: accept beta.8 visual baselines
Issue: #75
Issue: #90
Issue: #98
User-Visible: no
Release: v1.62.0-beta.8
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/31619095298
2026-08-12 19:51:56 +03:00
Matysh 121c9f10b9 Fix default display hint translation
Issue: #98
User-Visible: yes
2026-08-12 19:44:22 +03:00
Matysh 661eb784fb Fix beta.8 validation regressions
Issue: #75
Issue: #95
Issue: #98
User-Visible: yes
2026-08-12 19:38:34 +03:00
Matysh 9e74051652 Release v1.62.0-beta.8 candidate
Issue: #75
Issue: #76
Issue: #95
User-Visible: yes
2026-08-12 19:18:54 +03:00
Matysh 01980ac3e6 Release v1.62.0-beta.7 candidate
Validate / performance_smoke (push) Failing after 9m34s
Validate / backend (push) Failing after 4m47s
Validate / smoke (push) Failing after 19m8s
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 11s
Validate / frontend (push) Successful in 4m20s
Validate / golden (push) Failing after 6m33s
2026-08-12 15:13:38 +03:00
Matysh 36e81e9fb1 Release v1.62.0-beta.6 candidate 2026-08-12 14:07:09 +03:00
Matysh 6d0f97ef82 test: accept beta.5 visual baselines 2026-08-12 13:10:46 +03:00
Matysh bd9b6a6d75 Stabilize beta.5 transition validation 2026-08-12 13:02:22 +03:00
Matysh 4b03b888ff Release v1.62.0-beta.5 candidate 2026-08-12 12:43:27 +03:00
Matysh 58d952e94e test: align beta.4 action smokes 2026-08-12 10:36:10 +03:00
Matysh 5d3f580df0 test: accept beta.4 help icon golden baselines 2026-08-12 10:26:56 +03:00
Matysh 4dc3fdef36 Release v1.62.0-beta.4 candidate 2026-08-12 10:19:51 +03:00
Matysh 8a417539e0 test: align beta.3 action smokes
Validate / hacs (push) Failing after 8s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 3m57s
Validate / backend (push) Failing after 5m9s
Validate / performance_smoke (push) Failing after 1m40s
Validate / golden (push) Failing after 1m44s
Validate / smoke (push) Failing after 12m18s
2026-08-12 02:42:57 +03:00
Matysh c41231a7ba Release v1.62.0-beta.3 candidate 2026-08-12 02:33:54 +03:00
Matysh 2158e5d3f6 test: align beta.2 visual and badge gates
Validate / hacs (push) Failing after 44s
Validate / hassfest (push) Failing after 1m19s
Validate / frontend (push) Successful in 4m49s
Validate / golden (push) Failing after 2m9s
Validate / smoke (push) Failing after 2m15s
Validate / backend (push) Failing after 7m27s
Validate / performance_smoke (push) Failing after 6m30s
2026-08-11 22:21:09 +03:00
Matysh 554d2e6544 Release v1.62.0-beta.2 candidate 2026-08-11 22:12:08 +03:00
Matysh 2cf5c2748e test: accept v1.62.0-beta.1 golden matrix 2026-08-11 19:37:53 +03:00
Matysh 59d028caf7 fix: wrap backup import confirmation 2026-08-11 19:30:56 +03:00
Matysh 4381f65fde test: restore wall thickness in golden fixture 2026-08-11 19:23:49 +03:00
Matysh 446f33ed31 Release v1.62.0-beta.1 candidate 2026-08-11 19:15:21 +03:00
Matysh 9419842333 fix(hacs): keep exactly one *manifest.json in the tree
Validate / hassfest (push) Failing after 14s
Validate / frontend (push) Successful in 8m18s
Validate / backend (push) Failing after 10m34s
Validate / smoke (push) Failing after 24m45s
Validate / performance_smoke (push) Failing after 9m42s
Full Performance / performance (push) Failing after 54m31s
Validate / golden (push) Failing after 6m50s
Validate / hacs (push) Failing after 11s
The HACS submission check does not read hacs.json to find the integration: it
globs `*manifest.json` over the whole clone of the default branch and exits 1
unless there is exactly one (hacs/default, scripts/helpers/integration_path.py).
Three files matched — the two stand-only integrations added on 2026-07-31 and
the golden baseline index added on 2026-08-11 — so the Hassfest job of PR #9004
went red five weeks into the review queue, with a log that named no file.

The stand manifests ship as manifest.template.json and demo/stand/install.sh
renames them at install time; the golden index becomes baselines-index.json
(the exported constant keeps its name, so no consumer changes).
test/repo-hygiene.test.mjs fails if a second manifest ever appears, and the
existing golden-policy assertion — which compared against 'manifest.json' and
happily passed on 'baseline-manifest.json' — now checks the suffix.
2026-08-11 14:03:38 +03:00
Matysh 02373bb31f Release v1.61.0
Validate / performance_smoke (push) Failing after 8m49s
Validate / hacs (push) Failing after 14s
Validate / hassfest (push) Failing after 12s
Validate / frontend (push) Successful in 7m12s
Validate / golden (push) Failing after 6m12s
Validate / backend (push) Failing after 11m16s
Validate / smoke (push) Failing after 25m49s
Full Performance / performance (push) Failing after 53m37s
2026-08-11 08:32:16 +03:00
Matysh 9a4ecbf961 test: keep Linux golden baselines authoritative 2026-08-11 05:44:58 +03:00
Matysh 48eebfd8a5 Release v1.61.0-beta.8 candidate 2026-08-11 05:40:32 +03:00
Matysh 5c5833d69a fix: stabilize prerelease signing checks 2026-08-11 03:14:56 +03:00
Matysh a41a75eba5 test: accept visual continuity golden matrix v8 2026-08-11 02:57:05 +03:00
Matysh 6f34c06d74 test: repair persisted golden marker fixture 2026-08-11 02:53:47 +03:00
Matysh c237baaffd Release v1.61.0-beta.7 candidate 2026-08-11 02:49:42 +03:00
Matysh d2bc908280 Release v1.61.0-beta.6 candidate 2026-08-11 01:14:59 +03:00
Matysh b0c29fb57f fix: accept skipped prerelease announcement
Validate / hacs (push) Failing after 14s
Validate / hassfest (push) Failing after 11s
Validate / frontend (push) Successful in 4m0s
Validate / backend (push) Failing after 5m37s
Validate / smoke (push) Failing after 2m25s
Validate / golden (push) Failing after 2m23s
Validate / performance_smoke (push) Failing after 35m49s
2026-08-10 18:18:20 +03:00
Matysh 37d71d8cbb test: accept light-source golden matrix v6 2026-08-10 18:05:04 +03:00
Matysh f8f1718ad2 Release v1.61.0-beta.5 candidate 2026-08-10 18:01:16 +03:00
Matysh c8b06996b1 Fix exact-blob prerelease artifacts
Validate / hacs (push) Failing after 15s
Validate / hassfest (push) Failing after 22s
Validate / frontend (push) Successful in 2m41s
Validate / backend (push) Failing after 7m59s
Validate / golden (push) Failing after 6m45s
Validate / smoke (push) Failing after 18m41s
Validate / performance (push) Failing after 2h36m42s
2026-08-10 15:21:18 +03:00
Matysh 1ed281b0dd Release v1.61.0-beta.4 candidate 2026-08-10 14:32:20 +03:00
Matysh d55816acaf test: accept custom fill golden matrix v5 2026-08-10 10:05:05 +03:00
Matysh cd55a8e897 Release v1.61.0-beta.3 candidate 2026-08-10 09:44:31 +03:00
Matysh 7d152e04f9 test: align smoke contracts with independent Glow
Validate / hacs (push) Failing after 11s
Validate / hassfest (push) Failing after 10s
Validate / frontend (push) Successful in 2m21s
Validate / backend (push) Failing after 8m22s
Validate / golden (push) Failing after 7m32s
Validate / smoke (push) Failing after 16m23s
Validate / performance (push) Failing after 1h35m4s
2026-08-10 00:35:23 +03:00
Matysh 764129a45c test: accept Glow golden matrix v4 2026-08-10 00:29:26 +03:00
Matysh fb382bfa11 Release v1.61.0-beta.2 candidate 2026-08-10 00:24:42 +03:00
Matysh 112c260314 Release v1.61.0-beta.1
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 3m2s
Validate / golden (push) Failing after 51s
Validate / backend (push) Failing after 6m50s
Validate / smoke (push) Failing after 13m44s
Validate / performance (push) Failing after 24m13s
2026-08-09 21:51:33 +03:00
Matysh 5814cfe0c2 ci: handle first performance-gated main promotion
Validate / smoke (push) Failing after 19m59s
Validate / hacs (push) Failing after 26s
Validate / hassfest (push) Failing after 22s
Validate / frontend (push) Successful in 3m58s
Validate / golden (push) Failing after 1m8s
Validate / backend (push) Failing after 10m47s
Validate / performance (push) Failing after 25m35s
2026-08-09 15:44:05 +03:00
Matysh d839eb88eb test: accept editor tray golden baselines 2026-08-09 15:30:14 +03:00
Matysh bf5a040508 Release v1.60.3 2026-08-09 15:20:50 +03:00
Matysh faa1f4ea9a fix: settle editor chrome and opening render regressions 2026-08-09 15:14:58 +03:00
Matysh caf3c44ad9 ci: validate branch pushes without duplicate tag runs 2026-08-09 08:52:28 +03:00
Matysh 08b19363fd ci: make performance gate runner-noise aware 2026-08-09 08:50:26 +03:00
Matysh d5e6c5cff0 test: accept editor context tray golden baselines 2026-08-09 08:40:57 +03:00
Matysh c85dbaf4cb v1.60.3-beta.2: stabilize editor context tray 2026-08-09 08:34:05 +03:00
Matysh b1deb0beab Avoid hassfest manifest collision
Validate / hacs (push) Failing after 8s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 3m52s
Validate / backend (push) Failing after 8m33s
Validate / golden (push) Failing after 6m9s
Validate / smoke (push) Failing after 22m51s
2026-08-08 22:03:09 +03:00
Matysh 9590011ddb Add reviewed Linux golden baselines 2026-08-08 21:52:26 +03:00
Matysh ddbd3288fe Fix prerelease smoke regressions 2026-08-08 21:42:49 +03:00
Matysh e177c14603 v1.60.3-beta.1: harden rendering and QA tooling 2026-08-08 21:33:09 +03:00
Matysh 164f7cf76a chore(hacs): zip_release + workflow attaching houseplan.zip to every release
HACS will install from the named asset instead of the auto zipball, so
GitHub's public download counter becomes a per-version install metric.
The workflow also has a manual dispatch to backfill an existing release.
2026-08-08 19:43:24 +03:00
Matysh 2219700d63 Release v1.60.2
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 12s
Validate / frontend (push) Successful in 3m58s
Validate / backend (push) Failing after 8m11s
Validate / smoke (push) Failing after 22m39s
2026-08-08 17:39:25 +03:00
Matysh 854a6944a0 test: align smokes with active registry projection 2026-08-08 15:55:33 +03:00
Matysh 2a8302f4d6 v1.60.2-beta.3: unify boundaries and device presentation 2026-08-08 15:46:07 +03:00
Matysh f1537b2108 v1.60.2-beta.2: harden device state and editor interactions
Validate / hassfest (push) Failing after 19s
Validate / hacs (push) Failing after 20s
Validate / frontend (push) Successful in 3m2s
Validate / backend (push) Failing after 9m17s
Validate / smoke (push) Failing after 14m1s
2026-08-08 00:12:47 +03:00
Matysh 5e1315f61d ci: announce releases and prereleases in the Telegram chat
Validate / hacs (push) Failing after 20s
Validate / hassfest (push) Failing after 23s
Validate / frontend (push) Successful in 3m38s
Validate / smoke (push) Failing after 53s
Validate / backend (push) Failing after 7m14s
2026-08-07 22:35:10 +03:00
Matysh 4e29db1afb test: settle zoom baseline after editor collapse 2026-08-07 22:18:03 +03:00
Matysh 953063b984 fix: keep wall thickness beside room outline 2026-08-07 22:09:42 +03:00
Matysh d48d220a8c v1.60.2-beta.1: add persistent physical geometry 2026-08-07 22:02:41 +03:00
Matysh 1c949ae49d test: poll navigation background transitions
Validate / hacs (push) Failing after 11s
Validate / hassfest (push) Failing after 9s
Validate / frontend (push) Successful in 4m38s
Validate / backend (push) Failing after 10m49s
Validate / smoke (push) Failing after 25m55s
2026-08-07 14:39:24 +03:00
Matysh 9956a6cfe6 test: settle navigation transitions in browser smokes 2026-08-07 14:31:02 +03:00
Matysh fe5f5b6a24 v1.60.1-beta.1: harden editing and device state 2026-08-07 14:19:02 +03:00
Matysh 6db9eb0a66 test: use access token for non-admin websocket
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 4m33s
Validate / backend (push) Failing after 10m31s
Validate / smoke (push) Failing after 20m7s
2026-08-07 13:10:27 +03:00
Matysh 3028122016 v1.60.0: harden background editing and device state 2026-08-07 13:02:46 +03:00
Matysh 29fb9deb43 v1.60.0-beta.1: unify background editing and device deletion 2026-08-07 11:14:20 +03:00
Matysh 6a9122f41f v1.59.2: make dialogs accessible 2026-08-07 07:47:37 +03:00
Matysh 25f43da1bd v1.59.1: unify device and light state
Validate / smoke (push) Failing after 19m18s
Validate / hacs (push) Failing after 1m11s
Validate / hassfest (push) Failing after 1m37s
Validate / frontend (push) Successful in 3m0s
Validate / backend (push) Failing after 15m20s
2026-08-06 21:45:18 +03:00
488 changed files with 99544 additions and 8638 deletions
+14
View File
@@ -0,0 +1,14 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.pdf binary
*.mp4 binary
*.webm binary
*.zip binary
*.woff binary
*.woff2 binary
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
message_file=$1
# Git-generated merge commits do not represent an independently authored
# product change and inherit provenance from their parents.
case "${message_file##*/}" in
MERGE_MSG) exit 0 ;;
esac
repo_root=$(git rev-parse --show-toplevel)
node "$repo_root/scripts/validate-commit-provenance.mjs" \
--message-file "$message_file" --staged --check-hook-mode
+80
View File
@@ -0,0 +1,80 @@
#!/bin/sh
set -eu
# PROCESS.md 10.1: the blocking process gate lives here, because commits go
# straight to dev without pull requests and GitHub blocks nothing on its side.
# CI still runs the same script (10.3), but by then the code is already in dev —
# that catch-up pass reports, it does not prevent.
#
# Git feeds one line per ref on stdin:
# <local ref> <local sha> <remote ref> <remote sha>
repo_root=$(git rev-parse --show-toplevel)
gate="$repo_root/scripts/process-gate.mjs"
zero=$(printf '%040d' 0)
# The gate reasons about commits. A repository without it — an old checkout, a
# bisect, a worktree from before the script existed — must still be pushable.
if [ ! -f "$gate" ]; then
exit 0
fi
# Reading issue status needs gh, and a hook that cannot work on a train is a
# hook people disable. Offline the checks that need no network still run, and the
# strict pass happens in CI, where gh is always present.
issues_flag=""
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
issues_flag="--issues"
else
echo "process-gate: gh недоступен, проверка статуса issue пропущена — её выполнит CI" >&2
fi
status=0
while read -r local_ref local_sha remote_ref remote_sha; do
# Deleting a remote branch pushes nothing to examine.
if [ "$local_sha" = "$zero" ]; then
continue
fi
# Tags carry no process state of their own: the commit they point at was
# already checked when it was pushed.
case "$local_ref" in
refs/tags/*) continue ;;
esac
if [ "$remote_sha" = "$zero" ]; then
# A branch that does not exist on the remote yet. Everything it adds on top
# of dev is new, so that is the range — not the whole history, which would
# drag in every violation committed before the gate existed.
base=$(git merge-base "$local_sha" refs/remotes/origin/dev 2>/dev/null || true)
if [ -z "$base" ]; then
echo "process-gate: не нашёл общего предка с origin/dev, проверяю последние 20 коммитов" >&2
base="$local_sha~20"
fi
else
base="$remote_sha"
fi
echo "process-gate: $local_ref, диапазон ${base}..${local_sha}" >&2
# shellcheck disable=SC2086
if ! node "$gate" --range "${base}..${local_sha}" --target-ref "$remote_ref" $issues_flag >&2; then
status=1
fi
done
if [ "$status" -ne 0 ]; then
cat >&2 <<'EOF'
Push остановлен: нарушен процесс (PROCESS.md §10.2).
Починить надо причину, а не симптом. Если нарушение уже опубликовано, его
исправляет следующий коммит плюс issue с меткой `process` — не force-push
(§12, правило 17).
Обойти проверку можно через `git push --no-verify`, и тогда то же самое найдёт
job `process-gate` в Validate — уже после того, как код окажется в dev.
EOF
fi
exit "$status"
+95
View File
@@ -0,0 +1,95 @@
name: Announce release
# Telegram notifications for t.me/ha_houseplan (owner request, 2026-08-07).
# Stable releases are announced; prereleases are deliberately silent.
# workflow_dispatch exists purely as a connectivity test button and therefore
# remains allowed to send a test message.
on:
release:
types: [published]
workflow_dispatch: {}
workflow_call:
inputs:
reusable:
required: true
type: boolean
tag:
required: true
type: string
release_name:
required: true
type: string
url:
required: true
type: string
prerelease:
required: true
type: boolean
ref:
required: true
type: string
secrets:
TELEGRAM_BOT_TOKEN:
required: true
TELEGRAM_CHAT_ID:
required: true
permissions:
contents: read
jobs:
telegram:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.prerelease == false) || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
runs-on: ubuntu-latest
steps:
- name: Check out release notes for a reusable call
if: ${{ inputs.reusable == true }}
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- name: Send to Telegram
env:
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
CALLED: ${{ inputs.reusable }}
INPUT_TAG: ${{ inputs.tag }}
INPUT_NAME: ${{ inputs.release_name }}
INPUT_URL: ${{ inputs.url }}
INPUT_PRE: ${{ inputs.prerelease }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
RELEASE_NAME: ${{ github.event.release.name }}
RELEASE_URL: ${{ github.event.release.html_url }}
RELEASE_PRE: ${{ github.event.release.prerelease }}
# The body goes through env, never through shell interpolation —
# release notes are arbitrary text.
RELEASE_BODY: ${{ github.event.release.body }}
EVENT: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT" = "workflow_dispatch" ] && [ "$CALLED" != "true" ]; then
TEXT="✅ Тест: оповещения о релизах houseplan-card подключены."
else
if [ "$CALLED" = "true" ]; then
TAG=$INPUT_TAG
NAME=$INPUT_NAME
URL=$INPUT_URL
PRE=$INPUT_PRE
BODY=$(cat docs/RELEASE-NOTES.md)
else
TAG=$RELEASE_TAG
NAME=$RELEASE_NAME
URL=$RELEASE_URL
PRE=$RELEASE_PRE
BODY=$RELEASE_BODY
fi
if [ "$PRE" = "true" ]; then
echo "Prerelease Telegram announcement is disabled"
exit 0
fi
KIND="🏠 Релиз"
SUMMARY=$(printf '%s' "$BODY" | head -c 2500)
TEXT=$(printf '%s houseplan-card %s — %s\n\n%s\n\n%s' \
"$KIND" "$TAG" "$NAME" "$SUMMARY" "$URL")
fi
curl -sS --fail-with-body -X POST \
"https://api.telegram.org/bot$TOKEN/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=$TEXT" \
-d disable_web_page_preview=true
+61
View File
@@ -0,0 +1,61 @@
name: Mutation gate
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
# защитой, и это хуже его отсутствия.
#
# Прогон дорогой: пересборка бандла на каждого мутанта. Поэтому он не входит в
# Validate и не идёт на каждый push. Его место — перед стабильным релизом
# (PROCESS.md §8) и раз в неделю по расписанию, чтобы дрейф тестов не копился
# до релиза. Дешёвая половина — «якоря патчей живы, guard-файлы существуют» —
# идёт с обычными юнитами: test/mutation-gate.test.mjs.
on:
workflow_dispatch:
schedule:
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
- cron: '20 5 * * 1'
permissions:
contents: read
concurrency:
group: mutation-gate
cancel-in-progress: true
jobs:
mutants:
runs-on: ubuntu-latest
# Шесть мутантов × (сборка + браузерный смок) — это десятки минут, и это
# нормально: гейт предрелизный. Час — потолок против зависшего Chromium.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
ref: dev
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Реестр применим к текущему коду
run: node scripts/mutation-gate.mjs --check
- name: Каждый тест ловит свою поломку
run: node scripts/mutation-gate.mjs
+178
View File
@@ -0,0 +1,178 @@
name: Full Performance
on:
# Every main promotion is a stable-release candidate and must have an
# exact-SHA full comparison before stable assets are published.
push:
branches:
- main
schedule:
- cron: "0 4 * * 1"
workflow_dispatch:
inputs:
comparison_ref:
description: "Optional baseline tag, branch or SHA; empty uses the candidate parent"
required: false
type: string
permissions:
contents: read
concurrency:
group: full-performance-${{ github.ref }}
cancel-in-progress: false
jobs:
performance:
# Base and candidate stay sequential on one hosted runner. Splitting them
# across runners would turn machine variance into a false regression.
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Check out candidate
uses: actions/checkout@v4
with:
path: candidate
fetch-depth: 2
- name: Resolve comparison SHA
id: base
working-directory: candidate
env:
EVENT_NAME: ${{ github.event_name }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
MANUAL_BASE: ${{ inputs.comparison_ref }}
run: |
set -euo pipefail
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --force --tags --prune --unshallow origin
else
git fetch --force --tags --prune origin
fi
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -n "$MANUAL_BASE" ]; then
sha="$(git rev-parse "${MANUAL_BASE}^{commit}" 2>/dev/null || true)"
source="manual comparison ref $MANUAL_BASE"
elif [ "$EVENT_NAME" = "push" ] && [ -n "$PUSH_BEFORE_SHA" ] && ! printf '%s' "$PUSH_BEFORE_SHA" | grep -Eq '^0+$'; then
sha="$PUSH_BEFORE_SHA"
source="push before"
else
sha="$(git rev-parse HEAD^ 2>/dev/null || true)"
source="candidate parent"
fi
requested_sha="$sha"
usable=true
reason=""
if [ -z "$sha" ] || ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then
usable=false
reason="commit is not present after fetching all remote refs"
elif [ "$source" = "push before" ] && ! git merge-base --is-ancestor "$sha" HEAD; then
usable=false
reason="commit is no longer an ancestor of the pushed revision"
fi
if [ "$usable" != true ]; then
parent_sha="$(git rev-parse HEAD^ 2>/dev/null || true)"
if [ -n "$parent_sha" ] && [ "$parent_sha" != "$(git rev-parse HEAD)" ]; then
sha="$parent_sha"
source="candidate parent (unusable requested-base fallback)"
echo "::warning::Comparison SHA ${requested_sha:-none} is unusable ($reason); using candidate parent $sha."
usable=true
fi
fi
if [ "$usable" != true ]; then
fallback_tag=""
fallback_sha=""
head_sha="$(git rev-parse HEAD)"
while IFS= read -r tag; do
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) continue ;;
esac
tag_sha="$(git rev-list -n 1 "$tag")"
if [ "$tag_sha" != "$head_sha" ]; then
fallback_tag="$tag"
fallback_sha="$tag_sha"
break
fi
done < <(git tag --merged HEAD --sort=-version:refname)
if [ -z "$fallback_sha" ]; then
echo "::error::No usable comparison commit or previous release tag is reachable from HEAD."
exit 1
fi
sha="$fallback_sha"
source="release tag $fallback_tag"
echo "::warning::Using $fallback_tag ($sha) as the comparison base."
fi
if ! git cat-file -e "${sha}:demo/bundle-freshness.mjs" 2>/dev/null; then
echo "::warning::Comparison $sha predates HP-PERF-01; using candidate parent HEAD^."
sha="$(git rev-parse HEAD^)"
source="candidate parent (HP-PERF-01 compatibility)"
fi
echo "sha=$sha" >> "$GITHUB_OUTPUT"
echo "Comparison base: $sha ($source)" >> "$GITHUB_STEP_SUMMARY"
- name: Check out base SHA
uses: actions/checkout@v4
with:
ref: ${{ steps.base.outputs.sha }}
path: baseline
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: |
candidate/package-lock.json
baseline/package-lock.json
- name: Install candidate and baseline dependencies
run: npm ci --prefix candidate && npm ci --prefix baseline
- name: Install pinned Chromium
working-directory: candidate
run: npx playwright install --with-deps chromium
- name: Build both exact source trees
run: |
npm --prefix candidate run build
cp candidate/dist/houseplan-card.js candidate/demo/srv/assets/houseplan-card.js
npm --prefix baseline run build
cp baseline/dist/houseplan-card.js baseline/demo/srv/assets/houseplan-card.js
- name: Capture base and candidate profiles
working-directory: candidate
run: |
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/overlay-candidate.json
if ! grep -q "glow_enabled" ../baseline/src/logic.ts; then
echo "Base predates independent Glow; bootstrap relative overlay baseline, keep absolute gate"
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
fi
- name: Enforce relative and absolute performance budgets
working-directory: candidate
run: |
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
- name: Upload full performance reports
if: always()
uses: actions/upload-artifact@v4
with:
name: full-performance
path: artifacts/performance
+456
View File
@@ -0,0 +1,456 @@
name: Process
# Событийный конвейер процесса (PROCESS.md). Смена статусной метки — это
# сообщение: она порождает событие, событие запускает следующий шаг.
#
# S4-spec-review -> ревью ТЗ -> S5-ready | S3-spec
# S7-code-review -> код-ревью -> слияние в dev -> S8-merged | S6-in-progress
#
# Три вещи, без которых конвейер молча не работает:
#
# 1. Метки переставляются токеном HP_PROCESS_TOKEN, а не GITHUB_TOKEN. GitHub
# намеренно не запускает workflow от событий, вызванных GITHUB_TOKEN, чтобы
# не было циклов — цепочка оборвалась бы после первого шага.
# 2. Этот файл обязан лежать в ветке по умолчанию (main). Для события `issues`
# GitHub берёт workflow только оттуда, независимо от того, что в dev.
# 3. Многострочный текст внутри `run:` — только через heredoc. Строка с нулевым
# отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
# Проверять не только YAML, но и каждый `run` через `bash -n`.
on:
issues:
types: [labeled]
concurrency:
# Два события по одному issue не должны запускать два прогона.
group: process-issue-${{ github.event.issue.number }}
cancel-in-progress: false
permissions:
contents: read
issues: write
# Обязательно: claude-code-action получает OIDC-токен для авторизации
# GitHub App. Без этого прогон падает с «Could not fetch an OIDC token».
id-token: write
jobs:
guard:
runs-on: ubuntu-latest
outputs:
stage: ${{ steps.decide.outputs.stage }}
cycle: ${{ steps.decide.outputs.cycle }}
limit: ${{ steps.decide.outputs.limit }}
steps:
- id: decide
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
LABEL: ${{ github.event.label.name }}
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
SMALL: ${{ contains(github.event.issue.labels.*.name, 'small') }}
TRIVIAL: ${{ contains(github.event.issue.labels.*.name, 'trivial') }}
NUM: ${{ github.event.issue.number }}
run: |
# Этап определяется первым: от него зависит, какие вердикты считать.
stage=""; marker=""
case "$LABEL" in
S4-spec-review) stage="spec"; marker="SPEC-REVIEW" ;;
S7-code-review) stage="code"; marker="CODE-REVIEW" ;;
*) echo "метка $LABEL конвейер не запускает" ;;
esac
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
limit=4
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89
# первое код-ревью получило r2/4. На задаче с двумя циклами ТЗ второе
# код-ревью упиралось бы в review-4 после одной правки.
#
# Этап опознаётся по имени документа в теле комментария. Если документа
# нет, вердикт не посчитается — недосчёт даёт лишний цикл, а перерасчёт
# остановил бы работу досрочно; из двух ошибок выбрана обратимая.
done_cycles=0
if [ -n "$stage" ]; then
done_cycles=$(gh issue view "$NUM" --repo "${{ github.repository }}" \
--json comments \
-q "[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))] | length")
fi
# Отказ обязан быть виден в issue, а не только в логе прогона.
# Ревьюшная метка обещает работу; если конвейер её не начал и промолчал,
# задача стоит в этом статусе бесконечно и никто об этом не узнаёт.
# Так и вышло на #123: чужой issue довели до S4-spec-review, guard
# отказался за 9 секунд, и в issue не было ни слова.
#
# Пишем только когда пытались запустить ревью, то есть stage опознан.
# Иначе комментарий уходил бы на каждую смену любой метки.
refuse() {
echo "$1"
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
"Конвейер ревью не запущен: $2
Метка \`$LABEL\` обещает работу, которая не начнётся, поэтому статус лучше вернуть в предыдущий — иначе задача простоит здесь бесконечно. [Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
stage=""
}
# Автор issue здесь не проверяется (решение владельца 2026-08-13).
# Проверка стоит на входе в процесс, а не на каждом шаге: как только
# задача получила статусную метку, она в работе, и кто её завёл — не
# имеет значения. Само присвоение метки и есть явное подтверждение
# владельца, причём проверенное платформой: метки может ставить только
# тот, у кого есть право записи в репозиторий. Прежняя проверка здесь
# дублировала эту гарантию и заставляла переоформлять чужие отчёты
# своими issue — чистая работа впустую, как на #123.
if [ -z "$stage" ]; then
:
elif [ "$BLOCKED" = "true" ]; then
refuse "стоит blocked — конвейер не запускается" \
"на issue стоит \`blocked\` — задача ждёт внешнего решения. Снять метку, когда решение принято."
elif [ "$EXHAUSTED" = "true" ]; then
refuse "стоит review-4 — решение за владельцем" \
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
elif [ "$done_cycles" -ge "$limit" ]; then
echo "циклов этапа $stage пройдено $done_cycles из $limit — лимит исчерпан"
gh issue edit "$NUM" --repo "${{ github.repository }}" --add-label review-4
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
"Лимит циклов ревью исчерпан ($done_cycles из $limit на этапе \`$stage\`). Пятого захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
stage=""
else
echo "этап $stage, цикл $((done_cycles + 1)) из $limit"
fi
echo "stage=$stage" >> "$GITHUB_OUTPUT"
echo "cycle=$((done_cycles + 1))" >> "$GITHUB_OUTPUT"
echo "limit=$limit" >> "$GITHUB_OUTPUT"
review:
needs: guard
if: needs.guard.outputs.stage != ''
runs-on: ubuntu-latest
# Время — единственный настоящий ограничитель зациклившегося прогона.
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: dev
# Окружение готовит workflow, а не модель своими ходами. Раньше промпт
# велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
# тяжёлых job, здесь его не было.
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
# ревьюер прочтёт dev и не найдёт того, что должен оценивать.
- name: Перейти на ветку задачи
id: branch
env:
NUM: ${{ github.event.issue.number }}
run: |
branch=$(git ls-remote --heads origin "issue/${NUM}-*" \
| head -1 | sed 's|.*refs/heads/||')
if [ -n "$branch" ]; then
git checkout -q "origin/$branch"
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
echo "name=$branch" >> "$GITHUB_OUTPUT"
else
echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev"
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
fi
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
- name: Установить зависимости
run: npm ci
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
# но когда нужен — качать его заново дороже, чем держать в кэше.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Review
id: review
uses: anthropics/claude-code-action@v1
with:
# Подписка, а не отдельный счёт API: токен выпускается через
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
Ты ревьюер проекта House Plan. Язык ответа — русский.
Issue: #${{ github.event.issue.number }}
Репозиторий: ${{ github.repository }}
Этап: ${{ needs.guard.outputs.stage }}
spec — ревью ТЗ (PROCESS.md §2.4)
code — код-ревью (PROCESS.md §2.7)
Прочитай в этом порядке, прежде чем судить:
1. docs/SCOPE.md — зачем продукт существует и для кого. Он
ограничитель: «features are built, improved and accepted only
if they serve a job listed here». Первый вопрос к задаче —
какую строку Core user jobs она закрывает.
2. AGENTS.md и PROCESS.md — процесс, классы изменений, трейлеры,
лимит циклов, формат вердикта.
3. Тело issue #${{ github.event.issue.number }} и все комментарии.
4. Если меняется видимое поведение — docs/USER-GUIDE.ru.md:
терминология интерфейса берётся оттуда, а не изобретается.
5. Канонический документ затронутой подсистемы: docs/SUN.md,
LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md,
CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md.
Для этапа spec: если issue помечен small, ТЗ живёт в теле issue и
файла в docs/specs/ быть не должно. Иначе ТЗ — docs/specs/<NN>-*.md.
Проверь обязательные разделы §7.1, однозначность каждого AC и
указание способа доказательства. Отдельно проверь, что автор не
выдал догадку за решение: утверждение о поведении, которого нет ни
в одном документе и которое не помечено как предположение, —
замечание. Не бывает сложной задачи без единого открытого вопроса.
Владельцу задаются только продуктовые вопросы: что человек видит или
делает и каков объём видимых изменений в этом issue. Технический
вопрос, вынесенный владельцу, — тоже замечание: ты его снимаешь и
решаешь по существу в своём вердикте.
Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD`
и `git diff origin/dev...HEAD`. Ручного тестирования в цикле нет,
поэтому именно ты отвечаешь на вопрос «оно вообще работает».
По каждому AC: либо он доказан автотестом и ты убедился, что тест
умеет падать, либо разобран по коду с явной записью «проверено
чтением, не исполнением». «Verified» без названной команды и её
результата доказательством не является. Зависимости уже установлены
workflow, Chromium тоже — `npm ci` выполнять не нужно. Проверь
трейлеры Issue и User-Visible, при User-Visible: yes — правки в оба
changelog в том же коммите.
**Объём гейтов соразмерен задаче.** Прогонять весь набор на каждой
правке — не тщательность, а потеря времени: полные наборы это
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
Всегда, они дешёвые:
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
копий бандла.
По необходимости, и «необходимость» определяется diff'ом и AC:
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс
относящиеся к тронутым поверхностям. Их 127; прогон всех уместен
только когда задача действительно задевает всё;
- `npm run golden:verify` — если diff может изменить видимый
результат: рендер, геометрия, стили, слои;
- `python -m pytest tests_backend -q` — если тронут
`custom_components/**/*.py`;
- performance-профили — если названы в AC либо тронуты
чувствительные к перфу пути.
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
тем тестам, которые ты прогонял.
**В комментарии обязателен перечень: какие гейты прогнал, какие нет и
почему.** Это условие честности такого сужения: непрогнанный гейт
становится видимым решением, а не молчаливым пропуском. Раздел «чего
не проверял» в документе ревью — не формальность, а главный его
раздел на коротких задачах.
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
Серьёзность: High блокирует; Medium обязан стать отдельным issue;
Low либо правится, либо снимается с записью. Жёлтый вердикт
допустим при полностью выполненных AC, если изменение не решает
заявленный сценарий или ухудшает смежный. Продуктовое рассуждение
расширяет вопросы, но не отменяет AC и не даёт права менять скоуп.
Каждую Medium-находку заведи отдельным issue со ссылкой на
#${{ github.event.issue.number }} и метками: тип, приоритет,
S1-new. «Оставили в тексте ревью» закрытием не считается и прямо
запрещено §12.
Напиши полный документ ревью в файл
docs/reviews/<SPEC|CODE>-REVIEW-${{ github.event.issue.number }}-r${{ needs.guard.outputs.cycle }}.md
(SPEC для этапа spec, CODE для code): скоуп, как проверялось,
находки с воспроизведением, что проверено и корректно, чего не
проверял. Каталог docs/reviews/ создай, если его нет. Больше не
пиши ничего: любой файл вне docs/reviews/ опубликован не будет.
Затем оставь в issue краткий комментарий: вердикт, ключевые находки
и ссылка на документ. Первой строкой — вердикт в формате §7.2:
`Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → #…`
Затем верни JSON по схеме. Это последнее действие и оно обязательно:
без него метка не переставится и конвейер встанет.
claude_args: |
--max-turns 150
--allowedTools Read,Write,Grep,Glob,Bash,mcp__github__add_issue_comment,mcp__github__issue_write,mcp__github__issue_read
--json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["green","yellow","red"]},"high":{"type":"integer"},"medium":{"type":"integer"},"summary":{"type":"string"}},"required":["verdict","high","medium","summary"]}'
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
# решает этот шаг, а не модель: всё остальное откатывается.
- name: Опубликовать документ ревью
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
run: |
# Ветки задачи может не быть: у задач, размеченных до появления
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
# комментарием. Это тот же тихий отказ: шаг сообщал об успехе тем, что
# ничего не сделал. Документ ложится туда же, где лежит само ТЗ.
target="${BRANCH:-dev}"
if [ -z "$BRANCH" ]; then
echo "::warning::ветки задачи нет — документ ревью ляжет в dev"
fi
git checkout -- . 2>/dev/null || true
git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true
git add docs/reviews 2>/dev/null || true
if git diff --cached --quiet; then
echo "::warning::документ ревью не создан"
exit 0
fi
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
commit -q -F - <<EOF
docs: review document for #$NUM
Issue: #$NUM
User-Visible: no
EOF
# Публикация в dev идёт из детачнутого состояния поверх ветки задачи
# либо dev, поэтому push нужен с явным перебазированием при гонке:
# dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут.
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"; then
git fetch -q origin "$target"
if ! git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
rebase "origin/$target"; then
git rebase --abort || true
echo "::error::документ ревью не удалось опубликовать в $target: конфликт"
exit 0
fi
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"
fi
echo "документ опубликован в $target"
- name: Решение по вердикту
id: decide
env:
OUT: ${{ steps.review.outputs.structured_output }}
STAGE: ${{ needs.guard.outputs.stage }}
run: |
verdict=$(echo "$OUT" | jq -r '.verdict')
high=$(echo "$OUT" | jq -r '.high')
echo "вердикт: $verdict, High: $high"
# Вперёд двигает ТОЛЬКО зелёный. Жёлтый и красный возвращают
# автору: на прогоне #111 жёлтый означал, что AC описывает неверное
# изменение контракта — реализовать такое ТЗ значит сделать ошибку
# по инструкции. Оба считаются циклом.
if [ "$verdict" = "green" ] && [ "$high" -eq 0 ]; then
green=true
case "$STAGE" in
spec) from=S4-spec-review; to=S5-ready ;;
code) from=S7-code-review; to=S8-merged ;;
esac
else
green=false
case "$STAGE" in
spec) from=S4-spec-review; to=S3-spec ;;
code) from=S7-code-review; to=S6-in-progress ;;
esac
fi
echo "green=$green" >> "$GITHUB_OUTPUT"
echo "from=$from" >> "$GITHUB_OUTPUT"
echo "to=$to" >> "$GITHUB_OUTPUT"
# S8-merged утверждает, что код в dev. Значит слияние обязано произойти
# ДО метки, иначе она врёт в промежутке.
#
# При конфликте шаг НЕ падает и метку не оставляет на месте. Первая
# редакция делала именно так, и это оказалось тупиком: автор ждёт смену
# метки, метка не менялась, и он тридцать раз опрашивал впустую, чтобы
# затем отчитаться «лимит исчерпан» — при зелёном вердикте. Инвариант
# теперь жёстче: ПОСЛЕ ПРОГОНА РЕВЬЮ МЕТКА МЕНЯЕТСЯ ВСЕГДА.
- name: Слить ветку в dev
id: merge
if: needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true'
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
run: |
if [ -z "$BRANCH" ]; then
echo "::error::ветки задачи нет — сливать нечего"
echo "merged=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git fetch -q origin dev
git checkout -q -B merge-into-dev "origin/$BRANCH"
if ! git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
rebase origin/dev; then
git rebase --abort || true
echo "merged=false" >> "$GITHUB_OUTPUT"
echo "::warning::ветка $BRANCH не сливается в dev без конфликта"
cat > /tmp/conflict.md <<EOF
**Код-ревью зелёное — вердикт выше в силе, переделывать работу не нужно.** Не удалось только слияние: ветка \`$BRANCH\` конфликтует с \`dev\`.
Задача переведена в \`S6-in-progress\`, потому что работа вернулась к автору. Осталась не правка кода, а ребейз:
1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;
2. запушить ветку;
3. вернуть метку \`S7-code-review\`.
Повторный прогон ревью — не формальность: после ребейза на новый \`dev\` это другой код, и принимать его без проверки нельзя. Цикл считается по этапу, лимит на код-ревью тратится отдельно от ревью ТЗ.
EOF
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/conflict.md
exit 0
fi
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev
echo "merged=true" >> "$GITHUB_OUTPUT"
echo "слито в dev: $(git rev-parse --short HEAD)"
- name: Переставить метку
env:
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
FROM: ${{ steps.decide.outputs.from }}
# Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к
# автору: метка утверждала бы, что код в dev, а его там нет.
TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to }}
run: |
gh issue edit "$NUM" --repo "${{ github.repository }}" \
--add-label "$TO" --remove-label "$FROM"
echo "$FROM -> $TO"
- name: Позвать владельца, если ревью упало
if: failure()
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# Тело через heredoc, а не многострочный --body: строка с нулевым
# отступом обрывает блок YAML и оставляет незакрытую кавычку.
cat > /tmp/failure.md <<EOF
Автоматическое ревью не отработало: [прогон]($RUN_URL). Статусная метка не менялась, задача осталась на месте.
Если вердикт выше всё же опубликован — сбой произошёл после него. Перестановку метки в этом случае выполняет чат обслуживания или владелец, но не автор задачи: автор не толкует вердикт о своей же работе.
EOF
gh issue comment "${{ github.event.issue.number }}" \
--repo "${{ github.repository }}" --body-file /tmp/failure.md
+259
View File
@@ -0,0 +1,259 @@
name: Publish prerelease
run-name: Publish ${{ inputs.tag }}
on:
workflow_dispatch:
inputs:
tag:
description: "Exact prerelease tag, for example v1.61.0-beta.4"
required: true
type: string
permissions:
contents: write
actions: read
concurrency:
group: publish-prerelease-${{ inputs.tag }}
cancel-in-progress: false
jobs:
gate:
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.candidate.outputs.sha }}
tag: ${{ steps.candidate.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
fetch-depth: 0
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Pin the current dev candidate
id: candidate
env:
TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
test "$REF_NAME" = "dev" || {
echo "::error::Prereleases must be dispatched from the dev branch, got $REF_NAME"
exit 1
}
SHA=$(git rev-parse HEAD)
git fetch origin dev
test "$(git rev-parse origin/dev)" = "$SHA" || {
echo "::error::The dispatched SHA is no longer the origin/dev tip"
exit 1
}
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Verify version, changelogs and bilingual release notes
env:
TAG: ${{ inputs.tag }}
run: node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
- name: Require green Validate for this exact SHA
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ steps.candidate.outputs.sha }}
run: node scripts/release-gate.mjs "$SHA"
publish:
needs: gate
runs-on: ubuntu-latest
outputs:
url: ${{ steps.verify.outputs.url }}
newly_published: ${{ steps.release.outputs.newly_published }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.gate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Build and verify both release assets before publication
env:
TAG: ${{ needs.gate.outputs.tag }}
run: |
set -euo pipefail
npm ci
npm run build
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
VERSION=${TAG#v}
grep -Fq "$VERSION" dist/houseplan-card.js
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
unzip -l houseplan.zip | grep -q "manifest.json"
ZIP_VERSION=$(unzip -p houseplan.zip manifest.json | node -e \
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).version))")
test "$ZIP_VERSION" = "$VERSION" || {
echo "::error::houseplan.zip manifest version $ZIP_VERSION != $VERSION"
exit 1
}
test -s dist/houseplan-card.js
test -s houseplan.zip
- name: Create or verify the annotated tag
env:
TAG: ${{ needs.gate.outputs.tag }}
SHA: ${{ needs.gate.outputs.sha }}
run: |
set -euo pipefail
REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}")
if [ -n "$REMOTE" ]; then
PEELED=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}')
test -n "$PEELED" || {
echo "::error::Existing remote tag $TAG is not annotated"
exit 1
}
test "$PEELED" = "$SHA" || {
echo "::error::Existing tag $TAG points to $PEELED, expected $SHA"
exit 1
}
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
test "$(git cat-file -t "refs/tags/$TAG")" = "tag"
else
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" "$SHA" -m "$TAG"
git push origin "$TAG"
fi
- name: Stage, verify and publish the prerelease
id: release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.gate.outputs.tag }}
run: |
set -euo pipefail
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \
--draft --prerelease --title "$TAG" --notes-file docs/RELEASE-NOTES.md
fi
WAS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)
echo "newly_published=$WAS_DRAFT" >> "$GITHUB_OUTPUT"
gh release upload "$TAG" dist/houseplan-card.js houseplan.zip \
--repo "$GITHUB_REPOSITORY" --clobber
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,isDraft,isPrerelease,assets,url)
export RELEASE_JSON TAG
node <<'NODE'
const release = JSON.parse(process.env.RELEASE_JSON);
if (release.tagName !== process.env.TAG) throw new Error('release tag mismatch');
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
}
NODE
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease \
--title "$TAG" --notes-file docs/RELEASE-NOTES.md
- name: Verify the public release and assets
id: verify
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.gate.outputs.tag }}
SHA: ${{ needs.gate.outputs.sha }}
run: |
set -euo pipefail
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,isDraft,isPrerelease,assets,url)
export RELEASE_JSON TAG
node <<'NODE'
const release = JSON.parse(process.env.RELEASE_JSON);
if (release.tagName !== process.env.TAG || release.isDraft || !release.isPrerelease)
throw new Error('release is not a public prerelease for the requested tag');
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
}
NODE
test "$(git rev-list -n 1 "$TAG")" = "$SHA"
URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url")
echo "url=$URL" >> "$GITHUB_OUTPUT"
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n- assets: `houseplan-card.js`, `houseplan.zip`\n' \
"$TAG" "$SHA" "$URL" >> "$GITHUB_STEP_SUMMARY"
- name: Verify HACS prerelease discovery order
uses: actions/github-script@v7
env:
EXPECTED_TAG: ${{ needs.gate.outputs.tag }}
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const first = releases.find((release) => release.prerelease && !release.draft);
if (first?.tag_name !== process.env.EXPECTED_TAG) {
core.setFailed(
`HACS prerelease discovery is stale: ${first?.tag_name ?? 'none'} precedes ` +
process.env.EXPECTED_TAG,
);
}
# PROCESS.md 10.2 item 10: closing issues and stripping status labels happens
# because a beta was published, not because someone remembered to do it. The
# manual step was skipped twice, and both times it broke the invariant that a
# closed issue carries no status label — the one thing `verify` relies on.
#
# A manual step after a successful release is the worst kind: by the time it is
# due, the work already looks finished, which is exactly why it gets forgotten.
close-merged:
needs: [gate, publish]
if: ${{ needs.publish.outputs.newly_published == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
# Deliberately the stock token, not a PAT: events caused by GITHUB_TOKEN do
# not start workflows, so removing the label cannot wake the review
# pipeline. A PAT here would build a cascade out of a bookkeeping step.
issues: write
steps:
- name: Close the S8-merged queue and strip status labels
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ needs.gate.outputs.tag }}
URL: ${{ needs.publish.outputs.url }}
run: |
set -euo pipefail
# Only the owner's issues take part in the process; issues filed by
# anyone else never carry status labels and are not ours to close.
numbers=$(gh issue list --repo "$REPO" --state open --label S8-merged \
--author Matysh --limit 100 --json number --jq '.[].number')
if [ -z "$numbers" ]; then
echo "the S8-merged queue is empty, nothing to close"
else
for n in $numbers; do
gh issue comment "$n" --repo "$REPO" \
--body "Выпущено в \`$TAG\` · [релиз]($URL)"
# Label first, then close. If the run dies between the two steps an
# open issue without a status is visible and fixable in the flow;
# the reverse order would recreate the exact breakage this job is
# here to prevent.
gh issue edit "$n" --repo "$REPO" --remove-label S8-merged
gh issue close "$n" --repo "$REPO" --reason completed
echo "closed #$n"
done
fi
# Targeted at the defect that actually recurs, not at the invariant in
# general: no closed issue may still carry S8-merged.
leftover=$(gh issue list --repo "$REPO" --state closed --label S8-merged \
--limit 100 --json number --jq 'length')
test "$leftover" = "0" || {
echo "::error::$leftover closed issues still carry S8-merged"
exit 1
}
announce:
needs: [gate, publish]
if: ${{ needs.publish.outputs.newly_published == 'true' }}
uses: ./.github/workflows/announce.yml
with:
reusable: true
tag: ${{ needs.gate.outputs.tag }}
release_name: ${{ needs.gate.outputs.tag }}
url: ${{ needs.publish.outputs.url }}
prerelease: true
ref: ${{ needs.gate.outputs.tag }}
secrets: inherit
+40
View File
@@ -0,0 +1,40 @@
name: Attach HACS zip to release
# hacs.json declares zip_release + filename=houseplan.zip, so every release
# (prereleases included) must carry the asset — HACS installs from it and
# GitHub's public download counter becomes a free per-version install metric
# (owner request, 2026-08-08). Like announce.yml, the workflow file lives at
# the TAGGED commit: betas cut from dev pick it up as soon as this file is on
# dev, stable tags once it reaches main.
# workflow_dispatch lets us attach the zip to an EXISTING release (needed
# once for the latest stable after the hacs.json change reaches main).
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to attach the zip to"
required: true
permissions:
contents: write
jobs:
zip:
runs-on: ubuntu-latest
steps:
- name: Resolve tag
id: tag
env:
EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ github.event.inputs.tag }}
run: echo "tag=${EVENT_TAG:-$INPUT_TAG}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
run: cd custom_components/houseplan && zip -qr ../../houseplan.zip .
- name: Sanity check
run: unzip -l houseplan.zip | grep -q "manifest.json"
- name: Upload asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ steps.tag.outputs.tag }}" houseplan.zip --clobber --repo "$GITHUB_REPOSITORY"
+21
View File
@@ -34,6 +34,15 @@ jobs:
SHA=$(git rev-parse HEAD)
echo "release tag: $TAG; exact commit: $SHA"
node scripts/release-gate.mjs "$SHA"
- name: Require full performance for a stable release
if: ${{ !github.event.release.prerelease }}
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
SHA=$(git rev-parse HEAD)
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Full Performance"
build:
needs: gate
runs-on: ubuntu-latest
@@ -44,6 +53,18 @@ jobs:
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci && npm run build
- name: Verify compositor frame continuity for a stable release
if: ${{ !github.event.release.prerelease }}
run: |
npx playwright install --with-deps chromium
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
npm run continuity:screencast
- name: Upload failed continuity frames
if: ${{ failure() && !github.event.release.prerelease }}
uses: actions/upload-artifact@v4
with:
name: continuity-screencast
path: artifacts/continuity-screencast
- run: cp dist/houseplan-card.js custom_components/houseplan/frontend/
- name: Attach card to release
uses: softprops/action-gh-release@v2
+192 -11
View File
@@ -1,11 +1,113 @@
name: Validate
on:
push:
# The branch commit is the release-gate authority. An annotated tag points
# to the same SHA and must not duplicate the browser validation jobs.
branches:
- '**'
pull_request:
schedule:
- cron: "0 4 * * 1"
# A new push supersedes an unfinished validation for the same branch or PR.
# Exact-SHA release gates never depend on an obsolete commit.
concurrency:
group: validate-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
provenance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Validate commit trailers and hook mode
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
node scripts/validate-commit-provenance.mjs --check-hook-mode --github-range
# Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на
# машине автора, но их можно обойти `--no-verify`, а коммиты идут прямо в dev
# без PR — GitHub на своей стороне не блокирует ничего. Это последнее место,
# где нарушение правила №1 ловится машиной. Job независимый: краснеет сам и
# не роняет остальные, откат — удалить его отсюда, скрипт остаётся рабочим.
process-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Process gate
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TARGET_REF: ${{ github.ref }}
# Публичный репозиторий: штатного токена хватает на чтение issue.
GH_TOKEN: ${{ github.token }}
run: |
node scripts/process-gate.mjs --github-range --issues
# Классификация изменённых путей: тяжёлые job идут только там, где менялось
# относящееся к ним. НА DEV ФИЛЬТРОВ НЕТ: гейт беты принимает «зелёный Validate
# на точном SHA», и если объём прогона зависит от diff, «зелёный» перестаёт
# значить одно и то же — кандидат релиза (манифесты + changelog) пропустил бы
# браузерные тесты, а прогон с пропущенными job всё равно success. Фильтры
# экономят на ветках задач, где Validate — ранний сигнал: настоящую приёмку
# там делает код-ревью, которое гоняет гейты само (#127).
changes:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.classify.outputs.frontend }}
backend: ${{ steps.classify.outputs.backend }}
integration: ${{ steps.classify.outputs.integration }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- id: classify
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
REF: ${{ github.ref }}
run: |
if [ "$REF" = "refs/heads/dev" ]; then
echo "dev: без фильтров, всё true"
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
exit 0
fi
zero=$(printf '%040d' 0)
base="$BEFORE_SHA"
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
# Новая ветка: before нулевой, диапазон считается от merge-base с dev,
# иначе классифицировалась бы вся история.
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|| ! git cat-file -e "$base" 2>/dev/null; then
git fetch -q origin dev
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "$HEAD_SHA~1")
fi
files=$(git diff --name-only "$base" "$HEAD_SHA")
printf '%s\n' "$files" | head -50
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
{
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|pytest\.ini$)')"
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
} >> "$GITHUB_OUTPUT"
hacs:
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -13,18 +115,26 @@ jobs:
uses: hacs/action@main
with:
category: integration
hassfest:
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Hassfest validation
uses: home-assistant/actions/hassfest@master
frontend:
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
with:
node-version: 22
cache: npm
- run: npm ci
- name: Typecheck
run: npm run typecheck
@@ -32,23 +142,25 @@ jobs:
run: npm test
- name: Build
run: npm run build
- name: Card bundle in sync with integration
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
- name: Card bundle snapshots in sync
run: |
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
smoke:
# audit T2: the end-to-end layer used to run only when a human remembered.
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
needs: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
with:
node-version: 22
cache: npm
- run: npm ci
- name: Install Chromium for Playwright
run: npx playwright install --with-deps chromium
- name: Build a FRESH bundle for the smokes
# the committed demo/srv/assets copy is a snapshot; testing it would
# report green about code that no longer exists (audit T2)
- name: Build a fresh bundle for the smokes
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Smoke suite
run: |
@@ -72,10 +184,79 @@ jobs:
name: smoke-logs
path: /tmp/smoke-logs
backend:
golden:
# Deterministic visual correctness stays in every prerelease gate: it is
# inexpensive and catches a different class of regressions than timings.
needs: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Install pinned Chromium
run: npx playwright install --with-deps chromium
- name: Build the exact source under review
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Capture or verify golden matrix
id: golden
run: |
if find demo/golden/baselines -maxdepth 1 -name '*.png' -print -quit | grep -q .; then
echo "has_baselines=true" >> "$GITHUB_OUTPUT"
npm run golden:verify
else
echo "has_baselines=false" >> "$GITHUB_OUTPUT"
npm run golden:capture
fi
- name: Upload golden candidates/diffs
if: failure() || steps.golden.outputs.has_baselines == 'false'
uses: actions/upload-artifact@v4
with:
name: golden-images
path: artifacts/golden
performance_smoke:
# Candidate-only catastrophic-regression guard for ordinary pushes and
# prereleases. The expensive same-runner comparison lives in performance.yml.
needs: frontend
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Install pinned Chromium
run: npx playwright install --with-deps chromium
- name: Build the exact candidate source
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Capture the heaviest Glow state
run: |
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
- name: Enforce absolute smoke ceilings
run: |
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
- name: Upload performance smoke report
if: always()
uses: actions/upload-artifact@v4
with:
name: performance-smoke
path: artifacts/performance-smoke
backend:
needs: changes
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Browser fixtures are generated by their real ESM factories and then
# validated through the Python CONFIG_SCHEMA/LAYOUT_SCHEMA in the same test.
- uses: actions/setup-node@v4
with: { node-version: 22 }
- uses: actions/setup-python@v5
with: { python-version: "3.13" }
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
+2
View File
@@ -5,3 +5,5 @@ test-build/
__pycache__/
.pytest_cache/
.venv-backend/
artifacts/
.agents/
+405 -16
View File
@@ -6,22 +6,411 @@ House Plan is one HACS package with two parts plus a demo harness:
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
Standard commands live in `package.json` scripts, `CONTRIBUTING.md`, and `docs/DEVELOPMENT.md`. Read `docs/ARCHITECTURE.md` and `docs/STATUS.md` before non-trivial changes.
## Read this first
## Cursor Cloud specific instructions
**`docs/SCOPE.md` before anything else.** It was fixed with the owner and states
its own authority: features are built, improved and accepted **only** if they
serve a job listed there. It carries the mission, the three personas, the core
user jobs and the out-of-scope list.
The startup update script already runs `npm ci`, provisions a Python 3.13 backend venv at `.venv-backend`, and installs Playwright Chromium. You do not need to reinstall dependencies.
Its central consequence: **View mode is the product for two of the three
personas.** Editors are admin-only tools and must never leak interactions into
View.
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, ~270 tests), `npm run build`. After building, keep the integration copy in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/`. CI enforces `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` byte-for-byte.
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (126 tests). Running `python3 -m pytest tests_backend` on the system 3.12 silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the ~83 pure tests.
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed `demo/srv/assets/houseplan-card.js` is a stale snapshot; testing it reports green about code that no longer exists. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
- **Demo harness render quirk**: the fake `hass` in `demo.html` is set once, so opening the page directly in a browser renders the floor plan but **device icons only appear after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does not. This is a harness limitation, not a card bug.
- **Known environment-sensitive smoke**: `demo/smoke_opening_measure.mjs` fails two sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It reproduces against the pristine committed bundle, so treat it as pre-existing/pixel-precision, not a regression you introduced.
- **Owner's local Windows checkout is invisible here.** Path
`C:\Users\Sergey\Downloads\dev\houseplan-dev` (workflow notes + often
unpushed edits) is **not mounted** into managed Cloud Agent VMs. Do not
expect to `ls` or diff that folder. To bring local work into the cloud
agent: push a branch to GitHub and say its name, or run a **local** Cursor
Agent / My Machines worker inside that checkout. Day-to-day source of truth
for cloud sessions remains **`origin/dev`** (minors) and **`origin/main`**
(releases) — see `docs/STATUS.md`.
For work that changes visible behaviour, also read `docs/USER-GUIDE.ru.md` —
interface wording comes from there and is not invented, or the UI starts speaking
developer.
Then `PROCESS.md` (the full process), `docs/STATUS.md` (where the release line
is), and for non-trivial changes `docs/ARCHITECTURE.md` plus the canonical
document of the subsystem you touch: `SUN.md`, `LIGHT.md`, `CANVAS.md`,
`WALL-THICKNESS.md`, `UX-MODES.md`, `CONFIG-COMPATIBILITY.md`,
`TOUCH-SUPPORT.md`.
Standard commands live in `package.json` scripts, `CONTRIBUTING.md` and
`docs/DEVELOPMENT.md`.
## Canonical backlog and status
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical
task records: problem, scope, acceptance criteria and discussion.
**Status lives in labels:** `S1-new`, `S2-analysis`, `S3-spec`, `S4-spec-review`,
`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`, plus `blocked` on top
of a status and `rejected` on a closed issue. Exactly one `S*` label per open
issue. Labels are the whole of it: GitHub Projects is no longer used.
Two shortcuts exist for small work. `small` — the light track: the spec lives in
the issue body and its review is a comment. `trivial` — the short track: no spec
stage at all, `S2-analysis` straight to `S5-ready`, with the AC written into the
issue body first. `trivial` requires a bug confined to one surface with no new UX
contract, no migration, no i18n, no perf or touch impact, at most three checkable
AC, **and expected behaviour already on record** — nothing left to decide. Code
review is never skipped on either track; it is what stands in for testing.
`PROCESS.md` §5 and §5.1 hold the criteria.
An issue filed by an outsider is worked exactly like one of the owner's own, once
the owner has decided to take it. The check sits **at the entrance**, not on every
step: while an issue carries no status label it is outside the process and the
invariants do not apply to it; once a label is on, the task is in flight and **who
filed it stops mattering**.
Applying that first label *is* the owner's explicit decision, and the platform
already guarantees it — only someone with write access can label. The earlier rule
made outside reports be refiled as the owner's own issues, which turned out to be
work for nothing: on #123 the spec was already written by the time the guard
refused.
Specs, audits and ADRs may live under `docs/`, but must link to their issue and
must not become a parallel task list. When repository documentation disagrees with
Issues, the issue wins.
## Rule #1
> Changing product code without an issue is forbidden. Code changes only when the
> issue exists and sits in "Ready for development" or later.
Check before touching product code:
```
gh issue view <NN> --repo Matysh/houseplan-card --json number,state,labels
```
The label must be one of `S5-ready`, `S6-in-progress`, `S7-code-review`. Anything
else — refuse and say why. "Issue #83 is in `S2-analysis`, code is off limits.
Start with the spec?" is the correct answer, not a smaller patch.
## Change classes
| Class | Paths | Issue required |
|---|---|---|
| **A — product** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, i18n, `custom_components/**/translations/**` | yes |
| **B — gates and tooling** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, `.github/workflows/**`, `rollup.config.mjs`, `tsconfig*.json` | yes; may reuse the issue it covers |
| **C — documentation** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md` | not if it is part of its issue's DoD |
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**` | never changes on its own |
The table above is a summary; `PROCESS.md` §1 is the authority and now covers the
configuration files this one omits — `package.json`, `package-lock.json`,
`pytest.ini`, `.gitignore`, `.gitattributes`, `.githooks/**` and the rest of
`.github/**` are class B. Where paths overlap, **D beats A**: the built bundle
lives inside `custom_components/houseplan/frontend/` and would otherwise read as
product source.
## Commits
Hooks install themselves: `package.json` runs `"prepare": "node
scripts/install-hooks.mjs"`, so `npm ci` sets `core.hooksPath` in every fresh
clone. Verify with `git config core.hooksPath` — expect `.githooks`.
Every non-merge commit carries **terminal** trailers:
```text
Issue: #123
User-Visible: yes
```
One `Issue:` line per issue if a commit closes several. `User-Visible: no` for
tests, refactors, tooling and documentation that does not change the product.
`User-Visible: yes` requires edits to **both** changelogs — `docs/CHANGELOG.md`
and `docs/CHANGELOG.ru.md` — in the same commit.
A commit touching `demo/golden/baselines/**` additionally requires:
```text
Release: v1.62.0-beta.9
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/<run-id>
```
Never invent a review link and never rewrite published history to satisfy
trailers. `.githooks/commit-msg` and the `provenance` CI job both run
`scripts/validate-commit-provenance.mjs`.
Branch: `issue/<NN>-slug`. Direct commits to `dev`, no PR — the owner's decision;
CI checks after the fact, and a violation is fixed with a follow-up commit, never
a force-push.
**Push after every task, not before a beta.** While work sits unpushed there is
nothing to review, and reviewing twenty tasks at once is not review. `dev` may hold
unreviewed code while a task is in flight; what matters is its state when the
reviewer says it is accepted.
**Standing permission: push `issue/<NN>-slug` without asking.** The reviewer runs
in CI and can only read what is on the remote — an unpushed spec or commit means
the review either stalls or judges the wrong tree. Pushing a task branch publishes
nothing to users and does not touch the integration branch, so it needs no command.
**Do not merge into `dev` by hand.** On a green code review the pipeline rebases
the task branch onto `dev`, pushes it, and only then sets `S8-merged` — the label
asserts the code is in `dev`, so the merge has to happen first or the label lies
in between.
If the rebase conflicts the pipeline says so in the issue and sends the task back
to `S6-in-progress`. The verdict still stands: nothing needs reviewing again, the
remaining work is the rebase. Resolve it, push the branch, re-apply
`S7-code-review`. The second review run is not a formality — after a rebase onto a
moved `dev` this is different code, and accepting it unchecked is how regressions
arrive. Cycles are counted per stage, so a code review spends its own budget.
Everything else still requires the owner's explicit command: pushing `main`,
creating tags, publishing betas and releases, closing issues.
## Working trees (#115)
One checkout, one `HEAD`: two agents sharing a directory inherit each other's
branch, and twice in one hour a commit landed on someone else's task branch that
way. The layout is therefore fixed:
- **`houseplan-card-src/houseplan-card`** — the author's tree. Task branches live
here; nobody else commits in it. Unfamiliar local changes belong to the author
or the owner — never reset or clean them away.
- **`houseplan-card-src/hp-dev`** — the owner's worktree, permanently on `dev`. For owner-side operations that must not disturb the
author's tree: pushing `dev`, restoring a hook's executable bit, emergencies.
- **The reviewer and the infrastructure agent own no local tree.** The reviewer
runs in CI on a fresh checkout. The infrastructure agent reads via `git show`
and publishes through the GitHub API; it makes no local commits at all, so it
needs no `HEAD` of its own. Its scratch worktrees live outside the repo and are
pruned after use.
A worktree is only usable on the machine that created it: the `.git` file records
an absolute path in that machine's format. One created from a Linux sandbox is
dead on Windows and vice versa — create worktrees on the machine that will use
them, which for `hp-dev` means the owner's.
## Two-agent workflow
**Codex** writes analysis, specs and all product code. **Claude** reviews specs and
code and owns infrastructure and distribution. The owner rules on disputes, closes
issues and commands releases.
Author and reviewer are different models, which is what "a fresh session without
implementation context" means in practice. The reviewer never edits product code;
the author never grades their own work.
**Infrastructure-only work runs outside this flow.** CI, scripts, labels, demo
stands, the landing page and distribution are Claude's alone, and running them
through spec-writing and review buys nothing: the spec would restate what is
already unambiguous, and author and reviewer would be the same role. So no spec
file, no spec review, no code review, no walk through `S1`…`S8`.
The test for "infrastructure only" is mechanical: **not a single class A file** —
nothing under `src/**`, no `custom_components/**/*.py`, no manifests, no i18n. A
task that touches class A even once is not infrastructure and takes the full flow;
there is no such thing as "mostly infrastructure". The strictness is deliberate:
a loose reading would turn this into the route by which product changes skip
review.
What stays mandatory either way: an issue exists, both trailers are on every
commit, `typecheck`, `test` and `build` are green, and any non-obvious decision is
written down in the code or the issue rather than kept in someone's head.
**Review starts by itself.** Applying `S4-spec-review` or `S7-code-review` fires the
pipeline, which reviews without anyone asking and takes ten to forty-five minutes.
**Having applied one of those labels, wait for the result instead of ending the
session.** Reporting "handed over for review" stops a conveyor that could have kept
moving on its own. An agent has no clock — it exists only during its own turn — so
waiting means polling: every 90 seconds, at most 30 times. A single long sleep hits
the command timeout. Watch the **label**, not the comment: the label is the state,
the comment only explains it. Do not wait at all while `blocked` is set — the task
is waiting on the owner, not on the reviewer. On exhausting the attempts, stop and
tell the owner: a failed run leaves the label where it was, forever.
What the new label means:
| Now reads | What happened | What you do |
|---|---|---|
| `S5-ready` | the spec is accepted | write the code |
| `S3-spec` | the spec came back | read the verdict, revise, re-apply `S4-spec-review` |
| `S6-in-progress` | the code came back | revise, re-apply `S7-code-review` — **or**, if the verdict was green and only the merge conflicted, just rebase and re-apply. The comment says which |
| `S8-merged` | accepted and already in `dev` | nothing |
| `review-4` | the cycle limit is spent | stop, the owner decides |
**After a review run the label always changes.** If it did not, the run itself
failed rather than the work — say so to the owner instead of polling on.
**A failed pre-release gate does not send the issue back to review.** The
implementation loop runs only typecheck, unit and build; golden, browser smokes,
performance and the full HA harness run before a beta, which is after the code
review has passed and the issue sits in `S8-merged`. Some defects cannot surface
any earlier.
Fix it, re-run what failed, and a green run is enough for the release to continue.
The issue stays in `S8-merged`. Record the **exact command and its result** in the
issue — "verified" without a command proves nothing. Trailers as usual, and
`User-Visible: yes` still means both changelogs in the same commit.
The exception covers repairing the defect the gate named, not carrying on
development under the name of a repair. It goes through the normal flow — a new
issue, or back to `S6-in-progress` — if the fix changes a behaviour contract, gives
the user something new, reaches a subsystem the task never touched, or is
comparable in size to the task itself. And editing the gate so it stops failing is
concealment, not repair; the exception is a defect proven to be **in the fixture**,
as on #89, where the sun sat at azimuth 180° and the only window faced north, so no
ray was ever built.
Baselines are still accepted only via `npm run golden:accept -- --reviewed` on a
complete Linux CI artefact. "So the gate goes green" is not a reason.
The exchange happens in **issue comments** — there is no local message bus. Verdict
format:
```text
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → #… · Document: …
```
High blocks. Medium must become its own issue. Low is fixed or waived with a note
in the review document. A yellow verdict is legitimate even when every acceptance
criterion passes, if the change does not solve the stated scenario or degrades a
neighbouring one.
**Four review cycles** (two on the light track). The counter lives in the document
name, `-r1`…`-r4`; the fourth adds the `review-4` label. There is no fifth attempt:
the owner splits the task, rejects it, or arbitrates.
On the light track (`small`: complexity ≤3, one surface, no config migration, no
new UX contract, no perf or touch impact — all at once) the spec lives in the issue
body and the spec review is a comment. Code review is never skipped.
## Specs
`docs/specs/<NN>-<slug>.md`, linked to its issue in both directions. Required
sections are in `PROCESS.md` §7.1, plus two product ones: which persona meets this,
on which surface, at what moment; and what the person sees before and after, in one
sentence without implementation terms.
**Ambiguity is asked, not guessed — but only product ambiguity.** A guess written as
fact is the worst kind of defect: it passes review because it looks like a decision.
The owner answers exactly two kinds of question: **what a person sees or does**, and
**how much user-visible change belongs in this issue**. Behaviour in a boundary case,
which persona wins when two conflict, what counts as acceptable degradation, whether
a neighbouring behaviour is in scope here or becomes its own issue.
Everything a user cannot observe is yours to settle: where state is stored, which
module carries the guard, naming, file layout, test strategy, migration mechanics,
development policy. Decide it, record it in an explicit "assumed, change freely"
block, and let the reviewer challenge it. A technical disagreement between author and
reviewer is settled by the verdict, not by the owner; it reaches him only when the
cycle limit is exhausted.
Split a mixed question instead of escalating all of it. "Where does this state live"
is technical. "Does it survive a page reload and follow the plan across screens" is
product. Ask the second, decide the first.
Ask in one batched issue comment, each question carrying a proposed default, and put
`blocked` on top of `S3-spec` while waiting. A question with a default costs the
owner seconds; one without costs him minutes.
## Gates
```
npm run typecheck
npm test
npm run build
npm run inventory # the only correct way to get test counts
```
Never copy test counts into documents by hand; they go stale in days.
After building, keep all three bundle snapshots in sync — CI compares them
byte-for-byte:
```
cp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
```
During the implementation cycle the fast gates always run. Since 2026-08-14 the
owner's machine also carries Playwright with Chromium (Windows) and a full WSL
environment, which changes one thing (#151): **before moving an issue to
`S7-code-review`, run the smokes named in its AC locally** — `node
demo/smoke_<name>.mjs`. A red smoke that reaches the review costs a cycle; run
locally it costs a minute. Precedent: on #89 a fixture error lived through a
whole review round that a local run would have caught immediately.
The full smoke set, `golden` and `performance_smoke` still belong to the
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
harness (`~/houseplan-card`, venv) and `golden:verify` are advisory; **the canon
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
Locally only the pure subset runs; `python -m pytest tests_backend/ -q` without
Home Assistant **silently skips** `test_ha_*.py` (`conftest.py` ignores them when
`homeassistant` is not importable), so a green result proves nothing. Say so in the
report instead of claiming the backend was verified. Cloud agents have the harness
at `.venv-backend/bin/python`.
**Running the app / smoke suite**: build a fresh bundle and copy it into the demo
assets first, then run `node demo/smoke_*.mjs`. No real Home Assistant server is
required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
**Golden images**: `npm run golden:capture` and `npm run golden:verify` refuse a
stale demo bundle. Build and copy first, then review `artifacts/golden/actual/` and
`diff/`. Update baselines only with `npm run golden:accept -- --reviewed`, using the
complete Linux CI artifact; never accept a partial scenario or images merely to make
CI green. See `demo/golden/README.md`.
**Freshness contract**: the embedded fingerprint covers `src/` plus Rollup,
TypeScript and package-lock build inputs. Benchmark and golden tooling must call
`assertFreshDemoBundle` before recording any result; a missing or mismatched
fingerprint is a hard failure, not a warning.
**CI is pinned to an exact SHA.** The release gate accepts only a `completed
success` run for the candidate's SHA, not "the last green one"; a new push cancels
an unfinished Validate for the same branch. Jobs: `provenance`, `hacs`, `hassfest`,
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`.
**"Verified" without a named command and its result is not evidence.**
## Environments
**Local Windows checkout** is the day-to-day environment: Node 22 as in CI, Python
3.13 in a venv, `gh` authenticated. `.venv-backend` does **not** exist there — it is
provisioned only by cloud agent startup scripts, which also run `npm ci` and install
Playwright Chromium.
Known environment-sensitive smoke: `demo/smoke_opening_measure.mjs` fails two
sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned
Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It
reproduces against the pristine committed bundle, so treat it as
pre-existing/pixel-precision, not a regression you introduced.
## Labs flags
`src/labs.ts` is the single registry and resolver for hidden presentation
experiments. Activate a live flag through `?hp-labs=<id>` or the shared hash
grammar, remove it with `-<id>`, and use `off` to clear the set. Do not add a
YAML/config switch for a Labs-only experiment. A new entry needs a unique
lowercase id, issue, numeric-core `since`, numeric-core `expires`, summary and
unit/browser coverage. Invalid or duplicate registry entries fail closed.
Expiry is exclusive and ignores prerelease suffixes: an entry expiring at
`1.65.0` is unavailable in `1.65.0-beta.1`. Before that cycle, either remove the
experiment or graduate it through its own reviewed issue; never extend expiry as
an incidental change. Labs may alter presentation only and must not gate data,
migrations, stores, HA actions or network calls. Current renderer details are in
`docs/ISOMETRIC.md`.
Demo harness render quirk: the fake `hass` in `demo.html` is set once, so opening the
page directly in a browser renders the floor plan but **device icons only appear
after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The
smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does
not. This is a harness limitation, not a card bug.
## Promotion rule
Every new feature or material behaviour change must be published as a beta/RC
before it can enter a stable release, even when its local audit is clean. The
stable release commit is promotion-only: version fields, generated bundle
snapshots and changelog/release metadata. Do not add feature source code in
that commit. An explicit owner-requested emergency hotfix is the only exception
and must be called out in the release handoff.
A `Release vX.Y.Z-beta.N candidate` commit is **not** promotion-only: it carries
the work itself and follows the ordinary rules, trailers included.
Issues are closed in a batch when a beta ships, not when implementation ends: that
way a bug found in the beta returns to the same task, and the beta announcement can
list what went in. Status labels are stripped as the issues close.
+12 -1
View File
@@ -16,6 +16,15 @@ writing code? The **[Telegram chat @ha_houseplan](https://t.me/ha_houseplan)**
is the quickest route to the author and other users. Bugs and concrete feature
requests still belong in [issues](https://github.com/Matysh/houseplan-card/issues).
## Backlog and work status
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the only
active backlog. An issue owns scope and acceptance criteria; its **labels** own
priority and workflow status — `PROCESS.md` §9 holds the vocabulary. Before
starting planned work, link it to an existing issue or create one, and keep it
current until the verified result is closed. Design specs and ADRs may support an
issue, but they do not replace it or maintain a separate checklist.
## Five-minute setup
```bash
@@ -25,6 +34,7 @@ npm run typecheck # tsc --noEmit (strict)
npm test # node:test — pure logic, i18n parity, tap-action security
npm run build # tsc + rollup → dist/houseplan-card.js
pip install pytest voluptuous && python -m pytest tests_backend -q # pure backend tests
npm install # also installs .githooks through the prepare script
```
The HA-harness backend tests (`tests_backend/test_ha_*.py`) need Python ≥3.13 and
@@ -40,7 +50,8 @@ every push — locally they are skipped when `homeassistant` is not importable.
- The built card must be committed in sync: `cp dist/houseplan-card.js
custom_components/houseplan/frontend/` (CI compares them byte-for-byte).
- Tap actions have a security model (locks/alarms never toggle from the plan) —
see `resolveTapAction` in `src/logic.ts`; don't weaken it.
see `resolveToggleIntent` in `src/device-toggle.ts`; don't weaken it.
- Every commit follows the issue and trailer contract in `PROCESS.md`.
- Follow the Integration Quality Scale where applicable —
`custom_components/houseplan/quality_scale.yaml` tracks the self-assessment.
+884
View File
@@ -0,0 +1,884 @@
# Процесс работы над House Plan
> **Статус документа: канон** (редакция 2026-08-13). Решения владельца, на
> которых он стоит: прямые коммиты в `dev` **без PR** · канон статуса — **метки**,
> имена английские · лёгкий трек **включён** · автор и ревьюер — разные модели ·
> инфраструктурные задачи идут **вне** флоу.
>
> **Область действия:** обязателен для владельца и для любого агента. Читается
> сразу после `docs/SCOPE.md` и `AGENTS.md`, до `docs/STATUS.md`. Живёт в
> репозитории: до августа 2026 канон лежал только в папке владельца, и свежий клон
> его не содержал вовсе.
>
> **Приоритет источников.** Канонический бэклог — GitHub Issues; статус живёт в
> метках и больше нигде: Project v2 не используется. При расхождении
> документации с GitHub побеждает GitHub. При расхождении этого документа с
> `.github/workflows/*.yml` и `scripts/*` побеждает **фактическая автоматизация**:
> она исполняется, а описание — нет. Расхождение при этом не игнорируется, а
> заводится issue с меткой `process`.
>
> При расхождении процесса и привычки побеждает процесс.
---
## 1. Основное правило
**Изменение продуктового кода без issue запрещено.** Код меняется только тогда,
когда issue существует и находится в статусе «Готово к разработке» или дальше.
Исключения — только §11, и каждое оставляет след.
Правило работает лишь при точной границе «продуктового кода», иначе спор
переносится на границу:
| Класс | Что входит | Нужен ли issue |
|---|---|---|
| **A. Продукт** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, `src/i18n/*.json`, `custom_components/**/translations/*` | **Да, обязательно.** Только из «Готово к разработке» или дальше |
| **B. Гейты и инструменты** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, весь `.github/**`, `.githooks/**`, `rollup.config.mjs`, `tsconfig*.json`, `package.json`, `package-lock.json`, `pytest.ini`, `.gitignore`, `.gitattributes` | **Да.** Может использовать issue того изменения, которое покрывает; самостоятельная работа над гейтом получает свой issue (тип `tech-debt`) |
| **C. Документация** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md`, `CONTRIBUTING.md`, `PROCESS*.md`, `LICENSE`, `(CODE\|SPEC)-REVIEW-*.md` | Документирование A/B в том же коммите — часть DoD своего issue. Самостоятельная работа над документацией — свой issue |
| **D. Сгенерированное** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**` | Никогда не меняется само по себе. Коммит **только** класса D допустим лишь как релизный промоушен или как принятие эталонов с доказательством ревью |
Практический смысл таблицы: «я только поправил тест» и «я только пересобрал
бандл» перестают быть лазейками.
Классы неупорядочены, но при пересечении путей **D сильнее A**: собранный бандл
лежит внутри `custom_components/houseplan/frontend/`, и без этого правила он
считался бы продуктовым исходником.
**Инфраструктурная задача идёт вне флоу** (решение владельца 2026-08-13,
issue #118). Признак механический: **ни одного файла класса A**. Такая задача
делается без ТЗ, ревью ТЗ, код-ревью и без прохода по статусам — флоу построен
для изменений, у которых есть персона и видимое поведение, а в инфраструктуре ТЗ
пересказывало бы очевидное, и автор с ревьюером оказались бы одной ролью.
Проверкой служат гейты и CI. Обязательным остаётся issue, трейлеры и зелёные
`typecheck`, `test`, `build`.
Задача, задевающая класс A хотя бы одним файлом, инфраструктурной **не
является** и идёт полным флоу. «В основном инфраструктурная» не бывает: иначе
это дорога, по которой продуктовые правки минуют ревью. Признак задан через
класс файлов, а не через самоощущение исполнителя, именно поэтому.
---
## 2. Жизненный цикл
Восемь рабочих статусов и два служебных. Фазы тестирования в цикле сознательно
**нет**: найденные позже дефекты заводятся отдельными issue и проходят цикл
заново. Issue закрывается после выпуска беты.
```
S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
→ S6-in-progress → S7-code-review ⟲ → S8-merged → закрыт при выпуске беты
служебные: blocked (поверх статуса) rejected (закрыт)
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком и коротком треке 2
короткий трек (`trivial`, §5.1) идёт S2-analysis → S5-ready, минуя S3 и S4
```
Переходы `S4-spec-review` и `S7-code-review` выполняются **автоматически**: метка
порождает событие, событие запускает ревью (§10.4). Остальные ставит исполнитель.
### 2.1 Новое — заведение задачи
- **Кто:** любой — владелец, агент, пользователь (Telegram, GitHub).
- **Вход:** проблема в пользовательских терминах; как проявляется или зачем нужно.
Решение **не требуется** и не приветствуется.
- **Запрещено:** ставить приоритет, оценивать, писать ТЗ, начинать код.
### 2.2 Аналитика и оценка
Задача разбирается — и разобранная **сама идёт дальше**. Умолчание изменено
решением владельца 2026-08-14: раньше аналитика ждала подтверждения по каждому
пункту, и большинство ожиданий ничего не меняло — issue в основном описаны
однозначно.
- **Кто:** агент-аналитик. Владелец не утверждает переход — он правит асинхронно.
- **Чек-лист**, результат — комментарием в issue:
1. дубликаты проверены (ссылки на похожие issue);
2. в скоупе по `docs/SCOPE.md` и `docs/TOUCH-SUPPORT.md`;
3. **пользовательская ценность 1–10** и **ценность для разработки** — что
упрощает или разблокирует;
4. **сложность и риск 1–10** — трудоёмкость плюс вероятность задеть смежное;
5. приоритет **P1/P2/P3**;
6. тип: баг / фича / техдолг;
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
8. трек: обычный / `small` / `trivial` по критериям §5 и §5.1.
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
согласие**, несогласие он выражает правкой меток или комментарием, и это не
останавливает работу. Право отклонить задачу (`rejected`) остаётся за
владельцем на любой стадии.
- **Вопросов владельцу на этом этапе нет.** Единственный класс вопросов, который
вообще задаётся владельцу, — продуктовые (§7.1: что человек видит или делает,
объём видимых изменений), и их место — этап ТЗ, пачкой, с вариантами по
умолчанию и `blocked`. Вопрос, который можно отложить до ТЗ, не задаётся в
аналитике; вопрос, не блокирующий написание ТЗ, не задаётся вовсе — вместо
него в ТЗ пишется блок принятых предположений.
- **Выход:** `S3-spec` — переход выполняет сам аналитик, не дожидаясь ответа.
Либо, при явном конфликте со `SCOPE.md`, — предложение отклонить с причиной:
это единственный случай, когда аналитика останавливается и ждёт владельца.
### 2.3 ТЗ в работе — написание ТЗ
- **Кто:** автор ТЗ, назначает себя. Статус означает «занято».
- **Артефакт:** `docs/specs/<NN>-<slug>.md`, где `NN` — **номер issue**.
Многоэтапная задача: `<NN>-<slug>-stage<N>.md`.
- **Лёгкий трек:** ТЗ пишется в теле issue, файл не создаётся (§5).
- **Выход:** полная первая редакция по §7.
### 2.4 ТЗ на ревью
- **Ревьюер ≠ автор.** Ревьюер получает issue и ТЗ, без устных пояснений автора.
Его задача — не согласиться, а найти, где ТЗ не выполнимо или не проверяемо.
- **Артефакт:** `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, вердикт
зелёный / жёлтый / красный. Лёгкий трек — комментарий в issue.
- **High-находки блокируют.** Medium/Low — либо правятся, либо становятся
отдельными issue со ссылкой; «оставили в тексте ревью» не считается закрытием.
- **Выход:** «Готово к разработке» либо возврат в «ТЗ в работе» — не более
4 циклов (§4).
### 2.5 Готово к разработке (DoR)
Не работа, а **очередь**: единственный статус, из которого можно трогать код.
Все пункты обязательны:
- ТЗ существует, ревью ТЗ зелёное, ссылки issue ↔ ТЗ на месте;
- **AC1…ACn** — пронумерованные проверяемые критерии приёмки; у каждого указано,
чем он доказывается: `unit` / `backend` / `smoke` / `golden` / «ревью кода»;
- перечислены затронутые файлы и модули;
- i18n: ключи en + ru перечислены;
- миграция и compatibility-поля решены по `docs/CONFIG-COMPATIBILITY.md`;
- влияние на производительность и бюджеты названо (или явно «нет»);
- влияние на touch по `docs/TOUCH-SUPPORT.md` (View и киоск — блокирующие);
- release-артефакты по правилу `docs/specs/README.md` (changelog RU+EN,
документация, golden/скриншоты, performance/security);
- **откат**: как выключить или вернуть назад (флаг Labs, обратная миграция);
- открытых продуктовых вопросов нет; риски перечислены.
Если хоть один пункт не выполнен — статус не «Готово к разработке», как бы ни
хотелось начать.
### 2.6 В разработке — реализация
- **Занятие (claim):** назначить себя, поставить метку, комментарий
«Взял: <роль> · сессия <id> · ветка `issue/<NN>-<slug>`».
- **WIP-лимиты:** не более **1** issue в «В разработке» на исполнителя, не более
**3** одновременно на цикл релиза, не более **2** в «Код-ревью».
- **Трассируемость:** ветка `issue/<NN>-<slug>`; каждый коммит несёт трейлеры
`Issue: #<NN>` и `User-Visible: yes|no`.
- **Автотесты — часть реализации, а не отдельная фаза.** Каждый AC, помеченный
`unit`/`backend`/`smoke`/`golden`, получает свою проверку здесь же.
«Тестирование вне жизненного цикла» означает отсутствие фазы ручного
тестирования, а не отсутствие тестов.
- **Скоуп не расширяется.** Найденное по пути становится новым issue в «Новое».
Если находка блокирует — текущий issue уходит в «Заблокировано» со ссылкой.
Попутных правок «раз уж я здесь» не бывает.
- **Документация — в том же коммите,** что и поведение (действующая политика
`docs/STATUS.md`): changelog RU+EN для пользовательского, `STATUS.md` для
состояния, `DEVELOPMENT.md` для новых грабель, `ARCHITECTURE.md` для дизайна.
- **Выход:** локальный гейт зелёный (§8), хендофф-комментарий (§7.2).
### 2.7 Код-ревью
- **Ревьюер ≠ исполнитель**, свежая сессия без контекста реализации.
- **Артефакт:** `docs/reviews/CODE-REVIEW-<tag|NN>-r<N>.md` в действующем
формате: скоуп, как проверялось (таблица гейтов с результатами), находки
High/Medium/Low с воспроизведением, что проверено и корректно, чего не проверял.
- **Ревьюер отвечает за AC.** Раз ручного тестирования в цикле нет, именно ревью
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
коду с явной записью «проверено чтением, не исполнением».
- **High блокируют.** Medium **обязаны** превратиться в issue.
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
4 циклов (§4).
### 2.8 Закрытие после выпуска беты
- **Вход:** изменение вошло в опубликованную бету/RC, CI Validate зелёный на
**точном SHA** тега (промоушен-правило: ни одна фича не попадает в стабильный
релиз, не побывав в бете).
- **Закрывает** релиз-менеджер, не исполнитель. Комментарий закрытия: тег беты,
ссылка на прогон CI, ссылка на бюллетень changelog.
- **Стабильный релиз статусов не двигает** — issue уже закрыты; релизный коммит
promotion-only, changelog ссылается на закрытые issue.
- **Что приходит потом:** дефект, найденный на стенде, дома или пользователем, —
**новый issue** типа «баг» со ссылкой на исходный. Исходный не переоткрывается.
### 2.9 Заблокировано / Отклонено
- **Заблокировано:** обязательна ссылка на блокирующий issue или внешнюю причину
и дата пересмотра. Без причины статус не ставится.
- **Отклонено:** закрытие с записанной причиной (вне скоупа, дубликат, цена не
оправдана). Тихое закрытие без причины запрещено.
---
## 3. Правила
Продолжение черновика владельца. Каждое правило проверяемо — глазами или машиной.
1. **Никаких изменений в код, если нет issue** и он не помечен «Готово к
разработке» или дальше.
2. **Issue не может быть взят в разработку**, пока у него нет ТЗ с зелёным ревью,
пронумерованных AC с указанием доказательства и назначенного исполнителя.
3. **Issue не может быть взят дважды.** Занятие фиксируется назначением, меткой и
комментарием с именем ветки. У одного исполнителя одновременно не более одного
issue в разработке.
4. **Статус меняется до действия, а не после.** Взял — поставил метку; отдал на
ревью — поставил метку. Метка, поставленная задним числом, — дефект процесса.
5. **Ровно одна метка статуса** на issue в любой момент. Ноль или две — дефект,
еженедельная гигиена его показывает.
6. **Автор не ревьюит своё** — ни ТЗ, ни код. Никто не переводит свою работу через
ревью-гейт.
7. **Ревью возвращает не более 4 раз.** Пятый заход — решение владельца: разделить,
отклонить или арбитраж (§4).
8. **High блокирует. Medium становится issue.** Low либо правится, либо снимается
решением ревьюера с записью в документе.
9. **Скоуп не расширяется.** Всё найденное вне ТЗ — новый issue, а не попутная
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
`issue/NN-slug`, а `User-Visible: yes` требует правок в **обоих** changelog в
том же коммите.
11. **Документация — в том же коммите, что поведение.** Отдельным «допишу потом»
коммитом документация не бывает.
12. **Сгенерированное не коммитится само по себе.** Только релизный промоушен или
принятие эталонов со ссылкой на прогон CI.
13. **Golden-эталоны принимаются только** `npm run golden:accept -- --reviewed` по
полному Linux-артефакту. Принятие ради зелёного CI — нарушение процесса.
14. **Issue закрывается после выпуска беты** с зелёным CI на точном SHA. Не
раньше, не «по факту наличия кода», не исполнителем.
15. **Закрытый issue не переоткрывается.** Новый дефект — новый issue со ссылкой.
16. **Стабильный релиз — promotion-only:** версии, сгенерированные бандлы,
changelog и release-метаданные. Продуктового кода там нет.
17. **История `dev` не перезаписывается.** На неё ссылаются теги. Нарушение
исправляется следующим коммитом плюс issue с меткой `process` — не
force-push'ем.
18. **AC доказывает автотест или запись ревьюера.** Фразы «проверил локально, всё
работает» в процессе не существует: либо тест, который умеет падать, либо
честное «проверено чтением, не исполнением».
19. **Параллельных бэклогов нет.** Планы, разборы и приоритеты живут в issue;
файловые отчёты — разовые и датированные.
20. **Аварийный хотфикс — только решением владельца** и только по §11.2.
---
## 4. Лимит циклов ревью: 4
Оба ревью-гейта возвращают задачу на правки не более **4 раз**. Счётчик виден в
имени документа: `-r1` … `-r4`; на четвёртом заходе ставится метка `review-4`.
- **Что считается циклом:** отправка на ревью → вердикт с блокирующими находками
→ возврат. Уточняющий вопрос без вердикта циклом не считается.
- **Исчерпание лимита — не «пятая попытка», а разбор.** Задача уходит владельцу,
решение одно из трёх:
1. **разделить** — issue закрывается как «заменён», вместо него 2–3 меньших с
ясным скоупом (частый настоящий диагноз: ТЗ было слишком большим);
2. **отклонить** — цена решения оказалась выше ценности;
3. **арбитраж владельца** — владелец фиксирует решение в issue, оно принимается
как есть; несогласие ревьюера записывается, но не блокирует.
- **Граница между «циклом» и «новым багом»:** до закрытия беты находка ревьюера —
возврат на правки; после закрытия — новый issue. Иначе лимит 4 обходится
заведением issue вместо возврата.
- Для лёгкого трека лимит ревью ТЗ — **2** цикла: задача на три часа, которую
переписывают трижды, лёгкой не была.
---
## 5. Лёгкий трек (метка `small`)
**Критерии — все одновременно:**
- сложность и риск ≤ 3;
- одна поверхность (один диалог, один модуль, один эндпоинт);
- нет миграции конфига и новых compatibility-полей;
- нет нового UX-контракта — меняется поведение в рамках уже описанного;
- нет влияния на производительность и на touch-контракт.
**Что упрощается:**
- ТЗ пишется **в теле issue** по шаблону: проблема · контракт · AC1…ACn с
доказательством · откат. Файл в `docs/specs/` не создаётся;
- ревью ТЗ — комментарий второго агента, отдельный документ не нужен;
- лимит ревью ТЗ — 2 цикла.
**Что не упрощается:** issue, оценка, статусы, трейлеры коммитов, changelog,
**код-ревью и его документ**, закрытие после беты. Код-ревью не пропускается
никогда — именно оно в этом процессе заменяет тестирование. Единственное
исключение — починка упавшего предрелизного гейта, §11.4.
Если по ходу выясняется, что критерий нарушен (появилась миграция, задело второй
модуль) — метка `small` снимается, issue возвращается в `S3-spec` и получает
нормальный файл ТЗ. Это не провал, это ранняя диагностика.
### 5.1 Короткий трек (метка `trivial`)
Решение владельца 2026-08-13, issue #128. Лёгкий трек делает ТЗ дешёвым; короткий
обходится без него совсем.
**Маршрут:** `S1-new` → `S2-analysis` → `S5-ready` → `S6-in-progress` →
`S7-code-review` → `S8-merged`. Стадии `S3-spec` и `S4-spec-review` пропускаются.
`S2-analysis` остаётся: это комментарий, а не прогон CI, и именно там владелец
решает приоритет и ценность. AC пишет автор в теле issue при переводе в
`S5-ready` — до перехода, иначе ревьюеру нечего будет сверять.
**Критерии, все обязательны:**
- тип `bug`;
- правка ограничена одной поверхностью, нового UX-контракта нет;
- нет миграции конфига, новых ключей i18n, влияния на перф и touch;
- AC выражаются тремя проверяемыми утверждениями или меньше;
- **ожидаемое поведение уже зафиксировано** — в `docs/USER-GUIDE.ru.md`, в
каноническом документе подсистемы либо однозначно в самом отчёте. Решать нечего.
Если есть что решать, это `S3-spec`, и никакая экономия этого не отменяет.
Метка ставится в `S2-analysis` вместе с остальными оценками, одним комментарием,
где владелец утверждает и приоритет.
**Что не упрощается:** issue, оценка, статусы, трейлеры, changelog и **код-ревью**.
Лимит циклов код-ревью — 2, как на лёгком треке.
Если по ходу выясняется, что критерий нарушен, метка снимается и issue уходит в
`S3-spec` за нормальным ТЗ. Как и на лёгком треке, это не провал, а ранняя
диагностика.
**Чем этот трек опасен.** Он убирает единственное место, где решение проверялось
до написания кода. Признак «решать нечего» держит всю конструкцию, и его нельзя
подтверждать ощущением — только ссылкой на уже зафиксированное поведение.
---
## 6. Роли
Один агент может исполнять несколько ролей в разных issue, но **не две роли в
одном артефакте**.
| Роль | Делает | Не имеет права |
|---|---|---|
| Аналитик | разбор, оценки, поверхности | окончательно ставить приоритет |
| Автор ТЗ | `docs/specs/NN-*.md` или ТЗ в issue | ревьюить своё ТЗ |
| Ревьюер ТЗ | `docs/reviews/SPEC-REVIEW-NN-rN.md` | править ТЗ вместо автора |
| Разработчик | код, автотесты, документация, changelog | ревьюить свой код, принимать golden |
| Ревьюер кода | `docs/reviews/CODE-REVIEW-*-rN.md`, проверка AC | править продуктовый код |
| Релиз-менеджер | пре-релиз, стабильный релиз, закрытие issue | добавлять код в релизный коммит |
| Владелец | приоритет, ценность, скоуп, отклонение, арбитраж, хотфикс | — |
**Правило разделения:** ревьюер работает состязательно. Ему передаётся тег или
диапазон коммитов и ТЗ — не рассказ автора о том, как всё хорошо.
**Роли закреплены за исполнителями** (решение владельца 2026-08-12):
| Исполнитель | Роли |
|---|---|
| **Codex** | аналитик, автор ТЗ, разработчик, релиз-инженер по команде владельца |
| **Claude** | ревьюер ТЗ, ревьюер кода, вся инфраструктура и дистрибуция |
| **Владелец** | приоритет, скоуп, арбитраж, закрытие issue, команда на выпуск |
Автор и ревьюер — **разные модели**, и это сильнее требования «другая сессия»:
одна модель, читая свой же артефакт заново, повторяет свои же слепые пятна.
Ревью ТЗ и код-ревью держатся в **разных сессиях** Claude: ревьюер кода не должен
приходить с контекстом того, как обсуждали ТЗ.
---
## 7. Артефакты и трассируемость
### 7.1 Цепочка
```
issue #NN
↔ ТЗ docs/specs/NN-slug.md (или тело issue при `small`)
↔ ревью ТЗ docs/reviews/SPEC-REVIEW-NN-rN.md (или комментарий при `small`)
↔ ветка issue/NN-slug
↔ коммиты трейлеры Issue: #NN · User-Visible: yes|no
↔ ревью кода docs/reviews/CODE-REVIEW-<tag|NN>-rN.md
↔ changelog бюллетень RU+EN со ссылкой на #NN
↔ бета тег, зелёный CI на точном SHA → закрытие
```
Обязательные разделы ТЗ: **сценарий** · **что человек увидит до и после** ·
проблема · скоуп и **не-скоуп** · контракт поведения · UX · модель данных и
миграция · i18n · критерии приёмки AC1…ACn с указанием доказательства · план
автотестов · риски · откат · release-артефакты.
Два первых раздела — продуктовые, и они идут первыми не случайно. **Сценарий:**
какая персона (`docs/SCOPE.md`), на какой поверхности, в какой момент это
встретит. **Что человек увидит:** одной фразой, без терминов реализации. ТЗ,
которое не может ответить на эти два вопроса, описывает работу, а не изменение
продукта.
**Размытое место не додумывается, а выносится владельцу.** Догадка, записанная
как факт, — худший вид дефекта: она проходит ревью, потому что выглядит решением.
Но спрашивать обо всём нельзя: владелец один, и анкета из двадцати пунктов хуже
угадывания. Порог такой (решение владельца 2026-08-13).
**Владельцу задаются только продуктовые вопросы** — что человек видит или делает
и какой объём видимых изменений входит в этот issue. Поведение в пограничном
случае; какая из персон важнее в конфликте; что считать приемлемой деградацией;
относится ли смежное поведение сюда или становится отдельной задачей.
**Всё, чего пользователь не наблюдает, агенты решают сами** либо согласовывают
между собой: где хранится состояние, в каком модуле стоит гвард, именование,
раскладка файлов, стратегия тестов, механика миграции. Решение записывается явным
блоком в конце ТЗ — «принято предположительно, поменять свободно», и ревьюер
вправе его оспорить. Технический спор автора и ревьюера решается вердиктом, а не
владельцем; до него он доходит только при исчерпании лимита циклов (§4).
**Смешанный вопрос делится, а не эскалируется целиком.** «Где живёт это
состояние» — техническое. «Переживает ли оно перезагрузку страницы и общее ли оно
для всех экранов» — продуктовое.
Вопросы задаются **одним комментарием, пачкой**, каждый в форме: что неясно ·
что изменится от ответа · **предлагаемый вариант по умолчанию**. Вопрос с готовым
вариантом стоит владельцу пяти секунд, вопрос без него — пяти минут. Пока ждём
ответа, issue остаётся в `S3-spec` и получает `blocked`: статус не подменяется,
`blocked` его дополняет, иначе конвейер считает задачу в работе, а она стоит.
### 7.2 Шаблоны комментариев
Короткие и однообразные, чтобы читались и человеком, и машиной.
- **Аналитика:** `Оценка: ценность N/10 · сложность N/10 · P<1-3> · тип ·
поверхности: … · дубликаты: … · лёгкий трек: да/нет`
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · цикл r<N>/<лимит> ·
High: N · Medium: N → #… · Документ: docs/reviews/…`
- **Закрытие:** `Выпущено в <тег беты> · CI: <ссылка> · Changelog: <ссылка>`
**Вперёд двигает только зелёный вердикт.** Жёлтый и красный возвращают автору;
разница между ними содержательна для человека, но не для маршрута. Первая
редакция конвейера (§10.4) пропускала жёлтый при `High: 0`, и первый же живой
прогон показал, почему это неверно: жёлтый там означал, что AC описывает неверное
изменение контракта — реализовать такое ТЗ значило бы сделать ошибку по инструкции.
### 7.3 Расхождения с текущим состоянием, которые надо закрыть
1. **Статус ТЗ дублирует статус issue.** `docs/specs/README.md` держит колонку
«Статус ТЗ» со своим словарём («черновик решения», «в реализации»,
«реализовано»). Два источника статуса уже расходятся. Колонку убрать, оставить
таблицу «issue ↔ ТЗ».
2. **Ревью до релиза 1.62 живут вне репозитория.** Документы `CODE-REVIEW-*.md` и
`SPEC-REVIEW-*.md` за прежний период лежат в папке владельца, и переносить их
задним числом смысла нет: они описывают код, которого уже нет. Новые документы
ревью кладёт в `docs/reviews/` сам конвейер, в ветку задачи.
---
## 8. Гейты
**Локальный гейт перед выходом из «В разработке»** — минимальный набор,
покрывающий изменённые поверхности (действующее правило владельца):
```
npx tsc --noEmit
npm test
npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js \
&& cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
node demo/smoke_<целевые>.mjs
npm run golden:verify # если менялся визуал
python -m pytest tests_backend -q # py3.13, если менялся бэкенд
```
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла. По
необходимости, определяемой diff'ом и AC: браузерные смоки (их 127 — прогон всех
уместен только когда задача задевает всё), `golden:verify` при изменении видимого
результата, `pytest tests_backend` при правках в Python, performance-профили при
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
пропуском.
**Гейт беты** (условие закрытия issue): CI Validate зелёный на точном SHA тега.
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
Упавший предрелизный гейт автор чинит и повторно прогоняет; зелёный прогон
достаточен для продолжения релиза, повторное код-ревью не требуется — §11.4.
**Гейт стабильного релиза:** полный локальный прогон плюс Validate и Full
Performance зелёные на точном SHA; статусов issue не касается.
---
## 9. Метки — канонический статус
Статус читается из меток: их видно в списке issue, их читает любой токен с
доступом к Issues, и по ним же работает конвейер — смена метки порождает событие
(§10.4). **Project v2 не используется** (решение владельца 2026-08-14): второе
представление статуса рядом с метками требовало отдельного скоупа токена,
синхронизации и внимания, а давало вид доски. Два источника одного факта
расходятся — это уже случалось с колонкой «Статус ТЗ» в `docs/specs/README.md`.
**Имена меток английские** (решение владельца 2026-08-12). Русские имена в этом
документе были только на бумаге; репозиторий с самого начала жил на английских.
| Метка | Статус |
|---|---|
| `S1-new` | Новое, не разобрано |
| `S2-analysis` | Аналитика и оценка |
| `S3-spec` | ТЗ в работе |
| `S4-spec-review` | ТЗ на ревью |
| `S5-ready` | Готово к разработке — единственный статус, из которого можно начать трогать код |
| `S6-in-progress` | В разработке, занято исполнителем |
| `S7-code-review` | Код-ревью |
| `S8-merged` | Ревью пройдено, код в `dev`, ждёт беты. Issue закрывается пачкой при выпуске |
| `blocked` | Ждём внешнего или владельца, **поверх** статусной метки |
| `rejected` | Отклонено, issue закрыт |
Модификаторы: `small` (лёгкий трек, сложность ≤3), `trivial` (короткий трек,
§5.1), `hotfix`, `process`, `review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
Тематические метки (`polish`, `infra`, `tests`, `docs`, `security`, `vacuum`)
ортогональны процессу.
Инварианты: **ровно одна `S*`-метка** на открытом issue; закрытый issue статусных
меток не несёт; `blocked` не заменяет статус, а дополняет его.
**Чужой issue берётся в работу так же, как свой — после явного решения
владельца** (решение владельца 2026-08-13, уточнено в тот же день). Репозиторий
публичный, отчёты заводят и посторонние; проверка стоит **на входе**, а не на
каждом шаге.
Входом служит присвоение первой статусной метки: пока меток нет, issue вне
процесса и инварианты на него не распространяются. Как только метка стоит, задача
в работе, и **кто её завёл, дальше не имеет значения** — статусы, ревью и лимиты
работают одинаково.
Присвоение метки и есть то самое явное решение, причём проверенное платформой:
метки может ставить только тот, у кого есть право записи в репозиторий. Прежняя
редакция требовала переоформлять чужой отчёт своим issue со ссылкой на исходный;
это оказалось работой впустую — на #123 к моменту отказа ТЗ уже было написано.
`S8-merged` появился позже остальных и закрывает разрыв, который раньше
закрывался памятью человека: код принят, но бета ещё не вышла, и issue закрывать
рано. Без него принятая задача либо висела в `S7-code-review`, либо закрывалась
досрочно.
---
## 10. Механизация при прямых коммитах в `dev`
Решение владельца — работать без PR. Значит, GitHub не может ничего заблокировать
на своей стороне: **основной гейт переезжает на клиента, CI остаётся страховкой.**
### 10.1 Хуки, которые невозможно забыть поставить
`.githooks/` в репозитории, `core.hooksPath` выставляется автоматически при
установке зависимостей:
```json
"scripts": { "prepare": "node scripts/install-hooks.mjs" }
```
`npm ci` вызывает `prepare` сам — значит, хуки появляются в каждом окружении,
включая свежий контейнер облачного агента, без отдельного шага в инструкции.
- **`commit-msg`** — есть, работает. Отклоняет коммит без терминального
`Issue: #NN`, требует ровно один `User-Visible: yes|no`, а для коммитов,
трогающих `demo/golden/baselines/**`, — `Release:` плюс `Baseline-Reviewed:`.
Реализация — `scripts/validate-commit-provenance.mjs`, тот же скрипт вызывается
job `provenance` в `validate.yml`.
- **`pre-push`** — есть, работает. Прогоняет `scripts/process-gate.mjs` по каждому
пушимому ref и останавливает push при нарушении. Это и есть блокирующий гейт
вместо PR. Удаление ветки и теги пропускаются: в первом случае проверять нечего,
во втором коммит уже проверен, когда его пушили. Для новой ветки диапазон
считается от `merge-base` с `origin/dev`, а не от начала истории — иначе в него
попали бы все нарушения, совершённые до появления гейта.
При возврате `main` в `dev` диапазон merge-коммита содержит второй родитель —
уже опубликованные в `main` коммиты с закрытыми issue. Для destination `dev`
общий скрипт pre-push/CI исключает только SHA, доказанно достижимые из
`origin/main`; сам merge и новые post-merge коммиты остаются под всеми
проверками. На `main`, beta/issue-ветки и обычный push в `dev` это исключение
не распространяется (issue #155).
Проверка статуса issue требует `gh`, поэтому при его отсутствии хук печатает
предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук,
который не работает в самолёте, отключают целиком, а строгий проход всё равно
делает CI.
**Хук обязан быть исполняемым, и это тише всего ломается.** Git **молча** не
запускает файл без бита `+x`: гейт сообщает об успехе тем, что его нет. Проверено
на настоящем push — при `644` от гейта ноль строк и push проходит, при `755` он
останавливается.
Через GitHub API режим не выставляется: файл, отправленный так, приезжает
`100644`. Поэтому `scripts/install-hooks.mjs` восстанавливает бит при каждой
установке зависимостей, а `assertHookMode` дополнительно проверяет бит
`.githooks/commit-msg` в индексе. Правится вручную:
`git update-index --chmod=+x .githooks/<хук>`.
### 10.2 Что проверяет `process-gate.mjs`
Реализовано, `scripts/process-gate.mjs`, issue #105. Офлайн, без GitHub API:
1. трейлер `Issue: #NN` у каждого коммита класса A/B, допускается несколько;
2. имя ветки `issue/NN-slug` соответствует трейлерам;
3. для класса A существует `docs/specs/NN-*.md` — **или** issue помечен `small`.
Офлайн это предупреждение: лёгкий трек держит ТЗ в теле issue, и без чтения
меток «ТЗ в issue» неотличимо от «ТЗ не написано». С `--issues` — отказ;
4. `User-Visible: yes` → правки в обоих changelog в том же коммите;
5. коммит только класса D невалиден без `Release: vX.Y.Z` либо
`Baseline-Reviewed: <ссылка на прогон CI>`;
6. релизный коммит не содержит изменений в `src/` и `custom_components/**/*.py`;
7. документов ревью на один issue не больше четырёх (`-r1`…`-r4`).
С токеном GitHub:
8. `--issues` тянет каждый упомянутый issue и требует метку из
{`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`}; закрытый,
недоступный или помеченный `blocked` — отказ (**fail closed**).
Три оговорки к проверке 8 выяснились при реализации.
**`S8-merged` входит в множество**, хотя по смыслу задача уже принята. Причина
механическая: конвейер (§10.4) сливает ветку в `dev` **раньше**, чем ставит метку,
Validate стартует от этого push и успевает прочитать issue уже в `S8-merged`.
Строгое множество красило бы каждую принятую задачу. Локальная строгость
возвращается флагом `--no-merged`.
**Статус спрашивается только у коммитов класса A/B.** Правило №1 говорит о
продуктовом коде и инструментах, а не о документации. Иначе краснел бы каждый
документ ревью: он ложится в ветку задачи, пока та в `S4-spec-review` или
`S7-code-review`, то есть заведомо вне рабочего множества.
**При продвижении в `main` не перепроверяются коммиты, уже достижимые из
prerelease-тега.** После выпуска беты их issue по §2.8 должны быть закрыты, а
stable fast-forward снова включает эти коммиты в диапазон `old-main..candidate`.
Pre-push передаёт целевую remote ref через `--target-ref`, а Validate — через
`TARGET_REF`; оба исключают только уже опубликованную prerelease-историю. Любой
post-beta коммит остаётся в проверке и по закрытому issue отклоняется fail-closed.
Не реализовано и остаётся долгом:
9. `npm run release:prerelease -- --issues=…` не проверяет, есть ли у issue
зелёный вердикт код-ревью;
10. закрытие issue и снятие статусных меток при публикации беты делаются руками —
`node process-labels/apply.mjs cleanup --apply`, а не `publish-prerelease.yml`.
Пропуск этого шага уже ломал инвариант «закрытый issue без статусной метки».
### 10.3 Страховка и разбор
- **`process-gate.mjs` — job `process-gate` в `validate.yml`**, без `needs`:
краснеет сам и не роняет остальные. При прямом push проверка догоняющая: код уже
в `dev`, CI краснеет после. Это принятая цена отказа от PR: `pre-push` ловит
нарушение до отправки, а этот job — то, что прошло мимо хука, включая
`--no-verify` и окружение без установленных зависимостей.
- **Нарушение не откатывается force-push'ем** (правило 17): исправляющий коммит
плюс issue с меткой `process`. Починить надо проверку, а не только симптом.
- **Еженедельная гигиена** (workflow): issue в `S1-new` дольше 14 дней и в
`S6-in-progress` дольше 7; issue класса A в `S5-ready` без ТЗ; issue с нулём или
двумя `S*`-метками; коммиты без трейлера за неделю — **цель 0**; rework rate и
число issue, дошедших до `review-4`; **баги, заведённые после закрытия беты** —
прямая цена отказа от фазы тестирования.
### 10.4 Событийный конвейер: метка как триггер
`.github/workflows/process.yml`, issue #114. Смена статусной метки — не запись в
журнал, а **сообщение**: она порождает событие, событие запускает следующий шаг.
```
S4-spec-review → ревью ТЗ → S5-ready либо возврат в S3-spec
S7-code-review → код-ревью → слияние в dev → S8-merged либо возврат в S6-in-progress
```
Ревьюер — `anthropics/claude-code-action`. Он читает `docs/SCOPE.md`, `AGENTS.md`,
этот документ и тело issue, публикует разбор комментарием, заводит issue на каждую
Medium-находку, кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
структурированным JSON. **Метку переставляет отдельный детерминированный шаг по
вердикту, а не модель.**
Четыре вещи, без которых конвейер молча не работает:
1. метки переставляет **PAT**, а не `GITHUB_TOKEN`: GitHub намеренно не порождает
события от `GITHUB_TOKEN`, чтобы не было циклов, и цепочка обрывалась бы после
первого шага без ошибок в логах;
2. `process.yml` обязан лежать в **ветке по умолчанию**: для события `issues`
GitHub берёт workflow только оттуда, независимо от содержимого `dev`;
3. слияние в `dev` происходит **до** простановки `S8-merged`, иначе метка врёт в
промежутке — она утверждает, что код в `dev`;
4. многострочный текст внутри `run:` — только через heredoc: строка с нулевым
отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
**Автор обязан дождаться вердикта, а не заканчивать сессию.** Ревью идёт от десяти
минут до сорока пяти. Отчёт «передал на ревью» останавливает конвейер там, где он
мог идти сам: вердикт придёт, а подхватить его будет некому. У агента нет часов —
он существует только в момент своего хода, поэтому ожидание это опрос: раз в 90
секунд, не более 30 попыток. Смотреть на метку, а не на комментарий: метка и есть
состояние. При `blocked` не ждать — задача ждёт владельца.
**После прогона ревью метка меняется всегда.** Инвариант появился не сразу: первая
редакция при конфликте слияния оставляла метку на месте, и это оказалось тупиком —
автор ждёт смену метки, метка не менялась, и он тридцать раз опрашивал впустую,
чтобы отчитаться «лимит исчерпан» при зелёном вердикте. Состояние, из которого
никто не может выйти и о котором никто не узнает, для конвейера хуже громкой
ошибки.
Поэтому зелёное код-ревью с неудавшимся слиянием ведёт не в `S8-merged`, а в
`S6-in-progress`: работа действительно вернулась к автору, только осталась не
правка кода, а ребейз. Вердикт при этом в силе, переделывать нечего. После ребейза
метка `S7-code-review` возвращается и ревью идёт заново — не формальность:
после ребейза на ушедший вперёд `dev` это другой код.
Если метка не сменилась, значит упал сам прогон, а не работа: смотреть логи и
сообщать владельцу, а не продолжать опрос.
Цикл считается **по этапу**: вердикт по ТЗ не расходует бюджет код-ревью. Раньше
считались все вердикты подряд, и первое код-ревью #89 получило `r2/4`.
---
## 11. Исключения
### 11.1 Лёгкий трек
См. §5 — это не исключение из правила №1, а более дешёвый путь по тем же статусам.
### 11.2 Аварийный хотфикс (метка `hotfix`, решение владельца)
Разрешено писать код до появления issue. Обязательно:
- issue создан в **той же сессии до коммита**, метка `hotfix`;
- ТЗ «как сделано» + раздел «почему нельзя было ждать»;
- в течение 24 часов задача ретроспективно проходит код-ревью;
- аварийность названа явно в релизном хендоффе (действующее правило `AGENTS.md`).
### 11.3 Гигиена репозитория
Механические изменения без изменения поведения (форматирование, мёртвые файлы)
идут под квартальный umbrella-issue «Гигиена репозитория»; каждый коммит
ссылается на него. Трассируемость 1:1 сохраняется.
### 11.4 Починка предрелизных гейтов без повторного код-ревью
Решение владельца 2026-08-13.
В цикле реализации гоняется только лёгкий набор — typecheck, unit, build (§8).
Golden, браузерные смоки, performance и полный HA-харнесс запускаются перед бетой,
то есть **после** того, как код-ревью пройдено и issue в `S8-merged`. Часть
проблем физически не может быть найдена раньше.
**Если предрелизный гейт упал, автор правит, повторно прогоняет упавшее, и
зелёного прогона достаточно, чтобы релиз продолжился.** Issue остаётся в
`S8-merged` и на повторное код-ревью не отправляется.
Причина: полный цикл ревью в момент выпуска стоит дороже, чем риск, который он
здесь снимает. Гейт уже назвал дефект точно, а исправление проверяется тем же
гейтом — то есть проверка объективна и не зависит от чьего-либо суждения.
**Что при этом обязательно:**
- прогон упавшего гейта записан в issue: **точная команда и её результат**.
«Verified» без команды доказательством не является (§8);
- трейлеры на коммите как обычно, `Issue: #NN` того же issue;
- при `User-Visible: yes` — правки в оба changelog в том же коммите;
- эталоны golden принимаются только через `npm run golden:accept -- --reviewed`
на полном артефакте Linux CI. «Чтобы гейт позеленел» основанием не является.
**Границы, за которыми исключение не действует.** Оно про починку названного
гейтом дефекта, а не про продолжение разработки под видом починки. Правка идёт
обычным путём — новым issue либо возвратом в `S6-in-progress` — если она:
- меняет контракт поведения или добавляет пользователю что-то новое;
- задевает подсистему, которой в исходной задаче не было;
- по объёму сопоставима с самой задачей;
- меняет сам гейт вместо кода — правка теста, чтобы он перестал падать, это не
починка, а сокрытие. Исключение — когда дефект **в фикстуре** и это доказано
разбором, как на #89: солнце на азимуте 180° и единственное окно на северной
стене, поэтому луч честно не строился.
Границу определяет автор, и здесь процесс сознательно отдаёт ему то, что в
остальных местах не доверяет — оценку собственной работы. Плата за скорость в
единственной точке, где цикл ревью стоит дороже всего. Компенсируется тем, что
запись в issue публична и релиз-менеджер видит, что именно было сделано перед
выпуском.
Это исключение из правила «код-ревью не пропускается никогда» (§5, §7.1) —
единственное, и относится только к окну между `S8-merged` и выпуском.
---
## 12. Запрещено
- код без issue или из статуса раньше «Готово к разработке»;
- ТЗ, написанное после кода (кроме §11.2, и тогда с пометкой «как сделано»);
- ревью своей работы; перевод своей работы через ревью-гейт;
- пятый цикл ревью вместо разбора по §4;
- заведение issue вместо возврата на правки, чтобы обойти лимит циклов;
- принятие golden-эталонов ради зелёного CI или по частичному артефакту;
- закрытие issue до выпуска беты с зелёным CI;
- переоткрытие закрытого issue вместо нового бага;
- Medium-находки, оставленные как TODO в документе ревью;
- **параллельные бэклоги** в файлах (`BACKLOG-*.md`, «планы» в docs);
- ревью-документы вне репозитория;
- попутные правки «раз уж я здесь»;
- фича или материальное изменение поведения в стабильном релиз-коммите;
- force-push в `dev`;
- ручное копирование на домашний инстанс.
**Нарушение процесса — тоже issue** (метка `process`): если правило удалось
нарушить незаметно, виновата проверка.
---
## 13. Внедрение
Состояние на 2026-08-13.
1. ✅ **Метки созданы, бэклог размечен.** У всех открытых issue владельца ровно
одна `S*`-метка, инварианты чистые.
2. ⏳ **Колонку «Статус ТЗ» из `docs/specs/README.md` убрать** — не сделано, §7.3
п.1. Перенос старых документов ревью в `docs/reviews/` отменён: они описывают
код, которого уже нет.
3. ✅ **Гейт написан** — `scripts/process-gate.mjs` плюс job в `validate.yml`,
issue #105. Прошёл **вне** флоу как инфраструктурная задача (§1, issue #118), а
не через ТЗ и ревью, как предполагала прежняя редакция этого пункта.
4. ✅ **Долг ревью списан решением владельца.** Беты `beta.2`…`beta.10` сделаны по
прежнему процессу и не пересматриваются. Точка отсчёта — релиз 1.62.0; отсчёт
начинается с первой беты следующей линии.
5. ⏳ Завести issue на находку «смок `visual_continuity` не умеет падать» — это
ровно тот класс дефектов, который в процессе без ручного тестирования стоит
дороже всего.
6. ✅ `BACKLOG-2026-08-11.md` — разовый отчёт, решения живут в issue.
7. ✅ `AGENTS.md` переписан целиком, шире блока §14.
8. ✅ **Канон перенесён в репозиторий** (issue #112). До этого полный процесс жил
только в папке владельца, а в репозитории лежал файл на 51 строку про трейлеры
коммитов — из свежего клона канон не был виден вообще.
9. ✅ **`pre-push` написан** (§10.1, issue #121). Блокирующая проверка на клиенте
есть; обойти её можно только `--no-verify`, и тогда то же найдёт CI.
---
## 14. Блок для AGENTS.md
```markdown
## Процесс: код только через issue
Изменение продуктового кода без issue запрещено. Код меняется только из статуса
«Готово к разработке» или дальше. Полные правила, критерии статусов и гейты —
`docs/PROCESS.md`, читать до начала работы.
Жизненный цикл (статус = метка issue): `S1-new` → `S2-analysis` → `S3-spec` →
`S4-spec-review` → `S5-ready` → `S6-in-progress` → `S7-code-review` → `S8-merged`
→ закрытие пачкой при выпуске беты. Оба ревью возвращают на правки не более 4
циклов; пятый заход — разбор у владельца (разделить / отклонить / арбитраж).
Ревью запускается **само** от меток `S4-spec-review` и `S7-code-review` и идёт до
45 минут. Поставив такую метку, автор не заканчивает работу, а ждёт смены метки
опросом и продолжает по тому, чем она стала.
- ветка `issue/<NN>-<slug>`, коммиты с трейлерами `Issue: #NN` и `User-Visible: yes|no`;
- работаем прямыми коммитами в `dev`, без PR: блокирующий гейт — локальный
`pre-push` (ставится автоматически через `npm ci`), CI — страховка. Force-push
в `dev` запрещён;
- автор ≠ ревьюер, ни для ТЗ, ни для кода;
- фазы ручного тестирования нет: автотесты пишутся в реализации, AC проверяет
код-ревью, найденные позже дефекты — новые issue типа «баг»;
- мелкие задачи (метка `small`, сложность ≤3): ТЗ в теле issue, ревью ТЗ
комментарием, код-ревью — как обычно;
- найденное вне скоупа — новый issue, а не попутная правка;
- issue закрывает релиз-менеджер после выпуска беты, не исполнитель.
```
+63 -22
View File
@@ -15,6 +15,11 @@ room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wa
tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
right on your Lovelace dashboard.
> **Use a desktop computer to edit plans.** View and kiosk are fully supported
> on phones and tablets. The editors are designed primarily for a desktop
> browser with a mouse and keyboard; individual editing operations on touch
> devices may be awkward, limited, or unavailable.
![Interactive Home Assistant floor plan: live rooms, devices, lights and climate on a real floorplan card](docs/images/demo.gif)
> ### 🚀 Try it live — no install needed
@@ -39,8 +44,9 @@ right on your Lovelace dashboard.
you drag, so the drawing and the photo of your plan finally line up.
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
whole groups of lights (works for dumb switches and stateless remotes too).
- 🌒 **“Light sources” fill** — a dark house where every lit lamp casts a pool
of its own color that spills through doorways and open zone boundaries.
- 🌒 **“Light sources” fill** — a dark house where every lit lamp lights exactly
the floor it can see: through doorways and open boundaries, stopped by walls,
columns and partitions, which cast real shadows.
- ☀️ **The sun on the plan** — set the compass and the backdrop lives with
the day (white noon → golden hour → deep night), while windows on exterior
walls cast real wedges of sunlight into the rooms; optional cloud cover
@@ -57,8 +63,10 @@ right on your Lovelace dashboard.
- 🤖 **Live robot vacuums** — the dock marker stays put while a round puck
drives the plan in real time, pouring its path out from under itself;
current and previous cleanup runs are recorded server-side. Calibration is
one click (rooms matched by name) or a drag-and-stretch overlay. Works with
Xiaomi Cloud Map Extractor, Tasshack dreame-vacuum and Valetudo.
one click (rooms matched by name) or a drag-and-stretch overlay. A diagnostic
source picker also covers registry-less map cameras without silently
rebinding broken sources. Works with Xiaomi Cloud Map Extractor, Tasshack
dreame-vacuum and Valetudo.
- 🔔 New devices appear automatically with a red “new” dot; the layout is stored
**server-side** — one shared plan for every user and screen, synced live.
@@ -101,9 +109,10 @@ Assistant integration:
| **Zoom** | Smooth vector zoom | Usually a fixed image | SVG / virtual canvas |
**One sentence:** House Plan is the shared, area-aware live map of your home —
not a drawing app for furniture. If you want to sketch sofas and walls from
scratch, a draw-centric card may fit better; if you already have a plan image
and a real HA areas registry, House Plan keeps every tablet on the same layout.
not a general-purpose CAD package. Its Background editor covers practical
decor, labels and furniture; if you need unrestricted architectural drafting,
a draw-centric tool may fit better. With a plan and HA areas, House Plan keeps
every tablet on the same live layout.
Key advantages in short:
@@ -112,11 +121,17 @@ Key advantages in short:
- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
Icon colors follow one principle — **yellow means the device is doing its main job right now**:
a light is shining, a socket is powering, a fan is spinning, media is playing, a vacuum is
cleaning, a radiator valve is actually heating (not merely enabled). Orange = open / unlocked.
a light is shining, a socket is powering, a fan is spinning, a vacuum is
cleaning, a radiator valve is actually heating (not merely enabled). For climate integrations,
a reported work action is authoritative; when an integration exposes only its enabled HVAC mode,
that mode is the best available fallback. Orange = open / unlocked.
A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
A translucent icon = unavailable. Dark = idle.
- **A coherent visual Background editor.** Draw lines/shapes, place labels and
furniture, edit physical styles and transform every object with the same
selection model. The plan image has its own move/resize/rotate tool, numeric
properties and shared Undo/Redo.
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
---
@@ -198,7 +213,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png)
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions and can be moved and resized at any time in the Background editor.
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions by default and can be moved, resized or rotated at any time in the Background editor.
![Space creation dialog](docs/images/03-space-dialog.png)
@@ -222,15 +237,17 @@ Rooms may not overlap: a click strictly inside an existing room, or an outline t
- **Split** — click a room, then two points on its walls; the chord cuts it in two. The bigger part stays the room it was (name, area, devices); the smaller one asks for a new name and area.
### Doors, windows and locks
### Doors, windows, gates and locks
In markup mode the **"Opening"** tool places doors and windows: click next to a wall and the
In markup mode the **"Opening"** tool places doors, windows and gates: click next to a wall and the
opening snaps onto it. Pick the type, the **length in real centimetres** (defaults: door 90 cm,
window 120 cm), an open/close sensor and — for doors — a **lock entity**.
window 120 cm, gate 300 cm), an open/close sensor and — for doors and gates — a **lock entity**.
With a sensor bound, the plan comes alive: the door leaf swings on its hinge and the swing arc
draws itself in as the real door opens; a window opens its two casements. While open, the moving
parts take an accent colour. A door with a lock shows a padlock badge next to it — green when
parts take an accent colour. A gate keeps a 3–4 m opening compact on the plan: two half-width
leaves open only 10° outwards, without a full-width swing arc, while contact, lock and light
passage work exactly like a door. A door or gate with a lock shows a padlock badge next to it — green when
locked, orange when unlocked. For safety the lock can **not** be toggled from the plan; a click
on the opening shows a status card with both states instead.
@@ -241,7 +258,7 @@ walls** (it slides around corners too), and a **double click opens its propertie
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Show hidden"**: hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light.
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Hidden and disabled"**: user-hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light. A device disabled in Home Assistant appears there as a labelled grey service ghost and is excluded from all plan data/actions until it is enabled in HA again.
From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
@@ -261,12 +278,15 @@ Switch to the **Devices** tab to arrange icons: drag them with the mouse, click
### Tap actions: control devices from the plan
By default a tap on an icon opens its info card. In the card settings you can switch
**Tap on a device** to *Toggle* — a tap then switches lights, sockets, fans and
humidifiers directly on the plan (wall-tablet style). For safety, a card-wide toggle
never affects locks, alarms, covers or valves; you can consciously enable toggle for a
specific device (except locks and alarms — those never toggle from the plan) in its
edit dialog. A **long press** always opens the info card.
By default a tap on an icon opens its info card. A device can instead use the
universal **Toggle state** action. Its editor shows the exact entity or configured
group, the current state and what the next tap will do; when nothing can be toggled,
it says so and the tap is a quiet no-op rather than an unexpected info-card fallback.
Lights keep their convenient toggle default. Covers and valves use open/close/stop
semantics automatically, while locks, alarm panels and secure garage/door/gate covers
remain blocked. An exact entity binding never falls through to a sibling switch, and
temporarily unavailable group members are skipped without erasing the configuration.
A **long press** still opens the info card and right-click still opens HA more-info.
### Icon rules
@@ -282,6 +302,27 @@ You can also place a **single entity** (not just a whole device): start typing i
Not everything has to be left to the automation. With the **+** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**.
To represent a dumb physical lamp controlled by a smart relay, place a virtual
point where the lamp really is and set **Light source → Always**. Manual colour,
brightness and radius stay available even though the point has no HA entity.
Then open the relay and add that plan source under **Controls other light
sources**. The relay continues to show the aggregate working state, while Glow,
room fill and statistics belong to the lamp's position. An unlinked passive
Always source is deliberately constant-on. With several own `light.*`/`switch.*`
entities, Always also offers a leading-entity selector; a missing saved choice
is warned about and retained while a deterministic fallback is used.
To make that virtual lamp manually switchable without creating a Home
Assistant helper, also choose **Tap action → Toggle state** on the lamp itself.
This exact combination — virtual binding, **Light source → Always**, and
**Toggle state** — stores a shared on/off state in the House Plan integration.
It survives page reloads and Home Assistant restarts and updates Glow, room
fill/statistics, full cards and `houseplan-space-card` together. Any signed-in
dashboard viewer may toggle it. While this manual mode is active, saved
**Controls other light sources** remain intact but are not called; changing the
role, binding or tap action restores their normal behaviour. This operational
state is deliberately not part of plan exports or Home Assistant entities.
The same dialog controls how the device looks on the plan. **Display** switches between the
icon badge, an animated **presence ripple** (pulsing rings while the entity is active, a faint
dot when idle — great for motion sensors) or both, with a per-device ring colour and size. The
@@ -327,7 +368,7 @@ Services → House Plan**.
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden (the "Hide device from plan" checkbox — set automatically for bridges, scenes and other non-physical records) — open the device editor, press **"Show hidden"** and untick the box in its dialog.
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. Open the device editor and press **"Hidden and disabled"**: a blue ghost is user-hidden and can be shown; a grey disabled ghost must first be enabled in Home Assistant.
**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
+29 -18
View File
@@ -6,7 +6,7 @@
[![Live demo](https://img.shields.io/badge/демо-попробовать-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
[![Telegram chat](https://img.shields.io/badge/Telegram-чат-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · **[Продуктовый аудит и план улучшений](docs/PRODUCT-IMPROVEMENT-PLAN.ru.md)**
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · 🗂 **[Беклог проекта](https://github.com/users/Matysh/projects/1)**
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
@@ -15,6 +15,11 @@
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
редакторов — весь план настраивается прямо на дашборде.
> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и
> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны
> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные
> операции могут быть менее удобны, работать ограниченно или отсутствовать.
![Интерактивный план дома для Home Assistant: комнаты, устройства, свет и климат на реальном поэтажном плане](docs/images/demo.gif)
> ### 🚀 Попробовать вживую — без установки
@@ -31,14 +36,15 @@
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
план обратно в экран.
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
- 🖱 **Редакторы прямо в карточке** — комнаты, двери, окна и ворота, комнаты-острова,
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
линейка в реальных метрах.
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
даёт пятно своего цвета, проникающее через дверные проёмы и открытые зоны.
освещает ровно тот пол, который видит: через проёмы и открытые границы,
а стены, колонны и перегородки его не пропускают и дают настоящие тени.
- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
в комнаты настоящие клинья солнечного света; облачность — опционально, от
@@ -55,8 +61,10 @@
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
(по именам комнат) или перетаскиванием призрака карты. Работают Xiaomi
Cloud Map Extractor, dreame-vacuum (Tasshack) и Valetudo.
(по именам комнат) или перетаскиванием призрака карты. Диагностика и явный
выбор источника поддерживают registry-less камеры карт и не подменяют молча
сломавшуюся привязку. Работают Xiaomi Cloud Map Extractor, dreame-vacuum
(Tasshack) и Valetudo.
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
@@ -99,9 +107,10 @@ YAML/SVG, либо конфиг живёт в YAML карточки. House Plan
| **Масштаб** | Плавный векторный зум | Обычно фиксированная картинка | SVG / виртуальный холст |
**Одной фразой:** House Plan — это общая, area-aware живая карта дома, а не
редактор мебели. Если нужно с нуля нарисовать диваны и стены — лучше
draw-карточка; если есть картинка плана и зоны в HA — House Plan держит один
и тот же layout на всех планшетах.
универсальная CAD-система. Редактор подложки покрывает практический декор,
надписи и мебель; для свободного архитектурного черчения лучше отдельный
draw-инструмент. При наличии плана и зон HA House Plan держит один живой layout
на всех планшетах.
Ключевые преимущества коротко:
@@ -110,12 +119,14 @@ draw-карточка; если есть картинка плана и зоны
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка
реально греет (а не просто включена). Для climate-сущностей переданное действие приоритетно;
если интеграция сообщает только включённый HVAC-режим, он служит лучшим доступным приближением.
Оранжевый = открыто / не заперто. Пульсирующее красное
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
Полупрозрачный значок = недоступно. Тёмный = покой.
- **Подложку можно двигать и масштабировать.** Картинка плана не прибита к холсту: в редакторе подложки её тянут за тело и за угол, а плашка показывает реальный размер в метрах — чертёж и фотография плана наконец совмещаются.
- **Единый визуальный редактор подложки.** Линии, фигуры, надписи и мебель используют общее выделение, физические стили и Undo/Redo. Картинка плана не прибита к холсту: отдельный инструмент двигает, масштабирует и поворачивает её, а числовой диалог задаёт точный размер и угол.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
---
@@ -199,7 +210,7 @@ title: План дома
![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png)
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка сохраняет свои пропорции, а подвинуть и отмасштабировать её можно в любой момент в редакторе подложки.
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка по умолчанию сохраняет пропорции, а подвинуть, изменить размер или повернуть её можно в любой момент в редакторе подложки.
![Диалог создания пространства](docs/images/03-space-dialog.png)
@@ -223,15 +234,15 @@ title: План дома
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
### Двери, окна и замки
### Двери, окна, ворота и замки
В режиме разметки инструмент **«Проём»** ставит двери и окна: кликните рядом со стеной — проём
В режиме разметки инструмент **«Проём»** ставит двери, окна и ворота: кликните рядом со стеной — проём
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
окно 120 см), датчик открытия и — для двери — **замок**.
окно 120 см, ворота 300 см), датчик открытия и — для двери или ворот — **замок**.
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
подвижные части подсвечены акцентным цветом. У двери с замком рядом отображается замочек —
подвижные части подсвечены акцентным цветом. Ворота не занимают полплана даже при ширине 3–4 м: две половинные створки показаны открытыми наружу всего на 10°, без большой дуги. Датчик, замок и пропуск света работают как у двери. У двери или ворот с замком рядом отображается замочек —
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
переключить — клик по проёму показывает карточку с обоими статусами.
@@ -242,7 +253,7 @@ title: План дома
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти скрытые устройства, откройте редактор устройств и нажмите **«Показать скрытые»**: они отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти их, откройте редактор устройств и нажмите **«Скрытые и деактивированные»**: пользовательски скрытые устройства отображаются синими призраками. Деактивированное в HA устройство показывается серым служебным призраком и полностью исключается из данных и действий плана до повторной активации.
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
@@ -329,7 +340,7 @@ title: План дома
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто автоматически — откройте редактор устройств, нажмите **«Показать скрытые»**, откройте его диалог, нажмите **«Показать»**, затем **«Сохранить»**.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Откройте **«Скрытые и деактивированные»**: синий призрак можно показать в его диалоге, серый сначала нужно активировать в Home Assistant.
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
+81 -30
View File
@@ -1,6 +1,7 @@
"""House Plan: server-side house plan configuration + Lovelace card serving."""
from __future__ import annotations
import inspect
import logging
from datetime import timedelta
from pathlib import Path
@@ -23,7 +24,12 @@ from .const import (
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
from .plans import collect_attachments, collect_plans, sweep_upload_temps
from .repairs import async_check_plan_files
from .store import HouseplanConfigEntry, create_data
from .store import (
HouseplanConfigEntry,
async_save_config_state,
async_save_layout_state,
create_data,
)
_LOGGER = logging.getLogger(__name__)
@@ -32,22 +38,37 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
hass.data.setdefault(DOMAIN, {})
hp_ws.async_register(hass)
from .http_api import HouseplanContentView, HouseplanUploadView
from .http_api import HouseplanContentView, HouseplanImportPreviewView, HouseplanUploadView
hass.http.register_view(HouseplanUploadView())
hass.http.register_view(HouseplanContentView())
hass.http.register_view(HouseplanImportPreviewView())
return True
async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
"""Config entry: stores in runtime_data, static paths, card auto-registration."""
data = create_data(hass)
# Home Assistant's installation id never leaves the instance. Exports
# carry only a salted SHA-256 fingerprint so same-instance internal files
# can be distinguished from cross-instance references.
try:
from homeassistant.helpers import instance_id as ha_instance_id
value = ha_instance_id.async_get(hass)
data.instance_id = str(await value if inspect.isawaitable(value) else value)
except Exception: # noqa: BLE001 - old HA/test harness fallback
data.instance_id = str(entry.entry_id)
# test-before-setup: storage must be readable, otherwise retry later
try:
await data.store.async_load()
await data.config_store.async_load()
except Exception as err: # noqa: BLE001 — corrupt/unreadable .storage
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
try:
await data.virtual_light_store.async_load()
except Exception: # noqa: BLE001 — operational state fails safe to default on
_LOGGER.exception("House Plan: virtual-light storage is not readable; using default on")
entry.runtime_data = data
# server-side vacuum trails: the integration records the path itself
@@ -126,10 +147,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
cfg = stored.get("config")
lay_stored = await data.store.async_load() or {}
layout = lay_stored.get("layout") or {}
lay_meta = {
k: v for k, v in lay_stored.items()
if k not in ("layout", "rev", "geom_pending")
}
pending = {
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
}
@@ -137,15 +154,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
if merged:
lay_rev = int(lay_stored.get("rev", 0))
if merged != pending: # 1. the durable intent, before anything moves
await data.store.async_save(
{**lay_meta, "layout": layout, "rev": lay_rev, "geom_pending": merged}
await async_save_layout_state(
data, lay_stored, layout, lay_rev,
metadata={"geom_pending": merged}, remove=("geom_pending",),
)
rev = int(stored.get("rev", 0))
if cfg and migrate_config(cfg): # 2. the config half
rev += 1
await data.config_store.async_save({"config": cfg, "rev": rev})
await async_save_config_state(data, cfg, rev, previous_rev=rev - 1)
migrate_layout(layout, merged) # 3. the layout half + intent cleared
await data.store.async_save({**lay_meta, "layout": layout, "rev": lay_rev + 1})
await async_save_layout_state(
data, lay_stored, layout, lay_rev + 1, remove=("geom_pending",)
)
_LOGGER.info(
"House Plan: migrated %s space(s) to the square canvas", len(merged)
)
@@ -157,6 +177,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
# config and layout store writes. The target was persisted before either
# visible half changed, so setup can always converge on the requested pair.
optimize_revs: tuple[int, int] | None = None
recovered_import = False
async with data.write_lock:
stored = await data.config_store.async_load() or {}
lay_stored = await data.store.async_load() or {}
@@ -167,30 +188,60 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
target_layout = pending["layout"]
config_rev = int(stored.get("rev", 0))
layout_rev = int(lay_stored.get("rev", 0))
if stored.get("config") != target_config:
config_rev += 1
await data.config_store.async_save({
"config": target_config,
"rev": config_rev,
})
if lay_stored.get("layout", {}) != target_layout:
layout_rev += 1
layout_meta = {
k: v for k, v in lay_stored.items()
if k not in ("layout", "rev", "optimize_pending", "optimize_backup")
}
if not pending.get("clear_backup") and "optimize_backup" in lay_stored:
layout_meta["optimize_backup"] = lay_stored["optimize_backup"]
await data.store.async_save({
**layout_meta,
"layout": target_layout,
"rev": layout_rev,
})
target_config_rev = int(pending.get(
"config_rev", config_rev + (stored.get("config") != target_config)
))
target_layout_rev = int(pending.get(
"layout_rev", layout_rev + (lay_stored.get("layout", {}) != target_layout)
))
if stored.get("config") != target_config or config_rev < target_config_rev:
previous_config_rev = config_rev
config_rev = max(config_rev, target_config_rev)
await async_save_config_state(
data,
target_config,
config_rev,
previous_rev=previous_config_rev,
)
if lay_stored.get("layout", {}) != target_layout or layout_rev < target_layout_rev:
layout_rev = max(layout_rev, target_layout_rev)
exact_metadata = pending.get("final_metadata")
replace_metadata = isinstance(exact_metadata, dict)
metadata = dict(exact_metadata) if replace_metadata else None
if not replace_metadata and not pending.get("clear_backup") \
and "optimize_backup" in lay_stored:
metadata = {"optimize_backup": lay_stored["optimize_backup"]}
remove_metadata = ["optimize_pending", "optimize_backup"]
if pending.get("clear_backup"):
# A recovered whole-plan undo replaces the complete layout;
# a point-wise repair snapshot from the replaced layout must
# not survive and later restore coordinates into the new pair.
remove_metadata.append("repair_backup")
await async_save_layout_state(
data,
lay_stored,
target_layout,
layout_rev,
metadata=metadata,
remove=tuple(remove_metadata),
replace_metadata=replace_metadata,
)
optimize_revs = (config_rev, layout_rev)
_LOGGER.warning("House Plan: completed an interrupted plan optimization")
recovered_import = str(pending.get("kind") or "").startswith("import")
_LOGGER.warning(
"House Plan: completed an interrupted %s",
str(pending.get("kind") or "plan optimization").replace("_", " "),
)
if optimize_revs is not None:
hass.bus.async_fire("houseplan_config_updated", {"rev": optimize_revs[0]})
hass.bus.async_fire("houseplan_layout_updated", {"rev": optimize_revs[1]})
if recovered_import:
await recorder.async_refresh()
current = (await data.config_store.async_load() or {}).get("config") or {}
live_ids = {str(marker.get("id")) for marker in current.get("markers") or []}
for marker_id in list(recorder.book.data):
if marker_id not in live_ids:
await recorder.async_delete(marker_id)
await async_check_plan_files(hass, entry)
+18 -3
View File
@@ -3,8 +3,9 @@
DOMAIN = "houseplan"
STORAGE_KEY = f"{DOMAIN}.layout"
STORAGE_CONFIG_KEY = f"{DOMAIN}.config"
STORAGE_VIRTUAL_LIGHTS_KEY = f"{DOMAIN}.virtual_lights"
STORAGE_VERSION = 1
STORAGE_MINOR_VERSION = 1
STORAGE_MINOR_VERSION = 2
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
PLANS_URL = "/houseplan_files/plans"
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
@@ -45,10 +46,24 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.59.0"
VERSION = "1.64.0"
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
# user have different compatibility lifecycles.
PLAN_MODEL_VERSION = 6
EXPORT_VERSION = 1
MAX_EXPORT_BYTES = 8 * 1024 * 1024
IMPORT_PREVIEW_TTL_S = 10 * 60
MAX_IMPORT_PREVIEWS_PER_USER = 3
# Parsed documents are larger than their wire representation. Keep the
# original three-preview memory ceiling global as well as per user so turning
# off the admin-only policy cannot multiply it by the number of household
# accounts.
MAX_IMPORT_PREVIEWS_TOTAL = 3
DEFAULT_CONFIG: dict = {
"spaces": [],
"markers": [],
"settings": {},
"settings": {"bg_mode": "daynight"},
}
@@ -31,6 +31,9 @@ async def async_get_config_entry_diagnostics(
"has_plan": bool(s.get("plan_url")),
"rooms": len(s.get("rooms", [])),
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
"room_drafts": len(s.get("room_drafts", [])),
"partitions": len(s.get("partitions", [])),
"wall_columns": len(s.get("wall_columns", [])),
}
for s in config.get("spaces", [])
],
File diff suppressed because one or more lines are too long
@@ -64,6 +64,16 @@ def migrate_space(space: dict[str, Any]) -> bool:
if room.get("poly"):
room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]
for draft in space.get("room_drafts") or []:
draft["points"] = [_pt(p, dx, dy, kx, ky) for p in draft.get("points") or []]
for part in space.get("partitions") or []:
part["a"] = _pt(part.get("a"), dx, dy, kx, ky)
part["b"] = _pt(part.get("b"), dx, dy, kx, ky)
for column in space.get("wall_columns") or []:
column["center"] = _pt(column.get("center"), dx, dy, kx, ky)
for op in space.get("openings") or []:
op["x"] = dx + float(op.get("x", 0)) * kx
op["y"] = dy + float(op.get("y", 0)) * ky
+68 -1
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import logging
import os
import tempfile
from functools import partial
from pathlib import Path
from aiohttp import web
@@ -22,10 +23,13 @@ from homeassistant.core import HomeAssistant
from .const import (
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
MAX_FILES_COUNT, PLANS_DIR,
MAX_FILES_COUNT, MAX_EXPORT_BYTES, PLANS_DIR,
)
from .auth import may_write
from .import_export import ImportFailure, create_preview
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
from .registry_snapshot import import_registry_snapshot
from .store import get_data
from .validation import (
FILE_EXTENSIONS,
MAX_FILE_BYTES,
@@ -52,6 +56,69 @@ _MIME = {
}
class HouseplanImportPreviewView(HomeAssistantView):
"""Upload a bounded JSON backup and return a server-side preview token."""
url = "/api/houseplan/import/preview"
name = "api:houseplan:import-preview"
requires_auth = True
async def post(self, request: web.Request) -> web.Response:
hass: HomeAssistant = request.app[KEY_HASS]
user = request.get("hass_user")
if not may_write(hass, user):
return web.json_response({"error": "unauthorized"}, status=403)
runtime = get_data(hass)
if runtime is None:
return web.json_response({"error": "not_ready"}, status=503)
policy = request.query.get("duplicate_policy", "skip")
if policy not in ("skip", "virtual"):
return web.json_response({"error": "invalid_format"}, status=400)
declared = request.content_length
if declared is not None and declared > MAX_EXPORT_BYTES:
return web.json_response({"error": "too_large"}, status=413)
blocks: list[bytes] = []
size = 0
async for block in request.content.iter_chunked(_CHUNK):
size += len(block)
if size > MAX_EXPORT_BYTES:
return web.json_response({"error": "too_large"}, status=413)
blocks.append(block)
owner_id = str(getattr(user, "id", ""))
try:
# Hold the global writer only while taking one coherent store
# snapshot. Parsing up to 8 MiB, schema validation and space remap
# are CPU work and apply will revalidate both revisions anyway.
async with runtime.write_lock:
config_data = await runtime.config_store.async_load() or {}
layout_data = await runtime.store.async_load() or {}
try:
registry_snapshot = import_registry_snapshot(hass)
except Exception: # noqa: BLE001 - summary must not block a valid backup
_LOGGER.debug("House Plan import registry summary unavailable", exc_info=True)
registry_snapshot = None
result = await hass.async_add_executor_job(
partial(
create_preview,
runtime,
b"".join(blocks),
owner_id=owner_id,
duplicate_policy=policy,
current_config_data=config_data,
current_layout_data=layout_data,
config_root=Path(hass.config.path("")),
registry_snapshot=registry_snapshot,
)
)
except ImportFailure as err:
status = 413 if err.code == "too_large" else 400
return web.json_response({"error": err.code, "message": err.message}, status=status)
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan import preview failed")
return web.json_response({"error": "invalid_format"}, status=400)
return web.json_response(result)
class HouseplanContentView(HomeAssistantView):
"""Authenticated read access to plans and marker files (audit B1).
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.59.0"
"version": "1.64.0"
}
@@ -0,0 +1,47 @@
"""Small registry projection shared by import HTTP and WebSocket previews."""
from __future__ import annotations
from typing import Any
from homeassistant.core import HomeAssistant
def import_registry_snapshot(hass: HomeAssistant) -> dict[str, set[str]]:
"""Return non-sensitive target inventory used only for preview counts."""
from homeassistant.helpers import area_registry as ar
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
entities = list(er.async_get(hass).entities.values())
active_entity: set[str] = set()
disabled_entity: set[str] = set()
entities_by_device: dict[str, list[Any]] = {}
for entry in entities:
entity_id = str(entry.entity_id)
if getattr(entry, "disabled_by", None) is None:
active_entity.add(entity_id)
else:
disabled_entity.add(entity_id)
if entry.device_id:
entities_by_device.setdefault(str(entry.device_id), []).append(entry)
# Synthetic/runtime entities may legitimately have no registry row.
active_entity.update(str(state.entity_id) for state in hass.states.async_all())
active_device: set[str] = set()
disabled_device: set[str] = set()
for entry in dr.async_get(hass).devices.values():
device_id = str(entry.id)
children = entities_by_device.get(device_id, [])
disabled = getattr(entry, "disabled_by", None) is not None or (
bool(children)
and all(getattr(child, "disabled_by", None) is not None for child in children)
)
(disabled_device if disabled else active_device).add(device_id)
return {
"active_device": active_device,
"disabled_device": disabled_device,
"active_entity": active_entity,
"disabled_entity": disabled_entity - active_entity,
"areas": {str(entry.id) for entry in ar.async_get(hass).areas.values()},
}
+149 -5
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import asyncio
import copy
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
@@ -10,7 +12,43 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from .const import DOMAIN, STORAGE_CONFIG_KEY, STORAGE_KEY, STORAGE_MINOR_VERSION, STORAGE_VERSION
from .const import (
DOMAIN,
STORAGE_CONFIG_KEY,
STORAGE_KEY,
STORAGE_MINOR_VERSION,
STORAGE_VERSION,
STORAGE_VIRTUAL_LIGHTS_KEY,
)
_LOGGER = logging.getLogger(__name__)
_BG_MODES = frozenset({"static", "daynight"})
def migrate_config_background_mode(old_data: dict[str, Any]) -> dict[str, Any]:
"""Materialize the legacy implicit background mode without changing its view.
Only the config-store document has a top-level ``config`` object. Layout
and virtual-light stores pass through this helper unchanged even though
they share the same Store subclass and minor version.
"""
config = old_data.get("config")
if not isinstance(config, dict):
return old_data
settings = config.get("settings")
mode = settings.get("bg_mode") if isinstance(settings, dict) else None
if mode in _BG_MODES:
return old_data
data = copy.deepcopy(old_data)
migrated_config = data["config"]
migrated_settings = migrated_config.get("settings")
if not isinstance(migrated_settings, dict):
migrated_settings = {}
migrated_config["settings"] = migrated_settings
migrated_settings["bg_mode"] = "static"
return data
class HouseplanStore(Store):
@@ -28,10 +66,9 @@ class HouseplanStore(Store):
old_minor_version: int,
old_data: dict[str, Any],
) -> dict[str, Any]:
data = old_data
# if old_major_version == 1 and old_minor_version < 2:
# ...migrate...
return data
if old_major_version == 1 and old_minor_version < 2:
return migrate_config_background_mode(old_data)
return old_data
@dataclass
@@ -40,6 +77,7 @@ class HouseplanData:
store: HouseplanStore
config_store: HouseplanStore
virtual_light_store: HouseplanStore
# One lock for every load→modify→save cycle of both stores: prevents
# lost updates from concurrent WS calls and makes the rev check atomic.
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
@@ -54,6 +92,11 @@ class HouseplanData:
# directly — a test that fakes a 24 h jump proves the timer fires, not that
# the work happens, and those are different claims.
sweep: Callable[[], Awaitable[None]] | None = None
# Stable HA instance id used only through a one-way export fingerprint.
instance_id: str = ""
# Parsed import candidates are short-lived, user-bound and memory-only.
# dict keeps insertion order, which lets the preview service evict oldest.
import_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
HouseplanConfigEntry = ConfigEntry[HouseplanData]
@@ -66,6 +109,12 @@ def create_data(hass: HomeAssistant) -> HouseplanData:
config_store=HouseplanStore(
hass, STORAGE_VERSION, STORAGE_CONFIG_KEY, minor_version=STORAGE_MINOR_VERSION
),
virtual_light_store=HouseplanStore(
hass,
STORAGE_VERSION,
STORAGE_VIRTUAL_LIGHTS_KEY,
minor_version=STORAGE_MINOR_VERSION,
),
)
@@ -79,3 +128,98 @@ def get_entry(hass: HomeAssistant) -> ConfigEntry | None:
"""The loaded config entry, or None."""
entries = hass.config_entries.async_loaded_entries(DOMAIN)
return entries[0] if entries else None
OPTIMIZE_BACKUP = "optimize_backup"
OPTIMIZE_PENDING = "optimize_pending"
LAYOUT_STORE_CORE_KEYS = frozenset({"layout", "rev"})
def layout_store_payload(
stored: dict[str, Any],
layout: dict[str, Any],
rev: int,
*,
metadata: dict[str, Any] | None = None,
remove: tuple[str, ...] = (),
replace_metadata: bool = False,
) -> dict[str, Any]:
"""Build one layout-store write without silently dropping metadata.
Layout used to be saved by several independent dict comprehensions. Every
new metadata key therefore had to be added to every caller or was lost on
the next drag. All writers now express only the metadata they intentionally
add/remove and this helper preserves the rest.
"""
excluded = {*LAYOUT_STORE_CORE_KEYS, *remove}
out = {} if replace_metadata else {
key: value for key, value in stored.items() if key not in excluded
}
if metadata:
out.update(metadata)
out["layout"] = layout
out["rev"] = rev
return out
async def async_save_layout_state(
runtime: HouseplanData,
stored: dict[str, Any],
layout: dict[str, Any],
rev: int,
*,
metadata: dict[str, Any] | None = None,
remove: tuple[str, ...] = (),
replace_metadata: bool = False,
) -> dict[str, Any]:
"""Persist layout and return the exact store document written."""
payload = layout_store_payload(
stored,
layout,
rev,
metadata=metadata,
remove=remove,
replace_metadata=replace_metadata,
)
await runtime.store.async_save(payload)
return payload
async def async_save_config_state(
runtime: HouseplanData,
config: dict[str, Any],
rev: int,
*,
previous_rev: int | None = None,
) -> dict[str, Any]:
"""Persist configuration and reconcile dependent operational state.
Callers already hold ``runtime.write_lock``. Reading the previous
revision here keeps less common writers (import recovery and undo) on the
same path as ordinary editor saves without duplicating lifecycle rules.
"""
if previous_rev is None:
previous = await runtime.config_store.async_load() or {}
try:
previous_rev = int(previous.get("rev", 0))
except (TypeError, ValueError):
previous_rev = 0
payload = {"config": config, "rev": rev}
await runtime.config_store.async_save(payload)
# The config is already durable at this point. Reconciliation remains a
# separate Store write; an interrupted pair is detected from config_rev on
# the next read and fails safe to the compatibility default (all on).
from .virtual_lights import async_reconcile_virtual_lights
try:
await async_reconcile_virtual_lights(
runtime.virtual_light_store,
config,
rev,
previous_config_rev=previous_rev,
)
except Exception: # noqa: BLE001 - config commit already stands
_LOGGER.exception("House Plan: virtual-light state reconciliation failed")
return payload
@@ -27,6 +27,9 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"config_rev": cfg_raw.get("rev", 0),
"spaces": len(config.get("spaces", [])),
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
"room_drafts": sum(len(s.get("room_drafts", [])) for s in config.get("spaces", [])),
"partitions": sum(len(s.get("partitions", [])) for s in config.get("spaces", [])),
"wall_columns": sum(len(s.get("wall_columns", [])) for s in config.get("spaces", [])),
"markers": len(config.get("markers", [])),
"layout_entries": len(layout_raw.get("layout", {})),
}
+102 -11
View File
@@ -90,6 +90,10 @@ class TrailBook:
return True
return False
def delete(self, marker: str) -> bool:
"""Forget every stored run of one plan marker."""
return self.data.pop(marker, None) is not None
class TrailRecorder:
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
@@ -106,6 +110,9 @@ class TrailRecorder:
self._unsub_track = None
self._unsub_save = None
self._last_fire = 0.0
# One active incident per saved marker/source. `reason` is mutable so
# missing↔disabled changes do not create warning storms.
self._source_health: dict[tuple[str, str], str] = {}
# HP-1540-05: config/set fires refresh as a detached task; two of them
# interleaving across the awaited load both subscribed and the loser's
# unsub handle was overwritten — a leak until HA restart
@@ -130,27 +137,24 @@ class TrailRecorder:
return
cfg = stored.get("config") or {}
pairs: dict[str, list[tuple[str, str]]] = {}
health_pairs: set[tuple[str, str]] = set()
for m in cfg.get("markers") or []:
if m.get("removed") is True:
continue
v = m.get("vacuum") or {}
src = v.get("source")
if not src or v.get("live") is False:
continue
marker_id = str(m.get("id"))
health_pairs.add((marker_id, str(src)))
vac = self._vacuum_entity(m)
if vac:
# HP-1540-03: append, never overwrite — every floor's
# marker records its own copy of the run
pairs.setdefault(src, []).append((str(m.get("id")), vac))
pairs.setdefault(src, []).append((marker_id, vac))
self._refresh_source_health(health_pairs)
self.pairs = pairs
if self._unsub_track:
self._unsub_track()
self._unsub_track = None
# deduplicated: two markers of one robot share source AND vacuum
ents = set(self.pairs) | {vac for ps in self.pairs.values() for _, vac in ps}
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
if ents:
self._unsub_track = async_track_state_change_event(
self.hass, sorted(ents), self._on_state
)
self._resubscribe()
# A run already in progress (HA restarted mid-cleanup, or the user
# just finished calibrating) must start recording NOW, not at the
# next state change — otherwise the first seconds of the path are
@@ -158,6 +162,93 @@ class TrailRecorder:
for src in self.pairs:
self._sample(src, time.time())
def _source_failure_reason(self, source: str) -> str | None:
"""Classify only refresh-time health evidence.
A registry row or exact live state proves existence. No registry access
is neutral: it can neither create a loss incident nor recover one.
"""
registry = er.async_get(self.hass)
state = self.hass.states.get(source)
if registry is None or not hasattr(registry, "async_get"):
return None if state is not None else "unverified"
entry = registry.async_get(source)
if entry is not None and getattr(entry, "disabled_by", None) is not None:
return "disabled"
# Registry-less YAML entities are valid: exact live state is stronger
# evidence than a missing registry row.
if entry is not None or state is not None:
return None
return "missing"
def _refresh_source_health(self, expected: set[tuple[str, str]]) -> None:
"""Refresh deduplicated source incidents during config refresh/restart.
`unavailable` and unsupported-but-existing states count as proven
recovery. There is intentionally no registry subscription in Stage 1;
the next config refresh or restart observes a later transition.
"""
for key in list(self._source_health):
if key not in expected:
del self._source_health[key]
for marker_id, source in sorted(expected):
key = (marker_id, source)
reason = self._source_failure_reason(source)
previous = self._source_health.get(key)
# Limited/unavailable registry evidence is neutral: keep an
# existing incident as-is, and never create or recover one.
if reason == "unverified":
continue
if reason is None:
if previous is not None:
_LOGGER.info(
"Vacuum source recovered: marker=%s source=%s (was %s)",
marker_id, source, previous,
)
del self._source_health[key]
continue
if previous is None:
_LOGGER.warning(
"Vacuum source %s: marker=%s source=%s",
reason, marker_id, source,
)
self._source_health[key] = reason
async def async_delete(self, marker: str) -> bool:
"""Stop and erase one marker without racing subscription refresh/save."""
async with self._refresh_lock:
# The trail book owns deletion. When it has no such marker, this
# is a no-op and must not silently damage the live tracking graph.
removed = self.book.delete(marker)
if not removed:
return False
for src in list(self.pairs):
kept = [pair for pair in self.pairs[src] if pair[0] != marker]
if kept:
self.pairs[src] = kept
else:
del self.pairs[src]
self._resubscribe()
if self._unsub_save:
self._unsub_save()
self._unsub_save = None
await self.store.async_save(self.book.data)
self.hass.bus.async_fire("houseplan_trail_updated", {})
return True
def _resubscribe(self) -> None:
"""Replace the state subscription for the current pair graph."""
if self._unsub_track:
self._unsub_track()
self._unsub_track = None
# deduplicated: two markers of one robot share source AND vacuum
ents = set(self.pairs) | {vac for ps in self.pairs.values() for _, vac in ps}
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
if ents and not self._closed:
self._unsub_track = async_track_state_change_event(
self.hass, sorted(ents), self._on_state
)
def teardown(self) -> None:
# HP-1540-05: flag FIRST — a refresh parked on its awaited load must
# not re-subscribe after this cleanup has already run
+435 -22
View File
@@ -4,6 +4,7 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed).
"""
from __future__ import annotations
from collections import Counter
import re
import voluptuous as vol
@@ -19,6 +20,228 @@ _SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
# The name length the content view will accept back in a request. Anything a
# generated name must fit inside, collision tag included (HP-1460-01).
MAX_FILENAME = 120
MARKER_CONTROL_PREFIX = "marker:"
_CONTROL_ENTITY_ID_RE = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+\Z")
class MarkerControlError(ValueError):
"""Semantic marker-link error with a stable public code."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
VALUE_BADGE_ATTRIBUTES = {
"current_temperature", "temperature", "current_humidity", "humidity",
"current_position", "percentage", "brightness", "volume_level",
"battery_level", "fan_speed",
}
VALUE_BADGE_POSITIONS = {"right", "bottom", "left", "top"}
VALUE_BADGE_SOURCE_KINDS = {
"entity_state", "entity_attribute", "derived_lqi", "derived_marker_state",
}
_LIGHT_ENTITY_RE = re.compile(r"^(?:light|switch)\.[a-z0-9_]+\Z")
def _matching_previous_marker(
marker: dict, marker_id: str, old_by_id: dict[str, dict], old_markers: list[dict],
new_ids: set[str], consumed_old_ids: set[str], validate_all: bool,
) -> dict | None:
"""Match the previous marker across the binding-stable id rename path."""
old_marker = old_by_id.get(marker_id)
if old_marker is not None:
consumed_old_ids.add(marker_id)
return old_marker
if validate_all:
return None
binding = marker.get("binding")
matches = [
old for old in old_markers
if binding not in (None, "virtual")
and old.get("binding") == binding
and str(old.get("id")) not in new_ids
and str(old.get("id")) not in consumed_old_ids
]
if len(matches) == 1:
consumed_old_ids.add(str(matches[0].get("id")))
return matches[0]
return None
def validate_marker_value_badges(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate only new/changed badge data; dormant future/legacy data round-trips."""
markers = config.get("markers") or []
by_id = {str(marker.get("id")): marker for marker in markers}
old_markers = (previous or {}).get("markers") or []
old_by_id = {str(marker.get("id")): marker for marker in old_markers}
new_ids = set(by_id)
consumed_old_ids: set[str] = set()
known_source_fields = {"kind", "entity_id", "attribute", "ref"}
for marker_id, marker in by_id.items():
old_marker = _matching_previous_marker(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
badge = marker.get("value_badge")
old_badge = None if validate_all else (old_marker or {}).get("value_badge")
if not validate_all and badge == old_badge:
continue
if badge is None:
continue
if not isinstance(badge, dict):
raise MarkerControlError("invalid_value_badge", "Value badge must be an object")
if not isinstance(badge.get("enabled"), bool):
raise MarkerControlError("invalid_value_badge", "Value badge enabled must be boolean")
if badge.get("position") not in VALUE_BADGE_POSITIONS:
raise MarkerControlError("invalid_value_badge_position", "Invalid value badge position")
source = badge.get("source")
if badge["enabled"] and not isinstance(source, dict):
raise MarkerControlError("value_badge_source_required", "Enabled value badge needs a source")
if source is None:
continue
if not isinstance(source, dict) or source.get("kind") not in VALUE_BADGE_SOURCE_KINDS:
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge source")
kind = source["kind"]
allowed_fields = {
"entity_state": {"kind", "entity_id"},
"entity_attribute": {"kind", "entity_id", "attribute"},
"derived_lqi": {"kind"},
"derived_marker_state": {"kind", "ref"},
}[kind]
if (known_source_fields & set(source)) - allowed_fields:
raise MarkerControlError("invalid_value_badge_source", "Inconsistent value badge source")
if kind in {"entity_state", "entity_attribute"}:
entity_id = source.get("entity_id")
if not isinstance(entity_id, str) or not _CONTROL_ENTITY_ID_RE.fullmatch(entity_id):
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge entity id")
if kind == "entity_attribute":
if source.get("attribute") not in VALUE_BADGE_ATTRIBUTES:
raise MarkerControlError("invalid_value_badge_attribute", "Invalid value badge attribute")
if kind == "derived_marker_state":
ref = source.get("ref")
if not isinstance(ref, str) or not ref.startswith(MARKER_CONTROL_PREFIX) or not ref[len(MARKER_CONTROL_PREFIX):]:
raise MarkerControlError("invalid_value_badge_source", "Invalid marker value badge target")
target = by_id.get(ref[len(MARKER_CONTROL_PREFIX):])
if target is None or target.get("removed") is True:
raise MarkerControlError("value_badge_marker_missing", "Marker value badge target does not exist")
if target.get("is_light") is not True:
raise MarkerControlError("value_badge_marker_not_light", "Marker value badge target is not a forced light")
def validate_marker_light_entities(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate new/changed leading-light choices without rejecting dormant data.
The top-level schema must stay lossless: an old or future literal that the
current frontend cannot edit may round-trip unchanged. Imports validate the
whole incoming document because every imported value is new to this plan.
"""
markers = config.get("markers") or []
old_markers = (previous or {}).get("markers") or []
old_by_id = {str(marker.get("id")): marker for marker in old_markers}
new_ids = {str(marker.get("id")) for marker in markers}
consumed_old_ids: set[str] = set()
for marker in markers:
marker_id = str(marker.get("id"))
old_marker = _matching_previous_marker(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
value = marker.get("light_entity")
old_value = None if validate_all else (old_marker or {}).get("light_entity")
if not validate_all and value == old_value:
continue
if value is None:
continue
if not isinstance(value, str) or not _LIGHT_ENTITY_RE.fullmatch(value):
raise MarkerControlError(
"invalid_light_entity", "Leading light entity must be light.* or switch.*"
)
def validate_marker_controls(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate newly introduced marker:* edges without rewriting old data.
Existing broken refs remain editable and round-trip losslessly. Imports use
validate_all because their complete candidate graph is new to this plan.
"""
markers = config.get("markers") or []
by_id = {str(marker.get("id")): marker for marker in markers}
old_markers = (previous or {}).get("markers") or []
old_by_id = {
str(marker.get("id")): marker for marker in (previous or {}).get("markers") or []
}
new_ids = set(by_id)
consumed_old_ids: set[str] = set()
graph: dict[str, list[str]] = {}
added: list[tuple[str, str]] = []
for marker_id, marker in by_id.items():
old_marker = _matching_previous_marker(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
raw_controls = [
ref for ref in marker.get("controls") or [] if isinstance(ref, str)
]
old_controls = [] if validate_all else [
ref for ref in (old_marker or {}).get("controls") or [] if isinstance(ref, str)
]
refs = [
ref for ref in raw_controls
if isinstance(ref, str) and ref.startswith(MARKER_CONTROL_PREFIX)
]
graph[marker_id] = [ref[len(MARKER_CONTROL_PREFIX):] for ref in refs]
old_refs = [
ref for ref in old_controls
if isinstance(ref, str) and ref.startswith(MARKER_CONTROL_PREFIX)
]
remaining = list(old_refs)
for ref in refs:
if ref in remaining:
remaining.remove(ref)
else:
added.append((marker_id, ref[len(MARKER_CONTROL_PREFIX):]))
new_counts, old_counts = Counter(refs), Counter(old_refs)
if any(count > 1 and count > old_counts[ref] for ref, count in new_counts.items()):
raise MarkerControlError("duplicate_marker_control", "Duplicate marker light target")
remaining_controls = list(old_controls)
for ref in raw_controls:
if ref in remaining_controls:
remaining_controls.remove(ref)
elif not ref.startswith(MARKER_CONTROL_PREFIX) and not _CONTROL_ENTITY_ID_RE.fullmatch(ref):
raise MarkerControlError("invalid_marker_control", f"Invalid entity target: {ref}")
def reaches(start: str, wanted: str) -> bool:
stack, seen = [start], set()
while stack:
node = stack.pop()
if node == wanted:
return True
if node in seen:
continue
seen.add(node)
stack.extend(graph.get(node, []))
return False
for controller, target in added:
if not target:
raise MarkerControlError("invalid_marker_control", "Marker target id is empty")
if target == controller:
raise MarkerControlError("marker_control_self", "A marker cannot control itself")
target_marker = by_id.get(target)
if target_marker is None or target_marker.get("removed") is True:
raise MarkerControlError("marker_control_missing", f"Marker target does not exist: {target}")
if target_marker.get("is_light") is not True:
raise MarkerControlError("marker_control_not_light", f"Marker target is not a forced light: {target}")
if reaches(target, controller):
raise MarkerControlError("marker_control_cycle", "Marker light controls contain a cycle")
# ---------- sanitizers ----------
@@ -62,6 +285,19 @@ def _finite(value):
return f
# Persisted colours deliberately use one small, browser-independent format.
# Keep this exact contract in sync with src/color.ts.
# `^...$` accepts a trailing newline in Python. Persisted CSS tokens must
# match the whole string exactly, in parity with the frontend validator.
_COLOR = vol.Match(r"\A#[0-9a-fA-F]{6}\Z")
_CUSTOM_FILL = vol.Schema(
{
vol.Required("c"): _COLOR,
vol.Required("a"): vol.All(_finite, vol.Range(min=0.0, max=1.0)),
}
)
# generous caps: the product targets 20-200 devices and a handful of floors
MAX_SPACES = 50
MAX_ROOMS = 400
@@ -69,6 +305,10 @@ MAX_MARKERS = 2000
MAX_OPENINGS = 500
MAX_DECOR = 1000
MAX_WALLS = 500
MAX_ROOM_DRAFTS = 200
MAX_DRAFT_SEGMENTS = 2000
MAX_PARTITIONS = 2000
MAX_WALL_COLUMNS = 500
# Open (virtual) wall stretches, docs/superpowers/specs/2026-08-05-open-spans-delete-design.md.
# Every span is a piece of a shared boundary, so there can never be more of
# them than there are wall segments — the cap is the walls' one (AUD-159B6-03).
@@ -95,6 +335,8 @@ MAX_URL = 2000
# they can act on. For scale, a real three-floor home with ~200 devices stores
# about 70 KB, so this is ~30x headroom.
MAX_CONFIG_BYTES = 2 * 1024 * 1024
CELL_CM_MIN = 0.1
CELL_CM_MAX = 1000.0
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
_TEXT_OR_NONE = vol.Any(None, _TEXT)
@@ -204,7 +446,9 @@ ROOM_SCHEMA = vol.All(
None,
vol.Schema(
{
vol.Optional("fill_mode"): vol.Any(None, vol.In(["none", "lqi", "light", "temp"])),
vol.Optional("fill_mode"): vol.Any(None, vol.In(["none", "lqi", "light", "temp", "custom", "glow"])),
vol.Optional("custom_fill"): vol.Any(None, _CUSTOM_FILL),
vol.Optional("glow"): vol.Any(bool, None),
vol.Optional("temp_source"): vol.Any(str, None),
vol.Optional("hum_source"): vol.Any(str, None),
vol.Optional("name_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
@@ -243,11 +487,13 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
{
vol.Optional("show_borders"): bool,
vol.Optional("show_names"): bool,
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("room_color"): _COLOR,
# per-space background around the plan; absent = inherit the global one
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("bg_color"): _COLOR,
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "glow"]),
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "custom", "glow"]),
vol.Optional("custom_fill"): vol.Any(None, _CUSTOM_FILL),
vol.Optional("glow_enabled"): bool,
vol.Optional("temp_min"): vol.Coerce(float),
vol.Optional("temp_max"): vol.Coerce(float),
vol.Optional("show_lqi"): bool,
@@ -283,6 +529,7 @@ MAX_DECOR_UNIT = 16
# could mean on a 1000-unit canvas, the rest is garbage insurance.
DECOR_TEXT_SCALE_MIN = 0.15
DECOR_TEXT_SCALE_MAX = 20.0
DECOR_TEXT_CM_MAX = 2000.0
# A furniture symbol id (docs/FURNITURE.md). Deliberately NOT the card's list:
# the backend must accept a plan written by a NEWER card, and a card that has
# learnt a new symbol must not have to wait for the integration to be updated
@@ -298,7 +545,11 @@ _FURN_SIZE = vol.All(_finite, vol.Range(min=0.0000001, max=CANVAS_LIMIT))
_DECOR_COMMON = {
vol.Required("id"): str,
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("color"): _COLOR,
vol.Optional("opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0)),
# Physical centimetres are canonical. `width` remains accepted so plans
# written by older cards keep their exact appearance until edited.
vol.Optional("width_cm"): vol.All(_finite, vol.Range(min=0.1, max=100)),
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
}
# Decor lives on the same unbounded canvas as everything else (docs/CANVAS.md):
@@ -307,14 +558,18 @@ _NORM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
DECOR_SCHEMA = vol.Any(
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM},
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM,
vol.Optional("line_style"): vol.In(["solid", "dashed"])},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
# sizes are extents — negative/zero is garbage, not "canvas slack"
vol.Required("w"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
vol.Required("h"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
vol.Optional("fill"): bool},
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
vol.Optional("fill"): bool,
vol.Optional("fill_color"): _COLOR,
vol.Optional("fill_opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0))},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
@@ -326,8 +581,11 @@ DECOR_SCHEMA = vol.Any(
# written before that keeps it, and it is read as the scale it
# used to render at. Kept in the schema so it stays BOUNDED.
vol.Optional("size"): vol.In(["s", "m", "l"]),
# size and rotation of the block (docs/LIVE-TEXT.md §block).
# Both optional: a shape without them is the old one exactly.
# Canonical physical font size plus the legacy scale. Both are
# accepted so older plans remain pixel-identical until edited
# or explicitly optimized.
vol.Optional("size_cm"): vol.All(
_finite, vol.Range(min=0.1, max=DECOR_TEXT_CM_MAX)),
vol.Optional("scale"): vol.All(
_finite, vol.Range(min=DECOR_TEXT_SCALE_MIN, max=DECOR_TEXT_SCALE_MAX)),
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
@@ -374,10 +632,107 @@ WALL_SCHEMA = vol.All(
_wall_endpoints_pair,
)
SPACE_SCHEMA = vol.Schema(
def _room_draft_segments(value: dict) -> dict:
"""An open draft has exactly one thickness per consecutive edge."""
if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1):
raise vol.Invalid("room draft segments must match consecutive point pairs")
if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:])):
raise vol.Invalid("room draft consecutive points must differ")
return value
ROOM_DRAFT_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("points"): vol.All([POINT], vol.Length(min=2, max=500)),
vol.Required("segments"): vol.All(
[vol.Schema({vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100))},
extra=vol.ALLOW_EXTRA)],
vol.Length(min=1, max=499),
),
},
extra=vol.ALLOW_EXTRA,
),
_room_draft_segments,
)
def _partition_nonzero(value: dict) -> dict:
if value["a"] == value["b"]:
raise vol.Invalid("partition endpoints must differ")
return value
PARTITION_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("a"): POINT,
vol.Required("b"): POINT,
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
},
extra=vol.ALLOW_EXTRA,
),
_partition_nonzero,
)
def _strict_wall_column(value: dict) -> dict:
"""Reject shape-inapplicable or non-canonical column fields."""
if value["shape"] == "circle" and "angle" in value:
raise vol.Invalid("angle is allowed only for square wall columns")
if value["shape"] == "square" and value.get("angle", 0) >= 90:
raise vol.Invalid("square wall column angle must be in [0, 90)")
return value
WALL_COLUMN_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("shape"): vol.In(["square", "circle"]),
vol.Required("center"): POINT,
# Outer side for a square, outer diameter for a circle.
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=150)),
vol.Optional("angle"): vol.All(
_finite, vol.Range(min=0, max=90)
),
},
extra=vol.ALLOW_EXTRA,
),
_strict_wall_column,
)
def _space_geometry_invariants(value: dict) -> dict:
"""All stored geometry shares ids; draft segments also have a space cap."""
seen: set[str] = set()
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
for item in value.get(key, []):
item_id = item.get("id")
if not item_id:
continue
if item_id in seen:
raise vol.Invalid("geometry object ids must be unique within a space")
seen.add(item_id)
draft_segments = sum(
len(item.get("segments", [])) for item in value.get("room_drafts", [])
)
if draft_segments > MAX_DRAFT_SEGMENTS:
raise vol.Invalid("too many saved room-draft segments")
return value
SPACE_SCHEMA = vol.All(vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Match(SPACE_ID_RE.pattern)),
vol.Required("title"): str,
# Physical grid scale. It feeds every px/cell -> centimetres migration,
# so NaN/Infinity or an absurd value must not be allowed to manufacture
# invalid decor sizes later.
vol.Optional("cell_cm"): vol.All(
_finite, vol.Range(min=CELL_CM_MIN, max=CELL_CM_MAX)
),
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
vol.Optional("plan_url"): vol.Any(str, None),
# The canvas is square since v1.48.0. What used to be the space's own
@@ -389,10 +744,10 @@ SPACE_SCHEMA = vol.Schema(
vol.Optional("plan_aspect"): vol.Any(
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
),
# Backdrop placement (docs/BACKDROP.md): the picture may be moved and
# scaled UNIFORMLY on the canvas. All three are optional and their
# absence is the pre-v1.58.0 behaviour exactly — there is no migration,
# and an old config validates unchanged. The offset is a normalised
# Backdrop placement (docs/BACKDROP.md): the picture may be moved,
# resized per axis and rotated. Every transform field is optional; its
# complete absence is the pre-v1.58.0 behaviour exactly, and an old
# config validates unchanged. The offset is a normalised
# coordinate like every other one (the ±CANVAS_LIMIT garbage guard);
# the scale is a positive multiplier in a range a human could mean.
vol.Optional("plan_x"): vol.Any(None, _COORD),
@@ -400,6 +755,17 @@ SPACE_SCHEMA = vol.Schema(
vol.Optional("plan_scale"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
# New writes may stretch each axis independently and rotate. The old
# uniform field remains a read-compatible fallback for both axes.
vol.Optional("plan_scale_x"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
vol.Optional("plan_scale_y"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
vol.Optional("plan_angle"): vol.Any(
None, vol.All(_finite, vol.Range(min=-360.0, max=360.0))
),
vol.Required("view_box"): _view_box,
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
@@ -407,7 +773,7 @@ SPACE_SCHEMA = vol.Schema(
vol.Schema(
{
vol.Required("id"): str,
vol.Required("type"): vol.Any("door", "window"),
vol.Required("type"): vol.Any("door", "window", "gate"),
vol.Required("x"): _GEOM,
vol.Required("y"): _GEOM,
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
@@ -427,6 +793,15 @@ SPACE_SCHEMA = vol.Schema(
# (midpoint + direction), thickness always in centimetres. Optional —
# a space without `walls` validates and renders exactly as before.
vol.Optional("walls"): vol.All([WALL_SCHEMA], vol.Length(max=MAX_WALLS)),
vol.Optional("room_drafts"): vol.All(
[ROOM_DRAFT_SCHEMA], vol.Length(max=MAX_ROOM_DRAFTS)
),
vol.Optional("partitions"): vol.All(
[PARTITION_SCHEMA], vol.Length(max=MAX_PARTITIONS)
),
vol.Optional("wall_columns"): vol.All(
[WALL_COLUMN_SCHEMA], vol.Length(max=MAX_WALL_COLUMNS)
),
# Open (virtual) wall stretches: a piece of a shared boundary that the
# user opened. Optional and bounded — a space without `open_spans`
# validates exactly as before, and the legacy `rooms[].open_to` index
@@ -443,7 +818,7 @@ SPACE_SCHEMA = vol.Schema(
vol.Remove("segments"): object,
},
extra=vol.ALLOW_EXTRA,
)
), _space_geometry_invariants)
MARKER_SCHEMA = vol.Schema(
{
vol.Required("id"): str,
@@ -456,6 +831,9 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("space"): vol.Any(str, None),
vol.Optional("area"): vol.Any(str, None),
vol.Optional("hidden"): bool,
# A binding-level tombstone: not rendered or aggregated, but retained
# so automatic discovery does not put a deleted device straight back.
vol.Optional("removed"): bool,
vol.Optional("name"): _TEXT_OR_NONE,
vol.Optional("icon"): _TEXT_OR_NONE,
vol.Optional("model"): _TEXT_OR_NONE,
@@ -488,16 +866,48 @@ MARKER_SCHEMA = vol.Schema(
),
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
vol.Optional("glow_color"): vol.Any(
None,
vol.Schema(
{
vol.Required("c"): _COLOR,
vol.Optional("bri"): vol.Any(
None,
vol.All(_finite, vol.Range(min=0.01, max=1.0)),
),
}
),
),
vol.Optional("is_light"): vol.Any(bool, None),
# Explicit leading entity for composite Always sources. It is kept
# literally when temporarily absent; runtime falls back without
# deleting the user's choice.
# Semantic delta validation below the schema preserves unknown/future
# literals until that exact field is edited (lossless config doctrine).
vol.Optional("light_entity"): object,
vol.Optional("value_badge"): vol.Any(
None,
vol.Schema(
{
# Required semantically for changed/new data. Optional here
# keeps old/future configs readable until the user edits it.
vol.Optional("enabled"): object,
vol.Optional("position"): object,
vol.Optional("source"): vol.Any(
None,
vol.Schema({}, extra=vol.ALLOW_EXTRA),
),
},
extra=vol.ALLOW_EXTRA,
),
),
# climate current_temperature: badge + room-average vote (off unless True)
vol.Optional("use_climate_temp"): vol.Any(bool, None),
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.
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", None),
vol.Optional("ripple_color"): vol.Any(
None, vol.Match(r"^#[0-9a-fA-F]{6}$")
),
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", "static_icon", None),
vol.Optional("ripple_color"): vol.Any(None, _COLOR),
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
@@ -516,11 +926,14 @@ CONFIG_SCHEMA = vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("bg_color"): _COLOR,
# sun on the plan (docs/SUN.md): global defaults
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
# legacy field so an existing stored config can still load; the
# frontend ignores it and removes it on the next settings save.
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
@@ -528,7 +941,7 @@ CONFIG_SCHEMA = vol.Schema(
{
str: vol.Schema(
{
vol.Required("c"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Required("c"): _COLOR,
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
)
@@ -0,0 +1,125 @@
"""Persistent operational state for manual virtual lights."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .store import HouseplanStore
EVENT_VIRTUAL_LIGHT_UPDATED = "houseplan_virtual_light_updated"
def is_manual_virtual_light(marker: Any) -> bool:
"""Return whether a marker uses the exact persistent manual-light mode."""
return (
isinstance(marker, dict)
and isinstance(marker.get("id"), str)
and bool(marker["id"])
and marker.get("binding") == "virtual"
and marker.get("is_light") is True
and marker.get("tap_action") == "toggle"
and marker.get("removed") is not True
)
def eligible_virtual_light_ids(config: Any) -> set[str]:
"""Collect live marker ids eligible for persistent manual state."""
if not isinstance(config, dict):
return set()
markers = config.get("markers")
if not isinstance(markers, list):
return set()
return {marker["id"] for marker in markers if is_manual_virtual_light(marker)}
def _integer(value: Any, default: int = 0) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
return default
return max(0, parsed)
def _read_state(stored: Any) -> tuple[int, int, set[str]]:
if not isinstance(stored, dict):
return 0, 0, set()
raw_off = stored.get("off")
off = (
{item for item in raw_off if isinstance(item, str) and item}
if isinstance(raw_off, list)
else set()
)
return _integer(stored.get("rev")), _integer(stored.get("config_rev")), off
def _wire(rev: int, config_rev: int, off: set[str]) -> dict[str, Any]:
return {"rev": rev, "config_rev": config_rev, "off": sorted(off)}
async def async_virtual_light_snapshot(
store: HouseplanStore,
config: dict[str, Any],
config_rev: int,
) -> dict[str, Any]:
"""Return a coherent snapshot, repairing stale or interrupted state.
A revision gap means an older writer may have changed eligibility without
knowing about this Store. Clearing every manual-off bit is conservative:
it restores the pre-feature/default-on behaviour and cannot resurrect an
old off state for a marker whose role changed in the meantime.
"""
stored = await store.async_load() or {}
rev, state_config_rev, stored_off = _read_state(stored)
eligible = eligible_virtual_light_ids(config)
off = stored_off & eligible if state_config_rev == config_rev else set()
if off != stored_off:
rev += 1
payload = _wire(rev, config_rev, off)
if payload != stored:
await store.async_save(payload)
return payload
async def async_reconcile_virtual_lights(
store: HouseplanStore,
config: dict[str, Any],
config_rev: int,
*,
previous_config_rev: int,
) -> dict[str, Any]:
"""Carry eligible state across one known configuration transition."""
stored = await store.async_load() or {}
rev, state_config_rev, stored_off = _read_state(stored)
eligible = eligible_virtual_light_ids(config)
off = stored_off & eligible if state_config_rev == previous_config_rev else set()
if off != stored_off:
rev += 1
payload = _wire(rev, config_rev, off)
if payload != stored:
await store.async_save(payload)
return payload
async def async_toggle_virtual_light(
store: HouseplanStore,
config: dict[str, Any],
config_rev: int,
marker_id: str,
) -> dict[str, Any] | None:
"""Atomically invert one eligible marker and persist before returning."""
if marker_id not in eligible_virtual_light_ids(config):
return None
snapshot = await async_virtual_light_snapshot(store, config, config_rev)
off = set(snapshot["off"])
if marker_id in off:
off.remove(marker_id)
else:
off.add(marker_id)
payload = _wire(_integer(snapshot["rev"]) + 1, config_rev, off)
await store.async_save(payload)
return {
"marker_id": marker_id,
"on": marker_id not in off,
"rev": payload["rev"],
}
+663 -68
View File
@@ -8,6 +8,8 @@ import binascii
import json
import secrets
import time
from datetime import UTC, datetime
from functools import partial
from pathlib import Path
from typing import Any
@@ -24,25 +26,47 @@ from .const import (
PLANS_DIR, PLANS_URL,
)
from .auth import may_write
from .import_export import (
ImportFailure,
content_manifest,
create_export,
get_candidate,
live_layout,
prepare_apply,
revalidate_candidate,
)
from .plans import (
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
plan_basename, plan_refs, reserve_filename,
)
from .store import HouseplanData, get_data, get_entry
from .store import (
LAYOUT_STORE_CORE_KEYS,
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
HouseplanData,
async_save_config_state,
async_save_layout_state,
get_data,
get_entry,
)
from .virtual_lights import (
EVENT_VIRTUAL_LIGHT_UPDATED,
async_toggle_virtual_light,
async_virtual_light_snapshot,
)
from .registry_snapshot import import_registry_snapshot
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, sanitize_filename,
validate_marker_controls, validate_marker_light_entities,
validate_marker_value_badges, valid_space_id,
)
_LOGGER = logging.getLogger(__name__)
_OPTIMIZE_BACKUP = "optimize_backup"
_OPTIMIZE_PENDING = "optimize_pending"
def _optimizer_backup_is_current(config_data: dict[str, Any], layout_data: dict[str, Any]) -> bool:
"""An optimization can be undone only before any later plan edit."""
"""An optimization can be undone before any later ordinary plan edit."""
backup = layout_data.get(_OPTIMIZE_BACKUP)
if not isinstance(backup, dict):
return False
@@ -55,17 +79,46 @@ def _optimizer_backup_is_current(config_data: dict[str, Any], layout_data: dict[
return False
def _optimizer_backup_after_layout_maintenance(
layout_data: dict[str, Any], new_layout_rev: int,
) -> dict[str, Any]:
"""Carry a one-deep plan snapshot across explicit layout maintenance.
Geometry repair is part of plan maintenance, not an ordinary user edit:
losing the Optimize/Import undo there makes the advertised safety net
disappear. The snapshot must also follow the new layout revision or the
freshness guard will correctly, but unhelpfully, classify it as stale.
"""
backup = layout_data.get(_OPTIMIZE_BACKUP)
if not isinstance(backup, dict):
return {}
return {
_OPTIMIZE_BACKUP: {
**backup,
"after_layout_rev": new_layout_rev,
}
}
def _undo_kind(config_data: dict[str, Any], layout_data: dict[str, Any]) -> str | None:
if not _optimizer_backup_is_current(config_data, layout_data):
return None
backup = layout_data.get(_OPTIMIZE_BACKUP)
return str(backup.get("kind") or "optimize") if isinstance(backup, dict) else None
async def _discard_optimizer_snapshot(rt: HouseplanData) -> None:
"""Free a snapshot made stale by a later ordinary config edit."""
data = await rt.store.async_load() or {}
if _OPTIMIZE_BACKUP not in data and _OPTIMIZE_PENDING not in data:
return
await rt.store.async_save({
**{
k: v for k, v in data.items()
if k not in (_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
}
})
await async_save_layout_state(
rt,
data,
data.get("layout") or {},
int(data.get("rev", 0)),
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
@callback
@@ -73,11 +126,13 @@ def async_register(hass: HomeAssistant) -> None:
"""Register the WS commands."""
websocket_api.async_register_command(hass, ws_layout_get)
websocket_api.async_register_command(hass, ws_trail_get)
websocket_api.async_register_command(hass, ws_trail_delete)
websocket_api.async_register_command(hass, ws_layout_set)
websocket_api.async_register_command(hass, ws_geometry_repair)
websocket_api.async_register_command(hass, ws_layout_update)
websocket_api.async_register_command(hass, ws_layout_delete)
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_plan_optimize)
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
@@ -87,6 +142,9 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_files_migrate)
websocket_api.async_register_command(hass, ws_files_cleanup)
websocket_api.async_register_command(hass, ws_content_sign)
websocket_api.async_register_command(hass, ws_export_create)
websocket_api.async_register_command(hass, ws_import_revalidate)
websocket_api.async_register_command(hass, ws_import_apply)
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
@@ -107,9 +165,352 @@ def _check_write(hass: HomeAssistant, connection) -> bool:
return may_write(hass, getattr(connection, "user", None))
def _connection_user_id(connection) -> str:
return str(getattr(getattr(connection, "user", None), "id", ""))
def _send_import_error(connection, msg_id: int, err: ImportFailure) -> None:
connection.send_error(msg_id, err.code, err.message)
def _layout_metadata(stored: dict[str, Any]) -> dict[str, Any]:
"""Return the exact non-layout portion of a layout-store document."""
return {
key: value for key, value in stored.items()
if key not in LAYOUT_STORE_CORE_KEYS
}
async def _persist_pair_intent(
rt: HouseplanData,
pending: dict[str, Any],
) -> None:
"""Make a target pair recoverable before either visible half moves."""
stored = await rt.store.async_load() or {}
metadata = dict(pending.get("final_metadata") or {})
metadata[_OPTIMIZE_PENDING] = pending
await async_save_layout_state(
rt,
stored,
stored.get("layout") or {},
int(stored.get("rev", 0)),
metadata=metadata,
replace_metadata=True,
)
async def _converge_pair(rt: HouseplanData, pending: dict[str, Any]) -> None:
"""Write both target halves and remove the durable intent last."""
await async_save_config_state(
rt,
pending["config"],
int(pending["config_rev"]),
)
stored = await rt.store.async_load() or {}
await async_save_layout_state(
rt,
stored,
pending["layout"],
int(pending["layout_rev"]),
metadata=dict(pending.get("final_metadata") or {}),
replace_metadata=True,
)
async def _commit_import_pair(
rt: HouseplanData,
pending: dict[str, Any],
rollback: dict[str, Any],
) -> None:
"""Commit, retry once, or restore the before-pair before returning.
A Store write may raise after the bytes reached disk, so recovery always
reloads and converges an explicit pair instead of guessing which half won.
If the target cannot be completed, a rollback intent replaces it before we
restore the old pair; setup will therefore restore, never unexpectedly
finish an import that was reported as failed.
"""
try:
await _persist_pair_intent(rt, pending)
await _converge_pair(rt, pending)
return
except Exception: # noqa: BLE001 - one retry handles fail-after-write too
_LOGGER.warning("House Plan import pair write failed; retrying target", exc_info=True)
try:
await _persist_pair_intent(rt, pending)
await _converge_pair(rt, pending)
return
except Exception: # noqa: BLE001 - target is no longer the recovery policy
_LOGGER.exception("House Plan import target retry failed; restoring previous pair")
try:
await _persist_pair_intent(rt, rollback)
await _converge_pair(rt, rollback)
except Exception as rollback_error: # noqa: BLE001
_LOGGER.exception(
"House Plan import rollback could not finish; rollback intent remains for setup"
)
raise ImportFailure(
"commit_failed", "Import failed; the previous plan is pending recovery"
) from rollback_error
raise ImportFailure("commit_failed", "Import failed and the previous plan was restored")
# ---------------- portable backup / transfer ----------------
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/export/create",
vol.Required("kind"): vol.In(["full", "space"]),
vol.Optional("space_id"): str,
vol.Optional("card_version", default=""): str,
}
)
@websocket_api.async_response
async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Build a consistent full or one-space JSON snapshot."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only editors may export House Plan")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
try:
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
document, filename = await hass.async_add_executor_job(
partial(
create_export,
rt,
config_data,
layout_data,
kind=msg["kind"],
space_id=msg.get("space_id"),
card_version=msg.get("card_version", ""),
config_root=Path(hass.config.path("")),
)
)
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan export failed")
connection.send_error(msg["id"], "invalid_config", "Could not create export")
return
connection.send_result(msg["id"], {"document": document, "filename": filename})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/import/revalidate",
vol.Required("token"): str,
vol.Optional("duplicate_policy", default="skip"): vol.In(["skip", "virtual"]),
}
)
@websocket_api.async_response
async def ws_import_revalidate(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Re-evaluate a space candidate after its duplicate policy changes."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only editors may import House Plan")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
try:
async with rt.write_lock:
candidate = get_candidate(rt, msg["token"], _connection_user_id(connection))
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
try:
registry_snapshot = import_registry_snapshot(hass)
except Exception: # noqa: BLE001 - summary must not block revalidation
_LOGGER.debug("House Plan import registry summary unavailable", exc_info=True)
registry_snapshot = None
result = await hass.async_add_executor_job(
partial(
revalidate_candidate,
candidate,
config_data,
layout_data,
duplicate_policy=msg["duplicate_policy"],
registry_snapshot=registry_snapshot,
config_root=Path(hass.config.path("")),
)
)
result["token"] = msg["token"]
result["expires_at"] = datetime.fromtimestamp(
candidate["expires"], UTC
).isoformat().replace("+00:00", "Z")
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
connection.send_result(msg["id"], result)
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/import/apply",
vol.Required("token"): str,
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
vol.Optional("duplicate_policy"): vol.In(["skip", "virtual"]),
vol.Optional("confirm_missing_content", default=False): bool,
}
)
@websocket_api.async_response
async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Commit exactly the previewed candidate as one crash-resumable pair."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only editors may import House Plan")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
kind = ""
details: dict[str, Any] = {}
try:
async with rt.write_lock:
candidate = get_candidate(rt, msg["token"], _connection_user_id(connection))
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if (
msg["expected_config_rev"] != config_rev
or msg["expected_layout_rev"] != layout_rev
or candidate.get("config_rev") != config_rev
or candidate.get("layout_rev") != layout_rev
):
raise ImportFailure("conflict", "Plan changed after the preview")
kind = str(candidate["document"]["kind"])
requested_policy = msg.get("duplicate_policy", candidate.get("duplicate_policy"))
if kind == "space" and requested_policy != candidate.get("duplicate_policy"):
raise ImportFailure("conflict", "Duplicate policy changed after the preview")
target_config, target_layout, details = await hass.async_add_executor_job(
partial(
prepare_apply,
candidate,
config_data.get("config") or {"spaces": [], "markers": [], "settings": {}},
layout_data.get("layout") or {},
duplicate_policy=candidate.get("duplicate_policy"),
confirm_missing_content=msg["confirm_missing_content"],
)
)
missing = await hass.async_add_executor_job(
_missing_internal_plans,
Path(hass.config.path(PLANS_DIR)),
target_config,
config_data.get("config"),
)
if missing:
raise ImportFailure(
"missing_plan",
"Plan file no longer exists: " + ", ".join(sorted(missing)),
)
missing_attachments = _missing_internal_attachments(
Path(hass.config.path("")), target_config, config_data.get("config")
)
if missing_attachments:
raise ImportFailure(
"missing_content",
"Attachment no longer exists: " + ", ".join(sorted(missing_attachments)),
)
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
backup = None
if kind == "full":
backup = {
"kind": "import",
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout") or {},
"created": int(time.time()),
"after_config_rev": new_config_rev,
"after_layout_rev": new_layout_rev,
}
original_metadata = _layout_metadata(layout_data)
replaced_metadata = {_OPTIMIZE_PENDING, _OPTIMIZE_BACKUP}
if kind == "full":
replaced_metadata.update({"repair_backup", "geom_pending"})
final_metadata = {
key: value for key, value in original_metadata.items()
if key not in replaced_metadata
}
if backup is not None:
final_metadata[_OPTIMIZE_BACKUP] = backup
pending = {
"kind": "import",
"config": target_config,
"layout": target_layout,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"final_metadata": final_metadata,
}
rollback = {
"kind": "import_rollback",
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout") or {},
"config_rev": config_rev,
"layout_rev": layout_rev,
"final_metadata": original_metadata,
}
await _commit_import_pair(rt, pending, rollback)
# A token becomes single-use only after both durable halves land.
get_candidate(rt, msg["token"], _connection_user_id(connection), consume=True)
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan import commit failed")
connection.send_error(msg["id"], "commit_failed", "Import commit failed")
return
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
_refresh_trail_recorder(hass)
if kind == "full":
recorder = hass.data.get(DOMAIN, {}).get("trail_recorder")
live_marker_ids = {
str(marker.get("id")) for marker in target_config.get("markers") or []
}
if recorder is not None:
for marker_id in list(getattr(getattr(recorder, "book", None), "data", {})):
if marker_id not in live_marker_ids:
try:
await recorder.async_delete(marker_id)
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan: removing orphan import trail failed")
entry = get_entry(hass)
if entry is not None:
from .repairs import async_check_plan_files
hass.async_create_task(async_check_plan_files(hass, entry))
connection.send_result(msg["id"], {
"ok": True,
"kind": kind,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"counts": details.get("counts", {}),
"space_id": details.get("space_id"),
"can_undo": kind == "full",
})
# ---------------- layout ----------------
def _live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any]:
"""Drop positions which a deleted marker can no longer own.
HA device ids may be layout-only because auto-discovered devices need no
marker entry. Virtual ids are different: every live virtual marker is
explicit, so a missing `v_*` owner is stale data. The prefix itself is
only a legacy naming convention, however; an explicit real marker remains
authoritative even when its id happens to begin with `v_`.
"""
return live_layout(config, layout)
@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"})
@websocket_api.async_response
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
@@ -124,6 +525,7 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
"layout": data.get("layout", {}),
"rev": int(data.get("rev", 0)),
"can_optimize_undo": _optimizer_backup_is_current(config_data, data),
"undo_kind": _undo_kind(config_data, data),
}
)
@@ -151,6 +553,7 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if rt is None:
return
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
data = await rt.store.async_load() or {}
current_rev = int(data.get("rev", 0))
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
@@ -158,10 +561,12 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
)
return
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
new_rev = current_rev + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
"layout": msg["layout"], "rev": new_rev})
await async_save_layout_state(
rt, data, layout, new_rev,
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -183,6 +588,39 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
if rt is None:
return
async with rt.write_lock:
# A stale browser may still finish a drag after another client deleted
# the marker. Its tombstone is the server-side authority: acknowledge
# but ignore the late point so re-adding starts without a zombie
# position.
config_data = await rt.config_store.async_load() or {}
config = config_data.get("config") or {}
markers = config.get("markers") or []
deleted = any(
str(m.get("id")) == msg["device_id"] and m.get("removed") is True
for m in markers
)
live_virtual = any(
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
and m.get("binding") == "virtual"
for m in markers
)
live_explicit = any(
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
for m in markers
)
orphan_virtual = (
msg["device_id"].startswith("v_")
and not live_virtual
and not live_explicit
)
if deleted or orphan_virtual:
data = await rt.store.async_load() or {}
connection.send_result(msg["id"], {
"ok": True,
"ignored": "removed" if deleted else "missing_virtual",
"rev": int(data.get("rev", 0)),
})
return
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
layout[msg["device_id"]] = msg["pos"]
@@ -190,9 +628,10 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
# optimistic locking on layout/set meaningless — every drag reset the
# counter to 0 (HP-1454-08)
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
"layout": layout, "rev": new_rev})
await async_save_layout_state(
rt, data, layout, new_rev,
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -204,6 +643,7 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
vol.Optional("dry_run"): bool,
vol.Optional("undo"): bool,
vol.Optional("expected_rev"): int,
}
)
@websocket_api.async_response
@@ -236,6 +676,12 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
data = await rt.store.async_load() or {}
layout = data.get("layout") or {}
current_rev = int(data.get("rev", 0))
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
connection.send_error(
msg["id"], "conflict",
f"Layout was changed in another window (rev {current_rev} != {msg['expected_rev']})",
)
return
if msg.get("undo"):
backup = data.get("repair_backup")
if not isinstance(backup, dict) or backup.get("space") != space_id:
@@ -245,7 +691,11 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
for key, pos in (backup.get("positions") or {}).items():
restored[key] = pos
new_rev = current_rev + 1
await rt.store.async_save({"layout": restored, "rev": new_rev})
await async_save_layout_state(
rt, data, restored, new_rev,
metadata=_optimizer_backup_after_layout_maintenance(data, new_rev),
remove=("repair_backup",),
)
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev,
"restored": len(backup.get("positions") or {})})
@@ -277,10 +727,14 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
# the backup rides the same store write: either both are durable or
# neither — the deletion-shy rules of this project apply to positions
# too
await rt.store.async_save({
"layout": new_layout, "rev": new_rev,
"repair_backup": {"space": space_id, "positions": touched},
})
await async_save_layout_state(
rt, data, new_layout, new_rev,
metadata={
**_optimizer_backup_after_layout_maintenance(data, new_rev),
"repair_backup": {"space": space_id, "positions": touched},
},
remove=("repair_backup",),
)
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev, "moved": len(preview)})
@@ -588,9 +1042,10 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
if msg["device_id"] in layout:
del layout[msg["device_id"]]
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
"layout": layout, "rev": new_rev})
await async_save_layout_state(
rt, data, layout, new_rev,
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
if new_rev is not None:
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -611,20 +1066,71 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config = {**DEFAULT_CONFIG, **data.get("config", {})}
async with rt.write_lock:
data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config = {**DEFAULT_CONFIG, **data.get("config", {})}
config_rev = int(data.get("rev", 0))
try:
virtual_lights = await async_virtual_light_snapshot(
rt.virtual_light_store,
config,
config_rev,
)
except Exception: # noqa: BLE001 - config remains independently readable
_LOGGER.exception("House Plan: reading virtual-light state failed")
# Never expose a stale off bit after an unreadable/revision-gap
# operational store. Compatibility/default on is the safe frame.
virtual_lights = {"rev": 0, "config_rev": config_rev, "off": []}
connection.send_result(
msg["id"],
{
"config": config,
"rev": data.get("rev", 0),
"rev": config_rev,
"virtual_lights": virtual_lights,
"can_write": may_write(hass, getattr(connection, "user", None)),
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
"undo_kind": _undo_kind(data, layout_data),
},
)
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/virtual_light/toggle",
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=500)),
}
)
@websocket_api.async_response
async def ws_virtual_light_toggle(
hass: HomeAssistant, connection, msg: dict[str, Any]
) -> None:
"""Atomically toggle one eligible virtual light for any signed-in user."""
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
async with rt.write_lock:
data = await rt.config_store.async_load() or {}
config = {**DEFAULT_CONFIG, **data.get("config", {})}
result = await async_toggle_virtual_light(
rt.virtual_light_store,
config,
int(data.get("rev", 0)),
msg["marker_id"],
)
if result is None:
connection.send_error(
msg["id"],
"not_toggleable",
"Marker is not an active virtual light with tap_action=toggle",
)
return
# Both the reply and event follow the durable Store write. There is no
# optimistic client state, so all cards converge on this revision.
connection.send_result(msg["id"], result)
hass.bus.async_fire(EVENT_VIRTUAL_LIGHT_UPDATED, result)
def _internal_plan_names(config: dict[str, Any]) -> set[str]:
"""Plan file names a configuration names through OUR urls.
@@ -666,6 +1172,30 @@ def _missing_internal_plans(
}
def _missing_internal_attachments(
config_root: Path, config: dict[str, Any], previous: dict[str, Any] | None = None
) -> set[str]:
"""New local attachments that vanished between import preview and apply.
Existing broken references stay writable for the same reason as historical
plan URLs: refusing every unrelated write would prevent the user from
detaching or repairing them.
"""
known = {
str(item["url"])
for item in content_manifest(previous or {}, config_root)
if item.get("kind") == "attachment" and item.get("storage") == "internal"
}
return {
str(item["url"])
for item in content_manifest(config, config_root)
if item.get("kind") == "attachment"
and item.get("storage") == "internal"
and item.get("exists_at_export") is False
and str(item["url"]) not in known
}
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/config/set",
@@ -715,6 +1245,16 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
f"Configuration was changed in another window (rev {current_rev} != {msg['expected_rev']})",
)
return
# Marker-to-marker light links are a semantic graph layered on top of
# the lossless controls array. Validate only edges introduced by this
# write so an unrelated edit can still round-trip a legacy broken ref.
try:
validate_marker_controls(msg["config"], data.get("config"))
validate_marker_light_entities(msg["config"], data.get("config"))
validate_marker_value_badges(msg["config"], data.get("config"))
except MarkerControlError as err:
connection.send_error(msg["id"], err.code, str(err))
return
# An internal plan url must name a file that exists. The card can pick a
# plan and then delete it from the same dialog, and two clients can do
# the same thing in either order — the lock serialises them but says
@@ -733,7 +1273,12 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
)
return
new_rev = current_rev + 1
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
await async_save_config_state(
rt,
msg["config"],
new_rev,
previous_rev=int(current_rev),
)
try:
await _discard_optimizer_snapshot(rt)
except Exception: # noqa: BLE001 — stale backup cleanup is best-effort
@@ -810,6 +1355,17 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
)
return
# Optimization is a normal configuration write with an additional
# layout transaction. It must enforce the same marker-link semantics
# as config/set; otherwise a crafted client can persist a new cycle.
try:
validate_marker_controls(msg["config"], config_data.get("config"))
validate_marker_light_entities(msg["config"], config_data.get("config"))
validate_marker_value_badges(msg["config"], config_data.get("config"))
except MarkerControlError as err:
connection.send_error(msg["id"], err.code, str(err))
return
missing = await hass.async_add_executor_job(
_missing_internal_plans,
Path(hass.config.path(PLANS_DIR)),
@@ -826,6 +1382,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
backup = {
"kind": "optimize",
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout", {}),
"created": int(time.time()),
@@ -835,29 +1392,28 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
pending = {
"config": msg["config"],
"layout": msg["layout"],
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"clear_backup": False,
}
layout_meta = {
k: v for k, v in layout_data.items()
if k not in ("layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
}
# Intent first. A setup-time finisher completes whichever half a crash
# interrupted; until then the visible layout/revision remain unchanged.
await rt.store.async_save({
**layout_meta,
"layout": layout_data.get("layout", {}),
"rev": layout_rev,
_OPTIMIZE_BACKUP: backup,
_OPTIMIZE_PENDING: pending,
})
await rt.config_store.async_save({"config": msg["config"], "rev": new_config_rev})
await rt.store.async_save({
**layout_meta,
"layout": msg["layout"],
"rev": new_layout_rev,
_OPTIMIZE_BACKUP: backup,
})
await async_save_layout_state(
rt, layout_data, layout_data.get("layout", {}), layout_rev,
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
await async_save_config_state(
rt,
msg["config"],
new_config_rev,
previous_rev=config_rev,
)
await async_save_layout_state(
rt, layout_data, msg["layout"], new_layout_rev,
metadata={_OPTIMIZE_BACKUP: backup},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup", "geom_pending"),
)
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
@@ -887,6 +1443,7 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
if rt is None:
return
restored_kind = "optimize"
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
@@ -903,36 +1460,55 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
return
backup = layout_data[_OPTIMIZE_BACKUP]
restored_kind = str(backup.get("kind") or "optimize")
restored_config = backup.get("config") or DEFAULT_CONFIG
restored_layout = backup.get("layout") or {}
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
pending = {
"kind": "import_undo" if restored_kind == "import" else "optimize_undo",
"config": restored_config,
"layout": restored_layout,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"clear_backup": True,
}
layout_meta = {
k: v for k, v in layout_data.items()
if k not in ("layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
}
await rt.store.async_save({
**layout_meta,
"layout": layout_data.get("layout", {}),
"rev": layout_rev,
_OPTIMIZE_BACKUP: backup,
_OPTIMIZE_PENDING: pending,
})
await rt.config_store.async_save({"config": restored_config, "rev": new_config_rev})
await rt.store.async_save({
**layout_meta,
"layout": restored_layout,
"rev": new_layout_rev,
})
await async_save_layout_state(
rt, layout_data, layout_data.get("layout", {}), layout_rev,
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
await async_save_config_state(
rt,
restored_config,
new_config_rev,
previous_rev=config_rev,
)
await async_save_layout_state(
rt, layout_data, restored_layout, new_layout_rev,
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup"),
)
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
_refresh_trail_recorder(hass)
if restored_kind == "import":
recorder = hass.data.get(DOMAIN, {}).get("trail_recorder")
live_marker_ids = {
str(marker.get("id")) for marker in restored_config.get("markers") or []
}
if recorder is not None:
for marker_id in list(getattr(getattr(recorder, "book", None), "data", {})):
if marker_id not in live_marker_ids:
try:
await recorder.async_delete(marker_id)
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan: removing orphan undo trail failed")
entry = get_entry(hass)
if entry is not None:
from .repairs import async_check_plan_files
hass.async_create_task(async_check_plan_files(hass, entry))
connection.send_result(msg["id"], {
"ok": True,
"config_rev": new_config_rev,
@@ -1022,3 +1598,22 @@ async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConn
"""Current + previous cleanup runs per marker, raw robot coordinates."""
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
connection.send_result(msg["id"], {"trails": rec.book.data if rec else {}})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/trail/delete",
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=256)),
}
)
@websocket_api.async_response
async def ws_trail_delete(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
"""Permanently forget one deleted marker's current and previous runs."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete trails")
return
if _runtime(hass, connection, msg["id"]) is None:
return
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
removed = await rec.async_delete(msg["marker_id"]) if rec else False
connection.send_result(msg["id"], {"ok": True, "removed": removed})
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env node
/** Isolated 1/10/30/60-pool performance profiles for #19 and #55. */
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { performance } from 'node:perf_hooks';
import { launch } from './serve.mjs';
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
import { assertCardContract, GLOW_CARD_CONTRACT } from './performance/card-contract.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const profile = valueArg('profile') || 'large-light-blend-v1';
if (!['large-light-blend-v1', 'large-house-glow-overlay-v1'].includes(profile))
throw new Error(`unknown Glow profile: ${profile}`);
const parsedSamples = Number(valueArg('samples'));
const parsedWarmups = Number(valueArg('warmups'));
const samples = Math.max(1, Math.min(20, Number.isFinite(parsedSamples) && parsedSamples > 0 ? parsedSamples : 7));
const warmups = Math.max(0, Math.min(5, Number.isFinite(parsedWarmups) && parsedWarmups >= 0 ? parsedWarmups : 1));
const requestedVariants = valueArg('variants')?.split(',').map(Number);
if (requestedVariants?.some((count) => ![1, 10, 30, 60].includes(count)))
throw new Error(`invalid Glow variants: ${valueArg('variants')}`);
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const targetRoot = resolve(valueArg('target-root') ?? '.');
const additiveFixture = JSON.parse(readFileSync(
new URL('../test/fixtures/glow/additive-pools.json', import.meta.url), 'utf8',
));
additiveFixture.sourceIds = Object.keys(additiveFixture.ha.states)
.filter((entityId) => entityId.startsWith('light.'));
additiveFixture.roomCount = additiveFixture.config.spaces
.reduce((sum, space) => sum + space.rooms.length, 0);
additiveFixture.deviceCount = Object.keys(additiveFixture.ha.devices).length;
const makeOverlayFixture = () => {
const large = makeLargeHouseFixture();
const firstSpace = large.config.spaces[0].id;
const sourceDeviceIds = Object.entries(large.layout)
.filter(([, position]) => position.s === firstSpace)
.slice(0, 60)
.map(([deviceId]) => deviceId);
const sourceIds = [];
sourceDeviceIds.forEach((deviceId, index) => {
for (const [entityId, entity] of Object.entries(large.entities)) {
if (entity.device_id !== deviceId) continue;
delete large.entities[entityId];
delete large.states[entityId];
}
const entityId = `light.glow_overlay_${String(index + 1).padStart(3, '0')}`;
large.entities[entityId] = {
entity_id: entityId, device_id: deviceId, platform: 'houseplan_perf',
config_entry_id: 'perf_entry', disabled_by: null,
};
large.states[entityId] = {
entity_id: entityId, state: 'on',
attributes: {
friendly_name: `Overlay light ${index + 1}`,
brightness: 96 + (index % 5) * 32,
rgb_color: index % 2 ? [255, 154, 72] : [92, 156, 255],
},
};
sourceIds.push(entityId);
});
// The shared large-house fixture already contains a few ordinary lights.
// Keep them as devices but turn them off so the profile's pool cardinality
// is exactly the declared 1/10/30/60, not N plus an unrelated background lamp.
for (const [entityId, state] of Object.entries(large.states)) {
if (entityId.startsWith('light.') && !sourceIds.includes(entityId)) {
large.states[entityId] = { ...state, state: 'off' };
}
}
for (const space of large.config.spaces) {
space.settings = { ...(space.settings || {}), fill_mode: 'temp', glow_enabled: true };
}
return {
fixture: 'large-house-glow-overlay-v1', variants: [1, 10, 30, 60],
config: large.config, layout: large.layout,
ha: { devices: large.devices, entities: large.entities, areas: large.areas, states: large.states },
sourceIds,
roomCount: large.counts.rooms,
deviceCount: large.counts.devices,
};
};
const fixture = profile === 'large-light-blend-v1' ? additiveFixture : makeOverlayFixture();
if (requestedVariants?.length) fixture.variants = [...new Set(requestedVariants)];
const viewport = { width: 1280, height: 900 };
const { page, browser } = await launch(
viewport, 1,
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
{}, resolve(targetRoot, 'demo/srv'),
);
await page.addScriptTag({
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
});
const cdp = await page.context().newCDPSession(page);
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.addStyleTag({
content: '*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important;caret-color:transparent!important}',
});
const chromium = await browser.version();
let buildFingerprint;
try {
buildFingerprint = await assertFreshDemoBundle(page, targetRoot);
} catch (error) {
await browser.close();
throw error;
}
const rows = [];
try {
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const sample = iteration - warmups;
const row = await page.evaluate(async ({ fixture, profile, sample, cardContract }) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > timeout) throw new Error('Glow benchmark timed out');
await new Promise((done) => setTimeout(done, 10));
}
};
const observeLongTasks = () => {
const entries = [];
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask'))
return { stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }) };
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
observer.observe({ type: 'longtask', buffered: false });
return { stop: async () => {
await new Promise((done) => setTimeout(done, 0));
entries.push(...observer.takeRecords());
observer.disconnect();
const values = entries.map((entry) => entry.duration);
return {
supported: true,
count: values.length,
maxMs: Number((values.length ? Math.max(...values) : 0).toFixed(2)),
totalMs: Number(values.reduce((sum, value) => sum + value, 0).toFixed(2)),
};
}};
};
const forceGc = async () => {
if (typeof globalThis.gc !== 'function') return false;
globalThis.gc(); await frame(); globalThis.gc(); await frame();
return true;
};
const configFor = () => {
const config = structuredClone(fixture.config);
if (profile === 'large-light-blend-v1') {
const settings = config.spaces[0].settings;
settings.fill_mode = 'glow';
delete settings.glow_enabled;
}
return config;
};
const statesFor = (count, brightnessDelta = 0) => {
const active = new Set(fixture.sourceIds.slice(0, count));
const sources = new Set(fixture.sourceIds);
return Object.fromEntries(Object.entries(fixture.ha.states).map(([entityId, state]) => {
if (!sources.has(entityId)) return [entityId, state];
return [entityId, {
...state,
state: active.has(entityId) ? 'on' : 'off',
attributes: {
...state.attributes,
brightness: Math.max(1, Math.min(255, Number(state.attributes.brightness) + brightnessDelta)),
},
}];
}));
};
const connection = {
subscribeEvents: async () => () => undefined,
subscribeMessage: async () => () => undefined,
};
const hassFor = (states) => ({
language: 'en', locale: { language: 'en' },
user: { id: 'glow-perf', name: 'Glow performance', is_admin: true },
devices: fixture.ha.devices, entities: fixture.ha.entities,
areas: fixture.ha.areas, states, floors: {}, connection,
callWS: async (message) => {
if (message.type === 'houseplan/config/get')
return { config: configFor(), rev: 1, can_write: true };
if (message.type === 'houseplan/layout/get')
return { layout: structuredClone(fixture.layout), rev: 1 };
if (message.type === 'config/device_registry/list') return Object.values(fixture.ha.devices);
if (message.type === 'config/entity_registry/list') return Object.values(fixture.ha.entities);
if (message.type === 'config_entries/get')
return [{ entry_id: 'glow_fixture', domain: 'houseplan_fixture', title: 'Glow fixture' }];
if (message.type === 'manifest/list')
return [{ domain: 'houseplan_fixture', name: 'House Plan Glow Fixture' }];
return { ok: true };
},
callService: async () => undefined,
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
});
const cacheSnapshot = (card) => ({
cleanFloor: card._cleanFloorCache?.size ?? 0,
glowClip: card._glowClipCache?.size ?? 0,
wallUnion: card._wallUnionCache ? 1 : 0,
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
});
window.__card?.remove?.();
localStorage.clear();
const host = document.getElementById('host');
const result = { sample, longTasks: {}, renderCounts: {}, poolCounts: {} };
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', title: `Glow ${profile}`, icon_size: 2.4 });
host.replaceChildren(card);
card.hass = hassFor(statesFor(1));
window.__hpAssertCardContract(card, cardContract);
await until(() => card._loadOk && card._devices?.length === fixture.deviceCount);
if ('_glowScreenBlend' in card) {
const probeDeadline = performance.now() + 2500;
while (!card._glowScreenBlend && performance.now() < probeDeadline)
await new Promise((done) => setTimeout(done, 10));
}
await card.updateComplete;
await frame();
for (const count of fixture.variants) {
// Mount cost is not part of this profile. Prime each source-count
// state on the same full plan, then measure only the following HA tick.
card.hass = hassFor(statesFor(count));
await card.updateComplete;
await frame();
let renders = 0;
const originalUpdate = card.performUpdate.bind(card);
card.performUpdate = () => { renders++; return originalUpdate(); };
const longTasks = observeLongTasks();
const started = performance.now();
card.hass = hassFor(statesFor(count, 1));
await card.updateComplete;
await frame();
result[`stateUpdate${count}Ms`] = Number((performance.now() - started).toFixed(2));
result.longTasks[`stateUpdate${count}`] = await longTasks.stop();
result.renderCounts[count] = renders;
result.poolCounts[count] = card.renderRoot.querySelectorAll('.glow-pool, .glowlayer circle').length;
}
window.__card = card;
await forceGc();
const cacheBefore = cacheSnapshot(card);
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
for (let index = 0; index < 5; index++) {
card.hass = hassFor(statesFor(60, index % 2));
await card.updateComplete;
await frame();
}
await forceGc();
const cacheEntries = cacheSnapshot(card);
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
result.cacheEntries = cacheEntries;
result.cacheGrowth = Object.fromEntries(
Object.keys(cacheEntries).map((key) => [key, cacheEntries[key] - cacheBefore[key]]),
);
result.heapGrowthBytes = heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore;
result.preciseGc = typeof globalThis.gc === 'function';
result.renderedDevices = card._devices?.length ?? 0;
result.screenBlend = card._glowScreenBlend === true;
return result;
}, { fixture, profile, sample, cardContract: GLOW_CARD_CONTRACT });
const captureStarted = performance.now();
await page.screenshot({ type: 'png' });
row.screenshotCaptureMs = Number((performance.now() - captureStarted).toFixed(2));
if (sample >= 0) rows.push(row);
}
} finally {
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 }).catch(() => undefined);
await browser.close();
}
const metricNames = [
...fixture.variants.map((count) => `stateUpdate${count}Ms`), 'screenshotCaptureMs',
];
const report = {
schema: 2,
profile,
generatedAt: new Date().toISOString(),
buildFingerprint,
runtime: {
node: process.version, chromium, platform: process.platform, arch: process.arch,
viewport, deviceScaleFactor: 1, cpuThrottleRate: 4, reducedMotion: true,
},
fixture: {
id: fixture.fixture, variants: fixture.variants,
rooms: fixture.roomCount, devices: fixture.deviceCount,
},
samples,
warmups,
summary: summarizeTimings(rows, metricNames),
longTasks: summarizeLongTasks(rows),
rows,
};
const text = `${JSON.stringify(report, null, 2)}\n`;
if (output) {
mkdirSync(dirname(output), { recursive: true });
writeFileSync(output, text, 'utf8');
console.log(output);
} else process.stdout.write(text);
+457
View File
@@ -0,0 +1,457 @@
#!/usr/bin/env node
/** Reproducible browser benchmark and report producer for HP-PERF-01. */
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { launch } from './serve.mjs';
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
import { assertCardContract, LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const targetRoot = resolve(valueArg('target-root') ?? '.');
const profile = valueArg('profile') ?? 'large-house-v1';
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
throw new Error(`unknown large-house profile: ${profile}`);
const isometric = profile === 'large-house-isometric-v1';
const planSnap = profile === 'large-house-plan-snap-v1';
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
const fixture = makeLargeHouseFixture();
if (planSnap) {
for (const [floor, space] of fixture.config.spaces.entries()) {
space.room_drafts = [0, 1].map((draft) => {
const y = 0.985 + draft * 0.025;
return {
id: `perf-draft-${floor}-${draft}`,
points: [[0.10, y], [0.38, y], [0.46, y + 0.035]],
segments: [{ cm: 15 }, { cm: 20 }],
};
});
}
fixture.counts = { ...fixture.counts, drafts: 6, pointerMoves: 120 };
}
const viewport = { width: 1440, height: 1000 };
const { page, browser } = await launch(
viewport,
1,
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
{},
resolve(targetRoot, 'demo/srv'),
);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.addStyleTag({
content: '*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important;caret-color:transparent!important}',
});
await page.addScriptTag({
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
});
const chromium = await browser.version();
let buildFingerprint;
try {
buildFingerprint = await assertFreshDemoBundle(page, targetRoot);
} catch (error) {
await browser.close();
throw error;
}
const rows = [];
try {
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const measuredSample = iteration - warmups;
const row = await page.evaluate(async ({
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
}) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > timeout) throw new Error('large-house benchmark timed out');
await new Promise((done) => setTimeout(done, 10));
}
};
const startLongTaskWindow = () => {
const entries = [];
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask')) {
return { stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }) };
}
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
observer.observe({ type: 'longtask', buffered: false });
return {
stop: async () => {
await new Promise((done) => setTimeout(done, 0));
entries.push(...observer.takeRecords());
observer.disconnect();
const durations = entries.map((entry) => entry.duration);
return {
supported: true,
count: durations.length,
maxMs: Number((durations.length ? Math.max(...durations) : 0).toFixed(2)),
totalMs: Number(durations.reduce((sum, value) => sum + value, 0).toFixed(2)),
};
},
};
};
const duration = async (action) => {
const longTasks = startLongTaskWindow();
const started = performance.now();
await action();
await frame();
return {
ms: Number((performance.now() - started).toFixed(2)),
longTasks: await longTasks.stop(),
};
};
const forceGc = async () => {
if (typeof globalThis.gc !== 'function') return false;
globalThis.gc();
await frame();
globalThis.gc();
await frame();
return true;
};
const cacheSnapshot = (card) => ({
cleanFloor: card._cleanFloorCache?.size ?? 0,
glowClip: card._glowClipCache?.size ?? 0,
wallUnion: card._wallUnionCache ? 1 : 0,
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
isoGeometry: card._isoGeometryCache?.size ?? 0,
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
});
window.__card?.remove?.();
localStorage.clear();
if (isometric) {
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(['iso']));
localStorage.setItem('houseplan_card_view_v1', JSON.stringify(Object.fromEntries(
fixture.config.spaces.map((space) => [space.id, 'iso']),
)));
history.replaceState(null, '', '?hp-labs=iso');
} else history.replaceState(null, '', location.pathname);
const host = document.getElementById('host');
const card = document.createElement('houseplan-card');
card.setConfig({
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
});
let wsCalls = 0;
const connection = {
subscribeEvents: async () => () => undefined,
subscribeMessage: async () => () => undefined,
};
const hassFor = (states) => ({
language: 'en', locale: { language: 'en' },
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas, states,
floors: {
one: { floor_id: 'one', name: 'One', level: 0 },
two: { floor_id: 'two', name: 'Two', level: 1 },
three: { floor_id: 'three', name: 'Three', level: 2 },
},
callWS: async (message) => {
wsCalls++;
if (message.type === 'houseplan/config/get')
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
if (message.type === 'houseplan/layout/get')
return { layout: structuredClone(fixture.layout), rev: 1 };
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
if (message.type === 'config_entries/get')
return [{ entry_id: 'perf_entry', domain: 'houseplan_perf', title: 'Synthetic performance fixture' }];
if (message.type === 'manifest/list') return [{ domain: 'houseplan_perf', name: 'House Plan Performance' }];
return { ok: true };
},
callService: async () => undefined,
connection,
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
});
const loadLongTasks = startLongTaskWindow();
const loadStarted = performance.now();
host.replaceChildren(card);
card.hass = hassFor(fixture.states);
window.__hpAssertCardContract(card, cardContract);
if (requiresIsometric && (typeof card._setProjection !== 'function'
|| !(card._isoGeometryCache instanceof Map))) {
throw new Error('large-house-isometric-v1 candidate has no renderer contract');
}
await until(() => card._loadOk && card._model?.length === fixture.counts.floors);
await card.updateComplete;
await frame();
const modelReadyMs = Number((performance.now() - loadStarted).toFixed(2));
await until(() => card._booting === false);
await frame();
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
const loadLongTaskResult = await loadLongTasks.stop();
const viewToggle = isometric ? await duration(async () => {
if (typeof card._setProjection === 'function') {
card._setProjection('flat');
await card.updateComplete;
card._setProjection('iso');
await card.updateComplete;
} else {
// Comparison SHAs before #89 intentionally ignore the Labs operation.
card.requestUpdate();
await card.updateComplete;
}
}) : null;
const spaceSwitch = await duration(async () => {
card._pickSpace('perf-floor-2');
await card.updateComplete;
});
const firstEntity = Object.keys(fixture.states)[0];
const nextStates = {
...fixture.states,
[firstEntity]: { ...fixture.states[firstEntity], state: fixture.states[firstEntity].state === 'on' ? 'off' : 'on' },
};
const stateUpdate = await duration(async () => {
card.hass = hassFor(nextStates);
await card.updateComplete;
});
let planSnapDiagnostics = null;
const planSnapPointer = planSnap ? await duration(async () => {
card._setMode('plan');
card._tool = 'draw';
card._path = [];
card.requestUpdate();
await card.updateComplete;
await frame();
const stage = card.renderRoot.querySelector('.stage');
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
if (requiresPlanSnap && !overlay) throw new Error('plan-snap candidate has no overlay');
const staticLines = overlay?.querySelectorAll('.plan-snap-line').length ?? 0;
const staticNodes = overlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0;
const cacheValue = card._planSnapGeometryCache?.value ?? null;
const configBefore = JSON.stringify(card._serverCfg);
const callsBefore = wsCalls;
const view = card._viewOr(card._baseVb());
const rect = stage.getBoundingClientRect();
const fromPlan = (x, y) => ({
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
});
const firstEndpoint = overlay?.querySelector('.plan-snap-node[data-kind="endpoint"]');
const longLine = [...(overlay?.querySelectorAll('.plan-snap-line') || [])]
.map((line) => ({
line,
a: [+line.getAttribute('x1'), +line.getAttribute('y1')],
b: [+line.getAttribute('x2'), +line.getAttribute('y2')],
}))
.sort((a, b) => Math.hypot(b.b[0] - b.a[0], b.b[1] - b.a[1])
- Math.hypot(a.b[0] - a.a[0], a.b[1] - a.a[1]))[0];
const points = [
firstEndpoint
? [+firstEndpoint.getAttribute('cx'), +firstEndpoint.getAttribute('cy')]
: [40, 40],
longLine
? [(longLine.a[0] + longLine.b[0]) / 2, (longLine.a[1] + longLine.b[1]) / 2]
: [120, 40],
[10, 10],
];
const seenKinds = new Set();
for (let index = 0; index < 120; index++) {
const point = points[index % points.length];
stage.dispatchEvent(new PointerEvent('pointermove', {
...fromPlan(point[0], point[1]),
bubbles: true, composed: true, pointerId: 880, pointerType: 'mouse',
}));
await card.updateComplete;
const active = card.renderRoot.querySelector(
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
);
if (active) seenKinds.add(active.getAttribute('data-kind'));
if (requiresPlanSnap && card.renderRoot.querySelectorAll(
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
).length > 1) throw new Error('plan-snap rendered more than one active candidate');
}
const finalOverlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
planSnapDiagnostics = {
supported: requiresPlanSnap,
staticLines,
staticNodes,
activeKinds: [...seenKinds].sort(),
cacheStable: cacheValue != null && card._planSnapGeometryCache?.value === cacheValue,
domStable: (finalOverlay?.querySelectorAll('.plan-snap-line').length ?? 0) === staticLines
&& (finalOverlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0)
=== staticNodes,
configStable: JSON.stringify(card._serverCfg) === configBefore,
wsWrites: wsCalls - callsBefore,
};
if (requiresPlanSnap && (
staticLines < fixture.counts.rooms || staticNodes < fixture.counts.rooms
|| !planSnapDiagnostics.cacheStable || !planSnapDiagnostics.domStable
|| !planSnapDiagnostics.configStable || planSnapDiagnostics.wsWrites !== 0
|| !seenKinds.has('endpoint') || !seenKinds.has('line')
)) throw new Error(`plan-snap structural contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
card._setMode('view');
await card.updateComplete;
}) : null;
const resizePreview = await duration(async () => {
card._setMode('plan');
card._tool = 'resize';
await card.updateComplete;
const room = card._rszRooms()[0];
const pointerId = 777;
const quietEvent = {
pointerId,
stopPropagation: () => undefined,
preventDefault: () => undefined,
target: null,
};
card._rszEdgeDown(quietEvent, room.id, 1);
const plan = card._rszDrag?.plan;
if (!plan) throw new Error('large-house resize plan was not created');
const target = [
plan.a[0] + plan.n[0] * card._gridPitch,
plan.a[1] + plan.n[1] * card._gridPitch,
];
const stage = card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const view = card._viewOr(card._baseVb());
card._rszMove({
...quietEvent,
clientX: rect.left + ((target[0] - view.x) / view.w) * rect.width,
clientY: rect.top + ((target[1] - view.y) / view.h) * rect.height,
});
await card.updateComplete;
card._rszCancelDrag();
card._setMode('view');
await card.updateComplete;
});
const stage = card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const panZoom = await duration(async () => {
stage.dispatchEvent(new WheelEvent('wheel', {
deltaY: -120, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
bubbles: true, cancelable: true,
}));
await card.updateComplete;
});
const settingsDialog = await duration(async () => {
card._openSettingsDialog();
await card.updateComplete;
});
card._settingsDialog = null;
await card.updateComplete;
const switchCycle = await duration(async () => {
for (let index = 0; index < 12; index++) {
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
await card.updateComplete;
// A user cannot produce twelve tab clicks in one JavaScript task.
// Yield between interactions so Long Task entries describe one
// switch, while switchCycleMs still measures the complete cycle.
await new Promise((done) => setTimeout(done, 0));
}
});
await forceGc();
const cacheBefore = cacheSnapshot(card);
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
for (let round = 0; round < 4; round++) {
for (let index = 0; index < 12; index++) {
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
await card.updateComplete;
await new Promise((done) => setTimeout(done, 0));
}
await forceGc();
}
const cacheEntries = cacheSnapshot(card);
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
const cacheGrowth = Object.fromEntries(
Object.keys(cacheEntries).map((key) => [key, cacheEntries[key] - cacheBefore[key]]),
);
const result = {
sample,
modelReadyMs,
firstStableRenderMs,
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
...(planSnapPointer ? {
planSnapPointerMs: planSnapPointer.ms,
planSnapDiagnostics,
} : {}),
spaceSwitchMs: spaceSwitch.ms,
stateUpdateMs: stateUpdate.ms,
resizePreviewMs: resizePreview.ms,
panZoomMs: panZoom.ms,
settingsDialogMs: settingsDialog.ms,
switchCycleMs: switchCycle.ms,
longTasks: {
load: loadLongTaskResult,
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
spaceSwitch: spaceSwitch.longTasks,
stateUpdate: stateUpdate.longTasks,
resizePreview: resizePreview.longTasks,
panZoom: panZoom.longTasks,
settingsDialog: settingsDialog.longTasks,
switchCycle: switchCycle.longTasks,
},
cacheEntries,
cacheGrowth,
heapGrowthBytes: heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore,
preciseGc: typeof globalThis.gc === 'function',
renderedDevices: card._devices?.length ?? 0,
};
card.remove();
await frame();
return result;
}, {
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
isometric, requiresIsometric, planSnap, requiresPlanSnap,
});
if (measuredSample >= 0) rows.push(row);
}
} finally {
await browser.close();
}
const metricNames = [
'modelReadyMs', 'firstStableRenderMs', 'spaceSwitchMs', 'stateUpdateMs',
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
];
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
const report = {
schema: 2,
profile,
generatedAt: new Date().toISOString(),
buildFingerprint,
runtime: {
node: process.version,
chromium,
platform: process.platform,
arch: process.arch,
viewport,
deviceScaleFactor: 1,
reducedMotion: true,
},
fixture: LARGE_HOUSE_COUNTS,
samples,
warmups,
summary: summarizeTimings(rows, metricNames),
longTasks: summarizeLongTasks(rows),
rows,
note: `Compare with a base-SHA report captured by the same runner and evaluate the ${profile} budget.`,
};
const text = `${JSON.stringify(report, null, 2)}\n`;
if (output) {
mkdirSync(dirname(output), { recursive: true });
writeFileSync(output, text, 'utf8');
console.log(output);
} else {
process.stdout.write(text);
}
+31
View File
@@ -0,0 +1,31 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
const fingerprintForTree = async (root) => {
const modulePath = resolve(root, 'scripts/source-fingerprint.mjs');
if (!existsSync(modulePath)) return sourceFingerprint(root);
const module = await import(pathToFileURL(modulePath).href);
if (typeof module.sourceFingerprint !== 'function') {
throw new Error(`${modulePath} does not export sourceFingerprint`);
}
return module.sourceFingerprint(root);
};
/** Refuse measurements/screenshots made by a committed bundle from old source. */
export async function assertFreshDemoBundle(page, root = process.cwd()) {
// A comparative performance run may load an older tree whose fingerprint
// contract is intentionally different from the candidate's. Validate that
// tree with the implementation that built it, not with today's algorithm.
const expected = await fingerprintForTree(root);
const loaded = await page.evaluate(() => globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__ ?? null);
if (loaded !== expected) {
throw new Error(
'demo/srv/assets/houseplan-card.js is stale. Run npm run build and copy '
+ 'dist/houseplan-card.js to demo/srv/assets/houseplan-card.js first. '
+ `Expected ${expected}, loaded ${loaded || 'no fingerprint'}.`,
);
}
return expected;
}
+238
View File
@@ -0,0 +1,238 @@
/**
* Deterministic, fictional high-load fixture shared by performance and future
* visual-regression tooling. Nothing here depends on a real HA installation.
*/
const FLOOR_COUNT = 3;
const ROOMS_PER_FLOOR = 20;
const DEVICE_COUNT = 200;
const OPENING_COUNT = 100;
const PARTITION_COUNT = 60;
const COLUMN_COUNT = 40;
const DECOR_COUNT = 500;
const round = (value) => Number(value.toFixed(6));
const roomGrid = (floor) => {
const rooms = [];
const left = 0.04;
const top = 0.04;
const width = 0.92 / 5;
const height = 0.92 / 4;
for (let row = 0; row < 4; row++) {
for (let column = 0; column < 5; column++) {
const index = row * 5 + column;
const x1 = round(left + column * width);
const y1 = round(top + row * height);
const x2 = round(x1 + width);
const y2 = round(y1 + height);
rooms.push({
id: `perf-room-${floor}-${index}`,
name: `Room ${floor + 1}.${index + 1}`,
area: `perf_area_${floor}_${index}`,
poly: [[x1, y1], [x2, y1], [x2, y2], [x1, y2]],
});
}
}
return rooms;
};
const wallSegments = (rooms) => {
const unique = new Map();
for (const room of rooms) {
room.poly.forEach((a, index) => {
const b = room.poly[(index + 1) % room.poly.length];
const forward = `${a.join(',')}/${b.join(',')}`;
const reverse = `${b.join(',')}/${a.join(',')}`;
if (!unique.has(reverse)) unique.set(forward, { a, b });
});
}
return [...unique.values()];
};
const makeOpenings = (floor, walls, count) => walls.slice(0, count).map((wall, index) => {
const horizontal = Math.abs(wall.b[0] - wall.a[0]) >= Math.abs(wall.b[1] - wall.a[1]);
return {
id: `perf-opening-${floor}-${index}`,
type: index % 7 === 0 ? 'window' : index % 11 === 0 ? 'gate' : 'door',
x: round((wall.a[0] + wall.b[0]) / 2),
y: round((wall.a[1] + wall.b[1]) / 2),
angle: horizontal ? 0 : 90,
length: horizontal ? 0.045 : 0.055,
};
});
const makePartitions = (floor, rooms, count) => Array.from({ length: count }, (_, index) => {
const room = rooms[index % rooms.length];
const [a, , c] = room.poly;
const y = round(a[1] + (c[1] - a[1]) * (0.35 + (index % 3) * 0.12));
return {
id: `perf-partition-${floor}-${index}`,
a: [round(a[0] + 0.035), y],
b: [round(c[0] - 0.035), y],
cm: 10 + (index % 3) * 5,
};
});
const makeColumns = (floor, rooms, count) => Array.from({ length: count }, (_, index) => {
const room = rooms[(index * 3) % rooms.length];
const [a, , c] = room.poly;
return {
id: `perf-column-${floor}-${index}`,
shape: index % 3 === 0 ? 'circle' : 'square',
center: [
round(a[0] + (c[0] - a[0]) * (0.28 + (index % 2) * 0.44)),
round(a[1] + (c[1] - a[1]) * (0.28 + ((index >> 1) % 2) * 0.44)),
],
cm: 25 + (index % 4) * 5,
...(index % 3 === 0 ? {} : { angle: (index % 6) * 15 }),
};
});
const makeDecor = (floor, count) => Array.from({ length: count }, (_, index) => {
const column = index % 25;
const row = Math.floor(index / 25);
const x = round(0.02 + column * 0.039);
const y = round(0.018 + (row % 20) * 0.048);
if (index % 10 === 0) {
return {
id: `perf-decor-${floor}-${index}`,
kind: 'text', x, y, text: `F${floor + 1}-${index}`, size_cm: 14,
color: '#59636e', opacity: 0.75,
};
}
if (index % 3 === 0) {
return {
id: `perf-decor-${floor}-${index}`,
kind: 'rect', x, y, w: 0.022, h: 0.018, angle: (index % 12) * 5,
color: '#687681', opacity: 0.55, width_cm: 1.5,
fill: index % 2 === 0, fill_color: '#75838e', fill_opacity: 0.12,
};
}
return {
id: `perf-decor-${floor}-${index}`,
kind: 'line', x1: x, y1: y, x2: round(x + 0.025), y2: round(y + (index % 2 ? 0.012 : 0)),
color: '#687681', opacity: 0.6, width_cm: 1.2,
...(index % 9 === 0 ? { line_style: 'dashed' } : {}),
};
});
const entityKinds = [
['light', 'on'],
['switch', 'off'],
['sensor', '21.5'],
['binary_sensor', 'off'],
['climate', 'heat'],
['media_player', 'playing'],
['cover', 'closed'],
['fan', 'on'],
['lock', 'locked'],
['vacuum', 'docked'],
];
const makeRuntime = (spaces) => {
const devices = {};
const entities = {};
const states = {};
const areas = {};
const layout = {};
const roomRefs = spaces.flatMap((space) => space.rooms.map((room) => ({ space, room })));
for (const { room } of roomRefs) areas[room.area] = { area_id: room.area, name: room.name };
for (let index = 0; index < DEVICE_COUNT; index++) {
const { space, room } = roomRefs[index % roomRefs.length];
const [domain, baseState] = entityKinds[index % entityKinds.length];
const deviceId = `perf-device-${index}`;
const entityId = `${domain}.perf_${index}`;
devices[deviceId] = {
id: deviceId,
name: `Synthetic ${domain} ${index + 1}`,
model: `PERF-${String(index + 1).padStart(3, '0')}`,
area_id: room.area,
identifiers: [['houseplan_perf', deviceId]],
config_entries: ['perf_entry'],
entry_type: null,
via_device_id: null,
disabled_by: null,
};
entities[entityId] = {
entity_id: entityId,
device_id: deviceId,
platform: 'houseplan_perf',
config_entry_id: 'perf_entry',
disabled_by: null,
};
const attributes = { friendly_name: devices[deviceId].name };
if (domain === 'sensor') Object.assign(attributes, {
device_class: 'temperature', unit_of_measurement: '°C', state_class: 'measurement',
});
if (domain === 'binary_sensor') attributes.device_class = index % 2 ? 'motion' : 'occupancy';
if (domain === 'climate') Object.assign(attributes, { current_temperature: 21.5, temperature: 22 });
states[entityId] = { entity_id: entityId, state: index % 4 === 0 && domain === 'light' ? 'off' : baseState, attributes };
const [a, , c] = room.poly;
layout[deviceId] = {
s: space.id,
x: round(a[0] + (c[0] - a[0]) * (0.2 + (index % 4) * 0.2)),
y: round(a[1] + (c[1] - a[1]) * (0.28 + ((index >> 2) % 3) * 0.22)),
};
}
return { devices, entities, states, areas, layout };
};
export const LARGE_HOUSE_COUNTS = Object.freeze({
floors: FLOOR_COUNT,
rooms: FLOOR_COUNT * ROOMS_PER_FLOOR,
devices: DEVICE_COUNT,
openings: OPENING_COUNT,
partitions: PARTITION_COUNT,
columns: COLUMN_COUNT,
decor: DECOR_COUNT,
});
export const makeLargeHouseFixture = () => {
let openingsLeft = OPENING_COUNT;
let partitionsLeft = PARTITION_COUNT;
let columnsLeft = COLUMN_COUNT;
let decorLeft = DECOR_COUNT;
const spaces = Array.from({ length: FLOOR_COUNT }, (_, floor) => {
const rooms = roomGrid(floor);
const segments = wallSegments(rooms);
const floorsRemaining = FLOOR_COUNT - floor;
const openingCount = Math.ceil(openingsLeft / floorsRemaining);
const partitionCount = Math.ceil(partitionsLeft / floorsRemaining);
const columnCount = Math.ceil(columnsLeft / floorsRemaining);
const decorCount = Math.ceil(decorLeft / floorsRemaining);
openingsLeft -= openingCount;
partitionsLeft -= partitionCount;
columnsLeft -= columnCount;
decorLeft -= decorCount;
return {
id: `perf-floor-${floor + 1}`,
title: `Performance floor ${floor + 1}`,
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: { fill_mode: 'glow', show_borders: true, show_names: true },
rooms,
walls: segments.map((wall, index) => ({
key: `perf-wall-${floor}-${index}`, cm: 15, a: wall.a, b: wall.b,
})),
openings: makeOpenings(floor, segments, openingCount),
partitions: makePartitions(floor, rooms, partitionCount),
wall_columns: makeColumns(floor, rooms, columnCount),
decor: makeDecor(floor, decorCount),
};
});
const runtime = makeRuntime(spaces);
const lightMarkers = Object.entries(runtime.entities)
.filter(([entityId]) => entityId.startsWith('light.'))
.map(([_entityId, entity]) => ({
id: entity.device_id,
binding: `device:${entity.device_id}`,
is_light: true,
}));
return {
config: { spaces, markers: lightMarkers, settings: { glow_radius_cm: 300 } },
...runtime,
counts: LARGE_HOUSE_COUNTS,
};
};
+206
View File
@@ -0,0 +1,206 @@
/** Deterministic fictional scenes for HP-QA-01 golden-image coverage. */
const round = (value) => Number(value.toFixed(6));
// Golden fixtures must use the same persisted wall-key contract as real plan
// data. Arbitrary labels make every configured wall look virtual to the
// renderer, which lets a visually ineffective baseline pass unnoticed.
const WALL_KEY_PITCH = 1 / 240;
export const fixtureWallKey = (a, b) => {
const quantize = (value) => Math.round(value / WALL_KEY_PITCH) * WALL_KEY_PITCH;
const mx = quantize((a[0] + b[0]) / 2);
const my = quantize((a[1] + b[1]) / 2);
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
if (length < 1e-12) { dx = 1; dy = 0; }
else { dx /= length; dy /= length; }
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
const bucket = Math.round(angle * 1800) / 1800;
return `${mx.toFixed(4)},${my.toFixed(4)}@${bucket.toFixed(4)}`;
};
const uniqueEdges = (rooms) => {
const edges = new Map();
for (const room of rooms) {
room.poly.forEach((a, index) => {
const b = room.poly[(index + 1) % room.poly.length];
const forward = `${a.join(',')}/${b.join(',')}`;
const reverse = `${b.join(',')}/${a.join(',')}`;
if (!edges.has(reverse) && !edges.has(forward)) edges.set(forward, { a, b });
});
}
return [...edges.values()];
};
const wallsFor = (prefix, rooms, thickness) => uniqueEdges(rooms).map((edge, index) => ({
key: fixtureWallKey(edge.a, edge.b),
a: edge.a,
b: edge.b,
cm: typeof thickness === 'function' ? thickness(edge, index) : thickness,
}));
const geometryRooms = [
{ id: 'geo-nw', name: 'NW', area: 'golden_geo_nw', poly: [[0.06, 0.08], [0.48, 0.08], [0.48, 0.48], [0.06, 0.48]] },
{ id: 'geo-ne', name: 'NE', area: 'golden_geo_ne', poly: [[0.48, 0.08], [0.94, 0.08], [0.94, 0.48], [0.48, 0.48]] },
{ id: 'geo-sw', name: 'SW', area: 'golden_geo_sw', poly: [[0.06, 0.48], [0.48, 0.48], [0.48, 0.92], [0.06, 0.92]] },
{ id: 'geo-se', name: 'SE', area: 'golden_geo_se', poly: [[0.48, 0.48], [0.94, 0.48], [0.94, 0.92], [0.48, 0.92]] },
{ id: 'geo-nested', name: 'Nested', area: 'golden_geo_nested',
poly: [[0.72, 0.14], [0.84, 0.26], [0.72, 0.38], [0.60, 0.26]] },
];
const lightingRooms = [
{ id: 'light-left', name: 'Light source room', area: 'golden_light_left',
poly: [[0.07, 0.10], [0.50, 0.10], [0.50, 0.88], [0.07, 0.88]] },
{ id: 'light-right', name: 'Receiving room', area: 'golden_light_right',
poly: [[0.50, 0.10], [0.93, 0.10], [0.93, 0.88], [0.50, 0.88]] },
];
const geometrySpace = {
id: 'golden-geometry',
title: 'Geometry matrix',
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: {
fill_mode: 'none', show_borders: true, show_names: true,
room_color: '#2d8fce', room_opacity: 0.16,
},
rooms: geometryRooms,
walls: wallsFor('geo', geometryRooms, (edge, index) => {
const vertical = Math.abs(edge.a[0] - edge.b[0]) < 1e-9;
if (vertical && Math.abs(edge.a[0] - 0.48) < 1e-9) return 25;
return index % 4 === 0 ? 10 : 15;
}),
open_spans: [{ a: [0.48, 0.15], b: [0.48, 0.27] }],
openings: [
{ id: 'geo-window', type: 'window', x: 0.26, y: 0.08, angle: 0, length: 0.12 },
{ id: 'geo-door', type: 'door', x: 0.48, y: 0.37, angle: 90, length: 0.12 },
{ id: 'geo-gate', type: 'gate', x: 0.72, y: 0.92, angle: 0, length: 0.2 },
{ id: 'geo-diagonal-window', type: 'window', x: 0.78, y: 0.20, angle: 45, length: 0.08 },
],
partitions: [
{ id: 'geo-partition-h', a: [0.14, 0.68], b: [0.40, 0.68], cm: 12 },
{ id: 'geo-partition-v', a: [0.75, 0.56], b: [0.75, 0.82], cm: 20 },
],
wall_columns: [
{ id: 'geo-column-square', shape: 'square', center: [0.63, 0.67], cm: 35, angle: 30 },
{ id: 'geo-column-circle', shape: 'circle', center: [0.86, 0.72], cm: 40 },
],
decor: [
{ id: 'geo-axis-h', kind: 'line', x1: 0.04, y1: 0.5, x2: 0.96, y2: 0.5,
color: '#5d6a73', opacity: 0.35, width_cm: 0.8, line_style: 'dashed' },
],
};
const lightingSpace = {
id: 'golden-lighting',
title: 'Lighting matrix',
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: {
fill_mode: 'none', glow_enabled: true, show_borders: true, show_names: true,
north_deg: 0, sun_rays: true, bg_mode: 'static',
},
rooms: lightingRooms,
walls: wallsFor('light', lightingRooms, (edge) => (
Math.abs(edge.a[0] - 0.5) < 1e-9 && Math.abs(edge.b[0] - 0.5) < 1e-9 ? 25 : 15
)),
openings: [
{ id: 'light-window', type: 'window', x: 0.27, y: 0.10, angle: 0, length: 0.14 },
{ id: 'light-door', type: 'door', x: 0.50, y: 0.54, angle: 90, length: 0.15 },
{ id: 'light-gate', type: 'gate', x: 0.74, y: 0.88, angle: 0, length: 0.22 },
],
partitions: [
{ id: 'light-partition', a: [0.70, 0.22], b: [0.70, 0.70], cm: 18 },
],
wall_columns: [
{ id: 'light-column', shape: 'circle', center: [0.38, 0.64], cm: 45 },
],
decor: [],
};
const runtime = () => {
const devices = {};
const entities = {};
const states = {
'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 180, elevation: 24 },
},
};
// Keep sun.sun state-only on purpose. Core/runtime entities and YAML
// entities without unique_id may have a live state without a registry row.
// The production projection must preserve them.
const layout = {};
const areas = Object.fromEntries(
[...geometryRooms, ...lightingRooms].map((room) => [room.area, { area_id: room.area, name: room.name }]),
);
const add = (id, domain, area, x, y, state, attributes = {}) => {
const entityId = `${domain}.${id.replaceAll('-', '_')}`;
devices[id] = {
id, name: `Golden ${id}`, model: `GOLDEN-${id.toUpperCase()}`, area_id: area,
identifiers: [['houseplan_golden', id]], config_entries: ['golden_entry'],
entry_type: null, via_device_id: null, disabled_by: null,
};
entities[entityId] = {
entity_id: entityId, device_id: id, platform: 'houseplan_golden',
config_entry_id: 'golden_entry', disabled_by: null,
};
states[entityId] = { entity_id: entityId, state, attributes: { friendly_name: devices[id].name, ...attributes } };
layout[id] = { s: 'golden-lighting', x: round(x), y: round(y) };
};
add('golden-light-one', 'light', 'golden_light_left', 0.20, 0.34, 'on', { rgb_color: [255, 196, 112] });
add('golden-light-two', 'light', 'golden_light_left', 0.35, 0.72, 'on', { color_temp_kelvin: 2700 });
add('golden-light-three', 'light', 'golden_light_right', 0.82, 0.30, 'off');
add('golden-presence', 'binary_sensor', 'golden_light_right', 0.82, 0.62, 'on', { device_class: 'occupancy' });
add('golden-climate', 'climate', 'golden_light_right', 0.60, 0.28, 'heat', {
current_temperature: 22.4, temperature: 23, hvac_action: 'heating',
});
add('golden-left-temperature', 'sensor', 'golden_light_left', 0.19, 0.54, '17', {
device_class: 'temperature', unit_of_measurement: '°C',
});
add('golden-right-temperature', 'sensor', 'golden_light_right', 0.81, 0.48, '29', {
device_class: 'temperature', unit_of_measurement: '°C',
});
add('golden-left-linkquality', 'sensor', 'golden_light_left', 0.34, 0.54, '35', {
unit_of_measurement: 'lqi',
});
add('golden-right-linkquality', 'sensor', 'golden_light_right', 0.66, 0.70, '190', {
unit_of_measurement: 'lqi',
});
return { devices, entities, states, layout, areas };
};
export const VISUAL_MATRIX_COUNTS = Object.freeze({
spaces: 2,
rooms: geometryRooms.length + lightingRooms.length,
openings: geometrySpace.openings.length + lightingSpace.openings.length,
partitions: geometrySpace.partitions.length + lightingSpace.partitions.length,
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
});
export const makeVisualMatrixFixture = () => ({
config: {
spaces: [structuredClone(geometrySpace), structuredClone(lightingSpace)],
// A persisted marker is part of the fixture contract for scenarios that
// override per-source Glow controls. The device/layout alone are not a
// saved marker configuration and must not be silently treated as one.
markers: [{ id: 'golden-light-two', binding: 'device:golden-light-two' }],
settings: {
glow_radius_cm: 360,
north_deg: 0,
sun_rays: true,
bg_mode: 'static',
fill_colors: {
glow_base: { c: '#1b2530', a: 0.78 },
glow_light: { c: '#ffd27b', a: 0.70 },
wall_fill: { c: '#d7d9dc', a: 1 },
},
},
},
...runtime(),
counts: VISUAL_MATRIX_COUNTS,
});
+61
View File
@@ -0,0 +1,61 @@
# HP-QA-01 golden images
This layer catches visual regressions that DOM smokes cannot: wall seams and
end caps, thick opening tunnels, Glow/sun clipping, hover contours, editor
chrome, the open contextual tray at wide/medium/narrow widths in English and
Russian (selection, tool options, group and palette), long dialog
titles/footers, mobile clipping, themes and zoom/remount. The desktop and
mobile device-dialog scenarios use a real light and make the complete
source-role, Glow colour, brightness and radius controls visible; capturing
only the top of that section fails the scenario before comparison.
The Glow matrix also keeps one deliberately opaque custom-fill scene with a
single source and two doorways: it makes hard spill wedges and fully unlit
radial spokes visible instead of hiding them under a translucent room fill.
## Safety contract
- A build fingerprint embedded by Rollup must match `src/`, Rollup/TypeScript
configuration and locked package inputs; stale committed
demo bundles fail before the first screenshot.
- Chromium, viewport, locale, timezone, colour profile, font rendering,
animations and caret are controlled by the runner.
- `capture` writes only to ignored `artifacts/golden/`; it never changes a
baseline and never claims a missing baseline passed. Any scenario runtime
error makes capture fail, including the initial no-baseline CI run.
- `verify` requires every image plus a matching matrix manifest and fails on
missing/different/error scenarios, browser mismatch or a baseline whose hash
no longer matches the reviewed manifest.
- `accept` requires `--reviewed`, a complete candidate report and current
source fingerprint. It validates the whole set before copying anything and
is the only command allowed to update baselines.
## Workflow
Build and copy the exact current source first:
```bash
npm run build
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
npm run golden:capture
```
Review `artifacts/golden/actual/` and, when existing references are present,
`artifacts/golden/diff/`. If every image is intentional:
```bash
npm run golden:accept -- --reviewed
npm run golden:verify
```
Never accept images merely to make CI green. A matrix/framing change increments
`GOLDEN_MATRIX_VERSION`; a normal rendering fix does not. The first canonical
Linux baseline was reviewed and accepted during the v1.60.3-beta.1 gate.
Future updates must still use the `golden-images` artifact produced by the Linux
CI job as the review set: desktop font rasterisation can differ from the CI
environment even with the same pinned Chromium. Pass its unpacked root via
`--from=...` when accepting it locally.
Scenarios may also declare a semantic pixel region (for example, a receiving
room that must contain warm light). `golden:capture` and `golden:verify` reject
the capture before baseline comparison when that visual precondition is empty;
a reviewed but meaningless PNG therefore cannot become the contract.
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
import { GOLDEN_BASELINE_MANIFEST } from './policy.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const reviewed = process.argv.includes('--reviewed');
const fromArg = process.argv.find((arg) => arg.startsWith('--from='));
const from = resolve(fromArg ? fromArg.slice('--from='.length) : resolve(ROOT, 'artifacts/golden'));
if (!reviewed) throw new Error('refusing to replace baselines without explicit --reviewed');
const reportPath = resolve(from, 'golden-report.json');
if (!existsSync(reportPath)) throw new Error(`candidate report not found: ${reportPath}`);
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
if (report.matrixVersion !== GOLDEN_MATRIX_VERSION)
throw new Error(`candidate matrix ${report.matrixVersion} != current ${GOLDEN_MATRIX_VERSION}`);
if (report.buildFingerprint !== sourceFingerprint(ROOT))
throw new Error('candidate screenshots were not captured from the current frontend source');
if (typeof report.chromium !== 'string' || !report.chromium)
throw new Error('candidate report does not identify its Chromium build');
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
const byId = new Map(report.results.map((result) => [result.id, result]));
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
mkdirSync(baselineRoot, { recursive: true });
const hashes = {};
const candidates = [];
for (const scenario of GOLDEN_SCENARIOS) {
const result = byId.get(scenario.id);
const candidate = resolve(from, 'actual', `${scenario.id}.png`);
if (result?.error || !['missing-baseline', 'passed', 'different'].includes(result?.status))
throw new Error(`review candidate has an invalid run status: ${scenario.id} (${result?.status || 'missing'})`);
if (!result?.actualSha256 || !existsSync(candidate))
throw new Error(`review candidate missing: ${scenario.id}`);
const bytes = readFileSync(candidate);
const digest = createHash('sha256').update(bytes).digest('hex');
if (digest !== result.actualSha256) throw new Error(`candidate changed after capture: ${scenario.id}`);
candidates.push({ scenario, candidate });
hashes[scenario.id] = digest;
}
// Validate the complete set first: a broken report must never leave a half-
// updated baseline directory behind.
for (const { scenario, candidate } of candidates)
copyFileSync(candidate, resolve(baselineRoot, `${scenario.id}.png`));
writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify({
schema: 1,
matrixVersion: GOLDEN_MATRIX_VERSION,
acceptedAt: new Date().toISOString(),
sourceFingerprint: report.buildFingerprint,
chromium: report.chromium,
scenarios: hashes,
}, null, 2)}\n`, 'utf8');
console.log(`Accepted ${GOLDEN_SCENARIOS.length} reviewed golden baselines.`);
+1
View File
@@ -0,0 +1 @@
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

@@ -0,0 +1,69 @@
{
"schema": 1,
"matrixVersion": 22,
"acceptedAt": "2026-08-14T16:03:22.017Z",
"sourceFingerprint": "24117bcfb4c94d5421e0304612089743615233eab3911f065e8cfa003f48fa7d",
"chromium": "151.0.7922.34",
"scenarios": {
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
"isometric-geometry-view-dark": "4816ec4b22211c73765f9fac81741df685202d21df6b0fbc1649b23357086da3",
"isometric-geometry-view-light": "8ec81fc4f254ae6ebab55bc5f0ef5325a4e5d327cd1fb53bf5f293f06fbec671",
"isometric-live-layers-dark": "c841269f672c7ad8b208a549cc87592bd26f2a9e226f793a2b79592b65ee54c6",
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
"isometric-touch-kiosk-dark": "fd2e74c5966b4adcf4e66021e2cef781873f9a2b58f16ad19d57fb451ba45dc9",
"isometric-large-warm-remount-dark": "0afc1069d334f10be2d0ed135ee0eb52e9272a1a1ad1224e00f05753b8789d0c",
"geometry-view-dark-fit": "3df272f6c3c3d20e9e375ea037f3dbb885657b94b0b29d0067505f4a73741237",
"geometry-view-light-fit": "a7f2c9667d9872dd84a37d5318fd238c9eabcc0f413017eb19e02204587b4e1a",
"day-cycle-dawn-dark": "a573083c4ee21993cf2e482b60418a30e388a14117d056a2b0ea559a0c928039",
"day-cycle-day-dark": "6f24cd1d8669c9f3451c06ca5a5fd499d8f1ae2698a34f4a63ffee2e9e6b4330",
"day-cycle-dusk-dark": "80029577c25f8759090ee2550fe530a35c189806dd6fa26e887da4f5d1cf54a3",
"day-cycle-night-dark": "d855785914d3e11198d3e4671c1fbcad15104c95954bc1ed9b7366aa51aac65e",
"geometry-plan-editor-dark": "16364738754515e81e6d0ae13c0358db8c7f9d26c2f34dfde15f5855c6af13fa",
"plan-snap-endpoint-light": "c5f63ca2ca2706a062a2fc97e25670768662810bd7e0b251f0a9cfdbaf60c758",
"plan-snap-line-gaps-dark": "44808a816e62416b9c2c39a6e06cd4a8631860e178f246772ce8c7a46f98edd0",
"wall-junctions-plan-preview-light": "9e3a07da3e3ae1b2a95299f92b9500f347f0bfbd20d87429505b30769ee627c2",
"wall-junctions-plan-t-dark": "a4a958f20ed5f4b8d4e6bca9ebd1b289186c0cb48b48897a1624b49014f3ae5d",
"wall-junctions-view-dark": "73a64c65c8e77aa767bb7f401d68c61df5749e65e75273c85b0d6c72cb430766",
"isometric-wall-junctions-dark": "cb1e28f484b304ecda3e35f66e0c0429016d10a5a3022dcc9315a92eb537300f",
"opening-placement-door-thick-wall-dark": "395c03bbf5d968e83664fd6621f0ac25902e718022f2e92ffbcddb8ce629cf9c",
"geometry-devices-editor-dark": "a9e4846ce5453400b87e6ad3d575882bc23a07b59dc3eecb612ed872b0c871ec",
"geometry-decor-editor-dark": "435b36096bbb2996d56ff0af262ddebff4a727edd841b0fad9b8d4f507b987ac",
"tray-wide-selection-en": "06b0df980fd79e8bfc2957878966a11ae9d1a61e80ee870094dc60eae4de26bb",
"tray-wide-tool-ru": "e5a6b2057acd9af2c5417bf413778395112eb1ebecd784251c68c74a16b9dc5f",
"tray-medium-group-en": "5115910bc0f359ce91f361794a51ae1ae6a493203941d09166b412b696eda775",
"tray-medium-selection-ru": "4e5f235be8ed6296e136641d172e727a6a6b7a9d061f0928a96ccc1103c19f1f",
"tray-narrow-palette-en": "88b9846e4b451ed95b7ae7d2c3183a2ea191d7668768992a1364c6a7a53eb0c6",
"tray-narrow-tool-ru": "c4130715b3cb31c68619dfc706a3aa308e86edf272bfae6833b20666764ece2b",
"geometry-diagonal-45-opening-dark": "01206d25631c8fd09fa077932fbb5c1ee115b65ba76b38e6f7b09315dbbf6002",
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
"lighting-glow-sun-dark": "a98eee332f25a43c8d9d126c118c8cfea4ee73752b1060227548efedaf0efcdd",
"device-value-badge-positions-dark": "1ad43f2bd866733aa75c34de38fb97d469661799b540a8ae22150067d788cec8",
"lighting-sun-window-state-only-dark": "3bd581a23a2e0ebba58530db5182adea5cba6ec10bc032bee024415c19108a17",
"lighting-fill-light-axis-split-dark": "4f867528aeb9124229f81659876b03ff297a3a7a92bc57ffb32d5c24913c4938",
"lighting-fill-temp-axis-split-dark": "e0535b70701c9fe6753f74c943b9f288a0fb1a23a9cfc8590d4945e0a1724eb5",
"lighting-fill-lqi-axis-split-dark": "485ab183144913569ddc11154553ab4ac7db522ab188de74d652b717dc786d9c",
"lighting-temp-glow-dark": "ecaed039fb6aab4e1fdc9f1856c89e5f563219b8ff813877027737cecbeca41a",
"lighting-temp-glow-light": "5bc8a35aaa94c427d465d198f1cd5eecfdc704f5abeded9e407f32ce93aa7d32",
"lighting-custom-glow-dark": "899e334dfc0d3490ca291b7239bf7b88ca9d1ef3be199ddf347314bab1513f27",
"lighting-opaque-glow-two-doorways-dark": "413f5a8e39193ba941f72955a391ccac954c09f24322b7bf67494c7691275980",
"lighting-custom-glow-light": "266bba4ae1744a884b2cd224b37fdc36447d57405a1c5298ca30027e2957cc8a",
"lighting-temp-glow-no-sources-dark": "5100c81543fc30a7934a6db6f9e67e2c4fa185df0a974be5911879e43c0d3fc9",
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
"hover-nested-room-dark": "6c09526ad885c4555063def5b43287b41124a81d90972c425084dba6e622d055",
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
"device-dialog-desktop-en": "d6fcc83aa1335df1041e2b1aa445b0019d1e3567f98ef8f47a889894051f2b62",
"device-dialog-mobile-ru": "8cb928853ddacb61882804c3d00ead31da31bc4559ee6a8e293ef6b55cd5a463",
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
"decor-color-popover-mobile-ru": "46d4c2e4dd20c3a38e90efe3db59b3e878bdbcf273fbc1aa23de4b230723fa6e",
"backup-full-preview-desktop-en": "cc42a621f55f043b272014b9c32127823ca3573520e502966c71642027f7fdaf",
"backup-space-preview-mobile-ru": "16d06859ea6aac6cbed586f0d6918da972e9c95206fffd771fe43b6084f79877"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

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