Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1e6199b46 | ||
|
|
df4104ab38 | ||
|
|
f8ae546fb0 | ||
|
|
c2913b2866 | ||
|
|
52aa332311 | ||
|
|
97aabddce7 | ||
|
|
2f65a184fd | ||
|
|
5d281171c8 | ||
|
|
f2e38e4815 | ||
|
|
8e5667b9ec | ||
|
|
04f4cd10e1 | ||
|
|
93c9cfae22 | ||
|
|
ed63e4784f | ||
|
|
7f464deb37 | ||
|
|
28f8ea8f21 | ||
|
|
3f3b9ad723 | ||
|
|
a979699a57 | ||
|
|
80ff541cef | ||
|
|
4693d97d2f | ||
|
|
052549fa20 | ||
|
|
55d832ec8b | ||
|
|
a4bcf834e6 | ||
|
|
0145f9bafe | ||
|
|
d2d145427b | ||
|
|
a6077ac922 | ||
|
|
d12b1f20b0 | ||
|
|
4beb5c8829 | ||
|
|
c9194bfbb7 | ||
|
|
0c670a9862 | ||
|
|
38abe78c9a | ||
|
|
b754d362ca | ||
|
|
1cadd520c0 | ||
|
|
0b577be59b | ||
|
|
04d6c5cddb | ||
|
|
5ab41a7299 | ||
|
|
f1f05198a5 | ||
|
|
9dabf84652 | ||
|
|
c82ba84cc7 | ||
|
|
605a9a1c78 | ||
|
|
74eca9bdd1 | ||
|
|
9b4e1efb1d | ||
|
|
a31d8981a7 | ||
|
|
e37285c756 | ||
|
|
9f572b85b2 | ||
|
|
c0426dadd8 | ||
|
|
f35c054eaf | ||
|
|
c3716b6930 | ||
|
|
ab01586b32 | ||
|
|
b5ce94a7e2 | ||
|
|
28c653991a | ||
|
|
5d84511a9b | ||
|
|
194df5251a | ||
|
|
cb298b10f9 | ||
|
|
98bdd46a52 | ||
|
|
ddb2d5e07b | ||
|
|
9ce994452d | ||
|
|
7fb15fcf43 | ||
|
|
3fbf312bbc | ||
|
|
7195ad1914 | ||
|
|
88e4cf50e3 | ||
|
|
57a4a0ed69 | ||
|
|
d77d1953c7 | ||
|
|
e76f3909d9 | ||
|
|
a3396a6c99 | ||
|
|
d95255ada2 | ||
|
|
0c85307bf4 | ||
|
|
90a5b3f6e6 | ||
|
|
0cf6cf4016 | ||
|
|
49d4854221 | ||
|
|
b6afa6e501 | ||
|
|
5fbb9339c6 | ||
|
|
7a662d93ed | ||
|
|
dffe77e32d | ||
|
|
fc5973c223 | ||
|
|
2b80641db8 | ||
|
|
be5eeb41c3 | ||
|
|
73a2fc66d3 | ||
|
|
5d6c1992e4 | ||
|
|
90b185f603 | ||
|
|
2a7e09ee39 | ||
|
|
f52b2449ea | ||
|
|
a91a231e61 | ||
|
|
615045cb18 | ||
|
|
1ee302d9e5 | ||
|
|
b3ca0ca2dd | ||
|
|
196fa09696 | ||
|
|
9d2256c090 | ||
|
|
b69743c0bd | ||
|
|
c41292d49f | ||
|
|
f1b9bbf33e | ||
|
|
de2155787d | ||
|
|
1f28734875 | ||
|
|
c49611967d | ||
|
|
9cf5cdf3bd | ||
|
|
8c12474531 | ||
|
|
bd60edd238 | ||
|
|
77c28076a2 | ||
|
|
ee7d486924 | ||
|
|
3bf9e2d6d3 | ||
|
|
e0c27fb750 | ||
|
|
672570330b | ||
|
|
afba49a3fa | ||
|
|
87cab133ac | ||
|
|
0f7b0dff14 | ||
|
|
b7f82d54dd | ||
|
|
276bf193a2 | ||
|
|
565acb6046 | ||
|
|
a6004fc5f3 | ||
|
|
aae9efb9ff | ||
|
|
ce07db748e | ||
|
|
5ff97858f8 | ||
|
|
855b62729d | ||
|
|
0a955f5df5 | ||
|
|
198484f762 | ||
|
|
2558d68f06 | ||
|
|
7ab12294fc | ||
|
|
23c65e449e | ||
|
|
1856302cd3 | ||
|
|
523bd4569d | ||
|
|
f6956fa472 | ||
|
|
fc0c616698 | ||
|
|
daccaf7ea5 | ||
|
|
12e5728a17 | ||
|
|
65d38b2403 | ||
|
|
21a1993015 | ||
|
|
dc032dcb3e | ||
|
|
152a6ca5cc | ||
|
|
fcf0ce80eb | ||
|
|
a4fe939f63 | ||
|
|
248c926a17 | ||
|
|
0f5c3d8967 | ||
|
|
ea2ec19b71 | ||
|
|
282e52cc73 | ||
|
|
46d43a29be | ||
|
|
0466a02dfd | ||
|
|
69207f5757 | ||
|
|
6b77d598fd | ||
|
|
fcc4125c6e | ||
|
|
41e6eef941 | ||
|
|
13ab4b12b7 | ||
|
|
34d53bf2d1 | ||
|
|
04a1de091c | ||
|
|
9bdf531c1a | ||
|
|
1ef21037a8 | ||
|
|
16b4c2d6d3 | ||
|
|
9ee60993ff | ||
|
|
dfb4efaa1c | ||
|
|
5d09e722e4 | ||
|
|
5763c6aec0 | ||
|
|
570da3dd1a | ||
|
|
ca2a00231b | ||
|
|
833c9a2a33 | ||
|
|
50bc1d6955 | ||
|
|
ba47395337 | ||
|
|
33f16dd3cc | ||
|
|
ef5bfa4bcd | ||
|
|
b4898583d7 | ||
|
|
7320937f62 | ||
|
|
d80805acec | ||
|
|
896ceecf3a | ||
|
|
7cb1eee386 | ||
|
|
da22b87fe1 | ||
|
|
41b8e71248 | ||
|
|
58b1651570 | ||
|
|
793d06c75e | ||
|
|
d13ecf1237 | ||
|
|
a63aef0239 | ||
|
|
b04e4aacce | ||
|
|
c81d1d9775 | ||
|
|
6af6d7b388 | ||
|
|
7dd5b4798a | ||
|
|
5108202419 | ||
|
|
2c7879131f | ||
|
|
6dd9fb03c3 | ||
|
|
b286d7ba43 | ||
|
|
fe0c2e0c83 | ||
|
|
aac9232bbc | ||
|
|
de30066be7 | ||
|
|
6ef0804bc4 | ||
|
|
356b260035 | ||
|
|
3d0fe7d756 | ||
|
|
5adada070f | ||
|
|
5bd0c4a033 | ||
|
|
588f1188c3 | ||
|
|
17bfdaaed0 | ||
|
|
07ba2ffbd1 | ||
|
|
5692a2880f | ||
|
|
d7e97687ce | ||
|
|
444562e47c | ||
|
|
046efe9613 | ||
|
|
d513f13afa | ||
|
|
a7bee0f5fb | ||
|
|
4b6b4abc70 | ||
|
|
8d787530b1 | ||
|
|
fe03eae213 | ||
|
|
cb68492c99 | ||
|
|
f8b3445dc3 | ||
|
|
1850eb18b5 | ||
|
|
5254fb9ef5 | ||
|
|
67c000f17b | ||
|
|
8ef0b6a1fa | ||
|
|
5d594e70c8 | ||
|
|
c0d61ca3d2 | ||
|
|
137e160f1a | ||
|
|
66c2eec1f9 | ||
|
|
338700bdcb | ||
|
|
029aba6b88 | ||
|
|
0cd5145df0 | ||
|
|
5784a0e271 | ||
|
|
2a883217c1 | ||
|
|
d5e114524c | ||
|
|
9e7ae520a8 | ||
|
|
4b443d8cf5 | ||
|
|
dc76c5b2b8 | ||
|
|
8d8263202f | ||
|
|
cc9387163e | ||
|
|
f70a973834 | ||
|
|
36b4e1cf31 | ||
|
|
5016aa01cc | ||
|
|
28a4cb5eec | ||
|
|
9e56842bc1 | ||
|
|
1d8edf4a01 | ||
|
|
aa51ed2bf5 | ||
|
|
a158d75712 | ||
|
|
05ef318133 | ||
|
|
b39f99b3a4 | ||
|
|
85a064430b | ||
|
|
070c276e26 | ||
|
|
1a7488d695 | ||
|
|
1ce62613ed | ||
|
|
b8a31e90ca | ||
|
|
5da19f1793 | ||
|
|
a8ccb2b90f | ||
|
|
8935a736cb | ||
|
|
411a0a20a9 | ||
|
|
89647c10bd | ||
|
|
ed4ac972b3 | ||
|
|
ee9b28ed98 | ||
|
|
658f284b44 | ||
|
|
169fed01f0 | ||
|
|
7230e4248a | ||
|
|
0ec0785dfd | ||
|
|
e846833ed6 | ||
|
|
ed0fdd4859 | ||
|
|
112ec0eb66 | ||
|
|
775e55f0ed | ||
|
|
3f9ef807d8 | ||
|
|
f0ed526a59 | ||
|
|
4203a01b04 | ||
|
|
aab5c3ca95 | ||
|
|
a2867df9a2 | ||
|
|
2202b20730 | ||
|
|
85702afc0c | ||
|
|
e5587aed7c | ||
|
|
aed0b5b65a | ||
|
|
c53702cdfa | ||
|
|
a6a3df9310 | ||
|
|
ec9824f227 | ||
|
|
260767e2f8 | ||
|
|
3a1f2b2d86 | ||
|
|
6eea4d1c7a | ||
|
|
a54addb694 | ||
|
|
6a7ef48ae8 | ||
|
|
6190234c51 | ||
|
|
39ba4e2b88 | ||
|
|
15cfa96553 | ||
|
|
4aad9facdf | ||
|
|
81026539d3 | ||
|
|
f672de674f | ||
|
|
68992d5582 | ||
|
|
f29c4c5b79 | ||
|
|
d5bfa0e3e1 | ||
|
|
f62dd6b632 | ||
|
|
6b07b70d7c | ||
|
|
62ecb8566e | ||
|
|
818ad2d2bb | ||
|
|
6c668d4bfe | ||
|
|
a1fbea07d2 | ||
|
|
effb36295d | ||
|
|
7c4cb187fe | ||
|
|
5a7c98264a | ||
|
|
a0f0a52410 | ||
|
|
3c40580396 | ||
|
|
e30c73d5c7 | ||
|
|
081cdbc6f3 | ||
|
|
04e3f5ee43 | ||
|
|
317df72fc8 | ||
|
|
a6d0c0b980 | ||
|
|
eb5e15fb6f | ||
|
|
f6879b3788 | ||
|
|
560ca214b0 | ||
|
|
4c485ed647 | ||
|
|
582066d017 | ||
|
|
3a88f3ad6c | ||
|
|
215d59823b | ||
|
|
4bd30423f2 | ||
|
|
f6cd99cef4 | ||
|
|
2695307ef0 | ||
|
|
b7b805f2af | ||
|
|
1e5f33aa64 | ||
|
|
cebd20a9b5 | ||
|
|
83201da15d | ||
|
|
0d8cfffb92 | ||
|
|
32417d3ae7 | ||
|
|
a488e89889 | ||
|
|
a33203e7a1 | ||
|
|
75478905d5 | ||
|
|
0ea9c0f576 | ||
|
|
fa41d2981c | ||
|
|
79e922203a | ||
|
|
ecf2cc9222 | ||
|
|
53f91d974a | ||
|
|
49f69f60db | ||
|
|
4b8cf6e04d | ||
|
|
fe18f80fc2 | ||
|
|
8e2892b95f | ||
|
|
4032b810b7 | ||
|
|
c9c22916be | ||
|
|
26b0b58320 | ||
|
|
9899d628ac | ||
|
|
9809ec7a04 | ||
|
|
51b4cc7e2c | ||
|
|
567b456890 | ||
|
|
ca6ebc3035 | ||
|
|
fec99b907b | ||
|
|
c38501ef50 | ||
|
|
9126e5b430 | ||
|
|
a48bde0b1d | ||
|
|
4c56b389ca | ||
|
|
dd2b5d743c | ||
|
|
086b3682e9 | ||
|
|
3fafbb392d | ||
|
|
5409f0b2e3 | ||
|
|
c958e2431f | ||
|
|
53847e3997 | ||
|
|
0f513ac88b | ||
|
|
735710f16b | ||
|
|
173707d82d | ||
|
|
cc546ea66d | ||
|
|
d5ad0371aa | ||
|
|
5f90eaf4ef | ||
|
|
d4dd027b0a | ||
|
|
a9fc2321a9 | ||
|
|
694db13760 | ||
|
|
ea01e3a683 | ||
|
|
44a38b3378 | ||
|
|
ff42d650ca | ||
|
|
3a6ceeea54 | ||
|
|
a265830e3b | ||
|
|
294a70b6be | ||
|
|
cadcbcc5f1 | ||
|
|
b3bcd3df8f | ||
|
|
e56f0f297b | ||
|
|
590d178e29 | ||
|
|
fe8970ee84 | ||
|
|
dc95563d96 | ||
|
|
09accce808 | ||
|
|
25f750378b | ||
|
|
52333c73d0 | ||
|
|
d4ecace64c | ||
|
|
f399ad547b | ||
|
|
84b5f63cbb | ||
|
|
3df0f1646b | ||
|
|
4eae3398a6 | ||
|
|
e438c25ebe | ||
|
|
a1e1748b2d | ||
|
|
0f5161164f | ||
|
|
74ad675a24 | ||
|
|
653b9a7632 | ||
|
|
d59ceee905 | ||
|
|
8ed8ecc3fd | ||
|
|
70805c9c6b | ||
|
|
9811674195 | ||
|
|
921d0f4c0d | ||
|
|
8de9ea008a | ||
|
|
09c410000a | ||
|
|
cfa5c7790f | ||
|
|
3576cc2ea7 | ||
|
|
4a7de3370a | ||
|
|
2038ab91b9 | ||
|
|
766bf33cec | ||
|
|
7102994dc6 | ||
|
|
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 |
@@ -9,6 +9,12 @@
|
||||
# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`.
|
||||
# Та же конструкция, что у golden-эталонов, и по той же причине: картинки
|
||||
# попадают в репозиторий через явное решение, а не через бота.
|
||||
#
|
||||
# Снимать здесь больше не обязанность, а удобство (#401). Приёмка проверяет не
|
||||
# место съёмки, а её воспроизводимость: каждый кадр, не объявленный изменённым,
|
||||
# должен совпасть с закоммиченным байт-в-байт. Эта джоба потому и удобна, что
|
||||
# среда у неё та же, в которой снят закоммиченный набор, — но принять получится
|
||||
# из любой, где кадры воспроизводятся, и не получится ни из одной, где нет.
|
||||
name: Скриншоты документации
|
||||
|
||||
on:
|
||||
@@ -71,14 +77,40 @@ jobs:
|
||||
"oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/oxipng" --version
|
||||
- name: Capture
|
||||
run: node demo/docs/capture.mjs
|
||||
# Съёмка идёт дважды и сравнивается по хешам (#422). Прежний замер
|
||||
# (`--stability`) отвечает на вопрос «плавает ли кадр от времени внутри
|
||||
# страницы» и остаётся ниже; дефект #410 был по другой оси — обрезка
|
||||
# плавала МЕЖДУ прогонами, и три снимка внутри одного процесса совпали бы
|
||||
# всегда. Проверка, объявленная гарантией воспроизводимости, на
|
||||
# собственном инциденте промолчала бы.
|
||||
#
|
||||
# Второй прогон заодно оставляет в `docs/images` кадры, которые и уедут в
|
||||
# артефакт: сравнивать хеши и публиковать разные файлы было бы странно.
|
||||
- name: "Съёмка воспроизводима между прогонами (#410, #422)"
|
||||
run: node scripts/capture-determinism.mjs
|
||||
# Вердикт до всякой приёмки. Само число изменившихся файлов ничего не
|
||||
# говорит: набор, снятый другим браузером, меняет их все, и это нормально
|
||||
# ровно один раз — при переходе на канонический прогон. Сравнивать надо
|
||||
# браузер: тот же Chromium и десять изменившихся картинок означают, что
|
||||
# изменился продукт (или что-то не так), другой Chromium — ожидаемую
|
||||
# разницу рендеринга.
|
||||
# Хеши печатаются в лог, а не только уезжают в артефакт (#410): чтобы
|
||||
# сравнить два прогона одного и того же SHA, нужен текст, который видно с
|
||||
# экрана. Именно так измеряется недетерминированность съёмки — и её
|
||||
# отсутствие после починки.
|
||||
# Съёмка обязана быть воспроизводимой, и это проверяется, а не
|
||||
# предполагается (#410): три снимка на сценарий в одном процессе, без
|
||||
# правки состояния между ними, обязаны совпасть побайтово. Шаг падает,
|
||||
# если кадр снова начнёт зависеть от времени.
|
||||
#
|
||||
# Шаг стоит ПОСЛЕ съёмки и до вердикта: публикацию артефакта он всё равно
|
||||
# блокирует падением job'а, а снимать набор третий раз ради порядка строк
|
||||
# в логе незачем.
|
||||
- name: "Кадр не плавает внутри одного состояния (#410)"
|
||||
run: node demo/docs/capture.mjs --stability=3
|
||||
- name: Хеши кадров
|
||||
run: |
|
||||
for f in docs/images/*.png; do sha256sum "$f"; done
|
||||
- name: Вердикт
|
||||
run: |
|
||||
# Поле манифеста «до» — из закоммиченного состояния, «после» — из
|
||||
|
||||
@@ -31,8 +31,13 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Группа зависит от события (#472). Прежде она была одна на всё, и ручной
|
||||
# запуск перед релизом отменял идущий по расписанию — так 24.08 погиб
|
||||
# еженедельный прогон, а отменённый в списке выглядит «не красным». Поймать
|
||||
# отмену изнутри нельзя: вместе с прогоном отменяются и не начавшиеся job,
|
||||
# включая любой репортёр. Значит отмену надо не ловить, а не допускать.
|
||||
concurrency:
|
||||
group: mutation-gate
|
||||
group: mutation-gate-${{ github.event_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -61,10 +66,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
|
||||
@@ -83,5 +93,104 @@ jobs:
|
||||
- name: Тёплый test-build для инкрементальной компиляции мутантов
|
||||
run: npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||||
|
||||
# Вывод шарда сохраняется артефактом (#472): строки
|
||||
# `FAIL <id>: тест остался зелёным…` — единственное место, где названо,
|
||||
# ЧТО сбежало. Без артефакта отказ безымянный. `PIPESTATUS` — чтобы
|
||||
# `tee` не съел код выхода раннера.
|
||||
- name: Каждый тест ловит свою поломку
|
||||
run: node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
set -o pipefail
|
||||
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}.log
|
||||
- name: Сохранить лог шарда
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mutation-shard-${{ matrix.shard }}
|
||||
path: artifacts/mutation-shard-${{ matrix.shard }}.log
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
# Адресат у отказа (#472). Только по расписанию: ручные прогоны перед
|
||||
# релизом падают по замыслу, их результат владелец смотрит сам — issue на
|
||||
# каждый такой отказ был бы шумом, который снова перестанут читать.
|
||||
#
|
||||
# Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент —
|
||||
# validate.yml, job с actions: read): перечислены все три.
|
||||
report:
|
||||
name: "Отказ расписания: issue и Telegram"
|
||||
needs: mutants
|
||||
if: always() && github.event_name == 'schedule' && needs.mutants.result != 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: dev
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Забрать логи шардов
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: mutation-shard-*
|
||||
path: artifacts/mutation-logs
|
||||
- name: Собрать отчёт
|
||||
id: report
|
||||
env:
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
# SHA — того дерева, которое чекаутили и гоняли (dev), а не
|
||||
# github.sha: для расписания это вершина default-ветки main, и отчёт
|
||||
# называл бы «dev @ <SHA main>» (ревью r1). Образец — process.yml.
|
||||
SHA=$(git rev-parse HEAD)
|
||||
mkdir -p artifacts
|
||||
node scripts/mutation-gate-report.mjs \
|
||||
--logs=artifacts/mutation-logs --shards=4 \
|
||||
--run-url="$RUN_URL" --ref=dev --sha="$SHA" \
|
||||
--body-out=artifacts/mutation-report.md \
|
||||
--telegram-out=artifacts/mutation-telegram.txt >> "$GITHUB_OUTPUT"
|
||||
# Одно issue, а не одно на неделю: открытое с тем же маркером в заголовке
|
||||
# получает комментарий, новое заводится только если открытого нет.
|
||||
- name: Issue — создать или дописать
|
||||
id: issue
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
MARKER: ${{ steps.report.outputs.marker }}
|
||||
TITLE: ${{ steps.report.outputs.title }}
|
||||
run: |
|
||||
existing=$(gh issue list --repo "$REPO" --state open --search "\"$MARKER\" in:title" \
|
||||
--json number,title --jq '[.[] | select(.title | startswith(env.MARKER))][0].number // empty')
|
||||
if [ -n "$existing" ]; then
|
||||
gh issue comment "$existing" --repo "$REPO" --body-file artifacts/mutation-report.md
|
||||
url="${{ github.server_url }}/$REPO/issues/$existing"
|
||||
else
|
||||
url=$(gh issue create --repo "$REPO" --title "$TITLE" \
|
||||
--label infra --label process --label tests \
|
||||
--body-file artifacts/mutation-report.md)
|
||||
fi
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "issue: $url"
|
||||
# Тот же канал, что у релизов (announce.yml). Нет секретов — не отказ:
|
||||
# issue уже заведено, а Telegram — второй адресат, не единственный.
|
||||
- name: Telegram
|
||||
if: always() && steps.issue.outcome == 'success'
|
||||
env:
|
||||
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
ISSUE_URL: ${{ steps.issue.outputs.url }}
|
||||
run: |
|
||||
if [ -z "$TOKEN" ] || [ -z "$CHAT" ]; then
|
||||
echo "::warning::TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID не заданы — оповещение пропущено, issue заведено"
|
||||
exit 0
|
||||
fi
|
||||
TEXT=$(sed "s|(issue)|$ISSUE_URL|" artifacts/mutation-telegram.txt)
|
||||
curl -sS --fail-with-body -X POST \
|
||||
"https://api.telegram.org/bot$TOKEN/sendMessage" \
|
||||
--data-urlencode "chat_id=$CHAT" \
|
||||
--data-urlencode "text=$TEXT" \
|
||||
-d disable_web_page_preview=true
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Ночной полный прогон (#479).
|
||||
#
|
||||
# Тяжёлые job Validate — смоки, golden, performance_smoke — на обычном пуше не
|
||||
# идут: они ни разу не ловили дефект в момент ревью и стоили ~6 минут
|
||||
# критического пути на каждую итерацию. Полный набор идёт на кандидате беты
|
||||
# (трейлер `Release:`), по кнопке и здесь — каждую ночь на голове `dev`.
|
||||
#
|
||||
# Почему не `schedule` прямо в validate.yml: расписание исполняется на ветке
|
||||
# по умолчанию (`main`), а проверять надо `dev`. Один dispatch с `--ref dev`
|
||||
# делает это без переписывания checkout во всех job. Reuse (#208) сохраняется:
|
||||
# при неизменённом дереве ночной прогон обойдётся маркерами.
|
||||
#
|
||||
# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт:
|
||||
# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и
|
||||
# там полный набор идёт заново.
|
||||
name: Ночной полный прогон dev
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 2 * * *'
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
name: "Запустить Validate на dev с полным набором"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh workflow run validate.yml --repo "$REPO" --ref dev -f full=true
|
||||
echo "Validate(dev, full=true) поставлен в очередь: $(date -u +%FT%TZ)"
|
||||
@@ -36,7 +36,9 @@ jobs:
|
||||
profile:
|
||||
- large-house
|
||||
- isometric
|
||||
- isometric-stage3
|
||||
- plan-snap
|
||||
- interaction
|
||||
- blend
|
||||
- overlay
|
||||
- space-default
|
||||
@@ -186,10 +188,18 @@ jobs:
|
||||
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
|
||||
;;
|
||||
isometric-stage3)
|
||||
npm run benchmark:isometric-stage3-dense -- --allow-stage2-base --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-stage3-baseline.json
|
||||
npm run benchmark:isometric-stage3-dense -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-stage3-candidate.json
|
||||
;;
|
||||
plan-snap)
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
|
||||
;;
|
||||
interaction)
|
||||
npm run benchmark:large-house-interaction -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/interaction-baseline.json
|
||||
npm run benchmark:large-house-interaction -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/interaction-candidate.json
|
||||
;;
|
||||
blend)
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
|
||||
@@ -230,11 +240,17 @@ jobs:
|
||||
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
|
||||
;;
|
||||
isometric)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-comparison.json
|
||||
;;
|
||||
isometric-stage3)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-isometric-stage3-dense.json --baseline=../artifacts/performance/isometric-stage3-baseline.json --candidate=../artifacts/performance/isometric-stage3-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-stage3-comparison.json
|
||||
;;
|
||||
plan-snap)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
|
||||
;;
|
||||
interaction)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-interaction.json --baseline=../artifacts/performance/interaction-baseline.json --candidate=../artifacts/performance/interaction-candidate.json --output=../artifacts/performance/interaction-comparison.json
|
||||
;;
|
||||
blend)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
|
||||
;;
|
||||
|
||||
@@ -43,9 +43,19 @@ jobs:
|
||||
spent: ${{ steps.decide.outputs.spent }}
|
||||
limit: ${{ steps.decide.outputs.limit }}
|
||||
steps:
|
||||
# Мелкий checkout: guard остаётся лёгким, но ему нужен
|
||||
# scripts/review-doc-guard.mjs — счёт раундов вынесен туда, потому что
|
||||
# inline-shell не покрывается тестами (#454). Node на раннере
|
||||
# предустановлен, setup-node не нужен.
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
- id: decide
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
LABEL: ${{ github.event.label.name }}
|
||||
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
|
||||
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
|
||||
@@ -87,19 +97,78 @@ jobs:
|
||||
#
|
||||
# Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал
|
||||
# цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по
|
||||
# имени документа в теле комментария; документа нет — вердикт не
|
||||
# посчитается. Недосчёт даёт лишний заход, перерасчёт остановил бы
|
||||
# работу досрочно: из двух ошибок выбрана обратимая.
|
||||
# имени документа — раньше по подстроке маркера в теле комментария,
|
||||
# теперь по имени документа ЭТОЙ задачи, `<MARKER>-<NUM>`: голая
|
||||
# подстрока протекала на прозе. #454 поймала это на себе — разбор
|
||||
# чужих задач в комментарии содержал `CODE-REVIEW`, и первый же
|
||||
# код-ревью получил заход r3.
|
||||
#
|
||||
# Счёт по комментариям остаётся ровно тем же, но он БОЛЬШЕ НЕ
|
||||
# ЕДИНСТВЕННЫЙ (#454). Маркер этапа попадает в тело комментария,
|
||||
# только если ревьюер сам назвал имя файла, — то есть прежний счёт
|
||||
# зависел от формулировки. На #449 первый спек-вердикт файла не
|
||||
# назвал, заход r2 получил номер r1, и документ второго раунда лёг
|
||||
# ПОВЕРХ документа первого. Оценка «недосчёт обратим» была неверна
|
||||
# ровно здесь: номер захода входит в имя файла, и повтор номера — не
|
||||
# лишний заход, а потеря артефакта.
|
||||
#
|
||||
# Поэтому рядом встаёт второй источник — опубликованные документы:
|
||||
# их имена несёт сам конвейер, и подделать их прозой нельзя. Берётся
|
||||
# МАКСИМУМ двух источников: недосчёт возможен только при отказе
|
||||
# обоих, перерасчёт невозможен по построению.
|
||||
attempt=1; spent=0; spent_list=""
|
||||
if [ -n "$stage" ]; then
|
||||
comments=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json comments)
|
||||
of_stage="[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))]"
|
||||
# Блокирующим считается вердикт, у которого в строке вердикта стоит
|
||||
# «жёлтый» или «красный». Регистр и окружение слова не важны.
|
||||
blocking="$of_stage | map(select(.body | test(\"Вердикт:[^\\n]*(жёлт|красн)\"; \"i\")))"
|
||||
attempt=$(( $(printf '%s' "$comments" | jq -r "$of_stage | length") + 1 ))
|
||||
spent=$(printf '%s' "$comments" | jq -r "$blocking | length")
|
||||
spent_list=$(printf '%s' "$comments" | jq -r "$blocking | map(\"- \" + .url) | join(\"\\n\")")
|
||||
comments=$(mktemp)
|
||||
gh issue view "$NUM" --repo "$REPO" --json comments > "$comments"
|
||||
|
||||
# Ветка задачи — та же, что выберет шаг ревью: свежая по коммиту.
|
||||
# Её нет у задач, размеченных до появления конвейера; тогда счёт по
|
||||
# файлам даёт ноль и работает страховка по комментариям.
|
||||
branch=""; newest=""
|
||||
for ref in $(gh api "repos/$REPO/git/matching-refs/heads/issue/$NUM-" \
|
||||
--jq '.[].ref' 2>/dev/null | sed 's|^refs/heads/||'); do
|
||||
date=$(gh api "repos/$REPO/commits/$ref" --jq '.commit.committer.date' 2>/dev/null || true)
|
||||
if [ -n "$date" ] && { [ -z "$newest" ] || [ "$date" \> "$newest" ]; }; then
|
||||
newest="$date"; branch="$ref"
|
||||
fi
|
||||
done
|
||||
target="${branch:-dev}"
|
||||
names=$(mktemp); docs=$(mktemp -d)
|
||||
if ! gh api "repos/$REPO/contents/docs/reviews?ref=$target" --jq '.[].name' \
|
||||
> "$names" 2>/dev/null; then
|
||||
: > "$names"
|
||||
echo "::warning::список docs/reviews на $target не получен — счёт по файлам отключён"
|
||||
fi
|
||||
# Каталог перечисляется одним ответом до 1000 записей; за этой
|
||||
# границей ответ молча обрежется, и счёт по файлам занизится.
|
||||
if [ "$(grep -c . "$names")" -ge 1000 ]; then
|
||||
echo "::warning::в docs/reviews не меньше 1000 файлов — листинг contents обрезается, счёт по файлам ненадёжен"
|
||||
fi
|
||||
# Тела нужны только своим документам этапа: их единицы.
|
||||
for name in $(grep -E "^${marker}-${NUM}-r[0-9]+\\.md$" "$names" || true); do
|
||||
gh api "repos/$REPO/contents/docs/reviews/$name?ref=$target" \
|
||||
-H 'Accept: application/vnd.github.raw' > "$docs/$name" 2>/dev/null || rm -f "$docs/$name"
|
||||
done
|
||||
list=$(mktemp)
|
||||
counters=$(node scripts/review-doc-guard.mjs --counters \
|
||||
--marker="$marker" --num="$NUM" --names="$names" --docs="$docs" \
|
||||
--comments="$comments" --spent-list="$list")
|
||||
spent_list=$(cat "$list")
|
||||
# Пустой ответ означает, что скрипт не отработал. Тогда остаются
|
||||
# значения по умолчанию (заход 1, циклов 0): guard обязан
|
||||
# продолжить работу, а не встать.
|
||||
new_attempt=$(printf '%s\n' "$counters" | sed -n 's/^attempt=//p')
|
||||
new_spent=$(printf '%s\n' "$counters" | sed -n 's/^spent=//p')
|
||||
new_blocking=$(printf '%s\n' "$counters" | sed -n 's/^blocking=//p')
|
||||
case "$new_attempt" in ''|*[!0-9]*) echo "::warning::счётчик раундов не дал числа — работают значения по умолчанию" ;; *) attempt="$new_attempt" ;; esac
|
||||
case "$new_spent" in ''|*[!0-9]*) : ;; *) spent="$new_spent" ;; esac
|
||||
# Перечень учтённого обязан сходиться с числом: если цикл виден
|
||||
# только документом, ссылка на комментарий его не объяснит.
|
||||
if [ -n "$new_blocking" ]; then
|
||||
spent_list="$spent_list
|
||||
- документы: $new_blocking"
|
||||
fi
|
||||
echo "ветка материала: ${branch:-нет, читался dev}"
|
||||
fi
|
||||
|
||||
# Отказ обязан быть виден в issue, а не только в логе прогона.
|
||||
@@ -226,6 +295,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"
|
||||
@@ -629,6 +712,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. Раньше шаг в этом случае молча
|
||||
@@ -677,6 +763,14 @@ 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 не найден — документа для публикации нет"
|
||||
fi
|
||||
@@ -745,6 +839,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'
|
||||
|
||||
@@ -54,6 +54,23 @@ jobs:
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
|
||||
# #479: тяжёлые job Validate идут только на коммите с трейлером `Release:`.
|
||||
# Зелёный Validate без трейлера означал бы прогон без смоков и golden —
|
||||
# класс тихого пропуска #171/#207, поэтому трейлер проверяется здесь явно.
|
||||
- name: Require the Release trailer on the candidate commit
|
||||
env:
|
||||
SHA: ${{ steps.candidate.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git log -1 --format=%B "$SHA" > /tmp/head-message.txt
|
||||
if ! grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+' /tmp/head-message.txt; then
|
||||
echo "::error::Candidate $SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
|
||||
exit 1
|
||||
fi
|
||||
# #479: свежесть скриншотов на обычном пуше — предупреждение; на
|
||||
# кандидате она обязана быть доказана строгим режимом.
|
||||
- name: Documentation screenshots are fresh for the candidate
|
||||
run: node scripts/check-docs.mjs --screenshots=strict
|
||||
- name: Require green Validate for this exact SHA
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -34,6 +34,12 @@ jobs:
|
||||
# target_commitish (it may be a branch name) or an event-context SHA.
|
||||
SHA=$(git rev-parse HEAD)
|
||||
echo "release tag: $TAG; exact commit: $SHA"
|
||||
# #479: тяжёлые job Validate идут только на коммите с трейлером
|
||||
# `Release:`; без него зелёный Validate прогона без смоков не доказывает.
|
||||
if ! git log -1 --format=%B "$SHA" | grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+'; then
|
||||
echo "::error::$SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
|
||||
exit 1
|
||||
fi
|
||||
node scripts/release-gate.mjs "$SHA"
|
||||
- name: Require full performance for a stable release
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
|
||||
@@ -13,11 +13,22 @@ on:
|
||||
paths-ignore:
|
||||
- 'docs/reviews/**'
|
||||
pull_request:
|
||||
# Полный набор тяжёлых job по кнопке и по ночам (#479): nightly.yml делает
|
||||
# `gh workflow run validate.yml --ref dev -f full=true`.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
full:
|
||||
description: 'Полный набор: смоки, golden, performance_smoke'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
# A new push supersedes an unfinished validation for the same branch or PR.
|
||||
# Exact-SHA release gates never depend on an obsolete commit.
|
||||
# Полный прогон по кнопке/ночью (#479) живёт в своей группе: первый ручной
|
||||
# запуск отменил очередной push конвейера через полторы минуты — ночной
|
||||
# результат обнулялся бы любым коммитом, пришедшим в то же окно.
|
||||
concurrency:
|
||||
group: validate-${{ github.event.pull_request.number || github.ref }}
|
||||
group: validate-${{ github.event_name == 'workflow_dispatch' && 'dispatch-' || '' }}${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -32,6 +43,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 нужны сообщения
|
||||
# коммитов, трейлеры и ИМЕНА изменённых файлов, а не содержимое старых
|
||||
@@ -46,35 +64,80 @@ jobs:
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
|
||||
# Свежесть скриншотов на обычном пуше — предупреждение, на кандидате беты
|
||||
# (трейлер `Release:`), по кнопке и на PR — ошибка (#479). Остальные
|
||||
# проверки документации красят всегда. Режим считает тот же скрипт, что и
|
||||
# выход `heavy` job `changes`, чтобы два места не разошлись.
|
||||
- name: "Документация: гайды, ченджлоги, скриншот-индекс"
|
||||
id: docs
|
||||
continue-on-error: true
|
||||
run: node scripts/check-docs.mjs --external
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
FULL_INPUT: ${{ inputs.full }}
|
||||
run: |
|
||||
heavy=$(node scripts/classify-changes.mjs --heavy)
|
||||
mode=warn
|
||||
[ "$heavy" = "heavy=true" ] && mode=strict
|
||||
echo "скриншоты документации: режим $mode ($heavy)"
|
||||
node scripts/check-docs.mjs --external --screenshots=$mode
|
||||
|
||||
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
|
||||
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
|
||||
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
|
||||
- name: "Процесс: process.yml идентичен в main и dev"
|
||||
- name: "Процесс: process.yml и mutation-gate.yml идентичны в main и dev"
|
||||
id: workflow_sync
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git fetch --quiet origin main dev
|
||||
if diff <(git show origin/main:.github/workflows/process.yml) \
|
||||
<(git show origin/dev:.github/workflows/process.yml); then
|
||||
echo "main и dev идентичны"
|
||||
else
|
||||
echo "РАСХОЖДЕНИЕ: process.yml в main и dev различаются."
|
||||
echo "Конвейер исполняет версию из ветки по умолчанию, поэтому"
|
||||
echo "правку нужно отправить в обе ветки."
|
||||
exit 1
|
||||
fi
|
||||
# #472: расписание mutation-gate.yml тоже исполняется из ветки по
|
||||
# умолчанию — та же ловушка, что у process.yml. Сверяются оба.
|
||||
status=0
|
||||
for file in process.yml mutation-gate.yml; do
|
||||
if diff <(git show "origin/main:.github/workflows/$file") \
|
||||
<(git show "origin/dev:.github/workflows/$file"); then
|
||||
echo "$file: main и dev идентичны"
|
||||
else
|
||||
echo "РАСХОЖДЕНИЕ: $file в main и dev различаются."
|
||||
echo "Файл исполняется из ветки по умолчанию, поэтому"
|
||||
echo "правку нужно отправить в обе ветки."
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
exit $status
|
||||
|
||||
# Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до
|
||||
# #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 +155,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
|
||||
@@ -125,7 +188,7 @@ jobs:
|
||||
}
|
||||
echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY"
|
||||
check "документация" "$DOCS"
|
||||
check "process.yml в main и dev" "$WORKFLOW_SYNC"
|
||||
check "process.yml и mutation-gate.yml в main и dev" "$WORKFLOW_SYNC"
|
||||
check "провенанс коммитов" "$PROVENANCE"
|
||||
check "процессный гейт" "$PROCESS_GATE"
|
||||
exit $fail
|
||||
@@ -140,64 +203,126 @@ 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 }}
|
||||
perf_iso: ${{ steps.classify.outputs.perf_iso }} # #473 §5
|
||||
perf_interaction: ${{ steps.classify.outputs.perf_interaction }}
|
||||
mutants: ${{ steps.classify.outputs.mutants }}
|
||||
# Тяжёлые job только на кандидате/по кнопке/на PR (#479), см. шаг heavy.
|
||||
heavy: ${{ steps.heavy.outputs.heavy }}
|
||||
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) это дало зелёный статус ветки
|
||||
# без единого исполненного тяжёлого гейта.
|
||||
# Смоки, golden, performance_smoke — на кандидате беты (трейлер
|
||||
# `Release:`), по `workflow_dispatch full=true`, на PR (#479). Логика и
|
||||
# её тест — scripts/classify-changes.mjs.
|
||||
- id: heavy
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
FULL_INPUT: ${{ inputs.full }}
|
||||
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
|
||||
- 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: |
|
||||
if [ "$REF" = "refs/heads/dev" ]; then
|
||||
echo "dev: без фильтров, всё true"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
|
||||
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"
|
||||
node scripts/classify-changes.mjs --all >> "$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 "база не определена — полный прогон без классификации"
|
||||
node scripts/classify-changes.mjs --all >> "$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 "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
# Шаблоны — в scripts/classify-changes.mjs: там они проверяемы
|
||||
# unit-тестом, здесь — только переписываются в выходы (#473 AC8).
|
||||
printf '%s\n' "$files" | node scripts/classify-changes.mjs | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
|
||||
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
|
||||
@@ -214,6 +339,10 @@ jobs:
|
||||
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
|
||||
reuse:
|
||||
name: "Переиспользование: это дерево уже проверено"
|
||||
# `changes` нужна ради набора профилей перф-смока (#473): ключ
|
||||
# performance_smoke включает набор, иначе glow-only результат засчитался бы
|
||||
# прогону, которому нужен изометрический профиль.
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
smoke: ${{ steps.probe.outputs.smoke }}
|
||||
@@ -223,6 +352,7 @@ jobs:
|
||||
smoke_key: ${{ steps.keys.outputs.smoke }}
|
||||
golden_key: ${{ steps.keys.outputs.golden }}
|
||||
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
|
||||
performance_smoke_set: ${{ steps.keys.outputs.performance_smoke_set }}
|
||||
backend_key: ${{ steps.keys.outputs.backend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -230,12 +360,21 @@ jobs:
|
||||
with: { node-version: 22 }
|
||||
- name: Ключи переиспользования
|
||||
id: keys
|
||||
env:
|
||||
PERF_ISO: ${{ needs.changes.outputs.perf_iso }}
|
||||
PERF_INTERACTION: ${{ needs.changes.outputs.perf_interaction }}
|
||||
run: |
|
||||
for job in smoke golden performance_smoke backend; do
|
||||
key=$(node scripts/gate-reuse.mjs --job="$job")
|
||||
echo "$job=$key" >> "$GITHUB_OUTPUT"
|
||||
echo "$job: $key"
|
||||
done
|
||||
# Набор профилей перф-смока — часть ключа (#473 AC5).
|
||||
set=glow
|
||||
[ "$PERF_ISO" = "true" ] && set="$set-iso"
|
||||
[ "$PERF_INTERACTION" = "true" ] && set="$set-interaction"
|
||||
echo "performance_smoke_set=$set" >> "$GITHUB_OUTPUT"
|
||||
echo "performance_smoke set: $set"
|
||||
# lookup-only: маркер только проверяется, но не восстанавливается —
|
||||
# сохранять его в этой job нечего, она ничего не прогоняла.
|
||||
- name: Маркер smoke
|
||||
@@ -257,7 +396,7 @@ jobs:
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}
|
||||
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}-${{ steps.keys.outputs.performance_smoke_set }}
|
||||
lookup-only: true
|
||||
- name: Маркер backend
|
||||
id: m_backend
|
||||
@@ -314,6 +453,97 @@ jobs:
|
||||
- name: Hassfest validation
|
||||
uses: home-assistant/actions/hassfest@master
|
||||
|
||||
# Мутанты по диффу (#475). Свидетель гниёт двумя способами: изменился
|
||||
# файл, который он патчит, либо изменился его гард. `--check` этого не
|
||||
# видит — он проверяет существование якоря, а не достижимость ветки.
|
||||
# Полный прогон раз в неделю ловит гниль с опозданием до релиза (#466,
|
||||
# #467). Здесь гоняются только затронутые мутанты с той же базы диапазона,
|
||||
# что и остальные гейты ветки (#387/#388). Релизный диапазон всё же может
|
||||
# задеть сотню свидетелей, поэтому #480 делит тот же набор на три
|
||||
# детерминированных непересекающихся шарда вместо ослабления проверки.
|
||||
changed_mutants:
|
||||
name: "Мутанты по диффу (${{ matrix.shard }}/3): затронутые свидетели краснеют"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mutants == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- name: Backend test dependencies
|
||||
run: pip install -r tests_backend/requirements.txt
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
# Журнал пойманных свидетелей (#481). Отменённый или упавший по таймауту
|
||||
# прогон не пропадает: журнал пишется после каждого пойманного мутанта и
|
||||
# сохраняется при любом исходе шага, а следующий пуш начинает с того
|
||||
# места, где предыдущий остановился. Мутант с тем же отпечатком входов
|
||||
# (файлы патча и гарда, без строки версии) повторно не гоняется.
|
||||
- name: Журнал свидетелей (последний по шарду)
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: artifacts/mutation-ledger
|
||||
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}
|
||||
restore-keys: mutation-ledger-${{ matrix.shard }}-
|
||||
- name: Затронутые мутанты ловятся
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
PROVEN_BASE: ${{ needs.changes.outputs.range_base }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
REF: ${{ github.ref }}
|
||||
SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch -q origin dev
|
||||
zero=$(printf '%040d' 0)
|
||||
base=""
|
||||
if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then
|
||||
base="${PROVEN_BASE:-$BEFORE_SHA}"
|
||||
fi
|
||||
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|
||||
|| ! git cat-file -e "$base" 2>/dev/null; then
|
||||
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "")
|
||||
fi
|
||||
if [ -z "$base" ]; then
|
||||
echo "не нашёл базу диапазона — мутанты по диффу пропущены, это увидит следующий пуш"
|
||||
exit 0
|
||||
fi
|
||||
echo "диапазон: $base..$HEAD_SHA"
|
||||
npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||||
node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/3" \
|
||||
--ledger="artifacts/mutation-ledger/shard-$SHARD.json"
|
||||
# Сохраняется всегда: и после красного шага (пойманные до отказа уже
|
||||
# записаны), и при отмене прогона — post-шаг cache/save исполняется,
|
||||
# пока job не убита целиком. Ключ уникален на прогон: cache не
|
||||
# перезаписывает существующий ключ, а restore-keys берёт самый новый.
|
||||
- name: Сохранить журнал свидетелей
|
||||
if: always()
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: artifacts/mutation-ledger
|
||||
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}
|
||||
|
||||
frontend:
|
||||
name: "Фронтенд: типы, юниты, мутанты, синхрон бандла"
|
||||
needs: changes
|
||||
@@ -339,17 +569,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
|
||||
@@ -395,9 +632,10 @@ jobs:
|
||||
smoke:
|
||||
name: Смоки в браузере (шард ${{ matrix.shard }} из 3)
|
||||
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
needs: [changes, frontend, reuse]
|
||||
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
|
||||
# критическим путём всего Validate. Три шарда режут его примерно вдвое;
|
||||
# цена — трижды `npm ci` и сборка, около двух оплаченных минут раннера.
|
||||
@@ -441,6 +679,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 }}
|
||||
@@ -457,10 +703,20 @@ jobs:
|
||||
if [ $(( (index - 1) % SHARDS + 1 )) -ne "$SHARD" ]; then continue; fi
|
||||
ran=$((ran + 1))
|
||||
name=$(basename "$f" .mjs)
|
||||
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
|
||||
if timeout --kill-after=10s 180s node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
|
||||
echo "ok $name"
|
||||
else
|
||||
status=$?
|
||||
echo "FAIL $name"
|
||||
if [ "$status" -eq 124 ]; then
|
||||
echo "diagnostic smoke-timeout: $name exceeded 180s"
|
||||
fi
|
||||
# Диагностические строки смока печатаются раньше вердикта, и
|
||||
# `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
|
||||
@@ -480,8 +736,8 @@ jobs:
|
||||
# частично прогнанная матрица не имеет права выглядеть как выполненная работа.
|
||||
smoke_done:
|
||||
name: "Смоки: все шарды зелёные"
|
||||
needs: [smoke, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
needs: [changes, smoke, reuse]
|
||||
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Записать маркер успеха
|
||||
@@ -503,8 +759,8 @@ jobs:
|
||||
name: Golden-кадры против принятых эталонов
|
||||
# Deterministic visual correctness stays in every prerelease gate: it is
|
||||
# inexpensive and catches a different class of regressions than timings.
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.golden != 'true'
|
||||
needs: [changes, frontend, reuse]
|
||||
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.golden != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -537,6 +793,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 +817,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: Записать маркер успеха
|
||||
@@ -574,13 +857,14 @@ jobs:
|
||||
name: "Перф-смок: бюджет времени кадра"
|
||||
# Candidate-only catastrophic-regression guard for ordinary pushes and
|
||||
# prereleases. The expensive same-runner comparison lives in performance.yml.
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.performance_smoke != 'true'
|
||||
needs: [changes, frontend, reuse]
|
||||
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.performance_smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# 15 минут не хватало, когда установка браузера шла через apt: замер
|
||||
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
|
||||
# job укладывается в те же минуты, что и раньше.
|
||||
timeout-minutes: 20
|
||||
# job укладывается в те же минуты, что и раньше. Диффозависимые профили
|
||||
# (#473) добавляют до двух замеров по 3 образца — окно расширено под них.
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
@@ -620,10 +904,26 @@ jobs:
|
||||
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
|
||||
# Диффозависимые профили (#473 §5): изометрия — когда дифф задел
|
||||
# `src/iso-*`, взаимодействие — когда живой путь или оркестраторы кадра.
|
||||
# Абсолютные потолки — `hardMaxMs` полных профилей: регрессия #160
|
||||
# (9 870 мс против 3 500) ловится с одного образца, шум — нет.
|
||||
- name: Изометрический профиль по диффу (#473)
|
||||
if: needs.changes.outputs.perf_iso == 'true'
|
||||
run: |
|
||||
npm run benchmark:large-house -- --profile=large-house-isometric-v1 --samples=3 --warmups=1 --output=artifacts/performance-smoke/isometric-candidate.json
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-isometric-smoke.json --candidate=artifacts/performance-smoke/isometric-candidate.json --output=artifacts/performance-smoke/isometric-comparison.json
|
||||
- name: Профиль взаимодействия по диффу (#473)
|
||||
if: needs.changes.outputs.perf_interaction == 'true'
|
||||
run: |
|
||||
npm run benchmark:large-house -- --profile=large-house-interaction-v1 --samples=3 --warmups=1 --output=artifacts/performance-smoke/interaction-candidate.json
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-interaction-smoke.json --candidate=artifacts/performance-smoke/interaction-candidate.json --output=artifacts/performance-smoke/interaction-comparison.json
|
||||
# #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера),
|
||||
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
|
||||
- name: Бюджеты ограничений стыков (#330)
|
||||
run: npm run benchmark:junction-limits
|
||||
- name: Быстрый commit точки стены (#461)
|
||||
run: npm run benchmark:wall-draw-click -- --output=artifacts/performance-smoke/wall-draw-click.json
|
||||
- name: Upload performance smoke report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -645,7 +945,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
|
||||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}-${{ needs.reuse.outputs.performance_smoke_set }}
|
||||
|
||||
backend:
|
||||
name: "Бэкенд: pytest в Home Assistant"
|
||||
@@ -659,10 +959,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: Записать маркер успеха
|
||||
|
||||
@@ -153,8 +153,12 @@ in between.
|
||||
|
||||
If the rebase conflicts the pipeline says so in the issue and sends the task back
|
||||
to `S6-in-progress`. The verdict still stands: nothing needs reviewing again, the
|
||||
remaining work is the rebase. Resolve it, push the branch, re-apply
|
||||
`S7-code-review`. The second review run is not a formality — after a rebase onto a
|
||||
remaining work is the rebase. When the conflict is only in the committed bundle
|
||||
(`dist/**`, `custom_components/houseplan/frontend/**` — the usual case when two
|
||||
tasks built it in parallel), run `node scripts/rebase-on-dev.mjs` (#479): it takes
|
||||
`dev`'s copy through the rebase, rebuilds with `npm run bundle:sync` and amends
|
||||
the result into your last commit; a conflict anywhere else aborts and leaves the
|
||||
tree as it was. Then push the branch and re-apply `S7-code-review`. The second review run is not a formality — after a rebase onto a
|
||||
moved `dev` this is different code, and accepting it unchecked is how regressions
|
||||
arrive. Cycles are counted per stage, so a code review spends its own budget.
|
||||
|
||||
@@ -334,6 +338,23 @@ npm run bundle:sync # dist → custom_components + demo/srv/assets (#255)
|
||||
npm run bundle:budget # initial View graph <= 256000 B gzip (#337)
|
||||
```
|
||||
|
||||
`npm run gate:small` runs the mandatory part of PROCESS §8 in one go (#479):
|
||||
unit tests, build with typecheck, `no-new-any` and `smoke-select` in parallel,
|
||||
then the bundle-tree comparison and the bundle budget. It prints the smokes the
|
||||
diff selects but does not run them — those, `golden`, `pytest` and `check-docs
|
||||
--screenshots=strict` remain the author's call by diff and AC.
|
||||
|
||||
**Heavy CI gates run on the beta candidate, nightly and on demand — not on every
|
||||
push (#479).** `smoke`, `golden` and `performance_smoke` in Validate are gated
|
||||
on the `heavy` output: true for a head commit carrying a `Release:` trailer, for
|
||||
`workflow_dispatch full=true` (which `nightly.yml` issues on `dev` every night)
|
||||
and for pull requests. A plain push to `dev` runs preflight, frontend (types,
|
||||
units, build, bundle sync, no-new-any), backend, hacs and hassfest. Screenshot
|
||||
freshness in `check-docs` is likewise a warning on a plain push and an error on
|
||||
the candidate; `publish-prerelease.yml` and `release.yml` refuse a candidate
|
||||
without the `Release:` trailer, so a green Validate without the heavy jobs can
|
||||
never pass for a release.
|
||||
|
||||
During the implementation cycle the fast gates always run. Since 2026-08-14 the
|
||||
owner's machine also carries Playwright with Chromium (Windows) and a full WSL
|
||||
environment, which changes one thing (#151): **before moving an issue to
|
||||
@@ -344,9 +365,27 @@ whole review round that a local run would have caught immediately.
|
||||
|
||||
The full smoke set, `golden` and `performance_smoke` still belong to the
|
||||
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
|
||||
harness (`~/houseplan-card`, venv) and `golden:verify` are advisory; **the canon
|
||||
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
|
||||
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
|
||||
harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**:
|
||||
the beta gate is CI at the exact SHA.
|
||||
|
||||
**Verifying and capturing are different things (#455).** `golden:verify` is
|
||||
advisory and legal anywhere, Windows included: it reports differences and
|
||||
accepts nothing. **Capturing** frames is refused outside Linux
|
||||
before the browser even starts — `golden:capture` through
|
||||
`demo/golden/policy.mjs`, documentation screenshots through
|
||||
`npm run docs:capture` (use that script, not a bare `node
|
||||
demo/docs/capture.mjs`: the gate sits one step earlier because editing the
|
||||
capture script invalidates the committed screenshot index). The refusal prints
|
||||
the WSL command. The reason is not policy but physics: Windows
|
||||
rasterizes text through DirectWrite with different subpixel and DPI behaviour,
|
||||
so no frame ever matches an accepted baseline byte for byte, no environment
|
||||
witness can exist, and acceptance would refuse anyway (#401 accepts any
|
||||
environment that proves itself with byte-identical undeclared frames — Linux is
|
||||
simply the only one we have). The deliberate override is
|
||||
`HP_ALLOW_FOREIGN_CAPTURE="reason"`; the reason travels into the output and the
|
||||
manifest. Baselines are still accepted only via
|
||||
`npm run golden:accept -- --reviewed` on a complete artefact, and the accepted
|
||||
index records the platform next to the Chromium build.
|
||||
|
||||
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
|
||||
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
|
||||
@@ -399,21 +438,20 @@ Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It
|
||||
reproduces against the pristine committed bundle, so treat it as
|
||||
pre-existing/pixel-precision, not a regression you introduced.
|
||||
|
||||
## Labs flags
|
||||
## Alpha experiments
|
||||
|
||||
`src/labs.ts` is the single registry and resolver for hidden presentation
|
||||
experiments. Activate a live flag through `?hp-labs=<id>` or the shared hash
|
||||
grammar, remove it with `-<id>`, and use `off` to clear the set. Do not add a
|
||||
YAML/config switch for a Labs-only experiment. A new entry needs a unique
|
||||
lowercase id, issue, numeric-core `since`, numeric-core `expires`, summary and
|
||||
unit/browser coverage. Invalid or duplicate registry entries fail closed.
|
||||
experiments. One browser-local switch controls the complete set known to the
|
||||
installed build: `hp_alpha=1` in query or the shared hash grammar enables and
|
||||
persists it, while `hp_alpha=0` disables and persists it. Do not add per-feature
|
||||
URL/storage keys or a YAML/config switch. The legacy `hp-labs` grammar and
|
||||
`houseplan_card_labs_v1` storage are ignored and never migrated.
|
||||
|
||||
Expiry is exclusive and ignores prerelease suffixes: an entry expiring at
|
||||
`1.65.0` is unavailable in `1.65.0-beta.1`. Before that cycle, either remove the
|
||||
experiment or graduate it through its own reviewed issue; never extend expiry as
|
||||
an incidental change. Labs may alter presentation only and must not gate data,
|
||||
migrations, stores, HA actions or network calls. Current renderer details are in
|
||||
`docs/ISOMETRIC.md`.
|
||||
A new registry entry needs a unique lowercase id, issue, summary and
|
||||
unit/browser coverage; it does not get `since` or `expires`. Invalid or duplicate
|
||||
entries fail closed. Alpha capabilities may alter presentation only and must not
|
||||
gate data, migrations, stores, HA actions or network calls. Current renderer
|
||||
details are in `docs/ISOMETRIC.md`.
|
||||
|
||||
Demo harness render quirk: the fake `hass` in `demo.html` is set once, so opening the
|
||||
page directly in a browser renders the floor plan but **device icons only appear
|
||||
|
||||
@@ -193,6 +193,35 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
|
||||
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
|
||||
коду с явной записью «проверено чтением, не исполнением».
|
||||
- **Защитный AC доказывается таблицей «чем краснеет» (#435).** Для каждого AC,
|
||||
заявляющего защиту — валидация, гард, лимит, отказ, инвариант, — в документе
|
||||
ревью обязательна строка из трёх столбцов: **AC · чем доказан** (точная
|
||||
команда или имя теста) **· чем краснеет** — мутация, снятая защита или
|
||||
отрицательная проба, с результатом прогона. Пустой третий столбец — находка
|
||||
Medium, а не примечание.
|
||||
|
||||
«Тест умеет падать» без названной мутации и её вывода доказательством не
|
||||
является. Аудит v1.71.0-beta.1 нашёл пять контрактов #51 и #423, где тест
|
||||
оставался зелёным на снятой защите; все пять прошли код-ревью как доказанные,
|
||||
а два теста были записаны в закрытие coverage-ratchet под именами, обещавшими
|
||||
то, чего они не проверяли (#430).
|
||||
|
||||
Мутант в `scripts/mutation-gate.mjs` обязателен, когда защита живёт в
|
||||
продуктовом коде и проверяется дорогим гейтом (смок, бэкенд, golden): там
|
||||
ревьюер не воспроизведёт отрицательный прогон второй раз. Для чистых юнитов
|
||||
достаточно прогона со снятой защитой, приведённого в документе.
|
||||
|
||||
Считаются **защитные AC без названного свидетеля**, а не мутанты на
|
||||
подсистему: у #421 мутанты были, и дыра всё равно проехала. «Сколько мутантов
|
||||
принесла задача» остаётся признаком — у #423 их ноль, и именно у #423 нашёлся
|
||||
тест, спрашивавший регулярку, находит ли она подстроку, которую сам же и
|
||||
вырезал.
|
||||
|
||||
Правило не распространяется на AC, не заявляющие защиту (расположение, текст,
|
||||
формат вывода): там свидетель — обычное сравнение ожидаемого с фактическим, и
|
||||
третий столбец превратился бы в ритуал. И не отменяет «проверено чтением»:
|
||||
тогда во втором столбце стоит «чтением», а не имя теста, и читатель ревью
|
||||
видит разницу.
|
||||
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
|
||||
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
|
||||
Medium **вне скоупа** — отдельный issue (#202).
|
||||
@@ -237,16 +266,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).
|
||||
@@ -612,6 +661,17 @@ python -m pytest tests_backend -q # py3.13, если менялся бэке
|
||||
при следующей задаче (#237). Пересъёмка — `npm run build && node
|
||||
demo/docs/capture.mjs`, коммит вместе с задачей.
|
||||
|
||||
**Перф-смок в Validate зависит от диффа** (#473). Два glow-профиля
|
||||
гоняются всегда; при правке `src/iso-*` добавляется `large-house-isometric-v1`,
|
||||
при правке `src/live-*`, `src/render-*`, `houseplan-render-lifecycle.ts`,
|
||||
`houseplan-card.ts` — `large-house-interaction-v1`, оба по три образца против
|
||||
абсолютных потолков `hardMaxMs` полных профилей (`budgets-*-smoke.json`).
|
||||
Это гейт на «в разы», а не «на проценты»: регрессия #160 (первый кадр 9 870 мс
|
||||
против потолка 3 500) ловится ещё в ревью, а не предрелизным гейтом под тегом.
|
||||
Классификацию делает `scripts/classify-changes.mjs`, набор профилей входит в
|
||||
ключ переиспользования `performance_smoke`. Ревьюер по-прежнему принимает
|
||||
зелёный Validate на SHA как подтверждение дешёвых гейтов — смок его часть.
|
||||
|
||||
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
|
||||
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
|
||||
пропуском.
|
||||
|
||||
@@ -94,17 +94,46 @@ House Plan is in the HACS default catalog — no custom repository needed.
|
||||
2. Restart Home Assistant.
|
||||
3. Open **Settings → Devices & services → Add integration → House Plan**.
|
||||
|
||||
The card is registered automatically. If you manage Lovelace resources
|
||||
manually, use the URL served by the integration:
|
||||
The card is registered automatically. After installing or updating House Plan,
|
||||
restart Home Assistant and fully reload the page: `Ctrl+F5` on Windows/Linux or
|
||||
`Cmd+Shift+R` on macOS.
|
||||
|
||||
#### Storage mode (Home Assistant default)
|
||||
|
||||
No YAML is normally needed. If automatic registration did not make the card
|
||||
available, open **Settings → Dashboards → menu ⋮ → Resources → Add
|
||||
resource**, enter `/houseplan_files/houseplan-card.js`, and select **JavaScript
|
||||
module**.
|
||||
|
||||
#### YAML resources mode (Home Assistant 2026.2+)
|
||||
|
||||
To manage resources in `configuration.yaml` independently of the dashboard
|
||||
mode, use:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
lovelace:
|
||||
resource_mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
Do not use the on-disk path inside `custom_components`; Home Assistant does not
|
||||
serve that path as a JavaScript module.
|
||||
#### Legacy Home Assistant 2024.6–2026.1
|
||||
|
||||
Only for a full-YAML dashboard that is already managed in YAML, use:
|
||||
|
||||
```yaml
|
||||
lovelace:
|
||||
mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
`mode: yaml` changes the dashboard itself to YAML mode. Do not switch a storage
|
||||
dashboard to legacy YAML just for House Plan; use the Storage mode instructions
|
||||
above instead. Do not use the on-disk path inside `custom_components`; Home
|
||||
Assistant does not serve that path as a JavaScript module.
|
||||
|
||||
### Manual installation
|
||||
|
||||
|
||||
@@ -98,17 +98,46 @@ House Plan входит в основной каталог HACS — пользо
|
||||
2. Перезапустите Home Assistant.
|
||||
3. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
|
||||
|
||||
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
|
||||
добавьте именно URL, который публикует интеграция:
|
||||
Карточка регистрируется автоматически. После установки или обновления
|
||||
House Plan перезапустите Home Assistant и полностью перезагрузите
|
||||
страницу: `Ctrl+F5` в Windows/Linux или `Cmd+Shift+R` в macOS.
|
||||
|
||||
#### Режим Storage (по умолчанию в Home Assistant)
|
||||
|
||||
Обычно YAML не нужен. Если авторегистрация не сделала карточку доступной,
|
||||
откройте **Настройки → Панели управления → меню ⋮ → Ресурсы → Добавить
|
||||
ресурс**, укажите `/houseplan_files/houseplan-card.js` и выберите тип
|
||||
**JavaScript-модуль**.
|
||||
|
||||
#### YAML-ресурсы в Home Assistant 2026.2+
|
||||
|
||||
Чтобы управлять ресурсами в `configuration.yaml` независимо от режима
|
||||
самой панели, используйте:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
lovelace:
|
||||
resource_mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
Не используйте путь к файлу внутри `custom_components`: Home Assistant не
|
||||
публикует его как JavaScript-модуль.
|
||||
#### Home Assistant 2024.6–2026.1: устаревший режим
|
||||
|
||||
Только для панели, которая уже полностью управляется через YAML:
|
||||
|
||||
```yaml
|
||||
lovelace:
|
||||
mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
`mode: yaml` переводит в YAML-режим саму панель. Не переключайте
|
||||
storage-панель в устаревший YAML только ради House Plan; используйте
|
||||
инструкцию для Storage выше. Не используйте путь к файлу внутри
|
||||
`custom_components`: Home Assistant не публикует его как JavaScript-модуль.
|
||||
|
||||
### Вручную
|
||||
|
||||
|
||||
@@ -6,20 +6,20 @@ import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from . import websocket_api as hp_ws
|
||||
from .const import (
|
||||
ASSETS_DIR,
|
||||
DOMAIN,
|
||||
FILES_DIR,
|
||||
FILES_URL,
|
||||
FRONTEND_URL,
|
||||
PLANS_DIR,
|
||||
PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .frontend_registration import (
|
||||
async_remove_frontend_registration,
|
||||
async_setup_frontend_registration,
|
||||
)
|
||||
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
|
||||
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
@@ -38,10 +38,16 @@ 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,
|
||||
HouseplanDecorAssetUploadView,
|
||||
HouseplanImportPreviewView,
|
||||
HouseplanUploadView,
|
||||
)
|
||||
|
||||
hass.http.register_view(HouseplanUploadView())
|
||||
hass.http.register_view(HouseplanDecorAssetUploadView())
|
||||
hass.http.register_view(HouseplanContentView())
|
||||
hass.http.register_view(HouseplanImportPreviewView())
|
||||
hass.http.register_view(HouseplanFrontendAssetView())
|
||||
@@ -85,53 +91,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
|
||||
plans_path = Path(hass.config.path(PLANS_DIR))
|
||||
files_path = Path(hass.config.path(FILES_DIR))
|
||||
assets_path = Path(hass.config.path(ASSETS_DIR))
|
||||
await hass.async_add_executor_job(
|
||||
lambda: (plans_path.mkdir(parents=True, exist_ok=True), files_path.mkdir(parents=True, exist_ok=True))
|
||||
lambda: (
|
||||
plans_path.mkdir(parents=True, exist_ok=True),
|
||||
files_path.mkdir(parents=True, exist_ok=True),
|
||||
assets_path.mkdir(parents=True, exist_ok=True),
|
||||
)
|
||||
)
|
||||
|
||||
# Static paths cannot be unregistered — register once per HA run.
|
||||
if not hass.data[DOMAIN].get("static_registered"):
|
||||
hass.data[DOMAIN]["static_registered"] = True
|
||||
static_paths = []
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
|
||||
if card_path.exists():
|
||||
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
|
||||
# NOTE (audit B1): plans and marker files are NO LONGER static.
|
||||
# They are served by HouseplanContentView, which requires auth.
|
||||
# Only the entry and manifest-gated JS chunks stay public —
|
||||
# Lovelace modules must be loadable without an auth header.
|
||||
if static_paths:
|
||||
await hass.http.async_register_static_paths(static_paths)
|
||||
except ImportError: # very old HA versions
|
||||
if card_path.exists():
|
||||
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
|
||||
|
||||
if not card_path.exists():
|
||||
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
|
||||
return True
|
||||
|
||||
# Register the card. Preferably as a Lovelace resource (the frontend AWAITS
|
||||
# resources before rendering dashboards, so the card is available even on a cold
|
||||
# start of the mobile app). If the resource registry is unavailable (YAML-mode
|
||||
# Lovelace, old versions) — fall back to extra_module_url.
|
||||
module_url = f"{FRONTEND_URL}?v={VERSION}"
|
||||
registered = await _register_lovelace_resource(hass, module_url)
|
||||
if not registered:
|
||||
add_extra_js_url(hass, module_url)
|
||||
# Tell the user exactly where the card lives — the #1 support issue is people adding a
|
||||
# Lovelace resource pointing at the on-disk path (/custom_components/...), which HA does
|
||||
# not serve (wrong MIME → "Custom element doesn't exist"). The correct served URL is below.
|
||||
if registered:
|
||||
_LOGGER.info("House Plan card auto-registered as a Lovelace resource: %s", module_url)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"House Plan card is served at %s . Lovelace resources look YAML-managed — add it "
|
||||
"manually under `resources:` as { url: %s, type: module }. Do NOT use the on-disk "
|
||||
"path /custom_components/houseplan/frontend/houseplan-card.js (HA does not serve it).",
|
||||
module_url, module_url,
|
||||
)
|
||||
# NOTE (audit B1): plans and marker files are no longer static. They are
|
||||
# served by HouseplanContentView, which requires auth. Only the manifest-
|
||||
# gated frontend bundle stays public so Lovelace can load it. Registration
|
||||
# failures are observable and recoverable, but never skip storage migration,
|
||||
# repairs or housekeeping below.
|
||||
await async_setup_frontend_registration(hass, entry, card_path)
|
||||
|
||||
# One-time move to the square canvas (v1.48.0). Coordinates used to be
|
||||
# normalised against a per-space aspect ratio; the canvas is now always
|
||||
@@ -309,61 +283,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry) -> None:
|
||||
"""Clean up on integration removal: drop our Lovelace resource entry."""
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None or not hasattr(resources, "async_delete_item"):
|
||||
return
|
||||
for item in list(resources.async_items()):
|
||||
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL:
|
||||
await resources.async_delete_item(item["id"])
|
||||
_LOGGER.debug("House Plan Lovelace resource removed: %s", item.get("url"))
|
||||
except Exception as err: # noqa: BLE001 — best-effort cleanup
|
||||
_LOGGER.debug("Could not remove the Lovelace resource on uninstall: %s", err)
|
||||
|
||||
|
||||
def _lovelace_resources(hass: HomeAssistant):
|
||||
lovelace = hass.data.get("lovelace")
|
||||
resources = getattr(lovelace, "resources", None)
|
||||
if resources is None and isinstance(lovelace, dict):
|
||||
resources = lovelace.get("resources")
|
||||
return resources
|
||||
|
||||
|
||||
async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> bool:
|
||||
"""Register (or update) the card in the Lovelace resource registry.
|
||||
|
||||
Returns True on success. Idempotent: if a resource with our path exists —
|
||||
update the URL on version change; otherwise create it. Any exception → False
|
||||
(fall back to extra_module_url).
|
||||
"""
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None:
|
||||
return False
|
||||
# the resource registry must be loaded
|
||||
if hasattr(resources, "loaded") and not resources.loaded:
|
||||
await resources.async_load()
|
||||
resources.loaded = True
|
||||
elif hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
# only storage mode allows creating items
|
||||
if not hasattr(resources, "async_create_item"):
|
||||
return False
|
||||
base = FRONTEND_URL
|
||||
existing = [
|
||||
item for item in resources.async_items()
|
||||
if str(item.get("url", "")).split("?", 1)[0] == base
|
||||
]
|
||||
if existing:
|
||||
item = existing[0]
|
||||
if item.get("url") != module_url and hasattr(resources, "async_update_item"):
|
||||
await resources.async_update_item(item["id"], {"url": module_url})
|
||||
return True
|
||||
await resources.async_create_item({"res_type": "module", "url": module_url})
|
||||
_LOGGER.debug("House Plan card registered as a Lovelace resource: %s", module_url)
|
||||
return True
|
||||
except Exception as err: # noqa: BLE001 — any failure → fallback
|
||||
_LOGGER.debug("Could not register the Lovelace resource (%s), falling back to extra_module_url", err)
|
||||
return False
|
||||
async def async_remove_entry(
|
||||
hass: HomeAssistant, entry: HouseplanConfigEntry
|
||||
) -> None:
|
||||
"""Clean up frontend registration and notice on integration removal."""
|
||||
await async_remove_frontend_registration(hass, entry)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Bounded, shared integrity verification for content-addressed decor assets."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import stat
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ASSET_INTEGRITY_CACHE_ENTRIES = 256
|
||||
ASSET_HASH_CHUNK_BYTES = 64 * 1024
|
||||
ASSET_INTEGRITY_FOLLOWER_TIMEOUT_SECONDS = 30.0
|
||||
_HASS_DATA_KEY = "asset_integrity_verifier"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileSignature:
|
||||
"""File version facts available without reading its content."""
|
||||
|
||||
size: int
|
||||
mtime_ns: int
|
||||
ctime_ns: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CacheEntry:
|
||||
signature: FileSignature
|
||||
digest: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Flight:
|
||||
event: threading.Event
|
||||
digest: str | None = None
|
||||
|
||||
|
||||
def _signature(path: Path) -> FileSignature:
|
||||
current = path.stat()
|
||||
if not stat.S_ISREG(current.st_mode):
|
||||
raise OSError("asset is not a regular file")
|
||||
return FileSignature(
|
||||
size=current.st_size,
|
||||
mtime_ns=current.st_mtime_ns,
|
||||
ctime_ns=current.st_ctime_ns,
|
||||
)
|
||||
|
||||
|
||||
def _stream_sha256(path: Path) -> str:
|
||||
"""Hash a blob without retaining its bytes in memory."""
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(ASSET_HASH_CHUNK_BYTES):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
class AssetIntegrityVerifier:
|
||||
"""Thread-safe LRU digest cache with per-file-version single-flight."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_entries: int = ASSET_INTEGRITY_CACHE_ENTRIES,
|
||||
*,
|
||||
hasher: Callable[[Path], str] | None = None,
|
||||
event_factory: Callable[[], threading.Event] | None = None,
|
||||
) -> None:
|
||||
if max_entries < 1:
|
||||
raise ValueError("max_entries must be positive")
|
||||
self._max_entries = max_entries
|
||||
self._hasher = hasher or _stream_sha256
|
||||
self._event_factory = event_factory or threading.Event
|
||||
self._lock = threading.Lock()
|
||||
self._cache: OrderedDict[str, _CacheEntry] = OrderedDict()
|
||||
self._inflight: dict[tuple[str, FileSignature], _Flight] = {}
|
||||
|
||||
def verify(self, path: Path, expected_digest: str) -> bool:
|
||||
"""Return whether one stable file version has the expected digest."""
|
||||
try:
|
||||
canonical = str(path.resolve())
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
try:
|
||||
before = _signature(path)
|
||||
except OSError:
|
||||
with self._lock:
|
||||
self._cache.pop(canonical, None)
|
||||
return False
|
||||
|
||||
key = (canonical, before)
|
||||
with self._lock:
|
||||
cached = self._cache.get(canonical)
|
||||
if cached is not None and cached.signature == before:
|
||||
self._cache.move_to_end(canonical)
|
||||
return cached.digest == expected_digest
|
||||
if cached is not None:
|
||||
self._cache.pop(canonical, None)
|
||||
flight = self._inflight.get(key)
|
||||
owner = flight is None
|
||||
if owner:
|
||||
flight = _Flight(self._event_factory())
|
||||
self._inflight[key] = flight
|
||||
|
||||
assert flight is not None
|
||||
if not owner:
|
||||
if not flight.event.wait(ASSET_INTEGRITY_FOLLOWER_TIMEOUT_SECONDS):
|
||||
return False
|
||||
return flight.digest == expected_digest
|
||||
|
||||
digest: str | None = None
|
||||
stable = False
|
||||
try:
|
||||
digest = self._hasher(path)
|
||||
# Never publish a digest for bytes that changed while they were read.
|
||||
stable = _signature(path) == before
|
||||
except Exception as err: # noqa: BLE001 - filesystem/hash seam fails dark
|
||||
_LOGGER.debug("House Plan asset integrity check failed: %s", err)
|
||||
finally:
|
||||
with self._lock:
|
||||
if stable and digest is not None:
|
||||
self._cache[canonical] = _CacheEntry(before, digest)
|
||||
self._cache.move_to_end(canonical)
|
||||
while len(self._cache) > self._max_entries:
|
||||
self._cache.popitem(last=False)
|
||||
flight.digest = digest
|
||||
self._inflight.pop(key, None)
|
||||
flight.event.set()
|
||||
return stable and digest == expected_digest
|
||||
|
||||
|
||||
def get_asset_integrity_verifier(hass: Any) -> AssetIntegrityVerifier:
|
||||
"""Return the single verifier shared by HTTP and WS on this HA instance."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
verifier = domain_data.get(_HASS_DATA_KEY)
|
||||
if not isinstance(verifier, AssetIntegrityVerifier):
|
||||
verifier = AssetIntegrityVerifier()
|
||||
domain_data[_HASS_DATA_KEY] = verifier
|
||||
return verifier
|
||||
@@ -2,7 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
|
||||
from .const import CONF_ADMIN_ONLY, DOMAIN
|
||||
|
||||
@@ -29,6 +29,9 @@ MAX_PLANS_FILES = 200
|
||||
MAX_PLANS_LISTED = 60
|
||||
MAX_FILES_BYTES = 1024 * 1024 * 1024
|
||||
MAX_FILES_COUNT = 1000
|
||||
MAX_DECOR_ASSET_BYTES = 2 * 1024 * 1024
|
||||
MAX_DECOR_ASSETS_BYTES = 256 * 1024 * 1024
|
||||
MAX_DECOR_ASSETS_COUNT = 200
|
||||
# Refuse to write when the disk is nearly full: filling the config partition
|
||||
# breaks .storage, the recorder and backups, not just this card.
|
||||
MIN_FREE_BYTES = 512 * 1024 * 1024
|
||||
@@ -46,14 +49,29 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
# action says so, plus staging folders after PLAN_ORPHAN_TTL_S.
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
ASSETS_DIR = "houseplan/assets"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.69.0"
|
||||
VERSION = "1.73.0-beta.2"
|
||||
DECOR_ASSETS_API_VERSION = 1
|
||||
|
||||
# #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
|
||||
SUPPORT_API_VERSION = 1
|
||||
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
|
||||
# user have different compatibility lifecycles.
|
||||
PLAN_MODEL_VERSION = 9
|
||||
EXPORT_VERSION = 1
|
||||
PLAN_MODEL_VERSION = 10
|
||||
EXPORT_VERSION = 2
|
||||
MAX_EXPORT_BYTES = 8 * 1024 * 1024
|
||||
IMPORT_PREVIEW_TTL_S = 10 * 60
|
||||
MAX_IMPORT_PREVIEWS_PER_USER = 3
|
||||
@@ -63,8 +81,47 @@ 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_image", "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_vacuum_map_route",
|
||||
"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", "unsupported_image",
|
||||
"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,11 +10,11 @@ import copy
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
COORDINATE_DECIMALS = 9
|
||||
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
|
||||
LATTICE_GRID_N = 240
|
||||
LATTICE_NOISE_STEPS = 1e-4
|
||||
DECOR_BOX_KINDS = ("rect", "ellipse", "furniture", "image")
|
||||
|
||||
|
||||
def canonicalize_number(value: Any) -> Any:
|
||||
@@ -151,13 +151,15 @@ def canonicalize_config_geometry(config: Any) -> Any:
|
||||
kind = decor.get("kind")
|
||||
if kind == "line":
|
||||
_lattice_fields(decor, ("x1", "y1", "x2", "y2"))
|
||||
elif kind in ("rect", "ellipse", "furniture"):
|
||||
elif kind in DECOR_BOX_KINDS:
|
||||
_lattice_fields(decor, ("x", "y", "w", "h"))
|
||||
_scalar_fields(decor, ("angle",))
|
||||
elif kind == "text":
|
||||
_lattice_fields(decor, ("x", "y"))
|
||||
_scalar_fields(decor, ("scale", "angle"))
|
||||
|
||||
# Legacy-only read boundary: v9 drafts are canonicalized before the
|
||||
# wall-model migration turns their edges into current partitions.
|
||||
for draft in _records(space.get("room_drafts")):
|
||||
_lattice_points(draft.get("points"))
|
||||
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
"""Content-addressed custom images for the decorative layer (#51).
|
||||
|
||||
Upload plumbing hands this module untrusted bytes. It either returns one fully
|
||||
validated canonical asset or rejects the whole file; SVG is never repaired or
|
||||
partially stripped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import stat
|
||||
import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from xml.parsers import expat
|
||||
|
||||
from .const import CONTENT_URL, MAX_DECOR_ASSET_BYTES
|
||||
|
||||
ASSET_ID_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
ASSET_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".svg"})
|
||||
MIME_BY_EXT = {
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".webp": "image/webp", ".svg": "image/svg+xml",
|
||||
}
|
||||
EXT_BY_MIME = {
|
||||
"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
MAX_RASTER_DIMENSION = 16_384
|
||||
MAX_RASTER_PIXELS = (128 * 1024 * 1024) // 4
|
||||
MAX_SVG_ELEMENTS = 5_000
|
||||
MAX_SVG_DEPTH = 64
|
||||
MAX_SVG_ATTR_CHARS = 512_000
|
||||
MAX_SVG_ATTR_VALUE_CHARS = 65_536
|
||||
_SVG_TAGS = frozenset({
|
||||
"svg", "g", "defs", "title", "desc", "path", "rect", "circle", "ellipse",
|
||||
"line", "polyline", "polygon", "clipPath", "mask", "linearGradient",
|
||||
"radialGradient", "stop",
|
||||
})
|
||||
_SVG_ATTRS = frozenset({
|
||||
"xmlns", "viewBox", "width", "height", "x", "y", "x1", "y1", "x2", "y2",
|
||||
"cx", "cy", "r", "rx", "ry", "d", "points", "transform", "fill", "fill-rule",
|
||||
"fill-opacity", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin",
|
||||
"stroke-dasharray", "stroke-dashoffset", "stroke-opacity", "opacity", "offset",
|
||||
"stop-color", "stop-opacity", "gradientUnits", "gradientTransform", "id",
|
||||
"clip-path", "mask", "preserveAspectRatio", "href",
|
||||
})
|
||||
_LOCAL_REF = re.compile(r"^url\(#[A-Za-z_][A-Za-z0-9_.:-]*\)$")
|
||||
_LENGTH = re.compile(r"^\s*([0-9]+(?:\.[0-9]+)?)\s*(?:px)?\s*$", re.I)
|
||||
_SVG_NAMESPACE = "http://www.w3.org/2000/svg"
|
||||
_SVG_UNIT_INTERVAL_ATTRS = frozenset({
|
||||
"opacity", "fill-opacity", "stroke-opacity", "stop-opacity", "offset",
|
||||
})
|
||||
|
||||
|
||||
class DecorAssetError(ValueError):
|
||||
"""Stable validation failure suitable for an API error response."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidatedAsset:
|
||||
data: bytes
|
||||
mime: str
|
||||
ext: str
|
||||
width: int
|
||||
height: int
|
||||
|
||||
@property
|
||||
def asset_id(self) -> str:
|
||||
return hashlib.sha256(self.data).hexdigest()
|
||||
|
||||
|
||||
def _check_size(data: bytes) -> None:
|
||||
if not data:
|
||||
raise DecorAssetError("invalid_image", "The image is empty")
|
||||
if len(data) > MAX_DECOR_ASSET_BYTES:
|
||||
raise DecorAssetError("too_large", "The image exceeds the 2 MiB limit")
|
||||
|
||||
|
||||
def _raster_dimensions(data: bytes, ext: str) -> tuple[int, int]:
|
||||
try:
|
||||
if ext == ".png":
|
||||
if (len(data) < 45 or data[:8] != b"\x89PNG\r\n\x1a\n"
|
||||
or data[12:16] != b"IHDR" or b"IDAT" not in data
|
||||
or data[-8:-4] != b"IEND"):
|
||||
raise ValueError
|
||||
return struct.unpack(">II", data[16:24])
|
||||
if ext in (".jpg", ".jpeg"):
|
||||
if len(data) < 4 or data[:2] != b"\xff\xd8":
|
||||
raise ValueError
|
||||
pos = 2
|
||||
while pos + 4 <= len(data):
|
||||
if data[pos] != 0xFF:
|
||||
pos += 1
|
||||
continue
|
||||
marker = data[pos + 1]
|
||||
pos += 2
|
||||
if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
|
||||
continue
|
||||
length = int.from_bytes(data[pos:pos + 2], "big")
|
||||
if length < 2 or pos + length > len(data):
|
||||
raise ValueError
|
||||
if marker in {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}:
|
||||
if length < 7:
|
||||
raise ValueError
|
||||
return (
|
||||
int.from_bytes(data[pos + 5:pos + 7], "big"),
|
||||
int.from_bytes(data[pos + 3:pos + 5], "big"),
|
||||
)
|
||||
pos += length
|
||||
raise ValueError
|
||||
if ext == ".webp":
|
||||
if len(data) < 30 or data[:4] != b"RIFF" or data[8:12] != b"WEBP":
|
||||
raise ValueError
|
||||
kind = data[12:16]
|
||||
if kind == b"VP8X":
|
||||
return (
|
||||
1 + int.from_bytes(data[24:27], "little"),
|
||||
1 + int.from_bytes(data[27:30], "little"),
|
||||
)
|
||||
if kind == b"VP8 ":
|
||||
idx = data.find(b"\x9d\x01\x2a", 20)
|
||||
if idx < 0 or idx + 7 > len(data):
|
||||
raise ValueError
|
||||
return (
|
||||
int.from_bytes(data[idx + 3:idx + 5], "little") & 0x3FFF,
|
||||
int.from_bytes(data[idx + 5:idx + 7], "little") & 0x3FFF,
|
||||
)
|
||||
if kind == b"VP8L" and data[20] == 0x2F:
|
||||
bits = int.from_bytes(data[21:25], "little")
|
||||
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
||||
raise ValueError
|
||||
except (IndexError, struct.error, ValueError) as err:
|
||||
raise DecorAssetError(
|
||||
"invalid_image", "The raster image is corrupt or has the wrong type",
|
||||
) from err
|
||||
raise DecorAssetError("unsupported_image", "Unsupported image type")
|
||||
|
||||
|
||||
def _validate_dimensions(width: float, height: float) -> tuple[int, int]:
|
||||
if not math.isfinite(width) or not math.isfinite(height) or width <= 0 or height <= 0:
|
||||
raise DecorAssetError("invalid_image", "The image has no usable dimensions")
|
||||
w, h = int(round(width)), int(round(height))
|
||||
if w <= 0 or h <= 0:
|
||||
raise DecorAssetError("invalid_image", "The image has no usable dimensions")
|
||||
if w > MAX_RASTER_DIMENSION or h > MAX_RASTER_DIMENSION or w * h > MAX_RASTER_PIXELS:
|
||||
raise DecorAssetError("too_large", "Decoded image dimensions exceed the safety limit")
|
||||
return w, h
|
||||
|
||||
|
||||
def _svg_number(value: str | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
match = _LENGTH.fullmatch(value)
|
||||
return float(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _validate_svg_unit_interval(name: str, value: str) -> None:
|
||||
"""Reject non-finite and out-of-range opacity/gradient values."""
|
||||
raw = value.strip()
|
||||
percent = raw.endswith("%")
|
||||
try:
|
||||
number = float(raw[:-1] if percent else raw)
|
||||
except ValueError as err:
|
||||
raise DecorAssetError("invalid_image", f"The SVG {name} value is invalid") from err
|
||||
limit = 100 if percent else 1
|
||||
if not math.isfinite(number) or number < 0 or number > limit:
|
||||
raise DecorAssetError("invalid_image", f"The SVG {name} value is out of range")
|
||||
|
||||
|
||||
def _reject_svg_declarations(data: bytes) -> None:
|
||||
"""Parse with encoding-aware expat guards before building an XML tree."""
|
||||
parser = expat.ParserCreate()
|
||||
|
||||
def reject(*_args: Any) -> None:
|
||||
raise DecorAssetError(
|
||||
"invalid_image", "DTD, entities and processing instructions are forbidden",
|
||||
)
|
||||
|
||||
parser.StartDoctypeDeclHandler = reject
|
||||
parser.EntityDeclHandler = reject
|
||||
parser.ExternalEntityRefHandler = reject
|
||||
parser.ProcessingInstructionHandler = reject
|
||||
try:
|
||||
parser.Parse(data, True)
|
||||
except DecorAssetError:
|
||||
raise
|
||||
except (expat.ExpatError, UnicodeError) as err:
|
||||
raise DecorAssetError("invalid_image", "The SVG is not valid XML") from err
|
||||
|
||||
|
||||
def _validate_svg(data: bytes) -> ValidatedAsset:
|
||||
# Byte-substring checks are bypassable with UTF-16/UTF-32. Expat detects
|
||||
# the declared/input encoding first and our handlers reject declarations
|
||||
# before entity expansion can allocate an ElementTree.
|
||||
_reject_svg_declarations(data)
|
||||
try:
|
||||
root = ET.fromstring(data)
|
||||
except (ET.ParseError, UnicodeError) as err:
|
||||
raise DecorAssetError("invalid_image", "The SVG is not valid XML") from err
|
||||
if root.tag.rsplit("}", 1)[-1] != "svg":
|
||||
raise DecorAssetError("invalid_image", "Only an SVG root is accepted")
|
||||
count = attr_chars = 0
|
||||
ids: set[str] = set()
|
||||
refs: list[str] = []
|
||||
ref_graph: dict[str, set[str]] = {}
|
||||
stack: list[tuple[ET.Element, int, str | None]] = [(root, 1, None)]
|
||||
while stack:
|
||||
node, depth, inherited_owner = stack.pop()
|
||||
count += 1
|
||||
if count > MAX_SVG_ELEMENTS or depth > MAX_SVG_DEPTH:
|
||||
raise DecorAssetError("too_large", "The SVG structure exceeds the safety limit")
|
||||
if node.tag.startswith("{"):
|
||||
namespace, tag = node.tag[1:].split("}", 1)
|
||||
if namespace != _SVG_NAMESPACE:
|
||||
raise DecorAssetError("unsupported_image", "Unknown SVG namespace")
|
||||
else:
|
||||
raise DecorAssetError("unsupported_image", "The SVG namespace is required")
|
||||
if tag not in _SVG_TAGS:
|
||||
raise DecorAssetError("unsupported_image", f"Unsupported SVG element: {tag}")
|
||||
node_id = node.attrib.get("id")
|
||||
owner = node_id or inherited_owner
|
||||
if node.text and node.text.strip():
|
||||
if tag not in {"title", "desc"} or len(node.text) > 4096:
|
||||
raise DecorAssetError("unsupported_image", "Unsupported or oversized SVG text")
|
||||
for raw_name, value in node.attrib.items():
|
||||
if len(value) > MAX_SVG_ATTR_VALUE_CHARS:
|
||||
raise DecorAssetError("too_large", "An SVG attribute exceeds the safety limit")
|
||||
if raw_name.startswith("{"):
|
||||
raise DecorAssetError("unsupported_image", "Namespaced SVG attributes are unsupported")
|
||||
name = raw_name
|
||||
attr_chars += len(raw_name) + len(value)
|
||||
if attr_chars > MAX_SVG_ATTR_CHARS:
|
||||
raise DecorAssetError("too_large", "The SVG attributes exceed the safety limit")
|
||||
if name.lower().startswith("on") or name not in _SVG_ATTRS:
|
||||
raise DecorAssetError("unsupported_image", f"Unsupported SVG attribute: {name}")
|
||||
if name in _SVG_UNIT_INTERVAL_ATTRS:
|
||||
_validate_svg_unit_interval(name, value)
|
||||
low = value.strip().lower()
|
||||
if any(token in low for token in ("javascript:", "data:", "http:", "https:", "//")):
|
||||
raise DecorAssetError("invalid_image", "External SVG resources are forbidden")
|
||||
if "url(" in low:
|
||||
if not _LOCAL_REF.fullmatch(value.strip()):
|
||||
raise DecorAssetError("invalid_image", "Only local SVG references are allowed")
|
||||
ref = value.strip()[5:-1]
|
||||
refs.append(ref)
|
||||
if owner:
|
||||
ref_graph.setdefault(owner, set()).add(ref)
|
||||
if name == "href":
|
||||
if not re.fullmatch(r"#[A-Za-z_][A-Za-z0-9_.:-]*", value.strip()):
|
||||
raise DecorAssetError("invalid_image", "Only local SVG references are allowed")
|
||||
ref = value.strip()[1:]
|
||||
refs.append(ref)
|
||||
if owner:
|
||||
ref_graph.setdefault(owner, set()).add(ref)
|
||||
if name == "id":
|
||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.:-]*", value) or value in ids:
|
||||
raise DecorAssetError("invalid_image", "SVG ids must be unique and well formed")
|
||||
ids.add(value)
|
||||
stack.extend((child, depth + 1, owner) for child in node)
|
||||
if any(ref not in ids for ref in refs):
|
||||
raise DecorAssetError("invalid_image", "The SVG contains an unresolved local reference")
|
||||
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
|
||||
def _visit(node_id: str) -> None:
|
||||
if node_id in visiting:
|
||||
raise DecorAssetError("invalid_image", "The SVG contains a cyclic local reference")
|
||||
if node_id in visited:
|
||||
return
|
||||
visiting.add(node_id)
|
||||
for ref in ref_graph.get(node_id, ()):
|
||||
_visit(ref)
|
||||
visiting.remove(node_id)
|
||||
visited.add(node_id)
|
||||
|
||||
for node_id in ids:
|
||||
_visit(node_id)
|
||||
view_box = root.attrib.get("viewBox")
|
||||
width = _svg_number(root.attrib.get("width"))
|
||||
height = _svg_number(root.attrib.get("height"))
|
||||
if view_box:
|
||||
try:
|
||||
parts = [float(part) for part in re.split(r"[\s,]+", view_box.strip())]
|
||||
if len(parts) != 4:
|
||||
raise ValueError
|
||||
width, height = parts[2], parts[3]
|
||||
except ValueError as err:
|
||||
raise DecorAssetError("invalid_image", "The SVG viewBox is invalid") from err
|
||||
w, h = _validate_dimensions(width or 0, height or 0)
|
||||
ET.register_namespace("", _SVG_NAMESPACE)
|
||||
canonical = ET.tostring(
|
||||
root, encoding="utf-8", xml_declaration=False, short_empty_elements=True,
|
||||
)
|
||||
_check_size(canonical)
|
||||
try:
|
||||
ET.fromstring(canonical)
|
||||
except ET.ParseError as err: # pragma: no cover - serializer invariant
|
||||
raise DecorAssetError("invalid_image", "Canonical SVG could not be reparsed") from err
|
||||
return ValidatedAsset(canonical, "image/svg+xml", ".svg", w, h)
|
||||
|
||||
|
||||
def validate_asset(
|
||||
data: bytes, filename: str, declared_mime: str | None = None,
|
||||
) -> ValidatedAsset:
|
||||
"""Validate and canonicalise one complete upload."""
|
||||
_check_size(data)
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext not in ASSET_EXTENSIONS:
|
||||
raise DecorAssetError("unsupported_image", "Use PNG, JPEG, WebP or SVG")
|
||||
expected_mime = MIME_BY_EXT[ext]
|
||||
claimed = str(declared_mime or "").split(";", 1)[0].strip().lower()
|
||||
if claimed and claimed != "application/octet-stream" and claimed != expected_mime:
|
||||
raise DecorAssetError("invalid_format", "The filename, MIME type and image bytes disagree")
|
||||
if ext == ".svg":
|
||||
return _validate_svg(data)
|
||||
width, height = _validate_dimensions(*_raster_dimensions(data, ext))
|
||||
# Home Assistant ships Pillow. Header parsing above keeps this helper
|
||||
# independently testable, while a full decode here catches valid-looking
|
||||
# but truncated/corrupt payloads before they enter the authenticated store.
|
||||
try:
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(BytesIO(data)) as image:
|
||||
image.load()
|
||||
if image.size != (width, height):
|
||||
raise DecorAssetError("invalid_image", "Image dimensions are inconsistent")
|
||||
if getattr(image, "is_animated", False):
|
||||
raise DecorAssetError("unsupported_image", "Animated images are unsupported")
|
||||
except ImportError:
|
||||
# The Home Assistant runtime includes Pillow. Keeping the pure parser
|
||||
# usable without the full HA dependency set lets repository-local
|
||||
# security tests still exercise signatures, dimensions and SVG.
|
||||
pass
|
||||
except DecorAssetError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - decoder failures are one public error
|
||||
raise DecorAssetError("invalid_image", "The raster image cannot be decoded") from err
|
||||
mime = expected_mime
|
||||
return ValidatedAsset(data, mime, EXT_BY_MIME[mime], width, height)
|
||||
|
||||
|
||||
def asset_refs(config: dict[str, Any] | None) -> dict[str, list[dict[str, str]]]:
|
||||
"""Return server-authoritative references grouped by asset id."""
|
||||
out: dict[str, list[dict[str, str]]] = {}
|
||||
for space in (config or {}).get("spaces") or []:
|
||||
if not isinstance(space, dict):
|
||||
continue
|
||||
sid = str(space.get("id") or "")
|
||||
for shape in space.get("decor") or []:
|
||||
if not isinstance(shape, dict) or shape.get("kind") != "image":
|
||||
continue
|
||||
aid = shape.get("asset_id")
|
||||
if isinstance(aid, str) and ASSET_ID_RE.fullmatch(aid):
|
||||
out.setdefault(aid, []).append({
|
||||
"space_id": sid, "decor_id": str(shape.get("id") or ""),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def asset_meta_path(root: Path, asset_id: str) -> Path:
|
||||
return root / f"{asset_id}.json"
|
||||
|
||||
|
||||
def _physical_asset_inventory(root: Path) -> list[tuple[Path, int]]:
|
||||
"""Observe regular promoted blobs once, tolerating concurrent removals."""
|
||||
try:
|
||||
if not root.is_dir():
|
||||
return []
|
||||
entries = list(root.iterdir())
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
inventory: list[tuple[Path, int]] = []
|
||||
for path in entries:
|
||||
if path.suffix not in ASSET_EXTENSIONS or not ASSET_ID_RE.fullmatch(path.stem):
|
||||
continue
|
||||
try:
|
||||
current = path.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
if stat.S_ISREG(current.st_mode):
|
||||
inventory.append((path, current.st_size))
|
||||
return sorted(inventory, key=lambda item: item[0].name)
|
||||
|
||||
|
||||
def physical_asset_blobs(root: Path) -> list[Path]:
|
||||
"""Return exact promoted blob files, independently from their sidecars."""
|
||||
return [path for path, _size in _physical_asset_inventory(root)]
|
||||
|
||||
|
||||
def physical_asset_usage(root: Path) -> tuple[int, int]:
|
||||
"""Return promoted blob count and actual bytes used for quota checks."""
|
||||
inventory = _physical_asset_inventory(root)
|
||||
return len(inventory), sum(size for _path, size in inventory)
|
||||
|
||||
|
||||
def _read_catalog_row(root: Path, path: Path) -> dict[str, Any] | None:
|
||||
"""Read one sidecar through the validation shared by list and resolve."""
|
||||
try:
|
||||
row = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
aid = str(row.get("asset_id") or "")
|
||||
ext = row.get("ext")
|
||||
blob = root / f"{aid}{ext}"
|
||||
if (
|
||||
path.stem != aid
|
||||
or not ASSET_ID_RE.fullmatch(aid)
|
||||
or ext not in ASSET_EXTENSIONS
|
||||
or not blob.is_file()
|
||||
):
|
||||
return None
|
||||
return row
|
||||
except (OSError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def read_asset(root: Path, asset_id: str) -> dict[str, Any] | None:
|
||||
"""Read one exact catalog row without scanning unrelated sidecars."""
|
||||
if not ASSET_ID_RE.fullmatch(asset_id):
|
||||
return None
|
||||
return _read_catalog_row(root, asset_meta_path(root, asset_id))
|
||||
|
||||
|
||||
def read_catalog(root: Path) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
if not root.is_dir():
|
||||
return rows
|
||||
for path in root.glob("*.json"):
|
||||
row = _read_catalog_row(root, path)
|
||||
if row is not None:
|
||||
rows.append(row)
|
||||
return sorted(
|
||||
rows,
|
||||
key=lambda row: (str(row.get("created_at", "")), str(row["asset_id"])),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
def public_asset(
|
||||
row: dict[str, Any], used_by: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
aid = str(row["asset_id"])
|
||||
ext = str(row["ext"])
|
||||
return {
|
||||
"asset_id": aid,
|
||||
"name": row.get("name", "image"),
|
||||
"mime": row.get("mime"),
|
||||
"width": row.get("width"),
|
||||
"height": row.get("height"),
|
||||
"bytes": row.get("bytes"),
|
||||
"url": f"{CONTENT_URL}/assets/_/{aid}{ext}",
|
||||
"used_by": used_by or [],
|
||||
}
|
||||
@@ -31,7 +31,6 @@ async def async_get_config_entry_diagnostics(
|
||||
"has_plan": bool(s.get("plan_url")),
|
||||
"rooms": len(s.get("rooms", [])),
|
||||
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
|
||||
"room_drafts": len(s.get("room_drafts", [])),
|
||||
"partitions": len(s.get("partitions", [])),
|
||||
"wall_columns": len(s.get("wall_columns", [])),
|
||||
}
|
||||
|
||||
@@ -1,125 +1,232 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "d301cf55cc41f758a4c82b05cc37f4a616cabbfeff496053afc2dc62fd2374e5",
|
||||
"fingerprint": "6aa7e85aead059c4d286b3b943bfaf6a4b128ecf5e0a3be2860b34e0069d829a",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-C_ThwFPM.js",
|
||||
"houseplan-assets/houseplan-card-DPsHeJqW.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 277937,
|
||||
"initialViewGzipBytes": 288573,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-6gsq-iY1.js",
|
||||
"houseplan-assets/de-BIM0GgdJ.js",
|
||||
"houseplan-assets/editor-D18CND1l.js",
|
||||
"houseplan-assets/fr-BTpf9ehd.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BGFK1d9o.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Ji0RleOQ.js"
|
||||
"houseplan-assets/backdrop-pick-Dr_sQO9B.js",
|
||||
"houseplan-assets/de-Cso9fVIy.js",
|
||||
"houseplan-assets/editor-DiTpv53t.js",
|
||||
"houseplan-assets/fr-D24D6IzR.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-DCcpljFT.js",
|
||||
"houseplan-assets/guard-BQbM7Hae.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BY9_H46m.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-suTIxM0T.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-D_uDeHju.js",
|
||||
"houseplan-assets/iso-scene-render-gvfCh2yu.js",
|
||||
"houseplan-assets/live-interaction-runtime-Bj9ohcw0.js",
|
||||
"houseplan-assets/zigbee-topology-5lWJidfb.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-D_luJHXO.js"
|
||||
],
|
||||
"lazyGzipBytes": 192258,
|
||||
"lazyGzipBytes": 272628,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-6gsq-iY1.js",
|
||||
"houseplan-assets/editor-D18CND1l.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BGFK1d9o.js"
|
||||
"houseplan-assets/backdrop-pick-Dr_sQO9B.js",
|
||||
"houseplan-assets/editor-DiTpv53t.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-DCcpljFT.js",
|
||||
"houseplan-assets/guard-BQbM7Hae.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BY9_H46m.js",
|
||||
"houseplan-assets/zigbee-topology-5lWJidfb.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 140219,
|
||||
"lazyEditorGzipBytes": 195224,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-6gsq-iY1.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Ji0RleOQ.js"
|
||||
"houseplan-assets/backdrop-pick-Dr_sQO9B.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-suTIxM0T.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 9449,
|
||||
"lazyOnboardingGzipBytes": 14323,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-BIM0GgdJ.js",
|
||||
"houseplan-assets/fr-BTpf9ehd.js"
|
||||
"houseplan-assets/de-Cso9fVIy.js",
|
||||
"houseplan-assets/fr-D24D6IzR.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 45544,
|
||||
"lazyLocaleGzipBytes": 49743,
|
||||
"lazyIsometricFiles": [
|
||||
"houseplan-assets/guard-BQbM7Hae.js",
|
||||
"houseplan-assets/iso-scene-render-gvfCh2yu.js"
|
||||
],
|
||||
"lazyIsometricGzipBytes": 13455,
|
||||
"lazyFurnitureArtFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-DCcpljFT.js"
|
||||
],
|
||||
"lazyFurnitureArtGzipBytes": 10255,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-6gsq-iY1.js",
|
||||
"sha256": "af7a1ca7b52cd4acda43b4d550cb068d7f6e354855a8976974f70b1d7d85e66d",
|
||||
"rawBytes": 6967,
|
||||
"gzipBytes": 2954,
|
||||
"path": "houseplan-assets/backdrop-pick-Dr_sQO9B.js",
|
||||
"sha256": "dbd895d0e439080d0f4989e1b440053183aff8d4f1544c276ac505d873ac6913",
|
||||
"rawBytes": 21592,
|
||||
"gzipBytes": 7358,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-C_ThwFPM.js"
|
||||
"houseplan-assets/houseplan-card-DPsHeJqW.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-BIM0GgdJ.js",
|
||||
"sha256": "dab65691523e1136dc5ecd3d90716ebd7b3c060d2db4531d4eca6226440e7418",
|
||||
"rawBytes": 75572,
|
||||
"gzipBytes": 22993,
|
||||
"path": "houseplan-assets/de-Cso9fVIy.js",
|
||||
"sha256": "4e259f68243600d5748ff5554116647022cc4c1c67b328503a71c3f2c05c443e",
|
||||
"rawBytes": 83541,
|
||||
"gzipBytes": 25120,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-D18CND1l.js",
|
||||
"sha256": "666445fd34c53ddd9a8e0079149397b27e5433d649046752dd0b288bcdbec11f",
|
||||
"rawBytes": 3831,
|
||||
"gzipBytes": 1582,
|
||||
"path": "houseplan-assets/editor-DiTpv53t.js",
|
||||
"sha256": "6f4ac5ad3afce4d7bb23201c427225cc1776b1d8c2baae080cbd8888461f1469",
|
||||
"rawBytes": 3837,
|
||||
"gzipBytes": 1587,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-C_ThwFPM.js"
|
||||
"houseplan-assets/houseplan-card-DPsHeJqW.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-BTpf9ehd.js",
|
||||
"sha256": "844ff39c35a6afdb5158b975d660ca350257860350ee617fd766406489b57be3",
|
||||
"rawBytes": 77710,
|
||||
"gzipBytes": 22551,
|
||||
"path": "houseplan-assets/fr-D24D6IzR.js",
|
||||
"sha256": "6b0446546b25e5a3b2b39342190fa5c8af56ade3ddd34613d033dc5eddf1cb99",
|
||||
"rawBytes": 85540,
|
||||
"gzipBytes": 24623,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-C_ThwFPM.js",
|
||||
"sha256": "9ed9e68f64d7b15589ab4d869c4aa5d9dbe3f1fafcf0ae2dbeae2cd32a0dfa38",
|
||||
"rawBytes": 979423,
|
||||
"gzipBytes": 277142,
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-DCcpljFT.js",
|
||||
"sha256": "59591db67b6f95564927625a3172fdf3c6c166941ac884e4f04c963ed1dd53d0",
|
||||
"rawBytes": 33655,
|
||||
"gzipBytes": 10255,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/guard-BQbM7Hae.js",
|
||||
"sha256": "63d8b7722199e6f8617ac1c4d95a9b0e28d94a13b41868409e23a6494edaad08",
|
||||
"rawBytes": 518,
|
||||
"gzipBytes": 372,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DPsHeJqW.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-DPsHeJqW.js",
|
||||
"sha256": "0ebc811e3b01cde19ac484003c51812530b5f5c061310a37cc4ae88fef4cebea",
|
||||
"rawBytes": 1037072,
|
||||
"gzipBytes": 287778,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-BIM0GgdJ.js",
|
||||
"houseplan-assets/editor-D18CND1l.js",
|
||||
"houseplan-assets/fr-BTpf9ehd.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BGFK1d9o.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Ji0RleOQ.js"
|
||||
"houseplan-assets/de-Cso9fVIy.js",
|
||||
"houseplan-assets/editor-DiTpv53t.js",
|
||||
"houseplan-assets/fr-D24D6IzR.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-DCcpljFT.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BY9_H46m.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-suTIxM0T.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-D_uDeHju.js",
|
||||
"houseplan-assets/iso-scene-render-gvfCh2yu.js",
|
||||
"houseplan-assets/live-interaction-runtime-Bj9ohcw0.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-BGFK1d9o.js",
|
||||
"sha256": "ea588ce8cd3a2d4a9d75c36097a80ef56a4f6d195b002ba5a30214c1574b38ef",
|
||||
"rawBytes": 526479,
|
||||
"gzipBytes": 135683,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-BY9_H46m.js",
|
||||
"sha256": "1b719aa9ef49aa3f8eb73780c82f3837c988cbdf9dbf5f497d04d1fbae0c8c58",
|
||||
"rawBytes": 631884,
|
||||
"gzipBytes": 168225,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-6gsq-iY1.js",
|
||||
"houseplan-assets/houseplan-card-C_ThwFPM.js"
|
||||
"houseplan-assets/backdrop-pick-Dr_sQO9B.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-DCcpljFT.js",
|
||||
"houseplan-assets/guard-BQbM7Hae.js",
|
||||
"houseplan-assets/houseplan-card-DPsHeJqW.js",
|
||||
"houseplan-assets/zigbee-topology-5lWJidfb.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/zigbee-topology-runtime-D_luJHXO.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-suTIxM0T.js",
|
||||
"sha256": "ad6e3c7de56475c5089488832646978867af15e30a3681ce3f4eb1c7ba74012d",
|
||||
"rawBytes": 28071,
|
||||
"gzipBytes": 6965,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-Dr_sQO9B.js",
|
||||
"houseplan-assets/houseplan-card-DPsHeJqW.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-Ji0RleOQ.js",
|
||||
"sha256": "225fb52367703f6767e2c891ac46962d3e986f712bb7a44b6961bbd67c337552",
|
||||
"rawBytes": 26195,
|
||||
"gzipBytes": 6495,
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-D_uDeHju.js",
|
||||
"sha256": "ba30583fb3436c5ffe05634a63f02ae242c47bb9fbb13aab727d92f55544c4ec",
|
||||
"rawBytes": 11034,
|
||||
"gzipBytes": 3729,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-6gsq-iY1.js",
|
||||
"houseplan-assets/houseplan-card-C_ThwFPM.js"
|
||||
"houseplan-assets/houseplan-card-DPsHeJqW.js",
|
||||
"houseplan-assets/zigbee-topology-5lWJidfb.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-D_luJHXO.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/iso-scene-render-gvfCh2yu.js",
|
||||
"sha256": "bbe97e455d58c88c16f361368253df4ca39e7424e72ad25cd8161519a4560c97",
|
||||
"rawBytes": 38562,
|
||||
"gzipBytes": 13083,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/guard-BQbM7Hae.js",
|
||||
"houseplan-assets/houseplan-card-DPsHeJqW.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-Bj9ohcw0.js",
|
||||
"sha256": "4646bfaeb32a1d48909cc5dba8678a7374001c1162c1ce595cc339ab7beca2c5",
|
||||
"rawBytes": 5196,
|
||||
"gzipBytes": 2125,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DPsHeJqW.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-5lWJidfb.js",
|
||||
"sha256": "c78fd713884be766e8a1b4845b69e584ed346cc4f9dd554593e27c0456891a9c",
|
||||
"rawBytes": 18961,
|
||||
"gzipBytes": 7427,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DPsHeJqW.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-D_luJHXO.js",
|
||||
"sha256": "7dce8d77b41a653e35cdf78a4fbfe09bdf1a7a9d6eb545aa2d27934c30e1f299",
|
||||
"rawBytes": 3941,
|
||||
"gzipBytes": 1759,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DPsHeJqW.js",
|
||||
"houseplan-assets/zigbee-topology-5lWJidfb.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "4ea6a5cc96ba771f672613ccc4549512eb4ede8f297d6c60953b307af1b40302",
|
||||
"sha256": "19518aed8ddb7da540e7f66443460095113690729264b8f46cf4092dfc1c22c5",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 795,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-C_ThwFPM.js"
|
||||
"houseplan-assets/houseplan-card-DPsHeJqW.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="d301cf55cc41f758a4c82b05cc37f4a616cabbfeff496053afc2dc62fd2374e5";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-C_ThwFPM.js";class f 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,f={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")},h=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=>f[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(h.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(h.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)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="6aa7e85aead059c4d286b3b943bfaf6a4b128ecf5e0a3be2860b34e0069d829a";import{t as e,l as o,x as t,dN as s,A as a,dO as i,dP as l,dQ as n,dM as r,b as c}from"./houseplan-card-DPsHeJqW.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 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="6aa7e85aead059c4d286b3b943bfaf6a4b128ecf5e0a3be2860b34e0069d829a";import{dK as r,dL as e,dM as t}from"./houseplan-card-DPsHeJqW.js";const a={},s=r(class extends e{constructor(){super(...arguments),this.ot=a}render(r,e){return e()}update(r,[e,a]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((r,e)=>r===this.ot[e]))return t}else if(this.ot===e)return t;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,a)}});export{s as i};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="6aa7e85aead059c4d286b3b943bfaf6a4b128ecf5e0a3be2860b34e0069d829a";import{n as t,b as n}from"./zigbee-topology-5lWJidfb.js";import{F as e}from"./houseplan-card-DPsHeJqW.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="d301cf55cc41f758a4c82b05cc37f4a616cabbfeff496053afc2dc62fd2374e5";try{await import("./houseplan-assets/houseplan-card-C_ThwFPM.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)}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="6aa7e85aead059c4d286b3b943bfaf6a4b128ecf5e0a3be2860b34e0069d829a";try{await import("./houseplan-assets/houseplan-card-DPsHeJqW.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
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
"""Register the House Plan card with the Home Assistant frontend."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from homeassistant.components import frontend, persistent_notification
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
|
||||
from .const import DOMAIN, FRONTEND_URL, VERSION
|
||||
from .store import HouseplanConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FRONTEND_REGISTRATION_KEY = "frontend_registration"
|
||||
FRONTEND_STATIC_REGISTERED_KEY = "static_registered"
|
||||
FRONTEND_FALLBACK_URLS_KEY = "frontend_fallback_urls"
|
||||
FRONTEND_RELOAD_NOTICE_DATA_KEY = "frontend_reload_notice_created"
|
||||
FRONTEND_RELOAD_NOTICE_ID = "houseplan_frontend_reload_notice"
|
||||
FRONTEND_RETRY_DELAY_SECONDS = 1.0
|
||||
|
||||
_NOTICE_TITLE_KEY = "component.houseplan.issues.frontend_reload_notice.title"
|
||||
_NOTICE_DESCRIPTION_KEY = (
|
||||
"component.houseplan.issues.frontend_reload_notice.description"
|
||||
)
|
||||
_NOTICE_TITLE_FALLBACK = "House Plan card connected"
|
||||
_NOTICE_DESCRIPTION_FALLBACK = (
|
||||
"The House Plan card is connected. Fully reload this page to use the latest "
|
||||
"frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you "
|
||||
"manage dashboard resources manually in storage mode, open Settings → "
|
||||
"Dashboards → Resources."
|
||||
)
|
||||
|
||||
RegistrationStatus = Literal[
|
||||
"not_attempted",
|
||||
"created",
|
||||
"updated",
|
||||
"existing",
|
||||
"registry_pending",
|
||||
"yaml_fallback",
|
||||
"transient_error",
|
||||
"error_fallback",
|
||||
]
|
||||
ResourceLoader = Literal[
|
||||
"none",
|
||||
"lovelace_resource",
|
||||
"extra_module_url",
|
||||
"lovelace_resource_with_session_fallback",
|
||||
]
|
||||
ReloadNoticeStatus = Literal[
|
||||
"created",
|
||||
"already_created",
|
||||
"pending_frontend",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegistrationOutcome:
|
||||
"""Result of one Lovelace resource registry attempt."""
|
||||
|
||||
status: RegistrationStatus
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FrontendRegistrationState:
|
||||
"""Observable state and lifecycle handles for the current config-entry run."""
|
||||
|
||||
card_file_present: bool
|
||||
static_path_registered: bool = False
|
||||
resource_status: RegistrationStatus = "not_attempted"
|
||||
loader: ResourceLoader = "none"
|
||||
module_url: str | None = None
|
||||
retry_pending: bool = False
|
||||
retry_attempted: bool = False
|
||||
last_error: str | None = None
|
||||
first_reload_notice: ReloadNoticeStatus = "pending_frontend"
|
||||
active: bool = True
|
||||
fallback_added: bool = False
|
||||
_cancel_started: Callable[[], None] | None = field(default=None, repr=False)
|
||||
_cancel_timer: Callable[[], None] | None = field(default=None, repr=False)
|
||||
_retry_task: asyncio.Task[None] | None = field(default=None, repr=False)
|
||||
_notice_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
|
||||
|
||||
@callback
|
||||
def cancel(self) -> None:
|
||||
"""Prevent all delayed work for this setup generation."""
|
||||
self.active = False
|
||||
self.retry_pending = False
|
||||
if self._cancel_started is not None:
|
||||
self._cancel_started()
|
||||
self._cancel_started = None
|
||||
if self._cancel_timer is not None:
|
||||
self._cancel_timer()
|
||||
self._cancel_timer = None
|
||||
if self._retry_task is not None and not self._retry_task.done():
|
||||
self._retry_task.cancel()
|
||||
self._retry_task = None
|
||||
|
||||
|
||||
def _safe_error(phase: str, err: BaseException) -> str:
|
||||
"""Return a support-safe failure fingerprint without message or path data."""
|
||||
return f"{phase}:{type(err).__name__}"
|
||||
|
||||
|
||||
def _lovelace_resources(hass: HomeAssistant):
|
||||
"""Return the Lovelace resource collection across supported HA versions."""
|
||||
lovelace = hass.data.get("lovelace")
|
||||
resources = getattr(lovelace, "resources", None)
|
||||
if resources is None and isinstance(lovelace, dict):
|
||||
resources = lovelace.get("resources")
|
||||
return resources
|
||||
|
||||
|
||||
def _is_current(hass: HomeAssistant, state: FrontendRegistrationState) -> bool:
|
||||
"""Return whether a delayed operation still belongs to the loaded entry."""
|
||||
return (
|
||||
state.active
|
||||
and hass.data.get(DOMAIN, {}).get(FRONTEND_REGISTRATION_KEY) is state
|
||||
)
|
||||
|
||||
|
||||
def _owned_fallback_urls(hass: HomeAssistant) -> set[str]:
|
||||
"""Return exact extra-module URLs owned for this Home Assistant run."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
urls = domain_data.get(FRONTEND_FALLBACK_URLS_KEY)
|
||||
if not isinstance(urls, set):
|
||||
urls = set()
|
||||
domain_data[FRONTEND_FALLBACK_URLS_KEY] = urls
|
||||
return urls
|
||||
|
||||
|
||||
def _active_extra_module_urls(hass: HomeAssistant) -> frozenset[str] | None:
|
||||
"""Return HA's current module URLs, or None when the API is unknowable.
|
||||
|
||||
``add_extra_js_url`` is intentionally idempotent and has no ownership
|
||||
return value. Looking before adding is therefore the only way to avoid
|
||||
claiming (and later removing) an identical URL configured by the user.
|
||||
Both the oldest supported HA and current HA expose ``UrlManager.urls``;
|
||||
unknown future/legacy shapes fail closed and are never claimed.
|
||||
"""
|
||||
key = getattr(frontend, "DATA_EXTRA_MODULE_URL", None)
|
||||
if key is None:
|
||||
return None
|
||||
manager = hass.data.get(key)
|
||||
if manager is None:
|
||||
# Frontend setup normally creates the manager before this integration.
|
||||
# If it is genuinely absent, no configured URL can predate our add;
|
||||
# add_extra_js_url will either create/use its supported path or fail.
|
||||
return frozenset()
|
||||
urls = getattr(manager, "urls", None)
|
||||
if not isinstance(urls, (set, frozenset, list, tuple)):
|
||||
return None
|
||||
return frozenset(url for url in urls if isinstance(url, str))
|
||||
|
||||
|
||||
async def _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing: list[dict],
|
||||
canonical_id: str,
|
||||
*,
|
||||
is_active: Callable[[], bool],
|
||||
) -> None:
|
||||
"""Best-effort remove legacy duplicates after choosing one authority."""
|
||||
remover = getattr(resources, "async_delete_item", None)
|
||||
if remover is None:
|
||||
return
|
||||
for item in existing:
|
||||
if item.get("id") == canonical_id:
|
||||
continue
|
||||
if not is_active():
|
||||
return
|
||||
try:
|
||||
await remover(item["id"])
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - canonical registration still succeeded
|
||||
_LOGGER.debug(
|
||||
"Could not remove a duplicate House Plan Lovelace resource",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def async_register_lovelace_resource(
|
||||
hass: HomeAssistant,
|
||||
module_url: str,
|
||||
*,
|
||||
is_active: Callable[[], bool] | None = None,
|
||||
) -> RegistrationOutcome:
|
||||
"""Create or update the canonical Lovelace resource entry."""
|
||||
active = is_active or (lambda: True)
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None:
|
||||
return RegistrationOutcome("registry_pending")
|
||||
if not hasattr(resources, "async_create_item"):
|
||||
return RegistrationOutcome("yaml_fallback")
|
||||
|
||||
try:
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
if hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
elif hasattr(resources, "loaded") and not resources.loaded:
|
||||
await resources.async_load()
|
||||
resources.loaded = True
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
|
||||
existing = [
|
||||
item
|
||||
for item in resources.async_items()
|
||||
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL
|
||||
]
|
||||
if existing:
|
||||
# Prefer an already-current entry when legacy duplicates exist. This
|
||||
# preserves its stable id while the remaining same-base entries are
|
||||
# removed best effort below.
|
||||
item = next(
|
||||
(candidate for candidate in existing if candidate.get("url") == module_url),
|
||||
existing[0],
|
||||
)
|
||||
if item.get("url") == module_url and item.get("type") == "module":
|
||||
await _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing,
|
||||
item["id"],
|
||||
is_active=active,
|
||||
)
|
||||
return RegistrationOutcome("existing")
|
||||
if not hasattr(resources, "async_update_item"):
|
||||
return RegistrationOutcome(
|
||||
"transient_error", "registry_update:UnsupportedOperation"
|
||||
)
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
await resources.async_update_item(
|
||||
item["id"], {"url": module_url, "res_type": "module"}
|
||||
)
|
||||
await _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing,
|
||||
item["id"],
|
||||
is_active=active,
|
||||
)
|
||||
return RegistrationOutcome("updated")
|
||||
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
await resources.async_create_item(
|
||||
{"url": module_url, "res_type": "module"}
|
||||
)
|
||||
_LOGGER.debug("House Plan card registered as a Lovelace resource")
|
||||
return RegistrationOutcome("created")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - typed fallback is intentional
|
||||
_LOGGER.debug("Could not register the House Plan Lovelace resource", exc_info=True)
|
||||
return RegistrationOutcome("transient_error", _safe_error("registry", err))
|
||||
|
||||
|
||||
def _add_fallback(
|
||||
hass: HomeAssistant, state: FrontendRegistrationState
|
||||
) -> None:
|
||||
"""Add this setup's exact versioned extra-module URL, best effort."""
|
||||
if state.module_url is None or not _is_current(hass, state):
|
||||
return
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if state.module_url in owned_urls:
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
return
|
||||
active_before = _active_extra_module_urls(hass)
|
||||
if active_before is not None and state.module_url in active_before:
|
||||
# The exact URL predates this setup. It is a working loader, but it is
|
||||
# not ours to remove after registry recovery or on uninstall.
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
return
|
||||
try:
|
||||
frontend.add_extra_js_url(hass, state.module_url)
|
||||
except Exception as err: # noqa: BLE001 - frontend setup must remain available
|
||||
state.loader = "none"
|
||||
state.last_error = _safe_error("fallback_add", err)
|
||||
_LOGGER.warning("Could not add the House Plan frontend fallback", exc_info=True)
|
||||
return
|
||||
if active_before is not None:
|
||||
owned_urls.add(state.module_url)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"House Plan could not prove ownership of the frontend fallback; "
|
||||
"it will remain until Home Assistant restarts"
|
||||
)
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
|
||||
|
||||
def _remove_owned_fallback_url(
|
||||
hass: HomeAssistant,
|
||||
state: FrontendRegistrationState,
|
||||
module_url: str,
|
||||
) -> bool:
|
||||
"""Remove one exact integration-owned fallback when HA supports it."""
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if module_url not in owned_urls:
|
||||
return True
|
||||
remover = getattr(frontend, "remove_extra_js_url", None)
|
||||
if remover is None:
|
||||
return False
|
||||
try:
|
||||
remover(hass, module_url)
|
||||
except KeyError:
|
||||
# Another lifecycle path already removed the exact URL.
|
||||
owned_urls.discard(module_url)
|
||||
return True
|
||||
except Exception as err: # noqa: BLE001 - registry remains authoritative
|
||||
state.last_error = _safe_error("fallback_remove", err)
|
||||
_LOGGER.debug("Could not remove the House Plan frontend fallback", exc_info=True)
|
||||
return False
|
||||
owned_urls.discard(module_url)
|
||||
return True
|
||||
|
||||
|
||||
def _remove_fallback(
|
||||
hass: HomeAssistant, state: FrontendRegistrationState
|
||||
) -> bool:
|
||||
"""Remove this setup's exact fallback when the running HA supports it."""
|
||||
if state.module_url is None:
|
||||
return True
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if state.module_url in owned_urls:
|
||||
removed = _remove_owned_fallback_url(hass, state, state.module_url)
|
||||
state.fallback_added = not removed
|
||||
return removed
|
||||
|
||||
# A pre-existing/user-owned or uninspectable exact URL must never be
|
||||
# removed. Report the combined loader while it is known (or conservatively
|
||||
# assumed) to remain active in this HA document lifecycle.
|
||||
active = _active_extra_module_urls(hass)
|
||||
state.fallback_added = active is None or state.module_url in active
|
||||
return not state.fallback_added
|
||||
|
||||
|
||||
async def _async_create_reload_notice(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Create and persist the one-time localized hard-reload instruction."""
|
||||
# Initial fallback setup and the one-second retry may overlap when loading a
|
||||
# translation is slow. Serialize the persisted one-shot contract rather
|
||||
# than relying only on the stable notification ID (which prevents two UI
|
||||
# rows but would still perform and persist two creates).
|
||||
async with state._notice_lock:
|
||||
if entry.data.get(FRONTEND_RELOAD_NOTICE_DATA_KEY) is True:
|
||||
if state.first_reload_notice != "created":
|
||||
state.first_reload_notice = "already_created"
|
||||
return
|
||||
if (
|
||||
not _is_current(hass, state)
|
||||
or not state.card_file_present
|
||||
or not state.static_path_registered
|
||||
or state.loader == "none"
|
||||
):
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
return
|
||||
|
||||
title = _NOTICE_TITLE_FALLBACK
|
||||
description = _NOTICE_DESCRIPTION_FALLBACK
|
||||
try:
|
||||
translations = await async_get_translations(
|
||||
hass,
|
||||
hass.config.language,
|
||||
"issues",
|
||||
integrations={DOMAIN},
|
||||
)
|
||||
title = translations.get(_NOTICE_TITLE_KEY, title)
|
||||
description = translations.get(_NOTICE_DESCRIPTION_KEY, description)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - built-in English fallback is intentional
|
||||
_LOGGER.debug(
|
||||
"Could not load the House Plan reload notice translation",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
try:
|
||||
persistent_notification.async_create(
|
||||
hass,
|
||||
description,
|
||||
title=title,
|
||||
notification_id=FRONTEND_RELOAD_NOTICE_ID,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - do not consume flag on failure
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
_LOGGER.warning(
|
||||
"Could not create the House Plan reload notice", exc_info=True
|
||||
)
|
||||
return
|
||||
try:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={**entry.data, FRONTEND_RELOAD_NOTICE_DATA_KEY: True},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - roll back the unpersisted notice
|
||||
# A stable ID prevents duplicate rows, but dismissing the notice is
|
||||
# still important: without a durable flag, displaying it would make
|
||||
# System Health claim that the one-shot contract is pending while a
|
||||
# user-visible notice already exists. A later setup/retry can now
|
||||
# attempt the complete create+persist operation honestly.
|
||||
try:
|
||||
persistent_notification.async_dismiss(
|
||||
hass, FRONTEND_RELOAD_NOTICE_ID
|
||||
)
|
||||
except Exception: # noqa: BLE001 - stable ID remains a safe fallback
|
||||
_LOGGER.debug(
|
||||
"Could not roll back an unpersisted House Plan reload notice",
|
||||
exc_info=True,
|
||||
)
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
_LOGGER.warning(
|
||||
"Could not persist the House Plan reload notice state",
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
state.first_reload_notice = "created"
|
||||
|
||||
|
||||
async def _async_retry_registration(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Perform the single delayed resource-registry retry."""
|
||||
if not _is_current(hass, state) or state.module_url is None:
|
||||
return
|
||||
state.retry_pending = False
|
||||
state.retry_attempted = True
|
||||
outcome = await async_register_lovelace_resource(
|
||||
hass,
|
||||
state.module_url,
|
||||
is_active=lambda: _is_current(hass, state),
|
||||
)
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
|
||||
if outcome.status in {"created", "updated", "existing"}:
|
||||
state.resource_status = outcome.status
|
||||
state.last_error = outcome.last_error
|
||||
state.loader = (
|
||||
"lovelace_resource"
|
||||
if _remove_fallback(hass, state)
|
||||
else "lovelace_resource_with_session_fallback"
|
||||
)
|
||||
elif outcome.status == "yaml_fallback":
|
||||
state.resource_status = "yaml_fallback"
|
||||
state.last_error = outcome.last_error
|
||||
_add_fallback(hass, state)
|
||||
else:
|
||||
state.resource_status = "error_fallback"
|
||||
state.last_error = outcome.last_error or "registry_retry:Unavailable"
|
||||
_add_fallback(hass, state)
|
||||
|
||||
await _async_create_reload_notice(hass, entry, state)
|
||||
|
||||
|
||||
def _schedule_retry(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Schedule exactly one lifecycle-bound retry after HA start plus one second."""
|
||||
state.retry_pending = True
|
||||
|
||||
@callback
|
||||
def _after_started(_event=None) -> None:
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
state._cancel_started = None
|
||||
|
||||
@callback
|
||||
def _after_delay(_now=None) -> None:
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
state._cancel_timer = None
|
||||
state._retry_task = entry.async_create_background_task(
|
||||
hass,
|
||||
_async_retry_registration(hass, entry, state),
|
||||
"houseplan frontend resource retry",
|
||||
)
|
||||
|
||||
state._cancel_timer = async_call_later(
|
||||
hass, FRONTEND_RETRY_DELAY_SECONDS, _after_delay
|
||||
)
|
||||
|
||||
state._cancel_started = async_at_started(hass, _after_started)
|
||||
|
||||
|
||||
async def async_setup_frontend_registration(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
card_path: Path,
|
||||
) -> FrontendRegistrationState:
|
||||
"""Register static/card resources without blocking the rest of entry setup."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
previous = domain_data.get(FRONTEND_REGISTRATION_KEY)
|
||||
if isinstance(previous, FrontendRegistrationState):
|
||||
previous.cancel()
|
||||
|
||||
card_file_present = card_path.is_file()
|
||||
module_url = f"{FRONTEND_URL}?v={VERSION}" if card_file_present else None
|
||||
active_fallbacks = _active_extra_module_urls(hass)
|
||||
fallback_added = module_url is not None and (
|
||||
module_url in _owned_fallback_urls(hass)
|
||||
or (active_fallbacks is not None and module_url in active_fallbacks)
|
||||
)
|
||||
state = FrontendRegistrationState(
|
||||
card_file_present=card_file_present,
|
||||
loader="extra_module_url" if fallback_added else "none",
|
||||
module_url=module_url,
|
||||
first_reload_notice=(
|
||||
"already_created"
|
||||
if entry.data.get(FRONTEND_RELOAD_NOTICE_DATA_KEY) is True
|
||||
else "pending_frontend"
|
||||
),
|
||||
fallback_added=fallback_added,
|
||||
)
|
||||
domain_data[FRONTEND_REGISTRATION_KEY] = state
|
||||
entry.async_on_unload(state.cancel)
|
||||
|
||||
if domain_data.get(FRONTEND_STATIC_REGISTERED_KEY):
|
||||
state.static_path_registered = True
|
||||
elif card_file_present:
|
||||
try:
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
except ImportError: # Home Assistant versions before the async API
|
||||
hass.http.register_static_path(
|
||||
FRONTEND_URL, str(card_path), cache_headers=False
|
||||
)
|
||||
else:
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False)]
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - setup continues without frontend
|
||||
state.last_error = _safe_error("static_path", err)
|
||||
_LOGGER.warning("Could not register the House Plan static path", exc_info=True)
|
||||
else:
|
||||
domain_data[FRONTEND_STATIC_REGISTERED_KEY] = True
|
||||
state.static_path_registered = True
|
||||
|
||||
if not card_file_present:
|
||||
_LOGGER.warning("houseplan-card.js was not found next to the integration")
|
||||
return state
|
||||
if not state.static_path_registered or state.module_url is None:
|
||||
return state
|
||||
|
||||
outcome = await async_register_lovelace_resource(
|
||||
hass,
|
||||
state.module_url,
|
||||
is_active=lambda: _is_current(hass, state),
|
||||
)
|
||||
if not _is_current(hass, state):
|
||||
return state
|
||||
state.resource_status = outcome.status
|
||||
state.last_error = outcome.last_error
|
||||
|
||||
if outcome.status in {"created", "updated", "existing"}:
|
||||
state.loader = (
|
||||
"lovelace_resource"
|
||||
if _remove_fallback(hass, state)
|
||||
else "lovelace_resource_with_session_fallback"
|
||||
)
|
||||
_LOGGER.info(
|
||||
"House Plan card auto-registered as a Lovelace resource: %s",
|
||||
state.module_url,
|
||||
)
|
||||
else:
|
||||
_add_fallback(hass, state)
|
||||
if outcome.status in {"registry_pending", "transient_error"}:
|
||||
_schedule_retry(hass, entry, state)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"House Plan card uses the frontend extra-module fallback: %s",
|
||||
state.module_url,
|
||||
)
|
||||
|
||||
await _async_create_reload_notice(hass, entry, state)
|
||||
return state
|
||||
|
||||
|
||||
@callback
|
||||
def get_frontend_registration_state(
|
||||
hass: HomeAssistant,
|
||||
) -> FrontendRegistrationState | None:
|
||||
"""Return current frontend registration state for System Health."""
|
||||
state = hass.data.get(DOMAIN, {}).get(FRONTEND_REGISTRATION_KEY)
|
||||
return state if isinstance(state, FrontendRegistrationState) else None
|
||||
|
||||
|
||||
async def async_remove_frontend_registration(
|
||||
hass: HomeAssistant, entry: HouseplanConfigEntry
|
||||
) -> None:
|
||||
"""Remove all House Plan resource entries and its one-time notice."""
|
||||
state = get_frontend_registration_state(hass)
|
||||
if state is not None:
|
||||
state.cancel()
|
||||
domain_data = hass.data.get(DOMAIN, {})
|
||||
if domain_data.get(FRONTEND_REGISTRATION_KEY) is state:
|
||||
domain_data.pop(FRONTEND_REGISTRATION_KEY, None)
|
||||
|
||||
# Fallback ownership belongs to this HA run, not to the latest config-entry
|
||||
# generation. A bundle-missing reload can therefore have no current URL
|
||||
# while an earlier exact URL is still registered. Uninstall cleans every
|
||||
# URL that this integration itself added, and never pattern-matches foreign
|
||||
# extra-module entries.
|
||||
cleanup_state = state or FrontendRegistrationState(card_file_present=False)
|
||||
for module_url in tuple(_owned_fallback_urls(hass)):
|
||||
_remove_owned_fallback_url(hass, cleanup_state, module_url)
|
||||
|
||||
try:
|
||||
persistent_notification.async_dismiss(hass, FRONTEND_RELOAD_NOTICE_ID)
|
||||
except Exception: # noqa: BLE001 - uninstall cleanup is best effort
|
||||
_LOGGER.debug("Could not dismiss the House Plan reload notice", exc_info=True)
|
||||
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None or not hasattr(resources, "async_delete_item"):
|
||||
return
|
||||
if hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
for item in list(resources.async_items()):
|
||||
if str(item.get("url", "")).split("?", 1)[0] != FRONTEND_URL:
|
||||
continue
|
||||
try:
|
||||
await resources.async_delete_item(item["id"])
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - continue best-effort cleanup
|
||||
_LOGGER.debug(
|
||||
"Could not remove one House Plan Lovelace resource",
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
_LOGGER.debug("House Plan Lovelace resource removed")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - uninstall cleanup is best effort
|
||||
_LOGGER.debug("Could not remove the House Plan Lovelace resource", exc_info=True)
|
||||
@@ -5,27 +5,50 @@ breaks the connection on a large PDF) but via a plain multipart POST — like me
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
try: # KEY_HASS — the modern way to access hass from the aiohttp application
|
||||
from homeassistant.components.http import KEY_HASS
|
||||
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 .asset_integrity import get_asset_integrity_verifier
|
||||
from .auth import may_write
|
||||
from .const import (
|
||||
ASSETS_DIR,
|
||||
CONTENT_URL,
|
||||
FILES_DIR,
|
||||
MAX_DECOR_ASSET_BYTES,
|
||||
MAX_DECOR_ASSETS_BYTES,
|
||||
MAX_DECOR_ASSETS_COUNT,
|
||||
MAX_EXPORT_BYTES,
|
||||
MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT,
|
||||
MIN_FREE_BYTES,
|
||||
PLANS_DIR,
|
||||
)
|
||||
from .decor_assets import (
|
||||
ASSET_EXTENSIONS,
|
||||
ASSET_ID_RE,
|
||||
DecorAssetError,
|
||||
asset_meta_path,
|
||||
physical_asset_usage,
|
||||
public_asset,
|
||||
read_asset,
|
||||
validate_asset,
|
||||
)
|
||||
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 +136,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)
|
||||
@@ -135,25 +158,38 @@ class HouseplanContentView(HomeAssistantView):
|
||||
|
||||
async def get(self, request: web.Request, kind: str, sub: str, name: str) -> web.StreamResponse:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
if kind not in ("plans", "files"):
|
||||
if kind not in ("plans", "files", "assets"):
|
||||
return web.Response(status=404)
|
||||
safe_sub = sanitize_marker_id(sub)
|
||||
safe_name = sanitize_filename(name)
|
||||
if not safe_sub or not safe_name:
|
||||
return web.Response(status=404)
|
||||
base = Path(hass.config.path(PLANS_DIR if kind == "plans" else FILES_DIR)).resolve()
|
||||
root_dir = PLANS_DIR if kind == "plans" else ASSETS_DIR if kind == "assets" else FILES_DIR
|
||||
base = Path(hass.config.path(root_dir)).resolve()
|
||||
# plans live flat in one directory: the sub segment is a placeholder ("_")
|
||||
path = (base / safe_name if kind == "plans" else base / safe_sub / safe_name).resolve()
|
||||
if kind == "assets":
|
||||
stem, suffix = Path(safe_name).stem, Path(safe_name).suffix.lower()
|
||||
if safe_sub != "_" or not ASSET_ID_RE.fullmatch(stem) or suffix not in ASSET_EXTENSIONS:
|
||||
return web.Response(status=404)
|
||||
path = (base / safe_name).resolve()
|
||||
else:
|
||||
path = (base / safe_name if kind == "plans" else base / safe_sub / safe_name).resolve()
|
||||
# defence in depth: the sanitizers already strip separators
|
||||
if not str(path).startswith(str(base)):
|
||||
return web.Response(status=404)
|
||||
|
||||
if not await hass.async_add_executor_job(path.is_file):
|
||||
return web.Response(status=404)
|
||||
suffix = path.suffix.lower()
|
||||
if kind == "assets":
|
||||
verifier = get_asset_integrity_verifier(hass)
|
||||
if not await hass.async_add_executor_job(verifier.verify, path, path.stem):
|
||||
return web.Response(status=404)
|
||||
elif not await hass.async_add_executor_job(path.is_file):
|
||||
return web.Response(status=404)
|
||||
headers = {
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
"Cache-Control": "private, max-age=31536000, immutable"
|
||||
if kind == "assets" else "private, max-age=3600",
|
||||
"Content-Type": _MIME.get(suffix, "application/octet-stream"),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
if suffix == ".svg":
|
||||
# An uploaded SVG is user content served from Home Assistant's own
|
||||
@@ -179,6 +215,140 @@ class HouseplanContentView(HomeAssistantView):
|
||||
return web.FileResponse(path, chunk_size=_CHUNK, headers=headers)
|
||||
|
||||
|
||||
class HouseplanDecorAssetUploadView(HomeAssistantView):
|
||||
"""POST one bounded custom decor image and return its catalog row."""
|
||||
|
||||
url = "/api/houseplan/assets/upload"
|
||||
name = "api:houseplan:decor-asset-upload"
|
||||
requires_auth = True
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
if not may_write(hass, request.get("hass_user")):
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
runtime = get_data(hass)
|
||||
if runtime is None:
|
||||
return web.json_response({"error": "not_ready"}, status=503)
|
||||
if request.content_length is not None and request.content_length > MAX_DECOR_ASSET_BYTES + _FLUSH_AT:
|
||||
return web.json_response({"error": "too_large"}, status=413)
|
||||
filename: str | None = None
|
||||
declared_mime: str | None = None
|
||||
blocks: list[bytes] = []
|
||||
size = 0
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name != "file":
|
||||
continue
|
||||
if filename is not None:
|
||||
return web.json_response({"error": "invalid_format"}, status=400)
|
||||
filename = part.filename or "image"
|
||||
declared_mime = part.headers.get("Content-Type")
|
||||
while chunk := await part.read_chunk(_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > MAX_DECOR_ASSET_BYTES:
|
||||
return web.json_response({"error": "too_large"}, status=413)
|
||||
blocks.append(chunk)
|
||||
except Exception as err: # noqa: BLE001 - malformed multipart boundary
|
||||
_LOGGER.warning("House Plan decor asset upload: multipart read error: %s", err)
|
||||
return web.json_response({"error": "invalid_format"}, status=400)
|
||||
if not filename:
|
||||
return web.json_response({"error": "invalid_format"}, status=400)
|
||||
|
||||
try:
|
||||
validated = await hass.async_add_executor_job(
|
||||
validate_asset, b"".join(blocks), filename, declared_mime,
|
||||
)
|
||||
except DecorAssetError as err:
|
||||
return web.json_response({"error": err.code, "message": str(err)}, status=413 if err.code == "too_large" else 400)
|
||||
|
||||
root = Path(hass.config.path(ASSETS_DIR))
|
||||
|
||||
def _store() -> tuple[dict, bool]:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
existing = read_asset(root, validated.asset_id)
|
||||
if existing is not None:
|
||||
existing_blob = root / f"{validated.asset_id}{existing['ext']}"
|
||||
if hashlib.sha256(existing_blob.read_bytes()).hexdigest() != validated.asset_id:
|
||||
raise DecorAssetError("invalid_image", "The stored image failed its integrity check")
|
||||
return existing, True
|
||||
aid = validated.asset_id
|
||||
blob = root / f"{aid}{validated.ext}"
|
||||
meta = asset_meta_path(root, aid)
|
||||
safe_display = sanitize_filename(Path(filename).name) or "image"
|
||||
row = {
|
||||
"asset_id": aid, "name": safe_display, "mime": validated.mime,
|
||||
"ext": validated.ext, "width": validated.width, "height": validated.height,
|
||||
"bytes": len(validated.data), "created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
# A hard stop may have promoted the content-addressed blob before
|
||||
# its sidecar. Its exact path and digest prove that no new physical
|
||||
# bytes are needed, so repair remains possible even at full quota.
|
||||
if blob.exists():
|
||||
if not blob.is_file() or hashlib.sha256(blob.read_bytes()).hexdigest() != aid:
|
||||
raise DecorAssetError("invalid_image", "The stored image failed its integrity check")
|
||||
fd, meta_temp_name = tempfile.mkstemp(prefix=".asset-meta-", dir=str(root))
|
||||
os.close(fd)
|
||||
meta_temp = Path(meta_temp_name)
|
||||
try:
|
||||
with meta_temp.open("w", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(meta_temp, meta)
|
||||
return row, False
|
||||
finally:
|
||||
meta_temp.unlink(missing_ok=True)
|
||||
|
||||
count, used = physical_asset_usage(root)
|
||||
if count >= MAX_DECOR_ASSETS_COUNT or used + len(validated.data) > MAX_DECOR_ASSETS_BYTES:
|
||||
raise DecorAssetError("capacity_exceeded", "The decor image store is full")
|
||||
try:
|
||||
if shutil.disk_usage(root).free - len(validated.data) < MIN_FREE_BYTES:
|
||||
raise DecorAssetError("capacity_exceeded", "Not enough free disk space")
|
||||
except OSError:
|
||||
pass
|
||||
fd, temp_name = tempfile.mkstemp(prefix=".asset-", dir=str(root))
|
||||
os.close(fd)
|
||||
temp = Path(temp_name)
|
||||
meta_temp = temp.with_suffix(".json")
|
||||
promoted_blob = False
|
||||
try:
|
||||
with temp.open("wb") as stream:
|
||||
stream.write(validated.data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
with meta_temp.open("w", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temp, blob)
|
||||
promoted_blob = True
|
||||
os.replace(meta_temp, meta)
|
||||
return row, False
|
||||
except Exception:
|
||||
# A normal I/O failure is fully transactional. A process hard
|
||||
# stop between the two renames is healed by the orphan branch
|
||||
# above on the next identical upload.
|
||||
if promoted_blob:
|
||||
blob.unlink(missing_ok=True)
|
||||
raise
|
||||
finally:
|
||||
temp.unlink(missing_ok=True)
|
||||
meta_temp.unlink(missing_ok=True)
|
||||
|
||||
try:
|
||||
async with runtime.upload_lock:
|
||||
row, reused = await hass.async_add_executor_job(_store)
|
||||
except DecorAssetError as err:
|
||||
status = 507 if err.code == "capacity_exceeded" else 400
|
||||
return web.json_response({"error": err.code, "message": str(err)}, status=status)
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan decor asset upload: store failed: %s", err)
|
||||
return web.json_response({"error": "io_error"}, status=500)
|
||||
return web.json_response({"ok": True, "reused": reused, "asset": public_asset(row)})
|
||||
|
||||
|
||||
class HouseplanUploadView(HomeAssistantView):
|
||||
"""POST /api/houseplan/upload — save a marker file, return its URL."""
|
||||
|
||||
@@ -264,7 +434,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
|
||||
@@ -22,10 +21,11 @@ from urllib.parse import urlsplit
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import (
|
||||
ASSETS_DIR,
|
||||
CONTENT_URL,
|
||||
EXPORT_VERSION,
|
||||
FILES_URL,
|
||||
FILES_DIR,
|
||||
FILES_URL,
|
||||
IMPORT_PREVIEW_TTL_S,
|
||||
MAX_EXPORT_BYTES,
|
||||
MAX_IMPORT_PREVIEWS_PER_USER,
|
||||
@@ -36,10 +36,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 +44,22 @@ from .validation import (
|
||||
MAX_LAYOUT,
|
||||
MAX_MARKERS,
|
||||
MAX_SPACES,
|
||||
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,
|
||||
sanitize_filename,
|
||||
sanitize_marker_id,
|
||||
validate_marker_controls,
|
||||
validate_marker_light_entities,
|
||||
validate_marker_vacuum_routes,
|
||||
validate_marker_value_badges,
|
||||
validate_opening_passages,
|
||||
validate_partition_opening_hosts,
|
||||
)
|
||||
from .wall_segment_model import (
|
||||
WallSegmentMigrationError,
|
||||
commit_wall_segment_model,
|
||||
)
|
||||
|
||||
FORMAT = "houseplan-export"
|
||||
@@ -68,11 +70,17 @@ _LIVE_TEXT_ENTITY = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
||||
_LIVE_TEXT_ATTRIBUTE = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
||||
_PLAN_ONLY_DASH = "—"
|
||||
_IMPORT_ID_NAMESPACES = {
|
||||
"space", "room", "marker", "partition", "wall", "opening", "decor", "draft",
|
||||
"draft_segment", "column",
|
||||
"space", "room", "marker", "partition", "wall", "opening", "decor", "column",
|
||||
}
|
||||
_MAX_IMPORT_LINEAGE_DEPTH = 16
|
||||
_REPORT_EXAMPLE_LIMIT = 24
|
||||
_DECOR_ASSET_MIME_BY_SUFFIX = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
_DECOR_ASSET_MIME_TYPES = frozenset(_DECOR_ASSET_MIME_BY_SUFFIX.values())
|
||||
|
||||
_SPACE_PLAN_FIELDS = (
|
||||
"id", "title", "cell_cm", "plan_url", "plan_aspect", "plan_x", "plan_y",
|
||||
@@ -96,7 +104,8 @@ _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"),
|
||||
"image": ("asset_id", "x", "y", "w", "h", "angle", "flip_h", "flip_v"),
|
||||
}
|
||||
|
||||
|
||||
@@ -256,7 +265,6 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
|
||||
collections: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("walls", ("key", "cm", "a", "b")),
|
||||
("wall_segments", ("id", "a", "b", "cm")),
|
||||
("room_drafts", ("id", "points", "segments")),
|
||||
("partitions", ("id", "a", "b", "cm")),
|
||||
("wall_columns", ("id", "shape", "center", "cm", "angle")),
|
||||
("open_spans", ("a", "b")),
|
||||
@@ -267,11 +275,6 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
|
||||
values = []
|
||||
for item in space.get(name) or []:
|
||||
selected = _pick_fields(item, fields)
|
||||
if name == "room_drafts" and "segments" in selected:
|
||||
selected["segments"] = [
|
||||
_pick_fields(segment, ("id", "cm"))
|
||||
for segment in selected.get("segments") or []
|
||||
]
|
||||
values.append(selected)
|
||||
projected[name] = values
|
||||
if "openings" in space:
|
||||
@@ -389,6 +392,12 @@ def _internal_path(root: Path, url: str) -> tuple[str, Path] | None:
|
||||
if name != raw_name:
|
||||
return None
|
||||
return "plan", root / PLANS_DIR / name
|
||||
asset_prefix = CONTENT_URL + "/assets/_/"
|
||||
if url.startswith(asset_prefix):
|
||||
raw_name = url[len(asset_prefix):]
|
||||
if not re.fullmatch(r"[0-9a-f]{64}\.(?:png|jpg|webp|svg)", raw_name):
|
||||
return None
|
||||
return "decor_asset", root / ASSETS_DIR / raw_name
|
||||
for prefix in (CONTENT_URL + "/files/", FILES_URL + "/"):
|
||||
if url.startswith(prefix):
|
||||
tail = url[len(prefix):].split("/")
|
||||
@@ -433,6 +442,41 @@ def content_manifest(config: dict[str, Any], config_root: Path) -> list[dict[str
|
||||
"attachment", "marker", str(marker.get("id")),
|
||||
f"pdfs[{index}].url", pdf.get("url") if isinstance(pdf, dict) else None,
|
||||
)
|
||||
# Asset bytes are deliberately not embedded. The manifest records identity
|
||||
# and availability so a cross-instance import can preserve the image box as
|
||||
# a repairable editor placeholder instead of silently deleting geometry.
|
||||
assets_root = config_root / ASSETS_DIR
|
||||
for space in config.get("spaces") or []:
|
||||
for shape in space.get("decor") or []:
|
||||
if not isinstance(shape, dict) or shape.get("kind") != "image":
|
||||
continue
|
||||
aid = shape.get("asset_id")
|
||||
if not isinstance(aid, str) or not re.fullmatch(r"[0-9a-f]{64}", aid):
|
||||
continue
|
||||
matches = sorted(assets_root.glob(f"{aid}.*")) if assets_root.is_dir() else []
|
||||
blob = next((path for path in matches if path.suffix in {".png", ".jpg", ".webp", ".svg"}), None)
|
||||
metadata: dict[str, Any] = {}
|
||||
try:
|
||||
metadata = json.loads((assets_root / f"{aid}.json").read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
exists = False
|
||||
if blob and blob.is_file():
|
||||
try:
|
||||
exists = hashlib.sha256(blob.read_bytes()).hexdigest() == aid
|
||||
except OSError:
|
||||
pass
|
||||
out.append({
|
||||
"kind": "decor_asset", "owner": "decor",
|
||||
"owner_id": f"{space.get('id', '')}:{shape.get('id', '')}", "field": "asset_id",
|
||||
# Identity is extension-neutral: a missing target must compute
|
||||
# exactly the same expected manifest as the source.
|
||||
"url": aid, "asset_id": aid, "storage": "internal",
|
||||
"mime": metadata.get("mime") or _DECOR_ASSET_MIME_BY_SUFFIX.get(
|
||||
blob.suffix if blob else ""
|
||||
),
|
||||
"hash": aid, "exists_at_export": exists,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@@ -460,7 +504,16 @@ def create_export(
|
||||
# Read before voluptuous coercions: bool is an int subclass and must not
|
||||
# become a plausible model 1 in an otherwise valid export.
|
||||
model_version = _stored_model_version(raw_config)
|
||||
config = CONFIG_SCHEMA(raw_config)
|
||||
if model_version > PLAN_MODEL_VERSION:
|
||||
raise ImportFailure("future_model", "The stored plan uses a newer data model")
|
||||
try:
|
||||
raw_config["model_version"] = model_version
|
||||
config, _ = commit_wall_segment_model(raw_config)
|
||||
config = CONFIG_SCHEMA(config)
|
||||
except (WallSegmentMigrationError, vol.Invalid) as err:
|
||||
code = err.code if isinstance(err, WallSegmentMigrationError) else "invalid_config"
|
||||
raise ImportFailure(code, str(err)) from err
|
||||
model_version = PLAN_MODEL_VERSION
|
||||
# Compatibility belongs to the envelope. Keeping this field inside the
|
||||
# payload would either duplicate it or tempt an exporter to silently stamp
|
||||
# the current version over an older/future stored model.
|
||||
@@ -501,6 +554,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) \
|
||||
@@ -517,6 +578,23 @@ def create_export(
|
||||
and ref[len("marker:"):] not in selected_ids:
|
||||
marker.pop("value_source", None)
|
||||
dropped_marker_links += 1
|
||||
# #162: a robot map routed into ANOTHER space cannot travel
|
||||
# with a single-space export — its target would not exist on
|
||||
# import, and a route with a dead space resolves to nothing.
|
||||
# Same neutralisation contract as the marker refs above: drop
|
||||
# it and count it, so the preview states the loss up front.
|
||||
vacuum = marker.get("vacuum")
|
||||
routes = vacuum.get("map_routes") if isinstance(vacuum, dict) else None
|
||||
if isinstance(routes, list):
|
||||
kept_routes = [
|
||||
route for route in routes
|
||||
if isinstance(route, dict) and str(route.get("space")) == str(space_id)
|
||||
]
|
||||
dropped_marker_links += len(routes) - len(kept_routes)
|
||||
# An explicit list is route authority even when filtering
|
||||
# leaves it empty. Turning [] into None would revive any
|
||||
# legacy calibration dictionary after import (#443).
|
||||
vacuum["map_routes"] = kept_routes
|
||||
config = {
|
||||
"spaces": [_json_copy(space)],
|
||||
"markers": _json_copy(selected_markers),
|
||||
@@ -585,7 +663,7 @@ def parse_document(raw: bytes) -> dict[str, Any]:
|
||||
raise ImportFailure("invalid_json", str(err)) from err
|
||||
if not isinstance(document, dict) or document.get("format") != FORMAT:
|
||||
raise ImportFailure("invalid_format", "Not a House Plan export")
|
||||
if document.get("export_version") != EXPORT_VERSION:
|
||||
if document.get("export_version") not in (1, EXPORT_VERSION):
|
||||
raise ImportFailure("unsupported_export_version", "Unsupported export version")
|
||||
if document.get("kind") not in ("full", "space"):
|
||||
raise ImportFailure("invalid_format", "Export kind must be full or space")
|
||||
@@ -751,7 +829,11 @@ def _validate_plan_only_document(
|
||||
raise ImportFailure("invalid_format", "Plan-only placement manifest is not canonical")
|
||||
content = document.get("content_manifest")
|
||||
if not isinstance(content, list) or any(
|
||||
not isinstance(item, dict) or item.get("owner") != "space"
|
||||
not isinstance(item, dict)
|
||||
or not (
|
||||
item.get("owner") == "space"
|
||||
or item.get("owner") == "decor" and item.get("kind") == "decor_asset"
|
||||
)
|
||||
for item in content
|
||||
):
|
||||
raise ImportFailure("invalid_format", "Plan-only export contains private content")
|
||||
@@ -1198,7 +1280,7 @@ def build_space_merge(
|
||||
str(value)
|
||||
for sp in current_config.get("spaces") or []
|
||||
for collection in (
|
||||
(sp,), sp.get("rooms") or [], sp.get("room_drafts") or [],
|
||||
(sp,), sp.get("rooms") or [],
|
||||
sp.get("partitions") or [], sp.get("wall_segments") or [],
|
||||
sp.get("wall_columns") or [],
|
||||
sp.get("openings") or [], sp.get("decor") or [],
|
||||
@@ -1210,18 +1292,11 @@ def build_space_merge(
|
||||
} | {
|
||||
str(m.get("id")) for m in current_config.get("markers") or []
|
||||
if m.get("id") is not None
|
||||
} | {
|
||||
str(segment.get("id"))
|
||||
for sp in current_config.get("spaces") or []
|
||||
for draft in sp.get("room_drafts") or []
|
||||
for segment in draft.get("segments") or []
|
||||
if isinstance(segment, dict) and segment.get("id") is not None
|
||||
} | {
|
||||
str(value)
|
||||
for incoming_space in spaces
|
||||
for collection in (
|
||||
(incoming_space,), incoming_space.get("rooms") or [],
|
||||
incoming_space.get("room_drafts") or [],
|
||||
incoming_space.get("partitions") or [], incoming_space.get("wall_segments") or [],
|
||||
incoming_space.get("wall_columns") or [],
|
||||
incoming_space.get("openings") or [], incoming_space.get("decor") or [],
|
||||
@@ -1233,12 +1308,6 @@ def build_space_merge(
|
||||
} | {
|
||||
str(m.get("id")) for m in incoming.get("markers") or []
|
||||
if m.get("id") is not None
|
||||
} | {
|
||||
str(segment.get("id"))
|
||||
for incoming_space in spaces
|
||||
for draft in incoming_space.get("room_drafts") or []
|
||||
for segment in draft.get("segments") or []
|
||||
if isinstance(segment, dict) and segment.get("id") is not None
|
||||
}
|
||||
old_space_id = str(space.get("id"))
|
||||
_record_bounded_lineage(
|
||||
@@ -1251,7 +1320,7 @@ def build_space_merge(
|
||||
}
|
||||
id_maps["space"][old_space_id] = new_space_id
|
||||
for collection, prefix in (
|
||||
("rooms", "room"), ("room_drafts", "draft"), ("partitions", "partition"),
|
||||
("rooms", "room"), ("partitions", "partition"),
|
||||
("wall_segments", "wall"),
|
||||
("wall_columns", "column"), ("openings", "opening"), ("decor", "decor"),
|
||||
):
|
||||
@@ -1264,18 +1333,6 @@ def build_space_merge(
|
||||
id_map[old] = _fresh(prefix, old, used)
|
||||
id_maps[prefix][old] = id_map[old]
|
||||
item["id"] = id_map[old]
|
||||
for draft in space.get("room_drafts") or []:
|
||||
for segment in draft.get("segments") or []:
|
||||
if not isinstance(segment, dict) or segment.get("id") is None:
|
||||
continue
|
||||
old = str(segment["id"])
|
||||
_record_bounded_lineage(
|
||||
reference_report, bounded_seen, "draft_segment", old,
|
||||
)
|
||||
new = _fresh("draft_segment", old, used)
|
||||
id_map[old] = new
|
||||
id_maps["draft_segment"][old] = new
|
||||
segment["id"] = new
|
||||
old_room_ids = id_maps["room"]
|
||||
for room in space.get("rooms") or []:
|
||||
if room.get("open_to"):
|
||||
@@ -1518,6 +1575,7 @@ def build_space_merge(
|
||||
validate_marker_controls(merged_config, current_config)
|
||||
validate_marker_light_entities(merged_config, current_config)
|
||||
validate_marker_value_badges(merged_config, current_config)
|
||||
validate_marker_vacuum_routes(merged_config, current_config)
|
||||
validate_opening_passages(merged_config, current_config)
|
||||
validate_partition_opening_hosts(merged_config, current_config)
|
||||
except (
|
||||
@@ -1588,7 +1646,37 @@ def _content_state(document: dict[str, Any], same_source: bool, config_root: Pat
|
||||
for item in expected:
|
||||
row = dict(item)
|
||||
declared = supplied_by_id[identity(item)]
|
||||
row["exists_at_export"] = declared.get("exists_at_export")
|
||||
declared_exists = declared.get("exists_at_export")
|
||||
row["exists_at_export"] = declared_exists
|
||||
if item.get("kind") == "decor_asset":
|
||||
aid = str(item.get("asset_id") or item.get("url") or "")
|
||||
declared_mime = declared.get("mime")
|
||||
valid_mime = (
|
||||
isinstance(declared_mime, str)
|
||||
and declared_mime in _DECOR_ASSET_MIME_TYPES
|
||||
)
|
||||
missing_mime = declared_exists is False and declared_mime is None
|
||||
if (declared.get("asset_id") != aid or declared.get("hash") != aid
|
||||
or type(declared_exists) is not bool
|
||||
or not (valid_mime or missing_mime)):
|
||||
raise ImportFailure("invalid_content", "Invalid decor asset manifest row")
|
||||
# Preserve the source description for preview/digest purposes. It
|
||||
# never selects or serves target bytes; the exact local hash below
|
||||
# remains authoritative for availability.
|
||||
row["mime"] = declared_mime
|
||||
candidates = sorted((config_root / ASSETS_DIR).glob(f"{aid}.*"))
|
||||
blob = next((path for path in candidates if path.suffix in {".png", ".jpg", ".webp", ".svg"}), None)
|
||||
exists = False
|
||||
if blob:
|
||||
try:
|
||||
exists = hashlib.sha256(blob.read_bytes()).hexdigest() == aid
|
||||
except OSError:
|
||||
pass
|
||||
row["exists_on_target"] = exists
|
||||
row["state"] = "available" if exists else "missing_preserved"
|
||||
confirmation = confirmation or not exists
|
||||
rows.append(row)
|
||||
continue
|
||||
internal = _internal_path(config_root, item["url"])
|
||||
if _looks_internal(item["url"]) and internal is None:
|
||||
raise ImportFailure("invalid_content", "Non-canonical internal content URL")
|
||||
@@ -1687,21 +1775,20 @@ def _materialize_import_candidate(
|
||||
if confirmation_required:
|
||||
_detach_missing(imported_config, content)
|
||||
incoming_model = int(prepared.get("model_version", 0) or 0)
|
||||
target_model = int(current_config.get("model_version", 0) or 0)
|
||||
requires_v8 = incoming_model >= 8 or target_model >= 8
|
||||
if requires_v8:
|
||||
try:
|
||||
# A v8 target cannot be downgraded by a v7 backup, while an
|
||||
# incoming v8 graph must retain its already-persisted IDs. When
|
||||
# both sides are v7 the accepted compatibility contract keeps the
|
||||
# import v7 until a later structural write or explicit Optimize.
|
||||
imported_config["model_version"] = incoming_model
|
||||
imported_config, _ = commit_wall_segment_model(imported_config)
|
||||
prepared["payload"]["config"] = imported_config
|
||||
if prepared["kind"] == "space":
|
||||
current_config, _ = commit_wall_segment_model(current_config)
|
||||
except WallSegmentMigrationError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
migration: dict[str, int] = {}
|
||||
try:
|
||||
# Import preview and apply operate only on the current materialized
|
||||
# wall model. Legacy drafts are converted before copy/remap, so no
|
||||
# current merge path can recreate their carrier.
|
||||
imported_config["model_version"] = incoming_model
|
||||
imported_config, _ = commit_wall_segment_model(
|
||||
imported_config, migration_report=migration,
|
||||
)
|
||||
prepared["payload"]["config"] = imported_config
|
||||
if prepared["kind"] == "space":
|
||||
current_config, _ = commit_wall_segment_model(current_config)
|
||||
except WallSegmentMigrationError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
if prepared["kind"] == "space":
|
||||
config, layout, details = build_space_merge(
|
||||
prepared, current_config, current_layout, duplicate_policy,
|
||||
@@ -1709,14 +1796,13 @@ def _materialize_import_candidate(
|
||||
)
|
||||
else:
|
||||
config = imported_config
|
||||
if not requires_v8 and incoming_model > 0:
|
||||
config["model_version"] = incoming_model
|
||||
_materialize_global_background(config)
|
||||
layout = _json_copy(prepared["payload"]["layout"])
|
||||
if not same_source:
|
||||
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),
|
||||
}
|
||||
@@ -1729,6 +1815,7 @@ def _materialize_import_candidate(
|
||||
except vol.Invalid as err:
|
||||
raise ImportFailure("invalid_layout", str(err)) from err
|
||||
details = _json_copy(details)
|
||||
details["migration"] = migration
|
||||
details["counts"] = _counts(config, layout)
|
||||
return config, layout, details
|
||||
|
||||
@@ -1782,6 +1869,7 @@ def create_preview(
|
||||
validate_marker_controls(incoming_config, validate_all=True)
|
||||
validate_marker_light_entities(incoming_config, validate_all=True)
|
||||
validate_marker_value_badges(incoming_config, validate_all=True)
|
||||
validate_marker_vacuum_routes(incoming_config, validate_all=True)
|
||||
except MarkerControlError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
content, confirmation = _content_state(document, same_source, config_root)
|
||||
|
||||
@@ -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]:
|
||||
"""Every wall the limits judge: contour atoms, partitions, draft segments."""
|
||||
segments: list[dict] = []
|
||||
def limit_segments(space: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Every current wall the limits judge: contour atoms and partitions."""
|
||||
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({
|
||||
@@ -111,24 +113,11 @@ def limit_segments(space: dict) -> list[dict]:
|
||||
"a": partition["a"], "b": partition["b"],
|
||||
"cm": float(partition.get("cm") or 0),
|
||||
})
|
||||
for draft in space.get("room_drafts") or []:
|
||||
points = draft.get("points") or []
|
||||
drafted = draft.get("segments") or []
|
||||
for index in range(max(len(points) - 1, 0)):
|
||||
if not (_finite_point(points[index]) and _finite_point(points[index + 1])):
|
||||
continue
|
||||
piece = drafted[index] if index < len(drafted) else {}
|
||||
segments.append({
|
||||
"id": str((piece or {}).get("id")
|
||||
or f"{draft.get('id') or 'draft'}-{index}"),
|
||||
"a": points[index], "b": points[index + 1],
|
||||
"cm": float((piece or {}).get("cm") or 0),
|
||||
})
|
||||
return [segment for segment in segments
|
||||
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 +152,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 +173,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 +207,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 +224,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 +234,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 +245,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 +303,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 +314,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 +364,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 +377,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.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"domain": "houseplan",
|
||||
"name": "House Plan",
|
||||
"after_dependencies": [
|
||||
"lovelace"
|
||||
],
|
||||
"codeowners": [
|
||||
"@Matysh"
|
||||
],
|
||||
@@ -16,5 +19,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.69.0"
|
||||
"version": "1.73.0-beta.2"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Floor plan image is missing",
|
||||
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House Plan card connected",
|
||||
"description": "The House Plan card is connected. Fully reload this page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you manage dashboard resources manually in storage mode, open Settings → Dashboards → Resources."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "House Plan card file",
|
||||
"static_path": "Static frontend path",
|
||||
"resource_status": "Resource registration status",
|
||||
"resource_loader": "Frontend resource loader",
|
||||
"resource_url": "Frontend resource URL",
|
||||
"resource_retry": "Resource registration retry",
|
||||
"resource_error": "Frontend registration error",
|
||||
"first_reload_notice": "First reload notice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
"""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
|
||||
show_room_tooltip = value.get("show_room_tooltip")
|
||||
if isinstance(show_room_tooltip, bool):
|
||||
out["show_room_tooltip"] = show_room_tooltip
|
||||
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["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)),
|
||||
"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,
|
||||
) -> 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:
|
||||
filename_sha256 = str(attachment_sha256 or "")
|
||||
form.add_field(
|
||||
"attachment", attachment,
|
||||
filename=f"houseplan-support-{filename_sha256[:12]}.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
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
from homeassistant.components import system_health
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .frontend_registration import get_frontend_registration_state
|
||||
from .store import get_data
|
||||
|
||||
|
||||
@@ -23,13 +24,51 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
cfg_raw = await data.config_store.async_load() or {}
|
||||
layout_raw = await data.store.async_load() or {}
|
||||
config = cfg_raw.get("config", {})
|
||||
return {
|
||||
result = {
|
||||
"config_rev": cfg_raw.get("rev", 0),
|
||||
"spaces": len(config.get("spaces", [])),
|
||||
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
|
||||
"room_drafts": sum(len(s.get("room_drafts", [])) for s in config.get("spaces", [])),
|
||||
"partitions": sum(len(s.get("partitions", [])) for s in config.get("spaces", [])),
|
||||
"wall_columns": sum(len(s.get("wall_columns", [])) for s in config.get("spaces", [])),
|
||||
"markers": len(config.get("markers", [])),
|
||||
"layout_entries": len(layout_raw.get("layout", {})),
|
||||
}
|
||||
frontend = get_frontend_registration_state(hass)
|
||||
if frontend is None:
|
||||
result.update(
|
||||
{
|
||||
"card_file": "missing",
|
||||
"static_path": "not_registered",
|
||||
"resource_status": "not_attempted",
|
||||
"resource_loader": "none",
|
||||
"resource_url": "unavailable",
|
||||
"resource_retry": "not_needed",
|
||||
"resource_error": "none",
|
||||
"first_reload_notice": "pending_frontend",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
result.update(
|
||||
{
|
||||
"card_file": "present" if frontend.card_file_present else "missing",
|
||||
"static_path": (
|
||||
"registered"
|
||||
if frontend.static_path_registered
|
||||
else "not_registered"
|
||||
),
|
||||
"resource_status": frontend.resource_status,
|
||||
"resource_loader": frontend.loader,
|
||||
"resource_url": frontend.module_url or "unavailable",
|
||||
"resource_retry": (
|
||||
"pending"
|
||||
if frontend.retry_pending
|
||||
else "attempted"
|
||||
if frontend.retry_attempted
|
||||
else "not_needed"
|
||||
),
|
||||
"resource_error": frontend.last_error or "none",
|
||||
"first_reload_notice": frontend.first_reload_notice,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -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
|
||||
@@ -20,8 +21,8 @@ from homeassistant.helpers.event import async_call_later, async_track_state_chan
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN
|
||||
from .vacuum_routes import effective_routes
|
||||
|
||||
import logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TRAIL_CAP = 2000 # raw points per run before decimation
|
||||
@@ -31,14 +32,31 @@ FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
|
||||
MOVING_STATES = {"cleaning", "returning", "on"}
|
||||
|
||||
|
||||
def can_resume_trail_run(run: Any, map_id: str, now: float) -> bool:
|
||||
def same_run_identity(run: Any, route_id: str, map_id: str) -> bool:
|
||||
"""Whether a stored run and a fresh point belong to the same cleanup.
|
||||
|
||||
Route identity wins when both sides have one: two maps of one robot may
|
||||
share a map id only across different sources, and a route change (new
|
||||
source, map or target space) is a new run by definition (#162). A run
|
||||
written before #162 carries no route id, so it is still matched by map id
|
||||
and keeps resuming exactly as it used to.
|
||||
"""
|
||||
if not isinstance(run, dict):
|
||||
return False
|
||||
stored_route = run.get("route_id")
|
||||
if route_id and isinstance(stored_route, str) and stored_route:
|
||||
return stored_route == route_id
|
||||
return run.get("map_id") == map_id
|
||||
|
||||
|
||||
def can_resume_trail_run(run: Any, map_id: str, now: float, route_id: str = "") -> bool:
|
||||
"""Whether an ended current run may be reopened for this point.
|
||||
|
||||
Store timestamps are untrusted persisted data. Only finite JSON-number
|
||||
timestamps and a non-negative inclusive grace interval are accepted;
|
||||
malformed values and wall-clock rollback fail closed into a new run.
|
||||
"""
|
||||
if not isinstance(run, dict) or run.get("map_id") != map_id:
|
||||
if not same_run_identity(run, route_id, map_id):
|
||||
return False
|
||||
ended = run.get("ended")
|
||||
if (
|
||||
@@ -84,18 +102,27 @@ class TrailBook:
|
||||
def __init__(self, data: dict[str, Any] | None = None) -> None:
|
||||
self.data: dict[str, Any] = data if isinstance(data, dict) else {}
|
||||
|
||||
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
|
||||
def on_point(
|
||||
self, marker: str, map_id: str, x: float, y: float, now: float,
|
||||
route_id: str = "", source: str = "",
|
||||
) -> bool:
|
||||
rec = self.data.setdefault(marker, {})
|
||||
cur = rec.get("current")
|
||||
resumed = bool(cur and can_resume_trail_run(cur, map_id, now))
|
||||
resumed = bool(cur and can_resume_trail_run(cur, map_id, now, route_id))
|
||||
if resumed:
|
||||
cur["ended"] = None
|
||||
if not cur or cur.get("ended") is not None or cur.get("map_id") != map_id:
|
||||
if not cur or cur.get("ended") is not None or not same_run_identity(cur, route_id, map_id):
|
||||
# a new run begins: the old one becomes "previous" (and the one
|
||||
# before it is forgotten — we keep exactly two, per the owner)
|
||||
if cur:
|
||||
rec["previous"] = cur
|
||||
cur = {"map_id": map_id, "started": now, "ended": None, "points": []}
|
||||
# #162: the run remembers WHICH route wrote it, so the card can put
|
||||
# it on the right floor without re-deriving the routing itself.
|
||||
if route_id:
|
||||
cur["route_id"] = route_id
|
||||
if source:
|
||||
cur["source"] = source
|
||||
rec["current"] = cur
|
||||
pts: list[list[float]] = cur["points"]
|
||||
if pts and pts[-1][0] == x and pts[-1][1] == y:
|
||||
@@ -122,11 +149,33 @@ class TrailBook:
|
||||
"""Forget every stored run of one plan marker."""
|
||||
return self.data.pop(marker, None) is not None
|
||||
|
||||
def drop_unknown_routes(self, marker: str, route_ids: set[str]) -> bool:
|
||||
"""Forget runs whose route no longer exists (deleted or re-targeted).
|
||||
|
||||
Only runs that name a route are touched. A pre-#162 run names none and
|
||||
is adopted by the card instead — deleting it here would destroy history
|
||||
the user can still legitimately see.
|
||||
"""
|
||||
rec = self.data.get(marker)
|
||||
if not isinstance(rec, dict):
|
||||
return False
|
||||
changed = False
|
||||
for slot in ("current", "previous"):
|
||||
run = rec.get(slot)
|
||||
if not isinstance(run, dict):
|
||||
continue
|
||||
stored = run.get("route_id")
|
||||
if isinstance(stored, str) and stored and stored not in route_ids:
|
||||
rec.pop(slot)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
class TrailRecorder:
|
||||
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, rt: Any) -> None:
|
||||
self.routes_by_marker: dict[str, list[dict[str, Any]]] = {}
|
||||
self.hass = hass
|
||||
self.rt = rt
|
||||
self.store = Store(hass, 1, f"{DOMAIN}.trails")
|
||||
@@ -166,22 +215,35 @@ class TrailRecorder:
|
||||
cfg = stored.get("config") or {}
|
||||
pairs: dict[str, list[tuple[str, str]]] = {}
|
||||
health_pairs: set[tuple[str, str]] = set()
|
||||
routes_by_marker: dict[str, list[dict[str, Any]]] = {}
|
||||
for m in cfg.get("markers") or []:
|
||||
if m.get("removed") is True:
|
||||
continue
|
||||
v = m.get("vacuum") or {}
|
||||
src = v.get("source")
|
||||
if not src or v.get("live") is False:
|
||||
if v.get("live") is False:
|
||||
continue
|
||||
marker_id = str(m.get("id"))
|
||||
health_pairs.add((marker_id, str(src)))
|
||||
# #162: a multi-floor robot may publish each map through its own
|
||||
# camera, so every route source is watched — not only the root
|
||||
# discovery source, which is now just one of them.
|
||||
routes = effective_routes(marker_id, v, str(m.get("space") or ""), src)
|
||||
routes_by_marker[marker_id] = routes
|
||||
sources = {str(route["source"]) for route in routes if route.get("source")}
|
||||
if src:
|
||||
sources.add(str(src))
|
||||
if not sources:
|
||||
continue
|
||||
vac = self._vacuum_entity(m)
|
||||
if vac:
|
||||
# HP-1540-03: append, never overwrite — every floor's
|
||||
# marker records its own copy of the run
|
||||
pairs.setdefault(src, []).append((marker_id, vac))
|
||||
for source in sorted(sources):
|
||||
health_pairs.add((marker_id, source))
|
||||
if vac:
|
||||
# HP-1540-03: append, never overwrite — every floor's
|
||||
# marker records its own copy of the run
|
||||
pairs.setdefault(source, []).append((marker_id, vac))
|
||||
self._refresh_source_health(health_pairs)
|
||||
self.pairs = pairs
|
||||
self.routes_by_marker = routes_by_marker
|
||||
self._resubscribe()
|
||||
# A run already in progress (HA restarted mid-cleanup, or the user
|
||||
# just finished calibrating) must start recording NOW, not at the
|
||||
@@ -261,6 +323,17 @@ class TrailRecorder:
|
||||
for marker in config.get("markers") or []
|
||||
if marker.get("id") is not None and marker.get("removed") is not True
|
||||
}
|
||||
# #162: a route that vanished (deleted, or re-targeted to another
|
||||
# space, which is a new identity) takes its own runs with it, while the
|
||||
# marker and its other routes stay untouched.
|
||||
for marker in config.get("markers") or []:
|
||||
marker_id = str(marker.get("id"))
|
||||
if marker.get("removed") is True or marker_id not in self.book.data:
|
||||
continue
|
||||
vacuum = marker.get("vacuum") or {}
|
||||
routes = effective_routes(
|
||||
marker_id, vacuum, str(marker.get("space") or ""), vacuum.get("source"))
|
||||
self.book.drop_unknown_routes(marker_id, {str(r.get("id")) for r in routes})
|
||||
orphan_ids = set(self.book.data) - live_marker_ids
|
||||
if not orphan_ids:
|
||||
return 0
|
||||
@@ -381,9 +454,26 @@ class TrailRecorder:
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
map_id = resolve_map_id(attrs, st_vac.attributes)
|
||||
changed |= self.book.on_point(marker, map_id, x, y, now)
|
||||
route_id = self._route_id(marker, src, map_id)
|
||||
changed |= self.book.on_point(
|
||||
marker, map_id, x, y, now, route_id=route_id,
|
||||
source=src if route_id else "",
|
||||
)
|
||||
return changed
|
||||
|
||||
def _route_id(self, marker: str, source: str, map_id: str) -> str:
|
||||
"""The route this point belongs to, or "" when routing cannot say.
|
||||
|
||||
Validation keeps (source, map_id) unique inside one marker, so at most
|
||||
one route can match. No match means the map is unmapped: the point is
|
||||
still recorded — raw history is valuable — but it is not filed under a
|
||||
route that does not own it.
|
||||
"""
|
||||
for route in getattr(self, "routes_by_marker", {}).get(marker) or ():
|
||||
if route.get("source") == source and route.get("map_id") == map_id:
|
||||
return str(route.get("id") or "")
|
||||
return ""
|
||||
|
||||
@callback
|
||||
def _on_state(self, event: Any) -> None:
|
||||
eid = event.data.get("entity_id")
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Grundrissbild fehlt",
|
||||
"description": "Die Plandatei `{file}` für den Bereich `{space}` wurde in `config/houseplan/plans/` nicht gefunden. In den Bereichseinstellungen der House-Plan-Karte den Plan erneut hochladen."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House-Plan-Karte verbunden",
|
||||
"description": "Die House-Plan-Karte ist verbunden. Laden Sie diese Seite vollständig neu, um die aktuelle Benutzeroberfläche zu verwenden (`Ctrl+F5` unter Windows/Linux oder `Cmd+Shift+R` unter macOS). Wenn Sie Dashboard-Ressourcen im Speichermodus manuell verwalten, öffnen Sie Einstellungen → Dashboards → Ressourcen."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Datei der House-Plan-Karte",
|
||||
"static_path": "Statischer Frontend-Pfad",
|
||||
"resource_status": "Status der Ressourcenregistrierung",
|
||||
"resource_loader": "Lademethode der Frontend-Ressource",
|
||||
"resource_url": "URL der Frontend-Ressource",
|
||||
"resource_retry": "Wiederholung der Ressourcenregistrierung",
|
||||
"resource_error": "Fehler der Frontend-Registrierung",
|
||||
"first_reload_notice": "Erster Hinweis zum Neuladen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Floor plan image is missing",
|
||||
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House Plan card connected",
|
||||
"description": "The House Plan card is connected. Fully reload this page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you manage dashboard resources manually in storage mode, open Settings → Dashboards → Resources."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "House Plan card file",
|
||||
"static_path": "Static frontend path",
|
||||
"resource_status": "Resource registration status",
|
||||
"resource_loader": "Frontend resource loader",
|
||||
"resource_url": "Frontend resource URL",
|
||||
"resource_retry": "Resource registration retry",
|
||||
"resource_error": "Frontend registration error",
|
||||
"first_reload_notice": "First reload notice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"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."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "Carte House Plan connectée",
|
||||
"description": "La carte House Plan est connectée. Rechargez complètement cette page pour utiliser l’interface la plus récente (`Ctrl+F5` sous Windows/Linux ou `Cmd+Shift+R` sous macOS). Si vous gérez manuellement les ressources du tableau de bord en mode stockage, ouvrez Paramètres → Tableaux de bord → Ressources."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Fichier de la carte House Plan",
|
||||
"static_path": "Chemin statique de l’interface",
|
||||
"resource_status": "État de l’enregistrement de la ressource",
|
||||
"resource_loader": "Mode de chargement de la ressource",
|
||||
"resource_url": "URL de la ressource de l’interface",
|
||||
"resource_retry": "Nouvelle tentative d’enregistrement",
|
||||
"resource_error": "Erreur d’enregistrement de l’interface",
|
||||
"first_reload_notice": "Premier avis de rechargement"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Файл плана этажа не найден",
|
||||
"description": "Файл плана `{file}` пространства `{space}` не найден в `config/houseplan/plans/`. Откройте настройки пространства в карточке House Plan и загрузите план заново."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "Карточка House Plan подключена",
|
||||
"description": "Карточка House Plan подключена. Полностью перезагрузите эту страницу, чтобы использовать актуальный интерфейс (`Ctrl+F5` в Windows/Linux или `Cmd+Shift+R` в macOS). Если вы вручную управляете ресурсами панели в режиме хранения, откройте Настройки → Панели → Ресурсы."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Файл карточки House Plan",
|
||||
"static_path": "Статический путь интерфейса",
|
||||
"resource_status": "Статус регистрации ресурса",
|
||||
"resource_loader": "Способ загрузки ресурса",
|
||||
"resource_url": "URL ресурса интерфейса",
|
||||
"resource_retry": "Повтор регистрации ресурса",
|
||||
"resource_error": "Ошибка регистрации интерфейса",
|
||||
"first_reload_notice": "Первое уведомление о перезагрузке"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Map-to-space routing for multi-floor vacuums (#162).
|
||||
|
||||
Python mirror of ``src/vacuum-routes.ts``. The recorder and the import/export
|
||||
flows must answer "which floor is this map on?" exactly the way the card does,
|
||||
so both sides are driven by the same shared fixtures in
|
||||
``test/fixtures/vacuum-routes/``. Keep the two files in step: a divergence here
|
||||
shows up as a robot drawn on the wrong floor, which is worse than no robot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
VAC_ROUTE_LIMIT = 32
|
||||
VAC_ROUTE_ID_MAX = 128
|
||||
VAC_ROUTE_SOURCE_MAX = 255
|
||||
VAC_ROUTE_MAP_ID_MAX = 255
|
||||
VAC_ROUTE_ERROR = "invalid_vacuum_map_route"
|
||||
|
||||
_ENTITY_ID = re.compile(r"^[a-z_]+\.[a-zA-Z0-9_]+$")
|
||||
|
||||
|
||||
def normalize_route_matrix(raw: Any) -> list[float] | None:
|
||||
"""Six finite numbers, or nothing; a five-number matrix is not almost fine."""
|
||||
if not isinstance(raw, (list, tuple)) or len(raw) != 6:
|
||||
return None
|
||||
out: list[float] = []
|
||||
for value in raw:
|
||||
# bool is an int in Python; a boolean is not a coordinate.
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
number = float(value)
|
||||
if number != number or number in (float("inf"), float("-inf")):
|
||||
return None
|
||||
out.append(number)
|
||||
return out
|
||||
|
||||
|
||||
def is_entity_id_like(value: Any) -> bool:
|
||||
return isinstance(value, str) and bool(_ENTITY_ID.match(value))
|
||||
|
||||
|
||||
def validate_marker_routes(
|
||||
marker_id: str, routes: Any, space_ids: set[str] | None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Shape/uniqueness/reference issues for one marker's routes.
|
||||
|
||||
``space_ids`` None means the spaces are not known in this context (import
|
||||
preview), so the referential check is skipped instead of failing every
|
||||
route.
|
||||
"""
|
||||
if routes is None:
|
||||
return []
|
||||
|
||||
def issue(route_id: str, reason: str) -> dict[str, str]:
|
||||
return {
|
||||
"code": VAC_ROUTE_ERROR, "markerId": marker_id,
|
||||
"routeId": route_id, "reason": reason,
|
||||
}
|
||||
|
||||
if not isinstance(routes, list):
|
||||
return [issue("", "not_object")]
|
||||
problems: list[dict[str, str]] = []
|
||||
if len(routes) > VAC_ROUTE_LIMIT:
|
||||
problems.append(issue("", "limit"))
|
||||
seen_ids: set[str] = set()
|
||||
seen_identity: set[str] = set()
|
||||
for raw in routes:
|
||||
if not isinstance(raw, dict):
|
||||
problems.append(issue("", "not_object"))
|
||||
continue
|
||||
route_id = raw.get("id") if isinstance(raw.get("id"), str) else ""
|
||||
if not route_id or len(route_id) > VAC_ROUTE_ID_MAX:
|
||||
problems.append(issue(route_id or "", "id"))
|
||||
continue
|
||||
if route_id in seen_ids:
|
||||
problems.append(issue(route_id, "duplicate_id"))
|
||||
seen_ids.add(route_id)
|
||||
source = raw.get("source")
|
||||
if not is_entity_id_like(source) or len(source) > VAC_ROUTE_SOURCE_MAX:
|
||||
problems.append(issue(route_id, "source"))
|
||||
map_id = raw.get("map_id")
|
||||
# An empty map id is a real id (resolve_map_id): type and length only.
|
||||
if not isinstance(map_id, str) or len(map_id) > VAC_ROUTE_MAP_ID_MAX:
|
||||
problems.append(issue(route_id, "map_id"))
|
||||
space = raw.get("space")
|
||||
if not isinstance(space, str) or not space:
|
||||
problems.append(issue(route_id, "space"))
|
||||
elif space_ids is not None and space not in space_ids:
|
||||
problems.append(issue(route_id, "unknown_space"))
|
||||
calibration = raw.get("calibration")
|
||||
if calibration is not None and normalize_route_matrix(calibration) is None:
|
||||
problems.append(issue(route_id, "calibration"))
|
||||
if isinstance(source, str) and isinstance(map_id, str):
|
||||
identity = source + " " + map_id
|
||||
if identity in seen_identity:
|
||||
problems.append(issue(route_id, "duplicate_identity"))
|
||||
seen_identity.add(identity)
|
||||
return problems
|
||||
|
||||
|
||||
def legacy_route_id(marker_id: str, source: str, map_id: str) -> str:
|
||||
return "legacy:" + marker_id + " " + source + " " + map_id
|
||||
|
||||
|
||||
def effective_routes(
|
||||
marker_id: str, vacuum: Any, dock_space: str, discovered_source: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""The routes a marker effectively has, legacy calibration included."""
|
||||
if not isinstance(vacuum, dict):
|
||||
return []
|
||||
explicit = vacuum.get("map_routes")
|
||||
if isinstance(explicit, list):
|
||||
out = []
|
||||
for route in explicit:
|
||||
if not isinstance(route, dict) or not isinstance(route.get("id"), str):
|
||||
continue
|
||||
if not route["id"]:
|
||||
continue
|
||||
out.append({
|
||||
"id": route["id"],
|
||||
"source": str(route.get("source") or ""),
|
||||
"map_id": str(route.get("map_id") or ""),
|
||||
"space": str(route.get("space") or ""),
|
||||
"calibration": normalize_route_matrix(route.get("calibration")),
|
||||
})
|
||||
return out
|
||||
source = vacuum.get("source")
|
||||
if not isinstance(source, str) or not source:
|
||||
source = discovered_source or ""
|
||||
if not source:
|
||||
return []
|
||||
calibration = vacuum.get("calibration")
|
||||
if not isinstance(calibration, dict):
|
||||
return []
|
||||
out = []
|
||||
for map_id, matrix in calibration.items():
|
||||
normalized = normalize_route_matrix(matrix)
|
||||
if normalized is None:
|
||||
continue
|
||||
out.append({
|
||||
"id": legacy_route_id(marker_id, source, str(map_id)),
|
||||
"source": source, "map_id": str(map_id), "space": dock_space,
|
||||
"calibration": normalized,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def resolve_route(
|
||||
routes: list[dict[str, Any]], observed: dict[str, str], space_ids: set[str] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Pick the one route the robot is on right now — or refuse to pick.
|
||||
|
||||
List order must never decide a floor: two plausible routes are ambiguous,
|
||||
not "the first one".
|
||||
"""
|
||||
matched: list[dict[str, Any]] = []
|
||||
saw_telemetry = False
|
||||
fallback_source = ""
|
||||
fallback_map_id = ""
|
||||
for route in routes:
|
||||
source = route.get("source")
|
||||
if source not in observed:
|
||||
continue
|
||||
seen = observed[source]
|
||||
saw_telemetry = True
|
||||
if not fallback_source:
|
||||
fallback_source = source
|
||||
fallback_map_id = seen
|
||||
if seen == route.get("map_id"):
|
||||
matched.append(route)
|
||||
if len(matched) > 1:
|
||||
return {"kind": "ambiguous", "routeIds": sorted(str(r.get("id")) for r in matched)}
|
||||
if len(matched) == 1:
|
||||
route = matched[0]
|
||||
if space_ids is not None and route.get("space") not in space_ids:
|
||||
return {"kind": "missing_space", "route": route}
|
||||
matrix = normalize_route_matrix(route.get("calibration"))
|
||||
if matrix is None:
|
||||
return {"kind": "needs_calibration", "route": route}
|
||||
return {"kind": "ready", "route": {**route, "calibration": matrix}}
|
||||
if saw_telemetry:
|
||||
return {"kind": "unmapped", "source": fallback_source, "mapId": fallback_map_id}
|
||||
if observed:
|
||||
source = sorted(observed)[0]
|
||||
return {"kind": "unmapped", "source": source, "mapId": observed[source]}
|
||||
return {"kind": "none"}
|
||||
|
||||
|
||||
def adopt_legacy_run(
|
||||
run: Any, routes: list[dict[str, Any]], root_source: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Where a run recorded before #162 belongs (spec 11.3.1).
|
||||
|
||||
Such a run stores only ``{map_id, started, ended, points}``: it carries no
|
||||
source at all. The one surviving witness of the subscription that wrote it
|
||||
is the marker's root ``vacuum.source``, so that is what narrows the
|
||||
candidates; without it only the map id is compared. Two candidates mean the
|
||||
run is not drawn — not that the first one wins.
|
||||
"""
|
||||
if not isinstance(run, dict) or not isinstance(run.get("map_id"), str):
|
||||
return {"kind": "orphan_run"}
|
||||
map_id = run["map_id"]
|
||||
root = root_source if isinstance(root_source, str) else ""
|
||||
candidates = [route for route in routes if route.get("map_id") == map_id]
|
||||
if root:
|
||||
candidates = [route for route in candidates if route.get("source") == root]
|
||||
if len(candidates) == 1:
|
||||
return {"kind": "adopted", "route": candidates[0]}
|
||||
if not candidates:
|
||||
return {"kind": "orphan_run"}
|
||||
return {"kind": "ambiguous_run", "routeIds": sorted(str(r.get("id")) for r in candidates)}
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +17,7 @@ from custom_components.houseplan.coordinate_canonicalization import (
|
||||
canonicalize_layout_geometry,
|
||||
canonicalize_position,
|
||||
)
|
||||
from custom_components.houseplan.vacuum_routes import validate_marker_routes
|
||||
|
||||
# ---------- limits and extension sets ----------
|
||||
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
|
||||
@@ -49,9 +51,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 +77,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):
|
||||
@@ -174,6 +180,17 @@ def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
|
||||
except (TypeError, ValueError):
|
||||
return # CONFIG_SCHEMA owns malformed values.
|
||||
previous = previous or {}
|
||||
if old_model >= 10 and new_model < 10 and any(
|
||||
"room_drafts" in space for space in config.get("spaces") or []
|
||||
if isinstance(space, dict)
|
||||
):
|
||||
# A v9 card can echo a cached draft without changing the contour
|
||||
# projection at all. The old catalogue guard therefore cannot see the
|
||||
# destructive downgrade, but v10 must never persist the removed
|
||||
# carrier again (#478 AC2).
|
||||
raise WallModelClientOutdatedError(
|
||||
f"stored model={old_model}; submitted legacy room_drafts model={new_model}"
|
||||
)
|
||||
contour_geometry_changed = (
|
||||
_catalog_coupled_wall_geometry_projection(config)
|
||||
!= _catalog_coupled_wall_geometry_projection(previous)
|
||||
@@ -410,7 +427,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
|
||||
@@ -877,6 +894,50 @@ def validate_marker_value_badges(
|
||||
validate_source(source, "value_source")
|
||||
|
||||
|
||||
def validate_marker_vacuum_routes(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
"""Validate changed vacuum map routes; dormant future data round-trips.
|
||||
|
||||
Change-aware like the badge validator above (#162): an untouched legacy or
|
||||
future-shaped block must not block an unrelated save, but any edit to
|
||||
routing has to leave the marker in a state the resolver can answer for —
|
||||
unique identities, six-number matrices and target spaces that exist.
|
||||
"""
|
||||
markers = config.get("markers") or []
|
||||
spaces = config.get("spaces")
|
||||
space_ids = None
|
||||
if isinstance(spaces, list):
|
||||
space_ids = {
|
||||
str(space.get("id")) for space in spaces
|
||||
if isinstance(space, dict) and isinstance(space.get("id"), str)
|
||||
}
|
||||
old_markers = (previous or {}).get("markers") or []
|
||||
old_by_id = {str(marker.get("id")): marker for marker in old_markers}
|
||||
by_id = {str(marker.get("id")): marker for marker in markers}
|
||||
new_ids = set(by_id)
|
||||
consumed_old_ids: set[str] = set()
|
||||
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,
|
||||
)
|
||||
routes = (marker.get("vacuum") or {}).get("map_routes")
|
||||
old_routes = None if validate_all else ((old_marker or {}).get("vacuum") or {}).get("map_routes")
|
||||
if not validate_all and routes == old_routes:
|
||||
continue
|
||||
problems = validate_marker_routes(marker_id, routes, space_ids)
|
||||
if problems:
|
||||
reasons = ", ".join(sorted({problem["reason"] for problem in problems}))
|
||||
# Literal, not the constant: the #42 scanner proves the emitted
|
||||
# code against ERROR_CODES by reading the source, and it cannot
|
||||
# follow a name. The pair is pinned by a test below.
|
||||
raise MarkerControlError(
|
||||
"invalid_vacuum_map_route",
|
||||
f"Invalid vacuum map routes on {marker_id}: {reasons}",
|
||||
)
|
||||
|
||||
|
||||
def validate_marker_light_entities(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
@@ -1064,8 +1125,8 @@ MAX_DECOR = 1000
|
||||
# later constant declaration.
|
||||
MAX_WALLS = 200_000
|
||||
MAX_WALL_SEGMENTS = 200_000
|
||||
MAX_ROOM_DRAFTS = 200
|
||||
MAX_DRAFT_SEGMENTS = 2000
|
||||
_LEGACY_MAX_ROOM_DRAFTS = 200
|
||||
_LEGACY_MAX_DRAFT_SEGMENTS = 2000
|
||||
MAX_PARTITIONS = 2000
|
||||
MAX_WALL_COLUMNS = 500
|
||||
# Open (virtual) wall stretches, docs/superpowers/specs/2026-08-05-open-spans-delete-design.md.
|
||||
@@ -1085,6 +1146,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
|
||||
@@ -1098,6 +1160,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))
|
||||
|
||||
@@ -1312,6 +1375,7 @@ _FURN_SYMBOL = vol.All(str, vol.Length(min=1, max=MAX_FURN_SYMBOL),
|
||||
# …and its size: strictly positive, capped by the same canvas insurance limit
|
||||
# an opening's length is. A piece of furniture is a SIZE, not a coordinate.
|
||||
_FURN_SIZE = vol.All(_finite, vol.Range(min=0.0000001, max=CANVAS_LIMIT))
|
||||
_DECOR_ASSET_ID = vol.All(str, vol.Match(r"^[0-9a-f]{64}$"))
|
||||
|
||||
_DECOR_COMMON = {
|
||||
vol.Required("id"): str,
|
||||
@@ -1374,6 +1438,14 @@ 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),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "image",
|
||||
vol.Required("asset_id"): _DECOR_ASSET_ID,
|
||||
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),
|
||||
)
|
||||
@@ -1423,16 +1495,16 @@ WALL_SEGMENT_SCHEMA = vol.All(
|
||||
)
|
||||
|
||||
|
||||
def _room_draft_segments(value: dict) -> dict:
|
||||
def _legacy_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
|
||||
|
||||
|
||||
ROOM_DRAFT_SCHEMA = vol.All(
|
||||
_LEGACY_ROOM_DRAFT_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
@@ -1448,7 +1520,7 @@ ROOM_DRAFT_SCHEMA = vol.All(
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_room_draft_segments,
|
||||
_legacy_room_draft_segments,
|
||||
)
|
||||
|
||||
def _partition_nonzero(value: dict) -> dict:
|
||||
@@ -1531,7 +1603,7 @@ def _space_geometry_invariants(value: dict) -> dict:
|
||||
draft_segments = sum(
|
||||
len(item.get("segments", [])) for item in value.get("room_drafts", [])
|
||||
)
|
||||
if draft_segments > MAX_DRAFT_SEGMENTS:
|
||||
if draft_segments > _LEGACY_MAX_DRAFT_SEGMENTS:
|
||||
raise vol.Invalid("too many saved room-draft segments")
|
||||
for draft in value.get("room_drafts", []):
|
||||
for segment in draft.get("segments", []):
|
||||
@@ -1645,7 +1717,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
),
|
||||
vol.Optional("zero_wall_style"): vol.In(["dashed", "solid"]),
|
||||
vol.Optional("room_drafts"): vol.All(
|
||||
[ROOM_DRAFT_SCHEMA], vol.Length(max=MAX_ROOM_DRAFTS)
|
||||
[_LEGACY_ROOM_DRAFT_SCHEMA], vol.Length(max=_LEGACY_MAX_ROOM_DRAFTS)
|
||||
),
|
||||
vol.Optional("partitions"): vol.All(
|
||||
[PARTITION_SCHEMA], vol.Length(max=MAX_PARTITIONS)
|
||||
@@ -1690,7 +1762,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_]+$"))
|
||||
@@ -1713,6 +1785,22 @@ MARKER_SCHEMA = vol.Schema(
|
||||
{str: vol.All([_finite], vol.Length(min=6, max=6))}
|
||||
),
|
||||
vol.Optional("segment_map"): vol.Schema({str: str}),
|
||||
# #162: canonical map -> space routing. The schema checks the
|
||||
# shape only; identity uniqueness and the referential space
|
||||
# check live in validate_marker_vacuum_routes, which is
|
||||
# change-aware and must not reject untouched legacy data.
|
||||
vol.Optional("map_routes"): vol.Any(
|
||||
None,
|
||||
vol.All([vol.Schema({
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=128)),
|
||||
vol.Required("source"): vol.All(str, vol.Length(min=1, max=255)),
|
||||
vol.Required("map_id"): vol.All(str, vol.Length(max=255)),
|
||||
vol.Required("space"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Optional("calibration"): vol.Any(
|
||||
None, vol.All([_finite], vol.Length(min=6, max=6)),
|
||||
),
|
||||
}, extra=vol.ALLOW_EXTRA)], vol.Length(max=32)),
|
||||
),
|
||||
}),
|
||||
),
|
||||
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
|
||||
@@ -1800,6 +1888,8 @@ def _config_wall_segment_invariants(value: dict) -> dict:
|
||||
if model >= 9:
|
||||
if "open_spans" in space or any("open_to" in room for room in space.get("rooms", [])):
|
||||
raise vol.Invalid("v9 config must not contain legacy open boundaries")
|
||||
if model >= 10 and "room_drafts" in space:
|
||||
raise vol.Invalid("v10 config must not contain room_drafts")
|
||||
segments = space.get("wall_segments")
|
||||
if segments is None:
|
||||
raise vol.Invalid("v8+ space requires wall_segments")
|
||||
@@ -1840,9 +1930,10 @@ def _config_wall_segment_invariants(value: dict) -> dict:
|
||||
if actual_walls != expected_walls:
|
||||
raise vol.Invalid("wall compatibility projection must match wall_segments")
|
||||
|
||||
for draft in space.get("room_drafts", []):
|
||||
if any(not segment.get("id") for segment in draft.get("segments", [])):
|
||||
raise vol.Invalid("v8+ draft wall segments require ids")
|
||||
if model < 10:
|
||||
for draft in space.get("room_drafts", []):
|
||||
if any(not segment.get("id") for segment in draft.get("segments", [])):
|
||||
raise vol.Invalid("v8+ draft wall segments require ids")
|
||||
|
||||
partitions = {item["id"]: item for item in space.get("partitions", [])}
|
||||
wall_opening_intervals: dict[str, list[tuple[float, float]]] = {}
|
||||
@@ -1919,12 +2010,25 @@ CONFIG_SCHEMA = vol.All(
|
||||
vol.Optional("north_deg"): _north_deg,
|
||||
vol.Optional("bg_mode"): _BG_MODE,
|
||||
vol.Optional("sun_rays"): bool,
|
||||
vol.Optional("show_room_tooltip"): bool,
|
||||
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
|
||||
# legacy field so an existing stored config can still load; the
|
||||
# frontend ignores it and removes it on the next settings save.
|
||||
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
|
||||
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("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(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Deterministic persisted contour-wall identity (model v9, issues #282/#306).
|
||||
"""Deterministic persisted contour-wall identity (model v10, issues #282/#478).
|
||||
|
||||
This is the backend twin of ``src/wall-segment-model.ts``. Import preview is
|
||||
a server-side structural writer, so it cannot depend on a browser being open
|
||||
@@ -10,15 +10,17 @@ import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import math
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from .coordinate_canonicalization import canonicalize_config_geometry
|
||||
|
||||
|
||||
WALL_SEGMENT_MODEL_VERSION = 9
|
||||
WALL_SEGMENT_MODEL_VERSION = 10
|
||||
GRID_STEP_N = 1 / 240
|
||||
EPS = 1e-9
|
||||
LEGACY_DRAFT_POINT_EPSILON = 0.001
|
||||
_LEGACY_NUMBER = re.compile(r"^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$")
|
||||
|
||||
|
||||
class WallSegmentMigrationError(ValueError):
|
||||
@@ -31,6 +33,29 @@ class WallSegmentMigrationError(ValueError):
|
||||
super().__init__(f"{reason}: {detail}" if detail else reason)
|
||||
|
||||
|
||||
def _legacy_finite_number(value: Any) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||
return None
|
||||
if isinstance(value, str) and not _LEGACY_NUMBER.fullmatch(value.strip()):
|
||||
return None
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if math.isfinite(parsed) else None
|
||||
|
||||
|
||||
def _legacy_draft_point(value: Any) -> list[float] | None:
|
||||
if not isinstance(value, list) or len(value) < 2:
|
||||
return None
|
||||
x, y = _legacy_finite_number(value[0]), _legacy_finite_number(value[1])
|
||||
return None if x is None or y is None else [x, y]
|
||||
|
||||
|
||||
def _legacy_identity(value: Any) -> str:
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _point_key(point: list[float]) -> str:
|
||||
return f"{float(point[0]):.12f},{float(point[1]):.12f}"
|
||||
|
||||
@@ -192,10 +217,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 +249,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 +373,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 +577,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 +593,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 +618,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
|
||||
@@ -604,7 +644,74 @@ def _host_openings(
|
||||
opening.pop("host", None)
|
||||
|
||||
|
||||
def _migrate_space(space: dict[str, Any], initial_migration: bool) -> int:
|
||||
def _migrate_room_drafts_to_partitions(
|
||||
space: dict[str, Any], initial_migration: bool,
|
||||
) -> tuple[int, int]:
|
||||
if "room_drafts" not in space:
|
||||
return 0, 0
|
||||
if not initial_migration:
|
||||
raise WallSegmentMigrationError("duplicate-id", "model v10 must not contain room_drafts")
|
||||
drafts = space.get("room_drafts") or []
|
||||
used = {
|
||||
str(item["id"])
|
||||
for name in ("rooms", "openings", "decor", "partitions",
|
||||
"wall_columns", "wall_segments")
|
||||
for item in space.get(name) or []
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]
|
||||
}
|
||||
used.update(
|
||||
str(draft["id"]) for draft in drafts
|
||||
if isinstance(draft, dict) and isinstance(draft.get("id"), str) and draft["id"]
|
||||
)
|
||||
partitions = copy.deepcopy(space.get("partitions") or [])
|
||||
converted = 0
|
||||
for draft in drafts:
|
||||
draft_identity = _legacy_identity(draft.get("id")) if isinstance(draft, dict) else ""
|
||||
if not isinstance(draft, dict):
|
||||
raise WallSegmentMigrationError("zero-length", draft_identity)
|
||||
points = draft.get("points") if isinstance(draft.get("points"), list) else []
|
||||
segments = draft.get("segments") if isinstance(draft.get("segments"), list) else []
|
||||
if len(points) < 2 or len(segments) != len(points) - 1:
|
||||
raise WallSegmentMigrationError("zero-length", draft_identity)
|
||||
for index, segment in enumerate(segments):
|
||||
a = _legacy_draft_point(points[index])
|
||||
b = _legacy_draft_point(points[index + 1])
|
||||
if a is None or b is None or (
|
||||
abs(a[0] - b[0]) < LEGACY_DRAFT_POINT_EPSILON
|
||||
and abs(a[1] - b[1]) < LEGACY_DRAFT_POINT_EPSILON
|
||||
):
|
||||
raise WallSegmentMigrationError("zero-length", draft_identity)
|
||||
cm = _legacy_finite_number(segment.get("cm") if isinstance(segment, dict) else None)
|
||||
if cm is None or cm < 0 or cm > 100:
|
||||
raise WallSegmentMigrationError("thickness-conflict", draft_identity)
|
||||
segment_id = segment.get("id") if isinstance(segment, dict) and isinstance(segment.get("id"), str) else ""
|
||||
if not segment_id or segment_id in used:
|
||||
base = deterministic_wall_segment_id(
|
||||
_legacy_identity(space.get("id")), a, b,
|
||||
[f"draft:{draft_identity}:{index}"],
|
||||
)
|
||||
segment_id = base
|
||||
suffix = 2
|
||||
while segment_id in used:
|
||||
segment_id = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
used.add(segment_id)
|
||||
partitions.append({"id": segment_id, "a": a, "b": b, "cm": cm})
|
||||
converted += 1
|
||||
if partitions:
|
||||
space["partitions"] = partitions
|
||||
else:
|
||||
space.pop("partitions", None)
|
||||
space.pop("room_drafts", None)
|
||||
return len(drafts), converted
|
||||
|
||||
|
||||
def _migrate_space(
|
||||
space: dict[str, Any], initial_migration: bool,
|
||||
) -> tuple[int, int, int]:
|
||||
migrated_drafts, migrated_draft_segments = _migrate_room_drafts_to_partitions(
|
||||
space, initial_migration,
|
||||
)
|
||||
old: dict[str, dict[str, Any]] = {}
|
||||
for segment in space.get("wall_segments") or []:
|
||||
segment_id = str(segment.get("id", ""))
|
||||
@@ -638,47 +745,35 @@ def _migrate_space(space: dict[str, Any], initial_migration: bool) -> int:
|
||||
space.pop("open_spans", None)
|
||||
for room in space.get("rooms") or []:
|
||||
room.pop("open_to", None)
|
||||
draft_used = {
|
||||
str(item["id"])
|
||||
for name in ("rooms", "openings", "decor", "room_drafts", "partitions",
|
||||
"wall_columns", "wall_segments")
|
||||
for item in space.get(name) or []
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]
|
||||
}
|
||||
for draft in space.get("room_drafts") or []:
|
||||
for index, segment in enumerate(draft.get("segments") or []):
|
||||
if isinstance(segment.get("id"), str) and segment["id"]:
|
||||
if segment["id"] in draft_used:
|
||||
raise WallSegmentMigrationError("duplicate-id", segment["id"])
|
||||
draft_used.add(segment["id"])
|
||||
continue
|
||||
try:
|
||||
a, b = draft["points"][index], draft["points"][index + 1]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
raise WallSegmentMigrationError("zero-length", str(draft.get("id", ""))) from None
|
||||
if initial_migration:
|
||||
base = deterministic_wall_segment_id(
|
||||
str(space.get("id", "")), a, b, [f"draft:{draft.get('id', '')}"],
|
||||
)
|
||||
suffix, segment["id"] = 1, base
|
||||
while segment["id"] in draft_used:
|
||||
suffix += 1
|
||||
segment["id"] = f"{base}-{suffix}"
|
||||
else:
|
||||
segment["id"] = _fresh_wall_segment_id(draft_used)
|
||||
draft_used.add(segment["id"])
|
||||
_host_openings(space, segments, initial_migration)
|
||||
return sum(1 for segment in segments if segment["id"] not in old)
|
||||
return (
|
||||
sum(1 for segment in segments if segment["id"] not in old),
|
||||
migrated_drafts,
|
||||
migrated_draft_segments,
|
||||
)
|
||||
|
||||
|
||||
def commit_wall_segment_model(config: Any) -> tuple[Any, int]:
|
||||
def commit_wall_segment_model(
|
||||
config: Any, *, migration_report: dict[str, int] | None = None,
|
||||
) -> tuple[Any, int]:
|
||||
"""Return one migrated deep copy and the count of newly assigned wall ids."""
|
||||
candidate = canonicalize_config_geometry(copy.deepcopy(config))
|
||||
if not isinstance(candidate, dict):
|
||||
raise WallSegmentMigrationError("invalid-room")
|
||||
migrated = 0
|
||||
migrated_drafts = 0
|
||||
migrated_draft_segments = 0
|
||||
initial_migration = int(candidate.get("model_version", 0) or 0) < WALL_SEGMENT_MODEL_VERSION
|
||||
for space in candidate.get("spaces") or []:
|
||||
migrated += _migrate_space(space, initial_migration)
|
||||
wall_segments, drafts, draft_segments = _migrate_space(space, initial_migration)
|
||||
migrated += wall_segments
|
||||
migrated_drafts += drafts
|
||||
migrated_draft_segments += draft_segments
|
||||
candidate["model_version"] = WALL_SEGMENT_MODEL_VERSION
|
||||
if migration_report is not None:
|
||||
migration_report.clear()
|
||||
migration_report.update({
|
||||
"room_drafts": migrated_drafts,
|
||||
"room_draft_segments": migrated_draft_segments,
|
||||
})
|
||||
return canonicalize_config_geometry(candidate), migrated
|
||||
|
||||
@@ -1,36 +1,64 @@
|
||||
"""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 re
|
||||
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 .asset_integrity import get_asset_integrity_verifier
|
||||
from .auth import may_write
|
||||
from .const import (
|
||||
ASSETS_DIR,
|
||||
CONTENT_URL,
|
||||
DECOR_ASSETS_API_VERSION,
|
||||
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_API_VERSION,
|
||||
SUPPORT_PREVIEW_TTL_S,
|
||||
VERSION,
|
||||
)
|
||||
from .coordinate_canonicalization import (
|
||||
canonicalize_config_geometry,
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .decor_assets import (
|
||||
ASSET_EXTENSIONS,
|
||||
ASSET_ID_RE,
|
||||
asset_meta_path,
|
||||
asset_refs,
|
||||
public_asset,
|
||||
read_asset,
|
||||
read_catalog,
|
||||
)
|
||||
from .import_export import (
|
||||
ImportFailure,
|
||||
content_manifest,
|
||||
@@ -40,43 +68,67 @@ 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_vacuum_routes,
|
||||
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__)
|
||||
|
||||
@@ -157,10 +209,16 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_plans_delete)
|
||||
websocket_api.async_register_command(hass, ws_files_migrate)
|
||||
websocket_api.async_register_command(hass, ws_files_cleanup)
|
||||
websocket_api.async_register_command(hass, ws_assets_list)
|
||||
websocket_api.async_register_command(hass, ws_assets_resolve)
|
||||
websocket_api.async_register_command(hass, ws_assets_delete)
|
||||
websocket_api.async_register_command(hass, ws_content_sign)
|
||||
websocket_api.async_register_command(hass, ws_export_create)
|
||||
websocket_api.async_register_command(hass, ws_import_revalidate)
|
||||
websocket_api.async_register_command(hass, ws_import_apply)
|
||||
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 +247,56 @@ 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)
|
||||
|
||||
|
||||
_SUPPORT_REPAIR_FAMILY = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
|
||||
|
||||
|
||||
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), issue in list(registry.issues.items()):
|
||||
if domain != DOMAIN:
|
||||
continue
|
||||
family = getattr(issue, "translation_key", None)
|
||||
if isinstance(family, str) and _SUPPORT_REPAIR_FAMILY.fullmatch(family):
|
||||
counts[family] += 1
|
||||
return [{"code": code, "count": count} for code, count in sorted(counts.items())]
|
||||
|
||||
|
||||
def _support_preview_has_capacity(
|
||||
rt: HouseplanData,
|
||||
owner: str,
|
||||
draft_id: str,
|
||||
) -> bool:
|
||||
"""Check quota while treating this owner's current draft as replaceable."""
|
||||
retained = [
|
||||
item for item in rt.support_previews.values()
|
||||
if not (item.get("owner") == owner and item.get("draft_id") == draft_id)
|
||||
]
|
||||
owned = sum(1 for item in retained if item.get("owner") == owner)
|
||||
return (
|
||||
owned < MAX_SUPPORT_PREVIEWS_PER_USER
|
||||
and len(retained) < MAX_SUPPORT_PREVIEWS_TOTAL
|
||||
)
|
||||
|
||||
|
||||
def _layout_metadata(stored: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return the exact non-layout portion of a layout-store document."""
|
||||
return {
|
||||
@@ -261,7 +369,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 +420,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 +594,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
|
||||
@@ -1000,6 +1108,120 @@ async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
connection.send_result(msg["id"], {"urls": out})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/assets/list"})
|
||||
@websocket_api.async_response
|
||||
async def ws_assets_list(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""List reusable custom images with authoritative reference counts."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only writers may list image assets")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock, rt.upload_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
refs = asset_refs(stored.get("config") or {})
|
||||
root = Path(hass.config.path(ASSETS_DIR))
|
||||
rows = await hass.async_add_executor_job(read_catalog, root)
|
||||
connection.send_result(
|
||||
msg["id"], {"assets": [public_asset(row, refs.get(row["asset_id"])) for row in rows]},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/assets/resolve",
|
||||
vol.Required("asset_ids"): vol.All(
|
||||
[vol.All(str, vol.Match(r"^[0-9a-f]{64}$"))], vol.Length(max=200),
|
||||
),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_assets_resolve(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Resolve each unique id once; absent/corrupt content is reported missing."""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
requested = set(msg["asset_ids"])
|
||||
allowed = requested
|
||||
if not _check_write(hass, connection):
|
||||
async with rt.write_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
referenced = set(asset_refs(stored.get("config") or {}))
|
||||
allowed = requested & referenced
|
||||
|
||||
root = Path(hass.config.path(ASSETS_DIR))
|
||||
verifier = get_asset_integrity_verifier(hass)
|
||||
|
||||
def _resolve() -> tuple[list[dict], list[str]]:
|
||||
rows: list[dict] = []
|
||||
found: set[str] = set()
|
||||
for aid in sorted(allowed):
|
||||
row = read_asset(root, aid)
|
||||
if row is None:
|
||||
continue
|
||||
path = root / f"{aid}{row['ext']}"
|
||||
if not verifier.verify(path, aid):
|
||||
continue
|
||||
rows.append(public_asset(row))
|
||||
found.add(aid)
|
||||
return rows, sorted(requested - found)
|
||||
|
||||
assets, missing = await hass.async_add_executor_job(_resolve)
|
||||
connection.send_result(msg["id"], {"assets": assets, "missing": missing})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/assets/delete",
|
||||
vol.Required("asset_id"): vol.All(str, vol.Match(r"^[0-9a-f]{64}$")),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_assets_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Delete only an explicitly requested, currently unreferenced asset."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only writers may delete images")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
aid = msg["asset_id"]
|
||||
if not ASSET_ID_RE.fullmatch(aid):
|
||||
connection.send_error(msg["id"], "invalid_format", "Invalid asset id")
|
||||
return
|
||||
root = Path(hass.config.path(ASSETS_DIR))
|
||||
async with rt.write_lock, rt.upload_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
refs = asset_refs(stored.get("config") or {}).get(aid, [])
|
||||
if refs:
|
||||
connection.send_error(msg["id"], "in_use", "A decor object still uses this image")
|
||||
return
|
||||
|
||||
def _delete() -> bool:
|
||||
removed = False
|
||||
for extension in ASSET_EXTENSIONS:
|
||||
path = root / f"{aid}{extension}"
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
path.unlink()
|
||||
removed = True
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
meta = asset_meta_path(root, aid)
|
||||
if meta.is_file():
|
||||
try:
|
||||
meta.unlink()
|
||||
removed = True
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
removed = await hass.async_add_executor_job(_delete)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/files/cleanup",
|
||||
@@ -1169,6 +1391,9 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
# #295: the card compares this against its own version to decide
|
||||
# whether the «update House Plan» preflight hint can actually help.
|
||||
"integration_version": VERSION,
|
||||
# #423: protocol capability is independent from release skew.
|
||||
"support_api": SUPPORT_API_VERSION,
|
||||
"decor_assets_api": DECOR_ASSETS_API_VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1354,6 +1579,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
validate_marker_controls(msg["config"], data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], data.get("config"))
|
||||
validate_marker_vacuum_routes(msg["config"], data.get("config"))
|
||||
validate_opening_passages(msg["config"], data.get("config"))
|
||||
validate_partition_opening_hosts(msg["config"], data.get("config"))
|
||||
return validate_junction_limits(
|
||||
@@ -1618,7 +1844,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
|
||||
@@ -1712,6 +1938,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
validate_marker_controls(msg["config"], config_data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], config_data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], config_data.get("config"))
|
||||
validate_marker_vacuum_routes(msg["config"], config_data.get("config"))
|
||||
validate_opening_passages(msg["config"], config_data.get("config"))
|
||||
validate_partition_opening_hosts(
|
||||
msg["config"], config_data.get("config"),
|
||||
@@ -2010,3 +2237,245 @@ 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
|
||||
|
||||
owner = _connection_user_id(connection)
|
||||
_prune_support_previews(rt)
|
||||
if not _support_preview_has_capacity(rt, owner, msg["draft_id"]):
|
||||
_send_support_error(connection, msg["id"], "support_rate_limited")
|
||||
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()
|
||||
_prune_support_previews(rt, now)
|
||||
# The executor yields to other requests. Re-check after it returns so two
|
||||
# concurrent builds cannot both consume the final slot. The old preview is
|
||||
# retained until the replacement is ready and this second check succeeds.
|
||||
if not _support_preview_has_capacity(rt, owner, msg["draft_id"]):
|
||||
_send_support_error(connection, msg["id"], "support_rate_limited")
|
||||
return
|
||||
# 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)
|
||||
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,
|
||||
)
|
||||
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})
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
// rerun by hand when recalibrating WARN_DECODED_BYTES / HARD_DIMENSION.
|
||||
// PLAYWRIGHT_BROWSERS_PATH=... node demo/benchmark_backdrop_decode.mjs
|
||||
import { chromium } from 'playwright';
|
||||
import { reportPageErrors, watchPage } from './serve.mjs';
|
||||
|
||||
const guardProbe = process.argv.includes('--guard-probe');
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const page = await (await browser.newContext()).newPage();
|
||||
const page = watchPage(await (await browser.newContext()).newPage());
|
||||
const session = await page.context().newCDPSession(page);
|
||||
await page.goto('about:blank');
|
||||
const metrics = async () => {
|
||||
@@ -17,7 +20,7 @@ const cases = [
|
||||
{ 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) {
|
||||
for (const c of guardProbe ? [] : cases) {
|
||||
try {
|
||||
const r = await page.evaluate(async ({ mp, alpha }) => {
|
||||
const side = Math.round(Math.sqrt(mp * 1e6));
|
||||
@@ -53,4 +56,13 @@ for (const c of cases) {
|
||||
console.log(JSON.stringify({ mp: c.mp, alpha: c.alpha, FAIL: String(e).slice(0, 120) }));
|
||||
}
|
||||
}
|
||||
if (guardProbe) {
|
||||
await page.evaluate(() => {
|
||||
setTimeout(() => { throw new Error('houseplan backdrop guard probe'); }, 0);
|
||||
});
|
||||
}
|
||||
if (await reportPageErrors()) {
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
await browser.close();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
/** Reproducible browser benchmark and report producer for HP-PERF-01. */
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { launch } from './serve.mjs';
|
||||
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
@@ -8,6 +9,10 @@ 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';
|
||||
import {
|
||||
ISOMETRIC_STAGE3_DENSE_PROFILE,
|
||||
makeIsometricStage3DenseFixture,
|
||||
} from './performance/isometric-stage3-dense-fixture.mjs';
|
||||
|
||||
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
|
||||
@@ -15,26 +20,49 @@ const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
|
||||
const output = valueArg('output') ? resolve(valueArg('output')) : null;
|
||||
const targetRoot = resolve(valueArg('target-root') ?? '.');
|
||||
const profile = valueArg('profile') ?? 'large-house-v1';
|
||||
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
|
||||
if (![
|
||||
'large-house-v1', 'large-house-isometric-v1', ISOMETRIC_STAGE3_DENSE_PROFILE,
|
||||
'large-house-plan-snap-v1', 'large-house-interaction-v1',
|
||||
].includes(profile))
|
||||
throw new Error(`unknown large-house profile: ${profile}`);
|
||||
const isometric = profile === 'large-house-isometric-v1';
|
||||
const stage3Dense = profile === ISOMETRIC_STAGE3_DENSE_PROFILE;
|
||||
const allowStage2Base = process.argv.includes('--allow-stage2-base');
|
||||
if (allowStage2Base && !stage3Dense)
|
||||
throw new Error('--allow-stage2-base is valid only for the Stage 3 dense profile');
|
||||
const requireStage3 = stage3Dense && !allowStage2Base;
|
||||
const isometric = profile === 'large-house-isometric-v1' || stage3Dense;
|
||||
const planSnap = profile === 'large-house-plan-snap-v1';
|
||||
const interaction = profile === 'large-house-interaction-v1';
|
||||
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
|
||||
const requiresIsoStructuralBuildCounter = requiresIsometric
|
||||
&& readFileSync(resolve(targetRoot, 'src/houseplan-card.ts'), 'utf8')
|
||||
.includes('private _isoStructuralBuildCount');
|
||||
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
|
||||
const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.ts'));
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const requiresInteraction = interaction && existsSync(resolve(targetRoot, 'src/live-viewport.ts'));
|
||||
const fixture = stage3Dense ? makeIsometricStage3DenseFixture() : makeLargeHouseFixture();
|
||||
const fixtureCounts = stage3Dense ? fixture.counts : LARGE_HOUSE_COUNTS;
|
||||
const sourceSha = (() => {
|
||||
try {
|
||||
return execFileSync('git', ['-C', targetRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (isometric && !sourceSha)
|
||||
throw new Error(`${profile} requires an exact git source SHA`);
|
||||
if (planSnap) {
|
||||
for (const [floor, space] of fixture.config.spaces.entries()) {
|
||||
space.room_drafts = [0, 1].map((draft) => {
|
||||
const y = 0.985 + draft * 0.025;
|
||||
return {
|
||||
id: `perf-draft-${floor}-${draft}`,
|
||||
points: [[0.10, y], [0.38, y], [0.46, y + 0.035]],
|
||||
segments: [{ cm: 15 }, { cm: 20 }],
|
||||
};
|
||||
});
|
||||
space.partitions ||= [];
|
||||
for (const chain of [0, 1]) {
|
||||
const y = 0.985 + chain * 0.025;
|
||||
space.partitions.push(
|
||||
{ id: `perf-chain-${floor}-${chain}-0`, a: [0.10, y], b: [0.38, y], cm: 15 },
|
||||
{ id: `perf-chain-${floor}-${chain}-1`, a: [0.38, y], b: [0.46, y + 0.035], cm: 20 },
|
||||
);
|
||||
}
|
||||
}
|
||||
fixture.counts = { ...fixture.counts, drafts: 6, pointerMoves: 120 };
|
||||
fixture.counts = { ...fixture.counts, chainSegments: 12, pointerMoves: 120 };
|
||||
}
|
||||
const viewport = { width: 1440, height: 1000 };
|
||||
|
||||
@@ -71,7 +99,8 @@ try {
|
||||
const measuredSample = iteration - warmups;
|
||||
const row = await page.evaluate(async ({
|
||||
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
requiresWallFace,
|
||||
requiresWallFace, interaction, requiresInteraction, stage3Dense, requireStage3,
|
||||
requiresIsoStructuralBuildCounter, profile,
|
||||
}) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
@@ -113,6 +142,15 @@ try {
|
||||
longTasks: await longTasks.stop(),
|
||||
};
|
||||
};
|
||||
// Current candidates split Stage 3 out of the initial graph. Comparison
|
||||
// bundles remain valid: their older monolithic renderer has no preload
|
||||
// hook and is already available synchronously.
|
||||
const ensureIsoRuntime = async (card) => {
|
||||
if (typeof card._ensureIsoSceneRuntime !== 'function') return;
|
||||
if (!(await card._ensureIsoSceneRuntime()))
|
||||
throw new Error(`${profile} isometric runtime did not load`);
|
||||
await card.updateComplete;
|
||||
};
|
||||
const forceGc = async () => {
|
||||
if (typeof globalThis.gc !== 'function') return false;
|
||||
globalThis.gc();
|
||||
@@ -131,15 +169,128 @@ try {
|
||||
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
|
||||
wallFaceGraph: card._wallFaceGraphCache?.length ?? 0,
|
||||
});
|
||||
const isoStructuralBuildCount = (card) => Number.isFinite(card._isoStructuralBuildCount)
|
||||
? card._isoStructuralBuildCount : null;
|
||||
const stage3Snapshot = (card) => {
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
// Count the interactive HTML owners, not the diagnostic SVG
|
||||
// ground/tether/plate primitives which deliberately repeat kind/id.
|
||||
const overlayRoots = [...card.renderRoot.querySelectorAll(
|
||||
'[data-hp-iso-overlay-kind][data-hp-iso-floor][data-hp-iso-visual]',
|
||||
)];
|
||||
const overlaySelectors = {
|
||||
device: '[data-hp="device"]',
|
||||
'room-label': '[data-hp="room-label"]',
|
||||
'opening-lock': '.oplock',
|
||||
};
|
||||
const totalRootsByKind = Object.fromEntries(Object.entries(overlaySelectors)
|
||||
.map(([kind, selector]) => [kind, card.renderRoot.querySelectorAll(selector).length]));
|
||||
const stage3RootsByKind = Object.fromEntries(Object.keys(overlaySelectors)
|
||||
.map((kind) => [kind, overlayRoots.filter((root) =>
|
||||
root.getAttribute('data-hp-iso-overlay-kind') === kind).length]));
|
||||
const openingSurfaceCounts = Object.fromEntries(
|
||||
fixture.stage3Dense.expectedOpeningKinds.map((kind) => [kind,
|
||||
card.renderRoot.querySelectorAll(`.iso-opening-panel[data-kind="${kind}"]`).length,
|
||||
]),
|
||||
);
|
||||
const vacuumRoots = [...card.renderRoot.querySelectorAll('.vacpuck, .vactrail')];
|
||||
const materialDefinitions = card.renderRoot.querySelectorAll('[data-hp-iso-material-def]');
|
||||
const definitionCounts = {
|
||||
total: materialDefinitions.length,
|
||||
patterns: card.renderRoot.querySelectorAll('pattern[data-hp-iso-material-def]').length,
|
||||
filters: card.renderRoot.querySelectorAll('filter[data-hp-iso-material-def]').length,
|
||||
};
|
||||
const pulseDeviceId = fixture.stage3Dense?.pulseDeviceIdsBySpace?.[card._space] || '';
|
||||
const pulseRoot = card.renderRoot.querySelector(
|
||||
`.dev[data-id="${CSS.escape(pulseDeviceId)}"]`,
|
||||
);
|
||||
const facetCounts = {
|
||||
value: card.renderRoot.querySelectorAll('.dev .value-badge').length,
|
||||
lqi: card.renderRoot.querySelectorAll('.dev .lqi').length,
|
||||
new: card.renderRoot.querySelectorAll('.dev .newdot').length,
|
||||
pulse: pulseRoot?.querySelectorAll('.device-pulse, .activity-dot').length ?? 0,
|
||||
roomMetrics: card.renderRoot.querySelectorAll('.roomlabel .rlmetrics').length,
|
||||
};
|
||||
const rawBuilds = stage?.getAttribute('data-hp-iso-structural-builds');
|
||||
const structuralBuilds = rawBuilds == null ? null : Number(rawBuilds);
|
||||
return {
|
||||
effectiveProjection: typeof card._effectiveProjection === 'function'
|
||||
? card._effectiveProjection() : null,
|
||||
isoStageRevision: stage?.getAttribute('data-hp-iso-stage') ?? null,
|
||||
structuralBuilds: Number.isFinite(structuralBuilds) ? structuralBuilds : null,
|
||||
renderedOverlayCount: overlayRoots.length,
|
||||
raisedOverlayCount: overlayRoots
|
||||
.filter((root) => root.getAttribute('data-hp-iso-raised') === 'true').length,
|
||||
nudgedOverlayCount: overlayRoots
|
||||
.filter((root) => root.getAttribute('data-hp-iso-nudged') === 'true').length,
|
||||
raisedVacuumCount: vacuumRoots.filter((root) =>
|
||||
root.getAttribute('data-hp-iso-raised') === 'true'
|
||||
|| root.hasAttribute('data-hp-iso-overlay-kind')).length,
|
||||
overlayKinds: [...new Set(overlayRoots
|
||||
.map((root) => root.getAttribute('data-hp-iso-overlay-kind'))
|
||||
.filter(Boolean))].sort(),
|
||||
totalRootsByKind,
|
||||
stage3RootsByKind,
|
||||
openingSurfaceCounts,
|
||||
materialDefinitionCount: definitionCounts.total,
|
||||
definitionCounts,
|
||||
facetCounts,
|
||||
pulseDiagnostics: pulseRoot ? {
|
||||
id: pulseRoot.getAttribute('data-id'),
|
||||
state: pulseRoot.getAttribute('data-state'),
|
||||
className: pulseRoot.className,
|
||||
ariaLabel: pulseRoot.getAttribute('aria-label'),
|
||||
} : null,
|
||||
};
|
||||
};
|
||||
const assertStage3Snapshot = (snapshot, label) => {
|
||||
if (!requireStage3) return;
|
||||
const expectedKinds = fixture.stage3Dense.expectedOverlayKinds;
|
||||
const missingKinds = expectedKinds.filter((kind) => !snapshot.overlayKinds.includes(kind));
|
||||
const failures = [];
|
||||
if (snapshot.effectiveProjection !== 'iso') failures.push('effective projection is not iso');
|
||||
if (snapshot.isoStageRevision !== '3') failures.push('Stage 3 revision marker is absent');
|
||||
if (snapshot.structuralBuilds == null) failures.push('structural build counter is absent');
|
||||
if (snapshot.renderedOverlayCount < 1) failures.push('no raised overlay roots rendered');
|
||||
if (snapshot.raisedOverlayCount !== snapshot.renderedOverlayCount)
|
||||
failures.push('one or more Stage 3 overlay roots remained on the floor plane');
|
||||
if (snapshot.nudgedOverlayCount < 1) failures.push('dense fixture produced no bounded nudge');
|
||||
if (snapshot.raisedVacuumCount !== 0) failures.push('floor-bound vacuum was marked as raised');
|
||||
if (missingKinds.length) failures.push(`missing overlay kinds: ${missingKinds.join(', ')}`);
|
||||
for (const kind of expectedKinds) {
|
||||
const total = snapshot.totalRootsByKind[kind];
|
||||
const stage3 = snapshot.stage3RootsByKind[kind];
|
||||
if (total < 1) failures.push(`no rendered ${kind} roots`);
|
||||
else if (stage3 !== total)
|
||||
failures.push(`${kind} Stage 3 roots ${stage3}/${total}`);
|
||||
}
|
||||
for (const kind of fixture.stage3Dense.expectedOpeningKinds) {
|
||||
if (!(snapshot.openingSurfaceCounts[kind] > 0))
|
||||
failures.push(`no Stage 3 ${kind} opening surfaces`);
|
||||
}
|
||||
for (const [facet, count] of Object.entries(snapshot.facetCounts)) {
|
||||
if (!(count > 0)) failures.push(`no observable ${facet} facet${facet === 'pulse'
|
||||
? ` (${JSON.stringify(snapshot.pulseDiagnostics)})` : ''}`);
|
||||
}
|
||||
if (snapshot.materialDefinitionCount < 1
|
||||
|| snapshot.materialDefinitionCount > fixture.stage3Dense.materialDefinitionLimit) {
|
||||
failures.push(`material definitions ${snapshot.materialDefinitionCount}`
|
||||
+ ` exceed 1..${fixture.stage3Dense.materialDefinitionLimit}`);
|
||||
}
|
||||
if (!(snapshot.definitionCounts.patterns > 0)) failures.push('no shared texture pattern');
|
||||
if (!(snapshot.definitionCounts.filters > 0)) failures.push('no shared shadow filter');
|
||||
if (failures.length)
|
||||
throw new Error(`Stage 3 ${label} contract failed: ${failures.join('; ')}`);
|
||||
};
|
||||
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
if (isometric) {
|
||||
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(['iso']));
|
||||
localStorage.setItem('houseplan_card_alpha_v1', '1');
|
||||
localStorage.setItem('houseplan_card_view_v1', JSON.stringify(Object.fromEntries(
|
||||
fixture.config.spaces.map((space) => [space.id, 'iso']),
|
||||
)));
|
||||
history.replaceState(null, '', '?hp-labs=iso');
|
||||
history.replaceState(null, '', '?hp_alpha=1');
|
||||
} else history.replaceState(null, '', location.pathname);
|
||||
const host = document.getElementById('host');
|
||||
const card = document.createElement('houseplan-card');
|
||||
@@ -151,10 +302,10 @@ try {
|
||||
subscribeEvents: async () => () => undefined,
|
||||
subscribeMessage: async () => () => undefined,
|
||||
};
|
||||
const hassFor = (states) => ({
|
||||
const hassBase = {
|
||||
language: 'en', locale: { language: 'en' },
|
||||
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
|
||||
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas, states,
|
||||
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas,
|
||||
floors: {
|
||||
one: { floor_id: 'one', name: 'One', level: 0 },
|
||||
two: { floor_id: 'two', name: 'Two', level: 1 },
|
||||
@@ -178,17 +329,19 @@ try {
|
||||
localize: () => null,
|
||||
formatEntityState: (state) => state.state,
|
||||
config: { unit_system: { length: 'km' } },
|
||||
});
|
||||
};
|
||||
const hassFor = (states) => ({ ...hassBase, states });
|
||||
|
||||
const loadLongTasks = startLongTaskWindow();
|
||||
const loadStarted = performance.now();
|
||||
host.replaceChildren(card);
|
||||
if (requiresIsometric) {
|
||||
if (requiresIsometric && window.__hpAlpha !== true) {
|
||||
if (typeof card._onLabsSnapshot !== 'function')
|
||||
throw new Error('large-house-isometric-v1 candidate has no Labs fixture hook');
|
||||
// The product flag expires at 1.65.0. Performance keeps exercising
|
||||
// the dormant renderer without changing the public registry contract.
|
||||
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
// Comparison bundles before #448 do not understand hp_alpha. Preserve
|
||||
// the cross-version benchmark only for that base; current candidates
|
||||
// must activate through the real URL/storage contract above.
|
||||
card._onLabsSnapshot({ alpha: true, active: Object.freeze(['iso']), space: '' });
|
||||
}
|
||||
card.hass = hassFor(fixture.states);
|
||||
// launch() warms its own bootstrap card, but each measured sample creates
|
||||
@@ -196,6 +349,7 @@ try {
|
||||
// construction; monolithic stable bundles remain immediate (#380).
|
||||
if (!await window.__hpEnsureHarnessEditorRuntime(card))
|
||||
throw new Error('large-house editor runtime did not preload');
|
||||
if (isometric) await ensureIsoRuntime(card);
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
if (requiresIsometric && (typeof card._setProjection !== 'function'
|
||||
|| !(card._isoGeometryCache instanceof Map))) {
|
||||
@@ -206,14 +360,52 @@ try {
|
||||
await frame();
|
||||
const modelReadyMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
await until(() => card._booting === false);
|
||||
if (interaction && '_bootSoft' in card) await until(() => card._bootSoft === false);
|
||||
await frame();
|
||||
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
const initialProjection = typeof card._effectiveProjection === 'function'
|
||||
? card._effectiveProjection() : null;
|
||||
if (requiresIsometric && initialProjection !== 'iso')
|
||||
throw new Error(`${profile} entered Flat fallback instead of effective Iso`);
|
||||
const initialIsoFingerprint = card.renderRoot
|
||||
.querySelector('[data-hp="iso-walls"]')?.getAttribute('data-fingerprint') ?? null;
|
||||
if (requiresIsometric && !initialIsoFingerprint)
|
||||
throw new Error(`${profile} has no rendered Iso geometry fingerprint`);
|
||||
const initialIsoStructuralBuilds = isometric ? isoStructuralBuildCount(card) : null;
|
||||
if (requiresIsoStructuralBuildCounter && initialIsoStructuralBuilds == null)
|
||||
throw new Error(`${profile} has no structural build counter`);
|
||||
const initialStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (initialStage3) assertStage3Snapshot(initialStage3, 'initial render');
|
||||
const loadLongTaskResult = await loadLongTasks.stop();
|
||||
let fullRenderCount = 0;
|
||||
let diagnosticsScanCount = 0;
|
||||
let physicalPreflightCount = 0;
|
||||
let physicalPreflightMs = 0;
|
||||
const fullRenderReasons = [];
|
||||
if (interaction) {
|
||||
const renderBody = card._renderBody.bind(card);
|
||||
card._renderBody = (...args) => { fullRenderCount++; return renderBody(...args); };
|
||||
const bindingStatus = card._bindingStatus.bind(card);
|
||||
card._bindingStatus = (...args) => { diagnosticsScanCount++; return bindingStatus(...args); };
|
||||
const checkSpacePhysicalGeometry = card._checkSpacePhysicalGeometry.bind(card);
|
||||
card._checkSpacePhysicalGeometry = (...args) => {
|
||||
physicalPreflightCount++;
|
||||
const started = performance.now();
|
||||
try { return checkSpacePhysicalGeometry(...args); }
|
||||
finally { physicalPreflightMs += performance.now() - started; }
|
||||
};
|
||||
const willUpdate = card.willUpdate.bind(card);
|
||||
card.willUpdate = (changed) => {
|
||||
fullRenderReasons.push([...changed.keys()].map(String));
|
||||
return willUpdate(changed);
|
||||
};
|
||||
}
|
||||
const viewToggle = isometric ? await duration(async () => {
|
||||
if (typeof card._setProjection === 'function') {
|
||||
card._setProjection('flat');
|
||||
await card.updateComplete;
|
||||
card._setProjection('iso');
|
||||
await ensureIsoRuntime(card);
|
||||
await card.updateComplete;
|
||||
} else {
|
||||
// Comparison SHAs before #89 intentionally ignore the Labs operation.
|
||||
@@ -226,7 +418,19 @@ try {
|
||||
await card.updateComplete;
|
||||
});
|
||||
|
||||
const firstEntity = Object.keys(fixture.states)[0];
|
||||
const steadyStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (steadyStage3) assertStage3Snapshot(steadyStage3, 'steady floor');
|
||||
const steadyIsoFingerprint = card.renderRoot
|
||||
.querySelector('[data-hp="iso-walls"]')?.getAttribute('data-fingerprint') ?? null;
|
||||
if (requiresIsometric && !steadyIsoFingerprint)
|
||||
throw new Error(`${profile} lost Iso geometry after the space switch`);
|
||||
const steadyIsoStructuralBuilds = isometric ? isoStructuralBuildCount(card) : null;
|
||||
if (requiresIsoStructuralBuildCounter && steadyIsoStructuralBuilds == null)
|
||||
throw new Error(`${profile} lost its structural build counter after the space switch`);
|
||||
|
||||
const firstEntity = stage3Dense
|
||||
? fixture.stage3Dense.stateEntityId
|
||||
: Object.keys(fixture.states)[0];
|
||||
const nextStates = {
|
||||
...fixture.states,
|
||||
[firstEntity]: { ...fixture.states[firstEntity], state: fixture.states[firstEntity].state === 'on' ? 'off' : 'on' },
|
||||
@@ -235,6 +439,464 @@ try {
|
||||
card.hass = hassFor(nextStates);
|
||||
await card.updateComplete;
|
||||
});
|
||||
const afterStateStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (afterStateStage3) assertStage3Snapshot(afterStateStage3, 'HA-only update');
|
||||
const afterStateFingerprint = card.renderRoot
|
||||
.querySelector('[data-hp="iso-walls"]')?.getAttribute('data-fingerprint') ?? null;
|
||||
if (requiresIsometric && afterStateFingerprint !== steadyIsoFingerprint)
|
||||
throw new Error(`${profile} rebuilt Iso geometry for an HA-only state update`);
|
||||
const afterStateIsoStructuralBuilds = isometric ? isoStructuralBuildCount(card) : null;
|
||||
if (requiresIsoStructuralBuildCounter
|
||||
&& afterStateIsoStructuralBuilds !== steadyIsoStructuralBuilds) {
|
||||
throw new Error(`${profile} performed a structural rebuild for an HA-only state update`);
|
||||
}
|
||||
if (requireStage3 && afterStateStage3.structuralBuilds !== steadyStage3.structuralBuilds)
|
||||
throw new Error(`${profile} rebuilt Stage 3 structure for an HA-only state update`);
|
||||
|
||||
let stage3States = nextStates;
|
||||
const openingUpdate = stage3Dense ? await duration(async () => {
|
||||
const entityId = fixture.stage3Dense.contactEntityId;
|
||||
const current = stage3States[entityId];
|
||||
stage3States = {
|
||||
...stage3States,
|
||||
[entityId]: { ...current, state: current.state === 'on' ? 'off' : 'on' },
|
||||
};
|
||||
card.hass = hassFor(stage3States);
|
||||
await card.updateComplete;
|
||||
}) : null;
|
||||
const afterOpeningStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (afterOpeningStage3) assertStage3Snapshot(afterOpeningStage3, 'opening update');
|
||||
if (stage3Dense && requiresIsometric && afterOpeningStage3?.effectiveProjection !== 'iso')
|
||||
throw new Error(`${profile} entered Flat fallback during an opening update`);
|
||||
if (requireStage3 && afterOpeningStage3.structuralBuilds !== steadyStage3.structuralBuilds)
|
||||
throw new Error(`${profile} rebuilt Stage 3 structure for an opening update`);
|
||||
|
||||
const overlayInteraction = stage3Dense ? await duration(async () => {
|
||||
const overlay = card.renderRoot.querySelector(requireStage3
|
||||
? '[data-hp-iso-overlay-kind="device"][data-hp-iso-floor][data-hp-iso-visual]'
|
||||
: '[data-hp="device"]');
|
||||
if (!overlay) throw new Error(`${profile} dense fixture rendered no interactive device`);
|
||||
overlay.dispatchEvent(new PointerEvent('pointerover', {
|
||||
bubbles: true, composed: true, pointerId: 885, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
overlay.focus?.();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
overlay.dispatchEvent(new PointerEvent('pointerout', {
|
||||
bubbles: true, composed: true, pointerId: 885, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
overlay.blur?.();
|
||||
await card.updateComplete;
|
||||
}) : null;
|
||||
const afterInteractionStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (afterInteractionStage3)
|
||||
assertStage3Snapshot(afterInteractionStage3, 'hover/focus update');
|
||||
if (stage3Dense && requiresIsometric && afterInteractionStage3?.effectiveProjection !== 'iso')
|
||||
throw new Error(`${profile} entered Flat fallback during a hover/focus update`);
|
||||
if (requireStage3
|
||||
&& afterInteractionStage3.structuralBuilds !== steadyStage3.structuralBuilds) {
|
||||
throw new Error(`${profile} rebuilt Stage 3 structure for a hover/focus update`);
|
||||
}
|
||||
|
||||
let interactionDiagnostics = null;
|
||||
let interactionTimings = null;
|
||||
let interactionLongTasks = null;
|
||||
const interactionSeries = interaction ? await duration(async () => {
|
||||
const deltas = {};
|
||||
const timings = {};
|
||||
interactionLongTasks = {};
|
||||
const diagnosticsBefore = diagnosticsScanCount;
|
||||
const reasonsBefore = fullRenderReasons.length;
|
||||
const beforeIrrelevant = fullRenderCount;
|
||||
const intakeBefore = card._hassSequence;
|
||||
const irrelevantStarted = performance.now();
|
||||
let currentStates = nextStates;
|
||||
for (let index = 0; index < 30; index++) {
|
||||
currentStates = {
|
||||
...currentStates,
|
||||
'sensor.performance_unrelated': {
|
||||
entity_id: 'sensor.performance_unrelated', state: `${sample}:${index}`,
|
||||
},
|
||||
};
|
||||
card.hass = hassFor(currentStates);
|
||||
}
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
timings.irrelevantHaTicksMs = performance.now() - irrelevantStarted;
|
||||
deltas.irrelevantFullRenders = fullRenderCount - beforeIrrelevant;
|
||||
deltas.irrelevantHassIntakes = card._hassSequence - intakeBefore;
|
||||
|
||||
const relevantBefore = fullRenderCount;
|
||||
const relevantStarted = performance.now();
|
||||
currentStates = {
|
||||
...currentStates,
|
||||
[firstEntity]: {
|
||||
...currentStates[firstEntity],
|
||||
state: currentStates[firstEntity].state === 'on' ? 'off' : 'on',
|
||||
},
|
||||
};
|
||||
card.hass = hassFor(currentStates);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
timings.relevantHaTickMs = performance.now() - relevantStarted;
|
||||
deltas.relevantOutsideGestureFullRenders = fullRenderCount - relevantBefore;
|
||||
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const device = card.renderRoot.querySelector('[data-hp="device"]');
|
||||
const room = card.renderRoot.querySelector('[data-hp="room"]');
|
||||
const heavy = card.renderRoot.querySelector('.wallbodies');
|
||||
if (!stage || !device || !room || !heavy)
|
||||
throw new Error('interaction profile has no stage/device/room/heavy scene');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const hoverBefore = fullRenderCount;
|
||||
const hoverLongTasks = startLongTaskWindow();
|
||||
const hoverStarted = performance.now();
|
||||
device.dispatchEvent(new PointerEvent('pointerover', {
|
||||
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
for (let index = 0; index < 40; index++) {
|
||||
device.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 100 + index, clientY: rect.top + 100 + index / 2,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 20 === 0) {
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
}
|
||||
const liveTip = card.renderRoot.querySelector('[data-hp-live-tip]');
|
||||
deltas.tooltipVisible = !!liveTip && !liveTip.hidden && !!liveTip.textContent?.trim();
|
||||
device.dispatchEvent(new PointerEvent('pointerleave', {
|
||||
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
room.dispatchEvent(new PointerEvent('pointerenter', {
|
||||
clientX: rect.left + 300, clientY: rect.top + 300,
|
||||
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
for (let index = 0; index < 40; index++) {
|
||||
room.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 300 + index, clientY: rect.top + 300 + index / 2,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 20 === 0) { await card.updateComplete; await frame(); }
|
||||
}
|
||||
room.dispatchEvent(new PointerEvent('pointerleave', {
|
||||
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
for (let index = 0; index < 40; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 20 + index, clientY: rect.top + 20,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 20 === 0) { await card.updateComplete; await frame(); }
|
||||
}
|
||||
await card.updateComplete;
|
||||
timings.hoverSeriesMs = performance.now() - hoverStarted;
|
||||
deltas.hoverFullRenders = fullRenderCount - hoverBefore;
|
||||
deltas.hoverTargets = ['device', 'room', 'miss'];
|
||||
interactionLongTasks.hoverSeries = await hoverLongTasks.stop();
|
||||
|
||||
const cameraSvg = card.renderRoot.querySelector('[data-hp-live-viewbox="camera"], [data-hp-live-viewbox="floor"], .zoomwrap > svg');
|
||||
const initialViewBox = cameraSvg?.getAttribute('viewBox') || '';
|
||||
const panLongTasks = startLongTaskWindow();
|
||||
const panStarted = performance.now();
|
||||
const panMoveRenders = [];
|
||||
const panTerminalRenders = [];
|
||||
let relevantDuringGestureFullRenders = 0;
|
||||
let relevantDuringGestureIntakes = 0;
|
||||
for (let dragIndex = 0; dragIndex < 4; dragIndex++) {
|
||||
const pointerId = 992 + dragIndex;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: rect.left + 200, clientY: rect.top + 200, button: 0, buttons: 1,
|
||||
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
const panMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 205 + index * 3, clientY: rect.top + 202 + index,
|
||||
button: 0, buttons: 1, bubbles: true, composed: true,
|
||||
pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if (dragIndex === 0 && index === 10) {
|
||||
const deferredBefore = fullRenderCount;
|
||||
const deferredIntakeBefore = card._hassSequence;
|
||||
for (let tick = 0; tick < 3; tick++) {
|
||||
currentStates = {
|
||||
...currentStates,
|
||||
[firstEntity]: { ...currentStates[firstEntity], state: `gesture-${tick}` },
|
||||
};
|
||||
card.hass = hassFor(currentStates);
|
||||
}
|
||||
await card.updateComplete;
|
||||
relevantDuringGestureFullRenders += fullRenderCount - deferredBefore;
|
||||
relevantDuringGestureIntakes += card._hassSequence - deferredIntakeBefore;
|
||||
}
|
||||
if ((index + 1) % 10 === 0) {
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
}
|
||||
const beforeTerminal = fullRenderCount;
|
||||
panMoveRenders.push(beforeTerminal - panMoveBefore);
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', {
|
||||
clientX: rect.left + 265, clientY: rect.top + 222, button: 0, buttons: 0,
|
||||
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
panTerminalRenders.push(fullRenderCount - beforeTerminal);
|
||||
}
|
||||
timings.panSeriesMs = performance.now() - panStarted;
|
||||
interactionLongTasks.panSeries = await panLongTasks.stop();
|
||||
const movedViewBox = cameraSvg?.getAttribute('viewBox') || '';
|
||||
const currentView = card._viewOr(card._baseVb());
|
||||
const currentDevice = card._devices.find((item) => item.id === device.dataset.id);
|
||||
const position = currentDevice ? card._pos(currentDevice) : null;
|
||||
const scene = position ? card._scenePoint([position.x, position.y]) : null;
|
||||
const deviceRect = device.getBoundingClientRect();
|
||||
const expectedX = scene ? rect.left + ((scene[0] - currentView.x) / currentView.w) * rect.width : 0;
|
||||
const expectedY = scene ? rect.top + ((scene[1] - currentView.y) / currentView.h) * rect.height : 0;
|
||||
const overlayErrorPx = scene ? Math.hypot(
|
||||
deviceRect.left + deviceRect.width / 2 - expectedX,
|
||||
deviceRect.top + deviceRect.height / 2 - expectedY,
|
||||
) : Infinity;
|
||||
|
||||
const cameraLongTasks = startLongTaskWindow();
|
||||
const cameraStarted = performance.now();
|
||||
const cameraBefore = fullRenderCount;
|
||||
const pinchPointerA = 1001, pinchPointerB = 1002;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: rect.left + 360, clientY: rect.top + 300, button: 0, buttons: 1,
|
||||
bubbles: true, composed: true, pointerId: pinchPointerA,
|
||||
pointerType: 'touch', isPrimary: true,
|
||||
}));
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: rect.left + 460, clientY: rect.top + 300, button: 0, buttons: 1,
|
||||
bubbles: true, composed: true, pointerId: pinchPointerB,
|
||||
pointerType: 'touch', isPrimary: false,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
const pinchMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 360 - index, clientY: rect.top + 300,
|
||||
button: 0, buttons: 1, bubbles: true, composed: true,
|
||||
pointerId: pinchPointerA, pointerType: 'touch', isPrimary: true,
|
||||
}));
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 460 + index, clientY: rect.top + 300,
|
||||
button: 0, buttons: 1, bubbles: true, composed: true,
|
||||
pointerId: pinchPointerB, pointerType: 'touch', isPrimary: false,
|
||||
}));
|
||||
if ((index + 1) % 5 === 0) await frame();
|
||||
}
|
||||
const pinchTerminalBefore = fullRenderCount;
|
||||
for (const pointerId of [pinchPointerA, pinchPointerB]) {
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', {
|
||||
clientX: rect.left + 410, clientY: rect.top + 300,
|
||||
button: 0, buttons: 0, bubbles: true, composed: true,
|
||||
pointerId, pointerType: 'touch', isPrimary: pointerId === pinchPointerA,
|
||||
}));
|
||||
}
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const pinchTerminalRenders = fullRenderCount - pinchTerminalBefore;
|
||||
for (let index = 0; index < 4; index++) {
|
||||
stage.dispatchEvent(new WheelEvent('wheel', {
|
||||
deltaY: index % 2 ? 120 : -120,
|
||||
clientX: rect.left + rect.width / 2,
|
||||
clientY: rect.top + rect.height / 2,
|
||||
bubbles: true, cancelable: true,
|
||||
}));
|
||||
}
|
||||
await until(() => !card._cameraTransition.active);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
timings.cameraSeriesMs = performance.now() - cameraStarted;
|
||||
interactionLongTasks.cameraSeries = await cameraLongTasks.stop();
|
||||
deltas.cameraMoveFullRenders = pinchTerminalBefore - pinchMoveBefore;
|
||||
deltas.cameraTerminalFullRenders = pinchTerminalRenders;
|
||||
deltas.cameraFullRenders = fullRenderCount - cameraBefore;
|
||||
|
||||
const editorLongTaskWindows = [];
|
||||
const editorConfigBefore = JSON.stringify(card._serverCfg);
|
||||
const editorCallsBefore = wsCalls;
|
||||
const editorPaintBefore = card._liveEditorPaintCount || 0;
|
||||
let editorElapsed = 0;
|
||||
const editorMoveRenders = [];
|
||||
const editorTerminalRenders = [];
|
||||
|
||||
card._setMode('plan', false);
|
||||
card._tool = 'draw';
|
||||
card._path = [[100, 100]];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await until(() => !card._modeTransitionBusy && card._continuity.state === 'steady');
|
||||
for (let index = 0; index < 5; index++) await frame();
|
||||
let editorStage = card.renderRoot.querySelector('.stage');
|
||||
let editorRect = editorStage.getBoundingClientRect();
|
||||
let editorView = card._viewOr(card._baseVb());
|
||||
const fromScene = (x, y) => ({
|
||||
clientX: editorRect.left + ((x - editorView.x) / editorView.w) * editorRect.width,
|
||||
clientY: editorRect.top + ((y - editorView.y) / editorView.h) * editorRect.height,
|
||||
});
|
||||
let editorPartLongTasks = startLongTaskWindow();
|
||||
let partStarted = performance.now();
|
||||
let editorMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 40; index++) {
|
||||
editorStage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
...fromScene(110 + index * 2, 110 + index),
|
||||
bubbles: true, composed: true, pointerId: 1101,
|
||||
pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 10 === 0) await frame();
|
||||
}
|
||||
let editorTerminalBefore = fullRenderCount;
|
||||
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
|
||||
card._cursorPt = null;
|
||||
card._path = [];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
|
||||
editorElapsed += performance.now() - partStarted;
|
||||
editorLongTaskWindows.push(await editorPartLongTasks.stop());
|
||||
|
||||
card._tool = 'resize';
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
const resizeRoom = card._rszRooms()[0];
|
||||
const resizeEvent = {
|
||||
pointerId: 1102, stopPropagation: () => undefined,
|
||||
preventDefault: () => undefined, target: null,
|
||||
};
|
||||
card._rszEdgeDown(resizeEvent, resizeRoom.id, 1);
|
||||
const resizePlan = card._resize?.plan || card._rszDrag?.plan;
|
||||
if (!resizePlan) throw new Error('interaction resize plan was not created');
|
||||
const resizePreflightBefore = physicalPreflightCount;
|
||||
const resizePreflightMsBefore = physicalPreflightMs;
|
||||
editorStage = card.renderRoot.querySelector('.stage');
|
||||
editorRect = editorStage.getBoundingClientRect();
|
||||
editorView = card._viewOr(card._baseVb());
|
||||
// A browser delivers pointerdown and pointermove as separate tasks.
|
||||
// Keep the synthetic harness from attributing both handlers to one
|
||||
// impossible long task while still timing every move-side preflight.
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
editorPartLongTasks = startLongTaskWindow();
|
||||
partStarted = performance.now();
|
||||
editorMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 40; index++) {
|
||||
const target = [
|
||||
resizePlan.a[0] + resizePlan.n[0] * card._gridPitch * (1 + index / 40),
|
||||
resizePlan.a[1] + resizePlan.n[1] * card._gridPitch * (1 + index / 40),
|
||||
];
|
||||
card._rszMove({
|
||||
...resizeEvent,
|
||||
clientX: editorRect.left + ((target[0] - editorView.x) / editorView.w) * editorRect.width,
|
||||
clientY: editorRect.top + ((target[1] - editorView.y) / editorView.h) * editorRect.height,
|
||||
});
|
||||
if ((index + 1) % 10 === 0) await frame();
|
||||
}
|
||||
editorTerminalBefore = fullRenderCount;
|
||||
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
|
||||
deltas.resizeLivePreflightChecks = physicalPreflightCount - resizePreflightBefore;
|
||||
deltas.resizeLivePreflightMs = physicalPreflightMs - resizePreflightMsBefore;
|
||||
card._rszCancelDrag();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
|
||||
editorElapsed += performance.now() - partStarted;
|
||||
editorLongTaskWindows.push(await editorPartLongTasks.stop());
|
||||
|
||||
card._setMode('decor', false);
|
||||
card._decorTool = 'select';
|
||||
const decorShape = card._decorList.find((shape) =>
|
||||
shape.kind === 'furniture' || shape.kind === 'rect' || shape.kind === 'ellipse');
|
||||
if (!decorShape) throw new Error('interaction profile has no transformable decor');
|
||||
card._decorSel = decorShape.id;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await until(() => !card._modeTransitionBusy && card._continuity.state === 'steady');
|
||||
for (let index = 0; index < 5; index++) await frame();
|
||||
card._dtMeasure();
|
||||
await card.updateComplete;
|
||||
editorStage = card.renderRoot.querySelector('.stage');
|
||||
editorRect = editorStage.getBoundingClientRect();
|
||||
editorView = card._viewOr(card._baseVb());
|
||||
const box = card._decorBoxOf(decorShape);
|
||||
const cornerX = box.x + box.w, cornerY = box.y + box.h;
|
||||
const decorPointer = (x, y) => ({
|
||||
pointerId: 1103, pointerType: 'mouse', shiftKey: false,
|
||||
clientX: editorRect.left + ((x - editorView.x) / editorView.w) * editorRect.width,
|
||||
clientY: editorRect.top + ((y - editorView.y) / editorView.h) * editorRect.height,
|
||||
stopPropagation: () => undefined, preventDefault: () => undefined,
|
||||
currentTarget: null, target: null,
|
||||
});
|
||||
card._dtStart(decorPointer(cornerX, cornerY), 'scale', [1, 1]);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorPartLongTasks = startLongTaskWindow();
|
||||
partStarted = performance.now();
|
||||
editorMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 40; index++) {
|
||||
card._dtMove(decorPointer(cornerX + index, cornerY + index / 2));
|
||||
if ((index + 1) % 10 === 0) await frame();
|
||||
}
|
||||
editorTerminalBefore = fullRenderCount;
|
||||
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
|
||||
card._cancelDecorGesture();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
|
||||
editorElapsed += performance.now() - partStarted;
|
||||
editorLongTaskWindows.push(await editorPartLongTasks.stop());
|
||||
timings.editorSeriesMs = editorElapsed;
|
||||
interactionLongTasks.editorSeries = {
|
||||
supported: editorLongTaskWindows.every((window) => window.supported),
|
||||
count: editorLongTaskWindows.reduce((sum, window) => sum + window.count, 0),
|
||||
maxMs: Math.max(0, ...editorLongTaskWindows.map((window) => window.maxMs)),
|
||||
totalMs: editorLongTaskWindows.reduce((sum, window) => sum + window.totalMs, 0),
|
||||
};
|
||||
deltas.editorMoveFullRenders = editorMoveRenders;
|
||||
deltas.editorTerminalFullRenders = editorTerminalRenders;
|
||||
deltas.editorLivePaints = (card._liveEditorPaintCount || 0) - editorPaintBefore;
|
||||
deltas.editorConfigStable = JSON.stringify(card._serverCfg) === editorConfigBefore;
|
||||
deltas.editorWsWrites = wsCalls - editorCallsBefore;
|
||||
deltas.editorLongTaskParts = editorLongTaskWindows;
|
||||
|
||||
card._setMode('view', false);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
deltas.panMoveFullRenders = panMoveRenders;
|
||||
deltas.terminalFullRenders = panTerminalRenders;
|
||||
deltas.relevantDuringGestureFullRenders = relevantDuringGestureFullRenders;
|
||||
deltas.relevantDuringGestureIntakes = relevantDuringGestureIntakes;
|
||||
deltas.viewBoxMoved = !!initialViewBox && initialViewBox !== movedViewBox;
|
||||
deltas.overlayErrorPx = Number(overlayErrorPx.toFixed(2));
|
||||
deltas.heavyNodeStable = card.renderRoot.querySelector('.wallbodies') === heavy;
|
||||
deltas.diagnosticsScans = diagnosticsScanCount - diagnosticsBefore;
|
||||
deltas.fullRenderReasons = fullRenderReasons.slice(reasonsBefore);
|
||||
interactionTimings = timings;
|
||||
interactionDiagnostics = { supported: requiresInteraction, ...deltas };
|
||||
if (requiresInteraction && (
|
||||
deltas.irrelevantFullRenders !== 0 || deltas.irrelevantHassIntakes !== 30
|
||||
|| deltas.relevantOutsideGestureFullRenders !== 1 || deltas.hoverFullRenders !== 0
|
||||
|| deltas.hoverTargets.join(',') !== 'device,room,miss'
|
||||
|| deltas.panMoveFullRenders.some((count) => count !== 0)
|
||||
|| deltas.terminalFullRenders.some((count) => count !== 1)
|
||||
|| deltas.relevantDuringGestureFullRenders !== 0 || !deltas.tooltipVisible
|
||||
|| deltas.relevantDuringGestureIntakes !== 3
|
||||
|| deltas.cameraMoveFullRenders !== 0 || deltas.cameraTerminalFullRenders !== 1
|
||||
|| deltas.editorMoveFullRenders.some((count) => count !== 0)
|
||||
|| deltas.editorTerminalFullRenders.some((count) => count > 1)
|
||||
|| deltas.resizeLivePreflightChecks < 1
|
||||
|| deltas.editorLivePaints < 3 || !deltas.editorConfigStable || deltas.editorWsWrites !== 0
|
||||
|| !deltas.viewBoxMoved || deltas.overlayErrorPx > 1 || !deltas.heavyNodeStable
|
||||
|| deltas.diagnosticsScans !== 0
|
||||
)) throw new Error(`interaction structural contract failed: ${JSON.stringify(interactionDiagnostics)}`);
|
||||
}) : null;
|
||||
|
||||
let planSnapDiagnostics = null;
|
||||
const planSnapPointer = planSnap ? await duration(async () => {
|
||||
@@ -317,12 +979,14 @@ try {
|
||||
)) throw new Error(`plan-snap structural contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
|
||||
if (requiresWallFace) {
|
||||
const oldPath = card._path;
|
||||
const oldDraftId = card._activeDraftId;
|
||||
const oldCms = card._draftSegmentCms;
|
||||
const oldChainId = card._activeWallChainId;
|
||||
const oldPartitionIds = card._activeWallChainPartitionIds;
|
||||
const oldCms = card._wallChainSegmentCms;
|
||||
const beforePath = [[10, 10]];
|
||||
card._path = [[10, 10], [20, 10]];
|
||||
card._activeDraftId = 'perf-face-draft';
|
||||
card._draftSegmentCms = [15];
|
||||
card._activeWallChainId = 'perf-face-chain';
|
||||
card._activeWallChainPartitionIds = ['perf-face-segment'];
|
||||
card._wallChainSegmentCms = [15];
|
||||
const acceptedStarted = performance.now();
|
||||
card._offerWallFaces(beforePath);
|
||||
planSnapDiagnostics.wallFaceAcceptedClickMs = performance.now() - acceptedStarted;
|
||||
@@ -330,8 +994,9 @@ try {
|
||||
card._wallFaceBatch = null;
|
||||
card._roomDialog = false;
|
||||
card._path = oldPath;
|
||||
card._activeDraftId = oldDraftId;
|
||||
card._draftSegmentCms = oldCms;
|
||||
card._activeWallChainId = oldChainId;
|
||||
card._activeWallChainPartitionIds = oldPartitionIds;
|
||||
card._wallChainSegmentCms = oldCms;
|
||||
if (planSnapDiagnostics.wallFaceAcceptedClickMs > 1000
|
||||
|| planSnapDiagnostics.wallFaceCacheEntries < 1
|
||||
|| planSnapDiagnostics.wallFaceCacheEntries > 4) {
|
||||
@@ -422,16 +1087,29 @@ try {
|
||||
const cacheGrowth = Object.fromEntries(
|
||||
Object.keys(cacheEntries).map((key) => [key, cacheEntries[key] - cacheBefore[key]]),
|
||||
);
|
||||
const finalProjection = typeof card._effectiveProjection === 'function'
|
||||
? card._effectiveProjection() : null;
|
||||
if (requiresIsometric && finalProjection !== 'iso')
|
||||
throw new Error(`${profile} ended the sample in Flat fallback`);
|
||||
const finalStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (finalStage3) assertStage3Snapshot(finalStage3, 'final warmed state');
|
||||
|
||||
const result = {
|
||||
sample,
|
||||
modelReadyMs,
|
||||
firstStableRenderMs,
|
||||
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
|
||||
...(openingUpdate ? { openingUpdateMs: openingUpdate.ms } : {}),
|
||||
...(overlayInteraction ? { overlayInteractionMs: overlayInteraction.ms } : {}),
|
||||
...(planSnapPointer ? {
|
||||
planSnapPointerMs: planSnapPointer.ms,
|
||||
planSnapDiagnostics,
|
||||
} : {}),
|
||||
...(interactionSeries ? {
|
||||
interactionSeriesMs: interactionSeries.ms,
|
||||
...interactionTimings,
|
||||
interactionDiagnostics,
|
||||
} : {}),
|
||||
spaceSwitchMs: spaceSwitch.ms,
|
||||
stateUpdateMs: stateUpdate.ms,
|
||||
resizePreviewMs: resizePreview.ms,
|
||||
@@ -441,7 +1119,10 @@ try {
|
||||
longTasks: {
|
||||
load: loadLongTaskResult,
|
||||
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
|
||||
...(openingUpdate ? { openingUpdate: openingUpdate.longTasks } : {}),
|
||||
...(overlayInteraction ? { overlayInteraction: overlayInteraction.longTasks } : {}),
|
||||
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
|
||||
...(interactionSeries ? interactionLongTasks || {} : {}),
|
||||
spaceSwitch: spaceSwitch.longTasks,
|
||||
stateUpdate: stateUpdate.longTasks,
|
||||
resizePreview: resizePreview.longTasks,
|
||||
@@ -454,6 +1135,26 @@ try {
|
||||
heapGrowthBytes: heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore,
|
||||
preciseGc: typeof globalThis.gc === 'function',
|
||||
renderedDevices: card._devices?.length ?? 0,
|
||||
effectiveProjection: finalProjection,
|
||||
...(isometric ? {
|
||||
isoStructuralBuilds: {
|
||||
supported: steadyIsoStructuralBuilds != null && afterStateIsoStructuralBuilds != null,
|
||||
initial: initialIsoStructuralBuilds,
|
||||
beforeHaUpdate: steadyIsoStructuralBuilds,
|
||||
afterHaUpdate: afterStateIsoStructuralBuilds,
|
||||
haUpdateDelta: steadyIsoStructuralBuilds == null || afterStateIsoStructuralBuilds == null
|
||||
? null : afterStateIsoStructuralBuilds - steadyIsoStructuralBuilds,
|
||||
},
|
||||
} : {}),
|
||||
...(finalStage3 ? {
|
||||
isoStageRevision: finalStage3.isoStageRevision,
|
||||
stage3Diagnostics: {
|
||||
...finalStage3,
|
||||
steadyUpdateStructuralBuildDelta: requireStage3
|
||||
? afterInteractionStage3.structuralBuilds - steadyStage3.structuralBuilds
|
||||
: null,
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
card.remove();
|
||||
await frame();
|
||||
@@ -461,6 +1162,8 @@ try {
|
||||
}, {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
|
||||
interaction, requiresInteraction, stage3Dense, requireStage3,
|
||||
requiresIsoStructuralBuildCounter, profile,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
}
|
||||
@@ -473,10 +1176,15 @@ const metricNames = [
|
||||
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
|
||||
];
|
||||
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
|
||||
if (stage3Dense) metricNames.splice(3, 0, 'openingUpdateMs', 'overlayInteractionMs');
|
||||
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
|
||||
if (interaction) metricNames.splice(2, 0,
|
||||
'interactionSeriesMs', 'hoverSeriesMs', 'panSeriesMs', 'cameraSeriesMs',
|
||||
'editorSeriesMs', 'irrelevantHaTicksMs', 'relevantHaTickMs');
|
||||
const report = {
|
||||
schema: 2,
|
||||
profile,
|
||||
sourceSha,
|
||||
generatedAt: new Date().toISOString(),
|
||||
buildFingerprint,
|
||||
runtime: {
|
||||
@@ -488,11 +1196,16 @@ const report = {
|
||||
deviceScaleFactor: 1,
|
||||
reducedMotion: true,
|
||||
},
|
||||
fixture: LARGE_HOUSE_COUNTS,
|
||||
fixture: fixtureCounts,
|
||||
samples,
|
||||
warmups,
|
||||
summary: summarizeTimings(rows, metricNames),
|
||||
longTasks: summarizeLongTasks(rows),
|
||||
effectiveProjection: [...new Set(rows.map((row) => row.effectiveProjection))],
|
||||
...(stage3Dense ? {
|
||||
isoStageRevision: [...new Set(rows.map((row) => row.isoStageRevision))],
|
||||
stage3Required: requireStage3,
|
||||
} : {}),
|
||||
rows,
|
||||
note: `Compare with a base-SHA report captured by the same runner and evaluate the ${profile} budget.`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/** #461 production-bundle wall-draw terminal-click performance profile. */
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { launch, finish } from './serve.mjs';
|
||||
import {
|
||||
installWallDrawClickHarness, resetWallDrawClickHarness, runWallDrawClickChain,
|
||||
runWallDrawFinishProfile,
|
||||
} from './wall-draw-click-harness.mjs';
|
||||
|
||||
const outputArg = process.argv.find((arg) => arg.startsWith('--output='));
|
||||
const output = outputArg ? resolve(outputArg.slice('--output='.length)) : null;
|
||||
const { page, browser } = await launch({ width: 1200, height: 900 }, 1);
|
||||
await installWallDrawClickHarness(page);
|
||||
|
||||
const run = async (remote) => {
|
||||
const fixture = await resetWallDrawClickHarness(page, remote);
|
||||
// One complete chain warms the browser/JIT; reset removes all of its state.
|
||||
await runWallDrawClickChain(page);
|
||||
await resetWallDrawClickHarness(page, remote);
|
||||
return { fixture, result: await runWallDrawClickChain(page) };
|
||||
};
|
||||
const base = await run(false);
|
||||
const remote = await run(true);
|
||||
const runFinish = async (remoteVariant) => {
|
||||
await resetWallDrawClickHarness(page, remoteVariant);
|
||||
// Warm the distinct finalizer path, then measure it from the same fixture.
|
||||
await runWallDrawFinishProfile(page);
|
||||
await resetWallDrawClickHarness(page, remoteVariant);
|
||||
return runWallDrawFinishProfile(page);
|
||||
};
|
||||
const baseFinish = await runFinish(false);
|
||||
const remoteFinish = await runFinish(true);
|
||||
const sorted = [...base.result.times].sort((a, b) => a - b);
|
||||
const medianMs = sorted[Math.floor(sorted.length / 2)];
|
||||
const maxMs = Math.max(...base.result.times);
|
||||
const remoteSorted = [...remote.result.times].sort((a, b) => a - b);
|
||||
const remoteMedianMs = remoteSorted[Math.floor(remoteSorted.length / 2)];
|
||||
|
||||
const clickShape = (click) => click.fullSpacePhysicalChecks === 0
|
||||
&& click.localPhysicalChecks === 1
|
||||
&& click.junctionArtifactPasses === 1
|
||||
&& click.configWrites === 1 && click.history === 1 && click.pathPoints === 1;
|
||||
const fixtureShape = base.fixture.spaces === 5 && base.fixture.rooms === 12
|
||||
&& base.fixture.positiveSegments >= 36 && base.fixture.savedPartitions === 8;
|
||||
const remoteFixtureShape = remote.fixture.positiveSegments === base.fixture.positiveSegments * 2;
|
||||
const structural = fixtureShape && remoteFixtureShape
|
||||
&& base.result.first.fullSpacePhysicalChecks === 0
|
||||
&& base.result.first.localPhysicalChecks === 0
|
||||
&& base.result.first.configWrites === 0 && base.result.first.history === 0
|
||||
&& base.result.clicks.length === 7 && base.result.clicks.every(clickShape)
|
||||
&& remote.result.clicks.every(clickShape)
|
||||
&& base.result.metrics.localProofMaxObjects < base.fixture.positiveSegments
|
||||
&& remote.result.metrics.localProofMaxObjects === base.result.metrics.localProofMaxObjects
|
||||
&& base.result.ids.length === 7 && new Set(base.result.ids).size === 7
|
||||
&& base.result.metrics.wallGenericFallbacks === 0
|
||||
&& base.result.terminal.fullSpacePhysicalChecks === 0
|
||||
&& base.result.terminal.localPhysicalChecks === 0
|
||||
&& base.result.terminal.configWrites === 0 && base.result.terminal.history === 0
|
||||
&& base.result.terminalPartitionCount === base.fixture.savedPartitions + 7
|
||||
&& base.result.activeCleared;
|
||||
const finishStructural = baseFinish.terminal.fullSpacePhysicalChecks === 0
|
||||
&& baseFinish.terminal.localPhysicalChecks === 1
|
||||
&& baseFinish.terminal.configWrites === 1
|
||||
&& baseFinish.terminal.history === 0
|
||||
&& baseFinish.terminal.wallFinishBarriers === 1
|
||||
&& baseFinish.terminal.wallGenericFallbacks === 0
|
||||
&& baseFinish.surviving.length === 1
|
||||
&& baseFinish.terminalPartitionCount === base.fixture.savedPartitions + 1
|
||||
&& baseFinish.optimizeChanged === false
|
||||
&& baseFinish.activeCleared
|
||||
&& remoteFinish.terminal.fullSpacePhysicalChecks === 0
|
||||
&& remoteFinish.terminal.localPhysicalChecks === 1
|
||||
&& remoteFinish.terminal.configWrites === 1
|
||||
&& remoteFinish.terminal.wallFinishBarriers === 1
|
||||
&& remoteFinish.surviving.length === 1
|
||||
&& remoteFinish.optimizeChanged === false;
|
||||
const budgets = {
|
||||
medianMs: 150,
|
||||
maxMs: 250,
|
||||
remoteMedianMs: medianMs * 1.5 + 20,
|
||||
finishMs: 250,
|
||||
remoteFinishMs: baseFinish.finishMs * 1.5 + 20,
|
||||
};
|
||||
const timing = medianMs <= budgets.medianMs && maxMs <= budgets.maxMs
|
||||
&& remoteMedianMs <= budgets.remoteMedianMs
|
||||
&& baseFinish.finishMs <= budgets.finishMs
|
||||
&& remoteFinish.finishMs <= budgets.remoteFinishMs;
|
||||
const report = {
|
||||
issue: 461, profile: 'wall-draw-click-v1',
|
||||
fixture: base.fixture, remoteFixture: remote.fixture,
|
||||
timing: {
|
||||
samplesMs: base.result.times.map((value) => Math.round(value * 10) / 10),
|
||||
medianMs: Math.round(medianMs * 10) / 10,
|
||||
maxMs: Math.round(maxMs * 10) / 10,
|
||||
remoteMedianMs: Math.round(remoteMedianMs * 10) / 10,
|
||||
finishMs: Math.round(baseFinish.finishMs * 10) / 10,
|
||||
remoteFinishMs: Math.round(remoteFinish.finishMs * 10) / 10,
|
||||
},
|
||||
budgets: Object.fromEntries(Object.entries(budgets)
|
||||
.map(([key, value]) => [key, Math.round(value * 10) / 10])),
|
||||
counters: base.result.metrics,
|
||||
finishCounters: baseFinish.terminal,
|
||||
structural: structural && finishStructural,
|
||||
timingPass: timing,
|
||||
pass: structural && finishStructural && timing,
|
||||
};
|
||||
if (output) {
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!report.pass) process.exitCode = 1;
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,47 @@
|
||||
/** #54: bounded provider normalization and incident-only hover performance. */
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { mapTopologies, normalizeZhaTopology, resolveMappedTopologyHover } from '../test-build/zigbee-topology.js';
|
||||
|
||||
const NODES = 500;
|
||||
const SAMPLES = 9;
|
||||
const BUDGETS = { normalizeMs: 80, mapMs: 160, firstHoverMs: 180, repeatedHoverMs: 120 };
|
||||
const hex = (index) => index.toString(16).padStart(16, '0');
|
||||
const rows = Array.from({ length: NODES }, (_, index) => ({
|
||||
ieee: hex(index + 1), device_reg_id: `d${index}`, device_type: index ? 'Router' : 'Coordinator',
|
||||
neighbors: [1, 2, 3, 4, 5, 6].flatMap((offset) => {
|
||||
const other = index + offset;
|
||||
return other < NODES ? [{ ieee: hex(other + 1), lqi: (index * 17 + offset * 13) % 256 }] : [];
|
||||
}),
|
||||
}));
|
||||
const registry = {
|
||||
revision: 1, authoritative: true, access: 'full', lastSuccess: Date.now(), entities: {},
|
||||
devices: Object.fromEntries(rows.map((_, index) => [`d${index}`, { id: `d${index}` }])),
|
||||
};
|
||||
const active = { kind: 'active', enabledEntityIds: [], allEntityIds: [] };
|
||||
const devices = rows.map((_, index) => ({
|
||||
id: `m${index}`, name: '', model: '', area: '', space: index % 5 ? 'main' : 'remote',
|
||||
icon: '', entities: [], bindingKind: 'device', bindingRef: `d${index}`, bindingStatus: active,
|
||||
}));
|
||||
|
||||
const measured = (fn) => {
|
||||
const samples = [];
|
||||
for (let i = 0; i < SAMPLES; i++) {
|
||||
const started = performance.now(); fn(); samples.push(performance.now() - started);
|
||||
}
|
||||
samples.sort((a, b) => a - b);
|
||||
return samples[Math.floor(samples.length * .9)];
|
||||
};
|
||||
|
||||
let topology; let mapped;
|
||||
const normalizeMs = measured(() => { topology = normalizeZhaTopology(rows); });
|
||||
const mapMs = measured(() => { mapped = mapTopologies([topology], devices, registry); });
|
||||
const firstHoverMs = measured(() => resolveMappedTopologyHover(mapped, 'main', 'm1'));
|
||||
const repeatedHoverMs = measured(() => {
|
||||
for (let i = 1; i <= 20; i++) resolveMappedTopologyHover(mapped, 'main', `m${i}`);
|
||||
});
|
||||
const result = { nodes: topology.nodes.length, links: topology.links.length,
|
||||
normalizeMs, mapMs, firstHoverMs, repeatedHoverMs };
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
for (const [name, budget] of Object.entries(BUDGETS)) {
|
||||
if (result[name] > budget) throw new Error(`${name} ${result[name].toFixed(2)} ms > ${budget} ms`);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Аргументы запуска Chromium для съёмки документации (#410, #424).
|
||||
*
|
||||
* Модуль отдельный и чистый намеренно: `capture.mjs` — исполняемый скрипт,
|
||||
* который при импорте поднимает браузер и снимает набор. Тест, желающий
|
||||
* прочитать эти константы, не должен запускать съёмку внутри `npm test` — тот
|
||||
* же приём и та же причина, что у `clip.mjs`.
|
||||
*
|
||||
* Первые три флага фиксируют растеризацию текста и цветовой профиль: именно они
|
||||
* убрали основную часть дрейфа в #410.
|
||||
*
|
||||
* Последние два добавлены в #424 и обязаны стоять вместе — по отдельности ни
|
||||
* один дрейф не убирает (проверено перебором, восемь прогонов на конфигурацию):
|
||||
*
|
||||
* - `--disable-partial-raster` запрещает переиспользовать ранее нарисованные
|
||||
* куски тайла. С включённой частичной растеризацией кадр зависит от того,
|
||||
* что композитор рисовал ДО него, то есть от истории прогона;
|
||||
* - `--run-all-compositor-stages-before-draw` заставляет пройти все стадии
|
||||
* композитора до отрисовки, иначе снимок берётся на полпути и «докрутка»
|
||||
* каждый раз происходит по-своему.
|
||||
*
|
||||
* Подпись дефекта, ради которого это сделано: один-два случайных кадра из
|
||||
* десяти расходятся между прогонами на единицы пикселей и два уровня, при этом
|
||||
* внутри одного процесса всё стабильно.
|
||||
*/
|
||||
export const DETERMINISTIC_ARGS = [
|
||||
'--force-color-profile=srgb',
|
||||
'--font-render-hinting=none',
|
||||
'--disable-lcd-text',
|
||||
'--disable-partial-raster',
|
||||
'--run-all-compositor-stages-before-draw',
|
||||
];
|
||||
@@ -8,11 +8,13 @@ import { fileURLToPath } from 'node:url';
|
||||
// manifest-owned tree before launching Chromium; copying only the stable entry
|
||||
// leaves every content-hashed import at 404.
|
||||
import '../../scripts/bundle-sync.mjs';
|
||||
import { wholePixelClip } from './clip.mjs';
|
||||
import { visualFingerprint } from '../../scripts/source-fingerprint.mjs';
|
||||
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
|
||||
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
|
||||
import { launch } from '../serve.mjs';
|
||||
import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from './screenshots.mjs';
|
||||
import { DETERMINISTIC_ARGS } from './browser-args.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUTPUT = resolve(ROOT, 'docs/images');
|
||||
@@ -86,6 +88,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 +153,99 @@ 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
|
||||
// не менялась: подпись субпиксельного сглаживания, а не изменения продукта.
|
||||
//
|
||||
// Аргументы запуска и объяснение каждого — в `browser-args.mjs`: константы
|
||||
// живут отдельно, чтобы тест мог их прочитать, не поднимая браузер (#424).
|
||||
// `reducedMotion: 'reduce'` добавлен к `animations: 'disabled'` у самого
|
||||
// скриншота: первое гасит анимации в CSS, второе — уже начатые переходы на
|
||||
// момент съёмки.
|
||||
/**
|
||||
* Режим замера стабильности (#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 +262,48 @@ 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); почему именно так —
|
||||
// в `wholePixelClip`. Замер тогда показал, где искать: три снимка подряд в
|
||||
// одном состоянии совпадают побайтово у всех десяти сценариев, а между
|
||||
// прогонами плавают два. Значит дело не в рендере, а в том, что приходит на
|
||||
// вход съёмке — и проверяется это между прогонами (#422).
|
||||
const clip = wholePixelClip(rawClip);
|
||||
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 +320,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,29 @@
|
||||
/**
|
||||
* Выравнивание области съёмки по целым пикселям (#410, гейт — #422).
|
||||
*
|
||||
* Прямоугольник приходит из живого DOM через `getBoundingClientRect`, то есть
|
||||
* дробным. Дробная обрезка заставляет Chromium ресемплить кадр, и тогда сдвиг
|
||||
* раскладки на десятую пикселя — от метрики шрифта, от появившегося
|
||||
* скроллбара — переписывает границы всех элементов на единицы уровней. Ровно
|
||||
* эта подпись и была в #410: 76 пикселей, максимум 2 уровня, alpha не тронута,
|
||||
* всё на сглаженных границах полей.
|
||||
*
|
||||
* Внешняя рамка РАСШИРЯЕТСЯ, а не округляется к ближайшему: обрезка обязана
|
||||
* содержать цель целиком, потерять полпикселя по краю нельзя. Именно поэтому
|
||||
* здесь `floor` для начала и `ceil` для конца, а не `Math.round` — округление к
|
||||
* ближайшему срезало бы край на половине входов.
|
||||
*
|
||||
* Функция живёт отдельным модулем, потому что она чистая, а `capture.mjs` —
|
||||
* исполняемый скрипт: импортировать его в тест значит запустить съёмку.
|
||||
*/
|
||||
export function wholePixelClip(rect) {
|
||||
if (!rect) return rect;
|
||||
const x = Math.floor(rect.x);
|
||||
const y = Math.floor(rect.y);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.ceil(rect.x + rect.width) - x,
|
||||
height: Math.ceil(rect.y + rect.height) - y,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
const CELL_CM = 5;
|
||||
const point = (x, y) => [x / 240, y / 240];
|
||||
|
||||
const roomAt = (id, x, y) => {
|
||||
const poly = [point(x, y), point(x + 24, y), point(x + 24, y + 24), point(x, y + 24)];
|
||||
const wallIds = poly.map((_, index) => `${id}-wall-${index}`);
|
||||
return {
|
||||
room: { id, name: id, area: null, poly, wall_ids: wallIds },
|
||||
segments: poly.map((a, index) => ({
|
||||
id: wallIds[index], a, b: poly[(index + 1) % poly.length], cm: 15,
|
||||
})),
|
||||
walls: poly.map((a, index) => ({
|
||||
key: `${id}-legacy-${index}`, a, b: poly[(index + 1) % poly.length], cm: 15,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const roomGrid = (prefix, xOffset = 0) => Array.from({ length: 12 }, (_, index) =>
|
||||
roomAt(`${prefix}-${index}`, xOffset + 8 + (index % 4) * 32, 8 + Math.floor(index / 4) * 32));
|
||||
|
||||
export const WALL_DRAW_CLICK_POINTS = [
|
||||
point(152, 144), point(176, 144), point(176, 168), point(200, 168),
|
||||
point(200, 144), point(224, 144), point(224, 168), point(236, 168),
|
||||
];
|
||||
|
||||
export function makeWallDrawClickFixture(remoteVariant = false) {
|
||||
const primary = roomGrid('base');
|
||||
const remote = remoteVariant ? roomGrid('remote', 300) : [];
|
||||
const all = [...primary, ...remote];
|
||||
const savedPartitions = Array.from({ length: 4 }, (_, index) => {
|
||||
const points = [
|
||||
point(8 + index * 28, 112), point(20 + index * 28, 112),
|
||||
point(20 + index * 28, 124),
|
||||
];
|
||||
return points.slice(1).map((b, edge) => ({
|
||||
id: `saved-partition-${index}-${edge}`, a: points[edge], b, cm: 15,
|
||||
}));
|
||||
}).flat();
|
||||
const edited = {
|
||||
id: 'edited', title: 'Wall draw click', cell_cm: CELL_CM,
|
||||
view_box: [0, 0, 1, 1],
|
||||
rooms: all.map((item) => item.room),
|
||||
wall_segments: all.flatMap((item) => item.segments),
|
||||
walls: all.flatMap((item) => item.walls),
|
||||
partitions: savedPartitions, wall_columns: [], openings: [], open_spans: [],
|
||||
};
|
||||
return {
|
||||
model_version: 10,
|
||||
spaces: [edited, ...Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `other-${index}`, title: `Other ${index}`, cell_cm: CELL_CM,
|
||||
view_box: [0, 0, 1, 1], rooms: [], wall_segments: [], walls: [],
|
||||
partitions: [], wall_columns: [], openings: [], open_spans: [],
|
||||
}))],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
goldenAcceptancePlan, goldenAcceptanceRefusal, goldenSilentDeclarations,
|
||||
goldenWitnessRefusal,
|
||||
} from '../../scripts/golden-acceptance.mjs';
|
||||
import {
|
||||
assertCaptureEnvironment, captureEnvironment, environmentNote,
|
||||
} from '../../scripts/capture-environment.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const reviewed = process.argv.includes('--reviewed');
|
||||
@@ -38,6 +41,16 @@ if (typeof report.chromium !== 'string' || !report.chromium)
|
||||
throw new Error('candidate report does not identify its Chromium build');
|
||||
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
|
||||
|
||||
// Среда приёмки (#455). Отчёт съёмки платформу не несёт — `run.mjs` входит в
|
||||
// корпус sourceFingerprint, и его правка объявила бы устаревшими бандл,
|
||||
// скриншоты документации и сам индекс эталонов. Но приёмка идёт там же, где
|
||||
// лежат артефакты, поэтому её платформа — честный признак среды кадров.
|
||||
const environment = captureEnvironment();
|
||||
const foreignAllowed = assertCaptureEnvironment({ kind: 'golden', stage: 'accept' });
|
||||
if (foreignAllowed) {
|
||||
console.log(`Чужая среда приёмки разрешена осознанно: ${foreignAllowed}`);
|
||||
}
|
||||
|
||||
const refusal = goldenAcceptanceRefusal(report.results, declared, declaredNew);
|
||||
if (refusal) throw new Error(refusal);
|
||||
|
||||
@@ -63,13 +76,27 @@ const previous = existsSync(manifestPath)
|
||||
// байт-в-байт, доказывают, что среда съёмки та же, что у принятого эталона.
|
||||
const witnessCheck = goldenWitnessRefusal({
|
||||
results: report.results,
|
||||
// #408: от размера матрицы, а не от числа уцелевших эталонов — иначе порог
|
||||
// обходится удалением каталога эталонов.
|
||||
sceneCount: GOLDEN_SCENARIOS.length,
|
||||
declared,
|
||||
declaredNew,
|
||||
previousHashes: previous,
|
||||
skipWitnesses,
|
||||
skipReason,
|
||||
});
|
||||
if (witnessCheck.refusal) throw new Error(witnessCheck.refusal);
|
||||
if (witnessCheck.refusal) {
|
||||
// Приписка про среду — то, чего не хватало отказу: «свидетелей 0 из 10» без
|
||||
// неё читается как «объяви больше сцен», и обход в одну команду выглядит
|
||||
// решением (#455).
|
||||
const note = environmentNote({
|
||||
capturedOn: environment.platform,
|
||||
acceptedOn: existsSync(manifestPath)
|
||||
? JSON.parse(readFileSync(manifestPath, 'utf8')).platform || null
|
||||
: null,
|
||||
});
|
||||
throw new Error(note ? `${witnessCheck.refusal}\n${note}` : witnessCheck.refusal);
|
||||
}
|
||||
// Кандидат проверяется целиком, до всякого решения о замене: сломанный отчёт
|
||||
// не имеет права оставить каталог эталонов половинным.
|
||||
for (const scenario of GOLDEN_SCENARIOS) {
|
||||
@@ -99,6 +126,9 @@ writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify
|
||||
acceptedAt: new Date().toISOString(),
|
||||
sourceFingerprint: report.buildFingerprint,
|
||||
chromium: report.chromium,
|
||||
// Платформа рядом с версией браузера: провенанс среды, по которому следующая
|
||||
// приёмка отличит «объявили не то» от «сняли не там» (#455).
|
||||
platform: environment.platform,
|
||||
// #355: след приёмки в артефакте, не только в истории shell.
|
||||
witnesses: skipWitnesses
|
||||
? { skipped: true, reason: skipReason }
|
||||
|
||||
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
@@ -1,155 +1,174 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 51,
|
||||
"acceptedAt": "2026-08-29T20:06:16.499Z",
|
||||
"sourceFingerprint": "1c0a2661ccd125f4f23faab4730974a20d7b63ace15c3be781c5bbe602843d61",
|
||||
"matrixVersion": 56,
|
||||
"acceptedAt": "2026-09-06T16:52:30.905Z",
|
||||
"sourceFingerprint": "646efb7cbcc3983509681c3430fa42513ae62445f7606ebb8de7f3967afe4f00",
|
||||
"chromium": "151.0.7922.34",
|
||||
"platform": "linux",
|
||||
"witnesses": {
|
||||
"count": 130,
|
||||
"count": 129,
|
||||
"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",
|
||||
"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",
|
||||
"vacuum-trail-smoothing-dark": "17d564fdee66630b99e8514c557c96d3428afbd753fa0f20d82efd51f11bbb76",
|
||||
"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",
|
||||
"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",
|
||||
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
|
||||
"geometry-devices-editor-dark": "a9a9d22b37e03fde37120889a4d9aa50b9d318a31e416504f74e2905b5d947ff",
|
||||
"device-inbox-desktop-en-light": "c04254626f613b11e1b9165abf6bfad4b04a2670acab2887e38062d7d16b4533",
|
||||
"device-inbox-desktop-ru-dark": "2e7e7175ed770fea657828beda68b62d6eb657a4b72222b1977df3d7d692b0d6",
|
||||
"device-inbox-narrow-ru-dark": "d279390eb2ee323ccc1e4fe712cb3b0788f258c5e50502393051debcee265700",
|
||||
"geometry-decor-editor-dark": "4a4d40844f4f92b0e1baf169a25f8a15a507682cea677c631dc57f68e31e373f",
|
||||
"tray-wide-selection-en": "056f3b54d60bba83ef313f9c12fe697ea6a0b3d48db0470735394ee0d3f72c39",
|
||||
"tray-wide-tool-ru": "f8e050c96cb3e78f1ab8d0b8b276b3d154950e795c5b620148b9e395330cffe9",
|
||||
"tray-medium-group-en": "4513abc13f1bc24e4d04f14445069c8ce9785dc3fddcdc99bcee41b5eadb8d53",
|
||||
"tray-medium-selection-ru": "44de98a53d7de665daf2a3f466c281caa66ef6d6cd2941125e2cbfd975f8c8f4",
|
||||
"tray-narrow-palette-en": "392eeac327c899add9792e356d75f48ae541c38bd1c2ace6bdde8e9dcdf17fd6",
|
||||
"furniture-categories-light": "13ab156ce69ff67f82b0aaa1e755d7b2ed63bda2a06f248f56d713c0d88e6fb7",
|
||||
"furniture-variants-dark": "dc83d2fef638063f93a2474dba48d28f2b7c472bf7ed88147efbe65c745bcb1b",
|
||||
"furniture-variants-light": "a2f4742c392ff73b0b68ee91970a81c4550295180c51f4b9b3d3f19fc58cf2a4",
|
||||
"furniture-placement-preview-light": "282ecb96c5d251323c39ccc2fd27775d8c20a15e3a7d6b86eb2bd93dab46c312",
|
||||
"furniture-plan-art-dark": "d12db5d0a3d99caed4228ba04983f762794909efd366b999e240eba310724110",
|
||||
"tray-narrow-tool-ru": "3d002d2a153fe615e8dd7c9fca466525477318992d4bd4bb099dbfb3d9195c4a",
|
||||
"geometry-diagonal-45-opening-dark": "ba9462322da34b20fff1ee1f950c516855dd7ac56131e13863f576e2d0ba5177",
|
||||
"openings-thick-wall-dark": "539cdb9cb1460cd42c2e51901ea50f496e8214d02633e110e2780c3f177154db",
|
||||
"openings-filled-tunnel-dark": "bb2a4343a285e2fd867c6090108589549facf87b1a9471f3ae331d743af6653c",
|
||||
"decor-over-opaque-hover-light": "d6770c39d65f925c2e09bb31d49a7492af9129073d3fdf9b1ef17491b97c5dbc",
|
||||
"decor-over-glow-base-dark": "5543264be96f517ef3e330efad90c898d357c7d2927b56131f2ef7192def6f63",
|
||||
"openings-hidden-view-dark": "19ab4fd2ffce0fea0fe609b007a3e60f7abd84b40935cbf3c5fa9dc1fda21046",
|
||||
"lighting-glow-sun-dark": "08e2964df6fd03abad4b9a81f1dbd0fe3812fb6b5f7908aeb027ad2953b2f88c",
|
||||
"device-value-badge-positions-dark": "75ab0210d697f952b8a8977649e84308de3ce3b59c84ec93071179a1d6cb015d",
|
||||
"device-value-face-cover-source-dark": "c48fa33e0a85164ee64bfce6dbc4127c96b4a1c065e4604310f1b16be144639d",
|
||||
"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": "5326856baf1e8073d2cba507a5d073889249ba3acc73f87616c0e60034dd1807",
|
||||
"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": "606c41d53f9a996b24990032505180159ce032e2b4f14dfb214c79a8c4b85702",
|
||||
"decor-color-popover-desktop-en": "f626f246c8e29d1b1e5e067e0923438eb1222606388a33c3071e86307b4ca130",
|
||||
"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",
|
||||
"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",
|
||||
"version-mismatch-desktop-dark-en": "8efc4365de5e5f69dc864a36d87144c3a16a2a52f89ec6e5556fc7755f538fb6",
|
||||
"version-mismatch-touch-light-ru": "6fee87758c8373472b57542b8705595113ef07b92014ef6f5b977d4d1aa19808",
|
||||
"version-mismatch-kiosk-attempted-dark-de": "d3d0863fe814d20f5b081de6ac14f8bbf7d559be019808e62cd6dcdc7abcaa70",
|
||||
"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": "52b01bf23b31353900ea9174fdb4b9d27a586f8370fb9b00c4db6b9bbe7f73f2",
|
||||
"isometric-geometry-view-light": "6ee98e56782feaabf784eea7d927ea35bdde054d8605484f4a5e92ea93f7c82a",
|
||||
"isometric-live-layers-dark": "0d123ab0285fe3ad8487fb5263c3d8074fe2d2490091868ed648009039c04a3b",
|
||||
"isometric-no-borders-dark": "7cd9e7d9797c920e7f3564a24b1fd6143b4da94a342af3b34d5a1cd8d584f11b",
|
||||
"isometric-touch-kiosk-dark": "a6515349431d53688415e42a7711e8adf052eacf5e63a74f892fed60002c6d11",
|
||||
"isometric-large-warm-remount-dark": "91f24287e8c3148fd6470ab4abc4f856f2863c2ae7372cd90a3770ddee48f209",
|
||||
"isometric-stage3-overlays-light": "efb39c86776c507b25a8f1955f7371086cdca92e0df0ecb4c4127577fb925ba9",
|
||||
"isometric-stage3-overlays-dark": "af2af4520240ebec87d84727fccc249c5a008b0ce4403bb9c2d37aa317d8a3e8",
|
||||
"isometric-stage3-openings-dark": "a387ad33e2d238cd5f5088f9de2be8797f8b724d40599ef21eaae4ad0ff27b33",
|
||||
"isometric-stage3-forced-colors-dark": "36bd5a28a336b2be2d33640dc57d8eb3992198c812c6cfb5c20bf7daa6a611f2",
|
||||
"isometric-stage3-no-filter-dark": "277e79c005736ed81135f70517902ccea4d4300ee886e0cf05eb027e7b46d687",
|
||||
"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": "af692b2c01293c59cc74e5731309e6612c913ca1c8384572fd1cb67a19d0f902",
|
||||
"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": "e2ead596818f6182bd2478dc3823f31312d361080c99afbb9c7f7d1862cf781c",
|
||||
"geometry-devices-editor-dark": "7c4072fce11f91b2c894bdb5f4f9113e72089306d629c995d38dfd17f2b65cc8",
|
||||
"device-inbox-desktop-en-light": "a73761da0594ba2138f098d971d74566f9915f59a563fd55295add241145ba36",
|
||||
"device-inbox-desktop-ru-dark": "5a9eb32ffb7f408c6bf8adca9d76b0d01d7b0b514f8b33a01e500f9230541f1a",
|
||||
"device-inbox-narrow-ru-dark": "65c46b7311f850f305147d75a24ddeded67c9e8b18ea362c88b9964c50a5b727",
|
||||
"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": "f3a3a45dad2cc22e692366114a4f87272b39ba261c1509e8a501dbfb82b6584e",
|
||||
"device-dialog-mobile-ru": "7ac796286ab4e65275838b4095ea3f16491311f99fd98cad3bbb475c6061e31c",
|
||||
"device-dialog-desktop-de": "9d39d3e73a3f36171e8cc7c930bcb43bfbfd788e31db0f584962300a2b00ca93",
|
||||
"german-view-mobile-light": "55112787d9a26be66dfd8b724115962c4b891d1f8276b7b5b36b0ff25da48257",
|
||||
"toggle-entity-dialog-desktop-en": "3ea2bb828b554b6fb544b77fdef2af947e45af632769f4deab03c6adf195fa68",
|
||||
"toggle-entity-dialog-mobile-ru": "2d433f3f1fab9e9d4c3d413c302ac0d3b44a2d46ec9ecbf098c3063da6f1df04",
|
||||
"device-help-popover-light-ru": "b8cb39c77690bb2a2a150a2a77af55411b3f2053741eee86cd5fd19b41b4307f",
|
||||
"decor-color-popover-mobile-ru": "dbe9b01e848d3f2776ac74c6bad0134c978b04c07c37b674ca7c823aff5fcd33",
|
||||
"decor-color-popover-desktop-en": "5fb641e18c3a84c0056dbc90d0268a21ed36060a23eee8f862f07e278d945e17",
|
||||
"general-color-popover-desktop-en": "02697c1200e461c7b212bd59041de08a84e4b552efecace8c5e7fbee56579591",
|
||||
"settings-help-zoom-200-en-light": "a6f49b4d0a171dc5f74d409c6e7df0e1c57bc52086b3a66dd794efde1236eef8",
|
||||
"settings-help-zoom-200-ru-dark": "0b1f995abec3b14cd5b25e0c0fb0a8d04d08567b0b3e5717463059efa244296d",
|
||||
"support-desktop-empty-light-en": "04d968e28c85096f6eb637f9ae01c2bae86c6d1540a9b3bfb83f0f4992127b4e",
|
||||
"support-desktop-preview-dark-en": "dd3502ce1584109a837ba80f5683ddd5a1ea2063150217121f6e0b8832a0a659",
|
||||
"support-phone-validation-light-ru": "37059c99431d6890d1f1a66444c7e853e51387655cd1ee6474063299281f2b1f",
|
||||
"support-phone-success-dark-en": "024e72048558561b66f13d635fc22e1e3d289d0b39499e6595dc986761daa31f",
|
||||
"support-relay-error-light-en": "ab769c512f94a1dea22f36f57a36796bc3b5bc3ec788f4ca52bfccbc7fc25ab0",
|
||||
"support-tablet-preview-dark-ru": "416604355b3785aecda02f1ad21d031ce4c977180ed8606937b6d189b0afb7b1",
|
||||
"device-ripple-color-popover-mobile-ru": "93d04a498e16ac7e98bd420bef566645c2215488138568c5b150110380aa782e",
|
||||
"space-room-color-popover-desktop-ru": "3c85e58fc0be7c82369c7135c0585cc0fc63b8fbc20cea5f4e211a04493a0bde",
|
||||
"backup-full-preview-desktop-en": "fc3ac9ec9eae728f2524131f3f9e3a55fa4afce43f440ee6f7b04bd3b78e66df",
|
||||
"backup-plan-only-export-desktop-en": "b000db624416d90a8d9e658ec602d278f2bc8df63af2e8eb9562bc2fbff24ed8",
|
||||
"backup-space-preview-mobile-ru": "4575ba8783647518abbbe9903fd915ec0d3caceaa08a70fa70cf8bf8e7b08356",
|
||||
"optimize-preflight-dialog-dark-en": "0e140653af81cf8a965fee00d6f5ce7d0b49c22d329f5237e96e6ba1829252b4",
|
||||
"optimize-preflight-dialog-light-ru": "1fc545b7017c6ae3b0a2b0e1330b037dc0e565113c5892d48be64ca30f34abf6",
|
||||
"optimize-orphan-references-dark-en": "9611469c4d4e542b77abcc52c7c9484237aa18ff58994926be66514a377564f4",
|
||||
"optimize-orphan-references-light-ru": "7ea732b04dfd7aca25107935576326975b3cc7cf5757d844d809ceb4cef0a245",
|
||||
"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: 193 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 75 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: 416 KiB |
|
Before Width: | Height: | Size: 347 KiB After Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 184 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: 220 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 94 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 |