mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 15:18:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85875aee27 | ||
|
|
7f0a9aa2a3 | ||
|
|
ff528567e6 | ||
|
|
3473aa3f40 | ||
|
|
8ee5b1f03b | ||
|
|
89192f9dec | ||
|
|
a9a6ebe007 | ||
|
|
c7d54ec360 | ||
|
|
c880d36f29 | ||
|
|
e1e856d7da | ||
|
|
b9d9e5e75c | ||
|
|
f8385b8899 | ||
|
|
a77f48f5c4 | ||
|
|
79bcf276f5 | ||
|
|
93dc9a3664 | ||
|
|
1d5184f2bd | ||
|
|
8ecf353dd6 | ||
|
|
8560c11e06 | ||
|
|
4d287eed0c | ||
|
|
8d1240eba1 | ||
|
|
0c43c98306 | ||
|
|
396eb38e4b | ||
|
|
5957085fcc | ||
|
|
02a2fd8fef | ||
|
|
9d566f4c01 | ||
|
|
42d88fe90d | ||
|
|
909d2b32b7 | ||
|
|
061262ff97 | ||
|
|
142754b71c | ||
|
|
c599e19bf2 | ||
|
|
28a9f39dbc | ||
|
|
4e360d0bf2 | ||
|
|
15e385bac6 | ||
|
|
c7dae39799 | ||
|
|
b585e31304 | ||
|
|
bf888de478 | ||
|
|
f1156db227 | ||
|
|
d475aa5be5 | ||
|
+2 |
b71461e03c | ||
|
|
6cce05a514 | ||
|
|
30d311e849 | ||
|
|
402ed6094e | ||
|
|
e381d54f7e | ||
|
|
4b039103c0 | ||
|
|
8d8d37e388 | ||
|
|
09d010ea06 | ||
|
|
ede1dc5951 | ||
|
|
b0b02c30b2 | ||
|
|
9ddbb2a21a | ||
|
|
49b752da35 | ||
|
|
788b0d3993 | ||
|
|
f68834049c | ||
|
|
4afbce62eb | ||
|
|
4f412cb4da | ||
|
|
5a4d6b56ad | ||
|
|
26a0fd4de0 | ||
|
|
8d980ae6ec | ||
|
|
99e06a8d3e | ||
|
|
476efaa6e1 | ||
|
|
10478e797e | ||
|
|
5de47a0d93 | ||
|
|
14bffd58b1 | ||
|
|
999df2913f | ||
|
|
da1f77ec6e | ||
|
|
036ee471dd | ||
|
|
dd1c449796 | ||
|
|
6d5e7e334a | ||
|
|
8e682956e5 | ||
|
|
971ddaa3cb | ||
|
|
ad51d12a2d | ||
|
|
d9e4244f80 | ||
|
|
2bb44f7a8b | ||
|
|
20af50201e | ||
|
|
2e36084852 | ||
|
|
9d266bda9a | ||
|
|
043dd0ff1c | ||
|
|
ee6afaf1c6 | ||
|
|
4815d5149d | ||
|
|
6f0b02e009 | ||
|
|
5e1b7d4830 | ||
|
|
535f75e90c | ||
|
|
d44cdbde4f | ||
|
|
9a41ec2a39 | ||
|
|
1d87d2f234 | ||
|
|
29c757145d | ||
|
|
e68403d8d3 | ||
|
|
a32c8eec36 | ||
|
|
d4522c53ca | ||
|
|
57245e5234 | ||
|
|
4313baca09 | ||
|
|
ed52c49e34 | ||
|
|
a2b81ab894 | ||
|
|
3e60f915ff | ||
|
|
1eaa2f3e75 | ||
|
|
3adf7d5d8e | ||
|
|
1147cf16dd | ||
|
|
97a9b15240 | ||
|
|
3a49c95786 | ||
|
|
0eba0c953d | ||
|
|
4d6269e7e8 | ||
|
|
3727f078ec | ||
|
|
93c9704fcd | ||
|
|
2721b0ea78 | ||
|
|
4c48e9cfbc | ||
|
|
7cb5c58dc0 | ||
|
|
c61f67f061 | ||
|
|
58d98c7263 | ||
|
|
b10fa93700 | ||
|
|
c28a024664 | ||
|
|
8549b37228 | ||
|
|
4c02e3da8f | ||
|
|
ab0894715a | ||
|
|
1688a3b532 | ||
|
|
97694d2241 | ||
|
|
d6088c56db | ||
|
|
b39b8d478e | ||
|
|
01304cfc66 | ||
|
|
1d15c2142f | ||
|
|
3818c50b8a | ||
|
|
7f5f6a5849 | ||
|
|
0025ef87c5 | ||
|
|
3460f157df | ||
|
|
ecb0af4057 | ||
|
|
5db6ae0a19 | ||
|
|
ad3c09036b | ||
|
|
0c89629526 | ||
|
|
4574ffc71a | ||
|
|
1707f3abf3 | ||
|
|
8916fb7b50 | ||
|
|
6e70ef24d0 | ||
|
|
8a3c4bd59f | ||
|
|
c18d1329c8 | ||
|
|
ed1e7b333f | ||
|
|
95c38810a8 | ||
|
|
b70701f8b8 | ||
|
|
6d30fa156c | ||
|
|
9b6035387b | ||
|
|
9ca85dfac8 | ||
|
|
334fa2a60d | ||
|
|
a7f1345a91 | ||
|
|
bae9bb8812 | ||
|
|
740edd8b58 | ||
|
|
267fee3414 | ||
|
|
fa99c6dcca | ||
|
|
91f4b76970 | ||
|
|
fd4282b98d | ||
|
|
403bd4a725 | ||
|
|
ce27ff3cbf | ||
|
|
5f474e03b8 | ||
|
|
110149fa20 | ||
|
|
445890a071 | ||
|
|
3dce8548ce | ||
|
|
4a020fd8de | ||
|
|
d411fc5c69 | ||
|
|
631c4f6a57 | ||
|
|
c02ed09211 | ||
|
|
49b4876768 | ||
|
|
802271ed37 | ||
|
|
42fc6e03a7 | ||
|
|
35d8a5da7f | ||
|
|
72d862f4a0 | ||
|
|
ad0696856a | ||
|
|
a61646d584 | ||
|
|
854df498bd | ||
|
|
e2232e9e91 | ||
|
|
6775c4f17b | ||
|
|
17520f7cfb | ||
|
|
1707bcab88 | ||
|
|
c577c8e5f0 | ||
|
|
7b8efbe2c4 | ||
|
|
459b695f60 | ||
|
|
f26ade4f93 | ||
|
|
3e2aefbcf8 | ||
|
|
ba77ce7c8d | ||
|
|
4c21b66079 | ||
|
|
be774a4d0d | ||
|
|
7b7b988960 | ||
|
|
d6bf5174be | ||
|
|
73225e2436 | ||
|
|
afedab9680 | ||
|
|
53b7eaabf0 | ||
|
|
16d0e56968 | ||
|
|
aea41b5c82 | ||
|
|
564e9ab9eb | ||
|
|
61c6ec6111 | ||
|
|
4794b3c81f | ||
|
|
517b286583 | ||
|
|
aee173d10a | ||
|
|
940fff244c | ||
|
|
8822aedb5e | ||
|
|
e788c520a9 | ||
|
|
39c5bc8c04 | ||
|
|
5c4e43fd10 | ||
|
|
5a6a9cf4d2 | ||
|
|
ad0e91e61e | ||
|
|
5797b56871 | ||
|
|
ea1c5a8f4e | ||
|
|
8099322cc2 | ||
|
|
619b939b14 | ||
|
|
eeed56ef2c | ||
|
|
e9b4a6221b | ||
|
|
6e001b48ec | ||
|
|
dc7749b57e | ||
|
|
40bf14b93c | ||
|
|
b2c31f89c6 | ||
|
|
faa65f78a2 | ||
|
|
c30630091d | ||
|
|
61d99b7ece | ||
|
|
e037a2c21c | ||
|
|
592c256f96 | ||
|
|
82f1092302 | ||
|
|
39e43d8f40 | ||
|
|
1ea7f650a2 | ||
|
|
003c81b745 | ||
|
|
6c48498e0d | ||
|
|
e96630669a | ||
|
|
961265ad5e | ||
|
|
8ac2f55d87 | ||
|
|
469d40c4aa | ||
|
|
5b844ea64e | ||
|
|
d719c1f368 | ||
|
|
0993652420 | ||
|
|
846b06ccab | ||
|
|
3330d2aaed | ||
|
|
bc650ccf82 | ||
|
|
968df11b16 | ||
|
|
c4c129987d | ||
|
|
05382a9692 | ||
|
|
ef15924260 | ||
|
|
c0190c18ed | ||
|
|
238ea7c4c8 | ||
|
|
174ba64449 | ||
|
|
408052282d | ||
|
|
d8fc69dac3 | ||
|
|
0da5fe6837 | ||
|
|
df70f11a15 | ||
|
|
3ecb0c5e41 | ||
|
|
52d250b72f | ||
|
|
feac6aa677 | ||
|
|
1d69715a5a | ||
|
|
eaaed1fec7 | ||
|
|
b81dcdbf40 | ||
|
|
129a80d1bb | ||
|
|
03a81c6ab1 | ||
|
|
799f71b9b4 | ||
|
|
38d305eee6 | ||
|
|
354d06aebc | ||
|
|
ad9b114b1a | ||
|
|
aa916ad604 | ||
|
|
bafc8e4284 | ||
|
|
9c19aa9494 | ||
|
|
85c894dc4f | ||
|
|
5f893c5657 | ||
|
|
8865429d79 | ||
|
|
1a77675043 | ||
|
|
8018422823 | ||
|
|
8b1f5a555c | ||
|
|
79a27e3b06 | ||
|
|
5f08c3e83b | ||
|
|
e24ec79980 | ||
|
|
d18dbb360d | ||
|
|
74335fbb68 | ||
|
|
8b4a4be2a4 | ||
|
|
548f646f2f | ||
|
|
a75a40b042 | ||
|
|
09fd675d0f | ||
|
|
1d38da6ac5 | ||
|
|
c66008b36b | ||
|
|
ab82f0e9a8 | ||
|
|
d1ce146542 | ||
|
|
cf93c649aa | ||
|
|
ca489daad8 | ||
|
|
16f3f4d649 | ||
|
|
4d247f3ae1 | ||
|
|
c9d01f4118 | ||
|
|
1283859a12 | ||
|
|
a0d91cc063 | ||
|
|
116634193a | ||
|
|
f6c0ce735f | ||
|
|
508318de57 | ||
|
|
26c74b5778 | ||
|
|
87c474d2f5 | ||
|
|
2c70deb411 | ||
|
|
a310627e19 | ||
|
|
77926bc1c3 | ||
|
|
892f92448e | ||
|
|
e8847d1a34 | ||
|
|
79c0c0933a | ||
|
|
a5e1e8c0c3 | ||
|
|
382d37e839 | ||
|
|
c3e1525f60 | ||
|
|
2cb7dcc12a | ||
|
|
b2f74607cc | ||
|
|
2edf85f6e1 | ||
|
|
09f33b31a9 | ||
|
|
3f258342d8 | ||
|
|
c28ca79d8a | ||
|
|
5f6fae0df7 | ||
|
|
15e00ea8a4 | ||
|
|
01d5863487 | ||
|
|
fefae9957c | ||
|
|
a297b9255b | ||
|
|
86b458111b | ||
|
|
f022f55be6 | ||
|
|
dc991be28f | ||
|
|
62b62d224f | ||
|
|
5f2630c93b | ||
|
|
e8e4977327 | ||
|
|
54d729e37a | ||
|
|
3018e40dae | ||
|
|
1df1f5d388 | ||
|
|
d44463a330 | ||
|
|
e52203a1b8 | ||
|
|
073a261738 | ||
|
|
64ebb98ad1 | ||
|
|
3f27f79d7d | ||
|
|
06862c7259 | ||
|
|
0482d8b4e9 | ||
|
|
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 | ||
|
|
6fa1dabecb | ||
|
|
3b2ba0950e | ||
|
|
9f40936452 | ||
|
|
b2bae6ba94 | ||
|
|
406deaa9da | ||
|
|
65b1e60a3d | ||
|
|
6ed9b26657 | ||
|
|
cf3298bf6f | ||
|
|
00948c0be1 | ||
|
|
ed9de74f91 | ||
|
|
41587211e0 | ||
|
|
b4176d6fee | ||
|
|
ccdf6b3c6d | ||
|
|
8a05077792 | ||
|
|
df7772bfe0 | ||
|
|
6ae9970efd | ||
|
|
b92072fc6a | ||
|
|
5bdf90df9e | ||
|
|
22980177b6 | ||
|
|
837cbe5f26 | ||
|
|
e95116c773 | ||
|
|
27300ba8da | ||
|
|
801ab9066f | ||
|
|
2a631f3551 | ||
|
|
66fefaa827 | ||
|
|
c986fd6c66 | ||
|
|
29f436ab95 | ||
|
|
d827d954ed | ||
|
|
8ae2c4d42d | ||
|
|
9027c41fe8 | ||
|
|
a5748d620b | ||
|
|
d1734feb21 | ||
|
|
00b5e983d9 | ||
|
|
efd3d3dfb0 | ||
|
|
aeba93d0a3 | ||
|
|
7ca456d20e | ||
|
|
13ac8a0f53 | ||
|
|
fe6cacd87e | ||
|
|
00923685d2 | ||
|
|
bd7dec0f11 | ||
|
|
9bb21f486c | ||
|
|
d0f5733db3 | ||
|
|
5b610afa8f | ||
|
|
2cb8a8f5a5 | ||
|
|
3217332e46 | ||
|
|
a25ef81cf4 | ||
|
|
eac11a6439 | ||
|
|
a330830cde | ||
|
|
e132316ac4 | ||
|
|
3dc9a05b0c | ||
|
|
7cc5209af3 | ||
|
|
02dc8a7b87 | ||
|
|
3a573da83c | ||
|
|
26718eefea | ||
|
|
115b187c1a | ||
|
|
fd65b9a108 | ||
|
|
d9ba4524e4 | ||
|
|
5fcf295ab8 | ||
|
|
b7d9d9e142 | ||
|
|
1bf3032b98 | ||
|
|
85453091e7 | ||
|
|
26b1613948 | ||
|
|
c32c411f88 | ||
|
|
cda8927dc7 | ||
|
|
8c71afd93c | ||
|
|
712b920abb | ||
|
|
f795399e10 | ||
|
|
541221bbbd | ||
|
|
24c9159e19 | ||
|
|
5d05ebbfd2 | ||
|
|
bd78f56d5f | ||
|
|
af7467bf62 | ||
|
|
e3fa0bfb65 | ||
|
|
00661fac4b | ||
|
|
2fbb73a009 | ||
|
|
8397f47649 | ||
|
|
fe87b5dec7 | ||
|
|
2916d7ee52 | ||
|
|
eee1da620f | ||
|
|
5663afb27e | ||
|
|
1bf385c5ff | ||
|
|
6b9247c84d | ||
|
|
6cb62e53cf | ||
|
|
f97ad2e951 | ||
|
|
e917802f5f | ||
|
|
4210edf60e | ||
|
|
addc2385b6 | ||
|
|
2dbbed14f0 | ||
|
|
5c42883779 | ||
|
|
032692673b | ||
|
|
2d2b78e9b5 | ||
|
|
24c43d9400 | ||
|
|
2885eee795 | ||
|
|
8196460de0 | ||
|
|
5dde063119 | ||
|
|
dc7a06cf56 | ||
|
|
6ba42c6a12 | ||
|
|
940422a9d8 | ||
|
|
fa24d2d2ba | ||
|
|
c48db3f5b9 | ||
|
|
30227a13ae | ||
|
|
8780ab2792 | ||
|
|
dec9f10133 | ||
|
|
ee42d68472 | ||
|
|
ad84374046 | ||
|
|
3d772b5db3 | ||
|
|
8877c9e864 | ||
|
|
889e41ca4e | ||
|
|
2b36f7492b | ||
|
|
bcc36811a9 | ||
|
|
6c7d52a3ce | ||
|
|
9f1d6f7d94 | ||
|
|
89d3acf1d4 | ||
|
|
70e84ddc90 | ||
|
|
9c2264e86e | ||
|
|
a85d3e4331 | ||
|
|
d45e254f50 | ||
|
|
f57465324a | ||
|
|
83ac5649d3 | ||
|
|
d41cf43a7c | ||
|
|
fcdae971e5 | ||
|
|
ef2c939482 | ||
|
|
85356a2932 | ||
|
|
a371347ceb | ||
|
|
0754fa8552 | ||
|
|
47bba42357 | ||
|
|
eff341bd14 | ||
|
|
6064194e52 | ||
|
|
69541c6348 | ||
|
|
f18473017e | ||
|
|
aa53303c0f | ||
|
|
dbcae00316 | ||
|
|
a4a0c6f3b9 | ||
|
|
fd3d9a318d | ||
|
|
76959067e2 | ||
|
|
282dfd5b01 | ||
|
|
cdfbb17b4a | ||
|
|
9d95d61e32 | ||
|
|
881037c371 | ||
|
|
2b8eaa196a | ||
|
|
dbf0a3358d | ||
|
|
6fbf14cced | ||
|
|
40ad896114 | ||
|
|
6c123c28b3 | ||
|
|
519e123b69 | ||
|
|
8b4f753c5d | ||
|
|
0e6da85d19 | ||
|
|
8f195f67e6 | ||
|
|
457ed86ef8 | ||
|
|
6924db79e8 | ||
|
|
137087d8ac | ||
|
|
fe048e6e66 | ||
|
|
aa439d6687 | ||
|
|
b5fc1689c4 | ||
|
|
33d8223e52 | ||
|
|
2cc68fd862 | ||
|
|
a02fd3cee6 | ||
|
|
a3ea91deb9 | ||
|
|
1a12c2f15e | ||
|
|
f9b0fff185 | ||
|
|
b6ad6418a1 | ||
|
|
9f5b5d1a76 | ||
|
|
0b3f4eb0a7 | ||
|
|
ae8a59dd51 | ||
|
|
b984a2d092 | ||
|
|
9b3a39415f | ||
|
|
3b7392ead2 | ||
|
|
5ad7e1541f | ||
|
|
b2d32657f3 | ||
|
|
ef68cc328e | ||
|
|
30f6c8e60c | ||
|
|
d484405cf0 | ||
|
|
75608c79c8 | ||
|
|
63de2c5ccc | ||
|
|
91887c1877 | ||
|
|
8ab3c464dc | ||
|
|
ce6043acb0 | ||
|
|
2eec6a4d26 | ||
|
|
e9b7a847b0 | ||
|
|
e01662b3a2 | ||
|
|
2963397b60 | ||
|
|
2be947b29d | ||
|
|
4f9658132b | ||
|
|
d39baa6f1d | ||
|
|
cda867a387 | ||
|
|
0ba02d1f40 | ||
|
|
09e51e064b | ||
|
|
04e88baf60 | ||
|
|
ecf75ba4ce | ||
|
|
14d035007d | ||
|
|
c02c61dd56 | ||
|
|
6b60c4ea41 | ||
|
|
d043fd56f9 | ||
|
|
297e904c2f | ||
|
|
34f0332564 | ||
|
|
9e83b7bba2 | ||
|
|
ea39d86e6b | ||
|
|
06fd8a4eb1 | ||
|
|
d26ccaed65 | ||
|
|
989f667c81 | ||
|
|
cce41c20fb | ||
|
|
350c682e8f | ||
|
|
6bcb4b66b1 | ||
|
|
58d37b0f92 | ||
|
|
aecdfe638b | ||
|
|
cc9a95c74e | ||
|
|
834626d2e9 | ||
|
|
6b886f6268 | ||
|
|
a0c9a1769f | ||
|
|
3544d5172e | ||
|
|
e44608a46d | ||
|
|
6a69c16170 | ||
|
|
b2e8b3e00c | ||
|
|
358e2eee45 | ||
|
|
316654b5d3 | ||
|
|
eb49f523bf | ||
|
|
b754b9d463 | ||
|
|
e4c9bac348 | ||
|
|
beedfaefac | ||
|
|
329d832337 | ||
|
|
23e97d0001 | ||
|
|
3a61f0acfa | ||
|
|
5899436d74 | ||
|
|
97d5c5c97a | ||
|
|
ee5cf5a901 | ||
|
|
907591c467 | ||
|
|
55b5f91a96 | ||
|
|
ce915a6797 | ||
|
|
46f34c8cc7 | ||
|
|
e03013c581 | ||
|
|
8a288a9119 | ||
|
|
8e27ec24f6 | ||
|
|
4b291bf012 | ||
|
|
10e6ad0e67 | ||
|
|
1b089ef6ae | ||
|
|
d7e2d98a5a | ||
|
|
eb69319674 | ||
|
|
8c8a6a3ecf | ||
|
|
baa1d703d4 | ||
|
|
37551265a2 | ||
|
|
245afb1126 | ||
|
|
389779d4e3 | ||
|
|
df23240683 | ||
|
|
75f9743528 | ||
|
|
e5d632bc91 | ||
|
|
4f97a7ad2c | ||
|
|
4bf2ffa7dd | ||
|
|
a8f6bec673 | ||
|
|
80504b943d | ||
|
|
d9628df17b | ||
|
|
f7abbd5f5a | ||
|
|
1544317fce | ||
|
|
172aa7461a | ||
|
|
76f5425452 | ||
|
|
87aa18d14c | ||
|
|
e10ab5ccb2 | ||
|
|
f774753213 | ||
|
|
a210c238f1 | ||
|
|
cdbbe0d49f | ||
|
|
c98e5a74de | ||
|
|
8b8d660b06 | ||
|
|
3c1a4afcb7 | ||
|
|
cabc9f5e2f | ||
|
|
28b8aff1ae | ||
|
|
e7487189cf | ||
|
|
92f53235f3 | ||
|
|
002050f35b | ||
|
|
394a3fbd8a | ||
|
|
af3a0332f9 | ||
|
|
0c80129466 | ||
|
|
ae35aaed5e | ||
|
|
9d251dbad5 | ||
|
|
a020138f6b | ||
|
|
701cdda38c | ||
|
|
919c4eee73 | ||
|
|
3e913495b1 | ||
|
|
bbb6f9e042 | ||
|
|
cdd6fb0864 | ||
|
|
780c065fa1 | ||
|
|
db7d9e1120 | ||
|
|
73566dff98 | ||
|
|
bd0a41107a | ||
|
|
62844ff637 | ||
|
|
5077b03a02 | ||
|
|
ffc2ff1d0e | ||
|
|
3aadab0925 | ||
|
|
85c00b8b43 | ||
|
|
e51792c65a | ||
|
|
1e74b2f53f | ||
|
|
886b5345d7 | ||
|
|
7d8b5374ad | ||
|
|
0c7730f4c5 | ||
|
|
a7aab714ba | ||
|
|
032b9a096e | ||
|
|
1f3d61dc7f | ||
|
|
32b173c653 | ||
|
|
8c4cd8c9aa | ||
|
|
73423ebd60 | ||
|
|
334e260b85 | ||
|
|
731957dab9 | ||
|
|
9c505fd75f | ||
|
|
25cec20f0d | ||
|
|
f05d501380 | ||
|
|
b02c34c6e5 | ||
|
|
801134b630 | ||
|
|
73d43007d4 | ||
|
|
1a9e05e6ab | ||
|
|
1006725b09 | ||
|
|
a4f9c49ab5 | ||
|
|
658622880f | ||
|
|
720fc491ce | ||
|
|
56feb77f2d | ||
|
|
233d263e13 | ||
|
|
dbd616fbe5 | ||
|
|
86c2e90e06 | ||
|
|
43610bbff4 | ||
|
|
29543bad4b | ||
|
|
2a047a469c | ||
|
|
83502f2d30 | ||
|
|
b60f679814 | ||
|
|
14e044093c | ||
|
|
3188038a7b | ||
|
|
ebf996acd8 | ||
|
|
0e0eccbd13 | ||
|
|
e834e93deb | ||
|
|
d37c164993 | ||
|
|
6daa7aa61d | ||
|
|
1110d3f8ef | ||
|
|
1d73d9d476 | ||
|
|
2da80954d6 | ||
|
|
e2fa66a38c | ||
|
|
94382ee418 | ||
|
|
94c92ef021 | ||
|
|
8c86849bb9 | ||
|
|
30909d57fb | ||
|
|
aaed8ab95e | ||
|
|
f24b20b133 | ||
|
|
2a53b8e59b | ||
|
|
ccaa1281a3 | ||
|
|
01843fe6c4 | ||
|
|
66fb672492 | ||
|
|
38ed953599 | ||
|
|
86ed71cb1f | ||
|
|
ccc31670ec | ||
|
|
0d8f5780dd | ||
|
|
a8a262640f | ||
|
|
72333f0888 | ||
|
|
a84083be40 | ||
|
|
9b8583e778 | ||
|
|
aeaa040920 | ||
|
|
222b67b1b0 | ||
|
|
1ee1710a96 | ||
|
|
a1e2ee7837 | ||
|
|
f04ade6d76 | ||
|
|
fee0811893 | ||
|
|
956003aa0a | ||
|
|
8fb28f9e2f | ||
|
|
a06a0ea059 | ||
|
|
4f71c1e8bf | ||
|
|
c56470c83f | ||
|
|
cc27032752 | ||
|
|
7c97902d4a | ||
|
|
23959c5b0e | ||
|
|
e726d5907e | ||
|
|
1337f54e4a | ||
|
|
471902e531 | ||
|
|
22d33360f6 | ||
|
|
db1e3b73d1 | ||
|
|
fe73db4ff6 | ||
|
|
9dd6eeee7b | ||
|
|
3c609658fd | ||
|
|
8b694432aa | ||
|
|
b32bd309ca | ||
|
|
78b5e6fd4b | ||
|
|
c5c0a8b193 | ||
|
|
b51eef2319 | ||
|
|
ebed9d31d7 | ||
|
|
16b251d3c9 | ||
|
|
5766bf479b | ||
|
|
8a9477f2bb | ||
|
|
2aaf878889 | ||
|
|
dfa499659e | ||
|
|
24b5f9b43a | ||
|
|
0ab7390a64 | ||
|
|
c7b820c376 | ||
|
|
ce41f2f8ba | ||
|
|
8bc3c46c34 | ||
|
|
b4473d6e24 | ||
|
|
910f3a6860 | ||
|
|
81335e5b4b | ||
|
|
55653c974e | ||
|
|
a5a87a79a8 | ||
|
|
a479bf9039 | ||
|
|
b7e19cc86a | ||
|
|
fe0742a628 | ||
|
|
29bae3a734 | ||
|
|
6aa6f04059 | ||
|
|
fc6d74e2b7 | ||
|
|
92e0d7895b | ||
|
|
250d5bfc24 | ||
|
|
dfda63138f | ||
|
|
8ab6c16f6e | ||
|
|
a6d51a60a8 | ||
|
|
3d37892296 | ||
|
|
1df54f071f | ||
|
|
b3253bacc4 | ||
|
|
4b1721721b | ||
|
|
2db07236db | ||
|
|
7295955397 | ||
|
|
f83709d96c | ||
|
|
a678a1c652 | ||
|
|
3d1336dc6c | ||
|
|
da1d0d81c2 | ||
|
|
335e18fe98 | ||
|
|
6e2539af09 | ||
|
|
fdc9f7d4d4 | ||
|
|
08c03e48cc | ||
|
|
6621f6fd91 | ||
|
|
06b3395cff | ||
|
|
0b59a51c3c | ||
|
|
1cee011ca0 | ||
|
|
630b4ec560 | ||
|
|
4b6f50daf3 | ||
|
|
0361b13b66 | ||
|
|
49dea233b6 | ||
|
|
c16abdd832 | ||
|
|
c37f3e834e | ||
|
|
f3e79df4b4 | ||
|
|
03ba745e46 | ||
|
|
7e7d3c6328 | ||
|
|
59001babca | ||
|
|
0d716288bf | ||
|
|
a879382cbd | ||
|
|
a858798054 | ||
|
|
416e561b32 | ||
|
|
a240487bb4 | ||
|
|
518972882c | ||
|
|
ed639ec9d7 | ||
|
|
6372d84259 | ||
|
|
45579dcf10 | ||
|
|
97eac16aaf | ||
|
|
032557b1db | ||
|
|
de7bc7cf60 | ||
|
|
92588fefe3 | ||
|
|
8de20e2cf1 | ||
|
|
a1360d8e90 | ||
|
|
9f3b10235c | ||
|
|
1d6d386a47 | ||
|
|
76ea57b732 | ||
|
|
e76f568f0f | ||
|
|
080f3b6b50 | ||
|
|
a741b2f598 | ||
|
|
ac74fac1db | ||
|
|
ab9124a517 | ||
|
|
4e997c7b83 | ||
|
|
0007db42b0 | ||
|
|
6ccbb11fc2 | ||
|
|
4fe4e2ec2f | ||
|
|
2bd7df84eb | ||
|
|
ec3e37350d | ||
|
|
769bc14163 | ||
|
|
a38b88455f | ||
|
|
9a9bcd503e | ||
|
|
f58ee7ad58 | ||
|
|
3c9620428e | ||
|
|
2bf244c2de | ||
|
|
5c8f95ae01 | ||
|
|
363792aee8 | ||
|
|
ce7d022fda | ||
|
|
52f25eb101 | ||
|
|
ef84ff86fe | ||
|
|
dfbe93ef9d | ||
|
|
6bbe8d1844 | ||
|
|
9eaaa0f9d1 | ||
|
|
0807049b6c | ||
|
|
759c235923 | ||
|
|
14ac9a8f68 | ||
|
|
d942db9a92 | ||
|
|
212c70913c | ||
|
|
b17c22bb50 | ||
|
|
c8c5ea2de3 | ||
|
|
00230baedf | ||
|
|
540969be21 | ||
|
|
804182e30c | ||
|
|
99a57bfd08 | ||
|
|
b8edf273c6 | ||
|
|
e9aee4c6dc | ||
|
|
e6bcf91e17 | ||
|
|
7e37365a5f | ||
|
|
32b1d5b073 | ||
|
|
cb7b773e0d | ||
|
|
84387b27a4 | ||
|
|
3d8f0fc3a8 | ||
|
|
02a587f745 | ||
|
|
b5a90e921b | ||
|
|
32b0ded17c | ||
|
|
dee8781e51 | ||
|
|
64fd21c9c2 | ||
|
|
f9e0c2058f | ||
|
|
16730b14a1 | ||
|
|
05c65d31ed | ||
|
|
a8c791b030 | ||
|
|
a3a87f4580 | ||
|
|
ffa43915f0 | ||
|
|
7bca19dae7 | ||
|
|
6b6cc8aa34 | ||
|
|
03a1ce3895 | ||
|
|
82fea1fe41 | ||
|
|
084b048ff3 | ||
|
|
085b46c995 | ||
|
|
eaaf1b0b4f | ||
|
|
265d280efc | ||
|
|
e6e3d92971 | ||
|
|
bf744f48dc | ||
|
|
ec06efe86a | ||
|
|
56f57625b0 | ||
|
|
2110954bb8 | ||
|
|
d616e57daf | ||
|
|
d702919735 | ||
|
|
fe7e82f7d3 | ||
|
|
03857916c4 | ||
|
|
1a7265017a | ||
|
|
89f8e43ad1 | ||
|
|
6875146211 | ||
|
|
27ccd1a570 | ||
|
|
a5b959a6d5 | ||
|
|
b67888f938 | ||
|
|
af8c2d86fe | ||
|
|
29635a9b6a | ||
|
|
65d7f5f3e2 | ||
|
|
c26a6a28e4 | ||
|
|
19754c69e5 | ||
|
|
1e4ac34c44 | ||
|
|
89e6d307f2 | ||
|
|
e7e9616f7e | ||
|
|
6e52b2a7af | ||
|
|
2e704f3ff7 | ||
|
|
8f62a0a618 | ||
|
|
e0f50218d7 | ||
|
|
2c5d3e14f7 | ||
|
|
851e3f559a | ||
|
|
cefd6e5685 | ||
|
|
25de4fded9 | ||
|
|
ca6b24304c | ||
|
|
b415443126 | ||
|
|
bcf50bd0c5 | ||
|
|
7c81eda46a | ||
|
|
abde909b71 | ||
|
|
48347a51dc | ||
|
|
f64c00c8d3 | ||
|
|
87f6b9e81a | ||
|
|
0a2d9ba952 | ||
|
|
5aeec130b1 | ||
|
|
a26008f74c | ||
|
|
c7e294482b | ||
|
|
5ff10c3e5c | ||
|
|
be17544598 | ||
|
|
8929ab3b97 | ||
|
|
d1501052ad | ||
|
|
4cc88f1e16 | ||
|
|
4e6bc33a22 | ||
|
|
81033700e2 | ||
|
|
a17f650ccb | ||
|
|
59efc15f3a | ||
|
|
cb509426ec | ||
|
|
aa8144b739 | ||
|
|
f11eb2459b | ||
|
|
cb74db47f7 | ||
|
|
299b37a721 | ||
|
|
ffcb8202f0 | ||
|
|
e60b94b553 | ||
|
|
fbabe9674c | ||
|
|
8ab96e124f | ||
|
|
483acc3ca1 | ||
|
|
e0deef8889 | ||
|
|
bbb0ea329c | ||
|
|
bf69db3e82 | ||
|
|
adc08125fe | ||
|
|
aa9a916d61 | ||
|
|
5e3bd75f09 | ||
|
|
04d8522b3b | ||
|
|
dd1e0db407 | ||
|
|
2fa8afe37f | ||
|
|
5afd16ac65 | ||
|
|
9a2425a202 | ||
|
|
af02370777 | ||
|
|
9c8aee17dc | ||
|
|
a822873748 | ||
|
|
6e843d0f1c | ||
|
|
346cb90e9e | ||
|
|
6619146028 | ||
|
|
56589dccf1 | ||
|
|
982af4b724 | ||
|
|
7a516bba16 | ||
|
|
2080e36f19 | ||
|
|
a71f43d75b | ||
|
|
c20293078a | ||
|
|
37636b243f | ||
|
|
aae7312d8a | ||
|
|
3bbf89cba0 | ||
|
|
f976d4efb4 | ||
|
|
a904e7a396 | ||
|
|
12521add39 | ||
|
|
2dabc1e61b | ||
|
|
e5dc271a78 | ||
|
|
4e128e26fe | ||
|
|
b4c0a59501 | ||
|
|
9032bb3d13 | ||
|
|
8a6d90d70e | ||
|
|
b17487e6bb | ||
|
|
838629ca42 | ||
|
|
51a93418fa | ||
|
|
ef00a64bf8 | ||
|
|
ef4d9e1e55 | ||
|
|
2d08baf620 | ||
|
|
8cf41a137a | ||
|
|
61b08c94bf | ||
|
|
c87ac9cc2b | ||
|
|
ed20e2a1bc | ||
|
|
d4e3aa9d97 | ||
|
|
91aed7e98e | ||
|
|
fd19c86cb1 | ||
|
|
ad4bed8921 | ||
|
|
01d32cb230 | ||
|
|
746066c920 | ||
|
|
4815ac7110 | ||
|
|
035e2a3023 | ||
|
|
311ea070b1 | ||
|
|
736caac46d | ||
|
|
157dff14d3 | ||
|
|
a152bded93 | ||
|
|
6587812b72 | ||
|
|
c8848be981 | ||
|
|
baae082258 | ||
|
|
c8dcd716ab | ||
|
|
45ac44dd06 | ||
|
|
da04d223b4 | ||
|
|
d26ea1a411 | ||
|
|
9d5d61754d | ||
|
|
2bb77a31cf | ||
|
|
86ae0ed90f | ||
|
|
2540fbd771 | ||
|
|
354ada5c4c | ||
|
|
0fec5958fa | ||
|
|
ccc08a3fc7 | ||
|
|
5889cdd9ab | ||
|
|
93af087264 | ||
|
|
2d5bb2462e | ||
|
|
b71aa0a3fd | ||
|
|
1fd1686ee6 | ||
|
|
fc073e8743 | ||
|
|
be48d545cc | ||
|
|
13443a42ce | ||
|
|
071842ef49 | ||
|
|
366e29d4d9 | ||
|
|
02a5a1b14f | ||
|
|
64b26e47da | ||
|
|
1f2bd841c4 | ||
|
|
1e905598f2 | ||
|
|
e76b0ff282 | ||
|
|
9996c931e2 | ||
|
|
35386903f2 | ||
|
|
d79235e27b | ||
|
|
69f6891db5 | ||
|
|
fe25924780 | ||
|
|
0b3776916b | ||
|
|
98c7a2ab56 | ||
|
|
e2fccc219b | ||
|
|
6c7be58a3f | ||
|
|
2541feac25 | ||
|
|
7e82a491f6 | ||
|
|
e7749e25e9 | ||
|
|
d0a4f5f628 | ||
|
|
1fa10f6f28 | ||
|
|
f73cca0e39 | ||
|
|
0e55b382ba | ||
|
|
4d8c6b2856 | ||
|
|
52a545d86e | ||
|
|
08a5f32ea9 | ||
|
|
71dd0ab772 | ||
|
|
6d387b31c1 | ||
|
|
17ad362fa7 | ||
|
|
0cd99487cc | ||
|
|
e36983b4b5 | ||
|
|
62f5dc875a | ||
|
|
51f15bd306 | ||
|
|
8a28154661 | ||
|
|
ff85c63f29 | ||
|
|
a86f3d8765 | ||
|
|
5d1bd256f9 | ||
|
|
3461f11869 | ||
|
|
43a5c7b2f0 | ||
|
|
81a8df9855 | ||
|
|
31541bd6ba | ||
|
|
70b0fed2de | ||
|
|
cc86e76a7a | ||
|
|
29247e19ac | ||
|
|
afb83fcbd0 | ||
|
|
a0c13c1c1e | ||
|
|
d027b85200 | ||
|
|
bd57f45929 | ||
|
|
9cff19b322 | ||
|
|
a9e320e029 | ||
|
|
95d22c0822 | ||
|
|
2aae1bd54a | ||
|
|
9615faac2f | ||
|
|
cdce6b11d2 | ||
|
|
de7736a370 | ||
|
|
fcb0af00d3 | ||
|
|
daaade45d9 | ||
|
|
b085fac50f | ||
|
|
0b60cd1100 | ||
|
|
32c1bdfccb | ||
|
|
b261f2c6c1 | ||
|
|
8cda915637 | ||
|
|
de0f060378 | ||
|
|
0edc0cda94 | ||
|
|
7e63039144 | ||
|
|
b990ed9a60 | ||
|
|
ccc0f7ce53 | ||
|
|
83e331323c | ||
|
|
45ce494951 | ||
|
|
1c7bb8fc24 | ||
|
|
90763fd83f | ||
|
|
7ccb4bcafe | ||
|
|
a7bb3fc9e8 | ||
|
|
1190a342e9 | ||
|
|
31e4e8c552 | ||
|
|
4d53ac4c84 | ||
|
|
d99da63b89 | ||
|
|
9a7aeaf62b | ||
|
|
9b66ab1367 | ||
|
|
e3d1c63463 | ||
|
|
af209b6f53 | ||
|
|
7b558fef5f | ||
|
|
eac9240e06 | ||
|
|
84f9950556 | ||
|
|
5477163727 | ||
|
|
0a69bb54c8 | ||
|
|
4040840d8e | ||
|
|
cc95c85969 | ||
|
|
3b64ea9d92 | ||
|
|
08a1468dda | ||
|
|
6de7662ca8 | ||
|
|
4daddfc5f7 | ||
|
|
d6ed0f45da | ||
|
|
ea8de2ba1e | ||
|
|
f509410189 | ||
|
|
91a9e32a83 | ||
|
|
914b4c6757 | ||
|
|
03f381fdc2 | ||
|
|
3285850efa | ||
|
|
6654c7f1a3 | ||
|
|
d280e5ef2b | ||
|
|
f224ef7b7c | ||
|
|
0543ad4b19 | ||
|
|
9165bbb7b4 | ||
|
|
f4ef421d27 | ||
|
|
7ee207fc0e | ||
|
|
e78aa3589b | ||
|
|
68169f0e50 | ||
|
|
97f6a840e4 | ||
|
|
9a6445a8a0 | ||
|
|
0ec68a4b71 | ||
|
|
5084b1293e | ||
|
|
4949a54d66 | ||
|
|
b193d89042 | ||
|
|
bc17e6406b | ||
|
|
d1e50ce534 | ||
|
|
677db105e2 | ||
|
|
514d9f38b2 | ||
|
|
704ecf366e | ||
|
|
1439f4c6ac | ||
|
|
6a1b6a1403 | ||
|
|
239c8fc70c | ||
|
|
e33e335c0c | ||
|
|
71c4211d13 | ||
|
|
4b3a2c9f5a | ||
|
|
7185d9e501 | ||
|
|
dcbf4e5b0e | ||
|
|
db812cd729 | ||
|
|
c214f83298 | ||
|
|
3736b5901b | ||
|
|
3af130a2c0 | ||
|
|
145bc05dc3 | ||
|
|
cc0536c5ab | ||
|
|
0d00a144f5 | ||
|
|
aa021d988d | ||
|
|
ea147900ab | ||
|
|
b1796e3e1a | ||
|
|
b55cc0a037 | ||
|
|
8675b1c9ed | ||
|
|
b09b8ec3d6 | ||
|
|
7f05f31c72 | ||
|
|
53cb42103c | ||
|
|
a892a1b898 | ||
|
|
1f52faee39 | ||
|
|
e0038380c7 | ||
|
|
6050428566 | ||
|
|
b5e9ed436c | ||
|
|
cf313c503b | ||
|
|
abba8b3ba8 | ||
|
|
f08cd81ac0 | ||
|
|
68ec507e30 | ||
|
|
0718ca162d | ||
|
|
2d917a7d0d | ||
|
|
ed22ecedbf | ||
|
|
f6196107a7 | ||
|
|
49920ce093 | ||
|
|
33543c8588 | ||
|
|
c1413afa04 | ||
|
|
df93e4c8f9 | ||
|
|
7709af8a5e | ||
|
|
1ec1e57244 | ||
|
|
758b8144a1 | ||
|
|
590564c158 | ||
|
|
ea412cfe82 | ||
|
|
fa02bb5b74 | ||
|
|
683d29f9f7 | ||
|
|
3478e40017 | ||
|
|
ba0cc61965 | ||
|
|
8139d7f1f4 | ||
|
|
d487f37a7a | ||
|
|
087b85cb9f | ||
|
|
4bd281ce6c | ||
|
|
ced3d43dad | ||
|
|
91f22f1230 | ||
|
|
57d031d54a | ||
|
|
8e946623a9 | ||
|
|
e1d52ae43a | ||
|
|
f64cd7e8f3 | ||
|
|
df8e1687b2 | ||
|
|
3408e3c05a | ||
|
|
7a22cee639 | ||
|
|
dec91919bc | ||
|
|
313865b0a3 | ||
|
|
ee1a5f0400 | ||
|
|
7f2803b020 | ||
|
|
83205e43da | ||
|
|
45e127e22f | ||
|
|
d22d45a697 | ||
|
|
330a913e76 | ||
|
|
af0521b54c | ||
|
|
8cf8cc0231 | ||
|
|
f5f5b037b8 | ||
|
|
1252f10d39 | ||
|
|
a3c8e1c3c9 | ||
|
|
073989c7a4 | ||
|
|
05eff957eb | ||
|
|
3c855d2042 | ||
|
|
0bdf663ca1 | ||
|
|
813dd49a41 | ||
|
|
dab7e6a1ae | ||
|
|
b73c3e3dfb | ||
|
|
4ccfdfc6f4 | ||
|
|
15601d6339 | ||
|
|
1046f0e74a | ||
|
|
032e5238b8 | ||
|
|
5b3fdf7b9f | ||
|
|
0489e75e1e | ||
|
|
eb655f17db | ||
|
|
d8c7c00ca2 | ||
|
|
9aa7e1ec86 | ||
|
|
caa652b0ad | ||
|
|
08e82e4980 | ||
|
|
5067721fdf | ||
|
|
b014857a13 | ||
|
|
eb7945248b | ||
|
|
69638d7956 | ||
|
|
953f3a232b | ||
|
|
d1824f169b | ||
|
|
81973c3a2a | ||
|
|
213e2b50d6 | ||
|
|
0a63ae5376 | ||
|
|
61dd1dd96d | ||
|
|
ed85367e53 | ||
|
|
38c2ea5bf7 | ||
|
|
2d62027e24 | ||
|
|
a56719d19c | ||
|
|
94744525ba | ||
|
|
ea193f1938 | ||
|
|
88339473ce | ||
|
|
57566c76ed | ||
|
|
a09342836b | ||
|
|
b3fbf78bec | ||
|
|
a744838e9c | ||
|
|
712c7bdbaf | ||
|
|
6d3389b409 | ||
|
|
03005b38c8 | ||
|
|
17cff2a26f | ||
|
|
5777d76e55 | ||
|
|
91d67a0ef5 | ||
|
|
e083a6bff7 | ||
|
|
57aaa5a85a | ||
|
|
34bad92ec6 | ||
|
|
b6a4acda9b | ||
|
|
1dc0b24e9a | ||
|
|
38de19bb2c | ||
|
|
c47f5df021 | ||
|
|
4ad838c114 | ||
|
|
b0b3aee648 | ||
|
|
446dbbf1fc | ||
|
|
11f0be55d8 | ||
|
|
e10dd893ab | ||
|
|
0dd899679d | ||
|
|
05cab61e1f | ||
|
|
0c7e7e5089 | ||
|
|
5f71776434 | ||
|
|
a7e02a7be8 | ||
|
|
7df6b2bab5 | ||
|
|
a1bea8c5ab | ||
|
|
c23d244405 | ||
|
|
86898e5ceb | ||
|
|
c12bedb3e5 | ||
|
|
fd1622ba80 | ||
|
|
7564019ecd | ||
|
|
6d381da075 | ||
|
|
b10aa83fc8 | ||
|
|
a7997a3cd4 | ||
|
|
73c19bff71 | ||
|
|
80aa21123c | ||
|
|
afd1760b6f | ||
|
|
ddcab827ad | ||
|
|
d0a41548fd | ||
|
|
ff8e6b9c66 | ||
|
|
25f30a94f5 | ||
|
|
efe451df79 | ||
|
|
b97a09c75b | ||
|
|
f3b8c689a9 | ||
|
|
4d1a8ee873 |
@@ -1,2 +0,0 @@
|
||||
node_modules/*
|
||||
test/
|
||||
@@ -1,46 +0,0 @@
|
||||
|
||||
module.exports = {
|
||||
'env': {
|
||||
'jest/globals': true,
|
||||
'es6': true,
|
||||
'node': true,
|
||||
},
|
||||
'extends': ['eslint:recommended', 'google', 'plugin:jest/recommended', 'plugin:jest/style'],
|
||||
'parserOptions': {
|
||||
'ecmaVersion': 2018,
|
||||
'sourceType': 'module',
|
||||
},
|
||||
'rules': {
|
||||
'require-jsdoc': 'off',
|
||||
'indent': ['error', 4],
|
||||
'max-len': ['error', {'code': 120}],
|
||||
'no-prototype-builtins': 'off',
|
||||
'linebreak-style': ['error', (process.platform === 'win32' ? 'windows' : 'unix')], // https://stackoverflow.com/q/39114446/2771889
|
||||
},
|
||||
'plugins': [
|
||||
'jest',
|
||||
],
|
||||
'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-unused-vars': 'error',
|
||||
'@typescript-eslint/semi': ['error'],
|
||||
'array-bracket-spacing': ['error', 'never'],
|
||||
'indent': ['error', 4],
|
||||
'max-len': ['error', {'code': 120}],
|
||||
'no-return-await': 'error',
|
||||
'object-curly-spacing': ['error', 'never'],
|
||||
},
|
||||
}],
|
||||
};
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
github: [koenkk]
|
||||
custom:
|
||||
- https://www.paypal.me/koenkk
|
||||
- https://www.buymeacoffee.com/koenkk
|
||||
- https://www.paypal.me/koenkk
|
||||
- https://www.buymeacoffee.com/koenkk
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: "IMPORTANT: Check development branch changelog first!!"
|
||||
url: https://github.com/Koenkk/zigbee2mqtt/releases/tag/latest-dev
|
||||
about: "Before submitting an issue, check if 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
|
||||
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.
|
||||
- name: Home Assistant addon issues
|
||||
url: https://github.com/zigbee2mqtt/hassio-zigbee2mqtt/issues
|
||||
about: Issues related to the Home Assistant addon.
|
||||
- name: FAQ
|
||||
url: https://www.zigbee2mqtt.io/guide/faq
|
||||
about: Frequently asked questions.
|
||||
- name: Support Chat
|
||||
url: https://discord.gg/NyseBeK
|
||||
about: Chat for feedback, questions and troubleshooting.
|
||||
- name: 'IMPORTANT: Check development branch changelog first!!'
|
||||
url: https://gist.github.com/Koenkk/bfd4c3d1725a2cccacc11d6ba51008ba
|
||||
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 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 with the Home Assistant addon
|
||||
- name: FAQ
|
||||
url: https://www.zigbee2mqtt.io/guide/faq
|
||||
about: Frequently asked questions
|
||||
- name: Support Chat
|
||||
url: https://discord.gg/NyseBeK
|
||||
about: Chat for feedback, questions and troubleshooting
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: External converter
|
||||
description: Submit an external converter
|
||||
title: '[External Converter]: '
|
||||
labels: [external converter]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**IMPORTANT:** In case the external converter fully works, please consider submitting a [pull request](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request) instead.
|
||||
- type: input
|
||||
id: link
|
||||
attributes:
|
||||
label: Link
|
||||
description: Link of this device (product page)
|
||||
placeholder: https://www.linktomydevice.org
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: database
|
||||
attributes:
|
||||
label: Database entry
|
||||
description: Entry of this device in `data/database.db` after pairing it
|
||||
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: external_definition
|
||||
attributes:
|
||||
label: External converter
|
||||
description: Code of the external converter, to retrieve in the Z2M frontend go to Settings -> Dev Console -> External Converters
|
||||
render: JavaScript
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What does/doesn't work with the external definition?
|
||||
description: Indicate what works and what doesn't work with the external definition.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
label: Notes
|
||||
description: Any additional information or context that might be helpful
|
||||
placeholder: Add any relevant notes or context here
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,38 +1,38 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea for this project
|
||||
title: "[Feature request]: "
|
||||
title: '[Feature request]: '
|
||||
labels: [feature request]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**IMPORTANT:** Before submitting:
|
||||
- Is your feature request related to the fronted? Then click [here](https://github.com/nurikk/zigbee2mqtt-frontend/issues/new?assignees=&labels=&template=feature_request.md&title=)
|
||||
- type: textarea
|
||||
id: textarea1
|
||||
attributes:
|
||||
label: Is your feature request related to a problem? Please describe
|
||||
placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: textarea2
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
placeholder: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: textarea3
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
placeholder: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: textarea4
|
||||
attributes:
|
||||
label: Additional context
|
||||
placeholder: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**IMPORTANT:** Before submitting:
|
||||
- Is your feature request related to the frontend? Then click [here](https://github.com/nurikk/zigbee2mqtt-frontend/issues/new?assignees=&labels=&template=feature_request.md&title=)
|
||||
- type: textarea
|
||||
id: textarea1
|
||||
attributes:
|
||||
label: Is your feature request related to a problem? Please describe
|
||||
placeholder: A clear and concise description of what the problem is. Eg. I'm always frustrated when [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: textarea2
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
placeholder: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: textarea3
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
placeholder: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: textarea4
|
||||
attributes:
|
||||
label: Additional context
|
||||
placeholder: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -1,55 +1,59 @@
|
||||
name: New device support request
|
||||
description: Request support for a new device
|
||||
title: "[New device support]: "
|
||||
title: '[New device support]: '
|
||||
labels: [new device support]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**IMPORTANT:** Before submitting:
|
||||
- Make sure this device is not already supported in the [dev branch](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html)
|
||||
- Make sure there is no existing issue or PR for this device already, search for your device here: https://github.com/Koenkk/zigbee2mqtt/issues
|
||||
- Follow this [guide](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html)
|
||||
- If you are using the Home Assistant addon and are still on 1.18.1, check the first point of [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0)
|
||||
- type: input
|
||||
id: link
|
||||
attributes:
|
||||
label: Link
|
||||
description: Link of this device (product page)
|
||||
placeholder: https://www.linktomydevice.org
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: database
|
||||
attributes:
|
||||
label: Database entry
|
||||
description: Entry of this device in `data/database.db` after pairing it
|
||||
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: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
label: Comments
|
||||
placeholder: I tried to follow the supporting new device page but got stuck at...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: External converter
|
||||
description: In case you tried to create an external converter, put it below
|
||||
render: shell
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Lights
|
||||
In case this device is a light, fill in the fields below:
|
||||
- type: input
|
||||
attributes:
|
||||
label: Supported color modes
|
||||
placeholder: color temperature, color (rgb)
|
||||
- type: input
|
||||
attributes:
|
||||
label: Color temperature range
|
||||
description: In case this light supports color temperature, provide the color temperature range. [How to do this](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_3-1-retrieving-color-temperature-range-only-required-for-lights-which-support-color-temperature).
|
||||
placeholder: '{"colorTempPhysicalMin":153,"colorTempPhysicalMax":454}'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**IMPORTANT:** Before submitting:
|
||||
- Make sure this device is not already supported in the dev branch by checking the [dev branch changelog](https://gist.github.com/Koenkk/bfd4c3d1725a2cccacc11d6ba51008ba#new-supported-devices)
|
||||
- Make sure there is no existing issue or PR for this device already, search for your device here: https://github.com/Koenkk/zigbee2mqtt/issues
|
||||
- Follow this [guide](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html)
|
||||
- If you are using the Home Assistant addon and are still on 1.18.1, check the first point of [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0)
|
||||
- type: input
|
||||
id: link
|
||||
attributes:
|
||||
label: Link
|
||||
description: Link of this device (product page)
|
||||
placeholder: https://www.linktomydevice.org
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: database
|
||||
attributes:
|
||||
label: Database entry
|
||||
description: Entry of this device in `data/database.db` after pairing it
|
||||
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: external_definition
|
||||
attributes:
|
||||
label: External definition
|
||||
description: See [Creating the external definition](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_2-creating-the-external-definition)
|
||||
render: JavaScript
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What does/doesn't work with the external definition?
|
||||
description: Indicate what works and what doesn't work with the external definition.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
label: Notes
|
||||
description: Any additional information or context that might be helpful
|
||||
placeholder: Add any relevant notes or context here
|
||||
validations:
|
||||
required: false
|
||||
@@ -2,66 +2,92 @@ name: Problem report
|
||||
description: Create a report to help us improve
|
||||
labels: [problem]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**IMPORTANT:** Before submitting:
|
||||
- You read the [FAQ](https://www.zigbee2mqtt.io/guide/faq/)
|
||||
- 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)
|
||||
- Is your issue related to the fronted? Then click [here](https://github.com/nurikk/zigbee2mqtt-frontend/issues/new?assignees=&labels=bug%2Ctriage&template=bug_report.yaml&title=%5BBug%5D%3A+)
|
||||
- If you are using the Home Assistant addon and are still on 1.18.1, check the first point of [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0)
|
||||
- type: textarea
|
||||
id: what_happend
|
||||
attributes:
|
||||
label: What happened?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expect_to_happen
|
||||
attributes:
|
||||
label: What did you expect to happen?
|
||||
placeholder: I expected that ...
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: How to reproduce it (minimal and precise)
|
||||
placeholder: First do this, than this..
|
||||
validations:
|
||||
required: false
|
||||
- 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: input
|
||||
id: adapter_fwversion
|
||||
attributes:
|
||||
label: Adapter firmware version
|
||||
description: Can be found in the frontend -> settings -> about -> coordinator revision
|
||||
placeholder: '20211210'
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: adapter
|
||||
attributes:
|
||||
label: Adapter
|
||||
description: The adapter you are using
|
||||
placeholder: Electrolama zig-a-zig-ah! (zzh!), Slaesh's CC2652RB stick, ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes:
|
||||
label: Debug log
|
||||
description: After enabling [debug logging](https://www.zigbee2mqtt.io/guide/usage/debug.html) the log can be found under `data/log`. Attach the file below
|
||||
placeholder: Click here and drag the file into it or click on "Attach files by.." below
|
||||
validations:
|
||||
required: false
|
||||
- type: markdown
|
||||
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)
|
||||
- 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)
|
||||
- Is your issue related to the frontend? Then click [here](https://github.com/nurikk/zigbee2mqtt-frontend/issues/new?assignees=&labels=bug%2Ctriage&template=bug_report.yaml&title=%5BBug%5D%3A+)
|
||||
- If you are using the Home Assistant addon and are still on 1.18.1, check the first point of [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0)
|
||||
- type: textarea
|
||||
id: what_happend
|
||||
attributes:
|
||||
label: What happened?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expect_to_happen
|
||||
attributes:
|
||||
label: What did you expect to happen?
|
||||
placeholder: I expected that ...
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: How to reproduce it (minimal and precise)
|
||||
placeholder: First do this, then this..
|
||||
validations:
|
||||
required: false
|
||||
- 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: input
|
||||
id: adapter_fwversion
|
||||
attributes:
|
||||
label: Adapter firmware version
|
||||
description: Can be found in the frontend -> settings -> about -> coordinator revision
|
||||
placeholder: '20211210'
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: adapter
|
||||
attributes:
|
||||
label: Adapter
|
||||
description: The adapter you are using. In case of EZSP, try the [new `ember` driver](https://github.com/Koenkk/zigbee2mqtt/discussions/21462) first.
|
||||
placeholder: Electrolama zig-a-zig-ah! (zzh!), Slaesh's CC2652RB stick, ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: setup
|
||||
attributes:
|
||||
label: Setup
|
||||
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:
|
||||
label: Debug log
|
||||
description: After enabling [debug logging](https://www.zigbee2mqtt.io/guide/configuration/logging.html#debugging) the log can be found under `data/log`. Attach the file below
|
||||
placeholder: Click here and drag the file into it or click on "Attach files by.." below
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
label: Notes
|
||||
description: Any additional information or context that might be helpful
|
||||
placeholder: Add any relevant notes or context here
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -1,64 +1,67 @@
|
||||
name: Wrong device picture/vendor/model/description
|
||||
description: Use if device is detected as supported and is fully functional but has a wrong picture, vendor, model or description
|
||||
title: "[Wrong device]: "
|
||||
title: '[Wrong device]: '
|
||||
labels: [wrong device]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Only use this if the device is detected as **supported** and is **fully functional** but has a wrong picture, vendor, model or description.
|
||||
- type: input
|
||||
id: link
|
||||
attributes:
|
||||
label: Link
|
||||
description: Link of this device (product page)
|
||||
placeholder: https://www.linktomydevice.org
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: model
|
||||
attributes:
|
||||
label: Model
|
||||
description: Expected model, model that is printed on the device, for TuYa device this is NOT something like TS0601 or _TZE200_cf1sl3tj
|
||||
placeholder: RTCGQ01LM
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Expected description
|
||||
placeholder: Motion sensor
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: vendor
|
||||
attributes:
|
||||
label: Vendor
|
||||
description: Expected vendor
|
||||
placeholder: Xiaomi
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: picture
|
||||
attributes:
|
||||
label: Picture (link)
|
||||
description: Expected picture
|
||||
placeholder: https://www.linktomydevice.org/RTCGQ01LM.jpg
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: database
|
||||
attributes:
|
||||
label: Database entry
|
||||
description: Entry of this device in `data/database.db` after pairing it
|
||||
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: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
label: Notes
|
||||
placeholder: Some additional notes...
|
||||
validations:
|
||||
required: false
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Submit this issue only if your device is detected as **supported** and works **fully**, but displays an incorrect picture, vendor, model, or description.
|
||||
|
||||
**IMPORTANT**: Before opening this issue, please review the [guide](https://www.zigbee2mqtt.io/advanced/support-new-devices/02_support_new_tuya_devices.html#fixing-tuya-device-detection) for instructions on fixing the device detection yourself.
|
||||
This usually only takes 5 minutes.
|
||||
- type: input
|
||||
id: link
|
||||
attributes:
|
||||
label: Link
|
||||
description: Link of this device (product page)
|
||||
placeholder: https://www.linktomydevice.org
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: model
|
||||
attributes:
|
||||
label: Model
|
||||
description: Expected model, model that is printed on the device, for Tuya device this is NOT something like TS0601 or _TZE200_cf1sl3tj
|
||||
placeholder: RTCGQ01LM
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Expected description
|
||||
placeholder: Motion sensor
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: vendor
|
||||
attributes:
|
||||
label: Vendor
|
||||
description: Expected vendor
|
||||
placeholder: Xiaomi
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: picture
|
||||
attributes:
|
||||
label: Picture (link)
|
||||
description: Expected picture
|
||||
placeholder: https://www.linktomydevice.org/RTCGQ01LM.jpg
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: database
|
||||
attributes:
|
||||
label: Database entry
|
||||
description: Entry of this device in `data/database.db` after pairing it
|
||||
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: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
label: Notes
|
||||
placeholder: Some additional notes...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
# GitHub Copilot Instructions
|
||||
|
||||
## Priority Guidelines
|
||||
|
||||
When generating code for this repository:
|
||||
|
||||
1. **Version Compatibility**: Always detect and respect the exact versions of languages, frameworks, and libraries used in this project
|
||||
2. **Context Files**: Prioritize patterns and standards defined in the .github/copilot directory
|
||||
3. **Codebase Patterns**: When context files don't provide specific guidance, scan the codebase for established patterns
|
||||
4. **Architectural Consistency**: Maintain our layered architectural style with clear separation between controller, extensions, models, and utilities
|
||||
5. **Code Quality**: Prioritize maintainability, performance, security, and testability in all generated code
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Core Technologies
|
||||
- **Language**: TypeScript 5.9.3 with target `esnext` and module `NodeNext`
|
||||
- **Runtime**: Node.js ^20 || ^22 || ^24
|
||||
- **Package Manager**: pnpm 10.12.1
|
||||
- **Testing**: Vitest 3.1.1 with @vitest/coverage-v8
|
||||
- **Linting/Formatting**: Biome 2.2.5 (configured with 4-space indents, 150 line width, no bracket spacing)
|
||||
|
||||
### Key Dependencies
|
||||
- **zigbee-herdsman**: 6.2.0 (exact version - critical for Zigbee protocol compatibility)
|
||||
- **zigbee-herdsman-converters**: 25.42.0 (exact version - device definitions)
|
||||
- **MQTT**: mqtt 5.14.1
|
||||
- **Logging**: winston 3.18.3
|
||||
- **YAML**: js-yaml 4.1.0
|
||||
- **Decorators**: bind-decorator 1.0.11
|
||||
- **WebSocket**: ws 8.18.1
|
||||
|
||||
### TypeScript Configuration
|
||||
- **Strict Mode**: Enabled with `noImplicitAny` and `noImplicitThis`
|
||||
- **Module System**: NodeNext with ESM interop
|
||||
- **Decorators**: Experimental decorators enabled
|
||||
- **Composite**: True (for project references)
|
||||
- **Source Maps**: Inline source maps enabled
|
||||
- **Output**: Compiled to `dist/` directory
|
||||
|
||||
## Project Architecture
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
lib/ # Source TypeScript files
|
||||
├── controller.ts # Main controller orchestrating all components
|
||||
├── mqtt.ts # MQTT client management
|
||||
├── zigbee.ts # Zigbee network management
|
||||
├── state.ts # State management
|
||||
├── eventBus.ts # Event-driven communication
|
||||
├── extension/ # Extension system (plugins)
|
||||
│ ├── extension.ts # Abstract base class
|
||||
│ ├── availability.ts
|
||||
│ ├── bind.ts
|
||||
│ ├── bridge.ts
|
||||
│ ├── configure.ts
|
||||
│ └── ...
|
||||
├── model/ # Domain models
|
||||
│ ├── device.ts
|
||||
│ └── group.ts
|
||||
├── util/ # Utility functions
|
||||
│ ├── logger.ts
|
||||
│ ├── settings.ts
|
||||
│ ├── utils.ts
|
||||
│ └── ...
|
||||
└── types/ # TypeScript type definitions
|
||||
└── api.ts
|
||||
test/ # Vitest test files
|
||||
data/ # Runtime configuration and data
|
||||
```
|
||||
|
||||
### Architectural Patterns
|
||||
|
||||
#### Extension Pattern
|
||||
All extensions inherit from the abstract `Extension` base class:
|
||||
```typescript
|
||||
abstract class Extension {
|
||||
protected zigbee: Zigbee;
|
||||
protected mqtt: Mqtt;
|
||||
protected state: State;
|
||||
protected publishEntityState: PublishEntityState;
|
||||
protected eventBus: EventBus;
|
||||
|
||||
async start(): Promise<void> {}
|
||||
async stop(): Promise<void> {}
|
||||
}
|
||||
```
|
||||
|
||||
#### Event-Driven Architecture
|
||||
Use the `EventBus` for component communication. Events are strongly typed:
|
||||
```typescript
|
||||
interface EventBusMap {
|
||||
deviceMessage: [data: eventdata.DeviceMessage];
|
||||
mqttMessage: [data: eventdata.MQTTMessage];
|
||||
publishEntityState: [data: eventdata.PublishEntityState];
|
||||
// ... other events
|
||||
}
|
||||
```
|
||||
|
||||
#### Dependency Injection
|
||||
The `Controller` class instantiates and injects dependencies into extensions. Follow this pattern when creating new extensions.
|
||||
|
||||
## Code Style and Conventions
|
||||
|
||||
### Naming Conventions
|
||||
- **Classes**: PascalCase (e.g., `Extension`, `Device`, `EventBus`)
|
||||
- **Interfaces/Types**: PascalCase (e.g., `MqttPublishOptions`, `DeviceOptions`)
|
||||
- **Functions/Methods**: camelCase (e.g., `publishEntityState`, `enableDisableExtension`)
|
||||
- **Constants**: SCREAMING_SNAKE_CASE for top-level constants (e.g., `CURRENT_VERSION`, `LOG_LEVELS`)
|
||||
- **Private members**: Prefix with underscore for private class fields only when needed to distinguish from public properties (e.g., `_definitionModelID`)
|
||||
- **Files**: camelCase for TypeScript files (e.g., `eventBus.ts`, `externalJS.ts`)
|
||||
|
||||
### Import Organization
|
||||
Follow this import order (separated by blank lines):
|
||||
1. Node.js built-in modules (use `node:` prefix: `import fs from "node:fs"`)
|
||||
2. Third-party libraries (e.g., `bind-decorator`, `mqtt`)
|
||||
3. Type-only imports from external packages (using `type` keyword)
|
||||
4. Internal absolute imports from project root
|
||||
5. Type-only imports from internal modules
|
||||
|
||||
Example:
|
||||
```typescript
|
||||
import fs from "node:fs";
|
||||
import bind from "bind-decorator";
|
||||
import type {IClientOptions} from "mqtt";
|
||||
import {connectAsync} from "mqtt";
|
||||
import type {Zigbee2MQTTAPI} from "./types/api";
|
||||
import logger from "./util/logger";
|
||||
import * as settings from "./util/settings";
|
||||
```
|
||||
|
||||
### Type Annotations
|
||||
- Use `type` imports for TypeScript types: `import type * as zhc from "zigbee-herdsman-converters"`
|
||||
- Explicitly type function parameters and return types
|
||||
- Use `KeyValue` type for generic object payloads: `type KeyValue = Record<string, any>`
|
||||
- Prefer interfaces for object shapes, type aliases for unions/intersections
|
||||
- Use namespace exports for related types: `export type * as ZSpec from "zigbee-herdsman/dist/zspec"`
|
||||
|
||||
### Async/Await Patterns
|
||||
- Always use `async/await` for asynchronous operations
|
||||
- Return types should be explicitly `Promise<Type>`
|
||||
- Methods that don't return values should be `Promise<void>`
|
||||
- Use `Awaited<ReturnType<typeof fn>>` for inferring async function return types
|
||||
|
||||
### Decorators
|
||||
Use `@bind` decorator from `bind-decorator` for methods that need `this` binding:
|
||||
```typescript
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
- Use `throw new Error("message")` for explicit errors
|
||||
- Include descriptive error messages
|
||||
- Log errors using the logger: `logger.error("message")`
|
||||
- For Zigbee-herdsman errors, log the stack trace: `logger.error((error as Error).stack!)`
|
||||
- Catch and handle errors at appropriate boundaries (controller level)
|
||||
|
||||
### Logging
|
||||
Use the centralized logger (winston-based):
|
||||
```typescript
|
||||
import logger from "./util/logger";
|
||||
|
||||
logger.info("message");
|
||||
logger.warning("message");
|
||||
logger.error("message");
|
||||
logger.debug("message");
|
||||
```
|
||||
|
||||
- Use namespaced loggers for specific modules (created internally by logger)
|
||||
- Log levels: `error`, `warning`, `info`, `debug` (from most to least critical)
|
||||
- Include relevant context in log messages (device names, IEEE addresses, etc.)
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
### Maintainability
|
||||
- Write self-documenting code with clear, descriptive names
|
||||
- Keep methods focused on single responsibilities
|
||||
- Abstract classes should define clear contracts with protected members for subclasses
|
||||
- Use constructor dependency injection for required dependencies
|
||||
- Limit function complexity - methods should be concise and focused
|
||||
- Use TypeScript's strict mode features (`noImplicitAny`, `noImplicitThis`)
|
||||
|
||||
### Performance
|
||||
- Use `rimrafSync` for synchronous file deletion when appropriate
|
||||
- Leverage async/await for I/O operations to avoid blocking
|
||||
- Use JSON stable stringify for consistent object serialization: `json-stable-stringify-without-jsonify`
|
||||
- Cache computed values when appropriate (see device model patterns)
|
||||
- Use getter methods for computed properties that should be cached
|
||||
|
||||
### Security
|
||||
- Validate input using Ajv JSON schema validation (see `settings.ts` pattern)
|
||||
- Sanitize file paths using `path.join` from Node.js
|
||||
- Use YAML safe loading: `yaml.safeLoad()`
|
||||
- Handle sensitive data (credentials, tokens) through settings with proper defaults
|
||||
- Never log sensitive information (passwords, tokens)
|
||||
|
||||
### Testability
|
||||
- Write tests using Vitest with describe/it/expect patterns
|
||||
- Mock external dependencies using Vitest's `vi.mock()`
|
||||
- Use `beforeEach`, `afterEach`, `beforeAll`, `afterAll` for test setup/teardown
|
||||
- Place test files in `test/` directory with `.test.ts` extension
|
||||
- Mock constructors and modules in the pattern shown in `test/controller.test.ts`
|
||||
- Use `flushPromises()` utility for async test synchronization
|
||||
- Target 100% code coverage (configured in vitest.config.mts)
|
||||
|
||||
## Testing Standards
|
||||
|
||||
### Unit Testing Structure
|
||||
```typescript
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
|
||||
|
||||
describe("ComponentName", () => {
|
||||
beforeEach(() => {
|
||||
// Setup
|
||||
});
|
||||
|
||||
it("Should do something specific", async () => {
|
||||
// Arrange
|
||||
const input = {};
|
||||
|
||||
// Act
|
||||
const result = await someFunction(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking Patterns
|
||||
- Create mock modules in `test/mocks/` directory
|
||||
- Use `vi.fn()` for function mocks
|
||||
- Use `vi.mock()` for module mocks
|
||||
- Clear mocks in `afterEach` or between tests
|
||||
- Mock external libraries like `mqtt`, `zigbee-herdsman` consistently
|
||||
|
||||
### Test Coverage
|
||||
- All code in `lib/**` should be covered
|
||||
- Use coverage reports: `pnpm test:coverage`
|
||||
- Thresholds set to 100% (can be adjusted per project needs)
|
||||
- Tests should cover both success and failure paths
|
||||
|
||||
## Documentation Standards
|
||||
|
||||
### JSDoc Comments
|
||||
Use JSDoc-style comments for classes and public methods:
|
||||
```typescript
|
||||
/**
|
||||
* Besides initializing variables, the constructor should do nothing!
|
||||
*
|
||||
* @param {Zigbee} zigbee Zigbee 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
|
||||
*/
|
||||
constructor(zigbee: Zigbee, mqtt: Mqtt, state: State, ...) {
|
||||
```
|
||||
|
||||
### Comment Style
|
||||
- Use single-line comments (`//`) for implementation notes
|
||||
- Use JSDoc (`/** */`) for public APIs and class/method documentation
|
||||
- Include context for non-obvious logic
|
||||
- Document parameters with their types and purposes
|
||||
- Use `@param` tags with TypeScript types in braces
|
||||
- Use biome-ignore comments when necessary: `// biome-ignore lint/rule: reason`
|
||||
|
||||
### Code Documentation
|
||||
- Document complex algorithms or business logic
|
||||
- Explain "why" not just "what" when logic is non-trivial
|
||||
- Include links to relevant issues or documentation when applicable
|
||||
- Document deprecations and breaking changes
|
||||
|
||||
## TypeScript-Specific Guidelines
|
||||
|
||||
### Module System
|
||||
- Use ES modules with `import`/`export` syntax
|
||||
- Default exports for main classes: `export default class Device {}`
|
||||
- Named exports for utilities and types: `export const LOG_LEVELS = ...`
|
||||
- Namespace exports for related types: `export type * as ZSpec from ...`
|
||||
- Use `.js` extension in imports for local modules when using dynamic imports: `await import("./extension/frontend.js")`
|
||||
|
||||
### Type Safety
|
||||
- Enable all strict type checking options
|
||||
- Use type guards and assertions when necessary: `asserts expose is zhc.Numeric`
|
||||
- Prefer `unknown` over `any` when type is truly unknown
|
||||
- Use `// biome-ignore lint/suspicious/noExplicitAny: API` when `any` is necessary
|
||||
- Define proper interfaces for external module types (e.g., `unix-dgram.d.ts`)
|
||||
|
||||
### Generic Types
|
||||
- Use generics for reusable, type-safe abstractions
|
||||
- Example: `abstract class ExternalJSExtension<M> extends Extension`
|
||||
- Constrain generics when appropriate
|
||||
- Document generic type parameters
|
||||
|
||||
### Utility Types
|
||||
- Use built-in utility types: `Partial`, `Required`, `Pick`, `Omit`, `Record`
|
||||
- Use `Awaited<ReturnType<typeof fn>>` for async function return types
|
||||
- Define custom utility types when patterns emerge
|
||||
- Use `type` for aliases, `interface` for object shapes
|
||||
|
||||
## Version Control and Releases
|
||||
|
||||
### Versioning Strategy
|
||||
- Follow Semantic Versioning (MAJOR.MINOR.PATCH)
|
||||
- Current version managed in `package.json`
|
||||
- Use `-dev` suffix for development versions (e.g., `2.6.2-dev`)
|
||||
- Configuration version tracked separately: `CURRENT_VERSION = 4`
|
||||
|
||||
### Changelog
|
||||
- Maintain CHANGELOG.md with all changes
|
||||
- Group changes by type: Bug Fixes, Features, Breaking Changes
|
||||
- Include issue/PR references: `([#28583](url))`
|
||||
- Include commit references: `([09f33b3](url))`
|
||||
- Use conventional commits format
|
||||
|
||||
### Git Workflow
|
||||
- Development on `dev` branch
|
||||
- Production releases from `master` branch
|
||||
- Use meaningful commit messages
|
||||
- Reference issues in commits
|
||||
|
||||
## Project-Specific Patterns
|
||||
|
||||
### Settings Management
|
||||
- All configuration loaded through `util/settings.ts`
|
||||
- Validate settings using Ajv with JSON schema
|
||||
- Schema defined in `settings.schema.json`
|
||||
- Support runtime setting changes with restart detection
|
||||
- Use `settings.get()` to access current configuration
|
||||
- Use `settings.getDevice(ieeeAddr)` for device-specific config
|
||||
|
||||
### Device and Group Models
|
||||
- Devices and groups are domain models wrapping `zigbee-herdsman` entities
|
||||
- Access underlying entity via `.zh` property
|
||||
- Expose computed properties as getters
|
||||
- Include definition from `zigbee-herdsman-converters`
|
||||
- Handle coordinator devices specially (type checking)
|
||||
|
||||
### MQTT Integration
|
||||
- MQTT client wrapped in `Mqtt` class
|
||||
- Publish options: `retain`, `qos` properties
|
||||
- Topics follow pattern: `{base_topic}/{device}/{attribute}`
|
||||
- Event-based message handling via EventBus
|
||||
- Clean disconnect handling with retry logic
|
||||
|
||||
### Extension System
|
||||
- Extensions are loosely coupled plugins
|
||||
- Lifecycle: constructor → start() → stop()
|
||||
- Constructor should only assign properties (no side effects)
|
||||
- Use EventBus for inter-extension communication
|
||||
- Extensions can be enabled/disabled at runtime
|
||||
- External extensions loaded from `data/external_extensions/`
|
||||
|
||||
### State Management
|
||||
- State persisted to `state.json`
|
||||
- Cached in memory for performance
|
||||
- Device states include all exposed attributes
|
||||
- State changes trigger events via EventBus
|
||||
|
||||
## Best Practices Specific to This Project
|
||||
|
||||
1. **Never use language features beyond TypeScript 5.9.3 or ES2024**
|
||||
2. **Always respect exact versions of zigbee-herdsman and zigbee-herdsman-converters** - these are critical for device compatibility
|
||||
3. **Use the EventBus for all component communication** - avoid direct coupling
|
||||
4. **Follow the Extension pattern for new features** - don't add logic directly to Controller
|
||||
5. **Log appropriately** - info for user-relevant events, debug for developer info, error for failures
|
||||
6. **Test with real Zigbee scenarios** - many edge cases exist with different device types
|
||||
7. **Handle coordinator specially** - coordinator is a device but with unique behavior
|
||||
8. **Validate all external input** - MQTT messages, configuration files, device data
|
||||
9. **Use the bind decorator** for event handlers to preserve `this` context
|
||||
10. **Match the exact code formatting** - Biome enforces 4 spaces, 150 line width, no bracket spacing
|
||||
|
||||
## Common Patterns to Follow
|
||||
|
||||
### Creating a New Extension
|
||||
1. Extend `Extension` abstract class
|
||||
2. Accept all dependencies in constructor
|
||||
3. Implement `start()` method for initialization
|
||||
4. Subscribe to EventBus events in `start()`
|
||||
5. Implement `stop()` method for cleanup
|
||||
6. Export as default: `export default class MyExtension extends Extension`
|
||||
|
||||
### Accessing Device Information
|
||||
```typescript
|
||||
const device: Device; // Our wrapper
|
||||
device.ieeeAddr; // IEEE address
|
||||
device.name; // Friendly name
|
||||
device.zh; // Underlying zigbee-herdsman device
|
||||
device.definition; // zigbee-herdsman-converters definition
|
||||
device.options; // User configuration
|
||||
```
|
||||
|
||||
### Publishing MQTT Messages
|
||||
```typescript
|
||||
await this.mqtt.publish(topic, message, {retain: true, qos: 0});
|
||||
```
|
||||
|
||||
### Emitting Events
|
||||
```typescript
|
||||
this.eventBus.emit('deviceMessage', {device, message});
|
||||
```
|
||||
|
||||
### Listening to Events
|
||||
```typescript
|
||||
this.eventBus.on('deviceMessage', this.onDeviceMessage, this);
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Zigbee-Herdsman Integration
|
||||
- Start controller: `await this.zigbee.start()`
|
||||
- Access coordinator: `this.zigbee.coordinator()`
|
||||
- Device operations through `zigbee-herdsman` API
|
||||
- Event handling through EventBus wrappers
|
||||
|
||||
### MQTT Integration
|
||||
- Connect: `await this.mqtt.connect()`
|
||||
- Subscribe: `await this.mqtt.subscribe(topic)`
|
||||
- Publish: `await this.mqtt.publish(topic, message, options)`
|
||||
- Handle messages via EventBus `mqttMessage` event
|
||||
|
||||
### Frontend Integration
|
||||
- Optional extension loaded dynamically
|
||||
- Serves static files with compression
|
||||
- WebSocket support for real-time updates
|
||||
- Configurable port and base URL
|
||||
|
||||
### Home Assistant Integration
|
||||
- Optional extension for discovery
|
||||
- Publishes discovery messages to MQTT
|
||||
- Supports entities, sensors, and devices
|
||||
- Configurable discovery topic
|
||||
|
||||
## Critical Compatibility Notes
|
||||
|
||||
1. **Node.js**: Only versions 20, 22, and 24 are supported
|
||||
2. **TypeScript**: Features must be compatible with 5.9.3
|
||||
3. **Zigbee Libraries**: Exact versions are critical - do not suggest upgrades without testing
|
||||
4. **MQTT Protocol**: Uses MQTT 3.1.1 and 5.0 features
|
||||
5. **ES Modules**: Project uses ESM with NodeNext resolution
|
||||
6. **Experimental Decorators**: Required for `@bind` decorator support
|
||||
|
||||
## When in Doubt
|
||||
|
||||
1. **Search for similar patterns** in the existing codebase
|
||||
2. **Check existing extensions** for implementation examples
|
||||
3. **Follow the controller and extension architecture** - don't bypass it
|
||||
4. **Consult the test files** for usage examples
|
||||
5. **Match the exact style** - run `pnpm check` to verify
|
||||
6. **Prioritize consistency** over external best practices
|
||||
7. **Test thoroughly** - this project controls real hardware
|
||||
|
||||
## Resources
|
||||
|
||||
- Repository: https://github.com/Koenkk/zigbee2mqtt
|
||||
- Documentation: https://koenkk.github.io/zigbee2mqtt
|
||||
- License: GPL-3.0
|
||||
- Issue Tracker: https://github.com/Koenkk/zigbee2mqtt/issues
|
||||
+24
-11
@@ -1,13 +1,26 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
target-branch: dev
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/docker"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
target-branch: dev
|
||||
- package-ecosystem: npm
|
||||
versioning-strategy: increase
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
target-branch: dev
|
||||
commit-message:
|
||||
prefix: fix(ignore)
|
||||
groups:
|
||||
minor-patch:
|
||||
applies-to: version-updates
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
- package-ecosystem: docker
|
||||
directory: /docker
|
||||
schedule:
|
||||
interval: weekly
|
||||
target-branch: dev
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
target-branch: dev
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
---
|
||||
description: 'Technology-agnostic blueprint generator for creating comprehensive copilot-instructions.md files that guide GitHub Copilot to produce code consistent with project standards, architecture patterns, and exact technology versions by analyzing existing codebase patterns and avoiding assumptions.'
|
||||
mode: 'agent'
|
||||
---
|
||||
|
||||
# Copilot Instructions Blueprint Generator
|
||||
|
||||
## Configuration Variables
|
||||
${PROJECT_TYPE="Auto-detect|.NET|Java|JavaScript|TypeScript|React|Angular|Python|Multiple|Other"} <!-- Primary technology -->
|
||||
${ARCHITECTURE_STYLE="Layered|Microservices|Monolithic|Domain-Driven|Event-Driven|Serverless|Mixed"} <!-- Architectural approach -->
|
||||
${CODE_QUALITY_FOCUS="Maintainability|Performance|Security|Accessibility|Testability|All"} <!-- Quality priorities -->
|
||||
${DOCUMENTATION_LEVEL="Minimal|Standard|Comprehensive"} <!-- Documentation requirements -->
|
||||
${TESTING_REQUIREMENTS="Unit|Integration|E2E|TDD|BDD|All"} <!-- Testing approach -->
|
||||
${VERSIONING="Semantic|CalVer|Custom"} <!-- Versioning approach -->
|
||||
|
||||
## Generated Prompt
|
||||
|
||||
"Generate a comprehensive copilot-instructions.md file that will guide GitHub Copilot to produce code consistent with our project's standards, architecture, and technology versions. The instructions must be strictly based on actual code patterns in our codebase and avoid making any assumptions. Follow this approach:
|
||||
|
||||
### 1. Core Instruction Structure
|
||||
|
||||
```markdown
|
||||
# GitHub Copilot Instructions
|
||||
|
||||
## Priority Guidelines
|
||||
|
||||
When generating code for this repository:
|
||||
|
||||
1. **Version Compatibility**: Always detect and respect the exact versions of languages, frameworks, and libraries used in this project
|
||||
2. **Context Files**: Prioritize patterns and standards defined in the .github/copilot directory
|
||||
3. **Codebase Patterns**: When context files don't provide specific guidance, scan the codebase for established patterns
|
||||
4. **Architectural Consistency**: Maintain our ${ARCHITECTURE_STYLE} architectural style and established boundaries
|
||||
5. **Code Quality**: Prioritize ${CODE_QUALITY_FOCUS == "All" ? "maintainability, performance, security, accessibility, and testability" : CODE_QUALITY_FOCUS} in all generated code
|
||||
|
||||
## Technology Version Detection
|
||||
|
||||
Before generating code, scan the codebase to identify:
|
||||
|
||||
1. **Language Versions**: Detect the exact versions of programming languages in use
|
||||
- Examine project files, configuration files, and package managers
|
||||
- Look for language-specific version indicators (e.g., <LangVersion> in .NET projects)
|
||||
- Never use language features beyond the detected version
|
||||
|
||||
2. **Framework Versions**: Identify the exact versions of all frameworks
|
||||
- Check package.json, .csproj, pom.xml, requirements.txt, etc.
|
||||
- Respect version constraints when generating code
|
||||
- Never suggest features not available in the detected framework versions
|
||||
|
||||
3. **Library Versions**: Note the exact versions of key libraries and dependencies
|
||||
- Generate code compatible with these specific versions
|
||||
- Never use APIs or features not available in the detected versions
|
||||
|
||||
## Context Files
|
||||
|
||||
Prioritize the following files in .github/copilot directory (if they exist):
|
||||
|
||||
- **architecture.md**: System architecture guidelines
|
||||
- **tech-stack.md**: Technology versions and framework details
|
||||
- **coding-standards.md**: Code style and formatting standards
|
||||
- **folder-structure.md**: Project organization guidelines
|
||||
- **exemplars.md**: Exemplary code patterns to follow
|
||||
|
||||
## Codebase Scanning Instructions
|
||||
|
||||
When context files don't provide specific guidance:
|
||||
|
||||
1. Identify similar files to the one being modified or created
|
||||
2. Analyze patterns for:
|
||||
- Naming conventions
|
||||
- Code organization
|
||||
- Error handling
|
||||
- Logging approaches
|
||||
- Documentation style
|
||||
- Testing patterns
|
||||
|
||||
3. Follow the most consistent patterns found in the codebase
|
||||
4. When conflicting patterns exist, prioritize patterns in newer files or files with higher test coverage
|
||||
5. Never introduce patterns not found in the existing codebase
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
${CODE_QUALITY_FOCUS.includes("Maintainability") || CODE_QUALITY_FOCUS == "All" ? `### Maintainability
|
||||
- Write self-documenting code with clear naming
|
||||
- Follow the naming and organization conventions evident in the codebase
|
||||
- Follow established patterns for consistency
|
||||
- Keep functions focused on single responsibilities
|
||||
- Limit function complexity and length to match existing patterns` : ""}
|
||||
|
||||
${CODE_QUALITY_FOCUS.includes("Performance") || CODE_QUALITY_FOCUS == "All" ? `### Performance
|
||||
- Follow existing patterns for memory and resource management
|
||||
- Match existing patterns for handling computationally expensive operations
|
||||
- Follow established patterns for asynchronous operations
|
||||
- Apply caching consistently with existing patterns
|
||||
- Optimize according to patterns evident in the codebase` : ""}
|
||||
|
||||
${CODE_QUALITY_FOCUS.includes("Security") || CODE_QUALITY_FOCUS == "All" ? `### Security
|
||||
- Follow existing patterns for input validation
|
||||
- Apply the same sanitization techniques used in the codebase
|
||||
- Use parameterized queries matching existing patterns
|
||||
- Follow established authentication and authorization patterns
|
||||
- Handle sensitive data according to existing patterns` : ""}
|
||||
|
||||
${CODE_QUALITY_FOCUS.includes("Accessibility") || CODE_QUALITY_FOCUS == "All" ? `### Accessibility
|
||||
- Follow existing accessibility patterns in the codebase
|
||||
- Match ARIA attribute usage with existing components
|
||||
- Maintain keyboard navigation support consistent with existing code
|
||||
- Follow established patterns for color and contrast
|
||||
- Apply text alternative patterns consistent with the codebase` : ""}
|
||||
|
||||
${CODE_QUALITY_FOCUS.includes("Testability") || CODE_QUALITY_FOCUS == "All" ? `### Testability
|
||||
- Follow established patterns for testable code
|
||||
- Match dependency injection approaches used in the codebase
|
||||
- Apply the same patterns for managing dependencies
|
||||
- Follow established mocking and test double patterns
|
||||
- Match the testing style used in existing tests` : ""}
|
||||
|
||||
## Documentation Requirements
|
||||
|
||||
${DOCUMENTATION_LEVEL == "Minimal" ?
|
||||
`- Match the level and style of comments found in existing code
|
||||
- Document according to patterns observed in the codebase
|
||||
- Follow existing patterns for documenting non-obvious behavior
|
||||
- Use the same format for parameter descriptions as existing code` : ""}
|
||||
|
||||
${DOCUMENTATION_LEVEL == "Standard" ?
|
||||
`- Follow the exact documentation format found in the codebase
|
||||
- Match the XML/JSDoc style and completeness of existing comments
|
||||
- Document parameters, returns, and exceptions in the same style
|
||||
- Follow existing patterns for usage examples
|
||||
- Match class-level documentation style and content` : ""}
|
||||
|
||||
${DOCUMENTATION_LEVEL == "Comprehensive" ?
|
||||
`- Follow the most detailed documentation patterns found in the codebase
|
||||
- Match the style and completeness of the best-documented code
|
||||
- Document exactly as the most thoroughly documented files do
|
||||
- Follow existing patterns for linking documentation
|
||||
- Match the level of detail in explanations of design decisions` : ""}
|
||||
|
||||
## Testing Approach
|
||||
|
||||
${TESTING_REQUIREMENTS.includes("Unit") || TESTING_REQUIREMENTS == "All" ?
|
||||
`### Unit Testing
|
||||
- Match the exact structure and style of existing unit tests
|
||||
- Follow the same naming conventions for test classes and methods
|
||||
- Use the same assertion patterns found in existing tests
|
||||
- Apply the same mocking approach used in the codebase
|
||||
- Follow existing patterns for test isolation` : ""}
|
||||
|
||||
${TESTING_REQUIREMENTS.includes("Integration") || TESTING_REQUIREMENTS == "All" ?
|
||||
`### Integration Testing
|
||||
- Follow the same integration test patterns found in the codebase
|
||||
- Match existing patterns for test data setup and teardown
|
||||
- Use the same approach for testing component interactions
|
||||
- Follow existing patterns for verifying system behavior` : ""}
|
||||
|
||||
${TESTING_REQUIREMENTS.includes("E2E") || TESTING_REQUIREMENTS == "All" ?
|
||||
`### End-to-End Testing
|
||||
- Match the existing E2E test structure and patterns
|
||||
- Follow established patterns for UI testing
|
||||
- Apply the same approach for verifying user journeys` : ""}
|
||||
|
||||
${TESTING_REQUIREMENTS.includes("TDD") || TESTING_REQUIREMENTS == "All" ?
|
||||
`### Test-Driven Development
|
||||
- Follow TDD patterns evident in the codebase
|
||||
- Match the progression of test cases seen in existing code
|
||||
- Apply the same refactoring patterns after tests pass` : ""}
|
||||
|
||||
${TESTING_REQUIREMENTS.includes("BDD") || TESTING_REQUIREMENTS == "All" ?
|
||||
`### Behavior-Driven Development
|
||||
- Match the existing Given-When-Then structure in tests
|
||||
- Follow the same patterns for behavior descriptions
|
||||
- Apply the same level of business focus in test cases` : ""}
|
||||
|
||||
## Technology-Specific Guidelines
|
||||
|
||||
${PROJECT_TYPE == ".NET" || PROJECT_TYPE == "Auto-detect" || PROJECT_TYPE == "Multiple" ? `### .NET Guidelines
|
||||
- Detect and strictly adhere to the specific .NET version in use
|
||||
- Use only C# language features compatible with the detected version
|
||||
- Follow LINQ usage patterns exactly as they appear in the codebase
|
||||
- Match async/await usage patterns from existing code
|
||||
- Apply the same dependency injection approach used in the codebase
|
||||
- Use the same collection types and patterns found in existing code` : ""}
|
||||
|
||||
${PROJECT_TYPE == "Java" || PROJECT_TYPE == "Auto-detect" || PROJECT_TYPE == "Multiple" ? `### Java Guidelines
|
||||
- Detect and adhere to the specific Java version in use
|
||||
- Follow the exact same design patterns found in the codebase
|
||||
- Match exception handling patterns from existing code
|
||||
- Use the same collection types and approaches found in the codebase
|
||||
- Apply the dependency injection patterns evident in existing code` : ""}
|
||||
|
||||
${PROJECT_TYPE == "JavaScript" || PROJECT_TYPE == "TypeScript" || PROJECT_TYPE == "Auto-detect" || PROJECT_TYPE == "Multiple" ? `### JavaScript/TypeScript Guidelines
|
||||
- Detect and adhere to the specific ECMAScript/TypeScript version in use
|
||||
- Follow the same module import/export patterns found in the codebase
|
||||
- Match TypeScript type definitions with existing patterns
|
||||
- Use the same async patterns (promises, async/await) as existing code
|
||||
- Follow error handling patterns from similar files` : ""}
|
||||
|
||||
${PROJECT_TYPE == "React" || PROJECT_TYPE == "Auto-detect" || PROJECT_TYPE == "Multiple" ? `### React Guidelines
|
||||
- Detect and adhere to the specific React version in use
|
||||
- Match component structure patterns from existing components
|
||||
- Follow the same hooks and lifecycle patterns found in the codebase
|
||||
- Apply the same state management approach used in existing components
|
||||
- Match prop typing and validation patterns from existing code` : ""}
|
||||
|
||||
${PROJECT_TYPE == "Angular" || PROJECT_TYPE == "Auto-detect" || PROJECT_TYPE == "Multiple" ? `### Angular Guidelines
|
||||
- Detect and adhere to the specific Angular version in use
|
||||
- Follow the same component and module patterns found in the codebase
|
||||
- Match decorator usage exactly as seen in existing code
|
||||
- Apply the same RxJS patterns found in the codebase
|
||||
- Follow existing patterns for component communication` : ""}
|
||||
|
||||
${PROJECT_TYPE == "Python" || PROJECT_TYPE == "Auto-detect" || PROJECT_TYPE == "Multiple" ? `### Python Guidelines
|
||||
- Detect and adhere to the specific Python version in use
|
||||
- Follow the same import organization found in existing modules
|
||||
- Match type hinting approaches if used in the codebase
|
||||
- Apply the same error handling patterns found in existing code
|
||||
- Follow the same module organization patterns` : ""}
|
||||
|
||||
## Version Control Guidelines
|
||||
|
||||
${VERSIONING == "Semantic" ?
|
||||
`- Follow Semantic Versioning patterns as applied in the codebase
|
||||
- Match existing patterns for documenting breaking changes
|
||||
- Follow the same approach for deprecation notices` : ""}
|
||||
|
||||
${VERSIONING == "CalVer" ?
|
||||
`- Follow Calendar Versioning patterns as applied in the codebase
|
||||
- Match existing patterns for documenting changes
|
||||
- Follow the same approach for highlighting significant changes` : ""}
|
||||
|
||||
${VERSIONING == "Custom" ?
|
||||
`- Match the exact versioning pattern observed in the codebase
|
||||
- Follow the same changelog format used in existing documentation
|
||||
- Apply the same tagging conventions used in the project` : ""}
|
||||
|
||||
## General Best Practices
|
||||
|
||||
- Follow naming conventions exactly as they appear in existing code
|
||||
- Match code organization patterns from similar files
|
||||
- Apply error handling consistent with existing patterns
|
||||
- Follow the same approach to testing as seen in the codebase
|
||||
- Match logging patterns from existing code
|
||||
- Use the same approach to configuration as seen in the codebase
|
||||
|
||||
## Project-Specific Guidance
|
||||
|
||||
- Scan the codebase thoroughly before generating any code
|
||||
- Respect existing architectural boundaries without exception
|
||||
- Match the style and patterns of surrounding code
|
||||
- When in doubt, prioritize consistency with existing code over external best practices
|
||||
```
|
||||
|
||||
### 2. Codebase Analysis Instructions
|
||||
|
||||
To create the copilot-instructions.md file, first analyze the codebase to:
|
||||
|
||||
1. **Identify Exact Technology Versions**:
|
||||
- ${PROJECT_TYPE == "Auto-detect" ? "Detect all programming languages, frameworks, and libraries by scanning file extensions and configuration files" : `Focus on ${PROJECT_TYPE} technologies`}
|
||||
- Extract precise version information from project files, package.json, .csproj, etc.
|
||||
- Document version constraints and compatibility requirements
|
||||
|
||||
2. **Understand Architecture**:
|
||||
- Analyze folder structure and module organization
|
||||
- Identify clear layer boundaries and component relationships
|
||||
- Document communication patterns between components
|
||||
|
||||
3. **Document Code Patterns**:
|
||||
- Catalog naming conventions for different code elements
|
||||
- Note documentation styles and completeness
|
||||
- Document error handling patterns
|
||||
- Map testing approaches and coverage
|
||||
|
||||
4. **Note Quality Standards**:
|
||||
- Identify performance optimization techniques actually used
|
||||
- Document security practices implemented in the code
|
||||
- Note accessibility features present (if applicable)
|
||||
- Document code quality patterns evident in the codebase
|
||||
|
||||
### 3. Implementation Notes
|
||||
|
||||
The final copilot-instructions.md should:
|
||||
- Be placed in the .github directory
|
||||
- Reference only patterns and standards that exist in the codebase
|
||||
- Include explicit version compatibility requirements
|
||||
- Avoid prescribing any practices not evident in the code
|
||||
- Provide concrete examples from the codebase
|
||||
- Be comprehensive yet concise enough for Copilot to effectively use
|
||||
|
||||
Important: Only include guidance based on patterns actually observed in the codebase. Explicitly instruct Copilot to prioritize consistency with existing code over external best practices or newer language features.
|
||||
"
|
||||
|
||||
## Expected Output
|
||||
|
||||
A comprehensive copilot-instructions.md file that will guide GitHub Copilot to produce code that is perfectly compatible with your existing technology versions and follows your established patterns and architecture.
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
description: "Prompt for generating an AGENTS.md file for a repository"
|
||||
mode: "agent"
|
||||
---
|
||||
|
||||
# Create high‑quality AGENTS.md file
|
||||
|
||||
You are a code agent. Your task is to create a complete, accurate AGENTS.md at the root of this repository that follows the public guidance at https://agents.md/.
|
||||
|
||||
AGENTS.md is an open format designed to provide coding agents with the context and instructions they need to work effectively on a project.
|
||||
|
||||
## What is AGENTS.md?
|
||||
|
||||
AGENTS.md is a Markdown file that serves as a "README for agents" - a dedicated, predictable place to provide context and instructions to help AI coding agents work on your project. It complements README.md by containing detailed technical context that coding agents need but might clutter a human-focused README.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Agent-focused**: Contains detailed technical instructions for automated tools
|
||||
- **Complements README.md**: Doesn't replace human documentation but adds agent-specific context
|
||||
- **Standardized location**: Placed at repository root (or subproject roots for monorepos)
|
||||
- **Open format**: Uses standard Markdown with flexible structure
|
||||
- **Ecosystem compatibility**: Works across 20+ different AI coding tools and agents
|
||||
|
||||
## File Structure and Content Guidelines
|
||||
|
||||
### 1. Required Setup
|
||||
|
||||
- Create the file as `AGENTS.md` in the repository root
|
||||
- Use standard Markdown formatting
|
||||
- No required fields - flexible structure based on project needs
|
||||
|
||||
### 2. Essential Sections to Include
|
||||
|
||||
#### Project Overview
|
||||
|
||||
- Brief description of what the project does
|
||||
- Architecture overview if complex
|
||||
- Key technologies and frameworks used
|
||||
|
||||
#### Setup Commands
|
||||
|
||||
- Installation instructions
|
||||
- Environment setup steps
|
||||
- Dependency management commands
|
||||
- Database setup if applicable
|
||||
|
||||
#### Development Workflow
|
||||
|
||||
- How to start development server
|
||||
- Build commands
|
||||
- Watch/hot-reload setup
|
||||
- Package manager specifics (npm, pnpm, yarn, etc.)
|
||||
|
||||
#### Testing Instructions
|
||||
|
||||
- How to run tests (unit, integration, e2e)
|
||||
- Test file locations and naming conventions
|
||||
- Coverage requirements
|
||||
- Specific test patterns or frameworks used
|
||||
- How to run subset of tests or focus on specific areas
|
||||
|
||||
#### Code Style Guidelines
|
||||
|
||||
- Language-specific conventions
|
||||
- Linting and formatting rules
|
||||
- File organization patterns
|
||||
- Naming conventions
|
||||
- Import/export patterns
|
||||
|
||||
#### Build and Deployment
|
||||
|
||||
- Build commands and outputs
|
||||
- Environment configurations
|
||||
- Deployment steps and requirements
|
||||
- CI/CD pipeline information
|
||||
|
||||
### 3. Optional but Recommended Sections
|
||||
|
||||
#### Security Considerations
|
||||
|
||||
- Security testing requirements
|
||||
- Secrets management
|
||||
- Authentication patterns
|
||||
- Permission models
|
||||
|
||||
#### Monorepo Instructions (if applicable)
|
||||
|
||||
- How to work with multiple packages
|
||||
- Cross-package dependencies
|
||||
- Selective building/testing
|
||||
- Package-specific commands
|
||||
|
||||
#### Pull Request Guidelines
|
||||
|
||||
- Title format requirements
|
||||
- Required checks before submission
|
||||
- Review process
|
||||
- Commit message conventions
|
||||
|
||||
#### Debugging and Troubleshooting
|
||||
|
||||
- Common issues and solutions
|
||||
- Logging patterns
|
||||
- Debug configuration
|
||||
- Performance considerations
|
||||
|
||||
## Example Template
|
||||
|
||||
Use this as a starting template and customize based on the specific project:
|
||||
|
||||
```markdown
|
||||
# AGENTS.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
[Brief description of the project, its purpose, and key technologies]
|
||||
|
||||
## Setup Commands
|
||||
|
||||
- Install dependencies: `[package manager] install`
|
||||
- Start development server: `[command]`
|
||||
- Build for production: `[command]`
|
||||
|
||||
## Development Workflow
|
||||
|
||||
- [Development server startup instructions]
|
||||
- [Hot reload/watch mode information]
|
||||
- [Environment variable setup]
|
||||
|
||||
## Testing Instructions
|
||||
|
||||
- Run all tests: `[command]`
|
||||
- Run unit tests: `[command]`
|
||||
- Run integration tests: `[command]`
|
||||
- Test coverage: `[command]`
|
||||
- [Specific testing patterns or requirements]
|
||||
|
||||
## Code Style
|
||||
|
||||
- [Language and framework conventions]
|
||||
- [Linting rules and commands]
|
||||
- [Formatting requirements]
|
||||
- [File organization patterns]
|
||||
|
||||
## Build and Deployment
|
||||
|
||||
- [Build process details]
|
||||
- [Output directories]
|
||||
- [Environment-specific builds]
|
||||
- [Deployment commands]
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
- Title format: [component] Brief description
|
||||
- Required checks: `[lint command]`, `[test command]`
|
||||
- [Review requirements]
|
||||
|
||||
## Additional Notes
|
||||
|
||||
- [Any project-specific context]
|
||||
- [Common gotchas or troubleshooting tips]
|
||||
- [Performance considerations]
|
||||
```
|
||||
|
||||
## Working Example from agents.md
|
||||
|
||||
Here's a real example from the agents.md website:
|
||||
|
||||
```markdown
|
||||
# Sample AGENTS.md file
|
||||
|
||||
## Dev environment tips
|
||||
|
||||
- Use `pnpm dlx turbo run where <project_name>` to jump to a package instead of scanning with `ls`.
|
||||
- Run `pnpm install --filter <project_name>` to add the package to your workspace so Vite, ESLint, and TypeScript can see it.
|
||||
- Use `pnpm create vite@latest <project_name> -- --template react-ts` to spin up a new React + Vite package with TypeScript checks ready.
|
||||
- Check the name field inside each package's package.json to confirm the right name—skip the top-level one.
|
||||
|
||||
## Testing instructions
|
||||
|
||||
- Find the CI plan in the .github/workflows folder.
|
||||
- Run `pnpm turbo run test --filter <project_name>` to run every check defined for that package.
|
||||
- From the package root you can just call `pnpm test`. The commit should pass all tests before you merge.
|
||||
- To focus on one step, add the Vitest pattern: `pnpm vitest run -t "<test name>"`.
|
||||
- Fix any test or type errors until the whole suite is green.
|
||||
- After moving files or changing imports, run `pnpm lint --filter <project_name>` to be sure ESLint and TypeScript rules still pass.
|
||||
- Add or update tests for the code you change, even if nobody asked.
|
||||
|
||||
## PR instructions
|
||||
|
||||
- Title format: [<project_name>] <Title>
|
||||
- Always run `pnpm lint` and `pnpm test` before committing.
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Analyze the project structure** to understand:
|
||||
|
||||
- Programming languages and frameworks used
|
||||
- Package managers and build tools
|
||||
- Testing frameworks
|
||||
- Project architecture (monorepo, single package, etc.)
|
||||
|
||||
2. **Identify key workflows** by examining:
|
||||
|
||||
- package.json scripts
|
||||
- Makefile or other build files
|
||||
- CI/CD configuration files
|
||||
- Documentation files
|
||||
|
||||
3. **Create comprehensive sections** covering:
|
||||
|
||||
- All essential setup and development commands
|
||||
- Testing strategies and commands
|
||||
- Code style and conventions
|
||||
- Build and deployment processes
|
||||
|
||||
4. **Include specific, actionable commands** that agents can execute directly
|
||||
|
||||
5. **Test the instructions** by ensuring all commands work as documented
|
||||
|
||||
6. **Keep it focused** on what agents need to know, not general project information
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Be specific**: Include exact commands, not vague descriptions
|
||||
- **Use code blocks**: Wrap commands in backticks for clarity
|
||||
- **Include context**: Explain why certain steps are needed
|
||||
- **Stay current**: Update as the project evolves
|
||||
- **Test commands**: Ensure all listed commands actually work
|
||||
- **Consider nested files**: For monorepos, create AGENTS.md files in subprojects as needed
|
||||
|
||||
## Monorepo Considerations
|
||||
|
||||
For large monorepos:
|
||||
|
||||
- Place a main AGENTS.md at the repository root
|
||||
- Create additional AGENTS.md files in subproject directories
|
||||
- The closest AGENTS.md file takes precedence for any given location
|
||||
- Include navigation tips between packages/projects
|
||||
|
||||
## Final Notes
|
||||
|
||||
- AGENTS.md works with 20+ AI coding tools including Cursor, Aider, Gemini CLI, and many others
|
||||
- The format is intentionally flexible - adapt it to your project's needs
|
||||
- Focus on actionable instructions that help agents understand and work with your codebase
|
||||
- This is living documentation - update it as your project evolves
|
||||
|
||||
When creating the AGENTS.md file, prioritize clarity, completeness, and actionability. The goal is to give any coding agent enough context to effectively contribute to the project without requiring additional human guidance.
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
mode: 'agent'
|
||||
description: 'Create a new specification file for the solution, optimized for Generative AI consumption.'
|
||||
tools: ['changes', 'codebase', 'editFiles', 'extensions', 'fetch', 'githubRepo', 'openSimpleBrowser', 'problems', 'runTasks', 'search', 'searchResults', 'terminalLastCommand', 'terminalSelection', 'testFailure', 'usages', 'vscodeAPI']
|
||||
---
|
||||
# Create Specification
|
||||
|
||||
Your goal is to create a new specification file for `${input:SpecPurpose}`.
|
||||
|
||||
The specification file must define the requirements, constraints, and interfaces for the solution components in a manner that is clear, unambiguous, and structured for effective use by Generative AIs. Follow established documentation standards and ensure the content is machine-readable and self-contained.
|
||||
|
||||
## Best Practices for AI-Ready Specifications
|
||||
|
||||
- Use precise, explicit, and unambiguous language.
|
||||
- Clearly distinguish between requirements, constraints, and recommendations.
|
||||
- Use structured formatting (headings, lists, tables) for easy parsing.
|
||||
- Avoid idioms, metaphors, or context-dependent references.
|
||||
- Define all acronyms and domain-specific terms.
|
||||
- Include examples and edge cases where applicable.
|
||||
- Ensure the document is self-contained and does not rely on external context.
|
||||
|
||||
The specification should be saved in the [/spec/](/spec/) directory and named according to the following convention: `spec-[a-z0-9-]+.md`, where the name should be descriptive of the specification's content and starting with the highlevel purpose, which is one of [schema, tool, data, infrastructure, process, architecture, or design].
|
||||
|
||||
The specification file must be formatted in well formed Markdown.
|
||||
|
||||
Specification files must follow the template below, ensuring that all sections are filled out appropriately. The front matter for the markdown should be structured correctly as per the example following:
|
||||
|
||||
```md
|
||||
---
|
||||
title: [Concise Title Describing the Specification's Focus]
|
||||
version: [Optional: e.g., 1.0, Date]
|
||||
date_created: [YYYY-MM-DD]
|
||||
last_updated: [Optional: YYYY-MM-DD]
|
||||
owner: [Optional: Team/Individual responsible for this spec]
|
||||
tags: [Optional: List of relevant tags or categories, e.g., `infrastructure`, `process`, `design`, `app` etc]
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
[A short concise introduction to the specification and the goal it is intended to achieve.]
|
||||
|
||||
## 1. Purpose & Scope
|
||||
|
||||
[Provide a clear, concise description of the specification's purpose and the scope of its application. State the intended audience and any assumptions.]
|
||||
|
||||
## 2. Definitions
|
||||
|
||||
[List and define all acronyms, abbreviations, and domain-specific terms used in this specification.]
|
||||
|
||||
## 3. Requirements, Constraints & Guidelines
|
||||
|
||||
[Explicitly list all requirements, constraints, rules, and guidelines. Use bullet points or tables for clarity.]
|
||||
|
||||
- **REQ-001**: Requirement 1
|
||||
- **SEC-001**: Security Requirement 1
|
||||
- **[3 LETTERS]-001**: Other Requirement 1
|
||||
- **CON-001**: Constraint 1
|
||||
- **GUD-001**: Guideline 1
|
||||
- **PAT-001**: Pattern to follow 1
|
||||
|
||||
## 4. Interfaces & Data Contracts
|
||||
|
||||
[Describe the interfaces, APIs, data contracts, or integration points. Use tables or code blocks for schemas and examples.]
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
[Define clear, testable acceptance criteria for each requirement using Given-When-Then format where appropriate.]
|
||||
|
||||
- **AC-001**: Given [context], When [action], Then [expected outcome]
|
||||
- **AC-002**: The system shall [specific behavior] when [condition]
|
||||
- **AC-003**: [Additional acceptance criteria as needed]
|
||||
|
||||
## 6. Test Automation Strategy
|
||||
|
||||
[Define the testing approach, frameworks, and automation requirements.]
|
||||
|
||||
- **Test Levels**: Unit, Integration, End-to-End
|
||||
- **Frameworks**: MSTest, FluentAssertions, Moq (for .NET applications)
|
||||
- **Test Data Management**: [approach for test data creation and cleanup]
|
||||
- **CI/CD Integration**: [automated testing in GitHub Actions pipelines]
|
||||
- **Coverage Requirements**: [minimum code coverage thresholds]
|
||||
- **Performance Testing**: [approach for load and performance testing]
|
||||
|
||||
## 7. Rationale & Context
|
||||
|
||||
[Explain the reasoning behind the requirements, constraints, and guidelines. Provide context for design decisions.]
|
||||
|
||||
## 8. Dependencies & External Integrations
|
||||
|
||||
[Define the external systems, services, and architectural dependencies required for this specification. Focus on **what** is needed rather than **how** it's implemented. Avoid specific package or library versions unless they represent architectural constraints.]
|
||||
|
||||
### External Systems
|
||||
- **EXT-001**: [External system name] - [Purpose and integration type]
|
||||
|
||||
### Third-Party Services
|
||||
- **SVC-001**: [Service name] - [Required capabilities and SLA requirements]
|
||||
|
||||
### Infrastructure Dependencies
|
||||
- **INF-001**: [Infrastructure component] - [Requirements and constraints]
|
||||
|
||||
### Data Dependencies
|
||||
- **DAT-001**: [External data source] - [Format, frequency, and access requirements]
|
||||
|
||||
### Technology Platform Dependencies
|
||||
- **PLT-001**: [Platform/runtime requirement] - [Version constraints and rationale]
|
||||
|
||||
### Compliance Dependencies
|
||||
- **COM-001**: [Regulatory or compliance requirement] - [Impact on implementation]
|
||||
|
||||
**Note**: This section should focus on architectural and business dependencies, not specific package implementations. For example, specify "OAuth 2.0 authentication library" rather than "Microsoft.AspNetCore.Authentication.JwtBearer v6.0.1".
|
||||
|
||||
## 9. Examples & Edge Cases
|
||||
|
||||
```code
|
||||
// Code snippet or data example demonstrating the correct application of the guidelines, including edge cases
|
||||
```
|
||||
|
||||
## 10. Validation Criteria
|
||||
|
||||
[List the criteria or tests that must be satisfied for compliance with this specification.]
|
||||
|
||||
## 11. Related Specifications / Further Reading
|
||||
|
||||
[Link to related spec 1]
|
||||
[Link to relevant external documentation]
|
||||
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
mode: 'agent'
|
||||
description: 'Review and refactor code in your project according to defined instructions'
|
||||
---
|
||||
|
||||
## Role
|
||||
|
||||
You're a senior expert software engineer with extensive experience in maintaining projects over a long time and ensuring clean code and best practices.
|
||||
|
||||
## Task
|
||||
|
||||
1. Take a deep breath, and review all coding guidelines instructions in `.github/instructions/*.md` and `.github/copilot-instructions.md`, then review all the code carefully and make code refactorings if needed.
|
||||
2. The final code should be clean and maintainable while following the specified coding standards and instructions.
|
||||
3. Do not split up the code, keep the existing files intact.
|
||||
4. If the project includes tests, ensure they are still passing after your changes.
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
mode: 'agent'
|
||||
description: 'Update an existing specification file for the solution, optimized for Generative AI consumption based on new requirements or updates to any existing code.'
|
||||
tools: ['changes', 'codebase', 'editFiles', 'extensions', 'fetch', 'githubRepo', 'openSimpleBrowser', 'problems', 'runTasks', 'search', 'searchResults', 'terminalLastCommand', 'terminalSelection', 'testFailure', 'usages', 'vscodeAPI']
|
||||
---
|
||||
# Update Specification
|
||||
|
||||
Your goal is to update the existing specification file `${file}` based on new requirements or updates to any existing code.
|
||||
|
||||
The specification file must define the requirements, constraints, and interfaces for the solution components in a manner that is clear, unambiguous, and structured for effective use by Generative AIs. Follow established documentation standards and ensure the content is machine-readable and self-contained.
|
||||
|
||||
## Best Practices for AI-Ready Specifications
|
||||
|
||||
- Use precise, explicit, and unambiguous language.
|
||||
- Clearly distinguish between requirements, constraints, and recommendations.
|
||||
- Use structured formatting (headings, lists, tables) for easy parsing.
|
||||
- Avoid idioms, metaphors, or context-dependent references.
|
||||
- Define all acronyms and domain-specific terms.
|
||||
- Include examples and edge cases where applicable.
|
||||
- Ensure the document is self-contained and does not rely on external context.
|
||||
|
||||
The specification should be saved in the [/spec/](/spec/) directory and named according to the following convention: `[a-z0-9-]+.md`, where the name should be descriptive of the specification's content and starting with the highlevel purpose, which is one of [schema, tool, data, infrastructure, process, architecture, or design].
|
||||
|
||||
The specification file must be formatted in well formed Markdown.
|
||||
|
||||
Specification files must follow the template below, ensuring that all sections are filled out appropriately. The front matter for the markdown should be structured correctly as per the example following:
|
||||
|
||||
```md
|
||||
---
|
||||
title: [Concise Title Describing the Specification's Focus]
|
||||
version: [Optional: e.g., 1.0, Date]
|
||||
date_created: [YYYY-MM-DD]
|
||||
last_updated: [Optional: YYYY-MM-DD]
|
||||
owner: [Optional: Team/Individual responsible for this spec]
|
||||
tags: [Optional: List of relevant tags or categories, e.g., `infrastructure`, `process`, `design`, `app` etc]
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
[A short concise introduction to the specification and the goal it is intended to achieve.]
|
||||
|
||||
## 1. Purpose & Scope
|
||||
|
||||
[Provide a clear, concise description of the specification's purpose and the scope of its application. State the intended audience and any assumptions.]
|
||||
|
||||
## 2. Definitions
|
||||
|
||||
[List and define all acronyms, abbreviations, and domain-specific terms used in this specification.]
|
||||
|
||||
## 3. Requirements, Constraints & Guidelines
|
||||
|
||||
[Explicitly list all requirements, constraints, rules, and guidelines. Use bullet points or tables for clarity.]
|
||||
|
||||
- **REQ-001**: Requirement 1
|
||||
- **SEC-001**: Security Requirement 1
|
||||
- **[3 LETTERS]-001**: Other Requirement 1
|
||||
- **CON-001**: Constraint 1
|
||||
- **GUD-001**: Guideline 1
|
||||
- **PAT-001**: Pattern to follow 1
|
||||
|
||||
## 4. Interfaces & Data Contracts
|
||||
|
||||
[Describe the interfaces, APIs, data contracts, or integration points. Use tables or code blocks for schemas and examples.]
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
[Define clear, testable acceptance criteria for each requirement using Given-When-Then format where appropriate.]
|
||||
|
||||
- **AC-001**: Given [context], When [action], Then [expected outcome]
|
||||
- **AC-002**: The system shall [specific behavior] when [condition]
|
||||
- **AC-003**: [Additional acceptance criteria as needed]
|
||||
|
||||
## 6. Test Automation Strategy
|
||||
|
||||
[Define the testing approach, frameworks, and automation requirements.]
|
||||
|
||||
- **Test Levels**: Unit, Integration, End-to-End
|
||||
- **Frameworks**: MSTest, FluentAssertions, Moq (for .NET applications)
|
||||
- **Test Data Management**: [approach for test data creation and cleanup]
|
||||
- **CI/CD Integration**: [automated testing in GitHub Actions pipelines]
|
||||
- **Coverage Requirements**: [minimum code coverage thresholds]
|
||||
- **Performance Testing**: [approach for load and performance testing]
|
||||
|
||||
## 7. Rationale & Context
|
||||
|
||||
[Explain the reasoning behind the requirements, constraints, and guidelines. Provide context for design decisions.]
|
||||
|
||||
## 8. Dependencies & External Integrations
|
||||
|
||||
[Define the external systems, services, and architectural dependencies required for this specification. Focus on **what** is needed rather than **how** it's implemented. Avoid specific package or library versions unless they represent architectural constraints.]
|
||||
|
||||
### External Systems
|
||||
- **EXT-001**: [External system name] - [Purpose and integration type]
|
||||
|
||||
### Third-Party Services
|
||||
- **SVC-001**: [Service name] - [Required capabilities and SLA requirements]
|
||||
|
||||
### Infrastructure Dependencies
|
||||
- **INF-001**: [Infrastructure component] - [Requirements and constraints]
|
||||
|
||||
### Data Dependencies
|
||||
- **DAT-001**: [External data source] - [Format, frequency, and access requirements]
|
||||
|
||||
### Technology Platform Dependencies
|
||||
- **PLT-001**: [Platform/runtime requirement] - [Version constraints and rationale]
|
||||
|
||||
### Compliance Dependencies
|
||||
- **COM-001**: [Regulatory or compliance requirement] - [Impact on implementation]
|
||||
|
||||
**Note**: This section should focus on architectural and business dependencies, not specific package implementations. For example, specify "OAuth 2.0 authentication library" rather than "Microsoft.AspNetCore.Authentication.JwtBearer v6.0.1".
|
||||
|
||||
## 9. Examples & Edge Cases
|
||||
|
||||
```code
|
||||
// Code snippet or data example demonstrating the correct application of the guidelines, including edge cases
|
||||
```
|
||||
|
||||
## 10. Validation Criteria
|
||||
|
||||
[List the criteria or tests that must be satisfied for compliance with this specification.]
|
||||
|
||||
## 11. Related Specifications / Further Reading
|
||||
|
||||
[Link to related spec 1]
|
||||
[Link to relevant external documentation]
|
||||
|
||||
```
|
||||
+232
-100
@@ -1,108 +1,240 @@
|
||||
name: ci
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- deps/*
|
||||
tags:
|
||||
- '**'
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: https://registry.npmjs.org/
|
||||
cache: npm
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Test
|
||||
run: npm run test-with-coverage
|
||||
- name: Lint
|
||||
run: npm run eslint
|
||||
- name: Docker login
|
||||
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
|
||||
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
|
||||
- name: Docker setup - QEMU
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
uses: docker/setup-qemu-action@v2
|
||||
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@v2
|
||||
with:
|
||||
version: latest
|
||||
- name: Docker build dev
|
||||
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: Docker build release
|
||||
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" \
|
||||
.
|
||||
- name: Publish to npm
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
run: npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN }}
|
||||
- name: Trigger zigbee2mqtt/hassio-zigbee2mqtt build
|
||||
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
|
||||
run: |
|
||||
curl \
|
||||
-X POST \
|
||||
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
https://api.github.com/repos/zigbee2mqtt/hassio-zigbee2mqtt/actions/workflows/ci.yml/dispatches \
|
||||
-d '{"ref":"master","inputs":{}}'
|
||||
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@v6
|
||||
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 }}
|
||||
|
||||
tests:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node: [16, 18, 20]
|
||||
runs-on: ${{ matrix.os }}
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
registry-url: https://registry.npmjs.org/
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
# --ignore-scripts prevents the serialport build which often fails on Windows
|
||||
run: npm ci --ignore-scripts
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Test
|
||||
run: npm run test-with-coverage
|
||||
- uses: actions/checkout@v6
|
||||
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@v6
|
||||
with:
|
||||
node-version: 24
|
||||
registry-url: https://registry.npmjs.org/
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Check
|
||||
run: pnpm run check
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Test
|
||||
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'
|
||||
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'
|
||||
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 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: |
|
||||
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'
|
||||
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: 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: |
|
||||
curl \
|
||||
-X POST \
|
||||
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
|
||||
-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: |
|
||||
TAG=${GITHUB_REF#refs/*/}
|
||||
echo "Triggering with tag '$TAG'"
|
||||
curl \
|
||||
-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/zigbee2mqtt/hassio-zigbee2mqtt/dispatches \
|
||||
--data "{\"event_type\": \"release\", \"client_payload\": { \"version\": \"$TAG-1\"}}"
|
||||
|
||||
- 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 -L \
|
||||
-X POST \
|
||||
-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: |
|
||||
TAG=${GITHUB_REF#refs/*/}
|
||||
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git fetch --unshallow
|
||||
git fetch origin
|
||||
git checkout master
|
||||
git merge --ff-only origin/dev
|
||||
git push origin master
|
||||
git checkout dev
|
||||
jq --indent 4 ".version = \"$TAG-dev\"" package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
git add -A
|
||||
git commit -m "chore: promote to dev"
|
||||
git push origin dev
|
||||
|
||||
tests:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node: [20, 22, 24]
|
||||
runs-on: ${{ matrix.os }}
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
# --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: pnpm run build
|
||||
|
||||
- name: Test
|
||||
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@v3
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
@@ -0,0 +1,15 @@
|
||||
name: Dependency review
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: actions/checkout@v6
|
||||
- name: 'Dependency review'
|
||||
uses: actions/dependency-review-action@v4
|
||||
@@ -1,14 +0,0 @@
|
||||
name: deps-review
|
||||
on: [pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: actions/checkout@v3
|
||||
- name: 'Dependency review'
|
||||
uses: actions/dependency-review-action@v3
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Fail PR to master
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
fail-pr-to-master:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Fail PR to master
|
||||
run: |
|
||||
if [[ "${{ github.event.pull_request.title }}" == "chore(dev): release"* ]]; then
|
||||
echo "PR title starts with 'chore(dev): release', allowing PR"
|
||||
else
|
||||
echo "Pull requests to the master branch are not allowed, target dev branch"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,34 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
name: GHCR cleanup
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Delete untagged images
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const response = await github.request("GET /${{ env.OWNER }}/packages/container/${{ env.PACKAGE_NAME }}/versions",
|
||||
{ per_page: ${{ env.PER_PAGE }}
|
||||
});
|
||||
for(version of response.data) {
|
||||
if (version.metadata.container.tags.length == 0) {
|
||||
try {
|
||||
console.log("delete " + version.id)
|
||||
const deleteResponse = await github.request("DELETE /${{ env.OWNER }}/packages/container/${{ env.PACKAGE_NAME }}/versions/" + version.id, { });
|
||||
console.log("status " + deleteResponse.status)
|
||||
} catch (e) {
|
||||
console.log("failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
env:
|
||||
OWNER: user
|
||||
PACKAGE_NAME: zigbee2mqtt
|
||||
PER_PAGE: 2000
|
||||
@@ -1,34 +0,0 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
name: ghcr-cleanup
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Delete untagged images
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const response = await github.request("GET /${{ env.OWNER }}/packages/container/${{ env.PACKAGE_NAME }}/versions",
|
||||
{ per_page: ${{ env.PER_PAGE }}
|
||||
});
|
||||
for(version of response.data) {
|
||||
if (version.metadata.container.tags.length == 0) {
|
||||
try {
|
||||
console.log("delete " + version.id)
|
||||
const deleteResponse = await github.request("DELETE /${{ env.OWNER }}/packages/container/${{ env.PACKAGE_NAME }}/versions/" + version.id, { });
|
||||
console.log("status " + deleteResponse.status)
|
||||
} catch (e) {
|
||||
console.log("failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
env:
|
||||
OWNER: user
|
||||
PACKAGE_NAME: zigbee2mqtt
|
||||
PER_PAGE: 2000
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Issue Bot
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
contains(github.event.issue.labels.*.name, 'new device support') ||
|
||||
contains(github.event.issue.labels.*.name, 'external converter') ||
|
||||
startsWith(github.event.issue.title, '[New device support]') ||
|
||||
startsWith(github.event.issue.title, '[External Converter]')
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: |
|
||||
scripts
|
||||
path: z2m
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: Koenkk/zigbee-herdsman-converters
|
||||
ref: master
|
||||
fetch-depth: 1
|
||||
path: zhc
|
||||
|
||||
- name: Comment on new device support/external converter issue
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const {newDeviceSupport} = await import("${{ github.workspace }}/z2m/scripts/issueBot.mjs")
|
||||
await newDeviceSupport(github, core, context, "${{ github.workspace }}/zhc")
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Merge master to dev
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
merge-master-to-dev:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: devmasx/merge-branch@master
|
||||
with:
|
||||
type: now
|
||||
head_to_merge: master
|
||||
target_branch: dev
|
||||
message: 'chore: merge master to dev'
|
||||
github_token: ${{ secrets.GH_TOKEN }}
|
||||
@@ -1,19 +0,0 @@
|
||||
name: merge-master-to-dev
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
merge_master_to_dev:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: devmasx/merge-branch@master
|
||||
with:
|
||||
type: now
|
||||
head_to_merge: master
|
||||
target_branch: dev
|
||||
message: "chore: merge master to dev"
|
||||
github_token: ${{ secrets.GH_TOKEN }}
|
||||
@@ -0,0 +1,74 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
name: Release Please
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
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@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
target-branch: dev
|
||||
token: ${{secrets.GH_TOKEN}}
|
||||
|
||||
# Checkout repos
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: koenkk/zigbee2mqtt
|
||||
path: ./z2m
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: koenkk/zigbee2mqtt
|
||||
path: ./z2m-master
|
||||
ref: master
|
||||
|
||||
- name: Restore cache commit-user-lookup.json
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: z2m/scripts/commit-user-lookup.json
|
||||
key: commit-user-lookup-dummy
|
||||
restore-keys: |
|
||||
commit-user-lookup-
|
||||
- name: Generate changelog
|
||||
run: |
|
||||
MASTER_Z2M_VERSION=$(cat z2m-master/package.json | jq -r '.version')
|
||||
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
|
||||
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
|
||||
run: |
|
||||
gh gist edit bfd4c3d1725a2cccacc11d6ba51008ba -a changelog.md
|
||||
env:
|
||||
GH_TOKEN: ${{secrets.GH_TOKEN}}
|
||||
- name: Save cache commit-user-lookup.json
|
||||
uses: actions/cache/save@v5
|
||||
if: always()
|
||||
with:
|
||||
path: z2m/scripts/commit-user-lookup.json
|
||||
key: commit-user-lookup-${{ hashFiles('z2m/scripts/commit-user-lookup.json') }}
|
||||
@@ -1,112 +0,0 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
name: release-please
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
version: "${{steps.release.outputs.major}}.${{steps.release.outputs.minor}}.${{steps.release.outputs.patch}}"
|
||||
steps:
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: google-github-actions/release-please-action@v3
|
||||
id: release
|
||||
with:
|
||||
release-type: node
|
||||
package-name: release-please-action
|
||||
default-branch: dev
|
||||
token: ${{secrets.GH_TOKEN}}
|
||||
|
||||
# Checkout repos
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
repository: koenkk/zigbee2mqtt
|
||||
path: ./z2m
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
repository: koenkk/zigbee2mqtt
|
||||
path: ./z2m-master
|
||||
ref: master
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
repository: koenkk/zigbee2mqtt
|
||||
path: ./z2m-changelog
|
||||
ref: release-please--branches--dev--components--release-please-action
|
||||
|
||||
- name: Update latest-dev tag
|
||||
run: |
|
||||
cd z2m
|
||||
git push --delete origin latest-dev
|
||||
git tag latest-dev
|
||||
git push origin latest-dev
|
||||
- name: Update latest-dev release changelog
|
||||
run: |
|
||||
PR=$(echo '${{ steps.release.outputs.pr }}' | jq -r '.number')
|
||||
MASTER_Z2M_VERSION=$(cat z2m-master/package.json | jq -r '.version')
|
||||
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"')
|
||||
cp z2m-changelog/CHANGELOG.md z2m
|
||||
cd z2m
|
||||
npm ci
|
||||
node scripts/generateChangelog.js $MASTER_Z2M_VERSION $MASTER_ZHC_VERSION $MASTER_ZH_VERSION >> ../changelog.txt
|
||||
env:
|
||||
GH_TOKEN: ${{secrets.GH_TOKEN}}
|
||||
- uses: ncipollo/release-action@v1
|
||||
with:
|
||||
bodyFile: changelog.txt
|
||||
prerelease: true
|
||||
name: latest-dev
|
||||
allowUpdates: true
|
||||
tag: latest-dev
|
||||
- name: Upload changelog
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: changelog
|
||||
path: changelog.txt
|
||||
|
||||
create_release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-please]
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: dev
|
||||
token: ${{secrets.GH_TOKEN}}
|
||||
- name: Download changelog
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: changelog
|
||||
path: ../changelog.txt
|
||||
- uses: ncipollo/release-action@v1
|
||||
name: Create release
|
||||
with:
|
||||
bodyFile: ../changelog.txt
|
||||
draft: true
|
||||
name: ${{ needs.release-please.outputs.version }}
|
||||
tag: ${{ needs.release-please.outputs.version }}
|
||||
- name: Merge dev -> master
|
||||
uses: devmasx/merge-branch@master
|
||||
with:
|
||||
type: now
|
||||
from_branch: dev
|
||||
target_branch: master
|
||||
github_token: ${{secrets.GH_TOKEN}}
|
||||
- name: Promote to dev
|
||||
run: |
|
||||
jq '.version = "${{ needs.release-please.outputs.version }}-dev"' package.json > package.json
|
||||
jq '.version = "${{ needs.release-please.outputs.version }}-dev"' package-lock.json > package-lock.json
|
||||
- uses: stefanzweifel/git-auto-commit-action@v4
|
||||
with:
|
||||
commit_message: Promote to dev
|
||||
+16
-16
@@ -1,20 +1,20 @@
|
||||
name: stale
|
||||
name: Stale
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v8
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: 'This issue is stale because it has been open 30 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 30 days with no activity. Remove stale label or comment or this will be closed in 7 days'
|
||||
days-before-stale: 30
|
||||
days-before-close: 7
|
||||
exempt-issue-labels: dont-stale
|
||||
operations-per-run: 500
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
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
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: update_dep
|
||||
|
||||
name: Update dependency
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
update-dependency:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: dev
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
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@v8
|
||||
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: '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 }}"
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
@@ -1,38 +0,0 @@
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: update_dep
|
||||
|
||||
name: update-dep
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
update_dep:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: dev
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
- uses: actions/setup-node@v3
|
||||
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@v5
|
||||
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 }}
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
- run: sleep 5 # Otherwise pull request may not exist yet causing automerge to fail
|
||||
- uses: peter-evans/enable-pull-request-automerge@v3
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
merge-method: squash
|
||||
@@ -1,31 +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@v3
|
||||
with:
|
||||
ref: dev
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npx npm-check-updates -u -x connect-gzip-static # connect-gzip-static only supports node 16
|
||||
- run: npm install
|
||||
- uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
commit-message: "fix(ignore): update dependencies"
|
||||
branch: "deps/all"
|
||||
title: Update dependencies
|
||||
+14
-13
@@ -45,9 +45,16 @@ 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
|
||||
|
||||
@@ -60,23 +67,17 @@ typings/
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# data
|
||||
data/database.db
|
||||
data/config.json
|
||||
data/log*.txt
|
||||
data/state.json
|
||||
data/log
|
||||
data-backup/
|
||||
data/coordinator_backup.json
|
||||
data/.storage
|
||||
data/database.db.backup
|
||||
data/extension
|
||||
|
||||
# MacOS indexing file
|
||||
.DS_Store
|
||||
|
||||
# IDE specific folders
|
||||
.idea
|
||||
.*.swp
|
||||
|
||||
# Ignore config
|
||||
data/*.yaml
|
||||
data/*
|
||||
!data/configuration.example.yaml
|
||||
data-bench
|
||||
|
||||
# commit-user-lookup.json
|
||||
scripts/commit-user-lookup.json
|
||||
|
||||
@@ -8,6 +8,7 @@ coverage
|
||||
.codeclimate.yml
|
||||
.github
|
||||
babel.config.js
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
#linters
|
||||
.jscsrc
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
{
|
||||
".": "1.31.0",
|
||||
"bootstrap-sha": "b2dd21e164b457d5cd0c9d455cdb879d881c44bd"
|
||||
}
|
||||
".": "2.9.1"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
Zigbee2MQTT is a Zigbee to MQTT bridge that allows you to use your Zigbee devices without the vendor's bridge or gateway. It bridges events and allows you to control Zigbee devices via MQTT, integrating them with any smart home infrastructure.
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Language**: TypeScript 5.9.3 compiled to JavaScript (ES modules with NodeNext resolution)
|
||||
- **Runtime**: Node.js (versions 20, 22, or 24)
|
||||
- **Package Manager**: pnpm 10.12.1 (strictly enforced via `packageManager` field)
|
||||
- **Core Dependencies**:
|
||||
- `zigbee-herdsman` (6.2.0 - exact version, handles Zigbee adapter communication)
|
||||
- `zigbee-herdsman-converters` (25.42.0 - exact version, device definitions)
|
||||
- `mqtt` (5.14.1 - MQTT client)
|
||||
- `winston` (3.18.3 - logging)
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
lib/ # TypeScript source code
|
||||
├── controller.ts # Main controller orchestrating components
|
||||
├── mqtt.ts # MQTT client management
|
||||
├── zigbee.ts # Zigbee network management
|
||||
├── state.ts # State management
|
||||
├── eventBus.ts # Event-driven communication
|
||||
├── extension/ # Extension system (plugins)
|
||||
│ └── extension.ts # Abstract base class
|
||||
├── model/ # Domain models (Device, Group)
|
||||
├── util/ # Utility functions
|
||||
└── types/ # TypeScript type definitions
|
||||
test/ # Vitest test files with mocks
|
||||
data/ # Runtime configuration and database
|
||||
dist/ # Compiled JavaScript output
|
||||
```
|
||||
|
||||
## Setup Commands
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js version 20, 22, or 24
|
||||
- pnpm 10.12.1 (will be auto-installed via corepack if not present)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies (uses pnpm lockfile)
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# For development without lockfile restrictions
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Initial Build
|
||||
|
||||
```bash
|
||||
# Full build (TypeScript compilation + hash generation)
|
||||
pnpm run build
|
||||
|
||||
# Build type definitions only
|
||||
pnpm run build:types
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Starting Development
|
||||
|
||||
```bash
|
||||
# Watch mode - recompile on file changes
|
||||
pnpm run build:watch
|
||||
|
||||
# In another terminal, start Zigbee2MQTT
|
||||
pnpm start
|
||||
```
|
||||
|
||||
### Code Quality Checks
|
||||
|
||||
```bash
|
||||
# Run Biome linter and formatter (check only)
|
||||
pnpm run check
|
||||
|
||||
# Auto-fix linting and formatting issues
|
||||
pnpm run check:w
|
||||
|
||||
# The check runs with --error-on-warnings flag
|
||||
# Configuration: biome.json (4-space indent, 150 line width, no bracket spacing)
|
||||
```
|
||||
|
||||
### Clean Build
|
||||
|
||||
```bash
|
||||
# Remove build artifacts
|
||||
pnpm run clean
|
||||
|
||||
# Removes: coverage/, dist/, tsconfig.tsbuildinfo
|
||||
```
|
||||
|
||||
## Testing Instructions
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests once
|
||||
pnpm test
|
||||
|
||||
# Run tests with coverage report
|
||||
pnpm run test:coverage
|
||||
|
||||
# Watch mode - re-run tests on changes
|
||||
pnpm run test:watch
|
||||
|
||||
# Run benchmarks
|
||||
pnpm run bench
|
||||
```
|
||||
|
||||
### Test Requirements
|
||||
|
||||
- **Coverage**: 100% code coverage is enforced (configured in `test/vitest.config.mts`)
|
||||
- **Framework**: Vitest 3.1.1 with @vitest/coverage-v8
|
||||
- **Test Files**: Located in `test/` directory with `.test.ts` extension
|
||||
- **Mocks**: Centralized in `test/mocks/` directory
|
||||
- **Coverage Report**: Generated in `coverage/` directory (HTML report at `coverage/index.html`)
|
||||
|
||||
### Running Specific Tests
|
||||
|
||||
```bash
|
||||
# Run tests matching a pattern
|
||||
pnpm vitest run -t "test name pattern" --config ./test/vitest.config.mts
|
||||
|
||||
# Run specific test file
|
||||
pnpm vitest run test/controller.test.ts --config ./test/vitest.config.mts
|
||||
|
||||
# Focus on one test area in watch mode
|
||||
pnpm vitest watch -t "Extension" --config ./test/vitest.config.mts
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### TypeScript Conventions
|
||||
|
||||
- **Module System**: ES modules with NodeNext resolution
|
||||
- **Target**: ESNext
|
||||
- **Strict Mode**: Enabled (`noImplicitAny`, `noImplicitThis`)
|
||||
- **Decorators**: Experimental decorators enabled (used for `@bind` from `bind-decorator`)
|
||||
|
||||
### Import Order
|
||||
|
||||
1. Node.js built-in modules (with `node:` prefix)
|
||||
2. Third-party libraries
|
||||
3. Type-only imports from external packages (using `type` keyword)
|
||||
4. Internal absolute imports
|
||||
5. Type-only imports from internal modules
|
||||
|
||||
Example:
|
||||
```typescript
|
||||
import fs from "node:fs";
|
||||
import bind from "bind-decorator";
|
||||
import type {IClientOptions} from "mqtt";
|
||||
import {connectAsync} from "mqtt";
|
||||
import type {Zigbee2MQTTAPI} from "./types/api";
|
||||
import logger from "./util/logger";
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Classes**: PascalCase (e.g., `Extension`, `Device`)
|
||||
- **Functions/Methods**: camelCase (e.g., `publishEntityState`)
|
||||
- **Constants**: SCREAMING_SNAKE_CASE (e.g., `CURRENT_VERSION`)
|
||||
- **Interfaces/Types**: PascalCase (e.g., `MqttPublishOptions`)
|
||||
- **Files**: camelCase for TypeScript (e.g., `eventBus.ts`)
|
||||
|
||||
### Code Patterns
|
||||
|
||||
- **Async/Await**: Always use async/await, explicitly type return as `Promise<Type>`
|
||||
- **Error Handling**: Use `throw new Error("message")`, log with winston logger
|
||||
- **Event Handlers**: Use `@bind` decorator to preserve `this` context
|
||||
- **Logging**: Use `logger.info()`, `logger.warning()`, `logger.error()`, `logger.debug()`
|
||||
|
||||
### Formatting Rules (Biome)
|
||||
|
||||
- 4-space indentation
|
||||
- 150 character line width
|
||||
- No bracket spacing in objects
|
||||
- Run `pnpm run check:w` to auto-format
|
||||
|
||||
## Build and Deployment
|
||||
|
||||
### Build Process
|
||||
|
||||
```bash
|
||||
# Production build
|
||||
pnpm run build
|
||||
|
||||
# Outputs:
|
||||
# - Compiled JavaScript in dist/
|
||||
# - Type definitions in dist/types/
|
||||
# - Includes hash generation for version tracking
|
||||
```
|
||||
|
||||
### Pre-publish
|
||||
|
||||
```bash
|
||||
# Automatically runs before publishing
|
||||
pnpm run prepack
|
||||
|
||||
# Performs: clean + build
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
|
||||
- Configuration stored in `data/configuration.yaml`
|
||||
- Database in `data/database.db`
|
||||
- Logs in `data/log/`
|
||||
- External extensions in `data/external_extensions/`
|
||||
- External converters in `data/external_converters/`
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Extension System
|
||||
|
||||
All features are implemented as extensions that inherit from the abstract `Extension` base class:
|
||||
|
||||
```typescript
|
||||
abstract class Extension {
|
||||
protected zigbee: Zigbee;
|
||||
protected mqtt: Mqtt;
|
||||
protected state: State;
|
||||
protected publishEntityState: PublishEntityState;
|
||||
protected eventBus: EventBus;
|
||||
|
||||
async start(): Promise<void> {} // Initialize extension
|
||||
async stop(): Promise<void> {} // Cleanup extension
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
- Constructor should only assign properties (no side effects)
|
||||
- Initialization happens in `start()` method
|
||||
- Use EventBus for inter-component communication
|
||||
- Extensions are loaded and managed by the Controller
|
||||
|
||||
### Event-Driven Communication
|
||||
|
||||
Components communicate via the strongly-typed EventBus:
|
||||
|
||||
```typescript
|
||||
// Emit events
|
||||
this.eventBus.emit('deviceMessage', {device, message});
|
||||
|
||||
// Listen to events
|
||||
this.eventBus.on('deviceMessage', this.onDeviceMessage, this);
|
||||
```
|
||||
|
||||
### Dependency Injection
|
||||
|
||||
The Controller instantiates and injects dependencies into all extensions. Follow this pattern when creating new extensions.
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
### Target Branch
|
||||
|
||||
- **Always create PRs against the `dev` branch**
|
||||
- The `master` branch is for production releases only
|
||||
|
||||
### Before Submitting
|
||||
|
||||
```bash
|
||||
# Run all checks
|
||||
pnpm run check
|
||||
pnpm test
|
||||
|
||||
# Ensure 100% code coverage
|
||||
pnpm run test:coverage
|
||||
|
||||
# Build successfully
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
### PR Requirements
|
||||
|
||||
- All CI checks must pass (linting, tests, build)
|
||||
- 100% test coverage maintained
|
||||
- Code follows Biome formatting rules
|
||||
- Commit messages should be descriptive
|
||||
- Reference related issues when applicable
|
||||
|
||||
### CI Pipeline
|
||||
|
||||
The GitHub Actions CI workflow (`.github/workflows/ci.yml`) runs:
|
||||
1. Biome code quality checks (`pnpm run check`)
|
||||
2. TypeScript compilation (`pnpm run build`)
|
||||
3. Full test suite with coverage (`pnpm run test:coverage`)
|
||||
4. Benchmarks (on dev branch and PRs)
|
||||
5. Docker image builds (on dev branch and tags)
|
||||
|
||||
## Working with Device Support
|
||||
|
||||
### Adding New Devices
|
||||
|
||||
**Important**: Device support is NOT added to this repository. All device definitions live in `zigbee-herdsman-converters`.
|
||||
|
||||
- Follow the guide at: https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html
|
||||
- No changes to zigbee2mqtt codebase are needed for new devices
|
||||
- Device definitions are automatically picked up from `zigbee-herdsman-converters`
|
||||
|
||||
## Debugging and Troubleshooting
|
||||
|
||||
### Development Setup
|
||||
|
||||
For the easiest development experience, set up a bare-metal installation following:
|
||||
https://www.zigbee2mqtt.io/guide/installation/01_linux.html
|
||||
|
||||
### Logging
|
||||
|
||||
- Winston logger is initialized in `lib/util/logger.ts`
|
||||
- Log levels: `error`, `warning`, `info`, `debug`
|
||||
- Logs are written to console and/or file based on configuration
|
||||
- Use structured logging with context (device names, IEEE addresses)
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Import errors after file moves**: Run `pnpm run check` to verify TypeScript and ESLint
|
||||
2. **Test failures**: Check if mocks in `test/mocks/` need updates
|
||||
3. **Build errors**: Ensure Node.js version is 20, 22, or 24
|
||||
4. **Coverage issues**: View HTML report at `coverage/index.html` to identify uncovered code
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- Use `rimrafSync` for synchronous file operations
|
||||
- Leverage async/await to avoid blocking
|
||||
- Cache computed values in getters when appropriate
|
||||
- EventBus provides loose coupling between components
|
||||
|
||||
## Critical Version Requirements
|
||||
|
||||
### Exact Versions
|
||||
|
||||
These dependencies use **exact versions** (no semver ranges) - do not upgrade without thorough testing:
|
||||
|
||||
- `zigbee-herdsman@6.2.0` - Critical for Zigbee protocol compatibility
|
||||
- `zigbee-herdsman-converters@25.42.0` - Device definitions must match herdsman version
|
||||
|
||||
### Node.js Compatibility
|
||||
|
||||
Only these Node.js versions are supported:
|
||||
- Node.js 20.x
|
||||
- Node.js 22.x
|
||||
- Node.js 24.x
|
||||
|
||||
Using other versions may cause runtime errors or incompatibilities.
|
||||
|
||||
## Additional Notes
|
||||
|
||||
### Package Manager
|
||||
|
||||
This project **requires pnpm 10.12.1**. The `packageManager` field in package.json enforces this via Corepack.
|
||||
|
||||
Do not use npm or yarn - they will not respect the pnpm-specific configuration.
|
||||
|
||||
### TypeScript Compilation
|
||||
|
||||
- Source files in `lib/` are compiled to `dist/`
|
||||
- Type definitions exported from `dist/types/api.d.ts`
|
||||
- Source maps are inlined for debugging
|
||||
- Uses composite project references for faster incremental builds
|
||||
|
||||
### External Extensions
|
||||
|
||||
To load external extensions:
|
||||
1. Place JavaScript files in `data/external_extensions/`
|
||||
2. They will be automatically loaded on startup
|
||||
3. No configuration changes needed
|
||||
|
||||
### Code Quality Tools
|
||||
|
||||
- **Linting/Formatting**: Biome 2.2.5 (replaces ESLint + Prettier)
|
||||
- **Type Checking**: TypeScript 5.9.3
|
||||
- **Testing**: Vitest 3.1.1
|
||||
- **Coverage**: @vitest/coverage-v8
|
||||
|
||||
### Documentation
|
||||
|
||||
- Main documentation: https://www.zigbee2mqtt.io/
|
||||
- Contributing guide: `CONTRIBUTING.md`
|
||||
- Coding standards: `.github/copilot-instructions.md`
|
||||
- Issue tracker: https://github.com/Koenkk/zigbee2mqtt/issues
|
||||
+1783
-20
File diff suppressed because it is too large
Load Diff
+11
-12
@@ -1,4 +1,3 @@
|
||||
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
@@ -18,23 +17,23 @@ 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,
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
@@ -106,7 +105,7 @@ Violating these terms may lead to a permanent ban.
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
# Contributing to Zigbee2MQTT
|
||||
|
||||
Everybody is invited and welcome to contribute to Zigbee2MQTT. Zigbee2MQTT is writen 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 definition, zigbee-herdsman is responsible for handling all communication with the adapter.
|
||||
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 devices 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).
|
||||
- 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: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>
|
||||
@@ -43,48 +37,86 @@
|
||||
</div>
|
||||
|
||||
## [Getting started](https://www.zigbee2mqtt.io/guide/getting-started)
|
||||
|
||||
The [documentation](https://www.zigbee2mqtt.io/) provides you all the information needed to get up and running! Make sure you don't skip sections if this is your first visit, as there might be important details in there for you.
|
||||
|
||||
If you aren't familiar with **Zigbee** terminology make sure you [read this](https://www.zigbee2mqtt.io/advanced/zigbee/01_zigbee_network.html) to help you out.
|
||||
|
||||
## [Integrations](https://www.zigbee2mqtt.io/guide/usage/integrations.html)
|
||||
|
||||
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)
|
||||
|
||||
<br>
|
||||
<a href="https://www.home-assistant.io/"><img align="left" src="https://avatars.githubusercontent.com/u/13844975?s=75" style="padding-right: 20px;" alt="Home Assistant"></a>
|
||||
|
||||
<img align="left" height="100px" width="100px" src="https://user-images.githubusercontent.com/2734836/47615848-b8dd8700-dabd-11e8-9d77-175002dd8987.png">
|
||||
<br clear="right">
|
||||
|
||||
[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/)
|
||||
|
||||
<a href="https://homey.app/"><img align="left" src="https://avatars.githubusercontent.com/u/8502422?s=75" style="padding-right: 20px;" alt="Homey"></a>
|
||||
|
||||
<br clear="right">
|
||||
|
||||
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-zigbee2mqtt-plugin](https://github.com/stas-demydiuk/domoticz-zigbee2mqtt-plugin)
|
||||
|
||||
<br>
|
||||
<a href="https://www.domoticz.com/"><img align="left" src="https://avatars.githubusercontent.com/u/6067010?s=75" style="padding-right: 20px;" alt="Domoticz"></a>
|
||||
|
||||
<img align="left" height="100px" width="100px" src="./images/gladys-assistant-logo.jpg">
|
||||
<br clear="right">
|
||||
|
||||
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. Read documentation [here](https://gladysassistant.com/docs/integrations/zigbee2mqtt/)
|
||||
|
||||
<br>
|
||||
<a href="https://gladysassistant.com/"><img align="left" src="https://avatars.githubusercontent.com/u/7365162?s=75" style="padding-right: 20px;" alt="Gladys Assistant"></a>
|
||||
|
||||
<br clear="right">
|
||||
|
||||
Integration implemented natively in Gladys Assistant ([documentation](https://gladysassistant.com/docs/integrations/zigbee2mqtt/)).
|
||||
|
||||
<br clear="both">
|
||||
|
||||
### [ioBroker](https://www.iobroker.net/)
|
||||
|
||||
<a href="https://www.iobroker.net/"><img align="left" src="https://avatars.githubusercontent.com/u/7231148?s=75" style="padding-right: 20px;" alt="ioBroker"></a>
|
||||
|
||||
<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
|
||||
|
||||
See [Supported devices](https://www.zigbee2mqtt.io/supported-devices) to check whether your device is supported. There is quite an extensive list, including devices from vendors like [Xiaomi](https://www.zigbee2mqtt.io/supported-devices/#v=Xiaomi), [Ikea](https://www.zigbee2mqtt.io/supported-devices/#v=IKEA), [Philips](https://www.zigbee2mqtt.io/supported-devices/#v=Philips), [OSRAM](https://www.zigbee2mqtt.io/supported-devices/#v=OSRAM) and more.
|
||||
|
||||
If it's not listed in [Supported devices](https://www.zigbee2mqtt.io/supported-devices), support can be added (fairly) easily, see [How to support new devices](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html).
|
||||
|
||||
## Support & help
|
||||
|
||||
If you need assistance you can check [opened issues](https://github.com/Koenkk/zigbee2mqtt/issues). Feel free to help with Pull Requests when you were able to fix things or add new devices or just share the love on social media.
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['@babel/preset-env', {targets: {node: 'current'}}],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
assumptions: {setPublicClassFields: true},
|
||||
plugins: [
|
||||
['@babel/plugin-proposal-decorators', {'legacy': true}],
|
||||
['@babel/plugin-proposal-class-properties'],
|
||||
],
|
||||
};
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.1/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"formatter": {
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 4,
|
||||
"lineWidth": 150,
|
||||
"bracketSpacing": false
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!package.json", "!!**/dist", "!!**/coverage"]
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"nursery": {
|
||||
"useExhaustiveSwitchCases": "error",
|
||||
"noFloatingPromises": "error",
|
||||
"noMisusedPromises": "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");
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Indicates the configuration version (used by configuration migrations)
|
||||
version: 5
|
||||
|
||||
# Home Assistant integration (MQTT discovery)
|
||||
homeassistant:
|
||||
enabled: false
|
||||
|
||||
# Enable the frontend, runs on port 8080 by default
|
||||
frontend:
|
||||
enabled: true
|
||||
# port: 8080
|
||||
|
||||
# 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, 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
|
||||
pan_id: GENERATE
|
||||
# Let Zigbee2MQTT generate a ext_pan_id on first start
|
||||
ext_pan_id: GENERATE
|
||||
@@ -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
|
||||
+36
-13
@@ -1,26 +1,48 @@
|
||||
FROM alpine:3.18.0 as base
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
FROM alpine:3.23 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
|
||||
|
||||
COPY --from=dependencies_and_build /app/node_modules ./node_modules
|
||||
ARG DATE
|
||||
ARG VERSION
|
||||
LABEL org.opencontainers.image.authors="Koen Kanters"
|
||||
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=deps /app/node_modules ./node_modules
|
||||
COPY dist ./dist
|
||||
COPY package.json LICENSE index.js data/configuration.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
|
||||
@@ -30,7 +52,8 @@ RUN mkdir /app/data
|
||||
ARG COMMIT
|
||||
RUN echo "$COMMIT" > dist/.hash
|
||||
|
||||
ENV NODE_ENV production
|
||||
# Expose the default frontend port (see lib/util/settings.ts defaults.frontend.port)
|
||||
EXPOSE 8080
|
||||
|
||||
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.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 |
Executable → Regular
+120
-1
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 25 KiB |
@@ -1,133 +1,197 @@
|
||||
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 fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const {exec} = require("node:child_process");
|
||||
require("source-map-support").install();
|
||||
|
||||
/** @type {import("./dist/controller").Controller | undefined} */
|
||||
let controller;
|
||||
let stopping = false;
|
||||
let watchdogCount = 0;
|
||||
let unsolicitedStop = false;
|
||||
// csv in minutes, default: 1min, 5min, 15min, 30min, 60min
|
||||
let watchdogDelays = [2000, 60000, 300000, 900000, 1800000, 3600000];
|
||||
|
||||
const hashFile = path.join(__dirname, 'dist', '.hash');
|
||||
if (process.env.Z2M_WATCHDOG != null && 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");
|
||||
|
||||
/** @type {(code: number) => Promise<void>} */
|
||||
async function triggerWatchdog(code) {
|
||||
const delay = watchdogDelays[watchdogCount];
|
||||
watchdogCount += 1;
|
||||
|
||||
if (delay) {
|
||||
// garbage collector
|
||||
controller = undefined;
|
||||
|
||||
console.log(`WATCHDOG: Waiting ${delay / 60000}min before next start try.`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
await start();
|
||||
} else {
|
||||
process.exit(code);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {() => Promise<void>} */
|
||||
async function restart() {
|
||||
await stop(true);
|
||||
await start();
|
||||
}
|
||||
|
||||
async function exit(code, restart) {
|
||||
/** @type {(code: number, restart?: boolean) => Promise<void>} */
|
||||
async function exit(code, restart = false) {
|
||||
if (!restart) {
|
||||
process.exit(code);
|
||||
if (process.env.Z2M_WATCHDOG != null && unsolicitedStop) {
|
||||
await triggerWatchdog(code);
|
||||
} else {
|
||||
process.exit(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {() => Promise<string>} */
|
||||
async function currentHash() {
|
||||
const git = require('git-last-commit');
|
||||
return new Promise((resolve) => {
|
||||
git.getLastCommit((err, commit) => {
|
||||
if (err) resolve('unknown');
|
||||
else resolve(commit.shortHash);
|
||||
return await new Promise((resolve) => {
|
||||
exec("git rev-parse --short=8 HEAD", (error, stdout) => {
|
||||
const commitHash = stdout.trim();
|
||||
|
||||
if (error || commitHash === "") {
|
||||
resolve("unknown");
|
||||
} else {
|
||||
resolve(commitHash);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {() => Promise<void>} */
|
||||
async function writeHash() {
|
||||
const hash = await currentHash();
|
||||
|
||||
fs.writeFileSync(hashFile, hash);
|
||||
}
|
||||
|
||||
/** @type {(reason: "initial build" | "hash changed") => Promise<void>} */
|
||||
async function build(reason) {
|
||||
return new Promise((resolve, reject) => {
|
||||
process.stdout.write(`Building Zigbee2MQTT... (${reason})`);
|
||||
rimrafSync('dist');
|
||||
process.stdout.write(`Building Zigbee2MQTT... (${reason})`);
|
||||
const {totalmem} = await import("node:os");
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const env = {...process.env};
|
||||
const _600mb = 629145600;
|
||||
if (_600mb > os.totalmem() && !env.NODE_OPTIONS) {
|
||||
const mb600 = 629145600;
|
||||
|
||||
if (mb600 > 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {() => Promise<void>} */
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {() => Promise<void>} */
|
||||
async function start() {
|
||||
console.log(`Starting Zigbee2MQTT ${process.env.Z2M_WATCHDOG != null ? `with watchdog (${watchdogDelays})` : "without watchdog"}.`);
|
||||
await checkDist();
|
||||
|
||||
const version = engines.node;
|
||||
if (!semver.satisfies(process.version, version)) {
|
||||
console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`); // eslint-disable-line
|
||||
}
|
||||
// gc
|
||||
{
|
||||
const packageJSON = (await import("./package.json", {with: {type: "json"}})).default;
|
||||
const version = packageJSON.engines.node;
|
||||
const {satisfies} = await import("semver");
|
||||
|
||||
// Validate settings
|
||||
const settings = require('./dist/util/settings');
|
||||
settings.reRead();
|
||||
const errors = settings.validate();
|
||||
if (errors.length > 0) {
|
||||
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 (!satisfies(process.version, version)) {
|
||||
console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`);
|
||||
}
|
||||
|
||||
const {onboard} = await import("./dist/util/onboarding.js");
|
||||
const success = await onboard();
|
||||
|
||||
if (!success) {
|
||||
unsolicitedStop = false;
|
||||
|
||||
return await exit(1);
|
||||
}
|
||||
console.log(`\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration`); // eslint-disable-line
|
||||
console.log(`\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n`);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const Controller = require('./dist/controller');
|
||||
const {Controller} = await import("./dist/controller.js");
|
||||
controller = new Controller(restart, exit);
|
||||
|
||||
await controller.start();
|
||||
|
||||
// consider next controller.stop() call as unsolicited, only after successful first start
|
||||
unsolicitedStop = true;
|
||||
watchdogCount = 0; // reset
|
||||
}
|
||||
|
||||
async function stop(restart) {
|
||||
await controller.stop(restart);
|
||||
/** @type {(restart: boolean, signal?: NodeJS.Signals) => Promise<void>} */
|
||||
async function stop(restart, signal = undefined) {
|
||||
// `handleQuit` or `restart` never unsolicited
|
||||
unsolicitedStop = false;
|
||||
|
||||
await controller?.stop(restart, undefined, signal);
|
||||
}
|
||||
|
||||
async function handleQuit() {
|
||||
if (!stopping && controller) {
|
||||
stopping = true;
|
||||
await stop(false);
|
||||
/** @type {(signal: NodeJS.Signals) => Promise<void>} */
|
||||
async function handleQuit(signal) {
|
||||
if (!stopping) {
|
||||
if (controller) {
|
||||
stopping = true;
|
||||
|
||||
await stop(false, signal);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module || require.main.filename.endsWith(path.sep + 'cli.js')) {
|
||||
if (process.argv.length === 3 && process.argv[2] === 'writehash') {
|
||||
writeHash();
|
||||
if (require.main === module || require.main?.filename.endsWith(`${path.sep}cli.js`)) {
|
||||
if (process.argv.length === 3 && process.argv[2] === "writehash") {
|
||||
void writeHash();
|
||||
} else {
|
||||
process.on('SIGINT', handleQuit);
|
||||
process.on('SIGTERM', handleQuit);
|
||||
start();
|
||||
process.on("SIGINT", handleQuit);
|
||||
process.on("SIGTERM", handleQuit);
|
||||
void start();
|
||||
}
|
||||
} else {
|
||||
process.on('SIGINT', handleQuit);
|
||||
process.on('SIGTERM', handleQuit);
|
||||
process.on("SIGINT", handleQuit);
|
||||
process.on("SIGTERM", handleQuit);
|
||||
|
||||
module.exports = {start};
|
||||
}
|
||||
|
||||
+324
-172
@@ -1,196 +1,353 @@
|
||||
import MQTT from './mqtt';
|
||||
import Zigbee from './zigbee';
|
||||
import EventBus from './eventBus';
|
||||
import State from './state';
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import utils from './util/utils';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import assert from 'assert';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
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 ExtensionPublish from './extension/publish';
|
||||
import ExtensionReceive from './extension/receive';
|
||||
import ExtensionNetworkMap from './extension/networkMap';
|
||||
import ExtensionSoftReset from './extension/legacy/softReset';
|
||||
import ExtensionHomeAssistant from './extension/homeassistant';
|
||||
import ExtensionConfigure from './extension/configure';
|
||||
import ExtensionDeviceGroupMembership from './extension/legacy/deviceGroupMembership';
|
||||
import ExtensionBridgeLegacy from './extension/legacy/bridgeLegacy';
|
||||
import ExtensionBridge from './extension/bridge';
|
||||
import ExtensionGroups from './extension/groups';
|
||||
import ExtensionAvailability from './extension/availability';
|
||||
import ExtensionBind from './extension/bind';
|
||||
import ExtensionReport from './extension/legacy/report';
|
||||
import ExtensionOnEvent from './extension/onEvent';
|
||||
import ExtensionOTAUpdate from './extension/otaUpdate';
|
||||
import ExtensionExternalConverters from './extension/externalConverters';
|
||||
import ExtensionExternalExtension from './extension/externalExtension';
|
||||
|
||||
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,
|
||||
(enable: boolean, name: string) => Promise<void>, () => void, (extension: Extension) => Promise<void>];
|
||||
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;
|
||||
private restartCallback: () => void;
|
||||
private exitCallback: (code: number, restart: boolean) => void;
|
||||
private extensions: Extension[];
|
||||
private extensionArgs: ExtensionArgs;
|
||||
/** Allows canceling in-progress startup sequence if necessary. Signal can be passed to long-running ops as necessary for finer control. */
|
||||
#startAbortController: AbortController | undefined;
|
||||
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>;
|
||||
public readonly extensions: Set<Extension>;
|
||||
public readonly extensionArgs: ConstructorParameters<typeof Extension>;
|
||||
private sdNotify: Awaited<ReturnType<typeof initSdNotify>>;
|
||||
|
||||
constructor(restartCallback: () => void, exitCallback: (code: number, restart: boolean) => void) {
|
||||
constructor(restartCallback: () => Promise<void>, exitCallback: (code: number, restart: boolean) => Promise<void>) {
|
||||
logger.init();
|
||||
this.eventBus = new EventBus( /* istanbul ignore next */ (error) => {
|
||||
logger.error(`Error: ${error.message}`);
|
||||
logger.debug(error.stack);
|
||||
});
|
||||
zhSetLogger(logger);
|
||||
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;
|
||||
|
||||
// Initialize extensions.
|
||||
this.extensionArgs = [this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus,
|
||||
this.enableDisableExtension, this.restartCallback, this.addExtension];
|
||||
this.extensionArgs = [
|
||||
this.zigbee,
|
||||
this.mqtt,
|
||||
this.state,
|
||||
this.publishEntityState,
|
||||
this.eventBus,
|
||||
this.enableDisableExtension,
|
||||
this.restartCallback,
|
||||
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 ExtensionOnEvent(...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> {
|
||||
this.#startAbortController = new AbortController();
|
||||
const abortSignal = this.#startAbortController.signal;
|
||||
|
||||
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();
|
||||
logger.logOutput();
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
this.eventBus.onAdapterDisconnected(this, this.onZigbeeAdapterDisconnected);
|
||||
const started = await this.zigbee.start(abortSignal);
|
||||
|
||||
if (started) {
|
||||
this.eventBus.onAdapterDisconnected(this, async () => {
|
||||
logger.error("Adapter disconnected, stopping");
|
||||
await this.stop(false, 2);
|
||||
});
|
||||
}
|
||||
} 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'); /* eslint-disable-line max-len */
|
||||
logger.error('Exiting...');
|
||||
logger.error(error.stack);
|
||||
await this.exit(1);
|
||||
// skip if aborted by `stop`
|
||||
if (error !== "SIGINT" && error !== "SIGTERM" && error !== "STOPABORT") {
|
||||
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!);
|
||||
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
this.enableDisableExtension(false, 'BridgeLegacy');
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Log zigbee clients on startup
|
||||
const devices = this.zigbee.devices(false);
|
||||
logger.info(`Currently ${devices.length} devices are joined:`);
|
||||
for (const device of devices) {
|
||||
const model = device.definition ?
|
||||
`${device.definition.model} - ${device.definition.vendor} ${device.definition.description}` :
|
||||
'Not supported';
|
||||
let deviceCount = 0;
|
||||
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
// `definition` validated by `isSupported`
|
||||
const model = device.isSupported
|
||||
? // 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++;
|
||||
}
|
||||
|
||||
// Enable zigbee join
|
||||
try {
|
||||
if (settings.get().permit_join) {
|
||||
logger.warn('`permit_join` set to `true` in configuration.yaml.');
|
||||
logger.warn('Allowing new devices to join.');
|
||||
logger.warn('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}`);
|
||||
}
|
||||
logger.info(`Currently ${deviceCount} devices are joined.`);
|
||||
|
||||
// MQTT
|
||||
try {
|
||||
await this.mqtt.connect();
|
||||
} catch (error) {
|
||||
logger.error(`MQTT failed to connect, exiting...`);
|
||||
logger.error(`MQTT failed to connect, exiting... (${(error as Error).message})`);
|
||||
await this.zigbee.stop();
|
||||
await this.exit(1);
|
||||
return await this.exit(1);
|
||||
}
|
||||
|
||||
// Call extensions
|
||||
await this.callExtensions('start', [...this.extensions]);
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Send all cached states.
|
||||
if (settings.get().advanced.cache_state_send_on_startup && settings.get().advanced.cache_state) {
|
||||
for (const entity of [...devices, ...this.zigbee.groups()]) {
|
||||
for (const entity of this.zigbee.devicesAndGroupsIterator()) {
|
||||
if (this.state.exists(entity)) {
|
||||
this.publishEntityState(entity, this.state.get(entity), 'publishCached');
|
||||
await this.publishEntityState(entity, this.state.get(entity), "publishCached");
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.onLastSeenChanged(this,
|
||||
(data) => utils.publishLastSeen(data, settings.get(), false, this.publishEntityState));
|
||||
this.eventBus.onLastSeenChanged(this, (data) => {
|
||||
utils.publishLastSeen(data, settings.get(), false, this.publishEntityState).catch(() => {});
|
||||
});
|
||||
|
||||
logger.info(`Zigbee2MQTT started!`);
|
||||
logger.info("Zigbee2MQTT started!");
|
||||
|
||||
this.sdNotify = await initSdNotify();
|
||||
|
||||
settings.setOnboarding(false);
|
||||
|
||||
this.#startAbortController = undefined; // gc
|
||||
}
|
||||
|
||||
@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) {
|
||||
let extension: Extension;
|
||||
|
||||
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");
|
||||
|
||||
extension = 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");
|
||||
|
||||
extension = 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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingExtension = this.getExtension(name);
|
||||
if (existingExtension) {
|
||||
await this.removeExtension(existingExtension);
|
||||
}
|
||||
await this.extensions.add(extension);
|
||||
} 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> {
|
||||
// Call extensions
|
||||
await this.callExtensions('stop', this.extensions);
|
||||
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, signal: NodeJS.Signals | undefined = undefined): Promise<void> {
|
||||
logger.info(`Stopping Zigbee2MQTT (restart=${restart}, code=${code}, signal=${signal})`);
|
||||
|
||||
this.#startAbortController?.abort(signal ?? "STOPABORT");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.removeListeners(this);
|
||||
|
||||
// Wrap-up
|
||||
@@ -199,27 +356,23 @@ export class Controller {
|
||||
|
||||
try {
|
||||
await this.zigbee.stop();
|
||||
logger.info('Stopped Zigbee2MQTT');
|
||||
await this.exit(0, restart);
|
||||
logger.info("Stopped Zigbee2MQTT");
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop Zigbee2MQTT');
|
||||
await this.exit(1, restart);
|
||||
logger.error(`Failed to stop Zigbee2MQTT (${(error as Error).stack})`);
|
||||
localCode = 1;
|
||||
}
|
||||
|
||||
this.sdNotify?.stop();
|
||||
return await this.exit(code !== 0 ? code : localCode, restart);
|
||||
}
|
||||
|
||||
async exit(code: number, restart = false): Promise<void> {
|
||||
await logger.end();
|
||||
this.exitCallback(code, restart);
|
||||
return await this.exitCallback(code, restart);
|
||||
}
|
||||
|
||||
@bind async onZigbeeAdapterDisconnected(): Promise<void> {
|
||||
logger.error('Adapter disconnected, stopping');
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
@bind async publishEntityState(entity: Group | Device, payload: KeyValue,
|
||||
stateChangeReason?: StateChangeReason): Promise<void> {
|
||||
let message = {...payload};
|
||||
@bind async publishEntityState(entity: Group | Device, payload: KeyValue, stateChangeReason?: StateChangeReason): Promise<void> {
|
||||
let message: Zigbee2MQTTAPI["{friendlyName}"] = {...payload};
|
||||
|
||||
// Update state cache with new state.
|
||||
const newState = this.state.set(entity, payload, stateChangeReason);
|
||||
@@ -229,34 +382,46 @@ 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) {
|
||||
message.device = {
|
||||
friendlyName: entity.name, model: entity.definition ? entity.definition.model : 'unknown',
|
||||
ieeeAddr: entity.ieeeAddr, networkAddress: entity.zh.networkAddress, type: entity.zh.type,
|
||||
friendlyName: entity.name,
|
||||
model: entity.definition?.model,
|
||||
ieeeAddr: entity.ieeeAddr,
|
||||
networkAddress: entity.zh.networkAddress,
|
||||
type: entity.zh.type,
|
||||
manufacturerID: entity.zh.manufacturerID,
|
||||
powerSource: entity.zh.powerSource, applicationVersion: entity.zh.applicationVersion,
|
||||
stackVersion: entity.zh.stackVersion, zclVersion: entity.zh.zclVersion,
|
||||
hardwareVersion: entity.zh.hardwareVersion, dateCode: entity.zh.dateCode,
|
||||
powerSource: entity.zh.powerSource,
|
||||
applicationVersion: entity.zh.applicationVersion,
|
||||
stackVersion: entity.zh.stackVersion,
|
||||
zclVersion: entity.zh.zclVersion,
|
||||
hardwareVersion: entity.zh.hardwareVersion,
|
||||
dateCode: entity.zh.dateCode,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -272,13 +437,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);
|
||||
}
|
||||
}
|
||||
@@ -286,25 +451,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) {
|
||||
@@ -312,17 +477,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;
|
||||
|
||||
+168
-68
@@ -1,174 +1,274 @@
|
||||
import events from 'events';
|
||||
events.captureRejections = true;
|
||||
import events from "node:events";
|
||||
|
||||
import logger from "./util/logger";
|
||||
|
||||
// eslint-disable-next-line
|
||||
type ListenerKey = object;
|
||||
|
||||
export default class EventBus {
|
||||
private callbacksByExtension: { [s: string]: { event: string, callback: (...args: unknown[]) => void }[] } = {};
|
||||
private emitter = new events.EventEmitter();
|
||||
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;
|
||||
|
||||
constructor(onError: (error: Error) => void) {
|
||||
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 = 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);
|
||||
this.emitter.on('error', onError);
|
||||
}
|
||||
|
||||
public emitAdapterDisconnected(): void {
|
||||
this.emitter.emit('adapterDisconnected');
|
||||
this.emitter.emit("adapterDisconnected");
|
||||
}
|
||||
public onAdapterDisconnected(key: ListenerKey, callback: () => void): void {
|
||||
this.on('adapterDisconnected', callback, key);
|
||||
public onAdapterDisconnected(key: ListenerKey, callback: () => Promise<void>): void {
|
||||
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 emitDeviceRemoved(data: eventdata.DeviceRemoved): void {
|
||||
this.emitter.emit('deviceRemoved', data);
|
||||
public emitEntityRemoved(data: eventdata.EntityRemoved): void {
|
||||
this.emitter.emit("entityRemoved", data);
|
||||
}
|
||||
public onDeviceRemoved(key: ListenerKey, callback: (data: eventdata.DeviceRemoved) => void): void {
|
||||
this.on('deviceRemoved', callback, key);
|
||||
public onEntityRemoved(key: ListenerKey, callback: (data: eventdata.EntityRemoved) => void): void {
|
||||
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);
|
||||
public onDeviceNetworkAddressChanged(key: ListenerKey, callback: (data: eventdata.DeviceNetworkAddressChanged) => void): void {
|
||||
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);
|
||||
}
|
||||
public onExposesChanged(key: ListenerKey, callback: (data: eventdata.ExposesChanged) => void): void {
|
||||
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(): void {
|
||||
this.emitter.emit('scenesChanged');
|
||||
public emitScenesChanged(data: eventdata.ScenesChanged): void {
|
||||
this.emitter.emit("scenesChanged", data);
|
||||
}
|
||||
public onScenesChanged(key: ListenerKey, callback: () => void): void {
|
||||
this.on('scenesChanged', callback, key);
|
||||
public onScenesChanged(key: ListenerKey, callback: (data: eventdata.ScenesChanged) => void): void {
|
||||
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[]) => void, key: ListenerKey): void {
|
||||
if (!this.callbacksByExtension[key.constructor.name]) this.callbacksByExtension[key.constructor.name] = [];
|
||||
this.callbacksByExtension[key.constructor.name].push({event, callback});
|
||||
this.emitter.on(event, callback);
|
||||
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 as Error).message}`);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+241
-110
@@ -1,190 +1,296 @@
|
||||
import Extension from './extension';
|
||||
import logger from '../util/logger';
|
||||
import utils from '../util/utils';
|
||||
import * as settings from '../util/settings';
|
||||
import debounce from 'debounce';
|
||||
import bind from 'bind-decorator';
|
||||
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";
|
||||
|
||||
const retrieveOnReconnect = [
|
||||
{keys: ['state']},
|
||||
{keys: ['brightness'], condition: (state: KeyValue): boolean => state.state === 'ON'},
|
||||
{keys: ['color', 'color_temp'], condition: (state: KeyValue): boolean => state.state === 'ON'},
|
||||
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"},
|
||||
];
|
||||
|
||||
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;
|
||||
if (value == null) value = key == 'active' ? 10 : 1500;
|
||||
return utils.minutes(value);
|
||||
return utils.minutes(this.isActiveDevice(device) ? settings.get().availability.active.timeout : settings.get().availability.passive.timeout);
|
||||
}
|
||||
|
||||
private getMaxJitter(device: Device): number {
|
||||
if (typeof device.options.availability === "object" && device.options.availability?.max_jitter != null) {
|
||||
return device.options.availability.max_jitter;
|
||||
}
|
||||
|
||||
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 {
|
||||
if (entity.isDevice()) {
|
||||
const ago = Date.now() - entity.zh.lastSeen;
|
||||
return ago < this.getTimeout(entity);
|
||||
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, 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)) {
|
||||
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 {
|
||||
return entity.membersDevices().length === 0 ||
|
||||
entity.membersDevices().map((d) => this.availabilityCache[d.ieeeAddr]).includes(true);
|
||||
this.timers.set(
|
||||
device.ieeeAddr,
|
||||
setTimeout(this.publishAvailability.bind(this, device, true), this.getTimeout(device) + utils.seconds(1)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private resetTimer(device: Device): void {
|
||||
clearTimeout(this.timers[device.ieeeAddr]);
|
||||
|
||||
// If the timer triggers, the device is not available anymore otherwise resetTimer already have 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));
|
||||
} else {
|
||||
this.timers[device.ieeeAddr] = setTimeout(
|
||||
() => this.publishAvailability(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();
|
||||
this.pingQueueExecuteNext().catch(utils.noop);
|
||||
}
|
||||
|
||||
private removeFromPingQueue(device: Device): void {
|
||||
const index = this.pingQueue.findIndex((d) => d.ieeeAddr === device.ieeeAddr);
|
||||
index != -1 && this.pingQueue.splice(index, 1);
|
||||
if (index !== -1) {
|
||||
this.pingQueue.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private async pingQueueExecuteNext(): Promise<void> {
|
||||
if (this.pingQueue.length === 0 || this.pingQueueExecuting) return;
|
||||
this.pingQueueExecuting = true;
|
||||
if (this.pingQueue.length === 0 || this.pingQueueExecuting) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 = 0; i < attempts; i++) {
|
||||
|
||||
for (let i = 1; i <= attempts; i++) {
|
||||
try {
|
||||
// Enable recovery if device is marked as available and first ping fails.
|
||||
const disableRecovery = !(i == 1 && available);
|
||||
await device.zh.ping(disableRecovery);
|
||||
pingedSuccessfully = true;
|
||||
logger.debug(`Successfully pinged '${device.name}' (attempt ${i + 1}/${attempts})`);
|
||||
await device.zh.ping(!available || i !== 2);
|
||||
|
||||
pingSuccess = true;
|
||||
|
||||
logger.debug(`Successfully pinged '${device.name}' (attempt ${i}/${attempts})`);
|
||||
break;
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to ping '${device.name}' (attempt ${i + 1}/${attempts}, ${error.message})`);
|
||||
logger.warning(`Failed to ping '${device.name}' (attempt ${i}/${attempts}, ${(error as Error).message})`);
|
||||
|
||||
// Try again in 3 seconds.
|
||||
const lastAttempt = i - 1 === attempts;
|
||||
!lastAttempt && await utils.sleep(3);
|
||||
if (i !== attempts) {
|
||||
await utils.sleep(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.publishAvailability(device, !pingedSuccessfully);
|
||||
this.resetTimer(device);
|
||||
if (this.stopped) {
|
||||
// Exit here to avoid triggering any follow-up activity (e.g., re-queuing another ping attempt).
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
await utils.sleep(2);
|
||||
|
||||
this.pingQueueExecuting = false;
|
||||
this.pingQueueExecuteNext();
|
||||
|
||||
await this.pingQueueExecuteNext();
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onEntityRenamed(this, (data) => {
|
||||
if (this.stopped) {
|
||||
throw new Error("This extension cannot be restarted.");
|
||||
}
|
||||
|
||||
this.eventBus.onEntityRenamed(this, async (data) => {
|
||||
if (utils.isAvailabilityEnabledForEntity(data.entity, settings.get())) {
|
||||
this.mqtt.publish(`${data.from}/availability`, null, {retain: true, qos: 0});
|
||||
this.publishAvailability(data.entity, false, true);
|
||||
await this.mqtt.publish(`${data.from}/availability`, "", {clientOptions: {retain: true, qos: 1}});
|
||||
await this.publishAvailability(data.entity, false, true);
|
||||
}
|
||||
});
|
||||
|
||||
this.eventBus.onDeviceRemoved(this, (data) => clearTimeout(this.timers[data.ieeeAddr]));
|
||||
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));
|
||||
this.publishAvailabilityForAllEntities();
|
||||
}
|
||||
|
||||
@bind private publishAvailabilityForAllEntities(): void {
|
||||
for (const entity of [...this.zigbee.devices(false), ...this.zigbee.groups()]) {
|
||||
if (utils.isAvailabilityEnabledForEntity(entity, settings.get())) {
|
||||
// Publish initial availablility
|
||||
this.publishAvailability(entity, true, false, true);
|
||||
// Publish initial availability
|
||||
await this.publishAvailabilityForAllEntities();
|
||||
|
||||
if (entity.isDevice()) {
|
||||
this.resetTimer(entity);
|
||||
// Start availability for the devices
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
if (utils.isAvailabilityEnabledForEntity(device, settings.get())) {
|
||||
this.resetTimer(device);
|
||||
|
||||
// If an active device is initially unavailable, ping it.
|
||||
if (this.isActiveDevice(entity) && !this.isAvailable(entity)) {
|
||||
this.addToPingQueue(entity);
|
||||
}
|
||||
// If an active device is unavailable on start, add it to the pingqueue immediately.
|
||||
if (this.isActiveDevice(device) && !this.isAvailable(device)) {
|
||||
this.addToPingQueue(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private publishAvailability(entity: Device | Group, logLastSeen: boolean,
|
||||
forcePublish=false, skipGroups=false): 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.`);
|
||||
logger.debug(`Active device '${entity.name}' was last seen '${(ago / utils.minutes(1)).toFixed(2)}' minutes ago.`);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Passive device '${entity.name}' was last seen '${(ago / utils.hours(1)).toFixed(2)}' hours ago.`);
|
||||
logger.debug(`Passive device '${entity.name}' was last seen '${(ago / utils.hours(1)).toFixed(2)}' hours ago.`);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
this.mqtt.publish(topic, payload, {retain: true, qos: 0});
|
||||
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()) {
|
||||
this.zigbee.groups().filter((g) => g.hasMember(entity))
|
||||
.filter((g) => utils.isAvailabilityEnabledForEntity(g, settings.get()))
|
||||
.forEach((g) => this.publishAvailability(g, false, forcePublish));
|
||||
for (const group of this.zigbee.groupsIterator()) {
|
||||
if (group.hasMember(entity) && utils.isAvailabilityEnabledForEntity(group, settings.get())) {
|
||||
await this.publishAvailability(group, false, forcePublish);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind private onLastSeenChanged(data: eventdata.LastSeenChanged): void {
|
||||
@bind private async onLastSeenChanged(data: eventdata.LastSeenChanged): Promise<void> {
|
||||
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.publishAvailability(data.device, false);
|
||||
this.resetTimer(data.device, true);
|
||||
await this.publishAvailability(data.device, false);
|
||||
}
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
Object.values(this.timers).forEach((t) => clearTimeout(t));
|
||||
super.stop();
|
||||
this.stopped = true;
|
||||
this.pingQueue = [];
|
||||
|
||||
for (const [, t] of this.timers) {
|
||||
clearTimeout(t);
|
||||
}
|
||||
|
||||
await super.stop();
|
||||
}
|
||||
|
||||
private retrieveState(device: Device): void {
|
||||
@@ -192,23 +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`);
|
||||
// Color and color temperature converters do both, only needs to be called once.
|
||||
for (const item of retrieveOnReconnect) {
|
||||
if (item.condition && this.state.get(device) && !item.condition(this.state.get(device))) continue;
|
||||
const converter = device.definition.toZigbee.find((c) => c.key.find((k) => item.keys.includes(k)));
|
||||
await converter?.convertGet?.(device.endpoint(), item.keys[0],
|
||||
{message: this.state.get(device), mapped: device.definition})
|
||||
.catch((e) => {
|
||||
logger.error(`Failed to read state of '${device.name}' after reconnect (${e.message})`);
|
||||
});
|
||||
await utils.sleep(500);
|
||||
}
|
||||
}, utils.seconds(2));
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}, utils.seconds(2)),
|
||||
);
|
||||
}
|
||||
|
||||
this.retrieveStateDebouncers[device.ieeeAddr]?.();
|
||||
this.retrieveStateDebouncers.get(device.ieeeAddr)?.();
|
||||
}
|
||||
}
|
||||
|
||||
+455
-317
@@ -1,360 +1,454 @@
|
||||
import * as settings from '../util/settings';
|
||||
import logger from '../util/logger';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import debounce from 'debounce';
|
||||
import * as zigbeeHerdsman from 'zigbee-herdsman/dist';
|
||||
import bind from 'bind-decorator';
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
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";
|
||||
|
||||
const legacyApi = settings.get().advanced.legacy_api;
|
||||
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/(bind|unbind)/.+$`);
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
|
||||
const clusterCandidates = ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl', 'closuresWindowCovering',
|
||||
'hvacThermostat', 'msIlluminanceMeasurement', 'msTemperatureMeasurement', 'msRelativeHumidity',
|
||||
'msSoilMoisture', 'msCO2'];
|
||||
const ALL_CLUSTER_CANDIDATES: readonly ClusterName[] = [
|
||||
"genScenes",
|
||||
"genOnOff",
|
||||
"genLevelCtrl",
|
||||
"lightingColorCtrl",
|
||||
"closuresWindowCovering",
|
||||
"hvacThermostat",
|
||||
"msIlluminanceMeasurement",
|
||||
"msTemperatureMeasurement",
|
||||
"msRelativeHumidity",
|
||||
"msSoilMoisture",
|
||||
"msCO2",
|
||||
];
|
||||
|
||||
// See zigbee-herdsman-converters
|
||||
const defaultBindGroup = {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 defaultReportConfiguration = {
|
||||
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']);
|
||||
const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean; colorXY: boolean}> => {
|
||||
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,
|
||||
colorXY: (value & 1<<3) > 0,
|
||||
colorTemperature: (value & (1 << 4)) > 0,
|
||||
colorXY: (value & (1 << 3)) > 0,
|
||||
};
|
||||
};
|
||||
|
||||
const reportClusters: {[s: string]:
|
||||
{attribute: string, minimumReportInterval: number, maximumReportInterval: number, reportableChange: number
|
||||
condition?: (endpoint: zh.Endpoint) => Promise<boolean>}[]} =
|
||||
{
|
||||
'genOnOff': [
|
||||
{attribute: 'onOff', ...defaultReportConfiguration, minimumReportInterval: 0, reportableChange: 0},
|
||||
],
|
||||
'genLevelCtrl': [
|
||||
{attribute: 'currentLevel', ...defaultReportConfiguration},
|
||||
],
|
||||
'lightingColorCtrl': [
|
||||
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', ...defaultReportConfiguration,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
attribute: "colorTemperature" as const,
|
||||
...DEFAULT_REPORT_CONFIG,
|
||||
condition: async (endpoint: zh.Endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
},
|
||||
{
|
||||
attribute: 'currentX', ...defaultReportConfiguration,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
attribute: "currentX" as const,
|
||||
...DEFAULT_REPORT_CONFIG,
|
||||
condition: async (endpoint: zh.Endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
{
|
||||
attribute: 'currentY', ...defaultReportConfiguration,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
attribute: "currentY" as const,
|
||||
...DEFAULT_REPORT_CONFIG,
|
||||
condition: async (endpoint: zh.Endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
],
|
||||
'closuresWindowCovering': [
|
||||
{attribute: 'currentPositionLiftPercentage', ...defaultReportConfiguration},
|
||||
{attribute: 'currentPositionTiltPercentage', ...defaultReportConfiguration},
|
||||
closuresWindowCovering: [
|
||||
{attribute: "currentPositionLiftPercentage" as const, ...DEFAULT_REPORT_CONFIG},
|
||||
{attribute: "currentPositionTiltPercentage" as const, ...DEFAULT_REPORT_CONFIG},
|
||||
],
|
||||
};
|
||||
|
||||
type PollOnMessage = {
|
||||
cluster: {[s: string]: {type: string, data: KeyValue}[]}
|
||||
read: {cluster: string, attributes: string[], attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise<string[]>}
|
||||
manufacturerIDs: number[],
|
||||
manufacturerNames: string [],
|
||||
}[];
|
||||
|
||||
const pollOnMessage: 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: {}},
|
||||
],
|
||||
genScenes: [
|
||||
{type: 'commandRecall', 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: {}}],
|
||||
},
|
||||
// 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: [
|
||||
zigbeeHerdsman.Zcl.ManufacturerCode.Philips,
|
||||
zigbeeHerdsman.Zcl.ManufacturerCode.ATMEL,
|
||||
zigbeeHerdsman.Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
|
||||
zigbeeHerdsman.Zcl.ManufacturerCode.MUELLER_LICHT_INT,
|
||||
zigbeeHerdsman.Zcl.ManufacturerCode.TELINK,
|
||||
zigbeeHerdsman.Zcl.ManufacturerCode.BUSCH_JAEGER,
|
||||
],
|
||||
manufacturerNames: [
|
||||
'GLEDOPTO',
|
||||
'Trust International B.V.\u0000',
|
||||
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
|
||||
Zcl.ManufacturerCode.ATMEL,
|
||||
Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
|
||||
Zcl.ManufacturerCode.MUELLER_LICHT_INTERNATIONAL_INC,
|
||||
Zcl.ManufacturerCode.TELINK_MICRO,
|
||||
Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO,
|
||||
],
|
||||
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: {}},
|
||||
],
|
||||
genScenes: [
|
||||
{type: 'commandRecall', data: {}},
|
||||
{type: "commandOn", data: {}},
|
||||
{type: "commandOff", data: {}},
|
||||
{type: "commandOffWithEffect", data: {}},
|
||||
{type: "commandToggle", 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: [
|
||||
zigbeeHerdsman.Zcl.ManufacturerCode.Philips,
|
||||
zigbeeHerdsman.Zcl.ManufacturerCode.ATMEL,
|
||||
zigbeeHerdsman.Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
|
||||
zigbeeHerdsman.Zcl.ManufacturerCode.MUELLER_LICHT_INT,
|
||||
zigbeeHerdsman.Zcl.ManufacturerCode.TELINK,
|
||||
zigbeeHerdsman.Zcl.ManufacturerCode.BUSCH_JAEGER,
|
||||
],
|
||||
manufacturerNames: [
|
||||
'GLEDOPTO',
|
||||
'Trust International B.V.\u0000',
|
||||
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
|
||||
Zcl.ManufacturerCode.ATMEL,
|
||||
Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
|
||||
Zcl.ManufacturerCode.MUELLER_LICHT_INTERNATIONAL_INC,
|
||||
Zcl.ManufacturerCode.TELINK_MICRO,
|
||||
Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO,
|
||||
],
|
||||
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[] = [];
|
||||
supportedAttrs.colorXY && readAttrs.push('currentX', 'currentY');
|
||||
supportedAttrs.colorTemperature && readAttrs.push('colorTemperature');
|
||||
const readAttrs: TClusterAttributeKeys<"lightingColorCtrl"> = [];
|
||||
|
||||
if (supportedAttrs.colorXY) {
|
||||
readAttrs.push("currentX", "currentY");
|
||||
}
|
||||
|
||||
if (supportedAttrs.colorTemperature) {
|
||||
readAttrs.push("colorTemperature");
|
||||
}
|
||||
|
||||
return readAttrs;
|
||||
},
|
||||
},
|
||||
manufacturerIDs: [
|
||||
zigbeeHerdsman.Zcl.ManufacturerCode.Philips,
|
||||
zigbeeHerdsman.Zcl.ManufacturerCode.ATMEL,
|
||||
zigbeeHerdsman.Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
|
||||
zigbeeHerdsman.Zcl.ManufacturerCode.MUELLER_LICHT_INT,
|
||||
zigbeeHerdsman.Zcl.ManufacturerCode.TELINK,
|
||||
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
|
||||
Zcl.ManufacturerCode.ATMEL,
|
||||
Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
|
||||
Zcl.ManufacturerCode.MUELLER_LICHT_INTERNATIONAL_INC,
|
||||
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[], skipDisableReporting: boolean
|
||||
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|binds/clear)`);
|
||||
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: 'bind' | 'unbind' = null;
|
||||
let sourceKey = null;
|
||||
let targetKey = null;
|
||||
let clusters = null;
|
||||
let 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 (legacyApi && data.topic.match(legacyTopicRegex)) {
|
||||
const topic = data.topic.replace(`${settings.get().mqtt.base_topic}/bridge/`, '');
|
||||
type = topic.split('/')[0] as 'bind' | 'unbind';
|
||||
sourceKey = topic.replace(`${type}/`, '');
|
||||
targetKey = data.message;
|
||||
} else if (data.topic.match(topicRegex)) {
|
||||
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);
|
||||
if (!type) return null;
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
if (data.topic.endsWith("binds/clear")) {
|
||||
const message = JSON.parse(data.message) as Zigbee2MQTTAPI["bridge/request/device/binds/clear"];
|
||||
|
||||
let error = null;
|
||||
const parsedSource = utils.parseEntityID(sourceKey);
|
||||
const parsedTarget = utils.parseEntityID(targetKey);
|
||||
const source = this.zigbee.resolveEntity(parsedSource.ID);
|
||||
const target = targetKey === 'default_bind_group' ?
|
||||
defaultBindGroup : this.zigbee.resolveEntity(parsedTarget.ID);
|
||||
const responseData: KeyValue = {from: sourceKey, to: targetKey};
|
||||
if (typeof message !== "object" || typeof message.target !== "string") {
|
||||
await this.publishResponse("binds/clear", message, {}, "Invalid payload");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!source || !(source instanceof Device)) {
|
||||
error = `Source device '${sourceKey}' does not exist`;
|
||||
} else if (!target) {
|
||||
error = `Target device or group '${targetKey}' does not exist`;
|
||||
} else {
|
||||
const successfulClusters: string[] = [];
|
||||
const failedClusters = [];
|
||||
const attemptedClusters = [];
|
||||
const target = this.zigbee.resolveEntity(message.target);
|
||||
|
||||
const bindSource: zh.Endpoint = source.endpoint(parsedSource.endpoint);
|
||||
let bindTarget: number | zh.Group | zh.Endpoint = null;
|
||||
if (target instanceof Device) bindTarget = target.endpoint(parsedTarget.endpoint);
|
||||
else if (target instanceof Group) bindTarget = target.zh;
|
||||
else bindTarget = Number(target.ID);
|
||||
if (!(target instanceof Device)) {
|
||||
await this.publishResponse("binds/clear", message, {}, "Invalid target");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find which clusters are supported by both the source and target.
|
||||
// Groups are assumed to support all clusters.
|
||||
for (const cluster of clusterCandidates) {
|
||||
if (clusters && !clusters.includes(cluster)) continue;
|
||||
let matchingClusters = false;
|
||||
// this list is raw (not resolved) to allow clearing any specific target (not only currently known)
|
||||
const eui64List = message.ieee_list ?? ["0xffffffffffffffff"];
|
||||
|
||||
const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' ||
|
||||
(target as Device).zh.type === 'Coordinator';
|
||||
await target.zh.clearAllBindings(eui64List);
|
||||
|
||||
if (!anyClusterValid && utils.isEndpoint(bindTarget)) {
|
||||
matchingClusters = ((bindTarget.supportsInputCluster(cluster) &&
|
||||
bindSource.supportsOutputCluster(cluster)) ||
|
||||
(bindSource.supportsInputCluster(cluster) &&
|
||||
bindTarget.supportsOutputCluster(cluster)) );
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/device/binds/clear"] = {
|
||||
target: message.target,
|
||||
ieee_list: eui64List,
|
||||
};
|
||||
|
||||
await this.publishResponse("binds/clear", message, responseData);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
const [raw, parsed, error] = this.parseMQTTMessage(data);
|
||||
|
||||
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})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sourceValid = bindSource.supportsInputCluster(cluster) ||
|
||||
bindSource.supportsOutputCluster(cluster);
|
||||
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 ( sourceValid && (anyClusterValid || matchingClusters)) {
|
||||
logger.debug(`${type}ing cluster '${cluster}' from '${source.name}' to '${target.name}'`);
|
||||
attemptedClusters.push(cluster);
|
||||
if (failedClusters.length === attemptedClusters.length) {
|
||||
await this.publishResponse(parsed.type, raw, {}, `Failed to ${type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (type === 'bind') {
|
||||
await bindSource.bind(cluster, bindTarget);
|
||||
} else {
|
||||
await bindSource.unbind(cluster, bindTarget);
|
||||
}
|
||||
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,
|
||||
};
|
||||
|
||||
successfulClusters.push(cluster);
|
||||
logger.info(
|
||||
`Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from ` +
|
||||
`'${source.name}' to '${target.name}'`,
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
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})`,
|
||||
);
|
||||
await this.publishResponse(parsed.type, raw, responseData);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
}
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_${type}_failed`,
|
||||
message: {from: source.name, to: target.name, cluster}}),
|
||||
);
|
||||
private async publishResponse<T extends Zigbee2MQTTResponseEndpoints>(
|
||||
type: ParsedMQTTMessage["type"] | "binds/clear",
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onGroupMembersChanged(data: eventdata.GroupMembersChanged): Promise<void> {
|
||||
if (data.action === "add") {
|
||||
const bindsToGroup: zh.Bind[] = [];
|
||||
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
for (const endpoint of device.zh.endpoints) {
|
||||
for (const bind of endpoint.binds) {
|
||||
if (bind.target === data.group.zh) {
|
||||
bindsToGroup.push(bind);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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(legacyTopicRegex);
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, 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') {
|
||||
const bindsToGroup = this.zigbee.devices(false).map((c) => c.zh.endpoints)
|
||||
.reduce((a, v) => a.concat(v)).map((e) => e.binds)
|
||||
.reduce((a, v) => a.concat(v)).filter((b) => b.target === data.group.zh);
|
||||
await this.setupReporting(bindsToGroup);
|
||||
} else { // action === remove/remove_all
|
||||
} else {
|
||||
// action === remove/remove_all
|
||||
if (!data.skipDisableReporting) {
|
||||
await this.disableUnnecessaryReportings(data.endpoint);
|
||||
}
|
||||
@@ -362,36 +456,54 @@ 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) => {
|
||||
const supportsInputCluster = e.supportsInputCluster(bind.cluster.name);
|
||||
const hasConfiguredReporting = !!e.configuredReportings.find((c) => c.cluster.name === bind.cluster.name);
|
||||
const hasBind = !!e.binds.find((b) => b.cluster.name === bind.cluster.name && b.target === coordinatorEp);
|
||||
return supportsInputCluster && !(hasBind && hasConfiguredReporting);
|
||||
if (!e.supportsInputCluster(bind.cluster.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasConfiguredReporting = e.configuredReportings.some((c) => c.cluster.name === bind.cluster.name);
|
||||
|
||||
if (!hasConfiguredReporting) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasBind = e.binds.some((b) => b.cluster.name === bind.cluster.name && b.target === coordinatorEp);
|
||||
|
||||
return !hasBind;
|
||||
});
|
||||
}
|
||||
|
||||
async setupReporting(binds: zh.Bind[]): Promise<void> {
|
||||
const coordinatorEndpoint = this.zigbee.firstCoordinatorEndpoint();
|
||||
for (const bind of binds.filter((b) => b.cluster.name in reportClusters)) {
|
||||
for (const endpoint of this.getSetupReportingEndpoints(bind, coordinatorEndpoint)) {
|
||||
const entity = `${this.zigbee.resolveEntity(endpoint.getDevice()).name}/${endpoint.ID}`;
|
||||
try {
|
||||
await endpoint.bind(bind.cluster.name, coordinatorEndpoint);
|
||||
const items = [];
|
||||
for (const c of reportClusters[bind.cluster.name]) {
|
||||
/* istanbul ignore else */
|
||||
if (!c.condition || await c.condition(endpoint)) {
|
||||
const i = {...c};
|
||||
delete i.condition;
|
||||
items.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
await endpoint.configureReporting(bind.cluster.name, items);
|
||||
logger.info(`Successfully setup reporting for '${entity}' cluster '${bind.cluster.name}'`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to setup reporting for '${entity}' cluster '${bind.cluster.name}'`);
|
||||
for (const bind of binds) {
|
||||
if (bind.cluster.name in REPORT_CLUSTERS) {
|
||||
for (const endpoint of this.getSetupReportingEndpoints(bind, coordinatorEndpoint)) {
|
||||
// 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 = [];
|
||||
|
||||
// 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({attribute, minimumReportInterval, maximumReportInterval, reportableChange});
|
||||
}
|
||||
}
|
||||
|
||||
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 as Error).message})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -401,39 +513,52 @@ 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)) {
|
||||
for (const endpoint of device.zh.endpoints) {
|
||||
allBinds.push(...endpoint.binds);
|
||||
}
|
||||
}
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
const device = this.zigbee.resolveEntity(endpoint.getDevice()) as Device;
|
||||
const entity = `${device.name}/${endpoint.ID}`;
|
||||
const boundClusters = endpoint.binds.filter((b) => b.target === coordinator)
|
||||
.map((b) => b.cluster.name);
|
||||
const requiredClusters = this.zigbee.devices(false).map((c) => c.zh.endpoints)
|
||||
.reduce((a, v) => a.concat(v))
|
||||
.map((e) => e.binds).reduce((a, v) => a.concat(v)).filter((bind) => {
|
||||
if (utils.isEndpoint(bind.target)) {
|
||||
return bind.target === endpoint;
|
||||
} else {
|
||||
return bind.target.members.includes(endpoint);
|
||||
}
|
||||
}).map((b) => b.cluster.name).filter((v, i, a) => a.indexOf(v) === i);
|
||||
const requiredClusters: string[] = [];
|
||||
const boundClusters: string[] = [];
|
||||
|
||||
for (const cluster of boundClusters.filter((c) => !requiredClusters.includes(c) && c in reportClusters)) {
|
||||
for (const bind of allBinds) {
|
||||
if (utils.isZHEndpoint(bind.target) ? bind.target === endpoint : bind.target.members.includes(endpoint)) {
|
||||
requiredClusters.push(bind.cluster.name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const b of endpoint.binds) {
|
||||
if (b.target === coordinator && !requiredClusters.includes(b.cluster.name) && b.cluster.name in REPORT_CLUSTERS) {
|
||||
boundClusters.push(b.cluster.name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cluster of boundClusters) {
|
||||
try {
|
||||
await endpoint.unbind(cluster, coordinator);
|
||||
|
||||
const items = [];
|
||||
for (const item of reportClusters[cluster]) {
|
||||
/* istanbul ignore else */
|
||||
if (!item.condition || await item.condition(endpoint)) {
|
||||
const i = {...item};
|
||||
delete i.condition;
|
||||
items.push({...i, maximumReportInterval: 0xFFFF});
|
||||
|
||||
// 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({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.warn(`Failed to disable reporting for '${entity}' cluster '${cluster}'`);
|
||||
logger.warning(`Failed to disable reporting for '${entity}' cluster '${cluster}' (${(error as Error).message})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,48 +575,61 @@ export default class Bind extends Extension {
|
||||
* When dimming the bulb via the dimmer switch the state is therefore not reported.
|
||||
* When we receive a message from a Hue dimmer we read the brightness from the bulb (if bound).
|
||||
*/
|
||||
const polls = pollOnMessage.filter((p) =>
|
||||
p.cluster[data.cluster]?.find((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)));
|
||||
const polls = POLL_ON_MESSAGE.filter((p) =>
|
||||
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 (group) {
|
||||
group.zh.members.forEach((m) => toPoll.add(m));
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)) ||
|
||||
!endpoint.supportsInputCluster(poll.read.cluster)) {
|
||||
if (
|
||||
// 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;
|
||||
}
|
||||
|
||||
let readAttrs = poll.read.attributes;
|
||||
|
||||
if (poll.read.attributesForEndpoint) {
|
||||
const attrsForEndpoint = await poll.read.attributesForEndpoint(endpoint);
|
||||
readAttrs = [...poll.read.attributes, ...attrsForEndpoint];
|
||||
}
|
||||
|
||||
const key = `${endpoint.getDevice().ieeeAddr}_${endpoint.ID}_${pollOnMessage.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}`);
|
||||
// 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);
|
||||
}
|
||||
|
||||
+602
-405
File diff suppressed because it is too large
Load Diff
+91
-72
@@ -1,136 +1,155 @@
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import logger from '../util/logger';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import zhc from 'zigbee-herdsman-converters';
|
||||
import Extension from './extension';
|
||||
import bind from 'bind-decorator';
|
||||
import Device from '../model/device';
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
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
|
||||
*/
|
||||
export default class Configure extends Extension {
|
||||
private configuring = new Set();
|
||||
private attempts: {[s: string]: number} = {};
|
||||
private configuring = new Set<string>();
|
||||
private attempts = new Map<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.warn(`Skipping configure of '${device.name}', device does not require this.`);
|
||||
return;
|
||||
}
|
||||
|
||||
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 () => {
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
await this.configure(device, 'started');
|
||||
// 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")) {
|
||||
// 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");
|
||||
}
|
||||
});
|
||||
|
||||
this.eventBus.onDeviceJoined(this, (data) => {
|
||||
if (data.device.zh.meta.hasOwnProperty('configured')) {
|
||||
this.eventBus.onDeviceJoined(this, async (data) => {
|
||||
if (data.device.zh.meta.configured !== undefined) {
|
||||
delete data.device.zh.meta.configured;
|
||||
data.device.zh.save();
|
||||
}
|
||||
|
||||
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'));
|
||||
// TODO: this is triggering for any `data.status`, but should only for `successful`? (relies on `device.definition?` early-return?)
|
||||
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',
|
||||
force=false, throwError=false): Promise<void> {
|
||||
if (!force) {
|
||||
if (device.options.disabled || !device.definition?.configure || !device.zh.interviewCompleted) {
|
||||
return;
|
||||
}
|
||||
private async configure(
|
||||
device: Device,
|
||||
event: "started" | "zigbee_event" | "reporting_disabled" | "mqtt_message",
|
||||
force = false,
|
||||
throwError = false,
|
||||
): Promise<void> {
|
||||
if (!device.definition?.configure) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.zh.meta?.hasOwnProperty('configured') &&
|
||||
device.zh.meta.configured === zhc.getConfigureKey(device.definition)) {
|
||||
const definitionVersion = device.definition.version;
|
||||
|
||||
if (!force) {
|
||||
if (device.options.disabled || !device.interviewed) {
|
||||
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;
|
||||
}
|
||||
|
||||
const shouldReconfigure =
|
||||
// Should always reconfigure when not configured before
|
||||
device.zh.meta?.configured === undefined ||
|
||||
// Or should reconfigure when definition.version is not '0.0.0' and differs from last `meta.configured`.
|
||||
// In older Z2M versions the stored `meta.configured` was the hash of the configure function.
|
||||
// Since we don't want to reconfigure all devices, we don't re-configure when the definition has the default version of '0.0.0'.
|
||||
(definitionVersion !== "0.0.0" && device.zh.meta?.configured !== definitionVersion);
|
||||
if (!shouldReconfigure) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.configuring.has(device.ieeeAddr) || (this.attempts[device.ieeeAddr] >= 3 && !force)) {
|
||||
if (this.configuring.has(device.ieeeAddr)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attempts = this.attempts.get(device.ieeeAddr) ?? 0;
|
||||
|
||||
if (attempts >= 3 && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.configuring.add(device.ieeeAddr);
|
||||
|
||||
if (!this.attempts.hasOwnProperty(device.ieeeAddr)) {
|
||||
this.attempts[device.ieeeAddr] = 0;
|
||||
}
|
||||
|
||||
logger.info(`Configuring '${device.name}'`);
|
||||
|
||||
try {
|
||||
await device.definition.configure(device.zh, this.zigbee.firstCoordinatorEndpoint(), logger,
|
||||
device.options);
|
||||
logger.info(`Successfully configured '${device.name}'`);
|
||||
device.zh.meta.configured = zhc.getConfigureKey(device.definition);
|
||||
await device.definition.configure(device.zh, this.zigbee.firstCoordinatorEndpoint(), device.definition);
|
||||
this.attempts.delete(device.ieeeAddr);
|
||||
logger.info(`Successfully configured '${device.name}' (definition v${definitionVersion})`);
|
||||
device.zh.meta.configured = definitionVersion;
|
||||
device.zh.save();
|
||||
this.eventBus.emitDevicesChanged();
|
||||
} catch (error) {
|
||||
this.attempts[device.ieeeAddr]++;
|
||||
const attempt = this.attempts[device.ieeeAddr];
|
||||
const msg = `Failed to configure '${device.name}', attempt ${attempt} (${error.stack})`;
|
||||
const newAttempts = attempts + 1;
|
||||
this.attempts.set(device.ieeeAddr, newAttempts);
|
||||
|
||||
const msg = `Failed to configure '${device.name}', attempt ${newAttempts} (${(error as Error).stack})`;
|
||||
logger.error(msg);
|
||||
|
||||
if (throwError) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
this.configuring.delete(device.ieeeAddr);
|
||||
}
|
||||
|
||||
this.configuring.delete(device.ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-10
@@ -1,18 +1,18 @@
|
||||
abstract class Extension {
|
||||
protected zigbee: Zigbee;
|
||||
protected mqtt: MQTT;
|
||||
protected mqtt: Mqtt;
|
||||
protected state: State;
|
||||
protected publishEntityState: PublishEntityState;
|
||||
protected eventBus: EventBus;
|
||||
protected enableDisableExtension: (enable: boolean, name: string) => Promise<void>;
|
||||
protected restartCallback: () => void;
|
||||
protected restartCallback: () => Promise<void>;
|
||||
protected addExtension: (extension: Extension) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Besides intializing variables, the constructor should do nothing!
|
||||
* 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
|
||||
@@ -20,9 +20,16 @@ abstract class Extension {
|
||||
* @param {restartCallback} restartCallback Restart Zigbee2MQTT
|
||||
* @param {addExtension} addExtension Add an extension
|
||||
*/
|
||||
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => void, addExtension: (extension: Extension) => Promise<void>) {
|
||||
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>,
|
||||
) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.state = state;
|
||||
@@ -36,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,18 +1,67 @@
|
||||
import zhc from 'zigbee-herdsman-converters';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import type {ExternalDefinitionWithExtend} from "zigbee-herdsman-converters";
|
||||
|
||||
export default class ExternalConverters extends Extension {
|
||||
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => void, addExtension: (extension: Extension) => Promise<void>) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
|
||||
import {addExternalDefinition, removeExternalDefinitions} from "zigbee-herdsman-converters";
|
||||
|
||||
for (const definition of utils.getExternalConvertersDefinitions(settings.get())) {
|
||||
const toAdd = {...definition};
|
||||
delete toAdd['homeassistant'];
|
||||
zhc.addDeviceDefinition(toAdd);
|
||||
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,
|
||||
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,
|
||||
"converter",
|
||||
"external_converters",
|
||||
);
|
||||
}
|
||||
|
||||
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,109 +0,0 @@
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import fs from 'fs';
|
||||
import data from './../util/data';
|
||||
import path from 'path';
|
||||
import logger from './../util/logger';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import bind from 'bind-decorator';
|
||||
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};
|
||||
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);
|
||||
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);
|
||||
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 loadUserDefinedExtensions(): void {
|
||||
const extensions = this.getListOfUserDefinedExtensions();
|
||||
extensions
|
||||
.map(({code, name}) => utils.loadModuleFromText(code, name))
|
||||
.map(this.loadExtension);
|
||||
}
|
||||
|
||||
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) {
|
||||
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!);
|
||||
|
||||
// change ext so Z2M doesn't try to load it again and again
|
||||
fs.renameSync(filePath, `${filePath}.invalid`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
-118
@@ -1,142 +1,201 @@
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import gzipStatic, {RequestHandler} from 'connect-gzip-static';
|
||||
import finalhandler from 'finalhandler';
|
||||
import logger from '../util/logger';
|
||||
import frontend from 'zigbee2mqtt-frontend';
|
||||
import WebSocket from 'ws';
|
||||
import net from 'net';
|
||||
import url from 'url';
|
||||
import fs from 'fs';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import Extension from './extension';
|
||||
import bind from 'bind-decorator';
|
||||
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 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 retainedMessages = new Map();
|
||||
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;
|
||||
|
||||
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => void, addExtension: (extension: Extension) => Promise<void>) {
|
||||
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);
|
||||
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
|
||||
}
|
||||
|
||||
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.`); /* eslint-disable-line max-len */
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
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);
|
||||
|
||||
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}`);
|
||||
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!);
|
||||
|
||||
// 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> {
|
||||
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));
|
||||
await super.stop();
|
||||
|
||||
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('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);
|
||||
this.mqtt.onMessage(`${this.mqttBaseTopic}/${topic}`, stringify(payload));
|
||||
this.mqtt.onMessage(`${this.mqttBaseTopic}/${topic}`, Buffer.from(stringify(payload)));
|
||||
}
|
||||
});
|
||||
|
||||
for (const [key, value] of this.retainedMessages) {
|
||||
ws.send(stringify({topic: key, payload: value}));
|
||||
for (const [topic, payload] of Object.entries(this.mqtt.retainedMessages)) {
|
||||
if (topic.startsWith(`${this.mqttBaseTopic}/`)) {
|
||||
ws.send(
|
||||
stringify({
|
||||
// Send topic without base_topic
|
||||
topic: topic.substring(this.mqttBaseTopic.length + 1),
|
||||
payload: utils.parseJSON(payload.payload, payload.payload),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
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) {
|
||||
@@ -147,23 +206,34 @@ export default class Frontend extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
@bind private onMQTTPublishMessage(data: eventdata.MQTTMessagePublished): void {
|
||||
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);
|
||||
if (data.options.retain) {
|
||||
this.retainedMessages.set(topic, payload);
|
||||
}
|
||||
@bind private onMQTTPublishMessageOrEntityState(data: eventdata.MQTTMessagePublished | eventdata.PublishEntityState): void {
|
||||
let topic: string;
|
||||
let payload: KeyValue | string;
|
||||
|
||||
if (this.wss) {
|
||||
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;
|
||||
|
||||
+224
-282
@@ -1,173 +1,145 @@
|
||||
import * as settings from '../util/settings';
|
||||
import logger from '../util/logger';
|
||||
import utils from '../util/utils';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import bind from 'bind-decorator';
|
||||
import Extension from './extension';
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
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 topicRegex =
|
||||
new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
|
||||
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/(.+)/(remove|add|remove_all)$`);
|
||||
const legacyTopicRegexRemoveAll = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/remove_all$`);
|
||||
|
||||
const stateProperties: {[s: string]: (value: string, exposes: zhc.DefinitionExpose[]) => boolean} = {
|
||||
'state': () => true,
|
||||
'brightness': (value, exposes) =>
|
||||
!!exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'brightness')),
|
||||
'color_temp': (value, exposes) =>
|
||||
!!exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'color_temp')),
|
||||
'color': (value, exposes) =>
|
||||
!!exposes.find((e) => e.type === 'light' &&
|
||||
e.features.find((f) => f.name === 'color_xy' || f.name === 'color_hs')),
|
||||
'color_mode': (value, exposes) =>
|
||||
!!exposes.find((e) => e.type === 'light' && (
|
||||
(e.features.find((f) => f.name === `color_${value}`)) ||
|
||||
(value === 'color_temp' && e.features.find((f) => f.name === 'color_temp')) )),
|
||||
const STATE_PROPERTIES: Readonly<Record<string, (value: string, exposes: zhc.Expose[]) => boolean>> = {
|
||||
state: () => true,
|
||||
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) =>
|
||||
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,
|
||||
skipDisableReporting: boolean, resolvedEntityEndpoint: zh.Endpoint,
|
||||
type: "remove" | "add" | "remove_all";
|
||||
resolvedGroup?: Group;
|
||||
resolvedDevice?: Device;
|
||||
resolvedEndpoint?: zh.Endpoint;
|
||||
groupKey?: string;
|
||||
deviceKey?: string;
|
||||
endpointKey?: string | number;
|
||||
skipDisableReporting: boolean;
|
||||
}
|
||||
|
||||
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 zigbeeGroups = this.zigbee.groups();
|
||||
|
||||
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 = zigbeeGroups.find((g) => g.ID === groupID) || this.zigbee.createGroup(groupID);
|
||||
const settingsEndpoint = settingGroup.devices.map((d) => {
|
||||
const parsed = utils.parseEntityID(d);
|
||||
const entity = this.zigbee.resolveEntity(parsed.ID) as Device;
|
||||
if (!entity) logger.error(`Cannot find '${d}' of group '${settingGroup.friendly_name}'`);
|
||||
return {'endpoint': entity?.endpoint(parsed.endpoint), 'name': entity?.name};
|
||||
}).filter((e) => e.endpoint != null);
|
||||
|
||||
// In settings but not in zigbee
|
||||
for (const entity of settingsEndpoint) {
|
||||
if (!zigbeeGroup.zh.hasMember(entity.endpoint)) {
|
||||
addRemoveFromGroup('add', entity.name, settingGroup.friendly_name, entity.endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
|
||||
// In zigbee but not in settings
|
||||
for (const endpoint of zigbeeGroup.zh.members) {
|
||||
if (!settingsEndpoint.find((e) => e.endpoint === endpoint)) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name;
|
||||
addRemoveFromGroup('remove', deviceName, settingGroup.friendly_name, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const zigbeeGroup of zigbeeGroups) {
|
||||
if (!settingsGroups.find((g) => g.ID === zigbeeGroup.ID)) {
|
||||
for (const endpoint of zigbeeGroup.zh.members) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name;
|
||||
addRemoveFromGroup('remove', deviceName, zigbeeGroup.ID, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onStateChange(data: eventdata.StateChange): Promise<void> {
|
||||
const reason = 'groupOptimistic';
|
||||
if (data.reason === reason || data.reason === 'publishCached') {
|
||||
const reason = "groupOptimistic";
|
||||
|
||||
if (data.reason === reason || data.reason === "publishCached") {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: KeyValue = {};
|
||||
let endpointName: string | undefined;
|
||||
const endpointNames: string[] = data.entity instanceof Device ? data.entity.getEndpointNames() : [];
|
||||
|
||||
for (let prop of Object.keys(data.update)) {
|
||||
const value = data.update[prop];
|
||||
const endpointNameMatch = endpointNames.find((n) => prop.endsWith(`_${n}`));
|
||||
|
||||
let endpointName: string = null;
|
||||
for (let [prop, value] of Object.entries(data.update)) {
|
||||
const endpointNameMatch = utils.endpointNames.find((n) => prop.endsWith(`_${n}`));
|
||||
if (endpointNameMatch) {
|
||||
prop = prop.substring(0, prop.length - endpointNameMatch.length - 1);
|
||||
endpointName = endpointNameMatch;
|
||||
}
|
||||
|
||||
if (prop in stateProperties) {
|
||||
if (prop in STATE_PROPERTIES) {
|
||||
payload[prop] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length) {
|
||||
const payloadKeys = Object.keys(payload);
|
||||
|
||||
if (payloadKeys.length) {
|
||||
const entity = data.entity;
|
||||
const groups = this.zigbee.groups().filter((g) => {
|
||||
return g.options && (!g.options.hasOwnProperty('optimistic') || g.options.optimistic);
|
||||
});
|
||||
const groups = [];
|
||||
|
||||
for (const group of this.zigbee.groupsIterator()) {
|
||||
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;
|
||||
await this.publishEntityState(group, payload, reason);
|
||||
const endpoint = entity.endpoint(endpointName);
|
||||
|
||||
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;
|
||||
if (device.options.disabled) continue;
|
||||
|
||||
if (device.options.disabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const exposes = device.exposes();
|
||||
const memberPayload: KeyValue = {};
|
||||
Object.keys(payload).forEach((key) => {
|
||||
if (stateProperties[key](payload[key], exposes)) {
|
||||
|
||||
for (const key of payloadKeys) {
|
||||
if (STATE_PROPERTIES[key](payload[key], exposes)) {
|
||||
memberPayload[key] = payload[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const endpointName = device.endpointName(member);
|
||||
|
||||
if (endpointName) {
|
||||
Object.keys(memberPayload).forEach((key) => {
|
||||
for (const key of Object.keys(memberPayload)) {
|
||||
memberPayload[`${key}_${endpointName}`] = memberPayload[key];
|
||||
delete memberPayload[key];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.publishEntityState(device, memberPayload, reason);
|
||||
|
||||
for (const zigbeeGroup of groups) {
|
||||
if (zigbeeGroup.zh.hasMember(member) &&
|
||||
this.shouldPublishPayloadForGroup(zigbeeGroup, payload)) {
|
||||
if (zigbeeGroup.zh.hasMember(member) && this.shouldPublishPayloadForGroup(zigbeeGroup, payload)) {
|
||||
groupsToPublish.add(zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groupsToPublish.delete(entity);
|
||||
|
||||
for (const group of groupsToPublish) {
|
||||
await this.publishEntityState(group, payload, reason);
|
||||
}
|
||||
@@ -176,211 +148,181 @@ export default class Groups extends Extension {
|
||||
}
|
||||
|
||||
private shouldPublishPayloadForGroup(group: Group, payload: KeyValue): boolean {
|
||||
if (group.options.off_state === 'last_member_state') return true;
|
||||
if (!payload || payload.state !== 'OFF') return true;
|
||||
if (this.areAllMembersOff(group)) return true;
|
||||
return false;
|
||||
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);
|
||||
if (state.state === 'ON') {
|
||||
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[stateKey] === "ON" || state[stateKey] === "OPEN") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private parseMQTTMessage(data: eventdata.MQTTMessage): ParsedMQTTMessage {
|
||||
let type: 'remove' | 'add' | 'remove_all' = null;
|
||||
let resolvedEntityGroup: Group = null;
|
||||
let resolvedEntityDevice: Device = null;
|
||||
let resolvedEntityEndpoint: zh.Endpoint = null;
|
||||
let error: string = null;
|
||||
let groupKey: string = null;
|
||||
let deviceKey: string = null;
|
||||
let triggeredViaLegacyApi = false;
|
||||
let 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(topicRegex);
|
||||
const legacyTopicRegexRemoveAllMatch = data.topic.match(legacyTopicRegexRemoveAll);
|
||||
const legacyTopicRegexMatch = data.topic.match(legacyTopicRegex);
|
||||
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 'remove' | 'remove_all' | 'add';
|
||||
|
||||
if (!resolvedEntityGroup || !(resolvedEntityGroup instanceof Group)) {
|
||||
logger.error(`Group '${legacyTopicRegexMatch[1]}' does not exist`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {friendly_name: data.message,
|
||||
group: legacyTopicRegexMatch[1], error: 'group doesn\'t exists'};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_${type}_failed`, message: payload}),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
type = 'remove_all';
|
||||
if (typeof message !== "object" || message.device == null) {
|
||||
return [message, {type, skipDisableReporting}, "Invalid payload"];
|
||||
}
|
||||
|
||||
const parsedEntity = utils.parseEntityID(data.message);
|
||||
resolvedEntityDevice = this.zigbee.resolveEntity(parsedEntity.ID) as Device;
|
||||
if (!resolvedEntityDevice || !(resolvedEntityDevice instanceof Device)) {
|
||||
logger.error(`Device '${data.message}' does not exist`);
|
||||
const deviceKey = message.device;
|
||||
skipDisableReporting = message.skip_disable_reporting != null ? message.skip_disable_reporting : false;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {
|
||||
friendly_name: data.message, group: legacyTopicRegexMatch[1], error: 'entity doesn\'t exists',
|
||||
};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_${type}_failed`, message: payload}),
|
||||
);
|
||||
if (type !== "remove_all") {
|
||||
if (!("group" in message) || message.group == null) {
|
||||
return [message, {type, skipDisableReporting}, "Invalid payload"];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
resolvedEntityEndpoint = resolvedEntityDevice.endpoint(parsedEntity.endpoint);
|
||||
} 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 = utils.parseEntityID(message.device);
|
||||
resolvedEntityDevice = this.zigbee.resolveEntity(parsed.ID) as Device;
|
||||
if (!error && (!resolvedEntityDevice || !(resolvedEntityDevice instanceof Device))) {
|
||||
error = `Device '${message.device}' does not exist`;
|
||||
const resolvedDevice = this.zigbee.resolveEntity(message.device);
|
||||
|
||||
if (!resolvedDevice || !(resolvedDevice instanceof Device)) {
|
||||
return [message, {type, skipDisableReporting}, `Device '${message.device}' does not exist`];
|
||||
}
|
||||
if (!error) {
|
||||
resolvedEntityEndpoint = resolvedEntityDevice.endpoint(parsed.endpoint);
|
||||
|
||||
const endpointKey = message.endpoint ?? "default";
|
||||
const resolvedEndpoint = resolvedDevice.endpoint(message.endpoint);
|
||||
|
||||
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 = this.parseMQTTMessage(data);
|
||||
if (!parsed || !parsed.type) return;
|
||||
let {
|
||||
resolvedEntityGroup, resolvedEntityDevice, type, error, triggeredViaLegacyApi,
|
||||
groupKey, deviceKey, skipDisableReporting, resolvedEntityEndpoint,
|
||||
} = parsed;
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
let changedGroups: Group[] = [];
|
||||
const [raw, parsed, error] = this.parseMQTTMessage(data);
|
||||
|
||||
const responseData: KeyValue = {device: deviceKey};
|
||||
if (groupKey) {
|
||||
responseData.group = groupKey;
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
const keys = [
|
||||
`${resolvedEntityDevice.ieeeAddr}/${resolvedEntityEndpoint.ID}`,
|
||||
`${resolvedEntityDevice.name}/${resolvedEntityEndpoint.ID}`,
|
||||
];
|
||||
|
||||
const endpointNameLocal = resolvedEntityDevice.endpointName(resolvedEntityEndpoint);
|
||||
if (endpointNameLocal) {
|
||||
keys.push(`${resolvedEntityDevice.ieeeAddr}/${endpointNameLocal}`);
|
||||
keys.push(`${resolvedEntityDevice.name}/${endpointNameLocal}`);
|
||||
}
|
||||
|
||||
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 payload = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_add`, message: payload}),
|
||||
);
|
||||
}
|
||||
} 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 payload = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_remove`, message: payload}),
|
||||
);
|
||||
}
|
||||
} else { // remove_all
|
||||
logger.info(`Removing '${resolvedEntityDevice.name}' from all groups`);
|
||||
changedGroups = this.zigbee.groups().filter((g) => g.zh.members.includes(resolvedEntityEndpoint));
|
||||
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 payload = {friendly_name: resolvedEntityDevice.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_remove_all`, message: payload}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Failed to ${type} from group (${e.message})`;
|
||||
logger.debug(e.stack);
|
||||
}
|
||||
}
|
||||
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
await this.mqtt.publish(`bridge/response/group/members/${type}`, stringify(response));
|
||||
if (!raw || !parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
} else {
|
||||
for (const group of changedGroups) {
|
||||
this.eventBus.emitGroupMembersChanged({
|
||||
group, action: type, endpoint: resolvedEntityEndpoint, skipDisableReporting});
|
||||
await this.publishResponse(parsed.type, raw, {}, error);
|
||||
return;
|
||||
}
|
||||
|
||||
const {resolvedGroup, resolvedDevice, resolvedEndpoint, type, groupKey, deviceKey, endpointKey, skipDisableReporting} = parsed;
|
||||
const changedGroups: Group[] = [];
|
||||
|
||||
assert(resolvedDevice, "`resolvedDevice` is missing");
|
||||
assert(resolvedEndpoint, "`resolvedEndpoint` is missing");
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}});
|
||||
}
|
||||
}
|
||||
+1838
-1093
File diff suppressed because it is too large
Load Diff
@@ -1,456 +0,0 @@
|
||||
import * as settings from '../../util/settings';
|
||||
import logger from '../../util/logger';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
import utils from '../../util/utils';
|
||||
import assert from 'assert';
|
||||
import Extension from '../extension';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
const configRegex =
|
||||
new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/((?:\\w+/get)|(?:\\w+/factory_reset)|(?:\\w+))`);
|
||||
const allowedLogLevels = ['error', 'warn', 'info', 'debug'];
|
||||
|
||||
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 whitelist(topic: string, message: string): 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}'`);
|
||||
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 (e) {
|
||||
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');
|
||||
this.publish();
|
||||
}
|
||||
|
||||
@bind async reset(): Promise<void> {
|
||||
try {
|
||||
await this.zigbee.reset('soft');
|
||||
logger.info('Soft resetted ZNP');
|
||||
} catch (error) {
|
||||
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 logLevel(topic: string, message: string): void {
|
||||
const level = message.toLowerCase() as 'error' | 'warn' | 'info' | 'debug';
|
||||
if (allowedLogLevels.includes(level)) {
|
||||
logger.info(`Switching log level to '${level}'`);
|
||||
logger.setLevel(level);
|
||||
} else {
|
||||
logger.error(`Could not set log level to '${level}'. Allowed level: '${allowedLogLevels.join(',')}'`);
|
||||
}
|
||||
|
||||
this.publish();
|
||||
}
|
||||
|
||||
@bind async devices(topic: string): Promise<void> {
|
||||
const coordinator = await this.zigbee.getCoordinatorVersion();
|
||||
const devices = this.zigbee.devices().map((device) => {
|
||||
const payload: KeyValue = {
|
||||
ieeeAddr: device.ieeeAddr,
|
||||
type: device.zh.type,
|
||||
networkAddress: device.zh.networkAddress,
|
||||
};
|
||||
|
||||
if (device.zh.type !== 'Coordinator') {
|
||||
const definition = zigbeeHerdsmanConverters.findByDevice(device.zh);
|
||||
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();
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
|
||||
if (topic.split('/').pop() == 'get') {
|
||||
this.mqtt.publish(
|
||||
`bridge/config/devices`, stringify(devices), {}, settings.get().mqtt.base_topic, false, false,
|
||||
);
|
||||
} else {
|
||||
this.mqtt.publish('bridge/log', stringify({type: 'devices', message: devices}));
|
||||
}
|
||||
}
|
||||
|
||||
@bind groups(): void {
|
||||
const payload = settings.getGroups().map((g) => {
|
||||
return {...g, ID: Number(g.ID)};
|
||||
});
|
||||
|
||||
this.mqtt.publish('bridge/log', stringify({type: 'groups', message: payload}));
|
||||
}
|
||||
|
||||
@bind rename(topic: string, message: string): 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 (e) {
|
||||
logger.error(invalid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate message
|
||||
if (!json.new || !json.old) {
|
||||
logger.error(invalid);
|
||||
return;
|
||||
}
|
||||
|
||||
this._renameInternal(json.old, json.new);
|
||||
}
|
||||
|
||||
@bind renameLast(topic: string, message: string): void {
|
||||
if (!this.lastJoinedDeviceName) {
|
||||
logger.error(`Cannot rename last joined device, no device has joined during this session`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._renameInternal(this.lastJoinedDeviceName, message);
|
||||
}
|
||||
|
||||
_renameInternal(from: string, to: string): 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});
|
||||
}
|
||||
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `${isGroup ? 'group' : 'device'}_renamed`, message: {from, to}}),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to rename - ${from} to ${to}`);
|
||||
}
|
||||
}
|
||||
|
||||
@bind addGroup(topic: string, message: string): 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 (e) {
|
||||
// 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);
|
||||
this.mqtt.publish('bridge/log', stringify({type: `group_added`, message: name}));
|
||||
logger.info(`Added group '${name}'`);
|
||||
}
|
||||
|
||||
@bind removeGroup(topic: string, message: string): 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 {
|
||||
entity.zh.removeFromNetwork();
|
||||
}
|
||||
settings.removeGroup(message);
|
||||
|
||||
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`);
|
||||
|
||||
this.mqtt.publish('bridge/log', stringify({type: `device_${lookup[action][0]}_failed`, message}));
|
||||
return;
|
||||
}
|
||||
|
||||
const ieeeAddr = entity.ieeeAddr;
|
||||
const name = entity.name;
|
||||
|
||||
const cleanup = (): void => {
|
||||
// Fire event
|
||||
this.eventBus.emitDeviceRemoved({ieeeAddr, name});
|
||||
|
||||
// Remove from configuration.yaml
|
||||
settings.removeDevice(entity.ieeeAddr);
|
||||
|
||||
// Remove from state
|
||||
this.state.remove(ieeeAddr);
|
||||
|
||||
logger.info(`Successfully ${lookup[action][0]} ${entity.name}`);
|
||||
this.mqtt.publish('bridge/log', stringify({type: `device_${lookup[action][0]}`, message}));
|
||||
};
|
||||
|
||||
try {
|
||||
logger.info(`${lookup[action][1]} '${entity.name}'`);
|
||||
if (action === 'force_remove') {
|
||||
await entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
|
||||
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`);
|
||||
|
||||
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});
|
||||
}
|
||||
|
||||
onZigbeeEvent_(type: string, data: KeyValue, resolvedEntity: Device): void {
|
||||
if (type === 'deviceJoined' && resolvedEntity) {
|
||||
this.lastJoinedDeviceName = resolvedEntity.name;
|
||||
}
|
||||
|
||||
if (type === 'deviceJoined') {
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_connected`, message: {friendly_name: resolvedEntity.name}}),
|
||||
);
|
||||
} else if (type === 'deviceInterview') {
|
||||
if (data.status === 'successful') {
|
||||
if (resolvedEntity.definition) {
|
||||
const {vendor, description, model} = resolvedEntity.definition;
|
||||
const log = {friendly_name: resolvedEntity.name, model, vendor, description, supported: true};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `pairing`, message: 'interview_successful', meta: log}),
|
||||
);
|
||||
} else {
|
||||
const meta = {friendly_name: resolvedEntity.name, supported: false};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `pairing`, message: 'interview_successful', meta}),
|
||||
);
|
||||
}
|
||||
} else if (data.status === 'failed') {
|
||||
const meta = {friendly_name: resolvedEntity.name};
|
||||
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};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `pairing`, message: 'interview_started', meta}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (type === 'deviceAnnounce') {
|
||||
const meta = {friendly_name: resolvedEntity.name};
|
||||
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};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_removed`, message: 'left_network', meta}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind async touchlinkFactoryReset(): Promise<void> {
|
||||
logger.info('Starting touchlink factory reset...');
|
||||
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');
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `touchlink`, message: 'reset_success', meta: {status: 'success'}}),
|
||||
);
|
||||
} else {
|
||||
logger.warn('Failed to factory reset device through Touchlink');
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `touchlink`, message: 'reset_failed', meta: {status: 'failed'}}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/* istanbul ignore file */
|
||||
import * as settings from '../../util/settings';
|
||||
import logger from '../../util/logger';
|
||||
import utils from '../../util/utils';
|
||||
import Extension from '../extension';
|
||||
import bind from 'bind-decorator';
|
||||
import Device from '../../model/device';
|
||||
|
||||
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 = utils.parseEntityID(match[1]);
|
||||
const device = this.zigbee.resolveEntity(parsed.ID) as Device;
|
||||
if (!device || !(device instanceof Device)) {
|
||||
logger.error(`Device '${match[1]}' does not exist`);
|
||||
return;
|
||||
}
|
||||
const endpoint = device.endpoint(parsed.endpoint);
|
||||
const response = await endpoint.command(
|
||||
`genGroups`, 'getMembership', {groupcount: 0, grouplist: []}, {},
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
logger.warn(`Couldn't get group membership of ${device.ieeeAddr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let {grouplist, capacity} = 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 (capacity === 254) {
|
||||
msgCapacity = 'it can be a part of at least 1 more group';
|
||||
} else {
|
||||
msgCapacity = `its remaining group capacity is ${capacity === 255 ? 'unknown' : capacity}`;
|
||||
}
|
||||
logger.info(`${msgGroupList} and ${msgCapacity}`);
|
||||
|
||||
this.publishEntityState(device, {group_list: grouplist, group_capacity: capacity});
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
import logger from '../../util/logger';
|
||||
import * as settings from '../../util/settings';
|
||||
import Extension from '../extension';
|
||||
|
||||
const defaultConfiguration = {
|
||||
minimumReportInterval: 3, maximumReportInterval: 300, reportableChange: 1,
|
||||
};
|
||||
|
||||
const ZNLDP12LM = zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'ZNLDP12LM');
|
||||
|
||||
const devicesNotSupportingReporting = [
|
||||
zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'CC2530.ROUTER'),
|
||||
zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'BASICZBR3'),
|
||||
zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'ZM-CSW032-D'),
|
||||
zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'TS0001'),
|
||||
zigbeeHerdsmanConverters.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;
|
||||
}
|
||||
}
|
||||
|
||||
this.enabled ?
|
||||
await ep.bind(cluster, this.zigbee.firstCoordinatorEndpoint()) :
|
||||
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.devices(false)) {
|
||||
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 */
|
||||
// DEPRECATED
|
||||
import * as settings from '../../util/settings';
|
||||
import logger from '../../util/logger';
|
||||
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.Timer = 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.warn('Soft reset timeout triggered');
|
||||
|
||||
try {
|
||||
await this.zigbee.reset('soft');
|
||||
logger.warn('Soft resetted ZNP due to timeout');
|
||||
} catch (error) {
|
||||
logger.warn('Soft reset failed, trying stop/start');
|
||||
|
||||
await this.zigbee.stop();
|
||||
logger.warn('Zigbee stopped');
|
||||
|
||||
try {
|
||||
await this.zigbee.start();
|
||||
} catch (error) {
|
||||
logger.error('Failed to restart!');
|
||||
}
|
||||
}
|
||||
|
||||
this.resetTimer();
|
||||
}
|
||||
}
|
||||
+188
-178
@@ -1,91 +1,74 @@
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import logger from '../util/logger';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import Extension from './extension';
|
||||
import bind from 'bind-decorator';
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type {Eui64} from "zigbee-herdsman/dist/zspec/tstypes";
|
||||
import type {LQITableEntry, RoutingTableEntry} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
|
||||
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";
|
||||
|
||||
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;
|
||||
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
|
||||
@@ -93,229 +76,256 @@ 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.definition) {
|
||||
labels.push(`${node.definition.vendor} ${node.definition.description} (${node.definition.model})`);
|
||||
} else {
|
||||
// This model is not supported by zigbee-herdsman-converters, add zigbee model information
|
||||
labels.push(`${node.manufacturerName} ${node.modelID}`);
|
||||
}
|
||||
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);
|
||||
// Escape backslashes and double-quotes to avoid breaking Graphviz .dot files.
|
||||
const escapedLabels = labels.map((label) => label.replace(/\\/g, "\\\\").replace(/"/g, '\\"'));
|
||||
|
||||
// Shape the record according to device type
|
||||
if (node.type == 'Coordinator') {
|
||||
style = `style="bold, filled", fillcolor="${colors.fill.coordinator}", ` +
|
||||
`fontcolor="${colors.font.coordinator}"`;
|
||||
} else if (node.type == 'Router') {
|
||||
style = `style="rounded, filled", fillcolor="${colors.fill.router}", ` +
|
||||
`fontcolor="${colors.font.router}"`;
|
||||
if (node.type === "Coordinator") {
|
||||
style = `style="bold, filled", fillcolor="${colors.fill.coordinator}", fontcolor="${colors.font.coordinator}"`;
|
||||
} 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}"`;
|
||||
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="{${escapedLabels.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}"`;
|
||||
text += ` [${lineStyle}${lineWeight}${lineLabels}]\n`;
|
||||
});
|
||||
});
|
||||
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) => {
|
||||
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(`---`);
|
||||
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(',')}` : ''),
|
||||
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})${node.failed?.length ? ` failed: ${node.failed.join(",")}` : ""}`,
|
||||
);
|
||||
|
||||
// Add the device model
|
||||
if (node.type !== 'Coordinator') {
|
||||
text.push(`---`);
|
||||
if (node.type !== "Coordinator") {
|
||||
text.push("---");
|
||||
const definition = (this.zigbee.resolveEntity(node.ieeeAddr) as Device).definition;
|
||||
if (definition) {
|
||||
text.push(`${definition.vendor} ${definition.description} (${definition.model})`);
|
||||
} else {
|
||||
// This model is not supported by zigbee-herdsman-converters, add zigbee model information
|
||||
text.push(`${node.manufacturerName} ${node.modelID}`);
|
||||
}
|
||||
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;
|
||||
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;
|
||||
}
|
||||
text.push(`---`);
|
||||
text.push("---");
|
||||
text.push(lastSeen);
|
||||
text.push(`]`);
|
||||
text.push(``);
|
||||
});
|
||||
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 devices = this.zigbee.devices().filter((d) => d.zh.type !== 'GreenPower' && !d.options.disabled);
|
||||
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, LQITableEntry[]>();
|
||||
const routingTables = new Map<Device, RoutingTableEntry[]>();
|
||||
const failed = new Map<Device, string[]>();
|
||||
const requestWithRetry = async <T>(request: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
const result = await request();
|
||||
|
||||
for (const device of devices.filter((d) => d.zh.type != 'EndDevice')) {
|
||||
failed.set(device, []);
|
||||
return result;
|
||||
} catch {
|
||||
// Network is possibly congested, sleep 5 seconds to let the network settle.
|
||||
await utils.sleep(5);
|
||||
return await request();
|
||||
}
|
||||
};
|
||||
|
||||
for (const device of this.zigbee.devicesIterator((d) => d.type !== "GreenPower" && d.type !== "EndDevice")) {
|
||||
if (device.options.disabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const deviceFailures: string[] = [];
|
||||
failed.set(device, deviceFailures);
|
||||
await utils.sleep(1); // sleep 1 second between each scan to reduce stress on network.
|
||||
|
||||
const doRequest = async <T>(request: () => Promise<T>, firstAttempt = true): Promise<T> => {
|
||||
try {
|
||||
return await request();
|
||||
} catch (error) {
|
||||
if (!firstAttempt) {
|
||||
throw error;
|
||||
} else {
|
||||
// Network is possibly congested, sleep 5 seconds to let the network settle.
|
||||
await utils.sleep(5);
|
||||
return doRequest(request, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await doRequest<zh.LQI>(async () => device.zh.lqi());
|
||||
const result = await requestWithRetry<LQITableEntry[]>(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 doRequest(async () => device.zh.routingTable());
|
||||
const result = await requestWithRetry<RoutingTableEntry[]>(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}'`);
|
||||
// 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: []};
|
||||
// Add nodes
|
||||
for (const device of devices) {
|
||||
const definition = device.definition ? {
|
||||
model: device.definition.model,
|
||||
vendor: device.definition.vendor,
|
||||
description: device.definition.description,
|
||||
supports: Array.from(new Set((device.exposes()).map((e) => {
|
||||
return e.hasOwnProperty('name') ? e.name :
|
||||
`${e.type} (${e.features.map((f) => f.name).join(', ')})`;
|
||||
}))).join(', '),
|
||||
} : null;
|
||||
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")) {
|
||||
if (device.options.disabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add nodes
|
||||
const definition = device.definition
|
||||
? {
|
||||
model: device.definition.model,
|
||||
vendor: device.definition.vendor,
|
||||
description: device.definition.description,
|
||||
supports: Array.from(
|
||||
new Set(
|
||||
device.exposes().map((e) => {
|
||||
return e.name ?? `${e.type} (${e.features?.map((f) => f.name).join(", ")})`;
|
||||
}),
|
||||
),
|
||||
).join(", "),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
topology.nodes.push({
|
||||
ieeeAddr: device.ieeeAddr, friendlyName: device.name, type: device.zh.type,
|
||||
networkAddress: device.zh.networkAddress, manufacturerName: device.zh.manufacturerName,
|
||||
modelID: device.zh.modelID, failed: failed.get(device), lastSeen: device.zh.lastSeen,
|
||||
ieeeAddr: device.ieeeAddr,
|
||||
friendlyName: device.name,
|
||||
type: device.zh.type,
|
||||
networkAddress: device.zh.networkAddress,
|
||||
manufacturerName: device.zh.manufacturerName,
|
||||
modelID: device.zh.modelID,
|
||||
failed: failed.get(device),
|
||||
lastSeen: device.zh.lastSeen,
|
||||
definition,
|
||||
});
|
||||
}
|
||||
|
||||
// Add links
|
||||
lqis.forEach((lqi, device) => {
|
||||
for (const neighbor of lqi.neighbors) {
|
||||
for (const [device, table] of lqis) {
|
||||
for (const neighbor of table) {
|
||||
if (neighbor.relationship > 3) {
|
||||
// Relationship is not active, skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
let neighborEui64 = neighbor.eui64;
|
||||
|
||||
// Some Xiaomi devices return 0x00 as the neighbor ieeeAddr (obviously not correct).
|
||||
// Determine the correct ieeeAddr based on the networkAddress.
|
||||
const neighborDevice = this.zigbee.deviceByNetworkAddress(neighbor.networkAddress);
|
||||
if (neighbor.ieeeAddr === '0x0000000000000000' && neighborDevice) {
|
||||
neighbor.ieeeAddr = neighborDevice.ieeeAddr;
|
||||
if (neighborEui64 === "0x0000000000000000") {
|
||||
const neighborDevice = this.zigbee.deviceByNetworkAddress(neighbor.nwkAddress);
|
||||
|
||||
if (neighborDevice) {
|
||||
neighborEui64 = neighborDevice.ieeeAddr as Eui64;
|
||||
}
|
||||
}
|
||||
|
||||
const link: Link = {
|
||||
source: {ieeeAddr: neighbor.ieeeAddr, networkAddress: neighbor.networkAddress},
|
||||
const link: Zigbee2MQTTNetworkMap["links"][number] = {
|
||||
source: {ieeeAddr: neighborEui64, networkAddress: neighbor.nwkAddress},
|
||||
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.zh.networkAddress},
|
||||
linkquality: neighbor.linkquality, depth: neighbor.depth, routes: [],
|
||||
// DEPRECATED:
|
||||
sourceIeeeAddr: neighbor.ieeeAddr, targetIeeeAddr: device.ieeeAddr,
|
||||
sourceNwkAddr: neighbor.networkAddress, lqi: neighbor.linkquality,
|
||||
deviceType: neighbor.deviceType,
|
||||
rxOnWhenIdle: neighbor.rxOnWhenIdle,
|
||||
relationship: neighbor.relationship,
|
||||
permitJoining: neighbor.permitJoining,
|
||||
depth: neighbor.depth,
|
||||
lqi: neighbor.lqi,
|
||||
routes: [],
|
||||
// below are @deprecated
|
||||
sourceIeeeAddr: neighborEui64,
|
||||
targetIeeeAddr: device.ieeeAddr,
|
||||
sourceNwkAddr: neighbor.nwkAddress,
|
||||
linkquality: neighbor.lqi,
|
||||
};
|
||||
|
||||
const routingTable = routingTables.get(device);
|
||||
|
||||
if (routingTable) {
|
||||
link.routes = routingTable.table
|
||||
.filter((t) => t.status === 'ACTIVE' && t.nextHop === neighbor.networkAddress);
|
||||
for (const entry of routingTable) {
|
||||
if (entry.nextHopAddress === neighbor.nwkAddress) {
|
||||
link.routes.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
topology.links.push(link);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return topology;
|
||||
}
|
||||
|
||||
+73
-32
@@ -1,50 +1,91 @@
|
||||
import zhc from 'zigbee-herdsman-converters';
|
||||
import Extension from './extension';
|
||||
import {onEvent, type OnEvent as ZhcOnEvent} from "zigbee-herdsman-converters";
|
||||
|
||||
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.devices(false)) {
|
||||
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,
|
||||
(data) => {
|
||||
if (data.entity.isDevice()) {
|
||||
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) => {
|
||||
// TODO: this is triggering for any `data.status`, any use outside `successful`?
|
||||
// ZHC definition would skip from `device.definition?` but OnEvent from ZHC index wouldn't => triple triggering?
|
||||
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> {
|
||||
super.stop();
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
await this.callOnEvent(device, 'stop', {});
|
||||
await super.stop();
|
||||
|
||||
// ensure properly started, else this throws undesired errors (e.g. SIGINT during startup)
|
||||
if (this.zigbee.zhController !== undefined) {
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await this.callOnEvent(device, {type: "stop", data: {ieeeAddr: device.ieeeAddr}});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async callOnEvent(device: Device, type: string, data: KeyValue): Promise<void> {
|
||||
const state = this.state.get(device);
|
||||
zhc.onEvent(type, data, device.zh, device.options, state);
|
||||
private async callOnEvent(device: Device, event: ZhcOnEvent.Event): Promise<void> {
|
||||
if (device.options.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.definition?.onEvent) {
|
||||
await device.definition.onEvent(type, data, device.zh, device.options, state);
|
||||
if (event.type === "start") {
|
||||
this.#startCalled.add(device.ieeeAddr);
|
||||
} else if (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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+447
-240
@@ -1,317 +1,524 @@
|
||||
import * as settings from '../util/settings';
|
||||
import logger from '../util/logger';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import utils from '../util/utils';
|
||||
import tradfriOTA from 'zigbee-herdsman-converters/lib/ota/tradfri';
|
||||
import zigbeeOTA from 'zigbee-herdsman-converters/lib/ota/zigbeeOTA';
|
||||
import Extension from './extension';
|
||||
import bind from 'bind-decorator';
|
||||
import Device from '../model/device';
|
||||
import dataDir from '../util/data';
|
||||
import * as URI from 'uri-js';
|
||||
import path from 'path';
|
||||
import assert from "node:assert";
|
||||
import {existsSync, mkdirSync, rmSync, writeFileSync} from "node:fs";
|
||||
import {join} from "node:path";
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {setOtaConfiguration, Zcl} from "zigbee-herdsman";
|
||||
import type {OtaDataSettings, OtaSource, OtaUpdateAvailableResult} from "zigbee-herdsman/dist/controller/tstype";
|
||||
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";
|
||||
|
||||
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" | "scheduled";
|
||||
|
||||
type UpdateState = 'updating' | 'idle' | 'available';
|
||||
interface UpdatePayload {
|
||||
update_available?: boolean
|
||||
// eslint-disable-next-line camelcase
|
||||
export interface UpdatePayload {
|
||||
update: {
|
||||
progress?: number, remaining?: number, state: UpdateState,
|
||||
installed_version: number | null, latest_version: number | null
|
||||
}
|
||||
progress?: number;
|
||||
remaining?: number;
|
||||
state: UpdateState;
|
||||
installed_version: number | null;
|
||||
latest_version: number | null;
|
||||
latest_source: string | null;
|
||||
latest_release_notes: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
/**
|
||||
* Write to `dataDir` and return created path
|
||||
*/
|
||||
function writeFirmwareHexToDataDir(hex: string, fileName: string | undefined, deviceIeee: string): string {
|
||||
if (!fileName) {
|
||||
fileName = `${deviceIeee}_${Date.now()}`;
|
||||
}
|
||||
|
||||
const baseDir = dataDir.joinPath("ota");
|
||||
|
||||
if (!existsSync(baseDir)) {
|
||||
mkdirSync(baseDir, {recursive: true});
|
||||
}
|
||||
|
||||
const filePath = join(baseDir, fileName);
|
||||
|
||||
writeFileSync(filePath, Buffer.from(hex, "hex"));
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
#inProgress = new Set<string>();
|
||||
#lastChecked = new Map<string, number>();
|
||||
|
||||
// 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) {
|
||||
tradfriOTA.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);
|
||||
setOtaConfiguration(dataDir.getPath(), settings.get().ota.zigbee_ota_override_index_location);
|
||||
|
||||
// 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 = "idle";
|
||||
}
|
||||
|
||||
zigbeeOTA.useIndexOverride(overrideOTAIndex);
|
||||
}
|
||||
|
||||
// In order to support local firmware files we need to let zigbeeOTA know where the data directory is
|
||||
zigbeeOTA.setDataDir(dataDir.getPath());
|
||||
|
||||
// In case Zigbee2MQTT is restared during an update, progress and remaining values are still in state.
|
||||
// remove them.
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
}
|
||||
}
|
||||
|
||||
private removeProgressAndRemainingFromState(device: Device): void {
|
||||
delete this.state.get(device).update?.progress;
|
||||
delete this.state.get(device).update?.remaining;
|
||||
// mostly intended for testing
|
||||
clearState(): void {
|
||||
this.#inProgress.clear();
|
||||
this.#lastChecked.clear();
|
||||
}
|
||||
|
||||
#removeProgressAndRemainingFromState(device: Device): void {
|
||||
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.hasOwnProperty('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.zh.scheduledOta) {
|
||||
// allow custom source to override check for definition `ota`
|
||||
if (data.device.zh.scheduledOta?.url !== undefined || data.device.definition.ota) {
|
||||
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, logger, data.data);
|
||||
} 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`);
|
||||
|
||||
const payload = this.getEntityPublishPayload(data.device, availableResult ?? 'idle');
|
||||
this.publishEntityState(data.device, payload);
|
||||
|
||||
if (availableResult?.available) {
|
||||
const message = `Update available for '${data.device.name}'`;
|
||||
logger.info(message);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: 'available', device: data.device.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message, meta}),
|
||||
try {
|
||||
const otaSettings = settings.get().ota;
|
||||
const [, toVersion] = await this.#update(
|
||||
undefined, // uses internally registered schedule
|
||||
data.device,
|
||||
data.data as Zcl.ClustersTypes.TClusterCommandPayload<"genOta", "queryNextImageRequest">,
|
||||
data.meta.zclTransactionSequenceNumber,
|
||||
{
|
||||
// fallbacks are only to satisfy typing, should always be defined from settings defaults
|
||||
requestTimeout: otaSettings.image_block_request_timeout ?? /* v8 ignore next */ 150000,
|
||||
responseDelay: otaSettings.image_block_response_delay ?? /* v8 ignore next */ 250,
|
||||
baseSize: otaSettings.default_maximum_data_size ?? /* v8 ignore next */ 50,
|
||||
},
|
||||
data.endpoint,
|
||||
);
|
||||
|
||||
if (toVersion === undefined) {
|
||||
logger.info(`No OTA image currently available for '${data.device.name}'. Unscheduled.`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug(`OTA 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
|
||||
}
|
||||
}
|
||||
|
||||
if (data.device.definition.ota) {
|
||||
if (!data.device.options.disable_automatic_update_check && !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 (!check) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#inProgress.add(data.device.ieeeAddr);
|
||||
this.#lastChecked.set(data.device.ieeeAddr, Date.now());
|
||||
let availableResult: OtaUpdateAvailableResult | undefined;
|
||||
|
||||
try {
|
||||
// auto-check defaults to zigbee-OTA + potential local index, and never `downgrade`
|
||||
availableResult = await data.device.zh.checkOta(
|
||||
{downgrade: false},
|
||||
data.data as Zcl.ClustersTypes.TClusterCommandPayload<"genOta", "queryNextImageRequest">,
|
||||
data.device.otaExtraMetas,
|
||||
data.endpoint,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to check if OTA update available for '${data.device.name}' (${error})`);
|
||||
}
|
||||
|
||||
await this.publishEntityState(data.device, this.#getEntityPublishPayload(data.device, availableResult ?? "idle"));
|
||||
|
||||
if (availableResult?.available) {
|
||||
logger.info(`OTA update available for '${data.device.name}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Respond to the OTA request: respond with NO_IMAGE_AVAILABLE (0x98) (so the client stops requesting OTAs)
|
||||
const endpoint = data.device.zh.endpoints.find((e) => e.supportsOutputCluster('genOta')) || data.endpoint;
|
||||
await endpoint.commandResponse('genOta', 'queryNextImageResponse', {status: 0x98});
|
||||
// Respond to stop the client from requesting OTAs
|
||||
await data.endpoint.commandResponse(
|
||||
"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, sendWhen: 'active' | 'immediate'):
|
||||
Promise<{softwareBuildID: string, dateCode: string}> {
|
||||
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'], {sendWhen});
|
||||
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 (e) {
|
||||
return null;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getEntityPublishPayload(device: Device, state: zhc.OtaUpdateAvailableResult | UpdateState,
|
||||
progress: number=null, remaining: number=null): 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,
|
||||
}};
|
||||
if (progress !== null) payload.update.progress = progress;
|
||||
if (remaining !== null) payload.update.remaining = Math.round(remaining);
|
||||
#getEntityPublishPayload(device: Device, state: OtaUpdateAvailableResult | UpdateState, progress?: number, remaining?: number): UpdatePayload {
|
||||
const deviceUpdateState = this.state.get(device).update as UpdatePayload["update"];
|
||||
const update: UpdatePayload["update"] =
|
||||
typeof state === "string"
|
||||
? {
|
||||
state,
|
||||
installed_version: deviceUpdateState?.installed_version,
|
||||
latest_version: deviceUpdateState?.latest_version,
|
||||
latest_source: deviceUpdateState?.latest_source,
|
||||
latest_release_notes: deviceUpdateState?.latest_release_notes,
|
||||
}
|
||||
: {
|
||||
state: state.available ? "available" : "idle",
|
||||
installed_version: state.current.fileVersion,
|
||||
latest_version: state.availableMeta?.fileVersion ?? state.current.fileVersion,
|
||||
latest_source: state.availableMeta?.url || null,
|
||||
latest_release_notes: state.availableMeta?.releaseNotes || null,
|
||||
};
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (this.legacyApi) {
|
||||
payload.update_available = typeof state === 'string' ? state === 'available' : state.available;
|
||||
if (progress !== undefined) {
|
||||
update.progress = progress;
|
||||
}
|
||||
|
||||
return payload;
|
||||
if (remaining !== undefined) {
|
||||
update.remaining = Math.round(remaining);
|
||||
}
|
||||
|
||||
return {update};
|
||||
}
|
||||
|
||||
@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"];
|
||||
// TODO: deprecated 3.0 should remove string payload, enforce object
|
||||
const messageObject = typeof message === "object";
|
||||
|
||||
if (messageObject) {
|
||||
assert(message.id, "Invalid payload");
|
||||
}
|
||||
|
||||
const ID = (messageObject ? 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};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: error, meta}),
|
||||
);
|
||||
}
|
||||
} else if (this.inProgress.has(device.ieeeAddr)) {
|
||||
error = `Update or check for update already in progress for '${device.name}'`;
|
||||
} 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 = `OTA 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);
|
||||
const source: OtaSource = {downgrade};
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `checking_if_available`, device: device.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: msg, meta}),
|
||||
);
|
||||
}
|
||||
if (messageObject) {
|
||||
const payload = message as
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/check"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/check/downgrade"];
|
||||
|
||||
try {
|
||||
const availableResult = await device.definition.ota.isUpdateAvailable(device.zh, logger);
|
||||
const msg = `${availableResult.available ? 'Update' : 'No update'} available for '${device.name}'`;
|
||||
logger.info(msg);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {
|
||||
status: availableResult.available ? 'available' : 'not_available', device: device.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: msg, meta}),
|
||||
);
|
||||
if (payload.url) {
|
||||
source.url = payload.url;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
|
||||
const payload = this.getEntityPublishPayload(device, availableResult);
|
||||
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;
|
||||
logger.info(`Checking if OTA update available for '${device.name}'`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `check_failed`, device: device.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: error, meta}),
|
||||
);
|
||||
try {
|
||||
const availableResult = await device.zh.checkOta(source, undefined, device.otaExtraMetas);
|
||||
|
||||
logger.info(`${availableResult.available ? "" : "No "}OTA update available for '${device.name}'`);
|
||||
|
||||
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,
|
||||
downgrade: source.downgrade,
|
||||
source: availableResult.availableMeta?.url,
|
||||
release_notes: availableResult.availableMeta?.releaseNotes,
|
||||
});
|
||||
|
||||
await this.mqtt.publish("bridge/response/device/ota_update/check", stringify(response));
|
||||
} catch (e) {
|
||||
error = `Failed to check if OTA update available for '${device.name}' (${(e as Error).message})`;
|
||||
errorStack = (e as Error).stack;
|
||||
}
|
||||
}
|
||||
} 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};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: msg, meta}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const onProgress = (progress: number, remaining: number): void => {
|
||||
let msg = `Update of '${device.name}' at ${progress.toFixed(2)}%`;
|
||||
if (remaining) {
|
||||
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
|
||||
}
|
||||
case "update": {
|
||||
this.#inProgress.add(device.ieeeAddr);
|
||||
|
||||
logger.info(msg);
|
||||
|
||||
const payload = this.getEntityPublishPayload(device, 'updating', progress, remaining);
|
||||
this.publishEntityState(device, payload);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_progress`, device: device.name, progress};
|
||||
this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
|
||||
}
|
||||
const otaSettings = settings.get().ota;
|
||||
const source: OtaSource = {downgrade};
|
||||
const dataSettings: OtaDataSettings = {
|
||||
// fallbacks are only to satisfy typing, should always be defined from settings defaults
|
||||
requestTimeout: otaSettings.image_block_request_timeout ?? /* v8 ignore next */ 150000,
|
||||
responseDelay: otaSettings.image_block_response_delay ?? /* v8 ignore next */ 250,
|
||||
baseSize: otaSettings.default_maximum_data_size ?? /* v8 ignore next */ 50,
|
||||
};
|
||||
|
||||
const from_ = await this.readSoftwareBuildIDAndDateCode(device, 'immediate');
|
||||
const fileVersion = await device.definition.ota.updateToLatest(device.zh, logger, onProgress);
|
||||
logger.info(`Finished update of '${device.name}'`);
|
||||
this.eventBus.emitReconfigure({device});
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
const payload = this.getEntityPublishPayload(device,
|
||||
{available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion});
|
||||
this.publishEntityState(device, payload);
|
||||
const to = await this.readSoftwareBuildIDAndDateCode(device, 'active');
|
||||
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;
|
||||
this.eventBus.emitDevicesChanged();
|
||||
if (messageObject) {
|
||||
const payload = message as
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/update"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/update/downgrade"];
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_succeeded`, device: device.name, from: from_, to};
|
||||
this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message, meta}));
|
||||
if (payload.hex) {
|
||||
assert(payload.hex.data);
|
||||
|
||||
// write to `dataDir` and pass created path as source URL
|
||||
source.url = writeFirmwareHexToDataDir(payload.hex.data, payload.hex.file_name, device.ieeeAddr);
|
||||
} else if (payload.url) {
|
||||
source.url = payload.url;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (payload.image_block_request_timeout) {
|
||||
dataSettings.requestTimeout = payload.image_block_request_timeout;
|
||||
}
|
||||
|
||||
if (payload.image_block_response_delay) {
|
||||
dataSettings.responseDelay = payload.image_block_response_delay;
|
||||
}
|
||||
|
||||
if (payload.default_maximum_data_size) {
|
||||
dataSettings.baseSize = payload.default_maximum_data_size;
|
||||
}
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
} 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');
|
||||
this.publishEntityState(device, payload);
|
||||
logger.info(`OTA updating '${device.name}' to ${downgrade ? "previous" : "latest"} firmware`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_failed`, device: device.name};
|
||||
this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta}));
|
||||
try {
|
||||
const firmwareFrom = await this.#readSoftwareBuildIDAndDateCode(device, "immediate");
|
||||
const [fromVersion, toVersion] = await this.#update(source, device, undefined, undefined, dataSettings);
|
||||
|
||||
if (toVersion === undefined) {
|
||||
error = `Update of '${device.name}' failed (No image currently available)`;
|
||||
break;
|
||||
}
|
||||
|
||||
const firmwareTo = await this.#readSoftwareBuildIDAndDateCode(device);
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/update">(message, {
|
||||
id: ID,
|
||||
from: {
|
||||
file_version: fromVersion,
|
||||
software_build_id: firmwareFrom?.softwareBuildID,
|
||||
date_code: firmwareFrom?.dateCode,
|
||||
},
|
||||
to: {file_version: toVersion, software_build_id: firmwareTo?.softwareBuildID, date_code: firmwareTo?.dateCode},
|
||||
});
|
||||
|
||||
await this.mqtt.publish("bridge/response/device/ota_update/update", stringify(response));
|
||||
} catch (e) {
|
||||
logger.debug(`OTA update of '${device.name}' failed (${e})`);
|
||||
error = `OTA 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": {
|
||||
const source: OtaSource = {downgrade};
|
||||
|
||||
if (messageObject) {
|
||||
const payload = message as
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule/downgrade"];
|
||||
|
||||
if (payload.hex) {
|
||||
assert(payload.hex.data);
|
||||
|
||||
// write to `dataDir` and pass created path as source URL
|
||||
source.url = writeFirmwareHexToDataDir(payload.hex.data, payload.hex.file_name, device.ieeeAddr);
|
||||
} else if (payload.url) {
|
||||
source.url = payload.url;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
|
||||
device.zh.scheduleOta(source);
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, "scheduled"));
|
||||
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/schedule">(message, {id: ID, url: source.url});
|
||||
|
||||
await this.mqtt.publish("bridge/response/device/ota_update/schedule", stringify(response));
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "unschedule": {
|
||||
if (device.zh.scheduledOta?.url?.startsWith(dataDir.joinPath("ota"))) {
|
||||
rmSync(device.zh.scheduledOta.url, {force: true});
|
||||
}
|
||||
|
||||
device.zh.unscheduleOta();
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, "idle"));
|
||||
|
||||
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));
|
||||
this.#inProgress.delete(device.ieeeAddr);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const response = utils.getResponse(message, {}, error);
|
||||
|
||||
await this.mqtt.publish(`bridge/response/device/ota_update/${type}`, stringify(response));
|
||||
logger.error(error);
|
||||
errorStack && logger.debug(errorStack);
|
||||
|
||||
if (errorStack) {
|
||||
logger.debug(errorStack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Do the bulk of the update work (hand over to zigbee-herdsman, then re-interview).
|
||||
*
|
||||
* `dataSettings` object may be mutated by zigbee-herdsman to fit request (e.g. known manuf quirk)
|
||||
*/
|
||||
async #update(
|
||||
source: OtaSource | undefined,
|
||||
device: Device,
|
||||
requestPayload: Zcl.ClustersTypes.TClusterCommandPayload<"genOta", "queryNextImageRequest"> | undefined,
|
||||
requestTsn: number | undefined,
|
||||
dataSettings: OtaDataSettings,
|
||||
endpoint?: zh.Endpoint,
|
||||
): Promise<[from: number, to: number | undefined]> {
|
||||
const [from, to] = await device.zh.updateOta(
|
||||
source,
|
||||
requestPayload,
|
||||
requestTsn,
|
||||
device.otaExtraMetas,
|
||||
async (progress, remaining) => {
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, "updating", progress, remaining));
|
||||
},
|
||||
dataSettings,
|
||||
endpoint,
|
||||
);
|
||||
|
||||
if (to === undefined) {
|
||||
this.#removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, {available: false, current: from}));
|
||||
|
||||
return [from.fileVersion, undefined];
|
||||
}
|
||||
|
||||
logger.info(`Finished update of '${device.name}'`);
|
||||
|
||||
this.#removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, {available: false, current: to}));
|
||||
|
||||
logger.info(() => `Device '${device.name}' was OTA updated from '${from.fileVersion}' to '${to.fileVersion}'`);
|
||||
|
||||
// OTA update can bring new features & co, force full re-interview and re-configure, same as a "device joined"
|
||||
if (device.zh.meta.configured !== undefined) {
|
||||
delete device.zh.meta.configured;
|
||||
|
||||
device.zh.save();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
device.reInterview(this.eventBus).catch((error) => {
|
||||
logger.error(`${error.message}. Re-try manually after some time.`);
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
return [from.fileVersion, to.fileVersion];
|
||||
}
|
||||
}
|
||||
|
||||
+150
-142
@@ -1,165 +1,145 @@
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import * as settings from '../util/settings';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
import philips from 'zigbee-herdsman-converters/lib/philips';
|
||||
import logger from '../util/logger';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import Group from '../model/group';
|
||||
import Device from '../model/device';
|
||||
import bind from 'bind-decorator';
|
||||
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 topicRegex = new RegExp(`^(.+?)(?:/(${utils.endpointNames.join('|')}|\\d+))?/(get|set)(?:/(.+))?`);
|
||||
const propertyEndpointRegex = new RegExp(`^(.*?)_(${utils.endpointNames.join('|')})$`);
|
||||
const stateValues = ['on', 'off', 'toggle', 'open', 'close', 'stop', 'lock', 'unlock'];
|
||||
const sceneConverterKeys = ['scene_store', 'scene_add', 'scene_remove', 'scene_remove_all'];
|
||||
// TODO: get rid of this, use class member
|
||||
let topicGetSetRegex: RegExp;
|
||||
// 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)(?:/(.+))?$`);
|
||||
};
|
||||
|
||||
// Legacy: don't provide default converters anymore, this is required by older z2m installs not saving group members
|
||||
const defaultGroupConverters = [
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_onoff_brightness,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_color_colortemp,
|
||||
philips.tz.effect, // Support Hue effects for groups
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.ignore_transition,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.cover_position_tilt,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.thermostat_occupied_heating_setpoint,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.tint_scene,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_brightness_move,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_brightness_step,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_colortemp_step,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_colortemp_move,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_hue_saturation_move,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.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, attribute: string, type: 'get' | 'set'}
|
||||
interface ParsedTopic {
|
||||
ID: string;
|
||||
endpoint: string | undefined;
|
||||
attribute: string;
|
||||
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 {
|
||||
const match = topic.match(topicRegex);
|
||||
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 (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)
|
||||
|
||||
// Make the rough split on get/set keyword.
|
||||
// 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;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ID = match[1].replace(`${settings.get().mqtt.base_topic}/`, '');
|
||||
// If we didn't replace base_topic we received something we don't care about
|
||||
if (ID === match[1] || ID.match(/bridge/)) {
|
||||
return null;
|
||||
}
|
||||
const deviceNameAndEndpoint = match[1];
|
||||
const attribute = match[3];
|
||||
|
||||
return {ID: ID, endpoint: match[2], type: match[3] as 'get' | 'set', attribute: match[4]};
|
||||
// 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};
|
||||
}
|
||||
|
||||
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)};
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return {[parsedTopic.attribute]: data.message};
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return JSON.parse(data.message);
|
||||
} catch (e) {
|
||||
if (stateValues.includes(data.message.toLowerCase())) {
|
||||
return {state: data.message};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return STATE_VALUES.includes(data.message.toLowerCase()) ? {state: data.message} : undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
legacyLog(payload: KeyValue): void {
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
this.mqtt.publish('bridge/log', stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
legacyRetrieveState(re: Device | Group, converter: zhc.ToZigbeeConverter, result: zhc.ToZigbeeConverterResult,
|
||||
target: zh.Endpoint | zh.Group, key: string, meta: zhc.ToZigbeeConverterGetMeta): 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) {
|
||||
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.ToZigbeeConverter[];
|
||||
{
|
||||
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 if (definition) {
|
||||
converters = definition.toZigbee;
|
||||
} else {
|
||||
converters = [zigbeeHerdsmanConverters.toZigbeeConverters.read,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.write];
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
/**
|
||||
@@ -172,63 +152,82 @@ 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.ToZigbeeConverter[]} = {};
|
||||
const usedConverters: {[s: number]: zhc.Tz.Converter[]} = {};
|
||||
const toPublish: {[s: number | string]: KeyValue} = {};
|
||||
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};
|
||||
};
|
||||
|
||||
for (let [key, value] of entries) {
|
||||
const endpointNames = re instanceof Device ? re.getEndpointNames() : [];
|
||||
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);
|
||||
if (localTarget == null) {
|
||||
logger.error(`Device '${re.name}' has no endpoint '${endpointName}'`);
|
||||
continue;
|
||||
}
|
||||
// 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] = [];
|
||||
const converter = converters.find((c) => c.key.includes(key));
|
||||
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)) && (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 meta = {endpoint_name: endpointName, options: entitySettings, message: {...message}, logger, device,
|
||||
state: entityState, membersState, mapped: definition};
|
||||
const entitySettingsKeyValue: KeyValue = entitySettings;
|
||||
const meta: zhc.Tz.Meta = {
|
||||
endpoint_name: endpointName,
|
||||
options: entitySettingsKeyValue,
|
||||
message: {...message},
|
||||
device,
|
||||
state: entityState,
|
||||
membersState,
|
||||
mapped: definition,
|
||||
/* v8 ignore next */
|
||||
publish: (payload: KeyValue) => this.publishEntityState(re, payload),
|
||||
};
|
||||
|
||||
// Strip endpoint name from meta.message properties.
|
||||
if (endpointName) {
|
||||
@@ -242,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) {
|
||||
@@ -262,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 {
|
||||
@@ -277,26 +276,35 @@ export default class Publish extends Extension {
|
||||
continue;
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
`Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`;
|
||||
const message = `Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`;
|
||||
logger.error(message);
|
||||
logger.debug(error.stack);
|
||||
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);
|
||||
}
|
||||
|
||||
for (const [ID, payload] of Object.entries(toPublish)) {
|
||||
if (Object.keys(payload).length != 0) {
|
||||
this.publishEntityState(toPublishEntity[ID], payload);
|
||||
if (!scenesChanged && converter.key) {
|
||||
scenesChanged = converter.key.some((k) => SCENE_CONVERTER_KEYS.includes(k));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [ID, payload] of Object.entries(toPublish)) {
|
||||
if (!utils.objectIsEmpty(payload)) {
|
||||
await this.publishEntityState(toPublishEntity[ID], payload);
|
||||
}
|
||||
}
|
||||
|
||||
const scenesChanged = Object.values(usedConverters)
|
||||
.some((cl) => cl.some((c) => c.key.some((k) => sceneConverterKeys.includes(k))));
|
||||
if (scenesChanged) {
|
||||
this.eventBus.emitScenesChanged();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+89
-67
@@ -1,42 +1,55 @@
|
||||
import * as settings from '../util/settings';
|
||||
import logger from '../util/logger';
|
||||
import debounce from 'debounce';
|
||||
import Extension from './extension';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import bind from 'bind-decorator';
|
||||
import utils from '../util/utils';
|
||||
import assert from "node:assert";
|
||||
|
||||
type DebounceFunction = (() => void) & { clear(): void; } & { flush(): void; };
|
||||
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 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
|
||||
* remove it from the to be send debounced message.
|
||||
*/
|
||||
if (data.entity.isDevice() && this.debouncers[data.entity.ieeeAddr] &&
|
||||
data.stateChangeReason !== 'publishDebounce' && data.stateChangeReason !== 'lastSeenChanged') {
|
||||
if (
|
||||
data.entity.isDevice() &&
|
||||
this.debouncers[data.entity.ieeeAddr] &&
|
||||
data.stateChangeReason !== "publishDebounce" &&
|
||||
data.stateChangeReason !== "lastSeenChanged"
|
||||
) {
|
||||
for (const key of Object.keys(data.payload)) {
|
||||
delete this.debouncers[data.entity.ieeeAddr].payload[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(() => {
|
||||
this.publishEntityState(device, this.debouncers[device.ieeeAddr].payload, 'publishDebounce');
|
||||
publish: debounce(async () => {
|
||||
await this.publishEntityState(device, this.debouncers[device.ieeeAddr].payload, "publishDebounce");
|
||||
this.debouncers[device.ieeeAddr].payload = {};
|
||||
}, time * 1000),
|
||||
};
|
||||
@@ -59,48 +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) {
|
||||
if (data.device.zh.interviewing) {
|
||||
logger.debug(`Skipping message, definition is undefined and still interviewing`);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Received message from unsupported device with Zigbee model '${data.device.zh.modelID}' ` +
|
||||
`and manufacturer name '${data.device.zh.manufacturerName}'`);
|
||||
// eslint-disable-next-line max-len
|
||||
logger.warn(`Please see: https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`);
|
||||
}
|
||||
|
||||
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)) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -110,12 +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)) {
|
||||
logger.debug(`No converter available for '${data.device.definition.model}' with ` +
|
||||
`cluster '${data.cluster}' and type '${data.type}' and data '${stringify(data.data)}'`);
|
||||
utils.publishLastSeen({device: data.device, reason: 'messageEmitted'},
|
||||
settings.get(), true, this.publishEntityState);
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -124,7 +133,11 @@ export default class Receive extends Extension {
|
||||
// - If a payload is returned publish it to the MQTT broker
|
||||
// - 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 = (payload: KeyValue): void => {
|
||||
const publish = async (payload: KeyValue): Promise<void> => {
|
||||
assert(data.device.definition);
|
||||
const options: KeyValue = data.device.options;
|
||||
zhc.postProcessConvertedFromZigbeeMessage(data.device.definition, payload, options, data.device.zh);
|
||||
|
||||
if (settings.get().advanced.elapsed) {
|
||||
const now = Date.now();
|
||||
if (this.elapsed[data.device.ieeeAddr]) {
|
||||
@@ -134,35 +147,44 @@ 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);
|
||||
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 {
|
||||
this.publishEntityState(data.device, payload);
|
||||
await this.publishEntityState(data.device, payload);
|
||||
}
|
||||
};
|
||||
|
||||
const meta = {device: data.device.zh, logger, state: this.state.get(data.device)};
|
||||
const meta = {
|
||||
device: data.device.zh,
|
||||
logger,
|
||||
state: this.state.get(data.device),
|
||||
deviceExposesChanged: (): void => this.eventBus.emitExposesAndDevicesChanged(data.device),
|
||||
};
|
||||
let payload: KeyValue = {};
|
||||
for (const converter of converters) {
|
||||
try {
|
||||
const converted = await converter.convert(
|
||||
data.device.definition, data, publish, data.device.options, meta);
|
||||
const convertData = {...data, device: data.device.zh};
|
||||
const options: KeyValue = data.device.options;
|
||||
const converted = await converter.convert(data.device.definition, convertData, publish, options, meta);
|
||||
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 (Object.keys(payload).length) {
|
||||
publish(payload);
|
||||
if (!utils.objectIsEmpty(payload)) {
|
||||
await publish(payload);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+125
-51
@@ -1,79 +1,153 @@
|
||||
/* eslint-disable brace-style */
|
||||
import * as settings from '../util/settings';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
import assert from "node:assert";
|
||||
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
|
||||
import type {OtaExtraMetas} from "zigbee-herdsman/dist/controller/tstype";
|
||||
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 logger from "../util/logger";
|
||||
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;
|
||||
private _definition: zhc.Definition;
|
||||
private _definitionModelID: string;
|
||||
public definition?: zhc.Definition;
|
||||
private _definitionModelID?: string;
|
||||
|
||||
get ieeeAddr(): string {return this.zh.ieeeAddr;}
|
||||
get ID(): string {return this.zh.ieeeAddr;}
|
||||
get options(): DeviceOptions {return {...settings.get().device_options, ...settings.getDevice(this.ieeeAddr)};}
|
||||
get name(): string {
|
||||
return this.zh.type === 'Coordinator' ? 'Coordinator' : this.options?.friendly_name || this.ieeeAddr;
|
||||
get ieeeAddr(): string {
|
||||
return this.zh.ieeeAddr;
|
||||
}
|
||||
get definition(): zhc.Definition {
|
||||
// Some devices can change modelID, reconsider the definition in that case.
|
||||
// https://github.com/Koenkk/zigbee-herdsman-converters/issues/3016
|
||||
if (!this.zh.interviewing && (!this._definition || this._definitionModelID !== this.zh.modelID)) {
|
||||
this._definition = zigbeeHerdsmanConverters.findByDevice(this.zh);
|
||||
this._definitionModelID = this.zh.modelID;
|
||||
}
|
||||
return this._definition;
|
||||
// biome-ignore lint/style/useNamingConvention: API
|
||||
get ID(): string {
|
||||
return this.zh.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;
|
||||
}
|
||||
get isSupported(): boolean {
|
||||
return this.zh.type === "Coordinator" || Boolean(this.definition && !this.definition.generated);
|
||||
}
|
||||
get customClusters(): CustomClusters {
|
||||
return this.zh.customClusters;
|
||||
}
|
||||
get otaExtraMetas(): OtaExtraMetas {
|
||||
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.DefinitionExpose[] {
|
||||
/* istanbul ignore if */
|
||||
if (typeof this.definition.exposes == 'function') {
|
||||
return this.definition.exposes(this.zh, this.options);
|
||||
exposes(): zhc.Expose[] {
|
||||
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;
|
||||
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.interviewed && (!this.definition || this._definitionModelID !== this.zh.modelID || ignoreCache)) {
|
||||
this.definition = await zhc.findByDevice(this.zh, true);
|
||||
this._definitionModelID = this.zh.modelID;
|
||||
}
|
||||
}
|
||||
|
||||
async reInterview(eventBus: EventBus): Promise<void> {
|
||||
// logic follows that of receiving `deviceInterview` event from ZH layer
|
||||
logger.info(`Interviewing '${this.name}'`);
|
||||
eventBus.emitDeviceInterview({status: "started", device: this});
|
||||
|
||||
try {
|
||||
await this.zh.interview(true);
|
||||
// A re-interview can for example result in a different modelId, therefore reconsider the definition.
|
||||
await this.resolveDefinition(true);
|
||||
logger.info(`Successfully interviewed '${this.name}'`);
|
||||
eventBus.emitDeviceInterview({status: "successful", device: this});
|
||||
} catch (error) {
|
||||
eventBus.emitDeviceInterview({status: "failed", device: this});
|
||||
throw new Error(`Interview of '${this.name}' (${this.ieeeAddr}) failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
endpointName(endpoint: zh.Endpoint): string {
|
||||
let name = null;
|
||||
if (this.definition?.endpoint) {
|
||||
name = Object.entries(this.definition?.endpoint(this.zh)).find((e) => e[1] == endpoint.ID)[0];
|
||||
const ID = this.definition.endpoint(this.zh)[key];
|
||||
|
||||
if (ID) {
|
||||
return this.zh.getEndpoint(ID);
|
||||
}
|
||||
}
|
||||
/* istanbul ignore next */
|
||||
return name === 'default' ? null : name;
|
||||
|
||||
return key === "default" ? this.zh.endpoints[0] : undefined;
|
||||
}
|
||||
|
||||
isIkeaTradfri(): boolean {return this.zh.manufacturerID === 4476;}
|
||||
endpointName(endpoint: zh.Endpoint): string | undefined {
|
||||
let epName: string | undefined;
|
||||
|
||||
isDevice(): this is Device {return true;}
|
||||
/* istanbul ignore next */
|
||||
isGroup(): this is Group {return false;}
|
||||
if (this.definition?.endpoint) {
|
||||
const mapping = this.definition.endpoint(this.zh);
|
||||
|
||||
for (const name in mapping) {
|
||||
if (mapping[name] === endpoint.ID) {
|
||||
epName = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* v8 ignore next */
|
||||
return epName === "default" ? undefined : epName;
|
||||
}
|
||||
|
||||
getEndpointNames(): string[] {
|
||||
const names: string[] = [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
isGroup(): this is Group {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+45
-14
@@ -1,33 +1,64 @@
|
||||
/* eslint-disable brace-style */
|
||||
import * as settings from '../util/settings';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
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;
|
||||
|
||||
get ID(): number {return this.zh.groupID;}
|
||||
get options(): GroupOptions {return {...settings.getGroup(this.ID)};}
|
||||
get name(): string {return this.options?.friendly_name || this.ID.toString();}
|
||||
// biome-ignore lint/style/useNamingConvention: API
|
||||
get ID(): number {
|
||||
return this.zh.groupID;
|
||||
}
|
||||
get options(): GroupOptions {
|
||||
// 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.zh.members.map((m) =>
|
||||
zigbeeHerdsmanConverters.findByDevice(m.getDevice())).filter((d) => d) as zhc.Definition[];
|
||||
const definitions: zhc.Definition[] = [];
|
||||
|
||||
for (const member of this.membersDevices()) {
|
||||
if (member.definition) {
|
||||
definitions.push(member.definition);
|
||||
}
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
isDevice(): this is Device {return false;}
|
||||
isGroup(): this is Group {return true;}
|
||||
isDevice(): this is Device {
|
||||
return false;
|
||||
}
|
||||
isGroup(): this is Group {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+154
-98
@@ -1,35 +1,70 @@
|
||||
import mqtt from 'mqtt';
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import utils from './util/utils';
|
||||
import fs from 'fs';
|
||||
import bind from 'bind-decorator';
|
||||
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";
|
||||
|
||||
export default class MQTT {
|
||||
private publishedTopics: Set<string> = new Set();
|
||||
private connectionTimer: NodeJS.Timeout;
|
||||
private client: mqtt.MqttClient;
|
||||
import logger from "./util/logger";
|
||||
import * as settings from "./util/settings";
|
||||
import utils from "./util/utils";
|
||||
|
||||
const NS = "z2m:mqtt";
|
||||
|
||||
export interface MqttPublishOptions {
|
||||
clientOptions: IClientPublishOptions;
|
||||
baseTopic: string;
|
||||
skipLog: boolean;
|
||||
skipReceive: boolean;
|
||||
meta: {isEntityState?: boolean};
|
||||
}
|
||||
|
||||
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.Timer;
|
||||
private 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> {
|
||||
const mqttSettings = settings.get().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: 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) {
|
||||
@@ -57,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) {
|
||||
@@ -66,130 +104,148 @@ 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);
|
||||
|
||||
const onConnect = this.onConnect;
|
||||
this.client.on('connect', async () => {
|
||||
await onConnect();
|
||||
resolve();
|
||||
});
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/9822
|
||||
this.client.stream.setMaxListeners(0);
|
||||
|
||||
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 private async onConnect(): Promise<void> {
|
||||
// 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));
|
||||
|
||||
logger.info('Connected to MQTT server');
|
||||
await this.publishStateOnline();
|
||||
|
||||
if (!this.initialConnect) {
|
||||
this.republishRetainedTimer = setTimeout(() => {
|
||||
// Republish retained messages in case MQTT broker does not persist them.
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/9629
|
||||
Object.values(this.retainedMessages).forEach((e) =>
|
||||
this.publish(e.topic, e.payload, e.options, e.base, e.skipLog, e.skipReceive));
|
||||
}, 2000);
|
||||
if (mqttSettings.version != null && mqttSettings.version >= 5) {
|
||||
this.client.on("disconnect", (packet) => {
|
||||
logger.error(`MQTT disconnect: reason ${packet.reasonCode} (${packet.properties?.reasonString})`);
|
||||
});
|
||||
}
|
||||
|
||||
this.initialConnect = false;
|
||||
this.subscribe(`${settings.get().mqtt.base_topic}/#`);
|
||||
}
|
||||
this.client.on("message", this.onMessage);
|
||||
|
||||
async publishStateOnline(): Promise<void> {
|
||||
await this.publish('bridge/state', utils.availabilityPayload('online', settings.get()), {retain: true, qos: 0});
|
||||
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});
|
||||
this.eventBus.removeListeners(this);
|
||||
logger.info('Disconnecting from MQTT server');
|
||||
this.client.end();
|
||||
}
|
||||
clearTimeout(this.republishRetainedTimer);
|
||||
|
||||
subscribe(topic: string): void {
|
||||
this.client.subscribe(topic);
|
||||
}
|
||||
const stateData: Zigbee2MQTTAPI["bridge/state"] = {state: "offline"};
|
||||
|
||||
@bind public onMessage(topic: string, message: string): 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}'`);
|
||||
this.eventBus.emitMQTTMessage({topic, message: message + ''});
|
||||
// prevent undesirable error when receiving SIGTERM/SIGINT during startup
|
||||
if (this.client) {
|
||||
await this.publish("bridge/state", JSON.stringify(stateData), {clientOptions: {retain: true}});
|
||||
}
|
||||
|
||||
if (this.republishRetainedTimer && topic == `${settings.get().mqtt.base_topic}/bridge/state`) {
|
||||
this.eventBus.removeListeners(this);
|
||||
logger.info("Disconnecting from MQTT server");
|
||||
await this.client?.endAsync();
|
||||
}
|
||||
|
||||
async subscribe(topic: string): Promise<void> {
|
||||
await this.client.subscribeAsync(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);
|
||||
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: mqtt.QoS, retain: boolean} = {qos: 0, retain: false};
|
||||
topic = `${base}/${topic}`;
|
||||
async publish(topic: string, payload: string, options: Partial<MqttPublishOptions> = {}): Promise<void> {
|
||||
// TODO: add `options.validateTopic: boolean` to bypass these checks when topic is "controlled"
|
||||
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()) {
|
||||
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}'`);
|
||||
}
|
||||
|
||||
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((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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+76
-37
@@ -1,24 +1,44 @@
|
||||
import logger from './util/logger';
|
||||
import data from './util/data';
|
||||
import * as settings from './util/settings';
|
||||
import utils from './util/utils';
|
||||
import fs from 'fs';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import {existsSync, readFileSync, writeFileSync} from "node:fs";
|
||||
|
||||
const saveInterval = 1000 * 60 * 5; // 5 minutes
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
|
||||
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',
|
||||
import data from "./util/data";
|
||||
import logger from "./util/logger";
|
||||
import * as settings from "./util/settings";
|
||||
import utils from "./util/utils";
|
||||
|
||||
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.Timer = 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, private readonly zigbee: Zigbee) {
|
||||
constructor(
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly zigbee: Zigbee,
|
||||
) {
|
||||
this.eventBus = eventBus;
|
||||
this.zigbee = zigbee;
|
||||
}
|
||||
@@ -27,26 +47,43 @@ 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]);
|
||||
// ensure properly started, else this throws undesired errors (e.g. SIGINT during startup)
|
||||
if (this.zigbee.zhController !== undefined) {
|
||||
// Remove any invalid states (ie when the device has left the network) when the system is stopped
|
||||
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 (e) {
|
||||
logger.debug(`Failed to load state from file ${this.file} (corrupt file?)`);
|
||||
} catch (error) {
|
||||
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)`);
|
||||
@@ -56,40 +93,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1087
File diff suppressed because it is too large
Load Diff
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
+60
-278
@@ -1,38 +1,20 @@
|
||||
/* eslint-disable camelcase */
|
||||
import type {
|
||||
Device as ZHDevice,
|
||||
Group as ZHGroup,
|
||||
Endpoint as ZHEndpoint,
|
||||
} from 'zigbee-herdsman/dist/controller/model';
|
||||
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 type {
|
||||
NetworkParameters as ZHNetworkParameters,
|
||||
CoordinatorVersion as ZHCoordinatorVersion,
|
||||
LQI as ZHLQI,
|
||||
RoutingTable as ZHRoutingTable,
|
||||
RoutingTableEntry as ZHRoutingTableEntry,
|
||||
} from 'zigbee-herdsman/dist/adapter/tstype';
|
||||
|
||||
import type {
|
||||
Cluster as ZHCluster,
|
||||
} from 'zigbee-herdsman/dist/zcl/tstype';
|
||||
|
||||
import type * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
|
||||
|
||||
import type TypeEventBus from 'lib/eventBus';
|
||||
import type TypeMQTT from 'lib/mqtt';
|
||||
import type TypeState from 'lib/state';
|
||||
import type TypeZigbee from 'lib/zigbee';
|
||||
import type TypeDevice from 'lib/model/device';
|
||||
import type TypeGroup from 'lib/model/group';
|
||||
import type TypeExtension from 'lib/extension/extension';
|
||||
|
||||
import type mqtt from 'mqtt';
|
||||
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;
|
||||
@@ -40,276 +22,76 @@ declare global {
|
||||
type Extension = TypeExtension;
|
||||
|
||||
// Types
|
||||
interface MQTTResponse {data: KeyValue, status: 'error' | 'ok', error?: string, transaction?: string}
|
||||
interface MQTTOptions {qos?: mqtt.QoS, retain?: boolean, properties?: {messageExpiryInterval: number}}
|
||||
type StateChangeReason = 'publishDebounce' | 'groupOptimistic' | 'lastSeenChanged' | 'publishCached';
|
||||
type PublishEntityState = (entity: Device | Group, payload: KeyValue,
|
||||
stateChangeReason?: StateChangeReason) => Promise<void>;
|
||||
type RecursivePartial<T> = {[P in keyof T]?: RecursivePartial<T[P]>;};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
interface KeyValue {[s: string]: any}
|
||||
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 {
|
||||
// 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;
|
||||
type CoordinatorVersion = ZHAdapterTypes.CoordinatorVersion;
|
||||
type NetworkParameters = ZHAdapterTypes.NetworkParameters;
|
||||
interface Bind {
|
||||
cluster: zh.Cluster;
|
||||
cluster: ZHCluster;
|
||||
target: zh.Endpoint | zh.Group;
|
||||
}
|
||||
}
|
||||
|
||||
// zigbee-herdsman-converters
|
||||
namespace zhc {
|
||||
interface Logger {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
debug: (message: string) => void;
|
||||
}
|
||||
|
||||
interface ToZigbeeConverterGetMeta {message?: KeyValue, mapped?: Definition | Definition[]}
|
||||
|
||||
interface ToZigbeeConverterResult {state: KeyValue,
|
||||
membersState: {[s: string]: KeyValue}, readAfterWriteTime?: number}
|
||||
|
||||
interface ToZigbeeConverter {
|
||||
key: string[],
|
||||
convertGet?: (entity: zh.Endpoint | zh.Group, key: string, meta: ToZigbeeConverterGetMeta) => Promise<void>
|
||||
convertSet?: (entity: zh.Endpoint | zh.Group, key: string, value: KeyValue | string | number,
|
||||
meta: {state: KeyValue}) => Promise<ToZigbeeConverterResult>
|
||||
}
|
||||
|
||||
interface FromZigbeeConverter {
|
||||
cluster: string,
|
||||
type: string[] | string,
|
||||
convert: (model: Definition, msg: KeyValue, publish: (payload: KeyValue) => void, options: KeyValue,
|
||||
meta: {state: KeyValue, logger: Logger, device: zh.Device}) => Promise<KeyValue>,
|
||||
}
|
||||
|
||||
interface DefinitionExposeFeature {name: string, endpoint?: string,
|
||||
property: string, value_max?: number, value_min?: number, unit?: string,
|
||||
value_off?: string, value_on?: string, value_step?: number, values: string[], access: number}
|
||||
|
||||
interface DefinitionExpose {
|
||||
type: string, name?: string, features?: DefinitionExposeFeature[],
|
||||
endpoint?: string, values?: string[], value_off?: string, value_on?: string, value_step?: number,
|
||||
access: number, property: string, unit?: string,
|
||||
value_min?: number, value_max?: number}
|
||||
|
||||
interface OtaUpdateAvailableResult {available: boolean, currentFileVersion: number, otaFileVersion: number}
|
||||
|
||||
interface Definition {
|
||||
model: string,
|
||||
zigbeeModel: string[],
|
||||
endpoint?: (device: zh.Device) => {[s: string]: number}
|
||||
toZigbee: ToZigbeeConverter[]
|
||||
fromZigbee: FromZigbeeConverter[]
|
||||
icon?: string
|
||||
description: string
|
||||
options: zhc.DefinitionExpose[],
|
||||
vendor: string
|
||||
exposes: DefinitionExpose[] | ((device: zh.Device, options: KeyValue) => DefinitionExpose[])
|
||||
configure?: (device: zh.Device, coordinatorEndpoint: zh.Endpoint, logger: Logger,
|
||||
options?: DeviceOptions) => Promise<void>;
|
||||
onEvent?: (type: string, data: KeyValue, device: zh.Device,
|
||||
settings: KeyValue, state: KeyValue) => Promise<void>;
|
||||
ota?: {
|
||||
isUpdateAvailable: (device: zh.Device, logger: Logger, data?: KeyValue)
|
||||
=> Promise<OtaUpdateAvailableResult>;
|
||||
updateToLatest: (device: zh.Device, logger: Logger,
|
||||
onProgress: (progress: number, remaining: number) => void) => Promise<number>;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ExternalDefinition = Definition & {homeassistant: any};
|
||||
}
|
||||
|
||||
namespace eventdata {
|
||||
type EntityRenamed = { entity: Device | Group, homeAssisantRename: boolean, from: string, to: string };
|
||||
type DeviceRemoved = { ieeeAddr: string, name: string };
|
||||
type MQTTMessage = { topic: string, message: string };
|
||||
type MQTTMessagePublished = { topic: string, payload: string, options: {retain: boolean, qos: number} };
|
||||
type EntityRenamed = {entity: Device | Group; homeAssisantRename: boolean; from: string; to: string};
|
||||
type EntityRemoved = {entity: Device | Group; name: string};
|
||||
type MQTTMessage = {topic: string; message: string};
|
||||
type MQTTMessagePublished = {topic: string; payload: string; options: MqttPublishOptions};
|
||||
type StateChange = {
|
||||
entity: Device | Group, from: KeyValue, to: KeyValue, reason: string | null, update: KeyValue };
|
||||
entity: Device | Group;
|
||||
from: KeyValue;
|
||||
to: KeyValue;
|
||||
reason?: string;
|
||||
update: KeyValue;
|
||||
};
|
||||
type PermitJoinChanged = ZHEvents.PermitJoinChangedPayload;
|
||||
type LastSeenChanged = { device: Device,
|
||||
reason: 'deviceAnnounce' | 'networkAddress' | 'deviceJoined' | 'messageEmitted' | 'messageNonEmitted'; };
|
||||
type DeviceNetworkAddressChanged = { device: Device };
|
||||
type DeviceAnnounce = { device: Device };
|
||||
type DeviceInterview = { device: Device, status: 'started' | 'successful' | 'failed' };
|
||||
type DeviceJoined = { device: Device };
|
||||
type EntityOptionsChanged = { entity: Device | Group, from: KeyValue, to: KeyValue };
|
||||
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 LastSeenChanged = {
|
||||
device: Device;
|
||||
reason: "deviceAnnounce" | "networkAddress" | "deviceJoined" | "messageEmitted" | "messageNonEmitted";
|
||||
};
|
||||
type DeviceNetworkAddressChanged = {device: Device};
|
||||
type DeviceAnnounce = {device: Device};
|
||||
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; 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;};
|
||||
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?: number,
|
||||
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',
|
||||
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: 'debug' | 'info' | 'error' | 'warn',
|
||||
log_syslog: KeyValue,
|
||||
pan_id: number | 'GENERATE',
|
||||
ext_pan_id: number[],
|
||||
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;
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
declare module 'zigbee-herdsman-converters' {
|
||||
export function findByDevice(device: zh.Device): zhc.Definition;
|
||||
export function getConfigureKey(definition: zhc.Definition): string | number;
|
||||
export const toZigbeeConverters: {[s: string]: zhc.ToZigbeeConverter};
|
||||
export const definitions: zhc.Definition[];
|
||||
export function addDeviceDefinition(definition: zhc.Definition): Promise<void>;
|
||||
export function onEvent(type: string, data: KeyValue, device: zh.Device, settings: KeyValue,
|
||||
state: KeyValue): Promise<void>;
|
||||
}
|
||||
|
||||
declare module 'zigbee-herdsman-converters/lib/ota/tradfri' {
|
||||
export function useTestURL(): void;
|
||||
}
|
||||
|
||||
declare module 'zigbee-herdsman-converters/lib/ota/zigbeeOTA' {
|
||||
export function useIndexOverride(indexFileName: string): void;
|
||||
export function setDataDir(dataDir: string): void;
|
||||
}
|
||||
|
||||
declare module 'zigbee-herdsman-converters/lib/philips' {
|
||||
export const tz: {[s: string]: zhc.ToZigbeeConverter};
|
||||
}
|
||||
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};
|
||||
|
||||
+246
-198
@@ -1,225 +1,273 @@
|
||||
import winston from 'winston';
|
||||
import moment from 'moment';
|
||||
import * as settings from './settings';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import fx from 'mkdir-recursive';
|
||||
import {rimrafSync} from 'rimraf';
|
||||
import assert from 'assert';
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const colorizer = winston.format.colorize();
|
||||
import {rimrafSync} from "rimraf";
|
||||
import winston from "winston";
|
||||
import * as settings from "./settings";
|
||||
|
||||
type Z2MLogLevel = 'warn' | 'debug' | 'info' | 'error';
|
||||
type WinstonLogLevel = 'warning' | 'debug' | 'info' | 'error';
|
||||
const NAMESPACE_SEPARATOR = ":";
|
||||
|
||||
const z2mToWinstonLevel = (level: Z2MLogLevel): WinstonLogLevel => level === 'warn' ? 'warning' : level;
|
||||
const winstonToZ2mLevel = (level: WinstonLogLevel): Z2MLogLevel => level === 'warning' ? 'warn' : level;
|
||||
class Logger {
|
||||
private level!: settings.LogLevel;
|
||||
private output!: string[];
|
||||
private directory!: string;
|
||||
private logger!: winston.Logger;
|
||||
private fileTransport: winston.transports.FileTransportInstance | undefined;
|
||||
private debugNamespaceIgnoreRegex?: RegExp;
|
||||
private namespacedLevels!: Record<string, settings.LogLevel>;
|
||||
private cachedNamespacedLevels!: Record<string, settings.LogLevel>;
|
||||
|
||||
const levelWithCompensatedLength: {[s: string]: string} = {
|
||||
'info': 'info ',
|
||||
'error': 'error',
|
||||
'warn': 'warn ',
|
||||
'debug': 'debug',
|
||||
};
|
||||
public init(): void {
|
||||
// What transports to enable
|
||||
this.output = settings.get().advanced.log_output;
|
||||
const date = new Date();
|
||||
// offset UTC by current timezone, ISO keeps "Z" (UTC) which is then wrong but we strip it
|
||||
const timestamp = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 19)
|
||||
.replace("T", ".")
|
||||
.replaceAll(":", "-");
|
||||
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);
|
||||
|
||||
let logger: winston.Logger;
|
||||
let fileTransport : winston.transport;
|
||||
let output: string[];
|
||||
let directory: string;
|
||||
let logFilename: string;
|
||||
let transportsToUse: winston.transport[];
|
||||
assert(settings.LOG_LEVELS.includes(this.level), `'${this.level}' is not valid log_level, use one of '${settings.LOG_LEVELS.join(", ")}'`);
|
||||
|
||||
function init(): void {
|
||||
// What transports to enable
|
||||
output = settings.get().advanced.log_output;
|
||||
|
||||
// Directory to log to
|
||||
const timestamp = moment(Date.now()).format('YYYY-MM-DD.HH-mm-ss');
|
||||
directory = settings.get().advanced.log_directory.replace('%TIMESTAMP%', timestamp);
|
||||
logFilename = settings.get().advanced.log_file.replace('%TIMESTAMP%', timestamp);
|
||||
|
||||
// Make sure that log directory exists when not logging to stdout only
|
||||
if (output.includes('file')) {
|
||||
fx.mkdirSync(directory);
|
||||
|
||||
if (settings.get().advanced.log_symlink_current) {
|
||||
const current = settings.get().advanced.log_directory.replace('%TIMESTAMP%', 'current');
|
||||
const actual = './' + timestamp;
|
||||
/* istanbul ignore next */
|
||||
if (fs.existsSync(current)) {
|
||||
fs.unlinkSync(current);
|
||||
}
|
||||
fs.symlinkSync(actual, current);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the log level.
|
||||
const z2mLevel = settings.get().advanced.log_level;
|
||||
const validLevels = ['info', 'error', 'warn', 'debug'];
|
||||
assert(validLevels.includes(z2mLevel),
|
||||
`'${z2mLevel}' is not valid log_level, use one of '${validLevels.join(', ')}'`);
|
||||
const level = z2mToWinstonLevel(z2mLevel);
|
||||
|
||||
const timestampFormat = (): string => moment().format(settings.get().advanced.timestamp_format);
|
||||
|
||||
// Setup default console logger
|
||||
transportsToUse = [
|
||||
new winston.transports.Console({
|
||||
level,
|
||||
silent: !output.includes('console'),
|
||||
this.logger = winston.createLogger({
|
||||
level: "debug",
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({format: timestampFormat}),
|
||||
winston.format.printf(/* istanbul ignore next */(info) => {
|
||||
const {timestamp, level, message} = info;
|
||||
const l = winstonToZ2mLevel(level as WinstonLogLevel);
|
||||
|
||||
const plainPrefix = `Zigbee2MQTT:${levelWithCompensatedLength[l]}`;
|
||||
let prefix = plainPrefix;
|
||||
if (process.stdout.isTTY) {
|
||||
prefix = colorizer.colorize(l, plainPrefix);
|
||||
}
|
||||
return `${prefix} ${timestamp.split('.')[0]}: ${message}`;
|
||||
}),
|
||||
winston.format.errors({stack: true}),
|
||||
winston.format.timestamp({format: settings.get().advanced.timestamp_format}),
|
||||
),
|
||||
}),
|
||||
];
|
||||
|
||||
// Add file logger when enabled
|
||||
// NOTE: the initiation of the logger, even when not added as transport tries to create the logging directory
|
||||
const transportFileOptions: KeyValue = {
|
||||
filename: path.join(directory, logFilename),
|
||||
json: false,
|
||||
level,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({format: timestampFormat}),
|
||||
winston.format.printf(/* istanbul ignore next */(info) => {
|
||||
const {timestamp, level, message} = info;
|
||||
const l = winstonToZ2mLevel(level as WinstonLogLevel);
|
||||
return `${levelWithCompensatedLength[l]} ${timestamp.split('.')[0]}: ${message}`;
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
if (settings.get().advanced.log_rotation) {
|
||||
transportFileOptions.tailable = true;
|
||||
transportFileOptions.maxFiles = 3; // Keep last 3 files
|
||||
transportFileOptions.maxsize = 10000000; // 10MB
|
||||
}
|
||||
|
||||
if (output.includes('file')) {
|
||||
fileTransport = new winston.transports.File(transportFileOptions);
|
||||
transportsToUse.push(fileTransport);
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (output.includes('syslog')) {
|
||||
// eslint-disable-next-line
|
||||
require('winston-syslog').Syslog;
|
||||
const options: KeyValue = {
|
||||
app_name: 'Zigbee2MQTT',
|
||||
format: winston.format.printf(/* istanbul ignore next */(info) => {
|
||||
return `${info.message}`;
|
||||
}),
|
||||
...settings.get().advanced.log_syslog,
|
||||
};
|
||||
if (options.hasOwnProperty('type')) options.type = options.type.toString();
|
||||
// @ts-ignore
|
||||
transportsToUse.push(new winston.transports.Syslog(options));
|
||||
}
|
||||
|
||||
logger = winston.createLogger({transports: transportsToUse, levels: winston.config.syslog.levels});
|
||||
}
|
||||
|
||||
// Cleanup any old log directory.
|
||||
function cleanup(): void {
|
||||
if (settings.get().advanced.log_directory.includes('%TIMESTAMP%')) {
|
||||
const rootDirectory = path.join(directory, '..');
|
||||
|
||||
let directories = fs.readdirSync(rootDirectory).map((d) => {
|
||||
d = path.join(rootDirectory, d);
|
||||
return {path: d, birth: fs.statSync(d).mtime};
|
||||
levels: winston.config.syslog.levels,
|
||||
});
|
||||
|
||||
directories.sort((a: KeyValue, b: KeyValue) => b.birth - a.birth);
|
||||
directories = directories.slice(10, directories.length);
|
||||
directories.forEach((dir) => {
|
||||
logger.debug(`Removing old log directory '${dir.path}'`);
|
||||
rimrafSync(dir.path);
|
||||
});
|
||||
}
|
||||
}
|
||||
const consoleSilenced = !this.output.includes("console");
|
||||
// Print to user what logging is active
|
||||
let logging = `Logging to console${consoleSilenced ? " (silenced)" : ""}`;
|
||||
|
||||
// Print to user what logging is enabled
|
||||
function logOutput(): void {
|
||||
if (output.includes('file')) {
|
||||
if (output.includes('console')) {
|
||||
logger.info(`Logging to console and directory: '${directory}' filename: ${logFilename}`);
|
||||
} else {
|
||||
logger.info(`Logging to directory: '${directory}' filename: ${logFilename}`);
|
||||
// Setup default console logger
|
||||
this.logger.add(
|
||||
new winston.transports.Console({
|
||||
silent: consoleSilenced,
|
||||
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")) {
|
||||
logging += `, file (filename: ${logFilename})`;
|
||||
|
||||
// Make sure that log directory exists when not logging to stdout only
|
||||
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}`;
|
||||
|
||||
/* v8 ignore start */
|
||||
if (fs.existsSync(current)) {
|
||||
fs.unlinkSync(current);
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
fs.symlinkSync(actual, current);
|
||||
}
|
||||
|
||||
// Add file logger when enabled
|
||||
// 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((info) => {
|
||||
return `[${info.timestamp}] ${info.level}: \t${info.message}`;
|
||||
}),
|
||||
};
|
||||
|
||||
if (settings.get().advanced.log_rotation) {
|
||||
transportFileOptions.tailable = true;
|
||||
transportFileOptions.maxFiles = 3; // Keep last 3 files
|
||||
transportFileOptions.maxsize = 10000000; // 10MB
|
||||
}
|
||||
|
||||
this.fileTransport = new winston.transports.File(transportFileOptions);
|
||||
this.logger.add(this.fileTransport);
|
||||
this.cleanup();
|
||||
}
|
||||
cleanup();
|
||||
} else if (output.includes('console')) {
|
||||
logger.info(`Logging to console only'`);
|
||||
|
||||
/* 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 as string),
|
||||
...settings.get().advanced.log_syslog,
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
this.info(logging);
|
||||
}
|
||||
}
|
||||
|
||||
function addTransport(transport: winston.transport): void {
|
||||
transport.level = transportsToUse[0].level;
|
||||
logger.add(transport);
|
||||
}
|
||||
get winston(): winston.Logger {
|
||||
return this.logger;
|
||||
}
|
||||
|
||||
function getLevel(): Z2MLogLevel {
|
||||
return winstonToZ2mLevel(transportsToUse[0].level as WinstonLogLevel);
|
||||
}
|
||||
public addTransport(transport: winston.transport): void {
|
||||
this.logger.add(transport);
|
||||
}
|
||||
|
||||
function setLevel(level: Z2MLogLevel): void {
|
||||
logger.transports.forEach((transport) => transport.level = z2mToWinstonLevel(level as Z2MLogLevel));
|
||||
}
|
||||
public removeTransport(transport: winston.transport): void {
|
||||
this.logger.remove(transport);
|
||||
}
|
||||
|
||||
function warn(message: string): void {
|
||||
// winston.config.syslog.levels doesn't have warn, but is required for syslog.
|
||||
logger.warning(message);
|
||||
}
|
||||
public getDebugNamespaceIgnore(): string {
|
||||
return this.debugNamespaceIgnoreRegex?.toString().slice(1, -1) /* remove slashes */ ?? "";
|
||||
}
|
||||
|
||||
function warning(message: string): void {
|
||||
logger.warning(message);
|
||||
}
|
||||
public setDebugNamespaceIgnore(value: string): void {
|
||||
this.debugNamespaceIgnoreRegex = value !== "" ? new RegExp(value) : undefined;
|
||||
}
|
||||
|
||||
function info(message: string): void {
|
||||
logger.info(message);
|
||||
}
|
||||
public getLevel(): settings.LogLevel {
|
||||
return this.level;
|
||||
}
|
||||
|
||||
function debug(message: string): void {
|
||||
logger.debug(message);
|
||||
}
|
||||
public setLevel(level: settings.LogLevel): void {
|
||||
this.level = level;
|
||||
this.resetCachedNamespacedLevels();
|
||||
}
|
||||
|
||||
function error(message: string): void {
|
||||
logger.error(message);
|
||||
}
|
||||
public getNamespacedLevels(): Record<string, settings.LogLevel> {
|
||||
return this.namespacedLevels;
|
||||
}
|
||||
|
||||
// Workaround for https://github.com/winstonjs/winston/issues/1629.
|
||||
// https://github.com/Koenkk/zigbee2mqtt/pull/10905
|
||||
/* istanbul ignore next */
|
||||
async function end(): Promise<void> {
|
||||
logger.end();
|
||||
public setNamespacedLevels(nsLevels: Record<string, settings.LogLevel>): void {
|
||||
this.namespacedLevels = nsLevels;
|
||||
this.resetCachedNamespacedLevels();
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!fileTransport) {
|
||||
process.nextTick(resolve);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
if (fileTransport._dest) {
|
||||
// @ts-ignore
|
||||
fileTransport._dest.on('finish', resolve);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
fileTransport.on('open', () => fileTransport._dest.on('finish', resolve));
|
||||
private resetCachedNamespacedLevels(): void {
|
||||
this.cachedNamespacedLevels = Object.assign({}, this.namespacedLevels);
|
||||
}
|
||||
|
||||
private cacheNamespacedLevel(namespace: string): string {
|
||||
let cached = namespace;
|
||||
|
||||
while (this.cachedNamespacedLevels[namespace] === undefined) {
|
||||
const sep = cached.lastIndexOf(NAMESPACE_SEPARATOR);
|
||||
|
||||
if (sep === -1) {
|
||||
this.cachedNamespacedLevels[namespace] = this.level;
|
||||
|
||||
return this.level;
|
||||
}
|
||||
|
||||
cached = cached.slice(0, sep);
|
||||
this.cachedNamespacedLevels[namespace] = this.cachedNamespacedLevels[cached];
|
||||
}
|
||||
|
||||
return this.cachedNamespacedLevels[namespace];
|
||||
}
|
||||
|
||||
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(messageOrLambda: string | (() => string), namespace = "z2m"): void {
|
||||
this.log("error", messageOrLambda, namespace);
|
||||
}
|
||||
|
||||
public warning(messageOrLambda: string | (() => string), namespace = "z2m"): void {
|
||||
this.log("warning", messageOrLambda, namespace);
|
||||
}
|
||||
|
||||
public info(messageOrLambda: string | (() => string), namespace = "z2m"): void {
|
||||
this.log("info", messageOrLambda, namespace);
|
||||
}
|
||||
|
||||
public debug(messageOrLambda: string | (() => string), namespace = "z2m"): void {
|
||||
if (this.debugNamespaceIgnoreRegex?.test(namespace)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.log("debug", messageOrLambda, namespace);
|
||||
}
|
||||
|
||||
// Cleanup any old log 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);
|
||||
return {path: d, birth: fs.statSync(d).mtime};
|
||||
});
|
||||
|
||||
directories.sort((a: KeyValue, b: KeyValue) => b.birth - a.birth);
|
||||
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}'`);
|
||||
try {
|
||||
rimrafSync(dir.path);
|
||||
} catch (e) {
|
||||
this.error(`Failed to remove old log directory '${dir.path}': ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Workaround for https://github.com/winstonjs/winston/issues/1629.
|
||||
// https://github.com/Koenkk/zigbee2mqtt/pull/10905
|
||||
/* v8 ignore start */
|
||||
public async end(): Promise<void> {
|
||||
// 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);
|
||||
} else {
|
||||
// @ts-expect-error workaround
|
||||
this.fileTransport.on("open", () => this.fileTransport._dest.on("finish", resolve));
|
||||
}
|
||||
|
||||
if (this.fileTransport) {
|
||||
this.fileTransport.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
export default {
|
||||
init, logOutput, warn, warning, error, info, debug, setLevel, getLevel, cleanup, addTransport, end,
|
||||
winston: (): winston.Logger => logger,
|
||||
};
|
||||
export default new Logger();
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
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" : ""}
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_RTSCTS ? "disabled" : ""}
|
||||
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">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="homeassistant_enabled"
|
||||
name="homeassistant_enabled"
|
||||
${currentSettings.homeassistant?.enabled ? "checked" : ""}
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_HOMEASSISTANT_ENABLED ? "disabled" : ""}>
|
||||
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, 10), 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, 10), 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 => {},
|
||||
};
|
||||
}
|
||||
+984
-907
File diff suppressed because it is too large
Load Diff
+360
-374
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,644 @@
|
||||
import {copyFileSync, existsSync, readFileSync, 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, 4, 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),
|
||||
});
|
||||
}
|
||||
|
||||
function migrateToFive(
|
||||
_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 5",
|
||||
newValue: 5,
|
||||
});
|
||||
additions.push();
|
||||
removals.push();
|
||||
|
||||
const resetOtaStates = (_currentSettings: Partial<Settings>): ReturnType<SettingsCustomHandler["execute"]> => {
|
||||
const filePath = data.joinPath("state.json");
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
const stateFile = readFileSync(filePath, "utf8");
|
||||
const stateObj = JSON.parse(stateFile) as KeyValue;
|
||||
|
||||
for (const key in stateObj) {
|
||||
const entry = stateObj[key];
|
||||
|
||||
if (entry.update) {
|
||||
delete entry.update;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(stateObj), "utf8");
|
||||
|
||||
// hack, this does not actually affect settings, we just want to ensure the migration note triggers
|
||||
return [true, undefined, true];
|
||||
} catch (error) {
|
||||
console.error(`Failed to write state to '${filePath}' (${error})`);
|
||||
}
|
||||
}
|
||||
|
||||
return [false, undefined, false];
|
||||
};
|
||||
|
||||
customHandlers.push({
|
||||
note: "Reset all cached OTA states due to internal changes.",
|
||||
noteIf: () => true,
|
||||
execute: (currentSettings) => resetOtaStates(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);
|
||||
} else if (currentSettings.version === 4) {
|
||||
migrationNotesFileName = "migration-4-to-5.log";
|
||||
|
||||
migrateToFive(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();
|
||||
}
|
||||
+262
-240
@@ -1,11 +1,23 @@
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import humanizeDuration from 'humanize-duration';
|
||||
import data from './data';
|
||||
import vm from 'vm';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {detailedDiff} from 'deep-object-diff';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
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 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:
|
||||
@@ -13,89 +25,74 @@ import objectAssignDeep from 'object-assign-deep';
|
||||
// - 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)}`;
|
||||
}
|
||||
|
||||
const endpointNames = [
|
||||
'left', 'right', 'center', 'bottom_left', 'bottom_right', 'default',
|
||||
'top_left', 'top_right', 'white', 'rgb', 'cct', 'system', 'top', 'bottom', 'center_left', 'center_right',
|
||||
'ep1', 'ep2', 'row_1', 'row_2', 'row_3', 'row_4', 'relay', 'usb',
|
||||
'l1', 'l2', 'l3', 'l4', 'l5', 'l6', 'l7', 'l8',
|
||||
'l9', 'l10', 'l11', 'l12', 'l13', 'l14', 'l15', 'l16',
|
||||
'button_1', 'button_2', 'button_3', 'button_4', 'button_5',
|
||||
'button_6', 'button_7', 'button_8', 'button_9', 'button_10',
|
||||
'button_11', 'button_12', 'button_13', 'button_14', 'button_15',
|
||||
'button_16', 'button_17', 'button_18', 'button_19', 'button_20',
|
||||
'button_light', 'button_fan_high', 'button_fan_med', 'button_fan_low',
|
||||
'heat', 'cool', 'water', 'meter', 'wifi', 'no_occupancy_since',
|
||||
];
|
||||
|
||||
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');
|
||||
} catch (error) {
|
||||
/* istanbul ignore next */
|
||||
commitHash = 'unknown';
|
||||
commitHash = fs.readFileSync(path.join(__dirname, "..", "..", "dist", ".hash"), "utf-8");
|
||||
} catch {
|
||||
commitHash = "unknown";
|
||||
}
|
||||
} else {
|
||||
commitHash = commit.shortHash;
|
||||
}
|
||||
|
||||
commitHash = commitHash.trim();
|
||||
resolve({commitHash, version});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getDependencyVersion(depend: string): Promise<{version: string}> {
|
||||
const packageJSON = await import(path.join(require.resolve(depend), '..', '..', 'package.json'));
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -113,123 +110,90 @@ 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;
|
||||
}
|
||||
|
||||
function parseJSON(value: string, fallback: string): KeyValue | string {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function* getExternalConvertersDefinitions(settings: Settings): Generator<zhc.ExternalDefinition> {
|
||||
const externalConverters = settings.external_converters;
|
||||
|
||||
for (const moduleName of externalConverters) {
|
||||
let converter;
|
||||
|
||||
if (moduleName.endsWith('.js')) {
|
||||
converter = loadModuleFromFile(data.joinPath(moduleName));
|
||||
} else {
|
||||
converter = require(moduleName);
|
||||
}
|
||||
|
||||
if (Array.isArray(converter)) {
|
||||
for (const item of converter) {
|
||||
yield item;
|
||||
}
|
||||
} else {
|
||||
yield converter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeNullPropertiesFromObject(obj: KeyValue): void {
|
||||
/**
|
||||
* Delete all keys from passed object that have null/undefined values.
|
||||
*
|
||||
* @param {KeyValue} obj Object to process (in-place)
|
||||
* @param {string[]} [ignoreKeys] Recursively ignore these keys in the object (keep null/undefined values).
|
||||
*/
|
||||
function removeNullPropertiesFromObject(obj: KeyValue, ignoreKeys: string[] = []): void {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (ignoreKeys.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = obj[key];
|
||||
|
||||
if (value == null) {
|
||||
delete obj[key];
|
||||
} else if (typeof value === 'object') {
|
||||
removeNullPropertiesFromObject(value);
|
||||
} else if (typeof value === "object") {
|
||||
removeNullPropertiesFromObject(value, ignoreKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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[] {
|
||||
const result = [];
|
||||
for (let idx=start.charCodeAt(0), end=stop.charCodeAt(0); idx <=end; ++idx) {
|
||||
for (let idx = start.charCodeAt(0), end = stop.charCodeAt(0); idx <= end; ++idx) {
|
||||
result.push(idx);
|
||||
}
|
||||
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++) {
|
||||
const ch = str.charCodeAt(i);
|
||||
if (controlCharacters.includes(ch) || [0xFFFE, 0xFFFF].includes(ch & 0xFFFF)) {
|
||||
if (controlCharacters.includes(ch) || [0xfffe, 0xffff].includes(ch & 0xffff)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -238,31 +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[] {
|
||||
function validateFriendlyName(name: string, throwFirstError = false): string[] {
|
||||
const errors = [];
|
||||
for (const endpointName of endpointNames) {
|
||||
if (name.toLowerCase().endsWith('/' + endpointName)) {
|
||||
errors.push(`friendly_name is not allowed to end with: '/${endpointName}'`);
|
||||
}
|
||||
}
|
||||
|
||||
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 (endpointNames.includes(name)) errors.push(`Following friendly_name are not allowed: '${endpointNames}'`);
|
||||
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}')`);
|
||||
}
|
||||
|
||||
@@ -278,48 +243,37 @@ 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 {
|
||||
if (entity.isGroup()) {
|
||||
return !entity.membersDevices().map((d) => isAvailabilityEnabledForEntity(d, settings)).includes(false);
|
||||
if (entity.isDevice() && entity.options.disabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entity.options.hasOwnProperty('availability')) {
|
||||
if (entity.isGroup()) {
|
||||
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
|
||||
const enabledGlobal = settings.advanced.availability_timeout || settings.availability;
|
||||
if (!enabledGlobal) return false;
|
||||
|
||||
if (entity.isDevice() && entity.options.disabled) 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);
|
||||
return !blocklist.includes(entity.name) && !blocklist.includes(entity.ieeeAddr);
|
||||
return settings.availability.enabled;
|
||||
}
|
||||
|
||||
const entityIDRegex = new RegExp(`^(.+?)(?:/(${endpointNames.join('|')}|\\d+))?$`);
|
||||
function parseEntityID(ID: string): {ID: string, endpoint: string} {
|
||||
const match = ID.match(entityIDRegex);
|
||||
return match && {ID: match[1], endpoint: match[2]};
|
||||
}
|
||||
|
||||
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[] {
|
||||
@@ -327,19 +281,19 @@ 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});
|
||||
}
|
||||
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;
|
||||
|
||||
const hours = (hours: number): number => 1000 * 60 * 60 * hours;
|
||||
const minutes = (minutes: number): number => 1000 * 60 * minutes;
|
||||
const seconds = (seconds: number): number => 1000 * seconds;
|
||||
|
||||
function publishLastSeen(data: eventdata.LastSeenChanged, settings: Settings, allowMessageEmitted: boolean,
|
||||
publishEntityState: PublishEntityState): void {
|
||||
async function publishLastSeen(
|
||||
data: eventdata.LastSeenChanged,
|
||||
settings: Settings,
|
||||
allowMessageEmitted: boolean,
|
||||
publishEntityState: PublishEntityState,
|
||||
): Promise<void> {
|
||||
/**
|
||||
* Prevent 2 MQTT publishes when 1 message event is received;
|
||||
* - In case reason == messageEmitted, receive.ts will only call this when it did not publish a
|
||||
@@ -347,13 +301,13 @@ function publishLastSeen(data: eventdata.LastSeenChanged, settings: Settings, al
|
||||
* - 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) {
|
||||
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}$`))) {
|
||||
@@ -363,48 +317,116 @@ function filterProperties(filter: string[], data: KeyValue): void {
|
||||
}
|
||||
}
|
||||
|
||||
function clone(obj: KeyValue): KeyValue {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
export function isNumericExpose(expose: zhc.Expose): expose is zhc.Numeric {
|
||||
return expose?.type === "numeric";
|
||||
}
|
||||
|
||||
function computeSettingsToChange(current: KeyValue, new_: KeyValue): KeyValue {
|
||||
const diff: KeyValue = detailedDiff(current, new_);
|
||||
|
||||
// Remove any settings that are in the deleted.diff but not in the passed options
|
||||
const cleanupDeleted = (options: KeyValue, deleted: KeyValue): void => {
|
||||
for (const key of Object.keys(deleted)) {
|
||||
if (!(key in options)) {
|
||||
delete deleted[key];
|
||||
} else if (!Array.isArray(options[key])) {
|
||||
cleanupDeleted(options[key], deleted[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
cleanupDeleted(new_, diff.deleted);
|
||||
|
||||
// objectAssignDeep requires object prototype which is missing from detailedDiff, therefore clone
|
||||
const newSettings = objectAssignDeep({}, clone(diff.added), clone(diff.updated), clone(diff.deleted));
|
||||
|
||||
// deep-object-diff converts arrays to objects, set original array back here
|
||||
const convertBackArray = (before: KeyValue, after: KeyValue): void => {
|
||||
for (const [key, afterValue] of Object.entries(after)) {
|
||||
const beforeValue = before[key];
|
||||
if (Array.isArray(beforeValue)) {
|
||||
after[key] = beforeValue;
|
||||
} else if (afterValue && typeof beforeValue === 'object') {
|
||||
convertBackArray(beforeValue, afterValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
convertBackArray(new_, newSettings);
|
||||
return newSettings;
|
||||
export function assertEnumExpose(expose: zhc.Expose): asserts expose is zhc.Enum {
|
||||
assert(expose?.type === "enum");
|
||||
}
|
||||
|
||||
export function assertNumericExpose(expose: zhc.Expose): asserts expose is zhc.Numeric {
|
||||
assert(expose?.type === "numeric");
|
||||
}
|
||||
|
||||
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 = 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}`};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(scenes);
|
||||
}
|
||||
|
||||
function deviceNotCoordinator(device: zh.Device): boolean {
|
||||
return device.type !== "Coordinator";
|
||||
}
|
||||
|
||||
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 {
|
||||
endpointNames, capitalize, getZigbee2MQTTVersion, getDependencyVersion, formatDate, objectHasProperties,
|
||||
equalsPartial, getObjectProperty, getResponse, parseJSON, loadModuleFromText, loadModuleFromFile,
|
||||
getExternalConvertersDefinitions, removeNullPropertiesFromObject, toNetworkAddressHex, toSnakeCase,
|
||||
parseEntityID, isEndpoint, isZHGroup, hours, minutes, seconds, validateFriendlyName, sleep,
|
||||
sanitizeImageParameter, isAvailabilityEnabledForEntity, publishLastSeen, availabilityPayload,
|
||||
getAllFiles, filterProperties, flatten, arrayUnique, clone, computeSettingsToChange,
|
||||
matchBase64File,
|
||||
saveBase64DeviceIcon,
|
||||
capitalize,
|
||||
getZigbee2MQTTVersion,
|
||||
getDependencyVersion,
|
||||
formatDate,
|
||||
objectIsEmpty,
|
||||
objectHasProperties,
|
||||
equalsPartial,
|
||||
getObjectProperty,
|
||||
getResponse,
|
||||
parseJSON,
|
||||
removeNullPropertiesFromObject,
|
||||
toNetworkAddressHex,
|
||||
isZHEndpoint,
|
||||
isZHGroup,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
validateFriendlyName,
|
||||
sleep,
|
||||
sanitizeImageParameter,
|
||||
isAvailabilityEnabledForEntity,
|
||||
publishLastSeen,
|
||||
getAllFiles,
|
||||
filterProperties,
|
||||
flatten,
|
||||
arrayUnique,
|
||||
getScenes,
|
||||
deviceNotCoordinator,
|
||||
noop,
|
||||
};
|
||||
|
||||
+27
-8
@@ -1,25 +1,44 @@
|
||||
import yaml from 'js-yaml';
|
||||
import fs from 'fs';
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
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 {
|
||||
return yaml.load(fs.readFileSync(file, 'utf8'));
|
||||
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));
|
||||
}
|
||||
|
||||
+303
-168
@@ -1,40 +1,49 @@
|
||||
import {Controller} from 'zigbee-herdsman';
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import data from './util/data';
|
||||
import utils from './util/utils';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import Device from './model/device';
|
||||
import Group from './model/group';
|
||||
import * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
|
||||
import bind from 'bind-decorator';
|
||||
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";
|
||||
|
||||
const entityIDRegex = /^(.+?)(?:\/([^/]+))?$/;
|
||||
|
||||
export default class Zigbee {
|
||||
private herdsman: Controller;
|
||||
#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');
|
||||
get zhController() {
|
||||
return this.#herdsman;
|
||||
}
|
||||
|
||||
async start(abortSignal: AbortSignal): Promise<boolean> {
|
||||
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,
|
||||
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,
|
||||
@@ -45,238 +54,358 @@ export default class Zigbee {
|
||||
concurrent: settings.get().advanced.adapter_concurrent,
|
||||
delay: settings.get().advanced.adapter_delay,
|
||||
disableLED: settings.get().serial.disable_led,
|
||||
transmitPower: settings.get().advanced.transmit_power,
|
||||
},
|
||||
acceptJoiningDeviceHandler: this.acceptJoiningDeviceHandler,
|
||||
};
|
||||
|
||||
const herdsmanSettingsLog = objectAssignDeep({}, 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, logger);
|
||||
startResult = await this.herdsman.start();
|
||||
this.#herdsman = new Controller(herdsmanSettings);
|
||||
startResult = await this.#herdsman.start(abortSignal);
|
||||
} catch (error) {
|
||||
logger.error(`Error while starting zigbee-herdsman`);
|
||||
logger.error("Error while starting zigbee-herdsman");
|
||||
throw error;
|
||||
}
|
||||
|
||||
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.coordinatorIeeeAddr = this.#herdsman.getDevicesByType("Coordinator")[0].ieeeAddr;
|
||||
await this.resolveDevicesDefinitions(false, abortSignal);
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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', (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', (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.warn(`Device '${name}' left the network`);
|
||||
this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name});
|
||||
logger.warning(`Device '${name}' left the network`);
|
||||
this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name, device: this.deviceLookup.get(data.ieeeAddr)});
|
||||
});
|
||||
this.herdsman.on('message', (data: ZHEvents.MessagePayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
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;
|
||||
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(() => {
|
||||
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});
|
||||
});
|
||||
|
||||
logger.info(`zigbee-herdsman started (${startResult})`);
|
||||
logger.info(`Coordinator firmware version: '${stringify(await this.getCoordinatorVersion())}'`);
|
||||
logger.debug(`Zigbee network parameters: ${stringify(await this.herdsman.getNetworkParameters())}`);
|
||||
logger.debug(`Zigbee network parameters: ${stringify(await this.#herdsman.getNetworkParameters())}`);
|
||||
|
||||
for (const device of this.devices(false)) {
|
||||
// If a passlist is used, all other device will be removed from the network.
|
||||
const passlist = settings.get().passlist;
|
||||
const blocklist = settings.get().blocklist;
|
||||
const remove = async (device: Device): Promise<void> => {
|
||||
try {
|
||||
await device.zh.removeFromNetwork();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove '${device.ieeeAddr}' (${error.message})`);
|
||||
}
|
||||
};
|
||||
if (passlist.length > 0) {
|
||||
if (abortSignal.aborted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const remove = async (device: Device): Promise<void> => {
|
||||
try {
|
||||
await device.zh.removeFromNetwork();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove '${device.ieeeAddr}' (${(error as Error).message})`);
|
||||
}
|
||||
};
|
||||
// If a passlist is used, all other device will be removed from the network.
|
||||
const passlist = settings.get().passlist;
|
||||
|
||||
if (passlist.length > 0) {
|
||||
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
if (!passlist.includes(device.ieeeAddr)) {
|
||||
logger.warn(`Device which is not on passlist connected (${device.ieeeAddr}), removing...`);
|
||||
logger.warning(`Device not on passlist currently on the network (${device.ieeeAddr}), removing...`);
|
||||
await remove(device);
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const ieee of settings.get().blocklist) {
|
||||
const device = this.resolveDevice(ieee);
|
||||
|
||||
if (device) {
|
||||
if (device.zh.type === "Coordinator") {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.warning(`Device on blocklist currently on the network (${device.ieeeAddr}), removing...`);
|
||||
await remove(device);
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Ignoring blocklist device ${ieee}, not currently on the network`);
|
||||
}
|
||||
} else if (blocklist.includes(device.ieeeAddr)) {
|
||||
logger.warn(`Device on blocklist is connected (${device.ieeeAddr}), removing...`);
|
||||
await remove(device);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have to set a transmit power
|
||||
if (settings.get().advanced.hasOwnProperty('transmit_power')) {
|
||||
const transmitPower = settings.get().advanced.transmit_power;
|
||||
await this.herdsman.setTransmitPower(transmitPower);
|
||||
logger.info(`Set transmit power to '${transmitPower}'`);
|
||||
}
|
||||
|
||||
return startResult;
|
||||
return true;
|
||||
}
|
||||
|
||||
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.definition) {
|
||||
const {vendor, description, model} = data.device.definition;
|
||||
if (data.device.isSupported) {
|
||||
// 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.warn(`Device '${name}' with Zigbee model '${data.device.zh.modelID}' and manufacturer name ` +
|
||||
`'${data.device.zh.manufacturerName}' is NOT supported, ` +
|
||||
// eslint-disable-next-line max-len
|
||||
`please follow https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`);
|
||||
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`,
|
||||
);
|
||||
}
|
||||
} 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'
|
||||
} else {
|
||||
// data.status === 'started'
|
||||
logger.info(`Starting interview of '${name}'`);
|
||||
}
|
||||
}
|
||||
|
||||
private generateNetworkKey(): number[] {
|
||||
const key = Array.from({length: 16}, () => Math.floor(Math.random() * 255));
|
||||
settings.set(['advanced', 'network_key'], key);
|
||||
const key = Array.from({length: 16}, () => randomInt(256));
|
||||
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);
|
||||
return key;
|
||||
}
|
||||
|
||||
private generatePanID(): number {
|
||||
const panID = Math.floor(Math.random() * (0xFFFF - 2)) + 1;
|
||||
settings.set(['advanced', 'pan_id'], panID);
|
||||
const panID = randomInt(1, 0xffff - 1);
|
||||
settings.set(["advanced", "pan_id"], panID);
|
||||
return panID;
|
||||
}
|
||||
|
||||
async getCoordinatorVersion(): Promise<zh.CoordinatorVersion> {
|
||||
return this.herdsman.getCoordinatorVersion();
|
||||
return await this.#herdsman.getCoordinatorVersion();
|
||||
}
|
||||
|
||||
isStopping(): boolean {
|
||||
return this.herdsman.isStopping();
|
||||
return this.#herdsman.isStopping();
|
||||
}
|
||||
|
||||
async backup(): Promise<void> {
|
||||
return this.herdsman.backup();
|
||||
return await this.#herdsman.backup();
|
||||
}
|
||||
|
||||
async coordinatorCheck(): Promise<{missingRouters: Device[]}> {
|
||||
const check = await this.#herdsman.coordinatorCheck();
|
||||
// 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...');
|
||||
await this.herdsman.stop();
|
||||
logger.info('Stopped zigbee-herdsman');
|
||||
logger.info("Stopping zigbee-herdsman...");
|
||||
await this.#herdsman?.stop(); // could be undefined if this is called during startup (abort)
|
||||
logger.info("Stopped zigbee-herdsman");
|
||||
}
|
||||
|
||||
getPermitJoin(): boolean {
|
||||
return this.herdsman.getPermitJoin();
|
||||
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, abortSignal: AbortSignal | undefined = undefined): Promise<void> {
|
||||
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await device.resolveDefinition(ignoreCache);
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind private resolveDevice(ieeeAddr: string): Device {
|
||||
if (!this.deviceLookup[ieeeAddr]) {
|
||||
const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
|
||||
device && (this.deviceLookup[ieeeAddr] = new Device(device));
|
||||
@bind private resolveDevice(ieeeAddr: string): Device | undefined {
|
||||
if (!this.deviceLookup.has(ieeeAddr)) {
|
||||
const device = this.#herdsman.getDeviceByIeeeAddr(ieeeAddr);
|
||||
if (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;
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// device/name (device name with slashes)
|
||||
// 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: string | undefined;
|
||||
|
||||
// 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);
|
||||
|
||||
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) : undefined;
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
groups(): Group[] {
|
||||
return this.herdsman.getGroups().map((g) => this.resolveGroup(g.groupID));
|
||||
*devicesAndGroupsIterator(
|
||||
devicePredicate?: (value: zh.Device) => boolean,
|
||||
groupPredicate?: (value: zh.Group) => boolean,
|
||||
): Generator<Device | Group> {
|
||||
for (const device of this.#herdsman.getDevicesIterator(devicePredicate)) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveDevice(device.ieeeAddr)!;
|
||||
}
|
||||
|
||||
for (const group of this.#herdsman.getGroupsIterator(groupPredicate)) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveGroup(group.groupID)!;
|
||||
}
|
||||
}
|
||||
|
||||
devices(includeCoordinator=true): Device[] {
|
||||
return this.herdsman.getDevices()
|
||||
.map((d) => this.resolveDevice(d.ieeeAddr))
|
||||
.filter((d) => includeCoordinator || d.zh.type !== 'Coordinator');
|
||||
*groupsIterator(predicate?: (value: zh.Group) => boolean): Generator<Group> {
|
||||
for (const group of this.#herdsman.getGroupsIterator(predicate)) {
|
||||
// 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)) {
|
||||
// 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;
|
||||
@@ -285,54 +414,60 @@ 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.touchlink.factoryResetFirst();
|
||||
}
|
||||
|
||||
async touchlinkFactoryReset(ieeeAddr: string, channel: number): Promise<boolean> {
|
||||
return this.herdsman.touchlinkFactoryReset(ieeeAddr, channel);
|
||||
return await this.#herdsman.touchlink.factoryReset(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async addInstallCode(installCode: string): Promise<void> {
|
||||
await this.herdsman.addInstallCode(installCode);
|
||||
await this.#herdsman.addInstallCode(installCode);
|
||||
}
|
||||
|
||||
async touchlinkIdentify(ieeeAddr: string, channel: number): Promise<void> {
|
||||
await this.herdsman.touchlinkIdentify(ieeeAddr, channel);
|
||||
await this.#herdsman.touchlink.identify(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async touchlinkScan(): Promise<{ieeeAddr: string, channel: number}[]> {
|
||||
return this.herdsman.touchlinkScan();
|
||||
async touchlinkScan(): Promise<{ieeeAddr: string; channel: number}[]> {
|
||||
return await this.#herdsman.touchlink.scan();
|
||||
}
|
||||
|
||||
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 {
|
||||
const device = this.herdsman.getDeviceByNetworkAddress(networkAddress);
|
||||
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);
|
||||
}
|
||||
|
||||
removeGroupFromLookup(id: number): void {
|
||||
this.groupLookup.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-16594
File diff suppressed because it is too large
Load Diff
+99
-104
@@ -1,107 +1,102 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "1.31.2",
|
||||
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
|
||||
"main": "index.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Koenkk/zigbee2mqtt.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14 || ^16 || ^18 || ^19 || ^20"
|
||||
},
|
||||
"keywords": [
|
||||
"xiaomi",
|
||||
"tradfri",
|
||||
"hue",
|
||||
"bridge",
|
||||
"zigbee",
|
||||
"mqtt",
|
||||
"cc2531"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc && node index.js writehash",
|
||||
"build-watch": "tsc --watch",
|
||||
"eslint": "eslint lib/ --max-warnings=0",
|
||||
"start": "node index.js",
|
||||
"test-with-coverage": "jest test --coverage",
|
||||
"test": "jest test",
|
||||
"test-watch": "jest test --watch"
|
||||
},
|
||||
"author": "Koen Kanters",
|
||||
"license": "GPL-3.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/Koenkk/zigbee2mqtt/issues"
|
||||
},
|
||||
"homepage": "https://koenkk.github.io/zigbee2mqtt",
|
||||
"dependencies": {
|
||||
"ajv": "^8.12.0",
|
||||
"bind-decorator": "^1.0.11",
|
||||
"connect-gzip-static": "2.1.1",
|
||||
"debounce": "^1.2.1",
|
||||
"deep-object-diff": "^1.1.9",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"finalhandler": "^1.2.0",
|
||||
"git-last-commit": "^1.0.1",
|
||||
"humanize-duration": "^3.28.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"mkdir-recursive": "^0.4.0",
|
||||
"moment": "^2.29.4",
|
||||
"mqtt": "4.3.7",
|
||||
"object-assign-deep": "^0.4.0",
|
||||
"rimraf": "^5.0.1",
|
||||
"semver": "^7.5.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"uri-js": "^4.4.1",
|
||||
"winston": "^3.9.0",
|
||||
"winston-syslog": "^2.7.0",
|
||||
"winston-transport": "^4.5.0",
|
||||
"ws": "^8.13.0",
|
||||
"zigbee-herdsman": "0.14.117",
|
||||
"zigbee-herdsman-converters": "15.13.1-hotfix.1",
|
||||
"zigbee2mqtt-frontend": "0.6.129"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.22.1",
|
||||
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||
"@babel/plugin-proposal-decorators": "^7.22.3",
|
||||
"@babel/preset-env": "^7.22.2",
|
||||
"@babel/preset-typescript": "^7.21.5",
|
||||
"@types/debounce": "^1.2.1",
|
||||
"@types/finalhandler": "^1.2.0",
|
||||
"@types/humanize-duration": "^3.27.1",
|
||||
"@types/jest": "^29.5.1",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
"@types/object-assign-deep": "^0.4.0",
|
||||
"@types/rimraf": "^3.0.2",
|
||||
"@types/ws": "^8.5.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.7",
|
||||
"@typescript-eslint/parser": "^5.59.7",
|
||||
"babel-jest": "^29.5.0",
|
||||
"eslint": "^8.41.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-plugin-jest": "^27.2.1",
|
||||
"jest": "^29.5.0",
|
||||
"tmp": "^0.2.1",
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"jest": {
|
||||
"coverageThreshold": {
|
||||
"global": {
|
||||
"branches": 100,
|
||||
"functions": 100,
|
||||
"lines": 100,
|
||||
"statements": 100
|
||||
}
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "2.9.1",
|
||||
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
|
||||
"main": "index.js",
|
||||
"types": "dist/types/api.d.ts",
|
||||
"packageManager": "pnpm@10.18.3",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Koenkk/zigbee2mqtt.git"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"lib/**/*.js",
|
||||
"lib/**/*.ts"
|
||||
]
|
||||
},
|
||||
"bin": {
|
||||
"zigbee2mqtt": "cli.js"
|
||||
}
|
||||
"engines": {
|
||||
"node": "^20.15.0 || ^22.2.0 || ^24"
|
||||
},
|
||||
"keywords": [
|
||||
"xiaomi",
|
||||
"tradfri",
|
||||
"hue",
|
||||
"bridge",
|
||||
"zigbee",
|
||||
"mqtt",
|
||||
"cc2531"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc && node index.js writehash",
|
||||
"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": "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",
|
||||
"bugs": {
|
||||
"url": "https://github.com/Koenkk/zigbee2mqtt/issues"
|
||||
},
|
||||
"homepage": "https://koenkk.github.io/zigbee2mqtt",
|
||||
"dependencies": {
|
||||
"ajv": "^8.18.0",
|
||||
"bind-decorator": "^1.0.11",
|
||||
"debounce": "^3.0.0",
|
||||
"express-static-gzip": "^3.0.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"finalhandler": "^2.1.1",
|
||||
"humanize-duration": "^3.33.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"mqtt": "^5.15.0",
|
||||
"object-assign-deep": "^0.4.0",
|
||||
"rimraf": "^6.1.3",
|
||||
"semver": "^7.7.4",
|
||||
"source-map-support": "^0.5.21",
|
||||
"throttleit": "^2.1.0",
|
||||
"winston": "^3.19.0",
|
||||
"winston-syslog": "^2.7.1",
|
||||
"winston-transport": "^4.9.0",
|
||||
"ws": "^8.19.0",
|
||||
"zigbee-herdsman": "9.0.9",
|
||||
"zigbee-herdsman-converters": "26.12.0",
|
||||
"zigbee2mqtt-frontend": "0.9.21",
|
||||
"zigbee2mqtt-windfront": "2.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.4",
|
||||
"@types/finalhandler": "^1.2.3",
|
||||
"@types/humanize-duration": "^3.27.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^24.11.0",
|
||||
"@types/object-assign-deep": "^0.4.3",
|
||||
"@types/readable-stream": "4.0.23",
|
||||
"@types/serve-static": "^2.2.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"tmp": "^0.2.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"zigbee-herdsman": "$zigbee-herdsman"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@biomejs/biome",
|
||||
"@serialport/bindings-cpp",
|
||||
"esbuild",
|
||||
"unix-dgram"
|
||||
]
|
||||
},
|
||||
"bin": {
|
||||
"zigbee2mqtt": "cli.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"unix-dgram": "^2.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user