Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb181ba5ee | ||
|
|
6d31553a74 | ||
|
|
d46abe51fc | ||
|
|
b74731698f | ||
|
|
e943e2f1b2 | ||
|
|
9410be6028 | ||
|
|
d2e7626b2c | ||
|
|
e023adbbe5 | ||
|
|
dcb752db73 | ||
|
|
a44b664311 | ||
|
|
7105e645d9 | ||
|
|
f82141cd23 | ||
|
|
482afb73eb | ||
|
|
add9dfe3a4 | ||
|
|
b76f89a282 | ||
|
|
ddc141b1be | ||
|
|
0ac63b6296 | ||
|
|
c397b78e87 | ||
|
|
4dbdb446f8 | ||
|
|
51fde854e3 | ||
|
|
4a798e3e13 | ||
|
|
8442538b6b | ||
|
|
d5725c2fab | ||
|
|
052e19715e | ||
|
|
30ea73b0aa | ||
|
|
68cfbdff69 | ||
|
|
0d91c1e18e | ||
|
|
a4e8dd7ba7 | ||
|
|
e8b3c861b2 | ||
|
|
da597da274 | ||
|
|
4c065f5ec4 | ||
|
|
1af0cc1711 | ||
|
|
18cd0c8b01 | ||
|
|
ace6e62f89 | ||
|
|
2652bb360d | ||
|
|
7c6e631560 | ||
|
|
cb19b7d52e | ||
|
|
06c7934667 | ||
|
|
5582e9a35d | ||
|
|
636d0a56fa | ||
|
|
9e71334cb4 | ||
|
|
142f7ec121 | ||
|
|
1391aeacfe | ||
|
|
3aba493b25 | ||
|
|
6bd197ffd7 | ||
|
|
55d5a560ad | ||
|
|
691cea074b | ||
|
|
8fc3ec783b | ||
|
|
2f968996b1 | ||
|
|
9a131cca25 | ||
|
|
e44b3c97b0 | ||
|
|
66ffd6fdda | ||
|
|
81f38689bb | ||
|
|
bff47f522b | ||
|
|
abfaae3e38 | ||
|
|
969847a4c4 | ||
|
|
bc8c368db9 | ||
|
|
0312104527 | ||
|
|
77fa698a27 | ||
|
|
7b0747888c | ||
|
|
dc9ced2fb4 | ||
|
|
61c874953a | ||
|
|
09b5d394a5 | ||
|
|
e4b4f33c6d | ||
|
|
5fd9716906 | ||
|
|
1fb3a5754e | ||
|
|
0df5db8b1c | ||
|
|
c8e9597228 | ||
|
|
67505f8343 | ||
|
|
b2024c6626 | ||
|
|
b7a9cc4edf | ||
|
|
f4098fcf09 | ||
|
|
05b2c74442 | ||
|
|
1e2eba95b1 | ||
|
|
6836561340 | ||
|
|
6811cc0908 | ||
|
|
d3468c1fde | ||
|
|
579d6e5f4e | ||
|
|
edf1cca8e3 | ||
|
|
115a7d4ccd | ||
|
|
44a35fde0f | ||
|
|
6872dc420d | ||
|
|
2396044bae | ||
|
|
619516806f | ||
|
|
fab0c38ca9 | ||
|
|
fe2fcef735 | ||
|
|
ed13b4a5ca | ||
|
|
10d5b5a288 | ||
|
|
50a00e41a1 | ||
|
|
e6be43b90f | ||
|
|
483c29cc39 | ||
|
|
1ecd267138 | ||
|
|
5c560df1a2 | ||
|
|
ecc3d6a88b | ||
|
|
5b8b6899da | ||
|
|
81210f7afc | ||
|
|
fd43a25746 | ||
|
|
33470306d2 | ||
|
|
82bb03cdf9 | ||
|
|
a8aeecc32c | ||
|
|
90a00670a7 | ||
|
|
8369c0e839 | ||
|
|
07f1b8c674 | ||
|
|
afd5d589d5 | ||
|
|
99c6cd09f3 | ||
|
|
6176f02430 | ||
|
|
01d7554607 | ||
|
|
30bf18f508 | ||
|
|
9848f4a0cb | ||
|
|
5ba46dfd97 | ||
|
|
48308209c2 | ||
|
|
e57e1d920c | ||
|
|
c3278ddd07 | ||
|
|
0fd2331d4a | ||
|
|
7763af6b8e | ||
|
|
d486c64576 | ||
|
|
603bb3647f | ||
|
|
fb6d7ea693 | ||
|
|
73eb92257e | ||
|
|
62f398d359 | ||
|
|
38e6869c90 | ||
|
|
86778ed452 | ||
|
|
812e5ff391 | ||
|
|
810ad01ce9 | ||
|
|
2935c293e1 | ||
|
|
cb1e4cea64 | ||
|
|
4a84734b18 | ||
|
|
a0716dc404 | ||
|
|
442731e8eb | ||
|
|
723ebb8f61 | ||
|
|
8e0358e7dd | ||
|
|
0cb38e5562 | ||
|
|
ea50548b70 | ||
|
|
5f000cb6fb | ||
|
|
be0277f5f5 | ||
|
|
a20dd54ba6 | ||
|
|
3af04840a9 | ||
|
|
d68e958c0d | ||
|
|
e0b3c4715d | ||
|
|
4aa9d1b2bb | ||
|
|
d55e0dd298 | ||
|
|
ce400859f3 | ||
|
|
bc75e00c2d | ||
|
|
84fa434a64 | ||
|
|
16c8a28aaf | ||
|
|
6786c48a0e | ||
|
|
28d6b9ccd9 | ||
|
|
b56e122b07 | ||
|
|
3d11758e2b | ||
|
|
4c512fa59f | ||
|
|
72275de567 | ||
|
|
f9accc1198 | ||
|
|
c6ff34cea3 | ||
|
|
9e8393f874 | ||
|
|
39456dc44c | ||
|
|
c547586dc9 | ||
|
|
fe33deaa13 | ||
|
|
8ba840aa74 | ||
|
|
ea33edebf9 | ||
|
|
2b45086794 | ||
|
|
75ad20a1df | ||
|
|
cbaa7b0cb9 | ||
|
|
adc1d15b98 | ||
|
|
a14de0a122 | ||
|
|
0e5ee030fe | ||
|
|
c749d68a4d | ||
|
|
0c93be9e85 | ||
|
|
9f25f17c83 | ||
|
|
1521d3716a | ||
|
|
fbb426afc9 | ||
|
|
c5ba699cf1 | ||
|
|
b7bfc92d4e | ||
|
|
120d41317c | ||
|
|
5cae1fdf82 | ||
|
|
ec6f77b014 | ||
|
|
efcdb269e8 | ||
|
|
9f5ebec6f2 | ||
|
|
3490faae0d | ||
|
|
6063eead10 | ||
|
|
b89456ca81 | ||
|
|
6c3d376b2b | ||
|
|
c08d5a88ae | ||
|
|
0bd6094ca8 | ||
|
|
d197381894 | ||
|
|
4d1b62b57c | ||
|
|
303f710e03 | ||
|
|
270cf634e6 | ||
|
|
4e82976b4a | ||
|
|
6feb0189ee | ||
|
|
ce304646c8 | ||
|
|
4fc0f7bd32 | ||
|
|
93200eb56a | ||
|
|
aa97e7e0bc | ||
|
|
72eae1059c | ||
|
|
11abc0292f | ||
|
|
c447e41d4d | ||
|
|
c22b39e8fc | ||
|
|
842f9dc30e | ||
|
|
9daa2e91fd | ||
|
|
6fdb7dce1a | ||
|
|
7af6d742b9 | ||
|
|
040db9ad12 | ||
|
|
48bcdafab9 | ||
|
|
6731691ad8 | ||
|
|
2310e6d88a | ||
|
|
424e613c6f | ||
|
|
517a7101e7 | ||
|
|
a84338b042 | ||
|
|
2d5fec09d6 | ||
|
|
5ce3ceeca7 | ||
|
|
ef22b236f8 | ||
|
|
a797752c89 | ||
|
|
833e8e5472 | ||
|
|
9b05dd598d | ||
|
|
cd17a0b00f | ||
|
|
3a68efa62f | ||
|
|
2dd1731cc4 | ||
|
|
e158f8fdcc | ||
|
|
edf532e217 | ||
|
|
56e01148f8 | ||
|
|
d31ad3c562 | ||
|
|
5c09591ce9 | ||
|
|
5dc9016645 | ||
|
|
6f89002e3a | ||
|
|
4c8ba981e7 | ||
|
|
15dc8adc4f | ||
|
|
ad8e7a50cc | ||
|
|
f287bddd97 | ||
|
|
5ff80f3bdc | ||
|
|
e6016a966f | ||
|
|
142bd611b1 | ||
|
|
1290927f10 | ||
|
|
84e62dcd0f | ||
|
|
f7b811a621 | ||
|
|
950403debd | ||
|
|
009fed9bc0 | ||
|
|
10c0f3c95a | ||
|
|
f7abf14abd | ||
|
|
8b8b9ed90d | ||
|
|
7b759f316b | ||
|
|
b583d663e3 | ||
|
|
88a28775e7 | ||
|
|
cd029a0415 | ||
|
|
bf83246b7b | ||
|
|
4089c912c6 | ||
|
|
0b411dd802 | ||
|
|
07b0b3dec2 | ||
|
|
2ef32417bd | ||
|
|
016c75f539 | ||
|
|
71d369a295 | ||
|
|
f66e671b89 | ||
|
|
41e2cfffc6 | ||
|
|
a0c4c1cdd8 | ||
|
|
edfea67ddd | ||
|
|
57ba75b9da | ||
|
|
804b282f5f | ||
|
|
a356ec29ab | ||
|
|
67bf85e7d2 | ||
|
|
2453ec0d7f | ||
|
|
fb265282cc | ||
|
|
c9a83af50e | ||
|
|
f08c4adabe | ||
|
|
6e93aa705c | ||
|
|
dd2e0e9b08 | ||
|
|
d01d0926be | ||
|
|
74d19d4d14 | ||
|
|
c1dde9a0cf | ||
|
|
56f31dc199 | ||
|
|
db57180956 | ||
|
|
bd9409b33d | ||
|
|
b203e8faeb | ||
|
|
63249ffd97 | ||
|
|
327c35f606 | ||
|
|
fe7b28f3a7 | ||
|
|
19e92e0cc0 | ||
|
|
9bde4b1a6d | ||
|
|
1bf90ee0d8 | ||
|
|
fcee724638 | ||
|
|
f69ac71ef7 | ||
|
|
3540d24f18 | ||
|
|
25ea8fefab | ||
|
|
9ec3636a42 | ||
|
|
07d0c2ef86 | ||
|
|
053007414d | ||
|
|
fc22d9a6c5 | ||
|
|
fbbaed22de | ||
|
|
29ce5d9e65 | ||
|
|
3a8aae06d1 | ||
|
|
a75c729d87 | ||
|
|
a55ba3de8b | ||
|
|
2f0dc44f27 | ||
|
|
1c3404fdd0 | ||
|
|
3e335b8808 | ||
|
|
e2bb90b59b | ||
|
|
a80fa1fa5e | ||
|
|
0e53b1b0d6 | ||
|
|
fa015907d4 | ||
|
|
5d04e9b7c2 | ||
|
|
89789d8fa6 | ||
|
|
6846ffb828 | ||
|
|
c8755b7c51 | ||
|
|
fe15d863ce | ||
|
|
299da593d5 | ||
|
|
32e3a79dcd | ||
|
|
ffb10843c9 | ||
|
|
5fb510290a | ||
|
|
932773773f | ||
|
|
6c7958c6d0 | ||
|
|
a449edc545 | ||
|
|
e88c23b8ee | ||
|
|
acad3b32c1 | ||
|
|
b443a333e0 | ||
|
|
66fa8f476c | ||
|
|
047363c2d3 | ||
|
|
1e8503bd46 | ||
|
|
135497b272 | ||
|
|
0c2a5dedea | ||
|
|
c1676cf26a | ||
|
|
c65cbcc96c | ||
|
|
01fe48de00 | ||
|
|
9baf533c90 | ||
|
|
563a850aac | ||
|
|
9e5ff0b8a0 | ||
|
|
3fe0f8c443 | ||
|
|
742b3279a2 | ||
|
|
9f77e3e932 | ||
|
|
083621342a | ||
|
|
4f20befd77 | ||
|
|
2aaabc48d6 | ||
|
|
b9bf210804 | ||
|
|
d6007dc444 | ||
|
|
54c5ca3840 | ||
|
|
57fc434d4f | ||
|
|
9530ee2e5a | ||
|
|
58ba15ac89 | ||
|
|
f46be0e0e2 | ||
|
|
8086399aa6 | ||
|
|
034eb3f5f3 | ||
|
|
76f75f85aa | ||
|
|
7f1655c7f5 | ||
|
|
e46ef6f55c | ||
|
|
63eea47ac6 | ||
|
|
f54b9c0ccd | ||
|
|
aac2978359 | ||
|
|
fc1e7951e4 | ||
|
|
e206e8761c | ||
|
|
8a25b5224f | ||
|
|
1f11f8f410 | ||
|
|
f4b1a4766f | ||
|
|
4fda569f74 | ||
|
|
393ec62c61 | ||
|
|
dae2efc280 | ||
|
|
e727023bf4 | ||
|
|
91f2c23539 | ||
|
|
690f57aaba | ||
|
|
c4ba218ee8 | ||
|
|
df093c7e51 | ||
|
|
a9d999e2f2 | ||
|
|
6ca9aacebc | ||
|
|
ae7fb621e7 | ||
|
|
ba32234b52 | ||
|
|
4d71f57b4f | ||
|
|
0ee69ccb20 | ||
|
|
6cbf9fbdf0 | ||
|
|
fd0b52f3a9 | ||
|
|
2667f71bd6 | ||
|
|
c27185cfa4 | ||
|
|
382afd2766 | ||
|
|
a05aa5dc06 | ||
|
|
93a29cd79e | ||
|
|
260a994f5f | ||
|
|
c9a00b2a37 | ||
|
|
4a8f44210f | ||
|
|
91c8b01d7d | ||
|
|
bc478b1756 | ||
|
|
135a18a3ca | ||
|
|
c23b2d7a90 | ||
|
|
61978d4f1e | ||
|
|
141c657813 | ||
|
|
0040615a45 | ||
|
|
ff9f20f52e | ||
|
|
a1b8861eff | ||
|
|
2e47473619 | ||
|
|
b369eb5dcd | ||
|
|
7f397a6875 | ||
|
|
bf2937c1ed | ||
|
|
8c6ac30300 | ||
|
|
6df4722438 | ||
|
|
bdae05dafc | ||
|
|
4ed86b38bc | ||
|
|
9c0e9fab68 | ||
|
|
154c662a0b | ||
|
|
6beb40435f | ||
|
|
e8a5771a63 | ||
|
|
f7c8609ac5 | ||
|
|
8ce7d1f0bb | ||
|
|
d8e3b82da6 | ||
|
|
18f5155bbf | ||
|
|
b94b1b93cd | ||
|
|
82cf3ad2db | ||
|
|
0253c4765c | ||
|
|
88a647f6b2 | ||
|
|
26303adfec | ||
|
|
053e2a9b68 | ||
|
|
91f460e80c | ||
|
|
32518c6284 | ||
|
|
9f2c5f5ff4 | ||
|
|
20d7883699 | ||
|
|
6ebf12af1e | ||
|
|
09143e23a6 | ||
|
|
0e6cb7570b | ||
|
|
321d153c22 | ||
|
|
7f70b64f48 | ||
|
|
6c37cd5f05 | ||
|
|
7642c484d2 | ||
|
|
ec7408f3d5 | ||
|
|
0f8d35f516 | ||
|
|
295257240d | ||
|
|
f2fcf0d594 | ||
|
|
3e4e549b56 | ||
|
|
875b09cd8d | ||
|
|
84cd5f9331 | ||
|
|
debb13baa2 | ||
|
|
ab2a014568 | ||
|
|
558dae95cd | ||
|
|
1937c32572 | ||
|
|
5ed2821161 | ||
|
|
f66cf8ad4d | ||
|
|
d66cd2ebab | ||
|
|
f0e7700805 | ||
|
|
aad625a84d | ||
|
|
b57ea94cb8 | ||
|
|
737e7b62aa | ||
|
|
f11a4e1085 | ||
|
|
548677a99c | ||
|
|
087f7cf381 | ||
|
|
b860ef4c43 | ||
|
|
3b0b9eea50 | ||
|
|
0cf10613f2 | ||
|
|
e894ce2986 | ||
|
|
ac30f8913d | ||
|
|
de46db3343 | ||
|
|
782ff54e0f | ||
|
|
b989c84b71 | ||
|
|
400ca7043e | ||
|
|
9f5d729538 | ||
|
|
8eb4bab7c6 | ||
|
|
6c48c6d5c6 | ||
|
|
5bebc26aeb | ||
|
|
f5c36da648 | ||
|
|
e9a148315a | ||
|
|
fb4096f67b | ||
|
|
e83da25085 | ||
|
|
ae10b2861b | ||
|
|
eef3634f23 | ||
|
|
6ecbedfb85 | ||
|
|
e6366b6548 | ||
|
|
159094cfec | ||
|
|
188a386cd8 | ||
|
|
5df8b723e7 | ||
|
|
de0171dd02 | ||
|
|
f1e6cca3db | ||
|
|
1079cdfab2 | ||
|
|
b7b28ee579 | ||
|
|
af851cda85 | ||
|
|
0af095a6c4 | ||
|
|
9ad2b3b4ef | ||
|
|
fea0d55c67 | ||
|
|
bc98116a31 | ||
|
|
328ed7afc0 | ||
|
|
0e69c4a183 | ||
|
|
ef3cc98d1c | ||
|
|
e13215c02f | ||
|
|
42b3f44c4a | ||
|
|
4c73e2ccdb | ||
|
|
76ce755742 | ||
|
|
50099acc75 | ||
|
|
a282f850af | ||
|
|
d7f3bb8119 | ||
|
|
5c6ab8ea9b | ||
|
|
fda4893f0c | ||
|
|
888e90450a | ||
|
|
b2263a6551 | ||
|
|
565f518dcd | ||
|
|
02e0c9801d | ||
|
|
e93c405b13 | ||
|
|
955de3e69c | ||
|
|
7af4146614 | ||
|
|
a43602934c | ||
|
|
516257e322 | ||
|
|
9177c9a944 | ||
|
|
8a3f6efa0a | ||
|
|
be7d6b9706 | ||
|
|
2fd042a7de | ||
|
|
d7e2c4d4f0 | ||
|
|
d1be6891b2 | ||
|
|
9be81c1413 | ||
|
|
fafeca4540 | ||
|
|
3ade633538 | ||
|
|
68596a75a0 | ||
|
|
65a86db122 | ||
|
|
a29df12e0b | ||
|
|
9146b4c357 | ||
|
|
9ad01be813 |
@@ -58,7 +58,7 @@ while read -r local_ref local_sha remote_ref remote_sha; do
|
||||
|
||||
echo "process-gate: $local_ref, диапазон ${base}..${local_sha}" >&2
|
||||
# shellcheck disable=SC2086
|
||||
if ! node "$gate" --range "${base}..${local_sha}" $issues_flag >&2; then
|
||||
if ! node "$gate" --range "${base}..${local_sha}" --target-ref "$remote_ref" $issues_flag >&2; then
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check out release notes for a reusable call
|
||||
if: ${{ inputs.reusable == true }}
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- name: Send to Telegram
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Mutation gate
|
||||
|
||||
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
|
||||
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
|
||||
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
|
||||
# защитой, и это хуже его отсутствия.
|
||||
#
|
||||
# Прогон дорогой: пересборка бандла на каждого мутанта. Поэтому он не входит в
|
||||
# Validate и не идёт на каждый push. Его место — перед стабильным релизом
|
||||
# (PROCESS.md §8) и раз в неделю по расписанию, чтобы дрейф тестов не копился
|
||||
# до релиза. Дешёвая половина — «якоря патчей живы, guard-файлы существуют» —
|
||||
# идёт с обычными юнитами: test/mutation-gate.test.mjs.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Git ref whose mutation guards must be proved
|
||||
required: false
|
||||
default: dev
|
||||
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
|
||||
# Все мутанты × (сборка + свой guard) — это десятки минут, и это нормально:
|
||||
# гейт предрелизный. Час — потолок против зависшего Chromium.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Установить backend test dependencies
|
||||
run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
|
||||
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
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
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Check out candidate
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: candidate
|
||||
fetch-depth: 2
|
||||
@@ -116,12 +116,12 @@ jobs:
|
||||
echo "Comparison base: $sha ($source)" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Check out base SHA
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ steps.base.outputs.sha }}
|
||||
path: baseline
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -132,9 +132,17 @@ jobs:
|
||||
- name: Install candidate and baseline dependencies
|
||||
run: npm ci --prefix candidate && npm ci --prefix baseline
|
||||
|
||||
# То же, что в validate.yml: кэш браузеров, apt не трогаем (#206).
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('candidate/package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
working-directory: candidate
|
||||
run: npx playwright install --with-deps chromium
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Build both exact source trees
|
||||
run: |
|
||||
@@ -150,6 +158,8 @@ jobs:
|
||||
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
|
||||
@@ -164,12 +174,13 @@ jobs:
|
||||
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
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: full-performance
|
||||
path: artifacts/performance
|
||||
|
||||
@@ -39,6 +39,7 @@ jobs:
|
||||
outputs:
|
||||
stage: ${{ steps.decide.outputs.stage }}
|
||||
cycle: ${{ steps.decide.outputs.cycle }}
|
||||
spent: ${{ steps.decide.outputs.spent }}
|
||||
limit: ${{ steps.decide.outputs.limit }}
|
||||
steps:
|
||||
- id: decide
|
||||
@@ -48,6 +49,7 @@ jobs:
|
||||
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: |
|
||||
# Этап определяется первым: от него зависит, какие вердикты считать.
|
||||
@@ -58,22 +60,45 @@ jobs:
|
||||
*) echo "метка $LABEL конвейер не запускает" ;;
|
||||
esac
|
||||
|
||||
# Лимит циклов: 4 обычный, 2 на лёгком треке (PROCESS.md §4).
|
||||
limit=4; [ "$SMALL" = "true" ] && limit=2
|
||||
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
|
||||
limit=4
|
||||
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
|
||||
|
||||
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
|
||||
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89
|
||||
# первое код-ревью получило r2/4. На задаче с двумя циклами ТЗ второе
|
||||
# код-ревью упиралось бы в review-4 после одной правки.
|
||||
# Считаются ДВЕ РАЗНЫЕ величины, и это не педантизм (#227).
|
||||
#
|
||||
# Этап опознаётся по имени документа в теле комментария. Если документа
|
||||
# нет, вердикт не посчитается — недосчёт даёт лишний цикл, а перерасчёт
|
||||
# остановил бы работу досрочно; из двух ошибок выбрана обратимая.
|
||||
done_cycles=0
|
||||
# `attempt` — сколько раз ревью уже отработало на этом этапе. Он нужен
|
||||
# только для имени документа и метки: два захода с одинаковым номером
|
||||
# означают, что второй документ перезапишет первый и артефакт ревью
|
||||
# исчезнет.
|
||||
#
|
||||
# `spent` — сколько циклов израсходовано из бюджета §4. Цикл — это
|
||||
# «отправка на ревью → вердикт с блокирующими находками → возврат
|
||||
# автору», поэтому бюджет тратят ТОЛЬКО жёлтые и красные вердикты.
|
||||
# Зелёный ничего на правки не вернул и цикла не образует.
|
||||
#
|
||||
# Раньше обе роли исполнял один счётчик всех вердиктов, и конвейер
|
||||
# наказывал за то, что предписывал сам: при неудавшемся слиянии он
|
||||
# велит вернуть S7-code-review после ребейза, и этот заход добивал
|
||||
# бюджет. На #225 (лёгкий трек, лимит 2) последовательность
|
||||
# жёлтый → зелёный → ребейз дала review-4 на задаче с зелёным ревью и
|
||||
# зелёным CI: работа встала, хотя после вердикта не было ни одной
|
||||
# правки продуктового кода.
|
||||
#
|
||||
# Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал
|
||||
# цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по
|
||||
# имени документа в теле комментария; документа нет — вердикт не
|
||||
# посчитается. Недосчёт даёт лишний заход, перерасчёт остановил бы
|
||||
# работу досрочно: из двух ошибок выбрана обратимая.
|
||||
attempt=1; spent=0; spent_list=""
|
||||
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")
|
||||
comments=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json comments)
|
||||
of_stage="[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))]"
|
||||
# Блокирующим считается вердикт, у которого в строке вердикта стоит
|
||||
# «жёлтый» или «красный». Регистр и окружение слова не важны.
|
||||
blocking="$of_stage | map(select(.body | test(\"Вердикт:[^\\n]*(жёлт|красн)\"; \"i\")))"
|
||||
attempt=$(( $(printf '%s' "$comments" | jq -r "$of_stage | length") + 1 ))
|
||||
spent=$(printf '%s' "$comments" | jq -r "$blocking | length")
|
||||
spent_list=$(printf '%s' "$comments" | jq -r "$blocking | map(\"- \" + .url) | join(\"\\n\")")
|
||||
fi
|
||||
|
||||
# Отказ обязан быть виден в issue, а не только в логе прогона.
|
||||
@@ -107,19 +132,33 @@ jobs:
|
||||
refuse "стоит blocked — конвейер не запускается" \
|
||||
"на issue стоит \`blocked\` — задача ждёт внешнего решения. Снять метку, когда решение принято."
|
||||
elif [ "$EXHAUSTED" = "true" ]; then
|
||||
# Метку снимает владелец, а не конвейер: автоматика, отменяющая
|
||||
# остановку работы, дороже ручного снятия. Но пересчёт печатается —
|
||||
# метка могла остаться от прежнего правила, когда бюджет тратил и
|
||||
# зелёный вердикт (#227).
|
||||
stale=""
|
||||
if [ "$spent" -lt "$limit" ]; then
|
||||
stale=" Пересчёт по действующему правилу: блокирующих циклов $spent из $limit — метка могла остаться от прежнего правила, когда бюджет тратил любой вердикт. Снять её может владелец."
|
||||
fi
|
||||
refuse "стоит review-4 — решение за владельцем" \
|
||||
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
|
||||
elif [ "$done_cycles" -ge "$limit" ]; then
|
||||
echo "циклов этапа $stage пройдено $done_cycles из $limit — лимит исчерпан"
|
||||
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4).$stale"
|
||||
elif [ "$spent" -ge "$limit" ]; then
|
||||
echo "блокирующих циклов этапа $stage: $spent из $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)."
|
||||
"Лимит циклов ревью исчерпан: блокирующих циклов $spent из $limit на этапе \`$stage\` (заход $attempt). Следующего захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4).
|
||||
|
||||
Учтены вердикты с блокирующими находками — зелёные бюджет не тратят:
|
||||
$spent_list"
|
||||
stage=""
|
||||
else
|
||||
echo "этап $stage, цикл $((done_cycles + 1)) из $limit"
|
||||
echo "этап $stage, заход $attempt, блокирующих циклов $spent из $limit"
|
||||
fi
|
||||
echo "stage=$stage" >> "$GITHUB_OUTPUT"
|
||||
echo "cycle=$((done_cycles + 1))" >> "$GITHUB_OUTPUT"
|
||||
echo "cycle=$attempt" >> "$GITHUB_OUTPUT"
|
||||
echo "spent=$spent" >> "$GITHUB_OUTPUT"
|
||||
echo "limit=$limit" >> "$GITHUB_OUTPUT"
|
||||
|
||||
review:
|
||||
@@ -129,13 +168,40 @@ jobs:
|
||||
# Время — единственный настоящий ограничитель зациклившегося прогона.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
# Иначе в конфиге git остаётся креденшел GITHUB_TOKEN, и push с
|
||||
# мёртвым PAT молча уходит от github-actions[bot] — 403 при
|
||||
# contents: read. Отказ обязан быть громким и правильным.
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
# Живость PAT проверяется ДО ревью. На #150 истёкший токен обнаружился
|
||||
# только на публикации документа — после сорока минут работы ревьюера.
|
||||
- name: Секрет HP_PROCESS_TOKEN жив
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "::error::HP_PROCESS_TOKEN пуст — секрет удалён или недоступен"
|
||||
exit 1
|
||||
fi
|
||||
if ! login=$(gh api user -q .login 2>/dev/null); then
|
||||
echo "::error::HP_PROCESS_TOKEN не аутентифицируется — истёк или отозван. Обновить: Settings -> Secrets and variables -> Actions -> HP_PROCESS_TOKEN"
|
||||
exit 1
|
||||
fi
|
||||
echo "токен жив, действует от: $login"
|
||||
|
||||
# Окружение готовит workflow, а не модель своими ходами. Раньше промпт
|
||||
# велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без
|
||||
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
|
||||
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
|
||||
# тяжёлых job, здесь его не было.
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
|
||||
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
|
||||
@@ -145,8 +211,15 @@ jobs:
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
branch=$(git ls-remote --heads origin "issue/${NUM}-*" \
|
||||
| head -1 | sed 's|.*refs/heads/||')
|
||||
# Свежая по последнему коммиту, а не первая по алфавиту: на #150 рядом
|
||||
# жили ветка ТЗ и ветка реализации, и head -1 выбрал устаревшую.
|
||||
git fetch -q origin "+refs/heads/issue/${NUM}-*:refs/remotes/origin/issue/${NUM}-*" || true
|
||||
branches=$(git for-each-ref --sort=-committerdate \
|
||||
--format='%(refname:lstrip=3)' "refs/remotes/origin/issue/${NUM}-*")
|
||||
branch=$(printf '%s\n' "$branches" | head -1)
|
||||
if [ "$(printf '%s\n' "$branches" | grep -c .)" -gt 1 ]; then
|
||||
echo "::warning::веток issue/${NUM}-* несколько ($(echo $branches | tr '\n' ' ')) — выбрана свежая по коммиту: $branch. Устаревшую следует удалить."
|
||||
fi
|
||||
if [ -n "$branch" ]; then
|
||||
git checkout -q "origin/$branch"
|
||||
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
|
||||
@@ -156,9 +229,36 @@ jobs:
|
||||
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
|
||||
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
|
||||
- name: Установить зависимости
|
||||
run: npm ci
|
||||
|
||||
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
|
||||
# но когда нужен — качать его заново дороже, чем держать в кэше.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
# Без --with-deps: системные библиотеки Chromium предустановлены в
|
||||
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
|
||||
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
|
||||
# библиотека когда-нибудь пропадёт из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
env:
|
||||
# Вне рабочей копии: восстановление дерева ревьюером не должно
|
||||
# уничтожать его собственный артефакт (#220).
|
||||
REVIEW_DOC: ${{ runner.temp }}/review-document.md
|
||||
with:
|
||||
# Подписка, а не отдельный счёт API: токен выпускается через
|
||||
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
|
||||
@@ -171,6 +271,42 @@ jobs:
|
||||
Этап: ${{ needs.guard.outputs.stage }}
|
||||
spec — ревью ТЗ (PROCESS.md §2.4)
|
||||
code — код-ревью (PROCESS.md §2.7)
|
||||
Заход: r${{ needs.guard.outputs.cycle }} · блокирующих циклов израсходовано ${{ needs.guard.outputs.spent }} из ${{ needs.guard.outputs.limit }}
|
||||
Бюджет §4 тратят только жёлтые и красные вердикты: зелёный
|
||||
ничего не вернул на правки и цикла не образует (#227).
|
||||
Номер захода нужен для имени документа — два документа с
|
||||
одинаковым номером затёрли бы друг друга.
|
||||
|
||||
**Если цикл не первый — объём разбора по дельте, а не заново**
|
||||
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
|
||||
всех раундов, и повторный цикл заново выводил продуктовую рамку и
|
||||
перепроверял AC, которых правка не касалась: r2 по #150 стоил
|
||||
полного прогона ради одной строки в тестовой фикстуре.
|
||||
|
||||
Порядок для r2 и дальше:
|
||||
1. найди вердикт предыдущего раунда в комментариях issue и SHA,
|
||||
на котором он получен. SHA в вердикте не назван — это находка;
|
||||
2. объяви дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла
|
||||
ТЗ или тела issue для spec. Дельта — предмет этого раунда;
|
||||
3. по каждой находке предыдущего раунда покажи, чем именно она
|
||||
закрыта: строка кода или текста, а не заявление автора;
|
||||
4. заново проверяй только те AC, чьё доказательство дельта
|
||||
задевает. Остальные наследуй;
|
||||
5. в документе обязателен раздел «Унаследовано из r<N-1>»: что
|
||||
принято без повторной проверки, со ссылкой на документ того
|
||||
раунда и SHA, на котором вывод получен. Без этого перечня
|
||||
сокращение — молчаливое доверие, а такой тихий успех уже
|
||||
дважды стоил дня (#171, #207).
|
||||
|
||||
Разбор остаётся ПОЛНЫМ, если дельта не локальна: ребейз на ушедший
|
||||
вперёд dev (после ребейза это другой код, §7.2), смена контракта
|
||||
поведения, задета новая подсистема, либо объём дельты сопоставим с
|
||||
исходной задачей. Сомневаешься — разбирай полностью и скажи почему.
|
||||
|
||||
Сокращается объём РАЗБОРА, а не строгость: правка по замечанию
|
||||
способна сломать AC, который предыдущий раунд признал выполненным —
|
||||
так появилась регрессия #102. Поэтому граница не «только находки», а
|
||||
«находки плюс всё, до чего дотягивается дельта».
|
||||
|
||||
Прочитай в этом порядке, прежде чем судить:
|
||||
1. docs/SCOPE.md — зачем продукт существует и для кого. Он
|
||||
@@ -205,34 +341,90 @@ jobs:
|
||||
По каждому AC: либо он доказан автотестом и ты убедился, что тест
|
||||
умеет падать, либо разобран по коду с явной записью «проверено
|
||||
чтением, не исполнением». «Verified» без названной команды и её
|
||||
результата доказательством не является. Зависимостей в рабочей
|
||||
копии нет: перед гейтами выполни `npm ci`. Проверь трейлеры Issue и
|
||||
User-Visible, при User-Visible: yes — правки в оба changelog в том же
|
||||
коммите.
|
||||
результата доказательством не является. Зависимости уже установлены
|
||||
workflow, Chromium тоже — `npm ci` выполнять не нужно. Проверь
|
||||
трейлеры Issue и User-Visible, при User-Visible: yes — правки в оба
|
||||
changelog в том же коммите.
|
||||
|
||||
**Объём гейтов соразмерен задаче.** Прогонять весь набор на каждой
|
||||
правке — не тщательность, а потеря времени: полные наборы это
|
||||
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
|
||||
|
||||
Всегда, они дешёвые, и в повторном раунде тоже: код изменился,
|
||||
а стоят они минуты:
|
||||
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
|
||||
копий бандла. Плюс `node scripts/check-docs.mjs`, если diff трогает
|
||||
`src/**`: отпечаток скриншотов документации считается по всему
|
||||
`src/**`, поэтому любая правка фронтенда делает его устаревшим —
|
||||
выбирать тут нечего. Пропуск этого шага в #230 и #234 оставил `dev`
|
||||
с красным job `docs` до следующей задачи (#237).
|
||||
|
||||
По необходимости, и «необходимость» определяется diff'ом и AC:
|
||||
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс
|
||||
относящиеся к тронутым поверхностям. Их 163; прогон всех уместен
|
||||
только когда задача действительно задевает всё. Выбирать по теме
|
||||
недостаточно: регресс #234 поймал `smoke_wall_junctions`, который
|
||||
по названию про стыки стен, а не про толщину отрезка. Надёжнее
|
||||
grep — искать в `demo/smoke_*.mjs` имена изменённых функций и
|
||||
полей, и прогонять найденное;
|
||||
- `npm run golden:verify` — если diff может изменить видимый
|
||||
результат: рендер, геометрия, стили, слои;
|
||||
- `python -m pytest tests_backend -q` — если тронут
|
||||
`custom_components/**/*.py`;
|
||||
- performance-профили — если названы в AC либо тронуты
|
||||
чувствительные к перфу пути.
|
||||
|
||||
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
|
||||
тем тестам, которые ты прогонял.
|
||||
|
||||
**В комментарии обязателен перечень: какие гейты прогнал, какие нет и
|
||||
почему.** Это условие честности такого сужения: непрогнанный гейт
|
||||
становится видимым решением, а не молчаливым пропуском. Раздел «чего
|
||||
не проверял» в документе ревью — не формальность, а главный его
|
||||
раздел на коротких задачах.
|
||||
|
||||
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
|
||||
|
||||
Серьёзность: High блокирует; Medium обязан стать отдельным issue;
|
||||
Low либо правится, либо снимается с записью. Жёлтый вердикт
|
||||
допустим при полностью выполненных AC, если изменение не решает
|
||||
заявленный сценарий или ухудшает смежный. Продуктовое рассуждение
|
||||
расширяет вопросы, но не отменяет AC и не даёт права менять скоуп.
|
||||
Серьёзность: High блокирует; Medium В СКОУПЕ задачи чинится в ней
|
||||
же — без High это жёлтый вердикт и возврат автору, отдельный issue
|
||||
НЕ заводится (решение владельца 2026-08-19, #202: заведение и
|
||||
обслуживание issue дороже правки на месте); Low либо правится,
|
||||
либо снимается с записью. Жёлтый вердикт допустим и при полностью
|
||||
выполненных AC, если изменение не решает заявленный сценарий или
|
||||
ухудшает смежный. Продуктовое рассуждение расширяет вопросы, но не
|
||||
отменяет AC и не даёт права менять скоуп.
|
||||
|
||||
Каждую Medium-находку заведи отдельным issue со ссылкой на
|
||||
#${{ github.event.issue.number }} и метками: тип, приоритет,
|
||||
S1-new. «Оставили в тексте ревью» закрытием не считается и прямо
|
||||
запрещено §12.
|
||||
Только 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/ опубликован не будет.
|
||||
Напиши полный документ ревью в файл, путь которого лежит в
|
||||
переменной окружения REVIEW_DOC (абсолютный, ВНЕ репозитория).
|
||||
|
||||
Почему не в docs/reviews: документ там был некоммитнутым файлом того
|
||||
же дерева, которое ты мутируешь, проверяя «умеет ли тест падать». На
|
||||
#220 три раунда подряд документ исчезал — восстановление дерева
|
||||
(`git checkout -- .`, `git clean -fd`) сносит собственный артефакт
|
||||
ревью, потому что он untracked. В репозиторий его положит шаг
|
||||
публикации, взяв из REVIEW_DOC; тебе трогать docs/reviews не нужно.
|
||||
|
||||
В самом репозитории не создавай файлов вообще: любые изменения в
|
||||
рабочей копии будут отброшены. Имя документа в docs/reviews шаг
|
||||
публикации соберёт сам — SPEC-REVIEW для этапа spec, CODE-REVIEW для
|
||||
code, с номером issue и заходом.
|
||||
|
||||
Содержание документа: скоуп, как проверялось, находки с
|
||||
воспроизведением, что проверено и корректно, чего не проверял. Для
|
||||
r2 и дальше добавь два раздела: «Закрытие раунда r<N-1>» — таблица
|
||||
«находка | чем закрыта | где это видно», и «Унаследовано из r<N-1>» —
|
||||
что принято без повторной проверки, с документом и SHA.
|
||||
|
||||
Затем оставь в issue краткий комментарий: вердикт, ключевые находки
|
||||
и ссылка на документ. Первой строкой — вердикт в формате §7.2:
|
||||
`Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → #…`
|
||||
`Вердикт: зелёный/жёлтый/красный · заход r${{ needs.guard.outputs.cycle }} · блокирующих циклов ${{ needs.guard.outputs.spent }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…`
|
||||
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору жёлтым)
|
||||
|
||||
Затем верни JSON по схеме. Это последнее действие и оно обязательно:
|
||||
без него метка не переставится и конвейер встанет.
|
||||
@@ -248,15 +440,62 @@ jobs:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
SOURCE: ${{ runner.temp }}/review-document.md
|
||||
run: |
|
||||
# Ветки задачи может не быть: у задач, размеченных до появления
|
||||
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
|
||||
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
|
||||
# комментарием. Это тот же тихий отказ: шаг сообщал об успехе тем, что
|
||||
# ничего не сделал. Документ ложится туда же, где лежит само ТЗ.
|
||||
target="${BRANCH:-dev}"
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "ветки задачи нет — документ некуда класть"; exit 0
|
||||
echo "::warning::ветки задачи нет — документ ревью ляжет в dev"
|
||||
fi
|
||||
marker=CODE-REVIEW
|
||||
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
|
||||
doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
|
||||
# Рабочая копия отбрасывается ДО того, как документ попадёт в дерево:
|
||||
# ревьюер правит код, проверяя «умеет ли тест падать», и его правки
|
||||
# публиковаться не должны.
|
||||
git checkout -- . 2>/dev/null || true
|
||||
# docs/reviews исключён из уборки: ревьюер мог написать документ по
|
||||
# старому пути, и клин не должен его съесть до `git add` — ровно так
|
||||
# оба пути остаются работоспособными.
|
||||
git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true
|
||||
# Документ приезжает извне репозитория (#220). Три раунда подряд он
|
||||
# терялся, пока лежал некоммитнутым файлом в том же дереве, которое
|
||||
# ревьюер мутирует и затем восстанавливает: `git checkout -- .` плюс
|
||||
# `git clean -fd` сносят собственный артефакт ревью, потому что он
|
||||
# untracked. Теперь его место — RUNNER_TEMP, и уборка дерева ему не
|
||||
# страшна.
|
||||
if [ -f "$SOURCE" ]; then
|
||||
mkdir -p docs/reviews
|
||||
cp "$SOURCE" "$doc"
|
||||
echo "документ взят из $SOURCE ($(wc -c < "$doc") байт)"
|
||||
else
|
||||
# Совместимость: ревьюер мог написать по старому пути, если промпт
|
||||
# ещё не обновился в этой ветке.
|
||||
echo "::warning::$SOURCE не найден — ищу документ в рабочей копии"
|
||||
fi
|
||||
git add docs/reviews 2>/dev/null || true
|
||||
if git diff --cached --quiet; then
|
||||
echo "документ ревью не создан"; exit 0
|
||||
# Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит
|
||||
# документ сам, своим app-токеном мимо этого шага (CODE-REVIEW-150-r1,
|
||||
# коммиттер GitHub). Провал — когда файла нет и на ветке.
|
||||
git fetch -q origin "$target"
|
||||
if git cat-file -e "origin/$target:$doc" 2>/dev/null; then
|
||||
echo "документ уже опубликован ревьюером: $doc"
|
||||
exit 0
|
||||
fi
|
||||
# Ревью без артефакта запрещено (PROCESS.md §2.4/§10.4/§12). Раньше
|
||||
# здесь стоял warning с exit 0: на #150 оба вердикта ревью ТЗ
|
||||
# остались только комментариями, метки переставились, и пропажу
|
||||
# заметило лишь следующее ревью — issue #171. Падение ДО шага с
|
||||
# меткой сохраняет инвариант «метка не сменилась = прогон упал».
|
||||
echo "::error::вердикт есть, а документа нет: ни $SOURCE, ни $doc в рабочей копии, ни $doc в $target — ревью без артефакта (#171, #220)"
|
||||
exit 1
|
||||
fi
|
||||
git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
@@ -266,9 +505,32 @@ jobs:
|
||||
Issue: #$NUM
|
||||
User-Visible: no
|
||||
EOF
|
||||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$BRANCH"
|
||||
echo "документ опубликован в $BRANCH"
|
||||
# Публикация в 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
|
||||
# Тоже вердикт без артефакта: раньше exit 0 переставил бы метку.
|
||||
echo "::error::документ ревью не удалось опубликовать в $target: конфликт (#171)"
|
||||
exit 1
|
||||
fi
|
||||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$target"
|
||||
fi
|
||||
# Постусловие: до ветки дошёл именно ожидаемый файл. Коммит с
|
||||
# документом, названным не по формату, — тот же вердикт без
|
||||
# артефакта, только дороже в обнаружении.
|
||||
git fetch -q origin "$target"
|
||||
if ! git cat-file -e "origin/$target:$doc" 2>/dev/null; then
|
||||
echo "::error::коммит в $target опубликован, но ожидаемого $doc в нём нет — файл назван не по формату (#171)"
|
||||
exit 1
|
||||
fi
|
||||
echo "документ опубликован в $target: $doc"
|
||||
|
||||
- name: Решение по вердикту
|
||||
id: decide
|
||||
|
||||
@@ -24,11 +24,11 @@ jobs:
|
||||
sha: ${{ steps.candidate.outputs.sha }}
|
||||
tag: ${{ steps.candidate.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Pin the current dev candidate
|
||||
id: candidate
|
||||
@@ -67,11 +67,11 @@ jobs:
|
||||
url: ${{ steps.verify.outputs.url }}
|
||||
newly_published: ${{ steps.release.outputs.newly_published }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.gate.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Build and verify both release assets before publication
|
||||
env:
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
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
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
EXPECTED_TAG: ${{ needs.gate.outputs.tag }}
|
||||
with:
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
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
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ steps.tag.outputs.tag }}
|
||||
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
|
||||
|
||||
@@ -16,11 +16,11 @@ jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Require a green Validate for this exact commit
|
||||
env:
|
||||
@@ -47,10 +47,10 @@ jobs:
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- run: npm ci && npm run build
|
||||
- name: Verify compositor frame continuity for a stable release
|
||||
@@ -61,13 +61,13 @@ jobs:
|
||||
npm run continuity:screencast
|
||||
- name: Upload failed continuity frames
|
||||
if: ${{ failure() && !github.event.release.prerelease }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
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
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: dist/houseplan-card.js
|
||||
hacs-discovery:
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Verify the published tag is the prerelease HACS will discover
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
|
||||
@@ -15,12 +15,21 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Validate public documentation
|
||||
run: node scripts/check-docs.mjs --external
|
||||
|
||||
provenance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Validate commit trailers and hook mode
|
||||
env:
|
||||
@@ -28,8 +37,9 @@ jobs:
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
DEVELOPMENT_BRANCH: dev
|
||||
run: |
|
||||
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
|
||||
node scripts/validate-commit-provenance.mjs --check-hook-mode --github-range
|
||||
|
||||
# Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на
|
||||
@@ -40,9 +50,9 @@ jobs:
|
||||
process-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Process gate
|
||||
env:
|
||||
@@ -50,33 +60,181 @@ jobs:
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
DEVELOPMENT_BRANCH: dev
|
||||
TARGET_REF: ${{ github.ref }}
|
||||
# Публичный репозиторий: штатного токена хватает на чтение issue.
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
|
||||
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@v7
|
||||
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"
|
||||
|
||||
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
|
||||
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
|
||||
# сборки) ПЛЮС оснастка именно этой job. Маркер в кэше пишет только успешный
|
||||
# прогон с тем же ключом, поэтому попадание доказывает: job с побайтово теми
|
||||
# же входами уже завершилась успешно.
|
||||
#
|
||||
# Это НЕ фильтр путей из job `changes` (на dev они отключены намеренно): там
|
||||
# объём прогона угадывается по путям и «зелёный» начинает значить разное,
|
||||
# здесь эквивалентность входов доказана хешем.
|
||||
#
|
||||
# Свойство, снимающее главный риск: релизный кандидат бампает версию, а
|
||||
# CARD_VERSION и package.json входят в фингерпринт, поэтому ключи кандидата
|
||||
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
|
||||
reuse:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
smoke: ${{ steps.probe.outputs.smoke }}
|
||||
golden: ${{ steps.probe.outputs.golden }}
|
||||
performance_smoke: ${{ steps.probe.outputs.performance_smoke }}
|
||||
backend: ${{ steps.probe.outputs.backend }}
|
||||
smoke_key: ${{ steps.keys.outputs.smoke }}
|
||||
golden_key: ${{ steps.keys.outputs.golden }}
|
||||
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
|
||||
backend_key: ${{ steps.keys.outputs.backend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Ключи переиспользования
|
||||
id: keys
|
||||
run: |
|
||||
for job in smoke golden performance_smoke backend; do
|
||||
key=$(node scripts/gate-reuse.mjs --job="$job")
|
||||
echo "$job=$key" >> "$GITHUB_OUTPUT"
|
||||
echo "$job: $key"
|
||||
done
|
||||
# lookup-only: маркер только проверяется, но не восстанавливается —
|
||||
# сохранять его в этой job нечего, она ничего не прогоняла.
|
||||
- name: Маркер smoke
|
||||
id: m_smoke
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-smoke-${{ steps.keys.outputs.smoke }}
|
||||
lookup-only: true
|
||||
- name: Маркер golden
|
||||
id: m_golden
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-golden-${{ steps.keys.outputs.golden }}
|
||||
lookup-only: true
|
||||
- name: Маркер performance_smoke
|
||||
id: m_perf
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}
|
||||
lookup-only: true
|
||||
- name: Маркер backend
|
||||
id: m_backend
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-backend-${{ steps.keys.outputs.backend }}
|
||||
lookup-only: true
|
||||
- name: Что переиспользуем
|
||||
id: probe
|
||||
env:
|
||||
SMOKE: ${{ steps.m_smoke.outputs.cache-hit }}
|
||||
GOLDEN: ${{ steps.m_golden.outputs.cache-hit }}
|
||||
PERF: ${{ steps.m_perf.outputs.cache-hit }}
|
||||
BACKEND: ${{ steps.m_backend.outputs.cache-hit }}
|
||||
run: |
|
||||
# Пропуск обязан быть громким: молчаливый skip — тот самый тихий
|
||||
# успех, который уже дважды стоил нам дня (#171, #207).
|
||||
waive() {
|
||||
if [ "$2" = "true" ]; then
|
||||
echo "$1=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::$1 не прогоняется: входы побайтово те же, что в предыдущем успешном прогоне (#208)"
|
||||
echo "- **$1** переиспользована: входы не менялись" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "$1=false" >> "$GITHUB_OUTPUT"
|
||||
echo "- $1: прогоняется" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
}
|
||||
echo "### Переиспользование гейтов (#208)" >> "$GITHUB_STEP_SUMMARY"
|
||||
waive smoke "$SMOKE"
|
||||
waive golden "$GOLDEN"
|
||||
waive performance_smoke "$PERF"
|
||||
waive backend "$BACKEND"
|
||||
|
||||
hacs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: HACS validation
|
||||
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
|
||||
- uses: actions/checkout@v7
|
||||
- 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
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -94,17 +252,30 @@ jobs:
|
||||
|
||||
smoke:
|
||||
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||||
needs: frontend
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Install Chromium for Playwright
|
||||
run: npx playwright install --with-deps chromium
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
- 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
|
||||
@@ -124,25 +295,54 @@ jobs:
|
||||
exit $fail
|
||||
- name: Upload smoke logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: smoke-logs
|
||||
path: /tmp/smoke-logs
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "smoke прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-smoke-${{ needs.reuse.outputs.smoke_key }}
|
||||
|
||||
golden:
|
||||
# Deterministic visual correctness stays in every prerelease gate: it is
|
||||
# inexpensive and catches a different class of regressions than timings.
|
||||
needs: frontend
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.golden != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install 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
|
||||
@@ -157,26 +357,58 @@ jobs:
|
||||
fi
|
||||
- name: Upload golden candidates/diffs
|
||||
if: failure() || steps.golden.outputs.has_baselines == 'false'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: golden-images
|
||||
path: artifacts/golden
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "golden прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-golden-${{ needs.reuse.outputs.golden_key }}
|
||||
|
||||
performance_smoke:
|
||||
# Candidate-only catastrophic-regression guard for ordinary pushes and
|
||||
# prereleases. The expensive same-runner comparison lives in performance.yml.
|
||||
needs: frontend
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.performance_smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# 15 минут не хватало, когда установка браузера шла через apt: замер
|
||||
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
|
||||
# job укладывается в те же минуты, что и раньше.
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install 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
|
||||
@@ -187,21 +419,55 @@ jobs:
|
||||
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
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: performance-smoke
|
||||
path: artifacts/performance-smoke
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "performance_smoke прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
|
||||
|
||||
backend:
|
||||
needs: [changes, reuse]
|
||||
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
# 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
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
with: { python-version: "3.13" }
|
||||
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
|
||||
- name: Backend unit tests (pure + HA harness)
|
||||
run: python -m pytest tests_backend/ -q
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "backend прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-backend-${{ needs.reuse.outputs.backend_key }}
|
||||
|
||||
@@ -38,8 +38,16 @@ 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. [GitHub Projects (v2)](https://github.com/users/Matysh/projects/1) is a
|
||||
human-facing view synchronised from the labels, not the source of truth.
|
||||
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
|
||||
@@ -146,6 +154,28 @@ 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
|
||||
@@ -225,10 +255,15 @@ The exchange happens in **issue comments** — there is no local message bus. Ve
|
||||
format:
|
||||
|
||||
```text
|
||||
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → #… · Document: …
|
||||
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → in-task | #… · Document: …
|
||||
```
|
||||
|
||||
High blocks. Medium must become its own issue. Low is fixed or waived with a note
|
||||
High blocks. A Medium finding INSIDE the task's scope is fixed within the task:
|
||||
with no High findings the verdict is yellow, the author fixes it and the fix
|
||||
passes another review cycle — no separate issue (owner's decision 2026-08-19,
|
||||
#202: filing and servicing an issue costs far more than fixing in place). Only
|
||||
a Medium finding OUTSIDE the scope becomes its own issue — foreign scope is
|
||||
never patched from this branch. 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.
|
||||
@@ -290,9 +325,19 @@ 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 only the fast gates run. `smoke`, `golden` and
|
||||
`performance_smoke` spin up Chromium and belong to the pre-beta run — which is then
|
||||
mandatory and complete.
|
||||
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.
|
||||
@@ -313,14 +358,22 @@ complete Linux CI artifact; never accept a partial scenario or images merely to
|
||||
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.
|
||||
TypeScript and package-lock build inputs. Every browser check must verify it
|
||||
before trusting a result — benchmarks, golden runs and documentation captures
|
||||
call `assertFreshDemoBundle` themselves, and smokes get it from `launch()` in
|
||||
`demo/serve.mjs` (#236). A missing or mismatched fingerprint is a hard failure,
|
||||
not a warning; `HP_ALLOW_STALE_BUNDLE=1` skips the check for debugging and says
|
||||
so out loud. A smoke against a stale bundle does not fail cleanly: part of its
|
||||
assertions go red and part stay green, which reads as a logic defect.
|
||||
|
||||
**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`.
|
||||
an unfinished Validate for the same branch. Gate jobs, matching the actual
|
||||
`validate.yml` (#191): `docs`, `provenance`, `process-gate`, `hacs`, `hassfest`,
|
||||
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`. The `changes` job
|
||||
is a service path-filter, not a gate. `docs` is a real blocker: it checks the
|
||||
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
|
||||
went red after the #113 merge.
|
||||
|
||||
**"Verified" without a named command and its result is not evidence.**
|
||||
|
||||
|
||||
@@ -18,13 +18,12 @@ requests still belong in [issues](https://github.com/Matysh/houseplan-card/issue
|
||||
|
||||
## Backlog and work status
|
||||
|
||||
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and the linked
|
||||
[GitHub Project v2](https://github.com/users/Matysh/projects/1) are the
|
||||
only active project backlog. Issues own scope and acceptance criteria; Project
|
||||
v2 owns prioritization and workflow status. Before starting planned work, link
|
||||
it to an existing issue or create one, add it to the Project, and keep both
|
||||
surfaces 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.
|
||||
[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
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
> его не содержал вовсе.
|
||||
>
|
||||
> **Приоритет источников.** Канонический бэклог — GitHub Issues; статус живёт в
|
||||
> метках, Project v2 остаётся человеческим представлением. При расхождении
|
||||
> метках и больше нигде: Project v2 не используется. При расхождении
|
||||
> документации с GitHub побеждает GitHub. При расхождении этого документа с
|
||||
> `.github/workflows/*.yml` и `scripts/*` побеждает **фактическая автоматизация**:
|
||||
> она исполняется, а описание — нет. Расхождение при этом не игнорируется, а
|
||||
@@ -70,7 +70,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
→ S6-in-progress → S7-code-review ⟲ → S8-merged → закрыт при выпуске беты
|
||||
|
||||
служебные: blocked (поверх статуса) rejected (закрыт)
|
||||
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком треке 2
|
||||
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком и коротком треке 2
|
||||
короткий трек (`trivial`, §5.1) идёт S2-analysis → S5-ready, минуя S3 и S4
|
||||
```
|
||||
|
||||
Переходы `S4-spec-review` и `S7-code-review` выполняются **автоматически**: метка
|
||||
@@ -85,9 +86,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
|
||||
### 2.2 Аналитика и оценка
|
||||
|
||||
Задача разбирается, продуктовое «да» ещё не дано.
|
||||
Задача разбирается — и разобранная **сама идёт дальше**. Умолчание изменено
|
||||
решением владельца 2026-08-14: раньше аналитика ждала подтверждения по каждому
|
||||
пункту, и большинство ожиданий ничего не меняло — issue в основном описаны
|
||||
однозначно.
|
||||
|
||||
- **Кто:** агент-аналитик готовит, владелец решает.
|
||||
- **Кто:** агент-аналитик. Владелец не утверждает переход — он правит асинхронно.
|
||||
- **Чек-лист**, результат — комментарием в issue:
|
||||
1. дубликаты проверены (ссылки на похожие issue);
|
||||
2. в скоупе по `docs/SCOPE.md` и `docs/TOUCH-SUPPORT.md`;
|
||||
@@ -97,10 +101,21 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
5. приоритет **P1/P2/P3**;
|
||||
6. тип: баг / фича / техдолг;
|
||||
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
|
||||
8. лёгкий трек — да/нет по критериям §5.
|
||||
- **Приоритет и ценность — поля владельца.** Агент предлагает, владелец
|
||||
утверждает; иначе агенты приоритизируют сами и P1 разрастается.
|
||||
- **Выход:** «ТЗ в работе» либо «Отклонено» с записанной причиной.
|
||||
8. трек: обычный / `small` / `trivial` по критериям §5 и §5.1.
|
||||
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
|
||||
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
|
||||
согласие**, несогласие он выражает правкой меток или комментарием, и это не
|
||||
останавливает работу. Право отклонить задачу (`rejected`) остаётся за
|
||||
владельцем на любой стадии.
|
||||
- **Вопросов владельцу на этом этапе нет.** Единственный класс вопросов, который
|
||||
вообще задаётся владельцу, — продуктовые (§7.1: что человек видит или делает,
|
||||
объём видимых изменений), и их место — этап ТЗ, пачкой, с вариантами по
|
||||
умолчанию и `blocked`. Вопрос, который можно отложить до ТЗ, не задаётся в
|
||||
аналитике; вопрос, не блокирующий написание ТЗ, не задаётся вовсе — вместо
|
||||
него в ТЗ пишется блок принятых предположений.
|
||||
- **Выход:** `S3-spec` — переход выполняет сам аналитик, не дожидаясь ответа.
|
||||
Либо, при явном конфликте со `SCOPE.md`, — предложение отклонить с причиной:
|
||||
это единственный случай, когда аналитика останавливается и ждёт владельца.
|
||||
|
||||
### 2.3 ТЗ в работе — написание ТЗ
|
||||
|
||||
@@ -116,10 +131,14 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
Его задача — не согласиться, а найти, где ТЗ не выполнимо или не проверяемо.
|
||||
- **Артефакт:** `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, вердикт
|
||||
зелёный / жёлтый / красный. Лёгкий трек — комментарий в issue.
|
||||
- **High-находки блокируют.** Medium/Low — либо правятся, либо становятся
|
||||
отдельными issue со ссылкой; «оставили в тексте ревью» не считается закрытием.
|
||||
- **High-находки блокируют.** Medium **в скоупе задачи** чинится в текущем
|
||||
issue: без High это жёлтый вердикт, автор правит ТЗ, фикс проходит повторный
|
||||
цикл. Medium **вне скоупа** — отдельный issue: чужой скоуп в этой задаче не
|
||||
правится. «Оставили в тексте ревью» не считается закрытием ни для одной
|
||||
(решение владельца 2026-08-19, #202: отдельный issue дороже правки на месте).
|
||||
Low либо правится, либо снимается решением ревьюера с записью.
|
||||
- **Выход:** «Готово к разработке» либо возврат в «ТЗ в работе» — не более
|
||||
4 циклов (§4).
|
||||
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
|
||||
|
||||
### 2.5 Готово к разработке (DoR)
|
||||
|
||||
@@ -172,9 +191,11 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
|
||||
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
|
||||
коду с явной записью «проверено чтением, не исполнением».
|
||||
- **High блокируют.** Medium **обязаны** превратиться в issue.
|
||||
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
|
||||
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
|
||||
Medium **вне скоупа** — отдельный issue (#202).
|
||||
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
|
||||
4 циклов (§4).
|
||||
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
|
||||
|
||||
### 2.8 Закрытие после выпуска беты
|
||||
|
||||
@@ -195,6 +216,42 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- **Отклонено:** закрытие с записанной причиной (вне скоупа, дубликат, цена не
|
||||
оправдана). Тихое закрытие без причины запрещено.
|
||||
|
||||
### 2.10 Повторный раунд ревью — объём по дельте
|
||||
|
||||
Решение владельца 2026-08-19 (issue #214). Относится и к ревью ТЗ, и к
|
||||
код-ревью, начиная со второго цикла.
|
||||
|
||||
**Предмет повторного раунда — дельта, а не задача целиком.** Раньше объём
|
||||
разбора не был оговорён, промпт ревьюера для всех раундов был одинаковым, и
|
||||
повторный цикл заново выводил продуктовую рамку и перепроверял AC, которых
|
||||
правка не касалась: r2 по #150 стоил полного прогона конвейера ради одной
|
||||
строки в тестовой фикстуре.
|
||||
|
||||
Порядок:
|
||||
|
||||
1. найти вердикт предыдущего раунда и **SHA, на котором он получен**; SHA в
|
||||
вердикте не назван — это находка;
|
||||
2. объявить дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла ТЗ либо тела
|
||||
issue для этапа ТЗ;
|
||||
3. по каждой находке предыдущего раунда показать, **чем именно она закрыта** —
|
||||
строкой кода или текста, а не заявлением автора;
|
||||
4. заново проверять только те AC, чьё доказательство дельта задевает;
|
||||
5. **раздел «Унаследовано из r<N−1>»** обязателен: что принято без повторной
|
||||
проверки, со ссылкой на документ того раунда и SHA. Без перечня сокращение
|
||||
превращается в молчаливое доверие.
|
||||
|
||||
Дешёвые гейты (`typecheck`, `test`, `build` со сверкой копий бандла) гоняются в
|
||||
каждом раунде: код изменился, а стоят они минуты. Тяжёлые — по дельте (§10.2).
|
||||
|
||||
**Разбор остаётся полным**, если дельта не локальна: ребейз на ушедший вперёд
|
||||
`dev` (после ребейза это другой код, §7.2), смена контракта поведения, задета
|
||||
новая подсистема, либо объём дельты сопоставим с исходной задачей.
|
||||
|
||||
Сокращается объём **разбора, а не строгость**: правка по замечанию способна
|
||||
сломать AC, который предыдущий раунд признал выполненным — так появилась
|
||||
регрессия #102. Граница не «только находки», а «находки плюс всё, до чего
|
||||
дотягивается дельта».
|
||||
|
||||
---
|
||||
|
||||
## 3. Правила
|
||||
@@ -216,8 +273,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
ревью-гейт.
|
||||
7. **Ревью возвращает не более 4 раз.** Пятый заход — решение владельца: разделить,
|
||||
отклонить или арбитраж (§4).
|
||||
8. **High блокирует. Medium становится issue.** Low либо правится, либо снимается
|
||||
решением ревьюера с записью в документе.
|
||||
8. **High блокирует. Medium в скоупе чинится в текущем issue** (без High —
|
||||
жёлтый вердикт и повторный цикл); Medium вне скоупа становится отдельным
|
||||
issue (#202). Low либо правится, либо снимается решением ревьюера с записью
|
||||
в документе.
|
||||
9. **Скоуп не расширяется.** Всё найденное вне ТЗ — новый issue, а не попутная
|
||||
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
|
||||
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
|
||||
@@ -248,11 +307,24 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
|
||||
## 4. Лимит циклов ревью: 4
|
||||
|
||||
Оба ревью-гейта возвращают задачу на правки не более **4 раз**. Счётчик виден в
|
||||
имени документа: `-r1` … `-r4`; на четвёртом заходе ставится метка `review-4`.
|
||||
Оба ревью-гейта возвращают задачу на правки не более **4 раз**.
|
||||
|
||||
- **Что считается циклом:** отправка на ревью → вердикт с блокирующими находками
|
||||
→ возврат. Уточняющий вопрос без вердикта циклом не считается.
|
||||
- **Зелёный вердикт цикла не образует** и бюджет не тратит (решение владельца
|
||||
2026-08-20, issue #227): он ничего не вернул на правки. Практический случай —
|
||||
зелёное ревью, слияние которого не удалось: конвейер сам предписывает ребейз и
|
||||
возврат метки, и этот заход не должен наказываться. Раньше счётчик считал все
|
||||
вердикты подряд, и на #225 последовательность жёлтый → зелёный → ребейз дала
|
||||
`review-4` на задаче с зелёным ревью и зелёным CI.
|
||||
- **Заход и цикл — разные величины.** Заход — сколько раз ревью отработало; он
|
||||
виден в имени документа (`-r1`, `-r2`, …) и нужен, чтобы два документа не
|
||||
затёрли друг друга. Цикл — единица бюджета §4. Заходов законно бывает больше,
|
||||
чем циклов, поэтому порог проверки 7 в `scripts/process-gate.mjs` выше лимита
|
||||
циклов (шесть документов = четыре цикла плюс два ребейза).
|
||||
- Метка `review-4` ставится, когда исчерпан **бюджет циклов**; конвейер снимать
|
||||
её не вправе — это решение владельца. Если бюджет пересчитан и оказался ниже
|
||||
лимита, конвейер сообщает пересчёт, но метку не трогает.
|
||||
- **Исчерпание лимита — не «пятая попытка», а разбор.** Задача уходит владельцу,
|
||||
решение одно из трёх:
|
||||
1. **разделить** — issue закрывается как «заменён», вместо него 2–3 меньших с
|
||||
@@ -294,6 +366,42 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
модуль) — метка `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. Роли
|
||||
@@ -393,8 +501,12 @@ issue #NN
|
||||
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
|
||||
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
|
||||
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
|
||||
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · цикл r<N>/<лимит> ·
|
||||
High: N · Medium: N → #… · Документ: docs/reviews/…`
|
||||
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · заход r<N> ·
|
||||
блокирующих циклов K/<лимит> · High: N · Medium: N → в задаче | #… ·
|
||||
Документ: docs/reviews/…`
|
||||
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору.
|
||||
Заход — номер прогона ревью, K — израсходованный бюджет §4: зелёные вердикты
|
||||
его не тратят, поэтому заход и K расходятся, #227)
|
||||
- **Закрытие:** `Выпущено в <тег беты> · CI: <ссылка> · Changelog: <ссылка>`
|
||||
|
||||
**Вперёд двигает только зелёный вердикт.** Жёлтый и красный возвращают автору;
|
||||
@@ -428,9 +540,30 @@ npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend
|
||||
&& cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
node demo/smoke_<целевые>.mjs
|
||||
npm run golden:verify # если менялся визуал
|
||||
node scripts/check-docs.mjs # если менялся src/**
|
||||
python -m pytest tests_backend -q # py3.13, если менялся бэкенд
|
||||
```
|
||||
|
||||
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
|
||||
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла, а при
|
||||
любом diff'е по `src/**` — ещё и `node scripts/check-docs.mjs`. По
|
||||
необходимости, определяемой diff'ом и AC: браузерные смоки (их 163 — прогон всех
|
||||
уместен только когда задача задевает всё), `golden:verify` при изменении видимого
|
||||
результата, `pytest tests_backend` при правках в Python, performance-профили при
|
||||
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
|
||||
|
||||
`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток
|
||||
скриншотов документации считается по всему `src/**`, поэтому **любая** правка
|
||||
фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff
|
||||
всегда попадает, и решать нечего. Цена пропуска измерена: скриншоты не
|
||||
пересняли в #230 и #234, и `dev` стоял с красным job `docs`, пока это не нашли
|
||||
при следующей задаче (#237). Пересъёмка — `npm run build && node
|
||||
demo/docs/capture.mjs`, коммит вместе с задачей.
|
||||
|
||||
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
|
||||
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
|
||||
пропуском.
|
||||
|
||||
**Гейт беты** (условие закрытия issue): CI Validate зелёный на точном SHA тега.
|
||||
|
||||
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
|
||||
@@ -444,9 +577,12 @@ Performance зелёные на точном SHA; статусов issue не к
|
||||
|
||||
## 9. Метки — канонический статус
|
||||
|
||||
Статус читается из меток: их видно в списке issue и их читает любой токен с
|
||||
доступом к Issues, в отличие от Project v2, который требует отдельного скоупа.
|
||||
Project v2 остаётся человеческим представлением и синхронизируется по меткам.
|
||||
Статус читается из меток: их видно в списке issue, их читает любой токен с
|
||||
доступом к Issues, и по ним же работает конвейер — смена метки порождает событие
|
||||
(§10.4). **Project v2 не используется** (решение владельца 2026-08-14): второе
|
||||
представление статуса рядом с метками требовало отдельного скоупа токена,
|
||||
синхронизации и внимания, а давало вид доски. Два источника одного факта
|
||||
расходятся — это уже случалось с колонкой «Статус ТЗ» в `docs/specs/README.md`.
|
||||
|
||||
**Имена меток английские** (решение владельца 2026-08-12). Русские имена в этом
|
||||
документе были только на бумаге; репозиторий с самого начала жил на английских.
|
||||
@@ -464,8 +600,8 @@ Project v2 остаётся человеческим представление
|
||||
| `blocked` | Ждём внешнего или владельца, **поверх** статусной метки |
|
||||
| `rejected` | Отклонено, issue закрыт |
|
||||
|
||||
Модификаторы: `small` (лёгкий трек, сложность ≤3), `hotfix`, `process`,
|
||||
`review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
|
||||
Модификаторы: `small` (лёгкий трек, сложность ≤3), `trivial` (короткий трек,
|
||||
§5.1), `hotfix`, `process`, `review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
|
||||
Тематические метки (`polish`, `infra`, `tests`, `docs`, `security`, `vacuum`)
|
||||
ортогональны процессу.
|
||||
|
||||
@@ -523,6 +659,13 @@ Project v2 остаётся человеческим представление
|
||||
считается от `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`, поэтому при его отсутствии хук печатает
|
||||
предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук,
|
||||
который не работает в самолёте, отключают целиком, а строгий проход всё равно
|
||||
@@ -560,7 +703,7 @@ Project v2 остаётся человеческим представление
|
||||
{`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`}; закрытый,
|
||||
недоступный или помеченный `blocked` — отказ (**fail closed**).
|
||||
|
||||
Две оговорки к проверке 8, обе выяснились при реализации.
|
||||
Три оговорки к проверке 8 выяснились при реализации.
|
||||
|
||||
**`S8-merged` входит в множество**, хотя по смыслу задача уже принята. Причина
|
||||
механическая: конвейер (§10.4) сливает ветку в `dev` **раньше**, чем ставит метку,
|
||||
@@ -573,6 +716,13 @@ Validate стартует от этого push и успевает прочит
|
||||
документ ревью: он ложится в ветку задачи, пока та в `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
|
||||
@@ -607,8 +757,8 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
```
|
||||
|
||||
Ревьюер — `anthropics/claude-code-action`. Он читает `docs/SCOPE.md`, `AGENTS.md`,
|
||||
этот документ и тело issue, публикует разбор комментарием, заводит issue на каждую
|
||||
Medium-находку, кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
|
||||
этот документ и тело issue, публикует разбор комментарием, заводит issue на Medium-находки
|
||||
вне скоупа задачи (#202), кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
|
||||
структурированным JSON. **Метку переставляет отдельный детерминированный шаг по
|
||||
вердикту, а не модель.**
|
||||
|
||||
@@ -732,7 +882,8 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
- принятие golden-эталонов ради зелёного CI или по частичному артефакту;
|
||||
- закрытие issue до выпуска беты с зелёным CI;
|
||||
- переоткрытие закрытого issue вместо нового бага;
|
||||
- Medium-находки, оставленные как TODO в документе ревью;
|
||||
- Medium-находки, оставленные как TODO в документе ревью: в скоупе — чинятся
|
||||
в текущем issue, вне скоупа — становятся отдельным (#202);
|
||||
- **параллельные бэклоги** в файлах (`BACKLOG-*.md`, «планы» в docs);
|
||||
- ревью-документы вне репозитория;
|
||||
- попутные правки «раз уж я здесь»;
|
||||
|
||||
@@ -1,368 +1,157 @@
|
||||
# 🏠 House Plan — interactive floor plan card for Home Assistant
|
||||
# 🏠 House Plan — a live home map for Home Assistant
|
||||
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](LICENSE)
|
||||
[](https://demo.houseplan.tech)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||
**Turn Home Assistant into a live, interactive map of your home.** Upload or draw
|
||||
a floor plan, outline the rooms with your mouse — and every smart device appears
|
||||
in its real place: live states, tap-to-toggle lights, temperature and humidity per
|
||||
room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wall
|
||||
tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
|
||||
right on your Lovelace dashboard.
|
||||
📘 **[Full user guide](docs/USER-GUIDE.md)** · 🇷🇺 **[Русский](README.ru.md)** · 🗂 **[Project issues](https://github.com/Matysh/houseplan-card/issues)**
|
||||
|
||||
> **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.
|
||||
<!-- docs-section: overview -->
|
||||
|
||||

|
||||
## Your whole home at a glance
|
||||
|
||||
> ### 🚀 Try it live — no install needed
|
||||
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — a real Home Assistant
|
||||
> with a ready-made plan. Log in as **`demo`** / **`demo`** and click anything:
|
||||
> toggle lights, open the editors, break things. The stand resets itself to a
|
||||
> pristine state every hour.
|
||||
House Plan turns Home Assistant into a live map of your home. Upload a plan or
|
||||
draw rooms directly on the dashboard, bind them to Home Assistant areas, and
|
||||
the area's devices appear automatically. You can immediately see where a light
|
||||
is on, a door is open, a room is too cold, Zigbee signal is weak, or a leak
|
||||
sensor has fired.
|
||||
|
||||
🇷🇺 [Документация на русском](README.ru.md) · 💬 [Telegram chat: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||

|
||||
|
||||
**Feature highlights**
|
||||
Setup is entirely graphical: no floor-plan YAML, Inkscape, or external editor.
|
||||
Plan data and device positions live on the Home Assistant server and stay in
|
||||
sync across screens.
|
||||
|
||||
- ♾️ **An infinite canvas** — there is no "plan size" and no edge to run
|
||||
past: draw and place devices anywhere, pan at any zoom, zoom out to see
|
||||
everything, and let one tap fit the whole plan back on screen.
|
||||
- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms,
|
||||
virtual walls and a visual decor layer, all drawn with clicks; room resize
|
||||
by dragging walls, with live lengths and areas as you drag; smart
|
||||
alignment guides and a live ruler in real meters/feet.
|
||||
- 🖼 **A backdrop you can move and scale** — drag the floor-plan picture into
|
||||
place and pull a corner to size it, with its real size in metres shown as
|
||||
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 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
|
||||
from a weather entity.
|
||||
- 🪟 **Curtains and blinds open on a tap** — one action opens, closes or
|
||||
stops a cover, and the icon itself morphs between open and closed while a
|
||||
soft ring pulses as it travels.
|
||||
- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count;
|
||||
comfort-range temperature fills, per-room signal heatmap.
|
||||
- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an
|
||||
explicit button, never an accidental tap.
|
||||
- 📺 **Kiosk mode** for wall tablets and TVs: fullscreen, swipe between floors,
|
||||
auto-carousel, per-screen icon sizes.
|
||||
- 🤖 **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. 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.
|
||||
> **Edit on a desktop computer.** View and kiosk are fully supported on phones
|
||||
> and tablets. The editors are designed primarily for a mouse and keyboard;
|
||||
> individual touch editing operations may be awkward or unavailable. See the
|
||||
> exact [touch support contract](docs/TOUCH-SUPPORT.md).
|
||||
|
||||
---
|
||||
<!-- docs-section: features -->
|
||||
|
||||
## What it is and why
|
||||
## What House Plan provides
|
||||
|
||||
House Plan shows your smart home the way it actually looks — on a floor plan. Instead of long lists of entities, you see rooms and devices in their real places: where the leak is, what the temperature is in the kids' room, whether the light is on in the hallway, whether the gate is open.
|
||||
- **Live state and safe actions.** Lights and other safe devices can toggle from
|
||||
the plan; a lock cannot be opened by an accidental plan tap.
|
||||
- **Three built-in editors.** Plan creates rooms, walls and openings; Device
|
||||
places and configures markers; Background adds lines, labels and furniture.
|
||||
- **Area-aware rooms.** New devices appear automatically, while room cards can
|
||||
show temperature, humidity, light state and average LQI.
|
||||
- **Light and environment.** Room fills, lamp Glow, wall shadows, a day-cycle
|
||||
backdrop and sunlight through windows.
|
||||
- **Doors, windows, gates and vacuums.** Openings follow real contacts and locks;
|
||||
a robot can show its position, dock and travelled path.
|
||||
- **Several floors and screens.** Space tabs, swipe navigation, local viewport,
|
||||
and a separate initial floor for each card.
|
||||
- **Wall-display kiosk.** A plan-only view with fullscreen navigation and icon
|
||||
sizes saved for that display.
|
||||
|
||||
This is convenient when:
|
||||

|
||||
|
||||
- you have many devices and lists are awkward to use;
|
||||
- you need to grasp the state of the house "at a glance";
|
||||
- you want to give access to family members — anyone can figure out a picture;
|
||||
- you want a beautiful overview screen for a wall-mounted tablet.
|
||||
<!-- docs-section: first-run -->
|
||||
|
||||
The integration consists of two parts that are installed together:
|
||||
## Your first working room
|
||||
|
||||
- **the Lovelace card** `houseplan-card` — the interactive plan itself;
|
||||
- **the server-side component** — stores the room markup and icon positions in Home Assistant, so the plan is identical in all browsers and on all devices.
|
||||
1. Install the integration and add the card to a dashboard.
|
||||
2. Create the first **space**: upload SVG/PNG/JPG/WebP, reuse an uploaded image,
|
||||
or choose no image and draw the plan by hand.
|
||||
3. In Plan, select **Room outline**, place vertices, and click the first point to
|
||||
close the outline.
|
||||
4. Name the room and bind it to a Home Assistant area. Use “No area” for a room
|
||||
that has no devices.
|
||||
5. Open Device: devices from the bound area are already placed; drag their
|
||||
markers to the correct positions.
|
||||
6. Optionally use Background for lines, text and furniture.
|
||||
7. Return to View. The plan now displays live state and accepts safe actions.
|
||||
|
||||
---
|
||||

|
||||
|
||||
## How it differs from alternatives
|
||||

|
||||
|
||||
A house plan in Home Assistant is usually built with `picture-elements`,
|
||||
`ha-floorplan`, or newer GUI cards that draw walls and furniture in the
|
||||
dashboard. Those either lock you into YAML/SVG, or store the plan in the
|
||||
Lovelace card config. House Plan is a **shared live map** backed by a Home
|
||||
Assistant integration:
|
||||

|
||||
|
||||
| | House Plan | picture-elements / ha-floorplan | GUI draw cards (e.g. easy-floorplan) |
|
||||
|---|---|---|---|
|
||||
| **Setup** | Entirely through the UI, with the mouse | Manual YAML / Inkscape SVG | In-card drawing of walls & furniture |
|
||||
| **Adding devices** | Automatic, by HA **area** | You type every entity by hand | Place entities by hand on the drawing |
|
||||
| **Icon coordinates** | Drag with the mouse | Count pixels into YAML | Drag on the canvas |
|
||||
| **Room markup** | Built-in outline editor bound to areas | External SVG editor | Draw walls yourself (furniture CAD) |
|
||||
| **Storage** | On the HA server (`.storage`, shared, multi-client) | In the dashboard YAML | In the card / dashboard YAML |
|
||||
| **Overlays** | Glow, climate, LQI, sun, vacuums, kiosk | Whatever you script in SVG/CSS | Varies by card |
|
||||
| **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 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:
|
||||
Every workflow and edge case is in the [full user guide](docs/USER-GUIDE.md).
|
||||
The [Background editor contract](docs/DECOR-EDITOR.md) and
|
||||
[vacuum guide](docs/VACUUM.md) are the authorities for those subsystems.
|
||||
|
||||
- **No code at all.** Everything — spaces, rooms, devices — is configured with clicks.
|
||||
- **Automatic device placement.** Outline a room and bind it to a Home Assistant area — the devices of that area appear on the plan by themselves.
|
||||
- **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, 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.
|
||||
|
||||
---
|
||||
|
||||
## Wall tablet / TV (kiosk mode)
|
||||
|
||||
Add the card to a dedicated dashboard with a **panel view** and set `kiosk: true`
|
||||
(or tick "Wall device (kiosk) mode" in the card editor):
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
kiosk: true
|
||||
cycle: 0 # seconds between auto space switches, 0 = off (nice for TVs)
|
||||
```
|
||||
|
||||
No header, no editors — just the live plan. Swipe to change floors (at 1:1),
|
||||
pinch to zoom, double-tap to reset. Long-press an empty spot for 3 seconds to
|
||||
tune icon and text sizes for THIS screen (saved per device). To hide Home
|
||||
Assistant's own header use the companion app's kiosk settings or the
|
||||
[kiosk-mode](https://github.com/NemesisRE/kiosk-mode) plugin.
|
||||
<!-- docs-section: installation -->
|
||||
|
||||
## Installation
|
||||
|
||||
One click if you already run HACS:
|
||||
### HACS
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
1. In HACS open **⋮ → Custom repositories**.
|
||||
2. Add `https://github.com/Matysh/houseplan-card` as an **Integration**.
|
||||
3. Install House Plan and restart Home Assistant.
|
||||
4. Open **Settings → Devices & services → Add integration → House Plan**.
|
||||
|
||||
### Via HACS (recommended)
|
||||
The card is registered automatically. If you manage Lovelace resources
|
||||
manually, use the URL served by the integration:
|
||||
|
||||
1. Open **HACS → menu (⋮) → Custom repositories**.
|
||||
2. Paste the URL of this repository, set the category to **Integration**, and click **Add**.
|
||||
3. Find **House Plan** in the list, install it and **restart Home Assistant**.
|
||||
4. Go to **Settings → Devices & Services → Add integration** and select **House Plan**.
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
The card is registered automatically — no need to add a Lovelace resource manually.
|
||||
Do not use the on-disk path inside `custom_components`; Home Assistant does not
|
||||
serve that path as a JavaScript module.
|
||||
|
||||
> **Card doesn't load (`Custom element doesn't exist: houseplan-card`) or you manage Lovelace
|
||||
> resources in YAML?** Add the resource manually pointing at the URL the integration *serves*:
|
||||
>
|
||||
> ```yaml
|
||||
> resources:
|
||||
> - url: /houseplan_files/houseplan-card.js
|
||||
> type: module
|
||||
> ```
|
||||
>
|
||||
> Do **not** use `/custom_components/houseplan/frontend/houseplan-card.js` — that is the file
|
||||
> on disk, which Home Assistant does not serve over HTTP (you'll get a `text/plain` MIME error
|
||||
> and the element never registers). The correct, integration-served URL is
|
||||
> `/houseplan_files/houseplan-card.js`. Both cards (`houseplan-card` and
|
||||
> `houseplan-space-card`) ship in that one file — no separate resource is needed.
|
||||
### Manual installation
|
||||
|
||||
### Manually
|
||||
Copy `custom_components/houseplan` to `config/custom_components`, restart Home
|
||||
Assistant, and add the House Plan integration.
|
||||
|
||||
1. Copy the `custom_components/houseplan` folder into the `config/custom_components` directory of your Home Assistant.
|
||||
2. Restart Home Assistant.
|
||||
3. Add the integration: **Settings → Devices & Services → Add integration → House Plan**.
|
||||
### Add the card
|
||||
|
||||
### Adding a plan screen
|
||||
|
||||
Create a new dashboard tab (a "Panel" view works best) and add the card:
|
||||
Create a dashboard view (Panel works best) and add the card in the UI or as:
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
title: House plan
|
||||
```
|
||||
|
||||
Nothing else needs to be specified — everything else is configured right on the screen.
|
||||
Different screens may start on different spaces:
|
||||
|
||||
---
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
default_floor: ground
|
||||
```
|
||||
|
||||
## How to use
|
||||
All cards share server-side rooms and coordinates. Current mode, viewport and
|
||||
selected space remain local to the screen. Revision checks and live sync cover
|
||||
concurrent clients, but avoid editing the same object in two browsers at once.
|
||||
|
||||
### Step 1. Add a space (floor)
|
||||
## Detailed documentation
|
||||
|
||||
On first open the plan is still empty — House Plan immediately offers to create the first space.
|
||||
If your Home Assistant already has **floors** configured, a wizard offers to create a space
|
||||
for each floor (names prefilled, a plan image is asked for one by one; any floor can be skipped).
|
||||
- [Full user guide](docs/USER-GUIDE.md)
|
||||
- [Mouse/touch/keyboard matrix](docs/USER-GUIDE.md#6-navigation-zoom-and-input)
|
||||
- [Plan tools](docs/USER-GUIDE.md#plan-tools-at-a-glance)
|
||||
- [Background editor](docs/DECOR-EDITOR.md)
|
||||
- [Robot vacuums](docs/VACUUM.md)
|
||||
- [Touch support](docs/TOUCH-SUPPORT.md)
|
||||
|
||||

|
||||
<!-- docs-section: support -->
|
||||
|
||||
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.
|
||||
## Support and feedback
|
||||
|
||||

|
||||
- Questions and plan examples: [Telegram @ha_houseplan](https://t.me/ha_houseplan).
|
||||
- Bugs and proposals: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues).
|
||||
- Before reporting, update House Plan, restart HA and hard-refresh the page.
|
||||
Include the version, browser, logs and reproduction steps; private entity IDs
|
||||
may be replaced with fictional ones.
|
||||
|
||||
> 💡 You can draw the background in any floor planner (for example, REMPLANNER) or photograph a paper plan. SVG works best — it stays crisp when zoomed in.
|
||||
Documentation screenshots are produced by the reproducible
|
||||
`npm run build && node demo/docs/capture.mjs` command using synthetic data only. Scenario version,
|
||||
source fingerprint and every image hash are recorded in the
|
||||
[screenshot index](docs/images/screenshots.json).
|
||||
|
||||
Later you can add as many spaces as you like (floors, yard, garage) with the **+** button next to the tabs.
|
||||
|
||||
### Step 2. Outline the rooms
|
||||
|
||||
After the first space is added, the card switches to the **Plan** tab by itself. The card has three mode tabs in the header — **View** (default: display and device control only, nothing can be moved or edited), **Plan** (rooms, openings, labels, space settings) and **Devices** (placing and configuring markers); the edit tabs are shown to administrators. In Plan, click grid points, connecting them with lines, and close the room outline by clicking the first point.
|
||||
|
||||
As soon as the outline is closed, the room-save dialog appears. Here you need to **bind the room to a Home Assistant area** — this is exactly what enables the automation. For utility rooms with no devices (hall, sauna) there is a **"No area"** button.
|
||||
|
||||

|
||||
|
||||
While drawing, a ruler follows the cursor showing the current segment's real length (metres, or feet + inches on an imperial Home Assistant). The scale is set per space — the **"Scale (grid cell size)"** field in the space dialog says how many centimetres one grid cell represents (default 5 cm).
|
||||
|
||||
Rooms may not overlap: a click strictly inside an existing room, or an outline that would swallow one, is refused. Two more tools help you reshape the plan later:
|
||||
|
||||
- **Merge** — click a room, then a neighbour that shares a wall; they fuse into one. A dialog picks which name and area survive.
|
||||
- **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, gates and locks
|
||||
|
||||
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, 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 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.
|
||||
|
||||
Openings are easy to adjust later: hovering one highlights it, you can **drag it along the
|
||||
walls** (it slides around corners too), and a **double click opens its properties**.
|
||||
|
||||
### Step 3. Devices appear by themselves
|
||||
|
||||
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 **"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.
|
||||
|
||||

|
||||
|
||||
### Step 4. Zoom
|
||||
|
||||
The mouse wheel or the **- / ⊹ / +** buttons zoom the plan in and out; on a touch screen the two-finger pinch works. Zoomed out you see the whole plan, zoomed in you see the details, and everything stays crisp. The zoom level is remembered separately for each space.
|
||||
|
||||

|
||||
|
||||
### Step 5. Put the icons in their places
|
||||
|
||||
Switch to the **Devices** tab to arrange icons: drag them with the mouse, click one to open its editor. In **View** mode nothing can be moved — panning the map never displaces a sensor (a top user request). Positions are saved on the server and are identical in all browsers and devices. The **↺** button restores the automatic layout.
|
||||
|
||||

|
||||
|
||||
### Tap actions: control devices from the plan
|
||||
|
||||
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
|
||||
|
||||
Which MDI icon a device gets is decided by **icon rules** — editable right in the card
|
||||
(the ⬡ button in the header): an ordered list of “name pattern → icon” regexes with a
|
||||
live test field, bilingual defaults (EN/RU) and a one-click reset. When no rule
|
||||
matches, the entity *device class* decides (thermometer for temperature sensors, etc.).
|
||||
|
||||
### Step 6. Adding your own devices manually
|
||||
|
||||
You can also place a **single entity** (not just a whole device): start typing in the binding search and individual entities appear next to devices — handy when one device exposes several values (e.g. temperature and humidity) and you want each as its own icon.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
**icon size** (×0.5–3) and **rotation** are also per-device, so a wall valve can be small and
|
||||
turned the way it is mounted.
|
||||
|
||||

|
||||
|
||||
### Styling the plan with card-mod (advanced, unsupported)
|
||||
|
||||
The card ships finished and has no CSS field of its own — but if you already run [card-mod](https://github.com/thomasloven/lovelace-card-mod), every object on the plan now carries a stable hook you can aim at: `data-hp="device"` (plus `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. We promise not to rename them; we do not ship card-mod, do not support it, and are not responsible for what your CSS does to the card. The full table, the examples and the limits are in **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Uninstalling
|
||||
|
||||
1. Remove the card (or the tab with the plan) from the dashboard.
|
||||
2. **Settings → Devices & Services → House Plan → Delete** the integration entry.
|
||||
3. Remove the integration from **HACS** (or delete the `custom_components/houseplan` folder if installed manually) and restart Home Assistant.
|
||||
4. Optionally delete the saved plan data: the `config/houseplan/` files (backgrounds and attachments) and the `houseplan.config` / `houseplan.layout` entries in the `config/.storage` directory.
|
||||
|
||||
---
|
||||
|
||||
## Getting help & sharing your plan
|
||||
|
||||
- 💬 **[Telegram chat — @ha_houseplan](https://t.me/ha_houseplan)** — questions,
|
||||
setup help, feature ideas, and screenshots of your plans. The fastest way to
|
||||
reach the author and other users.
|
||||
- 🐞 [GitHub issues](https://github.com/Matysh/houseplan-card/issues) — bug
|
||||
reports and feature requests (please attach your House Plan version).
|
||||
- 💡 [GitHub discussions](https://github.com/Matysh/houseplan-card/discussions) —
|
||||
longer-form ideas.
|
||||
- 📜 [Changelog](docs/CHANGELOG.md) — what changed in every version
|
||||
([на русском](docs/CHANGELOG.ru.md)).
|
||||
|
||||
When reporting a problem, the version number helps a lot: it is shown in the
|
||||
browser console on load (`HOUSEPLAN-CARD vX.Y.Z`) and in **Settings → Devices &
|
||||
Services → House Plan**.
|
||||
|
||||
---
|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
**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. 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.
|
||||
|
||||
**Is the data stored in the cloud?** No. Everything is stored locally in your Home Assistant.
|
||||
|
||||
---
|
||||
|
||||
<p align="center"><sub>Screenshots were taken on a real Home Assistant configuration.</sub></p>
|
||||
License: [MIT](LICENSE).
|
||||
|
||||
@@ -1,351 +1,161 @@
|
||||
# 🏠 House Plan — интерактивный поэтажный план дома для Home Assistant
|
||||
# 🏠 House Plan — живой план дома для Home Assistant
|
||||
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](https://demo.houseplan.tech)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · 🗂 **[Беклог проекта](https://github.com/users/Matysh/projects/1)**
|
||||
📘 **[Полное руководство](docs/USER-GUIDE.ru.md)** · 🇬🇧 **[English](README.md)** · 🗂 **[Задачи проекта](https://github.com/Matysh/houseplan-card/issues)**
|
||||
|
||||
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
|
||||
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
|
||||
своих местах: живые состояния, свет по клику, температура и влажность по
|
||||
комнатам, карта Zigbee-сигнала, светящиеся пятна ламп и полноэкранный
|
||||
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
|
||||
редакторов — весь план настраивается прямо на дашборде.
|
||||
<!-- docs-section: overview -->
|
||||
|
||||
> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и
|
||||
> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны
|
||||
> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные
|
||||
> операции могут быть менее удобны, работать ограниченно или отсутствовать.
|
||||
## Дом целиком — одним взглядом
|
||||
|
||||

|
||||
House Plan превращает Home Assistant в живую карту дома. Загрузите изображение
|
||||
плана или нарисуйте комнаты прямо на дашборде, свяжите их с зонами Home
|
||||
Assistant — и устройства появятся на плане автоматически. Сразу видно, где
|
||||
горит свет, открыта дверь, слишком холодно, слабый Zigbee-сигнал или сработал
|
||||
датчик протечки.
|
||||
|
||||
> ### 🚀 Попробовать вживую — без установки
|
||||
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — настоящий Home
|
||||
> Assistant с готовым планом. Вход **`demo`** / **`demo`**, можно нажимать всё:
|
||||
> включать свет, открывать редакторы, ломать что угодно. Каждый час стенд сам
|
||||
> возвращается в исходное состояние.
|
||||

|
||||
|
||||
🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||
Настройка выполняется в графическом интерфейсе: без YAML-разметки, Inkscape и
|
||||
внешнего редактора плана. Данные плана и расположение устройств хранятся на
|
||||
сервере Home Assistant и синхронизируются между экранами.
|
||||
|
||||
**Главное**
|
||||
> **Редактируйте на компьютере.** Режим просмотра и киоск полноценно работают
|
||||
> на телефонах и планшетах. Редакторы рассчитаны прежде всего на мышь и
|
||||
> клавиатуру; на touch отдельные операции могут быть неудобны или недоступны.
|
||||
> Подробный контракт: [поддержка touch](docs/TOUCH-SUPPORT.md).
|
||||
|
||||
- ♾️ **Бесконечный холст** — нет «размера плана» и нет края, за который
|
||||
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
|
||||
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
|
||||
план обратно в экран.
|
||||
- 🖱 **Редакторы прямо в карточке** — комнаты, двери, окна и ворота, комнаты-острова,
|
||||
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
|
||||
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
|
||||
линейка в реальных метрах.
|
||||
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
|
||||
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
|
||||
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
|
||||
освещает ровно тот пол, который видит: через проёмы и открытые границы,
|
||||
а стены, колонны и перегородки его не пропускают и дают настоящие тени.
|
||||
- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
|
||||
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
|
||||
в комнаты настоящие клинья солнечного света; облачность — опционально, от
|
||||
weather-сущности.
|
||||
- 🪟 **Шторы открываются тапом** — одно действие открывает, закрывает или
|
||||
останавливает штору, а сам значок морфится между открытым и закрытым
|
||||
видом и мягко пульсирует кольцом, пока штора едет.
|
||||
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
|
||||
температурная заливка по комфортным границам.
|
||||
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
|
||||
никогда случайным тапом.
|
||||
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
|
||||
этажами, автокарусель, свои размеры на каждом экране.
|
||||
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
|
||||
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
|
||||
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
|
||||
(по именам комнат) или перетаскиванием призрака карты. Диагностика и явный
|
||||
выбор источника поддерживают registry-less камеры карт и не подменяют молча
|
||||
сломавшуюся привязку. Работают Xiaomi Cloud Map Extractor, dreame-vacuum
|
||||
(Tasshack) и Valetudo.
|
||||
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
|
||||
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
|
||||
<!-- docs-section: features -->
|
||||
|
||||
---
|
||||
## Что умеет House Plan
|
||||
|
||||
- **Живые состояния и безопасные действия.** Свет и другие безопасные устройства
|
||||
переключаются с плана; замок нельзя открыть случайным нажатием.
|
||||
- **Три встроенных редактора.** «План» создаёт комнаты, стены и проёмы;
|
||||
«Устройства» размещает и настраивает маркеры; «Подложка» добавляет линии,
|
||||
подписи и мебель.
|
||||
- **Комнаты, связанные с зонами HA.** Новые устройства появляются автоматически,
|
||||
а карточки комнат показывают температуру, влажность, свет и средний LQI.
|
||||
- **Свет и окружение.** Заливки комнат, Glow от ламп, тени от стен, дневной фон и
|
||||
солнечные лучи из окон.
|
||||
- **Двери, окна, ворота и пылесосы.** Проёмы отражают реальные датчики и замки;
|
||||
робот показывает позицию, базу и пройденный путь.
|
||||
- **Несколько этажей и экранов.** Вкладки пространств, жесты переключения,
|
||||
локальный масштаб и отдельный стартовый этаж для каждой карточки.
|
||||
- **Киоск для настенного экрана.** Только план, полноэкранная навигация и размеры
|
||||
значков, сохранённые отдельно для этого устройства.
|
||||
|
||||
## Что это и зачем
|
||||

|
||||
|
||||
House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота.
|
||||
<!-- docs-section: first-run -->
|
||||
|
||||
Это удобно, когда:
|
||||
## Первая рабочая комната
|
||||
|
||||
- устройств много, и списками пользоваться неудобно;
|
||||
- нужно быстро понять состояние дома «одним взглядом»;
|
||||
- хочется отдать доступ близким — по картинке разберётся любой;
|
||||
- вы хотите красивый обзорный экран для настенного планшета.
|
||||
1. Установите интеграцию и добавьте карточку на дашборд.
|
||||
2. Создайте первое **пространство**: загрузите SVG/PNG/JPG/WebP либо выберите
|
||||
вариант без изображения, чтобы нарисовать план вручную.
|
||||
3. В редакторе «План» выберите **Контур комнаты**, поставьте вершины и замкните
|
||||
контур нажатием на первую точку.
|
||||
4. Назовите комнату и свяжите её с зоной Home Assistant. Для помещения без
|
||||
устройств выберите «Без зоны».
|
||||
5. Откройте «Устройства»: устройства связанной зоны уже размещены автоматически;
|
||||
перетащите маркеры в нужные места.
|
||||
6. При необходимости оформите подложку линиями, текстом и мебелью.
|
||||
7. Вернитесь в «Просмотр» — теперь план показывает живые состояния и принимает
|
||||
безопасные действия.
|
||||
|
||||
Интеграция состоит из двух частей, которые ставятся вместе:
|
||||

|
||||
|
||||
- **карточка Lovelace** `houseplan-card` — сам интерактивный план;
|
||||
- **серверный компонент** — хранит разметку комнат и позиции иконок в Home Assistant, поэтому план одинаков во всех браузерах и на всех устройствах.
|
||||

|
||||
|
||||
---
|
||||

|
||||
|
||||
## Чем отличается от аналогов
|
||||

|
||||
|
||||
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan`
|
||||
или новые GUI-карточки, где стены и мебель рисуют прямо на дашборде. Там либо
|
||||
YAML/SVG, либо конфиг живёт в YAML карточки. House Plan — это **общий живой
|
||||
план** на серверной интеграции Home Assistant:
|
||||

|
||||
|
||||
| | House Plan | picture-elements / ha-floorplan | GUI-рисовалки (напр. easy-floorplan) |
|
||||
|---|---|---|---|
|
||||
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML / Inkscape SVG | Рисование стен и мебели в карточке |
|
||||
| **Добавление устройств** | Автоматически по **зоне** HA | Каждую сущность вписываете руками | Ставите сущности руками на чертёж |
|
||||
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели в YAML | Drag на холсте |
|
||||
| **Разметка комнат** | Встроенный редактор контуров, привязка к зонам | Сторонний SVG-редактор | Сами рисуете стены (мебельный CAD) |
|
||||
| **Хранение** | На сервере HA (`.storage`, общее, multi-client) | В YAML дашборда | В карточке / YAML дашборда |
|
||||
| **Оверлеи** | Glow, климат, LQI, солнце, пылесосы, киоск | Что пропишете в SVG/CSS | Зависит от карточки |
|
||||
| **Масштаб** | Плавный векторный зум | Обычно фиксированная картинка | SVG / виртуальный холст |
|
||||
Пошаговые сценарии, все инструменты и особые случаи описаны в
|
||||
[полном руководстве](docs/USER-GUIDE.ru.md). Возможности подложки отдельно
|
||||
зафиксированы в [документе редактора](docs/DECOR-EDITOR.md), а роботов — в
|
||||
[руководстве по пылесосам](docs/VACUUM.md).
|
||||
|
||||
**Одной фразой:** House Plan — это общая, area-aware живая карта дома, а не
|
||||
универсальная CAD-система. Редактор подложки покрывает практический декор,
|
||||
надписи и мебель; для свободного архитектурного черчения лучше отдельный
|
||||
draw-инструмент. При наличии плана и зон HA House Plan держит один живой layout
|
||||
на всех планшетах.
|
||||
|
||||
Ключевые преимущества коротко:
|
||||
|
||||
- **Никакого кода.** Всё — пространства, комнаты, устройства — настраивается кликами.
|
||||
- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане.
|
||||
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
|
||||
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
|
||||
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
|
||||
лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка
|
||||
реально греет (а не просто включена). Для climate-сущностей переданное действие приоритетно;
|
||||
если интеграция сообщает только включённый HVAC-режим, он служит лучшим доступным приближением.
|
||||
Оранжевый = открыто / не заперто. Пульсирующее красное
|
||||
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
|
||||
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
|
||||
Полупрозрачный значок = недоступно. Тёмный = покой.
|
||||
- **Единый визуальный редактор подложки.** Линии, фигуры, надписи и мебель используют общее выделение, физические стили и Undo/Redo. Картинка плана не прибита к холсту: отдельный инструмент двигает, масштабирует и поворачивает её, а числовой диалог задаёт точный размер и угол.
|
||||
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
|
||||
|
||||
---
|
||||
|
||||
## Настенный планшет / ТВ (киоск-режим)
|
||||
|
||||
Отдельный дашборд с view типа «панель», у карточки — `kiosk: true` (или
|
||||
галочка «Режим настенного устройства» в редакторе карточки):
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
kiosk: true
|
||||
cycle: 0 # автосмена пространств каждые N секунд, 0 = выкл (удобно для ТВ)
|
||||
```
|
||||
|
||||
Без шапки и редакторов — только живой план. Свайп листает этажи (при 1:1),
|
||||
пинч — зум, двойной тап — сброс. Долгое нажатие (3 с) по пустому месту —
|
||||
настройка размеров значков и текста для ЭТОГО экрана (хранится на
|
||||
устройстве). Шапку самого Home Assistant скрывают настройки companion-app
|
||||
или плагин [kiosk-mode](https://github.com/NemesisRE/kiosk-mode).
|
||||
<!-- docs-section: installation -->
|
||||
|
||||
## Установка
|
||||
|
||||
В один клик, если у вас уже есть HACS:
|
||||
### Через HACS
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
1. В HACS откройте **⋮ → Пользовательские репозитории**.
|
||||
2. Добавьте `https://github.com/Matysh/houseplan-card` с типом **Интеграция**.
|
||||
3. Установите House Plan и перезапустите Home Assistant.
|
||||
4. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
|
||||
|
||||
### Через HACS (рекомендуется)
|
||||
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
|
||||
добавьте именно URL, который публикует интеграция:
|
||||
|
||||
1. Откройте **HACS → меню (⋮) → Custom repositories**.
|
||||
2. Вставьте URL этого репозитория, категория — **Integration**, и нажмите **Add**.
|
||||
3. Найдите в списке **House Plan**, установите и **перезапустите Home Assistant**.
|
||||
4. Перейдите в **Настройки → Устройства и службы → Добавить интеграцию** и выберите **House Plan**.
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
Карточка подключается автоматически — добавлять ресурс Lovelace вручную не нужно.
|
||||
|
||||
> **Карточка не грузится (`Custom element doesn't exist: houseplan-card`) или вы ведёте ресурсы
|
||||
> Lovelace в YAML?** Добавьте ресурс вручную, указав URL, который *раздаёт сама интеграция*:
|
||||
>
|
||||
> ```yaml
|
||||
> resources:
|
||||
> - url: /houseplan_files/houseplan-card.js
|
||||
> type: module
|
||||
> ```
|
||||
>
|
||||
> **Не** используйте `/custom_components/houseplan/frontend/houseplan-card.js` — это путь к файлу
|
||||
> на диске, который Home Assistant не отдаёт по HTTP (получите ошибку MIME `text/plain`, и элемент
|
||||
> не зарегистрируется). Правильный URL, который раздаёт интеграция, — `/houseplan_files/houseplan-card.js`.
|
||||
> Обе карточки (`houseplan-card` и `houseplan-space-card`) лежат в этом одном файле — отдельный ресурс
|
||||
> не нужен.
|
||||
Не используйте путь к файлу внутри `custom_components`: Home Assistant не
|
||||
публикует его как JavaScript-модуль.
|
||||
|
||||
### Вручную
|
||||
|
||||
1. Скопируйте папку `custom_components/houseplan` в каталог `config/custom_components` вашего Home Assistant.
|
||||
2. Перезапустите Home Assistant.
|
||||
3. Добавьте интеграцию: **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
|
||||
Скопируйте `custom_components/houseplan` в `config/custom_components`,
|
||||
перезапустите Home Assistant и добавьте интеграцию House Plan.
|
||||
|
||||
### Добавление экрана с планом
|
||||
### Добавление карточки
|
||||
|
||||
Создайте новую вкладку дашборда (удобнее всего — в режиме «Панель»/Panel) и добавьте карточку:
|
||||
Создайте представление дашборда (лучше Panel) и добавьте карточку через UI либо:
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
title: План дома
|
||||
```
|
||||
|
||||
Больше ничего указывать не нужно — всё остальное настраивается прямо на экране.
|
||||
Для нескольких экранов можно задать разные стартовые пространства:
|
||||
|
||||
---
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
default_floor: ground
|
||||
```
|
||||
|
||||
## Как пользоваться
|
||||
Все карточки используют общие серверные комнаты и координаты. Текущий режим,
|
||||
масштаб и выбранное пространство локальны для экрана. Одновременное
|
||||
редактирование поддерживает синхронизацию и проверку ревизий, но один объект
|
||||
лучше не менять параллельно в двух браузерах.
|
||||
|
||||
### Шаг 1. Добавьте пространство (этаж)
|
||||
## Где искать подробности
|
||||
|
||||
При первом открытии план ещё пуст — House Plan сразу предложит создать первое пространство.
|
||||
- [Полное руководство пользователя](docs/USER-GUIDE.ru.md)
|
||||
- [Матрица mouse/touch/keyboard](docs/USER-GUIDE.ru.md#6-навигация-масштаб-и-жесты)
|
||||
- [Инструменты плана](docs/USER-GUIDE.ru.md#инструменты-плана-в-короткой-таблице)
|
||||
- [Редактор подложки](docs/DECOR-EDITOR.md)
|
||||
- [Роботы-пылесосы](docs/VACUUM.md)
|
||||
- [Поддержка touch](docs/TOUCH-SUPPORT.md)
|
||||
|
||||
Если в вашем Home Assistant уже настроены **этажи**, мастер предложит создать
|
||||
пространство для каждого: названия подставятся сами, план попросит по очереди,
|
||||
любой этаж можно пропустить.
|
||||
<!-- docs-section: support -->
|
||||
|
||||

|
||||
## Помощь и обратная связь
|
||||
|
||||
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка по умолчанию сохраняет пропорции, а подвинуть, изменить размер или повернуть её можно в любой момент в редакторе подложки.
|
||||
- Вопросы и примеры планов: [Telegram @ha_houseplan](https://t.me/ha_houseplan).
|
||||
- Баги и предложения: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues).
|
||||
- Перед отчётом обновите House Plan, перезапустите HA и выполните жёсткое
|
||||
обновление страницы (`Ctrl+F5`). Приложите версию, браузер, логи и шаги
|
||||
воспроизведения; приватные entity ID можно заменить вымышленными.
|
||||
|
||||

|
||||
Скриншоты в документации получены воспроизводимой командой
|
||||
`npm run build && node demo/docs/capture.mjs` только на синтетических данных. Версия сценариев,
|
||||
fingerprint исходников и хеш каждого изображения находятся в
|
||||
[индексе снимков](docs/images/screenshots.json).
|
||||
|
||||
> 💡 Подложку можно нарисовать в любом планировщике (например, РЕМПЛАННЕР) или сфотографировать бумажный план. Лучше всего SVG — он остаётся чётким при увеличении.
|
||||
|
||||
Позже можно добавить сколько угодно пространств (этажи, двор, гараж) кнопкой **+** рядом со вкладками.
|
||||
|
||||
### Шаг 2. Обведите комнаты
|
||||
|
||||
После добавления первого пространства карточка сама переходит в режим разметки. Кликайте по точкам сетки, соединяя их линиями, и замкните контур комнаты кликом по первой точке.
|
||||
|
||||
Как только контур замкнётся, появится окно сохранения комнаты. Здесь нужно **привязать комнату к зоне Home Assistant** — именно это включает автоматику. Для служебных помещений без устройств (холл, сауна) есть кнопка **«Без зоны»**.
|
||||
|
||||

|
||||
|
||||
Во время рисования у курсора показывается линейка с реальной длиной текущего отрезка (метры или футы+дюймы на имперской системе HA). Масштаб задаётся для каждого пространства — поле **«Масштаб (размер ячейки сетки)»** в диалоге пространства: сколько сантиметров в одной ячейке (по умолчанию 5 см).
|
||||
|
||||
Комнаты не могут пересекаться: клик строго внутри существующей комнаты или контур, охватывающий её, отклоняются. Ещё два инструмента помогают перекроить план позже:
|
||||
|
||||
- **Объединить** — кликните комнату, затем соседнюю с общей стеной; они сольются в одну. Диалог выбирает, чьё имя и зона останутся.
|
||||
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
|
||||
|
||||
|
||||
### Двери, окна, ворота и замки
|
||||
|
||||
В режиме разметки инструмент **«Проём»** ставит двери, окна и ворота: кликните рядом со стеной — проём
|
||||
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
|
||||
окно 120 см, ворота 300 см), датчик открытия и — для двери или ворот — **замок**.
|
||||
|
||||
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
|
||||
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
|
||||
подвижные части подсвечены акцентным цветом. Ворота не занимают полплана даже при ширине 3–4 м: две половинные створки показаны открытыми наружу всего на 10°, без большой дуги. Датчик, замок и пропуск света работают как у двери. У двери или ворот с замком рядом отображается замочек —
|
||||
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
|
||||
переключить — клик по проёму показывает карточку с обоими статусами.
|
||||
|
||||
Проёмы легко поправить позже: при наведении проём подсвечивается, его можно **перетащить вдоль
|
||||
стен** (в том числе за угол), а **двойной клик открывает свойства**.
|
||||
|
||||
### Шаг 3. Устройства появляются сами
|
||||
|
||||
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
|
||||
|
||||
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти их, откройте редактор устройств и нажмите **«Скрытые и деактивированные»**: пользовательски скрытые устройства отображаются синими призраками. Деактивированное в HA устройство показывается серым служебным призраком и полностью исключается из данных и действий плана до повторной активации.
|
||||
|
||||
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
|
||||
|
||||

|
||||
|
||||
### Шаг 4. Масштаб
|
||||
|
||||
Колесо мыши или кнопки **- / ⊹ / +** приближают и отдаляют план; на сенсорном экране работает «щипок» двумя пальцами. При отдалении виден весь план целиком, при приближении — детали, и всё остаётся чётким. Масштаб запоминается отдельно для каждого пространства.
|
||||
|
||||

|
||||
|
||||
### Шаг 5. Расставьте значки по местам
|
||||
|
||||
Расставлять значки нужно на вкладке **«Устройства»**: там они перетаскиваются мышью, а клик открывает редактор. В режиме **«Просмотр»** ничего сдвинуть нельзя — панорамирование карты больше не сдвигает датчики (главная просьба пользователей). Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** возвращает автоматическую раскладку.
|
||||
|
||||

|
||||
|
||||
### Управление с плана (tap actions)
|
||||
|
||||
По умолчанию тап по значку открывает инфо-карточку. В настройках карточки можно
|
||||
переключить **«Тап по устройству»** на *Переключить* — тогда тап включает/выключает
|
||||
свет, розетки, вентиляторы и увлажнители прямо с плана (режим настенного планшета).
|
||||
Для безопасности общий toggle не действует на замки, сигнализации, шторы/ворота и
|
||||
клапаны; для конкретного устройства toggle можно включить осознанно в его диалоге
|
||||
(кроме замков и сигнализаций — они с плана не переключаются никогда). **Долгое
|
||||
нажатие** всегда открывает инфо-карточку.
|
||||
|
||||
### Правила иконок
|
||||
|
||||
Какая MDI-иконка достанется устройству, решают **правила иконок** — редактируются
|
||||
прямо в карточке (кнопка ⬡ в шапке): упорядоченный список «шаблон имени → иконка»
|
||||
с живым тест-полем, двуязычные умолчания (EN/RU) и сброс одной кнопкой. Если ни одно
|
||||
правило не подошло — решает *device class* сущности (термометр для датчиков
|
||||
температуры и т.п.).
|
||||
|
||||
### Шаг 6. Добавление своих устройств вручную
|
||||
|
||||
Можно поставить и **отдельную сущность** (не только устройство целиком): начните печатать в поиске привязки — рядом с устройствами появятся отдельные сущности. Удобно, когда одно устройство отдаёт несколько значений (например, температуру и влажность), а вы хотите каждое своей иконкой.
|
||||
|
||||
|
||||
Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**.
|
||||
|
||||
В этом же диалоге настраивается вид устройства на плане. **Отображение** переключает значок,
|
||||
анимированную **пульсацию присутствия** (расходящиеся кольца, пока сущность активна, и тусклая
|
||||
точка в покое — идеально для датчиков движения) или то и другое сразу, с цветом и размером колец
|
||||
на устройство. **Размер значка** (×0,5–3) и **поворот** — тоже индивидуальные: вентиль на стене
|
||||
может быть маленьким и повёрнутым так, как он установлен.
|
||||
|
||||

|
||||
|
||||
### Свои стили через card-mod (для продвинутых, без поддержки)
|
||||
|
||||
Карточка приезжает готовой, и поля для CSS у неё нет — но если у вас уже стоит [card-mod](https://github.com/thomasloven/lovelace-card-mod), у каждого объекта плана теперь есть стабильный «крючок», за который можно зацепиться: `data-hp="device"` (плюс `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. Мы обещаем их не переименовывать; сам card-mod мы не поставляем, не поддерживаем и за то, что ваш CSS сделает с карточкой, не отвечаем. Полная таблица, примеры и ограничения — в **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Удаление
|
||||
|
||||
1. Уберите карточку (или вкладку с планом) из дашборда.
|
||||
2. **Настройки → Устройства и службы → House Plan → Удалить** запись интеграции.
|
||||
3. Удалите интеграцию из **HACS** (или папку `custom_components/houseplan` при ручной установке) и перезапустите Home Assistant.
|
||||
4. При желании удалите сохранённые данные плана: файлы `config/houseplan/` (подложки и вложения) и записи `houseplan.config` / `houseplan.layout` в каталоге `config/.storage`.
|
||||
|
||||
---
|
||||
|
||||
## Помощь и обмен опытом
|
||||
|
||||
- 💬 **[Чат в Telegram — @ha_houseplan](https://t.me/ha_houseplan)** — вопросы,
|
||||
помощь с настройкой, идеи и скриншоты ваших планов. Самый быстрый способ
|
||||
связаться с автором и другими пользователями.
|
||||
- 🐞 [Issues на GitHub](https://github.com/Matysh/houseplan-card/issues) — баги
|
||||
и запросы фич (пожалуйста, указывайте версию House Plan).
|
||||
- 💡 [Discussions](https://github.com/Matysh/houseplan-card/discussions) — для
|
||||
развёрнутых обсуждений.
|
||||
- 📜 [История изменений](docs/CHANGELOG.ru.md) — что менялось в каждой версии.
|
||||
|
||||
Версия видна в консоли браузера при загрузке (`HOUSEPLAN-CARD vX.Y.Z`) и в
|
||||
**Настройки → Устройства и службы → House Plan** — с ней разбираться сильно
|
||||
быстрее.
|
||||
|
||||
---
|
||||
|
||||
## Часто задаваемые вопросы
|
||||
|
||||
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
|
||||
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Откройте **«Скрытые и деактивированные»**: синий призрак можно показать в его диалоге, серый сначала нужно активировать в Home Assistant.
|
||||
|
||||
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
|
||||
|
||||
**Данные хранятся в облаке?** Нет. Всё хранится локально в вашем Home Assistant.
|
||||
|
||||
---
|
||||
|
||||
<p align="center"><sub>Скриншоты сделаны на реальной конфигурации Home Assistant.</sub></p>
|
||||
Лицензия: [MIT](LICENSE).
|
||||
|
||||
@@ -24,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, async_save_layout_state, create_data
|
||||
from .store import (
|
||||
HouseplanConfigEntry,
|
||||
async_save_config_state,
|
||||
async_save_layout_state,
|
||||
create_data,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,6 +65,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
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
|
||||
@@ -152,7 +161,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
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 async_save_layout_state(
|
||||
data, lay_stored, layout, lay_rev + 1, remove=("geom_pending",)
|
||||
@@ -186,11 +195,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
"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 data.config_store.async_save({
|
||||
"config": target_config,
|
||||
"rev": 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")
|
||||
|
||||
@@ -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,7 +46,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.63.0-beta.1"
|
||||
VERSION = "1.67.0-beta.1"
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
@@ -64,5 +65,5 @@ MAX_IMPORT_PREVIEWS_TOTAL = 3
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
"markers": [],
|
||||
"settings": {},
|
||||
"settings": {"bg_mode": "daynight"},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Lossless, allow-listed canonicalisation of persisted geometry.
|
||||
|
||||
The frontend mirrors this module in src/coordinate-canonicalization.ts.
|
||||
Keep the precision, scalar formula and field allow-list in lockstep; a shared
|
||||
fixture is exercised by both runtimes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
COORDINATE_DECIMALS = 9
|
||||
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
|
||||
|
||||
|
||||
def canonicalize_number(value: Any) -> Any:
|
||||
"""Return one stable IEEE-754 representation for an allow-listed number."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return value
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
return value
|
||||
sign = -1.0 if math.copysign(1.0, number) < 0 else 1.0
|
||||
result = sign * (
|
||||
math.floor(abs(number) * COORDINATE_FACTOR + 0.5)
|
||||
/ COORDINATE_FACTOR
|
||||
)
|
||||
if result == 0:
|
||||
return 0.0
|
||||
return result
|
||||
|
||||
|
||||
def _record(value: Any) -> dict[str, Any] | None:
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _records(value: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
|
||||
for name in names:
|
||||
if name in record:
|
||||
record[name] = canonicalize_number(record[name])
|
||||
|
||||
|
||||
def _point(value: Any) -> None:
|
||||
if not isinstance(value, list):
|
||||
return
|
||||
for index in range(min(2, len(value))):
|
||||
value[index] = canonicalize_number(value[index])
|
||||
|
||||
|
||||
def _points(value: Any) -> None:
|
||||
if not isinstance(value, list):
|
||||
return
|
||||
for point in value:
|
||||
_point(point)
|
||||
|
||||
|
||||
def canonicalize_position(position: Any) -> Any:
|
||||
"""Canonicalise x/y in one layout record without touching its metadata."""
|
||||
result = copy.deepcopy(position)
|
||||
record = _record(result)
|
||||
if record is not None:
|
||||
_fields(record, ("x", "y"))
|
||||
return result
|
||||
|
||||
|
||||
def canonicalize_layout_geometry(layout: Any) -> Any:
|
||||
"""Canonicalise x/y in every layout record, preserving future fields."""
|
||||
result = copy.deepcopy(layout)
|
||||
record = _record(result)
|
||||
if record is None:
|
||||
return result
|
||||
for position in record.values():
|
||||
item = _record(position)
|
||||
if item is not None:
|
||||
_fields(item, ("x", "y"))
|
||||
return result
|
||||
|
||||
|
||||
def canonicalize_config_geometry(config: Any) -> Any:
|
||||
"""Canonicalise only the named persisted geometry fields."""
|
||||
result = copy.deepcopy(config)
|
||||
root = _record(result)
|
||||
if root is None:
|
||||
return result
|
||||
|
||||
for space in _records(root.get("spaces")):
|
||||
_fields(
|
||||
space,
|
||||
(
|
||||
"plan_x",
|
||||
"plan_y",
|
||||
"plan_scale",
|
||||
"plan_scale_x",
|
||||
"plan_scale_y",
|
||||
"plan_angle",
|
||||
),
|
||||
)
|
||||
|
||||
for room in _records(space.get("rooms")):
|
||||
_fields(room, ("x", "y", "w", "h"))
|
||||
_points(room.get("poly"))
|
||||
|
||||
for wall in _records(space.get("walls")):
|
||||
_point(wall.get("a"))
|
||||
_point(wall.get("b"))
|
||||
|
||||
for opening in _records(space.get("openings")):
|
||||
_fields(opening, ("x", "y", "angle", "length"))
|
||||
host = _record(opening.get("host"))
|
||||
if host is not None:
|
||||
_fields(host, ("t",))
|
||||
|
||||
for decor in _records(space.get("decor")):
|
||||
kind = decor.get("kind")
|
||||
if kind == "line":
|
||||
_fields(decor, ("x1", "y1", "x2", "y2"))
|
||||
elif kind in ("rect", "ellipse", "furniture"):
|
||||
_fields(decor, ("x", "y", "w", "h", "angle"))
|
||||
elif kind == "text":
|
||||
_fields(decor, ("x", "y", "scale", "angle"))
|
||||
|
||||
for draft in _records(space.get("room_drafts")):
|
||||
_points(draft.get("points"))
|
||||
|
||||
for partition in _records(space.get("partitions")):
|
||||
_point(partition.get("a"))
|
||||
_point(partition.get("b"))
|
||||
|
||||
for column in _records(space.get("wall_columns")):
|
||||
_point(column.get("center"))
|
||||
if column.get("shape") == "square":
|
||||
_fields(column, ("angle",))
|
||||
|
||||
for span in _records(space.get("open_spans")):
|
||||
_point(span.get("a"))
|
||||
_point(span.get("b"))
|
||||
|
||||
for marker in _records(root.get("markers")):
|
||||
_fields(marker, ("angle",))
|
||||
|
||||
return result
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
@@ -16,6 +17,7 @@ import unicodedata
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -47,12 +49,44 @@ from .validation import (
|
||||
validate_marker_controls,
|
||||
validate_marker_light_entities,
|
||||
validate_marker_value_badges,
|
||||
validate_opening_passages, validate_partition_opening_hosts,
|
||||
MarkerControlError,
|
||||
OpeningPassageError,
|
||||
PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
)
|
||||
|
||||
FORMAT = "houseplan-export"
|
||||
_PROTO_KEYS = {"__proto__", "prototype", "constructor"}
|
||||
_SAFE_FILE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
_LIVE_TEXT_TOKEN = re.compile(r"\{([^{}\r\n]+)\}")
|
||||
_LIVE_TEXT_ENTITY = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
||||
_LIVE_TEXT_ATTRIBUTE = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
||||
_PLAN_ONLY_DASH = "—"
|
||||
|
||||
_SPACE_PLAN_FIELDS = (
|
||||
"id", "title", "cell_cm", "plan_url", "plan_aspect", "plan_x", "plan_y",
|
||||
"plan_scale", "plan_scale_x", "plan_scale_y", "plan_angle", "view_box",
|
||||
)
|
||||
_SPACE_DISPLAY_FIELDS = (
|
||||
"show_borders", "show_names", "room_color", "bg_color", "room_opacity",
|
||||
"fill_mode", "custom_fill", "glow_enabled", "temp_min", "temp_max",
|
||||
"show_lqi", "hide_decor", "hide_openings", "label_temp", "label_hum",
|
||||
"label_lqi", "label_light", "card_font_scale", "north_deg", "bg_mode",
|
||||
"sun_rays",
|
||||
)
|
||||
_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly")
|
||||
_ROOM_DISPLAY_FIELDS = (
|
||||
"fill_mode", "custom_fill", "glow", "name_scale", "label_scale",
|
||||
)
|
||||
_DECOR_COMMON_FIELDS = ("id", "kind", "color", "opacity", "width_cm", "width")
|
||||
_DECOR_KIND_FIELDS = {
|
||||
"line": ("x1", "y1", "x2", "y2", "line_style"),
|
||||
"rect": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
|
||||
"ellipse": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
|
||||
"text": ("x", "y", "text", "size", "size_cm", "scale", "angle"),
|
||||
"furniture": ("symbol", "x", "y", "w", "h", "angle"),
|
||||
}
|
||||
|
||||
|
||||
class ImportFailure(Exception):
|
||||
@@ -73,6 +107,35 @@ def _json_copy(value: Any) -> Any:
|
||||
return json.loads(json.dumps(value, ensure_ascii=False, allow_nan=False))
|
||||
|
||||
|
||||
def _background_mode(settings: Any) -> str | None:
|
||||
if not isinstance(settings, dict):
|
||||
return None
|
||||
mode = settings.get("bg_mode")
|
||||
return mode if mode in ("static", "daynight") else None
|
||||
|
||||
|
||||
def _materialize_global_background(config: dict[str, Any], fallback: str = "static") -> str:
|
||||
"""Make the global mode portable instead of relying on a code default."""
|
||||
settings = config.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
settings = {}
|
||||
config["settings"] = settings
|
||||
mode = _background_mode(settings) or fallback
|
||||
settings["bg_mode"] = mode
|
||||
return mode
|
||||
|
||||
|
||||
def _materialize_space_background(space: dict[str, Any], fallback: str) -> str:
|
||||
"""Make one space independent from the target installation's default."""
|
||||
settings = space.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
settings = {}
|
||||
space["settings"] = settings
|
||||
mode = _background_mode(settings) or fallback
|
||||
settings["bg_mode"] = mode
|
||||
return mode
|
||||
|
||||
|
||||
def _stored_model_version(config: dict[str, Any]) -> int:
|
||||
"""Return the model actually stored without silently upgrading it."""
|
||||
value = config.get("model_version", 0)
|
||||
@@ -110,6 +173,136 @@ def live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any
|
||||
}
|
||||
|
||||
|
||||
def _pick_fields(source: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]:
|
||||
"""Copy only fields explicitly classified as portable plan data."""
|
||||
return {key: _json_copy(source[key]) for key in fields if key in source}
|
||||
|
||||
|
||||
def _is_live_text_reference(raw: str) -> bool:
|
||||
"""Mirror ``liveTextReference`` without evaluating Home Assistant state."""
|
||||
ref = raw.strip()
|
||||
if not ref:
|
||||
return False
|
||||
entity = ref
|
||||
attribute = ""
|
||||
colon = ref.find(":")
|
||||
if colon >= 0:
|
||||
entity = ref[:colon].strip()
|
||||
attribute = ref[colon + 1:].strip()
|
||||
else:
|
||||
parts = ref.split(".")
|
||||
if len(parts) > 2:
|
||||
entity = ".".join(parts[:2])
|
||||
attribute = ".".join(parts[2:])
|
||||
if _LIVE_TEXT_ENTITY.fullmatch(entity) is None:
|
||||
return False
|
||||
if colon >= 0 and not attribute:
|
||||
return False
|
||||
return not attribute or _LIVE_TEXT_ATTRIBUTE.fullmatch(attribute) is not None
|
||||
|
||||
|
||||
def _plan_only_text(value: str) -> str:
|
||||
"""Freeze every recognized live reference while preserving authored copy."""
|
||||
replaced = _LIVE_TEXT_TOKEN.sub(
|
||||
lambda match: _PLAN_ONLY_DASH
|
||||
if _is_live_text_reference(match.group(1)) else match.group(0),
|
||||
value,
|
||||
)
|
||||
return replaced.replace("{}", _PLAN_ONLY_DASH)
|
||||
|
||||
|
||||
def _project_plan_only_room(room: dict[str, Any]) -> dict[str, Any]:
|
||||
projected = _pick_fields(room, _ROOM_PLAN_FIELDS)
|
||||
if "settings" in room:
|
||||
settings = room.get("settings")
|
||||
projected["settings"] = (
|
||||
_pick_fields(settings, _ROOM_DISPLAY_FIELDS)
|
||||
if isinstance(settings, dict) else None
|
||||
)
|
||||
return projected
|
||||
|
||||
|
||||
def _project_plan_only_decor(shape: dict[str, Any]) -> dict[str, Any]:
|
||||
kind = str(shape.get("kind", ""))
|
||||
projected = _pick_fields(
|
||||
shape, _DECOR_COMMON_FIELDS + _DECOR_KIND_FIELDS.get(kind, ()),
|
||||
)
|
||||
if kind == "text" and isinstance(projected.get("text"), str):
|
||||
projected["text"] = _plan_only_text(projected["text"])
|
||||
return projected
|
||||
|
||||
|
||||
def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the fail-closed geometry/presentation projection for #167."""
|
||||
projected = _pick_fields(space, _SPACE_PLAN_FIELDS)
|
||||
if "settings" in space:
|
||||
projected["settings"] = _pick_fields(
|
||||
space.get("settings") or {}, _SPACE_DISPLAY_FIELDS,
|
||||
)
|
||||
projected["rooms"] = [
|
||||
_project_plan_only_room(room) for room in space.get("rooms") or []
|
||||
]
|
||||
collections: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("walls", ("key", "cm", "a", "b")),
|
||||
("room_drafts", ("id", "points", "segments")),
|
||||
("partitions", ("id", "a", "b", "cm")),
|
||||
("wall_columns", ("id", "shape", "center", "cm", "angle")),
|
||||
("open_spans", ("a", "b")),
|
||||
)
|
||||
for name, fields in collections:
|
||||
if name not in space:
|
||||
continue
|
||||
values = []
|
||||
for item in space.get(name) or []:
|
||||
selected = _pick_fields(item, fields)
|
||||
if name == "room_drafts" and "segments" in selected:
|
||||
selected["segments"] = [
|
||||
_pick_fields(segment, ("cm",))
|
||||
for segment in selected.get("segments") or []
|
||||
]
|
||||
values.append(selected)
|
||||
projected[name] = values
|
||||
if "openings" in space:
|
||||
projected["openings"] = [
|
||||
_pick_fields(
|
||||
opening,
|
||||
("id", "type", "x", "y", "angle", "length")
|
||||
+ (() if opening.get("type") == "passage" else ("flip_h", "flip_v"))
|
||||
+ (("host",) if opening.get("host") else ()),
|
||||
)
|
||||
for opening in space.get("openings") or []
|
||||
]
|
||||
if "decor" in space:
|
||||
projected["decor"] = [
|
||||
_project_plan_only_decor(shape) for shape in space.get("decor") or []
|
||||
]
|
||||
return projected
|
||||
|
||||
|
||||
def _plan_only_room_label_layout(
|
||||
layout: dict[str, Any], space: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
space_id = str(space.get("id", ""))
|
||||
room_ids = {str(room.get("id", "")) for room in space.get("rooms") or []}
|
||||
projected: dict[str, Any] = {}
|
||||
for key, pos in layout.items():
|
||||
if not (
|
||||
isinstance(key, str)
|
||||
and key.startswith("rl_")
|
||||
and key[3:] in room_ids
|
||||
and isinstance(pos, dict)
|
||||
and str(pos.get("s", "")) == space_id
|
||||
):
|
||||
continue
|
||||
value = _pick_fields(pos, ("x", "y", "s"))
|
||||
scale = pos.get("k")
|
||||
if isinstance(scale, (int, float)) and not isinstance(scale, bool) \
|
||||
and math.isfinite(scale) and 0.5 <= scale <= 3:
|
||||
value["k"] = scale
|
||||
projected[key] = value
|
||||
return projected
|
||||
|
||||
|
||||
def _marker_owned(marker: dict[str, Any], space: dict[str, Any], layout: dict[str, Any]) -> bool:
|
||||
marker_id = str(marker.get("id", ""))
|
||||
room_ids = {str(room.get("id")) for room in space.get("rooms") or []}
|
||||
@@ -156,6 +349,24 @@ def placement_manifest(config: dict[str, Any], layout: dict[str, Any]) -> list[d
|
||||
|
||||
|
||||
def _internal_path(root: Path, url: str) -> tuple[str, Path] | None:
|
||||
# A url is parsed as a url, not as a string: everything after "?" or "#"
|
||||
# addresses the transfer, never the file. Legacy attachments carry a
|
||||
# cache-buster (".../files/m1/doc.pdf?v=1783170649"), and while the string
|
||||
# form fed "doc.pdf?v=1783170649" to sanitize_filename the name never
|
||||
# matched itself — the reference read as internal-but-non-canonical and
|
||||
# every backup holding one refused to import (issue #225). Path segments
|
||||
# keep doing the guarding: dropping the query cannot widen what a segment
|
||||
# is allowed to be.
|
||||
#
|
||||
# Only a same-document reference may be trusted this way: with a scheme or
|
||||
# an authority the path belongs to another host, and taking it would let
|
||||
# "https://evil.example/houseplan_files/files/m1/doc.pdf" resolve onto a
|
||||
# local file (review CODE-REVIEW-225-r1, M1). Such a url stays external,
|
||||
# which is also what _looks_internal says about it.
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme or parsed.netloc:
|
||||
return None
|
||||
url = parsed.path
|
||||
content_plan = CONTENT_URL + "/plans/_/"
|
||||
if url.startswith(content_plan) or url.startswith(PLANS_URL + "/"):
|
||||
prefix = content_plan if url.startswith(content_plan) else PLANS_URL + "/"
|
||||
@@ -225,9 +436,12 @@ def create_export(
|
||||
*,
|
||||
kind: str,
|
||||
space_id: str | None,
|
||||
plan_only: bool = False,
|
||||
card_version: str,
|
||||
config_root: Path,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
if not isinstance(plan_only, bool) or plan_only and kind != "space":
|
||||
raise ImportFailure("invalid_format", "Plan-only export requires one space")
|
||||
raw_config = _json_copy(
|
||||
config_data.get("config") or {"spaces": [], "markers": [], "settings": {}}
|
||||
)
|
||||
@@ -239,6 +453,7 @@ def create_export(
|
||||
# payload would either duplicate it or tempt an exporter to silently stamp
|
||||
# the current version over an older/future stored model.
|
||||
config.pop("model_version", None)
|
||||
global_background = _materialize_global_background(config)
|
||||
layout = live_layout(config, LAYOUT_SCHEMA(_json_copy(layout_data.get("layout") or {})))
|
||||
stamp = datetime.now(UTC).strftime("%Y-%m-%d_%H-%M-%S")
|
||||
title = ""
|
||||
@@ -247,41 +462,47 @@ def create_export(
|
||||
space = next((sp for sp in config.get("spaces") or [] if str(sp.get("id")) == space_id), None)
|
||||
if not space:
|
||||
raise ImportFailure("space_not_found", "Space was not found")
|
||||
_materialize_space_background(space, global_background)
|
||||
title = str(space.get("title") or space.get("id") or "space")
|
||||
selected_layout = {
|
||||
key: pos for key, pos in layout.items()
|
||||
if isinstance(pos, dict) and str(pos.get("s")) == str(space_id)
|
||||
}
|
||||
selected_markers = [
|
||||
m for m in config.get("markers") or []
|
||||
if m.get("removed") is not True and _marker_owned(m, space, selected_layout)
|
||||
]
|
||||
selected_ids = {str(marker.get("id")) for marker in selected_markers}
|
||||
for marker in selected_markers:
|
||||
controls = marker.get("controls")
|
||||
if isinstance(controls, list):
|
||||
kept = []
|
||||
for ref in controls:
|
||||
if isinstance(ref, str) and ref.startswith("marker:") \
|
||||
and ref[len("marker:"):] not in selected_ids:
|
||||
dropped_marker_links += 1
|
||||
continue
|
||||
kept.append(ref)
|
||||
marker["controls"] = kept or None
|
||||
badge = marker.get("value_badge")
|
||||
source = badge.get("source") if isinstance(badge, dict) else None
|
||||
ref = source.get("ref") if isinstance(source, dict) \
|
||||
and source.get("kind") == "derived_marker_state" else None
|
||||
if isinstance(ref, str) and ref.startswith("marker:") \
|
||||
and ref[len("marker:"):] not in selected_ids:
|
||||
badge["enabled"] = False
|
||||
badge["source"] = None
|
||||
dropped_marker_links += 1
|
||||
config = {
|
||||
"spaces": [_json_copy(space)],
|
||||
"markers": _json_copy(selected_markers),
|
||||
}
|
||||
layout = selected_layout
|
||||
if plan_only:
|
||||
projected_space = _project_plan_only_space(space)
|
||||
config = {"spaces": [projected_space], "markers": []}
|
||||
layout = _plan_only_room_label_layout(selected_layout, projected_space)
|
||||
else:
|
||||
selected_markers = [
|
||||
m for m in config.get("markers") or []
|
||||
if m.get("removed") is not True and _marker_owned(m, space, selected_layout)
|
||||
]
|
||||
selected_ids = {str(marker.get("id")) for marker in selected_markers}
|
||||
for marker in selected_markers:
|
||||
controls = marker.get("controls")
|
||||
if isinstance(controls, list):
|
||||
kept = []
|
||||
for ref in controls:
|
||||
if isinstance(ref, str) and ref.startswith("marker:") \
|
||||
and ref[len("marker:"):] not in selected_ids:
|
||||
dropped_marker_links += 1
|
||||
continue
|
||||
kept.append(ref)
|
||||
marker["controls"] = kept or None
|
||||
badge = marker.get("value_badge")
|
||||
source = badge.get("source") if isinstance(badge, dict) else None
|
||||
ref = source.get("ref") if isinstance(source, dict) \
|
||||
and source.get("kind") == "derived_marker_state" else None
|
||||
if isinstance(ref, str) and ref.startswith("marker:") \
|
||||
and ref[len("marker:"):] not in selected_ids:
|
||||
badge["enabled"] = False
|
||||
badge["source"] = None
|
||||
dropped_marker_links += 1
|
||||
config = {
|
||||
"spaces": [_json_copy(space)],
|
||||
"markers": _json_copy(selected_markers),
|
||||
}
|
||||
layout = selected_layout
|
||||
elif kind != "full":
|
||||
raise ImportFailure("invalid_format", "Unknown export kind")
|
||||
document = {
|
||||
@@ -296,7 +517,10 @@ def create_export(
|
||||
"payload": {"config": config, "layout": layout},
|
||||
"placement_manifest": placement_manifest(config, layout),
|
||||
"content_manifest": content_manifest(config, config_root),
|
||||
"transfer": {"dropped_marker_links": dropped_marker_links},
|
||||
"transfer": {
|
||||
"dropped_marker_links": dropped_marker_links,
|
||||
**({"plan_only": True} if plan_only else {}),
|
||||
},
|
||||
}
|
||||
if len(json.dumps(
|
||||
document, ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
||||
@@ -358,6 +582,12 @@ def parse_document(raw: bytes) -> dict[str, Any]:
|
||||
config = CONFIG_SCHEMA(_json_copy(payload.get("config")))
|
||||
except (vol.Invalid, TypeError, ValueError) as err:
|
||||
raise ImportFailure("invalid_config", str(err)) from err
|
||||
if document["kind"] == "full":
|
||||
_materialize_global_background(config)
|
||||
else:
|
||||
for space in config.get("spaces") or []:
|
||||
if isinstance(space, dict):
|
||||
_materialize_space_background(space, "static")
|
||||
try:
|
||||
layout = LAYOUT_SCHEMA(_json_copy(payload.get("layout") or {}))
|
||||
except (vol.Invalid, TypeError, ValueError) as err:
|
||||
@@ -379,10 +609,13 @@ def parse_document(raw: bytes) -> dict[str, Any]:
|
||||
if document["kind"] == "space" and placement_ids != set(layout):
|
||||
raise ImportFailure("invalid_format", "Placement manifest does not match layout")
|
||||
dropped_marker_links = _transfer_dropped_marker_links(document)
|
||||
plan_only = _transfer_plan_only(document)
|
||||
document["transfer"] = {
|
||||
**(document.get("transfer") or {}),
|
||||
"dropped_marker_links": dropped_marker_links,
|
||||
}
|
||||
if plan_only:
|
||||
_validate_plan_only_document(document, config, layout, placement)
|
||||
if len(json.dumps(
|
||||
config, ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
||||
).encode("utf-8")) > MAX_CONFIG_BYTES:
|
||||
@@ -453,6 +686,51 @@ def _transfer_dropped_marker_links(document: dict[str, Any]) -> int:
|
||||
return value
|
||||
|
||||
|
||||
def _transfer_plan_only(document: dict[str, Any]) -> bool:
|
||||
"""Read strict additive plan-only metadata without widening old exports."""
|
||||
transfer = document.get("transfer")
|
||||
if transfer is None:
|
||||
return False
|
||||
if not isinstance(transfer, dict):
|
||||
raise ImportFailure("invalid_format", "Transfer metadata must be an object")
|
||||
value = transfer.get("plan_only", False)
|
||||
if not isinstance(value, bool):
|
||||
raise ImportFailure("invalid_format", "Plan-only metadata must be a boolean")
|
||||
if value and document.get("kind") != "space":
|
||||
raise ImportFailure("invalid_format", "Plan-only metadata requires one space")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_plan_only_document(
|
||||
document: dict[str, Any],
|
||||
config: dict[str, Any],
|
||||
layout: dict[str, Any],
|
||||
placement: list[Any],
|
||||
) -> None:
|
||||
"""Reject forged plan-only flags unless every privacy invariant is true."""
|
||||
spaces = config.get("spaces") or []
|
||||
if len(spaces) != 1 or config.get("markers") != []:
|
||||
raise ImportFailure("invalid_format", "Plan-only export has invalid owners")
|
||||
expected_config = CONFIG_SCHEMA({
|
||||
"spaces": [_project_plan_only_space(spaces[0])],
|
||||
"markers": [],
|
||||
})
|
||||
if config != expected_config:
|
||||
raise ImportFailure("invalid_format", "Plan-only export contains private fields")
|
||||
expected_layout = _plan_only_room_label_layout(layout, spaces[0])
|
||||
if layout != expected_layout:
|
||||
raise ImportFailure("invalid_format", "Plan-only export contains device layout")
|
||||
expected_placement = placement_manifest(config, layout)
|
||||
if placement != expected_placement:
|
||||
raise ImportFailure("invalid_format", "Plan-only placement manifest is not canonical")
|
||||
content = document.get("content_manifest")
|
||||
if not isinstance(content, list) or any(
|
||||
not isinstance(item, dict) or item.get("owner") != "space"
|
||||
for item in content
|
||||
):
|
||||
raise ImportFailure("invalid_format", "Plan-only export contains private content")
|
||||
|
||||
|
||||
def _drop_invalid_import_marker_links(
|
||||
config: dict[str, Any], *, clean_ids: set[str] | None = None,
|
||||
) -> int:
|
||||
@@ -560,6 +838,7 @@ def build_space_merge(
|
||||
if len(spaces) != 1:
|
||||
raise ImportFailure("invalid_config", "A space export must contain exactly one space")
|
||||
space = spaces[0]
|
||||
_materialize_space_background(space, "static")
|
||||
used = {
|
||||
str(value)
|
||||
for sp in current_config.get("spaces") or []
|
||||
@@ -583,6 +862,16 @@ def build_space_merge(
|
||||
for room in space.get("rooms") or []:
|
||||
if room.get("open_to"):
|
||||
room["open_to"] = [old_room_ids.get(str(value), str(value)) for value in room["open_to"]]
|
||||
# Opening ownership is part of the same space-local id graph. Remap the
|
||||
# nested reference together with the partition itself; otherwise the
|
||||
# invariant validator correctly rejects a copied space whose host.id still
|
||||
# names the source partition.
|
||||
for opening in space.get("openings") or []:
|
||||
host = opening.get("host") if isinstance(opening, dict) else None
|
||||
if isinstance(host, dict) and host.get("kind") == "partition":
|
||||
old_host_id = str(host.get("id"))
|
||||
if old_host_id in id_map:
|
||||
host["id"] = id_map[old_host_id]
|
||||
space["id"] = new_space_id
|
||||
space["title"] = _unique_title(
|
||||
str(space.get("title") or old_space_id), current_config.get("spaces") or []
|
||||
@@ -637,7 +926,7 @@ def build_space_merge(
|
||||
for field in (
|
||||
"area", "controls", "tap_action", "tap_target", "tap_confirm",
|
||||
"vacuum", "is_light", "use_climate_temp", "glow_color",
|
||||
"glow_radius_cm", "light_entity", "hidden", "removed",
|
||||
"glow_radius_cm", "light_entity", "toggle_entity", "hidden", "removed",
|
||||
"value_badge",
|
||||
):
|
||||
marker.pop(field, None)
|
||||
@@ -722,7 +1011,12 @@ def build_space_merge(
|
||||
validate_marker_controls(merged_config, current_config)
|
||||
validate_marker_light_entities(merged_config, current_config)
|
||||
validate_marker_value_badges(merged_config, current_config)
|
||||
except MarkerControlError as err:
|
||||
validate_opening_passages(merged_config, current_config)
|
||||
validate_partition_opening_hosts(merged_config, current_config)
|
||||
except (
|
||||
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
) as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
try:
|
||||
merged_layout = LAYOUT_SCHEMA(merged_layout)
|
||||
@@ -881,6 +1175,13 @@ def create_preview(
|
||||
current_config = current_config_data.get("config") or {"spaces": [], "markers": [], "settings": {}}
|
||||
current_layout = current_layout_data.get("layout") or {}
|
||||
details: dict[str, Any] = {}
|
||||
try:
|
||||
# Every imported opening is new to this installation. A forged full or
|
||||
# one-space file must not use the broken-read exception reserved for an
|
||||
# already stored legacy passage.
|
||||
validate_opening_passages(incoming_config, validate_all=True)
|
||||
except OpeningPassageError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
if document["kind"] == "space":
|
||||
_merged_config, _merged_layout, details = build_space_merge(
|
||||
document, current_config, current_layout, duplicate_policy,
|
||||
@@ -926,6 +1227,7 @@ def create_preview(
|
||||
runtime.import_previews[token] = candidate
|
||||
preview = {
|
||||
"kind": document["kind"],
|
||||
"plan_only": _transfer_plan_only(document),
|
||||
"created_at": document.get("created_at"),
|
||||
"card_version": document.get("card_version"),
|
||||
"integration_version": document.get("integration_version"),
|
||||
@@ -1005,6 +1307,7 @@ def revalidate_candidate(
|
||||
return {
|
||||
"preview": {
|
||||
"kind": document["kind"],
|
||||
"plan_only": _transfer_plan_only(document),
|
||||
"counts": _counts(incoming["config"], incoming["layout"]),
|
||||
"current_counts": _counts(current_config, current_layout),
|
||||
"source": "same" if candidate.get("same_source") else "foreign",
|
||||
@@ -1039,6 +1342,7 @@ def prepare_apply(
|
||||
_detach_missing(imported_config, candidate.get("content") or [])
|
||||
if document["kind"] == "full":
|
||||
config = imported_config
|
||||
_materialize_global_background(config)
|
||||
# Full exports keep the data-model version in the portable envelope so
|
||||
# the payload can be validated independently. Restore it before the
|
||||
# configuration is persisted; otherwise every full round-trip silently
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.63.0-beta.1"
|
||||
"version": "1.67.0-beta.1"
|
||||
}
|
||||
|
||||
@@ -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,47 @@ 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,
|
||||
)
|
||||
from .coordinate_canonicalization import (
|
||||
canonicalize_config_geometry,
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
|
||||
|
||||
_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 +70,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 +81,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)
|
||||
@@ -71,6 +113,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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -113,7 +161,7 @@ def layout_store_payload(
|
||||
}
|
||||
if metadata:
|
||||
out.update(metadata)
|
||||
out["layout"] = layout
|
||||
out["layout"] = canonicalize_layout_geometry(layout)
|
||||
out["rev"] = rev
|
||||
return out
|
||||
|
||||
@@ -139,3 +187,44 @@ async def async_save_layout_state(
|
||||
)
|
||||
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
|
||||
|
||||
canonical_config = canonicalize_config_geometry(config)
|
||||
payload = {"config": canonical_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,
|
||||
canonical_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
|
||||
|
||||
@@ -10,6 +10,7 @@ want to see where the cleanup has already been).
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -24,11 +25,33 @@ import logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TRAIL_CAP = 2000 # raw points per run before decimation
|
||||
TRAIL_RESUME_GRACE_S = 30 * 60 # same-map stop/pause belongs to one cleanup
|
||||
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
|
||||
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
|
||||
MOVING_STATES = {"cleaning", "returning", "on"}
|
||||
|
||||
|
||||
def can_resume_trail_run(run: Any, map_id: str, now: float) -> bool:
|
||||
"""Whether an ended current run may be reopened for this point.
|
||||
|
||||
Store timestamps are untrusted persisted data. Only finite JSON-number
|
||||
timestamps and a non-negative inclusive grace interval are accepted;
|
||||
malformed values and wall-clock rollback fail closed into a new run.
|
||||
"""
|
||||
if not isinstance(run, dict) or run.get("map_id") != map_id:
|
||||
return False
|
||||
ended = run.get("ended")
|
||||
if (
|
||||
isinstance(ended, bool)
|
||||
or not isinstance(ended, (int, float))
|
||||
or isinstance(now, bool)
|
||||
or not isinstance(now, (int, float))
|
||||
):
|
||||
return False
|
||||
elapsed = now - ended
|
||||
return math.isfinite(elapsed) and 0 <= elapsed <= TRAIL_RESUME_GRACE_S
|
||||
|
||||
|
||||
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
|
||||
"""Map-id normalisation contract, shared with the frontend.
|
||||
|
||||
@@ -64,7 +87,10 @@ class TrailBook:
|
||||
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
|
||||
rec = self.data.setdefault(marker, {})
|
||||
cur = rec.get("current")
|
||||
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
|
||||
resumed = bool(cur and can_resume_trail_run(cur, map_id, now))
|
||||
if resumed:
|
||||
cur["ended"] = None
|
||||
if not cur or cur.get("ended") is not None or cur.get("map_id") != map_id:
|
||||
# a new run begins: the old one becomes "previous" (and the one
|
||||
# before it is forgotten — we keep exactly two, per the owner)
|
||||
if cur:
|
||||
@@ -73,7 +99,9 @@ class TrailBook:
|
||||
rec["current"] = cur
|
||||
pts: list[list[float]] = cur["points"]
|
||||
if pts and pts[-1][0] == x and pts[-1][1] == y:
|
||||
return False
|
||||
# Clearing ended is observable state even if the source repeats
|
||||
# the dock point: it must still reach Store and live cards.
|
||||
return resumed
|
||||
pts.append([x, y])
|
||||
if len(pts) > TRAIL_CAP:
|
||||
# decimate by two but never lose the freshest point
|
||||
@@ -85,7 +113,7 @@ class TrailBook:
|
||||
|
||||
def end_run(self, marker: str, now: float) -> bool:
|
||||
cur = (self.data.get(marker) or {}).get("current")
|
||||
if cur and not cur.get("ended"):
|
||||
if cur and cur.get("ended") is None:
|
||||
cur["ended"] = now
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -9,6 +9,12 @@ import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.houseplan.coordinate_canonicalization import (
|
||||
canonicalize_config_geometry,
|
||||
canonicalize_layout_geometry,
|
||||
canonicalize_position,
|
||||
)
|
||||
|
||||
# ---------- limits and extension sets ----------
|
||||
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
|
||||
MAX_PLAN_BYTES = 8 * 1024 * 1024
|
||||
@@ -32,6 +38,166 @@ class MarkerControlError(ValueError):
|
||||
self.code = code
|
||||
|
||||
|
||||
class OpeningPassageError(ValueError):
|
||||
"""Semantic open-passage error with a stable public code and payload."""
|
||||
|
||||
code = "invalid_passage_fields"
|
||||
|
||||
def __init__(self, space_id: str, opening_id: str, fields: list[str]) -> None:
|
||||
self.space_id = space_id
|
||||
self.opening_id = opening_id
|
||||
self.fields = tuple(sorted(fields))
|
||||
super().__init__(
|
||||
f"space={space_id}; opening={opening_id}; fields={','.join(self.fields)}"
|
||||
)
|
||||
|
||||
|
||||
class PartitionOpeningHostError(ValueError):
|
||||
"""A write tried to strip explicit host identity from a surviving opening."""
|
||||
|
||||
code = "invalid_partition_opening_host"
|
||||
|
||||
|
||||
class PartitionOpeningJambMarginError(ValueError):
|
||||
"""A direct geometry write leaves no physical jamb at a wall endpoint."""
|
||||
|
||||
code = "invalid_partition_opening_jamb_margin"
|
||||
|
||||
def __init__(
|
||||
self, space_id: str, opening_id: str, margin: float, margin_cm: float
|
||||
) -> None:
|
||||
self.space_id = space_id
|
||||
self.opening_id = opening_id
|
||||
self.margin = margin
|
||||
self.margin_cm = margin_cm
|
||||
super().__init__(
|
||||
f"space={space_id}; opening={opening_id}; "
|
||||
f"margin={margin:.12g}; margin_cm={margin_cm:.12g}"
|
||||
)
|
||||
|
||||
|
||||
# One normalized canvas width contains this many physical grid cells. Keep in
|
||||
# sync with GRID_STEP_N/NORM_W in the frontend; it is a geometry scale, not a
|
||||
# user setting.
|
||||
NORMALIZED_CANVAS_CELLS = 240.0
|
||||
|
||||
|
||||
def validate_partition_opening_hosts(
|
||||
config: dict, previous: dict | None = None
|
||||
) -> None:
|
||||
"""Validate hosted-opening write deltas without rejecting legacy reads.
|
||||
|
||||
Deleting the opening together with its partition remains valid. Surviving
|
||||
records keep their host, while new/direct geometry changes reserve half the
|
||||
actual wall depth at both endpoints. Rigid host translation and unrelated
|
||||
edits round-trip existing near-end records unchanged.
|
||||
"""
|
||||
old_spaces = {
|
||||
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
|
||||
}
|
||||
for space in config.get("spaces") or []:
|
||||
space_id = str(space.get("id", ""))
|
||||
old_space = old_spaces.get(space_id)
|
||||
old_openings = {
|
||||
str(opening.get("id")): opening
|
||||
for opening in (old_space or {}).get("openings") or []
|
||||
}
|
||||
partitions = {
|
||||
str(partition.get("id")): partition
|
||||
for partition in space.get("partitions") or []
|
||||
}
|
||||
old_partitions = {
|
||||
str(partition.get("id")): partition
|
||||
for partition in (old_space or {}).get("partitions") or []
|
||||
}
|
||||
for opening in space.get("openings") or []:
|
||||
opening_id = str(opening.get("id", ""))
|
||||
old = old_openings.get(opening_id)
|
||||
if old and old.get("host") is not None and opening.get("host") is None:
|
||||
raise PartitionOpeningHostError(
|
||||
f"space={space_id}; opening={opening_id}; host removed"
|
||||
)
|
||||
host = opening.get("host")
|
||||
if host is None:
|
||||
continue
|
||||
partition = partitions.get(str(host.get("id", "")))
|
||||
if partition is None:
|
||||
# SPACE_SCHEMA owns missing-host diagnostics.
|
||||
continue
|
||||
old_host = (old or {}).get("host")
|
||||
old_partition = old_partitions.get(str((old_host or {}).get("id", "")))
|
||||
ax, ay = partition["a"]
|
||||
bx, by = partition["b"]
|
||||
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
|
||||
old_span = None
|
||||
if old_partition is not None:
|
||||
old_ax, old_ay = old_partition["a"]
|
||||
old_bx, old_by = old_partition["b"]
|
||||
old_span = ((old_bx - old_ax) ** 2 + (old_by - old_ay) ** 2) ** 0.5
|
||||
strict = (
|
||||
old is None
|
||||
or old_host is None
|
||||
or old_host.get("id") != host.get("id")
|
||||
or old_host.get("t") != host.get("t")
|
||||
or old.get("length") != opening.get("length")
|
||||
or old_partition is None
|
||||
or old_partition.get("cm") != partition.get("cm")
|
||||
or abs(old_span - span) > 1e-9
|
||||
)
|
||||
if not strict:
|
||||
continue
|
||||
cell_cm = float(space.get("cell_cm", 5))
|
||||
margin_cm = float(partition["cm"]) / 2
|
||||
margin = margin_cm / cell_cm / NORMALIZED_CANVAS_CELLS
|
||||
along = float(host["t"]) * span
|
||||
half = float(opening["length"]) / 2
|
||||
if (along - half < margin - 1e-9
|
||||
or along + half > span - margin + 1e-9):
|
||||
raise PartitionOpeningJambMarginError(
|
||||
space_id, opening_id, margin, margin_cm
|
||||
)
|
||||
|
||||
|
||||
PASSAGE_FORBIDDEN_FIELDS = {"contact", "lock", "invert", "flip_h", "flip_v"}
|
||||
|
||||
|
||||
def validate_opening_passages(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
"""Reject new/changed inapplicable fields while preserving dormant bad data.
|
||||
|
||||
A passage read from an older/future writer may already contain door-only
|
||||
keys. Unrelated writes must remain possible, but imports and any write that
|
||||
introduces or changes such a key are fail-closed.
|
||||
"""
|
||||
old_spaces = {
|
||||
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
|
||||
}
|
||||
for space in config.get("spaces") or []:
|
||||
space_id = str(space.get("id", ""))
|
||||
old_space = None if validate_all else old_spaces.get(space_id)
|
||||
old_openings = {
|
||||
str(opening.get("id")): opening
|
||||
for opening in (old_space or {}).get("openings") or []
|
||||
}
|
||||
for opening in space.get("openings") or []:
|
||||
if opening.get("type") != "passage":
|
||||
continue
|
||||
opening_id = str(opening.get("id", ""))
|
||||
present = sorted(PASSAGE_FORBIDDEN_FIELDS & set(opening))
|
||||
if not present:
|
||||
continue
|
||||
old_opening = None if validate_all else old_openings.get(opening_id)
|
||||
if not old_opening or old_opening.get("type") != "passage":
|
||||
raise OpeningPassageError(space_id, opening_id, present)
|
||||
changed = [
|
||||
field for field in present
|
||||
if field not in old_opening or opening[field] != old_opening[field]
|
||||
]
|
||||
if changed:
|
||||
raise OpeningPassageError(space_id, opening_id, changed)
|
||||
|
||||
|
||||
VALUE_BADGE_ATTRIBUTES = {
|
||||
"current_temperature", "temperature", "current_humidity", "humidity",
|
||||
"current_position", "percentage", "brightness", "volume_level",
|
||||
@@ -135,7 +301,7 @@ def validate_marker_value_badges(
|
||||
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.
|
||||
"""Validate new/changed light/switch 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
|
||||
@@ -152,16 +318,26 @@ def validate_marker_light_entities(
|
||||
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.*"
|
||||
)
|
||||
for field, code, message in (
|
||||
(
|
||||
"light_entity",
|
||||
"invalid_light_entity",
|
||||
"Leading light entity must be light.* or switch.*",
|
||||
),
|
||||
(
|
||||
"toggle_entity",
|
||||
"invalid_toggle_entity",
|
||||
"Toggle entity must be light.* or switch.*",
|
||||
),
|
||||
):
|
||||
value = marker.get(field)
|
||||
old_value = None if validate_all else (old_marker or {}).get(field)
|
||||
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(code, message)
|
||||
|
||||
|
||||
def validate_marker_controls(
|
||||
@@ -352,11 +528,18 @@ _URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||
CANVAS_LIMIT = 5000.0
|
||||
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
|
||||
POS_SCHEMA = vol.Schema(
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
POS_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
),
|
||||
canonicalize_position,
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(
|
||||
vol.Schema({str: POS_SCHEMA}),
|
||||
vol.Length(max=MAX_LAYOUT),
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
|
||||
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
|
||||
@@ -703,6 +886,15 @@ WALL_COLUMN_SCHEMA = vol.All(
|
||||
_strict_wall_column,
|
||||
)
|
||||
|
||||
PARTITION_OPENING_HOST_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("kind"): vol.Equal("partition"),
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("t"): vol.All(_finite, vol.Range(min=0, max=1)),
|
||||
},
|
||||
extra=vol.PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
def _space_geometry_invariants(value: dict) -> dict:
|
||||
"""All stored geometry shares ids; draft segments also have a space cap."""
|
||||
@@ -720,6 +912,29 @@ def _space_geometry_invariants(value: dict) -> dict:
|
||||
)
|
||||
if draft_segments > MAX_DRAFT_SEGMENTS:
|
||||
raise vol.Invalid("too many saved room-draft segments")
|
||||
partitions = {
|
||||
item.get("id"): item for item in value.get("partitions", []) if item.get("id")
|
||||
}
|
||||
hosted_intervals: dict[str, list[tuple[float, float]]] = {}
|
||||
for opening in value.get("openings", []):
|
||||
host = opening.get("host")
|
||||
if host is None:
|
||||
continue
|
||||
partition = partitions.get(host["id"])
|
||||
if partition is None:
|
||||
raise vol.Invalid("partition opening host must exist in the same space")
|
||||
ax, ay = partition["a"]
|
||||
bx, by = partition["b"]
|
||||
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
|
||||
length = float(opening["length"])
|
||||
along = float(host["t"]) * span
|
||||
if length > span or along - length / 2 < -1e-9 or along + length / 2 > span + 1e-9:
|
||||
raise vol.Invalid("partition opening must fit inside its host")
|
||||
lo, hi = along - length / 2, along + length / 2
|
||||
occupied = hosted_intervals.setdefault(host["id"], [])
|
||||
if any(max(lo, old_lo) < min(hi, old_hi) - 1e-9 for old_lo, old_hi in occupied):
|
||||
raise vol.Invalid("partition openings must not overlap")
|
||||
occupied.append((lo, hi))
|
||||
return value
|
||||
|
||||
|
||||
@@ -773,7 +988,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("type"): vol.Any("door", "window", "gate"),
|
||||
vol.Required("type"): vol.Any("door", "window", "gate", "passage"),
|
||||
vol.Required("x"): _GEOM,
|
||||
vol.Required("y"): _GEOM,
|
||||
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
@@ -785,6 +1000,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
vol.Optional("invert"): bool,
|
||||
vol.Optional("flip_h"): bool,
|
||||
vol.Optional("flip_v"): bool,
|
||||
vol.Optional("host"): PARTITION_OPENING_HOST_SCHEMA,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
@@ -885,6 +1101,9 @@ MARKER_SCHEMA = vol.Schema(
|
||||
# Semantic delta validation below the schema preserves unknown/future
|
||||
# literals until that exact field is edited (lossless config doctrine).
|
||||
vol.Optional("light_entity"): object,
|
||||
# Exact own entity selected for Toggle. Delta validation preserves an
|
||||
# untouched future literal while bounding every new/changed value.
|
||||
vol.Optional("toggle_entity"): object,
|
||||
vol.Optional("value_badge"): vol.Any(
|
||||
None,
|
||||
vol.Schema(
|
||||
@@ -918,38 +1137,41 @@ MARKER_SCHEMA = vol.Schema(
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
|
||||
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
|
||||
vol.Optional("settings", default=dict): 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"): _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)),
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
{
|
||||
vol.Required("c"): _COLOR,
|
||||
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
|
||||
CONFIG_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
|
||||
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
|
||||
vol.Optional("settings", default=dict): 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"): _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)),
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
{
|
||||
vol.Required("c"): _COLOR,
|
||||
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
|
||||
),
|
||||
canonicalize_config_geometry,
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -25,6 +25,10 @@ from .const import (
|
||||
MAX_SIGN_PATHS,
|
||||
PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .coordinate_canonicalization import (
|
||||
canonicalize_config_geometry,
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .import_export import (
|
||||
ImportFailure,
|
||||
@@ -44,14 +48,22 @@ from .store import (
|
||||
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, MarkerControlError, sanitize_filename,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
|
||||
PartitionOpeningHostError, PartitionOpeningJambMarginError, sanitize_filename,
|
||||
validate_opening_passages, validate_partition_opening_hosts,
|
||||
validate_marker_controls, validate_marker_light_entities,
|
||||
validate_marker_value_badges, valid_space_id,
|
||||
)
|
||||
@@ -126,6 +138,7 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
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)
|
||||
@@ -194,10 +207,11 @@ async def _persist_pair_intent(
|
||||
|
||||
async def _converge_pair(rt: HouseplanData, pending: dict[str, Any]) -> None:
|
||||
"""Write both target halves and remove the durable intent last."""
|
||||
await rt.config_store.async_save({
|
||||
"config": pending["config"],
|
||||
"rev": int(pending["config_rev"]),
|
||||
})
|
||||
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,
|
||||
@@ -255,6 +269,7 @@ async def _commit_import_pair(
|
||||
vol.Required("type"): "houseplan/export/create",
|
||||
vol.Required("kind"): vol.In(["full", "space"]),
|
||||
vol.Optional("space_id"): str,
|
||||
vol.Optional("plan_only", default=False): bool,
|
||||
vol.Optional("card_version", default=""): str,
|
||||
}
|
||||
)
|
||||
@@ -279,6 +294,7 @@ async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
layout_data,
|
||||
kind=msg["kind"],
|
||||
space_id=msg.get("space_id"),
|
||||
plan_only=msg.get("plan_only", False),
|
||||
card_version=msg.get("card_version", ""),
|
||||
config_root=Path(hass.config.path("")),
|
||||
)
|
||||
@@ -414,8 +430,12 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
if kind == "full":
|
||||
backup = {
|
||||
"kind": "import",
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout") or {},
|
||||
"config": canonicalize_config_geometry(
|
||||
config_data.get("config") or DEFAULT_CONFIG
|
||||
),
|
||||
"layout": canonicalize_layout_geometry(
|
||||
layout_data.get("layout") or {}
|
||||
),
|
||||
"created": int(time.time()),
|
||||
"after_config_rev": new_config_rev,
|
||||
"after_layout_rev": new_layout_rev,
|
||||
@@ -432,16 +452,20 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
final_metadata[_OPTIMIZE_BACKUP] = backup
|
||||
pending = {
|
||||
"kind": "import",
|
||||
"config": target_config,
|
||||
"layout": target_layout,
|
||||
"config": canonicalize_config_geometry(target_config),
|
||||
"layout": canonicalize_layout_geometry(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": canonicalize_config_geometry(
|
||||
config_data.get("config") or DEFAULT_CONFIG
|
||||
),
|
||||
"layout": canonicalize_layout_geometry(
|
||||
layout_data.get("layout") or {}
|
||||
),
|
||||
"config_rev": config_rev,
|
||||
"layout_rev": layout_rev,
|
||||
"final_metadata": original_metadata,
|
||||
@@ -554,6 +578,9 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
return
|
||||
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
|
||||
if layout == data.get("layout", {}):
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": current_rev})
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await async_save_layout_state(
|
||||
rt, data, layout, new_rev,
|
||||
@@ -615,7 +642,10 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
return
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout", {})
|
||||
layout[msg["device_id"]] = msg["pos"]
|
||||
if layout.get(msg["device_id"]) == msg["pos"]:
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": int(data.get("rev", 0))})
|
||||
return
|
||||
layout = {**layout, msg["device_id"]: msg["pos"]}
|
||||
# keep the revision: a point-wise write used to drop it, which made the
|
||||
# optimistic locking on layout/set meaningless — every drag reset the
|
||||
# counter to 0 (HP-1454-08)
|
||||
@@ -723,7 +753,10 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
|
||||
rt, data, new_layout, new_rev,
|
||||
metadata={
|
||||
**_optimizer_backup_after_layout_maintenance(data, new_rev),
|
||||
"repair_backup": {"space": space_id, "positions": touched},
|
||||
"repair_backup": {
|
||||
"space": space_id,
|
||||
"positions": canonicalize_layout_geometry(touched),
|
||||
},
|
||||
},
|
||||
remove=("repair_backup",),
|
||||
)
|
||||
@@ -1058,14 +1091,28 @@ 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),
|
||||
@@ -1073,6 +1120,42 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
|
||||
|
||||
@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.
|
||||
@@ -1194,7 +1277,12 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
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:
|
||||
validate_opening_passages(msg["config"], data.get("config"))
|
||||
validate_partition_opening_hosts(msg["config"], data.get("config"))
|
||||
except (
|
||||
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
) 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
|
||||
@@ -1214,8 +1302,24 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
"Plan file no longer exists: " + ", ".join(sorted(missing)),
|
||||
)
|
||||
return
|
||||
if msg["config"] == data.get("config"):
|
||||
# A semantic no-op still has to reconcile Repairs with external
|
||||
# file-system changes. It must not create a revision, event, or
|
||||
# discard the optimizer snapshot merely to refresh diagnostics.
|
||||
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, "rev": int(current_rev)})
|
||||
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
|
||||
@@ -1299,7 +1403,12 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
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:
|
||||
validate_opening_passages(msg["config"], config_data.get("config"))
|
||||
validate_partition_opening_hosts(msg["config"], config_data.get("config"))
|
||||
except (
|
||||
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
) as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
|
||||
@@ -1320,15 +1429,19 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
new_layout_rev = layout_rev + 1
|
||||
backup = {
|
||||
"kind": "optimize",
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout", {}),
|
||||
"config": canonicalize_config_geometry(
|
||||
config_data.get("config") or DEFAULT_CONFIG
|
||||
),
|
||||
"layout": canonicalize_layout_geometry(
|
||||
layout_data.get("layout", {})
|
||||
),
|
||||
"created": int(time.time()),
|
||||
"after_config_rev": new_config_rev,
|
||||
"after_layout_rev": new_layout_rev,
|
||||
}
|
||||
pending = {
|
||||
"config": msg["config"],
|
||||
"layout": msg["layout"],
|
||||
"config": canonicalize_config_geometry(msg["config"]),
|
||||
"layout": canonicalize_layout_geometry(msg["layout"]),
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"clear_backup": False,
|
||||
@@ -1340,7 +1453,12 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_config_rev})
|
||||
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},
|
||||
@@ -1393,8 +1511,12 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
|
||||
|
||||
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 {}
|
||||
restored_config = canonicalize_config_geometry(
|
||||
backup.get("config") or DEFAULT_CONFIG
|
||||
)
|
||||
restored_layout = canonicalize_layout_geometry(
|
||||
backup.get("layout") or {}
|
||||
)
|
||||
new_config_rev = config_rev + 1
|
||||
new_layout_rev = layout_rev + 1
|
||||
pending = {
|
||||
@@ -1410,7 +1532,12 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
|
||||
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
await rt.config_store.async_save({"config": restored_config, "rev": new_config_rev})
|
||||
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"),
|
||||
|
||||
@@ -14,11 +14,27 @@ 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'].includes(profile))
|
||||
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 requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.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(
|
||||
@@ -48,7 +64,10 @@ 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 }) => {
|
||||
const row = await page.evaluate(async ({
|
||||
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
requiresWallFace,
|
||||
}) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
const started = performance.now();
|
||||
@@ -104,6 +123,8 @@ try {
|
||||
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
isoGeometry: card._isoGeometryCache?.size ?? 0,
|
||||
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
|
||||
wallFaceGraph: card._wallFaceGraphCache?.length ?? 0,
|
||||
});
|
||||
|
||||
window.__card?.remove?.();
|
||||
@@ -120,6 +141,7 @@ try {
|
||||
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,
|
||||
@@ -134,6 +156,7 @@ try {
|
||||
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')
|
||||
@@ -155,6 +178,13 @@ try {
|
||||
const loadLongTasks = startLongTaskWindow();
|
||||
const loadStarted = performance.now();
|
||||
host.replaceChildren(card);
|
||||
if (requiresIsometric) {
|
||||
if (typeof card._onLabsSnapshot !== 'function')
|
||||
throw new Error('large-house-isometric-v1 candidate has no Labs fixture hook');
|
||||
// The product flag expires at 1.65.0. Performance keeps exercising
|
||||
// the dormant renderer without changing the public registry contract.
|
||||
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
}
|
||||
card.hass = hassFor(fixture.states);
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
if (requiresIsometric && (typeof card._setProjection !== 'function'
|
||||
@@ -196,6 +226,112 @@ try {
|
||||
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 wallFaceCacheBeforePointer = card._wallFaceGraphCache?.length ?? 0;
|
||||
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,
|
||||
wallFaceCacheStableOnPointer:
|
||||
(card._wallFaceGraphCache?.length ?? 0) === wallFaceCacheBeforePointer,
|
||||
};
|
||||
if (requiresPlanSnap && (
|
||||
staticLines < fixture.counts.rooms || staticNodes < fixture.counts.rooms
|
||||
|| !planSnapDiagnostics.cacheStable || !planSnapDiagnostics.domStable
|
||||
|| !planSnapDiagnostics.configStable || planSnapDiagnostics.wsWrites !== 0
|
||||
|| !planSnapDiagnostics.wallFaceCacheStableOnPointer
|
||||
|| !seenKinds.has('endpoint') || !seenKinds.has('line')
|
||||
)) throw new Error(`plan-snap structural contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
|
||||
if (requiresWallFace) {
|
||||
const oldPath = card._path;
|
||||
const oldDraftId = card._activeDraftId;
|
||||
const oldCms = card._draftSegmentCms;
|
||||
const beforePath = [[10, 10]];
|
||||
card._path = [[10, 10], [20, 10]];
|
||||
card._activeDraftId = 'perf-face-draft';
|
||||
card._draftSegmentCms = [15];
|
||||
const acceptedStarted = performance.now();
|
||||
card._offerWallFaces(beforePath);
|
||||
planSnapDiagnostics.wallFaceAcceptedClickMs = performance.now() - acceptedStarted;
|
||||
planSnapDiagnostics.wallFaceCacheEntries = card._wallFaceGraphCache?.length ?? 0;
|
||||
card._wallFaceBatch = null;
|
||||
card._roomDialog = false;
|
||||
card._path = oldPath;
|
||||
card._activeDraftId = oldDraftId;
|
||||
card._draftSegmentCms = oldCms;
|
||||
if (planSnapDiagnostics.wallFaceAcceptedClickMs > 1000
|
||||
|| planSnapDiagnostics.wallFaceCacheEntries < 1
|
||||
|| planSnapDiagnostics.wallFaceCacheEntries > 4) {
|
||||
throw new Error(`wall-face accepted-click contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
|
||||
}
|
||||
}
|
||||
card._setMode('view');
|
||||
await card.updateComplete;
|
||||
}) : null;
|
||||
|
||||
const resizePreview = await duration(async () => {
|
||||
card._setMode('plan');
|
||||
card._tool = 'resize';
|
||||
@@ -279,6 +415,10 @@ try {
|
||||
modelReadyMs,
|
||||
firstStableRenderMs,
|
||||
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
|
||||
...(planSnapPointer ? {
|
||||
planSnapPointerMs: planSnapPointer.ms,
|
||||
planSnapDiagnostics,
|
||||
} : {}),
|
||||
spaceSwitchMs: spaceSwitch.ms,
|
||||
stateUpdateMs: stateUpdate.ms,
|
||||
resizePreviewMs: resizePreview.ms,
|
||||
@@ -288,6 +428,7 @@ try {
|
||||
longTasks: {
|
||||
load: loadLongTaskResult,
|
||||
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
|
||||
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
|
||||
spaceSwitch: spaceSwitch.longTasks,
|
||||
stateUpdate: stateUpdate.longTasks,
|
||||
resizePreview: resizePreview.longTasks,
|
||||
@@ -306,7 +447,7 @@ try {
|
||||
return result;
|
||||
}, {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
}
|
||||
@@ -319,6 +460,7 @@ const metricNames = [
|
||||
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
|
||||
];
|
||||
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
|
||||
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
|
||||
const report = {
|
||||
schema: 2,
|
||||
profile,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// #199: same-process production-builder baseline versus the complete Optimize
|
||||
// preflight wrapper on the deterministic 3-floor large-house fixture.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import {
|
||||
checkOptimizeGeometry,
|
||||
prepareSpacePhysicalGeometryInputs,
|
||||
} from '../test-build/plan-geometry-preflight.js';
|
||||
import { spaceModels } from '../test-build/space-geometry.js';
|
||||
import {
|
||||
floorFootprintGeometry,
|
||||
wallBodiesGeometry,
|
||||
} from '../test-build/wall-thickness.js';
|
||||
|
||||
const WARMUPS = 3;
|
||||
const SAMPLES = 20;
|
||||
const ABSOLUTE_P95_MS = 250;
|
||||
const RELATIVE_RATIO = 1.2;
|
||||
const RELATIVE_NOISE_MS = 15;
|
||||
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const models = spaceModels(fixture.config);
|
||||
const prepared = fixture.config.spaces.map((space, index) =>
|
||||
prepareSpacePhysicalGeometryInputs(space, models[index]));
|
||||
|
||||
const directProductionPass = () => {
|
||||
for (const input of prepared) {
|
||||
const hasWalls = input.walls.length > 0 || input.physicalBodies.length > 0;
|
||||
const united = hasWalls
|
||||
? wallBodiesGeometry(
|
||||
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
|
||||
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
|
||||
input.physicalBodies,
|
||||
)
|
||||
: null;
|
||||
if (hasWalls && united == null) throw new Error(`baseline wall failure: ${input.space.id}`);
|
||||
if (input.space.rooms.length && united?.paperGeom == null) {
|
||||
const floor = floorFootprintGeometry(
|
||||
input.space.rooms, input.walls, input.openCuts,
|
||||
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
|
||||
);
|
||||
if (floor == null) throw new Error(`baseline floor failure: ${input.space.id}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const completePreflight = () => {
|
||||
const result = checkOptimizeGeometry(fixture.config);
|
||||
if (!result.ok || result.spaces.length !== LARGE_HOUSE_COUNTS.floors
|
||||
|| result.spaces.some((space) => space.status !== 'ok')) {
|
||||
throw new Error(`candidate preflight failure: ${JSON.stringify(result.spaces)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const sample = (operation) => {
|
||||
const start = performance.now();
|
||||
operation();
|
||||
return performance.now() - start;
|
||||
};
|
||||
const run = (operation) => {
|
||||
for (let index = 0; index < WARMUPS; index++) operation();
|
||||
return Array.from({ length: SAMPLES }, () => sample(operation));
|
||||
};
|
||||
const quantile = (values, ratio) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
|
||||
};
|
||||
const summary = (values) => ({
|
||||
min: Math.min(...values),
|
||||
median: quantile(values, 0.5),
|
||||
p95: quantile(values, 0.95),
|
||||
max: Math.max(...values),
|
||||
});
|
||||
|
||||
const baseline = summary(run(directProductionPass));
|
||||
const candidate = summary(run(completePreflight));
|
||||
const relativeLimit = baseline.p95 * RELATIVE_RATIO + RELATIVE_NOISE_MS;
|
||||
const pass = candidate.p95 <= ABSOLUTE_P95_MS && candidate.p95 <= relativeLimit;
|
||||
const report = {
|
||||
issue: 199,
|
||||
fixture: LARGE_HOUSE_COUNTS,
|
||||
warmups: WARMUPS,
|
||||
samples: SAMPLES,
|
||||
baseline,
|
||||
candidate,
|
||||
budgets: {
|
||||
absoluteP95Ms: ABSOLUTE_P95_MS,
|
||||
relativeRatio: RELATIVE_RATIO,
|
||||
relativeNoiseMs: RELATIVE_NOISE_MS,
|
||||
relativeLimitP95Ms: relativeLimit,
|
||||
},
|
||||
pass,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -29,3 +29,32 @@ export async function assertFreshDemoBundle(page, root = process.cwd()) {
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
/** Env switch that lets a debugging session run against a stale bundle. */
|
||||
export const ALLOW_STALE_BUNDLE = 'HP_ALLOW_STALE_BUNDLE';
|
||||
|
||||
/**
|
||||
* The freshness gate for every browser check, escape hatch included (#236).
|
||||
*
|
||||
* The smoke launcher had no freshness check at all, while golden runs and
|
||||
* benchmarks did. A smoke against a stale `demo/srv/assets/houseplan-card.js`
|
||||
* does not fail cleanly: on #234 three assertions went red and a fourth went
|
||||
* GREEN, because the old code was wrong in two places that agreed with each
|
||||
* other. A partly-red partly-green result looks like a logic defect and sends
|
||||
* the reader hunting in the wrong file.
|
||||
*
|
||||
* Skipping is allowed for debugging, but never silently: a skipped guard that
|
||||
* says nothing is the same silent success this project keeps digging out.
|
||||
*/
|
||||
export async function assertFreshDemoBundleUnlessAllowed(
|
||||
page, root = process.cwd(), env = process.env,
|
||||
) {
|
||||
if (env[ALLOW_STALE_BUNDLE]) {
|
||||
console.warn(
|
||||
`[houseplan] ${ALLOW_STALE_BUNDLE} is set — bundle freshness NOT verified. `
|
||||
+ 'A red result may mean a stale bundle rather than a defect (#236).',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return assertFreshDemoBundle(page, root);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env node
|
||||
// Issue #211: human-reviewable Reference SVG <-> Runtime matrix.
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { mdiLightbulbSpot } from '@mdi/js';
|
||||
import { launch } from './serve.mjs';
|
||||
|
||||
const artifactDir = resolve('artifacts/device-icon-reference');
|
||||
mkdirSync(artifactDir, { recursive: true });
|
||||
|
||||
const referenceAsset = (theme, file, coreSize) => {
|
||||
let source = readFileSync(resolve('demo/srv/reference/device-icons', theme, file), 'utf8');
|
||||
if (file === 'Lock.svg') {
|
||||
const old = theme === 'Dark' ? '#252525' : 'black';
|
||||
source = source.replaceAll(old, '#66D17A');
|
||||
if (theme === 'Dark') source = source.replaceAll('fill="white"', 'fill="#252525"');
|
||||
}
|
||||
if (file === 'Unlock.svg') {
|
||||
source = source.replaceAll(theme === 'Dark' ? '#1DC21D' : '#F0A00C', '#F0410C');
|
||||
}
|
||||
const nativeWidth = Number(source.match(/<svg[^>]*width="([\d.]+)"/)?.[1] || 127);
|
||||
return {
|
||||
url: `data:image/svg+xml;base64,${Buffer.from(source).toString('base64')}`,
|
||||
displayWidth: nativeWidth * coreSize / 80,
|
||||
};
|
||||
};
|
||||
|
||||
const { page, browser } = await launch(
|
||||
{ width: 1280, height: 960 }, 1, [], { colorScheme: 'dark' },
|
||||
);
|
||||
|
||||
await page.evaluate((path) => { window.__ICONS['mdi:lightbulb-spot'] = path; }, mdiLightbulbSpot);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const marker = (id, patch) => ({
|
||||
...(c._serverCfg.markers || []).find((item) => item.id === id),
|
||||
id, binding: `device:${id}`, ...patch,
|
||||
});
|
||||
const replacements = new Map([
|
||||
['d_light1', marker('d_light1', { display: 'badge', icon: 'mdi:lightbulb-spot' })],
|
||||
['d_tv', marker('d_tv', { display: 'value' })],
|
||||
['d_temp', marker('d_temp', {
|
||||
display: 'badge',
|
||||
value_badge: {
|
||||
enabled: true,
|
||||
source: { kind: 'entity_state', entity_id: 'sensor.living_temp' },
|
||||
position: 'right',
|
||||
},
|
||||
})],
|
||||
]);
|
||||
c._serverCfg.markers = [
|
||||
...(c._serverCfg.markers || []).filter((item) => !replacements.has(item.id)),
|
||||
...replacements.values(),
|
||||
];
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
states: {
|
||||
...c.hass.states,
|
||||
'sensor.living_temp': {
|
||||
...c.hass.states['sensor.living_temp'],
|
||||
state: '23',
|
||||
attributes: { ...c.hass.states['sensor.living_temp']?.attributes, unit_of_measurement: '%' },
|
||||
},
|
||||
'media_player.tv': {
|
||||
...c.hass.states['media_player.tv'],
|
||||
state: 'Working',
|
||||
},
|
||||
},
|
||||
};
|
||||
c._regSignature = '';
|
||||
c._cfgEpoch++;
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('view');
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const qaStyle = document.createElement('style');
|
||||
qaStyle.textContent = '.devtip{display:none!important}';
|
||||
(c.renderRoot || c.shadowRoot).append(qaStyle);
|
||||
await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)));
|
||||
});
|
||||
|
||||
const selector = (id) => `.dev[data-id="${id}"]`;
|
||||
|
||||
async function runtimePng(theme, row, size) {
|
||||
await page.mouse.move(1, 1);
|
||||
await page.evaluate(({ id, themeName, classes, px, clearValues }) => {
|
||||
const node = (window.__card.renderRoot || window.__card.shadowRoot)
|
||||
.querySelector(`.dev[data-id="${id}"]`);
|
||||
for (const marker of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.dev'))
|
||||
marker.style.visibility = marker === node ? 'visible' : 'hidden';
|
||||
node.classList.remove(...[
|
||||
'theme-light', 'theme-dark', 'on', 'open', 'alarm', 'unavail', 'virtual',
|
||||
'sel', 'lock-locked', 'lock-unlocked',
|
||||
]);
|
||||
node.classList.add(`theme-${themeName}`, ...classes);
|
||||
node.style.setProperty('--device-base-size', `${px}px`);
|
||||
node.style.setProperty('--dev-scale', '1');
|
||||
node.querySelector('.device-core')?.style.setProperty('transition', 'none');
|
||||
node.querySelector('.device-shell-frame')?.style.setProperty('transition', 'none');
|
||||
if (clearValues) node.querySelectorAll('.value-badge').forEach((value) => value.remove());
|
||||
node.blur();
|
||||
}, {
|
||||
id: row.id,
|
||||
themeName: theme.toLowerCase(),
|
||||
classes: row.classes || [],
|
||||
px: size,
|
||||
clearValues: row.clearValues || false,
|
||||
});
|
||||
if (row.hover) {
|
||||
await page.hover(selector(row.id));
|
||||
await page.waitForTimeout(180);
|
||||
}
|
||||
if (row.focus) {
|
||||
await page.$eval(selector(row.id), (node) => node.focus());
|
||||
}
|
||||
await page.$eval(selector(row.id), (node) => {
|
||||
for (const tooltip of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.devtip'))
|
||||
tooltip.style.setProperty('display', 'none', 'important');
|
||||
node.querySelector('.lqi')?.style.setProperty('display', 'none');
|
||||
});
|
||||
const clip = await page.$eval(selector(row.id), (node) => {
|
||||
const shell = node.querySelector('.device-shell-frame').getBoundingClientRect();
|
||||
const pad = 22;
|
||||
return {
|
||||
x: Math.max(0, shell.left - pad),
|
||||
y: Math.max(0, shell.top - pad),
|
||||
width: shell.width + pad * 2,
|
||||
height: shell.height + pad * 2,
|
||||
};
|
||||
});
|
||||
return (await page.screenshot({ clip })).toString('base64');
|
||||
}
|
||||
|
||||
const rows = [
|
||||
{ label: 'Default', file: 'Icon Default.svg', id: 'd_light1' },
|
||||
{ label: 'Hover', file: 'Icon Hover.svg', id: 'd_light1', hover: true },
|
||||
{ label: 'Active', file: 'Icon Active.svg', id: 'd_light1', classes: ['on'] },
|
||||
{ label: 'Lock', file: 'Lock.svg', id: 'd_lock', classes: ['lock-locked'] },
|
||||
{ label: 'Unlock', file: 'Unlock.svg', id: 'd_lock', classes: ['lock-unlocked'] },
|
||||
{ label: 'Selected', file: 'Selected.svg', id: 'd_light1', classes: ['sel'] },
|
||||
{ label: 'Focus', file: 'Focus Visible.svg', id: 'd_light1', focus: true },
|
||||
{ label: 'Alert', file: 'Alert Value.svg', id: 'd_temp', classes: ['alarm'] },
|
||||
{ label: 'Virtual', file: 'Virtual Device Default.svg', id: 'd_motion', classes: ['virtual'] },
|
||||
{ label: 'Unavailable', file: 'Unavailable.svg', id: 'd_light1', classes: ['unavail'] },
|
||||
{ label: 'Text', file: 'Text Default.svg', id: 'd_tv' },
|
||||
{ label: 'Double Right', file: 'Double Default Right.svg', id: 'd_temp' },
|
||||
];
|
||||
|
||||
const matrix = [];
|
||||
for (const theme of ['Light', 'Dark']) {
|
||||
for (const row of rows) {
|
||||
matrix.push({
|
||||
theme,
|
||||
row,
|
||||
size: 56,
|
||||
runtime: await runtimePng(theme, row, 56),
|
||||
});
|
||||
}
|
||||
for (const size of [32, 96]) {
|
||||
const row = rows[0];
|
||||
matrix.push({ theme, row, size, runtime: await runtimePng(theme, row, size) });
|
||||
}
|
||||
const textRow = rows.find((row) => row.label === 'Text');
|
||||
matrix.push({ theme, row: textRow, size: 96, runtime: await runtimePng(theme, textRow, 96) });
|
||||
}
|
||||
|
||||
const escapeHtml = (value) => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
const body = matrix.map(({ theme, row, size, runtime }) => {
|
||||
const reference = referenceAsset(theme, row.file, size);
|
||||
return `
|
||||
<tr>
|
||||
<td>${theme}</td><td>${escapeHtml(row.label)}</td><td>${size}px</td>
|
||||
<td class="preview"><img style="width:${reference.displayWidth}px" src="${reference.url}" alt="Reference ${escapeHtml(row.label)}"></td>
|
||||
<td class="preview runtime"><img src="data:image/png;base64,${runtime}" alt="Runtime ${escapeHtml(row.label)}"></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
const html = `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Device icon reference/runtime matrix</title>
|
||||
<style>
|
||||
body{margin:24px;background:#777;color:#111;font:16px system-ui,sans-serif}
|
||||
h1,p{max-width:1100px} table{border-collapse:collapse;width:100%;background:#aaa}
|
||||
th,td{border:1px solid #555;padding:8px;text-align:left} th{position:sticky;top:0;background:#ddd;z-index:2}
|
||||
.preview{width:38%;text-align:center;background:linear-gradient(135deg,#d5d5d5 50%,#666 50%)}
|
||||
.preview img{display:block;margin:auto;max-width:300px;max-height:180px}.runtime img{image-rendering:auto}
|
||||
</style></head><body>
|
||||
<h1>House Plan device icons: package 1.1.1 vs runtime</h1>
|
||||
<p>Issues #211/#217. Reference SVG is loaded directly from the designer package; Runtime is a fresh browser capture. Default covers 32/56/96 px and Text has an additional large 96 px row so its outer stadium curvature is reviewable. Dark Unlock is evaluated using the owner's amber override from #179.</p>
|
||||
<table><thead><tr><th>Theme</th><th>State/layout</th><th>Core</th><th>Reference SVG</th><th>Runtime</th></tr></thead>
|
||||
<tbody>${body}</tbody></table></body></html>`;
|
||||
const htmlPath = resolve(artifactDir, 'device-icons-reference-runtime.html');
|
||||
writeFileSync(htmlPath, html);
|
||||
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
await page.setContent(html, { waitUntil: 'load' });
|
||||
await page.screenshot({
|
||||
path: resolve(artifactDir, 'device-icons-reference-runtime.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
await browser.close();
|
||||
console.log(`OK device icon reference/runtime matrix: ${htmlPath}`);
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, 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 { assertFreshDemoBundle } from '../bundle-freshness.mjs';
|
||||
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
|
||||
import { launch } from '../serve.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUTPUT = resolve(ROOT, 'docs/images');
|
||||
const BUNDLE = resolve(ROOT, 'dist/houseplan-card.js');
|
||||
const DEMO_BUNDLE = resolve(ROOT, 'demo/srv/assets/houseplan-card.js');
|
||||
const INTEGRATION_BUNDLE = resolve(ROOT, 'custom_components/houseplan/frontend/houseplan-card.js');
|
||||
const SCRIPT = fileURLToPath(import.meta.url);
|
||||
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
export const DOC_SCREENSHOT_VERSION = 1;
|
||||
export const DOC_SCREENSHOTS = Object.freeze([
|
||||
{
|
||||
id: 'view-desktop', file: '01-view-desktop.png', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', roomMetrics: true,
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'view-touch', file: '02-view-touch.png', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', roomMetrics: true, kiosk: true,
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 390, height: 760 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'space-create', file: '03-space-create.png', fixture: 'empty', noFloors: true,
|
||||
title: 'House Plan', language: 'en', theme: 'dark',
|
||||
viewport: { width: 900, height: 850 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
{
|
||||
id: 'room-contour-close', file: '04-room-contour-close.png', fixture: 'visual',
|
||||
space: 'golden-geometry', mode: 'plan',
|
||||
wallJunctionPreview: {
|
||||
path: [[0.18, 0.18], [0.40, 0.18], [0.40, 0.40], [0.18, 0.40]],
|
||||
pointer: [0.18, 0.18], cms: [440, 440, 440], cm: 15,
|
||||
},
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'plan-context-tray', file: '05-plan-context-tray.png', fixture: 'visual',
|
||||
space: 'golden-geometry', mode: 'plan', editorTray: 'plan-selection',
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'device-editor', file: '06-device-editor.png', fixture: 'visual',
|
||||
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
|
||||
deviceName: 'Living-room ceiling light',
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
{
|
||||
id: 'device-display-preview', file: '06-device-display-preview.png', fixture: 'visual',
|
||||
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
|
||||
deviceName: 'Living-room ceiling light', devicePresentationPreview: true,
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
{
|
||||
id: 'background-editor', file: '07-background-editor.png', fixture: 'visual',
|
||||
space: 'golden-geometry', mode: 'decor', editorTray: 'decor-selection',
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'room-card', file: '08-room-card.png', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', roomMetrics: true,
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'room-card',
|
||||
},
|
||||
{
|
||||
id: 'device-info', file: '09-device-info.png', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', dialog: 'device-info',
|
||||
deviceId: 'golden-light-two', deviceName: 'Living-room ceiling light',
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1000, height: 900 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const roomCardClip = (page) => page.evaluate(() => {
|
||||
const card = window.__goldenCard;
|
||||
const roomCards = [...(card?.renderRoot?.querySelectorAll('.roomlabel') || [])];
|
||||
const target = roomCards.find((item) => item.querySelector('.rlm')) || roomCards[0];
|
||||
if (!target) throw new Error('documentation room card is missing');
|
||||
const rect = target.getBoundingClientRect();
|
||||
const marginX = 80;
|
||||
const marginY = 70;
|
||||
return {
|
||||
x: Math.max(0, rect.left - marginX),
|
||||
y: Math.max(0, rect.top - marginY),
|
||||
width: Math.min(innerWidth, rect.right + marginX) - Math.max(0, rect.left - marginX),
|
||||
height: Math.min(innerHeight, rect.bottom + marginY) - Math.max(0, rect.top - marginY),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Documentation-only presentation state. Keep these mutations out of the
|
||||
* golden harness: changing that release fixture would invalidate every visual
|
||||
* baseline even though the production component and golden matrix are intact.
|
||||
*/
|
||||
const applyDocumentationState = (page, scenario) => page.evaluate(async (current) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const card = window.__goldenCard;
|
||||
if (!card) throw new Error(`documentation card is missing: ${current.id}`);
|
||||
|
||||
if (current.title) {
|
||||
card.setConfig({ ...card._config, title: current.title });
|
||||
}
|
||||
|
||||
if (current.roomMetrics) {
|
||||
const space = card._serverCfg?.spaces?.find((item) => item.id === current.space);
|
||||
if (!space) throw new Error(`documentation room metrics space is missing: ${current.space}`);
|
||||
space.settings = {
|
||||
...(space.settings || {}),
|
||||
label_temp: true,
|
||||
label_hum: true,
|
||||
label_lqi: true,
|
||||
label_light: true,
|
||||
};
|
||||
card._cfgEpoch += 1;
|
||||
card._modelCache = null;
|
||||
}
|
||||
|
||||
if (current.fixture === 'empty') {
|
||||
card._serverCfg = { ...(card._serverCfg || {}), spaces: [] };
|
||||
card._cfgEpoch += 1;
|
||||
card._modelCache = null;
|
||||
card._space = '';
|
||||
card._onboardingShown = true;
|
||||
card.hass = { ...card.hass, floors: {} };
|
||||
card._openSpaceDialog('create');
|
||||
}
|
||||
|
||||
if (current.dialog === 'device-info') {
|
||||
const device = card._devices.find((item) => item.id === current.deviceId);
|
||||
if (!device) throw new Error(`documentation device is missing: ${current.deviceId}`);
|
||||
card._infoCard = device;
|
||||
}
|
||||
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
|
||||
if (current.devicePresentationPreview) {
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const body = dialog?.querySelector('.body');
|
||||
const preview = dialog?.querySelector('hp-device-preview');
|
||||
await preview?.updateComplete;
|
||||
if (!body || !preview)
|
||||
throw new Error('documentation device presentation preview is missing');
|
||||
const bodyRect = body.getBoundingClientRect();
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
body.scrollTop += previewRect.top - bodyRect.top - 180;
|
||||
await frame();
|
||||
const visibleBody = body.getBoundingClientRect();
|
||||
const visiblePreview = preview.getBoundingClientRect();
|
||||
if (visiblePreview.top < visibleBody.top - 1 || visiblePreview.bottom > visibleBody.bottom + 1)
|
||||
throw new Error('documentation viewport does not show the device presentation preview');
|
||||
}
|
||||
|
||||
return { dialog: !!card.renderRoot.querySelector('hp-dialog') };
|
||||
}, scenario);
|
||||
|
||||
mkdirSync(OUTPUT, { recursive: true });
|
||||
copyFileSync(BUNDLE, DEMO_BUNDLE);
|
||||
copyFileSync(BUNDLE, INTEGRATION_BUNDLE);
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const browserErrors = [];
|
||||
page.on('pageerror', (error) => browserErrors.push(error.message));
|
||||
|
||||
try {
|
||||
const fingerprint = await assertFreshDemoBundle(page, ROOT);
|
||||
const scenarios = {};
|
||||
for (const scenario of DOC_SCREENSHOTS) {
|
||||
await prepareGoldenScenario(page, scenario);
|
||||
const runtime = await applyDocumentationState(page, scenario);
|
||||
if (scenario.expectDialog && !runtime.dialog)
|
||||
throw new Error(`documentation scenario did not open its dialog: ${scenario.id}`);
|
||||
const clip = scenario.capture === 'room-card'
|
||||
? await roomCardClip(page)
|
||||
: await goldenClip(page, scenario.capture);
|
||||
const image = await page.screenshot({
|
||||
...(clip ? { clip } : {}), animations: 'disabled', caret: 'hide', scale: 'css',
|
||||
});
|
||||
writeFileSync(resolve(OUTPUT, scenario.file), image);
|
||||
scenarios[scenario.id] = {
|
||||
file: scenario.file,
|
||||
viewport: scenario.viewport,
|
||||
theme: scenario.theme,
|
||||
language: scenario.language,
|
||||
sourceSha256: fingerprint,
|
||||
imageSha256: sha256(image),
|
||||
};
|
||||
console.log(`captured ${scenario.id} -> docs/images/${scenario.file}`);
|
||||
}
|
||||
if (browserErrors.length) throw new Error(`browser errors: ${browserErrors.join(' | ')}`);
|
||||
const manifest = {
|
||||
version: DOC_SCREENSHOT_VERSION,
|
||||
fixture: 'synthetic-only',
|
||||
sourceFingerprint: fingerprint,
|
||||
captureScriptSha256: sha256(readFileSync(SCRIPT)),
|
||||
command: 'npm run build && node demo/docs/capture.mjs',
|
||||
scenarios,
|
||||
};
|
||||
writeFileSync(resolve(OUTPUT, 'screenshots.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// AC-13 for #157. Usage:
|
||||
// node demo/downgrade_open_passage.mjs --bundle=/absolute/v1.64.0/dist/houseplan-card.js
|
||||
// The v1.64.0 frontend does not understand `passage`; this executable fixture
|
||||
// pins its documented best-effort fallback (door symbol) and, critically,
|
||||
// rejects any pageerror/unhandled exception while reading the newer literal.
|
||||
import { cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { isAbsolute, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const value = process.argv.find((arg) => arg.startsWith('--bundle='))?.slice('--bundle='.length);
|
||||
if (!value) {
|
||||
console.error('usage: node demo/downgrade_open_passage.mjs --bundle=/absolute/v1.64.0/houseplan-card.js');
|
||||
process.exit(2);
|
||||
}
|
||||
const bundle = isAbsolute(value) ? value : resolve(value);
|
||||
if (!existsSync(bundle)) {
|
||||
console.error(`v1.64.0 bundle not found: ${bundle}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const currentDemo = fileURLToPath(new URL('./srv', import.meta.url));
|
||||
const serveRoot = mkdtempSync(join(tmpdir(), 'hp-157-downgrade-'));
|
||||
let browser;
|
||||
try {
|
||||
cpSync(currentDemo, serveRoot, { recursive: true });
|
||||
cpSync(bundle, join(serveRoot, 'assets', 'houseplan-card.js'));
|
||||
const launched = await launch(undefined, 1, [], {}, serveRoot);
|
||||
browser = launched.browser;
|
||||
const out = await launched.page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const space = card._serverCfg.spaces.find((item) => item.id === card._space);
|
||||
space.openings = [{
|
||||
id: 'future-passage', type: 'passage', x: 0.3, y: 0.14,
|
||||
angle: 0, length: 0.09, future_material: 'stone',
|
||||
}];
|
||||
card._setMode('plan');
|
||||
card._cfgEpoch++;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)));
|
||||
const opening = root().querySelector('[data-hp="opening"][data-id="future-passage"]');
|
||||
const stored = space.openings[0];
|
||||
return {
|
||||
newerLiteralLoads: !!opening,
|
||||
documentedDoorFallback: !!opening?.querySelector('.op-leaf,.op-arc'),
|
||||
readDoesNotRewriteConfig: stored.type === 'passage'
|
||||
&& stored.future_material === 'stone' && space.openings.length === 1,
|
||||
};
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
browser = undefined;
|
||||
} finally {
|
||||
await browser?.close?.();
|
||||
rmSync(serveRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -57,6 +57,11 @@ const lightingRooms = [
|
||||
poly: [[0.50, 0.10], [0.93, 0.10], [0.93, 0.88], [0.50, 0.88]] },
|
||||
];
|
||||
|
||||
const applianceRooms = [
|
||||
{ id: 'appliance-room', name: 'Laundry', area: 'golden_appliance',
|
||||
poly: [[0.08, 0.10], [0.92, 0.10], [0.92, 0.90], [0.08, 0.90]] },
|
||||
];
|
||||
|
||||
const geometrySpace = {
|
||||
id: 'golden-geometry',
|
||||
title: 'Geometry matrix',
|
||||
@@ -122,7 +127,25 @@ const lightingSpace = {
|
||||
decor: [],
|
||||
};
|
||||
|
||||
const runtime = () => {
|
||||
const applianceSpace = {
|
||||
id: 'golden-appliance',
|
||||
title: 'Appliance lifecycle',
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
settings: {
|
||||
fill_mode: 'none', glow_enabled: false, show_borders: true, show_names: true,
|
||||
sun_rays: false, bg_mode: 'static',
|
||||
},
|
||||
rooms: applianceRooms,
|
||||
walls: wallsFor('appliance', applianceRooms, 15),
|
||||
openings: [],
|
||||
partitions: [],
|
||||
wall_columns: [],
|
||||
decor: [],
|
||||
};
|
||||
|
||||
const runtime = (includeAppliance = false) => {
|
||||
const devices = {};
|
||||
const entities = {};
|
||||
const states = {
|
||||
@@ -136,7 +159,8 @@ const runtime = () => {
|
||||
// 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 }]),
|
||||
[...geometryRooms, ...lightingRooms, ...(includeAppliance ? applianceRooms : [])]
|
||||
.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('-', '_')}`;
|
||||
@@ -171,6 +195,44 @@ const runtime = () => {
|
||||
add('golden-right-linkquality', 'sensor', 'golden_light_right', 0.66, 0.70, '190', {
|
||||
unit_of_measurement: 'lqi',
|
||||
});
|
||||
|
||||
if (includeAppliance) {
|
||||
const washerId = 'golden-washer';
|
||||
devices[washerId] = {
|
||||
id: washerId,
|
||||
name: 'Golden washing machine',
|
||||
model: 'GOLDEN-WASHER-COMPOSITE',
|
||||
area_id: 'golden_appliance',
|
||||
identifiers: [['houseplan_golden', washerId]],
|
||||
config_entries: ['golden_entry'],
|
||||
entry_type: null,
|
||||
via_device_id: null,
|
||||
disabled_by: null,
|
||||
};
|
||||
const addWasherEntity = (entityId, state, attributes = {}, registry = {}) => {
|
||||
entities[entityId] = {
|
||||
entity_id: entityId,
|
||||
device_id: washerId,
|
||||
platform: 'houseplan_golden',
|
||||
config_entry_id: 'golden_entry',
|
||||
disabled_by: null,
|
||||
...registry,
|
||||
};
|
||||
states[entityId] = {
|
||||
entity_id: entityId,
|
||||
state,
|
||||
attributes: { friendly_name: registry.original_name || entityId, ...attributes },
|
||||
};
|
||||
};
|
||||
addWasherEntity('switch.golden_washer_power', 'on', {}, { original_name: 'Power' });
|
||||
addWasherEntity('switch.golden_washer_child_lock', 'off', {}, { original_name: 'Child lock' });
|
||||
addWasherEntity('sensor.golden_washer_status', 'done', {}, {
|
||||
original_name: 'Status', translation_key: 'status',
|
||||
});
|
||||
addWasherEntity('sensor.golden_washer_stage', 'Rinse', {}, { original_name: 'Stage' });
|
||||
addWasherEntity('sensor.golden_washer_program', 'mixed_wash', {}, { original_name: 'Program' });
|
||||
layout[washerId] = { s: 'golden-appliance', x: 0.5, y: 0.5 };
|
||||
}
|
||||
return { devices, entities, states, layout, areas };
|
||||
};
|
||||
|
||||
@@ -182,9 +244,12 @@ export const VISUAL_MATRIX_COUNTS = Object.freeze({
|
||||
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
|
||||
});
|
||||
|
||||
export const makeVisualMatrixFixture = () => ({
|
||||
export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) => ({
|
||||
config: {
|
||||
spaces: [structuredClone(geometrySpace), structuredClone(lightingSpace)],
|
||||
spaces: [
|
||||
structuredClone(geometrySpace), structuredClone(lightingSpace),
|
||||
...(applianceLifecycle ? [structuredClone(applianceSpace)] : []),
|
||||
],
|
||||
// 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.
|
||||
@@ -201,6 +266,10 @@ export const makeVisualMatrixFixture = () => ({
|
||||
},
|
||||
},
|
||||
},
|
||||
...runtime(),
|
||||
counts: VISUAL_MATRIX_COUNTS,
|
||||
...runtime(applianceLifecycle),
|
||||
counts: applianceLifecycle ? {
|
||||
...VISUAL_MATRIX_COUNTS,
|
||||
spaces: VISUAL_MATRIX_COUNTS.spaces + 1,
|
||||
rooms: VISUAL_MATRIX_COUNTS.rooms + applianceRooms.length,
|
||||
} : VISUAL_MATRIX_COUNTS,
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 59 KiB |
@@ -1,56 +1,93 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 17,
|
||||
"acceptedAt": "2026-08-13T14:30:05.929Z",
|
||||
"sourceFingerprint": "66f31850eafc963848acdc4e56e363a364bbb9b189bf4ade73d3c111ae2fc375",
|
||||
"matrixVersion": 34,
|
||||
"acceptedAt": "2026-08-22T14:09:45.021Z",
|
||||
"sourceFingerprint": "2d8295e95a0721f11075dd0388ab4960c79b480ae199a381d3bbd1086e5e5b34",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"isometric-geometry-view-dark": "6db601f322fe55e33a1a89fe62f5d3a7d53f12bac4a68c7911b12f6382ef4fca",
|
||||
"isometric-geometry-view-light": "c4c005fa55f64280abf9fccccdf7dc8efc3497bd626572bc11f45cc3cddaa159",
|
||||
"isometric-live-layers-dark": "869c62bf9cd762c36d342d1a3bbd992969425b46b132f3243439735e2c75c14f",
|
||||
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
|
||||
"isometric-touch-kiosk-dark": "36c83bbe39809f346ebb5a0f4ed633c938e2fed028defaa48097c281db8e23a3",
|
||||
"isometric-large-warm-remount-dark": "798d312671dffebf59034a39f2865a65ece27a84b150e77bc59038bb2567d07c",
|
||||
"geometry-view-dark-fit": "a538deed6141b98b3e396d7da1024b18aee345312edd7954ad458751f08f48a3",
|
||||
"geometry-view-light-fit": "0c57dee930f30a1f9c16e4e704675156f57a7623a4edf839c14a4fecbf12881c",
|
||||
"geometry-plan-editor-dark": "6b06213324c5ff50c7176451307a33d8ac31ee636c96698f8efe61e8db763bae",
|
||||
"opening-placement-door-thick-wall-dark": "24f472818f2246eeaa6568648ca4a6c42d9d84723fbcdd879435253399e2995d",
|
||||
"geometry-devices-editor-dark": "c850e83f1af747f063b895109fe26d6a5804356fab505b0976073c28ad146104",
|
||||
"geometry-decor-editor-dark": "44a95fd0b397c2729fea65c750fedcb11df10042ae198f3690151ead077c84e1",
|
||||
"tray-wide-selection-en": "468ac6acfd7fcdcbfa947e032843ff117a32ae01b4ec30937a6cddecff534504",
|
||||
"tray-wide-tool-ru": "388e03d7bf7a2391d0581e8446d0692048b9792e80bf3b9dd9bca86222fe9418",
|
||||
"tray-medium-group-en": "190d5c356461ad2aa8b63b132416e8958a285047f8dea92301213678c7ac5a92",
|
||||
"tray-medium-selection-ru": "ab91e88583b2863305434ce2775e31328638414671ff0a4f8ee82c8274c04405",
|
||||
"tray-narrow-palette-en": "fb63483e7101d457d7fb1c83ae50435410ed797771adcbfde14dc2798a000ce1",
|
||||
"tray-narrow-tool-ru": "60ce3c75de88b72fdb5d3fe7a16790184a8265c74f0f97e5be4fd83dbe0259fd",
|
||||
"geometry-diagonal-45-opening-dark": "3736a75163d47a71f60c45cd554d604f27cfbc81119b5b55e8134da61a2a0f0a",
|
||||
"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",
|
||||
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
|
||||
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
|
||||
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
|
||||
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
|
||||
"isometric-geometry-view-dark": "9bc8eb0da8746bddc0b5245b337eef7477d4ce6fb39ac576cab1e49057fd837c",
|
||||
"isometric-geometry-view-light": "144f1cc3107562bc252cdcb54165f2c92569f4cf3484554148a7fdb78de372ec",
|
||||
"isometric-live-layers-dark": "01660f4cf3e7344a87c9acc9e80239757b0ef5f365a37257c4b147144399b7cc",
|
||||
"isometric-no-borders-dark": "ac92e782e5f58e514c3731f742ead6939cde3344810fe2f2080876b3deb1335b",
|
||||
"isometric-touch-kiosk-dark": "745482a797e236058e3ddd56cb5d85d700c9333a3aeb764333addeaba40cf6b0",
|
||||
"isometric-large-warm-remount-dark": "37b47189e395d56e746501955a5b215298a3261a49c7f27e248b7957b2f31e46",
|
||||
"geometry-view-dark-fit": "438817c56cd8f91ef63778a3d3d22a4ee1fb675064bc24bf613fb752d6c503d9",
|
||||
"geometry-view-light-fit": "667d38fd55a946ea6930b8674c4036e75c261ba4f111f893367ae72ed19500fc",
|
||||
"room-label-parity-view-dark": "3a636de579cd2d1de338d017ed6b53c3e2f70a901a0f5c81dfca85870d1b4bd5",
|
||||
"room-label-parity-plan-dark": "e782f0386a6b1e75c7bb9f135e9ec01a9ff4b540f0384d518c7ea9f2198683ea",
|
||||
"room-label-parity-view-light": "6eb2ba0023932c9b4e82a45b7ca9565a9ed1b89d14e543facbaf1f84aa5db9ff",
|
||||
"room-label-parity-plan-light": "333139affbf8936554072aebcaf58b4640779adbd6a64686c0929ca071063733",
|
||||
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
|
||||
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
|
||||
"day-cycle-dawn-dark": "289e6edd6c206308d3257775b2948085a24698398995bc6ce72201745705c58c",
|
||||
"day-cycle-day-dark": "df27409e83f4492d3f69dc6cebf2a6e7799e154698425b17fb3ab8cfddb426a6",
|
||||
"day-cycle-dusk-dark": "9798e9dac27e69727adbe9c9a782a3bd3dc5dd0850e7a49852c069d54b382f9a",
|
||||
"day-cycle-night-dark": "1abf9528ac05dc3a963eb19a8b3ed4b7bb9ce647f7b83dab4fd2e736756cd770",
|
||||
"geometry-plan-editor-dark": "a3828bdd877c23d283dcd30dc88287c2104c8d39e849aa826a76190d7daa30ae",
|
||||
"plan-snap-endpoint-light": "2df0bd2abcb615d6bd1a4422b29747f95a1edd7b02d4ae3a6b333e24b27e9f78",
|
||||
"plan-snap-line-gaps-dark": "a84364cf6bbb7b23fa9ec28ba8edbb03db7a4e401944b4a8e2c4a42871533bc3",
|
||||
"wall-junctions-plan-preview-light": "f50afa3358d34552ef92947ec07403e3ae507b12881f688742f630e8e54ffe43",
|
||||
"wall-junctions-plan-t-dark": "ec2b29ca87bba9b41e4c22ce94d2c4fe06c6d9c0833152d3a6224371763ae6bc",
|
||||
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
|
||||
"junction-patch-resilience-plan-dark": "c42203eea92d2bc972eed829aa726ad51f36fc76d0ae049730d0f4155ef8efbf",
|
||||
"junction-patch-resilience-view-dark": "d531f2a01def73fb074e55543c8543112af158df65f24dc549e0da5a23f1af40",
|
||||
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
|
||||
"opening-placement-door-thick-wall-dark": "2bd1a6e3f7b676fa4820f67a9ed0f9f87c452c318711523cb22cab8750b6e7ba",
|
||||
"opening-placement-passage-thick-wall-dark": "8b1955eaa2dd12f492d614d7642a71646d879810e52bd434adb5cfc5d1a36f18",
|
||||
"opening-placement-passage-thick-wall-light": "96cc1d50db1e86d71ddfc55b89640b28d65c7315936f3af9e38b75c881dae7ef",
|
||||
"geometry-devices-editor-dark": "2ec2647797c71f6c6406f6e33fcbf70d5cb6d36a01a65c70ecabc7ad11e86183",
|
||||
"geometry-decor-editor-dark": "46798a59d99982c4e73596861e20b6e466f6e7aa1d70a26bf917e1bd3bf73cb1",
|
||||
"tray-wide-selection-en": "457b53c4e5ae5f691457f98f3c2fd255e5b7ed1c599af92c303c93c11f87b7d4",
|
||||
"tray-wide-tool-ru": "1fcc25ee374c95f51c71b33720daa37f82a92ccd52119b8bdb48de5484591e49",
|
||||
"tray-medium-group-en": "3bebd852929ff67cff11827d09c4859e32e17fa0be36fcabd4a591c4728a4827",
|
||||
"tray-medium-selection-ru": "e40dcdadf3ebdbec200175dc6648382daa91a232d081c8add1f6646c7d1a0ee5",
|
||||
"tray-narrow-palette-en": "861adc403fbf0aff1e45b27fc07c4856f3ca409b58ef87cc4a81d870a09dbb9a",
|
||||
"tray-narrow-tool-ru": "ccf11295b104cecd4bf1e1bc95417003e2c40cfba29b5555ac9ba021b90d16a6",
|
||||
"geometry-diagonal-45-opening-dark": "dd93866af62313806a4444b707943c693e2b7709367156ebdc3b80168233bbe8",
|
||||
"openings-thick-wall-dark": "0c33cdb4637ee9788e75931ba142099e1d068b770e26ca24f4a59f8631f3373f",
|
||||
"openings-filled-tunnel-dark": "bb2a4343a285e2fd867c6090108589549facf87b1a9471f3ae331d743af6653c",
|
||||
"openings-hidden-view-dark": "19ab4fd2ffce0fea0fe609b007a3e60f7abd84b40935cbf3c5fa9dc1fda21046",
|
||||
"lighting-glow-sun-dark": "3f02abbcb3379b2e224ba7dd3126ec4cb736c0f71edbcdd2f2961c33d7d1b12a",
|
||||
"device-value-badge-positions-dark": "831f04e8b97e4cb473e2c9e53976a5422dd3718eb74086ed2503ffc6d3f5ad87",
|
||||
"device-icon-state-table-light": "b956debb1e3845c03b1a69ba1a45f65b682ea6ee058f40003657bb6f5ad09ee8",
|
||||
"device-icon-state-table-dark": "bf3caed74f7832a142f730481e50f9e2706aeeee009b39118a9ac4d011954e7c",
|
||||
"device-text-shell-long-light": "4ad4f05ebd24d65b98bd4c32f71184088062ae75a52a5a7cc54300ea1961b924",
|
||||
"device-text-shell-long-dark": "f35d426dccd0705465bfedc112776df06233aaa5ffc37765dd71222787cfb3d6",
|
||||
"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": "b70e385829a4fe45a77f5e1c3f8af4e18efec5282fb9594b26fab08c1f113d52",
|
||||
"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": "16d2859d1ed3715c1c4d3d451a8428c37e91ba00da17272c59cf83420a7b6c31",
|
||||
"backup-full-preview-desktop-en": "7f12943d1027c89f6fe46978fa1f4e1bcdbf85a1216281d850d467e68f52cbda",
|
||||
"backup-space-preview-mobile-ru": "998c6b52c1cc95109feb440a9966cade738154ceeae387246d40f8c56f9e8a3a"
|
||||
"lighting-fill-light-axis-split-dark": "4cb3f7591f005768d1d8e42f651184a7317b439318302d6bc48fcca000266c82",
|
||||
"lighting-fill-temp-axis-split-dark": "47383a2e81b34cd7cf385a0f33efe755906c1cb20de8345dc69f599597378801",
|
||||
"lighting-fill-lqi-axis-split-dark": "480b518fb2da24fc7d70297a4456a92485f5f5629a6bc951a36eedcc7631e5dc",
|
||||
"lighting-temp-glow-dark": "a5c8f7b981d4f310ca8cf6c19f72236dc29abdd88a1d60be6ef916dd9091f7b8",
|
||||
"lighting-temp-glow-light": "86620d7b68e797469fbec36438bf2725bd913bc8b4641dbe75c03098210d17f7",
|
||||
"lighting-custom-glow-dark": "86a1ba7ac8e6bc9515a4df550065c60fd600ce6898bdc5447122c9fc87fb3965",
|
||||
"lighting-opaque-glow-two-doorways-dark": "0cff53678f935bb9cee4570a47bf34a05d71b1fd512d76158e846c0b1aa7a5c5",
|
||||
"lighting-custom-glow-light": "2568f1ca7be270faabd2c97d955fd6e27dec00e921d8e6f21ab7425a86e7f958",
|
||||
"lighting-temp-glow-no-sources-dark": "ee37a7dda19a0bfe02405d4e9cf61023cecb623896388a4197adc4a5afe6b584",
|
||||
"lighting-temp-glow-room-override-dark": "fa20be4d2a29710143f437d5049c6e71959c6a56df2a12a9d2e9728e7833d9ed",
|
||||
"lighting-manual-auto-spill-overlap-dark": "9bb31a72bfa073eae4630ea5edf01b5a7dbb1885eb25153c4bf3b2574fc6e5c5",
|
||||
"hover-over-glow-dark": "d505d1514e492dd3f5e299ebace0392e14734f21626310a8efdc0d148a00ddf0",
|
||||
"hover-nested-room-dark": "2db78e53a76fa9b7cdc4597f23fc2c8ae439e5a56d82895109ca968d73075640",
|
||||
"large-house-zoom-040-dark": "a21faa9eafea706cf8d6366e93f526a8f83798b6b6cf058ee43010607182677d",
|
||||
"large-house-zoom-250-dark": "48b13045d83ac83bc35aba7182057ec56023ac448ad57f6b7dc9981349cea563",
|
||||
"large-house-warm-remount-dark": "99ef82407bd148cef968e99a593f25da06da2df7363af842f617113349c7ee85",
|
||||
"device-dialog-desktop-en": "ea341f6ea9db5f61bbcb6fd7ca5078fd2f347023de4279cb70bf190c1379c364",
|
||||
"device-dialog-mobile-ru": "c90be98e65c37963fd4443e413848ca68a412c9ee568d648aba76bf9232ee59d",
|
||||
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
|
||||
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
|
||||
"device-help-popover-light-ru": "c74c83ecfb4ee6cd693d87d4516e9ee4920ad453f5b7b9606cd98853b7273568",
|
||||
"decor-color-popover-mobile-ru": "af002e4c3106c008bbdb58ed1f00d8f8415dc86a8dfab2552defb4d16d8bbe71",
|
||||
"decor-color-popover-desktop-en": "11189416fd3752310979444aa92ce38764996655871009ff028ba9b4b31959a8",
|
||||
"general-color-popover-desktop-en": "da85d0159802afce90fd87ecf940ef1b97ffd57c27fbdc79469cc11082b9a482",
|
||||
"device-ripple-color-popover-mobile-ru": "3ab12c339106004c76fe3c7741d242269e157c40ddfa29afd8f13b445b0a7023",
|
||||
"space-room-color-popover-desktop-ru": "a5d62d68097af1ff5d2556be0c889efc25768ffc61af5d302f0b5775e082f296",
|
||||
"backup-full-preview-desktop-en": "6f0cfecf587b73f38088d414f489b67cc68ed71c37c96d4e307e66f6d97bda9f",
|
||||
"backup-plan-only-export-desktop-en": "1e1c8a9cc5383394b91e24af8342d92103a5e85b3e5c9b53c45ab0e9b0d57da4",
|
||||
"backup-space-preview-mobile-ru": "a4719cbe29b378ef7baa63bb7ff23e201b2943025008d939f2c08cee63bb9038",
|
||||
"optimize-preflight-dialog-dark-en": "9f6e35897adba06648d448e53755410cc831311038300affae396a396388b3d1",
|
||||
"optimize-preflight-dialog-light-ru": "cbfca454d9eab95aaab1b12330b3833c3eae21eac735eb2c09529082c5211abe"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 292 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 323 KiB After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 25 KiB |