mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 23:28:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9889e5d20 | ||
|
|
77886530fe | ||
|
|
bf22ddac46 | ||
|
|
203019aa26 | ||
|
|
08903f68d9 | ||
|
|
bdeab61001 | ||
|
|
c07fce936f | ||
|
|
264440ebcc | ||
|
|
32d82651e2 | ||
|
|
9079c16d16 | ||
|
|
a8fd23b6cc | ||
|
|
2df363f3af | ||
|
|
0147441c93 | ||
|
|
4d52fdd290 | ||
|
|
8e6ba7975b | ||
|
|
7bd7bce69c | ||
|
|
4bd0c04bdc | ||
|
|
ccfab84e7f | ||
|
|
23f831a1fe | ||
|
|
384bd309a6 | ||
|
|
5f7c00515a | ||
|
|
bed8beccbc | ||
|
|
b21f926b3e | ||
|
|
6e7cc30da7 | ||
|
|
4c9784255b | ||
|
|
1dd6cc3da5 | ||
|
|
d04ab53f1d | ||
|
|
f4cfcbacde | ||
|
|
2b1780879a | ||
|
|
800b85c812 | ||
|
|
c5fa13215c | ||
|
|
6414ade05d | ||
|
|
5286570bd6 | ||
|
|
3160219824 | ||
|
|
bd1be88602 | ||
|
|
a102fac2c8 | ||
|
|
63a4207061 | ||
|
|
092c95b716 | ||
|
|
09f046c72d | ||
|
|
b7cbd9b5d8 | ||
|
|
e12add17be | ||
|
|
bda5bf9e96 | ||
|
|
2056b89bac | ||
|
|
d698770169 | ||
|
|
b41e3e781e | ||
|
|
b72abd675f | ||
|
|
9836f0e27a | ||
|
|
e8cec3542f | ||
|
|
050ebfc9f5 | ||
|
|
449b99b0c0 | ||
|
|
0bdc3fd1f7 | ||
|
|
376554943d | ||
|
|
9fcc41b199 | ||
|
|
fe5639ce40 | ||
|
|
0182c4b32e | ||
|
|
c12f07c5a0 | ||
|
|
31d3e19f38 | ||
|
|
ee1c884031 | ||
|
|
e5f677bdd1 | ||
|
|
6dd812f71c | ||
|
|
71cf2b4160 | ||
|
|
e9f2a3f532 | ||
|
|
0725374db7 | ||
|
|
6f7c6eec04 | ||
|
|
29e56adc15 | ||
|
|
caf91cd7ff | ||
|
|
6432571c61 | ||
|
|
713551b525 | ||
|
|
9a215efe26 | ||
|
|
65eef1b690 | ||
|
|
81bafa5520 | ||
|
|
d322dc2047 | ||
|
|
5f9019a95f | ||
|
|
4bc6a59ff3 | ||
|
|
e505a93a40 | ||
|
|
bc74d0b11b | ||
|
|
cc6f7b02bc | ||
|
|
74e9357a59 | ||
|
|
8b876823da | ||
|
|
db20369447 | ||
|
|
0e0a5618af | ||
|
|
21fa5cbded | ||
|
|
e2b7df9f84 | ||
|
|
ef07505b31 | ||
|
|
435a6cceb9 | ||
|
|
a51216e3c7 | ||
|
|
6ede403688 | ||
|
|
ecadda45e7 | ||
|
|
a837702b2c | ||
|
|
57e04bb547 | ||
|
|
ffdd353e16 | ||
|
|
0215e76118 | ||
|
|
833605b71f | ||
|
|
f4d821deca | ||
|
|
c465ef8352 | ||
|
|
0dd4b6a991 | ||
|
|
1b37baefd3 | ||
|
|
b4a9424d60 | ||
|
|
93c1824b1d | ||
|
|
8ceb08704f | ||
|
|
eb82973067 | ||
|
|
a3e7f69b3f | ||
|
|
373fa43136 | ||
|
|
e04732462c | ||
|
|
e663e86e09 | ||
|
|
163a15d664 | ||
|
|
4afdd4e892 | ||
|
|
190cfe99ac | ||
|
|
c9284f6b33 | ||
|
|
5063c7d3ad | ||
|
|
4dd7dabf08 | ||
|
|
917d8527a5 | ||
|
|
f307467e37 | ||
|
|
c0cd2c6b96 | ||
|
|
4a4ee8888d | ||
|
|
5285b7b95e | ||
|
|
c8dc26b241 | ||
|
|
25ea77e038 | ||
|
|
50c7d2d6ad | ||
|
|
3ccbaf21d2 | ||
|
|
6930fa6f3f | ||
|
|
16faef1511 | ||
|
|
c87d4524d5 | ||
|
|
33234d9ce4 | ||
|
|
4190ba43b4 | ||
|
|
b7d9c78309 | ||
|
|
50b0ffd7e7 | ||
|
|
15f3eaf966 | ||
|
|
0d5de750ff | ||
|
|
7109900bcf | ||
|
|
281e10c15f | ||
|
|
cdad6ccd75 | ||
|
|
b537f9a93a | ||
|
|
1b56e63aab | ||
|
|
f33d3606be | ||
|
|
fb60cfde38 | ||
|
|
de800450ad | ||
|
|
977672976f | ||
|
|
cd9b752ede | ||
|
|
242815e139 | ||
|
|
87ff0f3711 | ||
|
|
8ecb9be98e | ||
|
|
0e66b29983 | ||
|
|
6a8d20858e | ||
|
|
e4b253e08b | ||
|
|
56d3a8f9d8 | ||
|
|
f6c3887c81 | ||
|
|
2affe188c5 | ||
|
|
587a02f663 | ||
|
|
0181263d32 | ||
|
|
eaa3e458b5 | ||
|
|
252bfbaa20 | ||
|
|
66e1318a79 | ||
|
|
9a4ce6fcd0 | ||
|
|
4b95b54be7 | ||
|
|
f637e7d59c | ||
|
|
018fad27af | ||
|
|
4594142c09 | ||
|
|
ac80bb6430 | ||
|
|
1765fe8925 | ||
|
|
7e82a3d1e9 | ||
|
|
9df6cccd31 | ||
|
|
bdb94da46e | ||
|
|
e0b0248172 | ||
|
|
5e44cf77b3 | ||
|
|
621cae2a63 | ||
|
|
711b456b06 | ||
|
|
278e3fb40e | ||
|
|
6ed18c757d | ||
|
|
1ca24700f2 | ||
|
|
cefc1dbf8d | ||
|
|
a5e4e9ef61 | ||
|
|
f5920fd53b | ||
|
|
a289f981bf | ||
|
|
00dda245e4 | ||
|
|
1960e026d2 | ||
|
|
4bf45630e2 | ||
|
|
ed03c40dad | ||
|
|
02b08834e4 | ||
|
|
1b85968e5e | ||
|
|
ea49d1ae44 | ||
|
|
a085b92c68 | ||
|
|
1303340aaf | ||
|
|
693f0d0a35 | ||
|
|
3edcfd7ff6 | ||
|
|
0ead302c0a | ||
|
|
418991386e | ||
|
|
7449c2357c | ||
|
|
8c3252c213 | ||
|
|
21c9faffaf | ||
|
|
e0611269f3 | ||
|
|
edcfe32572 | ||
|
|
d697c29c7b | ||
|
|
ba41985667 | ||
|
|
f656d9629c | ||
|
|
cc3521c877 | ||
|
|
615c53c93c | ||
|
|
f7e50519e3 | ||
|
|
e09d5fd526 | ||
|
|
8ef01fd9b1 | ||
|
|
19116995bb | ||
|
|
9d1427c104 | ||
|
|
317c9b4046 | ||
|
|
aad87cc3ec | ||
|
|
d8855b8e6f | ||
|
|
7a4f2869b9 | ||
|
|
40652d8a8f | ||
|
|
1d6ba5393a | ||
|
|
69364c958f | ||
|
|
164c3222b9 | ||
|
|
a8ba7eb552 | ||
|
|
5ff2148016 | ||
|
|
bca4bfa074 | ||
|
|
3cbcc4a0ab | ||
|
|
ff823dbaf0 | ||
|
|
3cb63aa922 | ||
|
|
b7b92da8ff | ||
|
|
39e4468308 | ||
|
|
908ecb3e2a | ||
|
|
aec59cb774 | ||
|
|
961a82e213 | ||
|
|
c36a177916 | ||
|
|
97dbe1e8af | ||
|
|
60808b4d36 | ||
|
|
7cbf31a129 | ||
|
|
5304b0ceb7 | ||
|
|
9a9f8d1438 | ||
|
|
a5135168c8 | ||
|
|
c1b62e83ef | ||
|
|
4acbdd7685 | ||
|
|
3ad20ddcdc | ||
|
|
69f9002e31 | ||
|
|
dfeaf22f70 | ||
|
|
faa6ad9ce8 | ||
|
|
3e4bda0fda | ||
|
|
8195b2dd4a | ||
|
|
ecc4696144 | ||
|
|
5e5bb18cf9 | ||
|
|
6fbb8b5ca2 | ||
|
|
130d041f75 | ||
|
|
5a97643211 | ||
|
|
3a474187c6 | ||
|
|
8be1ae16ab | ||
|
|
1e2ad28267 | ||
|
|
8a9f631973 | ||
|
|
b694dc1525 | ||
|
|
0c0e5a3794 | ||
|
|
36e2baf081 | ||
|
|
6e346073e7 | ||
|
|
06b53be5d8 | ||
|
|
12f858fbfe | ||
|
|
690d214caa | ||
|
|
ead72c11f5 | ||
|
|
d1c9f88c1d | ||
|
|
b4ce65bb41 | ||
|
|
e3d214ba9c | ||
|
|
8df5a484fc | ||
|
|
fc31e0af0a | ||
|
|
b8cc120da3 | ||
|
|
80502da80f | ||
|
|
8e547d06ec | ||
|
|
7fb10b1dcb | ||
|
|
850f0820ac | ||
|
|
17adc44a63 | ||
|
|
7d8232dc66 | ||
|
|
a2debc08b4 | ||
|
|
6949bbb883 | ||
|
|
2297b81640 | ||
|
|
d3bf771de0 | ||
|
|
a426bf01ea | ||
|
|
f8221ec2da | ||
|
|
d27ff76921 | ||
|
|
20be4054c1 | ||
|
|
23e71aab14 | ||
|
|
a34f547fa5 | ||
|
|
a14feef8c1 | ||
|
|
d6a5128aa8 | ||
|
|
9d0ee03f38 | ||
|
|
37927e698a | ||
|
|
89cdb1f180 | ||
|
|
e90afb286a | ||
|
|
fb4d01ebce | ||
|
|
9e481fe5e5 | ||
|
|
0b7d0039f7 | ||
|
|
5257c7eae1 | ||
|
|
952116d327 | ||
|
|
c5c07e7dfe | ||
|
|
e02bbdcf0b | ||
|
|
69f0840feb | ||
|
|
ea04f19709 | ||
|
|
9fe38b1677 | ||
|
|
6e6a989159 | ||
|
|
b52cfd87c3 | ||
|
|
516425b44b | ||
|
|
6a65769060 | ||
|
|
0a41af5dd2 | ||
|
|
267c399047 | ||
|
|
f824295b7e | ||
|
|
341c453657 | ||
|
|
c5c7f70ad6 | ||
|
|
440e3a0094 | ||
|
|
53e8226f0a | ||
|
|
7a1621a906 | ||
|
|
9e0c84beed | ||
|
|
5f9b49d66f | ||
|
|
93d0ee44ba | ||
|
|
fc35ac5737 | ||
|
|
2b90d7b3b9 | ||
|
|
f2a1c9a2be | ||
|
|
0714582eb9 | ||
|
|
5288549744 | ||
|
|
05d6f08a0d | ||
|
|
6f1266fd0e | ||
|
|
9792466aac | ||
|
|
52c255bcc2 | ||
|
|
badf107991 | ||
|
|
f872a5180c | ||
|
|
4f2b052016 | ||
|
|
ef5b3de5e2 | ||
|
|
ff7b09eb99 | ||
|
|
1baafbb6cf | ||
|
|
17ce9f4c52 | ||
|
|
e6ce7ecdc1 | ||
|
|
36ba1350a9 | ||
|
|
582bf7d2c6 | ||
|
|
46fd04364d | ||
|
|
747f6ac476 | ||
|
|
6e781eafae | ||
|
|
325199d445 | ||
|
|
e602dc849a | ||
|
|
ad0a222df1 | ||
|
|
6d6372ccf0 | ||
|
|
92955c02a4 | ||
|
|
e5a80e3b1b | ||
|
|
fb48bd0cb9 | ||
|
|
db578d9d11 | ||
|
|
8d2ef6df06 | ||
|
|
5db115057e | ||
|
|
be722a0500 | ||
|
|
e3985002c2 | ||
|
|
fcbb355f47 | ||
|
|
06ba62b833 | ||
|
|
ea5e0d47a5 | ||
|
|
8378949e1d | ||
|
|
78cde99469 | ||
|
|
a5195a6eee | ||
|
|
fb9816659b | ||
|
|
f6f7ba68c9 | ||
|
|
1b6ad46462 | ||
|
|
ad32fec1d0 | ||
|
|
c603f42c88 | ||
|
|
ed7e7c7e53 | ||
|
|
3941692665 | ||
|
|
8775ceb0c1 | ||
|
|
11fb858ff2 | ||
|
|
4fa794a639 | ||
|
|
1fe2d8dae6 | ||
|
|
bfeac07c4c | ||
|
|
b6c0f4e5db | ||
|
|
ba337bd329 | ||
|
|
7e7057eccd | ||
|
|
cbcd32064c | ||
|
|
d2d7ebeed9 | ||
|
|
9f72c5c455 | ||
|
|
e050e209b0 | ||
|
|
29d25aed66 | ||
|
|
063aba1b6c | ||
|
|
e8031efd2d | ||
|
|
102857a7fe | ||
|
|
db7b51ac80 | ||
|
|
d2166f2f4f | ||
|
|
07145accba | ||
|
|
44f9fe7a14 | ||
|
|
d522d2cd18 | ||
|
|
4e650b72cd | ||
|
|
a6072adcd0 | ||
|
|
0d193ebac2 | ||
|
|
ede1a470dd | ||
|
|
24779e3476 | ||
|
|
67e1a02525 | ||
|
|
7ce5b581aa | ||
|
|
40f41477e7 | ||
|
|
382c89c49e | ||
|
|
7d63a9a0f2 | ||
|
|
93af062a9b | ||
|
|
758c56647d | ||
|
|
845e03e48a | ||
|
|
dec5ca8b3a | ||
|
|
1e656c2114 | ||
|
|
ba76533c2f | ||
|
|
30d13d6e5e | ||
|
|
49fe147921 | ||
|
|
7a0964ebc9 | ||
|
|
b35cdc3474 | ||
|
|
5e55ef0454 | ||
|
|
3e3cefff1b | ||
|
|
97c74852c9 | ||
|
|
8542b35706 | ||
|
|
1eda69a50a | ||
|
|
16cecd917e | ||
|
|
1accb8b66b | ||
|
|
a3682b5fc7 | ||
|
|
cfea8b76e2 | ||
|
|
9e28818c53 | ||
|
|
bfbbfcda37 | ||
|
|
e4e4f16ec8 | ||
|
|
75c3c5e57f | ||
|
|
6a15328001 | ||
|
|
b960775160 | ||
|
|
41ba32db49 | ||
|
|
83ff4a7684 | ||
|
|
98b5a60886 | ||
|
|
b34723db36 | ||
|
|
5e9d53f0aa | ||
|
|
8c7961de1f | ||
|
|
ff466f817f | ||
|
|
c7a7c3d8c2 | ||
|
|
7540a0d02f | ||
|
|
33d5f0595d | ||
|
|
c4510d7382 | ||
|
|
e670524241 | ||
|
|
75f8ed07ed | ||
|
|
0706a5e039 | ||
|
|
32329695d0 | ||
|
|
cdbd55320a | ||
|
|
7b199f898e | ||
|
|
0462b94f5a | ||
|
|
85460cd5ce | ||
|
|
420a9f2ff6 | ||
|
|
5cf4547fe0 | ||
|
|
2af65cf28e | ||
|
|
e9f74f0958 | ||
|
|
2567b4c39e | ||
|
|
ac1671609c | ||
|
|
8da5fa663d | ||
|
|
3d3c68b54a | ||
|
|
5e60f1dc8d | ||
|
|
a1f74a312a | ||
|
|
b21167b326 | ||
|
|
be17e698a1 | ||
|
|
7392557af2 | ||
|
|
360be57079 | ||
|
|
361f9eef8b | ||
|
|
5b82d7e4df | ||
|
|
9daeba1f0a | ||
|
|
2d5e7bf9ff | ||
|
|
b5bef45cb2 | ||
|
|
f1f73bd464 | ||
|
|
23b709cee2 | ||
|
|
31bdf02a72 | ||
|
|
26ef565c8a | ||
|
|
6f3524b251 | ||
|
|
318dc04132 | ||
|
|
bf354f2121 | ||
|
|
1667ba64ac | ||
|
|
a28a5baf17 | ||
|
|
c33946ffc3 | ||
|
|
0d30eb553b | ||
|
|
43a5b24dcc | ||
|
|
3c46cfb2e9 | ||
|
|
7972950c84 | ||
|
|
060ae99cff | ||
|
|
b7847d8203 | ||
|
|
c586df8242 | ||
|
|
7cf01e9882 | ||
|
|
2d6b00bc69 | ||
|
|
9055eccae9 | ||
|
|
2046c05fd4 | ||
|
|
902db5ea5b | ||
|
|
60c37c11d0 | ||
|
|
61864f0fb2 | ||
|
|
240ab43f2d | ||
|
|
2713125768 | ||
|
|
ed75553603 | ||
|
|
e48cd22ab7 | ||
|
|
1e7e00e8dd | ||
|
|
02666e9139 | ||
|
|
47d73eb23a | ||
|
|
120a5b43e9 | ||
|
|
e8a5521a4c | ||
|
|
329b8c9c27 | ||
|
|
0e19c227fc | ||
|
|
e868a38c09 | ||
|
|
301cb331d5 | ||
|
|
bcec40f84a | ||
|
|
62e18a65f3 | ||
|
|
b8e6385e05 | ||
|
|
b65cc0ee74 | ||
|
|
eaafc7c28a | ||
|
|
2128e8323c | ||
|
|
06f44533af | ||
|
|
5d059b7bcb | ||
|
|
6eae15fb5e | ||
|
|
2abf55ccf8 | ||
|
|
803e9060ea | ||
|
|
7170215e96 | ||
|
|
758ab34747 | ||
|
|
c3b4628a1d | ||
|
|
e7f8de1e94 | ||
|
|
1c75eef16d | ||
|
|
c88f630299 | ||
|
|
ef6524b93f | ||
|
|
9822c75497 | ||
|
|
56746ded19 | ||
|
|
0b62aefbb2 | ||
|
|
e234f42e1f | ||
|
|
72428897a7 | ||
|
|
39c007d211 | ||
|
|
5e71cf2cc8 | ||
|
|
146b6c3860 | ||
|
|
2b08c688d3 | ||
|
|
0a20e4a455 | ||
|
|
08ac795bb3 | ||
|
|
a2027d79e8 | ||
|
|
1dde41f7ea | ||
|
|
e23d300fd9 | ||
|
|
32c341c7f5 | ||
|
|
9eea58eba4 | ||
|
|
ec13ebd3ad | ||
|
|
9fad883ae7 | ||
|
|
bc6eccf615 | ||
|
|
6905c10a05 | ||
|
|
1d176f4358 | ||
|
|
226945cae0 | ||
|
|
7d63a87aad | ||
|
|
f44ea32bca | ||
|
|
7803a166d2 | ||
|
|
ba45445544 | ||
|
|
1a9c79bb2f | ||
|
|
d891320314 | ||
|
|
639e3bb6fd | ||
|
|
7dc92d4b6c | ||
|
|
0687195aca | ||
|
|
0affca4fe4 | ||
|
|
f8ff89d442 | ||
|
|
66404cc9f4 | ||
|
|
d943b31372 | ||
|
|
b370b80dfb | ||
|
|
51f364a1d5 | ||
|
|
53208ae06f | ||
|
|
9227e139de | ||
|
|
71e163fcc3 | ||
|
|
5b907f2f77 | ||
|
|
acba533397 | ||
|
|
9bd20afb9c | ||
|
|
32e10d89f4 | ||
|
|
80b00b6254 | ||
|
|
c2c6b1991c | ||
|
|
1f56496869 | ||
|
|
b581dc6994 | ||
|
|
1ce45c8c41 | ||
|
|
a6b36051f0 | ||
|
|
d78c6facab | ||
|
|
8d58048f34 | ||
|
|
f54719fb89 | ||
|
|
9907005256 | ||
|
|
cc61f67215 | ||
|
|
23d8cea4e2 | ||
|
|
8aef77e57e | ||
|
|
c6958c4991 | ||
|
|
e052988663 | ||
|
|
563514c446 | ||
|
|
95279b02af | ||
|
|
e290057e21 | ||
|
|
61cf5094c7 | ||
|
|
9537ef05d5 | ||
|
|
3f76586a50 | ||
|
|
6f6e7c3ee0 | ||
|
|
2124d3450c | ||
|
|
b6186623f0 | ||
|
|
3220b27c8a | ||
|
|
98b977030e | ||
|
|
1fdc427358 | ||
|
|
9a74b015dc | ||
|
|
861cba63b3 | ||
|
|
59d0eb4534 | ||
|
|
57ee303cda | ||
|
|
4690fe1a55 | ||
|
|
826547a8e1 | ||
|
|
7047aef7f7 | ||
|
|
d82d782bac | ||
|
|
6351f4618e | ||
|
|
2c52c9884c | ||
|
|
5e47a21673 | ||
|
|
5ca4cf4acd | ||
|
|
267d800122 | ||
|
|
790373b896 | ||
|
|
ca6493528d | ||
|
|
1e77893f75 | ||
|
|
82a026e02e | ||
|
|
988984a1ae | ||
|
|
5859947207 | ||
|
|
b1508ae52f | ||
|
|
152ad12508 | ||
|
|
36e452b0f3 | ||
|
|
2a9bbb21ba | ||
|
|
f7e7115f79 | ||
|
|
6e04f7b03e | ||
|
|
2a1ab78f7a | ||
|
|
e5d02c4b95 | ||
|
|
69f7282952 | ||
|
|
b8c3104aa1 | ||
|
|
22e13c52b9 | ||
|
|
32e2637594 | ||
|
|
928635f1da | ||
|
|
a353acc8e3 | ||
|
|
b5d4a253b4 | ||
|
|
7267d27779 | ||
|
|
f2e5d59c35 | ||
|
|
1558a5d8ac | ||
|
|
87cee1aea6 | ||
|
|
0c2cc10ad8 | ||
|
|
84cc92f91a | ||
|
|
a6bfab7934 | ||
|
|
848f250dbb | ||
|
|
c078ccb136 | ||
|
|
67d3f05fd7 | ||
|
|
cc31ceabd1 | ||
|
|
c5d30b585b | ||
|
|
f1615a255c | ||
|
|
a2ce3230ac | ||
|
|
bd1f6d24a1 | ||
|
|
9837488f44 | ||
|
|
b620a21d46 | ||
|
|
2139e7a794 | ||
|
|
e0a0f868c0 | ||
|
|
ab726061b7 | ||
|
|
074b2e20e0 | ||
|
|
2b460d3a5f | ||
|
|
c09aeecd95 | ||
|
|
9ad7c70f24 | ||
|
|
64778650d3 | ||
|
|
eb606a5b98 | ||
|
|
669a16f13a | ||
|
|
b2d3bbce3c | ||
|
|
12cb6e135d | ||
|
|
d79e71b13e | ||
|
|
dbda1481ec | ||
|
|
d12b117b43 | ||
|
|
e2f19f19b4 | ||
|
|
618b318214 | ||
|
|
9f12ebb54e | ||
|
|
db00759a03 | ||
|
|
5f601873fe | ||
|
|
1fdf0a0a47 | ||
|
|
3bb4af253a | ||
|
|
076ada2325 | ||
|
|
5c3f71292d | ||
|
|
02292590ee | ||
|
|
b09ae10588 | ||
|
|
e5fac76060 | ||
|
|
17ac9cf8e7 | ||
|
|
063d4620d2 | ||
|
|
e06848db8c | ||
|
|
c82f93230d | ||
|
|
13e4937db2 | ||
|
|
58c856bbd0 | ||
|
|
df1913bc56 | ||
|
|
37f80dfb69 | ||
|
|
8fe76e090f | ||
|
|
15d845bf92 | ||
|
|
22e1480f74 | ||
|
|
5dd1bfb8a6 | ||
|
|
b1c7fd0033 | ||
|
|
2314147d0e | ||
|
|
ee4becb64e | ||
|
|
a5bb49aa71 | ||
|
|
d289194a71 | ||
|
|
7ad51ce662 | ||
|
|
951bbd3576 | ||
|
|
8a54a01583 | ||
|
|
e7a3f0da0a | ||
|
|
2ae19a7c42 | ||
|
|
da2a6974e0 | ||
|
|
d3de77da13 | ||
|
|
28650daf83 | ||
|
|
cedc0623f7 | ||
|
|
3efc2b237b | ||
|
|
03b5b0043a | ||
|
|
70a65c7dcf | ||
|
|
eb8685682f | ||
|
|
e642f7c86a | ||
|
|
c6ca472ca1 | ||
|
|
0540e4fbb4 | ||
|
|
9c72a768f2 | ||
|
|
8c0476612f | ||
|
|
afd80449b3 | ||
|
|
d989061122 | ||
|
|
48d77e4b5e | ||
|
|
63c3bb4166 | ||
|
|
403d3c090d | ||
|
|
9283c4b497 | ||
|
|
0d46354c48 | ||
|
|
13a108ddbc | ||
|
|
5524e881e6 | ||
|
|
a33c29f10a | ||
|
|
fc02071c8f | ||
|
|
06556d44f3 | ||
|
|
13084d22a7 | ||
|
|
14787d3a6a | ||
|
|
e2da2fa8d3 | ||
|
|
6dade8b11f | ||
|
|
557b17ad5b | ||
|
|
64222d231f | ||
|
|
b7fa722109 | ||
|
|
4a1d983168 |
@@ -1,2 +0,0 @@
|
||||
node_modules/*
|
||||
test/
|
||||
@@ -1,63 +0,0 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
'jest/globals': true,
|
||||
es6: true,
|
||||
node: true,
|
||||
},
|
||||
extends: ['eslint:recommended', 'plugin:jest/recommended', 'plugin:jest/style', 'prettier'],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2018,
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
'require-jsdoc': 'off',
|
||||
'no-prototype-builtins': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
},
|
||||
plugins: ['jest', 'perfectionist'],
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.ts'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: ['plugin:@typescript-eslint/recommended'],
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'error',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
'no-return-await': 'error',
|
||||
'perfectionist/sort-imports': [
|
||||
'error',
|
||||
{
|
||||
groups: [
|
||||
'type',
|
||||
['builtin', 'external'],
|
||||
'internal-type',
|
||||
'internal',
|
||||
['parent-type', 'sibling-type', 'index-type'],
|
||||
['parent', 'sibling', 'index'],
|
||||
'object',
|
||||
'unknown',
|
||||
],
|
||||
customGroups: {
|
||||
value: {},
|
||||
type: {},
|
||||
},
|
||||
newlinesBetween: 'always',
|
||||
internalPattern: ['~/**'],
|
||||
type: 'natural',
|
||||
order: 'asc',
|
||||
ignoreCase: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -2,19 +2,25 @@ blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 'IMPORTANT: Check development branch changelog first!!'
|
||||
url: https://gist.github.com/Koenkk/bfd4c3d1725a2cccacc11d6ba51008ba
|
||||
about: "Before submitting an issue, check that the issue hasn't already been solved in the development branch. Click 'Open' to see the release notes of the development branch. In case it is, you can read here how to switch to the development branch: https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html"
|
||||
- name: Questions/discussion
|
||||
about: Before submitting an issue, check that it has not already been solved in the development branch. Click here to see the release notes of the development branch.
|
||||
- name: Switch to the development branch
|
||||
url: https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html
|
||||
about: If the development branch solves an issue you are facing, click here to see the instructions to switch to the development branch.
|
||||
- name: Questions/discussions
|
||||
url: https://github.com/Koenkk/zigbee2mqtt/discussions/new
|
||||
about: Ask questions, discuss about devices or show things you made
|
||||
- name: Frontend issues
|
||||
url: https://github.com/nurikk/z2m-frontend/issues
|
||||
about: Issues related to the frontend.
|
||||
about: Ask questions, discuss devices, show things you made...
|
||||
- name: Original frontend issues
|
||||
url: https://github.com/nurikk/zigbee2mqtt-frontend/issues
|
||||
about: Issues with the frontend package zigbee2mqtt-frontend
|
||||
- name: WindFront frontend issues
|
||||
url: https://github.com/Nerivec/zigbee2mqtt-windfront/issues
|
||||
about: Issues with the frontend package zigbee2mqtt-windfront
|
||||
- name: Home Assistant addon issues
|
||||
url: https://github.com/zigbee2mqtt/hassio-zigbee2mqtt/issues
|
||||
about: Issues related to the Home Assistant addon.
|
||||
about: Issues with the Home Assistant addon
|
||||
- name: FAQ
|
||||
url: https://www.zigbee2mqtt.io/guide/faq
|
||||
about: Frequently asked questions.
|
||||
about: Frequently asked questions
|
||||
- name: Support Chat
|
||||
url: https://discord.gg/NyseBeK
|
||||
about: Chat for feedback, questions and troubleshooting.
|
||||
about: Chat for feedback, questions and troubleshooting
|
||||
|
||||
@@ -27,6 +27,14 @@ body:
|
||||
placeholder: '{"id":53,"type":"Router","ieeeAddr":"0x10458d00024284f69","nwkAddr":10148,"manufId":4151,"manufName":"LUMI","powerSource":"DC Source","modelId":"lumi.relay.c2acn01","epList":[1,2],"endpoints":{"1":{"profId":260,"epId":1,"devId":257,"inClusterList":[0,3,4,5,1,2,10,6,16,2820,12],"outClusterList":[25,10],"clusters":{"genBasic":{"attributes":{"modelId":"lumi.relay.c2acn01","appVersion":1,"manufacturerName":"LUMI","powerSource":4,"zclVersion":0,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020"}},"genAnalogInput":{"attributes":{"presentValue":129.04425048828125}},"genOnOff":{"attributes":{"61440":117440715,"onOff":1}}},"binds":[],"configuredReportings":[],"meta":{}},"2":{"profId":260,"epId":2,"devId":257,"inClusterList":[6,16,4,5],"outClusterList":[],"clusters":{"genOnOff":{"attributes":{"61440":237478966,"onOff":0}}},"binds":[],"configuredReportings":[],"meta":{}}},"appVersion":1,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020","zclVersion":0,"interviewCompleted":true,"meta":{},"lastSeen":1640285631405}'
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: z2m_version
|
||||
attributes:
|
||||
label: Zigbee2MQTT version
|
||||
description: Can be found in the frontend -> settings -> about -> Zigbee2MQTT version. Are you running Zigbee2MQTT 1.18.1? Then read [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0).
|
||||
placeholder: '1.22.1'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
|
||||
@@ -6,9 +6,10 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
**IMPORTANT:** Before submitting:
|
||||
- Zigbee2MQTT fails to start? Read [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html)
|
||||
- Raspberry Pi users? Make sure to check [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html#raspberry-pi-users-use-a-good-power-supply)
|
||||
- You read the [FAQ](https://www.zigbee2mqtt.io/guide/faq/)
|
||||
- Are you using an EZSP adapter (e.g. Dongle-E/SkyConnect)? Try the [new driver](https://github.com/Koenkk/zigbee2mqtt/discussions/21462)
|
||||
- Zigbee2MQTT fails to start? Read [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html)
|
||||
- Make sure the bug also occurs in the [dev branch](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html)
|
||||
- Make sure you are using the [latest firmware](https://www.zigbee2mqtt.io/guide/adapters/#recommended) on your adapter
|
||||
- The issue has not been [reported already](https://github.com/Koenkk/zigbee2mqtt/issues)
|
||||
@@ -58,14 +59,22 @@ body:
|
||||
placeholder: Electrolama zig-a-zig-ah! (zzh!), Slaesh's CC2652RB stick, ...
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
- type: textarea
|
||||
id: setup
|
||||
attributes:
|
||||
label: Setup
|
||||
description: How do you run Z2M (plain, add-on...) and on what machine (Pi, x86-64, containerized...)?
|
||||
description: |-
|
||||
How do you run Z2M (plain, add-on...) and on what machine (Pi, x86-64, containerized...)?
|
||||
When running on Linux also include output of: `uname -a && cat /etc/issue.net`
|
||||
placeholder: Add-on on Home Assistant OS on Intel NUC, Plain on Docker container, ...
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: Device `database.db` entry
|
||||
description: If the problem is related to a specific device, paste the `database.db` entry here
|
||||
placeholder: '{"id":117,"type":"EndDevice","ieeeAddr":"0x00158d0001d8e1d2","nwkAddr":25887 ...'
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes:
|
||||
|
||||
+20
-7
@@ -1,13 +1,26 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
- package-ecosystem: npm
|
||||
versioning-strategy: increase
|
||||
directory: /
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
interval: weekly
|
||||
target-branch: dev
|
||||
|
||||
- package-ecosystem: 'docker'
|
||||
directory: '/docker'
|
||||
commit-message:
|
||||
prefix: fix(ignore)
|
||||
groups:
|
||||
minor-patch:
|
||||
applies-to: version-updates
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
- package-ecosystem: docker
|
||||
directory: /docker
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
interval: weekly
|
||||
target-branch: dev
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
target-branch: dev
|
||||
|
||||
+142
-56
@@ -1,6 +1,13 @@
|
||||
name: ci
|
||||
name: CI
|
||||
|
||||
on: [pull_request, push]
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -9,74 +16,147 @@ permissions:
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # for npm publish
|
||||
actions: read # for Nerivec/action-ci-bench
|
||||
pull-requests: write # for Nerivec/action-ci-bench
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
with:
|
||||
# Required for `release: merge dev -> master and promote dev`
|
||||
token: ${{secrets.GH_TOKEN}}
|
||||
- uses: actions/checkout@v4
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
if: ((github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push') == false
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
registry-url: https://registry.npmjs.org/
|
||||
cache: npm
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Check
|
||||
run: pnpm run check
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Lint
|
||||
run: |
|
||||
npm run pretty:check
|
||||
npm run eslint
|
||||
run: pnpm run build
|
||||
|
||||
- name: Test
|
||||
run: npm run test-with-coverage
|
||||
- name: Docker login
|
||||
run: pnpm run test:coverage
|
||||
|
||||
- name: Bench
|
||||
if: github.ref == 'refs/heads/dev' || (github.event_name == 'pull_request' && !startsWith(github.head_ref, 'release-please-'))
|
||||
uses: Nerivec/action-ci-bench@main
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
compare-against: dev
|
||||
base-cmd: npx vitest bench --run --config ./test/vitest.config.mts --outputJson bench.json
|
||||
compare-cmd: npx vitest bench --run --config ./test/vitest.config.mts --compare bench.json
|
||||
base-result: bench.json
|
||||
|
||||
- name: Log in to the Docker container registry
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
run: echo ${{ secrets.DOCKER_KEY }} | docker login -u koenkk --password-stdin
|
||||
- name: Docker login ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: koenkk
|
||||
password: ${{ secrets.DOCKER_KEY }}
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
run: echo ${{ secrets.GH_TOKEN }} | docker login ghcr.io -u koenkk --password-stdin
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: koenkk
|
||||
password: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Docker setup - QEMU
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: all
|
||||
|
||||
- name: Docker setup - Buildx
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
version: latest
|
||||
- name: 'dev: Docker build'
|
||||
|
||||
- name: dev - Docker build and push
|
||||
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/riscv64,linux/386
|
||||
tags: koenkk/zigbee2mqtt:latest-dev,ghcr.io/koenkk/zigbee2mqtt:latest-dev
|
||||
push: true
|
||||
build-args: |
|
||||
COMMIT=${{ github.sha }}
|
||||
VERSION=dev
|
||||
DATE=${{ github.event.repository.updated_at }}
|
||||
|
||||
- name: dev - Publish types
|
||||
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
|
||||
run: |
|
||||
docker buildx build \
|
||||
--build-arg COMMIT=$(git rev-parse --short HEAD) \
|
||||
--platform linux/arm64/v8,linux/386,linux/amd64,linux/arm/v6,linux/arm/v7 \
|
||||
-f docker/Dockerfile \
|
||||
--provenance=false \
|
||||
--push \
|
||||
-t koenkk/zigbee2mqtt:latest-dev -t ghcr.io/koenkk/zigbee2mqtt:latest-dev \
|
||||
.
|
||||
- name: 'release: Docker build'
|
||||
pnpm run build:types
|
||||
temp_dir=$(mktemp -d)
|
||||
cp -R package.json LICENSE dist $temp_dir
|
||||
git fetch --unshallow
|
||||
git checkout dev-types
|
||||
rm -rf *
|
||||
cp -R $temp_dir/* .
|
||||
# remove unnecessary dependencies
|
||||
KEEP_DEPS='["zigbee-herdsman","zigbee-herdsman-converters"]'
|
||||
tmp_pkg=$(mktemp)
|
||||
jq --indent 4 --argjson keep "$KEEP_DEPS" 'def filterDeps: (. // {}) | with_entries(select(.key as $k | $keep | index($k))); .dependencies |= filterDeps | .devDependencies |= filterDeps | .optionalDependencies |= filterDeps' package.json > "$tmp_pkg"
|
||||
mv "$tmp_pkg" package.json
|
||||
git add -A
|
||||
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git commit -m "$GITHUB_SHA" || echo 'Nothing to commit'
|
||||
git push --force --set-upstream origin dev-types
|
||||
|
||||
- name: release - Docker meta
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
run: |
|
||||
TAG="$(git describe --tags)"
|
||||
docker buildx build \
|
||||
--build-arg COMMIT=$(git rev-parse --short HEAD) \
|
||||
--platform linux/arm64/v8,linux/386,linux/amd64,linux/arm/v6,linux/arm/v7 \
|
||||
-f docker/Dockerfile \
|
||||
--provenance=false \
|
||||
--push \
|
||||
-t koenkk/zigbee2mqtt:latest -t "koenkk/zigbee2mqtt:$TAG" -t ghcr.io/koenkk/zigbee2mqtt:latest -t "ghcr.io/koenkk/zigbee2mqtt:$TAG" \
|
||||
.
|
||||
uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: |
|
||||
koenkk/zigbee2mqtt
|
||||
ghcr.io/koenkk/zigbee2mqtt
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
|
||||
- name: release - Docker build and push
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/riscv64,linux/386
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
push: true
|
||||
build-args: |
|
||||
COMMIT=${{ github.sha }}
|
||||
VERSION=${{ github.ref_name }}
|
||||
DATE=${{ github.event.repository.updated_at }}
|
||||
|
||||
- name: 'release: Publish to npm'
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
run: npm publish
|
||||
run: pnpm publish --no-git-checks
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
|
||||
- name: 'dev: Trigger zigbee2mqtt/hassio-zigbee2mqtt build'
|
||||
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
|
||||
run: |
|
||||
@@ -86,6 +166,7 @@ jobs:
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
https://api.github.com/repos/zigbee2mqtt/hassio-zigbee2mqtt/actions/workflows/ci.yml/dispatches \
|
||||
-d '{"ref":"master","inputs":{}}'
|
||||
|
||||
- name: 'release: Trigger zigbee2mqtt/hassio-zigbee2mqtt build'
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
run: |
|
||||
@@ -98,18 +179,20 @@ jobs:
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/zigbee2mqtt/hassio-zigbee2mqtt/dispatches \
|
||||
--data "{\"event_type\": \"release\", \"client_payload\": { \"version\": \"$TAG-1\"}}"
|
||||
- name: 'release: Trigger zigbee2mqtt-chart release'
|
||||
|
||||
- name: 'release: Trigger zigbee2mqtt-chart image update'
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
run: |
|
||||
TAG=${GITHUB_REF#refs/*/}
|
||||
echo "Triggering with tag '$TAG'"
|
||||
curl \
|
||||
curl -L \
|
||||
-X POST \
|
||||
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.everest-preview+json" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/Koenkk/zigbee2mqtt-chart/dispatches \
|
||||
--data "{\"event_type\": \"release\", \"client_payload\": { \"version\": \"$TAG\"}}"
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${{ secrets.GH_TOKEN }}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/Koenkk/zigbee2mqtt-chart/actions/workflows/on_zigbee2mqtt_release.yaml/dispatches \
|
||||
--data "{\"ref\": \"main\", \"inputs\": { \"zigbee2mqtt_version\": \"$TAG\"}}"
|
||||
|
||||
- name: 'release: merge dev -> master and promote dev'
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
run: |
|
||||
@@ -123,9 +206,7 @@ jobs:
|
||||
git push origin master
|
||||
git checkout dev
|
||||
jq --indent 4 ".version = \"$TAG-dev\"" package.json > package.json.tmp
|
||||
jq --indent 4 ".version = \"$TAG-dev\"" package-lock.json > package-lock.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
mv package-lock.json.tmp package-lock.json
|
||||
git add -A
|
||||
git commit -m "chore: promote to dev"
|
||||
git push origin dev
|
||||
@@ -134,20 +215,25 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node: [18, 20, 22]
|
||||
node: [20, 22, 24]
|
||||
runs-on: ${{ matrix.os }}
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
registry-url: https://registry.npmjs.org/
|
||||
cache: 'npm'
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
# --ignore-scripts prevents the serialport build which often fails on Windows
|
||||
run: npm ci --ignore-scripts
|
||||
# --ignore-scripts prevents build on Windows (only for unix-dgram, so doesn't matter, others have pre-builds)
|
||||
run: pnpm i --frozen-lockfile ${{ matrix.os == 'windows-latest' && '--ignore-scripts' || '' }}
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Test
|
||||
run: npm run test-with-coverage
|
||||
run: pnpm run test:coverage
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
name: codeql
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
CodeQL-Build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
@@ -1,4 +1,5 @@
|
||||
name: deps-review
|
||||
name: Dependency review
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
permissions:
|
||||
@@ -9,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: 'Dependency review'
|
||||
uses: actions/dependency-review-action@v4
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
fail_pr:
|
||||
fail-pr-to-master:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Fail PR to master
|
||||
@@ -1,7 +1,7 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
name: ghcr-cleanup
|
||||
name: GHCR cleanup
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
name: merge-master-to-dev
|
||||
name: Merge master to dev
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -6,10 +6,10 @@ on:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
merge_master_to_dev:
|
||||
merge-master-to-dev:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: devmasx/merge-branch@master
|
||||
with:
|
||||
type: now
|
||||
@@ -7,7 +7,7 @@ permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
name: release-please
|
||||
name: Release Please
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
@@ -16,9 +16,13 @@ jobs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
version: '${{steps.release.outputs.major}}.${{steps.release.outputs.minor}}.${{steps.release.outputs.patch}}'
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
@@ -27,11 +31,11 @@ jobs:
|
||||
token: ${{secrets.GH_TOKEN}}
|
||||
|
||||
# Checkout repos
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: koenkk/zigbee2mqtt
|
||||
path: ./z2m
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: koenkk/zigbee2mqtt
|
||||
path: ./z2m-master
|
||||
@@ -50,10 +54,11 @@ jobs:
|
||||
MASTER_ZHC_VERSION=$(cat z2m-master/package.json | jq -r '.dependencies."zigbee-herdsman-converters"')
|
||||
MASTER_ZH_VERSION=$(cat z2m-master/package.json | jq -r '.dependencies."zigbee-herdsman"')
|
||||
MASTER_FRONTEND_VERSION=$(cat z2m-master/package.json | jq -r '.dependencies."zigbee2mqtt-frontend"')
|
||||
MASTER_WINDFRONT_VERSION=$(cat z2m-master/package.json | jq -r '.dependencies."zigbee2mqtt-windfront"')
|
||||
wget -q -O - https://raw.githubusercontent.com/Koenkk/zigbee2mqtt/release-please--branches--dev--components--zigbee2mqtt/CHANGELOG.md > z2m/CHANGELOG.md
|
||||
cd z2m
|
||||
npm ci
|
||||
node scripts/generateChangelog.js $MASTER_Z2M_VERSION $MASTER_ZHC_VERSION $MASTER_ZH_VERSION $MASTER_FRONTEND_VERSION >> ../changelog.md
|
||||
pnpm i --frozen-lockfile
|
||||
node scripts/generateChangelog.mjs $MASTER_Z2M_VERSION $MASTER_ZHC_VERSION $MASTER_ZH_VERSION $MASTER_FRONTEND_VERSION $MASTER_WINDFRONT_VERSION >> ../changelog.md
|
||||
env:
|
||||
GH_TOKEN: ${{secrets.GH_TOKEN}}
|
||||
- name: Update changelog gist
|
||||
@@ -1,4 +1,4 @@
|
||||
name: stale
|
||||
name: Stale
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -12,9 +12,9 @@ jobs:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: 'This issue is stale because it has been open 180 days with no activity. Remove stale label or comment or this will be closed in 30 days'
|
||||
stale-pr-message: 'This pull request is stale because it has been open 180 days with no activity. Remove stale label or comment or this will be closed in 30 days'
|
||||
days-before-stale: 180
|
||||
days-before-close: 30
|
||||
exempt-issue-labels: dont-stale
|
||||
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days'
|
||||
stale-pr-message: 'This pull request is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days'
|
||||
days-before-stale: 60
|
||||
days-before-close: 7
|
||||
exempt-issue-labels: dont-stale,feature-request
|
||||
operations-per-run: 500
|
||||
|
||||
@@ -2,32 +2,35 @@ on:
|
||||
repository_dispatch:
|
||||
types: update_dep
|
||||
|
||||
name: update-dep
|
||||
name: Update dependency
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
update_dep:
|
||||
update-dependency:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: dev
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm install ${{ github.event.client_payload.package }}@${{ github.event.client_payload.version }} --save-exact
|
||||
- uses: peter-evans/create-pull-request@v6
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: |
|
||||
pnpm install ${{ github.event.client_payload.package }}@${{ github.event.client_payload.version }} --save-exact
|
||||
pnpm install --no-frozen-lockfile
|
||||
- uses: peter-evans/create-pull-request@v7
|
||||
id: cpr
|
||||
with:
|
||||
commit-message: 'fix(ignore): update ${{ github.event.client_payload.package }} to ${{ github.event.client_payload.version }}'
|
||||
branch: 'deps/${{ github.event.client_payload.package }}'
|
||||
title: Update ${{ github.event.client_payload.package }} to ${{ github.event.client_payload.version }}
|
||||
title: 'fix(ignore): update ${{ github.event.client_payload.package }} to ${{ github.event.client_payload.version }}'
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
- run: sleep 5 # Otherwise pull request may not exist yet causing automerge to fail
|
||||
- run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Update dependencies
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update_deps:
|
||||
permissions:
|
||||
contents: write # for peter-evans/create-pull-request to create branch
|
||||
pull-requests: write # for peter-evans/create-pull-request to create a PR
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: dev
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
# connect-gzip-static@4.0.0 requires Node 20 >=
|
||||
# eslint: https://github.com/typescript-eslint/typescript-eslint/issues/8211
|
||||
- run: npx npm-check-updates -u -x connect-gzip-static -x eslint
|
||||
- run: rm -f package-lock.json
|
||||
- run: npm install
|
||||
- uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
commit-message: 'fix(ignore): update dependencies'
|
||||
branch: 'deps/all'
|
||||
title: Update dependencies
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
@@ -45,11 +45,15 @@ typings/
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional pnpm cache directory
|
||||
.pnpm-store
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Build cache
|
||||
tsconfig.tsbuildinfo
|
||||
tsconfig.types.tsbuildinfo
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
@@ -68,10 +72,12 @@ tsconfig.tsbuildinfo
|
||||
|
||||
# IDE specific folders
|
||||
.idea
|
||||
.*.swp
|
||||
|
||||
# Ignore config
|
||||
data/*
|
||||
!data/configuration.example.yaml
|
||||
data-bench
|
||||
|
||||
# commit-user-lookup.json
|
||||
scripts/commit-user-lookup.json
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
package-lock.json
|
||||
CHANGELOG.md
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 150,
|
||||
"bracketSpacing": false,
|
||||
"endOfLine": "lf",
|
||||
"tabWidth": 4
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.40.0"
|
||||
".": "2.6.1"
|
||||
}
|
||||
|
||||
+654
@@ -1,5 +1,659 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.6.0...2.6.1) (2025-09-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Always unlink `node_modules` symlinks on stop ([#28202](https://github.com/Koenkk/zigbee2mqtt/issues/28202)) ([63a4207](https://github.com/Koenkk/zigbee2mqtt/commit/63a4207061039e4dab50f5f999febe4d87397856))
|
||||
* Home Assistant: set icon and state class for orientation sensors ([#28192](https://github.com/Koenkk/zigbee2mqtt/issues/28192)) ([b7cbd9b](https://github.com/Koenkk/zigbee2mqtt/commit/b7cbd9b5d830fc1b936208076c440bc561552220))
|
||||
* Home Assistant: use "occupancy" device_class for human presence sensors ([#28329](https://github.com/Koenkk/zigbee2mqtt/issues/28329)) ([ccfab84](https://github.com/Koenkk/zigbee2mqtt/commit/ccfab84e7f93be151e841834943d8a65500fe5d9))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#28383](https://github.com/Koenkk/zigbee2mqtt/issues/28383)) ([a8fd23b](https://github.com/Koenkk/zigbee2mqtt/commit/a8fd23b6cc97468f345216913ebb6c8f27e83789))
|
||||
* **ignore:** bump the minor-patch group with 4 updates ([#28147](https://github.com/Koenkk/zigbee2mqtt/issues/28147)) ([b72abd6](https://github.com/Koenkk/zigbee2mqtt/commit/b72abd675f409fca324b34936c1bfb6f3d8aefd4))
|
||||
* **ignore:** bump the minor-patch group with 5 updates ([#28237](https://github.com/Koenkk/zigbee2mqtt/issues/28237)) ([1dd6cc3](https://github.com/Koenkk/zigbee2mqtt/commit/1dd6cc3da54527eb5560c7edec7e56324ef38ea1))
|
||||
* **ignore:** update zigbee-herdsman to 6.0.0 ([#28235](https://github.com/Koenkk/zigbee2mqtt/issues/28235)) ([bd1be88](https://github.com/Koenkk/zigbee2mqtt/commit/bd1be886022ac13bb71828fc773df0f8f144ec8a))
|
||||
* **ignore:** update zigbee-herdsman to 6.0.1 ([#28287](https://github.com/Koenkk/zigbee2mqtt/issues/28287)) ([bed8bec](https://github.com/Koenkk/zigbee2mqtt/commit/bed8beccbc54c06430bdd97bcfb8d14158756216))
|
||||
* **ignore:** update zigbee-herdsman to 6.0.2 ([#28406](https://github.com/Koenkk/zigbee2mqtt/issues/28406)) ([264440e](https://github.com/Koenkk/zigbee2mqtt/commit/264440ebcc7c86fb6f2fbacd76a3b1bc451f01ad))
|
||||
* **ignore:** update zigbee-herdsman to 6.0.4 ([#28438](https://github.com/Koenkk/zigbee2mqtt/issues/28438)) ([7788653](https://github.com/Koenkk/zigbee2mqtt/commit/77886530fe8c6599cfa1a1a797743fc07cdee75c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 24.12.0 ([#28170](https://github.com/Koenkk/zigbee2mqtt/issues/28170)) ([9836f0e](https://github.com/Koenkk/zigbee2mqtt/commit/9836f0e27a57863bdbd98f9770ea37036ccf243c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 24.13.0 ([#28175](https://github.com/Koenkk/zigbee2mqtt/issues/28175)) ([2056b89](https://github.com/Koenkk/zigbee2mqtt/commit/2056b89baca9018254e23b02fbf0a5250314159b))
|
||||
* **ignore:** update zigbee-herdsman-converters to 24.14.0 ([#28199](https://github.com/Koenkk/zigbee2mqtt/issues/28199)) ([09f046c](https://github.com/Koenkk/zigbee2mqtt/commit/09f046c72d309ad4270e404376bcf009b3411b00))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.0.0 ([#28236](https://github.com/Koenkk/zigbee2mqtt/issues/28236)) ([3160219](https://github.com/Koenkk/zigbee2mqtt/commit/3160219824bcb12c4cbff232d2b7ec5c5a73317b))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.1.0 ([#28264](https://github.com/Koenkk/zigbee2mqtt/issues/28264)) ([2b17808](https://github.com/Koenkk/zigbee2mqtt/commit/2b1780879a62c8f1a044be7f2a525517f8af4082))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.10.0 ([#28399](https://github.com/Koenkk/zigbee2mqtt/issues/28399)) ([32d8265](https://github.com/Koenkk/zigbee2mqtt/commit/32d82651e233665e1850d86d7f8a30a1cdead507))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.11.0 ([#28407](https://github.com/Koenkk/zigbee2mqtt/issues/28407)) ([c07fce9](https://github.com/Koenkk/zigbee2mqtt/commit/c07fce936f8bbbfe6405938221c426ba8dd5eff2))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.12.0 ([#28417](https://github.com/Koenkk/zigbee2mqtt/issues/28417)) ([bdeab61](https://github.com/Koenkk/zigbee2mqtt/commit/bdeab6100195a129980cf9d0151d6eb1b849a802))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.13.0 ([#28427](https://github.com/Koenkk/zigbee2mqtt/issues/28427)) ([203019a](https://github.com/Koenkk/zigbee2mqtt/commit/203019aa26e3ee028e68d1ef9b4d032624fd7991))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.14.0 ([#28439](https://github.com/Koenkk/zigbee2mqtt/issues/28439)) ([bf22dda](https://github.com/Koenkk/zigbee2mqtt/commit/bf22ddac466db6523b659b422a36c968700c385f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.2.0 ([#28280](https://github.com/Koenkk/zigbee2mqtt/issues/28280)) ([4c97842](https://github.com/Koenkk/zigbee2mqtt/commit/4c9784255bab79d7d8a394135bfd6f5b9e40ef9b))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.3.0 ([#28301](https://github.com/Koenkk/zigbee2mqtt/issues/28301)) ([5f7c005](https://github.com/Koenkk/zigbee2mqtt/commit/5f7c00515adc8941c51b6ede219529719949a9bf))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.4.0 ([#28313](https://github.com/Koenkk/zigbee2mqtt/issues/28313)) ([384bd30](https://github.com/Koenkk/zigbee2mqtt/commit/384bd309a6d980ba1555a7dcfe13539df67699a1))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.5.0 ([#28328](https://github.com/Koenkk/zigbee2mqtt/issues/28328)) ([23f831a](https://github.com/Koenkk/zigbee2mqtt/commit/23f831a1feae67aacd5b76ac81bf3c857065f5d3))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.6.0 ([#28344](https://github.com/Koenkk/zigbee2mqtt/issues/28344)) ([4bd0c04](https://github.com/Koenkk/zigbee2mqtt/commit/4bd0c04bdcdf22e08e152d52f3f5d9a565d8f5f7))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.7.0 ([#28361](https://github.com/Koenkk/zigbee2mqtt/issues/28361)) ([7bd7bce](https://github.com/Koenkk/zigbee2mqtt/commit/7bd7bce69c4065d94a809baef80966ee07691efc))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.8.0 ([#28373](https://github.com/Koenkk/zigbee2mqtt/issues/28373)) ([8e6ba79](https://github.com/Koenkk/zigbee2mqtt/commit/8e6ba7975b292b4ba0817de8b7877fb7b9892aed))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.9.0 ([#28380](https://github.com/Koenkk/zigbee2mqtt/issues/28380)) ([0147441](https://github.com/Koenkk/zigbee2mqtt/commit/0147441c935a4a77f4fccdb59499672de64fabd5))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.20 ([#28250](https://github.com/Koenkk/zigbee2mqtt/issues/28250)) ([800b85c](https://github.com/Koenkk/zigbee2mqtt/commit/800b85c812e57e982a689554b64cbe73aee4f192))
|
||||
* **ignore:** update zigbee2mqtt-windfront to 1.6.0 ([#28207](https://github.com/Koenkk/zigbee2mqtt/issues/28207)) ([092c95b](https://github.com/Koenkk/zigbee2mqtt/commit/092c95b7169d7e23c30db154988fb50d4099d750))
|
||||
* **ignore:** update zigbee2mqtt-windfront to 1.8.0 ([#28266](https://github.com/Koenkk/zigbee2mqtt/issues/28266)) ([f4cfcba](https://github.com/Koenkk/zigbee2mqtt/commit/f4cfcbacde8cab83e0a7ea742f39f87c2bceb1e1))
|
||||
* **ignore:** update zigbee2mqtt-windfront to 1.8.1 ([#28272](https://github.com/Koenkk/zigbee2mqtt/issues/28272)) ([d04ab53](https://github.com/Koenkk/zigbee2mqtt/commit/d04ab53f1d584aed400ab8fd0bcd985f78611cb5))
|
||||
* **ignore:** update zigbee2mqtt-windfront to 2.0.1 ([#28420](https://github.com/Koenkk/zigbee2mqtt/issues/28420)) ([08903f6](https://github.com/Koenkk/zigbee2mqtt/commit/08903f68d9a9e20994a1da2eb4835ad28ca68877))
|
||||
* Remove 60 MB (28%) of dev dependencies from Docker image ([#28160](https://github.com/Koenkk/zigbee2mqtt/issues/28160)) ([050ebfc](https://github.com/Koenkk/zigbee2mqtt/commit/050ebfc9f5dd5a2de07f5402d3c99ac8ab3b12b4))
|
||||
* Set minimum for some settings ([#28209](https://github.com/Koenkk/zigbee2mqtt/issues/28209)) ([a102fac](https://github.com/Koenkk/zigbee2mqtt/commit/a102fac2c8ab09a126fddfb0cc7170d57bf114c0))
|
||||
* Support for new ZH typing ([#28257](https://github.com/Koenkk/zigbee2mqtt/issues/28257)) ([b21f926](https://github.com/Koenkk/zigbee2mqtt/commit/b21f926b3e152176d09dc7f95fbc02b2b5ec13da))
|
||||
* Update to refactored zhc api and fix `onEvent(stop)` not being called when device is force removed ([#27984](https://github.com/Koenkk/zigbee2mqtt/issues/27984)) ([5286570](https://github.com/Koenkk/zigbee2mqtt/commit/5286570bd625a714fad432dd693308caf59344de))
|
||||
* update zigbee2mqtt-windfront to 2.0.0 ([#28389](https://github.com/Koenkk/zigbee2mqtt/issues/28389)) ([2df363f](https://github.com/Koenkk/zigbee2mqtt/commit/2df363f3af8f13c5fde91bab7ac276de3be7ee44))
|
||||
|
||||
## [2.6.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.5.1...2.6.0) (2025-08-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Allow to disable internal frontend UI serving for standalone serving ([#27851](https://github.com/Koenkk/zigbee2mqtt/issues/27851)) ([0215e76](https://github.com/Koenkk/zigbee2mqtt/commit/0215e76118c8dcbe77c1ed4890846aaf7da85bae))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* erroneous Health OS memory percent calculation ([#27892](https://github.com/Koenkk/zigbee2mqtt/issues/27892)) ([190cfe9](https://github.com/Koenkk/zigbee2mqtt/commit/190cfe99ac62b9dc6db4dc59a4389afda85e8177))
|
||||
* Exit with code 2 when adapters disconnects ([#28013](https://github.com/Koenkk/zigbee2mqtt/issues/28013)) ([0e0a561](https://github.com/Koenkk/zigbee2mqtt/commit/0e0a5618af9a1b5707ddccbca2cde983cafb40b5))
|
||||
* Fix settings schema defaults ([#27961](https://github.com/Koenkk/zigbee2mqtt/issues/27961)) ([ecadda4](https://github.com/Koenkk/zigbee2mqtt/commit/ecadda45e7c5665f9e242c956f5f140b7caa6eed))
|
||||
* Ignore symbolic links in `data` during backup ([#28132](https://github.com/Koenkk/zigbee2mqtt/issues/28132)) ([0182c4b](https://github.com/Koenkk/zigbee2mqtt/commit/0182c4b32efafc85d1f4a38840345944501feaef))
|
||||
* **ignore:** add test to specifically check HA envs ([#27969](https://github.com/Koenkk/zigbee2mqtt/issues/27969)) ([833605b](https://github.com/Koenkk/zigbee2mqtt/commit/833605b71f110b4e7aeb6632694820cdd1110e25))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#27924](https://github.com/Koenkk/zigbee2mqtt/issues/27924)) ([8ceb087](https://github.com/Koenkk/zigbee2mqtt/commit/8ceb08704f7e5d637b661c745c804beebf38eed9))
|
||||
* **ignore:** bump the minor-patch group with 4 updates ([#28046](https://github.com/Koenkk/zigbee2mqtt/issues/28046)) ([e505a93](https://github.com/Koenkk/zigbee2mqtt/commit/e505a93a4036affe9e531b24e29ee543ed4cb0f5))
|
||||
* **ignore:** fix 71cf2b41603a9923024c0472ffc1f7bfe3d14fc7 ([6dd812f](https://github.com/Koenkk/zigbee2mqtt/commit/6dd812f71cf9ca6737777c3d4acfab35c52a76b8))
|
||||
* **ignore:** fix breaking changes of https://github.com/Koenkk/zigbee2mqtt/pull/28077 ([713551b](https://github.com/Koenkk/zigbee2mqtt/commit/713551b5257f8624ed03f7901f0addf6864faf1f))
|
||||
* **ignore:** Remove retain/qos from mqtt publish logging https://github.com/Koenkk/zigbee2mqtt/pull/27983 ([e2b7df9](https://github.com/Koenkk/zigbee2mqtt/commit/e2b7df9f84550aa413f3453bb97a13ba2eee4187))
|
||||
* **ignore:** update zigbee-herdsman to 4.3.2 ([#27900](https://github.com/Koenkk/zigbee2mqtt/issues/27900)) ([4afdd4e](https://github.com/Koenkk/zigbee2mqtt/commit/4afdd4e89241eddeb27e5f6c1c7b6604ce4a41cb))
|
||||
* **ignore:** update zigbee-herdsman to 4.4.0 ([#27906](https://github.com/Koenkk/zigbee2mqtt/issues/27906)) ([e663e86](https://github.com/Koenkk/zigbee2mqtt/commit/e663e86e09cf86078877407e2233447468d66899))
|
||||
* **ignore:** update zigbee-herdsman to 4.4.1 ([#27918](https://github.com/Koenkk/zigbee2mqtt/issues/27918)) ([eb82973](https://github.com/Koenkk/zigbee2mqtt/commit/eb829730678dad354d3feede2155f142d58a140f))
|
||||
* **ignore:** update zigbee-herdsman to 4.5.0 ([#28044](https://github.com/Koenkk/zigbee2mqtt/issues/28044)) ([cc6f7b0](https://github.com/Koenkk/zigbee2mqtt/commit/cc6f7b02bc2d7548f142ec162696eae06027d6a3))
|
||||
* **ignore:** update zigbee-herdsman to 5.0.0 ([#28077](https://github.com/Koenkk/zigbee2mqtt/issues/28077)) ([9a215ef](https://github.com/Koenkk/zigbee2mqtt/commit/9a215efe26be6fafde0f0a779b651ded860cd90c))
|
||||
* **ignore:** update zigbee-herdsman to 5.0.1 ([#28080](https://github.com/Koenkk/zigbee2mqtt/issues/28080)) ([6432571](https://github.com/Koenkk/zigbee2mqtt/commit/6432571c61b6e2d4bcc17cc952eeacdaf72213f6))
|
||||
* **ignore:** update zigbee-herdsman to 5.0.2 ([#28110](https://github.com/Koenkk/zigbee2mqtt/issues/28110)) ([e9f2a3f](https://github.com/Koenkk/zigbee2mqtt/commit/e9f2a3f5326954f361199bd4d73ed944f72f6ebe))
|
||||
* **ignore:** update zigbee-herdsman to 5.0.3 ([#28121](https://github.com/Koenkk/zigbee2mqtt/issues/28121)) ([e5f677b](https://github.com/Koenkk/zigbee2mqtt/commit/e5f677bdd1cc3df4d05dd1f8164220ae2258f081))
|
||||
* **ignore:** update zigbee-herdsman to 5.0.4 ([#28141](https://github.com/Koenkk/zigbee2mqtt/issues/28141)) ([9fcc41b](https://github.com/Koenkk/zigbee2mqtt/commit/9fcc41b199082e47d96de4cad996c55c39da83a5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.70.0 ([#27905](https://github.com/Koenkk/zigbee2mqtt/issues/27905)) ([163a15d](https://github.com/Koenkk/zigbee2mqtt/commit/163a15d664432631f100814927647b918563d85b))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.70.1 ([#27916](https://github.com/Koenkk/zigbee2mqtt/issues/27916)) ([e047324](https://github.com/Koenkk/zigbee2mqtt/commit/e04732462c218cd42c2b617aefba37a5f61d1ce6))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.71.0 ([#27931](https://github.com/Koenkk/zigbee2mqtt/issues/27931)) ([93c1824](https://github.com/Koenkk/zigbee2mqtt/commit/93c1824b1d81c997f0e5a445acac421300f55758))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.72.0 ([#27943](https://github.com/Koenkk/zigbee2mqtt/issues/27943)) ([b4a9424](https://github.com/Koenkk/zigbee2mqtt/commit/b4a9424d604c47609071ef33e88ee55a55a6c8d5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 24.0.0 ([#27946](https://github.com/Koenkk/zigbee2mqtt/issues/27946)) ([c465ef8](https://github.com/Koenkk/zigbee2mqtt/commit/c465ef83526c434cac6555836d82e7707072b754))
|
||||
* **ignore:** update zigbee-herdsman-converters to 24.1.0 ([#27973](https://github.com/Koenkk/zigbee2mqtt/issues/27973)) ([a837702](https://github.com/Koenkk/zigbee2mqtt/commit/a837702b2c1f517dcfa3a0f578d9b5b3700b171d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 24.10.0 ([#28122](https://github.com/Koenkk/zigbee2mqtt/issues/28122)) ([ee1c884](https://github.com/Koenkk/zigbee2mqtt/commit/ee1c884031fdef516393ce68754a9dbcc8ad093d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 24.11.0 ([#28143](https://github.com/Koenkk/zigbee2mqtt/issues/28143)) ([3765549](https://github.com/Koenkk/zigbee2mqtt/commit/376554943d15c45a15d30713002d825286204581))
|
||||
* **ignore:** update zigbee-herdsman-converters to 24.2.0 ([#27986](https://github.com/Koenkk/zigbee2mqtt/issues/27986)) ([6ede403](https://github.com/Koenkk/zigbee2mqtt/commit/6ede4036886066bf91953b13714a16ba96217b6d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 24.3.0 ([#28015](https://github.com/Koenkk/zigbee2mqtt/issues/28015)) ([21fa5cb](https://github.com/Koenkk/zigbee2mqtt/commit/21fa5cbdeddb51d55364c4d28b887791384a951b))
|
||||
* **ignore:** update zigbee-herdsman-converters to 24.4.0 ([#28022](https://github.com/Koenkk/zigbee2mqtt/issues/28022)) ([74e9357](https://github.com/Koenkk/zigbee2mqtt/commit/74e9357a5989c6b1eeb25aa91c0da27b7b54fa67))
|
||||
* **ignore:** update zigbee-herdsman-converters to 24.5.0 ([#28045](https://github.com/Koenkk/zigbee2mqtt/issues/28045)) ([bc74d0b](https://github.com/Koenkk/zigbee2mqtt/commit/bc74d0b11b58bb2cb2bcc07f5ae9c47b1ca7a13e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 24.6.0 ([#28051](https://github.com/Koenkk/zigbee2mqtt/issues/28051)) ([4bc6a59](https://github.com/Koenkk/zigbee2mqtt/commit/4bc6a59ff38f9a102259afbff2606a75409042a3))
|
||||
* **ignore:** update zigbee-herdsman-converters to 24.7.0 ([#28070](https://github.com/Koenkk/zigbee2mqtt/issues/28070)) ([81bafa5](https://github.com/Koenkk/zigbee2mqtt/commit/81bafa55201d6d7a34b364a85c19423a56eceeee))
|
||||
* **ignore:** update zigbee-herdsman-converters to 24.8.0 ([#28081](https://github.com/Koenkk/zigbee2mqtt/issues/28081)) ([caf91cd](https://github.com/Koenkk/zigbee2mqtt/commit/caf91cd7ffb8ae4961c3f43875f051b182f1a379))
|
||||
* **ignore:** update zigbee-herdsman-converters to 24.9.0 ([71cf2b4](https://github.com/Koenkk/zigbee2mqtt/commit/71cf2b41603a9923024c0472ffc1f7bfe3d14fc7))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.17 ([#27917](https://github.com/Koenkk/zigbee2mqtt/issues/27917)) ([a3e7f69](https://github.com/Koenkk/zigbee2mqtt/commit/a3e7f69b3ff6173a59b4d5e2ad1f09c2c414b416))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.19 ([#28021](https://github.com/Koenkk/zigbee2mqtt/issues/28021)) ([8b87682](https://github.com/Koenkk/zigbee2mqtt/commit/8b876823da7fbde4f980fefa54cec7c3fa52c423))
|
||||
* **ignore:** update zigbee2mqtt-windfront to 1.3.0 ([#27953](https://github.com/Koenkk/zigbee2mqtt/issues/27953)) ([1b37bae](https://github.com/Koenkk/zigbee2mqtt/commit/1b37baefd3e6817fc9dbf36c9ec9e8aec4481643))
|
||||
* **ignore:** update zigbee2mqtt-windfront to 1.3.1 ([#27999](https://github.com/Koenkk/zigbee2mqtt/issues/27999)) ([a51216e](https://github.com/Koenkk/zigbee2mqtt/commit/a51216e3c76335b66a9383b1734a213ad948d70a))
|
||||
* **ignore:** update zigbee2mqtt-windfront to 1.4.1 ([#28062](https://github.com/Koenkk/zigbee2mqtt/issues/28062)) ([d322dc2](https://github.com/Koenkk/zigbee2mqtt/commit/d322dc20479faffa8f923e2190c1a5f82d17d5e4))
|
||||
* **ignore:** update zigbee2mqtt-windfront to 1.5.0 ([#28082](https://github.com/Koenkk/zigbee2mqtt/issues/28082)) ([29e56ad](https://github.com/Koenkk/zigbee2mqtt/commit/29e56adc159268038727d955135d3edbedbaae8b))
|
||||
* **ignore:** update zigbee2mqtt-windfront to 1.5.1 ([#28123](https://github.com/Koenkk/zigbee2mqtt/issues/28123)) ([31d3e19](https://github.com/Koenkk/zigbee2mqtt/commit/31d3e19f3862b067555bcbc9bbcd3a5ded6df211))
|
||||
* **ignore:** update zigbee2mqtt-windfront to 1.5.2 ([#28140](https://github.com/Koenkk/zigbee2mqtt/issues/28140)) ([fe5639c](https://github.com/Koenkk/zigbee2mqtt/commit/fe5639ce40cf0a4035cd996a558c952dc065fcd0))
|
||||
* Improve startup settings validation ([#27988](https://github.com/Koenkk/zigbee2mqtt/issues/27988)) ([435a6cc](https://github.com/Koenkk/zigbee2mqtt/commit/435a6cceb9aea1ac6941c99603d7785ef91e274a))
|
||||
* log error message when external JS fails to load ([#27837](https://github.com/Koenkk/zigbee2mqtt/issues/27837)) ([f4d821d](https://github.com/Koenkk/zigbee2mqtt/commit/f4d821deca71540f830f38f631d1a3759c1d0f40))
|
||||
* Publish definition source in `bridge/devices` ([#28076](https://github.com/Koenkk/zigbee2mqtt/issues/28076)) ([65eef1b](https://github.com/Koenkk/zigbee2mqtt/commit/65eef1b6900afdf831c809047ab68e93df59b4b0))
|
||||
* Publish endpoint names in `bridge/devices` ([#28057](https://github.com/Koenkk/zigbee2mqtt/issues/28057)) ([5f9019a](https://github.com/Koenkk/zigbee2mqtt/commit/5f9019a95fb460f1dc4a31d09e2424ff56049c14))
|
||||
* Set MQTT retain and qos=1 on (re)connect ([#27983](https://github.com/Koenkk/zigbee2mqtt/issues/27983)) ([ef07505](https://github.com/Koenkk/zigbee2mqtt/commit/ef07505b31782936a3ae517688c7e1eb9e541daf))
|
||||
* Various fixes to align behaviors (+add benchmarks for dev) ([#28011](https://github.com/Koenkk/zigbee2mqtt/issues/28011)) ([db20369](https://github.com/Koenkk/zigbee2mqtt/commit/db203694477ff718386a3d97ca260dccd0dc0597))
|
||||
|
||||
## [2.5.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.5.0...2.5.1) (2025-07-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ignore:** update zigbee-herdsman to 4.3.1 ([#27878](https://github.com/Koenkk/zigbee2mqtt/issues/27878)) ([917d852](https://github.com/Koenkk/zigbee2mqtt/commit/917d8527a5efcf349153ed71e8a44b6d6c87b398))
|
||||
* Various Windfront frontend improvements [@nerivec](https://github.com/nerivec) ([#27880](https://github.com/Koenkk/zigbee2mqtt/issues/27880)) ([4dd7dab](https://github.com/Koenkk/zigbee2mqtt/commit/4dd7dabf0803e3cad6e9b6aa1683a2ea8984fb0e))
|
||||
|
||||
## [2.5.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.4.0...2.5.0) (2025-07-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* New health extension & extras in `bridge/info` ([#27164](https://github.com/Koenkk/zigbee2mqtt/issues/27164)) ([cd9b752](https://github.com/Koenkk/zigbee2mqtt/commit/cd9b752ede91db1d02c567dded92c4e52c05b88d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Bump Alpine to 3.22 ([#27690](https://github.com/Koenkk/zigbee2mqtt/issues/27690)) ([f6c3887](https://github.com/Koenkk/zigbee2mqtt/commit/f6c3887c812ea6e15e950e6d55e0274356f2eea5))
|
||||
* Fix devices not immediately marked as offline when timeout expires ([#27691](https://github.com/Koenkk/zigbee2mqtt/issues/27691)) ([e4b253e](https://github.com/Koenkk/zigbee2mqtt/commit/e4b253e08beb147367872aa4517df93a03ef1a4f))
|
||||
* Generate group ID if empty string given ([#27702](https://github.com/Koenkk/zigbee2mqtt/issues/27702)) ([0e66b29](https://github.com/Koenkk/zigbee2mqtt/commit/0e66b29983ae691a2dab44f6649e20a9e00a43cd))
|
||||
* **ignore:** API correctness ([#27637](https://github.com/Koenkk/zigbee2mqtt/issues/27637)) ([018fad2](https://github.com/Koenkk/zigbee2mqtt/commit/018fad27afb5861b1d9c7adff3161c926ccbbaa4))
|
||||
* **ignore:** bump @types/node from 22.15.30 to 24.0.3 ([#27745](https://github.com/Koenkk/zigbee2mqtt/issues/27745)) ([de80045](https://github.com/Koenkk/zigbee2mqtt/commit/de800450ad31e5d8ad5c342b061dc3c59f0d565e))
|
||||
* **ignore:** bump the minor-patch group across 1 directory with 4 updates ([#27858](https://github.com/Koenkk/zigbee2mqtt/issues/27858)) ([c8dc26b](https://github.com/Koenkk/zigbee2mqtt/commit/c8dc26b241ae6562271d56fe59a13f05927b2bb4))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#27797](https://github.com/Koenkk/zigbee2mqtt/issues/27797)) ([50b0ffd](https://github.com/Koenkk/zigbee2mqtt/commit/50b0ffd7e7ef343fec983a62d2268feda28b01ba))
|
||||
* **ignore:** bump the minor-patch group with 3 updates ([#27598](https://github.com/Koenkk/zigbee2mqtt/issues/27598)) ([7e82a3d](https://github.com/Koenkk/zigbee2mqtt/commit/7e82a3d1e960d4fa2d89558b6d6a20534e7d6ecf))
|
||||
* **ignore:** bump the minor-patch group with 4 updates ([#27610](https://github.com/Koenkk/zigbee2mqtt/issues/27610)) ([ac80bb6](https://github.com/Koenkk/zigbee2mqtt/commit/ac80bb6430007e494c544089af323c3fb99c3541))
|
||||
* **ignore:** bump the minor-patch group with 6 updates ([#27678](https://github.com/Koenkk/zigbee2mqtt/issues/27678)) ([587a02f](https://github.com/Koenkk/zigbee2mqtt/commit/587a02f66319688cb93df82fde02539bce0263ea))
|
||||
* **ignore:** bump zigbee2mqtt-windfront from 1.0.5 to 1.0.6 in the minor-patch group ([#27744](https://github.com/Koenkk/zigbee2mqtt/issues/27744)) ([9776729](https://github.com/Koenkk/zigbee2mqtt/commit/977672976f06f644ed6e02e4a17c07b63873c8da))
|
||||
* **ignore:** Migrate to Biome 2 ([#27785](https://github.com/Koenkk/zigbee2mqtt/issues/27785)) ([c87d452](https://github.com/Koenkk/zigbee2mqtt/commit/c87d4524d599302a0728b85799c9bf7c0784a8f4))
|
||||
* **ignore:** update zigbee-herdsman to 4.1.1 ([#27640](https://github.com/Koenkk/zigbee2mqtt/issues/27640)) ([4b95b54](https://github.com/Koenkk/zigbee2mqtt/commit/4b95b54be7abc53a7f7a83814994f9cdde90134e))
|
||||
* **ignore:** update zigbee-herdsman to 4.1.2 ([#27729](https://github.com/Koenkk/zigbee2mqtt/issues/27729)) ([242815e](https://github.com/Koenkk/zigbee2mqtt/commit/242815e1390e9e3a5ded929e8bfc4a9a4329a526))
|
||||
* **ignore:** update zigbee-herdsman to 4.2.0 ([#27786](https://github.com/Koenkk/zigbee2mqtt/issues/27786)) ([15f3eaf](https://github.com/Koenkk/zigbee2mqtt/commit/15f3eaf96629caa5fbd7e02ae002883becc1b49f))
|
||||
* **ignore:** update zigbee-herdsman to 4.2.1 ([#27835](https://github.com/Koenkk/zigbee2mqtt/issues/27835)) ([16faef1](https://github.com/Koenkk/zigbee2mqtt/commit/16faef1511e9bfb1cf76c5faaa1008e195ef67fc))
|
||||
* **ignore:** update zigbee-herdsman to 4.3.0 ([#27853](https://github.com/Koenkk/zigbee2mqtt/issues/27853)) ([50c7d2d](https://github.com/Koenkk/zigbee2mqtt/commit/50c7d2d6adb9c9366d5c7f829003c84a140f7a19))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.54.0 ([#27600](https://github.com/Koenkk/zigbee2mqtt/issues/27600)) ([1765fe8](https://github.com/Koenkk/zigbee2mqtt/commit/1765fe8925e359b917c1e2e08d3691d4c8bdcf3f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.55.0 ([#27618](https://github.com/Koenkk/zigbee2mqtt/issues/27618)) ([4594142](https://github.com/Koenkk/zigbee2mqtt/commit/4594142c09ee8d19af2d32238ffa58512694d2ed))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.56.0 ([#27639](https://github.com/Koenkk/zigbee2mqtt/issues/27639)) ([f637e7d](https://github.com/Koenkk/zigbee2mqtt/commit/f637e7d59cc71f32b8d04e8f4bfc414027e7d23a))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.57.0 ([#27646](https://github.com/Koenkk/zigbee2mqtt/issues/27646)) ([252bfba](https://github.com/Koenkk/zigbee2mqtt/commit/252bfbaa20beccd315592dac811cb23309cdee01))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.57.1 ([#27689](https://github.com/Koenkk/zigbee2mqtt/issues/27689)) ([56d3a8f](https://github.com/Koenkk/zigbee2mqtt/commit/56d3a8f9d8bc01e30a6e469b517545d3406941e2))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.58.0 ([#27695](https://github.com/Koenkk/zigbee2mqtt/issues/27695)) ([6a8d208](https://github.com/Koenkk/zigbee2mqtt/commit/6a8d20858ede85de72c34ce58e9df41fa2e91d0d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.59.0 ([#27712](https://github.com/Koenkk/zigbee2mqtt/issues/27712)) ([87ff0f3](https://github.com/Koenkk/zigbee2mqtt/commit/87ff0f3711e043eb47319e9fb8abcdfa878d9868))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.60.0 ([#27752](https://github.com/Koenkk/zigbee2mqtt/issues/27752)) ([f33d360](https://github.com/Koenkk/zigbee2mqtt/commit/f33d3606be9e916989d08c7d418c4505dcbfe4ea))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.61.0 ([#27759](https://github.com/Koenkk/zigbee2mqtt/issues/27759)) ([b537f9a](https://github.com/Koenkk/zigbee2mqtt/commit/b537f9a93a61095b6b0b82d04d85f7e264aec27c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.62.0 ([#27767](https://github.com/Koenkk/zigbee2mqtt/issues/27767)) ([281e10c](https://github.com/Koenkk/zigbee2mqtt/commit/281e10c15fa09358e999f1f6b506fe9b08295608))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.63.0 ([#27784](https://github.com/Koenkk/zigbee2mqtt/issues/27784)) ([0d5de75](https://github.com/Koenkk/zigbee2mqtt/commit/0d5de750ffed0674c5a23c2c4d8d1e954c602197))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.64.0 ([#27799](https://github.com/Koenkk/zigbee2mqtt/issues/27799)) ([b7d9c78](https://github.com/Koenkk/zigbee2mqtt/commit/b7d9c78309a8c3e3cf551948b6542d2b71b70697))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.65.0 ([#27804](https://github.com/Koenkk/zigbee2mqtt/issues/27804)) ([4190ba4](https://github.com/Koenkk/zigbee2mqtt/commit/4190ba43b43e1f688e246f9236bfe7c60230a927))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.66.0 ([#27831](https://github.com/Koenkk/zigbee2mqtt/issues/27831)) ([33234d9](https://github.com/Koenkk/zigbee2mqtt/commit/33234d9ce45323a2a10b7839c5f7aebc84a9cc32))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.67.0 ([#27842](https://github.com/Koenkk/zigbee2mqtt/issues/27842)) ([6930fa6](https://github.com/Koenkk/zigbee2mqtt/commit/6930fa6f3fce8f415302c8db32294b1a9d0ea333))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.68.0 ([#27852](https://github.com/Koenkk/zigbee2mqtt/issues/27852)) ([3ccbaf2](https://github.com/Koenkk/zigbee2mqtt/commit/3ccbaf21d227b7c4ad7952522a980a9083f06463))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.69.0 ([#27860](https://github.com/Koenkk/zigbee2mqtt/issues/27860)) ([5285b7b](https://github.com/Koenkk/zigbee2mqtt/commit/5285b7b95e3c977cca14d28444b9f4f2a9d8996c))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.14 ([#27645](https://github.com/Koenkk/zigbee2mqtt/issues/27645)) ([66e1318](https://github.com/Koenkk/zigbee2mqtt/commit/66e1318a79502cb723cf88d888c9ac23daeb7fd8))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.15 ([#27711](https://github.com/Koenkk/zigbee2mqtt/issues/27711)) ([8ecb9be](https://github.com/Koenkk/zigbee2mqtt/commit/8ecb9be98efb44db23f386e05509389c7d681025))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.16 ([#27783](https://github.com/Koenkk/zigbee2mqtt/issues/27783)) ([7109900](https://github.com/Koenkk/zigbee2mqtt/commit/7109900bcfeff48fdea48456455cd1c6cebf380a))
|
||||
* **ignore:** update zigbee2mqtt-windfront to 1.0.5 ([#27656](https://github.com/Koenkk/zigbee2mqtt/issues/27656)) ([eaa3e45](https://github.com/Koenkk/zigbee2mqtt/commit/eaa3e458b5a8a9c5ebf31e12ae7e68fcf60f762f))
|
||||
* **ignore:** update zigbee2mqtt-windfront to 1.1.0 ([#27763](https://github.com/Koenkk/zigbee2mqtt/issues/27763)) ([cdad6cc](https://github.com/Koenkk/zigbee2mqtt/commit/cdad6ccd75200061ded795af032f0fed0bb21484))
|
||||
* Rename fix typo in backoff settings https://github.com/Koenkk/zigbee2mqtt/issues/27642 ([0181263](https://github.com/Koenkk/zigbee2mqtt/commit/0181263d32dd1099676ed92226a3fc2ee6ac701e))
|
||||
* update zigbee2mqtt-windfront to 1.0.4 ([#27641](https://github.com/Koenkk/zigbee2mqtt/issues/27641)) ([9a4ce6f](https://github.com/Koenkk/zigbee2mqtt/commit/9a4ce6fcd0fce6fea9c195ff48a41096b738dccb))
|
||||
|
||||
## [2.4.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.3.0...2.4.0) (2025-06-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* WindFront new frontend support ([#27489](https://github.com/Koenkk/zigbee2mqtt/issues/27489)) ([1b85968](https://github.com/Koenkk/zigbee2mqtt/commit/1b85968e5e691182305cf334836c069e70a79775))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* API missing a few request endpoints ([#27551](https://github.com/Koenkk/zigbee2mqtt/issues/27551)) ([a5e4e9e](https://github.com/Koenkk/zigbee2mqtt/commit/a5e4e9ef61e6f9e3251de43f2c9525d94c13249d))
|
||||
* Don't copy external JS to `dist` folder ([#27397](https://github.com/Koenkk/zigbee2mqtt/issues/27397)) ([0ead302](https://github.com/Koenkk/zigbee2mqtt/commit/0ead302c0a01f8fd2356e70c853f7795cb55b1c8))
|
||||
* Drop Node 18 support, add 24 ([#27348](https://github.com/Koenkk/zigbee2mqtt/issues/27348)) ([aad87cc](https://github.com/Koenkk/zigbee2mqtt/commit/aad87cc3ec48c664ec58a02fa39c5c23a73db492))
|
||||
* Fix bind/unbind by coordinator ieeeAddr not working ([#27333](https://github.com/Koenkk/zigbee2mqtt/issues/27333)) ([d8855b8](https://github.com/Koenkk/zigbee2mqtt/commit/d8855b8e6f1abc63daf6771a9ec07279ebc168d9))
|
||||
* Fix import error on Windows (ERR_UNSUPPORTED_ESM_URL_SCHEME) ([#27519](https://github.com/Koenkk/zigbee2mqtt/issues/27519)) ([ed03c40](https://github.com/Koenkk/zigbee2mqtt/commit/ed03c40dad1a8722b24e84f4ed8613b0e37a99ea))
|
||||
* HA integration for external sensors Aqara W100 ([#27520](https://github.com/Koenkk/zigbee2mqtt/issues/27520)) ([4bf4563](https://github.com/Koenkk/zigbee2mqtt/commit/4bf45630e28e5534fe68bb4bac6b020701c0e209))
|
||||
* **ignore:** bump the minor-patch group across 1 directory with 2 updates ([#27562](https://github.com/Koenkk/zigbee2mqtt/issues/27562)) ([278e3fb](https://github.com/Koenkk/zigbee2mqtt/commit/278e3fb40e84c97ab5dce8147fab7029f5bca168))
|
||||
* **ignore:** Cleanup publish options ([#27226](https://github.com/Koenkk/zigbee2mqtt/issues/27226)) ([b7b92da](https://github.com/Koenkk/zigbee2mqtt/commit/b7b92da8ffbaa25ce5927cb6dbf12f9da593a2e0))
|
||||
* **ignore:** Fix passive states not updating in frontend ([#27532](https://github.com/Koenkk/zigbee2mqtt/issues/27532)) ([a289f98](https://github.com/Koenkk/zigbee2mqtt/commit/a289f981bf81ef0734561317628f3de2e3b38f40))
|
||||
* **ignore:** networkmap typing ([#27566](https://github.com/Koenkk/zigbee2mqtt/issues/27566)) ([621cae2](https://github.com/Koenkk/zigbee2mqtt/commit/621cae2a6398d9eeced00d499bf542a8143de456))
|
||||
* **ignore:** update zigbee-herdsman to 4.0.2 ([#27380](https://github.com/Koenkk/zigbee2mqtt/issues/27380)) ([cc3521c](https://github.com/Koenkk/zigbee2mqtt/commit/cc3521c877a2c5048666e27c05f21b5852dceb5c))
|
||||
* **ignore:** update zigbee-herdsman to 4.1.0 ([#27462](https://github.com/Koenkk/zigbee2mqtt/issues/27462)) ([3edcfd7](https://github.com/Koenkk/zigbee2mqtt/commit/3edcfd7ff6b5e19ee0ad8780655d911b37b9bcab))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.37.0 ([#27300](https://github.com/Koenkk/zigbee2mqtt/issues/27300)) ([ff823db](https://github.com/Koenkk/zigbee2mqtt/commit/ff823dbaf0b9818be83b0e3ba02073b1afcd439d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.38.0 ([#27329](https://github.com/Koenkk/zigbee2mqtt/issues/27329)) ([164c322](https://github.com/Koenkk/zigbee2mqtt/commit/164c3222b9dd6cec12ac407145bc8e38985fa7cb))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.39.0 ([#27340](https://github.com/Koenkk/zigbee2mqtt/issues/27340)) ([1d6ba53](https://github.com/Koenkk/zigbee2mqtt/commit/1d6ba5393a1dc5626b6ca539e3ec245cc3d61a56))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.40.0 ([#27347](https://github.com/Koenkk/zigbee2mqtt/issues/27347)) ([7a4f286](https://github.com/Koenkk/zigbee2mqtt/commit/7a4f2869b9836cd9f0e037c324585bdf453e8e8c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.40.1 ([#27358](https://github.com/Koenkk/zigbee2mqtt/issues/27358)) ([317c9b4](https://github.com/Koenkk/zigbee2mqtt/commit/317c9b4046c4e02a6bad65b1e2cb9f5e59803f24))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.41.0 ([#27371](https://github.com/Koenkk/zigbee2mqtt/issues/27371)) ([f7e5051](https://github.com/Koenkk/zigbee2mqtt/commit/f7e50519e30ca65a396b07d89c3a81b922916ac9))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.42.0 ([#27392](https://github.com/Koenkk/zigbee2mqtt/issues/27392)) ([edcfe32](https://github.com/Koenkk/zigbee2mqtt/commit/edcfe325725aab283753b10f72521cb3f4b0dca4))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.43.0 ([#27394](https://github.com/Koenkk/zigbee2mqtt/issues/27394)) ([e061126](https://github.com/Koenkk/zigbee2mqtt/commit/e0611269f37aedec70ebfe17f75e44cbd2ce619d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.44.0 ([#27404](https://github.com/Koenkk/zigbee2mqtt/issues/27404)) ([21c9faf](https://github.com/Koenkk/zigbee2mqtt/commit/21c9faffafafbbf6f52f12ecd20bead82bf20777))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.45.0 ([#27422](https://github.com/Koenkk/zigbee2mqtt/issues/27422)) ([8c3252c](https://github.com/Koenkk/zigbee2mqtt/commit/8c3252c2136b6c7e92c5c63ccfbb3af7a943f818))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.46.0 ([#27444](https://github.com/Koenkk/zigbee2mqtt/issues/27444)) ([7449c23](https://github.com/Koenkk/zigbee2mqtt/commit/7449c2357c2b19fa94f6bb111783e723e0a746f5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.47.0 ([#27463](https://github.com/Koenkk/zigbee2mqtt/issues/27463)) ([693f0d0](https://github.com/Koenkk/zigbee2mqtt/commit/693f0d0a35231b529261fe1652bb6e355d5854fe))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.48.0 ([#27470](https://github.com/Koenkk/zigbee2mqtt/issues/27470)) ([1303340](https://github.com/Koenkk/zigbee2mqtt/commit/1303340aaf1aede9b6f4042d7a3780407a7d42e5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.49.0 ([#27479](https://github.com/Koenkk/zigbee2mqtt/issues/27479)) ([ea49d1a](https://github.com/Koenkk/zigbee2mqtt/commit/ea49d1ae446a422ebd480b630a51b2e6f8921276))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.50.0 ([#27521](https://github.com/Koenkk/zigbee2mqtt/issues/27521)) ([1960e02](https://github.com/Koenkk/zigbee2mqtt/commit/1960e026d20cefc8c64d0a47e32f0bea075f24f6))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.50.1 ([#27531](https://github.com/Koenkk/zigbee2mqtt/issues/27531)) ([00dda24](https://github.com/Koenkk/zigbee2mqtt/commit/00dda245e49761afe155d0920059893448af8506))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.51.0 ([#27540](https://github.com/Koenkk/zigbee2mqtt/issues/27540)) ([f5920fd](https://github.com/Koenkk/zigbee2mqtt/commit/f5920fd53be777a289fa480cf1ef62bd1701aba3))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.52.0 ([#27559](https://github.com/Koenkk/zigbee2mqtt/issues/27559)) ([6ed18c7](https://github.com/Koenkk/zigbee2mqtt/commit/6ed18c757dd81899d5a8dcf9f912927dfbdde95d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.52.1 ([#27563](https://github.com/Koenkk/zigbee2mqtt/issues/27563)) ([711b456](https://github.com/Koenkk/zigbee2mqtt/commit/711b456b068ef86c1add5d9cc16770f20a495622))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.53.0 ([#27572](https://github.com/Koenkk/zigbee2mqtt/issues/27572)) ([e0b0248](https://github.com/Koenkk/zigbee2mqtt/commit/e0b02481729916daf8a7ff02ddc61dbb717cc271))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.11 ([#27301](https://github.com/Koenkk/zigbee2mqtt/issues/27301)) ([3cb63aa](https://github.com/Koenkk/zigbee2mqtt/commit/3cb63aa9229a3b4a0f099ce3f8481badf241e202))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.12 ([#27379](https://github.com/Koenkk/zigbee2mqtt/issues/27379)) ([615c53c](https://github.com/Koenkk/zigbee2mqtt/commit/615c53c93c8cda90836d9edf98f54d5e589610b8))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.13 ([#27382](https://github.com/Koenkk/zigbee2mqtt/issues/27382)) ([f656d96](https://github.com/Koenkk/zigbee2mqtt/commit/f656d9629c405d32067ccbbf411b1c5d6255e4ff))
|
||||
* **ignore:** update zigbee2mqtt-windfront to 1.0.2 ([#27567](https://github.com/Koenkk/zigbee2mqtt/issues/27567)) ([5e44cf7](https://github.com/Koenkk/zigbee2mqtt/commit/5e44cf77b3a4c664be4b97a95455a39a18bd8632))
|
||||
* Improve update script ([#27308](https://github.com/Koenkk/zigbee2mqtt/issues/27308)) ([3cbcc4a](https://github.com/Koenkk/zigbee2mqtt/commit/3cbcc4a0abbe1cb09096d0b989abba48acea8772))
|
||||
* Only send JSON state over WebSocket ([#27225](https://github.com/Koenkk/zigbee2mqtt/issues/27225)) ([39e4468](https://github.com/Koenkk/zigbee2mqtt/commit/39e4468308c1909f03ecd068f5c7da23b040f80b))
|
||||
* Rerun onboarding if Z2M start failed after previous onboarding ([#27386](https://github.com/Koenkk/zigbee2mqtt/issues/27386)) ([d697c29](https://github.com/Koenkk/zigbee2mqtt/commit/d697c29c7be5e7e49d82d25f355a26875ac383a3))
|
||||
* Trim name on rename ([#27332](https://github.com/Koenkk/zigbee2mqtt/issues/27332)) ([69364c9](https://github.com/Koenkk/zigbee2mqtt/commit/69364c958f9f2b7430bfb5a072ffe5267bfcc378))
|
||||
|
||||
## [2.3.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.2.1...2.3.0) (2025-05-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Allow scheduling OTA on device request ([#26823](https://github.com/Koenkk/zigbee2mqtt/issues/26823)) ([6e34607](https://github.com/Koenkk/zigbee2mqtt/commit/6e346073e75685eed52da0d2e229ef68e073c76d))
|
||||
* Availability improvements ([#26811](https://github.com/Koenkk/zigbee2mqtt/issues/26811)) ([8df5a48](https://github.com/Koenkk/zigbee2mqtt/commit/8df5a484fc3dd3881da217605426e80e017ff467))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add icon example to settings schema ([#27194](https://github.com/Koenkk/zigbee2mqtt/issues/27194)) ([4acbdd7](https://github.com/Koenkk/zigbee2mqtt/commit/4acbdd7685a6cd3b6db99859b2ccd220aa60c3a2))
|
||||
* Add interview state ([#27163](https://github.com/Koenkk/zigbee2mqtt/issues/27163)) ([69f9002](https://github.com/Koenkk/zigbee2mqtt/commit/69f9002e316bdb3d1489d1434d5e53d0f9efaf84))
|
||||
* Do not call start() on extensions twice when starting ([#27032](https://github.com/Koenkk/zigbee2mqtt/issues/27032)) ([7fb10b1](https://github.com/Koenkk/zigbee2mqtt/commit/7fb10b1dcb5bce3485c53c331d6671dd81082209))
|
||||
* Don't allow publishing wildcards in MQTT topic ([#27025](https://github.com/Koenkk/zigbee2mqtt/issues/27025)) ([850f082](https://github.com/Koenkk/zigbee2mqtt/commit/850f0820ac2eb8666b7fa91e0129815e1a67c27c))
|
||||
* Guard against ZHC throwing ([#27093](https://github.com/Koenkk/zigbee2mqtt/issues/27093)) ([e3d214b](https://github.com/Koenkk/zigbee2mqtt/commit/e3d214ba9c66503ab023cce219e462b3da2f10ab))
|
||||
* **ignore:** Add missing API request endpoints ([#27242](https://github.com/Koenkk/zigbee2mqtt/issues/27242)) ([5304b0c](https://github.com/Koenkk/zigbee2mqtt/commit/5304b0ceb713c534c8700d12e33d1f8dd8242eaf))
|
||||
* **ignore:** API exporting issues ([#27211](https://github.com/Koenkk/zigbee2mqtt/issues/27211)) ([9a9f8d1](https://github.com/Koenkk/zigbee2mqtt/commit/9a9f8d143839e039fe50594848d658f630396752))
|
||||
* **ignore:** API typing fixes. ([#27266](https://github.com/Koenkk/zigbee2mqtt/issues/27266)) ([c36a177](https://github.com/Koenkk/zigbee2mqtt/commit/c36a1779169ecf75d37d933f32bae75799630365))
|
||||
* **ignore:** Group API proper typing ([#27243](https://github.com/Koenkk/zigbee2mqtt/issues/27243)) ([7cbf31a](https://github.com/Koenkk/zigbee2mqtt/commit/7cbf31a1296722a25b20fea561c2fedf04f22861))
|
||||
* **ignore:** partial cleanup for biome move ([#27076](https://github.com/Koenkk/zigbee2mqtt/issues/27076)) ([fc31e0a](https://github.com/Koenkk/zigbee2mqtt/commit/fc31e0af0a3c1592c2be9057a66287c60fa28a75))
|
||||
* **ignore:** update dependencies ([#27022](https://github.com/Koenkk/zigbee2mqtt/issues/27022)) ([d3bf771](https://github.com/Koenkk/zigbee2mqtt/commit/d3bf771de0365a2fb060d81ef07daccc6151abbf))
|
||||
* **ignore:** update dependencies ([#27094](https://github.com/Koenkk/zigbee2mqtt/issues/27094)) ([b4ce65b](https://github.com/Koenkk/zigbee2mqtt/commit/b4ce65bb41853ef177d910f7cb03131e861a0475))
|
||||
* **ignore:** update zigbee-herdsman to 3.5.0 ([#27000](https://github.com/Koenkk/zigbee2mqtt/issues/27000)) ([23e71aa](https://github.com/Koenkk/zigbee2mqtt/commit/23e71aab14536f4f38f12c3c8cb96eb1cd7ccb60))
|
||||
* **ignore:** update zigbee-herdsman to 3.5.1 ([#27024](https://github.com/Koenkk/zigbee2mqtt/issues/27024)) ([6949bbb](https://github.com/Koenkk/zigbee2mqtt/commit/6949bbb8838802573d2d1fd573fdba67132be446))
|
||||
* **ignore:** update zigbee-herdsman to 3.5.2 ([#27147](https://github.com/Koenkk/zigbee2mqtt/issues/27147)) ([130d041](https://github.com/Koenkk/zigbee2mqtt/commit/130d041f75121918dfd605aa0959e20718c392ab))
|
||||
* **ignore:** update zigbee-herdsman to 4.0.0 ([dfeaf22](https://github.com/Koenkk/zigbee2mqtt/commit/dfeaf22f703712e32dd1e27c96de7a374bc620c5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.21.0 ([#27001](https://github.com/Koenkk/zigbee2mqtt/issues/27001)) ([20be405](https://github.com/Koenkk/zigbee2mqtt/commit/20be4054c163c0b8fe420de606c163076be8c926))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.22.0 ([#27023](https://github.com/Koenkk/zigbee2mqtt/issues/27023)) ([2297b81](https://github.com/Koenkk/zigbee2mqtt/commit/2297b8164094b648678c52884b7e20b62ccbe93c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.23.0 ([#27036](https://github.com/Koenkk/zigbee2mqtt/issues/27036)) ([17adc44](https://github.com/Koenkk/zigbee2mqtt/commit/17adc44a63c1bc237101c02b6f780b37f578903c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.24.0 ([#27061](https://github.com/Koenkk/zigbee2mqtt/issues/27061)) ([8e547d0](https://github.com/Koenkk/zigbee2mqtt/commit/8e547d06ec9981d9bfa95a51e12620a9e4a88c39))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.25.0 ([#27075](https://github.com/Koenkk/zigbee2mqtt/issues/27075)) ([b8cc120](https://github.com/Koenkk/zigbee2mqtt/commit/b8cc120da3f29d8d4d4393d565540f2d24a3db8e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.26.0 ([06b53be](https://github.com/Koenkk/zigbee2mqtt/commit/06b53be5d891e01b498342e01502296dabeffa02))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.27.0 ([#27121](https://github.com/Koenkk/zigbee2mqtt/issues/27121)) ([b694dc1](https://github.com/Koenkk/zigbee2mqtt/commit/b694dc1525608d1d9394a405193aae88f34d2ada))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.28.0 ([#27130](https://github.com/Koenkk/zigbee2mqtt/issues/27130)) ([8a9f631](https://github.com/Koenkk/zigbee2mqtt/commit/8a9f63197319aad59e3028c86e4bb3da6b3e6a01))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.29.0 ([#27137](https://github.com/Koenkk/zigbee2mqtt/issues/27137)) ([8be1ae1](https://github.com/Koenkk/zigbee2mqtt/commit/8be1ae16ab5c47f5aaec28a6dc328c3d3d80d409))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.30.0 ([#27154](https://github.com/Koenkk/zigbee2mqtt/issues/27154)) ([ecc4696](https://github.com/Koenkk/zigbee2mqtt/commit/ecc46961446d17de540e48c76ab5b7c26f6c8c18))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.31.0 ([#27162](https://github.com/Koenkk/zigbee2mqtt/issues/27162)) ([8195b2d](https://github.com/Koenkk/zigbee2mqtt/commit/8195b2dd4ab514ba4ba944499ce71e66c41d6855))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.32.0 ([#27178](https://github.com/Koenkk/zigbee2mqtt/issues/27178)) ([faa6ad9](https://github.com/Koenkk/zigbee2mqtt/commit/faa6ad9ce883644eada82c04a9c11d41e62b4799))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.33.0 ([#27202](https://github.com/Koenkk/zigbee2mqtt/issues/27202)) ([c1b62e8](https://github.com/Koenkk/zigbee2mqtt/commit/c1b62e83ef22ae5880e833b645586a19bba661af))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.34.0 ([#27224](https://github.com/Koenkk/zigbee2mqtt/issues/27224)) ([a513516](https://github.com/Koenkk/zigbee2mqtt/commit/a5135168c81ae9e78eb84e66f2c4556a3e426442))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.35.0 ([#27253](https://github.com/Koenkk/zigbee2mqtt/issues/27253)) ([97dbe1e](https://github.com/Koenkk/zigbee2mqtt/commit/97dbe1e8af826c115d591712447031321f68cb60))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.36.0 ([#27276](https://github.com/Koenkk/zigbee2mqtt/issues/27276)) ([961a82e](https://github.com/Koenkk/zigbee2mqtt/commit/961a82e213889bee221abec0015ed45e171c6227))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.10 ([#27191](https://github.com/Koenkk/zigbee2mqtt/issues/27191)) ([3ad20dd](https://github.com/Koenkk/zigbee2mqtt/commit/3ad20ddcdc2995b4a0522b55adeaa25701e279fc))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.8 ([#27018](https://github.com/Koenkk/zigbee2mqtt/issues/27018)) ([d27ff76](https://github.com/Koenkk/zigbee2mqtt/commit/d27ff76921471ba6d88bfc14fa828665efaad5dd))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.9 ([#27120](https://github.com/Koenkk/zigbee2mqtt/issues/27120)) ([0c0e5a3](https://github.com/Koenkk/zigbee2mqtt/commit/0c0e5a3794260782ccee759d131762c8f148c0f5))
|
||||
* Migrate to Biome ([#27150](https://github.com/Koenkk/zigbee2mqtt/issues/27150)) ([6fbb8b5](https://github.com/Koenkk/zigbee2mqtt/commit/6fbb8b5ca2d3d98240f23e0208edb2fd05826daa))
|
||||
* OnEvent fixes ([#27063](https://github.com/Koenkk/zigbee2mqtt/issues/27063)) ([80502da](https://github.com/Koenkk/zigbee2mqtt/commit/80502da80f38bcbcd5ad9e0c83a0d1d77a269394))
|
||||
* Use Alpine 3.21 for arm v6 and v7 ([#25417](https://github.com/Koenkk/zigbee2mqtt/issues/25417)) ([1e2ad28](https://github.com/Koenkk/zigbee2mqtt/commit/1e2ad28267030097cc9e2d53edc0cea17d328220))
|
||||
* Use Map for State ([#27105](https://github.com/Koenkk/zigbee2mqtt/issues/27105)) ([36e2baf](https://github.com/Koenkk/zigbee2mqtt/commit/36e2baf081242c609907d616b2945a9ead9d6fdd))
|
||||
|
||||
## [2.2.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.2.0...2.2.1) (2025-04-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fix issue with some settings when migrating from 1.x.x to 2.2.x ([#26947](https://github.com/Koenkk/zigbee2mqtt/issues/26947)) ([9e481fe](https://github.com/Koenkk/zigbee2mqtt/commit/9e481fe5e57a8e664dd58cff7765b5365630c7c1))
|
||||
* Fix settings being overwriting when env var is set to a ref ([#26988](https://github.com/Koenkk/zigbee2mqtt/issues/26988)) ([d6a5128](https://github.com/Koenkk/zigbee2mqtt/commit/d6a5128aa819a85e2a06e57307329d2b35938397))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.19.0 ([#26938](https://github.com/Koenkk/zigbee2mqtt/issues/26938)) ([0b7d003](https://github.com/Koenkk/zigbee2mqtt/commit/0b7d0039f709215ac1a4895f15f06db41b4d4998))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.19.1 ([#26961](https://github.com/Koenkk/zigbee2mqtt/issues/26961)) ([e90afb2](https://github.com/Koenkk/zigbee2mqtt/commit/e90afb286a15c5416fd1126864acdfc4c03c3a64))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.20.0 ([#26978](https://github.com/Koenkk/zigbee2mqtt/issues/26978)) ([37927e6](https://github.com/Koenkk/zigbee2mqtt/commit/37927e698abcb242965423e9ee37623255feadab))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.20.1 ([#26989](https://github.com/Koenkk/zigbee2mqtt/issues/26989)) ([9d0ee03](https://github.com/Koenkk/zigbee2mqtt/commit/9d0ee03f38ee6881dce3b17c219a6580303607ef))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.6 ([#26959](https://github.com/Koenkk/zigbee2mqtt/issues/26959)) ([fb4d01e](https://github.com/Koenkk/zigbee2mqtt/commit/fb4d01ebced7046919d6657c7609a196d39a1a6c))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.7 ([#26976](https://github.com/Koenkk/zigbee2mqtt/issues/26976)) ([89cdb1f](https://github.com/Koenkk/zigbee2mqtt/commit/89cdb1f180bb343b56a50c94dc6a23dda1345582))
|
||||
|
||||
## [2.2.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.1.3...2.2.0) (2025-04-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Frontend: support filtering notifications ([#26805](https://github.com/Koenkk/zigbee2mqtt/issues/26805)) ([f872a51](https://github.com/Koenkk/zigbee2mqtt/commit/f872a5180c5618b7d3e7c0d11f1283a454dcbc6b))
|
||||
* Onboarding ([#26608](https://github.com/Koenkk/zigbee2mqtt/issues/26608)) ([05d6f08](https://github.com/Koenkk/zigbee2mqtt/commit/05d6f08a0dd6a5097b7d19b8de3b39e37e9a8533))
|
||||
* Support ZigBee on Host adapter ([#26742](https://github.com/Koenkk/zigbee2mqtt/issues/26742)) ([92955c0](https://github.com/Koenkk/zigbee2mqtt/commit/92955c02a4de09cbed79249db8bf0e6529423b93))
|
||||
* Use dynamic import for external JS ([#26522](https://github.com/Koenkk/zigbee2mqtt/issues/26522)) ([badf107](https://github.com/Koenkk/zigbee2mqtt/commit/badf1079919baa06f160a42c34e9f742a9b584c9))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add device name to no converter available error message ([#26867](https://github.com/Koenkk/zigbee2mqtt/issues/26867)) ([267c399](https://github.com/Koenkk/zigbee2mqtt/commit/267c3990474dcc646e6987a108867a54b8b940db))
|
||||
* Check if new commits are available in update.sh ([#26631](https://github.com/Koenkk/zigbee2mqtt/issues/26631)) ([1b6ad46](https://github.com/Koenkk/zigbee2mqtt/commit/1b6ad46462681b8f223a6de54c5996d0e8f66f64))
|
||||
* Fix bridge offline message format for frontend ([#26707](https://github.com/Koenkk/zigbee2mqtt/issues/26707)) ([e398500](https://github.com/Koenkk/zigbee2mqtt/commit/e3985002c2bf374c2edd82e95aaa2da7c920b464))
|
||||
* General cleanup ([#26595](https://github.com/Koenkk/zigbee2mqtt/issues/26595)) ([8775ceb](https://github.com/Koenkk/zigbee2mqtt/commit/8775ceb0c1d2bac552a345751935d0191410bfa3))
|
||||
* **gnore:** Onboarding fixes ([#26824](https://github.com/Koenkk/zigbee2mqtt/issues/26824)) ([f2a1c9a](https://github.com/Koenkk/zigbee2mqtt/commit/f2a1c9a2be052ec46d845299d73ff1495d4ea4e1))
|
||||
* Home Assistant: fix electrical measurements sensors category ([#26769](https://github.com/Koenkk/zigbee2mqtt/issues/26769)) ([747f6ac](https://github.com/Koenkk/zigbee2mqtt/commit/747f6ac4767522a51fb5e5cc0b796b3a92243476))
|
||||
* **ignore:** fix `EACCES: permission denied, mkdir '/app/dist/external_converters'` ([7a1621a](https://github.com/Koenkk/zigbee2mqtt/commit/7a1621a9060cd0109643655df2b2833e046158fa))
|
||||
* **ignore:** Fix HA addon onboarding ([#26862](https://github.com/Koenkk/zigbee2mqtt/issues/26862)) ([341c453](https://github.com/Koenkk/zigbee2mqtt/commit/341c453657d74aef61e4a44b24d081fab9671d39))
|
||||
* **ignore:** Fix tests failing when zhc is linked via `pnpm link` ([#26630](https://github.com/Koenkk/zigbee2mqtt/issues/26630)) ([ad32fec](https://github.com/Koenkk/zigbee2mqtt/commit/ad32fec1d02f2d43b7dc18bf38bbd14f7c97ffde))
|
||||
* **ignore:** Fix update check ([#26723](https://github.com/Koenkk/zigbee2mqtt/issues/26723)) ([fb48bd0](https://github.com/Koenkk/zigbee2mqtt/commit/fb48bd0cb9a0311302d82a7f1b9cec21a585799e))
|
||||
* **ignore:** Make update check less verbose ([#26774](https://github.com/Koenkk/zigbee2mqtt/issues/26774)) ([6e781ea](https://github.com/Koenkk/zigbee2mqtt/commit/6e781eafaefa54fad24ed857e1f96261ff3bebb7))
|
||||
* **ignore:** Module `NodeNext` ([#26719](https://github.com/Koenkk/zigbee2mqtt/issues/26719)) ([5db1150](https://github.com/Koenkk/zigbee2mqtt/commit/5db115057ef733a33575c889e981696d2b9e74b9))
|
||||
* **ignore:** Move to `Node16` `module` ([#26704](https://github.com/Koenkk/zigbee2mqtt/issues/26704)) ([ea5e0d4](https://github.com/Koenkk/zigbee2mqtt/commit/ea5e0d47a599ac66dcf4671e7a8b0687d8a08b85))
|
||||
* **ignore:** Move to Node16 module ([#26717](https://github.com/Koenkk/zigbee2mqtt/issues/26717)) ([be722a0](https://github.com/Koenkk/zigbee2mqtt/commit/be722a0500892e6787d669ceb10c7d0bfc1c7e4c))
|
||||
* **ignore:** Reduce chaining in `homeassistant.ts` ([#26582](https://github.com/Koenkk/zigbee2mqtt/issues/26582)) ([4fa794a](https://github.com/Koenkk/zigbee2mqtt/commit/4fa794a6392917d0140b94257bb7cd155fc25936))
|
||||
* **ignore:** Remove uncessary extensions spread ([#26581](https://github.com/Koenkk/zigbee2mqtt/issues/26581)) ([1fe2d8d](https://github.com/Koenkk/zigbee2mqtt/commit/1fe2d8dae6ace27c4b297b7943ac5501c096e8ab))
|
||||
* **ignore:** update dependencies ([#26554](https://github.com/Koenkk/zigbee2mqtt/issues/26554)) ([11fb858](https://github.com/Koenkk/zigbee2mqtt/commit/11fb858ff2819319f8aa4bd8a6b2f25944619a8e))
|
||||
* **ignore:** update dependencies ([#26662](https://github.com/Koenkk/zigbee2mqtt/issues/26662)) ([fb98166](https://github.com/Koenkk/zigbee2mqtt/commit/fb9816659bec15d0a9a39f73d4900fc7586a378e))
|
||||
* **ignore:** update dependencies ([#26751](https://github.com/Koenkk/zigbee2mqtt/issues/26751)) ([e602dc8](https://github.com/Koenkk/zigbee2mqtt/commit/e602dc849a1d17b9097ce06015ba587c59ba5006))
|
||||
* **ignore:** update dependencies ([#26833](https://github.com/Koenkk/zigbee2mqtt/issues/26833)) ([93d0ee4](https://github.com/Koenkk/zigbee2mqtt/commit/93d0ee44ba4445711b7e43ab4bfa06e8ac266d68))
|
||||
* **ignore:** update dependencies ([#26911](https://github.com/Koenkk/zigbee2mqtt/issues/26911)) ([e02bbdc](https://github.com/Koenkk/zigbee2mqtt/commit/e02bbdcf0bbf8577ab6e138c915f7e2ef1a32482))
|
||||
* **ignore:** update zigbee-herdsman to 3.3.0 ([#26628](https://github.com/Koenkk/zigbee2mqtt/issues/26628)) ([ed7e7c7](https://github.com/Koenkk/zigbee2mqtt/commit/ed7e7c7e530010aeb44eb71e50f31145c7285c26))
|
||||
* **ignore:** update zigbee-herdsman to 3.3.1 ([#26674](https://github.com/Koenkk/zigbee2mqtt/issues/26674)) ([a5195a6](https://github.com/Koenkk/zigbee2mqtt/commit/a5195a6eee9a3e852709c4b979c965dee33f2b54))
|
||||
* **ignore:** update zigbee-herdsman to 3.3.2 ([#26702](https://github.com/Koenkk/zigbee2mqtt/issues/26702)) ([8378949](https://github.com/Koenkk/zigbee2mqtt/commit/8378949e1d9cdfb3d33bc771dc61594ca7ef3c05))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.0 ([#26744](https://github.com/Koenkk/zigbee2mqtt/issues/26744)) ([e5a80e3](https://github.com/Koenkk/zigbee2mqtt/commit/e5a80e3b1bf8ab5f693d248f2b6a90481bdb10fc))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.1 ([#26779](https://github.com/Koenkk/zigbee2mqtt/issues/26779)) ([46fd043](https://github.com/Koenkk/zigbee2mqtt/commit/46fd04364ded0b6f7c9fcea6f33a2b523971b389))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.10 ([#26908](https://github.com/Koenkk/zigbee2mqtt/issues/26908)) ([b52cfd8](https://github.com/Koenkk/zigbee2mqtt/commit/b52cfd87c3deedfa0d1d391ce4f6f64083389559))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.11 ([#26924](https://github.com/Koenkk/zigbee2mqtt/issues/26924)) ([9fe38b1](https://github.com/Koenkk/zigbee2mqtt/commit/9fe38b1677681b1f3b47ac6d9876fa7305572244))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.2 ([#26789](https://github.com/Koenkk/zigbee2mqtt/issues/26789)) ([582bf7d](https://github.com/Koenkk/zigbee2mqtt/commit/582bf7d2c6a21003e09892b569ef5fb115f5d1ab))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.3 ([#26802](https://github.com/Koenkk/zigbee2mqtt/issues/26802)) ([4f2b052](https://github.com/Koenkk/zigbee2mqtt/commit/4f2b052016f4d313dfc43d35aca986b682c173b0))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.4 ([#26821](https://github.com/Koenkk/zigbee2mqtt/issues/26821)) ([0714582](https://github.com/Koenkk/zigbee2mqtt/commit/0714582eb975c39ef0fb5205d7f2c76ce7de2a05))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.5 ([#26826](https://github.com/Koenkk/zigbee2mqtt/issues/26826)) ([2b90d7b](https://github.com/Koenkk/zigbee2mqtt/commit/2b90d7b3b974353c312dc00c26b6223be74e1272))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.6 ([#26834](https://github.com/Koenkk/zigbee2mqtt/issues/26834)) ([9e0c84b](https://github.com/Koenkk/zigbee2mqtt/commit/9e0c84beed18d13ec310d91b89e35eba46fe3817))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.7 ([#26859](https://github.com/Koenkk/zigbee2mqtt/issues/26859)) ([440e3a0](https://github.com/Koenkk/zigbee2mqtt/commit/440e3a00942dc8bb7a7b9b66efd825e15dccad6c))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.8 ([#26866](https://github.com/Koenkk/zigbee2mqtt/issues/26866)) ([f824295](https://github.com/Koenkk/zigbee2mqtt/commit/f824295b7efefb78d6f803a4291fb2a485694c96))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.9 ([#26877](https://github.com/Koenkk/zigbee2mqtt/issues/26877)) ([0a41af5](https://github.com/Koenkk/zigbee2mqtt/commit/0a41af5dd23d1806d703b878feaba25d90c2d016))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.10.0 ([#26792](https://github.com/Koenkk/zigbee2mqtt/issues/26792)) ([52c255b](https://github.com/Koenkk/zigbee2mqtt/commit/52c255bcc2a37a7238afe675f7c521c0834e2d9d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.11.0 ([#26820](https://github.com/Koenkk/zigbee2mqtt/issues/26820)) ([5288549](https://github.com/Koenkk/zigbee2mqtt/commit/5288549744795296a9919828b1a85b1223edd755))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.12.0 ([#26835](https://github.com/Koenkk/zigbee2mqtt/issues/26835)) ([53e8226](https://github.com/Koenkk/zigbee2mqtt/commit/53e8226f0aff0cdfba76f6f30672c0bd3d72d52d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.13.0 ([#26861](https://github.com/Koenkk/zigbee2mqtt/issues/26861)) ([c5c7f70](https://github.com/Koenkk/zigbee2mqtt/commit/c5c7f70ad6aaca083c4edb4dba783e60f8ec17a5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.14.0 ([#26886](https://github.com/Koenkk/zigbee2mqtt/issues/26886)) ([6a65769](https://github.com/Koenkk/zigbee2mqtt/commit/6a657690604daacc4a2cc1afa8407fd0d4dc9eaf))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.15.0 ([#26900](https://github.com/Koenkk/zigbee2mqtt/issues/26900)) ([516425b](https://github.com/Koenkk/zigbee2mqtt/commit/516425b44b7f395db7bbed93699b989d6a8276be))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.16.0 ([#26918](https://github.com/Koenkk/zigbee2mqtt/issues/26918)) ([6e6a989](https://github.com/Koenkk/zigbee2mqtt/commit/6e6a989159383a3534ce6d12ea8c9bdc9092d2bc))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.17.0 ([#26925](https://github.com/Koenkk/zigbee2mqtt/issues/26925)) ([ea04f19](https://github.com/Koenkk/zigbee2mqtt/commit/ea04f197090e3a3bc52dc4eb4ecde65146484bfb))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.18.0 ([#26933](https://github.com/Koenkk/zigbee2mqtt/issues/26933)) ([69f0840](https://github.com/Koenkk/zigbee2mqtt/commit/69f0840feb2f8a5332148cfe271d16e413a02ebf))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.3.0 ([#26598](https://github.com/Koenkk/zigbee2mqtt/issues/26598)) ([3941692](https://github.com/Koenkk/zigbee2mqtt/commit/3941692665d96fee575995bb6a372bfb92e0a466))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.4.0 ([#26629](https://github.com/Koenkk/zigbee2mqtt/issues/26629)) ([c603f42](https://github.com/Koenkk/zigbee2mqtt/commit/c603f42c889b5508a56f0f43df7d09dba909b0cd))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.6.0 ([#26691](https://github.com/Koenkk/zigbee2mqtt/issues/26691)) ([06ba62b](https://github.com/Koenkk/zigbee2mqtt/commit/06ba62b8331d8d5db1696c47bd3b643449584b16))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.7.0 ([#26733](https://github.com/Koenkk/zigbee2mqtt/issues/26733)) ([8d2ef6d](https://github.com/Koenkk/zigbee2mqtt/commit/8d2ef6df06bbfcc856a91ef592e8ac54f46a705c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.8.0 ([#26761](https://github.com/Koenkk/zigbee2mqtt/issues/26761)) ([325199d](https://github.com/Koenkk/zigbee2mqtt/commit/325199d4455adb32f912687827ed01d89e009bc1))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.5 ([#26814](https://github.com/Koenkk/zigbee2mqtt/issues/26814)) ([9792466](https://github.com/Koenkk/zigbee2mqtt/commit/9792466aac8b94747fe41fe47db766b7a7e93367))
|
||||
* Implement systemd-notify directly ([#26456](https://github.com/Koenkk/zigbee2mqtt/issues/26456)) ([bfeac07](https://github.com/Koenkk/zigbee2mqtt/commit/bfeac07c4c38fb55db99d5f8204e85ec54552564))
|
||||
* Use dynamic import for optional extensions ([#26735](https://github.com/Koenkk/zigbee2mqtt/issues/26735)) ([db578d9](https://github.com/Koenkk/zigbee2mqtt/commit/db578d9d1180c01c14f1b4707e779c28961eb5b8))
|
||||
* Write `ZIGBEE2MQTT_CONFIG_*` envs to yaml as overrides ([#26653](https://github.com/Koenkk/zigbee2mqtt/issues/26653)) ([f6f7ba6](https://github.com/Koenkk/zigbee2mqtt/commit/f6f7ba68c9dd84ccdd82df0769b197756e42bbf7))
|
||||
|
||||
## [2.1.3](https://github.com/Koenkk/zigbee2mqtt/compare/2.1.2...2.1.3) (2025-03-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Home Assistant: fix brightness only lights ([#26560](https://github.com/Koenkk/zigbee2mqtt/issues/26560)) ([7e7057e](https://github.com/Koenkk/zigbee2mqtt/commit/7e7057eccd1fe638dd73b4a4f303c6612b73c89d))
|
||||
|
||||
## [2.1.2](https://github.com/Koenkk/zigbee2mqtt/compare/2.1.1...2.1.2) (2025-03-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add Home Assistant device class to external_temperature_input entity ([#26535](https://github.com/Koenkk/zigbee2mqtt/issues/26535)) ([e8031ef](https://github.com/Koenkk/zigbee2mqtt/commit/e8031efd2d95658c5f3fe72ee6e2f95dd584b5e8))
|
||||
* Allow configuring number of folders of old logs to keep ([#26398](https://github.com/Koenkk/zigbee2mqtt/issues/26398)) ([845e03e](https://github.com/Koenkk/zigbee2mqtt/commit/845e03e48acf1367feadeaae986f3c987bbc164d))
|
||||
* Fix Docker build hanging ([#26547](https://github.com/Koenkk/zigbee2mqtt/issues/26547)) ([e050e20](https://github.com/Koenkk/zigbee2mqtt/commit/e050e209b0d273cad6ad0efcf20761f33963da7b))
|
||||
* Fix HA MQTT dict entry indicating that an update is progress ([#26267](https://github.com/Koenkk/zigbee2mqtt/issues/26267)) ([b35cdc3](https://github.com/Koenkk/zigbee2mqtt/commit/b35cdc347423b219722c9f9dd0d2a82ded8eb24f))
|
||||
* Home Assistant: add `brightness` to `supported_color_modes` ([#26545](https://github.com/Koenkk/zigbee2mqtt/issues/26545)) ([063aba1](https://github.com/Koenkk/zigbee2mqtt/commit/063aba1b6c72901ed2d51a367485092691c358da))
|
||||
* Home Assistant: add `is_updating` attribute ([#26231](https://github.com/Koenkk/zigbee2mqtt/issues/26231)) ([1eda69a](https://github.com/Koenkk/zigbee2mqtt/commit/1eda69a50a8f92c3471acdbdc82b41055d5992f2))
|
||||
* Home Assistant: add missing `device_class` for some `current` and `power` entities ([#26324](https://github.com/Koenkk/zigbee2mqtt/issues/26324)) ([1e656c2](https://github.com/Koenkk/zigbee2mqtt/commit/1e656c2114d58b96fd7b7750e2e7d255a9175653))
|
||||
* Home Assistant: support speed-controlled fans ([#24483](https://github.com/Koenkk/zigbee2mqtt/issues/24483)) ([7ce5b58](https://github.com/Koenkk/zigbee2mqtt/commit/7ce5b581aa57d828043a1ca75ef7efe7b7bf1619))
|
||||
* **ignore:** Add `publish` to `meta` of toZigbee converters ([#26490](https://github.com/Koenkk/zigbee2mqtt/issues/26490)) ([07145ac](https://github.com/Koenkk/zigbee2mqtt/commit/07145accbae6c481f6d6740e66e27ab1baf5eb6d))
|
||||
* **ignore:** remove obsolete @types/eslint__js ([d522d2c](https://github.com/Koenkk/zigbee2mqtt/commit/d522d2cd18b8cb77fa50cd457823cfa573622cb7))
|
||||
* **ignore:** update dependencies ([#26266](https://github.com/Koenkk/zigbee2mqtt/issues/26266)) ([3e3ceff](https://github.com/Koenkk/zigbee2mqtt/commit/3e3cefff1b7f85f59b6bf529f6593339498561a1))
|
||||
* **ignore:** update dependencies ([#26383](https://github.com/Koenkk/zigbee2mqtt/issues/26383)) ([758c566](https://github.com/Koenkk/zigbee2mqtt/commit/758c56647d353e81ea22a34c2fa2665ed76dc149))
|
||||
* **ignore:** update dependencies ([#26478](https://github.com/Koenkk/zigbee2mqtt/issues/26478)) ([d2166f2](https://github.com/Koenkk/zigbee2mqtt/commit/d2166f2f4fa1e1e286face994ec4825a70144908))
|
||||
* **ignore:** update zigbee-herdsman to 3.2.6 ([#26303](https://github.com/Koenkk/zigbee2mqtt/issues/26303)) ([49fe147](https://github.com/Koenkk/zigbee2mqtt/commit/49fe1479213c928786c64adeb3031ca6fe91250d))
|
||||
* **ignore:** update zigbee-herdsman to 3.2.7 ([#26498](https://github.com/Koenkk/zigbee2mqtt/issues/26498)) ([4e650b7](https://github.com/Koenkk/zigbee2mqtt/commit/4e650b72cd187488893213018b619e933835a58e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.32.0 ([#26245](https://github.com/Koenkk/zigbee2mqtt/issues/26245)) ([8542b35](https://github.com/Koenkk/zigbee2mqtt/commit/8542b35706cefc87c9ec9dccb2980302ea25f100))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.33.0 ([#26258](https://github.com/Koenkk/zigbee2mqtt/issues/26258)) ([97c7485](https://github.com/Koenkk/zigbee2mqtt/commit/97c74852c95536945e33d0388dc5fcd0b132cd7e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.34.0 ([#26285](https://github.com/Koenkk/zigbee2mqtt/issues/26285)) ([7a0964e](https://github.com/Koenkk/zigbee2mqtt/commit/7a0964ebc905903b4ca5b168d9bd1041b004b4cf))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.35.0 ([#26305](https://github.com/Koenkk/zigbee2mqtt/issues/26305)) ([30d13d6](https://github.com/Koenkk/zigbee2mqtt/commit/30d13d6e5ee9678f7da812bd287128824a535d48))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.36.0 ([#26333](https://github.com/Koenkk/zigbee2mqtt/issues/26333)) ([ba76533](https://github.com/Koenkk/zigbee2mqtt/commit/ba76533c2f241c8362f43ad07d10b2fe084479a0))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.37.0 ([#26367](https://github.com/Koenkk/zigbee2mqtt/issues/26367)) ([dec5ca8](https://github.com/Koenkk/zigbee2mqtt/commit/dec5ca8b3a3703e6289652192657af9a035fe026))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.38.0 ([#26400](https://github.com/Koenkk/zigbee2mqtt/issues/26400)) ([93af062](https://github.com/Koenkk/zigbee2mqtt/commit/93af062a9b84358c866a5a1191ae5895fc6e866f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 22.0.1 ([#26418](https://github.com/Koenkk/zigbee2mqtt/issues/26418)) ([7d63a9a](https://github.com/Koenkk/zigbee2mqtt/commit/7d63a9a0f29445044bcfe0f3a4b629266db37654))
|
||||
* **ignore:** update zigbee-herdsman-converters to 22.1.0 ([#26445](https://github.com/Koenkk/zigbee2mqtt/issues/26445)) ([40f4147](https://github.com/Koenkk/zigbee2mqtt/commit/40f41477e776b4e75434251d03bf2844fceb7b13))
|
||||
* **ignore:** update zigbee-herdsman-converters to 22.2.0 ([#26469](https://github.com/Koenkk/zigbee2mqtt/issues/26469)) ([67e1a02](https://github.com/Koenkk/zigbee2mqtt/commit/67e1a0252517da28629a4c97b4dd610624d451a1))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.0.0 ([44f9fe7](https://github.com/Koenkk/zigbee2mqtt/commit/44f9fe7a1494a9450e247ad353ce788c621f901c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.1.0 ([#26509](https://github.com/Koenkk/zigbee2mqtt/issues/26509)) ([db7b51a](https://github.com/Koenkk/zigbee2mqtt/commit/db7b51ac801829801656d978aff2605d8bd7b56a))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.1.1 ([#26510](https://github.com/Koenkk/zigbee2mqtt/issues/26510)) ([102857a](https://github.com/Koenkk/zigbee2mqtt/commit/102857a7fec60e3f65887d92b6fe7a2d81add110))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.2.0 ([#26549](https://github.com/Koenkk/zigbee2mqtt/issues/26549)) ([9f72c5c](https://github.com/Koenkk/zigbee2mqtt/commit/9f72c5c4555bda5b347a6a5406f619b9668813ec))
|
||||
* Make compatible with pnpm 10 ([#26546](https://github.com/Koenkk/zigbee2mqtt/issues/26546)) ([29d25ae](https://github.com/Koenkk/zigbee2mqtt/commit/29d25aed66a66912fb55ede8751ba8056efb673f))
|
||||
* Support FreeBSD for `update.sh` ([#26261](https://github.com/Koenkk/zigbee2mqtt/issues/26261)) ([5e55ef0](https://github.com/Koenkk/zigbee2mqtt/commit/5e55ef045427f78b410e3d292387c3592366f698))
|
||||
|
||||
## [2.1.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.1.0...2.1.1) (2025-02-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ignore:** update dependencies ([#26126](https://github.com/Koenkk/zigbee2mqtt/issues/26126)) ([b960775](https://github.com/Koenkk/zigbee2mqtt/commit/b960775160b33dcd276376efaa7dc1ca3f791a53))
|
||||
* Rename `internal_temperature` to `internalTemperature` to correct typo ([#26114](https://github.com/Koenkk/zigbee2mqtt/issues/26114)) ([e4e4f16](https://github.com/Koenkk/zigbee2mqtt/commit/e4e4f16ec8994a63ba148b36d7e18a09f49ac298))
|
||||
|
||||
## [2.1.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.0.0...2.1.0) (2025-02-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add a settings option to log to console in json format ([#25649](https://github.com/Koenkk/zigbee2mqtt/issues/25649)) ([26ef565](https://github.com/Koenkk/zigbee2mqtt/commit/26ef565c8a20d1b6c2e09c679eb96d3f56811a87))
|
||||
* Allow Home Assistant OTA Update entity to show progress while updating ([#25632](https://github.com/Koenkk/zigbee2mqtt/issues/25632)) ([318dc04](https://github.com/Koenkk/zigbee2mqtt/commit/318dc0413284d204d13a99afd400a5f00f5ef338))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add Home Assistant device class for flow rates (e.g., Sonoff SWV) ([#26035](https://github.com/Koenkk/zigbee2mqtt/issues/26035)) ([cdbd553](https://github.com/Koenkk/zigbee2mqtt/commit/cdbd55320acbf7d857c518e8aac8ab9e495eb6b5))
|
||||
* Add semver tags to Docker releases ([#25530](https://github.com/Koenkk/zigbee2mqtt/issues/25530)) ([0d30eb5](https://github.com/Koenkk/zigbee2mqtt/commit/0d30eb553b9cb027f8ae5424b9e740136742f74c))
|
||||
* Allow to set throttle via frontend ([#25997](https://github.com/Koenkk/zigbee2mqtt/issues/25997)) ([5cf4547](https://github.com/Koenkk/zigbee2mqtt/commit/5cf4547fe04e9a5627826225c3b26586aea3edc1))
|
||||
* Container: remove modifying capabilities of `/usr/bin/node` ([#25456](https://github.com/Koenkk/zigbee2mqtt/issues/25456)) ([43a5b24](https://github.com/Koenkk/zigbee2mqtt/commit/43a5b24dcc22af3ec4c0bb76b45a2706f898421f))
|
||||
* Enable use of MQTT username without password ([#25611](https://github.com/Koenkk/zigbee2mqtt/issues/25611)) ([a28a5ba](https://github.com/Koenkk/zigbee2mqtt/commit/a28a5baf17b8dd3fe4703cdb38d80796157521af))
|
||||
* Fix `Error: write after end` error ([#25737](https://github.com/Koenkk/zigbee2mqtt/issues/25737)) ([2d5e7bf](https://github.com/Koenkk/zigbee2mqtt/commit/2d5e7bf9ff611387f2334a55caab6991d65ad3fa))
|
||||
* Fix `Failed to call 'Frontend' 'stop' (TypeError: Cannot read properties of undefined (reading 'close')` https://github.com/Koenkk/zigbee2mqtt/issues/25715 ([9daeba1](https://github.com/Koenkk/zigbee2mqtt/commit/9daeba1f0ab350d9609bcd4c1e8bc291b5eaab8b))
|
||||
* Fix duplicate `action` publish when `advanced.output` has `attribute` ([#25963](https://github.com/Koenkk/zigbee2mqtt/issues/25963)) ([2af65cf](https://github.com/Koenkk/zigbee2mqtt/commit/2af65cf28eea397013a0f629761536339d6ace63))
|
||||
* **ignore:** Ensure config example is never out of sync with settings ([#25707](https://github.com/Koenkk/zigbee2mqtt/issues/25707)) ([23b709c](https://github.com/Koenkk/zigbee2mqtt/commit/23b709cee28a5926f1608e70e30703ba52011005))
|
||||
* **ignore:** Remove `adapter` `auto` from schema ([#25896](https://github.com/Koenkk/zigbee2mqtt/issues/25896)) ([5e60f1d](https://github.com/Koenkk/zigbee2mqtt/commit/5e60f1dc8dae96a04d1a76a677f89f426da64a94))
|
||||
* **ignore:** update dependencies ([#25535](https://github.com/Koenkk/zigbee2mqtt/issues/25535)) ([6f3524b](https://github.com/Koenkk/zigbee2mqtt/commit/6f3524b251ed8218ffed132df262eb0850687e61))
|
||||
* **ignore:** update dependencies ([#25760](https://github.com/Koenkk/zigbee2mqtt/issues/25760)) ([5b82d7e](https://github.com/Koenkk/zigbee2mqtt/commit/5b82d7e4df9fdc868689f8a745e502b5beea837a))
|
||||
* **ignore:** update dependencies ([#25890](https://github.com/Koenkk/zigbee2mqtt/issues/25890)) ([8da5fa6](https://github.com/Koenkk/zigbee2mqtt/commit/8da5fa663d2fbf4a68e797cff9938b57dd620935))
|
||||
* **ignore:** update dependencies ([#26005](https://github.com/Koenkk/zigbee2mqtt/issues/26005)) ([85460cd](https://github.com/Koenkk/zigbee2mqtt/commit/85460cd5ce2a17a2036a54d3984a5f59b75be41b))
|
||||
* Long startup time when `OnEvent` 'start' times out (repeatedly) ([#25693](https://github.com/Koenkk/zigbee2mqtt/issues/25693)) ([f1f73bd](https://github.com/Koenkk/zigbee2mqtt/commit/f1f73bd46425262fc1ff7e897d879faf16e3d8a9))
|
||||
* Only use endpoint-specific states if the device definition uses them ([#26019](https://github.com/Koenkk/zigbee2mqtt/issues/26019)) ([0462b94](https://github.com/Koenkk/zigbee2mqtt/commit/0462b94f5a346e64a85a90cb6c7e33a36ac98311))
|
||||
* Remove invalid `warn` `log_level` https://github.com/nurikk/zigbee2mqtt-frontend/issues/2369 ([#25634](https://github.com/Koenkk/zigbee2mqtt/issues/25634)) ([1667ba6](https://github.com/Koenkk/zigbee2mqtt/commit/1667ba64ac2b8272cb0dea7ce30e459450e1c847))
|
||||
|
||||
## [2.0.0](https://github.com/Koenkk/zigbee2mqtt/compare/1.42.0...2.0.0) (2025-01-03)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* Change default `homeassistant.status_topic` to `homeassistant/status` ([#25286](https://github.com/Koenkk/zigbee2mqtt/issues/25286))
|
||||
* Docker: update Alpine to 3.21, drop x86 support ([#25171](https://github.com/Koenkk/zigbee2mqtt/issues/25171))
|
||||
* Remove `permit_join_timeout` ([#25082](https://github.com/Koenkk/zigbee2mqtt/issues/25082))
|
||||
* Home Assistant: remove duplicate `sensor`/`select` for `select`/`number`/`button` entities ([#25026](https://github.com/Koenkk/zigbee2mqtt/issues/25026))
|
||||
* Ensure `data` in response is always empty on `error` ([#24971](https://github.com/Koenkk/zigbee2mqtt/issues/24971))
|
||||
* Rework OTA ([#24634](https://github.com/Koenkk/zigbee2mqtt/issues/24634))
|
||||
* Remove deprecated Home Assistant integration features ([#24494](https://github.com/Koenkk/zigbee2mqtt/issues/24494))
|
||||
* Improve bind/bridge/groups parsing and resolving consistency ([#24432](https://github.com/Koenkk/zigbee2mqtt/issues/24432))
|
||||
* Remove configuring group members through `configuration.yaml` ([#24338](https://github.com/Koenkk/zigbee2mqtt/issues/24338))
|
||||
* Improve permit join ([#24257](https://github.com/Koenkk/zigbee2mqtt/issues/24257))
|
||||
* The big cleanup ([#24200](https://github.com/Koenkk/zigbee2mqtt/issues/24200))
|
||||
* HA permit join switch: disable automatically after 254 seconds ([#24245](https://github.com/Koenkk/zigbee2mqtt/issues/24245))
|
||||
|
||||
### Features
|
||||
|
||||
* Automatic settings migration ([#24871](https://github.com/Koenkk/zigbee2mqtt/issues/24871)) ([c6958c4](https://github.com/Koenkk/zigbee2mqtt/commit/c6958c4991c2cfbac87907c78e75bc634401a894))
|
||||
* Docker: RISC-V support ([#22824](https://github.com/Koenkk/zigbee2mqtt/issues/22824)) ([e23d300](https://github.com/Koenkk/zigbee2mqtt/commit/e23d300fd9f5c2380560b2d07071cb332ea93339))
|
||||
* Home Assistant: support endpoints for locks ([#25359](https://github.com/Koenkk/zigbee2mqtt/issues/25359)) ([c586df8](https://github.com/Koenkk/zigbee2mqtt/commit/c586df82429546fba56ab10b579bf225bb33ebb2))
|
||||
* Live loading of external JS (converters/extensions) ([#24764](https://github.com/Koenkk/zigbee2mqtt/issues/24764)) ([9907005](https://github.com/Koenkk/zigbee2mqtt/commit/9907005256c65902cbb9475e9410b795b262b41a))
|
||||
* The big cleanup ([#24200](https://github.com/Koenkk/zigbee2mqtt/issues/24200)) ([b618662](https://github.com/Koenkk/zigbee2mqtt/commit/b6186623f0bcf9d88de1772aebd8a9eaead21a4c))
|
||||
* Typing for MQTT API ([#25098](https://github.com/Koenkk/zigbee2mqtt/issues/25098)) ([1a9c79b](https://github.com/Koenkk/zigbee2mqtt/commit/1a9c79bb2f9b5e123b8cd017e82498ab534596fd))
|
||||
* Use Vitest for testing ([#25284](https://github.com/Koenkk/zigbee2mqtt/issues/25284)) ([06f4453](https://github.com/Koenkk/zigbee2mqtt/commit/06f44533af488cd632d4eec78e2c629d8d6ef278))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add `cap_net_bind_service=+ep` to `/usr/bin/node` in Docker container ([#25385](https://github.com/Koenkk/zigbee2mqtt/issues/25385)) ([#25387](https://github.com/Koenkk/zigbee2mqtt/issues/25387)) ([61864f0](https://github.com/Koenkk/zigbee2mqtt/commit/61864f0fb2c7ffc90bbb85c969ae5b589dbbb92b))
|
||||
* Add more OCI Image Labels ([#25074](https://github.com/Koenkk/zigbee2mqtt/issues/25074)) ([b370b80](https://github.com/Koenkk/zigbee2mqtt/commit/b370b80dfb6701b8601c32f44102aec60a5d5ac8))
|
||||
* Change default `homeassistant.status_topic` to `homeassistant/status` ([#25286](https://github.com/Koenkk/zigbee2mqtt/issues/25286)) ([2abf55c](https://github.com/Koenkk/zigbee2mqtt/commit/2abf55ccf8de35c2ec3463e29c31a80a35d7392b))
|
||||
* Cleanup Dockerfile ([23d8cea](https://github.com/Koenkk/zigbee2mqtt/commit/23d8cea4e243429bf5a376db94d09f535776de49))
|
||||
* Cleanup leftover legacy settings logic ([#24947](https://github.com/Koenkk/zigbee2mqtt/issues/24947)) ([d78c6fa](https://github.com/Koenkk/zigbee2mqtt/commit/d78c6facab57e0ec5676eb76ec099f5dce0f801a))
|
||||
* Docker: update Alpine to 3.21, drop x86 support ([#25171](https://github.com/Koenkk/zigbee2mqtt/issues/25171)) ([32c341c](https://github.com/Koenkk/zigbee2mqtt/commit/32c341c7f58ef920e99940f0bf4b2218301dc377))
|
||||
* Ensure `data` in response is always empty on `error` ([#24971](https://github.com/Koenkk/zigbee2mqtt/issues/24971)) ([a6b3605](https://github.com/Koenkk/zigbee2mqtt/commit/a6b36051f032a2acebefe0232a20abebfb9641be))
|
||||
* Fix `Cannot read properties of null (reading 'toString')` when Home Assistant event entities are enabled https://github.com/Koenkk/zigbee2mqtt/issues/25133 ([9eea58e](https://github.com/Koenkk/zigbee2mqtt/commit/9eea58eba471b9e670dff327a8666e9c78b223e0))
|
||||
* Fix dual cover Home Assistant discovery ([#25240](https://github.com/Koenkk/zigbee2mqtt/issues/25240)) ([ef6524b](https://github.com/Koenkk/zigbee2mqtt/commit/ef6524b93fb8e77ab2944715c2a2d3f3e3bd2923))
|
||||
* Fix group with only multi endpoints devices not being controllable ([#25156](https://github.com/Koenkk/zigbee2mqtt/issues/25156)) ([7242889](https://github.com/Koenkk/zigbee2mqtt/commit/72428897a7ab78057aa3aafa5d2a0e112e6a2a73))
|
||||
* Fix Home Assistant event entities for openHAB ([#25140](https://github.com/Koenkk/zigbee2mqtt/issues/25140)) ([226945c](https://github.com/Koenkk/zigbee2mqtt/commit/226945cae031007a86865dbbe22a42e951999f03))
|
||||
* Fix log directories not being deleted ([#25282](https://github.com/Koenkk/zigbee2mqtt/issues/25282)) ([7170215](https://github.com/Koenkk/zigbee2mqtt/commit/7170215e96d30259d7673cb9ae77c0d6d741431d))
|
||||
* Fix URL in startup failed bailout message ([#25339](https://github.com/Koenkk/zigbee2mqtt/issues/25339)) ([bcec40f](https://github.com/Koenkk/zigbee2mqtt/commit/bcec40f84a73142efef41d239fea0c77ce4bdfa3))
|
||||
* Fixed calculation of group state when including non-default endpoints ([#25059](https://github.com/Koenkk/zigbee2mqtt/issues/25059)) ([9227e13](https://github.com/Koenkk/zigbee2mqtt/commit/9227e139de86299eb3ec4b1585bdd21448bfa94a))
|
||||
* HA permit join switch: disable automatically after 254 seconds ([#24245](https://github.com/Koenkk/zigbee2mqtt/issues/24245)) ([3220b27](https://github.com/Koenkk/zigbee2mqtt/commit/3220b27c8a91fc65912d299db22b8ac489b929b2))
|
||||
* Home Assistant: expose the internal_temperature sensor with the correct device class ([#25289](https://github.com/Koenkk/zigbee2mqtt/issues/25289)) ([5d059b7](https://github.com/Koenkk/zigbee2mqtt/commit/5d059b7bcb7f710df93e34b2bb56a1e880757c05))
|
||||
* Home Assistant: remove duplicate `sensor`/`select` for `select`/`number`/`button` entities ([#25026](https://github.com/Koenkk/zigbee2mqtt/issues/25026)) ([ba45445](https://github.com/Koenkk/zigbee2mqtt/commit/ba45445544948ad74e3db67ffee1d23996a37fb5))
|
||||
* Home Assistant: use extended device identifiers ([#25389](https://github.com/Koenkk/zigbee2mqtt/issues/25389)) ([60c37c1](https://github.com/Koenkk/zigbee2mqtt/commit/60c37c11d0a2e22e0bec0b7c50d5725f3b062b05))
|
||||
* **ignore:** Add back `linux/386` ([#25187](https://github.com/Koenkk/zigbee2mqtt/issues/25187)) ([a2027d7](https://github.com/Koenkk/zigbee2mqtt/commit/a2027d79e899e8db22d3ca090d28d1fa4b9e3bc6))
|
||||
* **ignore:** Add back Home Assistant legacy action sensor ([#25192](https://github.com/Koenkk/zigbee2mqtt/issues/25192)) ([39c007d](https://github.com/Koenkk/zigbee2mqtt/commit/39c007d211453b3dc13335edba2396c13163ae96))
|
||||
* **ignore:** add cap_net_bind_service=+ep to /usr/bin/node ([#25385](https://github.com/Koenkk/zigbee2mqtt/issues/25385)) ([#25424](https://github.com/Koenkk/zigbee2mqtt/issues/25424)) ([7cf01e9](https://github.com/Koenkk/zigbee2mqtt/commit/7cf01e988209b78660ae1cb6fef609906db1df0b))
|
||||
* **ignore:** Changes for improved adapter discovery ([#24147](https://github.com/Koenkk/zigbee2mqtt/issues/24147)) ([98b9770](https://github.com/Koenkk/zigbee2mqtt/commit/98b977030ec73e5a75c28f4f779fce9189b12d13))
|
||||
* **ignore:** Fix 2 to 3 migration enabling frontend/availabilty/homeassistant when not set ([#25349](https://github.com/Koenkk/zigbee2mqtt/issues/25349)) ([02666e9](https://github.com/Koenkk/zigbee2mqtt/commit/02666e91393ec304e1098c01cd468889cccbd9d6))
|
||||
* **ignore:** Fix availabilty, frontend and homeassistant cannot be disabled via the frontend https://github.com/nurikk/zigbee2mqtt-frontend/issues/2339 ([ed75553](https://github.com/Koenkk/zigbee2mqtt/commit/ed7555360392e07b0ae22167dc28e4996e2148cc))
|
||||
* **ignore:** Fix external converter loading ([#25063](https://github.com/Koenkk/zigbee2mqtt/issues/25063)) ([51f364a](https://github.com/Koenkk/zigbee2mqtt/commit/51f364a1d5dfd46f50830c6412ae78fb74994929))
|
||||
* **ignore:** fix tests ([b581dc6](https://github.com/Koenkk/zigbee2mqtt/commit/b581dc6994c92e125b9a83a69ae74f23f4dc20b9))
|
||||
* **ignore:** Fixed additional case of calculation of group state when including no… ([#25104](https://github.com/Koenkk/zigbee2mqtt/issues/25104)) ([d891320](https://github.com/Koenkk/zigbee2mqtt/commit/d891320314b48d463d2926da8fb1f674913efcc9))
|
||||
* **ignore:** Force rebuild of @serialport/bindings-cpp in Docker ([#25202](https://github.com/Koenkk/zigbee2mqtt/issues/25202)) ([0b62aef](https://github.com/Koenkk/zigbee2mqtt/commit/0b62aefbb28d0376f89920f962a88ec5728e0b43))
|
||||
* **ignore:** import using `node:` prefix for built-in ([#25248](https://github.com/Koenkk/zigbee2mqtt/issues/25248)) ([758ab34](https://github.com/Koenkk/zigbee2mqtt/commit/758ab34747baeaf62e7a1552cd452c4445279ff1))
|
||||
* **ignore:** Improvements for [#25299](https://github.com/Koenkk/zigbee2mqtt/issues/25299) ([#25364](https://github.com/Koenkk/zigbee2mqtt/issues/25364)) ([1e7e00e](https://github.com/Koenkk/zigbee2mqtt/commit/1e7e00e8ddd2e491e6ba7d8b8f31121f15f66d31))
|
||||
* **ignore:** Minor cleanup ([#25166](https://github.com/Koenkk/zigbee2mqtt/issues/25166)) ([ec13ebd](https://github.com/Koenkk/zigbee2mqtt/commit/ec13ebd3ad461c3dc06bafda23d1bb87a94f3768))
|
||||
* **ignore:** overrides ([#25246](https://github.com/Koenkk/zigbee2mqtt/issues/25246)) ([c88f630](https://github.com/Koenkk/zigbee2mqtt/commit/c88f630299714d7496165fa1701d2906689e5180))
|
||||
* **ignore:** Rebuild serialport, attempt to fix [#25092](https://github.com/Koenkk/zigbee2mqtt/issues/25092) ([146b6c3](https://github.com/Koenkk/zigbee2mqtt/commit/146b6c386041b5139e3a1dff0688c7bbaaa88ea1))
|
||||
* **ignore:** remove `illuminance_lux` from cached payload https://github.com/Koenkk/zigbee2mqtt/issues/25379 ([2713125](https://github.com/Koenkk/zigbee2mqtt/commit/271312576824ea9315373be72d1a6480ab1e8db3))
|
||||
* **ignore:** remove dep in favor of built-in ([#25250](https://github.com/Koenkk/zigbee2mqtt/issues/25250)) ([1c75eef](https://github.com/Koenkk/zigbee2mqtt/commit/1c75eef16d1ed0c1748b23fcc9f9b39091b7b9b6))
|
||||
* **ignore:** Remove external definition type ([8aef77e](https://github.com/Koenkk/zigbee2mqtt/commit/8aef77e57e76aeb23ba9fd5f848ea5ee50c14a56))
|
||||
* **ignore:** Small cleanup ([#24696](https://github.com/Koenkk/zigbee2mqtt/issues/24696)) ([95279b0](https://github.com/Koenkk/zigbee2mqtt/commit/95279b02afe87788c2080ad7b9b2df2c5cbb5f7b))
|
||||
* **ignore:** some fixes ([cc61f67](https://github.com/Koenkk/zigbee2mqtt/commit/cc61f672155918c11ec7ac6c48096b9f0629695e))
|
||||
* **ignore:** update dependencies ([#24999](https://github.com/Koenkk/zigbee2mqtt/issues/24999)) ([32e10d8](https://github.com/Koenkk/zigbee2mqtt/commit/32e10d89f48871d8646f31772569fddab6d6824a))
|
||||
* **ignore:** update dependencies ([#25120](https://github.com/Koenkk/zigbee2mqtt/issues/25120)) ([7803a16](https://github.com/Koenkk/zigbee2mqtt/commit/7803a166d233ba0067e05ddf2afd35569f8d07e7))
|
||||
* **ignore:** update dependencies ([#25210](https://github.com/Koenkk/zigbee2mqtt/issues/25210)) ([56746de](https://github.com/Koenkk/zigbee2mqtt/commit/56746ded19013d47c681da142444b3c69bfd8992))
|
||||
* **ignore:** update dependencies ([#25288](https://github.com/Koenkk/zigbee2mqtt/issues/25288)) ([2128e83](https://github.com/Koenkk/zigbee2mqtt/commit/2128e8323cae6a79774d570ba36f0077586d09e4))
|
||||
* **ignore:** update dependencies ([#25360](https://github.com/Koenkk/zigbee2mqtt/issues/25360)) ([47d73eb](https://github.com/Koenkk/zigbee2mqtt/commit/47d73eb23a3c452f1757587184ba5a066f0e5edc))
|
||||
* **ignore:** update deps ([1ce45c8](https://github.com/Koenkk/zigbee2mqtt/commit/1ce45c8c4176a17d7aa03117fc6f39510b06f4a4))
|
||||
* **ignore:** Update usb discovery discussion link ([9bd20af](https://github.com/Koenkk/zigbee2mqtt/commit/9bd20afb9c7172a67c2d36f373ab151f20027cb9))
|
||||
* **ignore:** update zigbee-herdsman to 3.0.3 ([0a20e4a](https://github.com/Koenkk/zigbee2mqtt/commit/0a20e4a455ef1e25ab7134d6901512cb4980fe93))
|
||||
* **ignore:** Update zigbee-herdsman-converters to 21.4.0 ([#25191](https://github.com/Koenkk/zigbee2mqtt/issues/25191)) ([5e71cf2](https://github.com/Koenkk/zigbee2mqtt/commit/5e71cf2cc84cd6c5c1c24d801a1f2dadee4e7068))
|
||||
* **ignore:** Use new exports from ZH ([#25350](https://github.com/Koenkk/zigbee2mqtt/issues/25350)) ([120a5b4](https://github.com/Koenkk/zigbee2mqtt/commit/120a5b43e90a8fb206263e937c0031c3c1163da7))
|
||||
* Improve bind/bridge/groups parsing and resolving consistency ([#24432](https://github.com/Koenkk/zigbee2mqtt/issues/24432)) ([9537ef0](https://github.com/Koenkk/zigbee2mqtt/commit/9537ef05d563de815b24e45176a1bec68eb811b8))
|
||||
* Improve device icon serving ([#25299](https://github.com/Koenkk/zigbee2mqtt/issues/25299)) ([329b8c9](https://github.com/Koenkk/zigbee2mqtt/commit/329b8c9c27c2ba2537b59ef16241a6e84167fa7b))
|
||||
* Improve permit join ([#24257](https://github.com/Koenkk/zigbee2mqtt/issues/24257)) ([2124d34](https://github.com/Koenkk/zigbee2mqtt/commit/2124d3450cbc5ad5901a7fa2d657b881152a6db6))
|
||||
* OpenHAB: Fix rejectattr argument in `event` value_template ([#25179](https://github.com/Koenkk/zigbee2mqtt/issues/25179)) ([08ac795](https://github.com/Koenkk/zigbee2mqtt/commit/08ac795bb39854d29388e7ff1e12002cd89b87f8))
|
||||
* Proper extended PAN ID from `getNetworkParameters` ([#25174](https://github.com/Koenkk/zigbee2mqtt/issues/25174)) ([2b08c68](https://github.com/Koenkk/zigbee2mqtt/commit/2b08c688d3fab77fc1becb6646eb038040e5cf0a))
|
||||
* Refactor tests to TS ([#24357](https://github.com/Koenkk/zigbee2mqtt/issues/24357)) ([6f6e7c3](https://github.com/Koenkk/zigbee2mqtt/commit/6f6e7c3ee040504231e1aecda004edbd16301578))
|
||||
* Remove `permit_join_timeout` ([#25082](https://github.com/Koenkk/zigbee2mqtt/issues/25082)) ([7d63a87](https://github.com/Koenkk/zigbee2mqtt/commit/7d63a87aad954225c77156d22576939b1ffe7d6c))
|
||||
* Remove configuring group members through `configuration.yaml` ([#24338](https://github.com/Koenkk/zigbee2mqtt/issues/24338)) ([3f76586](https://github.com/Koenkk/zigbee2mqtt/commit/3f76586a506a3674333d17cff93d32810c76687e))
|
||||
* Remove deprecated Home Assistant integration features ([#24494](https://github.com/Koenkk/zigbee2mqtt/issues/24494)) ([e290057](https://github.com/Koenkk/zigbee2mqtt/commit/e290057e21ce5551563068420ea2942979206509))
|
||||
* Remove oneOf bool/obj from settings schema ([#25195](https://github.com/Koenkk/zigbee2mqtt/issues/25195)) ([c3b4628](https://github.com/Koenkk/zigbee2mqtt/commit/c3b4628a1d6891d186da01d9f4a7a3b089617fd4))
|
||||
* Rework OTA ([#24634](https://github.com/Koenkk/zigbee2mqtt/issues/24634)) ([563514c](https://github.com/Koenkk/zigbee2mqtt/commit/563514c446e39134ccd18829b10f6d2e02bef09b))
|
||||
* Use built-in async for mqtt ([#24786](https://github.com/Koenkk/zigbee2mqtt/issues/24786)) ([e052988](https://github.com/Koenkk/zigbee2mqtt/commit/e052988663d967ed8c41ecb5ea0e52e6f85905b4))
|
||||
|
||||
## [1.42.0](https://github.com/Koenkk/zigbee2mqtt/compare/1.41.0...1.42.0) (2024-12-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add experimental support for Home Assistant `event` entities ([#24233](https://github.com/Koenkk/zigbee2mqtt/issues/24233)) ([848f250](https://github.com/Koenkk/zigbee2mqtt/commit/848f250dbb7e0142c7e702dab97536b8be749f5b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Home Assistant `event` entities, part 2 ([#24717](https://github.com/Koenkk/zigbee2mqtt/issues/24717)) ([22e13c5](https://github.com/Koenkk/zigbee2mqtt/commit/22e13c52b96152a24d735791c313b8c6c2cf68e7))
|
||||
* Home Assistant: remove the diagnostic category classification for power ([#24575](https://github.com/Koenkk/zigbee2mqtt/issues/24575)) ([c078ccb](https://github.com/Koenkk/zigbee2mqtt/commit/c078ccb13606a0893a19f25378197be70c0127f6))
|
||||
* Home Assistant: template errors when `action` key is missing or empty in state payload ([#24931](https://github.com/Koenkk/zigbee2mqtt/issues/24931)) ([5ca4cf4](https://github.com/Koenkk/zigbee2mqtt/commit/5ca4cf4acd758b9c8163c3f8d07cb9903d91e118))
|
||||
* **ignore:** Fix `Cannot read properties of undefined (reading 'clients')` https://github.com/Koenkk/zigbee2mqtt/issues/24957 ([7047aef](https://github.com/Koenkk/zigbee2mqtt/commit/7047aef7f7810225e80d923e6c45d5922562e12b))
|
||||
* **ignore:** Ignore `None` actions for HA events ([#24972](https://github.com/Koenkk/zigbee2mqtt/issues/24972)) ([4690fe1](https://github.com/Koenkk/zigbee2mqtt/commit/4690fe1a55b4d6a2a8ad39c21c157b67a41d5f62))
|
||||
* **ignore:** Prevent empty events from being published to HA ([#24969](https://github.com/Koenkk/zigbee2mqtt/issues/24969)) ([826547a](https://github.com/Koenkk/zigbee2mqtt/commit/826547a8e14beabfced2d9da408fa1d7a8e8a015))
|
||||
* **ignore:** Prevent incorrect `event_type` due to duplicate keys ([#24954](https://github.com/Koenkk/zigbee2mqtt/issues/24954)) ([d82d782](https://github.com/Koenkk/zigbee2mqtt/commit/d82d782bac0deda328e7025f9a2bcbd50fecf857))
|
||||
* **ignore:** update dependencies ([#24614](https://github.com/Koenkk/zigbee2mqtt/issues/24614)) ([84cc92f](https://github.com/Koenkk/zigbee2mqtt/commit/84cc92f91a020c86d5ae3938c635512cd106cd67))
|
||||
* **ignore:** update dependencies ([#24702](https://github.com/Koenkk/zigbee2mqtt/issues/24702)) ([928635f](https://github.com/Koenkk/zigbee2mqtt/commit/928635f1daea6e3e80afc30ff1eca3bc83de7f97))
|
||||
* **ignore:** Update dependencies ([#24894](https://github.com/Koenkk/zigbee2mqtt/issues/24894)) ([ca64935](https://github.com/Koenkk/zigbee2mqtt/commit/ca6493528db3ffb374068e837ec0151f079a6ac8))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.8 ([#24763](https://github.com/Koenkk/zigbee2mqtt/issues/24763)) ([2a9bbb2](https://github.com/Koenkk/zigbee2mqtt/commit/2a9bbb21ba5c22bf32d702f02b60084c4e12f140))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.9 ([#24819](https://github.com/Koenkk/zigbee2mqtt/issues/24819)) ([152ad12](https://github.com/Koenkk/zigbee2mqtt/commit/152ad12508724be23f1792238e4bc37f48413ec0))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.40.0 ([#24644](https://github.com/Koenkk/zigbee2mqtt/issues/24644)) ([1558a5d](https://github.com/Koenkk/zigbee2mqtt/commit/1558a5d8ace136ac1469f34502901d368603cbd1))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.41.0 ([#24656](https://github.com/Koenkk/zigbee2mqtt/issues/24656)) ([f2e5d59](https://github.com/Koenkk/zigbee2mqtt/commit/f2e5d59c3582ec12e1717c9c28520d54c593dd0e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.42.0 ([#24666](https://github.com/Koenkk/zigbee2mqtt/issues/24666)) ([b5d4a25](https://github.com/Koenkk/zigbee2mqtt/commit/b5d4a253b4ef5b4930f8d84e878e2343cc0b38df))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.43.0 ([#24680](https://github.com/Koenkk/zigbee2mqtt/issues/24680)) ([a353acc](https://github.com/Koenkk/zigbee2mqtt/commit/a353acc8e3e98bf9ea3a935c1ee179aed335ff58))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.44.0 ([#24715](https://github.com/Koenkk/zigbee2mqtt/issues/24715)) ([32e2637](https://github.com/Koenkk/zigbee2mqtt/commit/32e2637594add5c6fa8f8bfccc28b0f759a5d873))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.45.0 ([#24733](https://github.com/Koenkk/zigbee2mqtt/issues/24733)) ([b8c3104](https://github.com/Koenkk/zigbee2mqtt/commit/b8c3104aa1baaf03c3db7954bc92b5ac71d985c4))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.46.0 ([#24762](https://github.com/Koenkk/zigbee2mqtt/issues/24762)) ([6e04f7b](https://github.com/Koenkk/zigbee2mqtt/commit/6e04f7b03e8c7f0493007ce1d9d359f819088557))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.47.0 ([#24772](https://github.com/Koenkk/zigbee2mqtt/issues/24772)) ([36e452b](https://github.com/Koenkk/zigbee2mqtt/commit/36e452b0f3284eb5ab872fb2d0aab04bbdfe4cee))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.49.1 ([#24830](https://github.com/Koenkk/zigbee2mqtt/issues/24830)) ([5859947](https://github.com/Koenkk/zigbee2mqtt/commit/5859947207adf8597436971706cdff101c1effbe))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.50.0 ([#24843](https://github.com/Koenkk/zigbee2mqtt/issues/24843)) ([988984a](https://github.com/Koenkk/zigbee2mqtt/commit/988984a1ae069d90ce1f67c165dd76c4edafa656))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.51.0 ([#24856](https://github.com/Koenkk/zigbee2mqtt/issues/24856)) ([82a026e](https://github.com/Koenkk/zigbee2mqtt/commit/82a026e02e0ecc662cf8302ff3005793cb66a23f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.52.0 ([#24874](https://github.com/Koenkk/zigbee2mqtt/issues/24874)) ([1e77893](https://github.com/Koenkk/zigbee2mqtt/commit/1e77893f759908bafc373b047b6409d227516759))
|
||||
* **ignore:** Update zigbee-herdsman-converters to 20.53.0 ([#24906](https://github.com/Koenkk/zigbee2mqtt/issues/24906)) ([790373b](https://github.com/Koenkk/zigbee2mqtt/commit/790373b896efdb6d9ccb5c29c97812db7445c6a3))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.7.5 ([#24631](https://github.com/Koenkk/zigbee2mqtt/issues/24631)) ([0c2cc10](https://github.com/Koenkk/zigbee2mqtt/commit/0c2cc10ad8fd8de1ac88566f8ca1a0d131b1055b))
|
||||
* Replace connect-gzip-static with express-static-gzip to become compatible with Node 23 ([#24619](https://github.com/Koenkk/zigbee2mqtt/issues/24619)) ([87cee1a](https://github.com/Koenkk/zigbee2mqtt/commit/87cee1aea6eace2efb31a2d30551b5ea32187e95))
|
||||
* Temporarily disable serving compressed index file. ([#24657](https://github.com/Koenkk/zigbee2mqtt/issues/24657)) ([7267d27](https://github.com/Koenkk/zigbee2mqtt/commit/7267d27779c854a216279b28b27551310b9cee97))
|
||||
|
||||
## [1.41.0](https://github.com/Koenkk/zigbee2mqtt/compare/1.40.2...1.41.0) (2024-10-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add `throttle` option for devices ([#24122](https://github.com/Koenkk/zigbee2mqtt/issues/24122)) ([17ac9cf](https://github.com/Koenkk/zigbee2mqtt/commit/17ac9cf8e777dfa4b86066650f3c6b2712b011b6))
|
||||
* Allow serving frontend under subpath ([#24244](https://github.com/Koenkk/zigbee2mqtt/issues/24244)) ([1fdf0a0](https://github.com/Koenkk/zigbee2mqtt/commit/1fdf0a0a47d2b11962862ff433089d7f17bb7421))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add additional discovery lookups for Home Assistant ([#24555](https://github.com/Koenkk/zigbee2mqtt/issues/24555)) ([a2ce323](https://github.com/Koenkk/zigbee2mqtt/commit/a2ce3230acff41ed52b684385130b67dbee3ad17))
|
||||
* Fix group state change for open/close devices ([#24226](https://github.com/Koenkk/zigbee2mqtt/issues/24226)) ([076ada2](https://github.com/Koenkk/zigbee2mqtt/commit/076ada23257dccbe206ae9de9e70be850e756262))
|
||||
* Frontend code cleanup @Nerivec ([#24322](https://github.com/Koenkk/zigbee2mqtt/issues/24322)) ([e2f19f1](https://github.com/Koenkk/zigbee2mqtt/commit/e2f19f19b4413fc4cdad6f64879840497bb2ca81))
|
||||
* **ignore:** update dependencies ([#24236](https://github.com/Koenkk/zigbee2mqtt/issues/24236)) ([5c3f712](https://github.com/Koenkk/zigbee2mqtt/commit/5c3f71292d330210474e381d18e73840ffd5f5df))
|
||||
* **ignore:** update dependencies ([#24309](https://github.com/Koenkk/zigbee2mqtt/issues/24309)) ([9f12ebb](https://github.com/Koenkk/zigbee2mqtt/commit/9f12ebb54e207ffbde7769f55ad494d689142d9c))
|
||||
* **ignore:** update dependencies ([#24411](https://github.com/Koenkk/zigbee2mqtt/issues/24411)) ([eb606a5](https://github.com/Koenkk/zigbee2mqtt/commit/eb606a5b9832866b494d24dc6ebfe4e776069780))
|
||||
* **ignore:** update dependencies ([#24498](https://github.com/Koenkk/zigbee2mqtt/issues/24498)) ([2139e7a](https://github.com/Koenkk/zigbee2mqtt/commit/2139e7a794c6bb1f32889eb1a951b039da124231))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.4 ([#24397](https://github.com/Koenkk/zigbee2mqtt/issues/24397)) ([12cb6e1](https://github.com/Koenkk/zigbee2mqtt/commit/12cb6e135d20e1631562d5b8e7d2bdf26cf22aa0))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.5 ([#24433](https://github.com/Koenkk/zigbee2mqtt/issues/24433)) ([6477865](https://github.com/Koenkk/zigbee2mqtt/commit/64778650d34cfa712c60bf662d25ea3e954bb3eb))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.6 ([#24545](https://github.com/Koenkk/zigbee2mqtt/issues/24545)) ([9837488](https://github.com/Koenkk/zigbee2mqtt/commit/9837488f44f24f0d5e7631fe79c68ce82ed50093))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.7 ([#24566](https://github.com/Koenkk/zigbee2mqtt/issues/24566)) ([f1615a2](https://github.com/Koenkk/zigbee2mqtt/commit/f1615a255c085c263519779be2444742765ee509))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.22.0 ([#24186](https://github.com/Koenkk/zigbee2mqtt/issues/24186)) ([e5fac76](https://github.com/Koenkk/zigbee2mqtt/commit/e5fac76060b6cb9e762584b204fff69c8f68f3cf))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.23.0 ([#24201](https://github.com/Koenkk/zigbee2mqtt/issues/24201)) ([b09ae10](https://github.com/Koenkk/zigbee2mqtt/commit/b09ae105882af2008dbb6a5f0f445ee0f03d1b3c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.23.1 ([#24225](https://github.com/Koenkk/zigbee2mqtt/issues/24225)) ([0229259](https://github.com/Koenkk/zigbee2mqtt/commit/02292590eee8e5ab0f655da39bba26681b814bec))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.24.0 ([#24246](https://github.com/Koenkk/zigbee2mqtt/issues/24246)) ([3bb4af2](https://github.com/Koenkk/zigbee2mqtt/commit/3bb4af253ad8439b1354ce7fda2cc12ad25c934f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.25.0 ([#24285](https://github.com/Koenkk/zigbee2mqtt/issues/24285)) ([5f60187](https://github.com/Koenkk/zigbee2mqtt/commit/5f601873feca094ae9c6c73fc3ff25e4fcd01907))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.26.0 ([#24307](https://github.com/Koenkk/zigbee2mqtt/issues/24307)) ([db00759](https://github.com/Koenkk/zigbee2mqtt/commit/db00759a030957ad6f80b8e1c230f959efb563ed))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.27.2 ([#24321](https://github.com/Koenkk/zigbee2mqtt/issues/24321)) ([618b318](https://github.com/Koenkk/zigbee2mqtt/commit/618b318214ca58b94a1068e0320d9a163a1cd22f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.28.0 ([#24337](https://github.com/Koenkk/zigbee2mqtt/issues/24337)) ([d12b117](https://github.com/Koenkk/zigbee2mqtt/commit/d12b117b433f937266fa65e5e79af3973b376ab7))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.29.0 ([#24363](https://github.com/Koenkk/zigbee2mqtt/issues/24363)) ([d79e71b](https://github.com/Koenkk/zigbee2mqtt/commit/d79e71b13e24002bf9dca469d558c7d70f39a487))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.31.0 ([#24408](https://github.com/Koenkk/zigbee2mqtt/issues/24408)) ([669a16f](https://github.com/Koenkk/zigbee2mqtt/commit/669a16f13a4f88825ae343fa7140615ba2913004))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.32.0 ([#24434](https://github.com/Koenkk/zigbee2mqtt/issues/24434)) ([9ad7c70](https://github.com/Koenkk/zigbee2mqtt/commit/9ad7c70f242e3b0bf68aef8568aff70ab28a9636))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.33.0 ([#24457](https://github.com/Koenkk/zigbee2mqtt/issues/24457)) ([c09aeec](https://github.com/Koenkk/zigbee2mqtt/commit/c09aeecd95e2656548df297e509e2fd6bda7896e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.34.0 ([#24493](https://github.com/Koenkk/zigbee2mqtt/issues/24493)) ([2b460d3](https://github.com/Koenkk/zigbee2mqtt/commit/2b460d3a5fef83e732160890153db9941f5ba000))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.35.0 ([#24514](https://github.com/Koenkk/zigbee2mqtt/issues/24514)) ([074b2e2](https://github.com/Koenkk/zigbee2mqtt/commit/074b2e20e03fe2a47636e9b072d21429681f9475))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.36.0 ([#24542](https://github.com/Koenkk/zigbee2mqtt/issues/24542)) ([b620a21](https://github.com/Koenkk/zigbee2mqtt/commit/b620a21d469d1f69df411e459ca4539221bd4b55))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.37.0 ([#24553](https://github.com/Koenkk/zigbee2mqtt/issues/24553)) ([bd1f6d2](https://github.com/Koenkk/zigbee2mqtt/commit/bd1f6d24a1febcbea6092716e503d280f2ba7916))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.38.0 ([#24567](https://github.com/Koenkk/zigbee2mqtt/issues/24567)) ([c5d30b5](https://github.com/Koenkk/zigbee2mqtt/commit/c5d30b585b263b5de4d97dc2d2d02772ef01f34c))
|
||||
* Improve `power_phase_b` and `power_phase_c` Home Assistant discovery ([#24538](https://github.com/Koenkk/zigbee2mqtt/issues/24538)) ([e0a0f86](https://github.com/Koenkk/zigbee2mqtt/commit/e0a0f868c05fd02cf21c802256c25a33ff8e89a0))
|
||||
* Remove extraneous whitespace in template for binary attributes ([#24520](https://github.com/Koenkk/zigbee2mqtt/issues/24520)) ([ab72606](https://github.com/Koenkk/zigbee2mqtt/commit/ab726061b71935d91c14517689fafeb238ab0228))
|
||||
|
||||
## [1.40.2](https://github.com/Koenkk/zigbee2mqtt/compare/1.40.1...1.40.2) (2024-10-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Enforce TS `strict` type checking ([#23601](https://github.com/Koenkk/zigbee2mqtt/issues/23601)) ([48d77e4](https://github.com/Koenkk/zigbee2mqtt/commit/48d77e4b5ef88e985a363fecf45b8c23038b2a11))
|
||||
* Fix Home Assistant `Invalid state message` error when state is too long ([#24045](https://github.com/Koenkk/zigbee2mqtt/issues/24045)) ([7ad51ce](https://github.com/Koenkk/zigbee2mqtt/commit/7ad51ce662e6e80aaaede7c7e5d1ecb37d89dff0))
|
||||
* **ignore:** Improvement for afd80449b35a10de40cc5393ccb6daa515f26e9b ([9c72a76](https://github.com/Koenkk/zigbee2mqtt/commit/9c72a768f255898141818bce5987547473982dbe))
|
||||
* **ignore:** Migrate to eslint 9 ([#23800](https://github.com/Koenkk/zigbee2mqtt/issues/23800)) ([afd8044](https://github.com/Koenkk/zigbee2mqtt/commit/afd80449b35a10de40cc5393ccb6daa515f26e9b))
|
||||
* **ignore:** Sync eslint settings from zhc ([#23951](https://github.com/Koenkk/zigbee2mqtt/issues/23951)) ([e642f7c](https://github.com/Koenkk/zigbee2mqtt/commit/e642f7c86a25e329c3f367f2f40c6634c207caee))
|
||||
* **ignore:** update dependencies ([#23898](https://github.com/Koenkk/zigbee2mqtt/issues/23898)) ([d989061](https://github.com/Koenkk/zigbee2mqtt/commit/d989061122334e6c5a7cde5a65bfee680b14db2d))
|
||||
* **ignore:** update dependencies ([#23904](https://github.com/Koenkk/zigbee2mqtt/issues/23904)) ([8c04766](https://github.com/Koenkk/zigbee2mqtt/commit/8c0476612fe88ca0eeab28c193d29963e128f5a7))
|
||||
* **ignore:** update dependencies ([#23973](https://github.com/Koenkk/zigbee2mqtt/issues/23973)) ([3efc2b2](https://github.com/Koenkk/zigbee2mqtt/commit/3efc2b237b51db932a758b2be90bd18dd6e25311))
|
||||
* **ignore:** update dependencies ([#24057](https://github.com/Koenkk/zigbee2mqtt/issues/24057)) ([a5bb49a](https://github.com/Koenkk/zigbee2mqtt/commit/a5bb49aa7118ea2d9822b62f1109b2f6f183ab78))
|
||||
* **ignore:** update dependencies ([#24132](https://github.com/Koenkk/zigbee2mqtt/issues/24132)) ([37f80df](https://github.com/Koenkk/zigbee2mqtt/commit/37f80dfb69fccd54db65de72024f36b7f9619cae))
|
||||
* **ignore:** update zigbee-herdsman to 0.57.4 ([#23968](https://github.com/Koenkk/zigbee2mqtt/issues/23968)) ([70a65c7](https://github.com/Koenkk/zigbee2mqtt/commit/70a65c7dcf35a0fe88662edf257bf8117203420f))
|
||||
* **ignore:** update zigbee-herdsman to 1.0.0 ([#24008](https://github.com/Koenkk/zigbee2mqtt/issues/24008)) ([da2a697](https://github.com/Koenkk/zigbee2mqtt/commit/da2a6974e078af2413db74b0c8eaa88c6a179126))
|
||||
* **ignore:** update zigbee-herdsman to 1.0.1 ([#24032](https://github.com/Koenkk/zigbee2mqtt/issues/24032)) ([8a54a01](https://github.com/Koenkk/zigbee2mqtt/commit/8a54a0158351c71ef5da9fb80ef8e35c15d770ca))
|
||||
* **ignore:** update zigbee-herdsman to 1.1.0 ([#24054](https://github.com/Koenkk/zigbee2mqtt/issues/24054)) ([d289194](https://github.com/Koenkk/zigbee2mqtt/commit/d289194a71f67dd821335d467532f74b3d47f03a))
|
||||
* **ignore:** update zigbee-herdsman to 2.0.0 ([#24064](https://github.com/Koenkk/zigbee2mqtt/issues/24064)) ([ee4becb](https://github.com/Koenkk/zigbee2mqtt/commit/ee4becb64ecd35ad2cfdf4cc78688967700e8719))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.0 ([#24076](https://github.com/Koenkk/zigbee2mqtt/issues/24076)) ([2314147](https://github.com/Koenkk/zigbee2mqtt/commit/2314147d0eb4d513ca84405245c4ff460134700a))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.1 ([#24093](https://github.com/Koenkk/zigbee2mqtt/issues/24093)) ([5dd1bfb](https://github.com/Koenkk/zigbee2mqtt/commit/5dd1bfb8a62d8e176800b2cfb66874e0ab73f125))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.2 ([#24138](https://github.com/Koenkk/zigbee2mqtt/issues/24138)) ([df1913b](https://github.com/Koenkk/zigbee2mqtt/commit/df1913bc56f9ac60beb8ae4d648faf3ce57f349c))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.3 ([#24171](https://github.com/Koenkk/zigbee2mqtt/issues/24171)) ([13e4937](https://github.com/Koenkk/zigbee2mqtt/commit/13e4937db2d2cb916013479fc9bd9c6650c1bb8f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.13.0 ([#23927](https://github.com/Koenkk/zigbee2mqtt/issues/23927)) ([0540e4f](https://github.com/Koenkk/zigbee2mqtt/commit/0540e4fbb4c8e81322f1fb1d5de1ba3d2c0d0cfc))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.14.1 ([#23952](https://github.com/Koenkk/zigbee2mqtt/issues/23952)) ([eb86856](https://github.com/Koenkk/zigbee2mqtt/commit/eb8685682f64c7a2dd09e22d04922b4d47053aa2))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.15.0 ([#23969](https://github.com/Koenkk/zigbee2mqtt/issues/23969)) ([03b5b00](https://github.com/Koenkk/zigbee2mqtt/commit/03b5b0043a314c9fa14832ed0111b8582deb1857))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.16.0 ([#23990](https://github.com/Koenkk/zigbee2mqtt/issues/23990)) ([d3de77d](https://github.com/Koenkk/zigbee2mqtt/commit/d3de77da13a391d717a773e33197c0b851227ac5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.16.1 ([#24009](https://github.com/Koenkk/zigbee2mqtt/issues/24009)) ([2ae19a7](https://github.com/Koenkk/zigbee2mqtt/commit/2ae19a7c4247fa511ef16b3db449c6ce6eb10eb8))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.16.2 ([#24022](https://github.com/Koenkk/zigbee2mqtt/issues/24022)) ([e7a3f0d](https://github.com/Koenkk/zigbee2mqtt/commit/e7a3f0da0aadfc9b7c12fed7534acc18f05ffa9d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.16.3 ([#24033](https://github.com/Koenkk/zigbee2mqtt/issues/24033)) ([951bbd3](https://github.com/Koenkk/zigbee2mqtt/commit/951bbd357690d849790a7342d352f32e2e0b1f76))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.17.0 ([#24077](https://github.com/Koenkk/zigbee2mqtt/issues/24077)) ([b1c7fd0](https://github.com/Koenkk/zigbee2mqtt/commit/b1c7fd0033e57f9119f78b6c94d0b9b053013ead))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.18.0 ([#24105](https://github.com/Koenkk/zigbee2mqtt/issues/24105)) ([15d845b](https://github.com/Koenkk/zigbee2mqtt/commit/15d845bf923f3a845a88a1f8bf506207c27a8aee))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.19.0 ([#24130](https://github.com/Koenkk/zigbee2mqtt/issues/24130)) ([8fe76e0](https://github.com/Koenkk/zigbee2mqtt/commit/8fe76e090f84d91c74e88555ee83716510b19caa))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.20.0 ([#24144](https://github.com/Koenkk/zigbee2mqtt/issues/24144)) ([58c856b](https://github.com/Koenkk/zigbee2mqtt/commit/58c856bbd0595c8270ee225685a09bfb34334d02))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.21.0 ([#24172](https://github.com/Koenkk/zigbee2mqtt/issues/24172)) ([c82f932](https://github.com/Koenkk/zigbee2mqtt/commit/c82f93230da8d430cbbdb4aa3a35c1a3d7659b1c))
|
||||
* Improve performance when logging is disabled ([#23893](https://github.com/Koenkk/zigbee2mqtt/issues/23893)) ([c6ca472](https://github.com/Koenkk/zigbee2mqtt/commit/c6ca472ca1374c1b217063816359315931bbcdb0))
|
||||
|
||||
## [1.40.1](https://github.com/Koenkk/zigbee2mqtt/compare/1.40.0...1.40.1) (2024-09-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fix `Cannot read properties of undefined (reading 'some')` https://github.com/Koenkk/zigbee2mqtt/issues/23821 ([557b17a](https://github.com/Koenkk/zigbee2mqtt/commit/557b17ad5b6dc375e361fb3ec5a84134be761a00))
|
||||
* **ignore:** update dependencies ([#23790](https://github.com/Koenkk/zigbee2mqtt/issues/23790)) ([b7fa722](https://github.com/Koenkk/zigbee2mqtt/commit/b7fa7221093656cee3c908aa1c0e45e350aa43c9))
|
||||
* **ignore:** update zigbee-herdsman to 0.57.2 ([#23872](https://github.com/Koenkk/zigbee2mqtt/issues/23872)) ([5524e88](https://github.com/Koenkk/zigbee2mqtt/commit/5524e881e6458afac2c5ae712bc6d0a1090e7115))
|
||||
* **ignore:** update zigbee-herdsman to 0.57.3 ([#23877](https://github.com/Koenkk/zigbee2mqtt/issues/23877)) ([0d46354](https://github.com/Koenkk/zigbee2mqtt/commit/0d46354c485ba5c644af8d933b1689468b3a058c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.11.0 ([#23837](https://github.com/Koenkk/zigbee2mqtt/issues/23837)) ([a33c29f](https://github.com/Koenkk/zigbee2mqtt/commit/a33c29f10af5fc423ff9668af59826a37d9784bf))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.12.1 ([#23884](https://github.com/Koenkk/zigbee2mqtt/issues/23884)) ([9283c4b](https://github.com/Koenkk/zigbee2mqtt/commit/9283c4b4973e9e7309081a9444427c87f9309f91))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.9.0 ([#23822](https://github.com/Koenkk/zigbee2mqtt/issues/23822)) ([6dade8b](https://github.com/Koenkk/zigbee2mqtt/commit/6dade8b11faa13096191f9774a7a1dd320af6bde))
|
||||
* Log stack trace when update check fails https://github.com/Koenkk/zigbee2mqtt/discussions/23798 ([13a108d](https://github.com/Koenkk/zigbee2mqtt/commit/13a108ddbc8f33d1cb23b92e2c67854fbd2dc338))
|
||||
|
||||
## [1.40.0](https://github.com/Koenkk/zigbee2mqtt/compare/1.39.1...1.40.0) (2024-09-01)
|
||||
|
||||
|
||||
|
||||
+15
-15
@@ -17,24 +17,24 @@ diverse, inclusive, and healthy community.
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
|
||||
+4
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
Everybody is invited and welcome to contribute to Zigbee2MQTT. Zigbee2MQTT is written in JavaScript and is based upon [zigbee-herdsman](https://github.com/koenkk/zigbee-herdsman) and [zigbee-herdsman-converters](https://github.com/koenkk/zigbee-herdsman-converters). Zigbee-herdsman-converters contains all device definitions, zigbee-herdsman is responsible for handling all communication with the adapter.
|
||||
|
||||
- Pull requests are always created against the [**dev**](https://github.com/Koenkk/zigbee2mqtt/tree/dev) branch.
|
||||
- Easiest way to start developing Zigbee2MQTT is by setting up a development environment (aka bare-metal installation). You can follow this [guide](https://www.zigbee2mqtt.io/guide/installation/01_linux.html) to do this.
|
||||
- You can run the tests locally by executing `npm test`. Zigbee2MQTT enforces 100% code coverage, in case you add new code check if your code is covered by running `npm run test-with-coverage`. The coverage report can be found under `coverage/lcov-report/index.html`. Linting is also enforced and can be run with `npm run eslint`.
|
||||
- When you want to add support for a new device no changes to Zigbee2MQTT have to be made, only to zigbee-herdsman-converters. You can find a guide for it [here](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html).
|
||||
- Pull requests are always created against the [**dev**](https://github.com/Koenkk/zigbee2mqtt/tree/dev) branch.
|
||||
- Easiest way to start developing Zigbee2MQTT is by setting up a development environment (aka bare-metal installation). You can follow this [guide](https://www.zigbee2mqtt.io/guide/installation/01_linux.html) to do this.
|
||||
- You can run the tests locally by executing `pnpm test`. Zigbee2MQTT enforces 100% code coverage, in case you add new code check if your code is covered by running `pnpm run test-with-coverage`. The coverage report can be found under `coverage/lcov-report/index.html`. Linting is also enforced and can be run with `pnpm run eslint`.
|
||||
- When you want to add support for a new device no changes to Zigbee2MQTT have to be made, only to zigbee-herdsman-converters. You can find a guide for it [here](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html).
|
||||
|
||||
@@ -5,33 +5,27 @@
|
||||
<br>
|
||||
<br>
|
||||
<div style="display: flex;">
|
||||
<a href="https://github.com/Koenkk/zigbee2mqtt/actions?query=workflow%3ACI">
|
||||
<img src="https://github.com/koenkk/zigbee2mqtt/workflows/CI/badge.svg">
|
||||
</a>
|
||||
<a href="https://github.com/Koenkk/zigbee2mqtt/releases">
|
||||
<img src="https://img.shields.io/github/release/koenkk/zigbee2mqtt.svg">
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/zigbee2mqtt">
|
||||
<img src="https://img.shields.io/npm/v/zigbee2mqtt">
|
||||
</a>
|
||||
<a href="https://github.com/Koenkk/zigbee2mqtt/actions/workflows/ci.yml">
|
||||
<img src="https://github.com/Koenkk/zigbee2mqtt/actions/workflows/ci.yml/badge.svg">
|
||||
</a>
|
||||
<a href="https://github.com/Koenkk/zigbee2mqtt/actions/workflows/github-code-scanning/codeql">
|
||||
<img src="https://github.com/Koenkk/zigbee2mqtt/actions/workflows/github-code-scanning/codeql/badge.svg">
|
||||
</a>
|
||||
<a href="https://discord.gg/dadfWYE">
|
||||
<img src="https://img.shields.io/discord/556563650429583360.svg">
|
||||
</a>
|
||||
<a href="https://github.com/Koenkk/zigbee2mqtt/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/koenkk/zigbee2mqtt.svg">
|
||||
</a>
|
||||
<a href="https://www.paypal.me/koenkk">
|
||||
<img src="https://img.shields.io/badge/donate-PayPal-blue.svg">
|
||||
</a>
|
||||
<a href="https://discord.gg/dadfWYE">
|
||||
<img src="https://img.shields.io/discord/556563650429583360.svg">
|
||||
</a>
|
||||
<a href="http://zigbee2mqtt.discourse.group/">
|
||||
<img src="https://img.shields.io/discourse/https/zigbee2mqtt.discourse.group/status.svg">
|
||||
</a>
|
||||
<a>
|
||||
<img src="https://img.shields.io/badge/Coverage-100%25-brightgreen.svg">
|
||||
</a>
|
||||
<a href="https://www.codacy.com/manual/Koenkk/zigbee2mqtt?utm_source=github.com&utm_medium=referral&utm_content=Koenkk/zigbee2mqtt&utm_campaign=Badge_Grade">
|
||||
<img src="https://api.codacy.com/project/badge/Grade/24f1e0fe39f04daa810e8a1416693d3f">
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/zigbee2mqtt">
|
||||
<img src="https://img.shields.io/npm/v/zigbee2mqtt">
|
||||
</a>
|
||||
</div>
|
||||
<h1>Zigbee2MQTT 🌉 🐝</h1>
|
||||
<p>
|
||||
@@ -52,59 +46,70 @@ If you aren't familiar with **Zigbee** terminology make sure you [read this](htt
|
||||
|
||||
Zigbee2MQTT integrates well with (almost) every home automation solution because it uses MQTT. However the following integrations are worth mentioning:
|
||||
|
||||
<img align="left" height="100px" width="100px" src="https://user-images.githubusercontent.com/7738048/40914297-49e6e560-6800-11e8-8904-36cce896e5a8.png">
|
||||
|
||||
### [Home Assistant](https://www.home-assistant.io/)
|
||||
|
||||
- [Home Assistant OS](https://www.home-assistant.io/installation/): Using [the official addon](https://github.com/zigbee2mqtt/hassio-zigbee2mqtt)
|
||||
- Other installation: using instructions [here](https://www.zigbee2mqtt.io/guide/usage/integrations/home_assistant.html)
|
||||
<img align="left" height="75px" width="75px" src="https://user-images.githubusercontent.com/7738048/40914297-49e6e560-6800-11e8-8904-36cce896e5a8.png">
|
||||
|
||||
<br>
|
||||
<br clear="right">
|
||||
|
||||
<img align="left" height="100px" width="100px" src="https://etc.athom.com/logo/white/256.png">
|
||||
[Home Assistant OS](https://www.home-assistant.io/installation/) using [the official addon](https://github.com/zigbee2mqtt/hassio-zigbee2mqtt) ([other installations](https://www.zigbee2mqtt.io/guide/usage/integrations/home_assistant.html))
|
||||
|
||||
<br clear="both">
|
||||
|
||||
### [Homey](https://homey.app/)
|
||||
|
||||
- Integration implemented in the [Homey App](https://homey.app/nl-nl/app/com.gruijter.zigbee2mqtt/)
|
||||
- Documentation and support in the [Homey Forum](https://community.homey.app/t/83214)
|
||||
<img align="left" height="75px" width="75px" src="https://etc.athom.com/logo/white/256.png">
|
||||
|
||||
<br>
|
||||
<br clear="right">
|
||||
|
||||
<img align="left" height="100px" width="100px" src="https://user-images.githubusercontent.com/2734836/47615848-b8dd8700-dabd-11e8-9d77-175002dd8987.png">
|
||||
Integration implemented in the [Homey App](https://homey.app/nl-nl/app/com.gruijter.zigbee2mqtt/) ([documentation & support](https://community.homey.app/t/83214))
|
||||
|
||||
<br clear="both">
|
||||
|
||||
### [Domoticz](https://www.domoticz.com/)
|
||||
|
||||
- Integration implemented in Domoticz ([documentation](https://www.domoticz.com/wiki/Zigbee2MQTT)).
|
||||
<img align="left" height="75px" width="75px" src="https://user-images.githubusercontent.com/2734836/47615848-b8dd8700-dabd-11e8-9d77-175002dd8987.png">
|
||||
|
||||
<br>
|
||||
<br clear="right">
|
||||
|
||||
<img align="left" height="100px" width="100px" src="./images/gladys-assistant-logo.jpg">
|
||||
Integration implemented in Domoticz ([documentation](https://www.domoticz.com/wiki/Zigbee2MQTT)).
|
||||
|
||||
<br clear="both">
|
||||
|
||||
### [Gladys Assistant](https://gladysassistant.com/)
|
||||
|
||||
- Integration implemented natively in Gladys Assistant ([documentation](https://gladysassistant.com/docs/integrations/zigbee2mqtt/)).
|
||||
<img align="left" height="75px" width="75px" src="./images/gladys-assistant-logo.jpg">
|
||||
|
||||
<br>
|
||||
<br clear="right">
|
||||
|
||||
<img align="left" height="100px" width="100px" src="https://forum.iobroker.net/assets/uploads/system/site-logo.png">
|
||||
Integration implemented natively in Gladys Assistant ([documentation](https://gladysassistant.com/docs/integrations/zigbee2mqtt/)).
|
||||
|
||||
<br clear="both">
|
||||
|
||||
### [IoBroker](https://www.iobroker.net/)
|
||||
|
||||
- Integration implemented in IoBroker ([documentation](https://github.com/o0shojo0o/ioBroker.zigbee2mqtt)).
|
||||
<img align="left" height="75px" width="75px" src="https://forum.iobroker.net/assets/uploads/system/site-logo.png">
|
||||
|
||||
<br>
|
||||
<br clear="right">
|
||||
|
||||
Integration implemented in IoBroker ([documentation](https://github.com/o0shojo0o/ioBroker.zigbee2mqtt)).
|
||||
|
||||
<br clear="both">
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||

|
||||
|
||||
### Internal Architecture
|
||||
|
||||
Zigbee2MQTT is made up of three modules, each developed in its own Github project. Starting from the hardware (adapter) and moving up; [zigbee-herdsman](https://github.com/koenkk/zigbee-herdsman) connects to your Zigbee adapter and makes an API available to the higher levels of the stack. For e.g. Texas Instruments hardware, zigbee-herdsman uses the [TI zStack monitoring and test API](https://github.com/koenkk/zigbee-herdsman/raw/master/docs/Z-Stack%20Monitor%20and%20Test%20API.pdf) to communicate with the adapter. Zigbee-herdsman handles the core Zigbee communication. The module [zigbee-herdsman-converters](https://github.com/koenkk/zigbee-herdsman-converters) handles the mapping from individual device models to the Zigbee clusters they support. [Zigbee clusters](https://github.com/Koenkk/zigbee-herdsman/blob/master/docs/07-5123-08-Zigbee-Cluster-Library.pdf) are the layers of the Zigbee protocol on top of the base protocol that define things like how lights, sensors and switches talk to each other over the Zigbee network. Finally, the Zigbee2MQTT module drives zigbee-herdsman and maps the zigbee messages to MQTT messages. Zigbee2MQTT also keeps track of the state of the system. It uses a `database.db` file to store this state; a text file with a JSON database of connected devices and their capabilities. Zigbee2MQTT provides a [web-based interface](https://github.com/nurikk/zigbee2mqtt-frontend) that allows monitoring and configuration.
|
||||
Zigbee2MQTT is made up of three modules, each developed in its own Github project. Starting from the hardware (adapter) and moving up; [zigbee-herdsman](https://github.com/koenkk/zigbee-herdsman) connects to your adapter to handle Zigbee communication and makes an API available to the higher levels of the stack. For e.g. Texas Instruments hardware, zigbee-herdsman uses the [TI zStack monitoring and test API](https://github.com/Koenkk/zigbee-herdsman/wiki/References#texas-instruments-zstack) to communicate with the adapter. The module [zigbee-herdsman-converters](https://github.com/koenkk/zigbee-herdsman-converters) handles the mapping from individual device models to the Zigbee clusters they support. [Zigbee clusters](https://github.com/Koenkk/zigbee-herdsman/wiki/References#csa-zigbee-alliance-spec) are the layers of the Zigbee protocol on top of the base protocol that define things like how lights, sensors and switches talk to each other over the Zigbee network. Finally, the Zigbee2MQTT module drives zigbee-herdsman and maps the zigbee messages to MQTT messages. Zigbee2MQTT also keeps track of the state of the system. It uses a `database.db` file to store this state; a text file with a JSON database of connected devices and their capabilities. Zigbee2MQTT provides several web-based interfaces ([zigbee2mqtt-frontend](https://github.com/nurikk/zigbee2mqtt-frontend), [zigbee2mqtt-windfront](https://github.com/Nerivec/zigbee2mqtt-windfront)) that allows monitoring and configuration.
|
||||
|
||||
### Developing
|
||||
|
||||
Zigbee2MQTT uses TypeScript (partially for now). Therefore after making changes to files in the `lib/` directory you need to recompile Zigbee2MQTT. This can be done by executing `npm run build`. For faster development instead of running `npm run build` you can run `npm run build-watch` in another terminal session, this will recompile as you change files.
|
||||
Zigbee2MQTT uses TypeScript. Therefore after making changes to files in the `lib/` directory you need to recompile Zigbee2MQTT. This can be done by executing `pnpm run build`. For faster development instead of running `pnpm run build` you can run `pnpm run build-watch` in another terminal session, this will recompile as you change files.
|
||||
|
||||
Before running any of the commands, you'll first need to run `pnpm install --include=dev`.
|
||||
Before submitting changes run `pnpm run check:w` then `pnpm run test:coverage`.
|
||||
|
||||
## Supported devices
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [['@babel/preset-env', {targets: {node: 'current'}}], '@babel/preset-typescript'],
|
||||
assumptions: {setPublicClassFields: true},
|
||||
plugins: [['@babel/plugin-proposal-decorators', {legacy: true}]],
|
||||
};
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"formatter": {
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 4,
|
||||
"lineWidth": 150,
|
||||
"bracketSpacing": false
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!package.json"]
|
||||
},
|
||||
"linter": {
|
||||
"includes": ["**"],
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noUnusedImports": "error"
|
||||
},
|
||||
"style": {
|
||||
"noParameterAssign": "off",
|
||||
"useThrowNewError": "error",
|
||||
"useThrowOnlyError": "error",
|
||||
"useNamingConvention": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"strictCase": false,
|
||||
"conventions": [
|
||||
{
|
||||
"selector": {
|
||||
"kind": "objectLiteralProperty"
|
||||
},
|
||||
"formats": ["snake_case", "camelCase", "CONSTANT_CASE", "PascalCase"]
|
||||
},
|
||||
{
|
||||
"selector": {
|
||||
"kind": "const"
|
||||
},
|
||||
"formats": ["snake_case", "camelCase", "CONSTANT_CASE", "PascalCase"]
|
||||
},
|
||||
{
|
||||
"selector": {
|
||||
"kind": "typeProperty"
|
||||
},
|
||||
"formats": ["snake_case", "camelCase", "CONSTANT_CASE", "PascalCase"]
|
||||
},
|
||||
{
|
||||
"selector": {
|
||||
"kind": "enumMember"
|
||||
},
|
||||
"formats": ["CONSTANT_CASE", "PascalCase"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"useAsConstAssertion": "error",
|
||||
"useDefaultParameterLast": "error",
|
||||
"useEnumInitializers": "error",
|
||||
"useSelfClosingElements": "error",
|
||||
"useSingleVarDeclarator": "error",
|
||||
"noUnusedTemplateLiteral": "error",
|
||||
"useNumberNamespace": "error",
|
||||
"noInferrableTypes": "error",
|
||||
"noUselessElse": "error"
|
||||
},
|
||||
"performance": {
|
||||
"noDelete": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noConstEnum": "off",
|
||||
"useAwait": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["test/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
"noNonNullAssertion": "off",
|
||||
"useNamingConvention": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noImplicitAnyLet": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
const path = require('path');
|
||||
process.env['ZIGBEE2MQTT_DATA'] = process.env['ZIGBEE2MQTT_DATA'] || path.join(process.env['HOME'], '.z2m');
|
||||
require('./index');
|
||||
const path = require("node:path");
|
||||
process.env.ZIGBEE2MQTT_DATA = process.env.ZIGBEE2MQTT_DATA || path.join(process.env.HOME, ".z2m");
|
||||
require("./index");
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# Indicates the configuration version (used by configuration migrations)
|
||||
version: 4
|
||||
|
||||
# Home Assistant integration (MQTT discovery)
|
||||
homeassistant: false
|
||||
homeassistant:
|
||||
enabled: false
|
||||
|
||||
# Enable the frontend, runs on port 8080 by default
|
||||
frontend: true
|
||||
frontend:
|
||||
enabled: true
|
||||
# port: 8080
|
||||
|
||||
# MQTT settings
|
||||
mqtt:
|
||||
@@ -14,13 +20,24 @@ mqtt:
|
||||
# user: my_user
|
||||
# password: my_password
|
||||
|
||||
# Serial settings
|
||||
serial:
|
||||
# Location of CC2531 USB sniffer
|
||||
port: /dev/ttyACM0
|
||||
# Serial settings, only required when Zigbee2MQTT fails to start with:
|
||||
# USB adapter discovery error (No valid USB adapter found).
|
||||
# Specify valid 'adapter' and 'port' in your configuration.
|
||||
# serial:
|
||||
# # Location of the adapter
|
||||
# # USB adapters - use format "port: /dev/serial/by-id/XXX"
|
||||
# # Ethernet adapters - use format "port: tcp://192.168.1.12:6638"
|
||||
# port: /dev/serial/by-id/usb-Texas_Instruments_TI_CC2531_USB_CDC___0X00124B0018ED3DDF-if00
|
||||
# # Adapter type, allowed values: `zstack`, `ember`, `deconz`, `zigate` or `zboss`
|
||||
# adapter: zstack
|
||||
|
||||
# Periodically check whether devices are online/offline
|
||||
# availability:
|
||||
# enabled: false
|
||||
|
||||
# Advanced settings
|
||||
advanced:
|
||||
# channel: 11
|
||||
# Let Zigbee2MQTT generate a network key on first start
|
||||
network_key: GENERATE
|
||||
# Let Zigbee2MQTT generate a pan_id on first start
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Home Assistant integration (MQTT discovery)
|
||||
homeassistant: false
|
||||
|
||||
# allow new devices to join
|
||||
permit_join: true
|
||||
|
||||
# MQTT settings
|
||||
mqtt:
|
||||
# MQTT base topic for zigbee2mqtt MQTT messages
|
||||
base_topic: zigbee2mqtt
|
||||
# MQTT server URL
|
||||
server: 'mqtt://localhost'
|
||||
# MQTT server authentication, uncomment if required:
|
||||
# user: my_user
|
||||
# password: my_password
|
||||
|
||||
# Serial settings
|
||||
serial:
|
||||
# Location of CC2531 USB sniffer
|
||||
port: /dev/ttyACM0
|
||||
+28
-15
@@ -1,33 +1,48 @@
|
||||
FROM alpine:3.18.4 as base
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
FROM alpine:3.22 AS base
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache tzdata eudev tini nodejs
|
||||
|
||||
# Dependencies and build
|
||||
FROM base as dependencies_and_build
|
||||
FROM base AS deps
|
||||
|
||||
COPY package*.json tsconfig.json index.js ./
|
||||
COPY lib ./lib
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
RUN apk add --no-cache --virtual .buildtools make gcc g++ python3 linux-headers git npm && \
|
||||
npm ci --production --no-audit --no-optional --no-update-notifier && \
|
||||
# Serialport needs to be rebuild for Alpine https://serialport.io/docs/9.x.x/guide-installation#alpine-linux
|
||||
npm rebuild --build-from-source && \
|
||||
apk del .buildtools
|
||||
# Make and such are needed to compile serialport for riscv64
|
||||
# Install pnpm, configure a tmp-based store, then use it under a cache mount so each arch gets its own store
|
||||
# NOTE: We do not install devDependencies here (the --prod argument in pnpm install) as we use an already
|
||||
# built artifact (./dist directory) from previous GitHub runner stages. Omitting devDependencies here
|
||||
# saves storage as this node_modules/ is being copied straight to the release stage later.
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/tmp/pnpm-store \
|
||||
apk add --no-cache make gcc g++ python3 linux-headers npm jq && \
|
||||
npm install -g $(jq -r '.packageManager' package.json) && \
|
||||
pnpm config set store-dir /tmp/pnpm-store && \
|
||||
pnpm install --frozen-lockfile --prod --no-optional && \
|
||||
# force serialport to rebuild on current platform instead of using prebuilds
|
||||
rm -rf $(find ./node_modules/.pnpm/ -wholename "*/@serialport/bindings-cpp/prebuilds" -type d) && \
|
||||
pnpm rebuild @serialport/bindings-cpp
|
||||
|
||||
# Release
|
||||
FROM base as release
|
||||
FROM base AS release
|
||||
|
||||
ARG DATE
|
||||
ARG VERSION
|
||||
LABEL org.opencontainers.image.authors="Koen Kanters"
|
||||
LABEL org.opencontainers.image.title="zigbee2mqtt"
|
||||
LABEL org.opencontainers.image.title="Zigbee2MQTT"
|
||||
LABEL org.opencontainers.image.description="Zigbee to MQTT bridge using Zigbee-herdsman"
|
||||
LABEL org.opencontainers.image.url="https://github.com/Koenkk/zigbee2mqtt"
|
||||
LABEL org.opencontainers.image.documentation="https://www.zigbee2mqtt.io/"
|
||||
LABEL org.opencontainers.image.source="https://github.com/Koenkk/zigbee2mqtt"
|
||||
LABEL org.opencontainers.image.licenses="GPL-3.0"
|
||||
LABEL org.opencontainers.image.created=${DATE}
|
||||
LABEL org.opencontainers.image.version=${VERSION}
|
||||
|
||||
COPY --from=dependencies_and_build /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY dist ./dist
|
||||
COPY package.json LICENSE index.js data/configuration.yaml data/configuration.example.yaml ./
|
||||
COPY package.json LICENSE index.js data/configuration.example.yaml ./
|
||||
|
||||
COPY docker/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
@@ -37,7 +52,5 @@ RUN mkdir /app/data
|
||||
ARG COMMIT
|
||||
RUN echo "$COMMIT" > dist/.hash
|
||||
|
||||
ENV NODE_ENV production
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD [ "/sbin/tini", "--", "node", "index.js"]
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
version: '3'
|
||||
services:
|
||||
zigbee2mqtt:
|
||||
container_name: zigbee2mqtt
|
||||
image: koenkk/zigbee2mqtt
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
devices:
|
||||
# CC251
|
||||
#- /dev/ttyUSB_cc2531:/dev/ttyACM0
|
||||
# CC2530 / GBAN GB2530S
|
||||
#- /dev/ttyUSB_cc2530:/dev/ttyACM0
|
||||
restart: always
|
||||
network_mode: host
|
||||
@@ -9,9 +9,4 @@ fi
|
||||
|
||||
echo "Using '$DATA' as data directory"
|
||||
|
||||
if [ ! -f "$DATA/configuration.yaml" ]; then
|
||||
echo "Creating configuration file..."
|
||||
cp /app/configuration.example.yaml "$DATA/configuration.yaml"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
@@ -1,30 +1,29 @@
|
||||
const semver = require('semver');
|
||||
const engines = require('./package.json').engines;
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {exec} = require('child_process');
|
||||
const {rimrafSync} = require('rimraf');
|
||||
require('source-map-support').install();
|
||||
const semver = require("semver");
|
||||
const engines = require("./package.json").engines;
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const {exec} = require("node:child_process");
|
||||
require("source-map-support").install();
|
||||
|
||||
let controller;
|
||||
let stopping = false;
|
||||
let watchdog = process.env.Z2M_WATCHDOG != undefined;
|
||||
const watchdog = process.env.Z2M_WATCHDOG != null;
|
||||
let watchdogCount = 0;
|
||||
let unsolicitedStop = false;
|
||||
// csv in minutes, default: 1min, 5min, 15min, 30min, 60min
|
||||
let watchdogDelays = [2000, 60000, 300000, 900000, 1800000, 3600000];
|
||||
|
||||
if (watchdog && process.env.Z2M_WATCHDOG !== 'default') {
|
||||
if (/^(?:(?:[0-9]*[.])?[0-9]+)+(?:,?(?:[0-9]*[.])?[0-9]+)*$/.test(process.env.Z2M_WATCHDOG)) {
|
||||
watchdogDelays = process.env.Z2M_WATCHDOG.split(',').map((v) => parseFloat(v) * 60000);
|
||||
if (watchdog && process.env.Z2M_WATCHDOG !== "default") {
|
||||
if (/^\d+(.\d+)?(,\d+(.\d+)?)*$/.test(process.env.Z2M_WATCHDOG)) {
|
||||
watchdogDelays = process.env.Z2M_WATCHDOG.split(",").map((v) => Number.parseFloat(v) * 60000);
|
||||
} else {
|
||||
console.log(`Invalid watchdog delays (must use number-only CSV format representing minutes, example: 'Z2M_WATCHDOG=1,5,15,30,60'.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const hashFile = path.join(__dirname, 'dist', '.hash');
|
||||
const hashFile = path.join(__dirname, "dist", ".hash");
|
||||
|
||||
async function triggerWatchdog(code) {
|
||||
const delay = watchdogDelays[watchdogCount];
|
||||
@@ -58,10 +57,16 @@ async function exit(code, restart = false) {
|
||||
}
|
||||
|
||||
async function currentHash() {
|
||||
const git = require('git-last-commit');
|
||||
return await new Promise((resolve) => {
|
||||
exec("git rev-parse --short=8 HEAD", (error, stdout) => {
|
||||
const commitHash = stdout.trim();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
git.getLastCommit((err, commit) => (err ? resolve('unknown') : resolve(commit.shortHash)));
|
||||
if (error || commitHash === "") {
|
||||
resolve("unknown");
|
||||
} else {
|
||||
resolve(commitHash);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,30 +77,30 @@ async function writeHash() {
|
||||
}
|
||||
|
||||
async function build(reason) {
|
||||
return new Promise((resolve, reject) => {
|
||||
process.stdout.write(`Building Zigbee2MQTT... (${reason})`);
|
||||
rimrafSync('dist');
|
||||
process.stdout.write(`Building Zigbee2MQTT... (${reason})`);
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const env = {...process.env};
|
||||
const _600mb = 629145600;
|
||||
const mb600 = 629145600;
|
||||
|
||||
if (_600mb > os.totalmem() && !env.NODE_OPTIONS) {
|
||||
if (mb600 > os.totalmem() && !env.NODE_OPTIONS) {
|
||||
// Prevent OOM on tsc compile for system with low memory
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/12034
|
||||
env.NODE_OPTIONS = '--max_old_space_size=256';
|
||||
env.NODE_OPTIONS = "--max_old_space_size=256";
|
||||
}
|
||||
|
||||
exec('npm run build', {env, cwd: __dirname}, async (err, stdout, stderr) => {
|
||||
// clean build, prevent failures due to tsc incremental building
|
||||
exec("pnpm run prepack", {env, cwd: __dirname}, (err) => {
|
||||
if (err) {
|
||||
process.stdout.write(', failed\n');
|
||||
process.stdout.write(", failed\n");
|
||||
|
||||
if (err.code === 134) {
|
||||
process.stderr.write('\n\nBuild failed; ran out-of-memory, free some memory (RAM) and start again\n\n');
|
||||
process.stderr.write("\n\nBuild failed; ran out-of-memory, free some memory (RAM) and start again\n\n");
|
||||
}
|
||||
|
||||
reject(err);
|
||||
} else {
|
||||
process.stdout.write(', finished\n');
|
||||
process.stdout.write(", finished\n");
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
@@ -104,51 +109,41 @@ async function build(reason) {
|
||||
|
||||
async function checkDist() {
|
||||
if (!fs.existsSync(hashFile)) {
|
||||
await build('initial build');
|
||||
await build("initial build");
|
||||
}
|
||||
|
||||
const distHash = fs.readFileSync(hashFile, 'utf-8');
|
||||
const distHash = fs.readFileSync(hashFile, "utf8");
|
||||
const hash = await currentHash();
|
||||
if (hash !== 'unknown' && distHash !== hash) {
|
||||
await build('hash changed');
|
||||
|
||||
if (hash !== "unknown" && distHash !== hash) {
|
||||
await build("hash changed");
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
console.log(`Starting Zigbee2MQTT ${watchdog ? `with watchdog (${watchdogDelays})` : `without watchdog`}.`);
|
||||
console.log(`Starting Zigbee2MQTT ${watchdog ? `with watchdog (${watchdogDelays})` : "without watchdog"}.`);
|
||||
await checkDist();
|
||||
|
||||
const version = engines.node;
|
||||
// gc
|
||||
{
|
||||
const version = engines.node;
|
||||
|
||||
if (!semver.satisfies(process.version, version)) {
|
||||
console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`);
|
||||
}
|
||||
|
||||
// Validate settings
|
||||
const settings = require('./dist/util/settings');
|
||||
|
||||
settings.reRead();
|
||||
|
||||
const errors = settings.validate();
|
||||
|
||||
if (errors.length > 0) {
|
||||
unsolicitedStop = false;
|
||||
|
||||
console.log(`\n\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`);
|
||||
console.log(' READ THIS CAREFULLY\n');
|
||||
console.log(`Refusing to start because configuration is not valid, found the following errors:`);
|
||||
|
||||
for (const error of errors) {
|
||||
console.log(`- ${error}`);
|
||||
if (!semver.satisfies(process.version, version)) {
|
||||
console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`);
|
||||
}
|
||||
|
||||
console.log(`\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration`);
|
||||
console.log(`\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n`);
|
||||
const {onboard} = require("./dist/util/onboarding");
|
||||
|
||||
return exit(1);
|
||||
const success = await onboard();
|
||||
|
||||
if (!success) {
|
||||
unsolicitedStop = false;
|
||||
|
||||
return await exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const Controller = require('./dist/controller');
|
||||
const {Controller} = require("./dist/controller");
|
||||
controller = new Controller(restart, exit);
|
||||
|
||||
await controller.start();
|
||||
@@ -177,17 +172,17 @@ async function handleQuit() {
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module || require.main.filename.endsWith(path.sep + 'cli.js')) {
|
||||
if (process.argv.length === 3 && process.argv[2] === 'writehash') {
|
||||
if (require.main === module || require.main.filename.endsWith(`${path.sep}cli.js`)) {
|
||||
if (process.argv.length === 3 && process.argv[2] === "writehash") {
|
||||
writeHash();
|
||||
} else {
|
||||
process.on('SIGINT', handleQuit);
|
||||
process.on('SIGTERM', handleQuit);
|
||||
process.on("SIGINT", handleQuit);
|
||||
process.on("SIGTERM", handleQuit);
|
||||
start();
|
||||
}
|
||||
} else {
|
||||
process.on('SIGINT', handleQuit);
|
||||
process.on('SIGTERM', handleQuit);
|
||||
process.on("SIGINT", handleQuit);
|
||||
process.on("SIGTERM", handleQuit);
|
||||
|
||||
module.exports = {start};
|
||||
}
|
||||
|
||||
+231
-186
@@ -1,86 +1,42 @@
|
||||
import assert from 'assert';
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import {setLogger as zhSetLogger} from 'zigbee-herdsman';
|
||||
import {setLogger as zhcSetLogger} from 'zigbee-herdsman-converters';
|
||||
|
||||
import EventBus from './eventBus';
|
||||
import ExtensionAvailability from './extension/availability';
|
||||
import ExtensionBind from './extension/bind';
|
||||
import ExtensionBridge from './extension/bridge';
|
||||
import ExtensionConfigure from './extension/configure';
|
||||
import ExtensionExternalConverters from './extension/externalConverters';
|
||||
import ExtensionExternalExtension from './extension/externalExtension';
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {setLogger as zhSetLogger} from "zigbee-herdsman";
|
||||
import {setLogger as zhcSetLogger} from "zigbee-herdsman-converters";
|
||||
import EventBus from "./eventBus";
|
||||
// Extensions
|
||||
import ExtensionFrontend from './extension/frontend';
|
||||
import ExtensionGroups from './extension/groups';
|
||||
import ExtensionHomeAssistant from './extension/homeassistant';
|
||||
import ExtensionBridgeLegacy from './extension/legacy/bridgeLegacy';
|
||||
import ExtensionDeviceGroupMembership from './extension/legacy/deviceGroupMembership';
|
||||
import ExtensionReport from './extension/legacy/report';
|
||||
import ExtensionSoftReset from './extension/legacy/softReset';
|
||||
import ExtensionNetworkMap from './extension/networkMap';
|
||||
import ExtensionOnEvent from './extension/onEvent';
|
||||
import ExtensionOTAUpdate from './extension/otaUpdate';
|
||||
import ExtensionPublish from './extension/publish';
|
||||
import ExtensionReceive from './extension/receive';
|
||||
import MQTT from './mqtt';
|
||||
import State from './state';
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import utils from './util/utils';
|
||||
import Zigbee from './zigbee';
|
||||
|
||||
const AllExtensions = [
|
||||
ExtensionPublish,
|
||||
ExtensionReceive,
|
||||
ExtensionNetworkMap,
|
||||
ExtensionSoftReset,
|
||||
ExtensionHomeAssistant,
|
||||
ExtensionConfigure,
|
||||
ExtensionDeviceGroupMembership,
|
||||
ExtensionBridgeLegacy,
|
||||
ExtensionBridge,
|
||||
ExtensionGroups,
|
||||
ExtensionBind,
|
||||
ExtensionReport,
|
||||
ExtensionOnEvent,
|
||||
ExtensionOTAUpdate,
|
||||
ExtensionExternalConverters,
|
||||
ExtensionFrontend,
|
||||
ExtensionExternalExtension,
|
||||
ExtensionAvailability,
|
||||
];
|
||||
|
||||
type ExtensionArgs = [
|
||||
Zigbee,
|
||||
MQTT,
|
||||
State,
|
||||
PublishEntityState,
|
||||
EventBus,
|
||||
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => Promise<void>,
|
||||
addExtension: (extension: Extension) => Promise<void>,
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let sdNotify: any = null;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
sdNotify = process.env.NOTIFY_SOCKET ? require('sd-notify') : null;
|
||||
} catch {
|
||||
// sd-notify is optional
|
||||
}
|
||||
import ExtensionAvailability from "./extension/availability";
|
||||
import ExtensionBind from "./extension/bind";
|
||||
import ExtensionBridge from "./extension/bridge";
|
||||
import ExtensionConfigure from "./extension/configure";
|
||||
import type Extension from "./extension/extension";
|
||||
import ExtensionExternalConverters from "./extension/externalConverters";
|
||||
import ExtensionExternalExtensions from "./extension/externalExtensions";
|
||||
import ExtensionGroups from "./extension/groups";
|
||||
import ExtensionHealth from "./extension/health";
|
||||
import ExtensionNetworkMap from "./extension/networkMap";
|
||||
import ExtensionOnEvent from "./extension/onEvent";
|
||||
import ExtensionOTAUpdate from "./extension/otaUpdate";
|
||||
import ExtensionPublish from "./extension/publish";
|
||||
import ExtensionReceive from "./extension/receive";
|
||||
import Mqtt, {type MqttPublishOptions} from "./mqtt";
|
||||
import State from "./state";
|
||||
import type {Zigbee2MQTTAPI} from "./types/api";
|
||||
import logger from "./util/logger";
|
||||
import {initSdNotify} from "./util/sd-notify";
|
||||
import * as settings from "./util/settings";
|
||||
import utils from "./util/utils";
|
||||
import Zigbee from "./zigbee";
|
||||
|
||||
export class Controller {
|
||||
private eventBus: EventBus;
|
||||
private zigbee: Zigbee;
|
||||
private state: State;
|
||||
private mqtt: MQTT;
|
||||
public readonly eventBus: EventBus;
|
||||
public readonly zigbee: Zigbee;
|
||||
public readonly state: State;
|
||||
public readonly mqtt: Mqtt;
|
||||
private restartCallback: () => Promise<void>;
|
||||
private exitCallback: (code: number, restart: boolean) => Promise<void>;
|
||||
private extensions: Extension[];
|
||||
private extensionArgs: ExtensionArgs;
|
||||
public readonly extensions: Set<Extension>;
|
||||
public readonly extensionArgs: ConstructorParameters<typeof Extension>;
|
||||
private sdNotify: Awaited<ReturnType<typeof initSdNotify>>;
|
||||
|
||||
constructor(restartCallback: () => Promise<void>, exitCallback: (code: number, restart: boolean) => Promise<void>) {
|
||||
logger.init();
|
||||
@@ -88,7 +44,7 @@ export class Controller {
|
||||
zhcSetLogger(logger);
|
||||
this.eventBus = new EventBus();
|
||||
this.zigbee = new Zigbee(this.eventBus);
|
||||
this.mqtt = new MQTT(this.eventBus);
|
||||
this.mqtt = new Mqtt(this.eventBus);
|
||||
this.state = new State(this.eventBus, this.zigbee);
|
||||
this.restartCallback = restartCallback;
|
||||
this.exitCallback = exitCallback;
|
||||
@@ -105,64 +61,72 @@ export class Controller {
|
||||
this.addExtension,
|
||||
];
|
||||
|
||||
this.extensions = [
|
||||
this.extensions = new Set([
|
||||
new ExtensionExternalConverters(...this.extensionArgs),
|
||||
new ExtensionOnEvent(...this.extensionArgs),
|
||||
new ExtensionBridge(...this.extensionArgs),
|
||||
new ExtensionPublish(...this.extensionArgs),
|
||||
new ExtensionReceive(...this.extensionArgs),
|
||||
new ExtensionDeviceGroupMembership(...this.extensionArgs),
|
||||
new ExtensionConfigure(...this.extensionArgs),
|
||||
new ExtensionNetworkMap(...this.extensionArgs),
|
||||
new ExtensionGroups(...this.extensionArgs),
|
||||
new ExtensionBind(...this.extensionArgs),
|
||||
new ExtensionOTAUpdate(...this.extensionArgs),
|
||||
new ExtensionReport(...this.extensionArgs),
|
||||
new ExtensionExternalExtension(...this.extensionArgs),
|
||||
new ExtensionExternalExtensions(...this.extensionArgs),
|
||||
new ExtensionAvailability(...this.extensionArgs),
|
||||
settings.get().frontend && new ExtensionFrontend(...this.extensionArgs),
|
||||
settings.get().advanced.legacy_api && new ExtensionBridgeLegacy(...this.extensionArgs),
|
||||
settings.get().external_converters.length && new ExtensionExternalConverters(...this.extensionArgs),
|
||||
settings.get().homeassistant && new ExtensionHomeAssistant(...this.extensionArgs),
|
||||
/* istanbul ignore next */
|
||||
settings.get().advanced.soft_reset_timeout !== 0 && new ExtensionSoftReset(...this.extensionArgs),
|
||||
].filter((n) => n);
|
||||
new ExtensionHealth(...this.extensionArgs),
|
||||
]);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (settings.get().frontend.enabled) {
|
||||
const {Frontend} = await import("./extension/frontend.js");
|
||||
|
||||
this.extensions.add(new Frontend(...this.extensionArgs));
|
||||
}
|
||||
|
||||
if (settings.get().homeassistant.enabled) {
|
||||
const {HomeAssistant} = await import("./extension/homeassistant.js");
|
||||
|
||||
this.extensions.add(new HomeAssistant(...this.extensionArgs));
|
||||
}
|
||||
|
||||
this.state.start();
|
||||
|
||||
const info = await utils.getZigbee2MQTTVersion();
|
||||
logger.info(`Starting Zigbee2MQTT version ${info.version} (commit #${info.commitHash})`);
|
||||
|
||||
// Start zigbee
|
||||
let startResult;
|
||||
try {
|
||||
startResult = await this.zigbee.start();
|
||||
await this.zigbee.start();
|
||||
this.eventBus.onAdapterDisconnected(this, this.onZigbeeAdapterDisconnected);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start zigbee');
|
||||
logger.error('Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html for possible solutions');
|
||||
logger.error('Exiting...');
|
||||
logger.error(error.stack);
|
||||
return this.exit(1);
|
||||
}
|
||||
logger.error("Failed to start zigbee-herdsman");
|
||||
logger.error(
|
||||
"Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html for possible solutions",
|
||||
);
|
||||
logger.error("Exiting...");
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.error((error as Error).stack!);
|
||||
|
||||
// Disable some legacy options on new network creation
|
||||
if (startResult === 'reset') {
|
||||
settings.set(['advanced', 'homeassistant_legacy_entity_attributes'], false);
|
||||
settings.set(['advanced', 'legacy_api'], false);
|
||||
settings.set(['advanced', 'legacy_availability_payload'], false);
|
||||
settings.set(['device_options', 'legacy'], false);
|
||||
await this.enableDisableExtension(false, 'BridgeLegacy');
|
||||
/* v8 ignore start */
|
||||
if ((error as Error).message.includes("USB adapter discovery error (No valid USB adapter found)")) {
|
||||
logger.error("If this happens after updating to Zigbee2MQTT 2.0.0, see https://github.com/Koenkk/zigbee2mqtt/discussions/24364");
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
return await this.exit(1);
|
||||
}
|
||||
|
||||
// Log zigbee clients on startup
|
||||
let deviceCount = 0;
|
||||
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
// `definition` validated by `isSupported`
|
||||
const model = device.isSupported
|
||||
? `${device.definition.model} - ${device.definition.vendor} ${device.definition.description}`
|
||||
: 'Not supported';
|
||||
? // biome-ignore lint/style/noNonNullAssertion: valid from `isSupported`
|
||||
`${device.definition!.model} - ${device.definition!.vendor} ${device.definition!.description}`
|
||||
: "Not supported";
|
||||
logger.info(`${device.name} (${device.ieeeAddr}): ${model} (${device.zh.type})`);
|
||||
|
||||
deviceCount++;
|
||||
@@ -170,108 +134,196 @@ export class Controller {
|
||||
|
||||
logger.info(`Currently ${deviceCount} devices are joined.`);
|
||||
|
||||
// Enable zigbee join
|
||||
try {
|
||||
if (settings.get().permit_join) {
|
||||
logger.warning('`permit_join` set to `true` in configuration.yaml.');
|
||||
logger.warning('Allowing new devices to join.');
|
||||
logger.warning('Set `permit_join` to `false` once you joined all devices.');
|
||||
}
|
||||
|
||||
await this.zigbee.permitJoin(settings.get().permit_join);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to set permit join to ${settings.get().permit_join} (${error.message})`);
|
||||
}
|
||||
|
||||
// MQTT
|
||||
try {
|
||||
await this.mqtt.connect();
|
||||
} catch (error) {
|
||||
logger.error(`MQTT failed to connect, exiting... (${error.message})`);
|
||||
logger.error(`MQTT failed to connect, exiting... (${(error as Error).message})`);
|
||||
await this.zigbee.stop();
|
||||
return this.exit(1);
|
||||
return await this.exit(1);
|
||||
}
|
||||
|
||||
// Call extensions
|
||||
await this.callExtensions('start', [...this.extensions]);
|
||||
// copy current Set of extensions to ignore possible external extensions added while looping
|
||||
for (const extension of new Set(this.extensions)) {
|
||||
await this.startExtension(extension);
|
||||
}
|
||||
|
||||
// Send all cached states.
|
||||
if (settings.get().advanced.cache_state_send_on_startup && settings.get().advanced.cache_state) {
|
||||
for (const entity of this.zigbee.devicesAndGroupsIterator()) {
|
||||
if (this.state.exists(entity)) {
|
||||
await this.publishEntityState(entity, this.state.get(entity), 'publishCached');
|
||||
await this.publishEntityState(entity, this.state.get(entity), "publishCached");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.onLastSeenChanged(this, (data) => utils.publishLastSeen(data, settings.get(), false, this.publishEntityState));
|
||||
|
||||
logger.info(`Zigbee2MQTT started!`);
|
||||
logger.info("Zigbee2MQTT started!");
|
||||
|
||||
const watchdogInterval = sdNotify?.watchdogInterval() || 0;
|
||||
if (watchdogInterval > 0) {
|
||||
sdNotify.startWatchdogMode(Math.floor(watchdogInterval / 2));
|
||||
}
|
||||
sdNotify?.ready();
|
||||
this.sdNotify = await initSdNotify();
|
||||
|
||||
settings.setOnboarding(false);
|
||||
}
|
||||
|
||||
@bind async enableDisableExtension(enable: boolean, name: string): Promise<void> {
|
||||
if (!enable) {
|
||||
const extension = this.extensions.find((e) => e.constructor.name === name);
|
||||
if (extension) {
|
||||
await this.callExtensions('stop', [extension]);
|
||||
this.extensions.splice(this.extensions.indexOf(extension), 1);
|
||||
if (enable) {
|
||||
switch (name) {
|
||||
case "Frontend": {
|
||||
if (!settings.get().frontend.enabled) {
|
||||
throw new Error("Tried to enable Frontend extension disabled in settings");
|
||||
}
|
||||
|
||||
// this is not actually used, not tested either
|
||||
/* v8 ignore start */
|
||||
const {Frontend} = await import("./extension/frontend.js");
|
||||
|
||||
await this.addExtension(new Frontend(...this.extensionArgs));
|
||||
|
||||
break;
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
case "HomeAssistant": {
|
||||
if (!settings.get().homeassistant.enabled) {
|
||||
throw new Error("Tried to enable HomeAssistant extension disabled in settings");
|
||||
}
|
||||
|
||||
const {HomeAssistant} = await import("./extension/homeassistant.js");
|
||||
|
||||
await this.addExtension(new HomeAssistant(...this.extensionArgs));
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(
|
||||
`Extension ${name} does not exist (should be added with 'addExtension') or is built-in that cannot be enabled at runtime`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const Extension = AllExtensions.find((e) => e.name === name);
|
||||
assert(Extension, `Extension '${name}' does not exist`);
|
||||
const extension = new Extension(...this.extensionArgs);
|
||||
this.extensions.push(extension);
|
||||
await this.callExtensions('start', [extension]);
|
||||
switch (name) {
|
||||
case "Frontend": {
|
||||
if (settings.get().frontend.enabled) {
|
||||
throw new Error("Tried to disable Frontend extension enabled in settings");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "HomeAssistant": {
|
||||
if (settings.get().homeassistant.enabled) {
|
||||
throw new Error("Tried to disable HomeAssistant extension enabled in settings");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "Availability":
|
||||
case "Bind":
|
||||
case "Bridge":
|
||||
case "Configure":
|
||||
case "ExternalConverters":
|
||||
case "ExternalExtensions":
|
||||
case "Groups":
|
||||
case "NetworkMap":
|
||||
case "OnEvent":
|
||||
case "OTAUpdate":
|
||||
case "Publish":
|
||||
case "Receive": {
|
||||
throw new Error(`Built-in extension ${name} cannot be disabled at runtime`);
|
||||
}
|
||||
}
|
||||
|
||||
const extension = this.getExtension(name);
|
||||
|
||||
if (extension) {
|
||||
await this.removeExtension(extension);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getExtension(name: string): Extension | undefined {
|
||||
for (const extension of this.extensions) {
|
||||
if (extension.constructor.name === name) {
|
||||
return extension;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind async addExtension(extension: Extension): Promise<void> {
|
||||
this.extensions.push(extension);
|
||||
await this.callExtensions('start', [extension]);
|
||||
for (const ext of this.extensions) {
|
||||
if (ext.constructor.name === extension.constructor.name) {
|
||||
throw new Error(`Extension with name ${ext.constructor.name} already present`);
|
||||
}
|
||||
}
|
||||
|
||||
this.extensions.add(extension);
|
||||
await this.startExtension(extension);
|
||||
}
|
||||
|
||||
async stop(restart = false): Promise<void> {
|
||||
sdNotify?.stopping();
|
||||
async removeExtension(extension: Extension): Promise<void> {
|
||||
if (this.extensions.delete(extension)) {
|
||||
await this.stopExtension(extension);
|
||||
}
|
||||
}
|
||||
|
||||
private async startExtension(extension: Extension): Promise<void> {
|
||||
try {
|
||||
await extension.start();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start '${extension.constructor.name}' (${(error as Error).stack})`);
|
||||
}
|
||||
}
|
||||
|
||||
private async stopExtension(extension: Extension): Promise<void> {
|
||||
try {
|
||||
await extension.stop();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop '${extension.constructor.name}' (${(error as Error).stack})`);
|
||||
}
|
||||
}
|
||||
|
||||
async stop(restart = false, code = 0): Promise<void> {
|
||||
this.sdNotify?.notifyStopping();
|
||||
|
||||
let localCode = 0;
|
||||
for (const extension of this.extensions) {
|
||||
try {
|
||||
await extension.stop();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop '${extension.constructor.name}' (${(error as Error).stack})`);
|
||||
localCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Call extensions
|
||||
await this.callExtensions('stop', this.extensions);
|
||||
this.eventBus.removeListeners(this);
|
||||
|
||||
// Wrap-up
|
||||
this.state.stop();
|
||||
await this.mqtt.disconnect();
|
||||
let code = 0;
|
||||
|
||||
try {
|
||||
await this.zigbee.stop();
|
||||
logger.info('Stopped Zigbee2MQTT');
|
||||
logger.info("Stopped Zigbee2MQTT");
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop Zigbee2MQTT (${error.message})`);
|
||||
code = 1;
|
||||
logger.error(`Failed to stop Zigbee2MQTT (${(error as Error).stack})`);
|
||||
localCode = 1;
|
||||
}
|
||||
|
||||
sdNotify?.stopWatchdogMode();
|
||||
return this.exit(code, restart);
|
||||
this.sdNotify?.stop();
|
||||
return await this.exit(code !== 0 ? code : localCode, restart);
|
||||
}
|
||||
|
||||
async exit(code: number, restart = false): Promise<void> {
|
||||
await logger.end();
|
||||
return this.exitCallback(code, restart);
|
||||
return await this.exitCallback(code, restart);
|
||||
}
|
||||
|
||||
@bind async onZigbeeAdapterDisconnected(): Promise<void> {
|
||||
logger.error('Adapter disconnected, stopping');
|
||||
await this.stop();
|
||||
logger.error("Adapter disconnected, stopping");
|
||||
await this.stop(false, 2);
|
||||
}
|
||||
|
||||
@bind async publishEntityState(entity: Group | Device, payload: KeyValue, stateChangeReason?: StateChangeReason): Promise<void> {
|
||||
let message = {...payload};
|
||||
let message: Zigbee2MQTTAPI["{friendlyName}"] = {...payload};
|
||||
|
||||
// Update state cache with new state.
|
||||
const newState = this.state.set(entity, payload, stateChangeReason);
|
||||
@@ -281,14 +333,19 @@ export class Controller {
|
||||
message = newState;
|
||||
}
|
||||
|
||||
const options: MQTTOptions = {
|
||||
retain: utils.getObjectProperty(entity.options, 'retain', false) as boolean,
|
||||
qos: utils.getObjectProperty(entity.options, 'qos', 0) as 0 | 1 | 2,
|
||||
const options: MakePartialExcept<MqttPublishOptions, "clientOptions" | "meta"> = {
|
||||
clientOptions: {
|
||||
retain: utils.getObjectProperty(entity.options, "retain", false),
|
||||
qos: utils.getObjectProperty(entity.options, "qos", 0),
|
||||
},
|
||||
meta: {
|
||||
isEntityState: true,
|
||||
},
|
||||
};
|
||||
const retention = utils.getObjectProperty<number | false>(entity.options, "retention", false);
|
||||
|
||||
const retention = utils.getObjectProperty(entity.options, 'retention', false);
|
||||
if (retention !== false) {
|
||||
options.properties = {messageExpiryInterval: retention as number};
|
||||
options.clientOptions.properties = {messageExpiryInterval: retention};
|
||||
}
|
||||
|
||||
if (entity.isDevice() && settings.get().mqtt.include_device_information) {
|
||||
@@ -308,13 +365,14 @@ export class Controller {
|
||||
softwareBuildID: entity.zh.softwareBuildID,
|
||||
// Manufacturer name can contain \u0000, remove this.
|
||||
// https://github.com/home-assistant/core/issues/85691
|
||||
manufacturerName: entity.zh.manufacturerName?.split('\u0000')[0],
|
||||
/* v8 ignore next */
|
||||
manufacturerName: entity.zh.manufacturerName?.split("\u0000")[0],
|
||||
};
|
||||
}
|
||||
|
||||
// Add lastseen
|
||||
const lastSeen = settings.get().advanced.last_seen;
|
||||
if (entity.isDevice() && lastSeen !== 'disable' && entity.zh.lastSeen) {
|
||||
if (entity.isDevice() && lastSeen !== "disable" && entity.zh.lastSeen) {
|
||||
message.last_seen = utils.formatDate(entity.zh.lastSeen, lastSeen);
|
||||
}
|
||||
|
||||
@@ -330,13 +388,13 @@ export class Controller {
|
||||
// Filter mqtt message attributes
|
||||
utils.filterProperties(entity.options.filtered_attributes, message);
|
||||
|
||||
if (Object.entries(message).length) {
|
||||
if (!utils.objectIsEmpty(message)) {
|
||||
const output = settings.get().advanced.output;
|
||||
if (output === 'attribute_and_json' || output === 'json') {
|
||||
if (output === "attribute_and_json" || output === "json") {
|
||||
await this.mqtt.publish(entity.name, stringify(message), options);
|
||||
}
|
||||
|
||||
if (output === 'attribute_and_json' || output === 'attribute') {
|
||||
if (output === "attribute_and_json" || output === "attribute") {
|
||||
await this.iteratePayloadAttributeOutput(`${entity.name}/`, message, options);
|
||||
}
|
||||
}
|
||||
@@ -344,25 +402,25 @@ export class Controller {
|
||||
this.eventBus.emitPublishEntityState({entity, message, stateChangeReason, payload});
|
||||
}
|
||||
|
||||
async iteratePayloadAttributeOutput(topicRoot: string, payload: KeyValue, options: MQTTOptions): Promise<void> {
|
||||
async iteratePayloadAttributeOutput(topicRoot: string, payload: KeyValue, options: Partial<MqttPublishOptions>): Promise<void> {
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
let subPayload = value;
|
||||
let message = null;
|
||||
|
||||
// Special cases
|
||||
if (key === 'color' && utils.objectHasProperties(subPayload, ['r', 'g', 'b'])) {
|
||||
if (key === "color" && utils.objectHasProperties(subPayload, ["r", "g", "b"])) {
|
||||
subPayload = [subPayload.r, subPayload.g, subPayload.b];
|
||||
}
|
||||
|
||||
// Check Array first, since it is also an Object
|
||||
if (subPayload === null || subPayload === undefined) {
|
||||
message = '';
|
||||
message = "";
|
||||
} else if (Array.isArray(subPayload)) {
|
||||
message = subPayload.map((x) => `${x}`).join(',');
|
||||
} else if (typeof subPayload === 'object') {
|
||||
message = subPayload.map((x) => `${x}`).join(",");
|
||||
} else if (typeof subPayload === "object") {
|
||||
await this.iteratePayloadAttributeOutput(`${topicRoot}${key}-`, subPayload, options);
|
||||
} else {
|
||||
message = typeof subPayload === 'string' ? subPayload : stringify(subPayload);
|
||||
message = typeof subPayload === "string" ? subPayload : stringify(subPayload);
|
||||
}
|
||||
|
||||
if (message !== null) {
|
||||
@@ -370,17 +428,4 @@ export class Controller {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async callExtensions(method: 'start' | 'stop', extensions: Extension[]): Promise<void> {
|
||||
for (const extension of extensions) {
|
||||
try {
|
||||
await extension[method]?.();
|
||||
} catch (error) {
|
||||
/* istanbul ignore next */
|
||||
logger.error(`Failed to call '${extension.constructor.name}' '${method}' (${error.stack})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Controller;
|
||||
|
||||
+149
-62
@@ -1,187 +1,274 @@
|
||||
import events from 'events';
|
||||
import events from "node:events";
|
||||
|
||||
import logger from './util/logger';
|
||||
import logger from "./util/logger";
|
||||
|
||||
// eslint-disable-next-line
|
||||
type ListenerKey = object;
|
||||
|
||||
interface EventBusMap {
|
||||
adapterDisconnected: [];
|
||||
permitJoinChanged: [data: eventdata.PermitJoinChanged];
|
||||
publishAvailability: [];
|
||||
entityRenamed: [data: eventdata.EntityRenamed];
|
||||
entityRemoved: [data: eventdata.EntityRemoved];
|
||||
lastSeenChanged: [data: eventdata.LastSeenChanged];
|
||||
deviceNetworkAddressChanged: [data: eventdata.DeviceNetworkAddressChanged];
|
||||
deviceAnnounce: [data: eventdata.DeviceAnnounce];
|
||||
deviceInterview: [data: eventdata.DeviceInterview];
|
||||
deviceJoined: [data: eventdata.DeviceJoined];
|
||||
entityOptionsChanged: [data: eventdata.EntityOptionsChanged];
|
||||
exposesChanged: [data: eventdata.ExposesChanged];
|
||||
deviceLeave: [data: eventdata.DeviceLeave];
|
||||
deviceMessage: [data: eventdata.DeviceMessage];
|
||||
mqttMessage: [data: eventdata.MQTTMessage];
|
||||
mqttMessagePublished: [data: eventdata.MQTTMessagePublished];
|
||||
publishEntityState: [data: eventdata.PublishEntityState];
|
||||
groupMembersChanged: [data: eventdata.GroupMembersChanged];
|
||||
devicesChanged: [];
|
||||
scenesChanged: [data: eventdata.ScenesChanged];
|
||||
reconfigure: [data: eventdata.Reconfigure];
|
||||
stateChange: [data: eventdata.StateChange];
|
||||
}
|
||||
type EventBusListener<K> = K extends keyof EventBusMap
|
||||
? EventBusMap[K] extends unknown[]
|
||||
? (...args: EventBusMap[K]) => Promise<void> | void
|
||||
: never
|
||||
: never;
|
||||
|
||||
type Stats = {
|
||||
devices: Map<
|
||||
string, // IEEE address
|
||||
{
|
||||
lastSeenChanges?: {messages: number; first: number};
|
||||
leaveCounts: number;
|
||||
networkAddressChanges: number;
|
||||
}
|
||||
>;
|
||||
mqtt: {
|
||||
published: number;
|
||||
received: number;
|
||||
};
|
||||
};
|
||||
|
||||
export default class EventBus {
|
||||
private callbacksByExtension: {[s: string]: {event: string; callback: (...args: unknown[]) => void}[]} = {};
|
||||
private emitter = new events.EventEmitter();
|
||||
private callbacksByExtension = new Map<string, {event: keyof EventBusMap; callback: EventBusListener<keyof EventBusMap>}[]>();
|
||||
private emitter = new events.EventEmitter<EventBusMap>();
|
||||
readonly stats: Stats = {devices: new Map(), mqtt: {published: 0, received: 0}};
|
||||
|
||||
constructor() {
|
||||
this.emitter.setMaxListeners(100);
|
||||
}
|
||||
|
||||
public emitAdapterDisconnected(): void {
|
||||
this.emitter.emit('adapterDisconnected');
|
||||
this.emitter.emit("adapterDisconnected");
|
||||
}
|
||||
public onAdapterDisconnected(key: ListenerKey, callback: () => void): void {
|
||||
this.on('adapterDisconnected', callback, key);
|
||||
this.on("adapterDisconnected", callback, key);
|
||||
}
|
||||
|
||||
public emitPermitJoinChanged(data: eventdata.PermitJoinChanged): void {
|
||||
this.emitter.emit('permitJoinChanged', data);
|
||||
this.emitter.emit("permitJoinChanged", data);
|
||||
}
|
||||
public onPermitJoinChanged(key: ListenerKey, callback: (data: eventdata.PermitJoinChanged) => void): void {
|
||||
this.on('permitJoinChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitPublishAvailability(): void {
|
||||
this.emitter.emit('publishAvailability');
|
||||
}
|
||||
public onPublishAvailability(key: ListenerKey, callback: () => void): void {
|
||||
this.on('publishAvailability', callback, key);
|
||||
this.on("permitJoinChanged", callback, key);
|
||||
}
|
||||
|
||||
public emitEntityRenamed(data: eventdata.EntityRenamed): void {
|
||||
this.emitter.emit('deviceRenamed', data);
|
||||
this.emitter.emit("entityRenamed", data);
|
||||
}
|
||||
public onEntityRenamed(key: ListenerKey, callback: (data: eventdata.EntityRenamed) => void): void {
|
||||
this.on('deviceRenamed', callback, key);
|
||||
this.on("entityRenamed", callback, key);
|
||||
}
|
||||
|
||||
public emitEntityRemoved(data: eventdata.EntityRemoved): void {
|
||||
this.emitter.emit('deviceRemoved', data);
|
||||
this.emitter.emit("entityRemoved", data);
|
||||
}
|
||||
public onEntityRemoved(key: ListenerKey, callback: (data: eventdata.EntityRemoved) => void): void {
|
||||
this.on('deviceRemoved', callback, key);
|
||||
this.on("entityRemoved", callback, key);
|
||||
}
|
||||
|
||||
public emitLastSeenChanged(data: eventdata.LastSeenChanged): void {
|
||||
this.emitter.emit('lastSeenChanged', data);
|
||||
this.emitter.emit("lastSeenChanged", data);
|
||||
|
||||
const device = this.stats.devices.get(data.device.ieeeAddr);
|
||||
|
||||
if (device?.lastSeenChanges) {
|
||||
device.lastSeenChanges.messages += 1;
|
||||
} else {
|
||||
this.stats.devices.set(data.device.ieeeAddr, {
|
||||
lastSeenChanges: {messages: 1, first: Date.now()},
|
||||
leaveCounts: 0,
|
||||
networkAddressChanges: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
public onLastSeenChanged(key: ListenerKey, callback: (data: eventdata.LastSeenChanged) => void): void {
|
||||
this.on('lastSeenChanged', callback, key);
|
||||
this.on("lastSeenChanged", callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceNetworkAddressChanged(data: eventdata.DeviceNetworkAddressChanged): void {
|
||||
this.emitter.emit('deviceNetworkAddressChanged', data);
|
||||
this.emitter.emit("deviceNetworkAddressChanged", data);
|
||||
|
||||
const device = this.stats.devices.get(data.device.ieeeAddr);
|
||||
|
||||
if (device) {
|
||||
device.networkAddressChanges += 1;
|
||||
} else {
|
||||
this.stats.devices.set(data.device.ieeeAddr, {leaveCounts: 0, networkAddressChanges: 1});
|
||||
}
|
||||
}
|
||||
public onDeviceNetworkAddressChanged(key: ListenerKey, callback: (data: eventdata.DeviceNetworkAddressChanged) => void): void {
|
||||
this.on('deviceNetworkAddressChanged', callback, key);
|
||||
this.on("deviceNetworkAddressChanged", callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceAnnounce(data: eventdata.DeviceAnnounce): void {
|
||||
this.emitter.emit('deviceAnnounce', data);
|
||||
this.emitter.emit("deviceAnnounce", data);
|
||||
}
|
||||
public onDeviceAnnounce(key: ListenerKey, callback: (data: eventdata.DeviceAnnounce) => void): void {
|
||||
this.on('deviceAnnounce', callback, key);
|
||||
this.on("deviceAnnounce", callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceInterview(data: eventdata.DeviceInterview): void {
|
||||
this.emitter.emit('deviceInterview', data);
|
||||
this.emitter.emit("deviceInterview", data);
|
||||
}
|
||||
public onDeviceInterview(key: ListenerKey, callback: (data: eventdata.DeviceInterview) => void): void {
|
||||
this.on('deviceInterview', callback, key);
|
||||
this.on("deviceInterview", callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceJoined(data: eventdata.DeviceJoined): void {
|
||||
this.emitter.emit('deviceJoined', data);
|
||||
this.emitter.emit("deviceJoined", data);
|
||||
}
|
||||
public onDeviceJoined(key: ListenerKey, callback: (data: eventdata.DeviceJoined) => void): void {
|
||||
this.on('deviceJoined', callback, key);
|
||||
this.on("deviceJoined", callback, key);
|
||||
}
|
||||
|
||||
public emitEntityOptionsChanged(data: eventdata.EntityOptionsChanged): void {
|
||||
this.emitter.emit('entityOptionsChanged', data);
|
||||
this.emitter.emit("entityOptionsChanged", data);
|
||||
}
|
||||
public onEntityOptionsChanged(key: ListenerKey, callback: (data: eventdata.EntityOptionsChanged) => void): void {
|
||||
this.on('entityOptionsChanged', callback, key);
|
||||
this.on("entityOptionsChanged", callback, key);
|
||||
}
|
||||
|
||||
public emitExposesChanged(data: eventdata.ExposesChanged): void {
|
||||
this.emitter.emit('exposesChanged', data);
|
||||
this.emitter.emit("exposesChanged", data);
|
||||
}
|
||||
public onExposesChanged(key: ListenerKey, callback: (data: eventdata.ExposesChanged) => void): void {
|
||||
this.on('exposesChanged', callback, key);
|
||||
this.on("exposesChanged", callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceLeave(data: eventdata.DeviceLeave): void {
|
||||
this.emitter.emit('deviceLeave', data);
|
||||
this.emitter.emit("deviceLeave", data);
|
||||
|
||||
const device = this.stats.devices.get(data.ieeeAddr);
|
||||
|
||||
if (device) {
|
||||
device.leaveCounts += 1;
|
||||
} else {
|
||||
this.stats.devices.set(data.ieeeAddr, {leaveCounts: 1, networkAddressChanges: 0});
|
||||
}
|
||||
}
|
||||
public onDeviceLeave(key: ListenerKey, callback: (data: eventdata.DeviceLeave) => void): void {
|
||||
this.on('deviceLeave', callback, key);
|
||||
this.on("deviceLeave", callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceMessage(data: eventdata.DeviceMessage): void {
|
||||
this.emitter.emit('deviceMessage', data);
|
||||
this.emitter.emit("deviceMessage", data);
|
||||
}
|
||||
public onDeviceMessage(key: ListenerKey, callback: (data: eventdata.DeviceMessage) => void): void {
|
||||
this.on('deviceMessage', callback, key);
|
||||
this.on("deviceMessage", callback, key);
|
||||
}
|
||||
|
||||
public emitMQTTMessage(data: eventdata.MQTTMessage): void {
|
||||
this.emitter.emit('mqttMessage', data);
|
||||
this.emitter.emit("mqttMessage", data);
|
||||
|
||||
this.stats.mqtt.received += 1;
|
||||
}
|
||||
public onMQTTMessage(key: ListenerKey, callback: (data: eventdata.MQTTMessage) => void): void {
|
||||
this.on('mqttMessage', callback, key);
|
||||
this.on("mqttMessage", callback, key);
|
||||
}
|
||||
|
||||
public emitMQTTMessagePublished(data: eventdata.MQTTMessagePublished): void {
|
||||
this.emitter.emit('mqttMessagePublished', data);
|
||||
this.emitter.emit("mqttMessagePublished", data);
|
||||
|
||||
this.stats.mqtt.published += 1;
|
||||
}
|
||||
public onMQTTMessagePublished(key: ListenerKey, callback: (data: eventdata.MQTTMessagePublished) => void): void {
|
||||
this.on('mqttMessagePublished', callback, key);
|
||||
this.on("mqttMessagePublished", callback, key);
|
||||
}
|
||||
|
||||
public emitPublishEntityState(data: eventdata.PublishEntityState): void {
|
||||
this.emitter.emit('publishEntityState', data);
|
||||
this.emitter.emit("publishEntityState", data);
|
||||
}
|
||||
public onPublishEntityState(key: ListenerKey, callback: (data: eventdata.PublishEntityState) => void): void {
|
||||
this.on('publishEntityState', callback, key);
|
||||
this.on("publishEntityState", callback, key);
|
||||
}
|
||||
|
||||
public emitGroupMembersChanged(data: eventdata.GroupMembersChanged): void {
|
||||
this.emitter.emit('groupMembersChanged', data);
|
||||
this.emitter.emit("groupMembersChanged", data);
|
||||
}
|
||||
public onGroupMembersChanged(key: ListenerKey, callback: (data: eventdata.GroupMembersChanged) => void): void {
|
||||
this.on('groupMembersChanged', callback, key);
|
||||
this.on("groupMembersChanged", callback, key);
|
||||
}
|
||||
|
||||
public emitDevicesChanged(): void {
|
||||
this.emitter.emit('devicesChanged');
|
||||
this.emitter.emit("devicesChanged");
|
||||
}
|
||||
public onDevicesChanged(key: ListenerKey, callback: () => void): void {
|
||||
this.on('devicesChanged', callback, key);
|
||||
this.on("devicesChanged", callback, key);
|
||||
}
|
||||
|
||||
public emitScenesChanged(data: eventdata.ScenesChanged): void {
|
||||
this.emitter.emit('scenesChanged', data);
|
||||
this.emitter.emit("scenesChanged", data);
|
||||
}
|
||||
public onScenesChanged(key: ListenerKey, callback: (data: eventdata.ScenesChanged) => void): void {
|
||||
this.on('scenesChanged', callback, key);
|
||||
this.on("scenesChanged", callback, key);
|
||||
}
|
||||
|
||||
public emitReconfigure(data: eventdata.Reconfigure): void {
|
||||
this.emitter.emit('reconfigure', data);
|
||||
this.emitter.emit("reconfigure", data);
|
||||
}
|
||||
public onReconfigure(key: ListenerKey, callback: (data: eventdata.Reconfigure) => void): void {
|
||||
this.on('reconfigure', callback, key);
|
||||
this.on("reconfigure", callback, key);
|
||||
}
|
||||
|
||||
public emitStateChange(data: eventdata.StateChange): void {
|
||||
this.emitter.emit('stateChange', data);
|
||||
this.emitter.emit("stateChange", data);
|
||||
}
|
||||
public onStateChange(key: ListenerKey, callback: (data: eventdata.StateChange) => void): void {
|
||||
this.on('stateChange', callback, key);
|
||||
this.on("stateChange", callback, key);
|
||||
}
|
||||
|
||||
private on(event: string, callback: (...args: unknown[]) => Promise<void> | void, key: ListenerKey): void {
|
||||
if (!this.callbacksByExtension[key.constructor.name]) this.callbacksByExtension[key.constructor.name] = [];
|
||||
const wrappedCallback = async (...args: unknown[]): Promise<void> => {
|
||||
public emitExposesAndDevicesChanged(device: Device): void {
|
||||
this.emitDevicesChanged();
|
||||
this.emitExposesChanged({device});
|
||||
}
|
||||
|
||||
private on<K extends keyof EventBusMap>(event: K, callback: EventBusListener<K>, key: ListenerKey): void {
|
||||
if (!this.callbacksByExtension.has(key.constructor.name)) {
|
||||
this.callbacksByExtension.set(key.constructor.name, []);
|
||||
}
|
||||
|
||||
const wrappedCallback = async (...args: never[]): Promise<void> => {
|
||||
try {
|
||||
await callback(...args);
|
||||
} catch (error) {
|
||||
logger.error(`EventBus error '${key.constructor.name}/${event}': ${error.message}`);
|
||||
logger.debug(error.stack);
|
||||
logger.error(`EventBus error '${key.constructor.name}/${event}': ${(error as Error).message}`);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
};
|
||||
this.callbacksByExtension[key.constructor.name].push({event, callback: wrappedCallback});
|
||||
this.emitter.on(event, wrappedCallback);
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: just created if wasn't valid
|
||||
this.callbacksByExtension.get(key.constructor.name)!.push({event, callback: wrappedCallback});
|
||||
this.emitter.on(event, wrappedCallback as EventBusListener<K>);
|
||||
}
|
||||
|
||||
public removeListeners(key: ListenerKey): void {
|
||||
this.callbacksByExtension[key.constructor.name]?.forEach((e) => this.emitter.removeListener(e.event, e.callback));
|
||||
const callbacks = this.callbacksByExtension.get(key.constructor.name);
|
||||
|
||||
if (callbacks) {
|
||||
for (const cb of callbacks) {
|
||||
this.emitter.removeListener(cb.event, cb.callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+167
-80
@@ -1,64 +1,129 @@
|
||||
import bind from 'bind-decorator';
|
||||
import debounce from 'debounce';
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import assert from "node:assert";
|
||||
import bind from "bind-decorator";
|
||||
import debounce from "debounce";
|
||||
import type * as zhc from "zigbee-herdsman-converters";
|
||||
import type {Zigbee2MQTTAPI} from "../types/api";
|
||||
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
const RETRIEVE_ON_RECONNECT: readonly {keys: string[]; condition?: (state: KeyValue) => boolean}[] = [
|
||||
{keys: ['state']},
|
||||
{keys: ['brightness'], condition: (state: KeyValue): boolean => state.state === 'ON'},
|
||||
{keys: ['color', 'color_temp'], condition: (state: KeyValue): boolean => state.state === 'ON'},
|
||||
{keys: ["state"]},
|
||||
{keys: ["brightness"], condition: (state: KeyValue): boolean => state.state === "ON"},
|
||||
{keys: ["color", "color_temp"], condition: (state: KeyValue): boolean => state.state === "ON"},
|
||||
];
|
||||
|
||||
export default class Availability extends Extension {
|
||||
private timers: {[s: string]: NodeJS.Timeout} = {};
|
||||
private availabilityCache: {[s: string]: boolean} = {};
|
||||
private retrieveStateDebouncers: {[s: string]: () => void} = {};
|
||||
/** Mapped by IEEE address */
|
||||
private readonly timers = new Map<string, NodeJS.Timeout>();
|
||||
/** Mapped by IEEE address or Group ID */
|
||||
private readonly lastPublishedAvailabilities = new Map<string | number, boolean>();
|
||||
/** Mapped by IEEE address */
|
||||
private readonly pingBackoffs = new Map<string, number>();
|
||||
/** IEEE addresses, waiting for last seen changes to take them out of "availability sleep" */
|
||||
private readonly backoffPausedDevices = new Set<string>();
|
||||
/** Mapped by IEEE address */
|
||||
private readonly retrieveStateDebouncers = new Map<string, () => void>();
|
||||
private pingQueue: Device[] = [];
|
||||
private pingQueueExecuting = false;
|
||||
private stopped = false;
|
||||
|
||||
private getTimeout(device: Device): number {
|
||||
if (typeof device.options.availability === 'object' && device.options.availability?.timeout != null) {
|
||||
if (typeof device.options.availability === "object" && device.options.availability?.timeout != null) {
|
||||
return utils.minutes(device.options.availability.timeout);
|
||||
}
|
||||
|
||||
const key = this.isActiveDevice(device) ? 'active' : 'passive';
|
||||
let value = settings.get().availability?.[key]?.timeout;
|
||||
return utils.minutes(this.isActiveDevice(device) ? settings.get().availability.active.timeout : settings.get().availability.passive.timeout);
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
value = key == 'active' ? 10 : 1500;
|
||||
private getMaxJitter(device: Device): number {
|
||||
if (typeof device.options.availability === "object" && device.options.availability?.max_jitter != null) {
|
||||
return device.options.availability.max_jitter;
|
||||
}
|
||||
|
||||
return utils.minutes(value);
|
||||
return settings.get().availability.active.max_jitter;
|
||||
}
|
||||
|
||||
private getBackoff(device: Device): boolean {
|
||||
if (typeof device.options.availability === "object" && device.options.availability?.backoff != null) {
|
||||
return device.options.availability.backoff;
|
||||
}
|
||||
|
||||
return settings.get().availability.active.backoff;
|
||||
}
|
||||
|
||||
private getPauseOnBackoffGt(device: Device): number {
|
||||
if (typeof device.options.availability === "object" && device.options.availability?.pause_on_backoff_gt != null) {
|
||||
return device.options.availability.pause_on_backoff_gt;
|
||||
}
|
||||
|
||||
return settings.get().availability.active.pause_on_backoff_gt;
|
||||
}
|
||||
|
||||
private isActiveDevice(device: Device): boolean {
|
||||
return (device.zh.type === 'Router' && device.zh.powerSource !== 'Battery') || device.zh.powerSource === 'Mains (single phase)';
|
||||
return (
|
||||
(device.zh.type === "Router" && device.zh.powerSource !== "Battery") ||
|
||||
(device.zh.powerSource !== undefined && device.zh.powerSource !== "Unknown" && device.zh.powerSource !== "Battery")
|
||||
);
|
||||
}
|
||||
|
||||
private isAvailable(entity: Device | Group): boolean {
|
||||
return entity.isDevice()
|
||||
? Date.now() - entity.zh.lastSeen < this.getTimeout(entity)
|
||||
: entity.membersDevices().length === 0 || entity.membersDevices().some((d) => this.availabilityCache[d.ieeeAddr]);
|
||||
if (entity.isDevice()) {
|
||||
const lastSeen = entity.zh.lastSeen ?? /* v8 ignore next */ 0;
|
||||
|
||||
return Date.now() - lastSeen < this.getTimeout(entity);
|
||||
}
|
||||
|
||||
for (const memberDevice of entity.membersDevices()) {
|
||||
if (this.lastPublishedAvailabilities.get(memberDevice.ieeeAddr) === true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private resetTimer(device: Device): void {
|
||||
clearTimeout(this.timers[device.ieeeAddr]);
|
||||
private resetTimer(device: Device, resetBackoff = false): void {
|
||||
clearTimeout(this.timers.get(device.ieeeAddr));
|
||||
this.removeFromPingQueue(device);
|
||||
|
||||
// If the timer triggers, the device is not available anymore otherwise resetTimer already has been called
|
||||
if (this.isActiveDevice(device)) {
|
||||
// If device did not check in, ping it, if that fails it will be marked as offline
|
||||
this.timers[device.ieeeAddr] = setTimeout(() => this.addToPingQueue(device), this.getTimeout(device) + utils.seconds(1));
|
||||
const backoffEnabled = this.getBackoff(device);
|
||||
const jitter = Math.random() * this.getMaxJitter(device);
|
||||
let backoff = 1;
|
||||
|
||||
if (resetBackoff) {
|
||||
// always cleanup even if backoff disabled (ensures proper state if changed at runtime)
|
||||
this.backoffPausedDevices.delete(device.ieeeAddr);
|
||||
this.pingBackoffs.delete(device.ieeeAddr);
|
||||
} else if (backoffEnabled) {
|
||||
backoff = this.pingBackoffs.get(device.ieeeAddr) ?? 1;
|
||||
}
|
||||
|
||||
// never paused if was reset (just deleted) or backoff disabled, might as well skip the Set lookup
|
||||
if (!backoffEnabled || resetBackoff || !this.backoffPausedDevices.has(device.ieeeAddr)) {
|
||||
// If device did not check in, ping it, if that fails it will be marked as offline
|
||||
this.timers.set(
|
||||
device.ieeeAddr,
|
||||
setTimeout(this.addToPingQueue.bind(this, device), (this.getTimeout(device) + utils.seconds(1) + jitter) * backoff),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.timers[device.ieeeAddr] = setTimeout(() => this.publishAvailability(device, true), this.getTimeout(device) + utils.seconds(1));
|
||||
this.timers.set(
|
||||
device.ieeeAddr,
|
||||
setTimeout(this.publishAvailability.bind(this, device, true), this.getTimeout(device) + utils.seconds(1)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private clearTimer(ieeeAddress: string): void {
|
||||
clearTimeout(this.timers.get(ieeeAddress));
|
||||
this.timers.delete(ieeeAddress);
|
||||
}
|
||||
|
||||
private addToPingQueue(device: Device): void {
|
||||
this.pingQueue.push(device);
|
||||
this.pingQueueExecuteNext().catch(utils.noop);
|
||||
@@ -66,7 +131,7 @@ export default class Availability extends Extension {
|
||||
|
||||
private removeFromPingQueue(device: Device): void {
|
||||
const index = this.pingQueue.findIndex((d) => d.ieeeAddr === device.ieeeAddr);
|
||||
if (index != -1) {
|
||||
if (index !== -1) {
|
||||
this.pingQueue.splice(index, 1);
|
||||
}
|
||||
}
|
||||
@@ -78,8 +143,8 @@ export default class Availability extends Extension {
|
||||
|
||||
this.pingQueueExecuting = true;
|
||||
const device = this.pingQueue[0];
|
||||
let pingedSuccessfully = false;
|
||||
const available = this.availabilityCache[device.ieeeAddr] || this.isAvailable(device);
|
||||
let pingSuccess = false;
|
||||
const available = this.lastPublishedAvailabilities.get(device.ieeeAddr) || this.isAvailable(device);
|
||||
const attempts = available ? 2 : 1;
|
||||
|
||||
for (let i = 1; i <= attempts; i++) {
|
||||
@@ -87,12 +152,12 @@ export default class Availability extends Extension {
|
||||
// Enable recovery if device is marked as available and first ping fails.
|
||||
await device.zh.ping(!available || i !== 2);
|
||||
|
||||
pingedSuccessfully = true;
|
||||
pingSuccess = true;
|
||||
|
||||
logger.debug(`Successfully pinged '${device.name}' (attempt ${i}/${attempts})`);
|
||||
break;
|
||||
} catch (error) {
|
||||
logger.warning(`Failed to ping '${device.name}' (attempt ${i}/${attempts}, ${error.message})`);
|
||||
logger.warning(`Failed to ping '${device.name}' (attempt ${i}/${attempts}, ${(error as Error).message})`);
|
||||
|
||||
// Try again in 3 seconds.
|
||||
if (i !== attempts) {
|
||||
@@ -106,8 +171,21 @@ export default class Availability extends Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.publishAvailability(device, !pingedSuccessfully);
|
||||
this.resetTimer(device);
|
||||
if (!pingSuccess && this.getBackoff(device)) {
|
||||
const currentBackoff = this.pingBackoffs.get(device.ieeeAddr) ?? 1;
|
||||
// setting is "greater than" but since we already did the ping, we use ">=" for comparison below (pause next)
|
||||
const pauseOnBackoff = this.getPauseOnBackoffGt(device);
|
||||
|
||||
if (pauseOnBackoff > 0 && currentBackoff >= pauseOnBackoff) {
|
||||
this.backoffPausedDevices.add(device.ieeeAddr);
|
||||
} else {
|
||||
// results in backoffs: *1.5, *3, *6, *12... (with default timeout: 10, 15, 30, 60, 120)
|
||||
this.pingBackoffs.set(device.ieeeAddr, currentBackoff * (available ? 1.5 : 2));
|
||||
}
|
||||
}
|
||||
|
||||
await this.publishAvailability(device, !pingSuccess);
|
||||
this.resetTimer(device, pingSuccess);
|
||||
this.removeFromPingQueue(device);
|
||||
|
||||
// Sleep 2 seconds before executing next ping
|
||||
@@ -120,22 +198,21 @@ export default class Availability extends Extension {
|
||||
|
||||
override async start(): Promise<void> {
|
||||
if (this.stopped) {
|
||||
throw new Error('This extension cannot be restarted.');
|
||||
throw new Error("This extension cannot be restarted.");
|
||||
}
|
||||
|
||||
this.eventBus.onEntityRenamed(this, async (data) => {
|
||||
if (utils.isAvailabilityEnabledForEntity(data.entity, settings.get())) {
|
||||
await this.mqtt.publish(`${data.from}/availability`, null, {retain: true, qos: 1});
|
||||
await this.mqtt.publish(`${data.from}/availability`, "", {clientOptions: {retain: true, qos: 1}});
|
||||
await this.publishAvailability(data.entity, false, true);
|
||||
}
|
||||
});
|
||||
|
||||
this.eventBus.onEntityRemoved(this, (data) => data.type == 'device' && clearTimeout(this.timers[data.id]));
|
||||
this.eventBus.onDeviceLeave(this, (data) => clearTimeout(this.timers[data.ieeeAddr]));
|
||||
this.eventBus.onEntityRemoved(this, (data) => data.entity.isDevice() && this.clearTimer(data.entity.ID));
|
||||
this.eventBus.onDeviceLeave(this, (data) => this.clearTimer(data.ieeeAddr));
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => this.retrieveState(data.device));
|
||||
this.eventBus.onLastSeenChanged(this, this.onLastSeenChanged);
|
||||
this.eventBus.onPublishAvailability(this, this.publishAvailabilityForAllEntities);
|
||||
this.eventBus.onGroupMembersChanged(this, (data) => this.publishAvailability(data.group, false));
|
||||
|
||||
// Publish initial availability
|
||||
await this.publishAvailabilityForAllEntities();
|
||||
|
||||
@@ -152,7 +229,7 @@ export default class Availability extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
@bind private async publishAvailabilityForAllEntities(): Promise<void> {
|
||||
private async publishAvailabilityForAllEntities(): Promise<void> {
|
||||
for (const entity of this.zigbee.devicesAndGroupsIterator(utils.deviceNotCoordinator)) {
|
||||
if (utils.isAvailabilityEnabledForEntity(entity, settings.get())) {
|
||||
await this.publishAvailability(entity, true, false, true);
|
||||
@@ -162,7 +239,8 @@ export default class Availability extends Extension {
|
||||
|
||||
private async publishAvailability(entity: Device | Group, logLastSeen: boolean, forcePublish = false, skipGroups = false): Promise<void> {
|
||||
if (logLastSeen && entity.isDevice()) {
|
||||
const ago = Date.now() - entity.zh.lastSeen;
|
||||
const ago = Date.now() - (entity.zh.lastSeen ?? /* v8 ignore next */ 0);
|
||||
|
||||
if (this.isActiveDevice(entity)) {
|
||||
logger.debug(`Active device '${entity.name}' was last seen '${(ago / utils.minutes(1)).toFixed(2)}' minutes ago.`);
|
||||
} else {
|
||||
@@ -172,19 +250,19 @@ export default class Availability extends Extension {
|
||||
|
||||
const available = this.isAvailable(entity);
|
||||
|
||||
if (!forcePublish && this.availabilityCache[entity.ID] == available) {
|
||||
if (!forcePublish && this.lastPublishedAvailabilities.get(entity.ID) === available) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entity.isDevice() && entity.ieeeAddr in this.availabilityCache && available && this.availabilityCache[entity.ieeeAddr] === false) {
|
||||
if (entity.isDevice() && available && this.lastPublishedAvailabilities.get(entity.ieeeAddr) === false) {
|
||||
logger.debug(`Device '${entity.name}' reconnected`);
|
||||
this.retrieveState(entity);
|
||||
}
|
||||
|
||||
const topic = `${entity.name}/availability`;
|
||||
const payload = utils.availabilityPayload(available ? 'online' : 'offline', settings.get());
|
||||
this.availabilityCache[entity.ID] = available;
|
||||
await this.mqtt.publish(topic, payload, {retain: true, qos: 1});
|
||||
const payload: Zigbee2MQTTAPI["{friendlyName}/availability"] = {state: available ? "online" : "offline"};
|
||||
this.lastPublishedAvailabilities.set(entity.ID, available);
|
||||
await this.mqtt.publish(topic, JSON.stringify(payload), {clientOptions: {retain: true, qos: 1}});
|
||||
|
||||
if (!skipGroups && entity.isDevice()) {
|
||||
for (const group of this.zigbee.groupsIterator()) {
|
||||
@@ -199,7 +277,7 @@ export default class Availability extends Extension {
|
||||
if (utils.isAvailabilityEnabledForEntity(data.device, settings.get())) {
|
||||
// Remove from ping queue, not necessary anymore since we know the device is online.
|
||||
this.removeFromPingQueue(data.device);
|
||||
this.resetTimer(data.device);
|
||||
this.resetTimer(data.device, true);
|
||||
await this.publishAvailability(data.device, false);
|
||||
}
|
||||
}
|
||||
@@ -208,7 +286,7 @@ export default class Availability extends Extension {
|
||||
this.stopped = true;
|
||||
this.pingQueue = [];
|
||||
|
||||
for (const t of Object.values(this.timers)) {
|
||||
for (const [, t] of this.timers) {
|
||||
clearTimeout(t);
|
||||
}
|
||||
|
||||
@@ -220,39 +298,48 @@ export default class Availability extends Extension {
|
||||
* Retrieve state of a device in a debounced manner, this function is called on a 'deviceAnnounce' which a
|
||||
* device can send multiple times after each other.
|
||||
*/
|
||||
if (device.definition && !device.zh.interviewing && !this.retrieveStateDebouncers[device.ieeeAddr]) {
|
||||
this.retrieveStateDebouncers[device.ieeeAddr] = debounce(async () => {
|
||||
logger.debug(`Retrieving state of '${device.name}' after reconnect`);
|
||||
if (device.definition && device.interviewed && !this.retrieveStateDebouncers.get(device.ieeeAddr)) {
|
||||
this.retrieveStateDebouncers.set(
|
||||
device.ieeeAddr,
|
||||
debounce(async () => {
|
||||
logger.debug(`Retrieving state of '${device.name}' after reconnect`);
|
||||
|
||||
// Color and color temperature converters do both, only needs to be called once.
|
||||
for (const item of RETRIEVE_ON_RECONNECT) {
|
||||
if (item.condition && this.state.get(device) && !item.condition(this.state.get(device))) {
|
||||
continue;
|
||||
// Color and color temperature converters do both, only needs to be called once.
|
||||
for (const item of RETRIEVE_ON_RECONNECT) {
|
||||
if (item.condition && this.state.get(device) && !item.condition(this.state.get(device))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: doesn't change once valid
|
||||
const converter = device.definition!.toZigbee.find((c) => !c.key || c.key.find((k) => item.keys.includes(k)));
|
||||
const options: KeyValue = device.options;
|
||||
const state = this.state.get(device);
|
||||
const meta: zhc.Tz.Meta = {
|
||||
message: this.state.get(device),
|
||||
// biome-ignore lint/style/noNonNullAssertion: doesn't change once valid
|
||||
mapped: device.definition!,
|
||||
endpoint_name: undefined,
|
||||
options,
|
||||
state,
|
||||
device: device.zh,
|
||||
/* v8 ignore next */
|
||||
publish: (payload: KeyValue) => this.publishEntityState(device, payload),
|
||||
};
|
||||
|
||||
try {
|
||||
const endpoint = device.endpoint();
|
||||
assert(endpoint);
|
||||
await converter?.convertGet?.(endpoint, item.keys[0], meta);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read state of '${device.name}' after reconnect (${(error as Error).message})`);
|
||||
}
|
||||
|
||||
await utils.sleep(500);
|
||||
}
|
||||
|
||||
const converter = device.definition.toZigbee.find((c) => !c.key || c.key.find((k) => item.keys.includes(k)));
|
||||
const options: KeyValue = device.options;
|
||||
const state = this.state.get(device);
|
||||
const meta: zhc.Tz.Meta = {
|
||||
message: this.state.get(device),
|
||||
mapped: device.definition,
|
||||
endpoint_name: null,
|
||||
options,
|
||||
state,
|
||||
device: device.zh,
|
||||
};
|
||||
|
||||
try {
|
||||
await converter?.convertGet?.(device.endpoint(), item.keys[0], meta);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read state of '${device.name}' after reconnect (${error.message})`);
|
||||
}
|
||||
|
||||
await utils.sleep(500);
|
||||
}
|
||||
}, utils.seconds(2));
|
||||
}, utils.seconds(2)),
|
||||
);
|
||||
}
|
||||
|
||||
this.retrieveStateDebouncers[device.ieeeAddr]?.();
|
||||
this.retrieveStateDebouncers.get(device.ieeeAddr)?.();
|
||||
}
|
||||
}
|
||||
|
||||
+318
-274
@@ -1,43 +1,42 @@
|
||||
import bind from 'bind-decorator';
|
||||
import debounce from 'debounce';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import {Zcl} from 'zigbee-herdsman';
|
||||
import {ClusterName} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
|
||||
import assert from "node:assert";
|
||||
import bind from "bind-decorator";
|
||||
import debounce from "debounce";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {Zcl} from "zigbee-herdsman";
|
||||
import type {TClusterAttributeKeys} from "zigbee-herdsman/dist/zspec/zcl/definition/clusters-types";
|
||||
import type {ClusterName} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
|
||||
import Device from "../model/device";
|
||||
import Group from "../model/group";
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils, {DEFAULT_BIND_GROUP_ID} from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
|
||||
const LEGACY_API = settings.get().advanced.legacy_api;
|
||||
const LEGACY_TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/(bind|unbind)/.+$`);
|
||||
const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
|
||||
const ALL_CLUSTER_CANDIDATES: readonly ClusterName[] = [
|
||||
'genScenes',
|
||||
'genOnOff',
|
||||
'genLevelCtrl',
|
||||
'lightingColorCtrl',
|
||||
'closuresWindowCovering',
|
||||
'hvacThermostat',
|
||||
'msIlluminanceMeasurement',
|
||||
'msTemperatureMeasurement',
|
||||
'msRelativeHumidity',
|
||||
'msSoilMoisture',
|
||||
'msCO2',
|
||||
"genScenes",
|
||||
"genOnOff",
|
||||
"genLevelCtrl",
|
||||
"lightingColorCtrl",
|
||||
"closuresWindowCovering",
|
||||
"hvacThermostat",
|
||||
"msIlluminanceMeasurement",
|
||||
"msTemperatureMeasurement",
|
||||
"msRelativeHumidity",
|
||||
"msSoilMoisture",
|
||||
"msCO2",
|
||||
];
|
||||
|
||||
// See zigbee-herdsman-converters
|
||||
const DEFAULT_BIND_GROUP = {type: 'group_number', ID: 901, name: 'default_bind_group'};
|
||||
const DEFAULT_BIND_GROUP = {type: "group_number", ID: DEFAULT_BIND_GROUP_ID, name: "default_bind_group"};
|
||||
const DEFAULT_REPORT_CONFIG = {minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1};
|
||||
|
||||
const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean; colorXY: boolean}> => {
|
||||
if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') == null) {
|
||||
await endpoint.read('lightingColorCtrl', ['colorCapabilities']);
|
||||
if (endpoint.getClusterAttributeValue("lightingColorCtrl", "colorCapabilities") == null) {
|
||||
await endpoint.read("lightingColorCtrl", ["colorCapabilities"]);
|
||||
}
|
||||
|
||||
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number;
|
||||
const value = endpoint.getClusterAttributeValue("lightingColorCtrl", "colorCapabilities") as number;
|
||||
|
||||
return {
|
||||
colorTemperature: (value & (1 << 4)) > 0,
|
||||
@@ -45,73 +44,53 @@ const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemper
|
||||
};
|
||||
};
|
||||
|
||||
const REPORT_CLUSTERS: Readonly<
|
||||
Partial<
|
||||
Record<
|
||||
ClusterName,
|
||||
Readonly<{
|
||||
attribute: string;
|
||||
minimumReportInterval: number;
|
||||
maximumReportInterval: number;
|
||||
reportableChange: number;
|
||||
condition?: (endpoint: zh.Endpoint) => Promise<boolean>;
|
||||
}>[]
|
||||
>
|
||||
>
|
||||
> = {
|
||||
genOnOff: [{attribute: 'onOff', ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}],
|
||||
genLevelCtrl: [{attribute: 'currentLevel', ...DEFAULT_REPORT_CONFIG}],
|
||||
const REPORT_CLUSTERS = {
|
||||
genOnOff: [{attribute: "onOff" as const, ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}],
|
||||
genLevelCtrl: [{attribute: "currentLevel" as const, ...DEFAULT_REPORT_CONFIG}],
|
||||
lightingColorCtrl: [
|
||||
{
|
||||
attribute: 'colorTemperature',
|
||||
attribute: "colorTemperature" as const,
|
||||
...DEFAULT_REPORT_CONFIG,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
condition: async (endpoint: zh.Endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
},
|
||||
{
|
||||
attribute: 'currentX',
|
||||
attribute: "currentX" as const,
|
||||
...DEFAULT_REPORT_CONFIG,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
condition: async (endpoint: zh.Endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
{
|
||||
attribute: 'currentY',
|
||||
attribute: "currentY" as const,
|
||||
...DEFAULT_REPORT_CONFIG,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
condition: async (endpoint: zh.Endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
],
|
||||
closuresWindowCovering: [
|
||||
{attribute: 'currentPositionLiftPercentage', ...DEFAULT_REPORT_CONFIG},
|
||||
{attribute: 'currentPositionTiltPercentage', ...DEFAULT_REPORT_CONFIG},
|
||||
{attribute: "currentPositionLiftPercentage" as const, ...DEFAULT_REPORT_CONFIG},
|
||||
{attribute: "currentPositionTiltPercentage" as const, ...DEFAULT_REPORT_CONFIG},
|
||||
],
|
||||
};
|
||||
|
||||
type PollOnMessage = {
|
||||
cluster: Readonly<Partial<Record<ClusterName, {type: string; data: KeyValue}[]>>>;
|
||||
read: Readonly<{cluster: string; attributes: string[]; attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise<string[]>}>;
|
||||
manufacturerIDs: readonly number[];
|
||||
manufacturerNames: readonly string[];
|
||||
}[];
|
||||
|
||||
const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
|
||||
const POLL_ON_MESSAGE = [
|
||||
{
|
||||
// On messages that have the cluster and type of below
|
||||
cluster: {
|
||||
manuSpecificPhilips: [
|
||||
{type: 'commandHueNotification', data: {button: 2}},
|
||||
{type: 'commandHueNotification', data: {button: 3}},
|
||||
{type: "commandHueNotification", data: {button: 2}},
|
||||
{type: "commandHueNotification", data: {button: 3}},
|
||||
],
|
||||
genLevelCtrl: [
|
||||
{type: 'commandStep', data: {}},
|
||||
{type: 'commandStepWithOnOff', data: {}},
|
||||
{type: 'commandStop', data: {}},
|
||||
{type: 'commandMoveWithOnOff', data: {}},
|
||||
{type: 'commandStopWithOnOff', data: {}},
|
||||
{type: 'commandMove', data: {}},
|
||||
{type: 'commandMoveToLevelWithOnOff', data: {}},
|
||||
{type: "commandStep", data: {}},
|
||||
{type: "commandStepWithOnOff", data: {}},
|
||||
{type: "commandStop", data: {}},
|
||||
{type: "commandMoveWithOnOff", data: {}},
|
||||
{type: "commandStopWithOnOff", data: {}},
|
||||
{type: "commandMove", data: {}},
|
||||
{type: "commandMoveToLevelWithOnOff", data: {}},
|
||||
],
|
||||
genScenes: [{type: 'commandRecall', data: {}}],
|
||||
genScenes: [{type: "commandRecall", data: {}}],
|
||||
},
|
||||
// Read the following attributes
|
||||
read: {cluster: 'genLevelCtrl', attributes: ['currentLevel']},
|
||||
read: {cluster: "genLevelCtrl" as const, attributes: ["currentLevel"] as TClusterAttributeKeys<"genLevelCtrl">},
|
||||
// When the bound devices/members of group have the following manufacturerIDs
|
||||
manufacturerIDs: [
|
||||
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
|
||||
@@ -121,29 +100,29 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
|
||||
Zcl.ManufacturerCode.TELINK_MICRO,
|
||||
Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO,
|
||||
],
|
||||
manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
|
||||
manufacturerNames: ["GLEDOPTO", "Trust International B.V.\u0000"],
|
||||
},
|
||||
{
|
||||
cluster: {
|
||||
genLevelCtrl: [
|
||||
{type: 'commandStepWithOnOff', data: {}},
|
||||
{type: 'commandMoveWithOnOff', data: {}},
|
||||
{type: 'commandStopWithOnOff', data: {}},
|
||||
{type: 'commandMoveToLevelWithOnOff', data: {}},
|
||||
{type: "commandStepWithOnOff", data: {}},
|
||||
{type: "commandMoveWithOnOff", data: {}},
|
||||
{type: "commandStopWithOnOff", data: {}},
|
||||
{type: "commandMoveToLevelWithOnOff", data: {}},
|
||||
],
|
||||
genOnOff: [
|
||||
{type: 'commandOn', data: {}},
|
||||
{type: 'commandOff', data: {}},
|
||||
{type: 'commandOffWithEffect', data: {}},
|
||||
{type: 'commandToggle', data: {}},
|
||||
{type: "commandOn", data: {}},
|
||||
{type: "commandOff", data: {}},
|
||||
{type: "commandOffWithEffect", data: {}},
|
||||
{type: "commandToggle", data: {}},
|
||||
],
|
||||
genScenes: [{type: 'commandRecall', data: {}}],
|
||||
genScenes: [{type: "commandRecall", data: {}}],
|
||||
manuSpecificPhilips: [
|
||||
{type: 'commandHueNotification', data: {button: 1}},
|
||||
{type: 'commandHueNotification', data: {button: 4}},
|
||||
{type: "commandHueNotification", data: {button: 1}},
|
||||
{type: "commandHueNotification", data: {button: 4}},
|
||||
],
|
||||
},
|
||||
read: {cluster: 'genOnOff', attributes: ['onOff']},
|
||||
read: {cluster: "genOnOff" as const, attributes: ["onOff"] as TClusterAttributeKeys<"genOnOff">},
|
||||
manufacturerIDs: [
|
||||
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
|
||||
Zcl.ManufacturerCode.ATMEL,
|
||||
@@ -152,29 +131,27 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
|
||||
Zcl.ManufacturerCode.TELINK_MICRO,
|
||||
Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO,
|
||||
],
|
||||
manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
|
||||
manufacturerNames: ["GLEDOPTO", "Trust International B.V.\u0000"],
|
||||
},
|
||||
{
|
||||
cluster: {
|
||||
genScenes: [{type: 'commandRecall', data: {}}],
|
||||
genScenes: [{type: "commandRecall", data: {}}],
|
||||
},
|
||||
read: {
|
||||
cluster: 'lightingColorCtrl',
|
||||
attributes: [] as string[],
|
||||
cluster: "lightingColorCtrl" as const,
|
||||
attributes: [] as TClusterAttributeKeys<"lightingColorCtrl">,
|
||||
// Since not all devices support the same attributes they need to be calculated dynamically
|
||||
// depending on the capabilities of the endpoint.
|
||||
attributesForEndpoint: async (endpoint): Promise<string[]> => {
|
||||
attributesForEndpoint: async (endpoint: zh.Endpoint): Promise<TClusterAttributeKeys<"lightingColorCtrl">> => {
|
||||
const supportedAttrs = await getColorCapabilities(endpoint);
|
||||
const readAttrs: string[] = [];
|
||||
const readAttrs: TClusterAttributeKeys<"lightingColorCtrl"> = [];
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (supportedAttrs.colorXY) {
|
||||
readAttrs.push('currentX', 'currentY');
|
||||
readAttrs.push("currentX", "currentY");
|
||||
}
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (supportedAttrs.colorTemperature) {
|
||||
readAttrs.push('colorTemperature');
|
||||
readAttrs.push("colorTemperature");
|
||||
}
|
||||
|
||||
return readAttrs;
|
||||
@@ -188,179 +165,245 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
|
||||
Zcl.ManufacturerCode.TELINK_MICRO,
|
||||
// Note: ManufacturerCode.BUSCH_JAEGER is left out intentionally here as their devices don't support colors
|
||||
],
|
||||
manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
|
||||
manufacturerNames: ["GLEDOPTO", "Trust International B.V.\u0000"],
|
||||
},
|
||||
];
|
||||
|
||||
interface ParsedMQTTMessage {
|
||||
type: 'bind' | 'unbind';
|
||||
sourceKey: string;
|
||||
targetKey: string;
|
||||
clusters: string[];
|
||||
type: "bind" | "unbind";
|
||||
sourceKey?: string;
|
||||
sourceEndpointKey?: string | number;
|
||||
targetKey?: string | number;
|
||||
targetEndpointKey?: string | number;
|
||||
clusters?: string[];
|
||||
skipDisableReporting: boolean;
|
||||
resolvedSource?: Device;
|
||||
resolvedTarget?: Device | Group | typeof DEFAULT_BIND_GROUP;
|
||||
resolvedSourceEndpoint?: zh.Endpoint;
|
||||
resolvedBindTarget?: number | zh.Endpoint | zh.Group;
|
||||
}
|
||||
|
||||
export default class Bind extends Extension {
|
||||
#topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
|
||||
private pollDebouncers: {[s: string]: () => void} = {};
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onDeviceMessage(this, this.poll);
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.eventBus.onGroupMembersChanged(this, this.onGroupMembersChanged);
|
||||
}
|
||||
|
||||
private parseMQTTMessage(data: eventdata.MQTTMessage): ParsedMQTTMessage {
|
||||
let type: ParsedMQTTMessage['type'] = null;
|
||||
let sourceKey: ParsedMQTTMessage['sourceKey'] = null;
|
||||
let targetKey: ParsedMQTTMessage['targetKey'] = null;
|
||||
let clusters: ParsedMQTTMessage['clusters'] = null;
|
||||
let skipDisableReporting: ParsedMQTTMessage['skipDisableReporting'] = false;
|
||||
private parseMQTTMessage(
|
||||
data: eventdata.MQTTMessage,
|
||||
): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] {
|
||||
if (data.topic.match(this.#topicRegex)) {
|
||||
const type = data.topic.endsWith("unbind") ? "unbind" : "bind";
|
||||
let skipDisableReporting = false;
|
||||
const message = JSON.parse(data.message) as Zigbee2MQTTAPI["bridge/request/device/bind"];
|
||||
|
||||
if (LEGACY_API && data.topic.match(LEGACY_TOPIC_REGEX)) {
|
||||
const topic = data.topic.replace(`${settings.get().mqtt.base_topic}/bridge/`, '');
|
||||
type = topic.split('/')[0] as ParsedMQTTMessage['type'];
|
||||
sourceKey = topic.replace(`${type}/`, '');
|
||||
targetKey = data.message;
|
||||
} else if (data.topic.match(TOPIC_REGEX)) {
|
||||
type = data.topic.endsWith('unbind') ? 'unbind' : 'bind';
|
||||
const message = JSON.parse(data.message);
|
||||
sourceKey = message.from;
|
||||
targetKey = message.to;
|
||||
clusters = message.clusters;
|
||||
skipDisableReporting = 'skip_disable_reporting' in message ? message.skip_disable_reporting : false;
|
||||
if (typeof message !== "object" || message.from == null || message.to == null) {
|
||||
return [message, {type, skipDisableReporting}, "Invalid payload"];
|
||||
}
|
||||
|
||||
const sourceKey = message.from;
|
||||
const sourceEndpointKey = message.from_endpoint ?? "default";
|
||||
const targetKey = message.to;
|
||||
const targetEndpointKey = message.to_endpoint;
|
||||
const clusters = message.clusters;
|
||||
skipDisableReporting = message.skip_disable_reporting != null ? message.skip_disable_reporting : false;
|
||||
const resolvedSource = this.zigbee.resolveEntity(message.from) as Device;
|
||||
|
||||
if (!resolvedSource || !(resolvedSource instanceof Device)) {
|
||||
return [message, {type, skipDisableReporting}, `Source device '${message.from}' does not exist`];
|
||||
}
|
||||
|
||||
const resolvedTarget =
|
||||
message.to === DEFAULT_BIND_GROUP.name || message.to === DEFAULT_BIND_GROUP.ID
|
||||
? DEFAULT_BIND_GROUP
|
||||
: this.zigbee.resolveEntity(message.to);
|
||||
|
||||
if (!resolvedTarget) {
|
||||
return [message, {type, skipDisableReporting}, `Target device or group '${message.to}' does not exist`];
|
||||
}
|
||||
|
||||
const resolvedSourceEndpoint = resolvedSource.endpoint(sourceEndpointKey);
|
||||
|
||||
if (!resolvedSourceEndpoint) {
|
||||
return [
|
||||
message,
|
||||
{type, skipDisableReporting},
|
||||
`Source device '${resolvedSource.name}' does not have endpoint '${sourceEndpointKey}'`,
|
||||
];
|
||||
}
|
||||
|
||||
// resolves to 'default' endpoint if targetEndpointKey is invalid (used by frontend for 'Coordinator')
|
||||
const resolvedBindTarget =
|
||||
resolvedTarget instanceof Device
|
||||
? resolvedTarget.endpoint(targetEndpointKey)
|
||||
: resolvedTarget instanceof Group
|
||||
? resolvedTarget.zh
|
||||
: Number(resolvedTarget.ID);
|
||||
|
||||
if (resolvedTarget instanceof Device && !resolvedBindTarget) {
|
||||
return [
|
||||
message,
|
||||
{type, skipDisableReporting},
|
||||
`Target device '${resolvedTarget.name}' does not have endpoint '${targetEndpointKey}'`,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
message,
|
||||
{
|
||||
type,
|
||||
sourceKey,
|
||||
sourceEndpointKey,
|
||||
targetKey,
|
||||
targetEndpointKey,
|
||||
clusters,
|
||||
skipDisableReporting,
|
||||
resolvedSource,
|
||||
resolvedTarget,
|
||||
resolvedSourceEndpoint,
|
||||
resolvedBindTarget,
|
||||
},
|
||||
undefined,
|
||||
];
|
||||
}
|
||||
|
||||
return {type, sourceKey, targetKey, clusters, skipDisableReporting};
|
||||
return [undefined, undefined, undefined];
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const {type, sourceKey, targetKey, clusters, skipDisableReporting} = this.parseMQTTMessage(data);
|
||||
const [raw, parsed, error] = this.parseMQTTMessage(data);
|
||||
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
|
||||
let error = null;
|
||||
const parsedSource = this.zigbee.resolveEntityAndEndpoint(sourceKey);
|
||||
const parsedTarget = this.zigbee.resolveEntityAndEndpoint(targetKey);
|
||||
const source = parsedSource.entity;
|
||||
const target = targetKey === DEFAULT_BIND_GROUP.name ? DEFAULT_BIND_GROUP : parsedTarget.entity;
|
||||
const responseData: KeyValue = {from: sourceKey, to: targetKey};
|
||||
|
||||
if (!source || !(source instanceof Device)) {
|
||||
error = `Source device '${sourceKey}' does not exist`;
|
||||
} else if (parsedSource.endpointID && !parsedSource.endpoint) {
|
||||
error = `Source device '${parsedSource.ID}' does not have endpoint '${parsedSource.endpointID}'`;
|
||||
} else if (!target) {
|
||||
error = `Target device or group '${targetKey}' does not exist`;
|
||||
} else if (target instanceof Device && parsedTarget.endpointID && !parsedTarget.endpoint) {
|
||||
error = `Target device '${parsedTarget.ID}' does not have endpoint '${parsedTarget.endpointID}'`;
|
||||
} else {
|
||||
const successfulClusters: string[] = [];
|
||||
const failedClusters = [];
|
||||
const attemptedClusters = [];
|
||||
|
||||
const bindSource: zh.Endpoint = parsedSource.endpoint;
|
||||
const bindTarget: number | zh.Group | zh.Endpoint =
|
||||
target instanceof Device ? parsedTarget.endpoint : target instanceof Group ? target.zh : Number(target.ID);
|
||||
// Find which clusters are supported by both the source and target.
|
||||
// Groups are assumed to support all clusters.
|
||||
const clusterCandidates = clusters ?? ALL_CLUSTER_CANDIDATES;
|
||||
|
||||
for (const cluster of clusterCandidates) {
|
||||
let matchingClusters = false;
|
||||
|
||||
const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' || (target as Device).zh.type === 'Coordinator';
|
||||
|
||||
if (!anyClusterValid && utils.isEndpoint(bindTarget)) {
|
||||
matchingClusters =
|
||||
(bindTarget.supportsInputCluster(cluster) && bindSource.supportsOutputCluster(cluster)) ||
|
||||
(bindSource.supportsInputCluster(cluster) && bindTarget.supportsOutputCluster(cluster));
|
||||
}
|
||||
|
||||
const sourceValid = bindSource.supportsInputCluster(cluster) || bindSource.supportsOutputCluster(cluster);
|
||||
|
||||
if (sourceValid && (anyClusterValid || matchingClusters)) {
|
||||
logger.debug(`${type}ing cluster '${cluster}' from '${source.name}' to '${target.name}'`);
|
||||
attemptedClusters.push(cluster);
|
||||
|
||||
try {
|
||||
if (type === 'bind') {
|
||||
await bindSource.bind(cluster, bindTarget);
|
||||
} else {
|
||||
await bindSource.unbind(cluster, bindTarget);
|
||||
}
|
||||
|
||||
successfulClusters.push(cluster);
|
||||
logger.info(
|
||||
`Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from '${source.name}' to '${target.name}'`,
|
||||
);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
await this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_${type}`, message: {from: source.name, to: target.name, cluster}}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
failedClusters.push(cluster);
|
||||
logger.error(`Failed to ${type} cluster '${cluster}' from '${source.name}' to '${target.name}' (${error})`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
await this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_${type}_failed`, message: {from: source.name, to: target.name, cluster}}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attemptedClusters.length === 0) {
|
||||
logger.error(`Nothing to ${type} from '${source.name}' to '${target.name}'`);
|
||||
error = `Nothing to ${type}`;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_${type}_failed`, message: {from: source.name, to: target.name}}));
|
||||
}
|
||||
} else if (failedClusters.length === attemptedClusters.length) {
|
||||
error = `Failed to ${type}`;
|
||||
}
|
||||
|
||||
responseData[`clusters`] = successfulClusters;
|
||||
responseData[`failed`] = failedClusters;
|
||||
|
||||
if (successfulClusters.length !== 0) {
|
||||
if (type === 'bind') {
|
||||
await this.setupReporting(bindSource.binds.filter((b) => successfulClusters.includes(b.cluster.name) && b.target === bindTarget));
|
||||
} else if (typeof bindTarget !== 'number' && !skipDisableReporting) {
|
||||
await this.disableUnnecessaryReportings(bindTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const triggeredViaLegacyApi = data.topic.match(LEGACY_TOPIC_REGEX);
|
||||
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
|
||||
await this.mqtt.publish(`bridge/response/device/${type}`, stringify(response));
|
||||
if (!raw || !parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
await this.publishResponse(parsed.type, raw, {}, error);
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
type,
|
||||
sourceKey,
|
||||
sourceEndpointKey,
|
||||
targetKey,
|
||||
targetEndpointKey,
|
||||
clusters,
|
||||
skipDisableReporting,
|
||||
resolvedSource,
|
||||
resolvedTarget,
|
||||
resolvedSourceEndpoint,
|
||||
resolvedBindTarget,
|
||||
} = parsed;
|
||||
|
||||
assert(resolvedSource, "`resolvedSource` is missing");
|
||||
assert(resolvedTarget, "`resolvedTarget` is missing");
|
||||
assert(resolvedSourceEndpoint, "`resolvedSourceEndpoint` is missing");
|
||||
assert(resolvedBindTarget !== undefined, "`resolvedBindTarget` is missing");
|
||||
|
||||
const successfulClusters: string[] = [];
|
||||
const failedClusters = [];
|
||||
const attemptedClusters = [];
|
||||
// Find which clusters are supported by both the source and target.
|
||||
// Groups are assumed to support all clusters.
|
||||
const clusterCandidates = clusters ?? ALL_CLUSTER_CANDIDATES;
|
||||
|
||||
for (const cluster of clusterCandidates) {
|
||||
let matchingClusters = false;
|
||||
|
||||
const anyClusterValid =
|
||||
utils.isZHGroup(resolvedBindTarget) ||
|
||||
typeof resolvedBindTarget === "number" ||
|
||||
(resolvedTarget instanceof Device && resolvedTarget.zh.type === "Coordinator");
|
||||
|
||||
if (!anyClusterValid && utils.isZHEndpoint(resolvedBindTarget)) {
|
||||
matchingClusters =
|
||||
(resolvedBindTarget.supportsInputCluster(cluster) && resolvedSourceEndpoint.supportsOutputCluster(cluster)) ||
|
||||
(resolvedSourceEndpoint.supportsInputCluster(cluster) && resolvedBindTarget.supportsOutputCluster(cluster));
|
||||
}
|
||||
|
||||
const sourceValid = resolvedSourceEndpoint.supportsInputCluster(cluster) || resolvedSourceEndpoint.supportsOutputCluster(cluster);
|
||||
|
||||
if (sourceValid && (anyClusterValid || matchingClusters)) {
|
||||
logger.debug(`${type}ing cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`);
|
||||
attemptedClusters.push(cluster);
|
||||
|
||||
try {
|
||||
if (type === "bind") {
|
||||
await resolvedSourceEndpoint.bind(cluster, resolvedBindTarget);
|
||||
} else {
|
||||
await resolvedSourceEndpoint.unbind(cluster, resolvedBindTarget);
|
||||
}
|
||||
|
||||
successfulClusters.push(cluster);
|
||||
logger.info(
|
||||
`Successfully ${type === "bind" ? "bound" : "unbound"} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`,
|
||||
);
|
||||
} catch (error) {
|
||||
failedClusters.push(cluster);
|
||||
logger.error(`Failed to ${type} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}' (${error})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attemptedClusters.length === 0) {
|
||||
logger.error(`Nothing to ${type} from '${resolvedSource.name}' to '${resolvedTarget.name}'`);
|
||||
await this.publishResponse(parsed.type, raw, {}, `Nothing to ${type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (failedClusters.length === attemptedClusters.length) {
|
||||
await this.publishResponse(parsed.type, raw, {}, `Failed to ${type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/device/bind"] | Zigbee2MQTTAPI["bridge/response/device/unbind"] = {
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid with assert above on `resolvedSource`
|
||||
from: sourceKey!,
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid with assert above on `resolvedSourceEndpoint`
|
||||
from_endpoint: sourceEndpointKey!,
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid with assert above on `resolvedTarget`
|
||||
to: targetKey!,
|
||||
to_endpoint: targetEndpointKey,
|
||||
clusters: successfulClusters,
|
||||
failed: failedClusters,
|
||||
};
|
||||
|
||||
if (successfulClusters.length !== 0) {
|
||||
if (type === "bind") {
|
||||
await this.setupReporting(
|
||||
resolvedSourceEndpoint.binds.filter((b) => successfulClusters.includes(b.cluster.name) && b.target === resolvedBindTarget),
|
||||
);
|
||||
} else if (typeof resolvedBindTarget !== "number" && !skipDisableReporting) {
|
||||
await this.disableUnnecessaryReportings(resolvedBindTarget);
|
||||
}
|
||||
}
|
||||
|
||||
await this.publishResponse(parsed.type, raw, responseData);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
}
|
||||
|
||||
private async publishResponse<T extends Zigbee2MQTTResponseEndpoints>(
|
||||
type: ParsedMQTTMessage["type"],
|
||||
request: KeyValue,
|
||||
data: Zigbee2MQTTAPI[T],
|
||||
error?: string,
|
||||
): Promise<void> {
|
||||
const response = utils.getResponse(request, data, error);
|
||||
await this.mqtt.publish(`bridge/response/device/${type}`, stringify(response));
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
} else {
|
||||
this.eventBus.emitDevicesChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onGroupMembersChanged(data: eventdata.GroupMembersChanged): Promise<void> {
|
||||
if (data.action === 'add') {
|
||||
if (data.action === "add") {
|
||||
const bindsToGroup: zh.Bind[] = [];
|
||||
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
@@ -383,7 +426,7 @@ export default class Bind extends Extension {
|
||||
}
|
||||
|
||||
getSetupReportingEndpoints(bind: zh.Bind, coordinatorEp: zh.Endpoint): zh.Endpoint[] {
|
||||
const endpoints = utils.isEndpoint(bind.target) ? [bind.target] : bind.target.members;
|
||||
const endpoints = utils.isZHEndpoint(bind.target) ? [bind.target] : bind.target.members;
|
||||
|
||||
return endpoints.filter((e) => {
|
||||
if (!e.supportsInputCluster(bind.cluster.name)) {
|
||||
@@ -406,30 +449,30 @@ export default class Bind extends Extension {
|
||||
const coordinatorEndpoint = this.zigbee.firstCoordinatorEndpoint();
|
||||
|
||||
for (const bind of binds) {
|
||||
/* istanbul ignore else */
|
||||
if (bind.cluster.name in REPORT_CLUSTERS) {
|
||||
for (const endpoint of this.getSetupReportingEndpoints(bind, coordinatorEndpoint)) {
|
||||
const entity = `${this.zigbee.resolveEntity(endpoint.getDevice()).name}/${endpoint.ID}`;
|
||||
// biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: ???
|
||||
const resolvedDevice = this.zigbee.resolveEntity(endpoint.getDevice())!;
|
||||
const entity = `${resolvedDevice.name}/${endpoint.ID}`;
|
||||
|
||||
try {
|
||||
await endpoint.bind(bind.cluster.name, coordinatorEndpoint);
|
||||
|
||||
const items = [];
|
||||
|
||||
for (const c of REPORT_CLUSTERS[bind.cluster.name as ClusterName]) {
|
||||
/* istanbul ignore else */
|
||||
if (!c.condition || (await c.condition(endpoint))) {
|
||||
const i = {...c};
|
||||
delete i.condition;
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from outer `if`
|
||||
for (const c of REPORT_CLUSTERS[bind.cluster.name as keyof typeof REPORT_CLUSTERS]!) {
|
||||
if (!("condition" in c) || !c.condition || (await c.condition(endpoint))) {
|
||||
const {attribute, minimumReportInterval, maximumReportInterval, reportableChange} = c;
|
||||
|
||||
items.push(i);
|
||||
items.push({attribute, minimumReportInterval, maximumReportInterval, reportableChange});
|
||||
}
|
||||
}
|
||||
|
||||
await endpoint.configureReporting(bind.cluster.name, items);
|
||||
await endpoint.configureReporting(bind.cluster.name as keyof typeof REPORT_CLUSTERS, items);
|
||||
logger.info(`Successfully setup reporting for '${entity}' cluster '${bind.cluster.name}'`);
|
||||
} catch (error) {
|
||||
logger.warning(`Failed to setup reporting for '${entity}' cluster '${bind.cluster.name}' (${error.message})`);
|
||||
logger.warning(`Failed to setup reporting for '${entity}' cluster '${bind.cluster.name}' (${(error as Error).message})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,7 +483,7 @@ export default class Bind extends Extension {
|
||||
|
||||
async disableUnnecessaryReportings(target: zh.Group | zh.Endpoint): Promise<void> {
|
||||
const coordinator = this.zigbee.firstCoordinatorEndpoint();
|
||||
const endpoints = utils.isEndpoint(target) ? [target] : target.members;
|
||||
const endpoints = utils.isZHEndpoint(target) ? [target] : target.members;
|
||||
const allBinds: zh.Bind[] = [];
|
||||
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
@@ -458,13 +501,12 @@ export default class Bind extends Extension {
|
||||
const boundClusters: string[] = [];
|
||||
|
||||
for (const bind of allBinds) {
|
||||
if (utils.isEndpoint(bind.target) ? bind.target === endpoint : bind.target.members.includes(endpoint)) {
|
||||
if (utils.isZHEndpoint(bind.target) ? bind.target === endpoint : bind.target.members.includes(endpoint)) {
|
||||
requiredClusters.push(bind.cluster.name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const b of endpoint.binds) {
|
||||
/* istanbul ignore else */
|
||||
if (b.target === coordinator && !requiredClusters.includes(b.cluster.name) && b.cluster.name in REPORT_CLUSTERS) {
|
||||
boundClusters.push(b.cluster.name);
|
||||
}
|
||||
@@ -476,20 +518,19 @@ export default class Bind extends Extension {
|
||||
|
||||
const items = [];
|
||||
|
||||
for (const item of REPORT_CLUSTERS[cluster as ClusterName]) {
|
||||
/* istanbul ignore else */
|
||||
if (!item.condition || (await item.condition(endpoint))) {
|
||||
const i = {...item};
|
||||
delete i.condition;
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from loop (pushed to array only if in)
|
||||
for (const item of REPORT_CLUSTERS[cluster as keyof typeof REPORT_CLUSTERS]!) {
|
||||
if (!("condition" in item) || !item.condition || (await item.condition(endpoint))) {
|
||||
const {attribute, minimumReportInterval, reportableChange} = item;
|
||||
|
||||
items.push({...i, maximumReportInterval: 0xffff});
|
||||
items.push({attribute, minimumReportInterval, maximumReportInterval: 0xffff, reportableChange});
|
||||
}
|
||||
}
|
||||
|
||||
await endpoint.configureReporting(cluster, items);
|
||||
await endpoint.configureReporting(cluster as keyof typeof REPORT_CLUSTERS, items);
|
||||
logger.info(`Successfully disabled reporting for '${entity}' cluster '${cluster}'`);
|
||||
} catch (error) {
|
||||
logger.warning(`Failed to disable reporting for '${entity}' cluster '${cluster}' (${error.message})`);
|
||||
logger.warning(`Failed to disable reporting for '${entity}' cluster '${cluster}' (${(error as Error).message})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,35 +548,38 @@ export default class Bind extends Extension {
|
||||
* When we receive a message from a Hue dimmer we read the brightness from the bulb (if bound).
|
||||
*/
|
||||
const polls = POLL_ON_MESSAGE.filter((p) =>
|
||||
p.cluster[data.cluster as ClusterName]?.some((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)),
|
||||
p.cluster[data.cluster as keyof (typeof p)["cluster"]]?.some((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)),
|
||||
);
|
||||
|
||||
if (polls.length) {
|
||||
const toPoll: Set<zh.Endpoint> = new Set();
|
||||
const toPoll = new Set<zh.Endpoint>();
|
||||
|
||||
// Add bound devices
|
||||
for (const endpoint of data.device.zh.endpoints) {
|
||||
for (const bind of endpoint.binds) {
|
||||
if (utils.isEndpoint(bind.target) && bind.target.getDevice().type !== 'Coordinator') {
|
||||
if (utils.isZHEndpoint(bind.target) && bind.target.getDevice().type !== "Coordinator") {
|
||||
toPoll.add(bind.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If message is published to a group, add members of the group
|
||||
const group = data.groupID && data.groupID !== 0 && this.zigbee.groupByID(data.groupID);
|
||||
if (data.groupID && data.groupID !== 0) {
|
||||
// If message is published to a group, add members of the group
|
||||
const group = this.zigbee.groupByID(data.groupID);
|
||||
|
||||
if (group) {
|
||||
for (const member of group.zh.members) {
|
||||
toPoll.add(member);
|
||||
if (group) {
|
||||
for (const member of group.zh.members) {
|
||||
toPoll.add(member);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const endpoint of toPoll) {
|
||||
const device = endpoint.getDevice();
|
||||
for (const poll of polls) {
|
||||
if (
|
||||
(!poll.manufacturerIDs.includes(endpoint.getDevice().manufacturerID) &&
|
||||
!poll.manufacturerNames.includes(endpoint.getDevice().manufacturerName)) ||
|
||||
// biome-ignore lint/style/noNonNullAssertion: manufacturerID/manufacturerName can be undefined and won't match `includes`, but TS enforces same-type
|
||||
(!poll.manufacturerIDs.includes(device.manufacturerID!) && !poll.manufacturerNames.includes(device.manufacturerName!)) ||
|
||||
!endpoint.supportsInputCluster(poll.read.cluster)
|
||||
) {
|
||||
continue;
|
||||
@@ -548,16 +592,16 @@ export default class Bind extends Extension {
|
||||
readAttrs = [...poll.read.attributes, ...attrsForEndpoint];
|
||||
}
|
||||
|
||||
const key = `${endpoint.getDevice().ieeeAddr}_${endpoint.ID}_${POLL_ON_MESSAGE.indexOf(poll)}`;
|
||||
const key = `${device.ieeeAddr}_${endpoint.ID}_${POLL_ON_MESSAGE.indexOf(poll)}`;
|
||||
|
||||
if (!this.pollDebouncers[key]) {
|
||||
this.pollDebouncers[key] = debounce(async () => {
|
||||
try {
|
||||
await endpoint.read(poll.read.cluster, readAttrs);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to poll ${readAttrs} from ${this.zigbee.resolveEntity(endpoint.getDevice()).name} (${error.message})`,
|
||||
);
|
||||
// biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: ???
|
||||
const resolvedDevice = this.zigbee.resolveEntity(device)!;
|
||||
logger.error(`Failed to poll ${readAttrs} from ${resolvedDevice.name} (${(error as Error).message})`);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
+378
-382
File diff suppressed because it is too large
Load Diff
+51
-53
@@ -1,12 +1,12 @@
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import Device from '../model/device';
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
import Device from "../model/device";
|
||||
import type {Zigbee2MQTTAPI} from "../types/api";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
/**
|
||||
* This extension calls the zigbee-herdsman-converters definition configure() method
|
||||
@@ -15,97 +15,95 @@ export default class Configure extends Extension {
|
||||
private configuring = new Set();
|
||||
private attempts: {[s: string]: number} = {};
|
||||
private topic = `${settings.get().mqtt.base_topic}/bridge/request/device/configure`;
|
||||
private legacyTopic = `${settings.get().mqtt.base_topic}/bridge/configure`;
|
||||
|
||||
@bind private async onReconfigure(data: eventdata.Reconfigure): Promise<void> {
|
||||
// Disabling reporting unbinds some cluster which could be bound by configure, re-setup.
|
||||
if (data.device.zh.meta?.hasOwnProperty('configured')) {
|
||||
if (data.device.zh.meta?.configured !== undefined) {
|
||||
delete data.device.zh.meta.configured;
|
||||
data.device.zh.save();
|
||||
}
|
||||
|
||||
await this.configure(data.device, 'reporting_disabled');
|
||||
await this.configure(data.device, "reporting_disabled");
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
if (data.topic === this.legacyTopic) {
|
||||
const device = this.zigbee.resolveEntity(data.message);
|
||||
if (!device || !(device instanceof Device)) {
|
||||
logger.error(`Device '${data.message}' does not exist`);
|
||||
return;
|
||||
}
|
||||
if (data.topic === this.topic) {
|
||||
const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI["bridge/request/device/configure"];
|
||||
const ID = typeof message === "object" ? message.id : message;
|
||||
let error: string | undefined;
|
||||
|
||||
if (!device.definition || !device.definition.configure) {
|
||||
logger.warning(`Skipping configure of '${device.name}', device does not require this.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.configure(device, 'mqtt_message', true);
|
||||
} else if (data.topic === this.topic) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message;
|
||||
let error = null;
|
||||
|
||||
const device = this.zigbee.resolveEntity(ID);
|
||||
if (!device || !(device instanceof Device)) {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!device.definition || !device.definition.configure) {
|
||||
error = `Device '${device.name}' cannot be configured`;
|
||||
if (ID === undefined) {
|
||||
error = "Invalid payload";
|
||||
} else {
|
||||
try {
|
||||
await this.configure(device, 'mqtt_message', true, true);
|
||||
} catch (e) {
|
||||
error = `Failed to configure (${e.message})`;
|
||||
const device = this.zigbee.resolveEntity(ID);
|
||||
|
||||
if (!device || !(device instanceof Device)) {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!device.definition || !device.definition.configure) {
|
||||
error = `Device '${device.name}' cannot be configured`;
|
||||
} else {
|
||||
try {
|
||||
await this.configure(device, "mqtt_message", true, true);
|
||||
} catch (e) {
|
||||
error = `Failed to configure (${(e as Error).message})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = utils.getResponse(message, {id: ID}, error);
|
||||
await this.mqtt.publish(`bridge/response/device/configure`, stringify(response));
|
||||
const response = utils.getResponse<"bridge/response/device/configure">(message, {id: ID}, error);
|
||||
|
||||
await this.mqtt.publish("bridge/response/device/configure", stringify(response));
|
||||
}
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
override start(): Promise<void> {
|
||||
setImmediate(async () => {
|
||||
// Only configure routers on startup, end devices are likely sleeping and
|
||||
// will reconfigure once they send a message
|
||||
for (const device of this.zigbee.devicesIterator((d) => d.type === 'Router')) {
|
||||
for (const device of this.zigbee.devicesIterator((d) => d.type === "Router")) {
|
||||
// Sleep 10 seconds between configuring on startup to not DDoS the coordinator when many devices have to be configured.
|
||||
await utils.sleep(10);
|
||||
await this.configure(device, 'started');
|
||||
await this.configure(device, "started");
|
||||
}
|
||||
});
|
||||
|
||||
this.eventBus.onDeviceJoined(this, async (data) => {
|
||||
if (data.device.zh.meta.hasOwnProperty('configured')) {
|
||||
if (data.device.zh.meta.configured !== undefined) {
|
||||
delete data.device.zh.meta.configured;
|
||||
data.device.zh.save();
|
||||
}
|
||||
|
||||
await this.configure(data.device, 'zigbee_event');
|
||||
await this.configure(data.device, "zigbee_event");
|
||||
});
|
||||
this.eventBus.onDeviceInterview(this, (data) => this.configure(data.device, 'zigbee_event'));
|
||||
this.eventBus.onLastSeenChanged(this, (data) => this.configure(data.device, 'zigbee_event'));
|
||||
this.eventBus.onDeviceInterview(this, (data) => this.configure(data.device, "zigbee_event"));
|
||||
this.eventBus.onLastSeenChanged(this, (data) => this.configure(data.device, "zigbee_event"));
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.eventBus.onReconfigure(this, this.onReconfigure);
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private async configure(
|
||||
device: Device,
|
||||
event: 'started' | 'zigbee_event' | 'reporting_disabled' | 'mqtt_message',
|
||||
event: "started" | "zigbee_event" | "reporting_disabled" | "mqtt_message",
|
||||
force = false,
|
||||
throwError = false,
|
||||
): Promise<void> {
|
||||
if (!device.definition?.configure) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
if (device.options.disabled || !device.definition?.configure || !device.zh.interviewCompleted) {
|
||||
if (device.options.disabled || !device.interviewed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.zh.meta?.hasOwnProperty('configured')) {
|
||||
if (device.zh.meta?.configured !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only configure end devices when it is active, otherwise it will likely fails as they are sleeping.
|
||||
if (device.zh.type === 'EndDevice' && event !== 'zigbee_event') {
|
||||
if (device.zh.type === "EndDevice" && event !== "zigbee_event") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -116,7 +114,7 @@ export default class Configure extends Extension {
|
||||
|
||||
this.configuring.add(device.ieeeAddr);
|
||||
|
||||
if (!this.attempts.hasOwnProperty(device.ieeeAddr)) {
|
||||
if (this.attempts[device.ieeeAddr] === undefined) {
|
||||
this.attempts[device.ieeeAddr] = 0;
|
||||
}
|
||||
|
||||
@@ -130,7 +128,7 @@ export default class Configure extends Extension {
|
||||
} catch (error) {
|
||||
this.attempts[device.ieeeAddr]++;
|
||||
const attempt = this.attempts[device.ieeeAddr];
|
||||
const msg = `Failed to configure '${device.name}', attempt ${attempt} (${error.stack})`;
|
||||
const msg = `Failed to configure '${device.name}', attempt ${attempt} (${(error as Error).stack})`;
|
||||
logger.error(msg);
|
||||
|
||||
if (throwError) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
abstract class Extension {
|
||||
protected zigbee: Zigbee;
|
||||
protected mqtt: MQTT;
|
||||
protected mqtt: Mqtt;
|
||||
protected state: State;
|
||||
protected publishEntityState: PublishEntityState;
|
||||
protected eventBus: EventBus;
|
||||
@@ -12,7 +12,7 @@ abstract class Extension {
|
||||
* Besides initializing variables, the constructor should do nothing!
|
||||
*
|
||||
* @param {Zigbee} zigbee Zigbee controller
|
||||
* @param {MQTT} mqtt MQTT controller
|
||||
* @param {Mqtt} mqtt MQTT controller
|
||||
* @param {State} state State controller
|
||||
* @param {Function} publishEntityState Method to publish device state to MQTT.
|
||||
* @param {EventBus} eventBus The event bus
|
||||
@@ -22,7 +22,7 @@ abstract class Extension {
|
||||
*/
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: MQTT,
|
||||
mqtt: Mqtt,
|
||||
state: State,
|
||||
publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus,
|
||||
@@ -43,18 +43,18 @@ abstract class Extension {
|
||||
/**
|
||||
* Is called once the extension has to start
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
async start(): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Is called once the extension has to stop
|
||||
*/
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
async stop(): Promise<void> {
|
||||
this.eventBus.removeListeners(this);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public adjustMessageBeforePublish(entity: Group | Device, message: KeyValue): void {}
|
||||
public adjustMessageBeforePublish(_entity: Group | Device, _message: KeyValue): void {}
|
||||
}
|
||||
|
||||
export default Extension;
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import type {ExternalDefinitionWithExtend} from "zigbee-herdsman-converters";
|
||||
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import {loadExternalConverter} from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import {addExternalDefinition, removeExternalDefinitions} from "zigbee-herdsman-converters";
|
||||
|
||||
export default class ExternalConverters extends Extension {
|
||||
import logger from "../util/logger";
|
||||
import ExternalJSExtension from "./externalJS";
|
||||
|
||||
type TModule = ExternalDefinitionWithExtend | ExternalDefinitionWithExtend[];
|
||||
|
||||
export default class ExternalConverters extends ExternalJSExtension<TModule> {
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: MQTT,
|
||||
mqtt: Mqtt,
|
||||
state: State,
|
||||
publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus,
|
||||
@@ -16,27 +18,50 @@ export default class ExternalConverters extends Extension {
|
||||
restartCallback: () => Promise<void>,
|
||||
addExtension: (extension: Extension) => Promise<void>,
|
||||
) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
|
||||
super(
|
||||
zigbee,
|
||||
mqtt,
|
||||
state,
|
||||
publishEntityState,
|
||||
eventBus,
|
||||
enableDisableExtension,
|
||||
restartCallback,
|
||||
addExtension,
|
||||
"converter",
|
||||
"external_converters",
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of settings.get().external_converters) {
|
||||
try {
|
||||
for (const definition of loadExternalConverter(file)) {
|
||||
const toAdd = {...definition};
|
||||
delete toAdd['homeassistant'];
|
||||
zhc.addDefinition(toAdd);
|
||||
}
|
||||
logger.info(`Loaded external converter '${file}'`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load external converter file '${file}' (${error.message})`);
|
||||
logger.error(
|
||||
`Probably there is a syntax error in the file or the external converter is not ` +
|
||||
`compatible with the current Zigbee2MQTT version`,
|
||||
);
|
||||
logger.error(
|
||||
`Note that external converters are not meant for long term usage, it's meant for local ` +
|
||||
`testing after which a pull request should be created to add out-of-the-box support for the device`,
|
||||
);
|
||||
protected async removeJS(name: string, _mod: TModule): Promise<void> {
|
||||
removeExternalDefinitions(name);
|
||||
|
||||
await this.zigbee.resolveDevicesDefinitions(true);
|
||||
}
|
||||
|
||||
protected async loadJS(name: string, mod: TModule, newName?: string): Promise<void> {
|
||||
try {
|
||||
removeExternalDefinitions(name);
|
||||
|
||||
const definitions = Array.isArray(mod) ? mod : [mod];
|
||||
|
||||
for (const definition of definitions) {
|
||||
definition.externalConverterName = newName ?? name;
|
||||
|
||||
addExternalDefinition(definition);
|
||||
logger.info(`Loaded external converter '${newName ?? name}'.`);
|
||||
}
|
||||
|
||||
await this.zigbee.resolveDevicesDefinitions(true);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
/* v8 ignore next */
|
||||
`Failed to load external converter '${newName ?? name}'. Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`,
|
||||
);
|
||||
logger.warning(
|
||||
"External converters are not meant for long term usage, but for local testing after which a pull request should be created to add out-of-the-box support for the device",
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import bind from 'bind-decorator';
|
||||
import fs from 'fs';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import path from 'path';
|
||||
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import data from './../util/data';
|
||||
import logger from './../util/logger';
|
||||
import Extension from './extension';
|
||||
|
||||
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/extension/(save|remove)`);
|
||||
|
||||
export default class ExternalExtension extends Extension {
|
||||
private requestLookup: {[s: string]: (message: KeyValue) => Promise<MQTTResponse>};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.requestLookup = {save: this.saveExtension, remove: this.removeExtension};
|
||||
await this.loadUserDefinedExtensions();
|
||||
await this.publishExtensions();
|
||||
}
|
||||
|
||||
private getExtensionsBasePath(): string {
|
||||
return data.joinPath('extension');
|
||||
}
|
||||
|
||||
private getListOfUserDefinedExtensions(): {name: string; code: string}[] {
|
||||
const basePath = this.getExtensionsBasePath();
|
||||
if (fs.existsSync(basePath)) {
|
||||
return fs
|
||||
.readdirSync(basePath)
|
||||
.filter((f) => f.endsWith('.js'))
|
||||
.map((fileName) => {
|
||||
const extensionFilePath = path.join(basePath, fileName);
|
||||
return {name: fileName, code: fs.readFileSync(extensionFilePath, 'utf-8')};
|
||||
});
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@bind private async removeExtension(message: KeyValue): Promise<MQTTResponse> {
|
||||
const {name} = message;
|
||||
const extensions = this.getListOfUserDefinedExtensions();
|
||||
const extensionToBeRemoved = extensions.find((e) => e.name === name);
|
||||
|
||||
if (extensionToBeRemoved) {
|
||||
await this.enableDisableExtension(false, extensionToBeRemoved.name);
|
||||
const basePath = this.getExtensionsBasePath();
|
||||
const extensionFilePath = path.join(basePath, path.basename(name));
|
||||
fs.unlinkSync(extensionFilePath);
|
||||
await this.publishExtensions();
|
||||
logger.info(`Extension ${name} removed`);
|
||||
return utils.getResponse(message, {}, null);
|
||||
} else {
|
||||
return utils.getResponse(message, {}, `Extension ${name} doesn't exists`);
|
||||
}
|
||||
}
|
||||
|
||||
@bind private async saveExtension(message: KeyValue): Promise<MQTTResponse> {
|
||||
const {name, code} = message;
|
||||
const ModuleConstructor = utils.loadModuleFromText(code, name) as typeof Extension;
|
||||
await this.loadExtension(ModuleConstructor);
|
||||
const basePath = this.getExtensionsBasePath();
|
||||
/* istanbul ignore else */
|
||||
if (!fs.existsSync(basePath)) {
|
||||
fs.mkdirSync(basePath);
|
||||
}
|
||||
const extensionFilePath = path.join(basePath, path.basename(name));
|
||||
fs.writeFileSync(extensionFilePath, code);
|
||||
await this.publishExtensions();
|
||||
logger.info(`Extension ${name} loaded`);
|
||||
return utils.getResponse(message, {}, null);
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(requestRegex);
|
||||
if (match && this.requestLookup[match[1].toLowerCase()]) {
|
||||
const message = utils.parseJSON(data.message, data.message) as KeyValue;
|
||||
try {
|
||||
const response = await this.requestLookup[match[1].toLowerCase()](message);
|
||||
await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response));
|
||||
} catch (error) {
|
||||
logger.error(`Request '${data.topic}' failed with error: '${error.message}'`);
|
||||
const response = utils.getResponse(message, {}, error.message);
|
||||
await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind private async loadExtension(ConstructorClass: typeof Extension): Promise<void> {
|
||||
await this.enableDisableExtension(false, ConstructorClass.name);
|
||||
// @ts-ignore
|
||||
await this.addExtension(new ConstructorClass(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus, settings, logger));
|
||||
}
|
||||
|
||||
private async loadUserDefinedExtensions(): Promise<void> {
|
||||
for (const extension of this.getListOfUserDefinedExtensions()) {
|
||||
await this.loadExtension(utils.loadModuleFromText(extension.code, extension.name) as typeof Extension);
|
||||
}
|
||||
}
|
||||
|
||||
private async publishExtensions(): Promise<void> {
|
||||
const extensions = this.getListOfUserDefinedExtensions();
|
||||
await this.mqtt.publish(
|
||||
'bridge/extensions',
|
||||
stringify(extensions),
|
||||
{
|
||||
retain: true,
|
||||
qos: 0,
|
||||
},
|
||||
settings.get().mqtt.base_topic,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import type Extension from "./extension";
|
||||
import ExternalJSExtension from "./externalJS";
|
||||
|
||||
type TModule = new (...args: ConstructorParameters<typeof Extension>) => Extension;
|
||||
|
||||
export default class ExternalExtensions extends ExternalJSExtension<TModule> {
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: Mqtt,
|
||||
state: State,
|
||||
publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus,
|
||||
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => Promise<void>,
|
||||
addExtension: (extension: Extension) => Promise<void>,
|
||||
) {
|
||||
super(
|
||||
zigbee,
|
||||
mqtt,
|
||||
state,
|
||||
publishEntityState,
|
||||
eventBus,
|
||||
enableDisableExtension,
|
||||
restartCallback,
|
||||
addExtension,
|
||||
"extension",
|
||||
"external_extensions",
|
||||
);
|
||||
}
|
||||
|
||||
protected async removeJS(_name: string, mod: TModule): Promise<void> {
|
||||
await this.enableDisableExtension(false, mod.name);
|
||||
}
|
||||
|
||||
protected async loadJS(name: string, mod: TModule, newName?: string): Promise<void> {
|
||||
try {
|
||||
// stop if already started
|
||||
await this.enableDisableExtension(false, mod.name);
|
||||
await this.addExtension(
|
||||
new mod(
|
||||
this.zigbee,
|
||||
this.mqtt,
|
||||
this.state,
|
||||
this.publishEntityState,
|
||||
this.eventBus,
|
||||
this.enableDisableExtension,
|
||||
this.restartCallback,
|
||||
this.addExtension,
|
||||
// @ts-expect-error additional params that don't fit the internal `Extension` type
|
||||
settings,
|
||||
logger,
|
||||
),
|
||||
);
|
||||
|
||||
/* v8 ignore start */
|
||||
logger.info(`Loaded external extension '${newName ?? name}'.`);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
/* v8 ignore next */
|
||||
`Failed to load external extension '${newName ?? name}'. Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse} from "../types/api";
|
||||
|
||||
import data from "../util/data";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
const SUPPORTED_OPERATIONS = ["save", "remove"];
|
||||
const TMP_PREFIX = ".tmp-ed42d4f2-";
|
||||
|
||||
export default abstract class ExternalJSExtension<M> extends Extension {
|
||||
protected folderName: string;
|
||||
protected mqttTopic: string;
|
||||
protected requestRegex: RegExp;
|
||||
protected basePath: string;
|
||||
protected nodeModulesSymlinked = false;
|
||||
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: Mqtt,
|
||||
state: State,
|
||||
publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus,
|
||||
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => Promise<void>,
|
||||
addExtension: (extension: Extension) => Promise<void>,
|
||||
mqttTopic: string,
|
||||
folderName: string,
|
||||
) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
|
||||
|
||||
this.folderName = folderName;
|
||||
this.mqttTopic = mqttTopic;
|
||||
this.requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/${mqttTopic}/(save|remove)`);
|
||||
this.basePath = data.joinPath(folderName);
|
||||
}
|
||||
|
||||
/**
|
||||
* In case the external JS is not in the Z2M install dir (e.g. when `ZIGBEE2MQTT_DATA` is used), the external
|
||||
* JS cannot import from `node_modules`.
|
||||
* To workaround this create a symlink to `node_modules` in the external JS dir.
|
||||
* https://nodejs.org/api/esm.html#no-node_path
|
||||
*/
|
||||
private symlinkNodeModulesIfNecessary() {
|
||||
if (!this.nodeModulesSymlinked) {
|
||||
this.nodeModulesSymlinked = true;
|
||||
const nodeModulesPath = path.join(__dirname, "..", "..", "node_modules");
|
||||
const z2mDirNormalized = `${path.resolve(path.join(nodeModulesPath, ".."))}${path.sep}`;
|
||||
const basePathNormalized = `${path.resolve(this.basePath)}${path.sep}`;
|
||||
const basePathInZ2mDir = basePathNormalized.startsWith(z2mDirNormalized);
|
||||
if (!basePathInZ2mDir) {
|
||||
logger.debug(`External JS folder '${this.folderName}' is outside the Z2M install dir, creating a symlink to 'node_modules'`);
|
||||
const nodeModulesSymlink = path.join(this.basePath, "node_modules");
|
||||
/* v8 ignore start */
|
||||
if (fs.existsSync(nodeModulesSymlink)) {
|
||||
fs.unlinkSync(nodeModulesSymlink);
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
// Type `junction` is required on Windows.
|
||||
// https://github.com/nodejs/node/issues/18518#issuecomment-513866491
|
||||
fs.symlinkSync(nodeModulesPath, nodeModulesSymlink, /* v8 ignore next */ os.platform() === "win32" ? "junction" : "dir");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
await super.start();
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
await this.loadFiles();
|
||||
await this.publishExternalJS();
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
await super.stop();
|
||||
|
||||
// ensure "node_modules" is never followed & included in 3rd-party backup systems
|
||||
const nodeModulesSymlink = path.join(this.basePath, "node_modules");
|
||||
|
||||
if (fs.existsSync(nodeModulesSymlink)) {
|
||||
fs.unlinkSync(nodeModulesSymlink);
|
||||
}
|
||||
}
|
||||
|
||||
private getFilePath(name: string, mkBasePath = false): string {
|
||||
if (mkBasePath && !fs.existsSync(this.basePath)) {
|
||||
fs.mkdirSync(this.basePath, {recursive: true});
|
||||
}
|
||||
|
||||
return path.join(this.basePath, name);
|
||||
}
|
||||
|
||||
protected getFileCode(name: string): string {
|
||||
return fs.readFileSync(this.getFilePath(name), "utf8");
|
||||
}
|
||||
|
||||
protected *getFiles(): Generator<{name: string; code: string}> {
|
||||
if (fs.existsSync(this.basePath)) {
|
||||
for (const fileName of fs.readdirSync(this.basePath)) {
|
||||
if (!fileName.startsWith(TMP_PREFIX) && (fileName.endsWith(".js") || fileName.endsWith(".cjs") || fileName.endsWith(".mjs"))) {
|
||||
yield {name: fileName, code: this.getFileCode(fileName)};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(this.requestRegex);
|
||||
|
||||
if (match && SUPPORTED_OPERATIONS.includes(match[1].toLowerCase())) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
|
||||
try {
|
||||
let response: Awaited<ReturnType<typeof this.save | typeof this.remove>>;
|
||||
|
||||
if (match[1].toLowerCase() === "save") {
|
||||
response = await this.save(
|
||||
message as Zigbee2MQTTAPI["bridge/request/converter/save"] | Zigbee2MQTTAPI["bridge/request/extension/save"],
|
||||
);
|
||||
} else {
|
||||
response = await this.remove(
|
||||
message as Zigbee2MQTTAPI["bridge/request/converter/remove"] | Zigbee2MQTTAPI["bridge/request/extension/remove"],
|
||||
);
|
||||
}
|
||||
|
||||
await this.mqtt.publish(`bridge/response/${this.mqttTopic}/${match[1]}`, stringify(response));
|
||||
} catch (error) {
|
||||
logger.error(`Request '${data.topic}' failed with error: '${(error as Error).message}'`);
|
||||
|
||||
const response = utils.getResponse(message, {}, `${(error as Error).message}`);
|
||||
|
||||
await this.mqtt.publish(`bridge/response/${this.mqttTopic}/${match[1]}`, stringify(response));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract removeJS(name: string, mod: M): Promise<void>;
|
||||
|
||||
protected abstract loadJS(name: string, mod: M, newName?: string): Promise<void>;
|
||||
|
||||
@bind private async remove(
|
||||
message: Zigbee2MQTTAPI["bridge/request/converter/remove"] | Zigbee2MQTTAPI["bridge/request/extension/remove"],
|
||||
): Promise<Zigbee2MQTTResponse<"bridge/response/converter/remove" | "bridge/response/extension/remove">> {
|
||||
if (!message.name) {
|
||||
return utils.getResponse(message, {}, "Invalid payload");
|
||||
}
|
||||
|
||||
const {name} = message;
|
||||
const toBeRemoved = this.getFilePath(name);
|
||||
|
||||
if (fs.existsSync(toBeRemoved)) {
|
||||
const mod = await this.importFile(toBeRemoved);
|
||||
|
||||
await this.removeJS(name, mod.default);
|
||||
fs.rmSync(toBeRemoved, {force: true});
|
||||
logger.info(`${name} (${toBeRemoved}) removed.`);
|
||||
await this.publishExternalJS();
|
||||
|
||||
return utils.getResponse(message, {});
|
||||
}
|
||||
|
||||
return utils.getResponse(message, {}, `${name} (${toBeRemoved}) doesn't exists`);
|
||||
}
|
||||
|
||||
@bind private async save(
|
||||
message: Zigbee2MQTTAPI["bridge/request/converter/save"] | Zigbee2MQTTAPI["bridge/request/extension/save"],
|
||||
): Promise<Zigbee2MQTTResponse<"bridge/response/converter/save" | "bridge/response/extension/save">> {
|
||||
if (!message.name || !message.code) {
|
||||
return utils.getResponse(message, {}, "Invalid payload");
|
||||
}
|
||||
|
||||
const {name, code} = message;
|
||||
const filePath = this.getFilePath(name, true);
|
||||
try {
|
||||
fs.writeFileSync(filePath, code, "utf8");
|
||||
this.symlinkNodeModulesIfNecessary();
|
||||
|
||||
const mod = await this.importFile(filePath);
|
||||
|
||||
await this.loadJS(name, mod.default, name);
|
||||
logger.info(`${name} loaded. Contents written to '${filePath}'.`);
|
||||
await this.publishExternalJS();
|
||||
|
||||
return utils.getResponse(message, {});
|
||||
} catch (error) {
|
||||
return utils.getResponse(message, {}, `${name} contains invalid code: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFiles(): Promise<void> {
|
||||
for (const extension of this.getFiles()) {
|
||||
this.symlinkNodeModulesIfNecessary();
|
||||
const filePath = this.getFilePath(extension.name);
|
||||
|
||||
try {
|
||||
const mod = await this.importFile(filePath);
|
||||
await this.loadJS(extension.name, mod.default);
|
||||
} catch (error) {
|
||||
// change ext so Z2M doesn't try to load it again and again
|
||||
fs.renameSync(filePath, `${filePath}.invalid`);
|
||||
|
||||
logger.error(
|
||||
`Invalid external ${this.mqttTopic} '${extension.name}' was ignored and renamed to prevent interference with Zigbee2MQTT. (${(error as Error).message})`,
|
||||
);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async publishExternalJS(): Promise<void> {
|
||||
await this.mqtt.publish(`bridge/${this.mqttTopic}s`, stringify(Array.from(this.getFiles())), {
|
||||
clientOptions: {retain: true},
|
||||
skipLog: true,
|
||||
});
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic module
|
||||
private async importFile(file: string): Promise<any> {
|
||||
const ext = path.extname(file);
|
||||
// Create the file in a temp path to bypass node module cache when importing multiple times.
|
||||
const tmpFile = path.join(this.basePath, `${TMP_PREFIX}${path.basename(file, ext)}-${crypto.randomUUID()}${ext}`);
|
||||
fs.copyFileSync(file, tmpFile);
|
||||
try {
|
||||
// Do `replaceAll("\\", "/")` to prevent issues on Windows
|
||||
/* v8 ignore next */
|
||||
const mod = await import(os.platform() === "win32" ? `file:///${tmpFile.replaceAll("\\", "/")}` : tmpFile);
|
||||
return mod;
|
||||
} finally {
|
||||
fs.rmSync(tmpFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
-109
@@ -1,124 +1,176 @@
|
||||
import bind from 'bind-decorator';
|
||||
import gzipStatic, {RequestHandler} from 'connect-gzip-static';
|
||||
import finalhandler from 'finalhandler';
|
||||
import fs from 'fs';
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import net from 'net';
|
||||
import url from 'url';
|
||||
import WebSocket from 'ws';
|
||||
import frontend from 'zigbee2mqtt-frontend';
|
||||
import assert from "node:assert";
|
||||
import {existsSync, readFileSync} from "node:fs";
|
||||
import type {IncomingMessage, Server, ServerResponse} from "node:http";
|
||||
import {createServer} from "node:http";
|
||||
import {createServer as createSecureServer} from "node:https";
|
||||
import type {Socket} from "node:net";
|
||||
import {posix} from "node:path";
|
||||
import {parse} from "node:url";
|
||||
import bind from "bind-decorator";
|
||||
import expressStaticGzip from "express-static-gzip";
|
||||
import finalhandler from "finalhandler";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import WebSocket from "ws";
|
||||
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import data from "../util/data";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
/**
|
||||
* This extension servers the frontend
|
||||
*/
|
||||
export default class Frontend extends Extension {
|
||||
private mqttBaseTopic = settings.get().mqtt.base_topic;
|
||||
private host = settings.get().frontend.host;
|
||||
private port = settings.get().frontend.port;
|
||||
private sslCert = settings.get().frontend.ssl_cert;
|
||||
private sslKey = settings.get().frontend.ssl_key;
|
||||
private authToken = settings.get().frontend.auth_token;
|
||||
private server: http.Server;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private fileServer: RequestHandler;
|
||||
private wss: WebSocket.Server = null;
|
||||
export class Frontend extends Extension {
|
||||
private mqttBaseTopic: string;
|
||||
private server: Server | undefined;
|
||||
private wss!: WebSocket.Server;
|
||||
private baseUrl: string;
|
||||
|
||||
private isHttpsConfigured(): boolean {
|
||||
if (this.sslCert && this.sslKey) {
|
||||
if (!fs.existsSync(this.sslCert) || !fs.existsSync(this.sslKey)) {
|
||||
logger.error(`defined ssl_cert '${this.sslCert}' or ssl_key '${this.sslKey}' file path does not exists, server won't be secured.`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: Mqtt,
|
||||
state: State,
|
||||
publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus,
|
||||
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => Promise<void>,
|
||||
addExtension: (extension: Extension) => Promise<void>,
|
||||
) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
|
||||
|
||||
const frontendSettings = settings.get().frontend;
|
||||
assert(frontendSettings.enabled, `Frontend extension created with setting 'enabled: false'`);
|
||||
this.baseUrl = frontendSettings.base_url;
|
||||
this.mqttBaseTopic = settings.get().mqtt.base_topic;
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
if (this.isHttpsConfigured()) {
|
||||
const serverOptions = {
|
||||
key: fs.readFileSync(this.sslKey),
|
||||
cert: fs.readFileSync(this.sslCert),
|
||||
};
|
||||
this.server = https.createServer(serverOptions, this.onRequest);
|
||||
if (settings.get().frontend.disable_ui_serving) {
|
||||
const {host, port} = settings.get().frontend;
|
||||
this.wss = new WebSocket.Server({port, host, path: posix.join(this.baseUrl, "api")});
|
||||
|
||||
logger.info(
|
||||
/* v8 ignore next */
|
||||
`Frontend UI serving is disabled. WebSocket at: ${this.wss.options.host ?? "0.0.0.0"}:${this.wss.options.port}${this.wss.options.path}`,
|
||||
);
|
||||
} else {
|
||||
this.server = http.createServer(this.onRequest);
|
||||
}
|
||||
const {host, port, ssl_key: sslKey, ssl_cert: sslCert} = settings.get().frontend;
|
||||
const hasSSL = (val: string | undefined, key: string): val is string => {
|
||||
if (val) {
|
||||
if (existsSync(val)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.server.on('upgrade', this.onUpgrade);
|
||||
|
||||
/* istanbul ignore next */
|
||||
const options = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setHeaders: (res: any, path: string): void => {
|
||||
if (path.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
logger.error(`Defined ${key} '${val}' file path does not exists, server won't be secured.`);
|
||||
}
|
||||
},
|
||||
};
|
||||
this.fileServer = gzipStatic(frontend.getPath(), options);
|
||||
this.wss = new WebSocket.Server({noServer: true});
|
||||
this.wss.on('connection', this.onWebSocketConnection);
|
||||
|
||||
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
|
||||
return false;
|
||||
};
|
||||
const options: expressStaticGzip.ExpressStaticGzipOptions = {
|
||||
enableBrotli: true,
|
||||
serveStatic: {
|
||||
/* v8 ignore start */
|
||||
setHeaders: (res: ServerResponse, path: string): void => {
|
||||
if (path.endsWith("index.html")) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
}
|
||||
},
|
||||
/* v8 ignore stop */
|
||||
},
|
||||
};
|
||||
const frontend = (await import(settings.get().frontend.package)) as typeof import("zigbee2mqtt-frontend");
|
||||
const fileServer = expressStaticGzip(frontend.default.getPath(), options);
|
||||
const deviceIconsFileServer = expressStaticGzip(data.joinPath("device_icons"), options);
|
||||
const onRequest = (request: IncomingMessage, response: ServerResponse): void => {
|
||||
const next = finalhandler(request, response);
|
||||
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
|
||||
const newUrl = posix.relative(this.baseUrl, request.url!);
|
||||
|
||||
if (!this.host) {
|
||||
this.server.listen(this.port);
|
||||
logger.info(`Started frontend on port ${this.port}`);
|
||||
} else if (this.host.startsWith('/')) {
|
||||
this.server.listen(this.host);
|
||||
logger.info(`Started frontend on socket ${this.host}`);
|
||||
} else {
|
||||
this.server.listen(this.port, this.host);
|
||||
logger.info(`Started frontend on port ${this.host}:${this.port}`);
|
||||
// The request url is not within the frontend base url, so the relative path starts with '..'
|
||||
if (newUrl.startsWith(".")) {
|
||||
next();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach originalUrl so that static-server can perform a redirect to '/' when serving the root directory.
|
||||
// This is necessary for the browser to resolve relative assets paths correctly.
|
||||
request.originalUrl = request.url;
|
||||
request.url = `/${newUrl}`;
|
||||
request.path = request.url;
|
||||
|
||||
if (newUrl.startsWith("device_icons/")) {
|
||||
request.path = request.path.replace("device_icons/", "");
|
||||
request.url = request.url.replace("/device_icons", "");
|
||||
|
||||
deviceIconsFileServer(request, response, next);
|
||||
} else {
|
||||
fileServer(request, response, next);
|
||||
}
|
||||
};
|
||||
|
||||
if (hasSSL(sslKey, "ssl_key") && hasSSL(sslCert, "ssl_cert")) {
|
||||
const serverOptions = {key: readFileSync(sslKey), cert: readFileSync(sslCert)};
|
||||
this.server = createSecureServer(serverOptions, onRequest);
|
||||
} else {
|
||||
this.server = createServer(onRequest);
|
||||
}
|
||||
|
||||
this.server.on("upgrade", this.onUpgrade);
|
||||
|
||||
if (!host) {
|
||||
this.server.listen(port);
|
||||
logger.info(`Started frontend on port ${port}`);
|
||||
} else if (host.startsWith("/")) {
|
||||
this.server.listen(host);
|
||||
logger.info(`Started frontend on socket ${host}`);
|
||||
} else {
|
||||
this.server.listen(port, host);
|
||||
logger.info(`Started frontend on port ${host}:${port}`);
|
||||
}
|
||||
|
||||
this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, "api")});
|
||||
}
|
||||
|
||||
this.wss.on("connection", this.onWebSocketConnection);
|
||||
|
||||
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessageOrEntityState);
|
||||
this.eventBus.onPublishEntityState(this, this.onMQTTPublishMessageOrEntityState);
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
await super.stop();
|
||||
this.wss?.clients.forEach((client) => {
|
||||
client.send(stringify({topic: 'bridge/state', payload: 'offline'}));
|
||||
client.terminate();
|
||||
});
|
||||
this.wss?.close();
|
||||
/* istanbul ignore else */
|
||||
if (this.server) {
|
||||
return new Promise((cb: () => void) => this.server.close(cb));
|
||||
|
||||
if (this.wss) {
|
||||
for (const client of this.wss.clients) {
|
||||
client.send(stringify({topic: "bridge/state", payload: {state: "offline"}}));
|
||||
client.terminate();
|
||||
}
|
||||
|
||||
this.wss.close();
|
||||
}
|
||||
|
||||
await new Promise((resolve) => (this.server ? this.server.close(resolve) : resolve(undefined)));
|
||||
}
|
||||
|
||||
@bind private onRequest(request: http.IncomingMessage, response: http.ServerResponse): void {
|
||||
// @ts-ignore
|
||||
this.fileServer(request, response, finalhandler(request, response));
|
||||
}
|
||||
|
||||
private authenticate(request: http.IncomingMessage, cb: (authenticate: boolean) => void): void {
|
||||
const {query} = url.parse(request.url, true);
|
||||
cb(!this.authToken || this.authToken === query.token);
|
||||
}
|
||||
|
||||
@bind private onUpgrade(request: http.IncomingMessage, socket: net.Socket, head: Buffer): void {
|
||||
@bind private onUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): void {
|
||||
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
this.authenticate(request, (isAuthenticated) => {
|
||||
if (isAuthenticated) {
|
||||
this.wss.emit('connection', ws, request);
|
||||
} else {
|
||||
ws.close(4401, 'Unauthorized');
|
||||
}
|
||||
});
|
||||
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
|
||||
const {query} = parse(request.url!, true);
|
||||
const authToken = settings.get().frontend.auth_token;
|
||||
|
||||
if (!authToken || authToken === query.token) {
|
||||
this.wss.emit("connection", ws, request);
|
||||
} else {
|
||||
ws.close(4401, "Unauthorized");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@bind private onWebSocketConnection(ws: WebSocket): void {
|
||||
ws.on('error', (msg) => logger.error(`WebSocket error: ${msg.message}`));
|
||||
ws.on('message', (data: Buffer, isBinary: boolean) => {
|
||||
ws.on("error", (msg) => logger.error(`WebSocket error: ${msg.message}`));
|
||||
ws.on("message", (data: Buffer, isBinary: boolean) => {
|
||||
if (!isBinary && data) {
|
||||
const message = data.toString();
|
||||
const {topic, payload} = JSON.parse(message);
|
||||
@@ -127,7 +179,6 @@ export default class Frontend extends Extension {
|
||||
});
|
||||
|
||||
for (const [topic, payload] of Object.entries(this.mqtt.retainedMessages)) {
|
||||
/* istanbul ignore else */
|
||||
if (topic.startsWith(`${this.mqttBaseTopic}/`)) {
|
||||
ws.send(
|
||||
stringify({
|
||||
@@ -142,9 +193,9 @@ export default class Frontend extends Extension {
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
const payload = this.state.get(device);
|
||||
const lastSeen = settings.get().advanced.last_seen;
|
||||
/* istanbul ignore if */
|
||||
if (lastSeen !== 'disable') {
|
||||
payload.last_seen = utils.formatDate(device.zh.lastSeen, lastSeen);
|
||||
|
||||
if (lastSeen !== "disable") {
|
||||
payload.last_seen = utils.formatDate(device.zh.lastSeen ?? /* v8 ignore next */ 0, lastSeen);
|
||||
}
|
||||
|
||||
if (device.zh.linkquality !== undefined) {
|
||||
@@ -155,19 +206,34 @@ export default class Frontend extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
@bind private onMQTTPublishMessage(data: eventdata.MQTTMessagePublished): void {
|
||||
/* istanbul ignore else */
|
||||
if (data.topic.startsWith(`${this.mqttBaseTopic}/`)) {
|
||||
// Send topic without base_topic
|
||||
const topic = data.topic.substring(this.mqttBaseTopic.length + 1);
|
||||
const payload = utils.parseJSON(data.payload, data.payload);
|
||||
@bind private onMQTTPublishMessageOrEntityState(data: eventdata.MQTTMessagePublished | eventdata.PublishEntityState): void {
|
||||
let topic: string;
|
||||
let payload: KeyValue | string;
|
||||
|
||||
for (const client of this.wss.clients) {
|
||||
/* istanbul ignore else */
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(stringify({topic, payload}));
|
||||
}
|
||||
if ("topic" in data) {
|
||||
// MQTTMessagePublished
|
||||
if (data.options.meta.isEntityState || !data.topic.startsWith(`${this.mqttBaseTopic}/`)) {
|
||||
// Don't send entity state to frontend on `MQTTMessagePublished` event, this is handled by
|
||||
// `PublishEntityState` instead. Reason for this is to skip attribute messages when `output` is
|
||||
// set to `attribute` or `attribute_and_json`, we only want to send JSON entity states to the
|
||||
// frontend.
|
||||
return;
|
||||
}
|
||||
// Send topic without base_topic
|
||||
topic = data.topic.substring(this.mqttBaseTopic.length + 1);
|
||||
payload = utils.parseJSON(data.payload, data.payload);
|
||||
} else {
|
||||
// PublishEntityState
|
||||
topic = data.entity.name;
|
||||
payload = data.message;
|
||||
}
|
||||
|
||||
for (const client of this.wss.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(stringify({topic, payload}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Frontend;
|
||||
|
||||
+165
-292
@@ -1,135 +1,59 @@
|
||||
import bind from 'bind-decorator';
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
|
||||
const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
|
||||
const LEGACY_TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/(.+)/(remove|add|remove_all)$`);
|
||||
const LEGACY_TOPIC_REGEX_REMOVE_ALL = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/remove_all$`);
|
||||
import assert from "node:assert";
|
||||
import bind from "bind-decorator";
|
||||
import equals from "fast-deep-equal/es6";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type * as zhc from "zigbee-herdsman-converters";
|
||||
import Device from "../model/device";
|
||||
import Group from "../model/group";
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils, {isLightExpose} from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
const STATE_PROPERTIES: Readonly<Record<string, (value: string, exposes: zhc.Expose[]) => boolean>> = {
|
||||
state: () => true,
|
||||
brightness: (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'brightness')),
|
||||
color_temp: (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'color_temp')),
|
||||
color: (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'color_xy' || f.name === 'color_hs')),
|
||||
brightness: (_value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === "brightness")),
|
||||
color_temp: (_value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === "color_temp")),
|
||||
color: (_value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === "color_xy" || f.name === "color_hs")),
|
||||
color_mode: (value, exposes) =>
|
||||
exposes.some(
|
||||
(e) =>
|
||||
e.type === 'light' &&
|
||||
(e.features.some((f) => f.name === `color_${value}`) || (value === 'color_temp' && e.features.some((f) => f.name === 'color_temp'))),
|
||||
isLightExpose(e) &&
|
||||
(e.features.some((f) => f.name === `color_${value}`) || (value === "color_temp" && e.features.some((f) => f.name === "color_temp"))),
|
||||
),
|
||||
};
|
||||
|
||||
interface ParsedMQTTMessage {
|
||||
type: 'remove' | 'add' | 'remove_all';
|
||||
resolvedEntityGroup: Group;
|
||||
resolvedEntityDevice: Device;
|
||||
error: string;
|
||||
groupKey: string;
|
||||
deviceKey: string;
|
||||
triggeredViaLegacyApi: boolean;
|
||||
type: "remove" | "add" | "remove_all";
|
||||
resolvedGroup?: Group;
|
||||
resolvedDevice?: Device;
|
||||
resolvedEndpoint?: zh.Endpoint;
|
||||
groupKey?: string;
|
||||
deviceKey?: string;
|
||||
endpointKey?: string | number;
|
||||
skipDisableReporting: boolean;
|
||||
resolvedEntityEndpoint: zh.Endpoint;
|
||||
}
|
||||
|
||||
export default class Groups extends Extension {
|
||||
private legacyApi = settings.get().advanced.legacy_api;
|
||||
#topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
|
||||
private lastOptimisticState: {[s: string]: KeyValue} = {};
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onStateChange(this, this.onStateChange);
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
await this.syncGroupsWithSettings();
|
||||
}
|
||||
|
||||
private async syncGroupsWithSettings(): Promise<void> {
|
||||
const settingsGroups = settings.getGroups();
|
||||
|
||||
const addRemoveFromGroup = async (
|
||||
action: 'add' | 'remove',
|
||||
deviceName: string,
|
||||
groupName: string | number,
|
||||
endpoint: zh.Endpoint,
|
||||
group: Group,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} '${deviceName}' to group '${groupName}'`);
|
||||
|
||||
if (action === 'remove') {
|
||||
await endpoint.removeFromGroup(group.zh);
|
||||
} else {
|
||||
await endpoint.addToGroup(group.zh);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${action} '${deviceName}' from '${groupName}'`);
|
||||
logger.debug(error.stack);
|
||||
}
|
||||
};
|
||||
|
||||
for (const settingGroup of settingsGroups) {
|
||||
const groupID = settingGroup.ID;
|
||||
const zigbeeGroup = this.zigbee.groupsIterator((g) => g.groupID === groupID).next().value || this.zigbee.createGroup(groupID);
|
||||
const settingsEndpoints: zh.Endpoint[] = [];
|
||||
|
||||
for (const d of settingGroup.devices) {
|
||||
const parsed = this.zigbee.resolveEntityAndEndpoint(d);
|
||||
const device = parsed.entity as Device;
|
||||
|
||||
if (!device) {
|
||||
logger.error(`Cannot find '${d}' of group '${settingGroup.friendly_name}'`);
|
||||
}
|
||||
|
||||
if (!parsed.endpoint) {
|
||||
if (parsed.endpointID) {
|
||||
logger.error(`Cannot find endpoint '${parsed.endpointID}' of device '${parsed.ID}'`);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// In settings but not in zigbee
|
||||
if (!zigbeeGroup.zh.hasMember(parsed.endpoint)) {
|
||||
await addRemoveFromGroup('add', device?.name, settingGroup.friendly_name, parsed.endpoint, zigbeeGroup);
|
||||
}
|
||||
|
||||
settingsEndpoints.push(parsed.endpoint);
|
||||
}
|
||||
|
||||
// In zigbee but not in settings
|
||||
for (const endpoint of zigbeeGroup.zh.members) {
|
||||
if (!settingsEndpoints.includes(endpoint)) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name;
|
||||
|
||||
await addRemoveFromGroup('remove', deviceName, settingGroup.friendly_name, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const zigbeeGroup of this.zigbee.groupsIterator((zg) => !settingsGroups.some((sg) => sg.ID === zg.groupID))) {
|
||||
for (const endpoint of zigbeeGroup.zh.members) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name;
|
||||
|
||||
await addRemoveFromGroup('remove', deviceName, zigbeeGroup.ID, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onStateChange(data: eventdata.StateChange): Promise<void> {
|
||||
const reason = 'groupOptimistic';
|
||||
const reason = "groupOptimistic";
|
||||
|
||||
if (data.reason === reason || data.reason === 'publishCached') {
|
||||
if (data.reason === reason || data.reason === "publishCached") {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: KeyValue = {};
|
||||
let endpointName: string = null;
|
||||
let endpointName: string | undefined;
|
||||
const endpointNames: string[] = data.entity instanceof Device ? data.entity.getEndpointNames() : [];
|
||||
|
||||
for (let prop of Object.keys(data.update)) {
|
||||
@@ -153,28 +77,32 @@ export default class Groups extends Extension {
|
||||
const groups = [];
|
||||
|
||||
for (const group of this.zigbee.groupsIterator()) {
|
||||
if (group.options && (group.options.optimistic == undefined || group.options.optimistic)) {
|
||||
if (group.options && (group.options.optimistic == null || group.options.optimistic)) {
|
||||
groups.push(group);
|
||||
}
|
||||
}
|
||||
|
||||
if (entity instanceof Device) {
|
||||
for (const group of groups) {
|
||||
if (
|
||||
group.zh.hasMember(entity.endpoint(endpointName)) &&
|
||||
!equals(this.lastOptimisticState[group.ID], payload) &&
|
||||
this.shouldPublishPayloadForGroup(group, payload)
|
||||
) {
|
||||
this.lastOptimisticState[group.ID] = payload;
|
||||
const endpoint = entity.endpoint(endpointName);
|
||||
|
||||
await this.publishEntityState(group, payload, reason);
|
||||
if (endpoint) {
|
||||
for (const group of groups) {
|
||||
if (
|
||||
group.zh.hasMember(endpoint) &&
|
||||
!equals(this.lastOptimisticState[group.ID], payload) &&
|
||||
this.shouldPublishPayloadForGroup(group, payload)
|
||||
) {
|
||||
this.lastOptimisticState[group.ID] = payload;
|
||||
|
||||
await this.publishEntityState(group, payload, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Invalidate the last optimistic group state when group state is changed directly.
|
||||
delete this.lastOptimisticState[entity.ID];
|
||||
|
||||
const groupsToPublish: Set<Group> = new Set();
|
||||
const groupsToPublish = new Set<Group>();
|
||||
|
||||
for (const member of entity.zh.members) {
|
||||
const device = this.zigbee.resolveEntity(member.getDevice()) as Device;
|
||||
@@ -220,17 +148,31 @@ export default class Groups extends Extension {
|
||||
}
|
||||
|
||||
private shouldPublishPayloadForGroup(group: Group, payload: KeyValue): boolean {
|
||||
return group.options.off_state === 'last_member_state' || !payload || payload.state !== 'OFF' || this.areAllMembersOff(group);
|
||||
return (
|
||||
group.options.off_state === "last_member_state" ||
|
||||
!payload ||
|
||||
(payload.state !== "OFF" && payload.state !== "CLOSE") ||
|
||||
this.areAllMembersOffOrClosed(group)
|
||||
);
|
||||
}
|
||||
|
||||
private areAllMembersOff(group: Group): boolean {
|
||||
private areAllMembersOffOrClosed(group: Group): boolean {
|
||||
for (const member of group.zh.members) {
|
||||
const device = this.zigbee.resolveEntity(member.getDevice());
|
||||
// biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: valid from loop?
|
||||
const device = this.zigbee.resolveEntity(member.getDevice())!;
|
||||
|
||||
if (this.state.exists(device)) {
|
||||
const state = this.state.get(device);
|
||||
const endpointNames = device.isDevice() && device.getEndpointNames();
|
||||
const stateKey =
|
||||
endpointNames &&
|
||||
endpointNames.length >= member.ID &&
|
||||
device.definition?.meta?.multiEndpoint &&
|
||||
(!device.definition.meta.multiEndpointSkip || !device.definition.meta.multiEndpointSkip.includes("state"))
|
||||
? `state_${endpointNames[member.ID - 1]}`
|
||||
: "state";
|
||||
|
||||
if (state.state === 'ON') {
|
||||
if (state[stateKey] === "ON" || state[stateKey] === "OPEN") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -239,217 +181,148 @@ export default class Groups extends Extension {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async parseMQTTMessage(data: eventdata.MQTTMessage): Promise<ParsedMQTTMessage> {
|
||||
let type: ParsedMQTTMessage['type'] = null;
|
||||
let resolvedEntityGroup: ParsedMQTTMessage['resolvedEntityGroup'] = null;
|
||||
let resolvedEntityDevice: ParsedMQTTMessage['resolvedEntityDevice'] = null;
|
||||
let resolvedEntityEndpoint: ParsedMQTTMessage['resolvedEntityEndpoint'] = null;
|
||||
let error: ParsedMQTTMessage['error'] = null;
|
||||
let groupKey: ParsedMQTTMessage['groupKey'] = null;
|
||||
let deviceKey: ParsedMQTTMessage['deviceKey'] = null;
|
||||
let triggeredViaLegacyApi: ParsedMQTTMessage['triggeredViaLegacyApi'] = false;
|
||||
let skipDisableReporting: ParsedMQTTMessage['skipDisableReporting'] = false;
|
||||
private parseMQTTMessage(
|
||||
data: eventdata.MQTTMessage,
|
||||
): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] {
|
||||
const topicRegexMatch = data.topic.match(this.#topicRegex);
|
||||
|
||||
/* istanbul ignore else */
|
||||
const topicRegexMatch = data.topic.match(TOPIC_REGEX);
|
||||
const legacyTopicRegexRemoveAllMatch = data.topic.match(LEGACY_TOPIC_REGEX_REMOVE_ALL);
|
||||
const legacyTopicRegexMatch = data.topic.match(LEGACY_TOPIC_REGEX);
|
||||
if (topicRegexMatch) {
|
||||
const type = topicRegexMatch[1] as "remove" | "add" | "remove_all";
|
||||
let resolvedGroup: Group | undefined;
|
||||
let groupKey: string | undefined;
|
||||
let skipDisableReporting = false;
|
||||
const message = JSON.parse(data.message) as
|
||||
| Zigbee2MQTTAPI["bridge/request/group/members/add"]
|
||||
| Zigbee2MQTTAPI["bridge/request/group/members/remove"]
|
||||
| Zigbee2MQTTAPI["bridge/request/group/members/remove_all"];
|
||||
|
||||
if (this.legacyApi && (legacyTopicRegexMatch || legacyTopicRegexRemoveAllMatch)) {
|
||||
triggeredViaLegacyApi = true;
|
||||
|
||||
if (legacyTopicRegexMatch) {
|
||||
resolvedEntityGroup = this.zigbee.resolveEntity(legacyTopicRegexMatch[1]) as Group;
|
||||
type = legacyTopicRegexMatch[2] as ParsedMQTTMessage['type'];
|
||||
|
||||
if (!resolvedEntityGroup || !(resolvedEntityGroup instanceof Group)) {
|
||||
logger.error(`Group '${legacyTopicRegexMatch[1]}' does not exist`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const message = {friendly_name: data.message, group: legacyTopicRegexMatch[1], error: `group doesn't exists`};
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_group_${type}_failed`, message}));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
type = 'remove_all';
|
||||
if (typeof message !== "object" || message.device == null) {
|
||||
return [message, {type, skipDisableReporting}, "Invalid payload"];
|
||||
}
|
||||
|
||||
const parsedEntity = this.zigbee.resolveEntityAndEndpoint(data.message);
|
||||
resolvedEntityDevice = parsedEntity.entity as Device;
|
||||
const deviceKey = message.device;
|
||||
skipDisableReporting = message.skip_disable_reporting != null ? message.skip_disable_reporting : false;
|
||||
|
||||
if (!resolvedEntityDevice || !(resolvedEntityDevice instanceof Device)) {
|
||||
logger.error(`Device '${data.message}' does not exist`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const message = {friendly_name: data.message, group: legacyTopicRegexMatch[1], error: "entity doesn't exists"};
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_group_${type}_failed`, message}));
|
||||
if (type !== "remove_all") {
|
||||
if (!("group" in message) || message.group == null) {
|
||||
return [message, {type, skipDisableReporting}, "Invalid payload"];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
resolvedEntityEndpoint = parsedEntity.endpoint;
|
||||
|
||||
if (parsedEntity.endpointID && !resolvedEntityEndpoint) {
|
||||
logger.error(`Device '${parsedEntity.ID}' does not have endpoint '${parsedEntity.endpointID}'`);
|
||||
return null;
|
||||
}
|
||||
} else if (topicRegexMatch) {
|
||||
type = topicRegexMatch[1] as 'remove' | 'add' | 'remove_all';
|
||||
const message = JSON.parse(data.message);
|
||||
deviceKey = message.device;
|
||||
skipDisableReporting = 'skip_disable_reporting' in message ? message.skip_disable_reporting : false;
|
||||
|
||||
if (type !== 'remove_all') {
|
||||
groupKey = message.group;
|
||||
resolvedEntityGroup = this.zigbee.resolveEntity(message.group) as Group;
|
||||
|
||||
if (!resolvedEntityGroup || !(resolvedEntityGroup instanceof Group)) {
|
||||
error = `Group '${message.group}' does not exist`;
|
||||
const group = this.zigbee.resolveEntity(message.group);
|
||||
|
||||
if (!group || !(group instanceof Group)) {
|
||||
return [message, {type, skipDisableReporting}, `Group '${message.group}' does not exist`];
|
||||
}
|
||||
|
||||
resolvedGroup = group;
|
||||
}
|
||||
|
||||
const parsed = this.zigbee.resolveEntityAndEndpoint(message.device);
|
||||
resolvedEntityDevice = parsed?.entity as Device;
|
||||
const resolvedDevice = this.zigbee.resolveEntity(message.device);
|
||||
|
||||
if (!error && (!resolvedEntityDevice || !(resolvedEntityDevice instanceof Device))) {
|
||||
error = `Device '${message.device}' does not exist`;
|
||||
if (!resolvedDevice || !(resolvedDevice instanceof Device)) {
|
||||
return [message, {type, skipDisableReporting}, `Device '${message.device}' does not exist`];
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
resolvedEntityEndpoint = parsed.endpoint;
|
||||
const endpointKey = message.endpoint ?? "default";
|
||||
const resolvedEndpoint = resolvedDevice.endpoint(message.endpoint);
|
||||
|
||||
if (parsed.endpointID && !resolvedEntityEndpoint) {
|
||||
error = `Device '${parsed.ID}' does not have endpoint '${parsed.endpointID}'`;
|
||||
}
|
||||
if (!resolvedEndpoint) {
|
||||
return [message, {type, skipDisableReporting}, `Device '${resolvedDevice.name}' does not have endpoint '${endpointKey}'`];
|
||||
}
|
||||
|
||||
return [
|
||||
message,
|
||||
{
|
||||
resolvedGroup,
|
||||
resolvedDevice,
|
||||
resolvedEndpoint,
|
||||
type,
|
||||
groupKey,
|
||||
deviceKey,
|
||||
endpointKey,
|
||||
skipDisableReporting,
|
||||
},
|
||||
undefined,
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
resolvedEntityGroup,
|
||||
resolvedEntityDevice,
|
||||
type,
|
||||
error,
|
||||
groupKey,
|
||||
deviceKey,
|
||||
triggeredViaLegacyApi,
|
||||
skipDisableReporting,
|
||||
resolvedEntityEndpoint,
|
||||
};
|
||||
return [undefined, undefined, undefined];
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const parsed = await this.parseMQTTMessage(data);
|
||||
const [raw, parsed, error] = this.parseMQTTMessage(data);
|
||||
|
||||
if (!parsed || !parsed.type) {
|
||||
if (!raw || !parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
resolvedEntityGroup,
|
||||
resolvedEntityDevice,
|
||||
type,
|
||||
triggeredViaLegacyApi,
|
||||
groupKey,
|
||||
deviceKey,
|
||||
skipDisableReporting,
|
||||
resolvedEntityEndpoint,
|
||||
} = parsed;
|
||||
let error = parsed.error;
|
||||
if (error) {
|
||||
await this.publishResponse(parsed.type, raw, {}, error);
|
||||
return;
|
||||
}
|
||||
|
||||
const {resolvedGroup, resolvedDevice, resolvedEndpoint, type, groupKey, deviceKey, endpointKey, skipDisableReporting} = parsed;
|
||||
const changedGroups: Group[] = [];
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
const keys = [
|
||||
`${resolvedEntityDevice.ieeeAddr}/${resolvedEntityEndpoint.ID}`,
|
||||
`${resolvedEntityDevice.name}/${resolvedEntityEndpoint.ID}`,
|
||||
];
|
||||
const endpointNameLocal = resolvedEntityDevice.endpointName(resolvedEntityEndpoint);
|
||||
assert(resolvedDevice, "`resolvedDevice` is missing");
|
||||
assert(resolvedEndpoint, "`resolvedEndpoint` is missing");
|
||||
|
||||
if (endpointNameLocal) {
|
||||
keys.push(`${resolvedEntityDevice.ieeeAddr}/${endpointNameLocal}`);
|
||||
keys.push(`${resolvedEntityDevice.name}/${endpointNameLocal}`);
|
||||
try {
|
||||
if (type === "add") {
|
||||
assert(resolvedGroup, "`resolvedGroup` is missing");
|
||||
logger.info(`Adding endpoint '${resolvedEndpoint.ID}' of device '${resolvedDevice.name}' to group '${resolvedGroup.name}'`);
|
||||
await resolvedEndpoint.addToGroup(resolvedGroup.zh);
|
||||
changedGroups.push(resolvedGroup);
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts
|
||||
const respPayload = {device: deviceKey!, endpoint: endpointKey!, group: groupKey!};
|
||||
await this.publishResponse<"bridge/response/group/members/add">(parsed.type, raw, respPayload);
|
||||
} else if (type === "remove") {
|
||||
assert(resolvedGroup, "`resolvedGroup` is missing");
|
||||
logger.info(`Removing endpoint '${resolvedEndpoint.ID}' of device '${resolvedDevice.name}' from group '${resolvedGroup.name}'`);
|
||||
await resolvedEndpoint.removeFromGroup(resolvedGroup.zh);
|
||||
changedGroups.push(resolvedGroup);
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts
|
||||
const respPayload = {device: deviceKey!, endpoint: endpointKey!, group: groupKey!};
|
||||
await this.publishResponse<"bridge/response/group/members/remove">(parsed.type, raw, respPayload);
|
||||
} else {
|
||||
// remove_all
|
||||
logger.info(`Removing endpoint '${resolvedEndpoint.ID}' of device '${resolvedDevice.name}' from all groups`);
|
||||
|
||||
for (const group of this.zigbee.groupsIterator((g) => g.members.includes(resolvedEndpoint))) {
|
||||
changedGroups.push(group);
|
||||
}
|
||||
|
||||
if (!endpointNameLocal) {
|
||||
keys.push(resolvedEntityDevice.name);
|
||||
keys.push(resolvedEntityDevice.ieeeAddr);
|
||||
}
|
||||
|
||||
if (type === 'add') {
|
||||
logger.info(`Adding '${resolvedEntityDevice.name}' to '${resolvedEntityGroup.name}'`);
|
||||
await resolvedEntityEndpoint.addToGroup(resolvedEntityGroup.zh);
|
||||
settings.addDeviceToGroup(resolvedEntityGroup.ID.toString(), keys);
|
||||
changedGroups.push(resolvedEntityGroup);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const message = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name};
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_group_add`, message}));
|
||||
}
|
||||
} else if (type === 'remove') {
|
||||
logger.info(`Removing '${resolvedEntityDevice.name}' from '${resolvedEntityGroup.name}'`);
|
||||
await resolvedEntityEndpoint.removeFromGroup(resolvedEntityGroup.zh);
|
||||
settings.removeDeviceFromGroup(resolvedEntityGroup.ID.toString(), keys);
|
||||
changedGroups.push(resolvedEntityGroup);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const message = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name};
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_group_remove`, message}));
|
||||
}
|
||||
} else {
|
||||
// remove_all
|
||||
logger.info(`Removing '${resolvedEntityDevice.name}' from all groups`);
|
||||
|
||||
for (const group of this.zigbee.groupsIterator((g) => g.members.includes(resolvedEntityEndpoint))) {
|
||||
changedGroups.push(group);
|
||||
}
|
||||
|
||||
await resolvedEntityEndpoint.removeFromAllGroups();
|
||||
|
||||
for (const settingsGroup of settings.getGroups()) {
|
||||
settings.removeDeviceFromGroup(settingsGroup.ID.toString(), keys);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const message = {friendly_name: resolvedEntityDevice.name};
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_group_remove_all`, message}));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Failed to ${type} from group (${e.message})`;
|
||||
logger.debug(e.stack);
|
||||
await resolvedEndpoint.removeFromAllGroups();
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts
|
||||
const respPayload = {device: deviceKey!, endpoint: endpointKey!};
|
||||
await this.publishResponse<"bridge/response/group/members/remove_all">(parsed.type, raw, respPayload);
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMsg = `Failed to ${type} ${type === "add" ? "to" : "from"} group (${(e as Error).message})`;
|
||||
await this.publishResponse(parsed.type, raw, {}, errorMsg);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((e as Error).stack!);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
const responseData: KeyValue = {device: deviceKey};
|
||||
|
||||
if (groupKey) {
|
||||
responseData.group = groupKey;
|
||||
}
|
||||
|
||||
await this.mqtt.publish(`bridge/response/group/members/${type}`, stringify(utils.getResponse(message, responseData, error)));
|
||||
for (const group of changedGroups) {
|
||||
this.eventBus.emitGroupMembersChanged({group, action: type, endpoint: resolvedEndpoint, skipDisableReporting});
|
||||
}
|
||||
}
|
||||
|
||||
private async publishResponse<T extends Zigbee2MQTTResponseEndpoints>(
|
||||
type: ParsedMQTTMessage["type"],
|
||||
request: KeyValue,
|
||||
data: Zigbee2MQTTAPI[T],
|
||||
error?: string,
|
||||
): Promise<void> {
|
||||
const response = utils.getResponse(request, data, error);
|
||||
await this.mqtt.publish(`bridge/response/group/members/${type}`, stringify(response));
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
} else {
|
||||
for (const group of changedGroups) {
|
||||
this.eventBus.emitGroupMembersChanged({group, action: type, endpoint: resolvedEntityEndpoint, skipDisableReporting});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as os from "node:os";
|
||||
import * as process from "node:process";
|
||||
import type {Zigbee2MQTTAPI} from "../types/api";
|
||||
import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
/** Round with 2 decimals */
|
||||
const round2 = (n: number): number => Math.round(n * 100.0) / 100.0;
|
||||
/** Round with 4 decimals */
|
||||
const round4 = (n: number): number => Math.round(n * 10000.0) / 10000.0;
|
||||
|
||||
export default class Health extends Extension {
|
||||
#checkTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
override async start(): Promise<void> {
|
||||
await super.start();
|
||||
|
||||
this.#checkTimer = setInterval(this.#checkHealth.bind(this), utils.minutes(settings.get().health.interval));
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
clearInterval(this.#checkTimer);
|
||||
await super.stop();
|
||||
}
|
||||
|
||||
clearStats(): void {
|
||||
this.eventBus.stats.devices.clear();
|
||||
this.eventBus.stats.mqtt.published = 0;
|
||||
this.eventBus.stats.mqtt.received = 0;
|
||||
}
|
||||
|
||||
async #checkHealth(): Promise<void> {
|
||||
const sysMemTotalKb = os.totalmem() / 1024;
|
||||
const sysMemFreeKb = os.freemem() / 1024;
|
||||
const sysMemUsedKb = sysMemTotalKb - sysMemFreeKb;
|
||||
const procMemUsedKb = process.memoryUsage().rss / 1024;
|
||||
const healthcheck: Zigbee2MQTTAPI["bridge/health"] = {
|
||||
response_time: Date.now(),
|
||||
os: {
|
||||
load_average: os.loadavg(), // will be [0,0,0] on Windows (not supported)
|
||||
memory_used_mb: round2(sysMemUsedKb / 1024),
|
||||
memory_percent: round4((sysMemUsedKb / sysMemTotalKb) * 100.0),
|
||||
},
|
||||
process: {
|
||||
uptime_sec: Math.floor(process.uptime()),
|
||||
memory_used_mb: round2(procMemUsedKb / 1024),
|
||||
memory_percent: round4((procMemUsedKb / sysMemTotalKb) * 100.0),
|
||||
},
|
||||
mqtt: {...this.mqtt.stats, ...this.eventBus.stats.mqtt},
|
||||
devices: {},
|
||||
};
|
||||
|
||||
for (const [ieeeAddr, device] of this.eventBus.stats.devices) {
|
||||
let messages = 0;
|
||||
let mps = 0;
|
||||
|
||||
if (device.lastSeenChanges) {
|
||||
const timeDiff = Date.now() - device.lastSeenChanges.first;
|
||||
messages = device.lastSeenChanges.messages;
|
||||
mps = timeDiff > 0 ? round4(messages / (timeDiff / 1000.0)) : 0;
|
||||
}
|
||||
|
||||
healthcheck.devices[ieeeAddr] = {
|
||||
messages,
|
||||
messages_per_sec: mps,
|
||||
leave_count: device.leaveCounts,
|
||||
network_address_changes: device.networkAddressChanges,
|
||||
};
|
||||
}
|
||||
|
||||
if (settings.get().health.reset_on_check) {
|
||||
this.clearStats();
|
||||
}
|
||||
|
||||
await this.mqtt.publish("bridge/health", JSON.stringify(healthcheck), {clientOptions: {retain: true, qos: 1}});
|
||||
}
|
||||
}
|
||||
+1319
-1270
File diff suppressed because it is too large
Load Diff
@@ -1,420 +0,0 @@
|
||||
import assert from 'assert';
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import logger from '../../util/logger';
|
||||
import * as settings from '../../util/settings';
|
||||
import utils from '../../util/utils';
|
||||
import Extension from '../extension';
|
||||
|
||||
const configRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/((?:\\w+/get)|(?:\\w+/factory_reset)|(?:\\w+))`);
|
||||
|
||||
export default class BridgeLegacy extends Extension {
|
||||
private lastJoinedDeviceName: string = null;
|
||||
private supportedOptions: {[s: string]: (topic: string, message: string) => Promise<void> | void};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.supportedOptions = {
|
||||
permit_join: this.permitJoin,
|
||||
last_seen: this.lastSeen,
|
||||
elapsed: this.elapsed,
|
||||
reset: this.reset,
|
||||
log_level: this.logLevel,
|
||||
devices: this.devices,
|
||||
groups: this.groups,
|
||||
'devices/get': this.devices,
|
||||
rename: this.rename,
|
||||
rename_last: this.renameLast,
|
||||
remove: this.remove,
|
||||
force_remove: this.forceRemove,
|
||||
ban: this.ban,
|
||||
device_options: this.deviceOptions,
|
||||
add_group: this.addGroup,
|
||||
remove_group: this.removeGroup,
|
||||
force_remove_group: this.removeGroup,
|
||||
whitelist: this.whitelist,
|
||||
'touchlink/factory_reset': this.touchlinkFactoryReset,
|
||||
};
|
||||
|
||||
this.eventBus.onDeviceJoined(this, (data) => this.onZigbeeEvent_('deviceJoined', data, data.device));
|
||||
this.eventBus.onDeviceInterview(this, (data) => this.onZigbeeEvent_('deviceInterview', data, data.device));
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => this.onZigbeeEvent_('deviceAnnounce', data, data.device));
|
||||
this.eventBus.onDeviceLeave(this, (data) => this.onZigbeeEvent_('deviceLeave', data, null));
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
|
||||
await this.publish();
|
||||
}
|
||||
|
||||
@bind async whitelist(topic: string, message: string): Promise<void> {
|
||||
try {
|
||||
const entity = settings.getDevice(message);
|
||||
assert(entity, `Entity '${message}' does not exist`);
|
||||
settings.addDeviceToPasslist(entity.ID.toString());
|
||||
logger.info(`Whitelisted '${entity.friendly_name}'`);
|
||||
await this.mqtt.publish('bridge/log', stringify({type: 'device_whitelisted', message: {friendly_name: entity.friendly_name}}));
|
||||
} catch (error) {
|
||||
logger.error(`Failed to whitelist '${message}' '${error}'`);
|
||||
}
|
||||
}
|
||||
|
||||
@bind deviceOptions(topic: string, message: string): void {
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(message);
|
||||
} catch {
|
||||
logger.error('Failed to parse message as JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!json.hasOwnProperty('friendly_name') || !json.hasOwnProperty('options')) {
|
||||
logger.error('Invalid JSON message, should contain "friendly_name" and "options"');
|
||||
return;
|
||||
}
|
||||
|
||||
const entity = settings.getDevice(json.friendly_name);
|
||||
assert(entity, `Entity '${json.friendly_name}' does not exist`);
|
||||
settings.changeEntityOptions(entity.ID.toString(), json.options);
|
||||
logger.info(`Changed device specific options of '${json.friendly_name}' (${stringify(json.options)})`);
|
||||
}
|
||||
|
||||
@bind async permitJoin(topic: string, message: string): Promise<void> {
|
||||
await this.zigbee.permitJoin(message.toLowerCase() === 'true');
|
||||
await this.publish();
|
||||
}
|
||||
|
||||
@bind async reset(): Promise<void> {
|
||||
try {
|
||||
await this.zigbee.reset('soft');
|
||||
logger.info('Soft reset ZNP');
|
||||
} catch {
|
||||
logger.error('Soft reset failed');
|
||||
}
|
||||
}
|
||||
|
||||
@bind lastSeen(topic: string, message: string): void {
|
||||
const allowed = ['disable', 'ISO_8601', 'epoch', 'ISO_8601_local'];
|
||||
if (!allowed.includes(message)) {
|
||||
logger.error(`${message} is not an allowed value, possible: ${allowed}`);
|
||||
return;
|
||||
}
|
||||
|
||||
settings.set(['advanced', 'last_seen'], message);
|
||||
logger.info(`Set last_seen to ${message}`);
|
||||
}
|
||||
|
||||
@bind elapsed(topic: string, message: string): void {
|
||||
const allowed = ['true', 'false'];
|
||||
if (!allowed.includes(message)) {
|
||||
logger.error(`${message} is not an allowed value, possible: ${allowed}`);
|
||||
return;
|
||||
}
|
||||
|
||||
settings.set(['advanced', 'elapsed'], message === 'true');
|
||||
logger.info(`Set elapsed to ${message}`);
|
||||
}
|
||||
|
||||
@bind async logLevel(topic: string, message: string): Promise<void> {
|
||||
const level = message.toLowerCase() as settings.LogLevel;
|
||||
if (settings.LOG_LEVELS.includes(level)) {
|
||||
logger.info(`Switching log level to '${level}'`);
|
||||
logger.setLevel(level);
|
||||
} else {
|
||||
logger.error(`Could not set log level to '${level}'. Allowed level: '${settings.LOG_LEVELS.join(',')}'`);
|
||||
}
|
||||
|
||||
await this.publish();
|
||||
}
|
||||
|
||||
@bind async devices(topic: string): Promise<void> {
|
||||
const coordinator = await this.zigbee.getCoordinatorVersion();
|
||||
const devices: KeyValue[] = [];
|
||||
|
||||
for (const device of this.zigbee.devicesIterator()) {
|
||||
const payload: KeyValue = {
|
||||
ieeeAddr: device.ieeeAddr,
|
||||
type: device.zh.type,
|
||||
networkAddress: device.zh.networkAddress,
|
||||
};
|
||||
|
||||
if (device.zh.type !== 'Coordinator') {
|
||||
const definition = device.definition;
|
||||
payload.model = definition ? definition.model : device.zh.modelID;
|
||||
payload.vendor = definition ? definition.vendor : '-';
|
||||
payload.description = definition ? definition.description : '-';
|
||||
payload.friendly_name = device.name;
|
||||
payload.manufacturerID = device.zh.manufacturerID;
|
||||
payload.manufacturerName = device.zh.manufacturerName;
|
||||
payload.powerSource = device.zh.powerSource;
|
||||
payload.modelID = device.zh.modelID;
|
||||
payload.hardwareVersion = device.zh.hardwareVersion;
|
||||
payload.softwareBuildID = device.zh.softwareBuildID;
|
||||
payload.dateCode = device.zh.dateCode;
|
||||
payload.lastSeen = device.zh.lastSeen;
|
||||
} else {
|
||||
payload.friendly_name = 'Coordinator';
|
||||
payload.softwareBuildID = coordinator.type;
|
||||
payload.dateCode = coordinator.meta.revision.toString();
|
||||
payload.lastSeen = Date.now();
|
||||
}
|
||||
|
||||
devices.push(payload);
|
||||
}
|
||||
|
||||
if (topic.split('/').pop() == 'get') {
|
||||
await this.mqtt.publish(`bridge/config/devices`, stringify(devices), {}, settings.get().mqtt.base_topic, false, false);
|
||||
} else {
|
||||
await this.mqtt.publish('bridge/log', stringify({type: 'devices', message: devices}));
|
||||
}
|
||||
}
|
||||
|
||||
@bind async groups(): Promise<void> {
|
||||
const payload = settings.getGroups().map((g) => {
|
||||
return {...g, ID: Number(g.ID)};
|
||||
});
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: 'groups', message: payload}));
|
||||
}
|
||||
|
||||
@bind async rename(topic: string, message: string): Promise<void> {
|
||||
const invalid = `Invalid rename message format expected {"old": "friendly_name", "new": "new_name"} got ${message}`;
|
||||
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(message);
|
||||
} catch {
|
||||
logger.error(invalid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate message
|
||||
if (!json.new || !json.old) {
|
||||
logger.error(invalid);
|
||||
return;
|
||||
}
|
||||
|
||||
await this._renameInternal(json.old, json.new);
|
||||
}
|
||||
|
||||
@bind async renameLast(topic: string, message: string): Promise<void> {
|
||||
if (!this.lastJoinedDeviceName) {
|
||||
logger.error(`Cannot rename last joined device, no device has joined during this session`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this._renameInternal(this.lastJoinedDeviceName, message);
|
||||
}
|
||||
|
||||
async _renameInternal(from: string, to: string): Promise<void> {
|
||||
try {
|
||||
const isGroup = settings.getGroup(from) !== null;
|
||||
settings.changeFriendlyName(from, to);
|
||||
logger.info(`Successfully renamed - ${from} to ${to} `);
|
||||
const entity = this.zigbee.resolveEntity(to);
|
||||
if (entity.isDevice()) {
|
||||
this.eventBus.emitEntityRenamed({homeAssisantRename: false, from, to, entity});
|
||||
}
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `${isGroup ? 'group' : 'device'}_renamed`, message: {from, to}}));
|
||||
} catch {
|
||||
logger.error(`Failed to rename - ${from} to ${to}`);
|
||||
}
|
||||
}
|
||||
|
||||
@bind async addGroup(topic: string, message: string): Promise<void> {
|
||||
let id = null;
|
||||
let name = null;
|
||||
try {
|
||||
// json payload with id and friendly_name
|
||||
const json = JSON.parse(message);
|
||||
if (json.hasOwnProperty('id')) {
|
||||
id = json.id;
|
||||
name = `group_${id}`;
|
||||
}
|
||||
if (json.hasOwnProperty('friendly_name')) {
|
||||
name = json.friendly_name;
|
||||
}
|
||||
} catch {
|
||||
// just friendly_name
|
||||
name = message;
|
||||
}
|
||||
|
||||
if (name == null) {
|
||||
logger.error('Failed to add group, missing friendly_name!');
|
||||
return;
|
||||
}
|
||||
|
||||
const group = settings.addGroup(name, id);
|
||||
this.zigbee.createGroup(group.ID);
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `group_added`, message: name}));
|
||||
logger.info(`Added group '${name}'`);
|
||||
}
|
||||
|
||||
@bind async removeGroup(topic: string, message: string): Promise<void> {
|
||||
const name = message;
|
||||
const entity = this.zigbee.resolveEntity(message) as Group;
|
||||
assert(entity && entity.isGroup(), `Group '${message}' does not exist`);
|
||||
|
||||
if (topic.includes('force')) {
|
||||
entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
settings.removeGroup(message);
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `group_removed`, message}));
|
||||
logger.info(`Removed group '${name}'`);
|
||||
}
|
||||
|
||||
@bind async forceRemove(topic: string, message: string): Promise<void> {
|
||||
await this.removeForceRemoveOrBan('force_remove', message);
|
||||
}
|
||||
|
||||
@bind async remove(topic: string, message: string): Promise<void> {
|
||||
await this.removeForceRemoveOrBan('remove', message);
|
||||
}
|
||||
|
||||
@bind async ban(topic: string, message: string): Promise<void> {
|
||||
await this.removeForceRemoveOrBan('ban', message);
|
||||
}
|
||||
|
||||
@bind async removeForceRemoveOrBan(action: string, message: string): Promise<void> {
|
||||
const entity = this.zigbee.resolveEntity(message.trim()) as Device;
|
||||
const lookup: KeyValue = {
|
||||
ban: ['banned', 'Banning', 'ban'],
|
||||
force_remove: ['force_removed', 'Force removing', 'force remove'],
|
||||
remove: ['removed', 'Removing', 'remove'],
|
||||
};
|
||||
|
||||
if (!entity) {
|
||||
logger.error(`Cannot ${lookup[action][2]}, device '${message}' does not exist`);
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_${lookup[action][0]}_failed`, message}));
|
||||
return;
|
||||
}
|
||||
|
||||
const ieeeAddr = entity.ieeeAddr;
|
||||
const name = entity.name;
|
||||
|
||||
const cleanup = async (): Promise<void> => {
|
||||
// Fire event
|
||||
this.eventBus.emitEntityRemoved({id: ieeeAddr, name: name, type: 'device'});
|
||||
|
||||
// Remove from configuration.yaml
|
||||
settings.removeDevice(entity.ieeeAddr);
|
||||
|
||||
// Remove from state
|
||||
this.state.remove(ieeeAddr);
|
||||
|
||||
logger.info(`Successfully ${lookup[action][0]} ${entity.name}`);
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_${lookup[action][0]}`, message}));
|
||||
};
|
||||
|
||||
try {
|
||||
logger.info(`${lookup[action][1]} '${entity.name}'`);
|
||||
if (action === 'force_remove') {
|
||||
entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${lookup[action][2]} ${entity.name} (${error})`);
|
||||
// eslint-disable-next-line
|
||||
logger.error(`See https://www.zigbee2mqtt.io/guide/usage/mqtt_topics_and_messages.html#zigbee2mqtt-bridge-request for more info`);
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_${lookup[action][0]}_failed`, message}));
|
||||
}
|
||||
|
||||
if (action === 'ban') {
|
||||
settings.blockDevice(ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const {topic, message} = data;
|
||||
if (!topic.match(configRegex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const option = topic.match(configRegex)[1];
|
||||
|
||||
if (!this.supportedOptions.hasOwnProperty(option)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.supportedOptions[option](topic, message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async publish(): Promise<void> {
|
||||
const info = await utils.getZigbee2MQTTVersion();
|
||||
const coordinator = await this.zigbee.getCoordinatorVersion();
|
||||
const topic = `bridge/config`;
|
||||
const payload = {
|
||||
version: info.version,
|
||||
commit: info.commitHash,
|
||||
coordinator,
|
||||
network: await this.zigbee.getNetworkParameters(),
|
||||
log_level: logger.getLevel(),
|
||||
permit_join: this.zigbee.getPermitJoin(),
|
||||
};
|
||||
|
||||
await this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0});
|
||||
}
|
||||
|
||||
async onZigbeeEvent_(type: string, data: KeyValue, resolvedEntity: Device): Promise<void> {
|
||||
if (type === 'deviceJoined' && resolvedEntity) {
|
||||
this.lastJoinedDeviceName = resolvedEntity.name;
|
||||
}
|
||||
|
||||
if (type === 'deviceJoined') {
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_connected`, message: {friendly_name: resolvedEntity.name}}));
|
||||
} else if (type === 'deviceInterview') {
|
||||
if (data.status === 'successful') {
|
||||
if (resolvedEntity.isSupported) {
|
||||
const {vendor, description, model} = resolvedEntity.definition;
|
||||
const log = {friendly_name: resolvedEntity.name, model, vendor, description, supported: true};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta: log}));
|
||||
} else {
|
||||
const meta = {friendly_name: resolvedEntity.name, supported: false};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta}));
|
||||
}
|
||||
} else if (data.status === 'failed') {
|
||||
const meta = {friendly_name: resolvedEntity.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_failed', meta}));
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (data.status === 'started') {
|
||||
const meta = {friendly_name: resolvedEntity.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_started', meta}));
|
||||
}
|
||||
}
|
||||
} else if (type === 'deviceAnnounce') {
|
||||
const meta = {friendly_name: resolvedEntity.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_announced`, message: 'announce', meta}));
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (type === 'deviceLeave') {
|
||||
const name = data.ieeeAddr;
|
||||
const meta = {friendly_name: name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_removed`, message: 'left_network', meta}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind async touchlinkFactoryReset(): Promise<void> {
|
||||
logger.info('Starting touchlink factory reset...');
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `touchlink`, message: 'reset_started', meta: {status: 'started'}}));
|
||||
const result = await this.zigbee.touchlinkFactoryResetFirst();
|
||||
|
||||
if (result) {
|
||||
logger.info('Successfully factory reset device through Touchlink');
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `touchlink`, message: 'reset_success', meta: {status: 'success'}}));
|
||||
} else {
|
||||
logger.warning('Failed to factory reset device through Touchlink');
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `touchlink`, message: 'reset_failed', meta: {status: 'failed'}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/* istanbul ignore file */
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
import Device from '../../model/device';
|
||||
import logger from '../../util/logger';
|
||||
import * as settings from '../../util/settings';
|
||||
import Extension from '../extension';
|
||||
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/device/(.+)/get_group_membership$`);
|
||||
|
||||
export default class DeviceGroupMembership extends Extension {
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(topicRegex);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = this.zigbee.resolveEntityAndEndpoint(match[1]);
|
||||
const device = parsed?.entity as Device;
|
||||
if (!device || !(device instanceof Device)) {
|
||||
logger.error(`Device '${match[1]}' does not exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = parsed.endpoint;
|
||||
if (parsed.endpointID && !endpoint) {
|
||||
logger.error(`Device '${parsed.ID}' does not have endpoint '${parsed.endpointID}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await endpoint.command(`genGroups`, 'getMembership', {groupcount: 0, grouplist: []}, {});
|
||||
|
||||
if (!response) {
|
||||
logger.warning(`Couldn't get group membership of ${device.ieeeAddr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let {grouplist} = response;
|
||||
|
||||
grouplist = grouplist.map((gid: string) => {
|
||||
const g = settings.getGroup(gid);
|
||||
return g ? g.friendly_name : gid;
|
||||
});
|
||||
|
||||
const msgGroupList = `${device.ieeeAddr} is in groups [${grouplist}]`;
|
||||
let msgCapacity;
|
||||
if (response.capacity === 254) {
|
||||
msgCapacity = 'it can be a part of at least 1 more group';
|
||||
} else {
|
||||
msgCapacity = `its remaining group capacity is ${response.capacity === 255 ? 'unknown' : response.capacity}`;
|
||||
}
|
||||
logger.info(`${msgGroupList} and ${msgCapacity}`);
|
||||
|
||||
await this.publishEntityState(device, {group_list: grouplist, group_capacity: response.capacity});
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import logger from '../../util/logger';
|
||||
import * as settings from '../../util/settings';
|
||||
import utils from '../../util/utils';
|
||||
import Extension from '../extension';
|
||||
|
||||
const defaultConfiguration = {
|
||||
minimumReportInterval: 3,
|
||||
maximumReportInterval: 300,
|
||||
reportableChange: 1,
|
||||
};
|
||||
|
||||
const ZNLDP12LM = zhc.definitions.find((d) => d.model === 'ZNLDP12LM');
|
||||
|
||||
const devicesNotSupportingReporting = [
|
||||
zhc.definitions.find((d) => d.model === 'CC2530.ROUTER'),
|
||||
zhc.definitions.find((d) => d.model === 'BASICZBR3'),
|
||||
zhc.definitions.find((d) => d.model === 'ZM-CSW032-D'),
|
||||
zhc.definitions.find((d) => d.model === 'TS0001'),
|
||||
zhc.definitions.find((d) => d.model === 'TS0115'),
|
||||
];
|
||||
|
||||
const reportKey = 1;
|
||||
|
||||
const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean; colorXY: boolean}> => {
|
||||
if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') === undefined) {
|
||||
await endpoint.read('lightingColorCtrl', ['colorCapabilities']);
|
||||
}
|
||||
|
||||
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number;
|
||||
return {
|
||||
colorTemperature: (value & (1 << 4)) > 0,
|
||||
colorXY: (value & (1 << 3)) > 0,
|
||||
};
|
||||
};
|
||||
|
||||
const clusters: {
|
||||
[s: string]: {
|
||||
attribute: string;
|
||||
minimumReportInterval: number;
|
||||
maximumReportInterval: number;
|
||||
reportableChange: number;
|
||||
condition?: (endpoint: zh.Endpoint) => Promise<boolean>;
|
||||
}[];
|
||||
} = {
|
||||
genOnOff: [{attribute: 'onOff', ...defaultConfiguration, minimumReportInterval: 0, reportableChange: 0}],
|
||||
genLevelCtrl: [{attribute: 'currentLevel', ...defaultConfiguration}],
|
||||
lightingColorCtrl: [
|
||||
{
|
||||
attribute: 'colorTemperature',
|
||||
...defaultConfiguration,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
},
|
||||
{
|
||||
attribute: 'currentX',
|
||||
...defaultConfiguration,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
{
|
||||
attribute: 'currentY',
|
||||
...defaultConfiguration,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
],
|
||||
closuresWindowCovering: [
|
||||
{attribute: 'currentPositionLiftPercentage', ...defaultConfiguration},
|
||||
{attribute: 'currentPositionTiltPercentage', ...defaultConfiguration},
|
||||
],
|
||||
};
|
||||
|
||||
export default class Report extends Extension {
|
||||
private queue: Set<string> = new Set();
|
||||
private failed: Set<string> = new Set();
|
||||
private enabled = settings.get().advanced.report;
|
||||
|
||||
shouldIgnoreClusterForDevice(cluster: string, definition: zhc.Definition): boolean {
|
||||
if (definition === ZNLDP12LM && cluster === 'closuresWindowCovering') {
|
||||
// Device announces it but doesn't support it
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/2611
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async setupReporting(device: Device): Promise<void> {
|
||||
if (this.queue.has(device.ieeeAddr) || this.failed.has(device.ieeeAddr)) return;
|
||||
this.queue.add(device.ieeeAddr);
|
||||
|
||||
const term1 = this.enabled ? 'Setup' : 'Disable';
|
||||
const term2 = this.enabled ? 'setup' : 'disabled';
|
||||
|
||||
try {
|
||||
for (const ep of device.zh.endpoints) {
|
||||
for (const [cluster, configuration] of Object.entries(clusters)) {
|
||||
if (ep.supportsInputCluster(cluster) && !this.shouldIgnoreClusterForDevice(cluster, device.definition)) {
|
||||
logger.debug(`${term1} reporting for '${device.ieeeAddr}' - ${ep.ID} - ${cluster}`);
|
||||
|
||||
const items = [];
|
||||
for (const entry of configuration) {
|
||||
if (!entry.hasOwnProperty('condition') || (await entry.condition(ep))) {
|
||||
const toAdd = {...entry};
|
||||
if (!this.enabled) toAdd.maximumReportInterval = 0xffff;
|
||||
items.push(toAdd);
|
||||
delete items[items.length - 1].condition;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.enabled) {
|
||||
await ep.bind(cluster, this.zigbee.firstCoordinatorEndpoint());
|
||||
} else {
|
||||
await ep.unbind(cluster, this.zigbee.firstCoordinatorEndpoint());
|
||||
}
|
||||
|
||||
await ep.configureReporting(cluster, items);
|
||||
logger.info(`Successfully ${term2} reporting for '${device.ieeeAddr}' - ${ep.ID} - ${cluster}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.enabled) {
|
||||
device.zh.meta.reporting = reportKey;
|
||||
} else {
|
||||
delete device.zh.meta.reporting;
|
||||
this.eventBus.emitReconfigure({device});
|
||||
}
|
||||
|
||||
this.eventBus.emitDevicesChanged();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${term1.toLowerCase()} reporting for '${device.ieeeAddr}' - ${error.stack}`);
|
||||
|
||||
this.failed.add(device.ieeeAddr);
|
||||
}
|
||||
|
||||
device.zh.save();
|
||||
this.queue.delete(device.ieeeAddr);
|
||||
}
|
||||
|
||||
shouldSetupReporting(device: Device, messageType: string): boolean {
|
||||
if (!device || !device.zh || !device.definition) return false;
|
||||
|
||||
// Handle messages of type endDeviceAnnce and devIncoming.
|
||||
// This message is typically send when a device comes online after being powered off
|
||||
// Ikea TRADFRI tend to forget their reporting after powered off.
|
||||
// Re-setup reporting.
|
||||
// Only resetup reporting if configuredReportings was not populated yet,
|
||||
// else reconfigure is done in zigbee-herdsman-converters ikea.js/bulbOnEvent
|
||||
// configuredReportings are saved since Zigbee2MQTT 1.17.0
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/966
|
||||
if (
|
||||
this.enabled &&
|
||||
messageType === 'deviceAnnounce' &&
|
||||
device.isIkeaTradfri() &&
|
||||
device.zh.endpoints.filter((e) => e.configuredReportings.length === 0).length === device.zh.endpoints.length
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// These do not support reproting.
|
||||
// https://github.com/Koenkk/zigbee-herdsman/issues/110
|
||||
const philipsIgnoreSw = ['5.127.1.26581', '5.130.1.30000'];
|
||||
if (device.zh.manufacturerName === 'Philips' && philipsIgnoreSw.includes(device.zh.softwareBuildID)) return false;
|
||||
|
||||
if (device.zh.interviewing === true) return false;
|
||||
if (device.zh.type !== 'Router' || device.zh.powerSource === 'Battery') return false;
|
||||
// Gledopto devices don't support reporting.
|
||||
if (devicesNotSupportingReporting.includes(device.definition) || device.definition.vendor === 'Gledopto') return false;
|
||||
|
||||
if (this.enabled && device.zh.meta.hasOwnProperty('reporting') && device.zh.meta.reporting === reportKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.enabled && !device.zh.meta.hasOwnProperty('reporting')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
if (this.shouldSetupReporting(device, null)) {
|
||||
await this.setupReporting(device);
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => this.onZigbeeEvent_('deviceAnnounce', data.device));
|
||||
this.eventBus.onDeviceMessage(this, (data) => this.onZigbeeEvent_('dummy', data.device));
|
||||
this.eventBus.onDeviceJoined(this, (data) => this.onZigbeeEvent_('dummy', data.device));
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, (data) => this.onZigbeeEvent_('dummy', data.device));
|
||||
}
|
||||
|
||||
async onZigbeeEvent_(type: string, device: Device): Promise<void> {
|
||||
if (this.shouldSetupReporting(device, type)) {
|
||||
await this.setupReporting(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/* istanbul ignore file */
|
||||
import logger from '../../util/logger';
|
||||
// DEPRECATED
|
||||
import * as settings from '../../util/settings';
|
||||
import utils from '../../util/utils';
|
||||
import Extension from '../extension';
|
||||
|
||||
/**
|
||||
* This extensions soft resets the ZNP after a certain timeout.
|
||||
*/
|
||||
export default class SoftReset extends Extension {
|
||||
private timer: NodeJS.Timeout = null;
|
||||
private timeout = utils.seconds(settings.get().advanced.soft_reset_timeout);
|
||||
|
||||
override async start(): Promise<void> {
|
||||
logger.debug(`Soft reset timeout set to ${this.timeout / 1000} seconds`);
|
||||
this.resetTimer();
|
||||
this.eventBus.onDeviceMessage(this, () => this.resetTimer());
|
||||
this.eventBus.onDeviceAnnounce(this, () => this.resetTimer());
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, () => this.resetTimer());
|
||||
this.eventBus.onDeviceJoined(this, () => this.resetTimer());
|
||||
this.eventBus.onDeviceInterview(this, () => this.resetTimer());
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private resetTimer(): void {
|
||||
if (this.timeout === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearTimer();
|
||||
this.timer = setTimeout(() => this.handleTimeout(), this.timeout);
|
||||
}
|
||||
|
||||
private async handleTimeout(): Promise<void> {
|
||||
logger.warning('Soft reset timeout triggered');
|
||||
|
||||
try {
|
||||
await this.zigbee.reset('soft');
|
||||
logger.warning('Soft reset ZNP due to timeout');
|
||||
} catch (error) {
|
||||
logger.warning(`Soft reset failed, trying stop/start (${error.message})`);
|
||||
|
||||
await this.zigbee.stop();
|
||||
logger.warning('Zigbee stopped');
|
||||
|
||||
try {
|
||||
await this.zigbee.start();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to restart! (${error.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
this.resetTimer();
|
||||
}
|
||||
}
|
||||
+125
-148
@@ -1,99 +1,73 @@
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTNetworkMap} from "../types/api";
|
||||
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
interface Link {
|
||||
source: {ieeeAddr: string; networkAddress: number};
|
||||
target: {ieeeAddr: string; networkAddress: number};
|
||||
linkquality: number;
|
||||
depth: number;
|
||||
routes: zh.RoutingTableEntry[];
|
||||
sourceIeeeAddr: string;
|
||||
targetIeeeAddr: string;
|
||||
sourceNwkAddr: number;
|
||||
lqi: number;
|
||||
relationship: number;
|
||||
}
|
||||
|
||||
interface Topology {
|
||||
nodes: {
|
||||
ieeeAddr: string;
|
||||
friendlyName: string;
|
||||
type: string;
|
||||
networkAddress: number;
|
||||
manufacturerName: string;
|
||||
modelID: string;
|
||||
failed: string[];
|
||||
lastSeen: number;
|
||||
definition: {model: string; vendor: string; supports: string; description: string};
|
||||
}[];
|
||||
links: Link[];
|
||||
}
|
||||
const SUPPORTED_FORMATS = ["raw", "graphviz", "plantuml"];
|
||||
|
||||
/**
|
||||
* This extension creates a network map
|
||||
*/
|
||||
export default class NetworkMap extends Extension {
|
||||
private legacyApi = settings.get().advanced.legacy_api;
|
||||
private legacyTopic = `${settings.get().mqtt.base_topic}/bridge/networkmap`;
|
||||
private legacyTopicRoutes = `${settings.get().mqtt.base_topic}/bridge/networkmap/routes`;
|
||||
private topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
|
||||
private supportedFormats: {[s: string]: (topology: Topology) => KeyValue | string};
|
||||
#topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.supportedFormats = {
|
||||
raw: this.raw,
|
||||
graphviz: this.graphviz,
|
||||
plantuml: this.plantuml,
|
||||
};
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
/* istanbul ignore else */
|
||||
if (this.legacyApi) {
|
||||
if ((data.topic === this.legacyTopic || data.topic === this.legacyTopicRoutes) && this.supportedFormats.hasOwnProperty(data.message)) {
|
||||
const includeRoutes = data.topic === this.legacyTopicRoutes;
|
||||
const topology = await this.networkScan(includeRoutes);
|
||||
let converted = this.supportedFormats[data.message](topology);
|
||||
converted = data.message === 'raw' ? stringify(converted) : converted;
|
||||
await this.mqtt.publish(`bridge/networkmap/${data.message}`, converted as string, {});
|
||||
}
|
||||
}
|
||||
if (data.topic === this.#topic) {
|
||||
const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI["bridge/request/networkmap"];
|
||||
|
||||
if (data.topic === this.topic) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
try {
|
||||
const type = typeof message === 'object' ? message.type : message;
|
||||
if (!this.supportedFormats.hasOwnProperty(type)) {
|
||||
throw new Error(`Type '${type}' not supported, allowed are: ${Object.keys(this.supportedFormats)}`);
|
||||
const type = typeof message === "object" ? message.type : message;
|
||||
|
||||
if (!SUPPORTED_FORMATS.includes(type)) {
|
||||
throw new Error(`Type '${type}' not supported, allowed are: ${SUPPORTED_FORMATS.join(",")}`);
|
||||
}
|
||||
|
||||
const routes = typeof message === 'object' && message.routes;
|
||||
const routes = typeof message === "object" && message.routes;
|
||||
const topology = await this.networkScan(routes);
|
||||
const value = this.supportedFormats[type](topology);
|
||||
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {routes, type, value}, null)));
|
||||
let responseData: Zigbee2MQTTAPI["bridge/response/networkmap"];
|
||||
|
||||
switch (type) {
|
||||
case "raw": {
|
||||
responseData = {type, routes, value: this.raw(topology)};
|
||||
break;
|
||||
}
|
||||
case "graphviz": {
|
||||
responseData = {type, routes, value: this.graphviz(topology)};
|
||||
break;
|
||||
}
|
||||
case "plantuml": {
|
||||
responseData = {type, routes, value: this.plantuml(topology)};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await this.mqtt.publish("bridge/response/networkmap", stringify(utils.getResponse(message, responseData)));
|
||||
} catch (error) {
|
||||
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {}, error.message)));
|
||||
await this.mqtt.publish("bridge/response/networkmap", stringify(utils.getResponse(message, {}, (error as Error).message)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind raw(topology: Topology): KeyValue {
|
||||
raw(topology: Zigbee2MQTTNetworkMap): Zigbee2MQTTNetworkMap {
|
||||
return topology;
|
||||
}
|
||||
|
||||
@bind graphviz(topology: Topology): string {
|
||||
graphviz(topology: Zigbee2MQTTNetworkMap): string {
|
||||
const colors = settings.get().map_options.graphviz.colors;
|
||||
|
||||
let text = 'digraph G {\nnode[shape=record];\n';
|
||||
let style = '';
|
||||
let text = "digraph G {\nnode[shape=record];\n";
|
||||
let style = "";
|
||||
|
||||
topology.nodes.forEach((node) => {
|
||||
for (const node of topology.nodes) {
|
||||
const labels = [];
|
||||
|
||||
// Add friendly name
|
||||
@@ -101,118 +75,119 @@ export default class NetworkMap extends Extension {
|
||||
|
||||
// Add the device short network address, ieeaddr and scan note (if any)
|
||||
labels.push(
|
||||
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})` +
|
||||
(node.failed && node.failed.length ? `failed: ${node.failed.join(',')}` : ''),
|
||||
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})${node.failed?.length ? `failed: ${node.failed.join(",")}` : ""}`,
|
||||
);
|
||||
|
||||
// Add the device model
|
||||
if (node.type !== 'Coordinator') {
|
||||
if (node.type !== "Coordinator") {
|
||||
labels.push(`${node.definition?.vendor} ${node.definition?.description} (${node.definition?.model})`);
|
||||
}
|
||||
|
||||
// Add the device last_seen timestamp
|
||||
let lastSeen = 'unknown';
|
||||
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen;
|
||||
let lastSeen = "unknown";
|
||||
const date = node.type === "Coordinator" ? Date.now() : node.lastSeen;
|
||||
if (date) {
|
||||
lastSeen = utils.formatDate(date, 'relative') as string;
|
||||
lastSeen = utils.formatDate(date, "relative") as string;
|
||||
}
|
||||
|
||||
labels.push(lastSeen);
|
||||
|
||||
// Shape the record according to device type
|
||||
if (node.type == 'Coordinator') {
|
||||
if (node.type === "Coordinator") {
|
||||
style = `style="bold, filled", fillcolor="${colors.fill.coordinator}", fontcolor="${colors.font.coordinator}"`;
|
||||
} else if (node.type == 'Router') {
|
||||
} else if (node.type === "Router") {
|
||||
style = `style="rounded, filled", fillcolor="${colors.fill.router}", fontcolor="${colors.font.router}"`;
|
||||
} else {
|
||||
style = `style="rounded, dashed, filled", fillcolor="${colors.fill.enddevice}", fontcolor="${colors.font.enddevice}"`;
|
||||
}
|
||||
|
||||
// Add the device with its labels to the graph as a node.
|
||||
text += ` "${node.ieeeAddr}" [${style}, label="{${labels.join('|')}}"];\n`;
|
||||
text += ` "${node.ieeeAddr}" [${style}, label="{${labels.join("|")}}"];\n`;
|
||||
|
||||
/**
|
||||
* Add an edge between the device and its child to the graph
|
||||
* NOTE: There are situations where a device is NOT in the topology, this can be e.g.
|
||||
* due to not responded to the lqi scan. In that case we do not add an edge for this device.
|
||||
*/
|
||||
topology.links
|
||||
.filter((e) => e.source.ieeeAddr === node.ieeeAddr)
|
||||
.forEach((e) => {
|
||||
const lineStyle = node.type == 'EndDevice' ? 'penwidth=1, ' : !e.routes.length ? 'penwidth=0.5, ' : 'penwidth=2, ';
|
||||
const lineWeight = !e.routes.length ? `weight=0, color="${colors.line.inactive}", ` : `weight=1, color="${colors.line.active}", `;
|
||||
const textRoutes = e.routes.map((r) => utils.toNetworkAddressHex(r.destinationAddress));
|
||||
const lineLabels = !e.routes.length ? `label="${e.linkquality}"` : `label="${e.linkquality} (routes: ${textRoutes.join(',')})"`;
|
||||
text += ` "${node.ieeeAddr}" -> "${e.target.ieeeAddr}"`;
|
||||
for (const link of topology.links) {
|
||||
if (link.source.ieeeAddr === node.ieeeAddr) {
|
||||
const lineStyle = node.type === "EndDevice" ? "penwidth=1, " : !link.routes.length ? "penwidth=0.5, " : "penwidth=2, ";
|
||||
const lineWeight = !link.routes.length
|
||||
? `weight=0, color="${colors.line.inactive}", `
|
||||
: `weight=1, color="${colors.line.active}", `;
|
||||
const textRoutes = link.routes.map((r) => utils.toNetworkAddressHex(r.destinationAddress));
|
||||
const lineLabels = !link.routes.length
|
||||
? `label="${link.linkquality}"`
|
||||
: `label="${link.linkquality} (routes: ${textRoutes.join(",")})"`;
|
||||
text += ` "${node.ieeeAddr}" -> "${link.target.ieeeAddr}"`;
|
||||
text += ` [${lineStyle}${lineWeight}${lineLabels}]\n`;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
text += '}';
|
||||
text += "}";
|
||||
|
||||
return text.replace(/\0/g, '');
|
||||
return text.replace(/\0/g, "");
|
||||
}
|
||||
|
||||
@bind plantuml(topology: Topology): string {
|
||||
plantuml(topology: Zigbee2MQTTNetworkMap): string {
|
||||
const text = [];
|
||||
|
||||
text.push(`' paste into: https://www.planttext.com/`);
|
||||
text.push(``);
|
||||
text.push('@startuml');
|
||||
text.push("");
|
||||
text.push("@startuml");
|
||||
|
||||
topology.nodes
|
||||
.sort((a, b) => a.friendlyName.localeCompare(b.friendlyName))
|
||||
.forEach((node) => {
|
||||
// Add friendly name
|
||||
text.push(`card ${node.ieeeAddr} [`);
|
||||
text.push(`${node.friendlyName}`);
|
||||
text.push(`---`);
|
||||
for (const node of topology.nodes.sort((a, b) => a.friendlyName.localeCompare(b.friendlyName))) {
|
||||
// Add friendly name
|
||||
text.push(`card ${node.ieeeAddr} [`);
|
||||
text.push(`${node.friendlyName}`);
|
||||
text.push("---");
|
||||
|
||||
// Add the device short network address, ieeaddr and scan note (if any)
|
||||
text.push(
|
||||
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})` +
|
||||
(node.failed && node.failed.length ? ` failed: ${node.failed.join(',')}` : ''),
|
||||
);
|
||||
// Add the device short network address, ieeaddr and scan note (if any)
|
||||
text.push(
|
||||
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})${node.failed?.length ? ` failed: ${node.failed.join(",")}` : ""}`,
|
||||
);
|
||||
|
||||
// Add the device model
|
||||
if (node.type !== 'Coordinator') {
|
||||
text.push(`---`);
|
||||
const definition = (this.zigbee.resolveEntity(node.ieeeAddr) as Device).definition;
|
||||
text.push(`${definition?.vendor} ${definition?.description} (${definition?.model})`);
|
||||
}
|
||||
// Add the device model
|
||||
if (node.type !== "Coordinator") {
|
||||
text.push("---");
|
||||
const definition = (this.zigbee.resolveEntity(node.ieeeAddr) as Device).definition;
|
||||
text.push(`${definition?.vendor} ${definition?.description} (${definition?.model})`);
|
||||
}
|
||||
|
||||
// Add the device last_seen timestamp
|
||||
let lastSeen = 'unknown';
|
||||
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen;
|
||||
if (date) {
|
||||
lastSeen = utils.formatDate(date, 'relative') as string;
|
||||
}
|
||||
text.push(`---`);
|
||||
text.push(lastSeen);
|
||||
text.push(`]`);
|
||||
text.push(``);
|
||||
});
|
||||
// Add the device last_seen timestamp
|
||||
let lastSeen = "unknown";
|
||||
const date = node.type === "Coordinator" ? Date.now() : node.lastSeen;
|
||||
if (date) {
|
||||
lastSeen = utils.formatDate(date, "relative") as string;
|
||||
}
|
||||
text.push("---");
|
||||
text.push(lastSeen);
|
||||
text.push("]");
|
||||
text.push("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Add edges between the devices
|
||||
* NOTE: There are situations where a device is NOT in the topology, this can be e.g.
|
||||
* due to not responded to the lqi scan. In that case we do not add an edge for this device.
|
||||
*/
|
||||
topology.links.forEach((link) => {
|
||||
for (const link of topology.links) {
|
||||
text.push(`${link.sourceIeeeAddr} --> ${link.targetIeeeAddr}: ${link.lqi}`);
|
||||
});
|
||||
text.push('');
|
||||
}
|
||||
|
||||
text.push(`@enduml`);
|
||||
text.push("");
|
||||
|
||||
return text.join(`\n`);
|
||||
text.push("@enduml");
|
||||
|
||||
return text.join("\n");
|
||||
}
|
||||
|
||||
async networkScan(includeRoutes: boolean): Promise<Topology> {
|
||||
async networkScan(includeRoutes: boolean): Promise<Zigbee2MQTTNetworkMap> {
|
||||
logger.info(`Starting network scan (includeRoutes '${includeRoutes}')`);
|
||||
const lqis: Map<Device, zh.LQI> = new Map();
|
||||
const routingTables: Map<Device, zh.RoutingTable> = new Map();
|
||||
const failed: Map<Device, string[]> = new Map();
|
||||
const lqis = new Map<Device, zh.LQI>();
|
||||
const routingTables = new Map<Device, zh.RoutingTable>();
|
||||
const failed = new Map<Device, string[]>();
|
||||
const requestWithRetry = async <T>(request: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
const result = await request();
|
||||
@@ -221,47 +196,50 @@ export default class NetworkMap extends Extension {
|
||||
} catch {
|
||||
// Network is possibly congested, sleep 5 seconds to let the network settle.
|
||||
await utils.sleep(5);
|
||||
return request();
|
||||
return await request();
|
||||
}
|
||||
};
|
||||
|
||||
for (const device of this.zigbee.devicesIterator((d) => d.type !== 'GreenPower' && d.type !== 'EndDevice')) {
|
||||
for (const device of this.zigbee.devicesIterator((d) => d.type !== "GreenPower" && d.type !== "EndDevice")) {
|
||||
if (device.options.disabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
failed.set(device, []);
|
||||
const deviceFailures: string[] = [];
|
||||
failed.set(device, deviceFailures);
|
||||
await utils.sleep(1); // sleep 1 second between each scan to reduce stress on network.
|
||||
|
||||
try {
|
||||
const result = await requestWithRetry<zh.LQI>(async () => device.zh.lqi());
|
||||
const result = await requestWithRetry<zh.LQI>(async () => await device.zh.lqi());
|
||||
lqis.set(device, result);
|
||||
logger.debug(`LQI succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
failed.get(device).push('lqi');
|
||||
deviceFailures.push("lqi"); // set above
|
||||
logger.error(`Failed to execute LQI for '${device.name}'`);
|
||||
logger.debug(error.stack);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
|
||||
if (includeRoutes) {
|
||||
try {
|
||||
const result = await requestWithRetry<zh.RoutingTable>(async () => device.zh.routingTable());
|
||||
const result = await requestWithRetry<zh.RoutingTable>(async () => await device.zh.routingTable());
|
||||
routingTables.set(device, result);
|
||||
logger.debug(`Routing table succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
failed.get(device).push('routingTable');
|
||||
deviceFailures.push("routingTable"); // set above
|
||||
logger.error(`Failed to execute routing table for '${device.name}'`);
|
||||
logger.debug(error.stack);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Network scan finished`);
|
||||
logger.info("Network scan finished");
|
||||
|
||||
const topology: Topology = {nodes: [], links: []};
|
||||
const topology: Zigbee2MQTTNetworkMap = {nodes: [], links: []};
|
||||
|
||||
// XXX: display GP/disabled devices in the map, better feedback than just hiding them?
|
||||
for (const device of this.zigbee.devicesIterator((d) => d.type !== 'GreenPower')) {
|
||||
for (const device of this.zigbee.devicesIterator((d) => d.type !== "GreenPower")) {
|
||||
if (device.options.disabled) {
|
||||
continue;
|
||||
}
|
||||
@@ -275,12 +253,12 @@ export default class NetworkMap extends Extension {
|
||||
supports: Array.from(
|
||||
new Set(
|
||||
device.exposes().map((e) => {
|
||||
return e.name ?? `${e.type} (${e.features.map((f) => f.name).join(', ')})`;
|
||||
return e.name ?? `${e.type} (${e.features?.map((f) => f.name).join(", ")})`;
|
||||
}),
|
||||
),
|
||||
).join(', '),
|
||||
).join(", "),
|
||||
}
|
||||
: null;
|
||||
: undefined;
|
||||
|
||||
topology.nodes.push({
|
||||
ieeeAddr: device.ieeeAddr,
|
||||
@@ -305,16 +283,15 @@ export default class NetworkMap extends Extension {
|
||||
|
||||
// Some Xiaomi devices return 0x00 as the neighbor ieeeAddr (obviously not correct).
|
||||
// Determine the correct ieeeAddr based on the networkAddress.
|
||||
if (neighbor.ieeeAddr === '0x0000000000000000') {
|
||||
if (neighbor.ieeeAddr === "0x0000000000000000") {
|
||||
const neighborDevice = this.zigbee.deviceByNetworkAddress(neighbor.networkAddress);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (neighborDevice) {
|
||||
neighbor.ieeeAddr = neighborDevice.ieeeAddr;
|
||||
}
|
||||
}
|
||||
|
||||
const link: Link = {
|
||||
const link: Zigbee2MQTTNetworkMap["links"][number] = {
|
||||
source: {ieeeAddr: neighbor.ieeeAddr, networkAddress: neighbor.networkAddress},
|
||||
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.zh.networkAddress},
|
||||
linkquality: neighbor.linkquality,
|
||||
@@ -332,7 +309,7 @@ export default class NetworkMap extends Extension {
|
||||
|
||||
if (routingTable) {
|
||||
for (const entry of routingTable.table) {
|
||||
if (entry.status === 'ACTIVE' && entry.nextHop === neighbor.networkAddress) {
|
||||
if (entry.status === "ACTIVE" && entry.nextHop === neighbor.networkAddress) {
|
||||
link.routes.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
+62
-29
@@ -1,51 +1,84 @@
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import {onEvent, type OnEvent as ZhcOnEvent} from "zigbee-herdsman-converters";
|
||||
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
/**
|
||||
* This extension calls the zigbee-herdsman-converters onEvent.
|
||||
*/
|
||||
export default class OnEvent extends Extension {
|
||||
override async start(): Promise<void> {
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await this.callOnEvent(device, 'start', {});
|
||||
}
|
||||
readonly #startCalled: Set<string> = new Set();
|
||||
|
||||
this.eventBus.onDeviceMessage(this, (data) => this.callOnEvent(data.device, 'message', this.convertData(data)));
|
||||
this.eventBus.onDeviceJoined(this, (data) => this.callOnEvent(data.device, 'deviceJoined', this.convertData(data)));
|
||||
this.eventBus.onDeviceInterview(this, (data) => this.callOnEvent(data.device, 'deviceInterview', this.convertData(data)));
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => this.callOnEvent(data.device, 'deviceAnnounce', this.convertData(data)));
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, (data) =>
|
||||
this.callOnEvent(data.device, 'deviceNetworkAddressChanged', this.convertData(data)),
|
||||
);
|
||||
this.eventBus.onEntityOptionsChanged(this, async (data) => {
|
||||
if (data.entity.isDevice()) {
|
||||
await this.callOnEvent(data.entity, 'deviceOptionsChanged', data).then(() => this.eventBus.emitDevicesChanged());
|
||||
}
|
||||
});
|
||||
#getOnEventBaseData(device: Device): ZhcOnEvent.BaseData {
|
||||
const deviceExposesChanged = (): void => this.eventBus.emitExposesAndDevicesChanged(device);
|
||||
const state = this.state.get(device);
|
||||
const options = device.options as KeyValue;
|
||||
return {deviceExposesChanged, device: device.zh, state, options};
|
||||
}
|
||||
|
||||
private convertData(data: KeyValue): KeyValue {
|
||||
return {...data, device: data.device.zh};
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
override async start(): Promise<void> {
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
// don't await, in case of repeated failures this would hold startup
|
||||
this.callOnEvent(device, {type: "start", data: this.#getOnEventBaseData(device)}).catch(utils.noop);
|
||||
}
|
||||
|
||||
this.eventBus.onDeviceJoined(this, async (data) => {
|
||||
await this.callOnEvent(data.device, {type: "deviceJoined", data: this.#getOnEventBaseData(data.device)});
|
||||
});
|
||||
this.eventBus.onDeviceLeave(this, async (data) => {
|
||||
if (data.device) {
|
||||
await this.callOnEvent(data.device, {type: "stop", data: {ieeeAddr: data.device.ieeeAddr}});
|
||||
}
|
||||
});
|
||||
this.eventBus.onEntityRemoved(this, async (data) => {
|
||||
if (data.entity.isDevice()) {
|
||||
await this.callOnEvent(data.entity, {type: "stop", data: {ieeeAddr: data.entity.ieeeAddr}});
|
||||
}
|
||||
});
|
||||
this.eventBus.onDeviceInterview(this, async (data) => {
|
||||
await this.callOnEvent(data.device, {type: "deviceInterview", data: {...this.#getOnEventBaseData(data.device), status: data.status}});
|
||||
});
|
||||
this.eventBus.onDeviceAnnounce(this, async (data) => {
|
||||
await this.callOnEvent(data.device, {type: "deviceAnnounce", data: this.#getOnEventBaseData(data.device)});
|
||||
});
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, async (data) => {
|
||||
await this.callOnEvent(data.device, {type: "deviceNetworkAddressChanged", data: this.#getOnEventBaseData(data.device)});
|
||||
});
|
||||
this.eventBus.onEntityOptionsChanged(this, async (data) => {
|
||||
if (data.entity.isDevice()) {
|
||||
await this.callOnEvent(data.entity, {
|
||||
type: "deviceOptionsChanged",
|
||||
data: {...this.#getOnEventBaseData(data.entity), from: data.from, to: data.to},
|
||||
});
|
||||
this.eventBus.emitDevicesChanged();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
await super.stop();
|
||||
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await this.callOnEvent(device, 'stop', {});
|
||||
await this.callOnEvent(device, {type: "stop", data: {ieeeAddr: device.ieeeAddr}});
|
||||
}
|
||||
}
|
||||
|
||||
private async callOnEvent(device: Device, type: zhc.OnEventType, data: KeyValue): Promise<void> {
|
||||
if (device.options.disabled) return;
|
||||
const state = this.state.get(device);
|
||||
await zhc.onEvent(type, data, device.zh);
|
||||
private async callOnEvent(device: Device, event: ZhcOnEvent.Event): Promise<void> {
|
||||
if (device.options.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.definition?.onEvent) {
|
||||
const options: KeyValue = device.options;
|
||||
await device.definition.onEvent(type, data, device.zh, options, state);
|
||||
if (event.type !== "start" && event.type !== "stop" && !this.#startCalled.has(device.ieeeAddr) && device.definition?.onEvent) {
|
||||
this.#startCalled.add(device.ieeeAddr);
|
||||
await device.definition.onEvent({type: "start", data: this.#getOnEventBaseData(device)});
|
||||
}
|
||||
|
||||
await onEvent(event);
|
||||
await device.definition?.onEvent?.(event);
|
||||
|
||||
if (event.type === "stop") {
|
||||
this.#startCalled.delete(device.ieeeAddr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+317
-205
@@ -1,32 +1,21 @@
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import path from 'path';
|
||||
import * as URI from 'uri-js';
|
||||
import {Zcl} from 'zigbee-herdsman';
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import assert from "node:assert";
|
||||
import path from "node:path";
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {Zcl} from "zigbee-herdsman";
|
||||
import type {Ota} from "zigbee-herdsman-converters";
|
||||
import {ota} from "zigbee-herdsman-converters";
|
||||
import Device from "../model/device";
|
||||
import type {Zigbee2MQTTAPI} from "../types/api";
|
||||
import dataDir from "../util/data";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
import Device from '../model/device';
|
||||
import dataDir from '../util/data';
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
type UpdateState = "updating" | "idle" | "available" | "scheduled";
|
||||
|
||||
function isValidUrl(url: string): boolean {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = URI.parse(url);
|
||||
} catch {
|
||||
// istanbul ignore next
|
||||
return false;
|
||||
}
|
||||
return parsed.scheme === 'http' || parsed.scheme === 'https';
|
||||
}
|
||||
|
||||
type UpdateState = 'updating' | 'idle' | 'available';
|
||||
interface UpdatePayload {
|
||||
update_available?: boolean;
|
||||
// eslint-disable-next-line camelcase
|
||||
export interface UpdatePayload {
|
||||
update: {
|
||||
progress?: number;
|
||||
remaining?: number;
|
||||
@@ -36,289 +25,412 @@ interface UpdatePayload {
|
||||
};
|
||||
}
|
||||
|
||||
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/ota_update/.+$`);
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check)`, 'i');
|
||||
|
||||
export default class OTAUpdate extends Extension {
|
||||
private inProgress = new Set();
|
||||
private lastChecked: {[s: string]: number} = {};
|
||||
private legacyApi = settings.get().advanced.legacy_api;
|
||||
#topicRegex = new RegExp(
|
||||
`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check|schedule|unschedule)/?(downgrade)?`,
|
||||
"i",
|
||||
);
|
||||
private inProgress = new Set<string>();
|
||||
private lastChecked = new Map<string, number>();
|
||||
private scheduledUpgrades = new Set<string>();
|
||||
private scheduledDowngrades = new Set<string>();
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.eventBus.onDeviceMessage(this, this.onZigbeeEvent);
|
||||
if (settings.get().ota.ikea_ota_use_test_url) {
|
||||
zhc.ota.tradfri.useTestURL();
|
||||
}
|
||||
|
||||
// Let zigbeeOTA module know if the override index file is provided
|
||||
let overrideOTAIndex = settings.get().ota.zigbee_ota_override_index_location;
|
||||
if (overrideOTAIndex) {
|
||||
// If the file name is not a full path, then treat it as a relative to the data directory
|
||||
if (!isValidUrl(overrideOTAIndex) && !path.isAbsolute(overrideOTAIndex)) {
|
||||
overrideOTAIndex = dataDir.joinPath(overrideOTAIndex);
|
||||
}
|
||||
const otaSettings = settings.get().ota;
|
||||
// Let OTA module know if the override index file is provided
|
||||
let overrideIndexLocation = otaSettings.zigbee_ota_override_index_location;
|
||||
|
||||
zhc.ota.zigbeeOTA.useIndexOverride(overrideOTAIndex);
|
||||
// If the file name is not a full path, then treat it as a relative to the data directory
|
||||
if (overrideIndexLocation && !ota.isValidUrl(overrideIndexLocation) && !path.isAbsolute(overrideIndexLocation)) {
|
||||
overrideIndexLocation = dataDir.joinPath(overrideIndexLocation);
|
||||
}
|
||||
|
||||
// In order to support local firmware files we need to let zigbeeOTA know where the data directory is
|
||||
zhc.ota.setDataDir(dataDir.getPath());
|
||||
ota.setConfiguration({
|
||||
dataDir: dataDir.getPath(),
|
||||
overrideIndexLocation,
|
||||
// TODO: implement me
|
||||
imageBlockResponseDelay: otaSettings.image_block_response_delay,
|
||||
defaultMaximumDataSize: otaSettings.default_maximum_data_size,
|
||||
});
|
||||
|
||||
// In case Zigbee2MQTT is restared during an update, progress and remaining values are still in state, remove them.
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
|
||||
// Reset update state, e.g. when Z2M restarted during update.
|
||||
if (this.state.get(device).update?.state === 'updating') {
|
||||
this.state.get(device).update.state = 'available';
|
||||
if (this.state.get(device).update?.state === "updating") {
|
||||
this.state.get(device).update.state = "available";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeProgressAndRemainingFromState(device: Device): void {
|
||||
delete this.state.get(device).update?.progress;
|
||||
delete this.state.get(device).update?.remaining;
|
||||
const deviceState = this.state.get(device);
|
||||
|
||||
if (deviceState.update) {
|
||||
delete deviceState.update.progress;
|
||||
delete deviceState.update.remaining;
|
||||
}
|
||||
}
|
||||
|
||||
@bind private async onZigbeeEvent(data: eventdata.DeviceMessage): Promise<void> {
|
||||
if (data.type !== 'commandQueryNextImageRequest' || !data.device.definition || this.inProgress.has(data.device.ieeeAddr)) return;
|
||||
if (data.type !== "commandQueryNextImageRequest" || !data.device.definition || this.inProgress.has(data.device.ieeeAddr)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// `commandQueryNextImageRequest` check above should ensures this is valid but...
|
||||
assert(
|
||||
data.meta.zclTransactionSequenceNumber !== undefined,
|
||||
"Missing 'queryNextImageRequest' transaction sequence number (cannot match reply)",
|
||||
);
|
||||
|
||||
logger.debug(`Device '${data.device.name}' requested OTA`);
|
||||
|
||||
const automaticOTACheckDisabled = settings.get().ota.disable_automatic_update_check;
|
||||
let supportsOTA = !!data.device.definition.ota;
|
||||
if (supportsOTA && !automaticOTACheckDisabled) {
|
||||
// When a device does a next image request, it will usually do it a few times after each other
|
||||
// with only 10 - 60 seconds inbetween. It doesn't make sense to check for a new update
|
||||
// each time, so this interval can be set by the user. The default is 1,440 minutes (one day).
|
||||
const updateCheckInterval = settings.get().ota.update_check_interval * 1000 * 60;
|
||||
const check = this.lastChecked.hasOwnProperty(data.device.ieeeAddr)
|
||||
? Date.now() - this.lastChecked[data.device.ieeeAddr] > updateCheckInterval
|
||||
: true;
|
||||
if (!check) return;
|
||||
if (data.device.definition.ota) {
|
||||
if (this.scheduledUpgrades.has(data.device.ieeeAddr) || this.scheduledDowngrades.has(data.device.ieeeAddr)) {
|
||||
this.inProgress.add(data.device.ieeeAddr);
|
||||
|
||||
this.lastChecked[data.device.ieeeAddr] = Date.now();
|
||||
let availableResult: zhc.OtaUpdateAvailableResult = null;
|
||||
try {
|
||||
availableResult = await data.device.definition.ota.isUpdateAvailable(data.device.zh, data.data as zhc.ota.ImageInfo);
|
||||
} catch (e) {
|
||||
supportsOTA = false;
|
||||
logger.debug(`Failed to check if update available for '${data.device.name}' (${e.message})`);
|
||||
logger.info(`Updating '${data.device.name}' to latest firmware`);
|
||||
|
||||
try {
|
||||
const fileVersion = await ota.update(
|
||||
data.device.zh,
|
||||
data.device.otaExtraMetas,
|
||||
this.scheduledDowngrades.has(data.device.ieeeAddr),
|
||||
async (progress, remaining) => {
|
||||
let msg = `Update of '${data.device.name}' at ${progress.toFixed(2)}%`;
|
||||
|
||||
if (remaining) {
|
||||
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
|
||||
}
|
||||
|
||||
logger.info(msg);
|
||||
|
||||
await this.publishEntityState(
|
||||
data.device,
|
||||
this.getEntityPublishPayload(data.device, "updating", progress, remaining ?? undefined),
|
||||
);
|
||||
},
|
||||
data.data as Ota.ImageInfo,
|
||||
data.meta.zclTransactionSequenceNumber,
|
||||
);
|
||||
|
||||
// remove right away on update success or no image in case any of the below calls fail
|
||||
this.scheduledUpgrades.delete(data.device.ieeeAddr);
|
||||
this.scheduledDowngrades.delete(data.device.ieeeAddr);
|
||||
|
||||
if (fileVersion === undefined) {
|
||||
logger.info(`No image currently available for '${data.device.name}'. Unscheduling.`);
|
||||
|
||||
// XXX: superfluous?
|
||||
this.removeProgressAndRemainingFromState(data.device);
|
||||
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, "idle"));
|
||||
this.inProgress.delete(data.device.ieeeAddr);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Finished update of '${data.device.name}'`);
|
||||
|
||||
this.removeProgressAndRemainingFromState(data.device);
|
||||
await this.publishEntityState(
|
||||
data.device,
|
||||
this.getEntityPublishPayload(data.device, {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}),
|
||||
);
|
||||
|
||||
const firmwareTo = await this.readSoftwareBuildIDAndDateCode(data.device);
|
||||
|
||||
logger.info(() => `Device '${data.device.name}' was updated to '${stringify(firmwareTo)}'`);
|
||||
|
||||
/**
|
||||
* Re-configure after reading software build ID and date code, some devices use a
|
||||
* custom attribute for this (e.g. Develco SMSZB-120)
|
||||
*/
|
||||
this.eventBus.emitReconfigure({device: data.device});
|
||||
this.eventBus.emitDevicesChanged();
|
||||
} catch (e) {
|
||||
logger.debug(`Update of '${data.device.name}' failed (${e}). Retry scheduled for next request.`);
|
||||
|
||||
this.removeProgressAndRemainingFromState(data.device);
|
||||
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, "scheduled"));
|
||||
}
|
||||
|
||||
this.inProgress.delete(data.device.ieeeAddr);
|
||||
|
||||
return; // we're done
|
||||
}
|
||||
|
||||
const payload = this.getEntityPublishPayload(data.device, availableResult ?? 'idle');
|
||||
await this.publishEntityState(data.device, payload);
|
||||
if (!settings.get().ota.disable_automatic_update_check) {
|
||||
// When a device does a next image request, it will usually do it a few times after each other
|
||||
// with only 10 - 60 seconds inbetween. It doesn't make sense to check for a new update
|
||||
// each time, so this interval can be set by the user. The default is 1,440 minutes (one day).
|
||||
const updateCheckInterval = settings.get().ota.update_check_interval * 1000 * 60;
|
||||
const deviceLastChecked = this.lastChecked.get(data.device.ieeeAddr);
|
||||
const check = deviceLastChecked !== undefined ? Date.now() - deviceLastChecked > updateCheckInterval : true;
|
||||
|
||||
if (availableResult?.available) {
|
||||
const message = `Update available for '${data.device.name}'`;
|
||||
logger.info(message);
|
||||
if (!check) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: 'available', device: data.device.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message, meta}));
|
||||
this.inProgress.add(data.device.ieeeAddr);
|
||||
this.lastChecked.set(data.device.ieeeAddr, Date.now());
|
||||
let availableResult: Ota.UpdateAvailableResult | undefined;
|
||||
|
||||
try {
|
||||
// never use 'previous' when responding to device request
|
||||
availableResult = await ota.isUpdateAvailable(data.device.zh, data.device.otaExtraMetas, data.data as Ota.ImageInfo, false);
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to check if update available for '${data.device.name}' (${error})`);
|
||||
}
|
||||
|
||||
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, availableResult ?? "idle"));
|
||||
|
||||
if (availableResult?.available) {
|
||||
const message = `Update available for '${data.device.name}'`;
|
||||
logger.info(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Respond to stop the client from requesting OTAs
|
||||
const endpoint = data.device.zh.endpoints.find((e) => e.supportsOutputCluster('genOta')) || data.endpoint;
|
||||
const endpoint = data.device.zh.endpoints.find((e) => e.supportsOutputCluster("genOta")) || data.endpoint;
|
||||
await endpoint.commandResponse(
|
||||
'genOta',
|
||||
'queryNextImageResponse',
|
||||
"genOta",
|
||||
"queryNextImageResponse",
|
||||
{status: Zcl.Status.NO_IMAGE_AVAILABLE},
|
||||
undefined,
|
||||
data.meta.zclTransactionSequenceNumber,
|
||||
);
|
||||
logger.debug(`Responded to OTA request of '${data.device.name}' with 'NO_IMAGE_AVAILABLE'`);
|
||||
this.inProgress.delete(data.device.ieeeAddr);
|
||||
}
|
||||
|
||||
private async readSoftwareBuildIDAndDateCode(device: Device, sendPolicy?: 'immediate'): Promise<{softwareBuildID: string; dateCode: string}> {
|
||||
private async readSoftwareBuildIDAndDateCode(
|
||||
device: Device,
|
||||
sendPolicy?: "immediate",
|
||||
): Promise<{softwareBuildID: string; dateCode: string} | undefined> {
|
||||
try {
|
||||
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster('genBasic'));
|
||||
const result = await endpoint.read('genBasic', ['dateCode', 'swBuildId'], {sendPolicy});
|
||||
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster("genBasic"));
|
||||
assert(endpoint);
|
||||
const result = await endpoint.read("genBasic", ["dateCode", "swBuildId"], {sendPolicy});
|
||||
return {softwareBuildID: result.swBuildId, dateCode: result.dateCode};
|
||||
} catch {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getEntityPublishPayload(
|
||||
device: Device,
|
||||
state: zhc.OtaUpdateAvailableResult | UpdateState,
|
||||
progress: number = null,
|
||||
remaining: number = null,
|
||||
state: Ota.UpdateAvailableResult | UpdateState,
|
||||
progress?: number,
|
||||
remaining?: number,
|
||||
): UpdatePayload {
|
||||
const deviceUpdateState = this.state.get(device).update;
|
||||
const payload: UpdatePayload = {
|
||||
update: {
|
||||
state: typeof state === 'string' ? state : state.available ? 'available' : 'idle',
|
||||
installed_version: typeof state === 'string' ? deviceUpdateState?.installed_version : state.currentFileVersion,
|
||||
latest_version: typeof state === 'string' ? deviceUpdateState?.latest_version : state.otaFileVersion,
|
||||
state: typeof state === "string" ? state : state.available ? "available" : "idle",
|
||||
installed_version: typeof state === "string" ? deviceUpdateState?.installed_version : state.currentFileVersion,
|
||||
latest_version: typeof state === "string" ? deviceUpdateState?.latest_version : state.otaFileVersion,
|
||||
},
|
||||
};
|
||||
if (progress !== null) payload.update.progress = progress;
|
||||
if (remaining !== null) payload.update.remaining = Math.round(remaining);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (this.legacyApi) {
|
||||
payload.update_available = typeof state === 'string' ? state === 'available' : state.available;
|
||||
if (progress !== undefined) {
|
||||
payload.update.progress = progress;
|
||||
}
|
||||
|
||||
if (remaining !== undefined) {
|
||||
payload.update.remaining = Math.round(remaining);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
if ((!this.legacyApi || !data.topic.match(legacyTopicRegex)) && !data.topic.match(topicRegex)) {
|
||||
return null;
|
||||
const topicMatch = data.topic.match(this.#topicRegex);
|
||||
|
||||
if (!topicMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
const ID = (typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message) as string;
|
||||
const message = utils.parseJSON(data.message, data.message) as
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/check"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/check/downgrade"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/update"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/update/downgrade"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule/downgrade"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/unschedule"];
|
||||
const ID = (typeof message === "object" && message.id !== undefined ? message.id : message) as string;
|
||||
const device = this.zigbee.resolveEntity(ID);
|
||||
const type = data.topic.substring(data.topic.lastIndexOf('/') + 1);
|
||||
const responseData: {id: string; updateAvailable?: boolean; from?: string; to?: string} = {id: ID};
|
||||
let error = null;
|
||||
let errorStack = null;
|
||||
const type = topicMatch[1] as "check" | "update" | "schedule" | "unschedule";
|
||||
const downgrade = topicMatch[2] === "downgrade";
|
||||
let error: string | undefined;
|
||||
let errorStack: string | undefined;
|
||||
|
||||
if (!(device instanceof Device)) {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `not_supported`, device: device.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta}));
|
||||
}
|
||||
} else if (this.inProgress.has(device.ieeeAddr)) {
|
||||
// also guards against scheduling while check/update op in progress that could result in undesired OTA state
|
||||
error = `Update or check for update already in progress for '${device.name}'`;
|
||||
} else {
|
||||
this.inProgress.add(device.ieeeAddr);
|
||||
switch (type) {
|
||||
case "check": {
|
||||
this.inProgress.add(device.ieeeAddr);
|
||||
|
||||
if (type === 'check') {
|
||||
const msg = `Checking if update available for '${device.name}'`;
|
||||
logger.info(msg);
|
||||
logger.info(`Checking if update available for '${device.name}'`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `checking_if_available`, device: device.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
|
||||
}
|
||||
try {
|
||||
const availableResult = await ota.isUpdateAvailable(device.zh, device.otaExtraMetas, undefined, downgrade);
|
||||
|
||||
try {
|
||||
const availableResult = await device.definition.ota.isUpdateAvailable(device.zh, null);
|
||||
const msg = `${availableResult.available ? 'Update' : 'No update'} available for '${device.name}'`;
|
||||
logger.info(msg);
|
||||
logger.info(`${availableResult.available ? "Update" : "No update"} available for '${device.name}'`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {
|
||||
status: availableResult.available ? 'available' : 'not_available',
|
||||
device: device.name,
|
||||
};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, availableResult));
|
||||
this.lastChecked.set(device.ieeeAddr, Date.now());
|
||||
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/check">(message, {
|
||||
id: ID,
|
||||
update_available: availableResult.available,
|
||||
});
|
||||
|
||||
await this.mqtt.publish("bridge/response/device/ota_update/check", stringify(response));
|
||||
} catch (e) {
|
||||
error = `Failed to check if update available for '${device.name}' (${(e as Error).message})`;
|
||||
errorStack = (e as Error).stack;
|
||||
}
|
||||
|
||||
const payload = this.getEntityPublishPayload(device, availableResult);
|
||||
await this.publishEntityState(device, payload);
|
||||
this.lastChecked[device.ieeeAddr] = Date.now();
|
||||
responseData.updateAvailable = availableResult.available;
|
||||
} catch (e) {
|
||||
error = `Failed to check if update available for '${device.name}' (${e.message})`;
|
||||
errorStack = e.stack;
|
||||
break;
|
||||
}
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `check_failed`, device: device.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta}));
|
||||
case "update": {
|
||||
this.inProgress.add(device.ieeeAddr);
|
||||
|
||||
if (this.scheduledUpgrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' upgrade was cancelled by manual update`);
|
||||
} else if (this.scheduledDowngrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' downgrade was cancelled by manual update`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// type === 'update'
|
||||
const msg = `Updating '${device.name}' to latest firmware`;
|
||||
logger.info(msg);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_in_progress`, device: device.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
|
||||
}
|
||||
logger.info(`Updating '${device.name}' to ${downgrade ? "previous" : "latest"} firmware`);
|
||||
|
||||
try {
|
||||
const onProgress = async (progress: number, remaining: number): Promise<void> => {
|
||||
let msg = `Update of '${device.name}' at ${progress.toFixed(2)}%`;
|
||||
if (remaining) {
|
||||
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
|
||||
try {
|
||||
const firmwareFrom = await this.readSoftwareBuildIDAndDateCode(device, "immediate");
|
||||
const fileVersion = await ota.update(device.zh, device.otaExtraMetas, downgrade, async (progress, remaining) => {
|
||||
let msg = `Update of '${device.name}' at ${progress.toFixed(2)}%`;
|
||||
|
||||
if (remaining) {
|
||||
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
|
||||
}
|
||||
|
||||
logger.info(msg);
|
||||
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, "updating", progress, remaining ?? undefined));
|
||||
});
|
||||
|
||||
if (fileVersion === undefined) {
|
||||
throw new Error("No image currently available");
|
||||
}
|
||||
|
||||
logger.info(msg);
|
||||
logger.info(`Finished update of '${device.name}'`);
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(
|
||||
device,
|
||||
this.getEntityPublishPayload(device, {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}),
|
||||
);
|
||||
|
||||
const payload = this.getEntityPublishPayload(device, 'updating', progress, remaining);
|
||||
await this.publishEntityState(device, payload);
|
||||
const firmwareTo = await this.readSoftwareBuildIDAndDateCode(device);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_progress`, device: device.name, progress};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
|
||||
logger.info(() => `Device '${device.name}' was updated from '${stringify(firmwareFrom)}' to '${stringify(firmwareTo)}'`);
|
||||
|
||||
/**
|
||||
* Re-configure after reading software build ID and date code, some devices use a
|
||||
* custom attribute for this (e.g. Develco SMSZB-120)
|
||||
*/
|
||||
this.eventBus.emitReconfigure({device});
|
||||
this.eventBus.emitDevicesChanged();
|
||||
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/update">(message, {
|
||||
id: ID,
|
||||
from: firmwareFrom ? {software_build_id: firmwareFrom.softwareBuildID, date_code: firmwareFrom.dateCode} : undefined,
|
||||
to: firmwareTo ? {software_build_id: firmwareTo.softwareBuildID, date_code: firmwareTo.dateCode} : undefined,
|
||||
});
|
||||
|
||||
await this.mqtt.publish("bridge/response/device/ota_update/update", stringify(response));
|
||||
} catch (e) {
|
||||
logger.debug(`Update of '${device.name}' failed (${e})`);
|
||||
error = `Update of '${device.name}' failed (${(e as Error).message})`;
|
||||
errorStack = (e as Error).stack;
|
||||
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, "available"));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "schedule": {
|
||||
// ensure only one type scheduled by deleting from the other if necessary
|
||||
if (downgrade) {
|
||||
if (this.scheduledUpgrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' upgrade was cancelled in favor of new downgrade request`);
|
||||
}
|
||||
};
|
||||
|
||||
const from_ = await this.readSoftwareBuildIDAndDateCode(device, 'immediate');
|
||||
const fileVersion = await device.definition.ota.updateToLatest(device.zh, onProgress);
|
||||
logger.info(`Finished update of '${device.name}'`);
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
const payload = this.getEntityPublishPayload(device, {
|
||||
available: false,
|
||||
currentFileVersion: fileVersion,
|
||||
otaFileVersion: fileVersion,
|
||||
this.scheduledDowngrades.add(device.ieeeAddr);
|
||||
} else {
|
||||
if (this.scheduledDowngrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' downgrade was cancelled in favor of new upgrade request`);
|
||||
}
|
||||
|
||||
this.scheduledUpgrades.add(device.ieeeAddr);
|
||||
}
|
||||
|
||||
logger.info(`Scheduled '${device.name}' to ${downgrade ? "downgrade" : "upgrade"} firmware on next request from device`);
|
||||
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, "scheduled", undefined, undefined));
|
||||
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/schedule">(message, {
|
||||
id: ID,
|
||||
});
|
||||
await this.publishEntityState(device, payload);
|
||||
const to = await this.readSoftwareBuildIDAndDateCode(device);
|
||||
const [fromS, toS] = [stringify(from_), stringify(to)];
|
||||
logger.info(`Device '${device.name}' was updated from '${fromS}' to '${toS}'`);
|
||||
responseData.from = from_ ? utils.toSnakeCase(from_) : null;
|
||||
responseData.to = to ? utils.toSnakeCase(to) : null;
|
||||
/**
|
||||
* Re-configure after reading software build ID and date code, some devices use a
|
||||
* custom attribute for this (e.g. Develco SMSZB-120)
|
||||
*/
|
||||
this.eventBus.emitReconfigure({device});
|
||||
this.eventBus.emitDevicesChanged();
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_succeeded`, device: device.name, from: from_, to};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message, meta}));
|
||||
await this.mqtt.publish("bridge/response/device/ota_update/schedule", stringify(response));
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "unschedule": {
|
||||
if (this.scheduledUpgrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' upgrade was cancelled`);
|
||||
} else if (this.scheduledDowngrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' downgrade was cancelled`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug(`Update of '${device.name}' failed (${e})`);
|
||||
error = `Update of '${device.name}' failed (${e.message})`;
|
||||
errorStack = e.stack;
|
||||
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
const payload = this.getEntityPublishPayload(device, 'available');
|
||||
await this.publishEntityState(device, payload);
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, "idle", undefined, undefined));
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_failed`, device: device.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta}));
|
||||
}
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/unschedule">(message, {
|
||||
id: ID,
|
||||
});
|
||||
|
||||
await this.mqtt.publish("bridge/response/device/ota_update/unschedule", stringify(response));
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.inProgress.delete(device.ieeeAddr);
|
||||
}
|
||||
|
||||
const triggeredViaLegacyApi = data.topic.match(legacyTopicRegex);
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
await this.mqtt.publish(`bridge/response/device/ota_update/${type}`, stringify(response));
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const response = utils.getResponse(message, {}, error);
|
||||
|
||||
await this.mqtt.publish(`bridge/response/device/ota_update/${type}`, stringify(response));
|
||||
logger.error(error);
|
||||
|
||||
if (errorStack) {
|
||||
logger.debug(errorStack);
|
||||
}
|
||||
|
||||
+107
-128
@@ -1,57 +1,41 @@
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import * as philips from 'zigbee-herdsman-converters/lib/philips';
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import Device from "../model/device";
|
||||
import Group from "../model/group";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
// TODO: get rid of this, use class member
|
||||
let topicGetSetRegex: RegExp;
|
||||
// Used by `publish.test.js` to reload regex when changing `mqtt.base_topic`.
|
||||
// Used by `publish.test.ts` to reload regex when changing `mqtt.base_topic`.
|
||||
export const loadTopicGetSetRegex = (): void => {
|
||||
topicGetSetRegex = new RegExp(`^${settings.get().mqtt.base_topic}/(?!bridge)(.+?)/(get|set)(?:/(.+))?$`);
|
||||
};
|
||||
loadTopicGetSetRegex();
|
||||
|
||||
const stateValues = ['on', 'off', 'toggle', 'open', 'close', 'stop', 'lock', 'unlock'];
|
||||
const sceneConverterKeys = ['scene_store', 'scene_add', 'scene_remove', 'scene_remove_all', 'scene_rename'];
|
||||
|
||||
// Legacy: don't provide default converters anymore, this is required by older z2m installs not saving group members
|
||||
const defaultGroupConverters = [
|
||||
zhc.toZigbee.light_onoff_brightness,
|
||||
zhc.toZigbee.light_color_colortemp,
|
||||
philips.tz.effect, // Support Hue effects for groups
|
||||
zhc.toZigbee.ignore_transition,
|
||||
zhc.toZigbee.cover_position_tilt,
|
||||
zhc.toZigbee.thermostat_occupied_heating_setpoint,
|
||||
zhc.toZigbee.tint_scene,
|
||||
zhc.toZigbee.light_brightness_move,
|
||||
zhc.toZigbee.light_brightness_step,
|
||||
zhc.toZigbee.light_colortemp_step,
|
||||
zhc.toZigbee.light_colortemp_move,
|
||||
zhc.toZigbee.light_hue_saturation_move,
|
||||
zhc.toZigbee.light_hue_saturation_step,
|
||||
];
|
||||
const STATE_VALUES: ReadonlyArray<string> = ["on", "off", "toggle", "open", "close", "stop", "lock", "unlock"];
|
||||
const SCENE_CONVERTER_KEYS: ReadonlyArray<string> = ["scene_store", "scene_add", "scene_remove", "scene_remove_all", "scene_rename"];
|
||||
|
||||
interface ParsedTopic {
|
||||
ID: string;
|
||||
endpoint: string;
|
||||
endpoint: string | undefined;
|
||||
attribute: string;
|
||||
type: 'get' | 'set';
|
||||
type: "get" | "set";
|
||||
}
|
||||
|
||||
export default class Publish extends Extension {
|
||||
async start(): Promise<void> {
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
override async start(): Promise<void> {
|
||||
loadTopicGetSetRegex();
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
parseTopic(topic: string): ParsedTopic | null {
|
||||
parseTopic(topic: string): ParsedTopic | undefined {
|
||||
// The function supports the following topic formats (below are for 'set'. 'get' will look the same):
|
||||
// - <base_topic>/device_name/set (endpoint and attribute is defined in the payload)
|
||||
// - <base_topic>/device_name/set (auto-matches endpoint and attribute is defined in the payload)
|
||||
// - <base_topic>/device_name/set/attribute (default endpoint used)
|
||||
// - <base_topic>/device_name/endpoint/set (attribute is defined in the payload)
|
||||
// - <base_topic>/device_name/endpoint/set/attribute (payload is the value)
|
||||
@@ -60,17 +44,20 @@ export default class Publish extends Extension {
|
||||
// Before the get/set is the device name and optional endpoint name.
|
||||
// After it there will be an optional attribute name.
|
||||
const match = topic.match(topicGetSetRegex);
|
||||
if (!match) return null;
|
||||
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const deviceNameAndEndpoint = match[1];
|
||||
const attribute = match[3];
|
||||
|
||||
// Now parse the device/group name, and endpoint name
|
||||
const entity = this.zigbee.resolveEntityAndEndpoint(deviceNameAndEndpoint);
|
||||
return {ID: entity.ID, endpoint: entity.endpointID, type: match[2] as 'get' | 'set', attribute: attribute};
|
||||
return {ID: entity.ID, endpoint: entity.endpointID, type: match[2] as "get" | "set", attribute: attribute};
|
||||
}
|
||||
|
||||
parseMessage(parsedTopic: ParsedTopic, data: eventdata.MQTTMessage): KeyValue | null {
|
||||
parseMessage(parsedTopic: ParsedTopic, data: eventdata.MQTTMessage): KeyValue | undefined {
|
||||
if (parsedTopic.attribute) {
|
||||
try {
|
||||
return {[parsedTopic.attribute]: JSON.parse(data.message)};
|
||||
@@ -81,105 +68,78 @@ export default class Publish extends Extension {
|
||||
try {
|
||||
return JSON.parse(data.message);
|
||||
} catch {
|
||||
if (stateValues.includes(data.message.toLowerCase())) {
|
||||
return {state: data.message};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return STATE_VALUES.includes(data.message.toLowerCase()) ? {state: data.message} : undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async legacyLog(payload: KeyValue): Promise<void> {
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
await this.mqtt.publish('bridge/log', stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
legacyRetrieveState(
|
||||
re: Device | Group,
|
||||
converter: zhc.Tz.Converter,
|
||||
result: zhc.Tz.ConvertSetResult,
|
||||
target: zh.Endpoint | zh.Group,
|
||||
key: string,
|
||||
meta: zhc.Tz.Meta,
|
||||
): void {
|
||||
// It's possible for devices to get out of sync when writing an attribute that's not reportable.
|
||||
// So here we re-read the value after a specified timeout, this timeout could for example be the
|
||||
// transition time of a color change or for forcing a state read for devices that don't
|
||||
// automatically report a new state when set.
|
||||
// When reporting is requested for a device (report: true in device-specific settings) we won't
|
||||
// ever issue a read here, as we assume the device will properly report changes.
|
||||
// Only do this when the retrieve_state option is enabled for this device.
|
||||
// retrieve_state == deprecated
|
||||
if (re instanceof Device && result && result.hasOwnProperty('readAfterWriteTime') && re.options.retrieve_state) {
|
||||
setTimeout(() => converter.convertGet(target, key, meta), result.readAfterWriteTime);
|
||||
}
|
||||
}
|
||||
|
||||
updateMessageHomeAssistant(message: KeyValue, entityState: KeyValue): void {
|
||||
/**
|
||||
* Home Assistant always publishes 'state', even when e.g. only setting
|
||||
* the color temperature. This would lead to 2 zigbee publishes, where the first one
|
||||
* (state) is probably unnecessary.
|
||||
*/
|
||||
if (settings.get().homeassistant) {
|
||||
const hasColorTemp = message.hasOwnProperty('color_temp');
|
||||
const hasColor = message.hasOwnProperty('color');
|
||||
const hasBrightness = message.hasOwnProperty('brightness');
|
||||
const isOn = entityState.state === 'ON' ? true : false;
|
||||
if (isOn && (hasColorTemp || hasColor) && !hasBrightness) {
|
||||
if (settings.get().homeassistant.enabled) {
|
||||
const hasColorTemp = message.color_temp !== undefined;
|
||||
const hasColor = message.color !== undefined;
|
||||
const hasBrightness = message.brightness !== undefined;
|
||||
if (entityState.state === "ON" && (hasColorTemp || hasColor) && !hasBrightness) {
|
||||
delete message.state;
|
||||
logger.debug('Skipping state because of Home Assistant');
|
||||
logger.debug("Skipping state because of Home Assistant");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const parsedTopic = this.parseTopic(data.topic);
|
||||
if (!parsedTopic) return;
|
||||
|
||||
if (!parsedTopic) {
|
||||
return;
|
||||
}
|
||||
|
||||
const re = this.zigbee.resolveEntity(parsedTopic.ID);
|
||||
if (re == null) {
|
||||
await this.legacyLog({type: `entity_not_found`, message: {friendly_name: parsedTopic.ID}});
|
||||
|
||||
if (!re) {
|
||||
logger.error(`Entity '${parsedTopic.ID}' is unknown`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get entity details
|
||||
const definition = re instanceof Device ? re.definition : re.membersDefinitions();
|
||||
let definition: zhc.Definition | zhc.Definition[];
|
||||
if (re instanceof Device) {
|
||||
if (!re.definition) {
|
||||
logger.error(`Cannot publish to unsupported device '${re.name}'`);
|
||||
return;
|
||||
}
|
||||
definition = re.definition;
|
||||
} else {
|
||||
definition = re.membersDefinitions();
|
||||
}
|
||||
const target = re instanceof Group ? re.zh : re.endpoint(parsedTopic.endpoint);
|
||||
if (target == null) {
|
||||
|
||||
if (!target) {
|
||||
logger.error(`Device '${re.name}' has no endpoint '${parsedTopic.endpoint}'`);
|
||||
return;
|
||||
}
|
||||
const device = re instanceof Device ? re.zh : null;
|
||||
const entitySettings = re.options;
|
||||
const entityState = this.state.get(re);
|
||||
const membersState =
|
||||
re instanceof Group
|
||||
? Object.fromEntries(
|
||||
re.zh.members.map((e) => [e.getDevice().ieeeAddr, this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr))]),
|
||||
)
|
||||
: null;
|
||||
let converters: zhc.Tz.Converter[];
|
||||
{
|
||||
if (Array.isArray(definition)) {
|
||||
const c = new Set(definition.map((d) => d.toZigbee).flat());
|
||||
if (c.size == 0) converters = defaultGroupConverters;
|
||||
else converters = Array.from(c);
|
||||
} else {
|
||||
converters = definition.toZigbee;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the MQTT message to a Zigbee message.
|
||||
const message = this.parseMessage(parsedTopic, data);
|
||||
if (message == null) {
|
||||
|
||||
if (!message) {
|
||||
logger.error(`Invalid message '${message}', skipping...`);
|
||||
return;
|
||||
}
|
||||
|
||||
const device = re instanceof Device ? re.zh : undefined;
|
||||
const entitySettings = re.options;
|
||||
const entityState = this.state.get(re);
|
||||
const membersState =
|
||||
re instanceof Group
|
||||
? // biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: might be a bit much assumed here?
|
||||
Object.fromEntries(re.zh.members.map((e) => [e.deviceIeeeAddress, this.state.get(this.zigbee.resolveEntity(e.deviceIeeeAddress)!)]))
|
||||
: undefined;
|
||||
const converters = this.getDefinitionConverters(definition);
|
||||
|
||||
this.updateMessageHomeAssistant(message, entityState);
|
||||
|
||||
/**
|
||||
@@ -192,8 +152,8 @@ export default class Publish extends Extension {
|
||||
* bulb off => move state & brightness to the front
|
||||
*/
|
||||
const entries = Object.entries(message);
|
||||
const sorter = typeof message.state === 'string' && message.state.toLowerCase() === 'off' ? 1 : -1;
|
||||
entries.sort((a) => (['state', 'brightness', 'brightness_percent'].includes(a[0]) ? sorter : sorter * -1));
|
||||
const sorter = typeof message.state === "string" && message.state.toLowerCase() === "off" ? 1 : -1;
|
||||
entries.sort((a) => (["state", "brightness", "brightness_percent"].includes(a[0]) ? sorter : sorter * -1));
|
||||
|
||||
// For each attribute call the corresponding converter
|
||||
const usedConverters: {[s: number]: zhc.Tz.Converter[]} = {};
|
||||
@@ -201,64 +161,72 @@ export default class Publish extends Extension {
|
||||
const toPublishEntity: {[s: number | string]: Device | Group} = {};
|
||||
const addToToPublish = (entity: Device | Group, payload: KeyValue): void => {
|
||||
const ID = entity.ID;
|
||||
|
||||
if (!(ID in toPublish)) {
|
||||
toPublish[ID] = {};
|
||||
toPublishEntity[ID] = entity;
|
||||
}
|
||||
|
||||
toPublish[ID] = {...toPublish[ID], ...payload};
|
||||
};
|
||||
|
||||
const endpointNames = re instanceof Device ? re.getEndpointNames() : [];
|
||||
const propertyEndpointRegex = new RegExp(`^(.*?)_(${endpointNames.join('|')})$`);
|
||||
const propertyEndpointRegex = new RegExp(`^(.*?)_(${endpointNames.join("|")})$`);
|
||||
let scenesChanged = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
let key = entry[0];
|
||||
const value = entry[1];
|
||||
let endpointName = parsedTopic.endpoint;
|
||||
let localTarget = target;
|
||||
let endpointOrGroupID = utils.isEndpoint(target) ? target.ID : target.groupID;
|
||||
let endpointOrGroupID = utils.isZHEndpoint(target) ? target.ID : target.groupID;
|
||||
|
||||
// When the key has a endpointName included (e.g. state_right), this will override the target.
|
||||
const propertyEndpointMatch = key.match(propertyEndpointRegex);
|
||||
|
||||
if (re instanceof Device && propertyEndpointMatch) {
|
||||
endpointName = propertyEndpointMatch[2];
|
||||
key = propertyEndpointMatch[1];
|
||||
localTarget = re.endpoint(endpointName);
|
||||
// biome-ignore lint/style/noNonNullAssertion: endpointName is always matched to an existing endpoint of the device since `propertyEndpointRegex` only contains valid endpoints for this device
|
||||
localTarget = re.endpoint(endpointName)!;
|
||||
endpointOrGroupID = localTarget.ID;
|
||||
}
|
||||
|
||||
if (!usedConverters.hasOwnProperty(endpointOrGroupID)) usedConverters[endpointOrGroupID] = [];
|
||||
/* istanbul ignore next */
|
||||
if (usedConverters[endpointOrGroupID] === undefined) usedConverters[endpointOrGroupID] = [];
|
||||
// Match any key if the toZigbee converter defines no key.
|
||||
const converter = converters.find((c) => (!c.key || c.key.includes(key)) && (!c.endpoint || c.endpoint == endpointName));
|
||||
const converter = converters.find(
|
||||
(c) =>
|
||||
(!c.key || c.key.includes(key)) && (re instanceof Group || !c.endpoints || (endpointName && c.endpoints.includes(endpointName))),
|
||||
);
|
||||
|
||||
if (parsedTopic.type === 'set' && usedConverters[endpointOrGroupID].includes(converter)) {
|
||||
if (parsedTopic.type === "set" && converter && usedConverters[endpointOrGroupID].includes(converter)) {
|
||||
// Use a converter for set only once
|
||||
// (e.g. light_onoff_brightness converters can convert state and brightness)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!converter) {
|
||||
logger.error(`No converter available for '${key}' (${stringify(message[key])})`);
|
||||
logger.error(`No converter available for '${key}' on '${re.name}': (${stringify(message[key])})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the endpoint_name name is a number, try to map it to a friendlyName
|
||||
if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isEndpoint(localTarget) && re.endpointName(localTarget)) {
|
||||
if (!Number.isNaN(Number(endpointName)) && re.isDevice() && utils.isZHEndpoint(localTarget) && re.endpointName(localTarget)) {
|
||||
endpointName = re.endpointName(localTarget);
|
||||
}
|
||||
|
||||
// Converter didn't return a result, skip
|
||||
const entitySettingsKeyValue: KeyValue = entitySettings;
|
||||
const meta = {
|
||||
const meta: zhc.Tz.Meta = {
|
||||
endpoint_name: endpointName,
|
||||
options: entitySettingsKeyValue,
|
||||
message: {...message},
|
||||
logger,
|
||||
device,
|
||||
state: entityState,
|
||||
membersState,
|
||||
mapped: definition,
|
||||
/* v8 ignore next */
|
||||
publish: (payload: KeyValue) => this.publishEntityState(re, payload),
|
||||
};
|
||||
|
||||
// Strip endpoint name from meta.message properties.
|
||||
@@ -273,11 +241,12 @@ export default class Publish extends Extension {
|
||||
}
|
||||
|
||||
try {
|
||||
if (parsedTopic.type === 'set' && converter.convertSet) {
|
||||
if (parsedTopic.type === "set" && converter.convertSet) {
|
||||
logger.debug(`Publishing '${parsedTopic.type}' '${key}' to '${re.name}'`);
|
||||
const result = await converter.convertSet(localTarget, key, value, meta);
|
||||
const optimistic = !entitySettings.hasOwnProperty('optimistic') || entitySettings.optimistic;
|
||||
if (result && result.state && optimistic) {
|
||||
const optimistic = entitySettings.optimistic === undefined || entitySettings.optimistic;
|
||||
|
||||
if (result?.state && optimistic) {
|
||||
const msg = result.state;
|
||||
|
||||
if (endpointName) {
|
||||
@@ -293,14 +262,13 @@ export default class Publish extends Extension {
|
||||
addToToPublish(re, msg);
|
||||
}
|
||||
|
||||
if (result && result.membersState && optimistic) {
|
||||
if (result?.membersState && optimistic) {
|
||||
for (const [ieeeAddr, state] of Object.entries(result.membersState)) {
|
||||
addToToPublish(this.zigbee.resolveEntity(ieeeAddr), state);
|
||||
// biome-ignore lint/style/noNonNullAssertion: might be a bit much assumed here?
|
||||
addToToPublish(this.zigbee.resolveEntity(ieeeAddr)!, state);
|
||||
}
|
||||
}
|
||||
|
||||
this.legacyRetrieveState(re, converter, result, localTarget, key, meta);
|
||||
} else if (parsedTopic.type === 'get' && converter.convertGet) {
|
||||
} else if (parsedTopic.type === "get" && converter.convertGet) {
|
||||
logger.debug(`Publishing get '${parsedTopic.type}' '${key}' to '${re.name}'`);
|
||||
await converter.convertGet(localTarget, key, meta);
|
||||
} else {
|
||||
@@ -310,11 +278,15 @@ export default class Publish extends Extension {
|
||||
} catch (error) {
|
||||
const message = `Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`;
|
||||
logger.error(message);
|
||||
logger.debug(error.stack);
|
||||
await this.legacyLog({type: `zigbee_publish_error`, message, meta: {friendly_name: re.name}});
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
|
||||
usedConverters[endpointOrGroupID].push(converter);
|
||||
|
||||
if (!scenesChanged && converter.key) {
|
||||
scenesChanged = converter.key.some((k) => SCENE_CONVERTER_KEYS.includes(k));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [ID, payload] of Object.entries(toPublish)) {
|
||||
@@ -323,9 +295,16 @@ export default class Publish extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
const scenesChanged = Object.values(usedConverters).some((cl) => cl.some((c) => c.key.some((k) => sceneConverterKeys.includes(k))));
|
||||
if (scenesChanged) {
|
||||
this.eventBus.emitScenesChanged({entity: re});
|
||||
}
|
||||
}
|
||||
|
||||
private getDefinitionConverters(definition: zhc.Definition | zhc.Definition[]): ReadonlyArray<zhc.Tz.Converter> {
|
||||
if (Array.isArray(definition)) {
|
||||
return definition.length ? Array.from(new Set(definition.flatMap((d) => d.toZigbee))) : [];
|
||||
}
|
||||
|
||||
return definition?.toZigbee;
|
||||
}
|
||||
}
|
||||
|
||||
+66
-48
@@ -1,25 +1,32 @@
|
||||
import bind from 'bind-decorator';
|
||||
import debounce from 'debounce';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import assert from "node:assert";
|
||||
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import bind from "bind-decorator";
|
||||
import debounce from "debounce";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import throttle from "throttleit";
|
||||
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
type DebounceFunction = (() => void) & {clear(): void} & {flush(): void};
|
||||
|
||||
export default class Receive extends Extension {
|
||||
// TODO: move all to `Map`
|
||||
private elapsed: {[s: string]: number} = {};
|
||||
private debouncers: {[s: string]: {payload: KeyValue; publish: DebounceFunction}} = {};
|
||||
private throttlers: {[s: string]: {publish: PublishEntityState}} = {};
|
||||
|
||||
async start(): Promise<void> {
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onPublishEntityState(this, this.onPublishEntityState);
|
||||
this.eventBus.onDeviceMessage(this, this.onDeviceMessage);
|
||||
}
|
||||
|
||||
@bind async onPublishEntityState(data: eventdata.PublishEntityState): Promise<void> {
|
||||
@bind onPublishEntityState(data: eventdata.PublishEntityState): void {
|
||||
/**
|
||||
* Prevent that outdated properties are being published.
|
||||
* In case that e.g. the state is currently held back by a debounce and a new state is published
|
||||
@@ -28,8 +35,8 @@ export default class Receive extends Extension {
|
||||
if (
|
||||
data.entity.isDevice() &&
|
||||
this.debouncers[data.entity.ieeeAddr] &&
|
||||
data.stateChangeReason !== 'publishDebounce' &&
|
||||
data.stateChangeReason !== 'lastSeenChanged'
|
||||
data.stateChangeReason !== "publishDebounce" &&
|
||||
data.stateChangeReason !== "lastSeenChanged"
|
||||
) {
|
||||
for (const key of Object.keys(data.payload)) {
|
||||
delete this.debouncers[data.entity.ieeeAddr].payload[key];
|
||||
@@ -37,12 +44,12 @@ export default class Receive extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
publishDebounce(device: Device, payload: KeyValue, time: number, debounceIgnore: string[]): void {
|
||||
publishDebounce(device: Device, payload: KeyValue, time: number, debounceIgnore: string[] | undefined): void {
|
||||
if (!this.debouncers[device.ieeeAddr]) {
|
||||
this.debouncers[device.ieeeAddr] = {
|
||||
payload: {},
|
||||
publish: debounce(async () => {
|
||||
await this.publishEntityState(device, this.debouncers[device.ieeeAddr].payload, 'publishDebounce');
|
||||
await this.publishEntityState(device, this.debouncers[device.ieeeAddr].payload, "publishDebounce");
|
||||
this.debouncers[device.ieeeAddr].payload = {};
|
||||
}, time * 1000),
|
||||
};
|
||||
@@ -65,38 +72,43 @@ export default class Receive extends Extension {
|
||||
this.debouncers[device.ieeeAddr].publish();
|
||||
}
|
||||
|
||||
async publishThrottle(device: Device, payload: KeyValue, time: number): Promise<void> {
|
||||
if (!this.throttlers[device.ieeeAddr]) {
|
||||
this.throttlers[device.ieeeAddr] = {
|
||||
publish: throttle(this.publishEntityState, time * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
// Update state cache right away. This makes sure that during throttling cached state is always up to date.
|
||||
// By updating cache we make sure that state cache is always up-to-date.
|
||||
this.state.set(device, payload);
|
||||
|
||||
await this.throttlers[device.ieeeAddr].publish(device, payload, "publishThrottle");
|
||||
}
|
||||
|
||||
// if debounce_ignore are specified (Array of strings)
|
||||
// then all newPayload values with key present in debounce_ignore
|
||||
// should equal or be undefined in oldPayload
|
||||
// otherwise payload is conflicted
|
||||
isPayloadConflicted(newPayload: KeyValue, oldPayload: KeyValue, debounceIgnore: string[] | null): boolean {
|
||||
isPayloadConflicted(newPayload: KeyValue, oldPayload: KeyValue, debounceIgnore: string[] | undefined): boolean {
|
||||
let result = false;
|
||||
Object.keys(oldPayload)
|
||||
.filter((key) => (debounceIgnore || []).includes(key))
|
||||
.forEach((key) => {
|
||||
if (typeof newPayload[key] !== 'undefined' && newPayload[key] !== oldPayload[key]) {
|
||||
result = true;
|
||||
}
|
||||
});
|
||||
|
||||
for (const key in oldPayload) {
|
||||
if (debounceIgnore?.includes(key) && typeof newPayload[key] !== "undefined" && newPayload[key] !== oldPayload[key]) {
|
||||
result = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
shouldProcess(data: eventdata.DeviceMessage): boolean {
|
||||
if (!data.device.definition || data.device.zh.interviewing) {
|
||||
logger.debug(`Skipping message, still interviewing`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@bind async onDeviceMessage(data: eventdata.DeviceMessage): Promise<void> {
|
||||
/* istanbul ignore next */
|
||||
/* v8 ignore next */
|
||||
if (!data.device) return;
|
||||
|
||||
if (!this.shouldProcess(data)) {
|
||||
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
|
||||
if (!data.device.definition || !data.device.interviewed) {
|
||||
logger.debug("Skipping message, still interviewing");
|
||||
await utils.publishLastSeen({device: data.device, reason: "messageEmitted"}, settings.get(), true, this.publishEntityState);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -106,13 +118,13 @@ export default class Receive extends Extension {
|
||||
});
|
||||
|
||||
// Check if there is an available converter, genOta messages are not interesting.
|
||||
const ignoreClusters: (string | number)[] = ['genOta', 'genTime', 'genBasic', 'genPollCtrl'];
|
||||
if (converters.length == 0 && !ignoreClusters.includes(data.cluster)) {
|
||||
const ignoreClusters: (string | number)[] = ["genOta", "genTime", "genBasic", "genPollCtrl"];
|
||||
if (converters.length === 0 && !ignoreClusters.includes(data.cluster)) {
|
||||
logger.debug(
|
||||
`No converter available for '${data.device.definition.model}' with ` +
|
||||
`cluster '${data.cluster}' and type '${data.type}' and data '${stringify(data.data)}'`,
|
||||
);
|
||||
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
|
||||
await utils.publishLastSeen({device: data.device, reason: "messageEmitted"}, settings.get(), true, this.publishEntityState);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -122,8 +134,9 @@ export default class Receive extends Extension {
|
||||
// - If NO payload is returned do nothing. This is for non-standard behaviour
|
||||
// for e.g. click switches where we need to count number of clicks and detect long presses.
|
||||
const publish = async (payload: KeyValue): Promise<void> => {
|
||||
assert(data.device.definition);
|
||||
const options: KeyValue = data.device.options;
|
||||
zhc.postProcessConvertedFromZigbeeMessage(data.device.definition, payload, options);
|
||||
zhc.postProcessConvertedFromZigbeeMessage(data.device.definition, payload, options, data.device.zh);
|
||||
|
||||
if (settings.get().advanced.elapsed) {
|
||||
const now = Date.now();
|
||||
@@ -134,20 +147,22 @@ export default class Receive extends Extension {
|
||||
this.elapsed[data.device.ieeeAddr] = now;
|
||||
}
|
||||
|
||||
// Check if we have to debounce
|
||||
// Check if we have to debounce or throttle
|
||||
if (data.device.options.debounce) {
|
||||
this.publishDebounce(data.device, payload, data.device.options.debounce, data.device.options.debounce_ignore);
|
||||
} else if (data.device.options.throttle) {
|
||||
await this.publishThrottle(data.device, payload, data.device.options.throttle);
|
||||
} else {
|
||||
await this.publishEntityState(data.device, payload);
|
||||
}
|
||||
};
|
||||
|
||||
const deviceExposesChanged = (): void => {
|
||||
this.eventBus.emitDevicesChanged();
|
||||
this.eventBus.emitExposesChanged({device: data.device});
|
||||
const meta = {
|
||||
device: data.device.zh,
|
||||
logger,
|
||||
state: this.state.get(data.device),
|
||||
deviceExposesChanged: (): void => this.eventBus.emitExposesAndDevicesChanged(data.device),
|
||||
};
|
||||
|
||||
const meta = {device: data.device.zh, logger, state: this.state.get(data.device), deviceExposesChanged: deviceExposesChanged};
|
||||
let payload: KeyValue = {};
|
||||
for (const converter of converters) {
|
||||
try {
|
||||
@@ -157,16 +172,19 @@ export default class Receive extends Extension {
|
||||
if (converted) {
|
||||
payload = {...payload, ...converted};
|
||||
}
|
||||
} catch (error) /* istanbul ignore next */ {
|
||||
logger.error(`Exception while calling fromZigbee converter: ${error.message}}`);
|
||||
logger.debug(error.stack);
|
||||
/* v8 ignore start */
|
||||
} catch (error) {
|
||||
logger.error(`Exception while calling fromZigbee converter: ${(error as Error).message}}`);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
if (!utils.objectIsEmpty(payload)) {
|
||||
await publish(payload);
|
||||
} else {
|
||||
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
|
||||
await utils.publishLastSeen({device: data.device, reason: "messageEmitted"}, settings.get(), true, this.publishEntityState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+72
-44
@@ -1,106 +1,134 @@
|
||||
/* eslint-disable brace-style */
|
||||
import {CustomClusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import assert from "node:assert";
|
||||
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
|
||||
import type {CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
import {access, Numeric} from "zigbee-herdsman-converters";
|
||||
|
||||
import * as settings from '../util/settings';
|
||||
import * as settings from "../util/settings";
|
||||
|
||||
const LINKQUALITY = new Numeric("linkquality", access.STATE)
|
||||
.withUnit("lqi")
|
||||
.withDescription("Link quality (signal strength)")
|
||||
.withValueMin(0)
|
||||
.withValueMax(255)
|
||||
.withCategory("diagnostic");
|
||||
|
||||
export default class Device {
|
||||
public zh: zh.Device;
|
||||
public definition: zhc.Definition;
|
||||
private _definitionModelID: string;
|
||||
public definition?: zhc.Definition;
|
||||
private _definitionModelID?: string;
|
||||
|
||||
get ieeeAddr(): string {
|
||||
return this.zh.ieeeAddr;
|
||||
}
|
||||
// biome-ignore lint/style/useNamingConvention: API
|
||||
get ID(): string {
|
||||
return this.zh.ieeeAddr;
|
||||
}
|
||||
get options(): DeviceOptions {
|
||||
return {...settings.get().device_options, ...settings.getDevice(this.ieeeAddr)};
|
||||
get options(): DeviceOptionsWithId {
|
||||
const deviceOptions = settings.getDevice(this.ieeeAddr) ?? {friendly_name: this.ieeeAddr, ID: this.ieeeAddr};
|
||||
return {...settings.get().device_options, ...deviceOptions};
|
||||
}
|
||||
get name(): string {
|
||||
return this.zh.type === 'Coordinator' ? 'Coordinator' : this.options?.friendly_name || this.ieeeAddr;
|
||||
return this.zh.type === "Coordinator" ? "Coordinator" : this.options?.friendly_name;
|
||||
}
|
||||
get isSupported(): boolean {
|
||||
return this.zh.type === 'Coordinator' || (this.definition && !this.definition.generated);
|
||||
return this.zh.type === "Coordinator" || Boolean(this.definition && !this.definition.generated);
|
||||
}
|
||||
get customClusters(): CustomClusters {
|
||||
return this.zh.customClusters;
|
||||
}
|
||||
get otaExtraMetas(): zhc.Ota.ExtraMetas {
|
||||
return typeof this.definition?.ota === "object" ? this.definition.ota : {};
|
||||
}
|
||||
get interviewed(): boolean {
|
||||
return this.zh.interviewState === InterviewState.Successful || this.zh.interviewState === InterviewState.Failed;
|
||||
}
|
||||
|
||||
constructor(device: zh.Device) {
|
||||
this.zh = device;
|
||||
}
|
||||
|
||||
exposes(): zhc.Expose[] {
|
||||
/* istanbul ignore if */
|
||||
if (typeof this.definition.exposes == 'function') {
|
||||
const exposes: zhc.Expose[] = [];
|
||||
assert(this.definition, "Cannot retreive exposes before definition is resolved");
|
||||
if (typeof this.definition.exposes === "function") {
|
||||
const options: KeyValue = this.options;
|
||||
return this.definition.exposes(this.zh, options);
|
||||
exposes.push(...this.definition.exposes(this.zh, options));
|
||||
} else {
|
||||
return this.definition.exposes;
|
||||
exposes.push(...this.definition.exposes);
|
||||
}
|
||||
exposes.push(LINKQUALITY);
|
||||
return exposes;
|
||||
}
|
||||
|
||||
async resolveDefinition(ignoreCache = false): Promise<void> {
|
||||
if (!this.zh.interviewing && (!this.definition || this._definitionModelID !== this.zh.modelID || ignoreCache)) {
|
||||
if (this.interviewed && (!this.definition || this._definitionModelID !== this.zh.modelID || ignoreCache)) {
|
||||
this.definition = await zhc.findByDevice(this.zh, true);
|
||||
this._definitionModelID = this.zh.modelID;
|
||||
}
|
||||
}
|
||||
|
||||
ensureInSettings(): void {
|
||||
if (this.zh.type !== 'Coordinator' && !settings.getDevice(this.zh.ieeeAddr)) {
|
||||
if (this.zh.type !== "Coordinator" && !settings.getDevice(this.zh.ieeeAddr)) {
|
||||
settings.addDevice(this.zh.ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
endpoint(key?: string | number): zh.Endpoint {
|
||||
let endpoint: zh.Endpoint;
|
||||
if (key == null || key == '') key = 'default';
|
||||
|
||||
if (!isNaN(Number(key))) {
|
||||
endpoint = this.zh.getEndpoint(Number(key));
|
||||
} else if (this.definition?.endpoint) {
|
||||
const ID = this.definition?.endpoint?.(this.zh)[key];
|
||||
if (ID) endpoint = this.zh.getEndpoint(ID);
|
||||
else if (key === 'default') endpoint = this.zh.endpoints[0];
|
||||
else return null;
|
||||
} else {
|
||||
/* istanbul ignore next */
|
||||
if (key !== 'default') return null;
|
||||
endpoint = this.zh.endpoints[0];
|
||||
endpoint(key?: string | number): zh.Endpoint | undefined {
|
||||
if (!key) {
|
||||
key = "default";
|
||||
} else if (!Number.isNaN(Number(key))) {
|
||||
return this.zh.getEndpoint(Number(key));
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
if (this.definition?.endpoint) {
|
||||
const ID = this.definition.endpoint(this.zh)[key];
|
||||
|
||||
if (ID) {
|
||||
return this.zh.getEndpoint(ID);
|
||||
}
|
||||
}
|
||||
|
||||
return key === "default" ? this.zh.endpoints[0] : undefined;
|
||||
}
|
||||
|
||||
endpointName(endpoint: zh.Endpoint): string {
|
||||
let epName = null;
|
||||
endpointName(endpoint: zh.Endpoint): string | undefined {
|
||||
let epName: string | undefined;
|
||||
|
||||
if (this.definition?.endpoint) {
|
||||
const mapping = this.definition?.endpoint(this.zh);
|
||||
for (const [name, id] of Object.entries(mapping)) {
|
||||
if (id == endpoint.ID) {
|
||||
const mapping = this.definition.endpoint(this.zh);
|
||||
|
||||
for (const name in mapping) {
|
||||
if (mapping[name] === endpoint.ID) {
|
||||
epName = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* istanbul ignore next */
|
||||
return epName === 'default' ? null : epName;
|
||||
|
||||
/* v8 ignore next */
|
||||
return epName === "default" ? undefined : epName;
|
||||
}
|
||||
|
||||
getEndpointNames(): string[] {
|
||||
return Object.keys(this.definition?.endpoint?.(this.zh) ?? {}).filter((name) => name !== 'default');
|
||||
}
|
||||
const names: string[] = [];
|
||||
|
||||
isIkeaTradfri(): boolean {
|
||||
return this.zh.manufacturerID === 4476;
|
||||
if (this.definition?.endpoint) {
|
||||
for (const name in this.definition.endpoint(this.zh)) {
|
||||
if (name !== "default") {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
isDevice(): this is Device {
|
||||
return true;
|
||||
}
|
||||
/* istanbul ignore next */
|
||||
|
||||
isGroup(): this is Group {
|
||||
return false;
|
||||
}
|
||||
|
||||
+31
-12
@@ -1,39 +1,58 @@
|
||||
/* eslint-disable brace-style */
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import * as settings from '../util/settings';
|
||||
import type * as zhc from "zigbee-herdsman-converters";
|
||||
import * as settings from "../util/settings";
|
||||
import {DEFAULT_BIND_GROUP_ID} from "../util/utils";
|
||||
|
||||
export default class Group {
|
||||
public zh: zh.Group;
|
||||
private resolveDevice: (ieeeAddr: string) => Device;
|
||||
private resolveDevice: (ieeeAddr: string) => Device | undefined;
|
||||
|
||||
// biome-ignore lint/style/useNamingConvention: API
|
||||
get ID(): number {
|
||||
return this.zh.groupID;
|
||||
}
|
||||
get options(): GroupOptions {
|
||||
return {...settings.getGroup(this.ID)};
|
||||
// biome-ignore lint/style/noNonNullAssertion: Group always exists in settings
|
||||
return {...settings.getGroup(this.ID)!};
|
||||
}
|
||||
get name(): string {
|
||||
return this.options?.friendly_name || this.ID.toString();
|
||||
}
|
||||
|
||||
constructor(group: zh.Group, resolveDevice: (ieeeAddr: string) => Device) {
|
||||
constructor(group: zh.Group, resolveDevice: (ieeeAddr: string) => Device | undefined) {
|
||||
this.zh = group;
|
||||
this.resolveDevice = resolveDevice;
|
||||
}
|
||||
|
||||
ensureInSettings(): void {
|
||||
if (this.ID !== DEFAULT_BIND_GROUP_ID && !settings.getGroup(this.ID)) {
|
||||
settings.addGroup(this.name, this.ID.toString());
|
||||
}
|
||||
}
|
||||
|
||||
hasMember(device: Device): boolean {
|
||||
return !!device.zh.endpoints.find((e) => this.zh.members.includes(e));
|
||||
}
|
||||
|
||||
membersDevices(): Device[] {
|
||||
return this.zh.members.map((e) => this.resolveDevice(e.getDevice().ieeeAddr)).filter((d) => d);
|
||||
*membersDevices(): Generator<Device> {
|
||||
for (const member of this.zh.members) {
|
||||
const resolvedDevice = this.resolveDevice(member.deviceIeeeAddress);
|
||||
|
||||
if (resolvedDevice) {
|
||||
yield resolvedDevice;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
membersDefinitions(): zhc.Definition[] {
|
||||
return this.membersDevices()
|
||||
.map((d) => d.definition)
|
||||
.filter((d) => d);
|
||||
const definitions: zhc.Definition[] = [];
|
||||
|
||||
for (const member of this.membersDevices()) {
|
||||
if (member.definition) {
|
||||
definitions.push(member.definition);
|
||||
}
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
isDevice(): this is Device {
|
||||
|
||||
+137
-101
@@ -1,28 +1,55 @@
|
||||
import type {QoS} from 'mqtt-packet';
|
||||
import fs from "node:fs";
|
||||
import bind from "bind-decorator";
|
||||
import type {IClientOptions, IClientPublishOptions, MqttClient} from "mqtt";
|
||||
import {connectAsync} from "mqtt";
|
||||
import type {Zigbee2MQTTAPI} from "./types/api";
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import fs from 'fs';
|
||||
import * as mqtt from 'mqtt';
|
||||
import logger from "./util/logger";
|
||||
import * as settings from "./util/settings";
|
||||
import utils from "./util/utils";
|
||||
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import utils from './util/utils';
|
||||
const NS = "z2m:mqtt";
|
||||
|
||||
const NS = 'z2m:mqtt';
|
||||
export interface MqttPublishOptions {
|
||||
clientOptions: IClientPublishOptions;
|
||||
baseTopic: string;
|
||||
skipLog: boolean;
|
||||
skipReceive: boolean;
|
||||
meta: {isEntityState?: boolean};
|
||||
}
|
||||
|
||||
export default class MQTT {
|
||||
private publishedTopics: Set<string> = new Set();
|
||||
private connectionTimer: NodeJS.Timeout;
|
||||
private client: mqtt.MqttClient;
|
||||
export default class Mqtt {
|
||||
private publishedTopics = new Set<string>();
|
||||
private connectionTimer?: NodeJS.Timeout;
|
||||
private client!: MqttClient;
|
||||
private eventBus: EventBus;
|
||||
private initialConnect = true;
|
||||
private republishRetainedTimer: NodeJS.Timeout;
|
||||
public retainedMessages: {
|
||||
[s: string]: {payload: string; options: MQTTOptions; skipLog: boolean; skipReceive: boolean; topic: string; base: string};
|
||||
} = {};
|
||||
private republishRetainedTimer?: NodeJS.Timeout;
|
||||
private defaultPublishOptions: MqttPublishOptions;
|
||||
public retainedMessages: {[s: string]: {topic: string; payload: string; options: MqttPublishOptions}} = {};
|
||||
|
||||
get info() {
|
||||
return {
|
||||
version: this.client.options.protocolVersion,
|
||||
server: `${this.client.options.protocol}://${this.client.options.host}:${this.client.options.port}`,
|
||||
};
|
||||
}
|
||||
|
||||
get stats() {
|
||||
return {
|
||||
connected: this.isConnected(),
|
||||
queued: this.client.queue.length,
|
||||
};
|
||||
}
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
this.eventBus = eventBus;
|
||||
this.defaultPublishOptions = {
|
||||
clientOptions: {},
|
||||
baseTopic: settings.get().mqtt.base_topic,
|
||||
skipLog: false,
|
||||
skipReceive: true,
|
||||
meta: {},
|
||||
};
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
@@ -30,13 +57,14 @@ export default class MQTT {
|
||||
|
||||
logger.info(`Connecting to MQTT server at ${mqttSettings.server}`);
|
||||
|
||||
const options: mqtt.IClientOptions = {
|
||||
const options: IClientOptions = {
|
||||
will: {
|
||||
topic: `${settings.get().mqtt.base_topic}/bridge/state`,
|
||||
payload: Buffer.from(utils.availabilityPayload('offline', settings.get())),
|
||||
retain: settings.get().mqtt.force_disable_retain ? false : true,
|
||||
payload: Buffer.from(JSON.stringify({state: "offline"})),
|
||||
retain: !settings.get().mqtt.force_disable_retain,
|
||||
qos: 1,
|
||||
},
|
||||
properties: {maximumPacketSize: mqttSettings.maximum_packet_size},
|
||||
};
|
||||
|
||||
if (mqttSettings.version) {
|
||||
@@ -64,8 +92,11 @@ export default class MQTT {
|
||||
logger.debug(`Using MQTT login with username: ${mqttSettings.user}`);
|
||||
options.username = mqttSettings.user;
|
||||
options.password = mqttSettings.password;
|
||||
} else if (mqttSettings.user) {
|
||||
logger.debug(`Using MQTT login with username only: ${mqttSettings.user}`);
|
||||
options.username = mqttSettings.user;
|
||||
} else {
|
||||
logger.debug(`Using MQTT anonymous login`);
|
||||
logger.debug("Using MQTT anonymous login");
|
||||
}
|
||||
|
||||
if (mqttSettings.client_id) {
|
||||
@@ -73,138 +104,143 @@ export default class MQTT {
|
||||
options.clientId = mqttSettings.client_id;
|
||||
}
|
||||
|
||||
if (mqttSettings.hasOwnProperty('reject_unauthorized') && !mqttSettings.reject_unauthorized) {
|
||||
logger.debug(`MQTT reject_unauthorized set false, ignoring certificate warnings.`);
|
||||
if (mqttSettings.reject_unauthorized !== undefined && !mqttSettings.reject_unauthorized) {
|
||||
logger.debug("MQTT reject_unauthorized set false, ignoring certificate warnings.");
|
||||
options.rejectUnauthorized = false;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client = mqtt.connect(mqttSettings.server, options);
|
||||
// @ts-ignore https://github.com/Koenkk/zigbee2mqtt/issues/9822
|
||||
this.client.stream.setMaxListeners(0);
|
||||
this.eventBus.onPublishAvailability(this, this.publishStateOnline);
|
||||
this.client = await connectAsync(mqttSettings.server, options);
|
||||
|
||||
this.client.on('connect', async () => {
|
||||
// Set timer at interval to check if connected to MQTT server.
|
||||
clearTimeout(this.connectionTimer);
|
||||
this.connectionTimer = setInterval(() => {
|
||||
if (this.client.reconnecting) {
|
||||
logger.error('Not connected to MQTT server!');
|
||||
}
|
||||
}, utils.seconds(10));
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/9822
|
||||
this.client.stream.setMaxListeners(0);
|
||||
|
||||
logger.info('Connected to MQTT server');
|
||||
await this.publishStateOnline();
|
||||
|
||||
if (!this.initialConnect) {
|
||||
this.republishRetainedTimer = setTimeout(async () => {
|
||||
// Republish retained messages in case MQTT broker does not persist them.
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/9629
|
||||
for (const msg of Object.values(this.retainedMessages)) {
|
||||
await this.publish(msg.topic, msg.payload, msg.options, msg.base, msg.skipLog, msg.skipReceive);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
this.initialConnect = false;
|
||||
this.subscribe(`${settings.get().mqtt.base_topic}/#`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
logger.error(`MQTT error: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.client.on('message', this.onMessage);
|
||||
this.client.on("error", (err) => {
|
||||
logger.error(`MQTT error: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
@bind async publishStateOnline(): Promise<void> {
|
||||
await this.publish('bridge/state', utils.availabilityPayload('online', settings.get()), {retain: true, qos: 0});
|
||||
if (mqttSettings.version != null && mqttSettings.version >= 5) {
|
||||
this.client.on("disconnect", (packet) => {
|
||||
logger.error(`MQTT disconnect: reason ${packet.reasonCode} (${packet.properties?.reasonString})`);
|
||||
});
|
||||
}
|
||||
|
||||
this.client.on("message", this.onMessage);
|
||||
|
||||
await this.onConnect();
|
||||
|
||||
this.client.on("connect", this.onConnect);
|
||||
|
||||
this.republishRetainedTimer = setTimeout(async () => {
|
||||
// Republish retained messages in case MQTT broker does not persist them.
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/9629
|
||||
for (const msg of Object.values(this.retainedMessages)) {
|
||||
await this.publish(msg.topic, msg.payload, msg.options);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Set timer at interval to check if connected to MQTT server.
|
||||
this.connectionTimer = setInterval(() => {
|
||||
if (!this.isConnected()) {
|
||||
logger.error("Not connected to MQTT server!");
|
||||
}
|
||||
}, utils.seconds(10));
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
clearTimeout(this.connectionTimer);
|
||||
await this.publish('bridge/state', utils.availabilityPayload('offline', settings.get()), {retain: true, qos: 0});
|
||||
clearTimeout(this.republishRetainedTimer);
|
||||
|
||||
const stateData: Zigbee2MQTTAPI["bridge/state"] = {state: "offline"};
|
||||
|
||||
await this.publish("bridge/state", JSON.stringify(stateData), {clientOptions: {retain: true}});
|
||||
this.eventBus.removeListeners(this);
|
||||
logger.info('Disconnecting from MQTT server');
|
||||
this.client?.end();
|
||||
logger.info("Disconnecting from MQTT server");
|
||||
await this.client?.endAsync();
|
||||
}
|
||||
|
||||
subscribe(topic: string): void {
|
||||
this.client.subscribe(topic);
|
||||
async subscribe(topic: string): Promise<void> {
|
||||
await this.client.subscribeAsync(topic);
|
||||
}
|
||||
|
||||
unsubscribe(topic: string): void {
|
||||
this.client.unsubscribe(topic);
|
||||
async unsubscribe(topic: string): Promise<void> {
|
||||
await this.client.unsubscribeAsync(topic);
|
||||
}
|
||||
|
||||
@bind private async onConnect(): Promise<void> {
|
||||
logger.info("Connected to MQTT server");
|
||||
|
||||
const stateData: Zigbee2MQTTAPI["bridge/state"] = {state: "online"};
|
||||
|
||||
await this.publish("bridge/state", JSON.stringify(stateData), {clientOptions: {retain: true, qos: 1}});
|
||||
await this.subscribe(`${settings.get().mqtt.base_topic}/#`);
|
||||
}
|
||||
|
||||
@bind public onMessage(topic: string, message: Buffer): void {
|
||||
// Since we subscribe to zigbee2mqtt/# we also receive the message we send ourselves, skip these.
|
||||
if (!this.publishedTopics.has(topic)) {
|
||||
logger.debug(`Received MQTT message on '${topic}' with data '${message.toString()}'`, NS);
|
||||
logger.debug(() => `Received MQTT message on '${topic}' with data '${message.toString()}'`, NS);
|
||||
this.eventBus.emitMQTTMessage({topic, message: message.toString()});
|
||||
}
|
||||
|
||||
if (this.republishRetainedTimer && topic === `${settings.get().mqtt.base_topic}/bridge/info`) {
|
||||
clearTimeout(this.republishRetainedTimer);
|
||||
|
||||
this.republishRetainedTimer = null;
|
||||
this.republishRetainedTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.client && !this.client.reconnecting;
|
||||
return this.client && !this.client.reconnecting && !this.client.disconnecting && !this.client.disconnected;
|
||||
}
|
||||
|
||||
async publish(
|
||||
topic: string,
|
||||
payload: string,
|
||||
options: MQTTOptions = {},
|
||||
base = settings.get().mqtt.base_topic,
|
||||
skipLog = false,
|
||||
skipReceive = true,
|
||||
): Promise<void> {
|
||||
const defaultOptions: {qos: QoS; retain: boolean} = {qos: 0, retain: false};
|
||||
topic = `${base}/${topic}`;
|
||||
async publish(topic: string, payload: string, options: Partial<MqttPublishOptions> = {}): Promise<void> {
|
||||
if (topic.includes("+") || topic.includes("#")) {
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/26939#issuecomment-2772309646
|
||||
logger.error(`Topic '${topic}' includes wildcard characters, skipping publish.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (skipReceive) {
|
||||
const finalOptions = {...this.defaultPublishOptions, ...options};
|
||||
topic = `${finalOptions.baseTopic}/${topic}`;
|
||||
|
||||
if (finalOptions.skipReceive) {
|
||||
this.publishedTopics.add(topic);
|
||||
}
|
||||
|
||||
if (options.retain) {
|
||||
if (finalOptions.clientOptions.retain) {
|
||||
if (payload) {
|
||||
this.retainedMessages[topic] = {payload, options, skipReceive, skipLog, topic: topic.substring(base.length + 1), base};
|
||||
this.retainedMessages[topic] = {payload, options: finalOptions, topic: topic.substring(finalOptions.baseTopic.length + 1)};
|
||||
} else {
|
||||
delete this.retainedMessages[topic];
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.emitMQTTMessagePublished({topic, payload, options: {...defaultOptions, ...options}});
|
||||
this.eventBus.emitMQTTMessagePublished({topic, payload, options: finalOptions});
|
||||
|
||||
if (!this.isConnected()) {
|
||||
/* istanbul ignore else */
|
||||
if (!skipLog) {
|
||||
logger.error(`Not connected to MQTT server!`);
|
||||
if (!finalOptions.skipLog) {
|
||||
logger.error("Not connected to MQTT server!");
|
||||
logger.error(`Cannot send message: topic: '${topic}', payload: '${payload}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!skipLog) {
|
||||
logger.info(`MQTT publish: topic '${topic}', payload '${payload}'`, NS);
|
||||
}
|
||||
|
||||
const actualOptions: mqtt.IClientPublishOptions = {...defaultOptions, ...options};
|
||||
|
||||
let clientOptions: IClientPublishOptions = finalOptions.clientOptions;
|
||||
if (settings.get().mqtt.force_disable_retain) {
|
||||
actualOptions.retain = false;
|
||||
clientOptions = {...finalOptions.clientOptions, retain: false};
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
this.client.publish(topic, payload, actualOptions, () => resolve());
|
||||
});
|
||||
if (!finalOptions.skipLog) {
|
||||
logger.info(() => `MQTT publish: topic '${topic}', payload '${payload}'`, NS);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.client.publishAsync(topic, payload, clientOptions);
|
||||
} catch (error) {
|
||||
if (!finalOptions.skipLog) {
|
||||
logger.error(`MQTT server error: ${(error as Error).message}`);
|
||||
logger.error(`Could not send message: topic: '${topic}', payload: '${payload}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+66
-50
@@ -1,39 +1,39 @@
|
||||
import fs from 'fs';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import {existsSync, readFileSync, writeFileSync} from "node:fs";
|
||||
|
||||
import data from './util/data';
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import utils from './util/utils';
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
|
||||
const saveInterval = 1000 * 60 * 5; // 5 minutes
|
||||
import data from "./util/data";
|
||||
import logger from "./util/logger";
|
||||
import * as settings from "./util/settings";
|
||||
import utils from "./util/utils";
|
||||
|
||||
const dontCacheProperties = [
|
||||
'action',
|
||||
'action_.*',
|
||||
'button',
|
||||
'button_left',
|
||||
'button_right',
|
||||
'click',
|
||||
'forgotten',
|
||||
'keyerror',
|
||||
'step_size',
|
||||
'transition_time',
|
||||
'group_list',
|
||||
'group_capacity',
|
||||
'no_occupancy_since',
|
||||
'step_mode',
|
||||
'transition_time',
|
||||
'duration',
|
||||
'elapsed',
|
||||
'from_side',
|
||||
'to_side',
|
||||
const SAVE_INTERVAL = 1000 * 60 * 5; // 5 minutes
|
||||
const CACHE_IGNORE_PROPERTIES = [
|
||||
"action",
|
||||
"action_.*",
|
||||
"button",
|
||||
"button_left",
|
||||
"button_right",
|
||||
"forgotten",
|
||||
"keyerror",
|
||||
"step_size",
|
||||
"transition_time",
|
||||
"group_list",
|
||||
"group_capacity",
|
||||
"no_occupancy_since",
|
||||
"step_mode",
|
||||
"transition_time",
|
||||
"duration",
|
||||
"elapsed",
|
||||
"from_side",
|
||||
"to_side",
|
||||
"illuminance_lux", // removed in z2m 2.0.0
|
||||
];
|
||||
|
||||
class State {
|
||||
private state: {[s: string | number]: KeyValue} = {};
|
||||
private file = data.joinPath('state.json');
|
||||
private timer: NodeJS.Timeout = null;
|
||||
private readonly state = new Map<string | number, KeyValue>();
|
||||
private readonly file = data.joinPath("state.json");
|
||||
private timer?: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private readonly eventBus: EventBus,
|
||||
@@ -47,26 +47,40 @@ class State {
|
||||
this.load();
|
||||
|
||||
// Save the state on every interval
|
||||
this.timer = setInterval(() => this.save(), saveInterval);
|
||||
this.timer = setInterval(() => this.save(), SAVE_INTERVAL);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Remove any invalid states (ie when the device has left the network) when the system is stopped
|
||||
Object.keys(this.state)
|
||||
.filter((k) => typeof k === 'string' && !this.zigbee.resolveEntity(k)) // string key = ieeeAddr
|
||||
.forEach((k) => delete this.state[k]);
|
||||
for (const [key] of this.state) {
|
||||
if (typeof key === "string" && key.startsWith("0x") && !this.zigbee.resolveEntity(key)) {
|
||||
// string key = ieeeAddr
|
||||
this.state.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(this.timer);
|
||||
this.save();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.state.clear();
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
if (fs.existsSync(this.file)) {
|
||||
this.state.clear();
|
||||
|
||||
if (existsSync(this.file)) {
|
||||
try {
|
||||
this.state = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
||||
const stateObj = JSON.parse(readFileSync(this.file, "utf8")) as KeyValue;
|
||||
|
||||
for (const key in stateObj) {
|
||||
this.state.set(key.startsWith("0x") ? key : Number.parseInt(key, 10), stateObj[key]);
|
||||
}
|
||||
|
||||
logger.debug(`Loaded state from file ${this.file}`);
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to load state from file ${this.file} (corrupt file?) (${error.message})`);
|
||||
logger.debug(`Failed to load state from file ${this.file} (corrupt file?) (${(error as Error).message})`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Can't load state from file ${this.file} (doesn't exist)`);
|
||||
@@ -76,40 +90,42 @@ class State {
|
||||
private save(): void {
|
||||
if (settings.get().advanced.cache_state_persistent) {
|
||||
logger.debug(`Saving state to file ${this.file}`);
|
||||
const json = JSON.stringify(this.state, null, 4);
|
||||
|
||||
const json = JSON.stringify(Object.fromEntries(this.state), null, 4);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(this.file, json, 'utf8');
|
||||
} catch (e) {
|
||||
logger.error(`Failed to write state to '${this.file}' (${e.message})`);
|
||||
writeFileSync(this.file, json, "utf8");
|
||||
} catch (error) {
|
||||
logger.error(`Failed to write state to '${this.file}' (${error})`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Not saving state`);
|
||||
logger.debug("Not saving state");
|
||||
}
|
||||
}
|
||||
|
||||
exists(entity: Device | Group): boolean {
|
||||
return this.state.hasOwnProperty(entity.ID);
|
||||
return this.state.has(entity.ID);
|
||||
}
|
||||
|
||||
get(entity: Group | Device): KeyValue {
|
||||
return this.state[entity.ID] || {};
|
||||
return this.state.get(entity.ID) || {};
|
||||
}
|
||||
|
||||
set(entity: Group | Device, update: KeyValue, reason: string = null): KeyValue {
|
||||
const fromState = this.state[entity.ID] || {};
|
||||
set(entity: Group | Device, update: KeyValue, reason?: string): KeyValue {
|
||||
const fromState = this.state.get(entity.ID) || {};
|
||||
const toState = objectAssignDeep({}, fromState, update);
|
||||
const newCache = {...toState};
|
||||
const entityDontCacheProperties = entity.options.filtered_cache || [];
|
||||
|
||||
utils.filterProperties(dontCacheProperties.concat(entityDontCacheProperties), newCache);
|
||||
utils.filterProperties(CACHE_IGNORE_PROPERTIES.concat(entityDontCacheProperties), newCache);
|
||||
|
||||
this.state[entity.ID] = newCache;
|
||||
this.state.set(entity.ID, newCache);
|
||||
this.eventBus.emitStateChange({entity, from: fromState, to: toState, reason, update});
|
||||
return toState;
|
||||
}
|
||||
|
||||
remove(ID: string | number): void {
|
||||
delete this.state[ID];
|
||||
remove(id: string | number): boolean {
|
||||
return this.state.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,973 @@
|
||||
import type * as zigbeeHerdsman from "zigbee-herdsman/dist";
|
||||
import type {ClusterDefinition, ClusterName, CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
|
||||
import type * as zigbeeHerdsmanConverter from "zigbee-herdsman-converters";
|
||||
import type {Base} from "zigbee-herdsman-converters/lib/exposes";
|
||||
|
||||
export type * as ZSpec from "zigbee-herdsman/dist/zspec";
|
||||
export type * as Zcl from "zigbee-herdsman/dist/zspec/zcl";
|
||||
export type * as Zdo from "zigbee-herdsman/dist/zspec/zdo";
|
||||
|
||||
export type Zigbee2MQTTFeatures = {
|
||||
base: Base;
|
||||
switch: zigbeeHerdsmanConverter.Switch;
|
||||
lock: zigbeeHerdsmanConverter.Lock;
|
||||
binary: zigbeeHerdsmanConverter.Binary;
|
||||
list: zigbeeHerdsmanConverter.List;
|
||||
numeric: zigbeeHerdsmanConverter.Numeric;
|
||||
enum: zigbeeHerdsmanConverter.Enum;
|
||||
text: zigbeeHerdsmanConverter.Text;
|
||||
composite: zigbeeHerdsmanConverter.Composite;
|
||||
light: zigbeeHerdsmanConverter.Light;
|
||||
cover: zigbeeHerdsmanConverter.Cover;
|
||||
fan: zigbeeHerdsmanConverter.Fan;
|
||||
climate: zigbeeHerdsmanConverter.Climate;
|
||||
};
|
||||
|
||||
import type {UpdatePayload} from "../extension/otaUpdate";
|
||||
import type {LogLevel, schemaJson} from "../util/settings";
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: API
|
||||
type KeyValue = Record<string, any>;
|
||||
|
||||
export interface Zigbee2MQTTDeviceOptions {
|
||||
disabled?: boolean;
|
||||
retention?: number;
|
||||
availability?:
|
||||
| boolean
|
||||
| {
|
||||
timeout: number;
|
||||
max_jitter?: number;
|
||||
backoff?: boolean;
|
||||
pause_on_backoff_gt?: number;
|
||||
};
|
||||
optimistic?: boolean;
|
||||
debounce?: number;
|
||||
debounce_ignore?: string[];
|
||||
throttle?: number;
|
||||
filtered_attributes?: string[];
|
||||
filtered_cache?: string[];
|
||||
filtered_optimistic?: string[];
|
||||
icon?: string;
|
||||
homeassistant?: KeyValue;
|
||||
friendly_name: string;
|
||||
description?: string;
|
||||
qos?: 0 | 1 | 2;
|
||||
}
|
||||
|
||||
export interface Zigbee2MQTTGroupOptions {
|
||||
ID: number;
|
||||
optimistic?: boolean;
|
||||
off_state?: "all_members_off" | "last_member_state";
|
||||
filtered_attributes?: string[];
|
||||
filtered_cache?: string[];
|
||||
filtered_optimistic?: string[];
|
||||
homeassistant?: KeyValue;
|
||||
friendly_name: string;
|
||||
description?: string;
|
||||
qos?: 0 | 1 | 2;
|
||||
}
|
||||
|
||||
export interface Zigbee2MQTTSettings {
|
||||
version?: number;
|
||||
/** only used internally during startup, removed on successful Z2M start */
|
||||
onboarding?: true;
|
||||
homeassistant: {
|
||||
enabled: boolean;
|
||||
discovery_topic: string;
|
||||
status_topic: string;
|
||||
experimental_event_entities: boolean;
|
||||
legacy_action_sensor: boolean;
|
||||
};
|
||||
availability: {
|
||||
enabled: boolean;
|
||||
active: {
|
||||
timeout: number;
|
||||
max_jitter: number;
|
||||
backoff: boolean;
|
||||
pause_on_backoff_gt: number;
|
||||
};
|
||||
passive: {timeout: number};
|
||||
};
|
||||
mqtt: {
|
||||
base_topic: string;
|
||||
include_device_information: boolean;
|
||||
force_disable_retain: boolean;
|
||||
version?: 3 | 4 | 5;
|
||||
user?: string;
|
||||
password?: string;
|
||||
server: string;
|
||||
ca?: string;
|
||||
keepalive?: number;
|
||||
key?: string;
|
||||
cert?: string;
|
||||
client_id?: string;
|
||||
reject_unauthorized?: boolean;
|
||||
maximum_packet_size: number;
|
||||
};
|
||||
serial: {
|
||||
disable_led: boolean;
|
||||
port?: string;
|
||||
adapter?: "deconz" | "zstack" | "ezsp" | "zigate" | "ember" | "zboss" | "zoh";
|
||||
baudrate?: number;
|
||||
rtscts?: boolean;
|
||||
};
|
||||
passlist: string[];
|
||||
blocklist: string[];
|
||||
map_options: {
|
||||
graphviz: {
|
||||
colors: {
|
||||
fill: {
|
||||
enddevice: string;
|
||||
coordinator: string;
|
||||
router: string;
|
||||
};
|
||||
font: {
|
||||
coordinator: string;
|
||||
router: string;
|
||||
enddevice: string;
|
||||
};
|
||||
line: {
|
||||
active: string;
|
||||
inactive: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
ota: {
|
||||
update_check_interval: number;
|
||||
disable_automatic_update_check: boolean;
|
||||
zigbee_ota_override_index_location?: string;
|
||||
image_block_response_delay?: number;
|
||||
default_maximum_data_size?: number;
|
||||
};
|
||||
frontend: {
|
||||
enabled: boolean;
|
||||
package: "zigbee2mqtt-frontend" | "zigbee2mqtt-windfront";
|
||||
auth_token?: string;
|
||||
host?: string;
|
||||
port: number;
|
||||
base_url: string;
|
||||
url?: string;
|
||||
ssl_cert?: string;
|
||||
ssl_key?: string;
|
||||
notification_filter?: string[];
|
||||
disable_ui_serving?: boolean;
|
||||
};
|
||||
devices: {[s: string]: Zigbee2MQTTDeviceOptions};
|
||||
groups: {[s: string]: Omit<Zigbee2MQTTGroupOptions, "ID">};
|
||||
device_options: KeyValue;
|
||||
advanced: {
|
||||
log_rotation: boolean;
|
||||
log_console_json: boolean;
|
||||
log_symlink_current: boolean;
|
||||
log_output: ("console" | "file" | "syslog")[];
|
||||
log_directory: string;
|
||||
log_file: string;
|
||||
log_level: LogLevel;
|
||||
log_namespaced_levels: Record<string, LogLevel>;
|
||||
log_syslog: KeyValue;
|
||||
log_debug_to_mqtt_frontend: boolean;
|
||||
log_debug_namespace_ignore: string;
|
||||
log_directories_to_keep: number;
|
||||
pan_id: number | "GENERATE";
|
||||
ext_pan_id: number[] | "GENERATE";
|
||||
channel: number;
|
||||
adapter_concurrent?: number;
|
||||
adapter_delay?: number;
|
||||
cache_state: boolean;
|
||||
cache_state_persistent: boolean;
|
||||
cache_state_send_on_startup: boolean;
|
||||
last_seen: "disable" | "ISO_8601" | "ISO_8601_local" | "epoch";
|
||||
elapsed: boolean;
|
||||
network_key: number[] | "GENERATE";
|
||||
timestamp_format: string;
|
||||
output: "json" | "attribute" | "attribute_and_json";
|
||||
transmit_power?: number;
|
||||
};
|
||||
health: {
|
||||
/** in minutes */
|
||||
interval: number;
|
||||
reset_on_check: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Zigbee2MQTTScene {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Zigbee2MQTTDeviceEndpoint {
|
||||
name?: string;
|
||||
bindings: Zigbee2MQTTDeviceEndpointBinding[];
|
||||
configured_reportings: Zigbee2MQTTDeviceEndpointConfiguredReporting[];
|
||||
clusters: {input: string[]; output: string[]};
|
||||
scenes: Zigbee2MQTTScene[];
|
||||
}
|
||||
|
||||
export interface Zigbee2MQTTDeviceEndpointBinding {
|
||||
cluster: string;
|
||||
target: Zigbee2MQTTDeviceEndpointBindingTarget;
|
||||
}
|
||||
|
||||
export type Zigbee2MQTTDeviceEndpointBindingTarget = {type: "endpoint"; ieee_address: string; endpoint: number} | {type: "group"; id: number};
|
||||
|
||||
export interface Zigbee2MQTTDeviceEndpointConfiguredReporting {
|
||||
cluster: string;
|
||||
attribute: string | number;
|
||||
minimum_report_interval: number;
|
||||
maximum_report_interval: number;
|
||||
reportable_change: number;
|
||||
}
|
||||
|
||||
export interface Zigbee2MQTTDeviceDefinition {
|
||||
source: "native" | "generated" | "external";
|
||||
model: string;
|
||||
vendor: string;
|
||||
description: string;
|
||||
exposes: zigbeeHerdsmanConverter.Expose[];
|
||||
supports_ota: boolean;
|
||||
options: zigbeeHerdsmanConverter.Option[];
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface Zigbee2MQTTDevice {
|
||||
ieee_address: zigbeeHerdsman.Models.Device["ieeeAddr"];
|
||||
type: zigbeeHerdsman.Models.Device["type"];
|
||||
network_address: zigbeeHerdsman.Models.Device["networkAddress"];
|
||||
supported: boolean;
|
||||
friendly_name: string;
|
||||
disabled: boolean;
|
||||
description?: string;
|
||||
definition?: Zigbee2MQTTDeviceDefinition;
|
||||
power_source: zigbeeHerdsman.Models.Device["powerSource"];
|
||||
software_build_id: zigbeeHerdsman.Models.Device["softwareBuildID"];
|
||||
date_code: zigbeeHerdsman.Models.Device["dateCode"];
|
||||
model_id: zigbeeHerdsman.Models.Device["modelID"];
|
||||
interviewing: boolean;
|
||||
interview_completed: boolean;
|
||||
interview_state: zigbeeHerdsman.Models.Device["interviewState"];
|
||||
manufacturer: zigbeeHerdsman.Models.Device["manufacturerName"];
|
||||
endpoints: Record<number, Zigbee2MQTTDeviceEndpoint>;
|
||||
}
|
||||
|
||||
export interface Zigbee2MQTTGroupMember {
|
||||
ieee_address: zigbeeHerdsman.Models.Device["ieeeAddr"];
|
||||
endpoint: number;
|
||||
}
|
||||
|
||||
export interface Zigbee2MQTTGroup {
|
||||
id: number;
|
||||
friendly_name: "default_bind_group" | string;
|
||||
description?: string;
|
||||
scenes: Zigbee2MQTTScene[];
|
||||
members: Zigbee2MQTTGroupMember[];
|
||||
}
|
||||
|
||||
export interface Zigbee2MQTTNetworkMap {
|
||||
nodes: {
|
||||
ieeeAddr: string;
|
||||
friendlyName: string;
|
||||
type: string;
|
||||
networkAddress: number;
|
||||
manufacturerName?: string;
|
||||
modelID?: string;
|
||||
failed?: string[];
|
||||
lastSeen?: number;
|
||||
definition?: {model: string; vendor: string; supports: string; description: string};
|
||||
}[];
|
||||
links: {
|
||||
source: {ieeeAddr: string; networkAddress: number};
|
||||
target: {ieeeAddr: string; networkAddress: number};
|
||||
linkquality: number;
|
||||
depth: number;
|
||||
routes: {
|
||||
destinationAddress: number;
|
||||
status: string;
|
||||
nextHop: number;
|
||||
}[];
|
||||
sourceIeeeAddr: string;
|
||||
targetIeeeAddr: string;
|
||||
sourceNwkAddr: number;
|
||||
lqi: number;
|
||||
relationship: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zigbee2MQTT state/request/response API endpoints
|
||||
*/
|
||||
export interface Zigbee2MQTTAPI {
|
||||
"bridge/logging": {
|
||||
message: string;
|
||||
level: LogLevel;
|
||||
namespace: string;
|
||||
};
|
||||
|
||||
"bridge/state": {
|
||||
state: "online" | "offline";
|
||||
};
|
||||
|
||||
"bridge/definitions": {
|
||||
clusters: Readonly<Record<ClusterName, Readonly<ClusterDefinition>>>;
|
||||
custom_clusters: Record<string, CustomClusters>;
|
||||
};
|
||||
|
||||
"bridge/event":
|
||||
| {
|
||||
type: "device_leave" | "device_joined" | "device_announce";
|
||||
data: {
|
||||
friendly_name: string;
|
||||
ieee_address: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "device_interview";
|
||||
data:
|
||||
| {
|
||||
friendly_name: string;
|
||||
ieee_address: string;
|
||||
status: "started" | "failed";
|
||||
}
|
||||
| {
|
||||
friendly_name: string;
|
||||
ieee_address: string;
|
||||
status: "successful";
|
||||
supported: boolean;
|
||||
definition: Zigbee2MQTTDeviceDefinition | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
"bridge/info": {
|
||||
os: {
|
||||
version: string;
|
||||
node_version: string;
|
||||
cpus: string;
|
||||
memory_mb: number;
|
||||
};
|
||||
mqtt: {
|
||||
version: number | undefined;
|
||||
server: string;
|
||||
};
|
||||
version: string;
|
||||
commit: string | undefined;
|
||||
zigbee_herdsman_converters: {version: string};
|
||||
zigbee_herdsman: {version: string};
|
||||
coordinator: {
|
||||
ieee_address: string;
|
||||
type: string;
|
||||
meta: {
|
||||
[s: string]: number | string;
|
||||
};
|
||||
};
|
||||
network: {
|
||||
pan_id: number;
|
||||
/** `0x${string}` 8-len */
|
||||
extended_pan_id: string;
|
||||
channel: number;
|
||||
};
|
||||
log_level: "debug" | "info" | "warning" | "error";
|
||||
permit_join: boolean;
|
||||
permit_join_end: number | undefined;
|
||||
restart_required: boolean;
|
||||
config: Zigbee2MQTTSettings;
|
||||
config_schema: typeof schemaJson;
|
||||
};
|
||||
|
||||
"bridge/health": {
|
||||
/** time of message, msec from epoch, UTC */
|
||||
response_time: number;
|
||||
os: {
|
||||
load_average: number[];
|
||||
memory_used_mb: number;
|
||||
memory_percent: number;
|
||||
};
|
||||
process: {
|
||||
uptime_sec: number;
|
||||
memory_used_mb: number;
|
||||
memory_percent: number;
|
||||
};
|
||||
mqtt: {
|
||||
connected: boolean;
|
||||
queued: number;
|
||||
received: number;
|
||||
published: number;
|
||||
};
|
||||
devices: Record<
|
||||
string /* ieee */,
|
||||
{
|
||||
messages: number;
|
||||
messages_per_sec: number;
|
||||
leave_count: number;
|
||||
network_address_changes: number;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
"bridge/devices": Zigbee2MQTTDevice[];
|
||||
|
||||
"bridge/groups": Zigbee2MQTTGroup[];
|
||||
|
||||
"bridge/converters": {name: string; code: string}[];
|
||||
|
||||
"bridge/extensions": {name: string; code: string}[];
|
||||
|
||||
"bridge/request/permit_join":
|
||||
| {
|
||||
/** [0-254], 0 meaning disable */
|
||||
time: number;
|
||||
device?: string;
|
||||
}
|
||||
| `${number}`;
|
||||
|
||||
"bridge/response/permit_join": {
|
||||
/** [0-254], 0 meaning disable */
|
||||
time: number;
|
||||
device?: string;
|
||||
};
|
||||
|
||||
"bridge/request/health_check": "";
|
||||
|
||||
"bridge/response/health_check": {
|
||||
/** XXX: currently always returns true */
|
||||
healthy: boolean;
|
||||
};
|
||||
|
||||
"bridge/request/coordinator_check": "";
|
||||
|
||||
"bridge/response/coordinator_check": {
|
||||
missing_routers: {
|
||||
ieee_address: string;
|
||||
friendly_name: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
"bridge/request/restart": "";
|
||||
|
||||
"bridge/response/restart": Record<string, never>;
|
||||
|
||||
"bridge/request/networkmap":
|
||||
| {
|
||||
type: "raw" | "graphviz" | "plantuml";
|
||||
routes: boolean;
|
||||
}
|
||||
| "raw"
|
||||
| "graphviz"
|
||||
| "plantuml";
|
||||
|
||||
"bridge/response/networkmap":
|
||||
| {
|
||||
type: "raw";
|
||||
routes: boolean;
|
||||
value: Zigbee2MQTTNetworkMap;
|
||||
}
|
||||
| {
|
||||
type: "graphviz" | "plantuml";
|
||||
routes: boolean;
|
||||
value: string;
|
||||
};
|
||||
|
||||
"bridge/request/extension/save": {
|
||||
name: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
"bridge/response/extension/save": Record<string, never>;
|
||||
|
||||
"bridge/request/extension/remove": {
|
||||
name: string;
|
||||
};
|
||||
|
||||
"bridge/response/extension/remove": Record<string, never>;
|
||||
|
||||
"bridge/request/converter/save": {
|
||||
name: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
"bridge/response/converter/save": Record<string, never>;
|
||||
|
||||
"bridge/request/converter/remove": {
|
||||
name: string;
|
||||
};
|
||||
|
||||
"bridge/response/converter/remove": Record<string, never>;
|
||||
|
||||
"bridge/request/backup": "";
|
||||
|
||||
"bridge/response/backup": {
|
||||
/** base64 encoded ZIP archive */
|
||||
zip: string;
|
||||
};
|
||||
|
||||
"bridge/request/install_code/add": {
|
||||
value: string;
|
||||
};
|
||||
|
||||
"bridge/response/install_code/add": {
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Applied on-the-fly:
|
||||
* - newSettings.homeassistant
|
||||
* - newSettings.advanced?.log_level
|
||||
* - newSettings.advanced?.log_namespaced_levels
|
||||
* - newSettings.advanced?.log_debug_namespace_ignore
|
||||
*/
|
||||
"bridge/request/options": {
|
||||
options: Record<string, unknown>;
|
||||
};
|
||||
|
||||
"bridge/response/options": {
|
||||
restart_required: boolean;
|
||||
};
|
||||
|
||||
"bridge/request/device/bind": {
|
||||
from: string;
|
||||
from_endpoint: string | number | "default";
|
||||
to: string | number;
|
||||
to_endpoint?: string | number;
|
||||
clusters?: string[];
|
||||
skip_disable_reporting?: boolean;
|
||||
};
|
||||
|
||||
"bridge/response/device/bind": {
|
||||
from: string;
|
||||
from_endpoint: string | number;
|
||||
to: string | number;
|
||||
to_endpoint: string | number | undefined;
|
||||
clusters: string[];
|
||||
failed: string[];
|
||||
};
|
||||
|
||||
"bridge/request/device/unbind": {
|
||||
from: string;
|
||||
from_endpoint: string | number | "default";
|
||||
to: string | number;
|
||||
to_endpoint?: string | number;
|
||||
clusters?: string[];
|
||||
skip_disable_reporting?: boolean;
|
||||
};
|
||||
|
||||
"bridge/response/device/unbind": {
|
||||
from: string;
|
||||
from_endpoint: string | number;
|
||||
to: string | number;
|
||||
to_endpoint: string | number | undefined;
|
||||
clusters: string[];
|
||||
failed: string[];
|
||||
};
|
||||
|
||||
"bridge/request/device/configure":
|
||||
| {
|
||||
id: string | number;
|
||||
}
|
||||
| string;
|
||||
|
||||
"bridge/response/device/configure": {
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
"bridge/request/device/remove": {
|
||||
id: string;
|
||||
block?: boolean;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
"bridge/response/device/remove": {
|
||||
id: string;
|
||||
block: boolean;
|
||||
force: boolean;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/check": {
|
||||
id: string;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/check/downgrade": {
|
||||
id: string;
|
||||
};
|
||||
|
||||
"bridge/response/device/ota_update/check": {
|
||||
id: string;
|
||||
update_available: boolean;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/update": {
|
||||
id: string;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/update/downgrade": {
|
||||
id: string;
|
||||
};
|
||||
|
||||
"bridge/response/device/ota_update/update": {
|
||||
id: string;
|
||||
from:
|
||||
| {
|
||||
software_build_id: string;
|
||||
date_code: string;
|
||||
}
|
||||
| undefined;
|
||||
to:
|
||||
| {
|
||||
software_build_id: string;
|
||||
date_code: string;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/schedule": {
|
||||
id: string;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/schedule/downgrade": {
|
||||
id: string;
|
||||
};
|
||||
|
||||
"bridge/response/device/ota_update/schedule": {
|
||||
id: string;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/unschedule": {
|
||||
id: string;
|
||||
};
|
||||
|
||||
"bridge/response/device/ota_update/unschedule": {
|
||||
id: string;
|
||||
};
|
||||
|
||||
"bridge/request/device/interview": {
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
"bridge/response/device/interview": {
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
"bridge/request/device/generate_external_definition": {
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
"bridge/response/device/generate_external_definition": {
|
||||
id: string | number;
|
||||
source: string;
|
||||
};
|
||||
|
||||
"bridge/request/device/options": {
|
||||
id: string;
|
||||
options: Record<string, unknown>;
|
||||
};
|
||||
|
||||
"bridge/response/device/options": {
|
||||
id: string;
|
||||
from: Record<string, unknown>;
|
||||
to: Record<string, unknown>;
|
||||
restart_required: boolean;
|
||||
};
|
||||
|
||||
"bridge/request/device/rename":
|
||||
| {
|
||||
last: true;
|
||||
from?: string;
|
||||
to: string;
|
||||
homeassistant_rename?: boolean;
|
||||
}
|
||||
| {
|
||||
last: false | undefined;
|
||||
from: string;
|
||||
to: string;
|
||||
homeassistant_rename?: boolean;
|
||||
};
|
||||
|
||||
"bridge/response/device/rename": {
|
||||
from: string;
|
||||
to: string;
|
||||
homeassistant_rename: boolean;
|
||||
};
|
||||
|
||||
"bridge/request/device/configure_reporting": {
|
||||
id: string;
|
||||
endpoint: string | number;
|
||||
cluster: string | number;
|
||||
attribute: string | number | {ID: number; type: number};
|
||||
minimum_report_interval: number;
|
||||
maximum_report_interval: number;
|
||||
reportable_change: number;
|
||||
option: Record<string, unknown>;
|
||||
};
|
||||
|
||||
"bridge/response/device/configure_reporting": {
|
||||
id: string;
|
||||
endpoint: string | number;
|
||||
cluster: string | number;
|
||||
attribute: string | number | {ID: number; type: number};
|
||||
minimum_report_interval: number;
|
||||
maximum_report_interval: number;
|
||||
reportable_change: number;
|
||||
};
|
||||
|
||||
"bridge/request/group/remove": {
|
||||
id: string;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
"bridge/response/group/remove": {
|
||||
id: string;
|
||||
force: boolean;
|
||||
};
|
||||
|
||||
"bridge/request/group/add": {
|
||||
friendly_name: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
"bridge/response/group/add": {
|
||||
friendly_name: string;
|
||||
id: number;
|
||||
};
|
||||
|
||||
"bridge/request/group/rename": {
|
||||
from: string;
|
||||
to: string;
|
||||
homeassistant_rename?: boolean;
|
||||
};
|
||||
|
||||
"bridge/response/group/rename": {
|
||||
from: string;
|
||||
to: string;
|
||||
homeassistant_rename: boolean;
|
||||
};
|
||||
|
||||
"bridge/request/group/options": {
|
||||
id: string;
|
||||
options: Record<string, unknown>;
|
||||
};
|
||||
|
||||
"bridge/response/group/options": {
|
||||
id: string;
|
||||
from: Record<string, unknown>;
|
||||
to: Record<string, unknown>;
|
||||
restart_required: boolean;
|
||||
};
|
||||
|
||||
"bridge/request/group/members/add": {
|
||||
device: string;
|
||||
group: string;
|
||||
endpoint: string | number | "default";
|
||||
skip_disable_reporting?: boolean;
|
||||
};
|
||||
|
||||
"bridge/response/group/members/add": {
|
||||
device: string;
|
||||
group: string;
|
||||
endpoint: string | number | "default";
|
||||
};
|
||||
|
||||
"bridge/request/group/members/remove": {
|
||||
device: string;
|
||||
group: string;
|
||||
endpoint: string | number | "default";
|
||||
skip_disable_reporting?: boolean;
|
||||
};
|
||||
|
||||
"bridge/response/group/members/remove": {
|
||||
device: string;
|
||||
group: string;
|
||||
endpoint: string | number | "default";
|
||||
};
|
||||
|
||||
"bridge/request/group/members/remove_all": {
|
||||
device: string;
|
||||
endpoint: string | number | "default";
|
||||
skip_disable_reporting?: boolean;
|
||||
};
|
||||
|
||||
"bridge/response/group/members/remove_all": {
|
||||
device: string;
|
||||
endpoint: string | number | "default";
|
||||
};
|
||||
|
||||
"bridge/request/touchlink/factory_reset":
|
||||
| {
|
||||
ieee_address: string;
|
||||
channel: number;
|
||||
}
|
||||
| "";
|
||||
|
||||
"bridge/response/touchlink/factory_reset":
|
||||
| {
|
||||
ieee_address: string;
|
||||
channel: number;
|
||||
}
|
||||
| Record<string, never>;
|
||||
|
||||
"bridge/request/touchlink/scan": "";
|
||||
|
||||
"bridge/response/touchlink/scan": {
|
||||
found: {
|
||||
ieee_address: string;
|
||||
channel: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
"bridge/request/touchlink/identify": {
|
||||
ieee_address: string;
|
||||
channel: number;
|
||||
};
|
||||
|
||||
"bridge/response/touchlink/identify": {
|
||||
ieee_address: string;
|
||||
channel: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* entity state response
|
||||
*/
|
||||
"{friendlyName}": {
|
||||
[key: string]: unknown;
|
||||
update?: UpdatePayload["update"];
|
||||
};
|
||||
|
||||
"{friendlyName}/availability": {
|
||||
state: "online" | "offline";
|
||||
};
|
||||
|
||||
/** entity set request (tries to match endpoint to definition, else uses "default") */
|
||||
"{friendlyNameOrId}/set": {
|
||||
[attribute: string]: {[key: string]: unknown} | string | boolean;
|
||||
};
|
||||
/** entity set request (tries to match endpoint to definition, else uses "default") */
|
||||
"{friendlyNameOrId}/set/{attribute}": {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** entity set request */
|
||||
"{friendlyNameOrId}/{endpoint}/set": {
|
||||
[attribute: string]: {[key: string]: unknown} | string | boolean;
|
||||
};
|
||||
/** entity set request */
|
||||
"{friendlyNameOrId}/{endpoint}/set/{attribute}": {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/** entity get request (tries to match endpoint to definition, else uses "default") */
|
||||
"{friendlyNameOrId}/get": {
|
||||
[attribute: string]: {[key: string]: unknown} | string | boolean;
|
||||
};
|
||||
/** entity get request (tries to match endpoint to definition, else uses "default") */
|
||||
"{friendlyNameOrId}/get/{attribute}": {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** entity get request */
|
||||
"{friendlyNameOrId}/{endpoint}/get": {
|
||||
[attribute: string]: {[key: string]: unknown} | string | boolean;
|
||||
};
|
||||
/** entity get request */
|
||||
"{friendlyNameOrId}/{endpoint}/get/{attribute}": {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export type Zigbee2MQTTRequestEndpoints =
|
||||
| "bridge/request/permit_join"
|
||||
| "bridge/request/health_check"
|
||||
| "bridge/request/coordinator_check"
|
||||
| "bridge/request/restart"
|
||||
| "bridge/request/networkmap"
|
||||
| "bridge/request/extension/save"
|
||||
| "bridge/request/extension/remove"
|
||||
| "bridge/request/converter/save"
|
||||
| "bridge/request/converter/remove"
|
||||
| "bridge/request/backup"
|
||||
| "bridge/request/install_code/add"
|
||||
| "bridge/request/options"
|
||||
| "bridge/request/device/bind"
|
||||
| "bridge/request/device/unbind"
|
||||
| "bridge/request/device/configure"
|
||||
| "bridge/request/device/remove"
|
||||
| "bridge/request/device/ota_update/check"
|
||||
| "bridge/request/device/ota_update/check/downgrade"
|
||||
| "bridge/request/device/ota_update/update"
|
||||
| "bridge/request/device/ota_update/update/downgrade"
|
||||
| "bridge/request/device/ota_update/schedule"
|
||||
| "bridge/request/device/ota_update/schedule/downgrade"
|
||||
| "bridge/request/device/ota_update/unschedule"
|
||||
| "bridge/request/device/interview"
|
||||
| "bridge/request/device/generate_external_definition"
|
||||
| "bridge/request/device/options"
|
||||
| "bridge/request/device/rename"
|
||||
| "bridge/request/device/configure_reporting"
|
||||
| "bridge/request/group/remove"
|
||||
| "bridge/request/group/add"
|
||||
| "bridge/request/group/rename"
|
||||
| "bridge/request/group/options"
|
||||
| "bridge/request/group/members/add"
|
||||
| "bridge/request/group/members/remove"
|
||||
| "bridge/request/group/members/remove_all"
|
||||
| "bridge/request/touchlink/factory_reset"
|
||||
| "bridge/request/touchlink/scan"
|
||||
| "bridge/request/touchlink/identify"
|
||||
| "{friendlyNameOrId}/set"
|
||||
| "{friendlyNameOrId}/set/{attribute}"
|
||||
| "{friendlyNameOrId}/{endpoint}/set"
|
||||
| "{friendlyNameOrId}/{endpoint}/set/{attribute}"
|
||||
| "{friendlyNameOrId}/get"
|
||||
| "{friendlyNameOrId}/get/{attribute}"
|
||||
| "{friendlyNameOrId}/{endpoint}/get"
|
||||
| "{friendlyNameOrId}/{endpoint}/get/{attribute}";
|
||||
|
||||
export type Zigbee2MQTTResponseEndpoints =
|
||||
| "bridge/response/permit_join"
|
||||
| "bridge/response/health_check"
|
||||
| "bridge/response/coordinator_check"
|
||||
| "bridge/response/restart"
|
||||
| "bridge/response/networkmap"
|
||||
| "bridge/response/extension/save"
|
||||
| "bridge/response/extension/remove"
|
||||
| "bridge/response/converter/save"
|
||||
| "bridge/response/converter/remove"
|
||||
| "bridge/response/backup"
|
||||
| "bridge/response/install_code/add"
|
||||
| "bridge/response/options"
|
||||
| "bridge/response/device/bind"
|
||||
| "bridge/response/device/unbind"
|
||||
| "bridge/response/device/configure"
|
||||
| "bridge/response/device/remove"
|
||||
| "bridge/response/device/ota_update/check"
|
||||
| "bridge/response/device/ota_update/update"
|
||||
| "bridge/response/device/ota_update/schedule"
|
||||
| "bridge/response/device/ota_update/unschedule"
|
||||
| "bridge/response/device/interview"
|
||||
| "bridge/response/device/generate_external_definition"
|
||||
| "bridge/response/device/options"
|
||||
| "bridge/response/device/rename"
|
||||
| "bridge/response/device/configure_reporting"
|
||||
| "bridge/response/group/remove"
|
||||
| "bridge/response/group/add"
|
||||
| "bridge/response/group/rename"
|
||||
| "bridge/response/group/options"
|
||||
| "bridge/response/group/members/add"
|
||||
| "bridge/response/group/members/remove"
|
||||
| "bridge/response/group/members/remove_all"
|
||||
| "bridge/response/touchlink/factory_reset"
|
||||
| "bridge/response/touchlink/scan"
|
||||
| "bridge/response/touchlink/identify";
|
||||
|
||||
export type Zigbee2MQTTRequest<T extends Zigbee2MQTTRequestEndpoints> = {
|
||||
transaction?: string;
|
||||
} & Zigbee2MQTTAPI[T];
|
||||
|
||||
export type Zigbee2MQTTResponseOK<T extends Zigbee2MQTTResponseEndpoints> = {
|
||||
status: "ok";
|
||||
data: Zigbee2MQTTAPI[T];
|
||||
transaction?: string;
|
||||
};
|
||||
|
||||
export type Zigbee2MQTTResponseError = {
|
||||
status: "error";
|
||||
data: Record<string, never>;
|
||||
error: string;
|
||||
transaction?: string;
|
||||
};
|
||||
|
||||
export type Zigbee2MQTTResponse<T extends Zigbee2MQTTResponseEndpoints> = Zigbee2MQTTResponseOK<T> | Zigbee2MQTTResponseError;
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
// minimal required because of sub-deps in mqtt >= 5.14.0 to avoid requiring `dom` type
|
||||
declare global {
|
||||
// map to node, doesn't really matter, just needs to be there, and the right "type vs value" to avoid lib check problems
|
||||
/** @deprecated DOM SHIM, DO NOT USE */
|
||||
type MessagePort = import("node:worker_threads").MessagePort;
|
||||
/** @deprecated DOM SHIM, DO NOT USE */
|
||||
type Worker = import("node:worker_threads").Worker;
|
||||
/** @deprecated DOM SHIM, DO NOT USE */
|
||||
type Transferable = import("node:worker_threads").Transferable;
|
||||
/** @deprecated DOM SHIM, DO NOT USE */
|
||||
const addEventListener: import("node:events").EventEmitter["addListener"];
|
||||
/** @deprecated DOM SHIM, DO NOT USE */
|
||||
const removeEventListener: import("node:events").EventEmitter["removeListener"];
|
||||
/** @deprecated DOM SHIM, DO NOT USE */
|
||||
const postMessage: import("node:worker_threads").MessagePort["postMessage"];
|
||||
}
|
||||
|
||||
export {};
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
declare module 'json-stable-stringify-without-jsonify' {
|
||||
declare module "json-stable-stringify-without-jsonify" {
|
||||
export default function (obj: unknown): string;
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
declare module 'mkdir-recursive' {
|
||||
export function mkdirSync(path: string): void;
|
||||
}
|
||||
Vendored
+41
-203
@@ -1,30 +1,20 @@
|
||||
/* eslint-disable camelcase */
|
||||
import type TypeEventBus from 'lib/eventBus';
|
||||
import type TypeExtension from 'lib/extension/extension';
|
||||
import type TypeDevice from 'lib/model/device';
|
||||
import type TypeGroup from 'lib/model/group';
|
||||
import type TypeMQTT from 'lib/mqtt';
|
||||
import type TypeState from 'lib/state';
|
||||
import type TypeZigbee from 'lib/zigbee';
|
||||
import type {QoS} from 'mqtt-packet';
|
||||
import type {
|
||||
NetworkParameters as ZHNetworkParameters,
|
||||
CoordinatorVersion as ZHCoordinatorVersion,
|
||||
LQI as ZHLQI,
|
||||
RoutingTable as ZHRoutingTable,
|
||||
RoutingTableEntry as ZHRoutingTableEntry,
|
||||
} from 'zigbee-herdsman/dist/adapter/tstype';
|
||||
import type * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
|
||||
import type {Device as ZHDevice, Group as ZHGroup, Endpoint as ZHEndpoint} from 'zigbee-herdsman/dist/controller/model';
|
||||
import type {Cluster as ZHCluster, FrameControl as ZHFrameControl} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
|
||||
import type * as zhc from 'zigbee-herdsman-converters';
|
||||
import type {AdapterTypes as ZHAdapterTypes, Events as ZHEvents, Models as ZHModels} from "zigbee-herdsman";
|
||||
import type {Cluster as ZHCluster, FrameControl as ZHFrameControl} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
|
||||
|
||||
import {LogLevel} from 'lib/util/settings';
|
||||
import type TypeEventBus from "../eventBus";
|
||||
import type TypeExtension from "../extension/extension";
|
||||
import type TypeDevice from "../model/device";
|
||||
import type TypeGroup from "../model/group";
|
||||
import type TypeMqtt from "../mqtt";
|
||||
import type {MqttPublishOptions} from "../mqtt";
|
||||
import type TypeState from "../state";
|
||||
import type TypeZigbee from "../zigbee";
|
||||
import type {Zigbee2MQTTDeviceOptions, Zigbee2MQTTGroupOptions, Zigbee2MQTTSettings} from "./api";
|
||||
|
||||
declare global {
|
||||
// Define some class types as global
|
||||
type EventBus = TypeEventBus;
|
||||
type MQTT = TypeMQTT;
|
||||
type Mqtt = TypeMqtt;
|
||||
type Zigbee = TypeZigbee;
|
||||
type Group = TypeGroup;
|
||||
type Device = TypeDevice;
|
||||
@@ -32,231 +22,79 @@ declare global {
|
||||
type Extension = TypeExtension;
|
||||
|
||||
// Types
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ExternalDefinition = zhc.Definition & {homeassistant: any};
|
||||
interface MQTTResponse {
|
||||
data: KeyValue;
|
||||
status: 'error' | 'ok';
|
||||
error?: string;
|
||||
transaction?: string;
|
||||
}
|
||||
interface MQTTOptions {
|
||||
qos?: QoS;
|
||||
retain?: boolean;
|
||||
properties?: {messageExpiryInterval: number};
|
||||
}
|
||||
type Scene = {id: number; name: string};
|
||||
type StateChangeReason = 'publishDebounce' | 'groupOptimistic' | 'lastSeenChanged' | 'publishCached';
|
||||
type StateChangeReason = "publishDebounce" | "groupOptimistic" | "lastSeenChanged" | "publishCached" | "publishThrottle";
|
||||
type PublishEntityState = (entity: Device | Group, payload: KeyValue, stateChangeReason?: StateChangeReason) => Promise<void>;
|
||||
type RecursivePartial<T> = {[P in keyof T]?: RecursivePartial<T[P]>};
|
||||
type MakePartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>;
|
||||
interface KeyValue {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// biome-ignore lint/suspicious/noExplicitAny: API
|
||||
[s: string]: any;
|
||||
}
|
||||
|
||||
// zigbee-herdsman
|
||||
namespace zh {
|
||||
type Endpoint = ZHEndpoint;
|
||||
type Device = ZHDevice;
|
||||
type Group = ZHGroup;
|
||||
type LQI = ZHLQI;
|
||||
type RoutingTable = ZHRoutingTable;
|
||||
type RoutingTableEntry = ZHRoutingTableEntry;
|
||||
type CoordinatorVersion = ZHCoordinatorVersion;
|
||||
type NetworkParameters = ZHNetworkParameters;
|
||||
type Cluster = ZHCluster;
|
||||
type Endpoint = ZHModels.Endpoint;
|
||||
type Device = ZHModels.Device;
|
||||
type Group = ZHModels.Group;
|
||||
// biome-ignore lint/style/useNamingConvention: API
|
||||
type LQI = ZHAdapterTypes.Lqi;
|
||||
type RoutingTable = ZHAdapterTypes.RoutingTable;
|
||||
type CoordinatorVersion = ZHAdapterTypes.CoordinatorVersion;
|
||||
type NetworkParameters = ZHAdapterTypes.NetworkParameters;
|
||||
interface Bind {
|
||||
cluster: zh.Cluster;
|
||||
cluster: ZHCluster;
|
||||
target: zh.Endpoint | zh.Group;
|
||||
}
|
||||
}
|
||||
|
||||
namespace eventdata {
|
||||
type EntityRenamed = {entity: Device | Group; homeAssisantRename: boolean; from: string; to: string};
|
||||
type EntityRemoved = {id: number | string; name: string; type: 'device' | 'group'};
|
||||
type EntityRemoved = {entity: Device | Group; name: string};
|
||||
type MQTTMessage = {topic: string; message: string};
|
||||
type MQTTMessagePublished = {topic: string; payload: string; options: {retain: boolean; qos: number}};
|
||||
type MQTTMessagePublished = {topic: string; payload: string; options: MqttPublishOptions};
|
||||
type StateChange = {
|
||||
entity: Device | Group;
|
||||
from: KeyValue;
|
||||
to: KeyValue;
|
||||
reason: string | null;
|
||||
reason?: string;
|
||||
update: KeyValue;
|
||||
};
|
||||
type PermitJoinChanged = ZHEvents.PermitJoinChangedPayload;
|
||||
type LastSeenChanged = {
|
||||
device: Device;
|
||||
reason: 'deviceAnnounce' | 'networkAddress' | 'deviceJoined' | 'messageEmitted' | 'messageNonEmitted';
|
||||
reason: "deviceAnnounce" | "networkAddress" | "deviceJoined" | "messageEmitted" | "messageNonEmitted";
|
||||
};
|
||||
type DeviceNetworkAddressChanged = {device: Device};
|
||||
type DeviceAnnounce = {device: Device};
|
||||
type DeviceInterview = {device: Device; status: 'started' | 'successful' | 'failed'};
|
||||
type DeviceInterview = {device: Device; status: "started" | "successful" | "failed"};
|
||||
type DeviceJoined = {device: Device};
|
||||
type EntityOptionsChanged = {entity: Device | Group; from: KeyValue; to: KeyValue};
|
||||
type ExposesChanged = {device: Device};
|
||||
type Reconfigure = {device: Device};
|
||||
type DeviceLeave = {ieeeAddr: string; name: string};
|
||||
type GroupMembersChanged = {group: Group; action: 'remove' | 'add' | 'remove_all'; endpoint: zh.Endpoint; skipDisableReporting: boolean};
|
||||
type PublishEntityState = {entity: Group | Device; message: KeyValue; stateChangeReason: StateChangeReason; payload: KeyValue};
|
||||
type DeviceLeave = {ieeeAddr: string; name: string; device?: Device};
|
||||
type GroupMembersChanged = {group: Group; action: "remove" | "add" | "remove_all"; endpoint: zh.Endpoint; skipDisableReporting: boolean};
|
||||
type PublishEntityState = {entity: Group | Device; message: KeyValue; stateChangeReason?: StateChangeReason; payload: KeyValue};
|
||||
type DeviceMessage = {
|
||||
type: ZHEvents.MessagePayloadType;
|
||||
device: Device;
|
||||
endpoint: zh.Endpoint;
|
||||
linkquality: number;
|
||||
groupID: number;
|
||||
groupID: number; // XXX: should this be `?`
|
||||
cluster: string | number;
|
||||
data: KeyValue | Array<string | number>;
|
||||
meta: {zclTransactionSequenceNumber?: number; manufacturerCode?: number; frameControl?: ZHFrameControl};
|
||||
meta: {zclTransactionSequenceNumber?: number; manufacturerCode?: number; frameControl?: ZHFrameControl; rawData: Buffer};
|
||||
};
|
||||
type ScenesChanged = {entity: Device | Group};
|
||||
}
|
||||
|
||||
// Settings
|
||||
// eslint-disable camelcase
|
||||
interface Settings {
|
||||
homeassistant?: {
|
||||
discovery_topic: string;
|
||||
status_topic: string;
|
||||
legacy_entity_attributes: boolean;
|
||||
legacy_triggers: boolean;
|
||||
};
|
||||
permit_join?: boolean;
|
||||
availability?: {
|
||||
active: {timeout: number};
|
||||
passive: {timeout: number};
|
||||
};
|
||||
external_converters: string[];
|
||||
mqtt: {
|
||||
base_topic: string;
|
||||
include_device_information: boolean;
|
||||
force_disable_retain: boolean;
|
||||
version?: 3 | 4 | 5;
|
||||
user?: string;
|
||||
password?: string;
|
||||
server: string;
|
||||
ca?: string;
|
||||
keepalive?: number;
|
||||
key?: string;
|
||||
cert?: string;
|
||||
client_id?: string;
|
||||
reject_unauthorized?: boolean;
|
||||
};
|
||||
serial: {
|
||||
disable_led: boolean;
|
||||
port?: string;
|
||||
adapter?: 'deconz' | 'zstack' | 'ezsp' | 'zigate' | 'ember';
|
||||
baudrate?: number;
|
||||
rtscts?: boolean;
|
||||
};
|
||||
passlist: string[];
|
||||
blocklist: string[];
|
||||
map_options: {
|
||||
graphviz: {
|
||||
colors: {
|
||||
fill: {
|
||||
enddevice: string;
|
||||
coordinator: string;
|
||||
router: string;
|
||||
};
|
||||
font: {
|
||||
coordinator: string;
|
||||
router: string;
|
||||
enddevice: string;
|
||||
};
|
||||
line: {
|
||||
active: string;
|
||||
inactive: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
ota: {
|
||||
update_check_interval: number;
|
||||
disable_automatic_update_check: boolean;
|
||||
zigbee_ota_override_index_location?: string;
|
||||
ikea_ota_use_test_url?: boolean;
|
||||
};
|
||||
frontend?: {
|
||||
auth_token?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
url?: string;
|
||||
ssl_cert?: string;
|
||||
ssl_key?: string;
|
||||
};
|
||||
devices?: {[s: string]: DeviceOptions};
|
||||
groups?: {[s: string]: GroupOptions};
|
||||
device_options: KeyValue;
|
||||
advanced: {
|
||||
legacy_api: boolean;
|
||||
legacy_availability_payload: boolean;
|
||||
log_rotation: boolean;
|
||||
log_symlink_current: boolean;
|
||||
log_output: ('console' | 'file' | 'syslog')[];
|
||||
log_directory: string;
|
||||
log_file: string;
|
||||
log_level: LogLevel;
|
||||
log_namespaced_levels: Record<string, LogLevel>;
|
||||
log_syslog: KeyValue;
|
||||
log_debug_to_mqtt_frontend: boolean;
|
||||
log_debug_namespace_ignore: string;
|
||||
pan_id: number | 'GENERATE';
|
||||
ext_pan_id: number[] | 'GENERATE';
|
||||
channel: number;
|
||||
adapter_concurrent: number | null;
|
||||
adapter_delay: number | null;
|
||||
cache_state: boolean;
|
||||
cache_state_persistent: boolean;
|
||||
cache_state_send_on_startup: boolean;
|
||||
last_seen: 'disable' | 'ISO_8601' | 'ISO_8601_local' | 'epoch';
|
||||
elapsed: boolean;
|
||||
network_key: number[] | 'GENERATE';
|
||||
timestamp_format: string;
|
||||
output: 'json' | 'attribute' | 'attribute_and_json';
|
||||
transmit_power?: number;
|
||||
// Everything below is deprecated
|
||||
availability_timeout?: number;
|
||||
availability_blocklist?: string[];
|
||||
availability_passlist?: string[];
|
||||
availability_blacklist?: string[];
|
||||
availability_whitelist?: string[];
|
||||
soft_reset_timeout: number;
|
||||
report: boolean;
|
||||
};
|
||||
type Settings = Zigbee2MQTTSettings;
|
||||
|
||||
type DeviceOptions = Zigbee2MQTTDeviceOptions;
|
||||
|
||||
interface DeviceOptionsWithId extends DeviceOptions {
|
||||
ID: string;
|
||||
}
|
||||
|
||||
interface DeviceOptions {
|
||||
ID?: string;
|
||||
disabled?: boolean;
|
||||
retention?: number;
|
||||
availability?: boolean | {timeout: number};
|
||||
optimistic?: boolean;
|
||||
retrieve_state?: boolean;
|
||||
debounce?: number;
|
||||
debounce_ignore?: string[];
|
||||
filtered_attributes?: string[];
|
||||
filtered_cache?: string[];
|
||||
filtered_optimistic?: string[];
|
||||
icon?: string;
|
||||
homeassistant?: KeyValue;
|
||||
legacy?: boolean;
|
||||
friendly_name: string;
|
||||
description?: string;
|
||||
qos?: 0 | 1 | 2;
|
||||
}
|
||||
|
||||
interface GroupOptions {
|
||||
devices?: string[];
|
||||
ID?: number;
|
||||
optimistic?: boolean;
|
||||
off_state?: 'all_members_off' | 'last_member_state';
|
||||
filtered_attributes?: string[];
|
||||
filtered_cache?: string[];
|
||||
filtered_optimistic?: string[];
|
||||
retrieve_state?: boolean;
|
||||
homeassistant?: KeyValue;
|
||||
friendly_name: string;
|
||||
description?: string;
|
||||
qos?: 0 | 1 | 2;
|
||||
}
|
||||
type GroupOptions = Zigbee2MQTTGroupOptions;
|
||||
}
|
||||
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
declare module "unix-dgram" {
|
||||
import {EventEmitter} from "node:events";
|
||||
|
||||
export class UnixDgramSocket extends EventEmitter {
|
||||
send(buf: Buffer, callback?: (err?: Error) => void): void;
|
||||
send(buf: Buffer, offset: number, length: number, path: string, callback?: (err?: Error) => void): void;
|
||||
bind(path: string): void;
|
||||
connect(remotePath: string): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export function createSocket(type: "unix_dgram", listener?: (msg: Buffer) => void): UnixDgramSocket;
|
||||
}
|
||||
Vendored
+11
-6
@@ -1,9 +1,14 @@
|
||||
declare module 'zigbee2mqtt-frontend' {
|
||||
export function getPath(): string;
|
||||
declare module "zigbee2mqtt-frontend" {
|
||||
const frontend: {
|
||||
getPath: () => string;
|
||||
};
|
||||
|
||||
export default frontend;
|
||||
}
|
||||
|
||||
declare module 'connect-gzip-static' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type RequestHandler = (req: any, res: any) => void;
|
||||
export default function gzipStatic(root: string, options?: Record<string, unknown>): RequestHandler;
|
||||
declare module "http" {
|
||||
interface IncomingMessage {
|
||||
originalUrl?: string;
|
||||
path?: string;
|
||||
}
|
||||
}
|
||||
|
||||
+8
-15
@@ -1,17 +1,10 @@
|
||||
import path from 'path';
|
||||
import path from "node:path";
|
||||
|
||||
let dataPath: string = null;
|
||||
|
||||
function load(): void {
|
||||
if (process.env.ZIGBEE2MQTT_DATA) {
|
||||
dataPath = process.env.ZIGBEE2MQTT_DATA;
|
||||
} else {
|
||||
dataPath = path.join(__dirname, '..', '..', 'data');
|
||||
dataPath = path.normalize(dataPath);
|
||||
}
|
||||
function setPath(): string {
|
||||
return process.env.ZIGBEE2MQTT_DATA ? process.env.ZIGBEE2MQTT_DATA : path.normalize(path.join(__dirname, "..", "..", "data"));
|
||||
}
|
||||
|
||||
load();
|
||||
let dataPath = setPath();
|
||||
|
||||
function joinPath(file: string): string {
|
||||
return path.resolve(dataPath, file);
|
||||
@@ -21,9 +14,9 @@ function getPath(): string {
|
||||
return dataPath;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
function testingOnlyReload(): void {
|
||||
load();
|
||||
function _testReload(): void {
|
||||
dataPath = setPath();
|
||||
}
|
||||
|
||||
export default {joinPath, getPath, testingOnlyReload};
|
||||
// biome-ignore lint/style/useNamingConvention: test
|
||||
export default {joinPath, getPath, _testReload};
|
||||
|
||||
+91
-73
@@ -1,79 +1,89 @@
|
||||
import assert from 'assert';
|
||||
import fs from 'fs';
|
||||
import fx from 'mkdir-recursive';
|
||||
import moment from 'moment';
|
||||
import path from 'path';
|
||||
import {rimrafSync} from 'rimraf';
|
||||
import winston from 'winston';
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import * as settings from './settings';
|
||||
import moment from "moment";
|
||||
import {rimrafSync} from "rimraf";
|
||||
import winston from "winston";
|
||||
|
||||
const NAMESPACE_SEPARATOR = ':';
|
||||
import * as settings from "./settings";
|
||||
|
||||
const NAMESPACE_SEPARATOR = ":";
|
||||
|
||||
class Logger {
|
||||
// @ts-expect-error initalized in `init`
|
||||
private level: settings.LogLevel;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private output: string[];
|
||||
// @ts-expect-error initalized in `init`
|
||||
private directory: string;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private logger: winston.Logger;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private fileTransport: winston.transports.FileTransportInstance;
|
||||
private debugNamespaceIgnoreRegex?: RegExp;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private namespacedLevels: Record<string, settings.LogLevel>;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private cachedNamespacedLevels: Record<string, settings.LogLevel>;
|
||||
|
||||
public init(): void {
|
||||
// What transports to enable
|
||||
this.output = settings.get().advanced.log_output;
|
||||
// Directory to log to
|
||||
const timestamp = moment(Date.now()).format('YYYY-MM-DD.HH-mm-ss');
|
||||
this.directory = settings.get().advanced.log_directory.replace('%TIMESTAMP%', timestamp);
|
||||
const logFilename = settings.get().advanced.log_file.replace('%TIMESTAMP%', timestamp);
|
||||
const timestamp = moment(Date.now()).format("YYYY-MM-DD.HH-mm-ss");
|
||||
this.directory = settings.get().advanced.log_directory.replace("%TIMESTAMP%", timestamp);
|
||||
const logFilename = settings.get().advanced.log_file.replace("%TIMESTAMP%", timestamp);
|
||||
this.level = settings.get().advanced.log_level;
|
||||
this.namespacedLevels = settings.get().advanced.log_namespaced_levels;
|
||||
this.cachedNamespacedLevels = Object.assign({}, this.namespacedLevels);
|
||||
|
||||
assert(settings.LOG_LEVELS.includes(this.level), `'${this.level}' is not valid log_level, use one of '${settings.LOG_LEVELS.join(', ')}'`);
|
||||
assert(settings.LOG_LEVELS.includes(this.level), `'${this.level}' is not valid log_level, use one of '${settings.LOG_LEVELS.join(", ")}'`);
|
||||
|
||||
const timestampFormat = (): string => moment().format(settings.get().advanced.timestamp_format);
|
||||
|
||||
this.logger = winston.createLogger({
|
||||
level: 'debug',
|
||||
level: "debug",
|
||||
format: winston.format.combine(winston.format.errors({stack: true}), winston.format.timestamp({format: timestampFormat})),
|
||||
levels: winston.config.syslog.levels,
|
||||
});
|
||||
|
||||
const consoleSilenced = !this.output.includes('console');
|
||||
const consoleSilenced = !this.output.includes("console");
|
||||
// Print to user what logging is active
|
||||
let logging = `Logging to console${consoleSilenced ? ' (silenced)' : ''}`;
|
||||
let logging = `Logging to console${consoleSilenced ? " (silenced)" : ""}`;
|
||||
|
||||
// Setup default console logger
|
||||
this.logger.add(
|
||||
new winston.transports.Console({
|
||||
silent: consoleSilenced,
|
||||
// winston.config.syslog.levels sets 'warning' as 'red'
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize({colors: {debug: 'blue', info: 'green', warning: 'yellow', error: 'red'}}),
|
||||
winston.format.printf(
|
||||
/* istanbul ignore next */ (info) => {
|
||||
return `[${info.timestamp}] ${info.level}: \t${info.message}`;
|
||||
},
|
||||
),
|
||||
),
|
||||
format: settings.get().advanced.log_console_json
|
||||
? winston.format.json()
|
||||
: winston.format.combine(
|
||||
// winston.config.syslog.levels sets 'warning' as 'red'
|
||||
winston.format.colorize({colors: {debug: "blue", info: "green", warning: "yellow", error: "red"}}),
|
||||
winston.format.printf((info) => {
|
||||
return `[${info.timestamp}] ${info.level}: \t${info.message}`;
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
if (this.output.includes('file')) {
|
||||
if (this.output.includes("file")) {
|
||||
logging += `, file (filename: ${logFilename})`;
|
||||
|
||||
// Make sure that log directory exists when not logging to stdout only
|
||||
fx.mkdirSync(this.directory);
|
||||
fs.mkdirSync(this.directory, {recursive: true});
|
||||
|
||||
if (settings.get().advanced.log_symlink_current) {
|
||||
const current = settings.get().advanced.log_directory.replace('%TIMESTAMP%', 'current');
|
||||
const actual = './' + timestamp;
|
||||
/* istanbul ignore next */
|
||||
const current = settings.get().advanced.log_directory.replace("%TIMESTAMP%", "current");
|
||||
const actual = `./${timestamp}`;
|
||||
|
||||
/* v8 ignore start */
|
||||
if (fs.existsSync(current)) {
|
||||
fs.unlinkSync(current);
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
fs.symlinkSync(actual, current);
|
||||
}
|
||||
|
||||
@@ -81,11 +91,9 @@ class Logger {
|
||||
// NOTE: the initiation of the logger even when not added as transport tries to create the logging directory
|
||||
const transportFileOptions: winston.transports.FileTransportOptions = {
|
||||
filename: path.join(this.directory, logFilename),
|
||||
format: winston.format.printf(
|
||||
/* istanbul ignore next */ (info) => {
|
||||
return `[${info.timestamp}] ${info.level}: \t${info.message}`;
|
||||
},
|
||||
),
|
||||
format: winston.format.printf((info) => {
|
||||
return `[${info.timestamp}] ${info.level}: \t${info.message}`;
|
||||
}),
|
||||
};
|
||||
|
||||
if (settings.get().advanced.log_rotation) {
|
||||
@@ -96,27 +104,28 @@ class Logger {
|
||||
|
||||
this.fileTransport = new winston.transports.File(transportFileOptions);
|
||||
this.logger.add(this.fileTransport);
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (this.output.includes('syslog')) {
|
||||
logging += `, syslog`;
|
||||
// eslint-disable-next-line
|
||||
require('winston-syslog').Syslog;
|
||||
/* v8 ignore start */
|
||||
if (this.output.includes("syslog")) {
|
||||
logging += ", syslog";
|
||||
require("winston-syslog").Syslog;
|
||||
|
||||
const options: KeyValue = {
|
||||
app_name: 'Zigbee2MQTT',
|
||||
format: winston.format.printf((info) => info.message),
|
||||
app_name: "Zigbee2MQTT",
|
||||
format: winston.format.printf((info) => info.message as string),
|
||||
...settings.get().advanced.log_syslog,
|
||||
};
|
||||
|
||||
if (options.hasOwnProperty('type')) {
|
||||
if (options.type !== undefined) {
|
||||
options.type = options.type.toString();
|
||||
}
|
||||
|
||||
// @ts-expect-error untyped transport
|
||||
this.logger.add(new winston.transports.Syslog(options));
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
this.setDebugNamespaceIgnore(settings.get().advanced.log_debug_namespace_ignore);
|
||||
|
||||
@@ -136,11 +145,15 @@ class Logger {
|
||||
}
|
||||
|
||||
public getDebugNamespaceIgnore(): string {
|
||||
return this.debugNamespaceIgnoreRegex?.toString().slice(1, -1) /* remove slashes */ ?? '';
|
||||
return (
|
||||
this.debugNamespaceIgnoreRegex
|
||||
?.toString()
|
||||
.slice(1, -1) /* remove slashes */ ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
public setDebugNamespaceIgnore(value: string): void {
|
||||
this.debugNamespaceIgnoreRegex = value != '' ? new RegExp(value) : undefined;
|
||||
this.debugNamespaceIgnoreRegex = value !== "" ? new RegExp(value) : undefined;
|
||||
}
|
||||
|
||||
public getLevel(): settings.LogLevel {
|
||||
@@ -168,11 +181,13 @@ class Logger {
|
||||
private cacheNamespacedLevel(namespace: string): string {
|
||||
let cached = namespace;
|
||||
|
||||
while (this.cachedNamespacedLevels[namespace] == undefined) {
|
||||
while (this.cachedNamespacedLevels[namespace] === undefined) {
|
||||
const sep = cached.lastIndexOf(NAMESPACE_SEPARATOR);
|
||||
|
||||
if (sep === -1) {
|
||||
return (this.cachedNamespacedLevels[namespace] = this.level);
|
||||
this.cachedNamespacedLevels[namespace] = this.level;
|
||||
|
||||
return this.level;
|
||||
}
|
||||
|
||||
cached = cached.slice(0, sep);
|
||||
@@ -182,38 +197,39 @@ class Logger {
|
||||
return this.cachedNamespacedLevels[namespace];
|
||||
}
|
||||
|
||||
private log(level: settings.LogLevel, message: string, namespace: string): void {
|
||||
private log(level: settings.LogLevel, messageOrLambda: string | (() => string), namespace: string): void {
|
||||
const nsLevel = this.cacheNamespacedLevel(namespace);
|
||||
|
||||
if (settings.LOG_LEVELS.indexOf(level) <= settings.LOG_LEVELS.indexOf(nsLevel)) {
|
||||
const message: string = messageOrLambda instanceof Function ? messageOrLambda() : messageOrLambda;
|
||||
this.logger.log(level, `${namespace}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public error(message: string, namespace: string = 'z2m'): void {
|
||||
this.log('error', message, namespace);
|
||||
public error(messageOrLambda: string | (() => string), namespace = "z2m"): void {
|
||||
this.log("error", messageOrLambda, namespace);
|
||||
}
|
||||
|
||||
public warning(message: string, namespace: string = 'z2m'): void {
|
||||
this.log('warning', message, namespace);
|
||||
public warning(messageOrLambda: string | (() => string), namespace = "z2m"): void {
|
||||
this.log("warning", messageOrLambda, namespace);
|
||||
}
|
||||
|
||||
public info(message: string, namespace: string = 'z2m'): void {
|
||||
this.log('info', message, namespace);
|
||||
public info(messageOrLambda: string | (() => string), namespace = "z2m"): void {
|
||||
this.log("info", messageOrLambda, namespace);
|
||||
}
|
||||
|
||||
public debug(message: string, namespace: string = 'z2m'): void {
|
||||
public debug(messageOrLambda: string | (() => string), namespace = "z2m"): void {
|
||||
if (this.debugNamespaceIgnoreRegex?.test(namespace)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.log('debug', message, namespace);
|
||||
this.log("debug", messageOrLambda, namespace);
|
||||
}
|
||||
|
||||
// Cleanup any old log directory.
|
||||
public cleanup(): void {
|
||||
if (settings.get().advanced.log_directory.includes('%TIMESTAMP%')) {
|
||||
const rootDirectory = path.join(this.directory, '..');
|
||||
private cleanup(): void {
|
||||
if (settings.get().advanced.log_directory.includes("%TIMESTAMP%")) {
|
||||
const rootDirectory = path.join(this.directory, "..");
|
||||
|
||||
let directories = fs.readdirSync(rootDirectory).map((d) => {
|
||||
d = path.join(rootDirectory, d);
|
||||
@@ -221,35 +237,37 @@ class Logger {
|
||||
});
|
||||
|
||||
directories.sort((a: KeyValue, b: KeyValue) => b.birth - a.birth);
|
||||
directories = directories.slice(10, directories.length);
|
||||
directories.forEach((dir) => {
|
||||
directories = directories.slice(settings.get().advanced.log_directories_to_keep, directories.length);
|
||||
|
||||
for (const dir of directories) {
|
||||
this.debug(`Removing old log directory '${dir.path}'`);
|
||||
rimrafSync(dir.path);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Workaround for https://github.com/winstonjs/winston/issues/1629.
|
||||
// https://github.com/Koenkk/zigbee2mqtt/pull/10905
|
||||
/* istanbul ignore next */
|
||||
/* v8 ignore start */
|
||||
public async end(): Promise<void> {
|
||||
this.logger.end();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!this.fileTransport) {
|
||||
process.nextTick(resolve);
|
||||
} else {
|
||||
// Only flush the file transport, don't end logger itself as log() might still be called
|
||||
// causing a UnhandledPromiseRejection (`Error: write after end`). Flushing the file transport
|
||||
// ensures the log files are written before stopping.
|
||||
if (this.fileTransport) {
|
||||
await new Promise<void>((resolve) => {
|
||||
// @ts-expect-error workaround
|
||||
if (this.fileTransport._dest) {
|
||||
// @ts-expect-error workaround
|
||||
this.fileTransport._dest.on('finish', resolve);
|
||||
this.fileTransport._dest.on("finish", resolve);
|
||||
} else {
|
||||
// @ts-expect-error workaround
|
||||
this.fileTransport.on('open', () => this.fileTransport._dest.on('finish', resolve));
|
||||
this.fileTransport.on("open", () => this.fileTransport._dest.on("finish", resolve));
|
||||
}
|
||||
}
|
||||
});
|
||||
this.fileTransport.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
export default new Logger();
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
import {existsSync, mkdirSync} from "node:fs";
|
||||
import {createServer} from "node:http";
|
||||
import {parse} from "node:querystring";
|
||||
import {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery";
|
||||
import data from "./data";
|
||||
import * as settings from "./settings";
|
||||
import {YAMLFileException} from "./yaml";
|
||||
|
||||
type OnboardSettings = {
|
||||
mqtt_base_topic?: string;
|
||||
mqtt_server?: string;
|
||||
mqtt_user?: string;
|
||||
mqtt_password?: string;
|
||||
serial_port?: string;
|
||||
serial_adapter?: Settings["serial"]["adapter"];
|
||||
serial_baudrate?: string;
|
||||
serial_rtscts?: "on";
|
||||
network_channel?: string;
|
||||
network_key?: string;
|
||||
network_pan_id?: string;
|
||||
network_ext_pan_id?: string;
|
||||
frontend_enabled?: "on";
|
||||
frontend_port?: string;
|
||||
homeassistant_enabled?: "on";
|
||||
log_level?: Settings["advanced"]["log_level"];
|
||||
};
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[^0-9A-Za-z \-_.]/g, (c) => `&#${c.charCodeAt(0)};`);
|
||||
}
|
||||
|
||||
function generateHtmlDone(frontendUrl: string | undefined): string {
|
||||
return `
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Zigbee2MQTT Onboarding</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Zigbee2MQTT Onboarding</h1>
|
||||
<p>Settings saved.</p>
|
||||
<p>Zigbee2MQTT is now starting...</p>
|
||||
<small>${frontendUrl ? `Redirecting to Zigbee2MQTT frontend at <a href="${frontendUrl}">${frontendUrl}</a> in 30 seconds.` : "You can close this page."}</small>
|
||||
</main>
|
||||
${frontendUrl ? `<script>setTimeout(() => { window.location.replace("${frontendUrl}"); }, 30000);</script>` : ""}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function generateHtmlForm(currentSettings: RecursivePartial<Settings>, devices: Awaited<ReturnType<typeof findAllDevices>>): string {
|
||||
let devicesSelect = "";
|
||||
|
||||
if (devices.length > 0) {
|
||||
devicesSelect += '<select id="found_device" onchange="setFoundDevice(this)">';
|
||||
devicesSelect += '<option value="">Select a device</option>';
|
||||
|
||||
for (const device of devices) {
|
||||
// just in case name has commas, remove them to not mess with `split` logic
|
||||
const deviceStr = `${device.name.replaceAll(",", "")}, ${device.path}, ${device.adapter ?? "unknown"}`;
|
||||
|
||||
devicesSelect += `<option value="${deviceStr}">${deviceStr}</option>`;
|
||||
}
|
||||
|
||||
devicesSelect += "</select>";
|
||||
devicesSelect += "<small>Optionally allows to configure coordinator port and type (if known) automatically.</small>";
|
||||
} else {
|
||||
devicesSelect = "<small>No device found</small>";
|
||||
}
|
||||
|
||||
let generateCheckbox = "";
|
||||
|
||||
if (
|
||||
Array.isArray(currentSettings.advanced?.network_key) ||
|
||||
typeof currentSettings.advanced?.pan_id === "number" ||
|
||||
Array.isArray(currentSettings.advanced?.ext_pan_id)
|
||||
) {
|
||||
generateCheckbox = `
|
||||
<label for="generate_network">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="generate_network"
|
||||
onclick="setGenerate(this)"
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_NETWORK_KEY || process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_PAN_ID || process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_EXT_PAN_ID ? "disabled" : ""}>
|
||||
Generate network?
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
/* v8 ignore start */
|
||||
return `
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Zigbee2MQTT Onboarding</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Zigbee2MQTT Onboarding</h1>
|
||||
<p>Set the base configuration to start Zigbee2MQTT.</p>
|
||||
<p>Optional fields will either be ignored or fallback to defaults if not set (see appropriate documentation page for more details).</p>
|
||||
<p>If a field is disabled, it means <a href="https://www.zigbee2mqtt.io/guide/configuration/#environment-variables" target="_blank">environment variables</a> are being used to override specific values (for example, through the Home Assistant add-on configuration page).</p>
|
||||
<hr>
|
||||
<form method="post">
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_SERIAL || process.env.ZIGBEE2MQTT_CONFIG_SERIAL_PORT || process.env.ZIGBEE2MQTT_CONFIG_SERIAL_ADAPTER ? "disabled" : ""}>
|
||||
<label for="found_device">Found Devices</label>
|
||||
${devicesSelect}
|
||||
</fieldset>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_SERIAL ? "disabled" : ""}>
|
||||
<label for="serial_port">Coordinator/Adapter Port/Path</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serial_port"
|
||||
name="serial_port"
|
||||
value="${currentSettings.serial?.port ?? ""}"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_PORT ? "disabled" : ""}>
|
||||
<label for="serial_adapter">Coordinator/Adapter Type/Stack/Driver</label>
|
||||
<select id="serial_adapter" name="serial_adapter" required ${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_ADAPTER ? "disabled" : ""}>
|
||||
<option value="zstack" ${currentSettings.serial?.adapter === "zstack" ? "selected" : ""}>zstack</option>
|
||||
<option value="ember" ${currentSettings.serial?.adapter === "ember" ? "selected" : ""}>ember</option>
|
||||
<option value="deconz" ${currentSettings.serial?.adapter === "deconz" ? "selected" : ""}>deconz</option>
|
||||
<option value="zigate" ${currentSettings.serial?.adapter === "zigate" ? "selected" : ""}>zigate</option>
|
||||
<option value="zboss" ${currentSettings.serial?.adapter === "zboss" ? "selected" : ""}>zboss</option>
|
||||
</select>
|
||||
<label for="serial_baudrate">Coordinator/Adapter Baudrate</label>
|
||||
<select id="serial_baudrate" name="serial_baudrate" ${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_BAUDRATE ? "disabled" : ""}>
|
||||
<option value="38400" ${currentSettings.serial?.baudrate === 38400 ? "selected" : ""}>38400</option>
|
||||
<option value="57600" ${currentSettings.serial?.baudrate === 57600 ? "selected" : ""}>57600</option>
|
||||
<option value="115200" ${!currentSettings.serial?.baudrate || currentSettings.serial?.baudrate === 115200 ? "selected" : ""}>115200</option>
|
||||
<option value="230400" ${currentSettings.serial?.baudrate === 230400 ? "selected" : ""}>230400</option>
|
||||
<option value="460800" ${currentSettings.serial?.baudrate === 460800 ? "selected" : ""}>460800</option>
|
||||
<option value="921600" ${currentSettings.serial?.baudrate === 921600 ? "selected" : ""}>921600</option>
|
||||
</select>
|
||||
<small>Can be ignored for networked coordinators (TCP).</small>
|
||||
<label for="serial_rtscts">Coordinator/Adapter Hardware Flow Control ("rtscts: true")</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="serial_rtscts"
|
||||
name="serial_rtscts"
|
||||
${currentSettings.serial?.rtscts ? "checked" : ""}
|
||||
style="margin-bottom: 1rem;">
|
||||
<small>Can be ignored for networked coordinators (TCP).</small>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/adapter-settings.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/adapter-settings.html</a>
|
||||
</small>
|
||||
<hr>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED ? "disabled" : ""}>
|
||||
<label for="closest_wifi_channel">Closest WiFi Channel</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="14"
|
||||
id="closest_wifi_channel"
|
||||
value="0"
|
||||
onclick="setBestZigbeeChannel(this)"
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_CHANNEL ? "disabled" : ""}>
|
||||
<small>Optionally set to your closest WiFi channel to pick the best value for "Network channel" below.</small>
|
||||
<label for="network_channel">Network Channel</label>
|
||||
<input
|
||||
type="number"
|
||||
min="11"
|
||||
max="26"
|
||||
id="network_channel"
|
||||
name="network_channel"
|
||||
value="${currentSettings.advanced?.channel ?? "25"}"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_CHANNEL ? "disabled" : ""}>
|
||||
</fieldset>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED ? "disabled" : ""}>
|
||||
${generateCheckbox}
|
||||
<label for="network_key">Network Key</label>
|
||||
<input
|
||||
type="text"
|
||||
id="network_key"
|
||||
name="network_key"
|
||||
value="${currentSettings.advanced?.network_key ?? "GENERATE"}"
|
||||
pattern="^([0-9]+(,[0-9]+){15})|GENERATE$"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_NETWORK_KEY ? "disabled" : ""}>
|
||||
<label for="network_pan_id">Network PAN ID</label>
|
||||
<input
|
||||
type="text"
|
||||
id="network_pan_id"
|
||||
name="network_pan_id"
|
||||
value="${currentSettings.advanced?.pan_id ?? "GENERATE"}"
|
||||
pattern="^([0-9]{1,5})|GENERATE$"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_PAN_ID ? "disabled" : ""}>
|
||||
<label for="network_ext_pan_id">Network Extended PAN ID</label>
|
||||
<input
|
||||
type="text"
|
||||
id="network_ext_pan_id"
|
||||
name="network_ext_pan_id"
|
||||
value="${currentSettings.advanced?.ext_pan_id ?? "GENERATE"}"
|
||||
pattern="^([0-9]+(,[0-9]+){7})|GENERATE$"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_EXT_PAN_ID ? "disabled" : ""}>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/zigbee-network.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/zigbee-network.html</a>
|
||||
</small>
|
||||
<hr>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_MQTT ? "disabled" : ""}>
|
||||
<label for="mqtt_base_topic">MQTT Base Topic</label>
|
||||
<input
|
||||
type="text"
|
||||
id="mqtt_base_topic"
|
||||
name="mqtt_base_topic"
|
||||
value="${currentSettings.mqtt?.base_topic ?? "zigbee2mqtt"}"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_MQTT_BASE_TOPIC ? "disabled" : ""}>
|
||||
<label for="mqtt_server">MQTT Server</label>
|
||||
<input
|
||||
type="text"
|
||||
id="mqtt_server"
|
||||
name="mqtt_server"
|
||||
value="${currentSettings.mqtt?.server ?? "mqtt://localhost:1883"}"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER ? "disabled" : ""}>
|
||||
<label for="mqtt_user">MQTT User</label>
|
||||
<input
|
||||
type="text"
|
||||
id="mqtt_user"
|
||||
name="mqtt_user"
|
||||
value="${currentSettings.mqtt?.user ?? ""}"
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_MQTT_USER ? "disabled" : ""}>
|
||||
<small>Optional. Set only if using authentication.</small>
|
||||
<label for="mqtt_password">MQTT Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="mqtt_password"
|
||||
name="mqtt_password"
|
||||
value="${currentSettings.mqtt?.password ?? ""}"
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_MQTT_PASSWORD ? "disabled" : ""}>
|
||||
<small>Optional. Set only if using authentication.</small>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/mqtt.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/mqtt.html</a>
|
||||
</small>
|
||||
<hr>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_FRONTEND ? "disabled" : ""}>
|
||||
<label for="frontend_enabled">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="frontend_enabled"
|
||||
name="frontend_enabled"
|
||||
${currentSettings.frontend?.enabled ? "checked" : ""}
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_FRONTEND_ENABLED ? "disabled" : ""}>
|
||||
Frontend enabled?
|
||||
</label>
|
||||
<label for="frontend_port">Frontend Port</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="65535"
|
||||
id="frontend_port"
|
||||
name="frontend_port"
|
||||
value="${currentSettings.frontend?.port ?? "8080"}"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_FRONTEND_PORT ? "disabled" : ""}>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/frontend.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/frontend.html</a>
|
||||
</small>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_HOMEASSISTANT ? "disabled" : ""}>
|
||||
<label for="homeassistant_enabled" ${process.env.ZIGBEE2MQTT_CONFIG_HOMEASSISTANT_ENABLED ? "disabled" : ""}>
|
||||
<input type="checkbox" id="homeassistant_enabled" name="homeassistant_enabled" ${currentSettings.homeassistant?.enabled ? "checked" : ""}>
|
||||
Home Assistant enabled?
|
||||
</label>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/homeassistant.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/homeassistant.html</a>
|
||||
</small>
|
||||
<hr>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED ? "disabled" : ""}>
|
||||
<label for="log_level">Log Level</label>
|
||||
<select id="log_level" name="log_level" ${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_LOG_LEVEL ? "disabled" : ""}>
|
||||
<option value="error" ${currentSettings.advanced?.log_level === "error" ? "selected" : ""}>error</option>
|
||||
<option value="warning" ${currentSettings.advanced?.log_level === "warning" ? "selected" : ""}>warning</option>
|
||||
<option value="info" ${!currentSettings.advanced?.log_level || currentSettings.advanced?.log_level === "info" ? "selected" : ""}>info</option>
|
||||
<option value="debug" ${currentSettings.advanced?.log_level === "debug" ? "selected" : ""}>debug</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/logging.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/logging.html</a>
|
||||
</small>
|
||||
<hr>
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
</main>
|
||||
<script>
|
||||
function setFoundDevice(e) {
|
||||
if (!e.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, path, adapter] = e.value.split(", ");
|
||||
const serialPortEl = document.querySelector("#serial_port");
|
||||
serialPortEl.value = path;
|
||||
const serialAdapterEl = document.querySelector("#serial_adapter");
|
||||
|
||||
if (['zstack', 'ember', 'deconz', 'zigate', 'zboss'].includes(adapter)) {
|
||||
serialAdapterEl.value = adapter;
|
||||
} else {
|
||||
serialAdapterEl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function setBestZigbeeChannel(e) {
|
||||
const wifiChannel = parseInt(e.value, 10);
|
||||
const networkChannelEl = document.querySelector("#network_channel");
|
||||
|
||||
if (wifiChannel >= 11) {
|
||||
// WiFi 11-14
|
||||
networkChannelEl.value = 15;
|
||||
} else if (wifiChannel >= 6) {
|
||||
// WiFi 6-10
|
||||
networkChannelEl.value = 11;
|
||||
} else {
|
||||
// WiFi 1-5
|
||||
networkChannelEl.value = 25;
|
||||
}
|
||||
}
|
||||
|
||||
function setGenerate(e) {
|
||||
document.querySelector("#network_key").value = e.checked ? "GENERATE" : "${currentSettings.advanced?.network_key ?? "GENERATE"}";
|
||||
document.querySelector("#network_pan_id").value = e.checked ? "GENERATE" : "${currentSettings.advanced?.pan_id ?? "GENERATE"}";
|
||||
document.querySelector("#network_ext_pan_id").value = e.checked ? "GENERATE" : "${currentSettings.advanced?.ext_pan_id ?? "GENERATE"}";
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
function generateHtmlError(errors: string): string {
|
||||
return `
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Zigbee2MQTT Onboarding</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Zigbee2MQTT configuration is not valid</h1>
|
||||
<p style="color: #F00;">Found the following errors:</p>
|
||||
${errors}
|
||||
<hr>
|
||||
<p>If you don't know how to solve this, read <a href="https://www.zigbee2mqtt.io/guide/configuration" target="_blank">https://www.zigbee2mqtt.io/guide/configuration</a></p>
|
||||
<form method="post" action="/">
|
||||
<input type="submit" value="Close">
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function getServerUrl(): URL {
|
||||
return new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080");
|
||||
}
|
||||
|
||||
async function startOnboardingServer(): Promise<boolean> {
|
||||
const currentSettings = settings.get();
|
||||
const serverUrl = getServerUrl();
|
||||
let server: ReturnType<typeof createServer> | undefined;
|
||||
let failed = false;
|
||||
|
||||
const success = await new Promise<boolean>((resolve) => {
|
||||
server = createServer(async (req, res) => {
|
||||
if (req.method === "POST") {
|
||||
if (failed) {
|
||||
res.end(() => {
|
||||
resolve(false);
|
||||
});
|
||||
} else {
|
||||
let body = "";
|
||||
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
const result = parse(body) as unknown as OnboardSettings;
|
||||
const frontendEnabled = result.frontend_enabled === "on";
|
||||
const updatedSettings: RecursivePartial<Settings> = {
|
||||
mqtt: {
|
||||
base_topic: result.mqtt_base_topic,
|
||||
server: result.mqtt_server,
|
||||
user: result.mqtt_user || undefined, // empty string => removed
|
||||
password: result.mqtt_password || undefined, // empty string => removed
|
||||
},
|
||||
serial: {
|
||||
port: result.serial_port,
|
||||
adapter: result.serial_adapter,
|
||||
baudrate: result.serial_baudrate ? Number.parseInt(result.serial_baudrate, 10) : undefined,
|
||||
rtscts: result.serial_rtscts === "on",
|
||||
},
|
||||
advanced: {
|
||||
log_level: result.log_level,
|
||||
channel: result.network_channel ? Number.parseInt(result.network_channel, 10) : undefined,
|
||||
network_key: result.network_key
|
||||
? result.network_key === "GENERATE"
|
||||
? result.network_key
|
||||
: result.network_key.split(",").map((v) => Number.parseInt(v, 10))
|
||||
: undefined,
|
||||
pan_id: result.network_pan_id
|
||||
? result.network_pan_id === "GENERATE"
|
||||
? result.network_pan_id
|
||||
: Number.parseInt(result.network_pan_id, 10)
|
||||
: undefined,
|
||||
ext_pan_id: result.network_ext_pan_id
|
||||
? result.network_ext_pan_id === "GENERATE"
|
||||
? result.network_ext_pan_id
|
||||
: result.network_ext_pan_id.split(",").map((v) => Number.parseInt(v, 10))
|
||||
: undefined,
|
||||
},
|
||||
frontend: {
|
||||
enabled: frontendEnabled,
|
||||
port: result.frontend_port ? Number.parseInt(result.frontend_port, 10) : undefined,
|
||||
},
|
||||
homeassistant: {
|
||||
enabled: result.homeassistant_enabled === "on",
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
settings.apply(updatedSettings);
|
||||
|
||||
// to redirect, make sure frontend "will be" enabled, and host isn't socket
|
||||
const redirect =
|
||||
!process.env.Z2M_ONBOARD_NO_REDIRECT &&
|
||||
frontendEnabled &&
|
||||
(!currentSettings.frontend?.host || !currentSettings.frontend.host.startsWith("/"));
|
||||
const protocol = currentSettings.frontend?.ssl_cert && currentSettings.frontend.ssl_key ? "https" : "http";
|
||||
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.writeHead(200);
|
||||
res.end(
|
||||
generateHtmlDone(
|
||||
redirect
|
||||
? /* v8 ignore next */ `${protocol}://${currentSettings.frontend?.host ?? "localhost"}:${currentSettings.frontend?.port ?? "8080"}${currentSettings.frontend?.base_url ?? "/"}`
|
||||
: undefined,
|
||||
),
|
||||
() => {
|
||||
resolve(true);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to apply configuration: ${(error as Error).message}`);
|
||||
failed = true;
|
||||
|
||||
if (process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) {
|
||||
res.end(() => {
|
||||
resolve(false);
|
||||
});
|
||||
} else {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.writeHead(406);
|
||||
res.end(generateHtmlError(`<p>${escapeHtml((error as Error).message)}</p>`));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.writeHead(200);
|
||||
res.end(generateHtmlForm(currentSettings, await findAllDevices()));
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => {
|
||||
console.log(`Onboarding page is available at ${serverUrl.href}`);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server?.close(resolve));
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
async function startFailureServer(errors: string): Promise<void> {
|
||||
const serverUrl = getServerUrl();
|
||||
let server: ReturnType<typeof createServer> | undefined;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server = createServer((req, res) => {
|
||||
if (req.method === "POST") {
|
||||
res.end(() => {
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.writeHead(406);
|
||||
res.end(generateHtmlError(errors));
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => {
|
||||
console.error(`Failure page is available at ${serverUrl.href}`);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server?.close(resolve));
|
||||
}
|
||||
|
||||
async function onSettingsErrors(errors: string[]): Promise<void> {
|
||||
let pErrors = "";
|
||||
|
||||
console.error("\n\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
|
||||
console.error(" READ THIS CAREFULLY\n");
|
||||
console.error("Refusing to start because configuration is not valid, found the following errors:");
|
||||
|
||||
for (const error of errors) {
|
||||
console.error(`- ${error}`);
|
||||
|
||||
pErrors += `<p>- ${escapeHtml(error)}</p>`;
|
||||
}
|
||||
|
||||
console.error("\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration");
|
||||
console.error("\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n");
|
||||
|
||||
if (!process.env.Z2M_ONBOARD_NO_SERVER && !process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) {
|
||||
await startFailureServer(pErrors);
|
||||
}
|
||||
}
|
||||
|
||||
export async function onboard(): Promise<boolean> {
|
||||
if (!existsSync(data.getPath())) {
|
||||
mkdirSync(data.getPath(), {recursive: true});
|
||||
}
|
||||
|
||||
const confExists = existsSync(data.joinPath("configuration.yaml"));
|
||||
|
||||
if (confExists) {
|
||||
// initial caching, ensure file is valid yaml first
|
||||
try {
|
||||
settings.getPersistedSettings();
|
||||
} catch (error) {
|
||||
await onSettingsErrors(
|
||||
error instanceof YAMLFileException
|
||||
? [`Your configuration file: '${error.file}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`]
|
||||
: [`${error}`],
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// migrate first
|
||||
const {migrateIfNecessary} = await import("./settingsMigration.js");
|
||||
|
||||
migrateIfNecessary();
|
||||
|
||||
// make sure existing settings are valid before applying envs
|
||||
const errors = settings.validateNonRequired();
|
||||
|
||||
if (errors.length > 0) {
|
||||
await onSettingsErrors(errors);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// trigger initial writing of `ZIGBEE2MQTT_CONFIG_*` ENVs
|
||||
settings.write();
|
||||
} else {
|
||||
settings.writeMinimalDefaults();
|
||||
}
|
||||
|
||||
// use `configuration.yaml` file to detect "brand new install"
|
||||
// env allows to re-run onboard even with existing install
|
||||
if (!process.env.Z2M_ONBOARD_NO_SERVER && (process.env.Z2M_ONBOARD_FORCE_RUN || !confExists || settings.get().onboarding)) {
|
||||
settings.setOnboarding(true);
|
||||
|
||||
const success = await startOnboardingServer();
|
||||
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
settings.reRead();
|
||||
|
||||
const errors = settings.validate();
|
||||
|
||||
if (errors.length > 0) {
|
||||
await onSettingsErrors(errors);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {platform} from "node:os";
|
||||
import type {UnixDgramSocket} from "unix-dgram";
|
||||
|
||||
import logger from "./logger";
|
||||
|
||||
/**
|
||||
* Handle sd_notify protocol, @see https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html
|
||||
* No-op if running on unsupported platforms or without Type=notify
|
||||
* Soft-fails if improperly setup (this is not necessary for Zigbee2MQTT to function properly)
|
||||
*/
|
||||
export async function initSdNotify(): Promise<{notifyStopping: () => void; stop: () => void} | undefined> {
|
||||
if (!process.env.NOTIFY_SOCKET) {
|
||||
return;
|
||||
}
|
||||
|
||||
let socket: UnixDgramSocket | undefined;
|
||||
|
||||
try {
|
||||
const {createSocket} = await import("unix-dgram");
|
||||
socket = createSocket("unix_dgram");
|
||||
} catch (error) {
|
||||
if (platform() !== "win32" || process.env.WSL_DISTRO_NAME) {
|
||||
// not on plain Windows
|
||||
logger.error(`Could not init sd_notify: ${(error as Error).message}`);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
} else {
|
||||
// this should not happen
|
||||
logger.warning(`NOTIFY_SOCKET env is set: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const sendToSystemd = (msg: string): void => {
|
||||
const buffer = Buffer.from(msg);
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from start of function
|
||||
socket.send(buffer, 0, buffer.byteLength, process.env.NOTIFY_SOCKET!, (err) => {
|
||||
if (err) {
|
||||
logger.warning(`Failed to send "${msg}" to systemd: ${err.message}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
const notifyStopping = (): void => sendToSystemd("STOPPING=1");
|
||||
|
||||
sendToSystemd("READY=1");
|
||||
|
||||
const wdUSec = process.env.WATCHDOG_USEC !== undefined ? Math.max(0, Number.parseInt(process.env.WATCHDOG_USEC, 10)) : -1;
|
||||
|
||||
if (wdUSec > 0) {
|
||||
// Convert us to ms, send twice as frequently as the timeout
|
||||
const watchdogInterval = setInterval(() => sendToSystemd("WATCHDOG=1"), wdUSec / 1000 / 2);
|
||||
|
||||
return {
|
||||
notifyStopping,
|
||||
stop: (): void => clearInterval(watchdogInterval),
|
||||
};
|
||||
}
|
||||
|
||||
if (wdUSec !== -1) {
|
||||
logger.warning(`WATCHDOG_USEC invalid: "${process.env.WATCHDOG_USEC}", parsed to "${wdUSec}"`);
|
||||
}
|
||||
|
||||
return {
|
||||
notifyStopping,
|
||||
stop: (): void => {},
|
||||
};
|
||||
}
|
||||
+307
-299
@@ -3,110 +3,115 @@
|
||||
"properties": {
|
||||
"homeassistant": {
|
||||
"title": "Home Assistant integration",
|
||||
"requiresRestart": true,
|
||||
"description": "Home Assistant integration (MQTT discovery)",
|
||||
"default": false,
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant (simple)"
|
||||
"title": "Enabled",
|
||||
"description": "Enable Home Assistant integration",
|
||||
"default": false,
|
||||
"requiresRestart": true
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Home Assistant (advanced)",
|
||||
"properties": {
|
||||
"legacy_triggers": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant legacy triggers",
|
||||
"description": "Home Assistant legacy triggers, when enabled Zigbee2mqt will send an empty 'action' or 'click' after one has been send. A 'sensor_action' and 'sensor_click' will be discoverd",
|
||||
"default": true
|
||||
},
|
||||
"discovery_topic": {
|
||||
"type": "string",
|
||||
"title": "Homeassistant discovery topic",
|
||||
"description": "Home Assistant discovery topic",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant"]
|
||||
},
|
||||
"legacy_entity_attributes": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant legacy entity attributes",
|
||||
"description": "Home Assistant legacy entity attributes, when enabled Zigbee2MQTT will add state attributes to each entity, additional to the separate entities and devices it already creates",
|
||||
"default": true
|
||||
},
|
||||
"status_topic": {
|
||||
"type": "string",
|
||||
"title": "Home Assistant status topic",
|
||||
"description": "Home Assistant status topic",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant/status"]
|
||||
}
|
||||
}
|
||||
"discovery_topic": {
|
||||
"type": "string",
|
||||
"title": "Homeassistant discovery topic",
|
||||
"description": "Home Assistant discovery topic",
|
||||
"default": "homeassistant",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant"]
|
||||
},
|
||||
"status_topic": {
|
||||
"type": "string",
|
||||
"title": "Home Assistant status topic",
|
||||
"description": "Home Assistant status topic",
|
||||
"default": "homeassistant/status",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant/status"]
|
||||
},
|
||||
"legacy_action_sensor": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant legacy action sensors",
|
||||
"description": "Home Assistant legacy actions sensor, when enabled a action sensor will be discoverd and an empty `action` will be send after every published action.",
|
||||
"default": false
|
||||
},
|
||||
"experimental_event_entities": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant experimental event entities",
|
||||
"description": "Home Assistant experimental event entities, when enabled Zigbee2MQTT will add event entities for exposed actions. The events and attributes are currently deemed experimental and subject to change.",
|
||||
"default": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"permit_join": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"title": "Permit join",
|
||||
"description": "Allow new devices to join (re-applied at restart)"
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"availability": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"title": "Availability (simple)"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Availability (advanced)",
|
||||
"properties": {
|
||||
"active": {
|
||||
"type": "object",
|
||||
"title": "Active",
|
||||
"requiresRestart": true,
|
||||
"description": "Options for active devices (routers/mains powered)",
|
||||
"properties": {
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"requiresRestart": true,
|
||||
"default": 10,
|
||||
"description": "Time after which an active device will be marked as offline in minutes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"passive": {
|
||||
"type": "object",
|
||||
"title": "Passive",
|
||||
"requiresRestart": true,
|
||||
"description": "Options for passive devices (mostly battery powered)",
|
||||
"properties": {
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"requiresRestart": true,
|
||||
"default": 1500,
|
||||
"description": "Time after which an passive device will be marked as offline in minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "object",
|
||||
"title": "Availability",
|
||||
"requiresRestart": true,
|
||||
"description": "Checks whether devices are online/offline"
|
||||
},
|
||||
"external_converters": {
|
||||
"type": "array",
|
||||
"title": "External converters",
|
||||
"description": "You can define external converters to e.g. add support for a DiY device",
|
||||
"requiresRestart": true,
|
||||
"items": {
|
||||
"type": "string"
|
||||
"description": "Checks whether devices are online/offline",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Enabled",
|
||||
"description": "Enable availability checks",
|
||||
"default": false,
|
||||
"requiresRestart": true
|
||||
},
|
||||
"active": {
|
||||
"type": "object",
|
||||
"title": "Active",
|
||||
"requiresRestart": true,
|
||||
"description": "Options for active devices (routers/mains powered)",
|
||||
"properties": {
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"requiresRestart": true,
|
||||
"default": 10,
|
||||
"minimum": 1,
|
||||
"description": "Time after which an active device will be marked as offline in minutes"
|
||||
},
|
||||
"max_jitter": {
|
||||
"type": "number",
|
||||
"title": "Max jitter",
|
||||
"default": 30000,
|
||||
"minimum": 1000,
|
||||
"description": "Maximum jitter (in msec) allowed on timeout to avoid availability pings trying to trigger around the same time"
|
||||
},
|
||||
"backoff": {
|
||||
"type": "boolean",
|
||||
"title": "Backoff",
|
||||
"description": "Enable timeout backoff on failed availability pings (x1.5, x3, x6, x12...)",
|
||||
"default": true
|
||||
},
|
||||
"pause_on_backoff_gt": {
|
||||
"type": "number",
|
||||
"title": "Pause on backoff greater than",
|
||||
"default": 0,
|
||||
"minimum": 0,
|
||||
"description": "Pause availability pings when backoff reaches over this limit until a new Zigbee message is received from the device. A value of zero disables pausing."
|
||||
}
|
||||
},
|
||||
"required": ["timeout"]
|
||||
},
|
||||
"passive": {
|
||||
"type": "object",
|
||||
"title": "Passive",
|
||||
"requiresRestart": true,
|
||||
"description": "Options for passive devices (mostly battery powered)",
|
||||
"properties": {
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"requiresRestart": true,
|
||||
"default": 1500,
|
||||
"minimum": 1,
|
||||
"description": "Time after which an passive device will be marked as offline in minutes"
|
||||
}
|
||||
},
|
||||
"required": ["timeout"]
|
||||
}
|
||||
},
|
||||
"examples": ["DIYRuZ_FreePad.js"]
|
||||
"required": []
|
||||
},
|
||||
"mqtt": {
|
||||
"type": "object",
|
||||
@@ -201,8 +206,17 @@
|
||||
"type": "boolean",
|
||||
"title": "Force disable retain",
|
||||
"requiresRestart": true,
|
||||
"description": "Disable retain for all send messages. ONLY enable if you MQTT broker doesn't support retained message (e.g. AWS IoT core, Azure IoT Hub, Google Cloud IoT core, IBM Watson IoT Platform). Enabling will break the Home Assistant integration",
|
||||
"description": "Disable retain for all send messages. ONLY enable if your MQTT broker doesn't support retained message (e.g. AWS IoT core, Azure IoT Hub, Google Cloud IoT core, IBM Watson IoT Platform). Enabling will break the Home Assistant integration",
|
||||
"default": false
|
||||
},
|
||||
"maximum_packet_size": {
|
||||
"type": "number",
|
||||
"title": "Maximum packet size",
|
||||
"requiresRestart": true,
|
||||
"description": "Specifies the maximum allowed packet length (in bytes) that the server can send to Zigbee2MQTT. NOTE: The same value exists in your MQTT broker but for the length the client can send to it instead.",
|
||||
"default": 1048576,
|
||||
"minimum": 20,
|
||||
"maximum": 268435456
|
||||
}
|
||||
},
|
||||
"required": ["server"]
|
||||
@@ -226,10 +240,9 @@
|
||||
"default": false
|
||||
},
|
||||
"adapter": {
|
||||
"type": ["string"],
|
||||
"enum": ["deconz", "zstack", "zigate", "ezsp", "auto", "ember", "zboss"],
|
||||
"type": "string",
|
||||
"enum": ["deconz", "zstack", "zigate", "ezsp", "ember", "zboss", "zoh"],
|
||||
"title": "Adapter",
|
||||
"default": "auto",
|
||||
"requiresRestart": true,
|
||||
"description": "Adapter type, not needed unless you are experiencing problems"
|
||||
},
|
||||
@@ -237,8 +250,8 @@
|
||||
"type": "number",
|
||||
"title": "Baudrate",
|
||||
"requiresRestart": true,
|
||||
"description": "Baud rate speed for serial port, this can be anything firmware support but default is 115200 for Z-Stack and EZSP, 38400 for Deconz, however note that some EZSP firmware need 57600",
|
||||
"examples": [38400, 57600, 115200]
|
||||
"description": "Baud rate speed for the serial port. This must match what the firmware on your adapter supports (most commonly 115200).",
|
||||
"examples": [115200, 921600, 460800, 230400, 57600, 38400]
|
||||
},
|
||||
"rtscts": {
|
||||
"type": "boolean",
|
||||
@@ -280,13 +293,16 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enddevice": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"default": "#fff8ce"
|
||||
},
|
||||
"coordinator": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"default": "#e04e5d"
|
||||
},
|
||||
"router": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"default": "#4ea3e0"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -294,13 +310,16 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enddevice": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"default": "#000000"
|
||||
},
|
||||
"coordinator": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"default": "#ffffff"
|
||||
},
|
||||
"router": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"default": "#ffffff"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -308,10 +327,12 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"default": "#009900"
|
||||
},
|
||||
"inactive": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"default": "#994444"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -329,7 +350,8 @@
|
||||
"type": "number",
|
||||
"title": "Update check interval",
|
||||
"description": "Your device may request a check for a new firmware update. This value determines how frequently third party servers may actually be contacted to look for firmware updates. The value is set in minutes, and the default is 1 day.",
|
||||
"default": 1440
|
||||
"default": 1440,
|
||||
"minimum": 1
|
||||
},
|
||||
"disable_automatic_update_check": {
|
||||
"type": "boolean",
|
||||
@@ -337,75 +359,113 @@
|
||||
"description": "Zigbee devices may request a firmware update, and do so frequently, causing Zigbee2MQTT to reach out to third party servers. If you disable these device initiated checks, you can still initiate a firmware update check manually.",
|
||||
"default": false
|
||||
},
|
||||
"ikea_ota_use_test_url": {
|
||||
"type": "boolean",
|
||||
"title": "IKEA TRADFRI OTA use test url",
|
||||
"requiresRestart": true,
|
||||
"description": "Use IKEA TRADFRI OTA test server, see OTA updates documentation",
|
||||
"default": false
|
||||
},
|
||||
"zigbee_ota_override_index_location": {
|
||||
"type": ["string", "null"],
|
||||
"title": "OTA index override file name",
|
||||
"requiresRestart": true,
|
||||
"description": "Location of override OTA index file",
|
||||
"examples": ["index.json"]
|
||||
},
|
||||
"image_block_response_delay": {
|
||||
"type": "number",
|
||||
"title": "Image block response delay",
|
||||
"description": "Limits the rate of requests (in milliseconds) during OTA updates to reduce network congestion. You can increase this value if your network appears unstable during OTA.",
|
||||
"default": 250,
|
||||
"minimum": 50,
|
||||
"requiresRestart": true
|
||||
},
|
||||
"default_maximum_data_size": {
|
||||
"type": "number",
|
||||
"title": "Default maximum data size",
|
||||
"description": "The size of file chunks sent during an update (in bytes). Note: This value may get ignored for manufacturers that require specific values.",
|
||||
"default": 50,
|
||||
"minimum": 10,
|
||||
"maximum": 100,
|
||||
"requiresRestart": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"title": "Frontend (simple)"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Frontend (advanced)",
|
||||
"properties": {
|
||||
"port": {
|
||||
"type": "number",
|
||||
"title": "Port",
|
||||
"description": "Frontend binding port. Ignored when using a unix domain socket",
|
||||
"default": 8080,
|
||||
"requiresRestart": true
|
||||
},
|
||||
"host": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Bind host",
|
||||
"description": "Frontend binding host. Binds to a unix socket when an absolute path is given instead.",
|
||||
"examples": ["127.0.0.1", "::1", "/run/zigbee2mqtt/zigbee2mqtt.sock"],
|
||||
"requiresRestart": true
|
||||
},
|
||||
"auth_token": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Auth token",
|
||||
"description": "Enables authentication, disabled by default",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"url": {
|
||||
"type": ["string", "null"],
|
||||
"title": "URL",
|
||||
"description": "URL on which the frontend can be reached, currently only used for the Home Assistant device configuration page",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"ssl_cert": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Certificate file path",
|
||||
"description": "SSL Certificate file path for exposing HTTPS. The sibling property 'ssl_key' must be set for HTTPS to be activated.",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"ssl_key": {
|
||||
"type": ["string", "null"],
|
||||
"title": "key file path",
|
||||
"description": "SSL key file path for exposing HTTPS. The sibling property 'ssl_cert' must be set for HTTPS to be activated.",
|
||||
"requiresRestart": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "object",
|
||||
"title": "Frontend",
|
||||
"requiresRestart": true
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Enabled",
|
||||
"description": "Enable frontend",
|
||||
"default": false,
|
||||
"requiresRestart": true
|
||||
},
|
||||
"package": {
|
||||
"type": "string",
|
||||
"enum": ["zigbee2mqtt-frontend", "zigbee2mqtt-windfront"],
|
||||
"title": "Package",
|
||||
"default": "zigbee2mqtt-frontend",
|
||||
"requiresRestart": true,
|
||||
"description": "Package used for the frontend"
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"title": "Port",
|
||||
"description": "Frontend binding port. Ignored when using a unix domain socket",
|
||||
"default": 8080,
|
||||
"requiresRestart": true
|
||||
},
|
||||
"host": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Bind host",
|
||||
"description": "Frontend binding host. Binds to a unix socket when an absolute path is given instead.",
|
||||
"examples": ["127.0.0.1", "::1", "/run/zigbee2mqtt/zigbee2mqtt.sock"],
|
||||
"requiresRestart": true
|
||||
},
|
||||
"auth_token": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Auth token",
|
||||
"description": "Enables authentication, disabled by default",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"url": {
|
||||
"type": ["string", "null"],
|
||||
"title": "URL",
|
||||
"description": "URL on which the frontend can be reached, currently only used for the Home Assistant device configuration page",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"ssl_cert": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Certificate file path",
|
||||
"description": "SSL Certificate file path for exposing HTTPS. The sibling property 'ssl_key' must be set for HTTPS to be activated.",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"ssl_key": {
|
||||
"type": ["string", "null"],
|
||||
"title": "key file path",
|
||||
"description": "SSL key file path for exposing HTTPS. The sibling property 'ssl_cert' must be set for HTTPS to be activated.",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"pattern": "^\\/.*",
|
||||
"title": "Base URL",
|
||||
"description": "Base URL for the frontend. If hosted under a subpath, e.g. 'http://localhost:8080/z2m', set this to '/z2m'",
|
||||
"default": "/",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"notification_filter": {
|
||||
"title": "Notification Filter",
|
||||
"description": "Hide frontend notifications matching specified regex strings. Example: 'z2m: Failed to ping.*'",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"disable_ui_serving": {
|
||||
"type": ["boolean", "null"],
|
||||
"title": "Disable UI serving",
|
||||
"description": "If true, the frontend UI is not served, only the WebSocket is maintained by Zigbee2MQTT (you are required to serve a standalone UI yourself as needed).",
|
||||
"requiresRestart": true
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"devices": {
|
||||
"type": "object",
|
||||
@@ -437,20 +497,6 @@
|
||||
"type": "object",
|
||||
"title": "Advanced",
|
||||
"properties": {
|
||||
"legacy_api": {
|
||||
"type": "boolean",
|
||||
"title": "Legacy API",
|
||||
"requiresRestart": true,
|
||||
"description": "Disables the legacy api (false = disable)",
|
||||
"default": true
|
||||
},
|
||||
"legacy_availability_payload": {
|
||||
"type": "boolean",
|
||||
"title": "Legacy availability payload",
|
||||
"requiresRestart": true,
|
||||
"description": "Payload to be used for device availability and bridge/state topics. true = text, false = JSON",
|
||||
"default": true
|
||||
},
|
||||
"log_rotation": {
|
||||
"type": "boolean",
|
||||
"title": "Log rotation",
|
||||
@@ -458,6 +504,13 @@
|
||||
"description": "Log rotation",
|
||||
"default": true
|
||||
},
|
||||
"log_console_json": {
|
||||
"type": "boolean",
|
||||
"title": "Console json log",
|
||||
"requiresRestart": true,
|
||||
"description": "Console json log",
|
||||
"default": false
|
||||
},
|
||||
"log_symlink_current": {
|
||||
"type": "boolean",
|
||||
"title": "Log symlink current",
|
||||
@@ -473,7 +526,8 @@
|
||||
"enum": ["console", "file", "syslog"]
|
||||
},
|
||||
"title": "Log output",
|
||||
"description": "Output location of the log, leave empty to suppress logging"
|
||||
"description": "Output location of the log, leave empty to suppress logging",
|
||||
"default": ["console", "file"]
|
||||
},
|
||||
"log_directory": {
|
||||
"type": "string",
|
||||
@@ -488,11 +542,11 @@
|
||||
"requiresRestart": true,
|
||||
"description": "Log file name, can also contain timestamp",
|
||||
"examples": ["zigbee2mqtt_%TIMESTAMP%.log"],
|
||||
"default": "log.txt"
|
||||
"default": "log.log"
|
||||
},
|
||||
"log_level": {
|
||||
"type": "string",
|
||||
"enum": ["error", "warning", "info", "debug", "warn"],
|
||||
"enum": ["error", "warning", "info", "debug"],
|
||||
"title": "Log level",
|
||||
"description": "Logging level",
|
||||
"default": "info"
|
||||
@@ -509,7 +563,14 @@
|
||||
"title": "Log Namespaced Levels",
|
||||
"description": "Set individual log levels for certain namespaces",
|
||||
"default": {},
|
||||
"examples": [{"z2m:mqtt": "warning"}, {"zh:ember:uart:ash": "info"}]
|
||||
"examples": [
|
||||
{
|
||||
"z2m:mqtt": "warning"
|
||||
},
|
||||
{
|
||||
"zh:ember:uart:ash": "info"
|
||||
}
|
||||
]
|
||||
},
|
||||
"log_debug_to_mqtt_frontend": {
|
||||
"type": "boolean",
|
||||
@@ -525,6 +586,14 @@
|
||||
"default": "",
|
||||
"examples": ["^zhc:legacy:fz:(tuya|moes)", "^zhc:legacy:fz:(tuya|moes)|^zh:ember:uart:|^zh:controller"]
|
||||
},
|
||||
"log_directories_to_keep": {
|
||||
"type": "number",
|
||||
"title": "Number of past log folders to keep",
|
||||
"description": "Number of log directories to keep before deleting the oldest one",
|
||||
"default": 10,
|
||||
"minimum": 5,
|
||||
"maximum": 1000
|
||||
},
|
||||
"log_syslog": {
|
||||
"requiresRestart": true,
|
||||
"oneOf": [
|
||||
@@ -604,12 +673,15 @@
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"title": "Pan ID (number)"
|
||||
"title": "Pan ID (number)",
|
||||
"minimum": 1,
|
||||
"maximum": 65534
|
||||
}
|
||||
],
|
||||
"title": "Pan ID",
|
||||
"requiresRestart": true,
|
||||
"description": "ZigBee pan ID, changing requires re-pairing all devices!"
|
||||
"description": "ZigBee pan ID, changing requires re-pairing all devices!",
|
||||
"default": 6754
|
||||
},
|
||||
"ext_pan_id": {
|
||||
"oneOf": [
|
||||
@@ -627,7 +699,8 @@
|
||||
],
|
||||
"title": "Ext Pan ID",
|
||||
"requiresRestart": true,
|
||||
"description": "Zigbee extended pan ID, changing requires re-pairing all devices!"
|
||||
"description": "Zigbee extended pan ID, changing requires re-pairing all devices!",
|
||||
"default": [221, 221, 221, 221, 221, 221, 221, 221]
|
||||
},
|
||||
"channel": {
|
||||
"type": "number",
|
||||
@@ -645,6 +718,7 @@
|
||||
"type": ["number", "null"],
|
||||
"minimum": 1,
|
||||
"maximum": 64,
|
||||
"default": null,
|
||||
"description": "Adapter concurrency (e.g. 2 for CC2531 or 16 for CC26X2R1) (default: null, uses recommended value)"
|
||||
},
|
||||
"adapter_delay": {
|
||||
@@ -653,6 +727,7 @@
|
||||
"title": "Adapter delay",
|
||||
"minimum": 0,
|
||||
"maximum": 1000,
|
||||
"default": null,
|
||||
"description": "Adapter delay"
|
||||
},
|
||||
"cache_state": {
|
||||
@@ -702,14 +777,16 @@
|
||||
],
|
||||
"title": "Network key",
|
||||
"requiresRestart": true,
|
||||
"description": "Network encryption key, changing requires re-pairing all devices!"
|
||||
"description": "Network encryption key, changing requires re-pairing all devices!",
|
||||
"default": [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13]
|
||||
},
|
||||
"timestamp_format": {
|
||||
"type": "string",
|
||||
"title": "Timestamp format",
|
||||
"requiresRestart": true,
|
||||
"description": "Log timestamp format",
|
||||
"examples": ["YYYY-MM-DD HH:mm:ss"]
|
||||
"default": "YYYY-MM-DD HH:mm:ss",
|
||||
"examples": ["YYYY-MM-DD HH:mm:ss.SSS"]
|
||||
},
|
||||
"transmit_power": {
|
||||
"type": ["number", "null"],
|
||||
@@ -723,106 +800,32 @@
|
||||
"type": "string",
|
||||
"enum": ["attribute_and_json", "attribute", "json"],
|
||||
"title": "MQTT output type",
|
||||
"description": "Examples when 'state' of a device is published json: topic: 'zigbee2mqtt/my_bulb' payload '{\"state\": \"ON\"}' attribute: topic 'zigbee2mqtt/my_bulb/state' payload 'ON' attribute_and_json: both json and attribute (see above)"
|
||||
},
|
||||
"homeassistant_discovery_topic": {
|
||||
"type": "string",
|
||||
"title": "Homeassistant discovery topic",
|
||||
"description": "Home Assistant discovery topic",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant"]
|
||||
},
|
||||
"homeassistant_legacy_entity_attributes": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant legacy entity attributes",
|
||||
"description": "Home Assistant legacy entity attributes, when enabled Zigbee2MQTT will add state attributes to each entity, additional to the separate entities and devices it already creates",
|
||||
"default": true
|
||||
},
|
||||
"homeassistant_status_topic": {
|
||||
"type": "string",
|
||||
"title": "Home Assistant status topic",
|
||||
"description": "Home Assistant status topic",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant/status"]
|
||||
},
|
||||
"homeassistant_legacy_triggers": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant legacy triggers",
|
||||
"description": "Home Assistant legacy triggers, when enabled Zigbee2mqt will send an empty 'action' or 'click' after one has been send. A 'sensor_action' and 'sensor_click' will be discovered",
|
||||
"default": true
|
||||
},
|
||||
"soft_reset_timeout": {
|
||||
"description": "Examples when 'state' of a device is published json: topic: 'zigbee2mqtt/my_bulb' payload '{\"state\": \"ON\"}' attribute: topic 'zigbee2mqtt/my_bulb/state' payload 'ON' attribute_and_json: both json and attribute (see above)",
|
||||
"default": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"health": {
|
||||
"title": "Health",
|
||||
"description": "Periodically check the health of Zigbee2MQTT",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"requiresRestart": true,
|
||||
"title": "Soft reset timeout (deprecated)",
|
||||
"description": "Soft reset ZNP after timeout",
|
||||
"readOnly": true
|
||||
"title": "Interval",
|
||||
"description": "Interval between checks in minutes",
|
||||
"default": 10,
|
||||
"minimum": 1,
|
||||
"requiresRestart": true
|
||||
},
|
||||
"report": {
|
||||
"reset_on_check": {
|
||||
"type": "boolean",
|
||||
"title": "Reporting",
|
||||
"requiresRestart": true,
|
||||
"readOnly": true,
|
||||
"description": "Enables report feature (deprecated)"
|
||||
},
|
||||
"baudrate": {
|
||||
"type": "number",
|
||||
"title": "Baudrate (deprecated)",
|
||||
"requiresRestart": true,
|
||||
"description": "Baud rate speed for serial port, this can be anything firmware support but default is 115200 for Z-Stack and EZSP, 38400 for Deconz, however note that some EZSP firmware need 57600",
|
||||
"examples": [38400, 57600, 115200]
|
||||
},
|
||||
"rtscts": {
|
||||
"type": "boolean",
|
||||
"title": "RTS / CTS (deprecated)",
|
||||
"requiresRestart": true,
|
||||
"description": "RTS / CTS Hardware Flow Control for serial port"
|
||||
},
|
||||
"ikea_ota_use_test_url": {
|
||||
"type": "boolean",
|
||||
"title": "IKEA TRADFRI OTA use test url (deprecated)",
|
||||
"requiresRestart": true,
|
||||
"description": "Use IKEA TRADFRI OTA test server, see OTA updates documentation",
|
||||
"title": "Reset on check",
|
||||
"description": "If true, will reset stats every time the health check is executed (only applicable to stats that can be reset).",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"experimental": {
|
||||
"type": "object",
|
||||
"title": "Experimental (deprecated)",
|
||||
"properties": {
|
||||
"transmit_power": {
|
||||
"type": ["number", "null"],
|
||||
"title": "Transmit power",
|
||||
"requiresRestart": true,
|
||||
"description": "Transmit power of adapter, only available for Z-Stack (CC253*/CC2652/CC1352) adapters, CC2652 = 5dbm, CC1352 max is = 20dbm (5dbm default)"
|
||||
},
|
||||
"output": {
|
||||
"type": "string",
|
||||
"enum": ["attribute_and_json", "attribute", "json"],
|
||||
"title": "MQTT output type",
|
||||
"description": "Examples when 'state' of a device is published json: topic: 'zigbee2mqtt/my_bulb' payload '{\"state\": \"ON\"}' attribute: topic 'zigbee2mqtt/my_bulb/state' payload 'ON' attribute_and_json: both json and attribute (see above)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"whitelist": {
|
||||
"readOnly": true,
|
||||
"type": "array",
|
||||
"requiresRestart": true,
|
||||
"title": "Whitelist (deprecated, use passlist)",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"ban": {
|
||||
"readOnly": true,
|
||||
"type": "array",
|
||||
"requiresRestart": true,
|
||||
"title": "Ban (deprecated, use blocklist)",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
},
|
||||
"required": ["mqtt"],
|
||||
@@ -853,14 +856,22 @@
|
||||
"description": "Sets the MQTT Message Expiry in seconds, Make sure to set mqtt.version to 5"
|
||||
},
|
||||
"qos": {
|
||||
"type": "number",
|
||||
"type": ["number"],
|
||||
"enum": [0, 1, 2],
|
||||
"title": "QoS",
|
||||
"description": "QoS level for MQTT messages of this device"
|
||||
},
|
||||
"throttle": {
|
||||
"type": "number",
|
||||
"title": "Throttle",
|
||||
"description": "The minimum time between payloads in seconds. Payloads received whilst the device is being throttled will be discarded",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"debounce": {
|
||||
"type": "number",
|
||||
"title": "Debounce",
|
||||
"description": "Debounces messages of this device"
|
||||
"description": "Debounces messages of this device",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"debounce_ignore": {
|
||||
"type": "array",
|
||||
@@ -907,7 +918,7 @@
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"title": "Icon",
|
||||
"description": "The user-defined device icon for the frontend. It can be a full URL link to an image (e.g. https://SOME.SITE/MODEL123.jpg) (you cannot use a path to a local file) or base64 encoded data URL (e.g. image/svg+xml;base64,PHN2ZyB3aW....R0aD)"
|
||||
"description": "The user-defined device icon for the frontend. It can be a full URL link to an image (e.g. https://SOME.SITE/MODEL123.jpg) or a path to a local file inside the `device_icons` directory (e.g. device_icons/MODEL123.png)."
|
||||
},
|
||||
"homeassistant": {
|
||||
"type": ["object", "null"],
|
||||
@@ -932,25 +943,22 @@
|
||||
"retain": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"devices": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"optimistic": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"qos": {
|
||||
"type": "number"
|
||||
"type": ["number"],
|
||||
"enum": [0, 1, 2],
|
||||
"title": "QoS",
|
||||
"description": "QoS level for MQTT messages of this group"
|
||||
},
|
||||
"off_state": {
|
||||
"type": ["string"],
|
||||
"enum": ["all_members_off", "last_member_state"],
|
||||
"title": "Group off state",
|
||||
"default": "auto",
|
||||
"default": "all_members_off",
|
||||
"requiresRestart": true,
|
||||
"description": "Control when to publish state OFF for a group. 'all_members_off': only publish state OFF when all group members are in state OFF, 'last_member_state': publish state OFF whenever one of its members changes to OFF"
|
||||
"description": "Control when to publish state OFF or CLOSE for a group. 'all_members_off': only publish state OFF/CLOSE when all group members are in state OFF/CLOSE, 'last_member_state': publish state OFF whenever one of its members changes to OFF"
|
||||
},
|
||||
"filtered_attributes": {
|
||||
"type": "array",
|
||||
|
||||
+343
-395
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,589 @@
|
||||
import {copyFileSync, writeFileSync} from "node:fs";
|
||||
|
||||
import data from "./data";
|
||||
import * as settings from "./settings";
|
||||
import utils from "./utils";
|
||||
|
||||
interface SettingsMigration {
|
||||
path: string[];
|
||||
note: string;
|
||||
noteIf?: (previousValue: unknown) => boolean;
|
||||
}
|
||||
|
||||
interface SettingsAdd extends Omit<SettingsMigration, "noteIf"> {
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
type SettingsRemove = SettingsMigration;
|
||||
|
||||
interface SettingsChange extends SettingsMigration {
|
||||
previousValueAnyOf?: unknown[];
|
||||
newValue: unknown;
|
||||
}
|
||||
|
||||
interface SettingsTransfer extends SettingsMigration {
|
||||
newPath: string[];
|
||||
}
|
||||
|
||||
interface SettingsCustomHandler extends Omit<SettingsMigration, "path"> {
|
||||
execute: (currentSettings: Partial<Settings>) => [validPath: boolean, previousValue: unknown, changed: boolean];
|
||||
}
|
||||
|
||||
const SUPPORTED_VERSIONS: Settings["version"][] = [undefined, 2, 3, settings.CURRENT_VERSION];
|
||||
|
||||
function backupSettings(version: number): void {
|
||||
const filePath = data.joinPath("configuration.yaml");
|
||||
|
||||
copyFileSync(filePath, filePath.replace(".yaml", `_backup_v${version}.yaml`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the given path in given settings to given value. If requested, create path.
|
||||
*
|
||||
* @param currentSettings
|
||||
* @param path
|
||||
* @param value
|
||||
* @param createPathIfNotExist
|
||||
* @returns Returns true if value was set, false if not.
|
||||
*/
|
||||
// biome-ignore lint/suspicious/noExplicitAny: auto-parsing
|
||||
function setValue(currentSettings: any, path: string[], value: unknown, createPathIfNotExist = false): boolean {
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const key = path[i];
|
||||
|
||||
if (i === path.length - 1) {
|
||||
currentSettings[key] = value;
|
||||
} else {
|
||||
if (!currentSettings[key]) {
|
||||
if (createPathIfNotExist) {
|
||||
currentSettings[key] = {};
|
||||
/* v8 ignore start */
|
||||
} else {
|
||||
// invalid path
|
||||
// ignored in test since currently call is always guarded by get-validated path, so this is never reached
|
||||
return false;
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
currentSettings = currentSettings[key];
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value at the given path in given settings.
|
||||
*
|
||||
* @param currentSettings
|
||||
* @param path
|
||||
* @returns
|
||||
* - true if path was valid
|
||||
* - the value at path
|
||||
*/
|
||||
// biome-ignore lint/suspicious/noExplicitAny: auto-parsing
|
||||
function getValue(currentSettings: any, path: string[]): [validPath: boolean, value: unknown] {
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const key = path[i];
|
||||
const value = currentSettings[key];
|
||||
|
||||
if (i === path.length - 1) {
|
||||
return [value !== undefined, value];
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
// invalid path
|
||||
break;
|
||||
}
|
||||
|
||||
currentSettings = value;
|
||||
}
|
||||
|
||||
return [false, undefined];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a value at given path, path is created as needed.
|
||||
* @param currentSettings
|
||||
* @param addition
|
||||
*/
|
||||
function addValue(currentSettings: Partial<Settings>, addition: SettingsAdd): void {
|
||||
setValue(currentSettings, addition.path, addition.value, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove value at given path, if path is valid.
|
||||
* Value is actually set to undefined, which triggers removal when `settings.apply` is called.
|
||||
* @param currentSettings
|
||||
* @param removal
|
||||
* @returns
|
||||
*/
|
||||
function removeValue(currentSettings: Partial<Settings>, removal: SettingsRemove): [validPath: boolean, previousValue: unknown] {
|
||||
const [validPath, previousValue] = getValue(currentSettings, removal.path);
|
||||
|
||||
if (validPath && previousValue != null) {
|
||||
setValue(currentSettings, removal.path, undefined);
|
||||
}
|
||||
|
||||
return [validPath, previousValue];
|
||||
}
|
||||
|
||||
/**
|
||||
* Change value at given path, if path is valid, and value matched one of the defined values (if any).
|
||||
* @param currentSettings
|
||||
* @param change
|
||||
* @returns
|
||||
*/
|
||||
function changeValue(currentSettings: Partial<Settings>, change: SettingsChange): [validPath: boolean, previousValue: unknown, changed: boolean] {
|
||||
const [validPath, previousValue] = getValue(currentSettings, change.path);
|
||||
let changed = false;
|
||||
|
||||
if (validPath && previousValue !== change.newValue) {
|
||||
if (!change.previousValueAnyOf || change.previousValueAnyOf.includes(previousValue)) {
|
||||
setValue(currentSettings, change.path, change.newValue);
|
||||
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return [validPath, previousValue, changed];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer value at given path, to new path.
|
||||
* Given path must be valid.
|
||||
* New path must not be valid or new path value must be nullish, otherwise given path is removed only.
|
||||
* Value at given path is actually set to undefined, which triggers removal when `settings.apply` is called.
|
||||
* New path is created as needed.
|
||||
* @param currentSettings
|
||||
* @param transfer
|
||||
* @returns
|
||||
*/
|
||||
function transferValue(
|
||||
currentSettings: Partial<Settings>,
|
||||
transfer: SettingsTransfer,
|
||||
): [validPath: boolean, previousValue: unknown, transfered: boolean] {
|
||||
const [validPath, previousValue] = getValue(currentSettings, transfer.path);
|
||||
const [destValidPath, destValue] = getValue(currentSettings, transfer.newPath);
|
||||
const transfered = validPath && previousValue != null && (!destValidPath || destValue == null || Array.isArray(destValue));
|
||||
|
||||
// no point in set if already undefined
|
||||
if (validPath && previousValue != null) {
|
||||
setValue(currentSettings, transfer.path, undefined);
|
||||
}
|
||||
|
||||
if (transfered) {
|
||||
if (Array.isArray(previousValue) && Array.isArray(destValue)) {
|
||||
setValue(currentSettings, transfer.newPath, [...previousValue, ...destValue], true);
|
||||
} else {
|
||||
setValue(currentSettings, transfer.newPath, previousValue, true);
|
||||
}
|
||||
}
|
||||
|
||||
return [validPath, previousValue, transfered];
|
||||
}
|
||||
|
||||
const noteIfWasTrue = (previousValue: unknown): boolean => previousValue === true;
|
||||
const noteIfWasDefined = (previousValue: unknown): boolean => previousValue != null;
|
||||
const noteIfWasNonEmptyArray = (previousValue: unknown): boolean => Array.isArray(previousValue) && previousValue.length > 0;
|
||||
|
||||
function migrateToTwo(
|
||||
currentSettings: Partial<Settings>,
|
||||
transfers: SettingsTransfer[],
|
||||
changes: SettingsChange[],
|
||||
additions: SettingsAdd[],
|
||||
removals: SettingsRemove[],
|
||||
customHandlers: SettingsCustomHandler[],
|
||||
): void {
|
||||
transfers.push(
|
||||
{
|
||||
path: ["advanced", "homeassistant_discovery_topic"],
|
||||
note: "HA discovery_topic was moved from advanced.homeassistant_discovery_topic to homeassistant.discovery_topic.",
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ["homeassistant", "discovery_topic"],
|
||||
},
|
||||
{
|
||||
path: ["advanced", "homeassistant_status_topic"],
|
||||
note: "HA status_topic was moved from advanced.homeassistant_status_topic to homeassistant.status_topic.",
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ["homeassistant", "status_topic"],
|
||||
},
|
||||
{
|
||||
path: ["advanced", "baudrate"],
|
||||
note: "Baudrate was moved from advanced.baudrate to serial.baudrate.",
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ["serial", "baudrate"],
|
||||
},
|
||||
{
|
||||
path: ["advanced", "rtscts"],
|
||||
note: "RTSCTS was moved from advanced.rtscts to serial.rtscts.",
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ["serial", "rtscts"],
|
||||
},
|
||||
{
|
||||
path: ["experimental", "transmit_power"],
|
||||
note: "Transmit power was moved from experimental.transmit_power to advanced.transmit_power.",
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ["advanced", "transmit_power"],
|
||||
},
|
||||
{
|
||||
path: ["experimental", "output"],
|
||||
note: "Output was moved from experimental.output to advanced.output.",
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ["advanced", "output"],
|
||||
},
|
||||
{
|
||||
path: ["ban"],
|
||||
note: "ban was renamed to passlist.",
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ["blocklist"],
|
||||
},
|
||||
{
|
||||
path: ["whitelist"],
|
||||
note: "whitelist was renamed to passlist.",
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ["passlist"],
|
||||
},
|
||||
);
|
||||
|
||||
changes.push({
|
||||
path: ["advanced", "log_level"],
|
||||
note: `Log level 'warn' has been renamed to 'warning'.`,
|
||||
noteIf: (previousValue): boolean => previousValue === "warn",
|
||||
previousValueAnyOf: ["warn"],
|
||||
newValue: "warning",
|
||||
});
|
||||
|
||||
additions.push({
|
||||
path: ["version"],
|
||||
note: "Migrated settings to version 2",
|
||||
value: 2,
|
||||
});
|
||||
|
||||
const haLegacyTriggers: SettingsRemove = {
|
||||
path: ["homeassistant", "legacy_triggers"],
|
||||
note: "Action and click sensors have been removed (homeassistant.legacy_triggers setting). This means all sensor.*_action and sensor.*_click entities are removed. Use the MQTT device trigger instead.",
|
||||
noteIf: noteIfWasTrue,
|
||||
};
|
||||
const haLegacyEntityAttrs: SettingsRemove = {
|
||||
path: ["homeassistant", "legacy_entity_attributes"],
|
||||
note: "Entity attributes (homeassistant.legacy_entity_attributes setting) has been removed. This means that entities discovered by Zigbee2MQTT will no longer have entity attributes (Home Assistant entity attributes are accessed via e.g. states.binary_sensor.my_sensor.attributes).",
|
||||
noteIf: noteIfWasTrue,
|
||||
};
|
||||
const otaIkeaUseTestUrl: SettingsRemove = {
|
||||
path: ["ota", "ikea_ota_use_test_url"],
|
||||
note: "Due to the OTA rework, the ota.ikea_ota_use_test_url option has been removed.",
|
||||
noteIf: noteIfWasTrue,
|
||||
};
|
||||
|
||||
removals.push(
|
||||
haLegacyTriggers,
|
||||
haLegacyEntityAttrs,
|
||||
{
|
||||
path: ["advanced", "homeassistant_legacy_triggers"],
|
||||
note: haLegacyTriggers.note,
|
||||
noteIf: haLegacyTriggers.noteIf,
|
||||
},
|
||||
{
|
||||
path: ["advanced", "homeassistant_legacy_entity_attributes"],
|
||||
note: haLegacyEntityAttrs.note,
|
||||
noteIf: haLegacyEntityAttrs.noteIf,
|
||||
},
|
||||
{
|
||||
path: ["permit_join"],
|
||||
note: "The permit_join setting has been removed, use the frontend or MQTT to permit joining.",
|
||||
noteIf: noteIfWasTrue,
|
||||
},
|
||||
otaIkeaUseTestUrl,
|
||||
{
|
||||
path: ["advanced", "ikea_ota_use_test_url"],
|
||||
note: otaIkeaUseTestUrl.note,
|
||||
noteIf: otaIkeaUseTestUrl.noteIf,
|
||||
},
|
||||
{
|
||||
path: ["advanced", "legacy_api"],
|
||||
note: "The MQTT legacy API has been removed (advanced.legacy_api setting). See link below for affected topics.",
|
||||
noteIf: noteIfWasTrue,
|
||||
},
|
||||
{
|
||||
path: ["advanced", "legacy_availability_payload"],
|
||||
note: 'Due to the removal of advanced.legacy_availability_payload, zigbee2mqtt/bridge/state will now always be a JSON object ({"state":"online"} or {"state":"offline"})',
|
||||
noteIf: noteIfWasTrue,
|
||||
},
|
||||
{
|
||||
path: ["advanced", "soft_reset_timeout"],
|
||||
note: "Removed deprecated: Soft reset feature (advanced.soft_reset_timeout setting)",
|
||||
noteIf: noteIfWasDefined,
|
||||
},
|
||||
{
|
||||
path: ["advanced", "report"],
|
||||
note: "Removed deprecated: Report feature (advanced.report setting)",
|
||||
noteIf: noteIfWasTrue,
|
||||
},
|
||||
{
|
||||
path: ["advanced", "availability_timeout"],
|
||||
note: "Removed deprecated: advanced.availability_timeout availability settings",
|
||||
noteIf: noteIfWasDefined,
|
||||
},
|
||||
{
|
||||
path: ["advanced", "availability_blocklist"],
|
||||
note: "Removed deprecated: advanced.availability_blocklist availability settings",
|
||||
noteIf: noteIfWasNonEmptyArray,
|
||||
},
|
||||
{
|
||||
path: ["advanced", "availability_passlist"],
|
||||
note: "Removed deprecated: advanced.availability_passlist availability settings",
|
||||
noteIf: noteIfWasNonEmptyArray,
|
||||
},
|
||||
{
|
||||
path: ["advanced", "availability_blacklist"],
|
||||
note: "Removed deprecated: advanced.availability_blacklist availability settings",
|
||||
noteIf: noteIfWasNonEmptyArray,
|
||||
},
|
||||
{
|
||||
path: ["advanced", "availability_whitelist"],
|
||||
note: "Removed deprecated: advanced.availability_whitelist availability settings",
|
||||
noteIf: noteIfWasNonEmptyArray,
|
||||
},
|
||||
{
|
||||
path: ["device_options", "legacy"],
|
||||
note: "Removed everything that was enabled through device_options.legacy. See link below for affected devices.",
|
||||
noteIf: noteIfWasTrue,
|
||||
},
|
||||
{
|
||||
path: ["experimental"],
|
||||
note: "The entire experimental section was removed.",
|
||||
noteIf: noteIfWasDefined,
|
||||
},
|
||||
{
|
||||
path: ["external_converters"],
|
||||
note: "External converters are now automatically loaded from the 'data/external_converters' directory without requiring settings to be set. Make sure your external converters are still needed (might be supported out-of-the-box now), and if so, move them to that directory.",
|
||||
noteIf: noteIfWasNonEmptyArray,
|
||||
},
|
||||
);
|
||||
|
||||
// note only once
|
||||
const noteEntityOptionsRetrieveState = "Retrieve state option ((devices|groups).xyz.retrieve_state setting)";
|
||||
|
||||
for (const deviceKey in currentSettings.devices) {
|
||||
removals.push({
|
||||
path: ["devices", deviceKey, "retrieve_state"],
|
||||
note: noteEntityOptionsRetrieveState,
|
||||
noteIf: noteIfWasTrue,
|
||||
});
|
||||
}
|
||||
|
||||
for (const groupKey in currentSettings.groups) {
|
||||
removals.push({
|
||||
path: ["groups", groupKey, "retrieve_state"],
|
||||
note: noteEntityOptionsRetrieveState,
|
||||
noteIf: noteIfWasTrue,
|
||||
});
|
||||
removals.push({
|
||||
path: ["groups", groupKey, "devices"],
|
||||
note: "Removed configuring group members through configuration.yaml (groups.xyz.devices setting). This will not impact current group members; however, you will no longer be able to add or remove devices from a group through the configuration.yaml.",
|
||||
noteIf: noteIfWasDefined,
|
||||
});
|
||||
}
|
||||
|
||||
customHandlers.push();
|
||||
}
|
||||
|
||||
function migrateToThree(
|
||||
_currentSettings: Partial<Settings>,
|
||||
transfers: SettingsTransfer[],
|
||||
changes: SettingsChange[],
|
||||
additions: SettingsAdd[],
|
||||
removals: SettingsRemove[],
|
||||
customHandlers: SettingsCustomHandler[],
|
||||
): void {
|
||||
transfers.push();
|
||||
changes.push({
|
||||
path: ["version"],
|
||||
note: "Migrated settings to version 3",
|
||||
newValue: 3,
|
||||
});
|
||||
additions.push();
|
||||
removals.push();
|
||||
|
||||
const changeToObject = (currentSettings: Partial<Settings>, path: string[]): ReturnType<SettingsCustomHandler["execute"]> => {
|
||||
const [validPath, previousValue] = getValue(currentSettings, path);
|
||||
|
||||
if (validPath) {
|
||||
if (typeof previousValue === "boolean") {
|
||||
setValue(currentSettings, path, {enabled: previousValue});
|
||||
} else {
|
||||
setValue(currentSettings, path, {enabled: true, ...(previousValue as object)});
|
||||
}
|
||||
}
|
||||
|
||||
return [validPath, previousValue, validPath];
|
||||
};
|
||||
|
||||
customHandlers.push(
|
||||
{
|
||||
note: `Property 'homeassistant' is now always an object.`,
|
||||
noteIf: () => true,
|
||||
execute: (currentSettings) => changeToObject(currentSettings, ["homeassistant"]),
|
||||
},
|
||||
{
|
||||
note: `Property 'frontend' is now always an object.`,
|
||||
noteIf: () => true,
|
||||
execute: (currentSettings) => changeToObject(currentSettings, ["frontend"]),
|
||||
},
|
||||
{
|
||||
note: `Property 'availability' is now always an object.`,
|
||||
noteIf: () => true,
|
||||
execute: (currentSettings) => changeToObject(currentSettings, ["availability"]),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function migrateToFour(
|
||||
_currentSettings: Partial<Settings>,
|
||||
transfers: SettingsTransfer[],
|
||||
changes: SettingsChange[],
|
||||
additions: SettingsAdd[],
|
||||
removals: SettingsRemove[],
|
||||
customHandlers: SettingsCustomHandler[],
|
||||
): void {
|
||||
transfers.push();
|
||||
changes.push({
|
||||
path: ["version"],
|
||||
note: "Migrated settings to version 4",
|
||||
newValue: 4,
|
||||
});
|
||||
additions.push();
|
||||
removals.push();
|
||||
|
||||
const saveBase64DeviceIconsAsImage = (currentSettings: Partial<Settings>): ReturnType<SettingsCustomHandler["execute"]> => {
|
||||
const [validPath, previousValue] = getValue(currentSettings, ["devices"]);
|
||||
let changed = false;
|
||||
|
||||
if (validPath) {
|
||||
for (const deviceKey in currentSettings.devices) {
|
||||
const base64Match = utils.matchBase64File(currentSettings.devices[deviceKey].icon);
|
||||
if (base64Match) {
|
||||
changed = true;
|
||||
currentSettings.devices[deviceKey].icon = utils.saveBase64DeviceIcon(base64Match);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [validPath, previousValue, changed];
|
||||
};
|
||||
|
||||
customHandlers.push({
|
||||
note: "Device icons are now saved as images.",
|
||||
noteIf: () => true,
|
||||
execute: (currentSettings) => saveBase64DeviceIconsAsImage(currentSettings),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Order of execution:
|
||||
* - Transfer
|
||||
* - Change
|
||||
* - Add
|
||||
* - Remove
|
||||
*
|
||||
* Should allow the most flexibility whenever combination of migrations is necessary (e.g. Transfer + Change)
|
||||
*/
|
||||
export function migrateIfNecessary(): void {
|
||||
const currentSettings = settings.getPersistedSettings();
|
||||
|
||||
if (!SUPPORTED_VERSIONS.includes(currentSettings.version)) {
|
||||
throw new Error(
|
||||
`Your configuration.yaml has an unsupported version ${currentSettings.version}, expected one of ${SUPPORTED_VERSIONS.map((v) => String(v)).join(",")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/* v8 ignore next */
|
||||
const finalVersion = process.env.VITEST_WORKER_ID ? settings.testing.CURRENT_VERSION : settings.CURRENT_VERSION;
|
||||
|
||||
if (currentSettings.version === finalVersion) {
|
||||
// when same version as current, nothing to do
|
||||
return;
|
||||
}
|
||||
|
||||
while (currentSettings.version !== finalVersion) {
|
||||
let migrationNotesFileName: string | undefined;
|
||||
// don't duplicate outputs
|
||||
const migrationNotes = new Set<string>();
|
||||
const transfers: SettingsTransfer[] = [];
|
||||
const changes: SettingsChange[] = [];
|
||||
const additions: SettingsAdd[] = [];
|
||||
const removals: SettingsRemove[] = [];
|
||||
const customHandlers: SettingsCustomHandler[] = [];
|
||||
|
||||
backupSettings(currentSettings.version || 1);
|
||||
|
||||
// each version should only bump to the next version so as to gradually migrate if necessary
|
||||
if (currentSettings.version == null) {
|
||||
// migrating from 1 (`version` did not exist) to 2
|
||||
migrationNotesFileName = "migration-1-to-2.log";
|
||||
|
||||
migrateToTwo(currentSettings, transfers, changes, additions, removals, customHandlers);
|
||||
} else if (currentSettings.version === 2) {
|
||||
migrationNotesFileName = "migration-2-to-3.log";
|
||||
|
||||
migrateToThree(currentSettings, transfers, changes, additions, removals, customHandlers);
|
||||
} else if (currentSettings.version === 3) {
|
||||
migrationNotesFileName = "migration-3-to-4.log";
|
||||
|
||||
migrateToFour(currentSettings, transfers, changes, additions, removals, customHandlers);
|
||||
}
|
||||
|
||||
for (const transfer of transfers) {
|
||||
const [validPath, previousValue, transfered] = transferValue(currentSettings, transfer);
|
||||
|
||||
if (validPath && (!transfer.noteIf || transfer.noteIf(previousValue))) {
|
||||
migrationNotes.add(`[${transfered ? "TRANSFER" : "REMOVAL"}] ${transfer.note}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const change of changes) {
|
||||
const [validPath, previousValue, changed] = changeValue(currentSettings, change);
|
||||
|
||||
if (validPath && changed && (!change.noteIf || change.noteIf(previousValue))) {
|
||||
migrationNotes.add(`[CHANGE] ${change.note}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const addition of additions) {
|
||||
addValue(currentSettings, addition);
|
||||
|
||||
migrationNotes.add(`[ADDITION] ${addition.note}`);
|
||||
}
|
||||
|
||||
for (const removal of removals) {
|
||||
const [validPath, previousValue] = removeValue(currentSettings, removal);
|
||||
|
||||
if (validPath && (!removal.noteIf || removal.noteIf(previousValue))) {
|
||||
migrationNotes.add(`[REMOVAL] ${removal.note}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const customHandler of customHandlers) {
|
||||
const [validPath, previousValue, changed] = customHandler.execute(currentSettings);
|
||||
|
||||
if (validPath && changed && (!customHandler.noteIf || customHandler.noteIf(previousValue))) {
|
||||
migrationNotes.add(`[SPECIAL] ${customHandler.note}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (migrationNotesFileName && migrationNotes.size > 0) {
|
||||
migrationNotes.add("For more details, see https://github.com/Koenkk/zigbee2mqtt/discussions/24198");
|
||||
const migrationNotesFilePath = data.joinPath(migrationNotesFileName);
|
||||
|
||||
writeFileSync(migrationNotesFilePath, Array.from(migrationNotes).join("\r\n\r\n"), "utf8");
|
||||
|
||||
console.log(`Migration notes written in ${migrationNotesFilePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// don't throw, onboarding will validate at end of process
|
||||
settings.apply(currentSettings as unknown as Record<string, unknown>, false);
|
||||
settings.reRead();
|
||||
}
|
||||
+183
-198
@@ -1,12 +1,23 @@
|
||||
import type * as zhc from 'zigbee-herdsman-converters';
|
||||
import assert from "node:assert";
|
||||
import {exec} from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import equals from "fast-deep-equal/es6";
|
||||
import humanizeDuration from "humanize-duration";
|
||||
import type * as zhc from "zigbee-herdsman-converters";
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints, Zigbee2MQTTScene} from "../types/api";
|
||||
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import fs from 'fs';
|
||||
import humanizeDuration from 'humanize-duration';
|
||||
import path from 'path';
|
||||
import vm from 'vm';
|
||||
import data from "./data";
|
||||
|
||||
import data from './data';
|
||||
const BASE64_IMAGE_REGEX = /data:image\/(?<extension>.+);base64,(?<data>.+)/;
|
||||
|
||||
export const DEFAULT_BIND_GROUP_ID = 901;
|
||||
|
||||
function pad(num: number): string {
|
||||
const norm = Math.floor(Math.abs(num));
|
||||
return (norm < 10 ? "0" : "") + norm;
|
||||
}
|
||||
|
||||
// construct a local ISO8601 string (instead of UTC-based)
|
||||
// Example:
|
||||
@@ -14,93 +25,74 @@ import data from './data';
|
||||
// - ISO8601 (local) = 2019-03-01T16:32:45.941+0100 (for timezone GMT+1)
|
||||
function toLocalISOString(date: Date): string {
|
||||
const tzOffset = -date.getTimezoneOffset();
|
||||
const plusOrMinus = tzOffset >= 0 ? '+' : '-';
|
||||
const pad = (num: number): string => {
|
||||
const norm = Math.floor(Math.abs(num));
|
||||
return (norm < 10 ? '0' : '') + norm;
|
||||
};
|
||||
const plusOrMinus = tzOffset >= 0 ? "+" : "-";
|
||||
|
||||
return (
|
||||
date.getFullYear() +
|
||||
'-' +
|
||||
pad(date.getMonth() + 1) +
|
||||
'-' +
|
||||
pad(date.getDate()) +
|
||||
'T' +
|
||||
pad(date.getHours()) +
|
||||
':' +
|
||||
pad(date.getMinutes()) +
|
||||
':' +
|
||||
pad(date.getSeconds()) +
|
||||
plusOrMinus +
|
||||
pad(tzOffset / 60) +
|
||||
':' +
|
||||
pad(tzOffset % 60)
|
||||
);
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${plusOrMinus}${pad(tzOffset / 60)}:${pad(tzOffset % 60)}`;
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s[0].toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitHash: string; version: string}> {
|
||||
const git = await import('git-last-commit');
|
||||
const packageJSON = await import('../..' + '/package.json');
|
||||
export async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitHash?: string; version: string}> {
|
||||
const packageJSON = (await import("../../package.json", {with: {type: "json"}})).default;
|
||||
const version = packageJSON.version;
|
||||
let commitHash: string | undefined;
|
||||
|
||||
if (!includeCommitHash) {
|
||||
return {version: packageJSON.version, commitHash: null};
|
||||
return {version, commitHash};
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const version = packageJSON.version;
|
||||
return await new Promise((resolve) => {
|
||||
exec("git rev-parse --short=8 HEAD", (error, stdout) => {
|
||||
commitHash = stdout.trim();
|
||||
|
||||
git.getLastCommit((err: Error, commit: {shortHash: string}) => {
|
||||
let commitHash = null;
|
||||
|
||||
if (err) {
|
||||
if (error || commitHash === "") {
|
||||
try {
|
||||
commitHash = fs.readFileSync(path.join(__dirname, '..', '..', 'dist', '.hash'), 'utf-8');
|
||||
commitHash = fs.readFileSync(path.join(__dirname, "..", "..", "dist", ".hash"), "utf-8");
|
||||
} catch {
|
||||
/* istanbul ignore next */
|
||||
commitHash = 'unknown';
|
||||
commitHash = "unknown";
|
||||
}
|
||||
} else {
|
||||
commitHash = commit.shortHash;
|
||||
}
|
||||
|
||||
commitHash = commitHash.trim();
|
||||
resolve({commitHash, version});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getDependencyVersion(depend: string): Promise<{version: string}> {
|
||||
const modulePath = path.dirname(require.resolve(depend));
|
||||
const packageJSONPath = path.join(modulePath.slice(0, modulePath.indexOf(depend) + depend.length), 'package.json');
|
||||
const packageJSON = await import(packageJSONPath);
|
||||
const version = packageJSON.version;
|
||||
return {version};
|
||||
const packageJSON = (await import(`${depend}/package.json`, {with: {type: "json"}})).default;
|
||||
return {version: packageJSON.version};
|
||||
}
|
||||
|
||||
function formatDate(time: number, type: 'ISO_8601' | 'ISO_8601_local' | 'epoch' | 'relative'): string | number {
|
||||
if (type === 'ISO_8601') return new Date(time).toISOString();
|
||||
else if (type === 'ISO_8601_local') return toLocalISOString(new Date(time));
|
||||
else if (type === 'epoch') return time;
|
||||
else {
|
||||
// relative
|
||||
return humanizeDuration(Date.now() - time, {language: 'en', largest: 2, round: true}) + ' ago';
|
||||
function formatDate(time: number, type: "ISO_8601" | "ISO_8601_local" | "epoch" | "relative"): string | number {
|
||||
switch (type) {
|
||||
case "ISO_8601":
|
||||
// ISO8601 (UTC) = 2019-03-01T15:32:45.941Z
|
||||
return new Date(time).toISOString();
|
||||
|
||||
case "ISO_8601_local":
|
||||
// ISO8601 (local) = 2019-03-01T16:32:45.941+01:00 (for timezone GMT+1)
|
||||
return toLocalISOString(new Date(time));
|
||||
|
||||
case "epoch":
|
||||
return time;
|
||||
|
||||
default:
|
||||
// relative
|
||||
return `${humanizeDuration(Date.now() - time, {language: "en", largest: 2, round: true})} ago`;
|
||||
}
|
||||
}
|
||||
|
||||
function objectIsEmpty(object: object): boolean {
|
||||
// much faster than checking `Object.keys(object).length`
|
||||
for (const k in object) return false;
|
||||
for (const _k in object) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function objectHasProperties(object: {[s: string]: unknown}, properties: string[]): boolean {
|
||||
for (const property of properties) {
|
||||
if (!object.hasOwnProperty(property)) {
|
||||
if (object[property] === undefined) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -118,16 +110,38 @@ function equalsPartial(object: KeyValue, expected: KeyValue): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function getObjectProperty(object: KeyValue, key: string, defaultValue: unknown): unknown {
|
||||
return object && object.hasOwnProperty(key) ? object[key] : defaultValue;
|
||||
function getObjectProperty<T>(object: KeyValue, key: string, defaultValue: NoInfer<T>): T {
|
||||
return object && object[key] !== undefined ? object[key] : defaultValue;
|
||||
}
|
||||
|
||||
function getResponse(request: KeyValue | string, data: KeyValue, error: string): MQTTResponse {
|
||||
const response: MQTTResponse = {data, status: error ? 'error' : 'ok'};
|
||||
if (error) response.error = error;
|
||||
if (typeof request === 'object' && request.hasOwnProperty('transaction')) {
|
||||
function getResponse<T extends Zigbee2MQTTResponseEndpoints>(
|
||||
request: KeyValue | string,
|
||||
data: Zigbee2MQTTAPI[T],
|
||||
error?: string,
|
||||
): Zigbee2MQTTResponse<T> {
|
||||
if (error !== undefined) {
|
||||
const response: Zigbee2MQTTResponse<T> = {
|
||||
data: {}, // always return an empty `data` payload on error
|
||||
status: "error",
|
||||
error: error,
|
||||
};
|
||||
|
||||
if (typeof request === "object" && request.transaction !== undefined) {
|
||||
response.transaction = request.transaction;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
const response: Zigbee2MQTTResponse<T> = {
|
||||
data, // valid from error check
|
||||
status: "ok",
|
||||
};
|
||||
|
||||
if (typeof request === "object" && request.transaction !== undefined) {
|
||||
response.transaction = request.transaction;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -139,48 +153,6 @@ function parseJSON(value: string, fallback: string): KeyValue | string {
|
||||
}
|
||||
}
|
||||
|
||||
function loadModuleFromText(moduleCode: string, name?: string): unknown {
|
||||
const moduleFakePath = path.join(__dirname, '..', '..', 'data', 'extension', name || 'externally-loaded.js');
|
||||
const sandbox = {
|
||||
require: require,
|
||||
module: {},
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
setImmediate,
|
||||
clearImmediate,
|
||||
};
|
||||
vm.runInNewContext(moduleCode, sandbox, moduleFakePath);
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
return sandbox.module.exports;
|
||||
}
|
||||
|
||||
function loadModuleFromFile(modulePath: string): unknown {
|
||||
const moduleCode = fs.readFileSync(modulePath, {encoding: 'utf8'});
|
||||
return loadModuleFromText(moduleCode);
|
||||
}
|
||||
|
||||
export function* loadExternalConverter(moduleName: string): Generator<ExternalDefinition> {
|
||||
let converter;
|
||||
|
||||
if (moduleName.endsWith('.js')) {
|
||||
converter = loadModuleFromFile(data.joinPath(moduleName));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
converter = require(moduleName);
|
||||
}
|
||||
|
||||
if (Array.isArray(converter)) {
|
||||
for (const item of converter) {
|
||||
yield item;
|
||||
}
|
||||
} else {
|
||||
yield converter;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all keys from passed object that have null/undefined values.
|
||||
*
|
||||
@@ -189,11 +161,15 @@ export function* loadExternalConverter(moduleName: string): Generator<ExternalDe
|
||||
*/
|
||||
function removeNullPropertiesFromObject(obj: KeyValue, ignoreKeys: string[] = []): void {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (ignoreKeys.includes(key)) continue;
|
||||
if (ignoreKeys.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = obj[key];
|
||||
|
||||
if (value == null) {
|
||||
delete obj[key];
|
||||
} else if (typeof value === 'object') {
|
||||
} else if (typeof value === "object") {
|
||||
removeNullPropertiesFromObject(value, ignoreKeys);
|
||||
}
|
||||
}
|
||||
@@ -201,27 +177,7 @@ function removeNullPropertiesFromObject(obj: KeyValue, ignoreKeys: string[] = []
|
||||
|
||||
function toNetworkAddressHex(value: number): string {
|
||||
const hex = value.toString(16);
|
||||
return `0x${'0'.repeat(4 - hex.length)}${hex}`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
function toSnakeCase(value: string | KeyValue): any {
|
||||
if (typeof value === 'object') {
|
||||
value = {...value};
|
||||
for (const key of Object.keys(value)) {
|
||||
const keySnakeCase = toSnakeCase(key);
|
||||
if (key !== keySnakeCase) {
|
||||
value[keySnakeCase] = value[key];
|
||||
delete value[key];
|
||||
}
|
||||
}
|
||||
return value;
|
||||
} else {
|
||||
return value
|
||||
.replace(/\.?([A-Z])/g, (x, y) => '_' + y.toLowerCase())
|
||||
.replace(/^_/, '')
|
||||
.replace('_i_d', '_id');
|
||||
}
|
||||
return `0x${"0".repeat(4 - hex.length)}${hex}`;
|
||||
}
|
||||
|
||||
function charRange(start: string, stop: string): number[] {
|
||||
@@ -232,7 +188,7 @@ function charRange(start: string, stop: string): number[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
const controlCharacters = [...charRange('\u0000', '\u001F'), ...charRange('\u007f', '\u009F'), ...charRange('\ufdd0', '\ufdef')];
|
||||
const controlCharacters = [...charRange("\u0000", "\u001F"), ...charRange("\u007f", "\u009F"), ...charRange("\ufdd0", "\ufdef")];
|
||||
|
||||
function containsControlCharacter(str: string): boolean {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
@@ -246,25 +202,32 @@ function containsControlCharacter(str: string): boolean {
|
||||
|
||||
function getAllFiles(path_: string): string[] {
|
||||
const result = [];
|
||||
for (let item of fs.readdirSync(path_)) {
|
||||
item = path.join(path_, item);
|
||||
if (fs.lstatSync(item).isFile()) {
|
||||
result.push(item);
|
||||
|
||||
for (const item of fs.readdirSync(path_, {withFileTypes: true})) {
|
||||
if (item.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = path.join(path_, item.name);
|
||||
|
||||
if (fs.lstatSync(fileName).isFile()) {
|
||||
result.push(fileName);
|
||||
} else {
|
||||
result.push(...getAllFiles(item));
|
||||
result.push(...getAllFiles(fileName));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateFriendlyName(name: string, throwFirstError = false): string[] {
|
||||
const errors = [];
|
||||
|
||||
if (name.length === 0) errors.push(`friendly_name must be at least 1 char long`);
|
||||
if (name.endsWith('/') || name.startsWith('/')) errors.push(`friendly_name is not allowed to end or start with /`);
|
||||
if (containsControlCharacter(name)) errors.push(`friendly_name is not allowed to contain control char`);
|
||||
if (name.length === 0) errors.push("friendly_name must be at least 1 char long");
|
||||
if (name.endsWith("/") || name.startsWith("/")) errors.push("friendly_name is not allowed to end or start with /");
|
||||
if (containsControlCharacter(name)) errors.push("friendly_name is not allowed to contain control char");
|
||||
if (name.match(/.*\/\d*$/)) errors.push(`Friendly name cannot end with a "/DIGIT" ('${name}')`);
|
||||
if (name.includes('#') || name.includes('+')) {
|
||||
if (name.includes("#") || name.includes("+")) {
|
||||
errors.push(`MQTT wildcard (+ and #) not allowed in friendly_name ('${name}')`);
|
||||
}
|
||||
|
||||
@@ -280,10 +243,7 @@ function sleep(seconds: number): Promise<void> {
|
||||
}
|
||||
|
||||
function sanitizeImageParameter(parameter: string): string {
|
||||
const replaceByDash = [/\?/g, /&/g, /[^a-z\d\- _./:]/gi];
|
||||
let sanitized = parameter;
|
||||
replaceByDash.forEach((r) => (sanitized = sanitized.replace(r, '-')));
|
||||
return sanitized;
|
||||
return parameter.replace(/\?|&|[^a-z\d\- _./:]/gi, "-");
|
||||
}
|
||||
|
||||
function isAvailabilityEnabledForEntity(entity: Device | Group, settings: Settings): boolean {
|
||||
@@ -292,39 +252,28 @@ function isAvailabilityEnabledForEntity(entity: Device | Group, settings: Settin
|
||||
}
|
||||
|
||||
if (entity.isGroup()) {
|
||||
return !entity.membersDevices().some((d) => !isAvailabilityEnabledForEntity(d, settings));
|
||||
for (const memberDevice of entity.membersDevices()) {
|
||||
if (!isAvailabilityEnabledForEntity(memberDevice, settings)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entity.options.availability != null) {
|
||||
return !!entity.options.availability;
|
||||
}
|
||||
|
||||
// availability_timeout = deprecated
|
||||
if (!(settings.advanced.availability_timeout || settings.availability)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const passlist = settings.advanced.availability_passlist.concat(settings.advanced.availability_whitelist);
|
||||
|
||||
if (passlist.length > 0) {
|
||||
return passlist.includes(entity.name) || passlist.includes(entity.ieeeAddr);
|
||||
}
|
||||
|
||||
const blocklist = settings.advanced.availability_blacklist.concat(settings.advanced.availability_blocklist);
|
||||
|
||||
if (blocklist.length > 0) {
|
||||
return !blocklist.includes(entity.name) && !blocklist.includes(entity.ieeeAddr);
|
||||
}
|
||||
|
||||
return true;
|
||||
return settings.availability.enabled;
|
||||
}
|
||||
|
||||
function isEndpoint(obj: unknown): obj is zh.Endpoint {
|
||||
return obj.constructor.name.toLowerCase() === 'endpoint';
|
||||
function isZHEndpoint(obj: unknown): obj is zh.Endpoint {
|
||||
return obj?.constructor.name.toLowerCase() === "endpoint";
|
||||
}
|
||||
|
||||
function flatten<Type>(arr: Type[][]): Type[] {
|
||||
return [].concat(...arr);
|
||||
return ([] as Type[]).concat(...arr);
|
||||
}
|
||||
|
||||
function arrayUnique<Type>(arr: Type[]): Type[] {
|
||||
@@ -332,16 +281,12 @@ function arrayUnique<Type>(arr: Type[]): Type[] {
|
||||
}
|
||||
|
||||
function isZHGroup(obj: unknown): obj is zh.Group {
|
||||
return obj.constructor.name.toLowerCase() === 'group';
|
||||
return obj?.constructor.name.toLowerCase() === "group";
|
||||
}
|
||||
|
||||
function availabilityPayload(state: 'online' | 'offline', settings: Settings): string {
|
||||
return settings.advanced.legacy_availability_payload ? state : JSON.stringify({state});
|
||||
}
|
||||
|
||||
const hours = (hours: number): number => 1000 * 60 * 60 * hours;
|
||||
const minutes = (minutes: number): number => 1000 * 60 * minutes;
|
||||
const seconds = (seconds: number): number => 1000 * seconds;
|
||||
export const hours = (hours: number): number => 1000 * 60 * 60 * hours;
|
||||
export const minutes = (minutes: number): number => 1000 * 60 * minutes;
|
||||
export const seconds = (seconds: number): number => 1000 * seconds;
|
||||
|
||||
async function publishLastSeen(
|
||||
data: eventdata.LastSeenChanged,
|
||||
@@ -356,13 +301,13 @@ async function publishLastSeen(
|
||||
* - In case reason !== messageEmitted, controller.ts will call this based on the zigbee-herdsman
|
||||
* lastSeenChanged event.
|
||||
*/
|
||||
const allow = data.reason !== 'messageEmitted' || (data.reason === 'messageEmitted' && allowMessageEmitted);
|
||||
if (settings.advanced.last_seen && settings.advanced.last_seen !== 'disable' && allow) {
|
||||
await publishEntityState(data.device, {}, 'lastSeenChanged');
|
||||
const allow = data.reason !== "messageEmitted" || (data.reason === "messageEmitted" && allowMessageEmitted);
|
||||
if (settings.advanced.last_seen && settings.advanced.last_seen !== "disable" && allow) {
|
||||
await publishEntityState(data.device, {}, "lastSeenChanged");
|
||||
}
|
||||
}
|
||||
|
||||
function filterProperties(filter: string[], data: KeyValue): void {
|
||||
function filterProperties(filter: string[] | undefined, data: KeyValue): void {
|
||||
if (filter) {
|
||||
for (const property of Object.keys(data)) {
|
||||
if (filter.find((p) => property.match(`^${p}$`))) {
|
||||
@@ -372,28 +317,50 @@ function filterProperties(filter: string[], data: KeyValue): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function isNumericExposeFeature(feature: zhc.Feature): feature is zhc.Numeric {
|
||||
return feature?.type === 'numeric';
|
||||
export function isNumericExpose(expose: zhc.Expose): expose is zhc.Numeric {
|
||||
return expose?.type === "numeric";
|
||||
}
|
||||
|
||||
export function isEnumExposeFeature(feature: zhc.Feature): feature is zhc.Enum {
|
||||
return feature?.type === 'enum';
|
||||
export function assertEnumExpose(expose: zhc.Expose): asserts expose is zhc.Enum {
|
||||
assert(expose?.type === "enum");
|
||||
}
|
||||
|
||||
export function isBinaryExposeFeature(feature: zhc.Feature): feature is zhc.Binary {
|
||||
return feature?.type === 'binary';
|
||||
export function assertNumericExpose(expose: zhc.Expose): asserts expose is zhc.Numeric {
|
||||
assert(expose?.type === "numeric");
|
||||
}
|
||||
|
||||
function getScenes(entity: zh.Endpoint | zh.Group): Scene[] {
|
||||
const scenes: {[id: number]: Scene} = {};
|
||||
const endpoints = isEndpoint(entity) ? [entity] : entity.members;
|
||||
const groupID = isEndpoint(entity) ? 0 : entity.groupID;
|
||||
export function assertBinaryExpose(expose: zhc.Expose): asserts expose is zhc.Binary {
|
||||
assert(expose?.type === "binary");
|
||||
}
|
||||
|
||||
export function isEnumExpose(expose: zhc.Expose): expose is zhc.Enum {
|
||||
return expose?.type === "enum";
|
||||
}
|
||||
|
||||
export function isBinaryExpose(expose: zhc.Expose): expose is zhc.Binary {
|
||||
return expose?.type === "binary";
|
||||
}
|
||||
|
||||
export function isLightExpose(expose: zhc.Expose): expose is zhc.Light {
|
||||
return expose.type === "light";
|
||||
}
|
||||
|
||||
export function assertString(value: unknown, property: string): asserts value is string {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`${property} is not a string, got ${typeof value} (${value})`);
|
||||
}
|
||||
}
|
||||
|
||||
function getScenes(entity: zh.Endpoint | zh.Group): Zigbee2MQTTScene[] {
|
||||
const scenes: {[id: number]: Zigbee2MQTTScene} = {};
|
||||
const endpoints = isZHEndpoint(entity) ? [entity] : entity.members;
|
||||
const groupID = isZHEndpoint(entity) ? 0 : entity.groupID;
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
for (const [key, data] of Object.entries(endpoint.meta?.scenes || {})) {
|
||||
const split = key.split('_');
|
||||
const sceneID = parseInt(split[0], 10);
|
||||
const sceneGroupID = parseInt(split[1], 10);
|
||||
const split = key.split("_");
|
||||
const sceneID = Number.parseInt(split[0], 10);
|
||||
const sceneGroupID = Number.parseInt(split[1], 10);
|
||||
if (sceneGroupID === groupID) {
|
||||
scenes[sceneID] = {id: sceneID, name: (data as KeyValue).name || `Scene ${sceneID}`};
|
||||
}
|
||||
@@ -404,13 +371,35 @@ function getScenes(entity: zh.Endpoint | zh.Group): Scene[] {
|
||||
}
|
||||
|
||||
function deviceNotCoordinator(device: zh.Device): boolean {
|
||||
return device.type !== 'Coordinator';
|
||||
return device.type !== "Coordinator";
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
function matchBase64File(value: string | undefined): {extension: string; data: string} | false {
|
||||
if (value !== undefined) {
|
||||
const match = value.match(BASE64_IMAGE_REGEX);
|
||||
if (match) {
|
||||
assert(match.groups?.extension && match.groups?.data);
|
||||
return {extension: match.groups.extension, data: match.groups.data};
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function saveBase64DeviceIcon(base64Match: {extension: string; data: string}): string {
|
||||
const md5Hash = crypto.createHash("md5").update(base64Match.data).digest("hex");
|
||||
const fileSettings = `device_icons/${md5Hash}.${base64Match.extension}`;
|
||||
const file = path.join(data.getPath(), fileSettings);
|
||||
fs.mkdirSync(path.dirname(file), {recursive: true});
|
||||
fs.writeFileSync(file, base64Match.data, {encoding: "base64"});
|
||||
return fileSettings;
|
||||
}
|
||||
|
||||
/* v8 ignore next */
|
||||
const noop = (): void => {};
|
||||
|
||||
export default {
|
||||
matchBase64File,
|
||||
saveBase64DeviceIcon,
|
||||
capitalize,
|
||||
getZigbee2MQTTVersion,
|
||||
getDependencyVersion,
|
||||
@@ -421,12 +410,9 @@ export default {
|
||||
getObjectProperty,
|
||||
getResponse,
|
||||
parseJSON,
|
||||
loadModuleFromText,
|
||||
loadModuleFromFile,
|
||||
removeNullPropertiesFromObject,
|
||||
toNetworkAddressHex,
|
||||
toSnakeCase,
|
||||
isEndpoint,
|
||||
isZHEndpoint,
|
||||
isZHGroup,
|
||||
hours,
|
||||
minutes,
|
||||
@@ -436,7 +422,6 @@ export default {
|
||||
sanitizeImageParameter,
|
||||
isAvailabilityEnabledForEntity,
|
||||
publishLastSeen,
|
||||
availabilityPayload,
|
||||
getAllFiles,
|
||||
filterProperties,
|
||||
flatten,
|
||||
|
||||
+27
-9
@@ -1,26 +1,44 @@
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import fs from 'fs';
|
||||
import yaml from 'js-yaml';
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
|
||||
import equals from "fast-deep-equal/es6";
|
||||
import yaml, {YAMLException} from "js-yaml";
|
||||
|
||||
export class YAMLFileException extends YAMLException {
|
||||
file: string;
|
||||
|
||||
constructor(error: YAMLException, file: string) {
|
||||
super(error.reason, error.mark);
|
||||
|
||||
this.name = "YAMLFileException";
|
||||
this.cause = error.cause;
|
||||
this.message = error.message;
|
||||
this.stack = error.stack;
|
||||
this.file = file;
|
||||
}
|
||||
}
|
||||
|
||||
function read(file: string): KeyValue {
|
||||
try {
|
||||
const result = yaml.load(fs.readFileSync(file, 'utf8'));
|
||||
return (result as KeyValue) ?? {};
|
||||
const result = yaml.load(fs.readFileSync(file, "utf8"));
|
||||
assert(result instanceof Object, `The content of ${file} is expected to be an object`);
|
||||
return result as KeyValue;
|
||||
} catch (error) {
|
||||
if (error.name === 'YAMLException') {
|
||||
error.file = file;
|
||||
if (error instanceof YAMLException) {
|
||||
throw new YAMLFileException(error, file);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function readIfExists(file: string, default_?: KeyValue): KeyValue {
|
||||
return fs.existsSync(file) ? read(file) : default_;
|
||||
function readIfExists(file: string, fallback: KeyValue = {}): KeyValue {
|
||||
return fs.existsSync(file) ? read(file) : fallback;
|
||||
}
|
||||
|
||||
function writeIfChanged(file: string, content: KeyValue): void {
|
||||
const before = readIfExists(file);
|
||||
|
||||
if (!equals(before, content)) {
|
||||
fs.writeFileSync(file, yaml.dump(content));
|
||||
}
|
||||
|
||||
+175
-148
@@ -1,45 +1,46 @@
|
||||
import bind from 'bind-decorator';
|
||||
import {randomInt} from 'crypto';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import {Controller} from 'zigbee-herdsman';
|
||||
import * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
|
||||
import {randomInt} from "node:crypto";
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type {Events as ZHEvents} from "zigbee-herdsman";
|
||||
import {Controller} from "zigbee-herdsman";
|
||||
import type {StartResult} from "zigbee-herdsman/dist/adapter/tstype";
|
||||
|
||||
import Device from './model/device';
|
||||
import Group from './model/group';
|
||||
import data from './util/data';
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import utils from './util/utils';
|
||||
import Device from "./model/device";
|
||||
import Group from "./model/group";
|
||||
import data from "./util/data";
|
||||
import logger from "./util/logger";
|
||||
import * as settings from "./util/settings";
|
||||
import utils from "./util/utils";
|
||||
|
||||
const entityIDRegex = new RegExp(`^(.+?)(?:/([^/]+))?$`);
|
||||
const entityIDRegex = /^(.+?)(?:\/([^/]+))?$/;
|
||||
|
||||
export default class Zigbee {
|
||||
private herdsman: Controller;
|
||||
private herdsman!: Controller;
|
||||
private eventBus: EventBus;
|
||||
private groupLookup: {[s: number]: Group} = {};
|
||||
private deviceLookup: {[s: string]: Device} = {};
|
||||
private groupLookup = new Map<number /* group ID */, Group>();
|
||||
private deviceLookup = new Map<string /* IEEE address */, Device>();
|
||||
private coordinatorIeeeAddr!: string;
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
async start(): Promise<'reset' | 'resumed' | 'restored'> {
|
||||
const infoHerdsman = await utils.getDependencyVersion('zigbee-herdsman');
|
||||
async start(): Promise<StartResult> {
|
||||
const infoHerdsman = await utils.getDependencyVersion("zigbee-herdsman");
|
||||
logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`);
|
||||
const panId = settings.get().advanced.pan_id;
|
||||
const extPanId = settings.get().advanced.ext_pan_id;
|
||||
const networkKey = settings.get().advanced.network_key;
|
||||
const herdsmanSettings = {
|
||||
network: {
|
||||
panID: settings.get().advanced.pan_id === 'GENERATE' ? this.generatePanID() : (settings.get().advanced.pan_id as number),
|
||||
extendedPanID:
|
||||
settings.get().advanced.ext_pan_id === 'GENERATE' ? this.generateExtPanID() : (settings.get().advanced.ext_pan_id as number[]),
|
||||
panID: panId === "GENERATE" ? this.generatePanID() : panId,
|
||||
extendedPanID: extPanId === "GENERATE" ? this.generateExtPanID() : extPanId,
|
||||
channelList: [settings.get().advanced.channel],
|
||||
networkKey:
|
||||
settings.get().advanced.network_key === 'GENERATE'
|
||||
? this.generateNetworkKey()
|
||||
: (settings.get().advanced.network_key as number[]),
|
||||
networkKey: networkKey === "GENERATE" ? this.generateNetworkKey() : networkKey,
|
||||
},
|
||||
databasePath: data.joinPath('database.db'),
|
||||
databaseBackupPath: data.joinPath('database.db.backup'),
|
||||
backupPath: data.joinPath('coordinator_backup.json'),
|
||||
databasePath: data.joinPath("database.db"),
|
||||
databaseBackupPath: data.joinPath("database.db.backup"),
|
||||
backupPath: data.joinPath("coordinator_backup.json"),
|
||||
serialPort: {
|
||||
baudRate: settings.get().serial.baudrate,
|
||||
rtscts: settings.get().serial.rtscts,
|
||||
@@ -55,69 +56,74 @@ export default class Zigbee {
|
||||
acceptJoiningDeviceHandler: this.acceptJoiningDeviceHandler,
|
||||
};
|
||||
|
||||
const herdsmanSettingsLog = JSON.stringify(herdsmanSettings).replaceAll(JSON.stringify(herdsmanSettings.network.networkKey), '"HIDDEN"');
|
||||
logger.debug(`Using zigbee-herdsman with settings: '${stringify(herdsmanSettingsLog)}'`);
|
||||
logger.debug(
|
||||
() =>
|
||||
`Using zigbee-herdsman with settings: '${stringify(JSON.stringify(herdsmanSettings).replaceAll(JSON.stringify(herdsmanSettings.network.networkKey), '"HIDDEN"'))}'`,
|
||||
);
|
||||
|
||||
let startResult;
|
||||
let startResult: StartResult;
|
||||
try {
|
||||
this.herdsman = new Controller(herdsmanSettings);
|
||||
startResult = await this.herdsman.start();
|
||||
} catch (error) {
|
||||
logger.error(`Error while starting zigbee-herdsman`);
|
||||
logger.error("Error while starting zigbee-herdsman");
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await device.resolveDefinition();
|
||||
}
|
||||
this.coordinatorIeeeAddr = this.herdsman.getDevicesByType("Coordinator")[0].ieeeAddr;
|
||||
await this.resolveDevicesDefinitions();
|
||||
|
||||
this.herdsman.on('adapterDisconnected', () => this.eventBus.emitAdapterDisconnected());
|
||||
this.herdsman.on('lastSeenChanged', (data: ZHEvents.LastSeenChangedPayload) => {
|
||||
this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr), reason: data.reason});
|
||||
this.herdsman.on("adapterDisconnected", () => this.eventBus.emitAdapterDisconnected());
|
||||
this.herdsman.on("lastSeenChanged", (data: ZHEvents.LastSeenChangedPayload) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr)!, reason: data.reason});
|
||||
});
|
||||
this.herdsman.on('permitJoinChanged', (data: ZHEvents.PermitJoinChangedPayload) => {
|
||||
this.herdsman.on("permitJoinChanged", (data: ZHEvents.PermitJoinChangedPayload) => {
|
||||
this.eventBus.emitPermitJoinChanged(data);
|
||||
});
|
||||
this.herdsman.on('deviceNetworkAddressChanged', (data: ZHEvents.DeviceNetworkAddressChangedPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
this.herdsman.on("deviceNetworkAddressChanged", (data: ZHEvents.DeviceNetworkAddressChangedPayload) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
const device = this.resolveDevice(data.device.ieeeAddr)!;
|
||||
logger.debug(`Device '${device.name}' changed network address`);
|
||||
this.eventBus.emitDeviceNetworkAddressChanged({device});
|
||||
});
|
||||
this.herdsman.on('deviceAnnounce', (data: ZHEvents.DeviceAnnouncePayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
this.herdsman.on("deviceAnnounce", (data: ZHEvents.DeviceAnnouncePayload) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
const device = this.resolveDevice(data.device.ieeeAddr)!;
|
||||
logger.debug(`Device '${device.name}' announced itself`);
|
||||
this.eventBus.emitDeviceAnnounce({device});
|
||||
});
|
||||
this.herdsman.on('deviceInterview', async (data: ZHEvents.DeviceInterviewPayload) => {
|
||||
this.herdsman.on("deviceInterview", async (data: ZHEvents.DeviceInterviewPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
/* istanbul ignore if */ if (!device) return; // Prevent potential race
|
||||
/* v8 ignore next */ if (!device) return; // Prevent potential race
|
||||
await device.resolveDefinition();
|
||||
const d = {device, status: data.status};
|
||||
this.logDeviceInterview(d);
|
||||
this.eventBus.emitDeviceInterview(d);
|
||||
});
|
||||
this.herdsman.on('deviceJoined', async (data: ZHEvents.DeviceJoinedPayload) => {
|
||||
this.herdsman.on("deviceJoined", async (data: ZHEvents.DeviceJoinedPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
/* istanbul ignore if */ if (!device) return; // Prevent potential race
|
||||
/* v8 ignore next */ if (!device) return; // Prevent potential race
|
||||
await device.resolveDefinition();
|
||||
logger.info(`Device '${device.name}' joined`);
|
||||
this.eventBus.emitDeviceJoined({device});
|
||||
});
|
||||
this.herdsman.on('deviceLeave', (data: ZHEvents.DeviceLeavePayload) => {
|
||||
this.herdsman.on("deviceLeave", (data: ZHEvents.DeviceLeavePayload) => {
|
||||
const name = settings.getDevice(data.ieeeAddr)?.friendly_name || data.ieeeAddr;
|
||||
logger.warning(`Device '${name}' left the network`);
|
||||
this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name});
|
||||
this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name, device: this.deviceLookup.get(data.ieeeAddr)});
|
||||
});
|
||||
this.herdsman.on('message', async (data: ZHEvents.MessagePayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
this.herdsman.on("message", async (data: ZHEvents.MessagePayload) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
const device = this.resolveDevice(data.device.ieeeAddr)!;
|
||||
await device.resolveDefinition();
|
||||
logger.debug(
|
||||
`Received Zigbee message from '${device.name}', type '${data.type}', ` +
|
||||
`cluster '${data.cluster}', data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}` +
|
||||
(data.hasOwnProperty('groupID') ? ` with groupID ${data.groupID}` : ``) +
|
||||
(device.zh.type === 'Coordinator' ? `, ignoring since it is from coordinator` : ``),
|
||||
);
|
||||
if (device.zh.type === 'Coordinator') return;
|
||||
logger.debug(() => {
|
||||
const groupId = data.groupID !== undefined ? ` with groupID ${data.groupID}` : "";
|
||||
const fromCoord = device.zh.type === "Coordinator" ? ", ignoring since it is from coordinator" : "";
|
||||
|
||||
return `Received Zigbee message from '${device.name}', type '${data.type}', cluster '${data.cluster}', data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}${groupId}${fromCoord}`;
|
||||
});
|
||||
if (device.zh.type === "Coordinator") return;
|
||||
this.eventBus.emitDeviceMessage({...data, device});
|
||||
});
|
||||
|
||||
@@ -133,7 +139,7 @@ export default class Zigbee {
|
||||
try {
|
||||
await device.zh.removeFromNetwork();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove '${device.ieeeAddr}' (${error.message})`);
|
||||
logger.error(`Failed to remove '${device.ieeeAddr}' (${(error as Error).message})`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -153,20 +159,19 @@ export default class Zigbee {
|
||||
|
||||
private logDeviceInterview(data: eventdata.DeviceInterview): void {
|
||||
const name = data.device.name;
|
||||
if (data.status === 'successful') {
|
||||
if (data.status === "successful") {
|
||||
logger.info(`Successfully interviewed '${name}', device has successfully been paired`);
|
||||
|
||||
if (data.device.isSupported) {
|
||||
const {vendor, description, model} = data.device.definition;
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from `isSupported`
|
||||
const {vendor, description, model} = data.device.definition!;
|
||||
logger.info(`Device '${name}' is supported, identified as: ${vendor} ${description} (${model})`);
|
||||
} else {
|
||||
logger.warning(
|
||||
`Device '${name}' with Zigbee model '${data.device.zh.modelID}' and manufacturer name ` +
|
||||
`'${data.device.zh.manufacturerName}' is NOT supported, ` +
|
||||
`please follow https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`,
|
||||
`Device '${name}' with Zigbee model '${data.device.zh.modelID}' and manufacturer name '${data.device.zh.manufacturerName}' is NOT supported, please follow https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`,
|
||||
);
|
||||
}
|
||||
} else if (data.status === 'failed') {
|
||||
} else if (data.status === "failed") {
|
||||
logger.error(`Failed to interview '${name}', device has not successfully been paired`);
|
||||
} else {
|
||||
// data.status === 'started'
|
||||
@@ -176,24 +181,24 @@ export default class Zigbee {
|
||||
|
||||
private generateNetworkKey(): number[] {
|
||||
const key = Array.from({length: 16}, () => randomInt(256));
|
||||
settings.set(['advanced', 'network_key'], key);
|
||||
settings.set(["advanced", "network_key"], key);
|
||||
return key;
|
||||
}
|
||||
|
||||
private generateExtPanID(): number[] {
|
||||
const key = Array.from({length: 8}, () => randomInt(256));
|
||||
settings.set(['advanced', 'ext_pan_id'], key);
|
||||
settings.set(["advanced", "ext_pan_id"], key);
|
||||
return key;
|
||||
}
|
||||
|
||||
private generatePanID(): number {
|
||||
const panID = randomInt(1, 0xffff - 1);
|
||||
settings.set(['advanced', 'pan_id'], panID);
|
||||
settings.set(["advanced", "pan_id"], panID);
|
||||
return panID;
|
||||
}
|
||||
|
||||
async getCoordinatorVersion(): Promise<zh.CoordinatorVersion> {
|
||||
return this.herdsman.getCoordinatorVersion();
|
||||
return await this.herdsman.getCoordinatorVersion();
|
||||
}
|
||||
|
||||
isStopping(): boolean {
|
||||
@@ -201,93 +206,106 @@ export default class Zigbee {
|
||||
}
|
||||
|
||||
async backup(): Promise<void> {
|
||||
return this.herdsman.backup();
|
||||
return await this.herdsman.backup();
|
||||
}
|
||||
|
||||
async coordinatorCheck(): Promise<{missingRouters: Device[]}> {
|
||||
const check = await this.herdsman.coordinatorCheck();
|
||||
return {missingRouters: check.missingRouters.map((d) => this.resolveDevice(d.ieeeAddr))};
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
return {missingRouters: check.missingRouters.map((d) => this.resolveDevice(d.ieeeAddr)!)};
|
||||
}
|
||||
|
||||
async getNetworkParameters(): Promise<zh.NetworkParameters> {
|
||||
return this.herdsman.getNetworkParameters();
|
||||
}
|
||||
|
||||
async reset(type: 'soft' | 'hard'): Promise<void> {
|
||||
await this.herdsman.reset(type);
|
||||
return await this.herdsman.getNetworkParameters();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
logger.info('Stopping zigbee-herdsman...');
|
||||
logger.info("Stopping zigbee-herdsman...");
|
||||
await this.herdsman.stop();
|
||||
logger.info('Stopped zigbee-herdsman');
|
||||
logger.info("Stopped zigbee-herdsman");
|
||||
}
|
||||
|
||||
getPermitJoin(): boolean {
|
||||
return this.herdsman.getPermitJoin();
|
||||
}
|
||||
|
||||
getPermitJoinTimeout(): number {
|
||||
return this.herdsman.getPermitJoinTimeout();
|
||||
getPermitJoinEnd(): number | undefined {
|
||||
return this.herdsman.getPermitJoinEnd();
|
||||
}
|
||||
|
||||
async permitJoin(permit: boolean, device?: Device, time: number = undefined): Promise<void> {
|
||||
if (permit) {
|
||||
logger.info(`Zigbee: allowing new devices to join${device ? ` via ${device.name}` : ''}.`);
|
||||
async permitJoin(time: number, device?: Device): Promise<void> {
|
||||
if (time > 0) {
|
||||
logger.info(`Zigbee: allowing new devices to join${device ? ` via ${device.name}` : ""}.`);
|
||||
} else {
|
||||
logger.info('Zigbee: disabling joining new devices.');
|
||||
logger.info("Zigbee: disabling joining new devices.");
|
||||
}
|
||||
|
||||
if (device && permit) {
|
||||
await this.herdsman.permitJoin(permit, device.zh, time);
|
||||
} else {
|
||||
await this.herdsman.permitJoin(permit, undefined, time);
|
||||
await this.herdsman.permitJoin(time, device?.zh);
|
||||
}
|
||||
|
||||
async resolveDevicesDefinitions(ignoreCache = false): Promise<void> {
|
||||
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await device.resolveDefinition(ignoreCache);
|
||||
}
|
||||
}
|
||||
|
||||
@bind private resolveDevice(ieeeAddr: string): Device {
|
||||
if (!this.deviceLookup[ieeeAddr]) {
|
||||
@bind private resolveDevice(ieeeAddr: string): Device | undefined {
|
||||
if (!this.deviceLookup.has(ieeeAddr)) {
|
||||
const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
|
||||
if (device) {
|
||||
this.deviceLookup[ieeeAddr] = new Device(device);
|
||||
this.deviceLookup.set(ieeeAddr, new Device(device));
|
||||
}
|
||||
}
|
||||
|
||||
const device = this.deviceLookup[ieeeAddr];
|
||||
const device = this.deviceLookup.get(ieeeAddr);
|
||||
if (device && !device.zh.isDeleted) {
|
||||
device.ensureInSettings();
|
||||
return device;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveGroup(groupID: number): Group {
|
||||
const group = this.herdsman.getGroupByID(Number(groupID));
|
||||
if (group && !this.groupLookup[groupID]) {
|
||||
this.groupLookup[groupID] = new Group(group, this.resolveDevice);
|
||||
}
|
||||
private resolveGroup(groupID: number): Group | undefined {
|
||||
if (!this.groupLookup.has(groupID)) {
|
||||
const group = this.herdsman.getGroupByID(groupID);
|
||||
|
||||
return this.groupLookup[groupID];
|
||||
}
|
||||
|
||||
resolveEntity(key: string | number | zh.Device): Device | Group {
|
||||
if (typeof key === 'object') {
|
||||
return this.resolveDevice(key.ieeeAddr);
|
||||
} else if (typeof key === 'string' && key.toLowerCase() === 'coordinator') {
|
||||
return this.resolveDevice(this.herdsman.getDevicesByType('Coordinator')[0].ieeeAddr);
|
||||
} else {
|
||||
const settingsDevice = settings.getDevice(key.toString());
|
||||
if (settingsDevice) return this.resolveDevice(settingsDevice.ID);
|
||||
|
||||
const groupSettings = settings.getGroup(key);
|
||||
if (groupSettings) {
|
||||
const group = this.resolveGroup(groupSettings.ID);
|
||||
// If group does not exist, create it (since it's already in configuration.yaml)
|
||||
return group ? group : this.createGroup(groupSettings.ID);
|
||||
if (group) {
|
||||
this.groupLookup.set(groupID, new Group(group, this.resolveDevice));
|
||||
}
|
||||
}
|
||||
|
||||
const group = this.groupLookup.get(groupID);
|
||||
|
||||
if (group) {
|
||||
group.ensureInSettings();
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
resolveEntityAndEndpoint(ID: string): {ID: string; entity: Device | Group; endpointID: string; endpoint: zh.Endpoint} {
|
||||
resolveEntity(key: string | number | zh.Device): Device | Group | undefined {
|
||||
if (typeof key === "object") {
|
||||
return this.resolveDevice(key.ieeeAddr);
|
||||
}
|
||||
|
||||
if (typeof key === "string" && (key.toLowerCase() === "coordinator" || key === this.coordinatorIeeeAddr)) {
|
||||
return this.resolveDevice(this.coordinatorIeeeAddr);
|
||||
}
|
||||
|
||||
const settingsDevice = settings.getDevice(key.toString());
|
||||
|
||||
if (settingsDevice) {
|
||||
return this.resolveDevice(settingsDevice.ID);
|
||||
}
|
||||
|
||||
const groupSettings = settings.getGroup(key);
|
||||
|
||||
if (groupSettings) {
|
||||
const group = this.resolveGroup(groupSettings.ID);
|
||||
// If group does not exist, create it (since it's already in configuration.yaml)
|
||||
return group ? group : this.createGroup(groupSettings.ID);
|
||||
}
|
||||
}
|
||||
|
||||
resolveEntityAndEndpoint(id: string): {ID: string; entity: Device | Group | undefined; endpointID?: string; endpoint?: zh.Endpoint} {
|
||||
// This function matches the following entity formats:
|
||||
// device_name (just device name)
|
||||
// device_name/ep_name (device name and endpoint numeric ID or name)
|
||||
@@ -295,31 +313,33 @@ export default class Zigbee {
|
||||
// device/name/ep_name (device name with slashes, and endpoint numeric ID or name)
|
||||
|
||||
// The function tries to find an exact match first
|
||||
let entityName = ID;
|
||||
let deviceOrGroup = this.resolveEntity(ID);
|
||||
let endpointNameOrID = undefined;
|
||||
let entityName = id;
|
||||
let deviceOrGroup = this.resolveEntity(id);
|
||||
let endpointNameOrID: string | undefined;
|
||||
|
||||
// If exact match did not happenc, try matching a device_name/endpoint pattern
|
||||
// If exact match did not happen, try matching a device_name/endpoint pattern
|
||||
if (!deviceOrGroup) {
|
||||
// First split the input token by the latest slash
|
||||
const match = ID.match(entityIDRegex);
|
||||
const match = id.match(entityIDRegex);
|
||||
|
||||
// Get the resulting IDs from the match
|
||||
entityName = match[1];
|
||||
deviceOrGroup = this.resolveEntity(match[1]);
|
||||
endpointNameOrID = match[2];
|
||||
if (match) {
|
||||
// Get the resulting IDs from the match
|
||||
entityName = match[1];
|
||||
deviceOrGroup = this.resolveEntity(entityName);
|
||||
endpointNameOrID = match[2];
|
||||
}
|
||||
}
|
||||
|
||||
// If the function returns non-null endpoint name, but the endpoint field is null, then
|
||||
// it means that endpoint was not matched because there is no such endpoint on the device
|
||||
// (or the entity is a group)
|
||||
const endpoint = deviceOrGroup?.isDevice() ? deviceOrGroup.endpoint(endpointNameOrID) : null;
|
||||
const endpoint = deviceOrGroup?.isDevice() ? deviceOrGroup.endpoint(endpointNameOrID) : undefined;
|
||||
|
||||
return {ID: entityName, entity: deviceOrGroup, endpointID: endpointNameOrID, endpoint: endpoint};
|
||||
return {ID: entityName, entity: deviceOrGroup, endpointID: endpointNameOrID, endpoint};
|
||||
}
|
||||
|
||||
firstCoordinatorEndpoint(): zh.Endpoint {
|
||||
return this.herdsman.getDevicesByType('Coordinator')[0].endpoints[0];
|
||||
return this.herdsman.getDevicesByType("Coordinator")[0].endpoints[0];
|
||||
}
|
||||
|
||||
*devicesAndGroupsIterator(
|
||||
@@ -327,26 +347,31 @@ export default class Zigbee {
|
||||
groupPredicate?: (value: zh.Group) => boolean,
|
||||
): Generator<Device | Group> {
|
||||
for (const device of this.herdsman.getDevicesIterator(devicePredicate)) {
|
||||
yield this.resolveDevice(device.ieeeAddr);
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveDevice(device.ieeeAddr)!;
|
||||
}
|
||||
|
||||
for (const group of this.herdsman.getGroupsIterator(groupPredicate)) {
|
||||
yield this.resolveGroup(group.groupID);
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveGroup(group.groupID)!;
|
||||
}
|
||||
}
|
||||
|
||||
*groupsIterator(predicate?: (value: zh.Group) => boolean): Generator<Group> {
|
||||
for (const group of this.herdsman.getGroupsIterator(predicate)) {
|
||||
yield this.resolveGroup(group.groupID);
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveGroup(group.groupID)!;
|
||||
}
|
||||
}
|
||||
|
||||
*devicesIterator(predicate?: (value: zh.Device) => boolean): Generator<Device> {
|
||||
for (const device of this.herdsman.getDevicesIterator(predicate)) {
|
||||
yield this.resolveDevice(device.ieeeAddr);
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveDevice(device.ieeeAddr)!;
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
@bind private async acceptJoiningDeviceHandler(ieeeAddr: string): Promise<boolean> {
|
||||
// If passlist is set, all devices not on passlist will be rejected to join the network
|
||||
const passlist = settings.get().passlist;
|
||||
@@ -355,29 +380,30 @@ export default class Zigbee {
|
||||
if (passlist.includes(ieeeAddr)) {
|
||||
logger.info(`Accepting joining device which is on passlist '${ieeeAddr}'`);
|
||||
return true;
|
||||
} else {
|
||||
logger.info(`Rejecting joining not in passlist device '${ieeeAddr}'`);
|
||||
return false;
|
||||
}
|
||||
} else if (blocklist.length > 0) {
|
||||
|
||||
logger.info(`Rejecting joining not in passlist device '${ieeeAddr}'`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (blocklist.length > 0) {
|
||||
if (blocklist.includes(ieeeAddr)) {
|
||||
logger.info(`Rejecting joining device which is on blocklist '${ieeeAddr}'`);
|
||||
return false;
|
||||
} else {
|
||||
logger.info(`Accepting joining not in blocklist device '${ieeeAddr}'`);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
|
||||
logger.info(`Accepting joining not in blocklist device '${ieeeAddr}'`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async touchlinkFactoryResetFirst(): Promise<boolean> {
|
||||
return this.herdsman.touchlinkFactoryResetFirst();
|
||||
return await this.herdsman.touchlinkFactoryResetFirst();
|
||||
}
|
||||
|
||||
async touchlinkFactoryReset(ieeeAddr: string, channel: number): Promise<boolean> {
|
||||
return this.herdsman.touchlinkFactoryReset(ieeeAddr, channel);
|
||||
return await this.herdsman.touchlinkFactoryReset(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async addInstallCode(installCode: string): Promise<void> {
|
||||
@@ -389,20 +415,21 @@ export default class Zigbee {
|
||||
}
|
||||
|
||||
async touchlinkScan(): Promise<{ieeeAddr: string; channel: number}[]> {
|
||||
return this.herdsman.touchlinkScan();
|
||||
return await this.herdsman.touchlinkScan();
|
||||
}
|
||||
|
||||
createGroup(ID: number): Group {
|
||||
this.herdsman.createGroup(ID);
|
||||
return this.resolveGroup(ID);
|
||||
createGroup(id: number): Group {
|
||||
this.herdsman.createGroup(id);
|
||||
// biome-ignore lint/style/noNonNullAssertion: just created
|
||||
return this.resolveGroup(id)!;
|
||||
}
|
||||
|
||||
deviceByNetworkAddress(networkAddress: number): Device {
|
||||
deviceByNetworkAddress(networkAddress: number): Device | undefined {
|
||||
const device = this.herdsman.getDeviceByNetworkAddress(networkAddress);
|
||||
return device && this.resolveDevice(device.ieeeAddr);
|
||||
}
|
||||
|
||||
groupByID(ID: number): Group {
|
||||
return this.resolveGroup(ID);
|
||||
groupByID(id: number): Group | undefined {
|
||||
return this.resolveGroup(id);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-10536
File diff suppressed because it is too large
Load Diff
+45
-59
@@ -1,14 +1,16 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "1.40.0",
|
||||
"version": "2.6.1",
|
||||
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
|
||||
"main": "index.js",
|
||||
"types": "dist/types/api.d.ts",
|
||||
"packageManager": "pnpm@10.12.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Koenkk/zigbee2mqtt.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || ^22"
|
||||
"node": "^20 || ^22 || ^24"
|
||||
},
|
||||
"keywords": [
|
||||
"xiaomi",
|
||||
@@ -21,14 +23,17 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc && node index.js writehash",
|
||||
"build-watch": "tsc --watch",
|
||||
"eslint": "eslint lib/ --max-warnings=0",
|
||||
"pretty:write": "prettier --write .",
|
||||
"pretty:check": "prettier --check .",
|
||||
"build:types": "pnpm run clean && tsc --project tsconfig.types.json && cp lib/util/settings.schema.json dist/util",
|
||||
"build:watch": "tsc --watch",
|
||||
"check": "biome check --error-on-warnings",
|
||||
"check:w": "biome check --write",
|
||||
"start": "node index.js",
|
||||
"test-with-coverage": "jest test --silent --maxWorkers=50% --coverage",
|
||||
"test": "jest test --silent --maxWorkers=50%",
|
||||
"test-watch": "jest test --silent --maxWorkers=25% --watch"
|
||||
"test": "vitest run --config ./test/vitest.config.mts",
|
||||
"test:coverage": "vitest run --config ./test/vitest.config.mts --coverage",
|
||||
"test:watch": "vitest watch --config ./test/vitest.config.mts",
|
||||
"bench": "vitest bench --run --config ./test/vitest.config.mts",
|
||||
"prepack": "pnpm run clean && pnpm run build",
|
||||
"clean": "rimraf coverage dist tsconfig.tsbuildinfo"
|
||||
},
|
||||
"author": "Koen Kanters",
|
||||
"license": "GPL-3.0",
|
||||
@@ -39,79 +44,60 @@
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"bind-decorator": "^1.0.11",
|
||||
"connect-gzip-static": "3.0.1",
|
||||
"debounce": "^2.1.0",
|
||||
"debounce": "^2.2.0",
|
||||
"express-static-gzip": "^3.0.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"finalhandler": "^1.2.0",
|
||||
"git-last-commit": "^1.0.1",
|
||||
"finalhandler": "^2.1.0",
|
||||
"humanize-duration": "^3.32.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"mkdir-recursive": "^0.4.0",
|
||||
"moment": "^2.30.1",
|
||||
"mqtt": "^5.10.0",
|
||||
"mqtt": "^5.14.0",
|
||||
"object-assign-deep": "^0.4.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"semver": "^7.6.3",
|
||||
"semver": "^7.7.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"uri-js": "^4.4.1",
|
||||
"winston": "^3.14.2",
|
||||
"throttleit": "^2.1.0",
|
||||
"winston": "^3.17.0",
|
||||
"winston-syslog": "^2.7.1",
|
||||
"winston-transport": "^4.7.1",
|
||||
"ws": "^8.18.0",
|
||||
"zigbee-herdsman": "0.57.1",
|
||||
"zigbee-herdsman-converters": "20.8.4",
|
||||
"zigbee2mqtt-frontend": "0.7.4"
|
||||
"winston-transport": "^4.9.0",
|
||||
"ws": "^8.18.1",
|
||||
"zigbee-herdsman": "6.0.4",
|
||||
"zigbee-herdsman-converters": "25.14.0",
|
||||
"zigbee2mqtt-frontend": "0.9.20",
|
||||
"zigbee2mqtt-windfront": "2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/plugin-proposal-decorators": "^7.24.7",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@babel/preset-typescript": "^7.24.7",
|
||||
"@biomejs/biome": "^2.2.2",
|
||||
"@types/finalhandler": "^1.2.3",
|
||||
"@types/humanize-duration": "^3.27.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.5.0",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/object-assign-deep": "^0.4.3",
|
||||
"@types/readable-stream": "4.0.15",
|
||||
"@types/ws": "8.5.12",
|
||||
"@typescript-eslint/eslint-plugin": "^8.2.0",
|
||||
"@typescript-eslint/parser": "^8.2.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jest": "^28.8.0",
|
||||
"eslint-plugin-perfectionist": "^3.2.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.3.3",
|
||||
"tmp": "^0.2.3",
|
||||
"typescript": "^5.5.4"
|
||||
"@types/readable-stream": "4.0.21",
|
||||
"@types/serve-static": "^1.15.7",
|
||||
"@types/ws": "8.18.1",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"tmp": "^0.2.5",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"overrides": {
|
||||
"zigbee-herdsman-converters": {
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"zigbee-herdsman": "$zigbee-herdsman"
|
||||
}
|
||||
},
|
||||
"jest": {
|
||||
"coverageThreshold": {
|
||||
"global": {
|
||||
"branches": 100,
|
||||
"functions": 100,
|
||||
"lines": 100,
|
||||
"statements": 100
|
||||
}
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"lib/**/*.js",
|
||||
"lib/**/*.ts"
|
||||
"onlyBuiltDependencies": [
|
||||
"@biomejs/biome",
|
||||
"@serialport/bindings-cpp",
|
||||
"esbuild",
|
||||
"unix-dgram"
|
||||
]
|
||||
},
|
||||
"bin": {
|
||||
"zigbee2mqtt": "cli.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sd-notify": "^2.8.0"
|
||||
"unix-dgram": "^2.0.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2808
File diff suppressed because it is too large
Load Diff
@@ -1,150 +0,0 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const process = require('process');
|
||||
const {execSync} = require('child_process');
|
||||
const zhc = require('zigbee-herdsman-converters');
|
||||
|
||||
const z2mTillVersion = process.argv[2];
|
||||
const zhcTillVersion = process.argv[3];
|
||||
const zhTillVersion = process.argv[4];
|
||||
const frontendTillVersion = process.argv[5];
|
||||
|
||||
const changelogs = [
|
||||
{
|
||||
tillVersion: z2mTillVersion,
|
||||
project: 'koenkk/zigbee2mqtt',
|
||||
contents: fs.readFileSync(path.join(__dirname, '..', 'CHANGELOG.md'), 'utf-8').split('\n'),
|
||||
},
|
||||
{
|
||||
tillVersion: zhcTillVersion,
|
||||
project: 'koenkk/zigbee-herdsman-converters',
|
||||
contents: fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'zigbee-herdsman-converters', 'CHANGELOG.md'), 'utf-8').split('\n'),
|
||||
},
|
||||
{
|
||||
tillVersion: zhTillVersion,
|
||||
project: 'koenkk/zigbee-herdsman',
|
||||
contents: fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'zigbee-herdsman', 'CHANGELOG.md'), 'utf-8').split('\n'),
|
||||
},
|
||||
{
|
||||
tillVersion: frontendTillVersion,
|
||||
project: 'nurikk/zigbee2mqtt-frontend',
|
||||
isFrontend: true,
|
||||
contents: fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'zigbee2mqtt-frontend', 'CHANGELOG.md'), 'utf-8').split('\n'),
|
||||
},
|
||||
];
|
||||
|
||||
const releaseRe = /## \[(.+)\]/;
|
||||
const changes = {features: [], fixes: [], detect: [], add: [], error: [], frontend: []};
|
||||
let context = null;
|
||||
const changeRe = [
|
||||
/^\* (\*\*(.+):\*\*)?(.+)\((\[#\d+\]\(.+\))\) \(\[.+\]\(https:.+\/(.+)\)\)$/,
|
||||
/^\* (\*\*(.+):\*\*)?(.+)(https:\/\/github\.com.+) \(\[.+\]\(https:.+\/(.+)\)\)$/,
|
||||
/^\* (\*\*(.+):\*\*)?(.+)() \(\[.+\]\(https:.+\/(.+)\)\)$/,
|
||||
];
|
||||
const frontendChangeRe = /^\* (\*\*.+:\*\* )()?(.+) \(\[.+\]\(https:.+\/()(.+)\)\)(.+)?$/;
|
||||
|
||||
let commitUserLookup = {};
|
||||
const commitUserFile = path.join(__dirname, 'commit-user-lookup.json');
|
||||
if (fs.existsSync(commitUserFile)) {
|
||||
commitUserLookup = JSON.parse(fs.readFileSync(commitUserFile, 'utf8'));
|
||||
}
|
||||
|
||||
const whiteLabels = zhc.definitions.filter((d) => d.whiteLabel).flatMap((d) => d.whiteLabel);
|
||||
const capitalizeFirstChar = (str) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
for (const changelog of changelogs) {
|
||||
for (const line of changelog.contents) {
|
||||
const releaseMatch = line.match(releaseRe);
|
||||
const changeMatch = changelog.isFrontend ? line.match(frontendChangeRe) : changeRe.map((re) => line.match(re)).find((e) => e);
|
||||
if (releaseMatch) {
|
||||
if (releaseMatch[1] === changelog.tillVersion) {
|
||||
break;
|
||||
}
|
||||
} else if (line === '### Features') {
|
||||
context = 'features';
|
||||
} else if (line === '### Bug Fixes') {
|
||||
context = 'fixes';
|
||||
} else if (line.startsWith('* **ignore:**')) {
|
||||
continue;
|
||||
} else if (changeMatch) {
|
||||
let localContext = changelog.isFrontend ? 'frontend' : changeMatch[2] ? changeMatch[2] : context;
|
||||
if (!changes[localContext]) localContext = 'error';
|
||||
|
||||
const commit = changeMatch[5];
|
||||
const commitUserKey = `${changelog.project}-${commit} `;
|
||||
let user =
|
||||
commitUserKey in commitUserLookup
|
||||
? commitUserLookup[commitUserKey]
|
||||
: execSync(`curl -s https://api.github.com/repos/${changelog.project}/commits/${commit} | jq -r '.author.login'`)
|
||||
.toString()
|
||||
.trim();
|
||||
if (user !== 'null') commitUserLookup[commitUserKey] = user;
|
||||
const messages = [];
|
||||
let message = changeMatch[3].trim();
|
||||
if (message.endsWith('.')) message = message.substring(0, message.length - 1);
|
||||
|
||||
if (changelog.isFrontend) {
|
||||
changes[localContext].push(
|
||||
`- [${commit.slice(0, 7)}](https://github.com/${changelog.project}/commit/${commit}) ${message} (@${user})`,
|
||||
);
|
||||
messages.push(capitalizeFirstChar(message));
|
||||
} else {
|
||||
const otherUser = message.match(/\[@(.+)\]\(https:\/\/github.com\/.+\)/) || message.match(/@(.+)/);
|
||||
if (otherUser) {
|
||||
user = otherUser[1];
|
||||
message = message.replace(otherUser[0], '');
|
||||
}
|
||||
|
||||
if (localContext === 'add') {
|
||||
for (const model of message.split(',')) {
|
||||
const definition = zhc.definitions.find((d) => d.model === model.trim());
|
||||
const whiteLabel = whiteLabels.find((d) => d.model === model.trim());
|
||||
const match = definition || whiteLabel;
|
||||
if (match) {
|
||||
messages.push(`\`${match.model}\` ${match.vendor} ${match.description}`);
|
||||
} else {
|
||||
changes['error'].push(`${line} (model '${model}' does not exist)`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messages.push(capitalizeFirstChar(message));
|
||||
}
|
||||
|
||||
let issue = changeMatch[4].trim();
|
||||
if (issue && !issue.startsWith('[#')) issue = `[#${issue.split('/').pop()}](${issue})`;
|
||||
if (!issue) {
|
||||
issue = '_NO_ISSUE_';
|
||||
localContext = 'error';
|
||||
}
|
||||
|
||||
messages.forEach((m) => changes[localContext].push(`- ${issue} ${m} (@${user})`));
|
||||
}
|
||||
} else if (line === '# Changelog' || line === '### ⚠ BREAKING CHANGES' || !line) {
|
||||
continue;
|
||||
} else {
|
||||
changes['error'].push(`- Unmatched line: ${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = '';
|
||||
const names = [
|
||||
['features', 'Improvements'],
|
||||
['fixes', 'Fixes'],
|
||||
['frontend', 'Frontend'],
|
||||
['add', 'New supported devices'],
|
||||
['detect', 'Fixed device detections'],
|
||||
['error', 'Changelog generator error'],
|
||||
];
|
||||
for (const name of names) {
|
||||
result += `# ${name[1]}\n`;
|
||||
if (name[0] === 'add') {
|
||||
result += `This release adds support for ${changes['add'].length} devices: \n`;
|
||||
}
|
||||
changes[name[0]].forEach((e) => (result += `${e}\n`));
|
||||
result += '\n';
|
||||
}
|
||||
|
||||
fs.writeFileSync(commitUserFile, JSON.stringify(commitUserLookup), 'utf-8');
|
||||
|
||||
console.log(result.trim());
|
||||
@@ -0,0 +1,185 @@
|
||||
import {execSync} from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import zhc from "zigbee-herdsman-converters";
|
||||
import definitionIndex from "zigbee-herdsman-converters/devices/index";
|
||||
|
||||
const z2mTillVersion = process.argv[2];
|
||||
const zhcTillVersion = process.argv[3];
|
||||
const zhTillVersion = process.argv[4];
|
||||
const frontendTillVersion = process.argv[5];
|
||||
const windfrontTillVersion = process.argv[6];
|
||||
|
||||
const changelogs = [
|
||||
{
|
||||
tillVersion: z2mTillVersion,
|
||||
project: "koenkk/zigbee2mqtt",
|
||||
contents: fs.readFileSync(path.join(import.meta.dirname, "..", "CHANGELOG.md"), "utf-8").split("\n"),
|
||||
},
|
||||
{
|
||||
tillVersion: zhcTillVersion,
|
||||
project: "koenkk/zigbee-herdsman-converters",
|
||||
contents: fs
|
||||
.readFileSync(path.join(import.meta.dirname, "..", "node_modules", "zigbee-herdsman-converters", "CHANGELOG.md"), "utf-8")
|
||||
.split("\n"),
|
||||
},
|
||||
{
|
||||
tillVersion: zhTillVersion,
|
||||
project: "koenkk/zigbee-herdsman",
|
||||
contents: fs.readFileSync(path.join(import.meta.dirname, "..", "node_modules", "zigbee-herdsman", "CHANGELOG.md"), "utf-8").split("\n"),
|
||||
},
|
||||
{
|
||||
tillVersion: frontendTillVersion,
|
||||
project: "nurikk/zigbee2mqtt-frontend",
|
||||
isFrontend: true,
|
||||
contents: fs.readFileSync(path.join(import.meta.dirname, "..", "node_modules", "zigbee2mqtt-frontend", "CHANGELOG.md"), "utf-8").split("\n"),
|
||||
},
|
||||
{
|
||||
tillVersion: windfrontTillVersion,
|
||||
project: "Nerivec/zigbee2mqtt-windfront",
|
||||
},
|
||||
];
|
||||
|
||||
const releaseRe = /## \[(.+)\]/;
|
||||
const windfrontChangeRe = /^\* (feat|fix): (.+?)(?: by @([^\s]+) in (https:\/\/github\.com\/Nerivec\/zigbee2mqtt-windfront\/pull\/(\d+)))?$/gm;
|
||||
const changes = {features: [], fixes: [], detect: [], add: [], error: [], frontend: [], windfront: []};
|
||||
let context = null;
|
||||
const changeRe = [
|
||||
/^\* (\*\*(.+):\*\*)?(.+)\((\[#\d+\]\(.+\))\) \(\[.+\]\(https:.+\/(.+)\)\)$/,
|
||||
/^\* (\*\*(.+):\*\*)?(.+)(https:\/\/github\.com.+) \(\[.+\]\(https:.+\/(.+)\)\)$/,
|
||||
/^\* (\*\*(.+):\*\*)?(.+)() \(\[.+\]\(https:.+\/(.+)\)\)$/,
|
||||
];
|
||||
const frontendChangeRe = /^\* (\*\*.+:\*\* )()?(.+) \(\[.+\]\(https:.+\/()(.+)\)\)(.+)?$/;
|
||||
|
||||
let commitUserLookup = {};
|
||||
const commitUserFile = path.join(import.meta.dirname, "commit-user-lookup.json");
|
||||
if (fs.existsSync(commitUserFile)) {
|
||||
commitUserLookup = JSON.parse(fs.readFileSync(commitUserFile, "utf8"));
|
||||
}
|
||||
|
||||
const definitions = definitionIndex.default.map((d) => zhc.prepareDefinition(d));
|
||||
const whiteLabels = definitions.filter((d) => d.whiteLabel).flatMap((d) => d.whiteLabel);
|
||||
const capitalizeFirstChar = (str) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
for (const changelog of changelogs) {
|
||||
if (changelog.project === "Nerivec/zigbee2mqtt-windfront") {
|
||||
const releaseRsp = await fetch("https://api.github.com/repos/Nerivec/zigbee2mqtt-windfront/releases");
|
||||
const releases = await releaseRsp.json();
|
||||
for (const release of releases) {
|
||||
if (release.name === `v${windfrontTillVersion}`) {
|
||||
break;
|
||||
}
|
||||
|
||||
let match = windfrontChangeRe.exec(release.body);
|
||||
while (match !== null) {
|
||||
const [, type, message, user, prLink, prId] = match;
|
||||
const entry = `- ${prId && prLink ? `[#${prId}](${prLink}) ` : ""}${type}: ${message} ${user ? `(@${user.split("[")[0]})` : ""}`;
|
||||
changes.windfront.push(entry);
|
||||
match = windfrontChangeRe.exec(release.body);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const line of changelog.contents) {
|
||||
const releaseMatch = line.match(releaseRe);
|
||||
const changeMatch = changelog.isFrontend ? line.match(frontendChangeRe) : changeRe.map((re) => line.match(re)).find((e) => e);
|
||||
if (releaseMatch) {
|
||||
if (releaseMatch[1] === changelog.tillVersion) {
|
||||
break;
|
||||
}
|
||||
} else if (line === "### Features") {
|
||||
context = "features";
|
||||
} else if (line === "### Bug Fixes") {
|
||||
context = "fixes";
|
||||
} else if (line.startsWith("* **ignore:**")) {
|
||||
// continue;
|
||||
} else if (changeMatch) {
|
||||
let localContext = changelog.isFrontend ? "frontend" : changeMatch[2] ? changeMatch[2] : context;
|
||||
if (!changes[localContext]) localContext = "error";
|
||||
|
||||
const commit = changeMatch[5];
|
||||
const commitUserKey = `${changelog.project}-${commit} `;
|
||||
let user =
|
||||
commitUserKey in commitUserLookup
|
||||
? commitUserLookup[commitUserKey]
|
||||
: execSync(`curl -s https://api.github.com/repos/${changelog.project}/commits/${commit} | jq -r '.author.login'`)
|
||||
.toString()
|
||||
.trim();
|
||||
if (user !== "null") commitUserLookup[commitUserKey] = user;
|
||||
const messages = [];
|
||||
let message = changeMatch[3].trim();
|
||||
if (message.endsWith(".")) message = message.substring(0, message.length - 1);
|
||||
|
||||
if (changelog.isFrontend) {
|
||||
changes[localContext].push(
|
||||
`- [${commit.slice(0, 7)}](https://github.com/${changelog.project}/commit/${commit}) ${message} (@${user})`,
|
||||
);
|
||||
messages.push(capitalizeFirstChar(message));
|
||||
} else {
|
||||
const otherUser = message.match(/\[@(.+)\]\(https:\/\/github.com\/.+\)/) || message.match(/@(.+)/);
|
||||
if (otherUser) {
|
||||
user = otherUser[1];
|
||||
message = message.replace(otherUser[0], "");
|
||||
}
|
||||
|
||||
if (localContext === "add") {
|
||||
for (const model of message.split(",")) {
|
||||
const definition = definitions.find((d) => d.model === model.trim());
|
||||
const whiteLabel = whiteLabels.find((d) => d.model === model.trim());
|
||||
const match = definition || whiteLabel;
|
||||
if (match) {
|
||||
messages.push(`\`${match.model}\` ${match.vendor} ${match.description}`);
|
||||
} else {
|
||||
changes.error.push(`${line} (model '${model}' does not exist)`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messages.push(capitalizeFirstChar(message));
|
||||
}
|
||||
|
||||
let issue = changeMatch[4].trim();
|
||||
if (issue && !issue.startsWith("[#")) issue = `[#${issue.split("/").pop()}](${issue})`;
|
||||
if (!issue) {
|
||||
issue = "_NO_ISSUE_";
|
||||
localContext = "error";
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
changes[localContext].push(`- ${issue} ${message} (@${user})`);
|
||||
}
|
||||
}
|
||||
} else if (line === "# Changelog" || line === "### ⚠ BREAKING CHANGES" || !line) {
|
||||
// continue;
|
||||
} else {
|
||||
changes.error.push(`- Unmatched line: ${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = "";
|
||||
const names = [
|
||||
["features", "Improvements"],
|
||||
["fixes", "Fixes"],
|
||||
["windfront", "Windfront"],
|
||||
["frontend", "Frontend"],
|
||||
["add", "New supported devices"],
|
||||
["detect", "Fixed device detections"],
|
||||
["error", "Changelog generator error"],
|
||||
];
|
||||
for (const name of names) {
|
||||
result += `# ${name[1]}\n`;
|
||||
if (name[0] === "add") {
|
||||
result += `This release adds support for ${changes.add.length} devices: \n`;
|
||||
}
|
||||
|
||||
for (const change of changes[name[0]]) {
|
||||
result += `${change}\n`;
|
||||
}
|
||||
|
||||
result += "\n";
|
||||
}
|
||||
|
||||
fs.writeFileSync(commitUserFile, JSON.stringify(commitUserLookup), "utf-8");
|
||||
|
||||
console.log(result.trim());
|
||||
@@ -1,75 +0,0 @@
|
||||
#!/bin/bash
|
||||
function zigbee2mqtt-show-short-info {
|
||||
echo "Setup for Zigbee2MQTT bridge."
|
||||
}
|
||||
|
||||
function zigbee2mqtt-show-long-info {
|
||||
echo "This script installs the Zigbee2MQTT bridge"
|
||||
}
|
||||
|
||||
function zigbee2mqtt-show-copyright-info {
|
||||
echo "Original concept by Landrash <https://github.com/landrash>."
|
||||
}
|
||||
|
||||
function zigbee2mqtt-install-package {
|
||||
echo -n "Installing dependencies : "
|
||||
node=$(which npm)
|
||||
if [ -z "${node}" ]; then #Installing NodeJS if not already installed.
|
||||
printf "Downloading and installing NodeJS...\\n"
|
||||
curl -sL https://deb.nodesource.com/setup_10.x | bash -
|
||||
apt install -y nodejs
|
||||
fi
|
||||
|
||||
echo "Cloning Zigbee2MQTT git repository"
|
||||
git clone https://github.com/Koenkk/zigbee2mqtt.git /opt/zigbee2mqtt
|
||||
chown -R pi:pi /opt/zigbee2mqtt
|
||||
|
||||
echo "Running install. This might take a while and can produce some expected errors"
|
||||
cd /opt/zigbee2mqtt || exit
|
||||
su pi -c "npm ci"
|
||||
|
||||
echo "Creating service file zigbee2mqtt.service"
|
||||
service_path="/etc/systemd/system/zigbee2mqtt.service"
|
||||
|
||||
echo "[Unit]
|
||||
Description=zigbee2mqtt
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
ExecStart=/usr/bin/node index.js
|
||||
WorkingDirectory=/opt/zigbee2mqtt
|
||||
StandardOutput=inherit
|
||||
StandardError=inherit
|
||||
WatchdogSec=10s
|
||||
Restart=always
|
||||
User=pi
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target" > $service_path
|
||||
|
||||
echo "Checking the installation..."
|
||||
if [ ! -f /opt/zigbee2mqtt/data/configuration.yaml ]; then
|
||||
validation=""
|
||||
else
|
||||
validation="ok"
|
||||
fi
|
||||
|
||||
if [ ! -z "${validation}" ]; then
|
||||
echo
|
||||
echo -e "\\e[32mInstallation done..\\e[0m"
|
||||
echo -e "Update of configuration.yaml is required found at /opt/zigbee2mqtt/data/"
|
||||
echo -e "Some further configuration is required and details can be found here https://www.zigbee2mqtt.io"
|
||||
echo
|
||||
echo -e "Service can be started after configuration but running sudo systemctl start zigbee2mqtt"
|
||||
echo
|
||||
else
|
||||
echo
|
||||
echo -e "\\e[31mInstallation failed..."
|
||||
echo
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
[[ "$_" == "$0" ]] && zigbee2mqtt-install-package
|
||||
@@ -1,10 +1,10 @@
|
||||
const assert = require('assert');
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require("node:assert");
|
||||
const vm = require("node:vm");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const filename = process.argv[2];
|
||||
const moduleCode = fs.readFileSync(filename);
|
||||
const moduleFakePath = path.join(__dirname, 'externally-loaded.js');
|
||||
const moduleFakePath = path.join(__dirname, "externally-loaded.js");
|
||||
const sandbox = {
|
||||
require: require,
|
||||
module: {},
|
||||
@@ -18,5 +18,5 @@ const sandbox = {
|
||||
};
|
||||
vm.runInNewContext(moduleCode, sandbox, moduleFakePath);
|
||||
const converter = sandbox.module.exports;
|
||||
assert(!converter.toZigbee.includes(undefined));
|
||||
assert(!converter.fromZigbee.includes(undefined));
|
||||
assert(!converter.toZigbee || !converter.toZigbee.includes(undefined));
|
||||
assert(!converter.fromZigbee || !converter.fromZigbee.includes(undefined));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const {ZnpCommandStatus, NvSystemIds} = require('zigbee-herdsman/dist/adapter/z-stack/constants/common');
|
||||
const {ZnpVersion} = require('zigbee-herdsman/dist/adapter/z-stack/adapter/tstype');
|
||||
const {Subsystem} = require('zigbee-herdsman/dist/adapter/z-stack/unpi/constants');
|
||||
const {Znp} = require('zigbee-herdsman/dist/adapter/z-stack/znp');
|
||||
const {ZnpCommandStatus, NvSystemIds} = require("zigbee-herdsman/dist/adapter/z-stack/constants/common");
|
||||
const {ZnpVersion} = require("zigbee-herdsman/dist/adapter/z-stack/adapter/tstype");
|
||||
const {Subsystem} = require("zigbee-herdsman/dist/adapter/z-stack/unpi/constants");
|
||||
const {Znp} = require("zigbee-herdsman/dist/adapter/z-stack/znp");
|
||||
|
||||
class ZStackNvMemEraser {
|
||||
constructor(device) {
|
||||
@@ -13,7 +13,7 @@ class ZStackNvMemEraser {
|
||||
const attempts = 3;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
await this.znp.request(Subsystem.SYS, 'ping', {capabilities: 1});
|
||||
await this.znp.request(Subsystem.SYS, "ping", {capabilities: 1});
|
||||
break;
|
||||
} catch (e) {
|
||||
if (attempts - 1 === i) {
|
||||
@@ -23,10 +23,10 @@ class ZStackNvMemEraser {
|
||||
}
|
||||
// Old firmware did not support version, assume it's Z-Stack 1.2 for now.
|
||||
try {
|
||||
this.version = (await this.znp.request(Subsystem.SYS, 'version', {})).payload;
|
||||
} catch (e) {
|
||||
console.log(`Failed to get zStack version, assuming 1.2`);
|
||||
this.version = {transportrev: 2, product: 0, majorrel: 2, minorrel: 0, maintrel: 0, revision: ''};
|
||||
this.version = (await this.znp.request(Subsystem.SYS, "version", {})).payload;
|
||||
} catch {
|
||||
console.log("Failed to get zStack version, assuming 1.2");
|
||||
this.version = {transportrev: 2, product: 0, majorrel: 2, minorrel: 0, maintrel: 0, revision: ""};
|
||||
}
|
||||
|
||||
console.log(`Detected znp version '${ZnpVersion[this.version.product]}' (${JSON.stringify(this.version)})`);
|
||||
@@ -39,13 +39,13 @@ class ZStackNvMemEraser {
|
||||
async clearAllNvMemItems() {
|
||||
let maxNvMemId;
|
||||
switch (this.version.product) {
|
||||
case ZnpVersion.zStack12:
|
||||
case ZnpVersion.ZStack12:
|
||||
maxNvMemId = 0x0302;
|
||||
break;
|
||||
case ZnpVersion.zStack30x:
|
||||
case ZnpVersion.ZStack30x:
|
||||
maxNvMemId = 0x033f;
|
||||
break;
|
||||
case ZnpVersion.zStack3x0:
|
||||
case ZnpVersion.ZStack3x0:
|
||||
maxNvMemId = 0x032f;
|
||||
break;
|
||||
}
|
||||
@@ -54,23 +54,23 @@ class ZStackNvMemEraser {
|
||||
console.log(`Clearing all NVMEM items, from 0 to ${maxNvMemId}`);
|
||||
for (let id = 0; id <= maxNvMemId; id++) {
|
||||
let len;
|
||||
const needOsal = !(this.version.product == ZnpVersion.zStack3x0 && id <= 7);
|
||||
const needOsal = !(this.version.product === ZnpVersion.zStack3x0 && id <= 7);
|
||||
if (needOsal) {
|
||||
const lengthRes = await this.znp.request(Subsystem.SYS, 'osalNvLength', {id: id});
|
||||
len = lengthRes.payload['length'];
|
||||
const lengthRes = await this.znp.request(Subsystem.SYS, "osalNvLength", {id: id});
|
||||
len = lengthRes.payload.length;
|
||||
} else {
|
||||
const lengthRes = await this.znp.request(Subsystem.SYS, 'nvLength', {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0});
|
||||
len = lengthRes.payload['len'];
|
||||
const lengthRes = await this.znp.request(Subsystem.SYS, "nvLength", {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0});
|
||||
len = lengthRes.payload.len;
|
||||
}
|
||||
if (len != 0) {
|
||||
if (len !== 0) {
|
||||
console.log(`NVMEM item #${id} - deleting, size: ${len}`);
|
||||
if (needOsal) {
|
||||
await this.znp.request(Subsystem.SYS, 'osalNvDelete', {id: id, len: len}, null, null, [
|
||||
await this.znp.request(Subsystem.SYS, "osalNvDelete", {id: id, len: len}, undefined, undefined, [
|
||||
ZnpCommandStatus.SUCCESS,
|
||||
ZnpCommandStatus.NV_ITEM_INITIALIZED,
|
||||
]);
|
||||
} else {
|
||||
await this.znp.request(Subsystem.SYS, 'nvDelete', {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0}, null, null, [
|
||||
await this.znp.request(Subsystem.SYS, "nvDelete", {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0}, undefined, undefined, [
|
||||
ZnpCommandStatus.SUCCESS,
|
||||
ZnpCommandStatus.NV_ITEM_INITIALIZED,
|
||||
]);
|
||||
@@ -82,10 +82,10 @@ class ZStackNvMemEraser {
|
||||
}
|
||||
}
|
||||
const processArgs = process.argv.slice(2);
|
||||
if (processArgs.length != 1) {
|
||||
console.log('ZStack NVMEM eraser.');
|
||||
console.log('Usage:');
|
||||
console.log(' node zStackEraseAllNvMem.js <SERIAL_PORT>');
|
||||
if (processArgs.length !== 1) {
|
||||
console.log("ZStack NVMEM eraser.");
|
||||
console.log("Usage:");
|
||||
console.log(" node zStackEraseAllNvMem.js <SERIAL_PORT>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
class Example {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
this.mqtt = mqtt;
|
||||
this.mqtt.publish('example/extension', 'call from constructor');
|
||||
}
|
||||
|
||||
start() {
|
||||
this.mqtt.publish('example/extension', 'test');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Example;
|
||||
@@ -0,0 +1,24 @@
|
||||
const mockDevices = [
|
||||
{
|
||||
mock: 1,
|
||||
model: "external_converters_device_1",
|
||||
zigbeeModel: ["external_converter_device_1"],
|
||||
vendor: "external_1",
|
||||
description: "external_1",
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
},
|
||||
{
|
||||
mock: 2,
|
||||
model: "external_converters_device_2",
|
||||
zigbeeModel: ["external_converter_device_2"],
|
||||
vendor: "external_2",
|
||||
description: "external_2",
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = mockDevices;
|
||||
@@ -0,0 +1,14 @@
|
||||
const {posix} = require("node:path");
|
||||
|
||||
const mockDevice = {
|
||||
mock: true,
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
vendor: "external",
|
||||
model: "external_converter_device",
|
||||
description: posix.join("external", "converter"),
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
};
|
||||
|
||||
module.exports = mockDevice;
|
||||
@@ -0,0 +1,22 @@
|
||||
export default [
|
||||
{
|
||||
mock: 1,
|
||||
model: "external_converters_device_1",
|
||||
zigbeeModel: ["external_converter_device_1"],
|
||||
vendor: "external_1",
|
||||
description: "external_1",
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
},
|
||||
{
|
||||
mock: 2,
|
||||
model: "external_converters_device_2",
|
||||
zigbeeModel: ["external_converter_device_2"],
|
||||
vendor: "external_2",
|
||||
description: "external_2",
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
import {posix} from "node:path";
|
||||
|
||||
import {identify} from "zigbee-herdsman-converters/lib/modernExtend";
|
||||
|
||||
export default {
|
||||
mock: true,
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
vendor: "external",
|
||||
model: "external_converter_device",
|
||||
description: posix.join("external", "converter"),
|
||||
extend: [identify()],
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
class Example2 {
|
||||
constructor(_zigbee, mqtt, _state, _publishEntityState, _eventBus) {
|
||||
this.mqtt = mqtt;
|
||||
this.mqtt.publish("example2/extension", "call2 from constructor");
|
||||
}
|
||||
|
||||
start() {
|
||||
this.mqtt.publish("example2/extension", "call2 from start");
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.mqtt.publish("example/extension", "call2 from stop");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Example2;
|
||||
@@ -0,0 +1,16 @@
|
||||
class Example {
|
||||
constructor(_zigbee, mqtt, _state, _publishEntityState, _eventBus) {
|
||||
this.mqtt = mqtt;
|
||||
this.mqtt.publish("example/extension", "call from constructor");
|
||||
}
|
||||
|
||||
start() {
|
||||
this.mqtt.publish("example/extension", "call from start");
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.mqtt.publish("example/extension", "call from stop");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Example;
|
||||
@@ -0,0 +1,14 @@
|
||||
export default class Example2 {
|
||||
constructor(_zigbee, mqtt, _state, _publishEntityState, _eventBus) {
|
||||
this.mqtt = mqtt;
|
||||
this.mqtt.publish("example2/extension", "call2 from constructor");
|
||||
}
|
||||
|
||||
start() {
|
||||
this.mqtt.publish("example2/extension", "call2 from start");
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.mqtt.publish("example/extension", "call2 from stop");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export default class Example {
|
||||
constructor(_zigbee, mqtt, _state, _publishEntityState, _eventBus) {
|
||||
this.mqtt = mqtt;
|
||||
this.mqtt.publish("example/extension", "call from constructor");
|
||||
this.counter = 0;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.mqtt.publish("example/extension", "call from start");
|
||||
this.mqtt.publish("example/extension/counter", `start ${this.counter++}`);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.mqtt.publish("example/extension", "call from stop");
|
||||
this.mqtt.publish("example/extension/counter", `stop ${--this.counter}`);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
const homeassistantSwitch = {
|
||||
type: 'switch',
|
||||
object_id: 'switch',
|
||||
discovery_payload: {
|
||||
payload_off: 'OFF',
|
||||
payload_on: 'ON',
|
||||
value_template: '{{ value_json.state }}',
|
||||
command_topic: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mockDevices = [
|
||||
{
|
||||
mock: 1,
|
||||
model: 'external_converters_device_1',
|
||||
homeassistant: [homeassistantSwitch],
|
||||
zigbeeModel: ['external_converter_device_1'],
|
||||
vendor: 'external_1',
|
||||
description: 'external_1',
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
},
|
||||
{
|
||||
mock: 2,
|
||||
model: 'external_converters_device_2',
|
||||
zigbeeModel: ['external_converter_device_2'],
|
||||
vendor: 'external_2',
|
||||
description: 'external_2',
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = mockDevices;
|
||||
@@ -1,12 +0,0 @@
|
||||
const mockDevice = {
|
||||
mock: true,
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
vendor: 'external',
|
||||
model: 'external_converter_device',
|
||||
description: 'external',
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
};
|
||||
|
||||
module.exports = mockDevice;
|
||||
@@ -1,401 +0,0 @@
|
||||
import data from './stub/data';
|
||||
import logger from './stub/logger';
|
||||
import MQTT from './stub/mqtt';
|
||||
import zigbeeHerdsman from './stub/zigbeeHerdsman';
|
||||
|
||||
import utils from '../lib/util/utils';
|
||||
import * as settings from '../lib/util/settings';
|
||||
import Controller from '../lib/controller';
|
||||
import Availability from '../lib/extension/availability';
|
||||
import flushPromises from './lib/flushPromises';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
const mocks = [MQTT.publish, logger.warning, logger.info];
|
||||
const devices = zigbeeHerdsman.devices;
|
||||
zigbeeHerdsman.returnDevices.push(
|
||||
...[devices.bulb_color.ieeeAddr, devices.bulb_color_2.ieeeAddr, devices.coordinator.ieeeAddr, devices.remote.ieeeAddr],
|
||||
);
|
||||
|
||||
describe('Availability', () => {
|
||||
let controller;
|
||||
|
||||
let resetExtension = async () => {
|
||||
await controller.enableDisableExtension(false, 'Availability');
|
||||
await controller.enableDisableExtension(true, 'Availability');
|
||||
};
|
||||
|
||||
const setTimeAndAdvanceTimers = async (value) => {
|
||||
jest.setSystemTime(Date.now() + value);
|
||||
await jest.advanceTimersByTimeAsync(value);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(utils, 'sleep').mockImplementation(async (seconds) => {});
|
||||
jest.useFakeTimers();
|
||||
settings.reRead();
|
||||
settings.set(['availability'], true);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.setSystemTime(utils.minutes(1));
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
settings.set(['availability'], true);
|
||||
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], false);
|
||||
Object.values(devices).forEach((d) => (d.lastSeen = utils.minutes(1)));
|
||||
mocks.forEach((m) => m.mockClear());
|
||||
await resetExtension();
|
||||
Object.values(devices).forEach((d) => d.ping.mockClear());
|
||||
});
|
||||
|
||||
afterEach(async () => {});
|
||||
|
||||
afterAll(async () => {
|
||||
await controller.stop();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('Should publish availability on startup for device where it is enabled for', async () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', 'online', {retain: true, qos: 1}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', 'online', {retain: true, qos: 1}, expect.any(Function));
|
||||
expect(MQTT.publish).not.toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color_2/availability',
|
||||
'online',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should ping on startup for enabled and unavailable devices', async () => {
|
||||
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], true);
|
||||
devices.bulb_color.lastSeen = Date.now() - utils.minutes(20);
|
||||
devices.bulb_color_2.lastSeen = Date.now();
|
||||
await resetExtension();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(1));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1); // enabled/unavailable
|
||||
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0); // enabled/available
|
||||
});
|
||||
|
||||
it('Should not ping on startup for available or disabled devices', async () => {
|
||||
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], true);
|
||||
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'disabled'], true);
|
||||
devices.bulb_color.lastSeen = Date.now();
|
||||
devices.bulb_color_2.lastSeen = Date.now() - utils.minutes(20);
|
||||
await resetExtension();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(1));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); // enabled/available
|
||||
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0); // disabled/unavailable
|
||||
});
|
||||
|
||||
it('Should publish offline for active device when not seen for 10 minutes', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
await setTimeAndAdvanceTimers(utils.minutes(5));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(7));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', 'offline', {retain: true, qos: 1}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Shouldnt do anything for a device when availability: false is set for device', async () => {
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color_2}); // Coverage satisfaction
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(12));
|
||||
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should publish offline for passive device when not seen for 25 hours', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
await setTimeAndAdvanceTimers(utils.hours(26));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', 'offline', {retain: true, qos: 1}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should reset ping timer when device last seen changes for active device', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(5));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color});
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', 'offline', {retain: true, qos: 1}, expect.any(Function));
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(7));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(10));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
|
||||
});
|
||||
|
||||
it('Should ping again when first ping fails', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color});
|
||||
|
||||
devices.bulb_color.ping.mockImplementationOnce(() => {
|
||||
throw new Error('failed');
|
||||
});
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(15));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
|
||||
it('Should reset ping timer when device last seen changes for passive device', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.hours(24));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.remote});
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', 'offline', {retain: true, qos: 1}, expect.any(Function));
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.hours(25));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.hours(3));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should immediately mark device as online when it lastSeen changes', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(15));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', 'offline', {retain: true, qos: 1}, expect.any(Function));
|
||||
|
||||
devices.bulb_color.lastSeen = Date.now();
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', 'online', {retain: true, qos: 1}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should allow to change availability timeout via device options', async () => {
|
||||
settings.set(['devices', '0x000b57fffec6a5b3', 'availability'], {timeout: 40});
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(25));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(17));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should not ping disabled devices', async () => {
|
||||
settings.set(['devices', devices.bulb_color.ieeeAddr, 'disabled'], true);
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(15));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should allow to change availability timeout via avaiability options', async () => {
|
||||
settings.set(['availability'], {active: {timeout: 30}});
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(25));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(7));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should stop pinging device when it leaves', async () => {
|
||||
await setTimeAndAdvanceTimers(utils.minutes(9));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await zigbeeHerdsman.events.deviceLeave({ieeeAddr: devices.bulb_color.ieeeAddr});
|
||||
await flushPromises();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(3));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should stop pinging device when it is removed', async () => {
|
||||
await setTimeAndAdvanceTimers(utils.minutes(9));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', stringify({id: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(3));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should allow to be disabled', async () => {
|
||||
settings.set(['availability'], false);
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(12));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should allow to enable availability for just one device', async () => {
|
||||
settings.set(['availability'], false);
|
||||
settings.set(['devices', devices.bulb_color.ieeeAddr, 'availability'], true);
|
||||
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(11));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should retrieve device state when it reconnects', async () => {
|
||||
//@ts-expect-error private
|
||||
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
|
||||
//@ts-expect-error private
|
||||
controller.state.set(device, {state: 'OFF'});
|
||||
|
||||
const endpoint = devices.bulb_color.getEndpoint(1);
|
||||
endpoint.read.mockClear();
|
||||
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
await setTimeAndAdvanceTimers(utils.seconds(1));
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(0);
|
||||
await setTimeAndAdvanceTimers(utils.seconds(2));
|
||||
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
|
||||
endpoint.read.mockClear();
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
endpoint.read.mockImplementationOnce(() => {
|
||||
throw new Error('');
|
||||
});
|
||||
await setTimeAndAdvanceTimers(utils.seconds(3));
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should republish availability when device is renamed', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({from: 'bulb_color', to: 'bulb_new_name'}));
|
||||
await flushPromises();
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', null, {retain: true, qos: 1}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', 'online', {retain: true, qos: 1}, expect.any(Function));
|
||||
await setTimeAndAdvanceTimers(utils.hours(12));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', 'offline', {retain: true, qos: 1}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should publish availability payload in JSON format', async () => {
|
||||
settings.set(['advanced', 'legacy_availability_payload'], false);
|
||||
await resetExtension();
|
||||
devices.remote.ping.mockClear();
|
||||
MQTT.publish.mockClear();
|
||||
await setTimeAndAdvanceTimers(utils.hours(26));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/remote/availability',
|
||||
stringify({state: 'offline'}),
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Deprecated - should allow to block via advanced.availability_blocklist', async () => {
|
||||
settings.set(['advanced', 'availability_blocklist'], [devices.bulb_color.ieeeAddr]);
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(12));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Deprecated - should allow to pass certain devices via availability_passlist', async () => {
|
||||
settings.set(['advanced', 'availability_passlist'], [devices.bulb_color_2.ieeeAddr]);
|
||||
settings.changeEntityOptions(devices.bulb_color_2.ieeeAddr, {availability: null});
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
devices.bulb_color_2.ping.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(12));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Deprecated - should allow to enable via availability_timeout', async () => {
|
||||
settings.set(['availability'], false);
|
||||
settings.set(['advanced', 'availability_timeout'], 60);
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(12));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should publish availability for groups', async () => {
|
||||
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], true);
|
||||
await resetExtension();
|
||||
devices.bulb_color_2.ping.mockClear();
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/group_tradfri_remote/availability',
|
||||
'online',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
MQTT.publish.mockClear();
|
||||
await setTimeAndAdvanceTimers(utils.minutes(12));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/group_tradfri_remote/availability',
|
||||
'offline',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
MQTT.publish.mockClear();
|
||||
devices.bulb_color_2.lastSeen = Date.now();
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color_2});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/group_tradfri_remote/availability',
|
||||
'online',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should clear the ping queue on stop', async () => {
|
||||
//@ts-expect-error private
|
||||
const availability = controller.extensions.find((extension) => extension instanceof Availability);
|
||||
const publishAvailabilitySpy = jest.spyOn(availability, 'publishAvailability');
|
||||
|
||||
devices.bulb_color.ping.mockImplementationOnce(() => new Promise((resolve) => setTimeout(resolve, 1000)));
|
||||
availability.addToPingQueue(devices.bulb_color);
|
||||
availability.addToPingQueue(devices.bulb_color_2);
|
||||
|
||||
await availability.stop();
|
||||
await setTimeAndAdvanceTimers(utils.minutes(1));
|
||||
|
||||
expect(availability.pingQueue).toEqual([]);
|
||||
// Validate the stop-interrupt implicitly by checking that it prevents further function invocations
|
||||
expect(publishAvailabilitySpy).not.toHaveBeenCalled();
|
||||
devices.bulb_color.ping = jest.fn(); // ensure reset
|
||||
});
|
||||
|
||||
it('Should prevent instance restart', async () => {
|
||||
//@ts-expect-error private
|
||||
const availability = controller.extensions.find((extension) => extension instanceof Availability);
|
||||
|
||||
await availability.stop();
|
||||
|
||||
await expect(() => availability.start()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user