Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c730b288ab | ||
|
|
f4b425f3c0 | ||
|
|
03cc606396 | ||
|
|
fac1f8840e | ||
|
|
7f9b919888 | ||
|
|
c409ec6a61 | ||
|
|
340c770b92 | ||
|
|
c5d9775c7a | ||
|
|
8215b61f4d | ||
|
|
567a45ab6b | ||
|
|
489e289f79 | ||
|
|
ce3e92b22b | ||
|
|
153dc3f794 | ||
|
|
1e479154f7 | ||
|
|
a3fb360ae1 | ||
|
|
080eb5ceff | ||
|
|
2b978d63ef | ||
|
|
e3073f6840 | ||
|
|
b71bc39b34 | ||
|
|
ad4cce0dda | ||
|
|
59ea93b4ba | ||
|
|
e46220f3eb | ||
|
|
1fd042d9e0 | ||
|
|
ef0d2be2be | ||
|
|
0e23ac2eee | ||
|
|
a79d4cd9d2 | ||
|
|
67935fa8c0 | ||
|
|
5e357a689a | ||
|
|
0e235047ef | ||
|
|
b660b7f63d | ||
|
|
5282967784 | ||
|
|
a73e1cdd22 | ||
|
|
a17820b3fe | ||
|
|
a173f9168e | ||
|
|
92ffca343b | ||
|
|
a0720e7584 | ||
|
|
308aae1fc8 | ||
|
|
6fb7bbb6ab | ||
|
|
27044543a6 | ||
|
|
206732e9f5 | ||
|
|
afe5e98944 | ||
|
|
95a958cca2 | ||
|
|
23c6781a82 | ||
|
|
9e47c30088 | ||
|
|
e176e79a77 | ||
|
|
1a953718a7 | ||
|
|
3e0124b7e9 | ||
|
|
6be4f9419e | ||
|
|
f5ba67d069 | ||
|
|
23cb71d382 | ||
|
|
c12ecad3e3 | ||
|
|
469aa3f7bd | ||
|
|
3bbe555ad8 | ||
|
|
d9c7c3df1c | ||
|
|
ca86e79501 | ||
|
|
736a6c143e | ||
|
|
6b3703f0b5 | ||
|
|
f36ded7fc2 | ||
|
|
4bdd266fbb | ||
|
|
65e0c2deed | ||
|
|
5bab93c421 | ||
|
|
8074612f6b | ||
|
|
319b25c281 | ||
|
|
9d5c0357b0 | ||
|
|
2d143b570f | ||
|
|
4fa670e873 | ||
|
|
ca0b318e5e | ||
|
|
ed700c8c8e | ||
|
|
d945b03208 | ||
|
|
823fc316b2 | ||
|
|
184e009806 | ||
|
|
36e1b7f9c2 | ||
|
|
24af34ccba | ||
|
|
b4809a12ff | ||
|
|
e926ea453e | ||
|
|
d5c93d4e28 | ||
|
|
bf40e26ed0 | ||
|
|
2c6d937d46 | ||
|
|
9c9a22354b | ||
|
|
77a22efc78 | ||
|
|
269d828dd4 | ||
|
|
816b1476f0 | ||
|
|
372d838ec2 | ||
|
|
8273af59ca | ||
|
|
7806ab7182 | ||
|
|
da82ab9199 | ||
|
|
c3bb62a3b6 | ||
|
|
bd780c6013 | ||
|
|
b3f22b33e4 | ||
|
|
aad1eeaa10 | ||
|
|
15ff625964 | ||
|
|
8ac8f0105a | ||
|
|
2903374b72 | ||
|
|
b04387ce91 | ||
|
|
2274e6eb84 | ||
|
|
355b0516c7 | ||
|
|
09613ce322 | ||
|
|
98d6edb7b2 | ||
|
|
64f48e7ffd | ||
|
|
8e7c927264 | ||
|
|
8119c523fa | ||
|
|
cd0ecf4db2 | ||
|
|
601e68e52d | ||
|
|
2fae84af58 | ||
|
|
ccd870ddf7 | ||
|
|
00b6f41233 | ||
|
|
931e49e3d6 | ||
|
|
11959e4c85 | ||
|
|
9b29df28db | ||
|
|
d94db87ee7 | ||
|
|
4794183114 | ||
|
|
bc076d3f50 | ||
|
|
26cb00db24 | ||
|
|
d5268086c2 | ||
|
|
61dd7ccb89 | ||
|
|
b319fc9548 | ||
|
|
633cb20e59 | ||
|
|
c8ef4f19e4 | ||
|
|
cdd9588ca6 | ||
|
|
bce0ae97b2 | ||
|
|
59546662e6 | ||
|
|
84f9901ca0 | ||
|
|
5c8cb58e1f | ||
|
|
4683a493cc | ||
|
|
6a0bb985e7 | ||
|
|
bd6722ccd2 | ||
|
|
d153b20a1d | ||
|
|
f07e3c54ff | ||
|
|
73ecfbd1d7 | ||
|
|
fe3b85c06b | ||
|
|
532743b01c | ||
|
|
60c211c82a | ||
|
|
6bf39ee9a7 | ||
|
|
5c4d8cba9e | ||
|
|
8b2a146c6c | ||
|
|
98028a3093 | ||
|
|
cc44b28f3b | ||
|
|
bb635298c1 | ||
|
|
6befe4168f | ||
|
|
6d229c66f1 | ||
|
|
627a5359c3 | ||
|
|
a1d7e0da55 | ||
|
|
f7fb3369b7 | ||
|
|
d9b6766362 | ||
|
|
2b3cabe7e0 | ||
|
|
9fde407e4e | ||
|
|
692e3b72f4 | ||
|
|
480d202fa5 | ||
|
|
a4b686a7fe | ||
|
|
69dd09a7c9 | ||
|
|
037a19d093 | ||
|
|
f4c66bed3f | ||
|
|
b17f416740 | ||
|
|
08d5612265 | ||
|
|
d87cc29804 | ||
|
|
f4fcb1fac0 | ||
|
|
6edcde012a | ||
|
|
38b48ee0aa | ||
|
|
83692e7959 | ||
|
|
9fcbb64633 | ||
|
|
a25de7383f | ||
|
|
f3620f2b27 | ||
|
|
f8cf18a25a | ||
|
|
d6a2607692 | ||
|
|
8f485c0b00 | ||
|
|
3421065286 | ||
|
|
371f2e9869 | ||
|
|
334dcf2bf8 | ||
|
|
7cf22cb54f | ||
|
|
4132e03642 | ||
|
|
54f9efb798 | ||
|
|
cc6bbdf24c | ||
|
|
2f021046aa | ||
|
|
2b3d5613f2 | ||
|
|
f3ac0ac061 | ||
|
|
7b363caabc | ||
|
|
751769908a | ||
|
|
c91ac74e19 | ||
|
|
2782f3b1f3 | ||
|
|
4ba1b2730f | ||
|
|
a6b5161f07 | ||
|
|
206bcd1ae7 | ||
|
|
8955c02e3a | ||
|
|
7e27bc7922 | ||
|
|
dddbbe5522 | ||
|
|
01fbb77f3b | ||
|
|
972e708a71 | ||
|
|
569867487c | ||
|
|
9f50778df5 | ||
|
|
26d45cf405 | ||
|
|
fa1aa7a877 | ||
|
|
f7ae00019c | ||
|
|
778da930cb | ||
|
|
92cf0ab17c | ||
|
|
512ab7f279 | ||
|
|
6582a06e17 | ||
|
|
c9dd3401fe | ||
|
|
fa20e37330 | ||
|
|
09adfd73a0 | ||
|
|
d274e38d57 | ||
|
|
10cdba8435 | ||
|
|
323b7803e0 | ||
|
|
066cf44c2f | ||
|
|
e2a681b543 | ||
|
|
2518abd961 | ||
|
|
31cb2827bf | ||
|
|
a577fde799 | ||
|
|
8dfb0a9e36 | ||
|
|
a3dcee5241 | ||
|
|
eb5aa2a0f8 | ||
|
|
162c8a3bef | ||
|
|
a47ec97a04 | ||
|
|
b2074c103c | ||
|
|
a015f31376 | ||
|
|
f2d620b5d5 | ||
|
|
fd301b6b08 | ||
|
|
6379334b21 | ||
|
|
ac625833f2 | ||
|
|
511b1bb980 | ||
|
|
23b94b2e61 | ||
|
|
7d50628672 | ||
|
|
4a202a0379 | ||
|
|
e2464cbb48 | ||
|
|
ad10803a29 | ||
|
|
bb79ece0b3 | ||
|
|
2cf82d9156 | ||
|
|
e98f869826 | ||
|
|
e39cd3dfa1 | ||
|
|
4e69078f90 | ||
|
|
32c70ecd19 | ||
|
|
12165aae46 | ||
|
|
cbf5cc1baa | ||
|
|
66db451280 | ||
|
|
3ae3ec0f48 | ||
|
|
83d646c51e | ||
|
|
6d73d6e909 | ||
|
|
2e30daa399 | ||
|
|
4b5c9f2382 | ||
|
|
a6b0850a0c | ||
|
|
fc63f3bf36 | ||
|
|
bdf81fad58 | ||
|
|
857537c620 | ||
|
|
861cb0ec94 | ||
|
|
df92f8d448 | ||
|
|
f049d3b1eb | ||
|
|
f0e0542c64 | ||
|
|
84008a0a8f | ||
|
|
65339f63ad | ||
|
|
6542d190ea | ||
|
|
5bdde98b50 | ||
|
|
6ee53e6171 | ||
|
|
0ed83c36f0 | ||
|
|
e270bd7137 | ||
|
|
50851dc3f9 | ||
|
|
582d673a2d | ||
|
|
f7ac00f895 | ||
|
|
fa146fb133 | ||
|
|
04da7eb18f | ||
|
|
00a7d7d43d | ||
|
|
cd9bd54801 | ||
|
|
981b6b25e8 | ||
|
|
cd7c53cd97 | ||
|
|
74ec0199fa | ||
|
|
6dcf1e4345 | ||
|
|
8e4fa54477 | ||
|
|
244271835f | ||
|
|
e5f773ce76 | ||
|
|
26304c8ce2 | ||
|
|
4e0f84200a | ||
|
|
30c15a070b | ||
|
|
802b2dc71b | ||
|
|
22f22980fc | ||
|
|
2b0ac0c091 | ||
|
|
5c027a9f9b | ||
|
|
e4e1e3705f | ||
|
|
34778d81e4 | ||
|
|
8d431d6ddf | ||
|
|
d14cf769d5 | ||
|
|
44aa6968f7 | ||
|
|
6e6747dc09 | ||
|
|
ba56876361 | ||
|
|
df1dbccfdb | ||
|
|
3d4c509088 | ||
|
|
8819e390c9 | ||
|
|
15fa33b9d9 | ||
|
|
a7e531787e | ||
|
|
572cf92896 | ||
|
|
f43187218f | ||
|
|
867f476289 | ||
|
|
24681ee26f | ||
|
|
1e0e7ccf21 | ||
|
|
1c1f9d8ea4 | ||
|
|
3653bbf807 | ||
|
|
8335191bff | ||
|
|
e5cef16ad7 | ||
|
|
b1b1e7439b | ||
|
|
0d5d2896a6 | ||
|
|
686557c5cf | ||
|
|
4a83bd0540 | ||
|
|
2103b86c17 | ||
|
|
95115de265 | ||
|
|
e561e5fc5d | ||
|
|
c872c93516 | ||
|
|
2e77fe09a5 | ||
|
|
223f499e37 | ||
|
|
fbbea47545 | ||
|
|
75d11d7c2e | ||
|
|
4f28bf4f7f | ||
|
|
1613339463 | ||
|
|
3bb732c803 | ||
|
|
e442a7c8d3 | ||
|
|
c1f2d36267 | ||
|
|
ea7803230a | ||
|
|
20cd3241c0 | ||
|
|
212606c3e4 | ||
|
|
f7e607f867 | ||
|
|
cfb8ce401d | ||
|
|
083038f25f | ||
|
|
687b296639 | ||
|
|
1f95fba270 | ||
|
|
3aac9bd268 | ||
|
|
c28b2f2d41 | ||
|
|
9c162c0926 | ||
|
|
285688de47 | ||
|
|
d68d912f83 | ||
|
|
c4eded3d04 | ||
|
|
816bc77020 | ||
|
|
b2470df5a7 | ||
|
|
b5863be168 | ||
|
|
5f6ee65768 | ||
|
|
0f7b6f5550 | ||
|
|
e524f07dde | ||
|
|
dbbe94ae08 | ||
|
|
795325ee08 | ||
|
|
552b78a134 | ||
|
|
591f8f6ac9 | ||
|
|
ad9c634901 | ||
|
|
a851c8f958 | ||
|
|
6e1d93648a | ||
|
|
ae89f841c4 | ||
|
|
2775a7032a | ||
|
|
2e32cbcb38 | ||
|
|
8fda392734 | ||
|
|
82d710f160 | ||
|
|
40ba222ac3 | ||
|
|
1b1c1c1d99 | ||
|
|
10a1a26612 | ||
|
|
720200e3df | ||
|
|
41c09ea6a2 | ||
|
|
f56d9a76a8 | ||
|
|
7ebead5df8 | ||
|
|
0d33691fc4 | ||
|
|
a143a8e63c | ||
|
|
4a343814b7 | ||
|
|
51196c6576 | ||
|
|
05a81403bd | ||
|
|
187c90ba69 | ||
|
|
92f1d3cbbf | ||
|
|
6a21727177 | ||
|
|
97bb0eceea | ||
|
|
b300586f80 | ||
|
|
44929293bd | ||
|
|
e2e4cec133 | ||
|
|
9e60ade8df | ||
|
|
7722b3c8c6 | ||
|
|
4eede5bc59 | ||
|
|
67109efdd3 | ||
|
|
f5a49718a0 | ||
|
|
6e3d08eda7 | ||
|
|
aa3ad429e0 | ||
|
|
9e9e32847e | ||
|
|
6d838d7750 | ||
|
|
0dfc74244b | ||
|
|
8914190c56 | ||
|
|
c4f3798612 | ||
|
|
8a65e0bfcd | ||
|
|
1cd84dd07b | ||
|
|
4c4563cdbe | ||
|
|
c7dfd712b8 | ||
|
|
7f3e67bce0 | ||
|
|
89fa8ca588 | ||
|
|
fbf2c1bd41 | ||
|
|
2bb7dfca5f | ||
|
|
27a90dfd68 | ||
|
|
3c733c390c | ||
|
|
6392179013 | ||
|
|
d696541aba | ||
|
|
eadd69644c | ||
|
|
dd591a6c77 | ||
|
|
89950e9944 | ||
|
|
06fbfcbc90 | ||
|
|
e5ecf39977 | ||
|
|
5ba0d9e1b7 | ||
|
|
3068a97cff | ||
|
|
e8916c7131 | ||
|
|
914bb4ede7 | ||
|
|
0c74b2fc4f | ||
|
|
0935a4d099 | ||
|
|
5a285a6a4b | ||
|
|
9d6d6eb9e9 | ||
|
|
c19a0540b0 | ||
|
|
ae6486de7a | ||
|
|
7daaae7209 | ||
|
|
1f032d51bf | ||
|
|
577e654e59 | ||
|
|
822c8000a2 | ||
|
|
e21f02b295 | ||
|
|
3965475c2d | ||
|
|
2c984d8f9d | ||
|
|
8ee1c91fff | ||
|
|
a041934bc2 | ||
|
|
e758c3d9fb | ||
|
|
0d4010f87d | ||
|
|
6ce42b33f5 | ||
|
|
0decce8d06 | ||
|
|
cc209b97d1 | ||
|
|
e27396a5a8 | ||
|
|
953eda4f6e | ||
|
|
64baa41cce | ||
|
|
c475f54e7e | ||
|
|
927ca2ab45 | ||
|
|
64114d2219 | ||
|
|
3ccda408ba | ||
|
|
a2c189eaa3 | ||
|
|
c50d9e4290 | ||
|
|
631bafea76 | ||
|
|
00197a3af0 | ||
|
|
88dc61dd40 | ||
|
|
23d6681d3e | ||
|
|
cbf9318efd | ||
|
|
f2e365726a | ||
|
|
7c45372c32 | ||
|
|
e84af749aa | ||
|
|
7c31725ac9 | ||
|
|
d2f4f94159 | ||
|
|
4d73774779 | ||
|
|
d3d3312adb | ||
|
|
866968d674 | ||
|
|
5d37438e11 | ||
|
|
f6da1ff57c | ||
|
|
9431a5cea2 | ||
|
|
ff47c5728c | ||
|
|
f787de997b | ||
|
|
b3ed32d2de | ||
|
|
3262b097e6 | ||
|
|
156be64559 | ||
|
|
e728eab876 | ||
|
|
38042e0fad | ||
|
|
fd762fa744 | ||
|
|
bb2919f8c0 | ||
|
|
84bdc7efbe | ||
|
|
8b7219e955 | ||
|
|
8b66d67d3d | ||
|
|
3c281bb12c | ||
|
|
842dc373b1 | ||
|
|
d6d2ae4ae4 | ||
|
|
392ef22c0d | ||
|
|
ccbbe94cab | ||
|
|
17da1e3b3c | ||
|
|
c743e1ecd8 | ||
|
|
6c0b3f7c31 | ||
|
|
3c2d9e8863 | ||
|
|
afd1ba6175 | ||
|
|
9d2ef12c96 | ||
|
|
2890b9a1ab | ||
|
|
43516cc15f | ||
|
|
52fbff6cbe | ||
|
|
f62926fbeb | ||
|
|
72e6939f67 | ||
|
|
3fa98a0915 | ||
|
|
50d3dbe7cc | ||
|
|
bb3c1fd12f | ||
|
|
5a662d829d | ||
|
|
22dc15c602 | ||
|
|
55155b0d4f | ||
|
|
2d1e69b0c1 | ||
|
|
7d29ca388b |
@@ -9,6 +9,12 @@
|
||||
# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`.
|
||||
# Та же конструкция, что у golden-эталонов, и по той же причине: картинки
|
||||
# попадают в репозиторий через явное решение, а не через бота.
|
||||
#
|
||||
# Снимать здесь больше не обязанность, а удобство (#401). Приёмка проверяет не
|
||||
# место съёмки, а её воспроизводимость: каждый кадр, не объявленный изменённым,
|
||||
# должен совпасть с закоммиченным байт-в-байт. Эта джоба потому и удобна, что
|
||||
# среда у неё та же, в которой снят закоммиченный набор, — но принять получится
|
||||
# из любой, где кадры воспроизводятся, и не получится ни из одной, где нет.
|
||||
name: Скриншоты документации
|
||||
|
||||
on:
|
||||
@@ -79,6 +85,22 @@ jobs:
|
||||
# браузер: тот же Chromium и десять изменившихся картинок означают, что
|
||||
# изменился продукт (или что-то не так), другой Chromium — ожидаемую
|
||||
# разницу рендеринга.
|
||||
# Хеши печатаются в лог, а не только уезжают в артефакт (#410): чтобы
|
||||
# сравнить два прогона одного и того же SHA, нужен текст, который видно с
|
||||
# экрана. Именно так измеряется недетерминированность съёмки — и её
|
||||
# отсутствие после починки.
|
||||
# Съёмка обязана быть воспроизводимой, и это проверяется, а не
|
||||
# предполагается (#410): три снимка на сценарий в одном процессе, без
|
||||
# правки состояния между ними, обязаны совпасть побайтово. Шаг падает,
|
||||
# если кадр снова начнёт зависеть от времени.
|
||||
#
|
||||
# Проверка стоит ПЕРЕД съёмкой набора: смысла публиковать артефакт,
|
||||
# снятый недетерминированной съёмкой, нет.
|
||||
- name: "Кадр не плавает внутри одного состояния (#410)"
|
||||
run: node demo/docs/capture.mjs --stability=3
|
||||
- name: Хеши кадров
|
||||
run: |
|
||||
for f in docs/images/*.png; do sha256sum "$f"; done
|
||||
- name: Вердикт
|
||||
run: |
|
||||
# Поле манифеста «до» — из закоммиченного состояния, «после» — из
|
||||
|
||||
@@ -61,10 +61,15 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.13'
|
||||
python-version: '3.14'
|
||||
|
||||
- name: Установить backend test dependencies
|
||||
run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
|
||||
run: |
|
||||
# Точные версии — в tests_backend/requirements.txt (#392): без них
|
||||
# резолвер выбирает набор сам, и «зелёный backend» значит разное
|
||||
# в разные дни.
|
||||
pip install -r tests_backend/requirements.txt
|
||||
pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest '
|
||||
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
name: Бенчмарки рендера и геометрии
|
||||
# Every profile keeps base and candidate sequential on one hosted runner.
|
||||
# Independent profile pairs run in parallel: cross-profile timing is never
|
||||
# compared, while serialising all five pairs cannot fit the job timeout.
|
||||
# compared, while serialising all profile pairs cannot fit the job timeout.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -39,6 +39,8 @@ jobs:
|
||||
- plan-snap
|
||||
- blend
|
||||
- overlay
|
||||
- space-default
|
||||
- space-glow
|
||||
steps:
|
||||
- name: Check out candidate
|
||||
uses: actions/checkout@v7
|
||||
@@ -200,6 +202,18 @@ jobs:
|
||||
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
|
||||
fi
|
||||
;;
|
||||
space-default)
|
||||
npm run benchmark:glow -- --profile=large-space-card-default-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/space-default-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-space-card-default-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/space-default-candidate.json
|
||||
;;
|
||||
space-glow)
|
||||
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/space-glow-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/space-glow-candidate.json
|
||||
if ! grep -q "light_pools" ../baseline/src/space-card.ts; then
|
||||
echo "Base predates opt-in static Glow; bootstrap relative baseline, keep absolute gate"
|
||||
cp ../artifacts/performance/space-glow-candidate.json ../artifacts/performance/space-glow-baseline.json
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown performance profile: $PROFILE"
|
||||
exit 1
|
||||
@@ -227,6 +241,12 @@ jobs:
|
||||
overlay)
|
||||
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
|
||||
;;
|
||||
space-default)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-default.json --baseline=../artifacts/performance/space-default-baseline.json --candidate=../artifacts/performance/space-default-candidate.json --output=../artifacts/performance/space-default-comparison.json
|
||||
;;
|
||||
space-glow)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-glow.json --baseline=../artifacts/performance/space-glow-baseline.json --candidate=../artifacts/performance/space-glow-candidate.json --output=../artifacts/performance/space-glow-comparison.json
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Upload full performance report
|
||||
|
||||
@@ -226,6 +226,20 @@ jobs:
|
||||
git checkout -q "origin/$branch"
|
||||
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
|
||||
echo "name=$branch" >> "$GITHUB_OUTPUT"
|
||||
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
|
||||
# ребейз меняет — содержимое нет: git адресует деревья и блобы их
|
||||
# хешем. Снимаются здесь, где рабочая копия ЕЩЁ равна тому, что
|
||||
# ревьюер прочтёт; в шаге публикации дерево уже сброшено на целевую
|
||||
# ветку, и спрашивать его поздно.
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
# ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст
|
||||
# где-нибудь достижим. Файлов может не быть (инфраструктурная
|
||||
# задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо
|
||||
# длиннее одного.
|
||||
specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \
|
||||
| awk '{print $2" "$4}' | tr '\n' ';')
|
||||
echo "specs=$specs" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev"
|
||||
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -266,9 +280,18 @@ jobs:
|
||||
before=$(git rev-parse "origin/$BRANCH")
|
||||
echo "dev впереди на $behind коммит(ов) — привожу ветку"
|
||||
if ! git rebase origin/dev; then
|
||||
# Список снимается ДО abort: он же снимает состояние конфликта, и
|
||||
# тогда автору достаётся «не ребейзится» без единого имени файла (#364).
|
||||
files=$(git diff --name-only --diff-filter=U | sort -u | paste -sd'\n' -)
|
||||
git rebase --abort || true
|
||||
echo "conflict=true" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo 'conflict=true'
|
||||
echo 'conflicts<<EOF_FILES'
|
||||
printf '%s\n' "${files:-(git не назвал файлы)}"
|
||||
echo 'EOF_FILES'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::ветка $BRANCH не ребейзится на dev без конфликта — ревью не запускается"
|
||||
printf 'конфликтуют:\n%s\n' "${files:-(git не назвал файлы)}"
|
||||
exit 0
|
||||
fi
|
||||
# --force-with-lease с явным ожидаемым значением обязателен: между
|
||||
@@ -308,10 +331,17 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
CONFLICTS: ${{ steps.rebase.outputs.conflicts }}
|
||||
run: |
|
||||
cat > /tmp/stale.md <<EOF
|
||||
**Ревью не запускалось:** ветка \`$BRANCH\` не ребейзится на \`dev\` без конфликта. Код никто не читал, вердикта нет, цикл ревью не израсходован.
|
||||
|
||||
Конфликтуют:
|
||||
|
||||
\`\`\`
|
||||
$CONFLICTS
|
||||
\`\`\`
|
||||
|
||||
Проверка стоит до ревью намеренно: конфликт всё равно вернул бы задачу, но уже после сорока минут работы ревьюера и потраченных лимитов.
|
||||
|
||||
Задача переведена в \`S6-in-progress\`. Осталось:
|
||||
@@ -613,6 +643,9 @@ jobs:
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
SOURCE: ${{ runner.temp }}/review-document.md
|
||||
MATERIAL_SHA: ${{ steps.branch.outputs.sha }}
|
||||
MATERIAL_TREE: ${{ steps.branch.outputs.tree }}
|
||||
MATERIAL_SPECS: ${{ steps.branch.outputs.specs }}
|
||||
run: |
|
||||
# Ветки задачи может не быть: у задач, размеченных до появления
|
||||
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
|
||||
@@ -626,14 +659,31 @@ jobs:
|
||||
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).
|
||||
if [ ! -f "$SOURCE" ] && [ -f "$doc" ]; then
|
||||
cp "$doc" "$SOURCE"
|
||||
echo "документ найден в рабочей копии и сохранён в $SOURCE"
|
||||
fi
|
||||
# Reset, а не checkout+clean, и вот почему (#365).
|
||||
#
|
||||
# 28.08 коммит bb2919f уехал в dev с тридцатью файлами вместо одного
|
||||
# markdown: откатил отревьюженную реализацию #359, вернул старые чанки
|
||||
# и держал dev откаченным три часа. Механизм воспроизведён:
|
||||
# `git checkout -- .` восстанавливает рабочее дерево ИЗ ИНДЕКСА, а
|
||||
# `git clean -fd` убирает неотслеживаемое — ни то, ни другое индекс не
|
||||
# трогает. Ревьюер работает с Bash и в ходе проверки «умеет ли тест
|
||||
# падать» вполне может сделать `git add`; всё, что осталось у него в
|
||||
# индексе, прежняя уборка сохраняла, и следующий же `git commit`
|
||||
# забирал это вместе с документом. Сообщение при этом невинное, и от
|
||||
# рутины инцидент отличается только диффом.
|
||||
#
|
||||
# `reset --hard` снимает и индекс, и дерево разом. Терять нечего:
|
||||
# документ приезжает извне репозитория, из RUNNER_TEMP.
|
||||
git fetch -q origin "$target"
|
||||
git reset -q --hard "origin/$target"
|
||||
git clean -fdq -e node_modules >/dev/null 2>&1 || true
|
||||
# Документ приезжает извне репозитория (#220). Три раунда подряд он
|
||||
# терялся, пока лежал некоммитнутым файлом в том же дереве, которое
|
||||
# ревьюер мутирует и затем восстанавливает: `git checkout -- .` плюс
|
||||
@@ -644,12 +694,21 @@ jobs:
|
||||
mkdir -p docs/reviews
|
||||
cp "$SOURCE" "$doc"
|
||||
echo "документ взят из $SOURCE ($(wc -c < "$doc") байт)"
|
||||
# Якоря дописывает конвейер, а не ревьюер (#414). Дисциплина здесь
|
||||
# уже подводила: на #403 SHA сняли до ребейза и не сверили перед
|
||||
# выводом — через раунд команда из §2.10 не работала. Машина же
|
||||
# снимает якоря в момент чтения материала и ошибиться в них не
|
||||
# может; блок помечен как машинный, чтобы никто не правил его руками.
|
||||
node scripts/review-doc-guard.mjs --anchor="$doc" \
|
||||
--sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \
|
||||
--branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS"
|
||||
else
|
||||
# Совместимость: ревьюер мог написать по старому пути, если промпт
|
||||
# ещё не обновился в этой ветке.
|
||||
echo "::warning::$SOURCE не найден — ищу документ в рабочей копии"
|
||||
echo "::warning::$SOURCE не найден — документа для публикации нет"
|
||||
fi
|
||||
git add docs/reviews 2>/dev/null || true
|
||||
# Индексируется ровно один путь, а не каталог: `git add docs/reviews`
|
||||
# забрал бы всё, что там окажется, а после reset там не должно быть
|
||||
# ничего постороннего — но полагаться на «не должно» здесь нельзя.
|
||||
git add -- "$doc" 2>/dev/null || true
|
||||
if git diff --cached --quiet; then
|
||||
# Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит
|
||||
# документ сам, своим app-токеном мимо этого шага (CODE-REVIEW-150-r1,
|
||||
@@ -667,6 +726,8 @@ jobs:
|
||||
echo "::error::вердикт есть, а документа нет: ни $SOURCE, ни $doc в рабочей копии, ни $doc в $target — ревью без артефакта (#171, #220)"
|
||||
exit 1
|
||||
fi
|
||||
# Первый рубеж: что вообще проиндексировано.
|
||||
git diff --cached --name-only | node scripts/review-doc-guard.mjs
|
||||
git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
commit -q -F - <<EOF
|
||||
@@ -675,6 +736,10 @@ jobs:
|
||||
Issue: #$NUM
|
||||
User-Visible: no
|
||||
EOF
|
||||
# Второй рубеж, и он главный: что пуш ДОБАВИТ в целевую ветку. Первый
|
||||
# судит намерение шага, этот — результат, а расходились они именно
|
||||
# тогда, когда база оказывалась не той.
|
||||
git diff --name-only "origin/$target...HEAD" | node scripts/review-doc-guard.mjs
|
||||
# Публикация в dev идёт из детачнутого состояния поверх ветки задачи
|
||||
# либо dev, поэтому push нужен с явным перебазированием при гонке:
|
||||
# dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут.
|
||||
@@ -689,6 +754,9 @@ jobs:
|
||||
echo "::error::документ ревью не удалось опубликовать в $target: конфликт (#171)"
|
||||
exit 1
|
||||
fi
|
||||
# После ребейза набор путей другой — проверяется заново. Форс здесь
|
||||
# запрещён и не появляется: ветка двигается только вперёд.
|
||||
git diff --name-only "origin/$target...HEAD" | node scripts/review-doc-guard.mjs
|
||||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$target"
|
||||
fi
|
||||
@@ -702,6 +770,42 @@ jobs:
|
||||
fi
|
||||
echo "документ опубликован в $target: $doc"
|
||||
|
||||
# Материал раунда обязан быть достижим с origin (#413).
|
||||
#
|
||||
# SPEC-REVIEW-403-r2 объявил материал на `HEAD = 83005c3c`, и тот же SHA
|
||||
# независимо назвал автор ТЗ в комментарии issue. Коммит существовал, но
|
||||
# к моменту публикации был осиротевшим: ветку перебазировали за 15 минут
|
||||
# ДО публикации документа, спец-коммит переехал в 94502d3d с тем же
|
||||
# сообщением и тем же содержимым. Через раунд команда `git diff
|
||||
# 83005c3c..HEAD` из §2.10 буквально не работала, и r3 восстанавливал
|
||||
# реальный коммит по содержимому диффа руками.
|
||||
#
|
||||
# Проверка стоит ПОСЛЕ публикации намеренно. Артефакт ревью терялся здесь
|
||||
# трижды (#171, #220), и «вердикт без документа» в этом репозитории
|
||||
# дороже мёртвой ссылки: документ сначала спасается, потом судится. Шаг
|
||||
# при этом идёт ДО «Переставить метку», поэтому инвариант «метка не
|
||||
# сменилась = прогон упал» сохраняется.
|
||||
#
|
||||
# Достижимость считается от `refs/remotes/origin/*`, а не от локальных
|
||||
# ссылок: осиротевший 83005c3c до сих пор лежит в клоне автора и
|
||||
# достижим там из необновлённой локальной ветки. Читателю отчёта от этого
|
||||
# пользы нет — он достанет только то, что есть на origin.
|
||||
- name: "Материал раунда воспроизводим (#413)"
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
run: |
|
||||
marker=CODE-REVIEW
|
||||
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
|
||||
doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
|
||||
target="${BRANCH:-dev}"
|
||||
git fetch -q origin "$target"
|
||||
# Судится опубликованная версия, а не рабочая копия: именно её прочтёт
|
||||
# следующий раунд.
|
||||
git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=-
|
||||
- name: Решение по вердикту
|
||||
id: decide
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
@@ -734,6 +838,30 @@ jobs:
|
||||
echo "from=$from" >> "$GITHUB_OUTPUT"
|
||||
echo "to=$to" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Ревью идёт десятки минут, а dev за это время двигается (28 августа —
|
||||
# четыре раза за день). Вердикт при этом вынесен по дереву, которое уже не
|
||||
# совпадает с вершиной линии, и слияние приведёт ветку к dev — то есть в
|
||||
# dev уедет код, отличный от прочитанного (§7.2). Молчать об этом нельзя,
|
||||
# но и шуметь на каждом прогоне ни к чему: строка появляется только когда
|
||||
# dev действительно ушёл и вердикт зелёный, то есть слияние вот-вот
|
||||
# случится (#364).
|
||||
- name: dev ушёл вперёд, пока шло ревью
|
||||
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
MATERIAL: ${{ steps.material.outputs.sha }}
|
||||
GREEN: ${{ steps.decide.outputs.green }}
|
||||
run: |
|
||||
git fetch -q origin dev
|
||||
moved=$(git rev-list --count "$MATERIAL..origin/dev")
|
||||
echo "dev продвинулся на $moved коммит(ов) с момента фиксации материала"
|
||||
echo "- dev продвинулся на **$moved** коммит(ов) во время ревью" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ "$moved" -eq 0 ] || [ "$GREEN" != "true" ]; then exit 0; fi
|
||||
short=$(git rev-parse --short "$MATERIAL")
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
|
||||
"Пока шло ревью, \`dev\` продвинулся на $moved коммит(ов). Материал ревью — \`$short\`. Вердикт вынесен по дереву, которое уже не совпадает с вершиной линии: слияние приведёт ветку к dev, и это другой код (§7.2)."
|
||||
|
||||
# S8-merged утверждает, что код в dev. Значит слияние обязано произойти
|
||||
# ДО метки, иначе она врёт в промежутке.
|
||||
#
|
||||
|
||||
@@ -32,6 +32,13 @@ jobs:
|
||||
preflight:
|
||||
name: "Предполётные проверки: документация, провенанс, процесс"
|
||||
runs-on: ubuntu-latest
|
||||
# `actions: read` — чтение списка прогонов Validate (#388), `issues: read` —
|
||||
# проверка 8 процессного гейта. Права перечислены явно, потому что job
|
||||
# обращается к API сверх содержимого репозитория.
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: read
|
||||
steps:
|
||||
# `blob:none` при полной истории (#345): этой job нужны сообщения
|
||||
# коммитов, трейлеры и ИМЕНА изменённых файлов, а не содержимое старых
|
||||
@@ -69,12 +76,38 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до
|
||||
# #388 брали его от головы предыдущего пуша. Прогон предыдущего пуша
|
||||
# штатно отменяется следующим (concurrency), и тогда его коммиты не судит
|
||||
# никто: свой прогон отменён, а следующий пуш сравнивает уже с ними.
|
||||
#
|
||||
# База — последний предок с УСПЕШНО завершённым Validate. Фолбэк остаётся
|
||||
# прежним `before`: расширять диапазон здесь нельзя, иначе гейт, который
|
||||
# сам красит прогон, лишает следующий пуш зелёного предка и запирает dev
|
||||
# в красноте навсегда.
|
||||
- name: "База диапазона: последний доказанно зелёный предок"
|
||||
id: range
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
FALLBACK: ${{ github.event.before }}
|
||||
run: |
|
||||
# `status=completed`, а не `success`: гейтам диапазона нужен факт
|
||||
# «коммит судили», а не «вердикт был оправдательный». Упавший прогон
|
||||
# коммит судил; отменённый — нет, его отсеивает judgedShas (#388).
|
||||
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
|
||||
-f branch=dev -f status=completed -F per_page=100 \
|
||||
> /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json
|
||||
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
|
||||
--fallback="$FALLBACK" --runs=/tmp/validate-runs.json
|
||||
- name: "Провенанс коммитов: трейлеры и эталоны"
|
||||
id: provenance
|
||||
continue-on-error: true
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BEFORE_SHA: ${{ steps.range.outputs.base || github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
DEVELOPMENT_BRANCH: dev
|
||||
@@ -92,7 +125,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BEFORE_SHA: ${{ steps.range.outputs.base || github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
DEVELOPMENT_BRANCH: dev
|
||||
@@ -140,20 +173,65 @@ jobs:
|
||||
changes:
|
||||
name: Классификация изменённых файлов
|
||||
runs-on: ubuntu-latest
|
||||
# Чтение списка прогонов Validate — единственное, что этой job нужно сверх
|
||||
# содержимого репозитория (#387).
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
backend: ${{ steps.classify.outputs.backend }}
|
||||
integration: ${{ steps.classify.outputs.integration }}
|
||||
base: ${{ steps.base.outputs.base }}
|
||||
# Разные вещи под разными именами намеренно: `base` — до какого коммита
|
||||
# классифицировать файлы ветки (#387), `range_base` — от какого коммита
|
||||
# судить сам диапазон на dev (#388). Общее имя означало бы, что
|
||||
# потребитель однажды молча возьмёт чужую базу.
|
||||
range_base: ${{ steps.base.outputs.range_base }}
|
||||
steps:
|
||||
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
|
||||
# блобы истории этой job не нужны ни на одном шаге (#345).
|
||||
- uses: actions/checkout@v7
|
||||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||||
# База диапазона — самый новый предок с УСПЕШНО завершённым Validate
|
||||
# (#387). Прежде бралась голова предыдущего пуша (`github.event.before`),
|
||||
# то есть допущение «до этого всё проверено». Concurrency отменяет прогон
|
||||
# предыдущего пуша штатно, и на #86 (r5) это дало зелёный статус ветки
|
||||
# без единого исполненного тяжёлого гейта.
|
||||
- id: base
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF: ${{ github.ref }}
|
||||
FALLBACK: ${{ github.event.before }}
|
||||
run: |
|
||||
git fetch -q origin dev
|
||||
# Недоступность API — не отказ гейта: пустой ответ уводит базу в
|
||||
# сторону БОЛЬШЕГО объёма проверок, а не меньшего.
|
||||
# Один запрос на оба режима: `completed` — надмножество `success`,
|
||||
# а нужный предикат применяет скрипт (#387 — успех, #388 — судимость).
|
||||
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
|
||||
-f branch="$BRANCH" -f status=completed -F per_page=100 \
|
||||
> /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json
|
||||
if [ "$REF" = "refs/heads/dev" ]; then
|
||||
# На dev классифицировать нечего (всё true), но база диапазона
|
||||
# нужна гейту «новый код не добавляет any» в job frontend (#388).
|
||||
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
|
||||
--name=range_base --fallback="$FALLBACK" --runs=/tmp/validate-runs.json
|
||||
exit 0
|
||||
fi
|
||||
merge_base=$(git merge-base origin/dev "$HEAD_SHA" || git rev-parse "$HEAD_SHA~1")
|
||||
node scripts/classify-base.mjs --head="$HEAD_SHA" \
|
||||
--merge-base="$merge_base" --runs=/tmp/validate-runs.json
|
||||
- id: classify
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
GREEN_BASE: ${{ steps.base.outputs.base }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
@@ -163,39 +241,45 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
zero=$(printf '%040d' 0)
|
||||
base="$BEFORE_SHA"
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||||
# #347: непустой before, которого больше не существует, — это
|
||||
# force-push (ребейз/аменд переписал историю ветки). Диапазон в
|
||||
# этом случае НЕ угадывается: merge-base после переписанной истории
|
||||
# уже выдавал «изменились только два doc-файла» при реальном диффе
|
||||
# в custom_components/**, и тяжёлые job тихо скипались, а общий
|
||||
# статус рана оставался success — ровно класс тихого пропуска
|
||||
# #171/#207. Force-push редок, полный прогон дешевле ложного
|
||||
# «зелёного».
|
||||
if [ -n "$base" ] && [ "$base" != "$zero" ] \
|
||||
&& ! git cat-file -e "$base" 2>/dev/null; then
|
||||
echo "force-push: before=$base переписан — полный прогон без классификации"
|
||||
# force-push (ребейз/аменд переписал историю ветки). Диапазон в этом
|
||||
# случае НЕ угадывается: merge-base после переписанной истории уже
|
||||
# выдавал «изменились только два doc-файла» при реальном диффе в
|
||||
# custom_components/**, и тяжёлые job тихо скипались, а общий статус
|
||||
# рана оставался success — ровно класс тихого пропуска #171/#207.
|
||||
# Force-push редок, полный прогон дешевле ложного «зелёного».
|
||||
#
|
||||
# Проверка сохранена как есть и после #387: там база стала доказуемой
|
||||
# (зелёный предок), но механизм, из-за которого merge-base врал на
|
||||
# переписанной истории, не разобран до конца. Снимать защиту, не
|
||||
# объяснив её, — способ получить #347 второй раз.
|
||||
if [ -n "$BEFORE_SHA" ] && [ "$BEFORE_SHA" != "$zero" ] \
|
||||
&& ! git cat-file -e "$BEFORE_SHA" 2>/dev/null; then
|
||||
echo "force-push: before=$BEFORE_SHA переписан — полный прогон без классификации"
|
||||
{
|
||||
echo "### Классификация изменённых файлов"
|
||||
echo "\`before\` (=$base) переписан force-push'ем: диапазон недоказуем,"
|
||||
echo "\`before\` (=$BEFORE_SHA) переписан force-push'ем: диапазон недоказуем,"
|
||||
echo "все тяжёлые job запускаются без фильтра путей (#347)."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Новая ветка: before нулевой, диапазон считается от merge-base с dev,
|
||||
# иначе классифицировалась бы вся история.
|
||||
if [ -z "$base" ] || [ "$base" = "$zero" ]; then
|
||||
git fetch -q origin dev
|
||||
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "$HEAD_SHA~1")
|
||||
# Диапазон считается от последнего ДОКАЗАННО зелёного предка (#387),
|
||||
# а не от головы предыдущего пуша: его прогон штатно отменяется
|
||||
# следующим пушем. PR — исключение: там база задана событием.
|
||||
base="$GREEN_BASE"
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||||
if [ -z "$base" ]; then
|
||||
echo "база не определена — полный прогон без классификации"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
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 "backend=$(has '^(custom_components/.*\.py$|tests_backend/|scripts/support-relay/|pytest\.ini$)')"
|
||||
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -339,17 +423,24 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PROVEN_BASE: ${{ needs.changes.outputs.range_base }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch -q origin dev
|
||||
zero=$(printf '%040d' 0)
|
||||
base="$BEFORE_SHA"
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||||
# Пуш прямо в dev: диапазон — то, что этот пуш добавил. Новая ветка или
|
||||
# перезаписанная история: точки отсчёта нет, берём merge-base с dev.
|
||||
if [ "$REF" != "refs/heads/dev" ] || [ -z "$base" ] || [ "$base" = "$zero" ] \
|
||||
base=""
|
||||
# Пуш прямо в dev: диапазон — то, что добавлено с последнего
|
||||
# ДОКАЗАННО зелёного предка (#388), а не с головы предыдущего пуша:
|
||||
# его прогон штатно отменяется следующим, и добавленные им строки не
|
||||
# судил бы никто. Фолбэк — прежний `before`.
|
||||
if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then
|
||||
base="${PROVEN_BASE:-$BEFORE_SHA}"
|
||||
fi
|
||||
# Ветка, PR, новая история: точки отсчёта нет, берём merge-base с dev.
|
||||
# Там диапазон и так шире, поэтому #388 этих случаев не касается.
|
||||
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|
||||
|| ! git cat-file -e "$base" 2>/dev/null; then
|
||||
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "")
|
||||
fi
|
||||
@@ -441,6 +532,14 @@ jobs:
|
||||
path: test-build
|
||||
- name: Разложить бандл по копиям
|
||||
run: node scripts/bundle-sync.mjs
|
||||
# Гард исключений внутри карточки объявлен в demo/serve.mjs с 2026-07-27 и
|
||||
# до #404 не срабатывал ни разу: счётчик читался раньше, чем Playwright
|
||||
# доставлял pageerror. Такое ловится только запуском, поэтому пробы живут
|
||||
# здесь — в единственной job с настоящим браузером. Один шард из трёх:
|
||||
# проверка не зависит от разбиения, а платить за неё трижды незачем.
|
||||
- name: "Гард исключений умеет падать (#404)"
|
||||
if: matrix.shard == 1
|
||||
run: node demo/guard/verify-guard.mjs
|
||||
- name: Smoke suite (шард ${{ matrix.shard }} из 3)
|
||||
env:
|
||||
SHARD: ${{ matrix.shard }}
|
||||
@@ -461,6 +560,12 @@ jobs:
|
||||
echo "ok $name"
|
||||
else
|
||||
echo "FAIL $name"
|
||||
# Диагностические строки смока печатаются раньше вердикта, и
|
||||
# `tail -20` их срезал: на #411 сам смок сообщал, НАСКОЛЬКО
|
||||
# разошлись кадры (введено в #302), а в логе прогона осталось
|
||||
# только «expected true, got false». Числа теперь достаются
|
||||
# адресно — они и есть разница между «чинить» и «гадать».
|
||||
grep -aE 'pixel-diffs|^EXC |diagnostic' "/tmp/smoke-logs/$name.log" | tail -5 || true
|
||||
tail -20 "/tmp/smoke-logs/$name.log"
|
||||
fail=1
|
||||
fi
|
||||
@@ -537,6 +642,14 @@ jobs:
|
||||
path: dist
|
||||
- name: Разложить бандл по копиям
|
||||
run: node scripts/bundle-sync.mjs
|
||||
# Маркер предыдущего падения с ТЕМ ЖЕ ключом (#386). Восстанавливается до
|
||||
# прогона, потому что после падения шаги кэша уже не помогут отличить
|
||||
# виновника от свидетеля.
|
||||
- name: Прежнее падение на тех же входах
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .fail-marker
|
||||
key: fail-golden-${{ needs.reuse.outputs.golden_key }}
|
||||
- name: Capture or verify golden matrix
|
||||
id: golden
|
||||
run: |
|
||||
@@ -553,6 +666,25 @@ jobs:
|
||||
with:
|
||||
name: golden-images
|
||||
path: artifacts/golden
|
||||
# Красная golden обязана сказать, ЧЕЙ это долг (#386). Без этого письмо
|
||||
# «Run failed» называет коммит, который пушнули следующим: 29 августа так
|
||||
# был назван документ ревью `0f7b6f5`, а сцену без эталона добавил
|
||||
# `dbbe94ae`. Вердикт job не меняется — меняется только формулировка.
|
||||
- name: Кто уронил golden
|
||||
id: fail_note
|
||||
if: failure()
|
||||
env:
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node scripts/gate-reuse.mjs --job=golden --note --marker=.fail-marker
|
||||
- name: Запомнить первопричину
|
||||
if: failure() && steps.fail_note.outputs.first == 'true'
|
||||
# Гонка двух прогонов даёт «Cache already exists»; для маркера падения
|
||||
# это тем более не отказ гейта — job и так красная по существу.
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: .fail-marker
|
||||
key: fail-golden-${{ needs.reuse.outputs.golden_key }}
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
@@ -615,9 +747,11 @@ jobs:
|
||||
- name: Capture the heaviest Glow state
|
||||
run: |
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
|
||||
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/space-candidate.json
|
||||
- name: Enforce absolute smoke ceilings
|
||||
run: |
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-space-glow-smoke.json --candidate=artifacts/performance-smoke/space-candidate.json --output=artifacts/performance-smoke/space-comparison.json
|
||||
# #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера),
|
||||
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
|
||||
- name: Бюджеты ограничений стыков (#330)
|
||||
@@ -657,10 +791,57 @@ jobs:
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- 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
|
||||
with: { python-version: "3.14" }
|
||||
- run: |
|
||||
# Точные версии — в tests_backend/requirements.txt (#392): без них
|
||||
# резолвер выбирает набор сам, и «зелёный backend» значит разное
|
||||
# в разные дни. #42 ставит из того же файла ruff для шага линта.
|
||||
pip install -r tests_backend/requirements.txt
|
||||
pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest '
|
||||
- name: Линт бэкенда (ruff, узкий набор)
|
||||
run: python -m ruff check custom_components/houseplan
|
||||
# #42: типизация была измеримой только локально — CI её не исполнял, и
|
||||
# регрессия в любом из allowlist-модулей проходила молча. Список модулей
|
||||
# берётся ИЗ pyproject.toml, а не дублируется здесь: разошедшийся дубль
|
||||
# означал бы зелёный шаг, проверяющий не то. Пустой список — отказ.
|
||||
- name: Типы бэкенда (mypy strict по allowlist)
|
||||
run: |
|
||||
modules=$(python -c "
|
||||
import tomllib
|
||||
cfg = tomllib.load(open('pyproject.toml', 'rb'))
|
||||
for o in cfg['tool']['mypy'].get('overrides', []):
|
||||
if o.get('strict'):
|
||||
print(' '.join('-p ' + m for m in o['module']))
|
||||
")
|
||||
test -n "$modules" || { echo '::error::strict-allowlist в pyproject.toml пуст — проверять нечего'; exit 1; }
|
||||
echo "mypy strict: $modules"
|
||||
python -m mypy $modules
|
||||
# #42: защита от тихого скипа HA-harness — импорт и порог collect
|
||||
- name: HA-harness присутствует
|
||||
run: |
|
||||
python -c "import homeassistant"
|
||||
collected=$(python -m pytest tests_backend/ -q --collect-only 2>/dev/null | grep -c "test_ha_" || true)
|
||||
echo "collected HA-harness tests: $collected"
|
||||
test "$collected" -ge 50
|
||||
- name: Backend unit tests (pure + HA harness, coverage)
|
||||
run: >
|
||||
python -m pytest tests_backend/ -q
|
||||
--cov=custom_components/houseplan --cov-branch
|
||||
--cov-report=xml --cov-report=term
|
||||
- name: Support relay unit tests
|
||||
run: python -m unittest discover -s scripts/support-relay/tests -q
|
||||
# #42: гейт «не ниже baseline» — пороги 90/95 поднимаются trivial-правкой числа
|
||||
- name: Порог покрытия не ниже baseline
|
||||
run: |
|
||||
baseline=$(head -1 scripts/backend-coverage-baseline.txt)
|
||||
actual=$(python -c "import xml.etree.ElementTree as ET; print(round(float(ET.parse('coverage.xml').getroot().get('line-rate'))*100, 1))")
|
||||
echo "coverage: $actual% (baseline $baseline%)"
|
||||
python -c "import sys; sys.exit(0 if float('$actual') >= float('$baseline') - 0.1 else 1)"
|
||||
- name: Артефакт coverage.xml
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-coverage
|
||||
path: coverage.xml
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
|
||||
@@ -237,16 +237,36 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
|
||||
Порядок:
|
||||
|
||||
1. найти вердикт предыдущего раунда и **SHA, на котором он получен**; SHA в
|
||||
вердикте не назван — это находка;
|
||||
1. найти вердикт предыдущего раунда и **материал, на котором он получен**.
|
||||
Материал объявлен блоком «Материал раунда» в конце документа предыдущего
|
||||
раунда: конвейер дописывает туда SHA ветки, **дерево** материала и **блоб**
|
||||
каждого ТЗ вместе с командами поиска (issue #416). Блок машинный — править
|
||||
его руками не нужно и не следует;
|
||||
2. объявить дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла ТЗ либо тела
|
||||
issue для этапа ТЗ;
|
||||
issue для этапа ТЗ.
|
||||
|
||||
**Если SHA не резолвится — это не находка, а обычное дело.** Ветку задачи
|
||||
между раундами перебазируют, сквошат или удаляют, и SHA умирает: по корпусу
|
||||
ревью таких объявлений 98 из 804. Материал в этом случае берётся по якорям,
|
||||
которые ребейз не меняет, потому что адресуются содержимым:
|
||||
|
||||
```
|
||||
git log --all --format='%H %T' | grep <дерево>
|
||||
git log --all --find-object=<блоб> -- <путь к ТЗ>
|
||||
```
|
||||
|
||||
Находкой остаётся другое: **SHA, мёртвый уже в момент публикации отчёта** —
|
||||
он означает, что значение сняли до `amend` или `rebase` и не сверили перед
|
||||
выводом, как требует §7.2. Это отличие не теоретическое: на #403 оба
|
||||
источника, автор и ревьюер, независимо назвали один и тот же осиротевший
|
||||
SHA, и следующий раунд восстанавливал коммит по содержимому диффа руками
|
||||
(issue #413). Конвейер теперь такую публикацию останавливает сам;
|
||||
3. по каждой находке предыдущего раунда показать, **чем именно она закрыта** —
|
||||
строкой кода или текста, а не заявлением автора;
|
||||
4. заново проверять только те AC, чьё доказательство дельта задевает;
|
||||
5. **раздел «Унаследовано из r<N−1>»** обязателен: что принято без повторной
|
||||
проверки, со ссылкой на документ того раунда и SHA. Без перечня сокращение
|
||||
превращается в молчаливое доверие.
|
||||
проверки, со ссылкой на документ того раунда и его материал. Без перечня
|
||||
сокращение превращается в молчаливое доверие.
|
||||
|
||||
Дешёвые гейты (`typecheck`, `test`, `build` со сверкой копий бандла) гоняются в
|
||||
каждом раунде: код изменился, а стоят они минуты. Тяжёлые — по дельте (§10.2).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Sergey Matyushin (Matysh)
|
||||
Copyright (c) 2026 Sergey Matyunin (Matysh)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -8,7 +8,7 @@ Canonical source artwork for the built-in House Plan furniture library.
|
||||
- `pack.json`: stable ids, category links, default dimensions and names. The
|
||||
filename deliberately avoids the `*manifest.json` suffix reserved by HACS.
|
||||
|
||||
The original author, Sergey Matyushin (`Matysh`), granted House Plan permission
|
||||
The original author, Sergey Matyunin (`Matysh`), granted House Plan permission
|
||||
to use, modify and distribute all 77 SVG files under the repository MIT
|
||||
License without separate UI attribution in
|
||||
[issue #159](https://github.com/Matysh/houseplan-card/issues/159#issuecomment-5454085168).
|
||||
@@ -21,3 +21,8 @@ with SHA-256
|
||||
The editable source is linked from `pack.json`. Generated TypeScript must
|
||||
not be edited by hand; run `npm run furniture:generate` after changing this
|
||||
directory.
|
||||
|
||||
Note (#369): the source archive referenced by the SHA-256 above may still
|
||||
carry the earlier romanisation of the author's name; the authoritative
|
||||
spelling is Sergey Matyunin (Сергей Матюнин), fixed 2026-08-29 by the
|
||||
owner's decision in issue #369. The archive bytes are unchanged.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"pack_version": "0.3.0",
|
||||
"title_ru": "Набор мебели: меню и план",
|
||||
"title_en": "Furniture pack: menu and plan",
|
||||
"author": "Sergey Matyushin (Matysh)",
|
||||
"author": "Sergey Matyunin (Matysh)",
|
||||
"license": "MIT",
|
||||
"source_url": "https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan?node-id=271-95",
|
||||
"menu_icons": [
|
||||
|
||||
@@ -15,10 +15,8 @@ from . import websocket_api as hp_ws
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
FILES_DIR,
|
||||
FILES_URL,
|
||||
FRONTEND_URL,
|
||||
PLANS_DIR,
|
||||
PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
|
||||
@@ -38,8 +36,12 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hp_ws.async_register(hass)
|
||||
from .http_api import HouseplanContentView, HouseplanImportPreviewView, HouseplanUploadView
|
||||
from .frontend_assets import HouseplanFrontendAssetView
|
||||
from .http_api import (
|
||||
HouseplanContentView,
|
||||
HouseplanImportPreviewView,
|
||||
HouseplanUploadView,
|
||||
)
|
||||
|
||||
hass.http.register_view(HouseplanUploadView())
|
||||
hass.http.register_view(HouseplanContentView())
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
|
||||
from .const import CONF_ADMIN_ONLY, DOMAIN
|
||||
|
||||
@@ -47,7 +47,19 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.69.0-beta.2"
|
||||
VERSION = "1.70.0"
|
||||
|
||||
# #43: the support transport is deliberately not configurable. A user supplied
|
||||
# URL would turn the integration into an SSRF proxy and make the privacy notice
|
||||
# false. The relay is deployed and operated by the project; changing it is a
|
||||
# reviewed release change, not a Home Assistant option.
|
||||
SUPPORT_RELAY_URL = "https://support.houseplan.tech/v1/reports"
|
||||
SUPPORT_PREVIEW_TTL_S = 10 * 60
|
||||
MAX_SUPPORT_PREVIEWS_PER_USER = 3
|
||||
MAX_SUPPORT_PREVIEWS_TOTAL = 3
|
||||
MAX_SUPPORT_ATTACHMENT_BYTES = 8 * 1024 * 1024
|
||||
MAX_SUPPORT_MESSAGE_CODEPOINTS = 10_000
|
||||
MAX_SUPPORT_CONTACT_CODEPOINTS = 320
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
@@ -63,8 +75,46 @@ MAX_IMPORT_PREVIEWS_PER_USER = 3
|
||||
# accounts.
|
||||
MAX_IMPORT_PREVIEWS_TOTAL = 3
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
DEFAULT_CONFIG: dict[str, object] = {
|
||||
"spaces": [],
|
||||
"markers": [],
|
||||
"settings": {"bg_mode": "daynight"},
|
||||
}
|
||||
|
||||
# #42: THE stable public error-code contract. Every code a user-facing
|
||||
# failure can carry — send_error literals, exception-class codes and the
|
||||
# literal MarkerControlError codes — lives here; the scanner in
|
||||
# tests_backend/test_backend_quality.py fails when a source emits a code
|
||||
# outside this set (fail-closed), and every fixed code has a localized
|
||||
# `backup.error.<code>` message on the frontend.
|
||||
ERROR_CODES: frozenset[str] = frozenset({
|
||||
"capacity_exceeded", "commit_failed", "conflict",
|
||||
"content_confirmation_required", "duplicate_marker_control",
|
||||
"future_model", "in_use", "invalid_config", "invalid_content",
|
||||
"invalid_data", "invalid_format", "invalid_json", "invalid_layout",
|
||||
"invalid_light_entity", "invalid_marker_control", "invalid_name",
|
||||
"invalid_partition_opening_host",
|
||||
"invalid_partition_opening_jamb_margin", "invalid_passage_fields",
|
||||
"invalid_space_id", "invalid_toggle_entity", "invalid_value_badge",
|
||||
"invalid_value_badge_attribute", "invalid_value_badge_position",
|
||||
"invalid_value_badge_source", "invalid_value_source",
|
||||
"invalid_value_source_attribute", "io_error", "marker_control_cycle",
|
||||
"marker_control_missing", "marker_control_not_light",
|
||||
"marker_control_self", "missing_content", "missing_plan", "no_backup",
|
||||
"not_ready", "not_toggleable", "nothing_to_repair", "preview_expired",
|
||||
"preview_owner_mismatch", "space_in_use", "space_not_found",
|
||||
"support_invalid_message", "support_package_too_large",
|
||||
"support_preview_expired", "support_rate_limited", "support_rejected",
|
||||
"support_unavailable",
|
||||
"too_large", "unauthorized", "unsupported_export_version",
|
||||
"value_badge_source_required", "wall_model_client_outdated",
|
||||
"wall_model_migration_blocked",
|
||||
})
|
||||
|
||||
# Template-code families: f-string codes carry one of these prefixes and are
|
||||
# served by the generic per-code fallback on the frontend.
|
||||
ERROR_CODE_FAMILIES: tuple[str, ...] = (
|
||||
"junction_limit_",
|
||||
"value_badge_",
|
||||
"value_source_",
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ import copy
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
COORDINATE_DECIMALS = 9
|
||||
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
|
||||
LATTICE_GRID_N = 240
|
||||
|
||||
@@ -1,111 +1,125 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "f3b9d9068e440e59a8abb9e4390a828ab8a3ffeb691e2d0777997c1e56c3c467",
|
||||
"fingerprint": "e6543fc39221c583933f6d9e97f847122aeb08929e89fe91e48b78e88e53eb97",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-Bit484fR.js",
|
||||
"houseplan-assets/houseplan-card-CI-veuXE.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 271143,
|
||||
"initialViewGzipBytes": 291046,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/de-Bm8vPaKB.js",
|
||||
"houseplan-assets/editor-CcQvuoGr.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CWaq9LTE.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Tm1y6xbk.js",
|
||||
"houseplan-assets/space-deletion-1ap2PeUr.js"
|
||||
"houseplan-assets/backdrop-pick-CBW60-WD.js",
|
||||
"houseplan-assets/de-DXJcadLA.js",
|
||||
"houseplan-assets/editor-CvldtwRy.js",
|
||||
"houseplan-assets/fr-Dwlzfaqm.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-B9YuSiM3.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CjoSEnYZ.js"
|
||||
],
|
||||
"lazyGzipBytes": 165964,
|
||||
"lazyGzipBytes": 206069,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/editor-CcQvuoGr.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CWaq9LTE.js",
|
||||
"houseplan-assets/space-deletion-1ap2PeUr.js"
|
||||
"houseplan-assets/backdrop-pick-CBW60-WD.js",
|
||||
"houseplan-assets/editor-CvldtwRy.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-B9YuSiM3.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 136854,
|
||||
"lazyEditorGzipBytes": 148959,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Tm1y6xbk.js",
|
||||
"houseplan-assets/space-deletion-1ap2PeUr.js"
|
||||
"houseplan-assets/backdrop-pick-CBW60-WD.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CjoSEnYZ.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 7198,
|
||||
"lazyOnboardingGzipBytes": 14031,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-Bm8vPaKB.js"
|
||||
"houseplan-assets/de-DXJcadLA.js",
|
||||
"houseplan-assets/fr-Dwlzfaqm.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 22465,
|
||||
"lazyLocaleGzipBytes": 50150,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/de-Bm8vPaKB.js",
|
||||
"sha256": "e8e5acd5cda2ac637e178a08ae4eb4ea1127472b736b8cab7c19ae2ab6b8bb0a",
|
||||
"rawBytes": 73919,
|
||||
"gzipBytes": 22465,
|
||||
"path": "houseplan-assets/backdrop-pick-CBW60-WD.js",
|
||||
"sha256": "2eeec7aa4e0d29f403ae232e84c432e76892d16ab934aed2925e31ece6d90205",
|
||||
"rawBytes": 20636,
|
||||
"gzipBytes": 7071,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CI-veuXE.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-DXJcadLA.js",
|
||||
"sha256": "09989bcfc51f28fc7ee705dcffbb9a3665a19a145dea53a09e26f0b97f0db4b0",
|
||||
"rawBytes": 83981,
|
||||
"gzipBytes": 25346,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-CcQvuoGr.js",
|
||||
"sha256": "661537fb7547eaaf3d5c6cfd040db88c74ae95b40554998ef0360c8bccfe3de0",
|
||||
"rawBytes": 3831,
|
||||
"gzipBytes": 1583,
|
||||
"path": "houseplan-assets/editor-CvldtwRy.js",
|
||||
"sha256": "5a0cbed7620de794906df86f5e70c6df066dd956e99facf291c28c9c8ed7c22e",
|
||||
"rawBytes": 3826,
|
||||
"gzipBytes": 1581,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bit484fR.js"
|
||||
"houseplan-assets/houseplan-card-CI-veuXE.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-Bit484fR.js",
|
||||
"sha256": "9069adcc9c0be4411d89d822d3610de6e1154598693cb3cb8575d9eaa6743bcc",
|
||||
"rawBytes": 956442,
|
||||
"gzipBytes": 270390,
|
||||
"path": "houseplan-assets/fr-Dwlzfaqm.js",
|
||||
"sha256": "79001970adf5e2ccaa3c39d3019b3a7916f9536cd2e136d0305a655f1bf5ef8e",
|
||||
"rawBytes": 86212,
|
||||
"gzipBytes": 24804,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-CI-veuXE.js",
|
||||
"sha256": "ddf0acf4f133cabd1f8daf3b341cec2e5e800f272b7c25a345b75858a2007932",
|
||||
"rawBytes": 1034200,
|
||||
"gzipBytes": 290250,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-Bm8vPaKB.js",
|
||||
"houseplan-assets/editor-CcQvuoGr.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CWaq9LTE.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Tm1y6xbk.js"
|
||||
"houseplan-assets/de-DXJcadLA.js",
|
||||
"houseplan-assets/editor-CvldtwRy.js",
|
||||
"houseplan-assets/fr-Dwlzfaqm.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-B9YuSiM3.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CjoSEnYZ.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-CWaq9LTE.js",
|
||||
"sha256": "30bd78227922512c03aa6d0177f776b17508048ff311eac0b8b993e4c3ca0602",
|
||||
"rawBytes": 520370,
|
||||
"gzipBytes": 134718,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-B9YuSiM3.js",
|
||||
"sha256": "3c90573752881cf8fddc487c8023d2a4f38bb729399581f2e7cf80899d8981d5",
|
||||
"rawBytes": 544110,
|
||||
"gzipBytes": 140307,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bit484fR.js",
|
||||
"houseplan-assets/space-deletion-1ap2PeUr.js"
|
||||
"houseplan-assets/backdrop-pick-CBW60-WD.js",
|
||||
"houseplan-assets/houseplan-card-CI-veuXE.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-Tm1y6xbk.js",
|
||||
"sha256": "3115311691b5b2164c3fd4d00d311040d1c430a2c0cb6637fafbf1a6775c328a",
|
||||
"rawBytes": 26356,
|
||||
"gzipBytes": 6645,
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-CjoSEnYZ.js",
|
||||
"sha256": "2e269e2140bb45f86bdaf74ba5537a3c299ba16ef24a0a740ba7cde6030d6a6b",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6960,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bit484fR.js",
|
||||
"houseplan-assets/space-deletion-1ap2PeUr.js"
|
||||
"houseplan-assets/backdrop-pick-CBW60-WD.js",
|
||||
"houseplan-assets/houseplan-card-CI-veuXE.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/space-deletion-1ap2PeUr.js",
|
||||
"sha256": "f972b71546589bc2601e2432022d835bf10349f876c32d81fa957767ba72c7c4",
|
||||
"rawBytes": 911,
|
||||
"gzipBytes": 553,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "a1123da36e218354a3633fe550fe099f6ffb0fb7062dec34c0b38ba3b912ab6c",
|
||||
"rawBytes": 1089,
|
||||
"gzipBytes": 753,
|
||||
"sha256": "8ce2dbd2f12c7cc7c9229f86473886e392675f453e1b368255d6ca1f35c4e9e8",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 796,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bit484fR.js"
|
||||
"houseplan-assets/houseplan-card-CI-veuXE.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f3b9d9068e440e59a8abb9e4390a828ab8a3ffeb691e2d0777997c1e56c3c467";import{c as e,l as o,t,c$ as s,A as a,d0 as i,d1 as l,d2 as n,E as r,b as c}from"./houseplan-card-Bit484fR.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e6543fc39221c583933f6d9e97f847122aeb08929e89fe91e48b78e88e53eb97";import{b as e,l as o,t,d8 as s,A as a,d9 as i,da as l,db as n,E as r,c}from"./houseplan-card-CI-veuXE.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -1 +0,0 @@
|
||||
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),a=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:a,count:a.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f3b9d9068e440e59a8abb9e4390a828ab8a3ffeb691e2d0777997c1e56c3c467";export{t as a,e as c,o as i,r as s,s as t};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f3b9d9068e440e59a8abb9e4390a828ab8a3ffeb691e2d0777997c1e56c3c467";try{await import("./houseplan-assets/houseplan-card-Bit484fR.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e6543fc39221c583933f6d9e97f847122aeb08929e89fe91e48b78e88e53eb97";try{await import("./houseplan-assets/houseplan-card-CI-veuXE.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
try:
|
||||
from homeassistant.components.http import KEY_HASS
|
||||
except ImportError: # pragma: no cover - old HA compatibility
|
||||
|
||||
@@ -12,7 +12,6 @@ from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
try: # KEY_HASS — the modern way to access hass from the aiohttp application
|
||||
@@ -21,11 +20,15 @@ except ImportError: # older HA versions
|
||||
KEY_HASS = "hass" # type: ignore[assignment]
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT, MAX_EXPORT_BYTES, PLANS_DIR,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .const import (
|
||||
CONTENT_URL,
|
||||
FILES_DIR,
|
||||
MAX_EXPORT_BYTES,
|
||||
MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT,
|
||||
PLANS_DIR,
|
||||
)
|
||||
from .import_export import ImportFailure, create_preview
|
||||
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
@@ -113,7 +116,7 @@ class HouseplanImportPreviewView(HomeAssistantView):
|
||||
except ImportFailure as err:
|
||||
status = 413 if err.code == "too_large" else 400
|
||||
return web.json_response({"error": err.code, "message": err.message}, status=status)
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception: # noqa: BLE001 - the HTTP boundary must answer 400, never leak a traceback
|
||||
_LOGGER.exception("House Plan import preview failed")
|
||||
return web.json_response({"error": "invalid_format"}, status=400)
|
||||
return web.json_response(result)
|
||||
@@ -264,7 +267,7 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
break
|
||||
if pending:
|
||||
await hass.async_add_executor_job(_flush, tmp, pending)
|
||||
except Exception as err: # noqa: BLE001
|
||||
except Exception as err: # noqa: BLE001 - a broken multipart stream must answer 400, not crash the view
|
||||
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
|
||||
error = ({"error": "bad_request"}, 400)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ only the exact candidate that produced that preview.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
@@ -24,8 +23,8 @@ import voluptuous as vol
|
||||
from .const import (
|
||||
CONTENT_URL,
|
||||
EXPORT_VERSION,
|
||||
FILES_URL,
|
||||
FILES_DIR,
|
||||
FILES_URL,
|
||||
IMPORT_PREVIEW_TTL_S,
|
||||
MAX_EXPORT_BYTES,
|
||||
MAX_IMPORT_PREVIEWS_PER_USER,
|
||||
@@ -36,10 +35,6 @@ from .const import (
|
||||
VERSION,
|
||||
)
|
||||
from .store import HouseplanData
|
||||
from .wall_segment_model import (
|
||||
WallSegmentMigrationError,
|
||||
commit_wall_segment_model,
|
||||
)
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA,
|
||||
LAYOUT_SCHEMA,
|
||||
@@ -48,16 +43,21 @@ from .validation import (
|
||||
MAX_LAYOUT,
|
||||
MAX_MARKERS,
|
||||
MAX_SPACES,
|
||||
MarkerControlError,
|
||||
OpeningPassageError,
|
||||
PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
sanitize_filename,
|
||||
sanitize_marker_id,
|
||||
validate_marker_controls,
|
||||
validate_marker_light_entities,
|
||||
validate_marker_value_badges,
|
||||
validate_opening_passages, validate_partition_opening_hosts,
|
||||
MarkerControlError,
|
||||
OpeningPassageError,
|
||||
PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
validate_opening_passages,
|
||||
validate_partition_opening_hosts,
|
||||
)
|
||||
from .wall_segment_model import (
|
||||
WallSegmentMigrationError,
|
||||
commit_wall_segment_model,
|
||||
)
|
||||
|
||||
FORMAT = "houseplan-export"
|
||||
@@ -96,7 +96,7 @@ _DECOR_KIND_FIELDS = {
|
||||
"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"),
|
||||
"furniture": ("symbol", "x", "y", "w", "h", "angle", "flip_h", "flip_v"),
|
||||
}
|
||||
|
||||
|
||||
@@ -501,6 +501,14 @@ def create_export(
|
||||
continue
|
||||
kept.append(ref)
|
||||
marker["controls"] = kept or None
|
||||
# #385(г): the two neutralisation formats below differ on
|
||||
# purpose and are a PAIR — each disarms an external ref the
|
||||
# way its own model represents "no source": the badge keeps
|
||||
# its object with enabled=False/source=None (enabled is part
|
||||
# of the badge model), while value_source's absence IS auto,
|
||||
# so the key is dropped. Both count in dropped_marker_links;
|
||||
# changing either format would break round-trips of existing
|
||||
# exports.
|
||||
badge = marker.get("value_badge")
|
||||
source = badge.get("source") if isinstance(badge, dict) else None
|
||||
ref = source.get("ref") if isinstance(source, dict) \
|
||||
@@ -510,6 +518,13 @@ def create_export(
|
||||
badge["enabled"] = False
|
||||
badge["source"] = None
|
||||
dropped_marker_links += 1
|
||||
value_source = marker.get("value_source")
|
||||
ref = value_source.get("ref") if isinstance(value_source, dict) \
|
||||
and value_source.get("kind") == "derived_marker_state" else None
|
||||
if isinstance(ref, str) and ref.startswith("marker:") \
|
||||
and ref[len("marker:"):] not in selected_ids:
|
||||
marker.pop("value_source", None)
|
||||
dropped_marker_links += 1
|
||||
config = {
|
||||
"spaces": [_json_copy(space)],
|
||||
"markers": _json_copy(selected_markers),
|
||||
@@ -699,7 +714,7 @@ def _transfer_dropped_marker_links(document: dict[str, Any]) -> int:
|
||||
if not isinstance(transfer, dict):
|
||||
raise ImportFailure("invalid_format", "Transfer metadata must be an object")
|
||||
value = transfer.get("dropped_marker_links", 0)
|
||||
maximum = MAX_MARKERS * (MAX_CONTROLS + 1)
|
||||
maximum = MAX_MARKERS * (MAX_CONTROLS + 2)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= maximum:
|
||||
raise ImportFailure("invalid_format", "Invalid dropped marker link count")
|
||||
return value
|
||||
@@ -803,6 +818,16 @@ def _drop_invalid_import_marker_links(
|
||||
badge["enabled"] = False
|
||||
badge["source"] = None
|
||||
dropped += 1
|
||||
source = marker.get("value_source")
|
||||
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
|
||||
ref = source.get("ref")
|
||||
target_id = ref[len("marker:"):] \
|
||||
if isinstance(ref, str) and ref.startswith("marker:") else ""
|
||||
target = by_id.get(target_id)
|
||||
if not target_id or target is None or target.get("removed") is True \
|
||||
or target.get("is_light") is not True:
|
||||
marker.pop("value_source", None)
|
||||
dropped += 1
|
||||
return dropped
|
||||
|
||||
|
||||
@@ -1110,6 +1135,20 @@ def _repair_target_space_refs(
|
||||
preserve_related(
|
||||
marker_id, "marker.value_badge", old_target, resolve_marker,
|
||||
)
|
||||
source = marker.get("value_source")
|
||||
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
|
||||
ref = source.get("ref")
|
||||
old_target = ref[len("marker:"):] \
|
||||
if isinstance(ref, str) and ref.startswith("marker:") else None
|
||||
mapped = replace(
|
||||
marker_id, "marker.value_source", old_target, resolve_marker_link,
|
||||
)
|
||||
if mapped is not None:
|
||||
source["ref"] = "marker:" + mapped
|
||||
else:
|
||||
preserve_related(
|
||||
marker_id, "marker.value_source", old_target, resolve_marker,
|
||||
)
|
||||
|
||||
# Destination wins collisions. Rekey only proven dead plan-owned keys;
|
||||
# opaque HA owners remain literal.
|
||||
@@ -1345,13 +1384,17 @@ def build_space_merge(
|
||||
source = badge.get("source") if isinstance(badge, dict) else None
|
||||
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
|
||||
dropped_marker_links += 1
|
||||
value_source = marker.get("value_source")
|
||||
if isinstance(value_source, dict) \
|
||||
and value_source.get("kind") == "derived_marker_state":
|
||||
dropped_marker_links += 1
|
||||
marker["binding"] = "virtual"
|
||||
marker["display"] = "static_icon"
|
||||
for field in (
|
||||
"area", "controls", "tap_action", "tap_target", "tap_confirm",
|
||||
"vacuum", "is_light", "use_climate_temp", "glow_color",
|
||||
"glow_radius_cm", "light_entity", "toggle_entity", "hidden", "removed",
|
||||
"value_badge",
|
||||
"value_badge", "value_source",
|
||||
):
|
||||
marker.pop(field, None)
|
||||
output_markers.append(marker)
|
||||
@@ -1395,6 +1438,21 @@ def build_space_merge(
|
||||
badge["enabled"] = False
|
||||
badge["source"] = None
|
||||
dropped_marker_links += 1
|
||||
source = marker.get("value_source")
|
||||
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
|
||||
ref = source.get("ref")
|
||||
old_target = ref[len("marker:"):] \
|
||||
if isinstance(ref, str) and ref.startswith("marker:") else ""
|
||||
target = None if old_target in virtualized_targets else marker_map.get(old_target)
|
||||
if target:
|
||||
source["ref"] = "marker:" + target
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.value_source",
|
||||
str(marker.get("id", "?")), old_target,
|
||||
)
|
||||
else:
|
||||
marker.pop("value_source", None)
|
||||
dropped_marker_links += 1
|
||||
|
||||
output_markers_by_id = {
|
||||
str(marker.get("id")): marker for marker in output_markers
|
||||
@@ -1667,6 +1725,7 @@ def _materialize_import_candidate(
|
||||
settings = config.get("settings") or {}
|
||||
settings.pop("known_devices", None)
|
||||
settings.pop("new_device_ids", None)
|
||||
settings.pop("marker_area_snapshot", None)
|
||||
details = {
|
||||
"dropped_marker_links": _transfer_dropped_marker_links(prepared),
|
||||
}
|
||||
|
||||
@@ -19,12 +19,14 @@ ugly, not corrupt — see docs/specs/329-junction-limits.md §5.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from .wall_segment_model import (
|
||||
WALL_SEGMENT_MODEL_VERSION, WallSegmentMigrationError, commit_wall_segment_model,
|
||||
WALL_SEGMENT_MODEL_VERSION,
|
||||
WallSegmentMigrationError,
|
||||
commit_wall_segment_model,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -82,11 +84,11 @@ def _quantize_key_coord(value: float) -> float:
|
||||
return 0.0 if rounded == 0 else rounded
|
||||
|
||||
|
||||
def _key(point) -> str:
|
||||
def _key(point: list[float]) -> str:
|
||||
return f"{_quantize_key_coord(point[0])},{_quantize_key_coord(point[1])}"
|
||||
|
||||
|
||||
def _length(a, b) -> float:
|
||||
def _length(a: list[float], b: list[float]) -> float:
|
||||
return math.hypot(b[0] - a[0], b[1] - a[1])
|
||||
|
||||
|
||||
@@ -94,9 +96,9 @@ def cm_to_units(cm: float, cell_cm: float, grid_pitch: float = GRID_STEP_N) -> f
|
||||
return (cm / (cell_cm or 1)) * grid_pitch
|
||||
|
||||
|
||||
def limit_segments(space: dict) -> list[dict]:
|
||||
def limit_segments(space: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Every wall the limits judge: contour atoms, partitions, draft segments."""
|
||||
segments: list[dict] = []
|
||||
segments: list[dict[str, Any]] = []
|
||||
for segment in space.get("wall_segments") or []:
|
||||
if _finite_point(segment.get("a")) and _finite_point(segment.get("b")):
|
||||
segments.append({
|
||||
@@ -128,7 +130,7 @@ def limit_segments(space: dict) -> list[dict]:
|
||||
if _length(segment["a"], segment["b"]) > _EPS]
|
||||
|
||||
|
||||
def check_nodes(segments: list[dict]) -> list[tuple[str, str, float, float]]:
|
||||
def check_nodes(segments: list[dict[str, Any]]) -> list[tuple[str, str, float, float]]:
|
||||
"""П1 + П2: valence of a node and the narrowest wedge in it."""
|
||||
rays: dict[str, list[float]] = {}
|
||||
for segment in segments:
|
||||
@@ -163,20 +165,20 @@ def check_nodes(segments: list[dict]) -> list[tuple[str, str, float, float]]:
|
||||
return violations
|
||||
|
||||
|
||||
def _axis_degrees(segment: dict) -> float:
|
||||
def _axis_degrees(segment: dict[str, Any]) -> float:
|
||||
degrees = math.degrees(math.atan2(
|
||||
segment["b"][1] - segment["a"][1], segment["b"][0] - segment["a"][0]
|
||||
))
|
||||
return (degrees % 180 + 180) % 180
|
||||
|
||||
|
||||
def _collinear(left: dict, right: dict, tolerance_deg: float = 1.0) -> bool:
|
||||
def _collinear(left: dict[str, Any], right: dict[str, Any], tolerance_deg: float = 1.0) -> bool:
|
||||
delta = abs(_axis_degrees(left) - _axis_degrees(right))
|
||||
return min(delta, 180 - delta) <= tolerance_deg
|
||||
|
||||
|
||||
def _build_node_index(segments: list[dict]) -> dict[str, list[dict]]:
|
||||
by_node: dict[str, list[dict]] = {}
|
||||
def _build_node_index(segments: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
||||
by_node: dict[str, list[dict[str, Any]]] = {}
|
||||
for item in segments:
|
||||
for point in (item["a"], item["b"]):
|
||||
by_node.setdefault(_key(point), []).append(item)
|
||||
@@ -184,8 +186,8 @@ def _build_node_index(segments: list[dict]) -> dict[str, list[dict]]:
|
||||
|
||||
|
||||
def collinear_run_length_units(
|
||||
segment: dict, segments: list[dict],
|
||||
by_node_index: dict[str, list[dict]] | None = None,
|
||||
segment: dict[str, Any], segments: list[dict[str, Any]],
|
||||
by_node_index: dict[str, list[dict[str, Any]]] | None = None,
|
||||
) -> float:
|
||||
"""Length of the WALL a segment belongs to, not of the atom.
|
||||
|
||||
@@ -218,7 +220,7 @@ def collinear_run_length_units(
|
||||
return total
|
||||
|
||||
def check_segment_lengths(
|
||||
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
|
||||
segments: list[dict[str, Any]], cell_cm: float, grid_pitch: float = GRID_STEP_N,
|
||||
) -> list[tuple[str, str, float, float]]:
|
||||
"""П3: a wall is at least 20 cm and never shorter than its own thickness."""
|
||||
violations: list[tuple[str, str, float, float]] = []
|
||||
@@ -235,7 +237,7 @@ def check_segment_lengths(
|
||||
return violations
|
||||
|
||||
|
||||
def _distance_to_segment(point, a, b) -> float:
|
||||
def _distance_to_segment(point: list[float], a: list[float], b: list[float]) -> float:
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
length_sq = dx * dx + dy * dy
|
||||
t = 0.0 if length_sq <= _EPS else max(0.0, min(1.0, (
|
||||
@@ -245,7 +247,7 @@ def _distance_to_segment(point, a, b) -> float:
|
||||
|
||||
|
||||
def check_node_distances(
|
||||
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
|
||||
segments: list[dict[str, Any]], cell_cm: float, grid_pitch: float = GRID_STEP_N,
|
||||
) -> list[tuple[str, str, float, float]]:
|
||||
"""П4: non-incident nodes and node-to-foreign-wall clearance.
|
||||
|
||||
@@ -256,19 +258,19 @@ def check_node_distances(
|
||||
suite. Pair order inside the subject follows the lexicographic key order,
|
||||
which replaces the i<j of the all-pairs loop.
|
||||
"""
|
||||
nodes: dict[str, list] = {}
|
||||
nodes: dict[str, list[Any]] = {}
|
||||
for segment in segments:
|
||||
nodes[_key(segment["a"])] = segment["a"]
|
||||
nodes[_key(segment["b"])] = segment["b"]
|
||||
min_units = cm_to_units(MIN_NODE_DISTANCE_CM, cell_cm, grid_pitch)
|
||||
size = min_units if min_units > _EPS else 1.0
|
||||
|
||||
node_grid: dict[tuple[int, int], list] = {}
|
||||
node_grid: dict[tuple[int, int], list[Any]] = {}
|
||||
for node_key, point in nodes.items():
|
||||
node_grid.setdefault(
|
||||
(int(point[0] // size), int(point[1] // size)), []
|
||||
).append((node_key, point))
|
||||
segment_grid: dict[tuple[int, int], list] = {}
|
||||
segment_grid: dict[tuple[int, int], list[Any]] = {}
|
||||
for segment in segments:
|
||||
x0 = min(segment["a"][0], segment["b"][0]) - min_units
|
||||
x1 = max(segment["a"][0], segment["b"][0]) + min_units
|
||||
@@ -314,7 +316,7 @@ def check_node_distances(
|
||||
))
|
||||
return violations
|
||||
|
||||
def space_violations(space: dict) -> list[tuple[str, str, float, float]]:
|
||||
def space_violations(space: dict[str, Any]) -> list[tuple[str, str, float, float]]:
|
||||
"""П1–П4 over one space, in the frontend's order."""
|
||||
segments = limit_segments(space)
|
||||
cell_cm = float(space.get("cell_cm") or 1)
|
||||
@@ -325,7 +327,7 @@ def space_violations(space: dict) -> list[tuple[str, str, float, float]]:
|
||||
]
|
||||
|
||||
|
||||
def _migrated_spaces(config: dict | None, *, side: str = "previous") -> dict[str, dict]:
|
||||
def _migrated_spaces(config: dict[str, Any] | None, *, side: str = "previous") -> dict[str, dict[str, Any]]:
|
||||
"""Spaces of one document AFTER the wall-segment migration, by id.
|
||||
|
||||
The limits read `wall_segments`, so a document that predates the catalogue
|
||||
@@ -375,7 +377,7 @@ def _migrated_spaces(config: dict | None, *, side: str = "previous") -> dict[str
|
||||
}
|
||||
|
||||
|
||||
def space_violation_counts(spaces: dict[str, dict]) -> dict[str, dict[str, int]]:
|
||||
def space_violation_counts(spaces: dict[str, dict[str, Any]]) -> dict[str, dict[str, int]]:
|
||||
"""Violation counts per space per rule — the shape the barrier consumes
|
||||
and the rev cache (#330 §4.2) stores. Documents are NOT retained."""
|
||||
result: dict[str, dict[str, int]] = {}
|
||||
@@ -388,7 +390,7 @@ def space_violation_counts(spaces: dict[str, dict]) -> dict[str, dict[str, int]]
|
||||
|
||||
|
||||
def validate_junction_limits(
|
||||
config: dict, previous: dict | None = None, *,
|
||||
config: dict[str, Any], previous: dict[str, Any] | None = None, *,
|
||||
baseline_counts: dict[str, dict[str, int]] | None = None,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Refuse a write that ADDS a junction violation; inherit the rest.
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.69.0-beta.2"
|
||||
"version": "1.70.0"
|
||||
}
|
||||
|
||||
@@ -297,7 +297,6 @@ def collect_plans(
|
||||
a file-system problem must not turn a durable commit into a failed call.
|
||||
"""
|
||||
new_refs = plan_refs(new_cfg)
|
||||
old_refs = plan_refs(old_cfg)
|
||||
# A commit knows what it superseded. The timer only knows what nothing
|
||||
# points at *right now*, and for a plan that is a reversible state: the
|
||||
# editor detaches the image when a space switches to "draw" and says the
|
||||
|
||||
@@ -85,7 +85,11 @@ rules:
|
||||
comment: No authentication against an external service.
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: Backend covered by pure tests + HA-harness tests in CI; measuring >95% is planned.
|
||||
comment: >-
|
||||
#42 stage 1 shipped the machinery: pytest-cov in CI with a committed
|
||||
baseline gate, coverage.xml artifact and a hard fail on a silently
|
||||
skipped HA harness. The 90%/95% thresholds are raised by follow-up
|
||||
baseline bumps; the rule flips to done at >=95%.
|
||||
# ---- Gold (selected; entity/device rules are exempt — no entities) ----
|
||||
diagnostics:
|
||||
status: done
|
||||
@@ -97,13 +101,23 @@ rules:
|
||||
status: done
|
||||
comment: Missing plan files raise repair issues (translation_key broken_plan).
|
||||
docs-troubleshooting:
|
||||
status: todo
|
||||
status: done
|
||||
comment: >-
|
||||
USER-GUIDE.md #22 Troubleshooting and USER-GUIDE.ru.md #22 «Диагностика»
|
||||
cover load failures, missing devices, geometry, vacuum and reporting.
|
||||
docs-examples:
|
||||
status: todo
|
||||
status: done
|
||||
comment: >-
|
||||
USER-GUIDE carries executable YAML examples for both cards (view,
|
||||
space card with fit/light_pools, filters) and the setup walkthrough.
|
||||
# ---- Platinum ----
|
||||
strict-typing:
|
||||
status: todo
|
||||
comment: Python is annotated; mypy strict pass is planned.
|
||||
comment: >-
|
||||
#42 stage 1: mypy strict gates six pure modules (const, projection,
|
||||
coordinate_canonicalization, frontend_asset_manifest, junction_limits,
|
||||
plans) via a grow-only allowlist; the HA-boundary modules are the next
|
||||
stage.
|
||||
async-dependency:
|
||||
status: exempt
|
||||
comment: No dependencies.
|
||||
|
||||
@@ -25,7 +25,6 @@ from .coordinate_canonicalization import (
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_BG_MODES = frozenset({"static", "daynight"})
|
||||
|
||||
@@ -101,6 +100,10 @@ class HouseplanData:
|
||||
# Parsed import candidates are short-lived, user-bound and memory-only.
|
||||
# dict keeps insertion order, which lets the preview service evict oldest.
|
||||
import_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
# #43: already-sanitized support-package bytes only. Raw config/layout is
|
||||
# never retained here; the write lock below is used to take one coherent
|
||||
# deep copy and the privacy projection immediately replaces it.
|
||||
support_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
# #330 §4.2: junction-limit violation counts of the STORED document,
|
||||
# keyed by its rev — (rev, {space_id: {rule: count}}). One slot,
|
||||
# memory-only, invalidated by a rev mismatch; the previous document is
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
"""Strict, allowlisted House Plan support-package projection (#43).
|
||||
|
||||
This module deliberately has no Home Assistant imports. Besides making the
|
||||
privacy boundary easy to test, that prevents an innocent serializer helper from
|
||||
gaining access to registries, states, URLs or paths that the package must never
|
||||
contain. The caller supplies one coherent config/layout copy and bounded,
|
||||
already-classified runtime facts; this code constructs a new object field by
|
||||
field. It never serializes the source and then tries to redact it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .const import (
|
||||
EXPORT_VERSION,
|
||||
MAX_SUPPORT_ATTACHMENT_BYTES,
|
||||
PLAN_MODEL_VERSION,
|
||||
)
|
||||
|
||||
PACKAGE_FORMAT = "houseplan-support-package"
|
||||
PACKAGE_VERSION = 1
|
||||
_SAFE_VERSION = re.compile(r"\A[0-9A-Za-z._+-]{1,32}\Z")
|
||||
_SAFE_ICON = re.compile(r"\Amdi:[a-z0-9-]{1,80}\Z")
|
||||
_LANGUAGES = frozenset({"en", "ru", "de", "fr"})
|
||||
_BROWSERS = frozenset({"chromium", "firefox", "webkit", "unknown"})
|
||||
_REGISTRY_ACCESS = frozenset({"full", "partial", "unavailable"})
|
||||
_REGISTRY_AGE = frozenset({"fresh", "stale", "unknown"})
|
||||
_VALUE_BADGE_POSITIONS = frozenset({"right", "bottom", "left", "top"})
|
||||
|
||||
|
||||
class SupportPackageError(ValueError):
|
||||
"""A stable public failure without source data in its text."""
|
||||
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _safe_version(value: object) -> str:
|
||||
text = str(value or "unknown")
|
||||
return text if _SAFE_VERSION.fullmatch(text) else "unknown"
|
||||
|
||||
|
||||
def validate_frontend_facts(value: object) -> dict[str, Any]:
|
||||
"""Accept only the small runtime enum/boolean contract from the browser."""
|
||||
if not isinstance(value, dict):
|
||||
raise SupportPackageError("support_rejected")
|
||||
browser = value.get("browser_family")
|
||||
language = value.get("language")
|
||||
registry = value.get("registry_access")
|
||||
age = value.get("registry_age_bucket")
|
||||
major = value.get("browser_major")
|
||||
if browser not in _BROWSERS or language not in _LANGUAGES:
|
||||
raise SupportPackageError("support_rejected")
|
||||
if registry not in _REGISTRY_ACCESS or age not in _REGISTRY_AGE:
|
||||
raise SupportPackageError("support_rejected")
|
||||
if isinstance(major, bool) or not isinstance(major, int) or not 0 <= major <= 999:
|
||||
raise SupportPackageError("support_rejected")
|
||||
for key in ("coarse_pointer", "hover_capable"):
|
||||
if not isinstance(value.get(key), bool):
|
||||
raise SupportPackageError("support_rejected")
|
||||
return {
|
||||
"browser_family": browser,
|
||||
"browser_major": major,
|
||||
"language": language,
|
||||
"coarse_pointer": value["coarse_pointer"],
|
||||
"hover_capable": value["hover_capable"],
|
||||
"registry_access": registry,
|
||||
"registry_age_bucket": age,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Pseudonyms:
|
||||
namespace: str
|
||||
maps: dict[str, dict[str, str]] = field(default_factory=dict)
|
||||
|
||||
def get(self, kind: str, raw: object) -> str | None:
|
||||
if raw is None:
|
||||
return None
|
||||
value = str(raw)
|
||||
if not value:
|
||||
return None
|
||||
items = self.maps.setdefault(kind, {})
|
||||
if value not in items:
|
||||
items[value] = f"{kind}-{self.namespace}-{len(items) + 1}"
|
||||
return items[value]
|
||||
|
||||
|
||||
def _copy_keys(source: object, keys: tuple[str, ...]) -> dict[str, Any]:
|
||||
if not isinstance(source, dict):
|
||||
return {}
|
||||
return {key: source[key] for key in keys if key in source}
|
||||
|
||||
|
||||
def _point(value: object) -> list[float] | None:
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 2:
|
||||
return None
|
||||
if any(isinstance(item, bool) or not isinstance(item, (int, float)) for item in value):
|
||||
return None
|
||||
return [value[0], value[1]]
|
||||
|
||||
|
||||
def _points(value: object) -> list[list[float]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [point for item in value if (point := _point(item)) is not None]
|
||||
|
||||
|
||||
def _entity_ref(ids: _Pseudonyms, value: object) -> str | None:
|
||||
return ids.get("entity", value)
|
||||
|
||||
|
||||
def _binding(ids: _Pseudonyms, value: object) -> tuple[str, str]:
|
||||
text = str(value or "")
|
||||
if text == "virtual":
|
||||
return "virtual", "virtual"
|
||||
kind, separator, raw = text.partition(":")
|
||||
if separator and kind in {"device", "entity"} and raw:
|
||||
# Reuse the same per-kind namespace as controls/value sources so one
|
||||
# raw entity remains one pseudonym everywhere in this package.
|
||||
pseudo = ids.get(kind, raw)
|
||||
return kind, f"{kind}:{pseudo}"
|
||||
return "unknown", "unknown"
|
||||
|
||||
|
||||
def _custom_fill(value: object) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
return _copy_keys(value, ("c", "a"))
|
||||
|
||||
|
||||
def _global_settings(value: object) -> dict[str, Any]:
|
||||
out = _copy_keys(value, ("glow_radius_cm", "bg_color", "north_deg", "bg_mode", "sun_rays"))
|
||||
if not isinstance(value, dict):
|
||||
return out
|
||||
fill_colors = value.get("fill_colors")
|
||||
if isinstance(fill_colors, dict):
|
||||
out["fill_colors"] = {
|
||||
str(key): _copy_keys(item, ("c", "a"))
|
||||
for key, item in fill_colors.items()
|
||||
if isinstance(key, str) and isinstance(item, dict)
|
||||
}
|
||||
style = value.get("decor_default_style")
|
||||
if isinstance(style, dict):
|
||||
out["decor_default_style"] = _copy_keys(
|
||||
style, ("color", "opacity", "width_cm", "fill", "fill_color", "fill_opacity"),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _space_settings(value: object) -> dict[str, Any]:
|
||||
out = _copy_keys(value, (
|
||||
"show_borders", "show_names", "room_color", "bg_color", "room_opacity",
|
||||
"fill_mode", "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",
|
||||
))
|
||||
if isinstance(value, dict) and "custom_fill" in value:
|
||||
out["custom_fill"] = _custom_fill(value.get("custom_fill"))
|
||||
return out
|
||||
|
||||
|
||||
def _room_settings(ids: _Pseudonyms, value: object) -> dict[str, Any]:
|
||||
out = _copy_keys(value, ("fill_mode", "glow", "name_scale", "label_scale"))
|
||||
if not isinstance(value, dict):
|
||||
return out
|
||||
if "custom_fill" in value:
|
||||
out["custom_fill"] = _custom_fill(value.get("custom_fill"))
|
||||
for key in ("temp_source", "hum_source"):
|
||||
source = value.get(key)
|
||||
if source:
|
||||
kind, pseudo = _binding(ids, source)
|
||||
out[key] = pseudo
|
||||
out[f"{key}_kind"] = kind
|
||||
return out
|
||||
|
||||
|
||||
def _project_room(ids: _Pseudonyms, room: dict[str, Any], index: int) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
"id": ids.get("room", room.get("id")),
|
||||
"name": f"Room {index}",
|
||||
}
|
||||
out.update(_copy_keys(room, ("x", "y", "w", "h")))
|
||||
if "poly" in room:
|
||||
out["poly"] = _points(room.get("poly"))
|
||||
if isinstance(room.get("wall_ids"), list):
|
||||
out["wall_ids"] = [ids.get("wall", item) for item in room["wall_ids"]]
|
||||
if isinstance(room.get("open_to"), list):
|
||||
out["open_to"] = [ids.get("room", item) for item in room["open_to"]]
|
||||
settings = _room_settings(ids, room.get("settings"))
|
||||
if settings:
|
||||
out["settings"] = settings
|
||||
return out
|
||||
|
||||
|
||||
def _project_decor(ids: _Pseudonyms, item: dict[str, Any]) -> dict[str, Any]:
|
||||
kind = str(item.get("kind") or "unknown")
|
||||
out: dict[str, Any] = {"id": ids.get("decor", item.get("id")), "kind": kind}
|
||||
out.update(_copy_keys(item, (
|
||||
"color", "opacity", "width_cm", "width", "x1", "y1", "x2", "y2",
|
||||
"line_style", "x", "y", "w", "h", "angle", "fill", "fill_color",
|
||||
"fill_opacity", "size", "size_cm", "scale", "symbol", "flip_h", "flip_v",
|
||||
)))
|
||||
if kind == "text":
|
||||
out["text"] = "[redacted text]"
|
||||
if item.get("entity"):
|
||||
out["entity"] = _entity_ref(ids, item.get("entity"))
|
||||
return out
|
||||
|
||||
|
||||
def _project_value_source(ids: _Pseudonyms, value: object) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
kind = value.get("kind")
|
||||
if kind in {"entity_state", "entity_attribute"}:
|
||||
out: dict[str, Any] = {"kind": kind, "entity_id": _entity_ref(ids, value.get("entity_id"))}
|
||||
# Attribute names can be arbitrary user strings and are not needed to
|
||||
# reproduce geometry or marker placement, so they are deliberately absent.
|
||||
return out
|
||||
if kind == "derived_lqi":
|
||||
return {"kind": kind}
|
||||
if kind == "derived_marker_state":
|
||||
raw = str(value.get("ref") or "")
|
||||
marker = raw[7:] if raw.startswith("marker:") else raw
|
||||
return {"kind": kind, "ref": f"marker:{ids.get('marker', marker)}"}
|
||||
return None
|
||||
|
||||
|
||||
def _project_value_badge(ids: _Pseudonyms, value: object) -> dict[str, Any] | None:
|
||||
"""Project the compatibility-tolerant badge config into safe scalar fields."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
out: dict[str, Any] = {}
|
||||
enabled = value.get("enabled")
|
||||
if isinstance(enabled, bool):
|
||||
out["enabled"] = enabled
|
||||
position = value.get("position")
|
||||
if isinstance(position, str) and position in _VALUE_BADGE_POSITIONS:
|
||||
out["position"] = position
|
||||
source = _project_value_source(ids, value.get("source"))
|
||||
if source is not None:
|
||||
out["source"] = source
|
||||
return out or None
|
||||
|
||||
|
||||
def _project_marker(ids: _Pseudonyms, marker: dict[str, Any]) -> dict[str, Any]:
|
||||
binding_kind, binding = _binding(ids, marker.get("binding"))
|
||||
out: dict[str, Any] = {
|
||||
"id": ids.get("marker", marker.get("id")),
|
||||
"binding": binding,
|
||||
"binding_kind": binding_kind,
|
||||
}
|
||||
out.update(_copy_keys(marker, (
|
||||
"hidden", "removed", "tap_action", "tap_confirm", "display", "ripple_color",
|
||||
"ripple_size", "size", "angle", "glow_radius_cm", "glow_color", "is_light",
|
||||
"use_climate_temp",
|
||||
)))
|
||||
icon = marker.get("icon")
|
||||
if isinstance(icon, str) and _SAFE_ICON.fullmatch(icon):
|
||||
out["icon"] = icon
|
||||
if marker.get("space"):
|
||||
out["space"] = ids.get("space", marker.get("space"))
|
||||
if marker.get("room_id"):
|
||||
out["room_id"] = ids.get("room", marker.get("room_id"))
|
||||
for key in ("light_entity", "toggle_entity", "tap_target"):
|
||||
if marker.get(key):
|
||||
out[key] = _entity_ref(ids, marker.get(key))
|
||||
controls = marker.get("controls")
|
||||
if isinstance(controls, list):
|
||||
out["controls"] = [_entity_ref(ids, value) for value in controls]
|
||||
vacuum = marker.get("vacuum")
|
||||
if isinstance(vacuum, dict):
|
||||
out["vacuum"] = _copy_keys(vacuum, ("live", "trail", "trail_mode", "room_highlight"))
|
||||
for key in ("value_source",):
|
||||
projected = _project_value_source(ids, marker.get(key))
|
||||
if projected:
|
||||
out[key] = projected
|
||||
safe_badge = _project_value_badge(ids, marker.get("value_badge"))
|
||||
if safe_badge is not None:
|
||||
out["value_badge"] = safe_badge
|
||||
return out
|
||||
|
||||
|
||||
def _project_space(ids: _Pseudonyms, space: dict[str, Any], index: int) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
"id": ids.get("space", space.get("id")),
|
||||
"title": f"Space {index}",
|
||||
"has_plan": bool(space.get("plan_url")),
|
||||
}
|
||||
out.update(_copy_keys(space, (
|
||||
"cell_cm", "plan_aspect", "plan_x", "plan_y", "plan_scale", "plan_scale_x",
|
||||
"plan_scale_y", "plan_angle", "view_box", "zero_wall_style",
|
||||
)))
|
||||
settings = _space_settings(space.get("settings"))
|
||||
if settings:
|
||||
out["settings"] = settings
|
||||
|
||||
out["rooms"] = [
|
||||
_project_room(ids, room, room_index)
|
||||
for room_index, room in enumerate(space.get("rooms") or [], 1)
|
||||
if isinstance(room, dict)
|
||||
]
|
||||
out["wall_segments"] = [
|
||||
{
|
||||
"id": ids.get("wall", item.get("id")),
|
||||
"a": _point(item.get("a")), "b": _point(item.get("b")), "cm": item.get("cm"),
|
||||
}
|
||||
for item in space.get("wall_segments") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["walls"] = [
|
||||
{
|
||||
"key": ids.get("wall", item.get("key")), "cm": item.get("cm"),
|
||||
**({"a": _point(item.get("a")), "b": _point(item.get("b"))}
|
||||
if "a" in item and "b" in item else {}),
|
||||
}
|
||||
for item in space.get("walls") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["room_drafts"] = []
|
||||
for draft in space.get("room_drafts") or []:
|
||||
if not isinstance(draft, dict):
|
||||
continue
|
||||
out["room_drafts"].append({
|
||||
"id": ids.get("draft", draft.get("id")),
|
||||
"points": _points(draft.get("points")),
|
||||
"segments": [
|
||||
{
|
||||
**({"id": ids.get("wall", segment.get("id"))} if segment.get("id") else {}),
|
||||
"cm": segment.get("cm"),
|
||||
}
|
||||
for segment in draft.get("segments") or [] if isinstance(segment, dict)
|
||||
],
|
||||
})
|
||||
out["partitions"] = [
|
||||
{"id": ids.get("partition", item.get("id")), "a": _point(item.get("a")),
|
||||
"b": _point(item.get("b")), "cm": item.get("cm")}
|
||||
for item in space.get("partitions") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["wall_columns"] = [
|
||||
{"id": ids.get("column", item.get("id")), **_copy_keys(item, ("shape", "center", "cm", "angle"))}
|
||||
for item in space.get("wall_columns") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["openings"] = []
|
||||
for opening in space.get("openings") or []:
|
||||
if not isinstance(opening, dict):
|
||||
continue
|
||||
projected = {"id": ids.get("opening", opening.get("id"))}
|
||||
projected.update(_copy_keys(opening, (
|
||||
"type", "x", "y", "angle", "length", "invert", "flip_h", "flip_v",
|
||||
)))
|
||||
for key in ("contact", "lock"):
|
||||
if opening.get(key):
|
||||
projected[key] = _entity_ref(ids, opening.get(key))
|
||||
host = opening.get("host")
|
||||
if isinstance(host, dict) and host.get("kind") in {"wall", "partition"}:
|
||||
kind = host["kind"]
|
||||
projected["host"] = {
|
||||
"kind": kind,
|
||||
"id": ids.get(kind, host.get("id")),
|
||||
"t": host.get("t"),
|
||||
}
|
||||
out["openings"].append(projected)
|
||||
out["decor"] = [
|
||||
_project_decor(ids, item) for item in space.get("decor") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["open_spans"] = [
|
||||
{"a": _point(item.get("a")), "b": _point(item.get("b"))}
|
||||
for item in space.get("open_spans") or [] if isinstance(item, dict)
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
def _project_layout(ids: _Pseudonyms, layout: object) -> dict[str, Any]:
|
||||
if not isinstance(layout, dict):
|
||||
return {}
|
||||
marker_ids = ids.maps.get("marker", {})
|
||||
room_ids = ids.maps.get("room", {})
|
||||
out: dict[str, Any] = {}
|
||||
for raw_key, value in layout.items():
|
||||
if not isinstance(raw_key, str) or not isinstance(value, dict):
|
||||
continue
|
||||
if raw_key in marker_ids:
|
||||
key = marker_ids[raw_key]
|
||||
elif raw_key.startswith("rl_") and raw_key[3:] in room_ids:
|
||||
key = "rl_" + room_ids[raw_key[3:]]
|
||||
else:
|
||||
# Unknown keys may be stale device ids. Omitting them is the only
|
||||
# fail-closed choice; copying them would disclose the raw id.
|
||||
continue
|
||||
position = _copy_keys(value, ("x", "y", "k"))
|
||||
if value.get("s"):
|
||||
position["s"] = ids.get("space", value.get("s"))
|
||||
out[key] = position
|
||||
return out
|
||||
|
||||
|
||||
def _summary(config: object, layout: object) -> dict[str, Any]:
|
||||
spaces = config.get("spaces", []) if isinstance(config, dict) else []
|
||||
markers = config.get("markers", []) if isinstance(config, dict) else []
|
||||
kinds: Counter[str] = Counter()
|
||||
bindings: Counter[str] = Counter()
|
||||
lifecycles: Counter[str] = Counter()
|
||||
decor_kinds: Counter[str] = Counter()
|
||||
for marker in markers:
|
||||
if not isinstance(marker, dict):
|
||||
continue
|
||||
bindings[_binding_kind(marker.get("binding"))] += 1
|
||||
lifecycles[
|
||||
"removed" if marker.get("removed") else "hidden" if marker.get("hidden") else "active"
|
||||
] += 1
|
||||
for space in spaces:
|
||||
if not isinstance(space, dict):
|
||||
continue
|
||||
for opening in space.get("openings") or []:
|
||||
if isinstance(opening, dict):
|
||||
kinds[str(opening.get("type") or "unknown")] += 1
|
||||
for item in space.get("decor") or []:
|
||||
if isinstance(item, dict):
|
||||
decor_kinds[str(item.get("kind") or "unknown")] += 1
|
||||
return {
|
||||
"spaces": len(spaces),
|
||||
"rooms": sum(len(space.get("rooms") or []) for space in spaces if isinstance(space, dict)),
|
||||
"room_drafts": sum(len(space.get("room_drafts") or []) for space in spaces if isinstance(space, dict)),
|
||||
"walls": sum(len(space.get("wall_segments") or space.get("walls") or []) for space in spaces if isinstance(space, dict)),
|
||||
"partitions": sum(len(space.get("partitions") or []) for space in spaces if isinstance(space, dict)),
|
||||
"columns": sum(len(space.get("wall_columns") or []) for space in spaces if isinstance(space, dict)),
|
||||
"openings": dict(sorted(kinds.items())),
|
||||
"decor": dict(sorted(decor_kinds.items())),
|
||||
"markers": {
|
||||
"total": len(markers),
|
||||
"lifecycle": dict(sorted(lifecycles.items())),
|
||||
"binding": dict(sorted(bindings.items())),
|
||||
},
|
||||
"layout_entries": len(layout) if isinstance(layout, dict) else 0,
|
||||
}
|
||||
|
||||
|
||||
def _binding_kind(value: object) -> str:
|
||||
text = str(value or "")
|
||||
if text == "virtual":
|
||||
return "virtual"
|
||||
if text.startswith("device:"):
|
||||
return "device"
|
||||
if text.startswith("entity:"):
|
||||
return "entity"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def build_support_package(
|
||||
config: dict[str, Any],
|
||||
layout: dict[str, Any],
|
||||
*,
|
||||
config_rev: int,
|
||||
layout_rev: int,
|
||||
card_version: str,
|
||||
integration_version: str,
|
||||
home_assistant_version: str,
|
||||
runtime: dict[str, Any],
|
||||
repairs: list[dict[str, Any]] | None = None,
|
||||
namespace: str | None = None,
|
||||
) -> tuple[bytes, dict[str, Any]]:
|
||||
"""Build canonical bytes and a bounded summary for the preview response."""
|
||||
facts = validate_frontend_facts(runtime)
|
||||
ids = _Pseudonyms(namespace or secrets.token_hex(4))
|
||||
spaces = [item for item in config.get("spaces", []) if isinstance(item, dict)]
|
||||
markers = [item for item in config.get("markers", []) if isinstance(item, dict)]
|
||||
projected_spaces = [
|
||||
_project_space(ids, space, index) for index, space in enumerate(spaces, 1)
|
||||
]
|
||||
projected_markers = [_project_marker(ids, marker) for marker in markers]
|
||||
summary = _summary(config, layout)
|
||||
package = {
|
||||
"format": PACKAGE_FORMAT,
|
||||
"version": PACKAGE_VERSION,
|
||||
"versions": {
|
||||
"card": _safe_version(card_version),
|
||||
"integration": _safe_version(integration_version),
|
||||
"home_assistant": _safe_version(home_assistant_version),
|
||||
"model": PLAN_MODEL_VERSION,
|
||||
"export_schema": EXPORT_VERSION,
|
||||
},
|
||||
"runtime": facts,
|
||||
"revisions": {"config": int(config_rev), "layout": int(layout_rev)},
|
||||
"summary": summary,
|
||||
"validation": {
|
||||
"config": "valid", "layout": "valid", "unknown_fields": "dropped",
|
||||
},
|
||||
"repairs": repairs or [],
|
||||
"plan_backup": {
|
||||
"config": {
|
||||
"model_version": int(config.get("model_version", PLAN_MODEL_VERSION)),
|
||||
"settings": _global_settings(config.get("settings")),
|
||||
"spaces": projected_spaces,
|
||||
"markers": projected_markers,
|
||||
},
|
||||
"layout": _project_layout(ids, layout),
|
||||
},
|
||||
}
|
||||
raw = (json.dumps(
|
||||
package, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False,
|
||||
) + "\n").encode("utf-8")
|
||||
if len(raw) > MAX_SUPPORT_ATTACHMENT_BYTES:
|
||||
raise SupportPackageError("support_package_too_large")
|
||||
return raw, {
|
||||
"size": len(raw),
|
||||
"sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"spaces": summary["spaces"],
|
||||
"format": PACKAGE_FORMAT,
|
||||
"version": PACKAGE_VERSION,
|
||||
"versions": package["versions"],
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Bounded outbound submit to the compile-time House Plan support relay (#43)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import aiohttp
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import SUPPORT_RELAY_URL
|
||||
|
||||
_REPORT_ID_PREFIX = "hpr-"
|
||||
|
||||
|
||||
class SupportTransportError(RuntimeError):
|
||||
"""Stable frontend-facing error; never contains the remote response."""
|
||||
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
async def async_submit_report(
|
||||
hass,
|
||||
*,
|
||||
message: str,
|
||||
contact: str,
|
||||
versions: dict[str, Any],
|
||||
idempotency_key: str,
|
||||
attachment: bytes | None,
|
||||
attachment_sha256: str | None,
|
||||
filename_token: str,
|
||||
) -> str:
|
||||
"""Send once, without redirects, proxies, arbitrary hosts or reflected text."""
|
||||
target = urlsplit(SUPPORT_RELAY_URL)
|
||||
if target.scheme != "https" or target.hostname != "support.houseplan.tech":
|
||||
raise SupportTransportError("support_unavailable")
|
||||
|
||||
request: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"message": message,
|
||||
"idempotency_key": idempotency_key,
|
||||
"versions": {key: str(value) for key, value in versions.items()},
|
||||
}
|
||||
if contact:
|
||||
request["contact"] = contact
|
||||
if attachment is not None:
|
||||
request["attachment"] = {
|
||||
"size": len(attachment),
|
||||
"sha256": str(attachment_sha256 or ""),
|
||||
}
|
||||
form = aiohttp.FormData()
|
||||
form.add_field(
|
||||
"request", json.dumps(request, ensure_ascii=False, separators=(",", ":")),
|
||||
content_type="application/json",
|
||||
)
|
||||
if attachment is not None:
|
||||
form.add_field(
|
||||
"attachment", attachment,
|
||||
filename=f"houseplan-support-{filename_token[:32]}.json",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
timeout = aiohttp.ClientTimeout(total=20, sock_connect=5)
|
||||
try:
|
||||
async with session.post(
|
||||
SUPPORT_RELAY_URL, data=form, timeout=timeout, allow_redirects=False,
|
||||
) as response:
|
||||
if response.status == 429:
|
||||
raise SupportTransportError("support_rate_limited")
|
||||
if response.status == 413:
|
||||
raise SupportTransportError("support_package_too_large")
|
||||
if response.status in {400, 401, 403, 404, 405, 409, 415, 422}:
|
||||
raise SupportTransportError("support_rejected")
|
||||
if response.status != 200:
|
||||
raise SupportTransportError("support_unavailable")
|
||||
# A compromised/misconfigured relay cannot make HA buffer an
|
||||
# unbounded response or reflect its details to the browser.
|
||||
body = await response.content.read(4097)
|
||||
if len(body) > 4096:
|
||||
raise SupportTransportError("support_unavailable")
|
||||
except SupportTransportError:
|
||||
raise
|
||||
except (aiohttp.ClientError, TimeoutError, OSError):
|
||||
raise SupportTransportError("support_unavailable") from None
|
||||
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
report_id = payload.get("report_id") if isinstance(payload, dict) else None
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
report_id = None
|
||||
if not isinstance(report_id, str) or not report_id.startswith(_REPORT_ID_PREFIX):
|
||||
raise SupportTransportError("support_unavailable")
|
||||
if not 8 <= len(report_id) <= 64 or any(
|
||||
char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."
|
||||
for char in report_id
|
||||
):
|
||||
raise SupportTransportError("support_unavailable")
|
||||
return report_id
|
||||
@@ -10,6 +10,7 @@ want to see where the cleanup has already been).
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from typing import Any
|
||||
@@ -21,7 +22,6 @@ from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
import logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TRAIL_CAP = 2000 # raw points per run before decimation
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "House Plan",
|
||||
"data": {
|
||||
"admin_only": "Seuls les administrateurs peuvent modifier le plan"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Déjà configuré — une seule entrée est autorisée."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"admin_only": "Seuls les administrateurs peuvent modifier le plan"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"broken_plan": {
|
||||
"title": "L’image du plan est introuvable",
|
||||
"description": "Le fichier de plan `{file}` pour l’espace `{space}` est introuvable dans `config/houseplan/plans/`. Ouvrez les paramètres de l’espace dans la carte House Plan et téléversez de nouveau le plan."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,11 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections import Counter
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -49,9 +50,12 @@ class OpeningPassageError(ValueError):
|
||||
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)}"
|
||||
)
|
||||
# #42: structured details as JSON — the frontend parses this instead
|
||||
# of regexing an English sentence; the legacy "space=..;.." format is
|
||||
# still accepted there for one beta.
|
||||
super().__init__(json.dumps({
|
||||
"space": space_id, "opening": opening_id, "fields": list(self.fields),
|
||||
}, ensure_ascii=False))
|
||||
|
||||
|
||||
class PartitionOpeningHostError(ValueError):
|
||||
@@ -72,10 +76,11 @@ class PartitionOpeningJambMarginError(ValueError):
|
||||
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}"
|
||||
)
|
||||
# #42: structured details as JSON (see OpeningPassageError).
|
||||
super().__init__(json.dumps({
|
||||
"space": space_id, "opening": opening_id,
|
||||
"margin": round(margin, 12), "margin_cm": round(margin_cm, 12),
|
||||
}, ensure_ascii=False))
|
||||
|
||||
|
||||
class WallModelClientOutdatedError(ValueError):
|
||||
@@ -410,7 +415,7 @@ def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:
|
||||
points = sorted(set(round(point, 12) for point in breakpoints
|
||||
if -_OPTIMIZE_REHOST_EPSILON <= point
|
||||
<= length + _OPTIMIZE_REHOST_EPSILON))
|
||||
for lo, hi in zip(points, points[1:]):
|
||||
for lo, hi in zip(points, points[1:], strict=False): # #42 B905: adjacent pairs, unequal length is the point
|
||||
if hi - lo <= _OPTIMIZE_REHOST_EPSILON:
|
||||
continue
|
||||
if any(start <= lo + _OPTIMIZE_REHOST_EPSILON
|
||||
@@ -805,7 +810,7 @@ def _matching_previous_marker(
|
||||
def validate_marker_value_badges(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
"""Validate only new/changed badge data; dormant future/legacy data round-trips."""
|
||||
"""Validate changed badge/face sources; dormant future data round-trips."""
|
||||
markers = config.get("markers") or []
|
||||
by_id = {str(marker.get("id")): marker for marker in markers}
|
||||
old_markers = (previous or {}).get("markers") or []
|
||||
@@ -814,30 +819,14 @@ def validate_marker_value_badges(
|
||||
consumed_old_ids: set[str] = set()
|
||||
known_source_fields = {"kind", "entity_id", "attribute", "ref"}
|
||||
|
||||
for marker_id, marker in by_id.items():
|
||||
old_marker = _matching_previous_marker(
|
||||
marker, marker_id, old_by_id, old_markers, new_ids,
|
||||
consumed_old_ids, validate_all,
|
||||
)
|
||||
badge = marker.get("value_badge")
|
||||
old_badge = None if validate_all else (old_marker or {}).get("value_badge")
|
||||
if not validate_all and badge == old_badge:
|
||||
continue
|
||||
if badge is None:
|
||||
continue
|
||||
if not isinstance(badge, dict):
|
||||
raise MarkerControlError("invalid_value_badge", "Value badge must be an object")
|
||||
if not isinstance(badge.get("enabled"), bool):
|
||||
raise MarkerControlError("invalid_value_badge", "Value badge enabled must be boolean")
|
||||
if badge.get("position") not in VALUE_BADGE_POSITIONS:
|
||||
raise MarkerControlError("invalid_value_badge_position", "Invalid value badge position")
|
||||
source = badge.get("source")
|
||||
if badge["enabled"] and not isinstance(source, dict):
|
||||
raise MarkerControlError("value_badge_source_required", "Enabled value badge needs a source")
|
||||
if source is None:
|
||||
continue
|
||||
def validate_source(source: object, field: str) -> None:
|
||||
prefix = "value_badge" if field == "value_badge" else "value_source"
|
||||
source_error = "invalid_value_badge_source" if field == "value_badge" \
|
||||
else "invalid_value_source"
|
||||
attribute_error = "invalid_value_badge_attribute" if field == "value_badge" \
|
||||
else "invalid_value_source_attribute"
|
||||
if not isinstance(source, dict) or source.get("kind") not in VALUE_BADGE_SOURCE_KINDS:
|
||||
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge source")
|
||||
raise MarkerControlError(source_error, f"Invalid {field} source")
|
||||
kind = source["kind"]
|
||||
allowed_fields = {
|
||||
"entity_state": {"kind", "entity_id"},
|
||||
@@ -846,23 +835,51 @@ def validate_marker_value_badges(
|
||||
"derived_marker_state": {"kind", "ref"},
|
||||
}[kind]
|
||||
if (known_source_fields & set(source)) - allowed_fields:
|
||||
raise MarkerControlError("invalid_value_badge_source", "Inconsistent value badge source")
|
||||
raise MarkerControlError(source_error, f"Inconsistent {field} source")
|
||||
if kind in {"entity_state", "entity_attribute"}:
|
||||
entity_id = source.get("entity_id")
|
||||
if not isinstance(entity_id, str) or not _CONTROL_ENTITY_ID_RE.fullmatch(entity_id):
|
||||
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge entity id")
|
||||
if kind == "entity_attribute":
|
||||
if source.get("attribute") not in VALUE_BADGE_ATTRIBUTES:
|
||||
raise MarkerControlError("invalid_value_badge_attribute", "Invalid value badge attribute")
|
||||
raise MarkerControlError(source_error, f"Invalid {field} entity id")
|
||||
if kind == "entity_attribute" and source.get("attribute") not in VALUE_BADGE_ATTRIBUTES:
|
||||
raise MarkerControlError(attribute_error, f"Invalid {field} attribute")
|
||||
if kind == "derived_marker_state":
|
||||
ref = source.get("ref")
|
||||
if not isinstance(ref, str) or not ref.startswith(MARKER_CONTROL_PREFIX) or not ref[len(MARKER_CONTROL_PREFIX):]:
|
||||
raise MarkerControlError("invalid_value_badge_source", "Invalid marker value badge target")
|
||||
if not isinstance(ref, str) or not ref.startswith(MARKER_CONTROL_PREFIX) \
|
||||
or not ref[len(MARKER_CONTROL_PREFIX):]:
|
||||
raise MarkerControlError(source_error, f"Invalid {field} target")
|
||||
target = by_id.get(ref[len(MARKER_CONTROL_PREFIX):])
|
||||
if target is None or target.get("removed") is True:
|
||||
raise MarkerControlError("value_badge_marker_missing", "Marker value badge target does not exist")
|
||||
raise MarkerControlError(f"{prefix}_marker_missing", f"{field} target does not exist")
|
||||
if target.get("is_light") is not True:
|
||||
raise MarkerControlError("value_badge_marker_not_light", "Marker value badge target is not a forced light")
|
||||
raise MarkerControlError(f"{prefix}_marker_not_light", f"{field} target is not a forced light")
|
||||
|
||||
for marker_id, marker in by_id.items():
|
||||
old_marker = _matching_previous_marker(
|
||||
marker, marker_id, old_by_id, old_markers, new_ids,
|
||||
consumed_old_ids, validate_all,
|
||||
)
|
||||
badge = marker.get("value_badge")
|
||||
old_badge = None if validate_all else (old_marker or {}).get("value_badge")
|
||||
if validate_all or badge != old_badge:
|
||||
if badge is not None:
|
||||
if not isinstance(badge, dict):
|
||||
raise MarkerControlError("invalid_value_badge", "Value badge must be an object")
|
||||
if not isinstance(badge.get("enabled"), bool):
|
||||
raise MarkerControlError("invalid_value_badge", "Value badge enabled must be boolean")
|
||||
if badge.get("position") not in VALUE_BADGE_POSITIONS:
|
||||
raise MarkerControlError("invalid_value_badge_position", "Invalid value badge position")
|
||||
source = badge.get("source")
|
||||
if badge["enabled"] and not isinstance(source, dict):
|
||||
raise MarkerControlError(
|
||||
"value_badge_source_required", "Enabled value badge needs a source"
|
||||
)
|
||||
if source is not None:
|
||||
validate_source(source, "value_badge")
|
||||
|
||||
source = marker.get("value_source")
|
||||
old_source = None if validate_all else (old_marker or {}).get("value_source")
|
||||
if (validate_all or source != old_source) and source is not None:
|
||||
validate_source(source, "value_source")
|
||||
|
||||
|
||||
def validate_marker_light_entities(
|
||||
@@ -1073,6 +1090,7 @@ MAX_OPEN_TO = 50
|
||||
MAX_CONTROLS = 200
|
||||
MAX_PDFS = 50
|
||||
MAX_KNOWN_DEVICES = 20000
|
||||
MAX_MARKER_AREA_SNAPSHOT = MAX_KNOWN_DEVICES
|
||||
MAX_TEXT = 500 # names, models, ids
|
||||
MAX_DESCRIPTION = 4000
|
||||
MAX_URL = 2000
|
||||
@@ -1086,6 +1104,7 @@ CELL_CM_MIN = 0.1
|
||||
CELL_CM_MAX = 1000.0
|
||||
|
||||
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
|
||||
_NONEMPTY_TEXT = vol.All(str, vol.Length(min=1, max=MAX_TEXT))
|
||||
_TEXT_OR_NONE = vol.Any(None, _TEXT)
|
||||
_URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||
|
||||
@@ -1362,6 +1381,7 @@ DECOR_SCHEMA = vol.Any(
|
||||
vol.Required("symbol"): _FURN_SYMBOL,
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("w"): _FURN_SIZE, vol.Required("h"): _FURN_SIZE,
|
||||
vol.Optional("flip_h"): bool, vol.Optional("flip_v"): bool,
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
@@ -1415,7 +1435,7 @@ def _room_draft_segments(value: dict) -> dict:
|
||||
"""An open draft has exactly one thickness per consecutive edge."""
|
||||
if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1):
|
||||
raise vol.Invalid("room draft segments must match consecutive point pairs")
|
||||
if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:])):
|
||||
if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:], strict=False)): # #42 B905: adjacent pairs
|
||||
raise vol.Invalid("room draft consecutive points must differ")
|
||||
return value
|
||||
|
||||
@@ -1678,7 +1698,7 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("model"): _TEXT_OR_NONE,
|
||||
vol.Optional("link"): vol.Any(None, _URL),
|
||||
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", "cover", None),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", "none", "cover", None),
|
||||
# the 'run' target: only the runnable domains, nothing else is callable
|
||||
vol.Optional("tap_target"): vol.Any(
|
||||
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
|
||||
@@ -1743,6 +1763,12 @@ MARKER_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
),
|
||||
# Explicit source for display:value. Semantic delta validation shares
|
||||
# the badge source contract while keeping untouched future literals.
|
||||
vol.Optional("value_source"): vol.Any(
|
||||
None,
|
||||
vol.Schema({}, extra=vol.ALLOW_EXTRA),
|
||||
),
|
||||
# climate current_temperature: badge + room-average vote (off unless True)
|
||||
vol.Optional("use_climate_temp"): vol.Any(bool, None),
|
||||
vol.Optional("room_id"): vol.Any(str, None),
|
||||
@@ -1885,6 +1911,18 @@ CONFIG_SCHEMA = vol.All(
|
||||
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,
|
||||
# #377: the Background editor's default style for new decor
|
||||
# objects; absence of the key means the built-in default
|
||||
vol.Optional("decor_default_style"): vol.Schema(
|
||||
{
|
||||
vol.Optional("color"): _COLOR,
|
||||
vol.Optional("opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
vol.Optional("width_cm"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=100)),
|
||||
vol.Optional("fill"): bool,
|
||||
vol.Optional("fill_color"): _COLOR,
|
||||
vol.Optional("fill_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
}
|
||||
),
|
||||
# sun on the plan (docs/SUN.md): global defaults
|
||||
vol.Optional("north_deg"): _north_deg,
|
||||
vol.Optional("bg_mode"): _BG_MODE,
|
||||
@@ -1895,6 +1933,18 @@ CONFIG_SCHEMA = vol.All(
|
||||
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("marker_area_snapshot"): vol.All(
|
||||
vol.Schema({
|
||||
_NONEMPTY_TEXT: vol.Schema({
|
||||
vol.Required("binding"): vol.All(
|
||||
_NONEMPTY_TEXT,
|
||||
vol.Match(r"^(?:device|entity):.+$"),
|
||||
),
|
||||
vol.Required("area"): _NONEMPTY_TEXT,
|
||||
}),
|
||||
}),
|
||||
vol.Length(max=MAX_MARKER_AREA_SNAPSHOT),
|
||||
),
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
|
||||
@@ -15,7 +15,6 @@ from typing import Any
|
||||
|
||||
from .coordinate_canonicalization import canonicalize_config_geometry
|
||||
|
||||
|
||||
WALL_SEGMENT_MODEL_VERSION = 9
|
||||
GRID_STEP_N = 1 / 240
|
||||
EPS = 1e-9
|
||||
@@ -192,10 +191,11 @@ def _atomize(
|
||||
float(span["b"][0]), float(span["b"][1]),
|
||||
])
|
||||
if not legacy_segments:
|
||||
linked = lambda first, second: (
|
||||
str(second.get("id", "")) in (first.get("open_to") or [])
|
||||
or str(first.get("id", "")) in (second.get("open_to") or [])
|
||||
)
|
||||
def linked(first: dict, second: dict) -> bool: # #42 E731
|
||||
return (
|
||||
str(second.get("id", "")) in (first.get("open_to") or [])
|
||||
or str(first.get("id", "")) in (second.get("open_to") or [])
|
||||
)
|
||||
for first_index, first in enumerate(rooms):
|
||||
for second in rooms[first_index + 1:]:
|
||||
if not linked(first, second):
|
||||
@@ -223,7 +223,6 @@ def _atomize(
|
||||
])
|
||||
for segment in canonical_zero_segments:
|
||||
global_breaks.extend((segment[:2], segment[2:]))
|
||||
zero_segments = canonical_zero_segments + legacy_segments
|
||||
|
||||
# #316 §3.1: a legacy open_spans/open_to cut never zeroes the atom that
|
||||
# carries an existing contour opening; its edges become atom boundaries so
|
||||
@@ -348,10 +347,15 @@ def _atomize(
|
||||
parent_index = parents[index]
|
||||
atom["parent_keys"].add(_wall_key(original[parent_index], original[(parent_index + 1) % len(original)]))
|
||||
midpoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
|
||||
covered_by = lambda cuts: any(
|
||||
_distance_to_segment(midpoint, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
|
||||
for cut in cuts
|
||||
)
|
||||
|
||||
# #42 B023/E731: the loop variable is bound as a default — the
|
||||
# closure is only called inside this iteration, but the binding
|
||||
# makes that safety structural instead of incidental.
|
||||
def covered_by(cuts: list[list[float]], *, midpoint: list[float] = midpoint) -> bool:
|
||||
return any(
|
||||
_distance_to_segment(midpoint, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
|
||||
for cut in cuts
|
||||
)
|
||||
# Canonical cm:0 atoms stay zero; a LEGACY cut spares the atom
|
||||
# that carries an opening (#316 §3.1).
|
||||
atom["zero_wall"] = atom["zero_wall"] or covered_by(canonical_zero_segments) or (
|
||||
@@ -547,7 +551,11 @@ def _host_openings(
|
||||
continue
|
||||
raise WallSegmentMigrationError("opening-host", str(opening.get("id", ""))) from None
|
||||
|
||||
def eligible(segment: dict[str, Any]) -> bool:
|
||||
# #42 B023: loop variables are bound as defaults (see covered_by).
|
||||
def eligible(
|
||||
segment: dict[str, Any], *,
|
||||
centre: list[float] = centre, angle: float = angle, half: float = half,
|
||||
) -> bool:
|
||||
if float(segment.get("cm", 0)) <= 0:
|
||||
return False
|
||||
t = _project_t(centre, segment["a"], segment["b"])
|
||||
@@ -559,7 +567,10 @@ def _host_openings(
|
||||
and half >= 0 and t * span - half >= -EPS
|
||||
and t * span + half <= span + EPS)
|
||||
|
||||
def materialize(carrier: dict[str, Any]) -> None:
|
||||
def materialize(
|
||||
carrier: dict[str, Any], *,
|
||||
opening: dict[str, Any] = opening, centre: list[float] = centre,
|
||||
) -> None:
|
||||
opening["host"] = {
|
||||
"kind": "wall", "id": carrier["id"],
|
||||
"t": max(0.0, min(1.0, _project_t(centre, carrier["a"], carrier["b"]))),
|
||||
@@ -581,15 +592,18 @@ def _host_openings(
|
||||
if not initial_migration:
|
||||
raise WallSegmentMigrationError("opening-host", str(opening.get("id", "")))
|
||||
|
||||
def pick(pool: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
def pick(
|
||||
pool: list[dict[str, Any]], *,
|
||||
current: dict[str, Any] | None = current, centre: list[float] = centre,
|
||||
) -> dict[str, Any] | None:
|
||||
if not pool:
|
||||
return None
|
||||
if current is not None and any(item is current for item in pool):
|
||||
return current
|
||||
return sorted(pool, key=lambda segment: (
|
||||
_distance_to_segment(centre, segment["a"], segment["b"]),
|
||||
-float(segment.get("cm", 0)),
|
||||
str(segment.get("id", "")),
|
||||
return sorted(pool, key=lambda candidate: (
|
||||
_distance_to_segment(centre, candidate["a"], candidate["b"]),
|
||||
-float(candidate.get("cm", 0)),
|
||||
str(candidate.get("id", "")),
|
||||
))[0]
|
||||
|
||||
# #316 §3.2 tie-break. No distant fallback pool (CODE-REVIEW-316-r1
|
||||
|
||||
@@ -1,36 +1,50 @@
|
||||
"""House Plan WS commands: layout, space configuration, plan uploads."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import UTC, datetime
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.const import __version__ as HA_VERSION
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .auth import may_write
|
||||
from .const import (
|
||||
CONTENT_URL,
|
||||
DEFAULT_CONFIG,
|
||||
DOMAIN,
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
|
||||
EXPORT_VERSION,
|
||||
FILES_DIR,
|
||||
MAX_PLANS_BYTES,
|
||||
MAX_PLANS_FILES,
|
||||
MAX_PLANS_LISTED,
|
||||
MAX_SIGN_PATHS,
|
||||
PLANS_DIR, PLANS_URL,
|
||||
MAX_SUPPORT_CONTACT_CODEPOINTS,
|
||||
MAX_SUPPORT_MESSAGE_CODEPOINTS,
|
||||
MAX_SUPPORT_PREVIEWS_PER_USER,
|
||||
MAX_SUPPORT_PREVIEWS_TOTAL,
|
||||
PLAN_MODEL_VERSION,
|
||||
PLANS_DIR,
|
||||
PLANS_URL,
|
||||
SUPPORT_PREVIEW_TTL_S,
|
||||
VERSION,
|
||||
)
|
||||
from .coordinate_canonicalization import (
|
||||
canonicalize_config_geometry,
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .import_export import (
|
||||
ImportFailure,
|
||||
content_manifest,
|
||||
@@ -40,43 +54,66 @@ from .import_export import (
|
||||
prepare_apply,
|
||||
revalidate_candidate,
|
||||
)
|
||||
from .junction_limits import JunctionLimitError, validate_junction_limits
|
||||
from .plans import (
|
||||
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
|
||||
plan_basename, plan_refs, reserve_filename,
|
||||
QuotaError,
|
||||
check_quota,
|
||||
collect_attachments,
|
||||
collect_plans,
|
||||
is_plan_file,
|
||||
plan_basename,
|
||||
plan_refs,
|
||||
reserve_filename,
|
||||
)
|
||||
from .projection import project_config, project_layout
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .store import (
|
||||
LAYOUT_STORE_CORE_KEYS,
|
||||
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
|
||||
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
|
||||
HouseplanData,
|
||||
async_save_config_state,
|
||||
async_save_layout_state,
|
||||
get_data,
|
||||
get_entry,
|
||||
)
|
||||
from .store import (
|
||||
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
|
||||
)
|
||||
from .store import (
|
||||
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
|
||||
)
|
||||
from .support_package import SupportPackageError, build_support_package
|
||||
from .support_transport import SupportTransportError, async_submit_report
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA,
|
||||
LAYOUT_SCHEMA,
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS,
|
||||
POS_SCHEMA,
|
||||
MarkerControlError,
|
||||
OpeningPassageError,
|
||||
PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
WallModelClientOutdatedError,
|
||||
sanitize_filename,
|
||||
valid_space_id,
|
||||
validate_marker_controls,
|
||||
validate_marker_light_entities,
|
||||
validate_marker_value_badges,
|
||||
validate_opening_passages,
|
||||
validate_partition_opening_hosts,
|
||||
validate_wall_model_transition,
|
||||
)
|
||||
from .virtual_lights import (
|
||||
EVENT_VIRTUAL_LIGHT_UPDATED,
|
||||
async_toggle_virtual_light,
|
||||
async_virtual_light_snapshot,
|
||||
)
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .projection import project_config, project_layout
|
||||
from .wall_segment_model import (
|
||||
WALL_SEGMENT_MODEL_VERSION,
|
||||
WallSegmentMigrationError,
|
||||
commit_wall_segment_model,
|
||||
)
|
||||
from .junction_limits import JunctionLimitError, validate_junction_limits
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
|
||||
PartitionOpeningHostError, PartitionOpeningJambMarginError,
|
||||
WallModelClientOutdatedError, sanitize_filename,
|
||||
validate_opening_passages, validate_partition_opening_hosts,
|
||||
validate_marker_controls, validate_marker_light_entities,
|
||||
validate_marker_value_badges, validate_wall_model_transition, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -161,6 +198,9 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_export_create)
|
||||
websocket_api.async_register_command(hass, ws_import_revalidate)
|
||||
websocket_api.async_register_command(hass, ws_import_apply)
|
||||
websocket_api.async_register_command(hass, ws_support_preview)
|
||||
websocket_api.async_register_command(hass, ws_support_preview_discard)
|
||||
websocket_api.async_register_command(hass, ws_support_submit)
|
||||
|
||||
|
||||
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
||||
@@ -189,6 +229,35 @@ def _send_import_error(connection, msg_id: int, err: ImportFailure) -> None:
|
||||
connection.send_error(msg_id, err.code, err.message)
|
||||
|
||||
|
||||
def _send_support_error(connection, msg_id: int, code: str) -> None:
|
||||
"""Return only a stable code; support data never enters an error string."""
|
||||
connection.send_error(msg_id, code, code)
|
||||
|
||||
|
||||
def _support_monotonic() -> float:
|
||||
"""Clock seam for deterministic support-preview lifecycle tests."""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _prune_support_previews(rt: HouseplanData, now: float | None = None) -> None:
|
||||
current = _support_monotonic() if now is None else now
|
||||
for token, preview in list(rt.support_previews.items()):
|
||||
if float(preview.get("expires", 0)) <= current:
|
||||
rt.support_previews.pop(token, None)
|
||||
|
||||
|
||||
def _support_repairs(hass: HomeAssistant) -> list[dict[str, Any]]:
|
||||
"""Expose stable House Plan repair families, never raw issue ids/placeholders."""
|
||||
counts: Counter[str] = Counter()
|
||||
registry = ir.async_get(hass)
|
||||
for domain, issue_id in list(registry.issues):
|
||||
if domain != DOMAIN:
|
||||
continue
|
||||
if str(issue_id).startswith("broken_plan_"):
|
||||
counts["broken_plan"] += 1
|
||||
return [{"code": code, "count": count} for code, count in sorted(counts.items())]
|
||||
|
||||
|
||||
def _layout_metadata(stored: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return the exact non-layout portion of a layout-store document."""
|
||||
return {
|
||||
@@ -261,7 +330,7 @@ async def _commit_import_pair(
|
||||
try:
|
||||
await _persist_pair_intent(rt, rollback)
|
||||
await _converge_pair(rt, rollback)
|
||||
except Exception as rollback_error: # noqa: BLE001
|
||||
except Exception as rollback_error: # noqa: BLE001 - a failed rollback must not mask the original commit error
|
||||
_LOGGER.exception(
|
||||
"House Plan import rollback could not finish; rollback intent remains for setup"
|
||||
)
|
||||
@@ -312,7 +381,7 @@ async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
except ImportFailure as err:
|
||||
_send_import_error(connection, msg["id"], err)
|
||||
return
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception: # noqa: BLE001 - defensive: a listener must never break the write path
|
||||
_LOGGER.exception("House Plan export failed")
|
||||
connection.send_error(msg["id"], "invalid_config", "Could not create export")
|
||||
return
|
||||
@@ -486,7 +555,7 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
except ImportFailure as err:
|
||||
_send_import_error(connection, msg["id"], err)
|
||||
return
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception: # noqa: BLE001 - defensive: a listener must never break the write path
|
||||
_LOGGER.exception("House Plan import commit failed")
|
||||
connection.send_error(msg["id"], "commit_failed", "Import commit failed")
|
||||
return
|
||||
@@ -594,8 +663,9 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
connection.send_error(
|
||||
msg["id"], "conflict",
|
||||
f"Layout revision is required; reload the layout "
|
||||
f"(current rev {current_rev})",
|
||||
f"Layout revision is required; reload the layout, or — for "
|
||||
f"external clients — include expected_rev from "
|
||||
f"houseplan/layout/get (current rev {current_rev})",
|
||||
)
|
||||
return
|
||||
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||
@@ -1322,8 +1392,9 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
connection.send_error(
|
||||
msg["id"], "conflict",
|
||||
f"Configuration revision is required; reload the configuration "
|
||||
f"(current rev {current_rev})",
|
||||
f"Configuration revision is required; reload the configuration, "
|
||||
f"or — for external clients — include expected_rev from "
|
||||
f"houseplan/config/get (current rev {current_rev})",
|
||||
)
|
||||
return
|
||||
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||
@@ -1616,7 +1687,7 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
except vol.Invalid as err:
|
||||
connection.send_error(msg["id"], "invalid_config", str(err))
|
||||
return
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception: # noqa: BLE001 - best-effort backup pruning never fails the request
|
||||
_LOGGER.exception("House Plan space delete failed")
|
||||
connection.send_error(msg["id"], "commit_failed", "Space delete failed")
|
||||
return
|
||||
@@ -2008,3 +2079,239 @@ async def ws_trail_delete(hass: HomeAssistant, connection: websocket_api.ActiveC
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
removed = await rec.async_delete(msg["marker_id"]) if rec else False
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
|
||||
|
||||
# ---------------- private support package / feedback (#43) ----------------
|
||||
|
||||
|
||||
def _support_string(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise vol.Invalid("support field must be a string")
|
||||
return value
|
||||
|
||||
|
||||
def _support_bool(value: object) -> bool:
|
||||
if not isinstance(value, bool):
|
||||
raise vol.Invalid("support field must be a boolean")
|
||||
return value
|
||||
|
||||
|
||||
def _support_browser_major(value: object) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 999:
|
||||
raise vol.Invalid("browser_major must be an integer in 0..999")
|
||||
return value
|
||||
|
||||
|
||||
_SUPPORT_TOKEN = vol.All(
|
||||
_support_string, vol.Length(min=16, max=128), vol.Match(r"^[0-9a-f]+$")
|
||||
)
|
||||
_SUPPORT_ID = vol.All(
|
||||
_support_string, vol.Length(min=8, max=128), vol.Match(r"^[A-Za-z0-9_.:-]+$")
|
||||
)
|
||||
_SUPPORT_VERSION = vol.All(
|
||||
_support_string, vol.Length(min=1, max=32), vol.Match(r"^[0-9A-Za-z._+-]+$")
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/support/preview",
|
||||
vol.Required("card_version"): _SUPPORT_VERSION,
|
||||
vol.Required("browser_family"): vol.In(["chromium", "firefox", "webkit", "unknown"]),
|
||||
vol.Required("browser_major"): _support_browser_major,
|
||||
vol.Required("language"): vol.In(["en", "ru", "de", "fr"]),
|
||||
vol.Required("coarse_pointer"): _support_bool,
|
||||
vol.Required("hover_capable"): _support_bool,
|
||||
vol.Required("registry_access"): vol.In(["full", "partial", "unavailable"]),
|
||||
vol.Required("registry_age_bucket"): vol.In(["fresh", "stale", "unknown"]),
|
||||
vol.Required("draft_id"): _SUPPORT_ID,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_support_preview(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Build and retain one exact, already-sanitized support-package snapshot."""
|
||||
if not _check_write(hass, connection):
|
||||
_send_support_error(connection, msg["id"], "unauthorized")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
|
||||
# The write lock guarantees that config/layout and both revisions describe
|
||||
# one accepted pair. Projection happens after the deep copy, so the lock is
|
||||
# not held while JSON is built and a large plan cannot stall an editor save.
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config = copy.deepcopy({**DEFAULT_CONFIG, **(config_data.get("config") or {})})
|
||||
layout = copy.deepcopy(layout_data.get("layout") or {})
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
|
||||
# A package must not turn malformed stored data into an apparently valid
|
||||
# diagnostic artifact. Validation runs on disposable copies because the
|
||||
# schemas canonicalize coordinates.
|
||||
repairs = _support_repairs(hass)
|
||||
|
||||
def _build_snapshot() -> tuple[bytes, dict[str, Any]]:
|
||||
CONFIG_SCHEMA(copy.deepcopy(config))
|
||||
LAYOUT_SCHEMA(copy.deepcopy(layout))
|
||||
return build_support_package(
|
||||
config,
|
||||
layout,
|
||||
config_rev=config_rev,
|
||||
layout_rev=layout_rev,
|
||||
card_version=msg["card_version"],
|
||||
integration_version=VERSION,
|
||||
home_assistant_version=HA_VERSION,
|
||||
runtime={
|
||||
"browser_family": msg["browser_family"],
|
||||
"browser_major": msg["browser_major"],
|
||||
"language": msg["language"],
|
||||
"coarse_pointer": msg["coarse_pointer"],
|
||||
"hover_capable": msg["hover_capable"],
|
||||
"registry_access": msg["registry_access"],
|
||||
"registry_age_bucket": msg["registry_age_bucket"],
|
||||
},
|
||||
repairs=repairs,
|
||||
)
|
||||
|
||||
try:
|
||||
# The maximum valid package is deliberately large. Keep validation,
|
||||
# pseudonymisation and canonical JSON away from Home Assistant's event
|
||||
# loop while retaining the coherent copies captured under write_lock.
|
||||
raw, preview = await hass.async_add_executor_job(_build_snapshot)
|
||||
except (vol.Invalid, ValueError, TypeError, OverflowError) as error:
|
||||
code = error.code if isinstance(error, SupportPackageError) else "support_rejected"
|
||||
_send_support_error(connection, msg["id"], code)
|
||||
return
|
||||
|
||||
now = _support_monotonic()
|
||||
owner = _connection_user_id(connection)
|
||||
_prune_support_previews(rt, now)
|
||||
# A refresh replaces only this card instance's draft. Other cards keep
|
||||
# their token and exact bytes.
|
||||
for old_token, record in list(rt.support_previews.items()):
|
||||
if record.get("owner") == owner and record.get("draft_id") == msg["draft_id"]:
|
||||
rt.support_previews.pop(old_token, None)
|
||||
owned = sum(1 for item in rt.support_previews.values() if item.get("owner") == owner)
|
||||
if owned >= MAX_SUPPORT_PREVIEWS_PER_USER or len(rt.support_previews) >= MAX_SUPPORT_PREVIEWS_TOTAL:
|
||||
_send_support_error(connection, msg["id"], "support_rate_limited")
|
||||
return
|
||||
token = secrets.token_hex(24)
|
||||
expires = now + SUPPORT_PREVIEW_TTL_S
|
||||
rt.support_previews[token] = {
|
||||
"owner": owner,
|
||||
"draft_id": msg["draft_id"],
|
||||
"created": now,
|
||||
"expires": expires,
|
||||
"bytes": raw,
|
||||
"sha256": preview["sha256"],
|
||||
"versions": preview["versions"],
|
||||
}
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"token": token,
|
||||
"expires_in": SUPPORT_PREVIEW_TTL_S,
|
||||
"size": preview["size"],
|
||||
"sha256": preview["sha256"],
|
||||
"spaces": preview["spaces"],
|
||||
"format": preview["format"],
|
||||
"version": preview["version"],
|
||||
"text": raw.decode("utf-8"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/support/preview/discard",
|
||||
vol.Required("token"): _SUPPORT_TOKEN,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_support_preview_discard(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Idempotently discard only a preview owned by this HA user."""
|
||||
if not _check_write(hass, connection):
|
||||
_send_support_error(connection, msg["id"], "unauthorized")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
_prune_support_previews(rt)
|
||||
preview = rt.support_previews.get(msg["token"])
|
||||
if preview is not None and preview.get("owner") != _connection_user_id(connection):
|
||||
_send_support_error(connection, msg["id"], "support_preview_expired")
|
||||
return
|
||||
rt.support_previews.pop(msg["token"], None)
|
||||
connection.send_result(msg["id"], {"ok": True})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/support/submit",
|
||||
vol.Required("message"): _support_string,
|
||||
vol.Optional("contact", default=""): _support_string,
|
||||
vol.Optional("preview_token"): _SUPPORT_TOKEN,
|
||||
vol.Required("idempotency_key"): _SUPPORT_ID,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_support_submit(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Submit text and, when selected, the exact previewed package bytes."""
|
||||
if not _check_write(hass, connection):
|
||||
_send_support_error(connection, msg["id"], "unauthorized")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
message = msg["message"].strip()
|
||||
contact = msg.get("contact", "").strip()
|
||||
if not message or len(message) > MAX_SUPPORT_MESSAGE_CODEPOINTS:
|
||||
_send_support_error(connection, msg["id"], "support_invalid_message")
|
||||
return
|
||||
if len(contact) > MAX_SUPPORT_CONTACT_CODEPOINTS:
|
||||
_send_support_error(connection, msg["id"], "support_rejected")
|
||||
return
|
||||
|
||||
_prune_support_previews(rt)
|
||||
token = msg.get("preview_token")
|
||||
preview = rt.support_previews.get(token) if token else None
|
||||
if token and (preview is None or preview.get("owner") != _connection_user_id(connection)):
|
||||
_send_support_error(connection, msg["id"], "support_preview_expired")
|
||||
return
|
||||
attachment = preview.get("bytes") if preview else None
|
||||
versions = preview.get("versions") if preview else {
|
||||
"card": VERSION,
|
||||
"integration": VERSION,
|
||||
"home_assistant": HA_VERSION,
|
||||
"model": PLAN_MODEL_VERSION,
|
||||
"export_schema": EXPORT_VERSION,
|
||||
}
|
||||
try:
|
||||
report_id = await async_submit_report(
|
||||
hass,
|
||||
message=message,
|
||||
contact=contact,
|
||||
versions=versions,
|
||||
idempotency_key=msg["idempotency_key"],
|
||||
attachment=attachment,
|
||||
attachment_sha256=preview.get("sha256") if preview else None,
|
||||
filename_token=token or msg["idempotency_key"].lower().replace("_", "-"),
|
||||
)
|
||||
except SupportTransportError as error:
|
||||
_send_support_error(connection, msg["id"], error.code)
|
||||
return
|
||||
if token:
|
||||
# Retry after a timeout keeps the token; only a confirmed delivery
|
||||
# consumes it. The relay's idempotency record handles uncertain first
|
||||
# attempts with the same frontend key.
|
||||
rt.support_previews.pop(token, None)
|
||||
connection.send_result(msg["id"], {"report_id": report_id})
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// #39: decode/downscale calibration matrix behind the backdrop guard
|
||||
// thresholds (docs/specs/039-large-backdrops.md §Research). Not a CI gate —
|
||||
// rerun by hand when recalibrating WARN_DECODED_BYTES / HARD_DIMENSION.
|
||||
// PLAYWRIGHT_BROWSERS_PATH=... node demo/benchmark_backdrop_decode.mjs
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const page = await (await browser.newContext()).newPage();
|
||||
const session = await page.context().newCDPSession(page);
|
||||
await page.goto('about:blank');
|
||||
const metrics = async () => {
|
||||
const m = await session.send('Performance.getMetrics');
|
||||
const get = (n) => m.metrics.find((x) => x.name === n)?.value || 0;
|
||||
return { jsHeap: Math.round(get('JSHeapUsedSize') / 1048576) };
|
||||
};
|
||||
const cases = [
|
||||
{ mp: 4, alpha: false }, { mp: 8, alpha: false }, { mp: 16, alpha: false },
|
||||
{ mp: 32, alpha: false }, { mp: 32, alpha: true }, { mp: 64, alpha: false },
|
||||
{ mp: 100, alpha: false }, { mp: 165, alpha: false },
|
||||
];
|
||||
for (const c of cases) {
|
||||
try {
|
||||
const r = await page.evaluate(async ({ mp, alpha }) => {
|
||||
const side = Math.round(Math.sqrt(mp * 1e6));
|
||||
const w = side, h = side;
|
||||
// сгенерировать PNG нужного размера
|
||||
const cv = new OffscreenCanvas(w, h);
|
||||
const ctx = cv.getContext('2d');
|
||||
const g = ctx.createLinearGradient(0, 0, w, h);
|
||||
g.addColorStop(0, alpha ? 'rgba(200,30,30,0.5)' : '#c81e1e');
|
||||
g.addColorStop(1, '#1e50c8');
|
||||
ctx.fillStyle = g; ctx.fillRect(0, 0, w, h);
|
||||
for (let i = 0; i < 200; i++) { ctx.fillStyle = `hsl(${i*7},60%,50%)`; ctx.fillRect((i*97)%w, (i*61)%h, 40, 40); }
|
||||
const blob = await cv.convertToBlob({ type: 'image/png' });
|
||||
// decode: createImageBitmap
|
||||
const t0 = performance.now();
|
||||
const bmp = await createImageBitmap(blob);
|
||||
const decodeMs = Math.round(performance.now() - t0);
|
||||
// downscale до 4096 по длинной стороне
|
||||
const target = 4096;
|
||||
const scale = Math.min(1, target / Math.max(bmp.width, bmp.height));
|
||||
const t1 = performance.now();
|
||||
const out = new OffscreenCanvas(Math.round(bmp.width*scale), Math.round(bmp.height*scale));
|
||||
out.getContext('2d').drawImage(bmp, 0, 0, out.width, out.height);
|
||||
const outBlob = await out.convertToBlob({ type: 'image/jpeg', quality: 0.9 });
|
||||
const downMs = Math.round(performance.now() - t1);
|
||||
bmp.close();
|
||||
return { w, h, fileMB: +(blob.size/1048576).toFixed(1), rgbaMB: Math.round(w*h*4/1048576),
|
||||
decodeMs, downMs, outMB: +(outBlob.size/1048576).toFixed(1) };
|
||||
}, c);
|
||||
const m = await metrics();
|
||||
console.log(JSON.stringify({ mp: c.mp, alpha: c.alpha, ...r, jsHeapMB: m.jsHeap }));
|
||||
} catch (e) {
|
||||
console.log(JSON.stringify({ mp: c.mp, alpha: c.alpha, FAIL: String(e).slice(0, 120) }));
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
@@ -7,12 +7,19 @@ import { launch } from './serve.mjs';
|
||||
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
|
||||
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
|
||||
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
import { assertCardContract, GLOW_CARD_CONTRACT } from './performance/card-contract.mjs';
|
||||
import {
|
||||
assertCardContract, GLOW_CARD_CONTRACT, SPACE_GLOW_CARD_CONTRACT,
|
||||
} from './performance/card-contract.mjs';
|
||||
|
||||
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const profile = valueArg('profile') || 'large-light-blend-v1';
|
||||
if (!['large-light-blend-v1', 'large-house-glow-overlay-v1'].includes(profile))
|
||||
if (![
|
||||
'large-light-blend-v1', 'large-house-glow-overlay-v1',
|
||||
'large-space-card-default-v1', 'large-space-card-glow-v1',
|
||||
].includes(profile))
|
||||
throw new Error(`unknown Glow profile: ${profile}`);
|
||||
const staticCardProfile = profile.startsWith('large-space-card-');
|
||||
const staticGlowProfile = profile === 'large-space-card-glow-v1';
|
||||
const parsedSamples = Number(valueArg('samples'));
|
||||
const parsedWarmups = Number(valueArg('warmups'));
|
||||
const samples = Math.max(1, Math.min(20, Number.isFinite(parsedSamples) && parsedSamples > 0 ? parsedSamples : 7));
|
||||
@@ -196,22 +203,37 @@ try {
|
||||
config: { unit_system: { length: 'km' } },
|
||||
});
|
||||
const cacheSnapshot = (card) => ({
|
||||
cleanFloor: card._cleanFloorCache?.size ?? 0,
|
||||
glowClip: card._glowClipCache?.size ?? 0,
|
||||
wallUnion: card._wallUnionCache ? 1 : 0,
|
||||
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
...(card.localName === 'houseplan-space-card' ? {
|
||||
// #380: the previous stable predates the shared static-card Glow
|
||||
// runtime. Its default profile still has a valid empty cache, and
|
||||
// the opt-in Glow profile is explicitly bootstrap-compared below.
|
||||
glowClip: card._glowRuntimeState?.clipCache?.size ?? 0,
|
||||
} : {
|
||||
cleanFloor: card._cleanFloorCache?.size ?? 0,
|
||||
glowClip: card._glowClipCache?.size ?? 0,
|
||||
wallUnion: card._wallUnionCache ? 1 : 0,
|
||||
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
}),
|
||||
});
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
const host = document.getElementById('host');
|
||||
const result = { sample, longTasks: {}, renderCounts: {}, poolCounts: {} };
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', title: `Glow ${profile}`, icon_size: 2.4 });
|
||||
const staticCardProfile = profile.startsWith('large-space-card-');
|
||||
const card = document.createElement(staticCardProfile ? 'houseplan-space-card' : 'houseplan-card');
|
||||
card.setConfig(staticCardProfile ? {
|
||||
type: 'custom:houseplan-space-card', space: fixture.config.spaces[0].id,
|
||||
show_button: false, light_pools: profile === 'large-space-card-glow-v1',
|
||||
} : {
|
||||
type: 'custom:houseplan-card', title: `Glow ${profile}`, icon_size: 2.4,
|
||||
});
|
||||
host.replaceChildren(card);
|
||||
card.hass = hassFor(statesFor(1));
|
||||
await until(() => staticCardProfile
|
||||
? !!card._snap && card._devices?.length > 0
|
||||
: card._loadOk && card._devices?.length === fixture.deviceCount);
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
await until(() => card._loadOk && card._devices?.length === fixture.deviceCount);
|
||||
if ('_glowScreenBlend' in card) {
|
||||
const probeDeadline = performance.now() + 2500;
|
||||
while (!card._glowScreenBlend && performance.now() < probeDeadline)
|
||||
@@ -259,7 +281,10 @@ try {
|
||||
result.renderedDevices = card._devices?.length ?? 0;
|
||||
result.screenBlend = card._glowScreenBlend === true;
|
||||
return result;
|
||||
}, { fixture, profile, sample, cardContract: GLOW_CARD_CONTRACT });
|
||||
}, {
|
||||
fixture, profile, sample,
|
||||
cardContract: staticCardProfile ? SPACE_GLOW_CARD_CONTRACT : GLOW_CARD_CONTRACT,
|
||||
});
|
||||
const captureStarted = performance.now();
|
||||
await page.screenshot({ type: 'png' });
|
||||
row.screenshotCaptureMs = Number((performance.now() - captureStarted).toFixed(2));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { dirname, resolve } from 'node:path';
|
||||
import { launch } from './serve.mjs';
|
||||
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
|
||||
import { ensureHarnessEditorRuntime } from './editor-runtime-compat.mjs';
|
||||
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
|
||||
import { assertCardContract, LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
|
||||
|
||||
@@ -52,6 +53,9 @@ await page.addStyleTag({
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
|
||||
});
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpEnsureHarnessEditorRuntime = ${ensureHarnessEditorRuntime.toString()};`,
|
||||
});
|
||||
const chromium = await browser.version();
|
||||
let buildFingerprint;
|
||||
try {
|
||||
@@ -187,6 +191,11 @@ try {
|
||||
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
}
|
||||
card.hass = hassFor(fixture.states);
|
||||
// launch() warms its own bootstrap card, but each measured sample creates
|
||||
// a fresh card. Current lazy bundles therefore need their own controller
|
||||
// construction; monolithic stable bundles remain immediate (#380).
|
||||
if (!await window.__hpEnsureHarnessEditorRuntime(card))
|
||||
throw new Error('large-house editor runtime did not preload');
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
if (requiresIsometric && (typeof card._setProjection !== 'function'
|
||||
|| !(card._isoGeometryCache instanceof Map))) {
|
||||
@@ -346,7 +355,10 @@ try {
|
||||
target: null,
|
||||
};
|
||||
card._rszEdgeDown(quietEvent, room.id, 1);
|
||||
const plan = card._resize?.plan;
|
||||
// #380: the previous stable owns the active session directly; current
|
||||
// candidates own it through ResizeController. The explicit contract
|
||||
// above requires one of these shapes before measurements begin.
|
||||
const plan = card._resize?.plan || card._rszDrag?.plan;
|
||||
if (!plan) throw new Error('large-house resize plan was not created');
|
||||
const target = [
|
||||
plan.a[0] + plan.n[0] * card._gridPitch,
|
||||
|
||||
@@ -86,6 +86,15 @@ 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}`);
|
||||
const settleCamera = async () => {
|
||||
const started = performance.now();
|
||||
do { await frame(); }
|
||||
while (card._cameraTransition?.active && performance.now() - started < 1200);
|
||||
if (card._cameraTransition?.active)
|
||||
throw new Error(`documentation camera did not settle: ${current.id}`);
|
||||
// One quiet pair proves that the settled reactive frame reached layout.
|
||||
await frame();
|
||||
};
|
||||
|
||||
if (current.title) {
|
||||
card.setConfig({ ...card._config, title: current.title });
|
||||
@@ -142,12 +151,102 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
|
||||
throw new Error('documentation viewport does not show the device presentation preview');
|
||||
}
|
||||
|
||||
await settleCamera();
|
||||
return { dialog: !!card.renderRoot.querySelector('hp-dialog') };
|
||||
}, scenario);
|
||||
|
||||
mkdirSync(OUTPUT, { recursive: true });
|
||||
|
||||
const { page, browser } = await launch();
|
||||
// Растеризация закрепляется теми же тремя флагами, что у golden
|
||||
// (`demo/golden/run.mjs`), и по той же причине — только golden их имел с самого
|
||||
// начала, а съёмка документации запускалась без единого (#410).
|
||||
//
|
||||
// Измерено на dev SHA 184e0098: два прогона канонического workflow на одном и
|
||||
// том же коммите, одном Chromium 151.0.7922.34 и одном oxipng 10.2.0 дали три
|
||||
// разошедшихся кадра из десяти (06-device-editor, 08-room-card,
|
||||
// 09-device-info). Дельта — единицы уровней в RGB на сглаженных границах, alpha
|
||||
// не менялась: подпись субпиксельного сглаживания, а не изменения продукта.
|
||||
//
|
||||
// `--disable-lcd-text` убирает субпиксельное сглаживание (оно и плавало),
|
||||
// `--font-render-hinting=none` снимает зависимость от хинтинга,
|
||||
// `--force-color-profile=srgb` фиксирует профиль. `reducedMotion: 'reduce'`
|
||||
// добавлен к `animations: 'disabled'` у самого скриншота: первое гасит анимации
|
||||
// в CSS, второе — уже начатые переходы на момент съёмки.
|
||||
const DETERMINISTIC_ARGS = [
|
||||
'--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text',
|
||||
];
|
||||
/**
|
||||
* Режим замера стабильности (#410): `node demo/docs/capture.mjs --stability=3`.
|
||||
*
|
||||
* Отвечает на вопрос, который иначе решается гаданием: плавает ли кадр ВНУТРИ
|
||||
* одного состояния страницы или разница копится между подготовками сценария.
|
||||
* Для каждого сценария делается N снимков подряд без единой правки состояния, и
|
||||
* они сравниваются попиксельно прямо в странице — тем же приёмом, что у golden
|
||||
* (`createImageBitmap` + canvas), чтобы не тащить декодер PNG в зависимости.
|
||||
*
|
||||
* Ничего не пишет на диск и манифест не трогает: это измерение, а не съёмка.
|
||||
*/
|
||||
const unstable = [];
|
||||
const stabilityArg = process.argv.find((arg) => arg.startsWith('--stability'));
|
||||
const STABILITY_SHOTS = stabilityArg
|
||||
? Math.max(2, Number(stabilityArg.split('=')[1] || 3))
|
||||
: 0;
|
||||
|
||||
const comparePairs = (target, shots) => target.evaluate(async (base64Shots) => {
|
||||
const decode = async (base64) => {
|
||||
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
||||
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
||||
};
|
||||
const read = (bitmap) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
return context.getImageData(0, 0, bitmap.width, bitmap.height).data;
|
||||
};
|
||||
const frames = await Promise.all(base64Shots.map(async (shot) => {
|
||||
const bitmap = await decode(shot);
|
||||
return { data: read(bitmap), width: bitmap.width, height: bitmap.height };
|
||||
}));
|
||||
const first = frames[0];
|
||||
return frames.slice(1).map((frame, index) => {
|
||||
if (frame.width !== first.width || frame.height !== first.height) {
|
||||
return { pair: `1↔${index + 2}`, sizeMismatch: true };
|
||||
}
|
||||
let pixels = 0;
|
||||
let maxDelta = 0;
|
||||
let alphaTouched = 0;
|
||||
const box = { x0: Infinity, y0: Infinity, x1: -1, y1: -1 };
|
||||
for (let at = 0; at < first.data.length; at += 4) {
|
||||
const dr = Math.abs(first.data[at] - frame.data[at]);
|
||||
const dg = Math.abs(first.data[at + 1] - frame.data[at + 1]);
|
||||
const db = Math.abs(first.data[at + 2] - frame.data[at + 2]);
|
||||
const da = Math.abs(first.data[at + 3] - frame.data[at + 3]);
|
||||
if (!dr && !dg && !db && !da) continue;
|
||||
pixels += 1;
|
||||
maxDelta = Math.max(maxDelta, dr, dg, db);
|
||||
if (da) alphaTouched += 1;
|
||||
const pixel = at / 4;
|
||||
const x = pixel % first.width;
|
||||
const y = Math.floor(pixel / first.width);
|
||||
box.x0 = Math.min(box.x0, x); box.y0 = Math.min(box.y0, y);
|
||||
box.x1 = Math.max(box.x1, x); box.y1 = Math.max(box.y1, y);
|
||||
}
|
||||
return {
|
||||
pair: `1↔${index + 2}`,
|
||||
size: [first.width, first.height],
|
||||
pixels,
|
||||
maxDelta,
|
||||
alphaTouched,
|
||||
box: pixels ? box : null,
|
||||
};
|
||||
});
|
||||
}, shots.map((shot) => shot.toString('base64')));
|
||||
|
||||
const { page, browser } = await launch(
|
||||
undefined, undefined, DETERMINISTIC_ARGS, { reducedMotion: 'reduce' },
|
||||
);
|
||||
const browserErrors = [];
|
||||
page.on('pageerror', (error) => browserErrors.push(error.message));
|
||||
|
||||
@@ -164,12 +263,63 @@ try {
|
||||
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'
|
||||
const rawClip = scenario.capture === 'room-card'
|
||||
? await roomCardClip(page)
|
||||
: await goldenClip(page, scenario.capture);
|
||||
const image = await page.screenshot({
|
||||
// Обрезка выравнивается по целым пикселям (#410).
|
||||
//
|
||||
// Прямоугольник считается из живого DOM через getBoundingClientRect, то есть
|
||||
// приходит дробным. Дробная обрезка заставляет Chromium ресемплить кадр, и
|
||||
// тогда сдвиг раскладки на десятую пикселя — от метрики шрифта, от
|
||||
// появившегося скроллбара — переписывает границы всех элементов на единицы
|
||||
// уровней. Ровно эта подпись и была в #410: 76 пикселей, максимум 2 уровня,
|
||||
// alpha не тронута, всё на сглаженных границах полей.
|
||||
//
|
||||
// Замер показал, где искать: три снимка подряд в одном состоянии страницы
|
||||
// совпадают побайтово у всех десяти сценариев, а между прогонами плавают
|
||||
// два. Значит дело не в рендере, а в том, что приходит на вход съёмке.
|
||||
//
|
||||
// Внешняя рамка расширяется, а не округляется к ближайшему: обрезка обязана
|
||||
// содержать цель целиком, потерять полпикселя по краю нельзя.
|
||||
const clip = rawClip && {
|
||||
x: Math.floor(rawClip.x),
|
||||
y: Math.floor(rawClip.y),
|
||||
width: Math.ceil(rawClip.x + rawClip.width) - Math.floor(rawClip.x),
|
||||
height: Math.ceil(rawClip.y + rawClip.height) - Math.floor(rawClip.y),
|
||||
};
|
||||
if (STABILITY_SHOTS && rawClip) {
|
||||
console.log(`${scenario.id} обрезка: сырая`
|
||||
+ ` ${rawClip.x},${rawClip.y} ${rawClip.width}x${rawClip.height}`
|
||||
+ ` → целая ${clip.x},${clip.y} ${clip.width}x${clip.height}`);
|
||||
}
|
||||
// Два кадра ожидания перед съёмкой — как в golden. `animations: 'disabled'`
|
||||
// гасит анимации, но не гарантирует, что уже запланированный ре-рендер
|
||||
// успел лечь в композитор до захвата.
|
||||
await page.evaluate(() => new Promise((done) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(done));
|
||||
}));
|
||||
const shotOptions = {
|
||||
...(clip ? { clip } : {}), animations: 'disabled', caret: 'hide', scale: 'css',
|
||||
});
|
||||
};
|
||||
if (STABILITY_SHOTS) {
|
||||
const shots = [];
|
||||
for (let attempt = 0; attempt < STABILITY_SHOTS; attempt += 1) {
|
||||
await page.evaluate(() => new Promise((done) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(done));
|
||||
}));
|
||||
shots.push(await page.screenshot(shotOptions));
|
||||
}
|
||||
for (const result of await comparePairs(page, shots)) {
|
||||
console.log(`${scenario.id} ${result.pair}: пикселей ${result.pixels}`
|
||||
+ `, максимум ${result.maxDelta}, alpha ${result.alphaTouched}`
|
||||
+ (result.box ? `, bbox ${result.box.x0},${result.box.y0}`
|
||||
+ `..${result.box.x1},${result.box.y1}` : '')
|
||||
+ `, кадр ${result.size ? result.size.join('x') : '?'}`);
|
||||
if (result.pixels || result.sizeMismatch) unstable.push(`${scenario.id} ${result.pair}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const image = await page.screenshot(shotOptions);
|
||||
const imagePath = resolve(OUTPUT, scenario.file);
|
||||
writeFileSync(imagePath, image);
|
||||
// Хеш считается ПОСЛЕ перепаковки: манифест обязан описывать те байты,
|
||||
@@ -186,20 +336,30 @@ try {
|
||||
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',
|
||||
// Кто снимал. Смена браузера переписывает все картинки без содержательных
|
||||
// изменений (#246), поэтому окружение съёмки — часть доказательства.
|
||||
chromium: browser.version(),
|
||||
// Чем жали — тоже часть доказательства: без инструмента байты другие.
|
||||
oxipng: oxipngVersion,
|
||||
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`);
|
||||
if (STABILITY_SHOTS) {
|
||||
console.log(`\nзамер стабильности: ${STABILITY_SHOTS} снимка на сценарий, манифест не тронут`);
|
||||
if (unstable.length) {
|
||||
// Режим — не только диагностика, но и гейт: если кадр снова начнёт зависеть
|
||||
// от времени, это должно останавливать съёмку, а не печататься в лог.
|
||||
throw new Error(`кадр плавает внутри одного состояния страницы: ${unstable.join(', ')}`);
|
||||
}
|
||||
console.log('кадр не плавает ни в одном сценарии');
|
||||
} else {
|
||||
const manifest = {
|
||||
version: DOC_SCREENSHOT_VERSION,
|
||||
fixture: 'synthetic-only',
|
||||
// Кто снимал. Смена браузера переписывает все картинки без содержательных
|
||||
// изменений (#246), поэтому окружение съёмки — часть доказательства.
|
||||
chromium: browser.version(),
|
||||
// Чем жали — тоже часть доказательства: без инструмента байты другие.
|
||||
oxipng: oxipngVersion,
|
||||
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,15 @@
|
||||
/**
|
||||
* Prepare editor internals for demo harnesses across comparison baselines.
|
||||
*
|
||||
* Since #337 current builds keep the editor in a lazy runtime and expose an
|
||||
* explicit preload method. Older stable builds are monolithic: absence of the
|
||||
* method means there is nothing to preload, not that the baseline is broken.
|
||||
* A present method remains fail-closed through its result or exception.
|
||||
*/
|
||||
export async function ensureHarnessEditorRuntime(
|
||||
card = globalThis.window?.__card,
|
||||
) {
|
||||
const ensure = card?._ensureEditorRuntime;
|
||||
if (typeof ensure !== 'function') return true;
|
||||
return Boolean(await ensure.call(card));
|
||||
}
|
||||
@@ -144,6 +144,18 @@ const runtime = (includeAppliance = false) => {
|
||||
entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 180, elevation: 24 },
|
||||
},
|
||||
// State-only YAML covers deliberately have no registry/device row. They
|
||||
// keep the Glow opening-state golden independent from entity-registry
|
||||
// discovery while exercising the same exact-reference projection as a
|
||||
// user's template cover.
|
||||
'cover.golden_door_closed': {
|
||||
entity_id: 'cover.golden_door_closed', state: 'closed',
|
||||
attributes: { friendly_name: 'Golden closed door', current_position: 0 },
|
||||
},
|
||||
'cover.golden_door_half': {
|
||||
entity_id: 'cover.golden_door_half', state: 'open',
|
||||
attributes: { friendly_name: 'Golden half-open door', current_position: 50 },
|
||||
},
|
||||
};
|
||||
// Keep sun.sun state-only on purpose. Core/runtime entities and YAML
|
||||
// entities without unique_id may have a live state without a registry row.
|
||||
|
||||
@@ -63,6 +63,9 @@ const previous = existsSync(manifestPath)
|
||||
// байт-в-байт, доказывают, что среда съёмки та же, что у принятого эталона.
|
||||
const witnessCheck = goldenWitnessRefusal({
|
||||
results: report.results,
|
||||
// #408: от размера матрицы, а не от числа уцелевших эталонов — иначе порог
|
||||
// обходится удалением каталога эталонов.
|
||||
sceneCount: GOLDEN_SCENARIOS.length,
|
||||
declared,
|
||||
declaredNew,
|
||||
previousHashes: previous,
|
||||
|
||||
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 97 KiB |
@@ -1,152 +1,165 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 49,
|
||||
"acceptedAt": "2026-08-28T16:30:55.119Z",
|
||||
"sourceFingerprint": "ff001fc4fdb7a72be9bf3afda60fc34ec1513092f8fe4127c2c6a6f1d11e43da",
|
||||
"matrixVersion": 54,
|
||||
"acceptedAt": "2026-09-02T05:20:33.824Z",
|
||||
"sourceFingerprint": "baf59953cfd6eb3543f7da5af21787b20dc76f136b74782ae917f811d2fc1ab5",
|
||||
"chromium": "151.0.7922.34",
|
||||
"witnesses": {
|
||||
"count": 121,
|
||||
"count": 90,
|
||||
"floor": 10
|
||||
},
|
||||
"scenarios": {
|
||||
"hidden-wall-diagnostics-plan-light": "37ba151aa6b1ea7b1b270d461ccca348444c5eeb14a5592bf045a0e69b0fb83e",
|
||||
"hidden-wall-diagnostics-plan-dark": "565d5481f744704a2a6f18658b9b6fa65e7cdbfcd11b50a2bedbc0daae2d8488",
|
||||
"coincident-partition-before-dark": "19626db704fccb7a42b519ffb20b7bad82c726e8845c752ab451f731d264561c",
|
||||
"coincident-partition-thin-dark": "3afede1f99b86454bf4e50d9628196a2240243e034644592c5c3efa1b4561930",
|
||||
"coincident-partition-thick-dark": "57960bd09e6cbbeb073561692690ca68a72d6dbf8242198ef73b8d4ae4d8fbcc",
|
||||
"coincident-partition-virtual-dark": "da7adbf931ad95f4e620d9d088177f1da20d88a21b9e85b6ff2675925d5557b4",
|
||||
"span-over-door-migrated-dark": "a817f5baacf5ff80d1f236ffaa176cb6331b75423393dae5b16e54f67f37c7e0",
|
||||
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
|
||||
"split-corner-wall-thin-dark": "48062a49e12728c377a7c4352148381b1be2a5606b6792d754ae055b04c22be1",
|
||||
"split-corner-wall-thick-dark": "a5d795eec4561e3a1aa9c37843fadddcb04d888d5a49e6ffacf29c17b7798568",
|
||||
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
|
||||
"hidden-wall-diagnostics-plan-light": "4113474796ccb1b7b8c876fcb463e97eb77bb1ec96129195a0dafdcc7ccca334",
|
||||
"hidden-wall-diagnostics-plan-dark": "40c48868f0251aec3b1a14c0eef77a78cc7965420fb9b41f46f8190d1fdfd11a",
|
||||
"coincident-partition-before-dark": "daacbc763c81ad037b305fc34572729c063ec542d031821660b8d3d096a35d19",
|
||||
"coincident-partition-thin-dark": "36f506ed50b92df11ab36b022a98d8fd8f3526b081bd634e6a8a38269f661051",
|
||||
"coincident-partition-thick-dark": "4beacb2fdf3ab4cfdf8bb50c9a96bbee2de88d72ba7bec8924862f9a707461b1",
|
||||
"coincident-partition-virtual-dark": "f37e7b03d99afdc0c588e256053965f404f31c14ea4c53bb95db5a0f9d2d465f",
|
||||
"span-over-door-migrated-dark": "5e37e627eaecb455baa5e279d23f8c6d09f9cbdddfb58976c3b26383fce6df64",
|
||||
"split-corner-wall-before-dark": "14407cf23f6c29c3ba7161be5445acb3606a59116fdfd03e33acb87155b3518f",
|
||||
"split-corner-wall-thin-dark": "9a509f0fa7ee8503c54a508c313b47e10f8044f0a9931791d1cab83467dc3f90",
|
||||
"split-corner-wall-thick-dark": "51ac5e7594386e306f5ebc94e50d6c6d428fd6a4f26a447b28e1cbdd429afda7",
|
||||
"split-zero-divider-taper-dark": "eb43a7ba81ebd8b1f7c7f76306bb6205ea77a5cef3c21739d40ca540f0f7a1bf",
|
||||
"isometric-geometry-view-dark": "0d8427299c77e1e3bb86f37f53128a6640b6666e0b682e5f96085c4f07a657ec",
|
||||
"isometric-geometry-view-light": "a3e592448ad3ad4ff65266f1fd6f31259c01a9baf47392f6b8be9811d74f8dce",
|
||||
"isometric-live-layers-dark": "90fa1c0b64a12626e481c5f34e515c53b3e06cd657f5976bb8a72127aec3e1e9",
|
||||
"isometric-no-borders-dark": "a0abf9fdc31092980de3bcbf8598844b6e3d152363694e9bd070d45af73c26d9",
|
||||
"isometric-touch-kiosk-dark": "d3ae363165c4f0aba65bcbac9bd6bdca79f29aca03bba997f3abec1860eb1e55",
|
||||
"isometric-large-warm-remount-dark": "299401bd4b499876d40678593308b42ac8aa4d3328113f27ad5940fc3621184a",
|
||||
"geometry-view-dark-fit": "2d96e776ea9fc8a136896b58df30abb13a8a53e6c40e5b4f4e12c6dba522380f",
|
||||
"geometry-view-light-fit": "60e8a908fc1c3be3ca5c9b82bf432120d808d913f743c04683b4a2c501d84659",
|
||||
"room-label-parity-view-dark": "b35675251b85144bca1bc15ed7bf27a4dd1eaa0bfc288e3e3b0d1cdefac79ae5",
|
||||
"room-label-parity-plan-dark": "34c1010481e2799b8598e3c6f659ca7ec8e695a064eecacf3562000639f44d6a",
|
||||
"room-label-parity-view-light": "789f8f3b6211d35e70021094f2063dc9c6ac57599d9d64743a33b7ae9dd868c2",
|
||||
"room-label-parity-plan-light": "272dc35d85b1f07efc4c2a05c31f6adbf7d10c648e3c678e7235e35167f83a7d",
|
||||
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
|
||||
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
|
||||
"day-cycle-dawn-dark": "be3b891520637857b7f89dc88284753a15601ebae4db7db750714b9535348d33",
|
||||
"day-cycle-day-dark": "1b791b8a773a5910a71338351ab12fdc36c22742a358b647ff4f295431fa4cfd",
|
||||
"day-cycle-dusk-dark": "14182ad89301cb260ba21ee5077134ed408f038ea878ed4539b84f987182f910",
|
||||
"day-cycle-night-dark": "407632fc54e13e6362b7e583d6e2b7bdd31b4093823846e4e630de0c8f542120",
|
||||
"geometry-plan-editor-dark": "40a0083511c1ff9d0b8f1ec419af63e2a28f1f9a0ec3768387a10c89efe73905",
|
||||
"safe-resize-handles-clamp-light": "f47a5bb09fa4415416d01ca5f4164014cc1d8dc7be54ea888e5399277104e94c",
|
||||
"safe-resize-handles-clamp-dark": "3efd0ca2db8a06aa475e81e3635b878c3038d4763f840e6e856661b04a4fed96",
|
||||
"space-tab-drop-before-light": "7024186821b13feb63ba254aee841379788bca1a5c01bfd07c42e15fe6b81179",
|
||||
"space-tab-drop-after-dark": "5a2199e8b8364da9e85e0887334144e426f43b8022f2918bf130ea13cd099f5d",
|
||||
"plan-snap-endpoint-light": "2185d5ec6726e07097ba1a911984a11d5ca9b460a1cd1e7b2a719255b7d0ed0b",
|
||||
"plan-snap-line-gaps-dark": "276eb10f32be938640bd68a1cf837494f11cce8d10703721831360f3a1100d23",
|
||||
"wall-junctions-plan-preview-light": "965860eb4302b60864b5e3191514c60b97bbcd6073960c437a973b4a880ecddb",
|
||||
"wall-junctions-plan-t-dark": "258869fb3c5af9a223d63518bc6127bfde78c2c039d39583a965d68b225338ba",
|
||||
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
|
||||
"junction-patch-resilience-plan-dark": "90acdf0658772c2883e775dd6972141ad699e165afcd9bf55382fbd99c203717",
|
||||
"junction-patch-resilience-view-dark": "afd83241d9a1e1f19ab59c3f1e6fa7af058c3e42a7fafa5ba271ff92ccea8f91",
|
||||
"wall-union-isolation-view-light": "106f8ac97b82107fed36aa4456c0b9f7a39eeb44be86864b1d4f083497cdbd6e",
|
||||
"wall-union-isolation-view-dark": "914451a813546189bdc9ac22dcf158bbc822b4403080a3c2fd4c6b02bb7780a4",
|
||||
"multiwall-junction-bevel-view-dark": "b80c4584a649722897c206d242a9df88d80169806a73db579b45595173b698b8",
|
||||
"orthogonal-strip-cell-5-view-dark": "66a288f7e7fc6228c3c63f10e1df8df0656ca0cd36681d998b126b03acb70b5a",
|
||||
"orthogonal-strip-cell-1-view-dark": "c23e6d0410b8f5c2b7fb0707f6bea40408730af08f9e7a0b166aa8c0a3d6c85f",
|
||||
"wall-key-roundtrip-view-dark": "d7b0749d0f6c0cfa74ad4d9f76d8a7b7962fadf4e2648260b5647548a988341c",
|
||||
"geometry-view-dark-fit": "1ea111a2f68d71c2d2269ee65ac70b586b7d8a3a76f550bb1caf8d154bf42b26",
|
||||
"geometry-view-light-fit": "80bbb618619731dcb9dbd5e872785f32f1e52ff71ce989a2154c5f1218812eda",
|
||||
"vacuum-trail-smoothing-dark": "b33ddbfaed72f51e601576bbf87664a08578fbce5c22c69a11d83cc2644ff312",
|
||||
"room-label-parity-view-dark": "0b4dd478e9b5e0f7acbf46c135309c8b1ed984b8c21f9c7ca0be9a25c10779b7",
|
||||
"room-label-parity-plan-dark": "9112819b3af20153022dbc79940961c2776d70eaeb61716dad62df3c4c518665",
|
||||
"room-label-parity-view-light": "a8a9915d2900a1347e85baa3df2ebde602904a9e35bbcc143fea7b1067c171f0",
|
||||
"room-label-parity-plan-light": "354e6c78c6daf94305b9b0c9b4efa5caea89a327d3a81f634f1c3f5c6674524c",
|
||||
"washer-active-cycle-dark": "0f38b85f2f912059a6380a0642a4d0314ea5c6fa6acb9568e1c4abbf4a7731a2",
|
||||
"washer-idle-cycle-dark": "08d5f10dc14b548e6a149c8d9c93f9b635854604cebd681644f30a1ca0b4ee4d",
|
||||
"day-cycle-dawn-dark": "cee9dea7017ee192dc69771822e53583df973dceecd46279c93079ba6bc730c5",
|
||||
"day-cycle-day-dark": "96fb83defa72f78d9e58413505f9250fae9cc766e21bdecb4b23374e04f15d43",
|
||||
"day-cycle-dusk-dark": "eca235de3abf21776b160d1b79cde3309952f0030bc2031a0ada7bdf2847d2be",
|
||||
"day-cycle-night-dark": "feec7492db1187b84e59cb627de9dd8d6d412f1dfc2643c3eab5cafe8f198f7c",
|
||||
"geometry-plan-editor-dark": "0fc3d491560d87e9c9b0285729c50bd20bc85c959b487b40c4d47f8c9378e8f4",
|
||||
"safe-resize-handles-clamp-light": "1a5adb25fd12180bc7b9781924abb4ffad637579ec1eba9e1a76481523080960",
|
||||
"safe-resize-handles-clamp-dark": "bd0035cacc4d8c16c789754507a00f673b75cfecd1569635e673683e5b9f0247",
|
||||
"space-tab-drop-before-light": "d174ac6b543672ebd649e7c9363c3bb79b22b417f7d899c08f755e273632f702",
|
||||
"space-tab-drop-after-dark": "eb8d6bae0e32392234bd4462652e2a8f7831d7017bf5e8d8106c9d6f203b3eaf",
|
||||
"plan-snap-endpoint-light": "dd4fc2254ae457e9ae5628b1e97ca0228699cdedf9b57f5358b2280dbebd6d6e",
|
||||
"plan-snap-line-gaps-dark": "b8457468ef5d4b8b38b62c4a52950364d231aedb7549fcbcbd4ee6f9444569e1",
|
||||
"wall-junctions-plan-preview-light": "3151196d0015c56a46b17a599513ced13dc5558c03106e98fb7d6dda5fad8de2",
|
||||
"wall-junctions-plan-t-dark": "6d16a52e7cf362622778e5b5692b6f3ae4ec04c790e5117144fb0ca50f6c70b2",
|
||||
"wall-junctions-view-dark": "ad23e5fb0ca9ba23f58ee4839cec489e7d05a28ef193d424b451a4020ef8ce0b",
|
||||
"junction-patch-resilience-plan-dark": "80d032f5962dce27cd3577972e3d651d41080dae22c114fde8aec4c4b605f24e",
|
||||
"junction-patch-resilience-view-dark": "4ba710fd6859f958ad6c576a01d0b240a3b1723fe9df54f8cabd971414a943a6",
|
||||
"wall-union-isolation-view-light": "426ef2ada0e69febd73ab305aa99b600a95e69db68dc30dae4c5288ee1151604",
|
||||
"wall-union-isolation-view-dark": "215ed9d8cba31c5de4cb9d0e2b347bf0c96c8d1bba4d35feca57d6f66fc25322",
|
||||
"multiwall-junction-bevel-view-dark": "d64a9549ecb9bc166f99283901411f08651389ebe5ae3420c935a677c02d4b5c",
|
||||
"orthogonal-strip-cell-5-view-dark": "05cdd744d09025719eda4c5e68cf3ebc5d7d201afd3028da91262d0f4874c126",
|
||||
"orthogonal-strip-cell-1-view-dark": "9c0f91b11d47f1c5df4476774ae6028db21dc15cc6fb81d9f80009e0fa86bffe",
|
||||
"wall-key-roundtrip-view-dark": "cc6f755c03b1a586684627fc1dc27eb1aa28d2673a6c79bcb687da94a6bf9b3b",
|
||||
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
|
||||
"opening-placement-door-thick-wall-dark": "c21c2af812b847e7fd9a3a5b9df832db95266f81eba3d7b48c97d2b32619e7fe",
|
||||
"opening-placement-passage-thick-wall-dark": "90f9a837898169c2d9ff9ec3b44b2a3d247ea034fd84b1753519da7b0c89ebdb",
|
||||
"opening-placement-passage-thick-wall-light": "dd17884027d85f5727450cd08f3fe6daaed290770dbd04fe5ce10c4c2b9d43db",
|
||||
"opening-symbol-room-wall-light": "403f1c6e1c59122331fbe99019a3ebd80de4106dc6d33f5f5f33feb748229645",
|
||||
"opening-symbol-diagonal-partition-dark": "620102c00e77613cb341c906829bfcc7e9a5430075aa576f563adafa8bdd8f08",
|
||||
"opening-symbol-flip-pairs-light": "6d780d1d06909e6e5a7ebb35987b9681d6902d78408e974dfe2fe587521990ce",
|
||||
"opening-placement-door-thick-wall-dark": "d986d82fe68bec4b0b0556870d3ec7de280a00acb42cf0165a3d64f91f994bed",
|
||||
"opening-placement-passage-thick-wall-dark": "f53908dbdf8cf6681c97317398e8a04cd98ebacdbc81571c2d15d38b2599e049",
|
||||
"opening-placement-passage-thick-wall-light": "f7bdf033bbc511b34269044c450f8c93a3493e27bbbcf758050b7798197efd80",
|
||||
"opening-symbol-room-wall-light": "c487c11f12b2bba1473559a82af3320b7995fa9c8d1b379274b942e431930beb",
|
||||
"opening-symbol-diagonal-partition-dark": "77101350b15845207644b6df86a3bc5f48b4dc873b9cfd7443c0d23507412de3",
|
||||
"opening-symbol-flip-pairs-light": "c2d9102921483da1d0216ed9bc55fc9112d67762f159df8af64a277f0db0acf4",
|
||||
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
|
||||
"geometry-devices-editor-dark": "acc779a2a21d5c2a26f40d1a23495cafaffc460c57c1100660fb3cde554303f1",
|
||||
"device-inbox-desktop-en-light": "c04254626f613b11e1b9165abf6bfad4b04a2670acab2887e38062d7d16b4533",
|
||||
"device-inbox-desktop-ru-dark": "2e7e7175ed770fea657828beda68b62d6eb657a4b72222b1977df3d7d692b0d6",
|
||||
"device-inbox-narrow-ru-dark": "5263f518d4a415caedbb7917c79f9acbb03de234feec11e3128c235d2bd5b39f",
|
||||
"geometry-decor-editor-dark": "2a0d4368e49b51fc4fcf5ee72679efb7b7e094fd8b6f47682b0696ec909c423e",
|
||||
"tray-wide-selection-en": "056f3b54d60bba83ef313f9c12fe697ea6a0b3d48db0470735394ee0d3f72c39",
|
||||
"tray-wide-tool-ru": "f8e050c96cb3e78f1ab8d0b8b276b3d154950e795c5b620148b9e395330cffe9",
|
||||
"tray-medium-group-en": "4513abc13f1bc24e4d04f14445069c8ce9785dc3fddcdc99bcee41b5eadb8d53",
|
||||
"tray-medium-selection-ru": "bd97bee66778a65ebb849331724a307d0b089d6cdf5f3b85c6c905c413d5290f",
|
||||
"tray-narrow-palette-en": "aa346cce2e0af7e7107be692eac52dd62b6b2471d0fb90205e7480ce49bdbe3b",
|
||||
"furniture-categories-light": "8ce8d9804a3b97f8c786c80c4859dc9635a80757b8ad4671a43256f51fdad1c0",
|
||||
"furniture-variants-dark": "79528f3607b32409d6d4d80330d1365a185f2c17641f88e386703222b0671bd9",
|
||||
"furniture-variants-light": "8236ce0e9d30fb55a359ced5b39c23ba2ad6ccd01bc2b782cbb817422642385a",
|
||||
"furniture-plan-art-dark": "ed0cfdd8b9da903b34f7945eed6fa0e0140e215e6d5368bf81b953f86dea27bd",
|
||||
"tray-narrow-tool-ru": "95fa0f072efb595e1f9fe3f3287e7ce346fdee640e90dd70e4f5e5b31049ef29",
|
||||
"geometry-diagonal-45-opening-dark": "ba9462322da34b20fff1ee1f950c516855dd7ac56131e13863f576e2d0ba5177",
|
||||
"openings-thick-wall-dark": "539cdb9cb1460cd42c2e51901ea50f496e8214d02633e110e2780c3f177154db",
|
||||
"openings-filled-tunnel-dark": "bb2a4343a285e2fd867c6090108589549facf87b1a9471f3ae331d743af6653c",
|
||||
"decor-over-opaque-hover-light": "4a9533301a41c10de10420246e8be30450988887f1588fa0e838ff1641482524",
|
||||
"decor-over-glow-base-dark": "d22ec58d8a7a627de9f9c1e6300a6e00105c3732df22031a0b6dbfd43c4b9138",
|
||||
"openings-hidden-view-dark": "19ab4fd2ffce0fea0fe609b007a3e60f7abd84b40935cbf3c5fa9dc1fda21046",
|
||||
"lighting-glow-sun-dark": "08e2964df6fd03abad4b9a81f1dbd0fe3812fb6b5f7908aeb027ad2953b2f88c",
|
||||
"device-value-badge-positions-dark": "75ab0210d697f952b8a8977649e84308de3ce3b59c84ec93071179a1d6cb015d",
|
||||
"device-icon-state-table-light": "85c785824ccafc2d5f50e834ad2d746319d64ecb292d1b9cf7fab8661d5d410d",
|
||||
"device-icon-state-table-dark": "03a90523132c3dbe6c78d44d01fe89df93e2d0474f91ef84e7107e6e4bc7d337",
|
||||
"device-text-shell-long-light": "97a113b5164f373b4af2922c8e71bc1f063d8c2761b72c3182697fcb1aac3cab",
|
||||
"device-text-shell-long-dark": "c8fe32aced704c7e7562f0c8499183fad848a977063ab731401cc01e7f20ef38",
|
||||
"lighting-sun-window-state-only-dark": "4da798c4de6d1303d17be246e9a2e63b12da3f53a42136cf745d1a92ad5a4f21",
|
||||
"lighting-fill-light-axis-split-dark": "e45d44496c83ad92f8d5bd058821e6d71c97c48a2b66c1933206535aea2655c1",
|
||||
"lighting-fill-temp-axis-split-dark": "895e1e0beee38bf659b1313ed9951353a7792d2e8ae3a8620c4cf7a4f6a84a4d",
|
||||
"lighting-fill-lqi-axis-split-dark": "4a029b17419aaf60feab23f7f0cb385d97353e3aec0e39ed67e9ac94f6c08029",
|
||||
"lighting-temp-glow-dark": "3ce504d83fda2ba5d772c25f8cbb47c379b335addd8650f6933b58f22f5655a7",
|
||||
"lighting-temp-glow-light": "e9f2db0a686a9e20bf71564c10bab826a2a473c73d572a6b67ef02a735d61e8e",
|
||||
"lighting-custom-glow-dark": "94cc6135573aa9462de9e7f309dea62e236668b84970bae35d48f1aad338f8f4",
|
||||
"lighting-opaque-glow-two-doorways-dark": "ee3c53a408f6b62aa6b770925fa6948934e0c1c6e73877e379695bc260b0ceac",
|
||||
"lighting-custom-glow-light": "aa9477aaff4d137abfcb6d67a5cf62b91f41b3631383ee8edb7499802a5c9b87",
|
||||
"lighting-temp-glow-no-sources-dark": "cd992af54d1c9dcc58bbd0b21b15b475be6f19eaabcf9ea866bfa82f205460c9",
|
||||
"lighting-temp-glow-room-override-dark": "66a418829b51379d024c542f8b9a12bd42d72afa81a6bf66561d6498ac4cdb45",
|
||||
"lighting-manual-auto-spill-overlap-dark": "0dc780335317d6dd577c8d5539f33223228074e1c95e936e4666f50a8db09739",
|
||||
"hover-over-glow-dark": "49136f6073662482ebf49a28f8f22b698d2c20a9cb64b5667d111b9835717968",
|
||||
"hover-nested-room-dark": "b546f923fd31d28d2881f074e3a67c5d9d3f9450169f9118502ecafc98c4467d",
|
||||
"junction-t-90-equal15-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
|
||||
"junction-t-90-bar50-leg15-dark": "e981f0597a55e9e9e623dd49417f33a0ebac18d18e0e66675a9af1f2224bd152",
|
||||
"junction-t-90-bar70-leg15-dark": "4428fbe75679fab6e57ef61c5d5097d498960e2539bb9e759e1bae9a160ef174",
|
||||
"junction-x-90-equal15-dark": "8fe0e262e5873714f94b9a6a76ed920881bc6b267e69153039f52a7102ec55d5",
|
||||
"junction-x-90-mixed-dark": "deabce4c2b5fa7866a4f56d25404c6a515d47437298bde114442bc80a6339560",
|
||||
"junction-x-45-alternating-dark": "49192516aabc14e84a2da4d1361c1be57d24e5c947b199cdf8b6c55fd7318504",
|
||||
"junction-star5-equal15-dark": "2fd822f73f486173700405208168300d27d4e360b17cb278c7ee7bc1cda3769e",
|
||||
"junction-y-60-equal50-dark": "7a61dbfc36437674c2c2bf5f450a64797e21a124f42c0e45bfb56e3236e0b53e",
|
||||
"junction-acute30-mixed-dark": "9ea64ae5864295a1d93a6819efb6e210de2599030fe650913862d99f40b11a69",
|
||||
"junction-acute15-equal50-dark": "e7e657137e8dcf6294d400f3114497318c055405dbc0d867603514b56c42b907",
|
||||
"junction-splay10-170-dark": "ae27454bb4213237313f1fe9e2591ada48facf20baca5517968f92aebbfb330e",
|
||||
"junction-t-virtual-arm-dark": "d325dc112e2f56c31d4511fd586adaa92b6785fc676c2a60ed5aba69c164fd6e",
|
||||
"junction-x-virtual-through-dark": "cce24ac9d7678959844803c6306e65ab48f723ec0375ee6d3f2dbf1c7def7be1",
|
||||
"junction-column-node-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
|
||||
"junction-draft-end-node-dark": "6453bb8463ffdc9a42dbd7acb171c6249fe6d5beec0eae0c789e40f48e65a1f9",
|
||||
"junction-309-step-dark": "f5e0e649ee19197026b87687fd3512b18536883f071348589ec3a7f0ea2a216f",
|
||||
"junction-309-spike-dark": "98e709aa2fa58a01fe280635c6a3799e3a5f4158b2ada12c31c38818f92c4f44",
|
||||
"junction-309-hump-dark": "abae96fd96ed8515d7b1e33b84858bdf08ac28a888898549874baa8c98202e7a",
|
||||
"sharp-apex-legacy-dark": "93a46c66f7263a0325d0c4d50fa0677f3c902647ea5880960b99dc8fa04a01a5",
|
||||
"junction-owner-repro-dark": "f5407d420449ebf897f94fcfcdaaf4630e818c043d3076b090f7d1944df5296b",
|
||||
"large-house-zoom-040-dark": "736af1c340646c4c1cbab3d2270f67a535995e1ea1e2abe49c91d2c396052097",
|
||||
"large-house-zoom-250-dark": "0a74099e265b38f376bb7bfd86d4a57c5d493ac3870478a7d55c48cf99e8f892",
|
||||
"large-house-warm-remount-dark": "1c14dba8cdbc4628984091d395c1ac14faead1d43338e44aa3cb57976e519df6",
|
||||
"device-dialog-desktop-en": "e142521eae1bd40861c1d92eadbcfd6d88aefa689cd5ee9b4c1b6378c8ce712c",
|
||||
"device-dialog-mobile-ru": "7921b7d5e2c082fc4dd5053607a8228c2e63e0e72a401d86153effd54c2c6ca0",
|
||||
"device-dialog-desktop-de": "ac3f657e072006f5c1686d89da61df922991a04f69d2d7994a03d1ba93532681",
|
||||
"german-view-mobile-light": "9496071b11f510f1c851a9c60583a96bfe768c1fd1e12a2b1b9183dee7e453a8",
|
||||
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
|
||||
"toggle-entity-dialog-mobile-ru": "77e8fc03817a784888993490cdfba675482b8c56483199c1efa512db8531516b",
|
||||
"device-help-popover-light-ru": "259a50065b7f09d07db656b6cfe79d1de34397665ff8dd8a8e361f6f29a7ea6a",
|
||||
"decor-color-popover-mobile-ru": "51725859afc6a1e2d1b2ae8be270ab584608901623ef7e2d283b6b195644fd16",
|
||||
"decor-color-popover-desktop-en": "98d769f5393afb2bad3fb5143a562da5b8bb9a5c8049f4c707706bbcb4f99f8d",
|
||||
"general-color-popover-desktop-en": "008865918113e05847a7ad8ae0fdc068b5326acf1c591d855806b333084978a3",
|
||||
"device-ripple-color-popover-mobile-ru": "4f2365818f46fe1e2b401e5835f5d29ea9c41b288d894025d7512db92f579762",
|
||||
"space-room-color-popover-desktop-ru": "946c550232ff3819bb44058eff6c5deadfccf85527c23065137ee02fa457aed9",
|
||||
"backup-full-preview-desktop-en": "6b68188269fdbaddaf4f2ce8fc0d10ea41e050f0c08cdbd9507548871a3786ef",
|
||||
"backup-plan-only-export-desktop-en": "805fb7cd15959a7eee0b17150c4417e335f1b1030a438f7051c2b006a5f7d4f4",
|
||||
"geometry-devices-editor-dark": "7c4072fce11f91b2c894bdb5f4f9113e72089306d629c995d38dfd17f2b65cc8",
|
||||
"device-inbox-desktop-en-light": "89587944da06872f610da33e8e39441d108cb34849d8b09d4925bff4d0977565",
|
||||
"device-inbox-desktop-ru-dark": "fe9506408fc198c167f2d4b9cfe91bd9fdfb722b311772457ae44c9d06248680",
|
||||
"device-inbox-narrow-ru-dark": "3ddb3b66a613c6406d3d9ce2d4d479c8d84eff04acd9b5b2dce02c4c777fc738",
|
||||
"geometry-decor-editor-dark": "4023e36ff4521011153983538e4a43ad80efd92bf07d523c333982bbd2f80ad6",
|
||||
"tray-wide-selection-en": "5a535b457b8b274ff25085ee992aebd95d756415afe1321d0aadee70610d5801",
|
||||
"tray-wide-tool-ru": "56c09bb397b76b7e5c6f4d7eaf7ab59e5d96faa409e553c45c6ce7605e9a9418",
|
||||
"tray-medium-group-en": "b38abb69d1fac7414fc24d6cda6b5698eb861f8af94054605902e8529fe1e993",
|
||||
"tray-medium-selection-ru": "b53f9513b167a33cffed2177603ae5b562264a83d5544d938c2af42ec8a551a5",
|
||||
"tray-narrow-palette-en": "ae23f1b1384bec23ec272156cb2b7d3fe4088addaefa2f575bc1daa0480569a1",
|
||||
"furniture-categories-light": "02ce65879aca968d05c7400e32d3f38c73a53f3bc036ee306fdf851ed84ed88c",
|
||||
"furniture-variants-dark": "f52db569690a67e84701aeb79cc8967c9ee117b543fd9bf3d6fb8c3fee7a660b",
|
||||
"furniture-variants-light": "4a78f688423f0f0946f50ec1a92fe9a3de5bfa288c7e153425c1beadf5c5f839",
|
||||
"furniture-placement-preview-light": "f983eda1bf42e27d17afb78eada8b96b192a7e91bebb0c70e6766994000aabe3",
|
||||
"furniture-plan-art-dark": "e83a8c40add2bf33af304f86ad29fc77d8d47bd7228f17c152e962f3431e0c50",
|
||||
"furniture-transform-light": "3e37e03457bac87c9d7eaf6bbeb0f4054fb6e6a66a7da5953714446a92ccf296",
|
||||
"furniture-transform-dark": "15d7d719a2de7b8b142c8745f262da198a57ec92e3bdeecd39339414118cb367",
|
||||
"tray-narrow-tool-ru": "fa8f7125ea9f5c43f47ad090cf78ab970950a03dc5a9fa1c348c880ced7c0c4c",
|
||||
"geometry-diagonal-45-opening-dark": "0ddd027a7c899b5772c80555ac8de37c33e8539cc5d911ba454f562e787f6853",
|
||||
"openings-thick-wall-dark": "5dfbd731752515ece69b39d39e6b0dcf43eb5c5d8eadc1bb5f55d43f94b82992",
|
||||
"openings-filled-tunnel-dark": "362802a9b810e506b81eb4bd26b883592c0d1d5aa491823d6b81888deb96fb3f",
|
||||
"decor-over-opaque-hover-light": "23cf9d8aaa544fea547b189bac77e8e8f41521ac98bd0508f96737311857d3c0",
|
||||
"decor-over-glow-base-dark": "49f09a452295d8e281fbe3a4ed56517ac4039454fcc680bc76ead7c222095c42",
|
||||
"openings-hidden-view-dark": "bd66bea4249806cc4fb02ae8813d97e8373a12063b734d9e32a538adf086e9da",
|
||||
"lighting-glow-sun-dark": "7cc688674888ff726f5e834b37d100e065afaa6c7c1ee6b83600de93172bd8bc",
|
||||
"device-value-badge-positions-dark": "0e94d408ea27669d0b227504bf5ce5a637e19e4d16bbd8314b9b49acad32c271",
|
||||
"device-value-face-cover-source-dark": "df67d07b96fad02d62b6a7354e31c4be7c8fcf965de1709011f08d7c9d094c36",
|
||||
"device-icon-state-table-light": "588daaee4d3747aae9f026a728d2769fdbb89f7a1a4b045dd8295dbe8dfdd680",
|
||||
"device-icon-state-table-dark": "9b2e03ce937f92e63a87e215f6c3a7663c199a6f9d8f33ea438fecbd6466e18a",
|
||||
"device-text-shell-long-light": "5b254c2c9c2fc1d977e7417cfcaae146e0dab30b6ee29ae6860650e8ee27b61c",
|
||||
"device-text-shell-long-dark": "bad52d0c18e2d0255566c6a144d6267d2ccaf06739e11e9f7a3071ed2d04f889",
|
||||
"lighting-sun-window-state-only-dark": "df3d1f55b9036edd9787327e705f4218157bc9a954ac36742809aa74ffbf7e97",
|
||||
"lighting-fill-light-axis-split-dark": "62757164bb514acd4cda3a51166136d2199480a5341b75b2f7df5324276f094a",
|
||||
"lighting-fill-temp-axis-split-dark": "bf0ec890e47087f117f14509954e3a2cf1a0d093014580f6e3bce6833c00c8f4",
|
||||
"lighting-fill-lqi-axis-split-dark": "58f9d537812ff3cddafab305478e76ae1adee6368d735eb0ebb56be7ee287cc0",
|
||||
"lighting-temp-glow-dark": "9a4d24c2a2a11fd08083aba0de97fe43ff53698b8893ab13f76910315b4f1119",
|
||||
"lighting-temp-glow-light": "3fa32baec780312465ebb2237c97c486e65b19a1d93caff0e34a5a9f6c1f731e",
|
||||
"lighting-custom-glow-dark": "ac97ac02b8c2de217cf0ddb81e91ec0c3ce9f2572b79d89e10e8c3077e22b60f",
|
||||
"lighting-opaque-glow-two-doorways-dark": "9a8e3495c474019693c8e8eece4aa78a51889c66ca2ef1de882a1ecf1963282c",
|
||||
"lighting-custom-glow-light": "99700dfc1ed1f29e72977e033d54814b41aa1f36ec57fd2b0d586dadd685d3df",
|
||||
"lighting-temp-glow-no-sources-dark": "9828ecb9ecf6368b9e3b394cbcb0c87bf2185b79ad2ed2021543bd2a4ff39016",
|
||||
"lighting-temp-glow-room-override-dark": "929af750e3d6fc09942fde9cfa8234e73953c6dfd287136af3d471d7bd7ce5ac",
|
||||
"lighting-manual-auto-spill-overlap-dark": "992de7a2bc21955ce8bf79ec84506463094d5d88392e7f2dd06bdd10dced31b1",
|
||||
"hover-over-glow-dark": "32cb8ee5a15eb6a1eb65e3b89429976c553e75e628a49308911ff5aefd00e76f",
|
||||
"hover-nested-room-dark": "5733d93d50b2e8a715174c8138ddd88739db12f8ef2cbaa913501612c8ceb0b2",
|
||||
"junction-t-90-equal15-dark": "cad34f8c81afe7fa4c44f69892a49e6f6e48afb4e079f636c5769b9d02868ae7",
|
||||
"junction-t-90-bar50-leg15-dark": "8f9e9f8d73106918c2d85782ede967bb1db554a4d09ed57cb1b062497fb7d502",
|
||||
"junction-t-90-bar70-leg15-dark": "9b9110cebdb141b326c9a4863306f0026d574191e28a9642b238008cfecea38d",
|
||||
"junction-x-90-equal15-dark": "e973bbedfb3816a14ee26469187345bed73cfcaa4249d152a638ab9135e007f4",
|
||||
"junction-x-90-mixed-dark": "c06ca8cf7f54b05278769945477834fb97a1bc679ab821a6b3a2543c9dfaccae",
|
||||
"junction-x-45-alternating-dark": "2f407f8ae20f83b1fbf394a58ec75bbaf8c18136b08998e593c5b06512e1db96",
|
||||
"junction-star5-equal15-dark": "0ee20d4710260ff0dc186fa6dace9232affc9df77f1ef9debbf70066ccf2cd87",
|
||||
"junction-y-60-equal50-dark": "dd34fb276b09413e63769f17e4b9e11ad4635f5af5ebe28530ed65f27c871418",
|
||||
"junction-acute30-mixed-dark": "314d1b732259f68f06b68b0c0ea84325962e29083c136202b3d785902003300f",
|
||||
"junction-acute15-equal50-dark": "d3cd930fecd30d71b6cf2feb993de0ebe05fe8028bf8a4351bcc9d58e99f50fd",
|
||||
"junction-splay10-170-dark": "bc5b5ce280135badf598d5b2995ab40b55689284af20e429abfe7e24bf198fc7",
|
||||
"junction-t-virtual-arm-dark": "3e56730afa1b74c17a15007e46fd9d5bcd2b63643421db317a54cba6cd43ef2c",
|
||||
"junction-x-virtual-through-dark": "4f03f100ab2f25059629fe3724f5612d2a91988360c6eaa1092dacc14d4ddfe9",
|
||||
"junction-column-node-dark": "cad34f8c81afe7fa4c44f69892a49e6f6e48afb4e079f636c5769b9d02868ae7",
|
||||
"junction-draft-end-node-dark": "e9b60c1a4a62b0ac27068a5980ef17fab37eb98f6ee310f239a54d6c9b752f3f",
|
||||
"junction-309-step-dark": "ede9b8f9c75504b677f2c60a8ab5c72db9e989e86d347b685a676f6d225592a2",
|
||||
"junction-309-spike-dark": "9e4df39fa724ad63c87f49fa221c8c4f3011246c69a6aafc648e5864acb26b58",
|
||||
"junction-309-hump-dark": "b5cad52f701423f421d8561efb542e0bb1f2352570de36f6ccd1a6e9838121f8",
|
||||
"sharp-apex-legacy-dark": "8112eda510a11993bbfda5b1febbbd8bf3a8ea0786afc276b3fbe85e6963bf00",
|
||||
"junction-owner-repro-dark": "a4744295587d362ff58a57a68be202e69be67adc3d0504c9fd7c4d9e767494f8",
|
||||
"large-house-zoom-040-dark": "5601d5ce3b1fe79365984301872a851d6c2e81878ec6f6cc3b88aab811d8609a",
|
||||
"large-house-zoom-250-dark": "7abb72baba00ed035d9f7519b99e1d9f872a6cef81861ed096a511fe9320471f",
|
||||
"large-house-warm-remount-dark": "441c32f130b8f291582482fa6445f9c85f6f13c61b956020d4fa1d6153094efa",
|
||||
"device-dialog-desktop-en": "465175fc9960c7ceb5208cf62f919381d56179ef12a93bee20e5c7af63757e7a",
|
||||
"device-dialog-mobile-ru": "8128783f4e9f9ff301f15db9fa7388b51aa7e8a4a74ac0aaf962b21ecda52c78",
|
||||
"device-dialog-desktop-de": "8440d4e980fac5df53142b6b118e527d9c0272e7515b65faaba6a58bc9441075",
|
||||
"german-view-mobile-light": "55112787d9a26be66dfd8b724115962c4b891d1f8276b7b5b36b0ff25da48257",
|
||||
"toggle-entity-dialog-desktop-en": "7a8ac3071d8bb3f79f2bfe724d0138e2b309baab3104ef8afb0b2b081c8b82b1",
|
||||
"toggle-entity-dialog-mobile-ru": "d3062e76da27218cac45ca4c76f34b358d9cb98e85288f24e64b7de1bcca949d",
|
||||
"device-help-popover-light-ru": "c2146f13d03841c395b0772f1a21de358c62a8703d3d02e7d026fac8ae29955e",
|
||||
"decor-color-popover-mobile-ru": "c65318327c6e4631c769419e791a749d667b4dfc36f2e3fc46d6fac53180cf1b",
|
||||
"decor-color-popover-desktop-en": "60de64b1bc072327cb88d2e876732d50d5cb7441c850f52b7af6f4ed5c6de22b",
|
||||
"general-color-popover-desktop-en": "762c20c3d0499c1ea444e5d980eec4e78c7112455589b5202010f53a8b51099c",
|
||||
"settings-help-zoom-200-en-light": "bb1192a52bab5ebc6984a5d799a32d05f9ce374ce6b493f5f618b639eedce8d5",
|
||||
"settings-help-zoom-200-ru-dark": "13e7fdd19017c38a5659394e622ff54ac8a426f9cb6e85b3248864767aa9f42b",
|
||||
"support-desktop-empty-light-en": "fecabb63521f258acc66ecdf7a438db10c73d0eac39618c55fb51287080f01f5",
|
||||
"support-desktop-preview-dark-en": "a866aa74e3385ac45e837b328d64ed65bc4b108eb9dc4149fcccf393975e72b6",
|
||||
"support-phone-validation-light-ru": "7a461c00631a05872b9aab1f4c0b0b65d1c918273ff06bba25c7bd2e87fba1ca",
|
||||
"support-phone-success-dark-en": "81f83402933a573002581646d0b322f3770f3df41d5334373315ed5ad4974883",
|
||||
"support-relay-error-light-en": "e9a9ad906e106eedfa6e84c592325f1604eb64c89c51135c076ac87a6a0766b2",
|
||||
"support-tablet-preview-dark-ru": "1264e005cd764408edb9b285a5dfbe92ceb90cb925f624b8f65cd47edb96c0c9",
|
||||
"device-ripple-color-popover-mobile-ru": "d9ebf83ad0d2995b46f6174daf6e21ca5404952afa308ddd3a66ecefcee565d0",
|
||||
"space-room-color-popover-desktop-ru": "9bbbcf9c5ea6ff5490b500be8cb3086af9d368d14ca50c304f42a697e296ece1",
|
||||
"backup-full-preview-desktop-en": "0fbd23a92121668001faf5c9ed555a31107704f3074d1791709c8436517d3d1f",
|
||||
"backup-plan-only-export-desktop-en": "ce66fb57edbc87d56355b0d73a2d4203a67c275d5927d400ef1758ac0b607b34",
|
||||
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
|
||||
"optimize-preflight-dialog-dark-en": "0473d7f16dd830aaddbec5c33f2b423fde48e2053a1094dbab052e6a2cbad9d4",
|
||||
"optimize-preflight-dialog-light-ru": "a4eb6ef9930652bd7e55fbb725fd1bcc5a0e5b71e8060dbf6f772dd62cec5d36",
|
||||
"optimize-orphan-references-dark-en": "f40417298e126fa804ddafb91d62b728c86a56c5a28b4fad12b732070faa4ad6",
|
||||
"optimize-orphan-references-light-ru": "2d0319f681cfbe472ad5dede05d145a995dedee577d8d816e2fe46a519e019f5",
|
||||
"optimize-preflight-dialog-dark-en": "101e1a97c6229c693a4edfb4878c6a4564132d6fbcaddd6276cb25aefa429260",
|
||||
"optimize-preflight-dialog-light-ru": "4f34a59baa95b7ad0ad3dbb47f23996537878f6b666dc9111236713f513de97e",
|
||||
"optimize-orphan-references-dark-en": "c8122eda61a52ead0f3609b9872b513004ffbc44c1d4928ec11e5d3b5978af78",
|
||||
"optimize-orphan-references-light-ru": "3cc0b16af015cf695f8bd278dee4d76b160639021a33bc3a28441fb72a5e8e5c",
|
||||
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 372 KiB After Width: | Height: | Size: 367 KiB |
|
Before Width: | Height: | Size: 347 KiB After Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |