Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dc48fb6d6 | ||
|
|
5017f556fa | ||
|
|
437eeb350c | ||
|
|
cfd792dc70 | ||
|
|
2a4722bd38 | ||
|
|
d381f2daa4 | ||
|
|
b05b4adf57 | ||
|
|
96d72103f2 | ||
|
|
97ffa9610b | ||
|
|
09d6c8601c | ||
|
|
fc409a6884 | ||
|
|
fa63ede4b6 | ||
|
|
fd24c8186e | ||
|
|
0816d07b93 | ||
|
|
c1b9345613 | ||
|
|
7f198d18ac | ||
|
|
802bccd1f3 | ||
|
|
77bd76d1ca | ||
|
|
8dd8b2848c | ||
|
|
c5d54a845a | ||
|
|
f1a19151fc | ||
|
|
963c2d86d3 | ||
|
|
9e6b4083b2 | ||
|
|
f29c4a24c2 | ||
|
|
a75e1c2b2b | ||
|
|
62499483ce | ||
|
|
304bb7b9d1 | ||
|
|
15a069e3c7 | ||
|
|
e0a9323d6e | ||
|
|
a0182c2e39 | ||
|
|
327f816c0e | ||
|
|
040d2a1b36 | ||
|
|
497a48b8b0 | ||
|
|
36a4754430 | ||
|
|
60b9f6f6b2 | ||
|
|
2bdc7b7aca | ||
|
|
fea5d44a57 | ||
|
|
281e3870d3 | ||
|
|
c9b2a7f6d4 | ||
|
|
6314b86d9b | ||
|
|
619d5232db | ||
|
|
da4a3b9a77 | ||
|
|
62284dbd1a | ||
|
|
addb09275a | ||
|
|
9c45a5fdf0 | ||
|
|
0aa29a0fab | ||
|
|
5f778a7570 | ||
|
|
e8f6def246 | ||
|
|
99485fa324 | ||
|
|
483ff4aa8a | ||
|
|
b6c0afc129 | ||
|
|
7def5ea9bf | ||
|
|
4e590ece19 | ||
|
|
c2faa3815a | ||
|
|
52f8753a09 | ||
|
|
a9b8808237 | ||
|
|
26b323e1f6 | ||
|
|
5990bb384a | ||
|
|
be70b83be5 | ||
|
|
7f28c557cd | ||
|
|
b1f9f5f3b3 | ||
|
|
f5740021a1 | ||
|
|
2996cfaf07 | ||
|
|
eda087af99 | ||
|
|
343ef7fd9f | ||
|
|
316daee49d | ||
|
|
68aae1a450 | ||
|
|
d92b2f2ea9 | ||
|
|
e132a22f20 | ||
|
|
ebcf49b504 | ||
|
|
6a2de3cd3b | ||
|
|
8d8da9f286 | ||
|
|
759c2f3f00 | ||
|
|
b140e4bfe6 | ||
|
|
4ef9ea749d | ||
|
|
11f3f5b170 | ||
|
|
b6f88aad80 | ||
|
|
d07b65a03d | ||
|
|
aabc010191 | ||
|
|
cf8e7b10db | ||
|
|
63777464cc | ||
|
|
26fc4609f0 | ||
|
|
1d8d6fe070 | ||
|
|
ca2186a9c5 | ||
|
|
7d8b506c49 | ||
|
|
994de20f49 | ||
|
|
f553601ff1 | ||
|
|
3478715926 | ||
|
|
ed351ee712 | ||
|
|
affa15cbae | ||
|
|
c6d504a5df | ||
|
|
420b34c909 | ||
|
|
4347034ba1 | ||
|
|
ec8e7ed946 | ||
|
|
440fc74368 | ||
|
|
a0255d0f2c | ||
|
|
14216b7454 | ||
|
|
0da033ce95 | ||
|
|
3bd3196a40 | ||
|
|
47e05c32ec | ||
|
|
bcd19509fb | ||
|
|
05a9920638 | ||
|
|
8af3ad5541 | ||
|
|
6ef9e4f479 | ||
|
|
183dad97e7 | ||
|
|
3bbdf17fed | ||
|
|
db021e6711 | ||
|
|
4a58dbf84c | ||
|
|
ee227d85c7 | ||
|
|
d8c0ef8a9c | ||
|
|
53ae00c46f | ||
|
|
dedbf336af | ||
|
|
bc4ffc00b6 | ||
|
|
5662e6c29e | ||
|
|
4cc838accf | ||
|
|
3b58ac79dc | ||
|
|
bab764e2be | ||
|
|
81892ef70d | ||
|
|
cd23aeaa46 | ||
|
|
5d5b731053 | ||
|
|
c973632efd | ||
|
|
e0b63f2baa | ||
|
|
3d406c3c0d | ||
|
|
d4811791fc | ||
|
|
3b7ef63300 | ||
|
|
8adb2f1247 | ||
|
|
326c2aaa90 | ||
|
|
e291687870 | ||
|
|
80f8fb4d98 | ||
|
|
402081e39d | ||
|
|
05309db92b | ||
|
|
d3fa977ebf | ||
|
|
92f7bc8eb5 | ||
|
|
80b6f1dfa3 | ||
|
|
1eaa73bb52 | ||
|
|
0fc831cd82 | ||
|
|
59c18af1f7 | ||
|
|
0fd47c0ad1 | ||
|
|
6b643f0e67 | ||
|
|
3c9fab143c | ||
|
|
f2e953cf18 | ||
|
|
ecb18a6c41 | ||
|
|
d405c43202 | ||
|
|
24e7a368cb | ||
|
|
8848e1e6ce | ||
|
|
820c20af80 | ||
|
|
4b8b951c28 | ||
|
|
8964421350 | ||
|
|
0895f29a83 | ||
|
|
f213729dcb | ||
|
|
6df20010dd | ||
|
|
a5204cdeb1 | ||
|
|
dd129830d3 | ||
|
|
a8233e0521 | ||
|
|
9b2a92106e | ||
|
|
8fe6ab4866 | ||
|
|
a08ff9bf6d | ||
|
|
83b490a968 | ||
|
|
3498c6e260 | ||
|
|
5c93aaf6db | ||
|
|
e860978564 | ||
|
|
2d7a6e9f42 | ||
|
|
1390bc1d04 | ||
|
|
a9349ac49d | ||
|
|
5bed214aef | ||
|
|
4f181b0107 | ||
|
|
8e2f0f613f | ||
|
|
2bb4f91d1c | ||
|
|
389ff844c4 | ||
|
|
cb0542eaad | ||
|
|
300b2973e1 | ||
|
|
5c44582b69 | ||
|
|
2f2a66db2e | ||
|
|
b075d6bbda | ||
|
|
a252914ecd | ||
|
|
87edd1e6f1 | ||
|
|
ba69e1788f | ||
|
|
05538ec28b | ||
|
|
2676c3f641 | ||
|
|
e525919160 | ||
|
|
d064a73131 | ||
|
|
64ab3b0492 | ||
|
|
e1c9d68c68 | ||
|
|
35b9e01230 | ||
|
|
1596714ccb | ||
|
|
e852851273 | ||
|
|
24e9c5bc9f | ||
|
|
dc4f4ad047 | ||
|
|
cc42ab7584 | ||
|
|
03fdb0cb88 | ||
|
|
f7c6518506 | ||
|
|
3c5854fa9f | ||
|
|
900175af47 | ||
|
|
9b9e2289d3 | ||
|
|
ea10936641 | ||
|
|
6b56b0bcf8 | ||
|
|
975eb7d6ae | ||
|
|
4de04c19c0 | ||
|
|
1db12612e6 | ||
|
|
ec010df79a | ||
|
|
88b65625de | ||
|
|
4e28df88ae | ||
|
|
44815435e7 | ||
|
|
fe011ba7b3 | ||
|
|
68035a1256 | ||
|
|
cc535f11c6 | ||
|
|
7001b2fc79 | ||
|
|
05975109a4 | ||
|
|
502443aae8 | ||
|
|
43a82ad835 | ||
|
|
a1db076c02 | ||
|
|
bfc70c3bb8 | ||
|
|
b268b1ed75 | ||
|
|
64769b3e9f | ||
|
|
66e804d71e | ||
|
|
1c30a4f4bc | ||
|
|
be55e3ee80 | ||
|
|
1aa7971ca1 | ||
|
|
6e2ab644a0 | ||
|
|
4fa6c33622 | ||
|
|
3fd405e570 | ||
|
|
c1372852e9 | ||
|
|
95eedc9ec1 | ||
|
|
d7b926d47f | ||
|
|
67039a1a94 | ||
|
|
f79fb16e41 | ||
|
|
d910c05e6e | ||
|
|
4858e06060 | ||
|
|
f71db6816a | ||
|
|
c6b129c68d | ||
|
|
e5dbe01329 | ||
|
|
c00768b7c7 | ||
|
|
ad070c99ef | ||
|
|
aec082c0cf | ||
|
|
cc84452f23 | ||
|
|
84bac2b8d8 | ||
|
|
29a9d227de | ||
|
|
39ae0c775a | ||
|
|
f1eaf2d7aa | ||
|
|
c6840a9a43 | ||
|
|
3a1c64a88e | ||
|
|
d4a0c93c91 | ||
|
|
5ae8347317 | ||
|
|
afbfa4631f | ||
|
|
096f906ac7 | ||
|
|
b844f607ee | ||
|
|
6002bc475b | ||
|
|
24cfdf4594 | ||
|
|
bc5fd1a52f | ||
|
|
fd344a3017 | ||
|
|
f9bd184543 | ||
|
|
f2c1e85b4b | ||
|
|
920b4924b0 | ||
|
|
99e67b1495 | ||
|
|
e65344c9fa | ||
|
|
147fe22f08 | ||
|
|
7f1f1497c6 | ||
|
|
6278c86d02 | ||
|
|
6f09d45a7b | ||
|
|
49a730f77d | ||
|
|
e5a74894d2 | ||
|
|
723dd8358f | ||
|
|
47a22cce2a | ||
|
|
b3791684fd | ||
|
|
ebd3255fc6 | ||
|
|
9b594a4cba | ||
|
|
d0530b8214 | ||
|
|
5c7e968028 | ||
|
|
40ef638e0e | ||
|
|
b9499618d3 | ||
|
|
6618872839 | ||
|
|
b977dd1140 | ||
|
|
71d5c7b4a2 | ||
|
|
dd8a83e592 | ||
|
|
664182def2 | ||
|
|
3f6a13786e | ||
|
|
a800197262 | ||
|
|
0c145d81cf | ||
|
|
7d7f8aabfe | ||
|
|
95c2fcfb00 | ||
|
|
2a6a6c43ce | ||
|
|
47ed86d735 | ||
|
|
50990d1f7a | ||
|
|
f7b484f24f | ||
|
|
f6ad3c9926 | ||
|
|
11b415634c | ||
|
|
d980d38747 | ||
|
|
808671dd59 | ||
|
|
cf22dd60a0 | ||
|
|
3b820bae9f | ||
|
|
60a987c004 | ||
|
|
d90be2dc66 | ||
|
|
e305ecf571 | ||
|
|
ec2bf1a95d | ||
|
|
7d5c44a5bd | ||
|
|
8c4312db72 | ||
|
|
037a72e0f3 | ||
|
|
d657ea48cf | ||
|
|
b4e0c3afcf | ||
|
|
27bc646cf6 | ||
|
|
23ef22e9ba | ||
|
|
84c8da2532 | ||
|
|
e6ec66c844 | ||
|
|
d1c26f9aa0 | ||
|
|
a7704b9038 | ||
|
|
eabd662757 | ||
|
|
764895b9d2 | ||
|
|
8f5b26a2fb | ||
|
|
533cd36ffd | ||
|
|
3a77ebf52d | ||
|
|
0e3bef7e42 | ||
|
|
dda63bd65a | ||
|
|
2aa1b4ead3 | ||
|
|
422b7e0168 | ||
|
|
8053eceb84 | ||
|
|
d10d3c9b56 | ||
|
|
6ff45b1006 | ||
|
|
c901e66e7a | ||
|
|
6ee7f56df3 | ||
|
|
d7073409d4 | ||
|
|
e46ddb8c79 | ||
|
|
f91d0f6bfe | ||
|
|
7e61252d11 | ||
|
|
67c5481965 | ||
|
|
e251fc0803 | ||
|
|
5d4268515c | ||
|
|
91d80f1bb0 | ||
|
|
c9fbc6d52e | ||
|
|
b84ad454e5 | ||
|
|
51ae801cb8 | ||
|
|
cabef43421 | ||
|
|
8fd8df75be | ||
|
|
51f8c1c487 | ||
|
|
aeb9927c20 | ||
|
|
0163b7281a | ||
|
|
0471eb5501 | ||
|
|
2679219d87 | ||
|
|
d83eb72c4b | ||
|
|
414ea10b14 | ||
|
|
d6e29538b1 | ||
|
|
eb9b764cc5 | ||
|
|
db642832f8 | ||
|
|
a56e5e0f36 | ||
|
|
1b0fbf556b | ||
|
|
3e7eddf3ce | ||
|
|
ad24c4f677 | ||
|
|
6f1460e47b | ||
|
|
91239291ec | ||
|
|
beccc18ba7 | ||
|
|
2901df37fb | ||
|
|
1c1ca6faea | ||
|
|
85308daf5f | ||
|
|
4631f6fc22 | ||
|
|
95c698758d | ||
|
|
95c48309a0 | ||
|
|
2e42f50d6a | ||
|
|
62a7b5115c | ||
|
|
b805dadceb | ||
|
|
f95c0bb048 | ||
|
|
0202399ff6 | ||
|
|
e4d4f04a05 | ||
|
|
efe768d24a | ||
|
|
0a868b451f | ||
|
|
a73caa3e66 | ||
|
|
02bc325fc2 | ||
|
|
847e7f8652 | ||
|
|
0cd02c7495 | ||
|
|
cae83c90b9 | ||
|
|
2524e030fa | ||
|
|
652d4018dd | ||
|
|
5b730575e4 | ||
|
|
83f8250358 | ||
|
|
8bf06901c2 | ||
|
|
4a0cd427de | ||
|
|
23aacc4c00 | ||
|
|
e7aef4c208 | ||
|
|
3cf3366070 | ||
|
|
8f1787ed4a | ||
|
|
b515c3213f | ||
|
|
1aea3194bb | ||
|
|
8f98a2f6d0 | ||
|
|
52d151e70f | ||
|
|
42f229b19e | ||
|
|
e5d6bd1d68 | ||
|
|
c49f546256 | ||
|
|
141f9f6d04 | ||
|
|
a73a5b2cd4 | ||
|
|
f7c6207d4f | ||
|
|
9592147972 | ||
|
|
a1cc8049b1 | ||
|
|
625236721f | ||
|
|
364a6dd2aa | ||
|
|
3535b2c6c2 | ||
|
|
91d597e53d | ||
|
|
7a2ddf24d3 | ||
|
|
d123ea3e61 | ||
|
|
eda6e7ca37 | ||
|
|
b50e40c9ca | ||
|
|
1eb3287029 | ||
|
|
bc8cd8df94 | ||
|
|
8751eca171 | ||
|
|
107b2726b2 | ||
|
|
987205c004 | ||
|
|
4f7ee6898a | ||
|
|
67ffcebe69 | ||
|
|
afe94a7cba | ||
|
|
a5db6ef0c4 | ||
|
|
5dc369c5a4 | ||
|
|
65bd5423e8 | ||
|
|
9c33c90650 | ||
|
|
caafc64fbf | ||
|
|
9e35f22ac4 | ||
|
|
23b7898a43 | ||
|
|
0fdbb9153d | ||
|
|
a345181812 | ||
|
|
7f4f48309e | ||
|
|
f1eb605bef | ||
|
|
9cfc38223e | ||
|
|
abdb2b20c2 | ||
|
|
6d521147fa | ||
|
|
df97c79b78 | ||
|
|
f6f507cda0 | ||
|
|
ba1843ff74 | ||
|
|
aace02ccda | ||
|
|
7ec6a2b483 | ||
|
|
84043702c1 | ||
|
|
825308df20 | ||
|
|
e0694a444e | ||
|
|
f284a5d2d5 | ||
|
|
5849a90030 | ||
|
|
b518e3e200 | ||
|
|
bccd7b390c | ||
|
|
4933b9080e | ||
|
|
cd9fd2d5c2 | ||
|
|
9602574cd2 | ||
|
|
711e2d9cfc | ||
|
|
d8ecc6f26e | ||
|
|
7fe803a28c | ||
|
|
6394d0f42e | ||
|
|
52607d03cf | ||
|
|
5dd8873fcd | ||
|
|
02e18b9d5b | ||
|
|
42bb73e263 | ||
|
|
39bf36b0b5 | ||
|
|
b5e8566f58 | ||
|
|
21567ab810 | ||
|
|
f9c4b4e926 | ||
|
|
8357da270d | ||
|
|
4def85b3ca | ||
|
|
0e11ae07d9 | ||
|
|
9fb1eb40d3 | ||
|
|
ddcb8e12d5 | ||
|
|
3470681ea7 | ||
|
|
41ece365aa | ||
|
|
73f02bfee3 | ||
|
|
fae8e96229 | ||
|
|
7f9b198722 | ||
|
|
6ea61bae2c | ||
|
|
5c915e5fd7 | ||
|
|
b1f50701b2 | ||
|
|
273fc61099 | ||
|
|
59ef38f076 | ||
|
|
9171ef35da | ||
|
|
912360c1f0 | ||
|
|
04290274b8 | ||
|
|
9fc413dea9 | ||
|
|
7863f600c8 | ||
|
|
0bdbf7194b | ||
|
|
81131c32a8 | ||
|
|
26ae01a0e4 | ||
|
|
93921c6be9 | ||
|
|
2721cc1bbf | ||
|
|
9120ccf403 | ||
|
|
b92131cad6 | ||
|
|
0b06483ce0 | ||
|
|
6a4fba82df | ||
|
|
1a2943d45e | ||
|
|
1a70b80282 | ||
|
|
c4c02d2e2d | ||
|
|
5b9b7e4804 | ||
|
|
8e8c3d7b49 | ||
|
|
f0f7662f9e | ||
|
|
5ef7b673d8 | ||
|
|
b6d26e351c | ||
|
|
a10306b247 | ||
|
|
e7e23a1092 | ||
|
|
d5de1c6248 | ||
|
|
14dd4800f1 | ||
|
|
3451c71a21 | ||
|
|
8e72b3bf64 | ||
|
|
3ba185815f | ||
|
|
64f83d74b7 | ||
|
|
ef2f28019b | ||
|
|
e446c81677 | ||
|
|
d87347923b | ||
|
|
a867b719dc | ||
|
|
da3817bd40 | ||
|
|
7f98caca22 | ||
|
|
107ac3659f | ||
|
|
48dfd581c1 | ||
|
|
8e53d745cd | ||
|
|
6877c591b8 | ||
|
|
d0ceaba6f2 | ||
|
|
0d60777565 | ||
|
|
074c70be54 | ||
|
|
b6a7ade6f2 | ||
|
|
d6ee1a6450 | ||
|
|
3617ba2b1d | ||
|
|
ec8e7f57fa | ||
|
|
87d33febcf | ||
|
|
43e7ca7ad6 | ||
|
|
4fd98c2aca | ||
|
|
1a0a9a6eab | ||
|
|
57852fcddf | ||
|
|
c1b0a13e10 | ||
|
|
3835b792f0 | ||
|
|
00124215a8 | ||
|
|
cac384c3fb | ||
|
|
0b3727d296 | ||
|
|
30177b0db4 | ||
|
|
ee6b035108 | ||
|
|
6ac8a6237e | ||
|
|
44ee2f5f2b | ||
|
|
9d6009ec99 | ||
|
|
cd60eb5069 | ||
|
|
a10687ecaa | ||
|
|
89693df2e1 | ||
|
|
e783117a3a | ||
|
|
e6b041436a | ||
|
|
3d4d7dc910 | ||
|
|
811ef1dcb5 | ||
|
|
208a552c02 | ||
|
|
74cf20b172 | ||
|
|
dbcd768e1d | ||
|
|
43ded111fb | ||
|
|
16842a3e72 | ||
|
|
e88ceeec2e | ||
|
|
f51a7883fe | ||
|
|
4f887a1a3b | ||
|
|
23bdc18608 | ||
|
|
101e0192bd | ||
|
|
24d9065d26 | ||
|
|
05d545945d | ||
|
|
089ff8f370 | ||
|
|
301ba14b00 | ||
|
|
9d120d225d | ||
|
|
38b07fb8ca | ||
|
|
e8caddaf63 | ||
|
|
15f1df8fa4 | ||
|
|
414c51f5b2 | ||
|
|
0c42067006 | ||
|
|
5741904e75 | ||
|
|
c60fdfd39b | ||
|
|
a7682498ef | ||
|
|
c6a3b90c2f | ||
|
|
ebd35052c0 | ||
|
|
2849f30417 | ||
|
|
e8526b7b58 | ||
|
|
3166d37096 | ||
|
|
2d2c01981e | ||
|
|
58b702e8ef | ||
|
|
6efa20fa1d | ||
|
|
bf72347468 | ||
|
|
73a6ad4cf8 | ||
|
|
1e75dd5628 | ||
|
|
bc92bd73e6 | ||
|
|
8756367666 | ||
|
|
79e5f0844a | ||
|
|
b0585172f4 | ||
|
|
89996835ed | ||
|
|
4f2a1e55e5 | ||
|
|
d488563f2a | ||
|
|
4310171abc | ||
|
|
36d7b4b022 | ||
|
|
50a6ed14d4 | ||
|
|
ee623c2484 | ||
|
|
2e6588719b | ||
|
|
fb35b52ca6 | ||
|
|
3306b63299 | ||
|
|
7a4ecadb6a | ||
|
|
511f3b09af | ||
|
|
828982d36c | ||
|
|
61bd95f8b8 | ||
|
|
74cff6a983 | ||
|
|
fdf7d10a41 | ||
|
|
1145e4b81b | ||
|
|
93f01d545e | ||
|
|
30e7385cac | ||
|
|
513314963c | ||
|
|
3c0cd8ebfa | ||
|
|
5bedc044d6 | ||
|
|
2a8a98f9a3 | ||
|
|
03f24a63d5 | ||
|
|
53003b3d0d | ||
|
|
cd5dd39971 | ||
|
|
41b150af82 | ||
|
|
3ba1f8cad0 | ||
|
|
f9aa525048 | ||
|
|
8b4be3d6ad | ||
|
|
0efe869fcb | ||
|
|
18cfbae9ca | ||
|
|
7625d261a5 | ||
|
|
6d8eea2142 | ||
|
|
2a9d22e2b3 | ||
|
|
656446c828 | ||
|
|
f1ffc1d265 | ||
|
|
2d1df8f0cb | ||
|
|
253e985388 | ||
|
|
1dd1163288 | ||
|
|
bb5627d33b | ||
|
|
38f42bb3c8 | ||
|
|
5f37145a02 | ||
|
|
a56061b36d | ||
|
|
b890b1bcb7 | ||
|
|
aa84a140e1 | ||
|
|
d875b297ad | ||
|
|
1c6ca6519c | ||
|
|
900c9f302e | ||
|
|
ea04021438 | ||
|
|
5a624ae188 | ||
|
|
42569d6c39 | ||
|
|
46e06dbbee | ||
|
|
a282221e40 | ||
|
|
bb22216d09 | ||
|
|
28cb8b08fb | ||
|
|
594a463f8b | ||
|
|
9c1a15465d | ||
|
|
22836f224d | ||
|
|
aafd937f8a | ||
|
|
d0195d0ff9 | ||
|
|
7592c627c2 | ||
|
|
98a57b4540 | ||
|
|
70b9d29ddf | ||
|
|
ac16d7ce69 | ||
|
|
d4f560d198 | ||
|
|
4136b1e539 | ||
|
|
b4af8329e7 | ||
|
|
ac0aca59a3 | ||
|
|
bc8274b9fd | ||
|
|
e5321d3f20 | ||
|
|
7a81a04b2f | ||
|
|
3b3d886121 | ||
|
|
48b790cbf3 | ||
|
|
9c93103fb7 | ||
|
|
33b35c1e61 | ||
|
|
073c7aedf7 | ||
|
|
e789514777 | ||
|
|
fd762fcf10 | ||
|
|
45b49bac0a | ||
|
|
055fba259a | ||
|
|
b206dc910f | ||
|
|
70db18f3ab | ||
|
|
cb2ef51df8 | ||
|
|
68e5b39172 | ||
|
|
b17ea2e4d9 | ||
|
|
41f9361922 | ||
|
|
dae66fd3fc | ||
|
|
d6ea54b8df | ||
|
|
c47b9c7c9b | ||
|
|
c2b5229f76 | ||
|
|
0c605688af | ||
|
|
6876e19008 | ||
|
|
437b1dfb3e | ||
|
|
fda1f23829 | ||
|
|
ed9d09a629 | ||
|
|
d298412a1d | ||
|
|
e7447cf1e3 | ||
|
|
c90171d2cc | ||
|
|
d2cf0dd8aa | ||
|
|
d415f57e30 | ||
|
|
56998f72f5 | ||
|
|
97bd090ad6 | ||
|
|
df182be21f | ||
|
|
290a0312d4 | ||
|
|
4d432658e9 | ||
|
|
c2d9ffa7ad | ||
|
|
82f5ddb748 | ||
|
|
1069ec2a1a | ||
|
|
cd4ac0c174 | ||
|
|
134b80ccf6 | ||
|
|
b07450d413 | ||
|
|
65f7fc0532 | ||
|
|
0a7a477281 | ||
|
|
a170f304c7 | ||
|
|
35ed5154c8 | ||
|
|
641853e6d5 | ||
|
|
2af9f21961 | ||
|
|
51321affcf | ||
|
|
5fca6fbd09 | ||
|
|
070065a248 | ||
|
|
9a8f204683 | ||
|
|
167c05681e | ||
|
|
01d7bd25be | ||
|
|
372ec315b5 | ||
|
|
df0543d4b7 | ||
|
|
a9e3fb3358 | ||
|
|
678e19fd7e | ||
|
|
9128838534 | ||
|
|
35876616ef | ||
|
|
0bc3129b7a | ||
|
|
5e3b18a053 | ||
|
|
ad3ff7fa40 | ||
|
|
008d6f9aa0 | ||
|
|
854353fb71 | ||
|
|
e35ba0f83c | ||
|
|
a154e18326 | ||
|
|
c793cc83ad | ||
|
|
a51626e357 | ||
|
|
0f3315063f | ||
|
|
1065916a01 | ||
|
|
a980b4dff5 | ||
|
|
825c7465ae | ||
|
|
97521478c6 | ||
|
|
6279c83e65 | ||
|
|
91479fc0ec | ||
|
|
9dbe6f0a6c | ||
|
|
5d415aa299 | ||
|
|
6a75e0d4ee | ||
|
|
13e9616c92 | ||
|
|
cc03be46df | ||
|
|
64154e0cb2 | ||
|
|
b2eaa3141e | ||
|
|
d192bbd28f | ||
|
|
9582808b09 | ||
|
|
623f999ec8 | ||
|
|
3141a14fec | ||
|
|
7e2a2bdd34 | ||
|
|
2f503bf39a | ||
|
|
945b72b990 | ||
|
|
3bfc7f31cf | ||
|
|
1f60edff99 | ||
|
|
3249ab00ab | ||
|
|
8c8f907fd3 | ||
|
|
170ef40982 | ||
|
|
e547062702 | ||
|
|
6ca8d35183 | ||
|
|
420bae4259 | ||
|
|
97dacd7623 | ||
|
|
8ef82fe23f | ||
|
|
cd3df10a54 | ||
|
|
d5ccd9221c | ||
|
|
fc9abfe53b | ||
|
|
01ea46b280 | ||
|
|
ab629d52ea | ||
|
|
9e216011d4 | ||
|
|
91d90bdcf1 | ||
|
|
6ba743cbed | ||
|
|
2399ec040f | ||
|
|
f151e1d371 | ||
|
|
55802ef648 | ||
|
|
89af770d96 | ||
|
|
0efd0cfe17 | ||
|
|
c852434dde | ||
|
|
57e93a48ff | ||
|
|
181944d2ff | ||
|
|
a09aa4243e | ||
|
|
932ee433c4 | ||
|
|
20b6fd42d3 | ||
|
|
178da2db1d | ||
|
|
e504da33d4 | ||
|
|
fa1775d8b4 | ||
|
|
2612de3c34 | ||
|
|
416f4fa548 | ||
|
|
ee57b0ce0b | ||
|
|
9729a72d33 | ||
|
|
23719df7ac | ||
|
|
ef6bf0a8f9 | ||
|
|
b3e9afaf3e | ||
|
|
5709898c94 | ||
|
|
68b5be0ce7 | ||
|
|
62bf76c11f | ||
|
|
243d333526 | ||
|
|
09383877b0 | ||
|
|
359af6f9ff | ||
|
|
cf68507ec3 | ||
|
|
78a3e4a371 | ||
|
|
11753a7078 | ||
|
|
512b785427 | ||
|
|
0a1fccf6b3 | ||
|
|
9e4b65b188 | ||
|
|
39c0b522d9 | ||
|
|
109c62bb4d | ||
|
|
a2e3f60de3 | ||
|
|
bbf05b5c95 | ||
|
|
a5413b9254 | ||
|
|
d6890b5fe4 | ||
|
|
b1c1637547 | ||
|
|
ed8ca293a3 | ||
|
|
abd4b23024 | ||
|
|
7a198ce4a3 | ||
|
|
e0c3ea28c0 | ||
|
|
d137d1975b | ||
|
|
e7bc8b9a9a | ||
|
|
72ffe4d118 | ||
|
|
e81d197979 | ||
|
|
1dc36f4cc1 | ||
|
|
252994968d | ||
|
|
7fed9e7334 | ||
|
|
bef5bff637 | ||
|
|
498a5b3f32 | ||
|
|
b08ce534c8 | ||
|
|
56812e7844 | ||
|
|
d19c60be07 | ||
|
|
387378e935 | ||
|
|
16c3f912b1 | ||
|
|
3f0a2fed9d | ||
|
|
c8e3c9b5a2 | ||
|
|
b665106d44 | ||
|
|
88314f1c6a | ||
|
|
8c12551ac8 | ||
|
|
011d02d0e6 | ||
|
|
8ac3989ada | ||
|
|
caa22c559e | ||
|
|
7469e29877 | ||
|
|
c647e87641 | ||
|
|
de825014c0 | ||
|
|
ca7bb0cd4d | ||
|
|
158341a9ff | ||
|
|
eaad39a199 | ||
|
|
ca255d3aa3 | ||
|
|
97d15b8d15 | ||
|
|
549ccf036e | ||
|
|
8ae4909836 | ||
|
|
bfb2427a6c | ||
|
|
3571937060 | ||
|
|
f7c0b46bef | ||
|
|
ad5cda4d40 | ||
|
|
d18455b596 | ||
|
|
0ae7bd0a2d | ||
|
|
8fac172ced | ||
|
|
cff4ea726e | ||
|
|
749381bf2f | ||
|
|
6b380526ae | ||
|
|
e8dd29e183 | ||
|
|
b58e123594 | ||
|
|
899383d79e | ||
|
|
de7b120ed2 | ||
|
|
4608de5d57 | ||
|
|
a362e6c99d | ||
|
|
ed8a863c30 | ||
|
|
7086bc3e9d | ||
|
|
2144e9b622 | ||
|
|
ff5411a328 | ||
|
|
47297983c2 | ||
|
|
a774b43d63 | ||
|
|
7f8632437d | ||
|
|
6977235200 | ||
|
|
4e520bb042 | ||
|
|
45f096ec0a | ||
|
|
0ea74e18b2 | ||
|
|
ab5d368a4e | ||
|
|
ebd20e30dc | ||
|
|
c2d4335e45 | ||
|
|
38cbfa4e91 | ||
|
|
f7fd0ce825 | ||
|
|
ea550a8fcf | ||
|
|
9907e59fa7 | ||
|
|
3b0a906a97 | ||
|
|
c88f643407 | ||
|
|
89d2b0088d | ||
|
|
d03c5e2372 | ||
|
|
90d20f936e | ||
|
|
6f9371e4b0 | ||
|
|
4082763f59 | ||
|
|
42497f3176 | ||
|
|
7ea29eadf4 | ||
|
|
db8d0ecf20 | ||
|
|
bd216d7a24 | ||
|
|
23b87755cf | ||
|
|
7415e8416d | ||
|
|
8e2c79f85d | ||
|
|
ab3a9da630 | ||
|
|
7ccf781b65 | ||
|
|
30badb62e7 | ||
|
|
a0512a489d | ||
|
|
edb145a8c3 | ||
|
|
45a4978ca8 | ||
|
|
fb96551beb | ||
|
|
2fb59f8efa | ||
|
|
c28731957a | ||
|
|
0282011255 | ||
|
|
045ee573a0 | ||
|
|
839e280159 | ||
|
|
44922120a6 | ||
|
|
c7b9c06c89 | ||
|
|
258326a88c | ||
|
|
4ec828d68e | ||
|
|
d9735f5981 | ||
|
|
fa8011983d | ||
|
|
7f98771e99 | ||
|
|
7d6d71dcbe | ||
|
|
8d9f14e30b | ||
|
|
06d30d1036 | ||
|
|
f517b85a75 | ||
|
|
738b241a31 | ||
|
|
2605d48557 | ||
|
|
918d8e2f88 | ||
|
|
0dfadb3a10 | ||
|
|
27a7bdb14a | ||
|
|
5829663b95 | ||
|
|
a8eec889f5 | ||
|
|
6888394600 | ||
|
|
57b4d678d5 | ||
|
|
59dd6e6bff | ||
|
|
89dea86b3a | ||
|
|
74ee5581fe | ||
|
|
aaad76e323 | ||
|
|
7557c10095 | ||
|
|
58957a30e4 | ||
|
|
32ddcc5e61 | ||
|
|
6369fe1528 | ||
|
|
187dde436c | ||
|
|
80b35ba728 | ||
|
|
41b1d53723 | ||
|
|
9f12ef7370 | ||
|
|
7f4a9c2a9b | ||
|
|
48de2fa647 | ||
|
|
27b36b316c | ||
|
|
ca3c6ce32f | ||
|
|
0d3998f369 | ||
|
|
b929dcbeb8 | ||
|
|
b558bbdc85 | ||
|
|
bb5be4df25 | ||
|
|
eca5492a8e | ||
|
|
ac44a50d3d | ||
|
|
32b127d280 | ||
|
|
8c0827afe3 | ||
|
|
7a97c67f74 | ||
|
|
bb1cbf3de7 | ||
|
|
75d2fe393d | ||
|
|
fb081316e2 | ||
|
|
feff729eb6 | ||
|
|
69e3deb7ac | ||
|
|
c1eb56aa30 | ||
|
|
cadcdc3525 | ||
|
|
d140d2b4b2 | ||
|
|
22bba95a70 | ||
|
|
291383fe2f | ||
|
|
7e5814aaad | ||
|
|
22a607da7b | ||
|
|
d07333c70d | ||
|
|
3c87803c79 | ||
|
|
02a508270f | ||
|
|
4a51e0c0c9 | ||
|
|
edf14b80d2 | ||
|
|
3708b314d1 | ||
|
|
7da50f9248 | ||
|
|
e59f347d68 | ||
|
|
b112184b65 | ||
|
|
1a514b824f | ||
|
|
a2653037e9 | ||
|
|
cf019195af | ||
|
|
b8c245b9f3 | ||
|
|
31327f7e1b | ||
|
|
e050a763f7 | ||
|
|
4125ae0888 | ||
|
|
c299d9b632 | ||
|
|
eb5750d968 | ||
|
|
d8432b502e | ||
|
|
15685cacf8 | ||
|
|
d92bdf1c57 | ||
|
|
4f7264e565 | ||
|
|
05b19117cf | ||
|
|
991b937451 | ||
|
|
9d60f9b64f | ||
|
|
9e3afbf48a | ||
|
|
17b2315d89 | ||
|
|
7122137639 | ||
|
|
a9e4745075 | ||
|
|
fd7351ff61 | ||
|
|
22430631d2 | ||
|
|
7b8c5944a0 | ||
|
|
09198d5ad5 | ||
|
|
352ebceadd | ||
|
|
3774a78134 | ||
|
|
5b6b4c1736 | ||
|
|
8bb296f3f7 | ||
|
|
6741e43e32 | ||
|
|
d78bf2a3d2 | ||
|
|
13e757429d | ||
|
|
f21516a2f2 | ||
|
|
ccc91b499a | ||
|
|
1234c5a3e0 | ||
|
|
9129f95c39 | ||
|
|
b18373b669 | ||
|
|
5e2e8f3eb3 | ||
|
|
d7ae59886e | ||
|
|
8f75dd8652 | ||
|
|
d07f1dab31 | ||
|
|
9e8dcb2784 | ||
|
|
59f6455f13 | ||
|
|
69d51c95db | ||
|
|
dffc58aa6b | ||
|
|
c7bf3324dc | ||
|
|
cb47efe3fe | ||
|
|
94cfad445b | ||
|
|
eebc92f931 | ||
|
|
f9451ffe22 | ||
|
|
288c0b795c | ||
|
|
c134d4e975 | ||
|
|
fb390544af | ||
|
|
c93b9300da | ||
|
|
486333d342 | ||
|
|
773de25657 | ||
|
|
726462caed | ||
|
|
fe24391de8 | ||
|
|
05deca6261 | ||
|
|
755e8d3866 | ||
|
|
ffc48131de | ||
|
|
0d5d5a696a | ||
|
|
a8545d08b6 | ||
|
|
e5f20f342d | ||
|
|
c18a698e6b | ||
|
|
f74047d090 | ||
|
|
17dc22fdb0 | ||
|
|
5a2f262fe5 | ||
|
|
61e133b894 | ||
|
|
fc2e8304f5 | ||
|
|
a3f22833cd | ||
|
|
a41c00faea | ||
|
|
632f9f579c | ||
|
|
6dcae7b83e | ||
|
|
5c8fa5cb48 | ||
|
|
500642d42c | ||
|
|
bee7474b8f | ||
|
|
911c3d285e | ||
|
|
86743179a2 | ||
|
|
108694276e | ||
|
|
3d27a63b5a | ||
|
|
950498898c | ||
|
|
fab62e10fe | ||
|
|
08fa79d6d8 | ||
|
|
1b2398f13f | ||
|
|
0685ade693 | ||
|
|
10615e29f3 | ||
|
|
a380df4f21 | ||
|
|
86d92a9591 | ||
|
|
33ee9f7bd7 | ||
|
|
056e07a411 | ||
|
|
62d763614a | ||
|
|
7db4e1b8d0 | ||
|
|
db61a623d5 | ||
|
|
c813eca843 | ||
|
|
ceb63071c8 | ||
|
|
70891eec39 | ||
|
|
e637eca3ea | ||
|
|
30f80c08d2 | ||
|
|
745951f38d | ||
|
|
30ff15b7dc | ||
|
|
be2689ee04 | ||
|
|
cee9eb1e26 | ||
|
|
1e5e3bef97 | ||
|
|
2457b7ee27 | ||
|
|
6068cd8541 | ||
|
|
ffea1ed97d | ||
|
|
05a535cf30 | ||
|
|
b1ee119575 | ||
|
|
cd033c8995 | ||
|
|
bf29fa8164 | ||
|
|
26ade49513 | ||
|
|
f9b8217996 | ||
|
|
35c2ffc0e5 | ||
|
|
3653edeac3 | ||
|
|
6e84c149fe | ||
|
|
236b751bd1 | ||
|
|
b09f20e62e | ||
|
|
f74f5fd638 | ||
|
|
7279bc0e3c | ||
|
|
0e498c6d49 | ||
|
|
f571938efc | ||
|
|
402a9f6d95 | ||
|
|
aae288b3fe | ||
|
|
7b65dc631b | ||
|
|
321b34721f | ||
|
|
eaaa83dbe5 | ||
|
|
7675dd1bcb | ||
|
|
46e2a772fe | ||
|
|
6242da009c | ||
|
|
bf97e668b0 | ||
|
|
c6fdb253c6 | ||
|
|
51f4348e41 | ||
|
|
b172c49bf3 | ||
|
|
f8927a94ec | ||
|
|
07ff52a56f | ||
|
|
a1c0bf2a85 | ||
|
|
0fd25cddb2 | ||
|
|
f8742344c9 | ||
|
|
be246fa841 | ||
|
|
515599ad13 | ||
|
|
a2bf9c5d01 | ||
|
|
0974cbe08d | ||
|
|
465d47c163 | ||
|
|
c4936da726 | ||
|
|
0a2a8dd563 | ||
|
|
3e005170b4 | ||
|
|
685c707d90 | ||
|
|
589af545af | ||
|
|
9edbe5a2a8 | ||
|
|
60a1cf3727 | ||
|
|
4e9863aa76 | ||
|
|
679d231743 | ||
|
|
8c929cf94b | ||
|
|
486ae903a3 | ||
|
|
fbf9a58fff | ||
|
|
d67649e458 | ||
|
|
08c5758aee | ||
|
|
c85987459a | ||
|
|
47882ab933 | ||
|
|
a76c13c461 | ||
|
|
67391615d9 | ||
|
|
53ae10e17b | ||
|
|
223ba7fdee | ||
|
|
6e1d6b5051 | ||
|
|
96657e1a7d | ||
|
|
82767a2a18 | ||
|
|
f8a978d1d8 | ||
|
|
fe1aa084fb | ||
|
|
cd1a4573b1 | ||
|
|
0df8663c8b | ||
|
|
1985e24517 | ||
|
|
234e39eaea | ||
|
|
41b67fdd07 | ||
|
|
112a731812 | ||
|
|
be36316355 | ||
|
|
2afd9ab68b | ||
|
|
4cfa22501c | ||
|
|
519a6e91f4 | ||
|
|
1587dc7b84 | ||
|
|
523b315ab1 | ||
|
|
1c6730ea5f | ||
|
|
a4eb3a7a0b | ||
|
|
6898f9c54a | ||
|
|
dadda2de96 | ||
|
|
c27c300324 | ||
|
|
06a30f7ae9 | ||
|
|
74c2b1c252 | ||
|
|
7b7f511dc7 | ||
|
|
0c45812c69 | ||
|
|
ac855963cb | ||
|
|
02e4abbff2 | ||
|
|
76a0cd507f | ||
|
|
91c16812df | ||
|
|
922f5024ff | ||
|
|
8dc7ad8dcc | ||
|
|
c8fdfc77f2 | ||
|
|
ce311c93b1 | ||
|
|
04c4c77d1e | ||
|
|
1c8fd479d0 | ||
|
|
497ca1800b | ||
|
|
7f995e964d | ||
|
|
3b3dae51b5 | ||
|
|
c314e8d267 | ||
|
|
8dab33dee9 | ||
|
|
217ce2212a | ||
|
|
4ccee02fe3 | ||
|
|
ee7bdf4ae2 | ||
|
|
fa8593c898 | ||
|
|
000884b080 | ||
|
|
9eb1fe61e7 | ||
|
|
741b936680 | ||
|
|
61b57aceae | ||
|
|
7b13b2d213 | ||
|
|
0401a13d5d | ||
|
|
95d39a3ff6 | ||
|
|
34e286c261 | ||
|
|
0b19d01e6d | ||
|
|
5e7387fa9c | ||
|
|
51fd598e32 | ||
|
|
269dac2bf8 | ||
|
|
9781c4352f | ||
|
|
9bd46932c3 | ||
|
|
ac5ebae639 | ||
|
|
a608c3984b | ||
|
|
c5081559cf | ||
|
|
0ea5f14ab7 | ||
|
|
9f02eb3359 | ||
|
|
89cb4f9536 | ||
|
|
fbf34f51ec | ||
|
|
337c1fdef3 | ||
|
|
e0b9e3e738 | ||
|
|
34bc82ddd6 | ||
|
|
cae4e1ff7c | ||
|
|
e1bc0b53a2 | ||
|
|
ae4a332946 | ||
|
|
15a7bab5f3 | ||
|
|
c6e2e875d5 | ||
|
|
2e739cdc07 | ||
|
|
bd64be2b5d | ||
|
|
fff76e6013 | ||
|
|
3d00338bc8 | ||
|
|
7d7ef3c7eb | ||
|
|
9941be1d1a | ||
|
|
7185741881 | ||
|
|
a996f5ca34 | ||
|
|
f41789e8ba | ||
|
|
fc22702bc1 | ||
|
|
2e8a9d9050 | ||
|
|
6e3822caa2 | ||
|
|
085983d4cd | ||
|
|
4af5314cd5 | ||
|
|
3ef1f6a726 | ||
|
|
85d397b1a5 | ||
|
|
2faa252f88 | ||
|
|
5195f5f248 | ||
|
|
597a5a4925 | ||
|
|
ab84000040 | ||
|
|
ab466d3073 | ||
|
|
792883c3db | ||
|
|
6ca931207d | ||
|
|
70f5838c85 | ||
|
|
49942df205 | ||
|
|
888cc7eeee | ||
|
|
8344622815 | ||
|
|
dd9caff60f | ||
|
|
55916d7cd8 | ||
|
|
7671a983bf | ||
|
|
891e7029fc | ||
|
|
7bdf52d824 | ||
|
|
c171e02cc9 | ||
|
|
45aa352578 | ||
|
|
92cef6726a | ||
|
|
0d91b9474c | ||
|
|
c50547e465 | ||
|
|
74bf357757 | ||
|
|
669489fe97 | ||
|
|
9d0dd59c6c | ||
|
|
638233263d | ||
|
|
5aa1d21ed4 | ||
|
|
f20429664f | ||
|
|
3557779efc | ||
|
|
fe8d20f86e | ||
|
|
39b68aa07d | ||
|
|
55ce381d28 | ||
|
|
05b9252a4b | ||
|
|
766c632c30 | ||
|
|
333db4ca77 | ||
|
|
f799550095 | ||
|
|
fb7be8684f | ||
|
|
82f69beb38 | ||
|
|
d8dc4e79fe | ||
|
|
8f07addade | ||
|
|
4666ee7261 | ||
|
|
a6736c4cd5 | ||
|
|
7447b61841 | ||
|
|
723121d671 | ||
|
|
fe8098c8b2 | ||
|
|
e6b317b762 | ||
|
|
56397e2027 | ||
|
|
6bbe738f91 | ||
|
|
a893cac951 | ||
|
|
c27249b676 | ||
|
|
1ec33bdd8a | ||
|
|
d74865b5f1 | ||
|
|
548589cb70 | ||
|
|
febcb2b1c7 | ||
|
|
beacbd3496 | ||
|
|
a77e857868 | ||
|
|
6c6956f1c1 | ||
|
|
504917bfa6 | ||
|
|
37f773a5ee | ||
|
|
aace96a96c | ||
|
|
b2a64ea066 | ||
|
|
baeac6fa68 | ||
|
|
a35faa9554 | ||
|
|
700e9c0c3a | ||
|
|
2dc409bf38 | ||
|
|
7fa04cf183 | ||
|
|
b76e63d222 | ||
|
|
3393ac1035 | ||
|
|
2886d90dde | ||
|
|
e3deb03331 | ||
|
|
e64b7d2e86 | ||
|
|
2fe085d409 | ||
|
|
a1983e2a32 | ||
|
|
4f495a0ed7 | ||
|
|
f2e39af12c | ||
|
|
111d4d70bd | ||
|
|
fae2e4851c | ||
|
|
43ff2daebe | ||
|
|
19373d815f | ||
|
|
e9a5ec1a58 | ||
|
|
ec97931ee4 | ||
|
|
20c0712aba | ||
|
|
2e5e5beb83 | ||
|
|
54ab0a3f98 | ||
|
|
d61d6e28f2 | ||
|
|
1d8b37eee0 | ||
|
|
2b488ccff4 | ||
|
|
cedbd1fd4c | ||
|
|
f27e5efe0f | ||
|
|
7eeaeafbd0 | ||
|
|
e4d868652a | ||
|
|
71d77ac27c | ||
|
|
1f80a05477 | ||
|
|
a02c8d242a | ||
|
|
edc18e3376 | ||
|
|
1f797e5e3a | ||
|
|
aee4e96619 | ||
|
|
7631c191c5 | ||
|
|
8802354ca9 | ||
|
|
627b94e5ab | ||
|
|
8ed842603c | ||
|
|
6904020f7d | ||
|
|
d3fd3bcacc | ||
|
|
366c5fcb4d | ||
|
|
2c44f4ede9 | ||
|
|
3f0ef8e08f | ||
|
|
01af249e95 | ||
|
|
a1e6439a5c | ||
|
|
ba305ad18b | ||
|
|
95dd6c485c | ||
|
|
dbfdbcba4a | ||
|
|
14cc33cd44 | ||
|
|
9a53b954f8 | ||
|
|
615026984c | ||
|
|
bd660dee82 | ||
|
|
75dc72550f | ||
|
|
c7443191ff | ||
|
|
dff4af94f5 | ||
|
|
a1fc606d99 | ||
|
|
89ceaac3d1 | ||
|
|
8df8ef629e | ||
|
|
07262bd62f | ||
|
|
5963f3b7f4 | ||
|
|
7e861aa3bb | ||
|
|
a4da036266 | ||
|
|
487ab0648d | ||
|
|
e91a23b3ed | ||
|
|
d5d16d1616 | ||
|
|
e81ac11466 | ||
|
|
93c0a8a7ba | ||
|
|
42a4596be6 | ||
|
|
53772e5536 | ||
|
|
2446e173b6 | ||
|
|
30efb3e824 | ||
|
|
ab0ae8ce91 | ||
|
|
172689976e | ||
|
|
bf47282ea7 | ||
|
|
ec8348854b | ||
|
|
348c562f97 | ||
|
|
92bd2166e3 | ||
|
|
d6a1d2deb5 | ||
|
|
f98d71ba6e | ||
|
|
abd8a09264 | ||
|
|
9bfbd25bd8 | ||
|
|
afc2b2ea95 | ||
|
|
6bf27b9976 | ||
|
|
29c8a179e3 | ||
|
|
be906fec76 | ||
|
|
9aacf32ac7 | ||
|
|
010a140835 | ||
|
|
0b72de7056 | ||
|
|
ddbfa02d5c | ||
|
|
cd563bffd5 | ||
|
|
306e1b6391 | ||
|
|
f8f79d3d18 | ||
|
|
58bc53683d | ||
|
|
41a4dea208 | ||
|
|
e56f6b602e | ||
|
|
fda0270343 | ||
|
|
0493cf60da | ||
|
|
98b98ddf8f | ||
|
|
171e87d6ba | ||
|
|
6d19579a70 | ||
|
|
766ab452cc | ||
|
|
add5179fc8 | ||
|
|
e66e2cbc04 | ||
|
|
9c6ed20d6c | ||
|
|
b6085fceae | ||
|
|
326823ee00 | ||
|
|
4d2eaa882b | ||
|
|
f6e7bec357 | ||
|
|
9b1772824b | ||
|
|
840e97edd9 | ||
|
|
a2f847e370 | ||
|
|
c255625131 | ||
|
|
5df8d1c4c0 | ||
|
|
04ebe253bb | ||
|
|
3541db891d | ||
|
|
fb4f88295c | ||
|
|
8c6bb7383c | ||
|
|
5a37400408 | ||
|
|
1acccc002a | ||
|
|
144fdb43da | ||
|
|
a17f3b1c5a | ||
|
|
a9b5d5ea58 | ||
|
|
b85825ba4a | ||
|
|
06a14c5b20 | ||
|
|
1f09b853d2 | ||
|
|
953e0e7f12 | ||
|
|
7b54d188f4 | ||
|
|
39239d1416 | ||
|
|
036aab2e2e | ||
|
|
eb930ac544 | ||
|
|
29343f8e9c | ||
|
|
0a2466e55b | ||
|
|
07c02f94da | ||
|
|
f70e1b343d | ||
|
|
26d4f3d70b | ||
|
|
581145eae2 | ||
|
|
50cbc85da6 | ||
|
|
df670a872e | ||
|
|
6163265323 | ||
|
|
20e60ba812 | ||
|
|
00c2894bb4 | ||
|
|
f10b0e3070 | ||
|
|
ece9e03fba | ||
|
|
87f0408bd5 | ||
|
|
46848cb5c9 | ||
|
|
0ca834cc70 | ||
|
|
0d60ae2f58 | ||
|
|
2712d98871 | ||
|
|
377356b9c1 | ||
|
|
6fa5f75510 | ||
|
|
199d970947 | ||
|
|
42f2bd496d | ||
|
|
5508b6cc60 | ||
|
|
92a374e63c | ||
|
|
e5a9c947aa | ||
|
|
c97fce71f7 | ||
|
|
1a266637c5 | ||
|
|
a43d1f3917 | ||
|
|
8b9dfb3ee3 | ||
|
|
b9c69f96fd | ||
|
|
5ed30eb005 | ||
|
|
7e52087c73 | ||
|
|
4100ebb670 | ||
|
|
52b8022173 | ||
|
|
d77231b99f | ||
|
|
1341973f07 | ||
|
|
e552b76b04 | ||
|
|
fb2ab44e3e | ||
|
|
827f9cdcdf | ||
|
|
3fd66193d7 | ||
|
|
2e80a9422f | ||
|
|
82ccf0290f | ||
|
|
18269d65f8 | ||
|
|
57165e3d71 | ||
|
|
12c34a8ed7 | ||
|
|
9d835ff9dc | ||
|
|
744dd47336 | ||
|
|
fa5a7b19af | ||
|
|
4ca3aabf49 | ||
|
|
1a56874b65 | ||
|
|
1ddd2ac30a | ||
|
|
b2e28acc79 | ||
|
|
95b99a3ece | ||
|
|
a8cd925c6a | ||
|
|
1f60c1733f | ||
|
|
cd71a9d918 | ||
|
|
26326f71a6 | ||
|
|
cb7ac97f79 | ||
|
|
efc4428a7c | ||
|
|
212ed66139 | ||
|
|
88a105ede9 | ||
|
|
428c9c581c | ||
|
|
2ed9582d6b | ||
|
|
dae989bbf2 | ||
|
|
3768504480 | ||
|
|
6346e07532 | ||
|
|
2d0b18c17b | ||
|
|
9badca362a | ||
|
|
7f9dc04112 | ||
|
|
d5b2486772 | ||
|
|
028792e5b4 | ||
|
|
0c011e0a97 | ||
|
|
049deaf590 | ||
|
|
20def3ff37 | ||
|
|
207e84d382 | ||
|
|
51cec634a6 | ||
|
|
a16635d475 | ||
|
|
5953d68916 | ||
|
|
32388fb960 | ||
|
|
67bb215894 | ||
|
|
a151d765ca | ||
|
|
6484feecb7 | ||
|
|
1914673ef7 | ||
|
|
f1a473d506 | ||
|
|
6f2210e792 | ||
|
|
91173a71b8 | ||
|
|
0b459e4075 | ||
|
|
8eb1a986fd | ||
|
|
0b66e84295 | ||
|
|
8d3edf1dd5 | ||
|
|
c3ad65688a | ||
|
|
6ab609cd46 | ||
|
|
e358ec305e | ||
|
|
9dd9f33b4a | ||
|
|
89a9dbba42 | ||
|
|
d1550c7511 | ||
|
|
b34d795b77 | ||
|
|
1df52cc6e8 | ||
|
|
1b76cd0ae5 | ||
|
|
7f60605c09 | ||
|
|
a63b6c3eb4 | ||
|
|
10fe61d4ff | ||
|
|
1e37ba1271 | ||
|
|
5986152349 | ||
|
|
545caef6d5 | ||
|
|
e07f065cb5 | ||
|
|
07f012a577 | ||
|
|
cf333cacd8 | ||
|
|
6cf32b9fb1 | ||
|
|
084d6bad1a | ||
|
|
2b71b2a19a | ||
|
|
b8284370d0 | ||
|
|
c08a8ec230 | ||
|
|
85ed093008 | ||
|
|
6c5e1cc346 | ||
|
|
2515d5f816 | ||
|
|
5a5237c39d | ||
|
|
1d90c32719 | ||
|
|
d2a62297df | ||
|
|
fddada4c1c | ||
|
|
1f29f86f49 | ||
|
|
294bc2aab1 | ||
|
|
0d1782bfd0 | ||
|
|
1bcc5f1bab | ||
|
|
036c884452 | ||
|
|
012d7d9d7b | ||
|
|
5d3d6fc59c | ||
|
|
f992597392 | ||
|
|
e996eb5c7c | ||
|
|
0e821cc7c8 | ||
|
|
9df40bd9be | ||
|
|
385531cec7 | ||
|
|
687511ec0c | ||
|
|
654e66b280 | ||
|
|
d43b445749 | ||
|
|
f673840246 | ||
|
|
33b6ea86f9 | ||
|
|
f30d9eb276 | ||
|
|
a35ae185ba | ||
|
|
69d3843794 | ||
|
|
0b006ae9e7 | ||
|
|
b9e0293325 | ||
|
|
9114bc91ed | ||
|
|
5889588a8a | ||
|
|
bf2d793577 | ||
|
|
0d7e471fe5 | ||
|
|
caafca3d6d | ||
|
|
3687863a62 | ||
|
|
e3dde51f8f | ||
|
|
9add83acdb | ||
|
|
11e7aec9c6 | ||
|
|
27111ef3fd | ||
|
|
ade6fabb1f | ||
|
|
62e0ef0cff | ||
|
|
7982715502 | ||
|
|
6c3f4713a1 | ||
|
|
341d1853fa | ||
|
|
6c3ac2cd85 | ||
|
|
836aac46c7 | ||
|
|
e31de39c52 | ||
|
|
7c1e2c1bbf | ||
|
|
f042b9df2c | ||
|
|
ed7f12eb89 | ||
|
|
efd1a2e8ad | ||
|
|
e6c71d79a7 | ||
|
|
ada49fa764 | ||
|
|
50d33a202c | ||
|
|
99274c72c2 | ||
|
|
8c88f370e6 | ||
|
|
7cf746aa6a | ||
|
|
504e1141f1 | ||
|
|
0f1aad6b98 | ||
|
|
f1c29c9bfc | ||
|
|
3918b33e2d | ||
|
|
9f5c68e9ec | ||
|
|
1ce11b2ad6 | ||
|
|
236ba3951f | ||
|
|
26450ff930 | ||
|
|
3cfad2ac16 | ||
|
|
0903f4c777 | ||
|
|
61b9ddcd60 | ||
|
|
e769d17ea5 | ||
|
|
435a64a407 | ||
|
|
2f107b6b27 | ||
|
|
833ab1a1c0 | ||
|
|
45be18027c | ||
|
|
403de64e5e | ||
|
|
de51d4c561 | ||
|
|
5772eb4413 | ||
|
|
54fc4fa82b | ||
|
|
5cdd8eb7be | ||
|
|
435963a8a4 | ||
|
|
a038f7e334 | ||
|
|
665a6c9bf4 | ||
|
|
051df6fb14 | ||
|
|
d95bbd9820 | ||
|
|
fa33ac21f5 | ||
|
|
ee18da225b | ||
|
|
104520b8ce | ||
|
|
acb0945927 | ||
|
|
9ca483d18d | ||
|
|
548030aa33 | ||
|
|
8c5595894f | ||
|
|
6a33670296 | ||
|
|
1b32a46649 | ||
|
|
d80a84aca1 | ||
|
|
e65d660236 | ||
|
|
4e2e04e3e7 | ||
|
|
51c2a8fdb8 | ||
|
|
8fcad24adb | ||
|
|
c67de5eb28 | ||
|
|
0d59b52479 | ||
|
|
d1a1035e43 | ||
|
|
48767a7bcf | ||
|
|
2d870bd7ea | ||
|
|
2ef2c54702 | ||
|
|
9a8476dc15 | ||
|
|
dbf84e775e | ||
|
|
0e6779db3a | ||
|
|
4a9bdf16f5 | ||
|
|
6a24c609b6 | ||
|
|
a48a36aaf2 | ||
|
|
e5bf3cdb34 | ||
|
|
bbe4f9cd5f | ||
|
|
5050ca0dc0 | ||
|
|
d50d010b82 | ||
|
|
4f777cae87 | ||
|
|
97827d4309 | ||
|
|
16cd176822 | ||
|
|
a822165c26 | ||
|
|
bdc261ff9e | ||
|
|
9604c836fd | ||
|
|
d752f0593d | ||
|
|
197c80165b | ||
|
|
34c69c4309 | ||
|
|
e6ec3c6c9b | ||
|
|
f512e36b9e | ||
|
|
ca5cdd4f8b | ||
|
|
5943863c78 | ||
|
|
ed76bbbdc5 | ||
|
|
09d436fb9c | ||
|
|
351cf41884 | ||
|
|
19c54040bc | ||
|
|
8026dde26a | ||
|
|
7014bb05f0 | ||
|
|
7b0eb87685 | ||
|
|
9189dd1d3d | ||
|
|
77a6ec09fb | ||
|
|
f356be8253 | ||
|
|
4626d557d3 | ||
|
|
cab93d0c72 | ||
|
|
d7c74ca3ad | ||
|
|
b2cd519e0b | ||
|
|
0a56e5c3d5 | ||
|
|
4fc10f0cfb | ||
|
|
2942ae9f4a | ||
|
|
2524020963 | ||
|
|
2cf9657bd4 | ||
|
|
4c95265d5e | ||
|
|
cd5246865b | ||
|
|
4b8500603d | ||
|
|
adfb9f0c42 | ||
|
|
acfd938e48 | ||
|
|
90abc2e846 | ||
|
|
1be15c67cf | ||
|
|
660796a1e4 | ||
|
|
5336ed9d9d | ||
|
|
7b3beafddc | ||
|
|
1ffbf2ca71 | ||
|
|
d53b63d67e | ||
|
|
1298769d4d | ||
|
|
914819799a | ||
|
|
cf7156707d | ||
|
|
2dc9016f9f | ||
|
|
3a9e6ad2c3 | ||
|
|
07cdc9d549 | ||
|
|
03eaa17800 | ||
|
|
3d3f754433 | ||
|
|
f999f58fa8 | ||
|
|
89272ce300 | ||
|
|
a203b1a0c0 | ||
|
|
92f3caa1c9 | ||
|
|
016137b82b | ||
|
|
897f5a9324 | ||
|
|
43d963ff5d | ||
|
|
2d1e99f89b | ||
|
|
357b9c1e72 | ||
|
|
3558635797 | ||
|
|
ff59480bce | ||
|
|
0c6406e7ff | ||
|
|
451d612f70 | ||
|
|
41a0cc455b | ||
|
|
db8a3d85a9 | ||
|
|
8575470ff2 | ||
|
|
332ccee44a | ||
|
|
0e1d683c77 | ||
|
|
09c5631b4d | ||
|
|
c0fa732833 | ||
|
|
155e9119c0 | ||
|
|
03ef80c463 | ||
|
|
9345fe8c99 | ||
|
|
59ab1e6c22 | ||
|
|
d4a2f777fa | ||
|
|
6c4b345a58 | ||
|
|
9b7ecff5d0 | ||
|
|
2385cf62e5 | ||
|
|
0bc86a97e7 | ||
|
|
029afbe13c | ||
|
|
7c7df1426b | ||
|
|
b53d9d18bc | ||
|
|
0b8cd232af | ||
|
|
20e40a278e | ||
|
|
50053e34f8 | ||
|
|
45e2cf75c6 | ||
|
|
93741bf731 | ||
|
|
f33ce378de | ||
|
|
99c77d2e98 | ||
|
|
a1ae5ec9e0 | ||
|
|
d1842ab69a | ||
|
|
7045c1c669 | ||
|
|
fe2034f4d2 | ||
|
|
83221fd7dd | ||
|
|
8e87cbd479 | ||
|
|
be6b693198 | ||
|
|
fb4b014940 | ||
|
|
5e226efe9d | ||
|
|
c174163e8e | ||
|
|
8cc8e84afe | ||
|
|
8e3dd2e666 | ||
|
|
fdbd947451 | ||
|
|
90ee76c061 | ||
|
|
eba8849d49 | ||
|
|
38aaa9a5ab | ||
|
|
619846002e | ||
|
|
2468d61414 | ||
|
|
6b50f4074d | ||
|
|
9abcaddaba | ||
|
|
026f1bfd1c | ||
|
|
bc4c74d5b6 | ||
|
|
dd68d1d2be | ||
|
|
2b64a476c9 | ||
|
|
e0fa6814a5 | ||
|
|
1f679213b5 | ||
|
|
d0132a4494 | ||
|
|
4078f2da02 | ||
|
|
b6c4c2d73c | ||
|
|
ab3fd769d5 | ||
|
|
c29ffcf39e | ||
|
|
ae4ea1120e | ||
|
|
7ca8535714 | ||
|
|
293c32d465 | ||
|
|
9b1e20f100 | ||
|
|
05142044cb | ||
|
|
fd7d39ca77 | ||
|
|
2134966f6e | ||
|
|
c78206b9ef | ||
|
|
743c701ac5 | ||
|
|
3f2eec85b2 | ||
|
|
92b942e4f2 | ||
|
|
94c76b591d | ||
|
|
47d26c8887 | ||
|
|
81704f1807 | ||
|
|
4bcdd22e28 | ||
|
|
be61249d04 | ||
|
|
a5505f7fe2 | ||
|
|
ee2032f3aa | ||
|
|
9ff232fbb2 | ||
|
|
1c634dde60 | ||
|
|
dba9b4d931 | ||
|
|
0e648dcebd | ||
|
|
9da405a6e5 | ||
|
|
04c15f7fe8 | ||
|
|
4ac8d0df95 | ||
|
|
e7bb0c3def | ||
|
|
15d04eda7c | ||
|
|
8b508971db | ||
|
|
b1b134fbf0 | ||
|
|
3cbed1dcd2 | ||
|
|
bda3548cd2 | ||
|
|
200b099de1 | ||
|
|
b8f5e67bee | ||
|
|
9238748978 | ||
|
|
9e0ea50da2 | ||
|
|
e0c2d69244 | ||
|
|
26841f53aa | ||
|
|
6548250eb4 | ||
|
|
ab8035d6bb | ||
|
|
99207358d5 | ||
|
|
7b98a00a24 | ||
|
|
749e6789b8 | ||
|
|
68f243221f | ||
|
|
4b1b1a4c3b | ||
|
|
9ead1a248e | ||
|
|
b5f62f15c7 | ||
|
|
cb9699e12a | ||
|
|
9b79620bde | ||
|
|
481cdefafc | ||
|
|
9ee35f6933 | ||
|
|
a5c2c483ad | ||
|
|
2fa70fea4d | ||
|
|
554f44e361 | ||
|
|
33a0a6a651 | ||
|
|
982e918a50 | ||
|
|
ebad3d673c | ||
|
|
aa6554c1b7 | ||
|
|
fc19f2e903 | ||
|
|
c1f7ce7389 | ||
|
|
5f7553f624 | ||
|
|
7a774ef3ed | ||
|
|
153b82ca77 | ||
|
|
4978d09eb1 | ||
|
|
350ec1776e | ||
|
|
8d01a20d6f | ||
|
|
1e9553fadc | ||
|
|
49f37acd18 | ||
|
|
bce2877641 | ||
|
|
02a945d84b | ||
|
|
1eaf14a861 | ||
|
|
aabb88fbf4 | ||
|
|
9c8323326d | ||
|
|
da3b3b565f | ||
|
|
c1e5c75473 | ||
|
|
6c22f285b4 | ||
|
|
4c9dd749b9 | ||
|
|
918064feb3 | ||
|
|
1c3ad9da72 | ||
|
|
033863925a | ||
|
|
b5436d4b93 | ||
|
|
cbf353e387 | ||
|
|
ca33e228e6 | ||
|
|
97215c9072 | ||
|
|
dd582a3f7e | ||
|
|
8034ea272c | ||
|
|
e6124c2631 | ||
|
|
35813ff951 | ||
|
|
1ff4be4600 | ||
|
|
7305960d53 | ||
|
|
d5d2b68e67 | ||
|
|
25b080a0f1 | ||
|
|
f8943011d4 | ||
|
|
21b8e01149 | ||
|
|
ee50fb40f2 | ||
|
|
3c218305fa | ||
|
|
f31961982d | ||
|
|
8c20fa5d83 | ||
|
|
6ef08e855c | ||
|
|
431c7f7327 | ||
|
|
ab7d507806 | ||
|
|
b1f129c6e8 | ||
|
|
5224f0f372 | ||
|
|
30cd101adf | ||
|
|
f99257ae83 | ||
|
|
0c6d4a0f05 | ||
|
|
752bb6c4d1 | ||
|
|
6b32f30654 | ||
|
|
ddddd88ef6 | ||
|
|
40619a5a53 | ||
|
|
c27e66b618 | ||
|
|
3c2eab00d0 | ||
|
|
dc89b92e2a | ||
|
|
828b4b3e4e | ||
|
|
bd7d0be07a | ||
|
|
2ee3b5f12b | ||
|
|
195d56d4ef | ||
|
|
ca53013661 | ||
|
|
9ed84e130f | ||
|
|
47cd482461 | ||
|
|
4fb8d4820f | ||
|
|
a09bb3bc00 | ||
|
|
4d3e6eb11f | ||
|
|
606f7f44cc | ||
|
|
1933b0f29d | ||
|
|
98d49a77ed | ||
|
|
7cead8cb68 | ||
|
|
487cc20129 | ||
|
|
09ae27503e | ||
|
|
e1b5a105dd | ||
|
|
79275f9a4c | ||
|
|
aac7cb1ff6 | ||
|
|
d78610eecf | ||
|
|
6880dd786f | ||
|
|
a62f8e17ca | ||
|
|
084fccc53d | ||
|
|
5d643bac8e | ||
|
|
82f3d7d9bb | ||
|
|
697955997a | ||
|
|
9a53399a8c | ||
|
|
b2049b449b | ||
|
|
a2992ab144 | ||
|
|
21fcfb0b17 | ||
|
|
e449b772cc | ||
|
|
922f828696 | ||
|
|
fee631080e | ||
|
|
22681be3c7 | ||
|
|
7b0498aab0 | ||
|
|
c9e030bb19 | ||
|
|
c702badd98 | ||
|
|
63044421fd | ||
|
|
8895c14f42 | ||
|
|
ddbf936102 | ||
|
|
70a72dd32a | ||
|
|
b13d18fa9a | ||
|
|
fcf10680f4 | ||
|
|
bd91b704b7 | ||
|
|
bd149af508 | ||
|
|
aa9ba03228 | ||
|
|
3cbe7e3f39 | ||
|
|
040f612cd5 | ||
|
|
851aef3b2d | ||
|
|
6e74cb571f | ||
|
|
466e9636a5 | ||
|
|
099aa26c6f | ||
|
|
d73aea8ede | ||
|
|
5134f0b689 | ||
|
|
b9662ed866 | ||
|
|
3de953aa2b | ||
|
|
09fbed6c75 | ||
|
|
a9106924c7 | ||
|
|
9ac660bc22 | ||
|
|
87cabc29d4 | ||
|
|
a5c9fb2940 | ||
|
|
6837cfa2fc | ||
|
|
949a16eba0 | ||
|
|
61e449430b | ||
|
|
9e4e8acdb0 | ||
|
|
84e725d5ce | ||
|
|
8319cdbd61 | ||
|
|
467be5410c | ||
|
|
e63a2ac7f6 | ||
|
|
24188a3eea | ||
|
|
c2f28d4ed6 | ||
|
|
fa5ac2902f | ||
|
|
ebdf4bb2e2 | ||
|
|
73bc34f419 | ||
|
|
9098bb1e06 | ||
|
|
8202a10837 | ||
|
|
2aa652f6e9 | ||
|
|
983340f44c | ||
|
|
d9b9b05494 | ||
|
|
723e3a3964 | ||
|
|
ad0905607b | ||
|
|
e836984e5e | ||
|
|
e92a852349 | ||
|
|
eecd15ebfa | ||
|
|
da91158f94 | ||
|
|
9dc34c421f | ||
|
|
759751629d | ||
|
|
24d8e5f113 | ||
|
|
9d29bcc566 | ||
|
|
e2e51fc32c | ||
|
|
75ca6f3970 | ||
|
|
33985bc976 | ||
|
|
17545a6f72 | ||
|
|
e36ca7f8f8 | ||
|
|
ed8b4e567b | ||
|
|
c1a1b91a25 | ||
|
|
330927d4b7 | ||
|
|
40ab11bc49 | ||
|
|
f618926d09 | ||
|
|
0e30a73a19 | ||
|
|
fb7b8ba867 | ||
|
|
6fa4807a5a | ||
|
|
456fb4014c | ||
|
|
a571949f49 | ||
|
|
ddf3b57ec5 | ||
|
|
5ec3ef5be6 | ||
|
|
61e650e7b5 | ||
|
|
8cae206c64 | ||
|
|
e9c96e7a24 | ||
|
|
434981b567 | ||
|
|
dcaaef286d | ||
|
|
bc7a436241 | ||
|
|
667bc42bee | ||
|
|
9257cd82b3 | ||
|
|
6989f303b8 | ||
|
|
ebca056ad9 | ||
|
|
172773a7f2 | ||
|
|
3a1f697d93 | ||
|
|
8f40d18c0a | ||
|
|
a132094be4 | ||
|
|
ba57e352b7 | ||
|
|
5d1975d60c | ||
|
|
a982acc1ca | ||
|
|
c045b90d2b | ||
|
|
34e82118a1 | ||
|
|
e90182642d | ||
|
|
6ca8982237 | ||
|
|
e30b2576b7 | ||
|
|
20c643552c | ||
|
|
d5a3bf5d93 | ||
|
|
cb0cf20e46 | ||
|
|
a2a7cebd07 | ||
|
|
2fb3cb62f6 | ||
|
|
a7f931b8e3 | ||
|
|
aea803bec7 | ||
|
|
628ca0daa1 | ||
|
|
75ec7c3e05 | ||
|
|
57b5529cd1 | ||
|
|
9df1ea4359 | ||
|
|
2a08127e13 | ||
|
|
0c36b7894b | ||
|
|
43f085b069 | ||
|
|
14c96a1045 | ||
|
|
145f1e862d | ||
|
|
b5dc732a91 | ||
|
|
30449565ac | ||
|
|
2117e79324 | ||
|
|
50226d2c84 | ||
|
|
c75c89c388 | ||
|
|
4c2ce0d5eb |
@@ -1,8 +1,9 @@
|
||||
.eslintignore
|
||||
.eslintrc.json
|
||||
.eslintrc.js
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
.idea
|
||||
.npmrc
|
||||
.travis*
|
||||
.travis.yml
|
||||
@@ -12,4 +13,5 @@ images
|
||||
node_modules
|
||||
scripts
|
||||
test
|
||||
update.sh
|
||||
coverage
|
||||
update.sh
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
|
||||
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'],
|
||||
},
|
||||
}],
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"plugins": [
|
||||
"jest"
|
||||
]
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
github: [koenkk]
|
||||
custom: https://www.paypal.me/koenkk
|
||||
custom:
|
||||
- https://www.paypal.me/koenkk
|
||||
- https://www.buymeacoffee.com/koenkk
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<!--
|
||||
Before submitting an issue make sure you've searched for a similar issue and read the documentation: https://www.zigbee2mqtt.io/.
|
||||
|
||||
Rules (don't ignore these, your issue will be closed without further notice):
|
||||
- English only
|
||||
- Make sure you are on the latest Zigbee2MQTT version
|
||||
- Provide a clear description of the problem
|
||||
- DON'T copy logs directly here, post a link to https://hastebin.com/ or https://pastebin.com/.
|
||||
- Make sure you are running the latest firmware https://github.com/koenkk/z-stack-firmware.
|
||||
|
||||
Did you read the FAQ?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html
|
||||
|
||||
Zigbee2MQTT fails to start?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html#help-zigbee2mqtt-fails-to-start
|
||||
|
||||
Having issues when using a CC2531?
|
||||
- Make sure the CC2531 is connected through a USB extension cable
|
||||
- Try the source routing firmware: https://github.com/Koenkk/Z-Stack-firmware/tree/master/coordinator/Z-Stack_Home_1.2/bin/source_routing
|
||||
- With larger networks (30/40+) devices, your CC2531 might not be powerful enough. This will cause weird issues, in this case it's advised to use a more powerful adapter: https://www.zigbee2mqtt.io/information/supported_adapters.html#texas-instruments-cc26x2r1
|
||||
|
||||
Unsupported device?
|
||||
- https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html
|
||||
|
||||
Device does not pair or interview fails?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html#why-does-my-device-not-or-fail-to-pair
|
||||
|
||||
Bug report?
|
||||
- If applicable, provide steps how to reproduce the problem.
|
||||
- Provide the herdsman debug logging: https://www.zigbee2mqtt.io/information/debug.html#zigbee-herdsman-debug-logging
|
||||
|
||||
-->
|
||||
|
||||
<!--Start your bug report below this line-->
|
||||
|
||||
# Bug Report
|
||||
## What happened
|
||||
|
||||
## What did you expect to happen
|
||||
|
||||
## How to reproduce it (minimal and precise)
|
||||
|
||||
## Debug Info
|
||||
Zigbee2MQTT version:
|
||||
Adapter hardware: CC2531, CC2530, CC26X2R1 or CC1352P-2
|
||||
Adapter firmware version:
|
||||
@@ -1,11 +1,17 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- 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/danielwelch/hassio-zigbee2mqtt/issues
|
||||
url: https://github.com/zigbee2mqtt/hassio-zigbee2mqtt/issues
|
||||
about: Issues related to the Home Assistant addon.
|
||||
- name: FAQ
|
||||
url: https://www.zigbee2mqtt.io/information/FAQ.html
|
||||
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.
|
||||
about: Chat for feedback, questions and troubleshooting.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: feature request
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Is your feature request related to a problem? Please describe
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
## Describe the solution you'd like
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
## Describe alternatives you've considered
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
## Additional context
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea for this project
|
||||
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
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: New device support request
|
||||
about: Request support for a new device
|
||||
title: ''
|
||||
labels: new device support
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
IMPORTANT: Before submitting a request, first follow: https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html
|
||||
-->
|
||||
|
||||
## Information about the device + link
|
||||
..
|
||||
|
||||
## data/database.db entry of the device
|
||||
..
|
||||
@@ -0,0 +1,55 @@
|
||||
name: New device support request
|
||||
description: Request support for a new device
|
||||
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}'
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
name: Problem report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: problem
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
Before submitting an issue make sure you've searched for a similar issue and read the documentation: https://www.zigbee2mqtt.io/.
|
||||
|
||||
Rules (don't ignore these, your issue will be closed without further notice):
|
||||
- English only
|
||||
- Make sure you are on the latest Zigbee2MQTT version
|
||||
- Provide a clear description of the problem
|
||||
- DON'T copy logs directly here, post a link to https://hastebin.com/ or https://pastebin.com/.
|
||||
- Make sure you are running the latest firmware https://github.com/koenkk/z-stack-firmware.
|
||||
|
||||
Did you read the FAQ?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html
|
||||
|
||||
Zigbee2MQTT fails to start?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html#help-zigbee2mqtt-fails-to-start
|
||||
|
||||
Having issues when using a CC2531?
|
||||
- Make sure the CC2531 is connected through a USB extension cable
|
||||
- Try the source routing firmware: https://github.com/Koenkk/Z-Stack-firmware/tree/master/coordinator/Z-Stack_Home_1.2/bin/source_routing
|
||||
- With larger networks (30/40+) devices, your CC2531 might not be powerful enough. This will cause weird issues, in this case it's advised to use a more powerful adapter: https://www.zigbee2mqtt.io/information/supported_adapters.html#texas-instruments-cc26x2r1
|
||||
|
||||
Unsupported device?
|
||||
- https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html
|
||||
|
||||
Device does not pair or interview fails?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html#why-does-my-device-not-or-fail-to-pair
|
||||
|
||||
Bug report?
|
||||
- If applicable, provide steps how to reproduce the problem.
|
||||
- Provide the herdsman debug logging: https://www.zigbee2mqtt.io/information/debug.html#zigbee-herdsman-debug-logging
|
||||
|
||||
-->
|
||||
|
||||
<!--Start your bug report below this line-->
|
||||
|
||||
## What happened
|
||||
|
||||
## What did you expect to happen
|
||||
|
||||
## How to reproduce it (minimal and precise)
|
||||
|
||||
## Debug info
|
||||
Zigbee2MQTT version:
|
||||
Adapter hardware: CC2531, CC2530, CC26X2R1, CC1352P-2, Conbee II
|
||||
Adapter firmware version:
|
||||
@@ -0,0 +1,67 @@
|
||||
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
|
||||
@@ -1 +0,0 @@
|
||||
preferredBranch: dev
|
||||
@@ -0,0 +1,13 @@
|
||||
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
|
||||
@@ -2,28 +2,23 @@ name: CI
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2-beta
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 12
|
||||
node-version: 16
|
||||
registry-url: https://registry.npmjs.org/
|
||||
- name: Get NodeJS Version
|
||||
id: nodeversion
|
||||
run: |
|
||||
echo "::set-output name=version::$(node --version)"
|
||||
- name: Restore node_modules cache
|
||||
id: cache-node-modules
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-${{ steps.nodeversion.outputs.version }}-${{ hashFiles('npm-shrinkwrap.json') }}
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Test
|
||||
run: npm run test-with-coverage
|
||||
- name: Lint
|
||||
@@ -31,46 +26,76 @@ jobs:
|
||||
- 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 setup
|
||||
- name: Docker login ghcr.io
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
run: |
|
||||
sudo wget -O /usr/local/bin/buildx https://github.com/docker/buildx/releases/download/v0.3.1/buildx-v0.3.1.linux-amd64
|
||||
sudo chmod a+x /usr/local/bin/buildx
|
||||
docker run --rm --privileged hypriot/qemu-register:v2.7.0
|
||||
buildx create --use
|
||||
buildx ls
|
||||
run: echo ${{ secrets.CR_PAT }} | 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: |
|
||||
buildx build \
|
||||
docker buildx build \
|
||||
--build-arg COMMIT=$(git rev-parse --short HEAD) \
|
||||
--platform linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/386 \
|
||||
--platform linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7 \
|
||||
-f docker/Dockerfile \
|
||||
--push \
|
||||
-t koenkk/zigbee2mqtt:latest-dev \
|
||||
-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)"
|
||||
buildx build \
|
||||
docker buildx build \
|
||||
--build-arg COMMIT=$(git rev-parse --short HEAD) \
|
||||
--platform linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/386 \
|
||||
--platform linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7 \
|
||||
-f docker/Dockerfile \
|
||||
--push \
|
||||
-t koenkk/zigbee2mqtt:latest -t "koenkk/zigbee2mqtt:$TAG" \
|
||||
-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 danielwelch/hassio-zigbee2mqtt build
|
||||
- name: Trigger zigbee2mqtt/hassio-zigbee2mqtt build
|
||||
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
|
||||
run: |
|
||||
curl -s \
|
||||
--request POST \
|
||||
--header "Content-Type: application/json" \
|
||||
--data '{"definition": {"id": 1}}' \
|
||||
-u ${{ secrets.HASSIO_TRIGGER_SECRET }} \
|
||||
"https://dev.azure.com/danielwelch2101/hassio-zigbee2mqtt/_apis/build/builds?api-version=5.1"
|
||||
curl \
|
||||
-X POST \
|
||||
-H "Authorization: token ${{ secrets.CR_PAT }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
https://api.github.com/repos/zigbee2mqtt/hassio-zigbee2mqtt/actions/workflows/ci.yml/dispatches \
|
||||
-d '{"ref":"master","inputs":{}}'
|
||||
|
||||
tests:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node: [14, 16, 18]
|
||||
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: Lint
|
||||
run: npm run eslint
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Test
|
||||
run: npm run test-with-coverage
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: "Code Scanning - Action"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
CodeQL-Build:
|
||||
# CodeQL runs on ubuntu-latest, windows-latest, and macos-latest
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below).
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
@@ -0,0 +1,14 @@
|
||||
name: 'Dependency 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@v2
|
||||
@@ -0,0 +1,33 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
name: GHCR cleanup
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Delete untagged images
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{ secrets.CR_PAT }}
|
||||
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
|
||||
@@ -4,11 +4,17 @@ on:
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
permissions:
|
||||
issues: write # for actions/stale to close stale issues
|
||||
pull-requests: write # for actions/stale to close stale PRs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v3
|
||||
- uses: actions/stale@v5
|
||||
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'
|
||||
@@ -16,3 +22,4 @@ jobs:
|
||||
days-before-stale: 30
|
||||
days-before-close: 7
|
||||
operations-per-run: 500
|
||||
exempt-issue-labels: dont-stale
|
||||
|
||||
@@ -4,21 +4,26 @@ on:
|
||||
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@v2
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: dev
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
- uses: actions/setup-node@v2-beta
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 12
|
||||
node-version: 16
|
||||
- run: npx npm-check-updates -u -x connect-gzip-static # connect-gzip-static only supports node 16
|
||||
- run: npm install
|
||||
- run: npm ls --depth 0 --prod --parseable | tail -n +2 | grep -v "zigbee" | xargs -I '{}' basename "{}" | xargs -I '{}' npm install '{}'@latest
|
||||
- run: rm -rf node_modules npm-shrinkwrap.json && npm install && npm shrinkwrap --dev
|
||||
- uses: peter-evans/create-pull-request@v2
|
||||
- uses: peter-evans/create-pull-request@v4
|
||||
with:
|
||||
commit-message: Update dependencies
|
||||
branch: "update_deps"
|
||||
|
||||
@@ -7,16 +7,24 @@ jobs:
|
||||
update_frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: dev
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
- uses: actions/setup-node@v2-beta
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 12
|
||||
node-version: 16
|
||||
- run: npm install zigbee2mqtt-frontend@${{ github.event.client_payload.version }} --save-exact
|
||||
- uses: peter-evans/create-pull-request@v2
|
||||
- uses: peter-evans/create-pull-request@v4
|
||||
id: cpr
|
||||
with:
|
||||
commit-message: Update zigbee2mqtt-frontend to ${{ github.event.client_payload.version }}
|
||||
branch: "update_frontend"
|
||||
title: Update zigbee-frontend to ${{ github.event.client_payload.version }}
|
||||
- run: sleep 5 # Otherwise pull request may not exist yet causing automerge to fail
|
||||
- uses: peter-evans/enable-pull-request-automerge@v2
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
with:
|
||||
token: ${{ secrets.CR_PAT }}
|
||||
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
merge-method: squash
|
||||
|
||||
@@ -7,16 +7,24 @@ jobs:
|
||||
update_zh:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: dev
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
- uses: actions/setup-node@v2-beta
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 12
|
||||
node-version: 16
|
||||
- run: npm install zigbee-herdsman@${{ github.event.client_payload.version }} --save-exact
|
||||
- uses: peter-evans/create-pull-request@v2
|
||||
- uses: peter-evans/create-pull-request@v4
|
||||
id: cpr
|
||||
with:
|
||||
commit-message: Update zigbee-herdsman to ${{ github.event.client_payload.version }}
|
||||
branch: "update_zh"
|
||||
title: Update zigbee-herdsman to ${{ github.event.client_payload.version }}
|
||||
- run: sleep 5 # Otherwise pull request may not exist yet causing automerge to fail
|
||||
- uses: peter-evans/enable-pull-request-automerge@v2
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
with:
|
||||
token: ${{ secrets.CR_PAT }}
|
||||
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
merge-method: squash
|
||||
@@ -7,16 +7,24 @@ jobs:
|
||||
update_zhc:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: dev
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
- uses: actions/setup-node@v2-beta
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 12
|
||||
node-version: 16
|
||||
- run: npm install zigbee-herdsman-converters@${{ github.event.client_payload.version }} --save-exact
|
||||
- uses: peter-evans/create-pull-request@v2
|
||||
- uses: peter-evans/create-pull-request@v4
|
||||
id: cpr
|
||||
with:
|
||||
commit-message: Update zigbee-herdsman-converters to ${{ github.event.client_payload.version }}
|
||||
branch: "update_zhc"
|
||||
title: Update zigbee-herdsman-converters to ${{ github.event.client_payload.version }}
|
||||
- run: sleep 5 # Otherwise pull request may not exist yet causing automerge to fail
|
||||
- uses: peter-evans/enable-pull-request-automerge@v2
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
with:
|
||||
token: ${{ secrets.CR_PAT }}
|
||||
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
merge-method: squash
|
||||
@@ -11,6 +11,9 @@ pids
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Compiled source
|
||||
dist/*
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
@@ -70,4 +73,10 @@ data/database.db.backup
|
||||
data/extension
|
||||
|
||||
# MacOS indexing file
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
# IDE specific folders
|
||||
.idea
|
||||
|
||||
# Ignore config
|
||||
data/*.yaml
|
||||
|
||||
@@ -7,6 +7,7 @@ coverage
|
||||
.jenkins.yml
|
||||
.codeclimate.yml
|
||||
.github
|
||||
babel.config.js
|
||||
|
||||
#linters
|
||||
.jscsrc
|
||||
@@ -14,11 +15,13 @@ coverage
|
||||
.eslintrc*
|
||||
.dockerignore
|
||||
.eslintignore
|
||||
.gitignore
|
||||
|
||||
#editor settings
|
||||
.vscode
|
||||
|
||||
#src
|
||||
lib
|
||||
docs
|
||||
docker
|
||||
images
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"*.yaml": "home-assistant"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
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
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available
|
||||
at [https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -0,0 +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.
|
||||
|
||||
- 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).
|
||||
@@ -35,26 +35,26 @@
|
||||
</div>
|
||||
<h1>Zigbee2MQTT 🌉 🐝</h1>
|
||||
<p>
|
||||
Allows you to use your Zigbee devices <b>without</b> the vendors bridge or gateway.
|
||||
Allows you to use your Zigbee devices <b>without</b> the vendor's bridge or gateway.
|
||||
</p>
|
||||
<p>
|
||||
It bridges events and allows you to control your Zigbee devices via MQTT. In this way you can integrate your Zigbee devices with whatever smart home infrastructure you are using.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## [Getting started](https://www.zigbee2mqtt.io/#getting-started)
|
||||
## [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/information/zigbee_network.html) to help you out.
|
||||
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/#integration)
|
||||
## [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](https://www.home-assistant.io/hassio/): Using [the official addon](https://github.com/danielwelch/hassio-zigbee2mqtt) from [danielwelch](https://github.com/danielwelch)
|
||||
- Generic install or Hassbian: Using instructions [here](https://www.zigbee2mqtt.io/integration/home_assistant.html)
|
||||
- [Home Assistant](https://www.home-assistant.io/hassio/): Using [the official addon](https://github.com/zigbee2mqtt/hassio-zigbee2mqtt)
|
||||
- Generic install or Hassbian: Using instructions [here](https://www.zigbee2mqtt.io/guide/usage/integrations/home_assistant.html)
|
||||
|
||||
<img align="left" height="100px" width="100px" src="https://user-images.githubusercontent.com/2734836/47615848-b8dd8700-dabd-11e8-9d77-175002dd8987.png">
|
||||
|
||||
@@ -67,12 +67,15 @@ Zigbee2MQTT integrates well with (almost) every home automation solution because
|
||||

|
||||
|
||||
### 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 an 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/raw/master/docs/07-5123-06-zigbee-cluster-library-specification.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 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.
|
||||
|
||||
### 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.
|
||||
|
||||
## Supported devices
|
||||
See [Supported devices](https://www.zigbee2mqtt.io/information/supported_devices.html) to check whether your device is supported. There is quite an extensive list, including devices from vendors like Xiaomi, Ikea, Philips, OSRAM and more.
|
||||
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, Ikea, Philips, OSRAM and more.
|
||||
|
||||
If it's not listed in [Supported devices](https://www.zigbee2mqtt.io/information/supported_devices.html), support can be added (fairly) easy, see [How to support new devices](https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html).
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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'],
|
||||
],
|
||||
};
|
||||
@@ -0,0 +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');
|
||||
@@ -1,25 +1,24 @@
|
||||
FROM node:12-alpine as base
|
||||
FROM node:16-alpine3.15 as base
|
||||
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache tzdata eudev
|
||||
RUN apk add --no-cache tzdata eudev tini
|
||||
|
||||
COPY package.json ./
|
||||
# Dependencies and build
|
||||
FROM base as dependencies_and_build
|
||||
|
||||
# Dependencies
|
||||
FROM base as dependencies
|
||||
COPY package*.json tsconfig.json index.js ./
|
||||
COPY lib ./lib
|
||||
|
||||
COPY npm-shrinkwrap.json ./
|
||||
|
||||
RUN apk add --no-cache --virtual .buildtools make gcc g++ python linux-headers git && \
|
||||
npm ci --production && \
|
||||
RUN apk add --no-cache --virtual .buildtools make gcc g++ python3 linux-headers git && \
|
||||
npm ci --production --no-audit --no-optional --no-update-notifier && \
|
||||
apk del .buildtools
|
||||
|
||||
# Release
|
||||
FROM base as release
|
||||
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY lib ./lib
|
||||
COPY index.js data/configuration.yaml ./
|
||||
COPY --from=dependencies_and_build /app/node_modules ./node_modules
|
||||
COPY dist ./dist
|
||||
COPY package.json LICENSE index.js data/configuration.yaml ./
|
||||
|
||||
COPY docker/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
@@ -27,7 +26,7 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
RUN mkdir /app/data
|
||||
|
||||
ARG COMMIT
|
||||
RUN echo "{\"hash\": \"$COMMIT\"}" > .hash.json
|
||||
RUN echo "$COMMIT" > dist/.hash
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["npm", "start"]
|
||||
CMD [ "/sbin/tini", "--", "node", "index.js"]
|
||||
@@ -1 +0,0 @@
|
||||
qemu-aarch64-static and qemu-arm-static are taken from https://github.com/resin-io/qemu/releases/tag/v2.9.0%2Bresin1
|
||||
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 10 KiB |
@@ -1,38 +1,129 @@
|
||||
require('core-js/features/object/from-entries');
|
||||
require('core-js/features/array/flat');
|
||||
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 rimraf = require('rimraf');
|
||||
require('source-map-support').install();
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Validate settings
|
||||
const settings = require('./lib/util/settings');
|
||||
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}`);
|
||||
}
|
||||
console.log(`\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/information/configuration.html`);
|
||||
console.log(`\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const Controller = require('./lib/controller');
|
||||
const controller = new Controller();
|
||||
controller.start();
|
||||
|
||||
process.on('SIGINT', handleQuit);
|
||||
process.on('SIGTERM', handleQuit);
|
||||
|
||||
let controller;
|
||||
let stopping = false;
|
||||
|
||||
function handleQuit() {
|
||||
if (!stopping) {
|
||||
stopping = true;
|
||||
controller.stop();
|
||||
const hashFile = path.join(__dirname, 'dist', '.hash');
|
||||
|
||||
async function restart() {
|
||||
await stop(true);
|
||||
await start();
|
||||
}
|
||||
|
||||
async function exit(code, restart) {
|
||||
if (!restart) {
|
||||
process.exit(code);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function writeHash() {
|
||||
const hash = await currentHash();
|
||||
fs.writeFileSync(hashFile, hash);
|
||||
}
|
||||
|
||||
async function build(reason) {
|
||||
return new Promise((resolve, reject) => {
|
||||
process.stdout.write(`Building Zigbee2MQTT... (${reason})`);
|
||||
rimraf.sync('dist');
|
||||
const env = {...process.env};
|
||||
const _600mb = 629145600;
|
||||
if (_600mb > os.totalmem() && !env.NODE_OPTIONS) {
|
||||
// Prevent OOM on tsc compile for system with low memory
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/12034
|
||||
env.NODE_OPTIONS = '--max_old_space_size=256';
|
||||
}
|
||||
|
||||
exec('npm run build', {env, cwd: __dirname}, async (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
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');
|
||||
}
|
||||
reject(err);
|
||||
} else {
|
||||
process.stdout.write(', finished\n');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function checkDist() {
|
||||
if (!fs.existsSync(hashFile)) {
|
||||
await build('initial build');
|
||||
}
|
||||
|
||||
const distHash = fs.readFileSync(hashFile, 'utf-8');
|
||||
const hash = await currentHash();
|
||||
if (hash !== 'unknown' && distHash !== hash) {
|
||||
await build('hash changed');
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
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
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
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');
|
||||
controller = new Controller(restart, exit);
|
||||
await controller.start();
|
||||
}
|
||||
|
||||
async function stop(restart) {
|
||||
await controller.stop(restart);
|
||||
}
|
||||
|
||||
async function handleQuit() {
|
||||
if (!stopping && controller) {
|
||||
stopping = true;
|
||||
await stop(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv.length === 3 && process.argv[2] === 'writehash') {
|
||||
writeHash();
|
||||
} else {
|
||||
process.on('SIGINT', handleQuit);
|
||||
process.on('SIGTERM', handleQuit);
|
||||
start();
|
||||
}
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
const MQTT = require('./mqtt');
|
||||
const Zigbee = require('./zigbee');
|
||||
const EventBus = require('./eventBus');
|
||||
const State = require('./state');
|
||||
const logger = require('./util/logger');
|
||||
const settings = require('./util/settings');
|
||||
const objectAssignDeep = require('object-assign-deep');
|
||||
const utils = require('./util/utils');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const fs = require('fs');
|
||||
const data = require('./util/data');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
// Extensions
|
||||
const ExtensionFrontend = require('./extension/frontend');
|
||||
const ExtensionPublish = require('./extension/publish');
|
||||
const ExtensionReceive = require('./extension/receive');
|
||||
const ExtensionNetworkMap = require('./extension/networkMap');
|
||||
const ExtensionSoftReset = require('./extension/legacy/softReset');
|
||||
const ExtensionHomeAssistant = require('./extension/homeassistant');
|
||||
const ExtensionConfigure = require('./extension/configure');
|
||||
const ExtensionDeviceGroupMembership = require('./extension/legacy/deviceGroupMembership');
|
||||
const ExtensionBridgeLegacy = require('./extension/legacy/bridgeLegacy');
|
||||
const ExtensionBridge = require('./extension/bridge');
|
||||
const ExtensionGroups = require('./extension/groups');
|
||||
const ExtensionAvailability = require('./extension/availability');
|
||||
const ExtensionBind = require('./extension/bind');
|
||||
const ExtensionReport = require('./extension/report');
|
||||
const ExtensionOnEvent = require('./extension/onEvent');
|
||||
const ExtensionOTAUpdate = require('./extension/otaUpdate');
|
||||
const ExtensionExternalConverters = require('./extension/externalConverters');
|
||||
|
||||
const AllExtensions = [
|
||||
ExtensionPublish, ExtensionReceive, ExtensionNetworkMap, ExtensionSoftReset, ExtensionHomeAssistant,
|
||||
ExtensionConfigure, ExtensionDeviceGroupMembership, ExtensionBridgeLegacy, ExtensionBridge, ExtensionGroups,
|
||||
ExtensionAvailability, ExtensionBind, ExtensionReport, ExtensionOnEvent, ExtensionOTAUpdate,
|
||||
ExtensionExternalConverters,
|
||||
];
|
||||
|
||||
class Controller {
|
||||
constructor() {
|
||||
this.zigbee = new Zigbee();
|
||||
this.mqtt = new MQTT();
|
||||
this.eventBus = new EventBus();
|
||||
this.state = new State(this.eventBus);
|
||||
|
||||
this.publishEntityState = this.publishEntityState.bind(this);
|
||||
this.enableDisableExtension = this.enableDisableExtension.bind(this);
|
||||
this.onZigbeeAdapterDisconnected = this.onZigbeeAdapterDisconnected.bind(this);
|
||||
|
||||
// Initialize extensions.
|
||||
const args = [this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus];
|
||||
this.extensions = [
|
||||
new ExtensionPublish(...args),
|
||||
new ExtensionReceive(...args),
|
||||
new ExtensionDeviceGroupMembership(...args),
|
||||
new ExtensionConfigure(...args),
|
||||
new ExtensionNetworkMap(...args),
|
||||
new ExtensionGroups(...args),
|
||||
new ExtensionBind(...args),
|
||||
new ExtensionOnEvent(...args),
|
||||
new ExtensionOTAUpdate(...args),
|
||||
new ExtensionReport(...args),
|
||||
];
|
||||
|
||||
if (settings.get().experimental.new_api) {
|
||||
this.extensions.push(new ExtensionBridge(...args, this.enableDisableExtension));
|
||||
}
|
||||
|
||||
if (settings.get().frontend) {
|
||||
this.extensions.push(new ExtensionFrontend(...args));
|
||||
}
|
||||
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
this.extensions.push(new ExtensionBridgeLegacy(...args));
|
||||
}
|
||||
|
||||
if (settings.get().homeassistant) {
|
||||
this.extensions.push(new ExtensionHomeAssistant(...args));
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (settings.get().advanced.soft_reset_timeout !== 0) {
|
||||
this.extensions.push(new ExtensionSoftReset(...args));
|
||||
}
|
||||
|
||||
if (settings.get().advanced.availability_timeout) {
|
||||
this.extensions.push(new ExtensionAvailability(...args));
|
||||
}
|
||||
if (settings.get().external_converters.length) {
|
||||
this.extensions.push(new ExtensionExternalConverters(...args));
|
||||
}
|
||||
|
||||
const extensionPath = data.joinPath('extension');
|
||||
if (fs.existsSync(extensionPath)) {
|
||||
const extensions = fs.readdirSync(extensionPath).filter((f) => f.endsWith('.js'));
|
||||
for (const extension of extensions) {
|
||||
const Extension = require(path.join(extensionPath, extension.split('.')[0]));
|
||||
this.extensions.push(new Extension(...args, settings, logger));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.state.start();
|
||||
|
||||
const info = await utils.getZigbee2mqttVersion();
|
||||
logger.info(`Starting Zigbee2MQTT version ${info.version} (commit #${info.commitHash})`);
|
||||
|
||||
// Start zigbee
|
||||
try {
|
||||
await this.zigbee.start();
|
||||
this.callExtensionMethod('onZigbeeStarted', []);
|
||||
this.zigbee.on('event', this.onZigbeeEvent.bind(this));
|
||||
this.zigbee.on('adapterDisconnected', this.onZigbeeAdapterDisconnected);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start zigbee');
|
||||
logger.error('Exiting...');
|
||||
logger.error(error.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Log zigbee clients on startup
|
||||
const devices = this.zigbee.getClients();
|
||||
logger.info(`Currently ${devices.length} devices are joined:`);
|
||||
for (const device of devices) {
|
||||
const entity = this.zigbee.resolveEntity(device);
|
||||
const model = entity.definition ?
|
||||
`${entity.definition.model} - ${entity.definition.vendor} ${entity.definition.description}` :
|
||||
'Not supported';
|
||||
logger.info(`${entity.name} (${entity.device.ieeeAddr}): ${model} (${entity.device.type})`);
|
||||
}
|
||||
|
||||
// Enable zigbee join.
|
||||
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.');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.zigbee.permitJoin(settings.get().permit_join);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to set permit join to ${settings.get().permit_join}`);
|
||||
}
|
||||
|
||||
// MQTT
|
||||
this.mqtt.on('message', this.onMQTTMessage.bind(this));
|
||||
await this.mqtt.connect();
|
||||
this.mqtt.publish('bridge/state', 'online', {retain: true, qos: 0});
|
||||
|
||||
// Send all cached states.
|
||||
if (settings.get().advanced.cache_state_send_on_startup && settings.get().advanced.cache_state) {
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
if (this.state.exists(device.ieeeAddr)) {
|
||||
this.publishEntityState(device.ieeeAddr, this.state.get(device.ieeeAddr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add devices which are in database but not in configuration to configuration
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
if (!settings.getDevice(device.ieeeAddr)) {
|
||||
settings.addDevice(device.ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
// Call extensions
|
||||
await this.callExtensionMethod('onMQTTConnected', []);
|
||||
}
|
||||
|
||||
async enableDisableExtension(enable, name) {
|
||||
if (!enable) {
|
||||
const extension = this.extensions.find((e) => e.constructor.name === name);
|
||||
if (extension) {
|
||||
await this.callExtensionMethod('stop', [], [extension]);
|
||||
this.extensions.splice(this.extensions.indexOf(extension), 1);
|
||||
}
|
||||
} else {
|
||||
const Extension = AllExtensions.find((e) => e.name === name);
|
||||
assert(Extension, `Extension '${name}' does not exist`);
|
||||
const extension = new Extension(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus);
|
||||
this.extensions.push(extension);
|
||||
this.callExtensionMethod('onZigbeeStarted', [], [extension]);
|
||||
this.callExtensionMethod('onMQTTConnected', [], [extension]);
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
// Call extensions
|
||||
await this.callExtensionMethod('stop', []);
|
||||
|
||||
// Wrap-up
|
||||
this.state.stop();
|
||||
await this.mqtt.disconnect();
|
||||
|
||||
try {
|
||||
await this.zigbee.stop();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop zigbee');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async onZigbeeAdapterDisconnected() {
|
||||
logger.error('Adapter disconnected, stopping');
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
async onZigbeeEvent(type, data) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(data.device || data.ieeeAddr);
|
||||
if (data.device && !settings.getDevice(data.device.ieeeAddr) && data.device.type !== 'Coordinator') {
|
||||
// Only deviceLeave doesn't have a device (not interesting to add to settings)
|
||||
resolvedEntity.settings = {...settings.get().device_options, ...settings.addDevice(data.device.ieeeAddr)};
|
||||
}
|
||||
|
||||
const name = resolvedEntity && resolvedEntity.settings ? resolvedEntity.settings.friendlyName : null;
|
||||
|
||||
if (type === 'message') {
|
||||
logger.debug(
|
||||
`Received Zigbee message from '${name}', type '${data.type}', cluster '${data.cluster}'` +
|
||||
`, data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}` +
|
||||
(data.hasOwnProperty('groupID') ? ` with groupID ${data.groupID}` : ``),
|
||||
);
|
||||
} else if (type === 'deviceJoined') {
|
||||
logger.info(`Device '${name}' joined`);
|
||||
} else if (type === 'deviceInterview') {
|
||||
if (data.status === 'successful') {
|
||||
logger.info(`Successfully interviewed '${name}', device has successfully been paired`);
|
||||
|
||||
if (resolvedEntity.definition) {
|
||||
const {vendor, description, model} = resolvedEntity.definition;
|
||||
logger.info(`Device '${name}' is supported, identified as: ${vendor} ${description} (${model})`);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Device '${name}' with Zigbee model '${data.device.modelID}' is NOT supported, ` +
|
||||
`please follow https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html`,
|
||||
);
|
||||
}
|
||||
} else if (data.status === 'failed') {
|
||||
logger.error(`Failed to interview '${name}', device has not successfully been paired`);
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (data.status === 'started') {
|
||||
logger.info(`Starting interview of '${name}'`);
|
||||
}
|
||||
}
|
||||
} else if (type === 'deviceAnnounce') {
|
||||
logger.debug(`Device '${name}' announced itself`);
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (type === 'deviceLeave') {
|
||||
logger.warn(`Device '${name || data.ieeeAddr}' left the network`);
|
||||
}
|
||||
}
|
||||
|
||||
// Call extensions
|
||||
this.callExtensionMethod('onZigbeeEvent', [type, data, resolvedEntity]);
|
||||
}
|
||||
|
||||
onMQTTMessage(payload) {
|
||||
const {topic, message} = payload;
|
||||
logger.debug(`Received MQTT message on '${topic}' with data '${message}'`);
|
||||
|
||||
// Call extensions
|
||||
this.callExtensionMethod('onMQTTMessage', [topic, message]);
|
||||
}
|
||||
|
||||
async publishEntityState(IDorName, payload, stateChangeReason=null) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(IDorName);
|
||||
if (!resolvedEntity || !resolvedEntity.settings) {
|
||||
logger.error(`'${IDorName}' does not exist, skipping publish`);
|
||||
return;
|
||||
}
|
||||
|
||||
let messagePayload = {...payload};
|
||||
const currentState = this.state.exists(resolvedEntity.settings.ID) ?
|
||||
this.state.get(resolvedEntity.settings.ID) : {};
|
||||
const newState = objectAssignDeep.noMutate(currentState, payload);
|
||||
|
||||
// Update state cache with new state.
|
||||
this.state.set(resolvedEntity.settings.ID, newState, stateChangeReason);
|
||||
|
||||
if (settings.get().advanced.cache_state) {
|
||||
// Add cached state to payload
|
||||
messagePayload = newState;
|
||||
}
|
||||
|
||||
const options = {
|
||||
retain: utils.getObjectProperty(resolvedEntity.settings, 'retain', false),
|
||||
qos: utils.getObjectProperty(resolvedEntity.settings, 'qos', 0),
|
||||
};
|
||||
|
||||
const retention = utils.getObjectProperty(resolvedEntity.settings, 'retention', false);
|
||||
if (retention !== false) {
|
||||
options.properties = {messageExpiryInterval: retention};
|
||||
}
|
||||
|
||||
const isDevice = resolvedEntity.type === 'device';
|
||||
if (isDevice && settings.get().mqtt.include_device_information) {
|
||||
const attributes = [
|
||||
'ieeeAddr', 'networkAddress', 'type', 'manufacturerID', 'manufacturerName', 'powerSource',
|
||||
'applicationVersion', 'stackVersion', 'zclVersion', 'hardwareVersion', 'dateCode', 'softwareBuildID',
|
||||
];
|
||||
|
||||
messagePayload.device = {
|
||||
friendlyName: resolvedEntity.name,
|
||||
model: resolvedEntity.definition ? resolvedEntity.definition.model : 'unknown',
|
||||
};
|
||||
|
||||
attributes.forEach((a) => messagePayload.device[a] = resolvedEntity.device[a]);
|
||||
}
|
||||
|
||||
const lastSeen = settings.get().advanced.last_seen;
|
||||
if (isDevice && lastSeen !== 'disable' && resolvedEntity.device.lastSeen) {
|
||||
messagePayload.last_seen = utils.formatDate(resolvedEntity.device.lastSeen, lastSeen);
|
||||
}
|
||||
|
||||
// filter mqtt message attributes
|
||||
if (resolvedEntity.settings.filtered_attributes) {
|
||||
resolvedEntity.settings.filtered_attributes.forEach((a) => delete messagePayload[a]);
|
||||
}
|
||||
|
||||
if (Object.entries(messagePayload).length) {
|
||||
const output = settings.get().experimental.output;
|
||||
if (output === 'attribute_and_json' || output === 'json') {
|
||||
await this.mqtt.publish(resolvedEntity.name, stringify(messagePayload), options);
|
||||
}
|
||||
|
||||
if (output === 'attribute_and_json' || output === 'attribute') {
|
||||
await this.iteratePayloadAttributeOutput(`${resolvedEntity.name}/`, messagePayload, options);
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.emit('publishEntityState', {payload: messagePayload, entity: resolvedEntity, stateChangeReason});
|
||||
}
|
||||
|
||||
async iteratePayloadAttributeOutput(topicRoot, payload, options) {
|
||||
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'])) {
|
||||
subPayload = [subPayload.r, subPayload.g, subPayload.b];
|
||||
}
|
||||
|
||||
// Check Array first, since it is also an Object
|
||||
if (subPayload === null || subPayload === undefined) {
|
||||
message = '';
|
||||
} else if (Array.isArray(subPayload)) {
|
||||
message = subPayload.map((x) => `${x}`).join(',');
|
||||
} else if (typeof subPayload === 'object') {
|
||||
this.iteratePayloadAttributeOutput(`${topicRoot}${key}-`, subPayload, options);
|
||||
} else {
|
||||
message = typeof subPayload === 'string' ? subPayload : stringify(subPayload);
|
||||
}
|
||||
|
||||
if (message !== null) {
|
||||
await this.mqtt.publish(`${topicRoot}${key}`, message, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async callExtensionMethod(method, parameters, extensions=null) {
|
||||
for (const extension of extensions || this.extensions) {
|
||||
if (extension[method]) {
|
||||
try {
|
||||
await extension[method](...parameters);
|
||||
} catch (error) {
|
||||
/* istanbul ignore next */
|
||||
logger.error(`Failed to call '${extension.constructor.name}' '${method}' (${error.stack})`);
|
||||
/* istanbul ignore next */
|
||||
if (process.env.JEST_WORKER_ID !== undefined) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Controller;
|
||||
@@ -0,0 +1,324 @@
|
||||
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';
|
||||
|
||||
// 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>];
|
||||
|
||||
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;
|
||||
|
||||
constructor(restartCallback: () => void, exitCallback: (code: number) => void) {
|
||||
logger.init();
|
||||
this.eventBus = new EventBus( /* istanbul ignore next */ (error) => {
|
||||
logger.error(`Error: ${error.message}`);
|
||||
logger.debug(error.stack);
|
||||
});
|
||||
this.zigbee = new Zigbee(this.eventBus);
|
||||
this.mqtt = new MQTT(this.eventBus);
|
||||
this.state = new State(this.eventBus);
|
||||
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.extensions = [
|
||||
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 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);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.state.start();
|
||||
logger.logOutput();
|
||||
|
||||
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);
|
||||
} 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);
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// 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';
|
||||
logger.info(`${device.name} (${device.ieeeAddr}): ${model} (${device.zh.type})`);
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
// MQTT
|
||||
try {
|
||||
await this.mqtt.connect();
|
||||
} catch (error) {
|
||||
logger.error(`MQTT failed to connect: ${error.message}`);
|
||||
logger.error('Exiting...');
|
||||
await this.zigbee.stop();
|
||||
await this.exit(1);
|
||||
}
|
||||
|
||||
// Call extensions
|
||||
await this.callExtensions('start', [...this.extensions]);
|
||||
|
||||
// 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()]) {
|
||||
if (this.state.exists(entity)) {
|
||||
this.publishEntityState(entity, this.state.get(entity), 'publishCached');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.onLastSeenChanged(this,
|
||||
(data) => utils.publishLastSeen(data, settings.get(), false, this.publishEntityState));
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
} 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]);
|
||||
}
|
||||
}
|
||||
|
||||
@bind async addExtension(extension: Extension): Promise<void> {
|
||||
this.extensions.push(extension);
|
||||
await this.callExtensions('start', [extension]);
|
||||
}
|
||||
|
||||
async stop(restart = false): Promise<void> {
|
||||
// Call extensions
|
||||
await this.callExtensions('stop', this.extensions);
|
||||
this.eventBus.removeListeners(this);
|
||||
|
||||
// Wrap-up
|
||||
this.state.stop();
|
||||
await this.mqtt.disconnect();
|
||||
|
||||
try {
|
||||
await this.zigbee.stop();
|
||||
logger.info('Stopped Zigbee2MQTT');
|
||||
await this.exit(0, restart);
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop Zigbee2MQTT');
|
||||
await this.exit(1, restart);
|
||||
}
|
||||
}
|
||||
|
||||
async exit(code: number, restart = false): Promise<void> {
|
||||
await logger.end();
|
||||
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};
|
||||
|
||||
// Update state cache with new state.
|
||||
const newState = this.state.set(entity, payload, stateChangeReason);
|
||||
|
||||
if (settings.get().advanced.cache_state) {
|
||||
// Add cached state to payload
|
||||
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 retention = utils.getObjectProperty(entity.options, 'retention', false);
|
||||
if (retention !== false) {
|
||||
options.properties = {messageExpiryInterval: retention as number};
|
||||
}
|
||||
|
||||
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,
|
||||
manufacturerID: entity.zh.manufacturerID, manufacturerName: entity.zh.manufacturerName,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// Add lastseen
|
||||
const lastSeen = settings.get().advanced.last_seen;
|
||||
if (entity.isDevice() && lastSeen !== 'disable' && entity.zh.lastSeen) {
|
||||
message.last_seen = utils.formatDate(entity.zh.lastSeen, lastSeen);
|
||||
}
|
||||
|
||||
// Add device linkquality.
|
||||
if (entity.isDevice() && entity.zh.linkquality !== undefined) {
|
||||
message.linkquality = entity.zh.linkquality;
|
||||
}
|
||||
|
||||
for (const extension of this.extensions) {
|
||||
extension.adjustMessageBeforePublish?.(entity, message);
|
||||
}
|
||||
|
||||
// Filter mqtt message attributes
|
||||
utils.filterProperties(entity.options.filtered_attributes, message);
|
||||
|
||||
if (Object.entries(message).length) {
|
||||
const output = settings.get().advanced.output;
|
||||
if (output === 'attribute_and_json' || output === 'json') {
|
||||
await this.mqtt.publish(entity.name, stringify(message), options);
|
||||
}
|
||||
|
||||
if (output === 'attribute_and_json' || output === 'attribute') {
|
||||
await this.iteratePayloadAttributeOutput(`${entity.name}/`, message, options);
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.emitPublishEntityState({entity, message, stateChangeReason, payload});
|
||||
}
|
||||
|
||||
async iteratePayloadAttributeOutput(topicRoot: string, payload: KeyValue, options: MQTTOptions): 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'])) {
|
||||
subPayload = [subPayload.r, subPayload.g, subPayload.b];
|
||||
}
|
||||
|
||||
// Check Array first, since it is also an Object
|
||||
if (subPayload === null || subPayload === undefined) {
|
||||
message = '';
|
||||
} else if (Array.isArray(subPayload)) {
|
||||
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);
|
||||
}
|
||||
|
||||
if (message !== null) {
|
||||
await this.mqtt.publish(`${topicRoot}${key}`, message, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1,43 +0,0 @@
|
||||
const events = require('events');
|
||||
const assert = require('assert');
|
||||
|
||||
const allowedEvents = [
|
||||
'deviceRemoved', // Device has been removed
|
||||
'deviceRenamed', // Device has been renamed
|
||||
'groupRenamed', // Group has been renamed
|
||||
'publishEntityState', // Entity state will be published
|
||||
'stateChange', // Entity changes its state
|
||||
'groupMembersChanged', // Members of a group has been changed
|
||||
'reportingDisabled', // Reporting is disabled for a device
|
||||
'devicesChanged', // Devices changed
|
||||
];
|
||||
|
||||
class EventBus extends events.EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.callbackByExtension = {};
|
||||
}
|
||||
|
||||
emit(event, data) {
|
||||
assert(allowedEvents.includes(event), `Event '${event}' not supported`);
|
||||
super.emit(event, data);
|
||||
}
|
||||
|
||||
on(event, callback, extension=null) {
|
||||
assert(allowedEvents.includes(event), `Event '${event}' not supported`);
|
||||
if (extension) {
|
||||
if (!this.callbackByExtension[extension]) this.callbackByExtension[extension] = [];
|
||||
this.callbackByExtension[extension].push({event, callback});
|
||||
}
|
||||
|
||||
super.on(event, callback);
|
||||
}
|
||||
|
||||
removeListenersExtension(extension) {
|
||||
for (const entry of this.callbackByExtension[extension] || []) {
|
||||
super.removeListener(entry.event, entry.callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EventBus;
|
||||
@@ -0,0 +1,174 @@
|
||||
import events from 'events';
|
||||
events.captureRejections = true;
|
||||
|
||||
// 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();
|
||||
|
||||
constructor(onError: (error: Error) => void) {
|
||||
this.emitter.setMaxListeners(100);
|
||||
this.emitter.on('error', onError);
|
||||
}
|
||||
|
||||
public emitAdapterDisconnected(): void {
|
||||
this.emitter.emit('adapterDisconnected');
|
||||
}
|
||||
public onAdapterDisconnected(key: ListenerKey, callback: () => void): void {
|
||||
this.on('adapterDisconnected', callback, key);
|
||||
}
|
||||
|
||||
public emitPermitJoinChanged(data: eventdata.PermitJoinChanged): void {
|
||||
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);
|
||||
}
|
||||
|
||||
public emitEntityRenamed(data: eventdata.EntityRenamed): void {
|
||||
this.emitter.emit('deviceRenamed', data);
|
||||
}
|
||||
public onEntityRenamed(key: ListenerKey, callback: (data: eventdata.EntityRenamed) => void): void {
|
||||
this.on('deviceRenamed', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceRemoved(data: eventdata.DeviceRemoved): void {
|
||||
this.emitter.emit('deviceRemoved', data);
|
||||
}
|
||||
public onDeviceRemoved(key: ListenerKey, callback: (data: eventdata.DeviceRemoved) => void): void {
|
||||
this.on('deviceRemoved', callback, key);
|
||||
}
|
||||
|
||||
public emitLastSeenChanged(data: eventdata.LastSeenChanged): void {
|
||||
this.emitter.emit('lastSeenChanged', data);
|
||||
}
|
||||
public onLastSeenChanged(key: ListenerKey, callback: (data: eventdata.LastSeenChanged) => void): void {
|
||||
this.on('lastSeenChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceNetworkAddressChanged(data: eventdata.DeviceNetworkAddressChanged): void {
|
||||
this.emitter.emit('deviceNetworkAddressChanged', data);
|
||||
}
|
||||
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);
|
||||
}
|
||||
public onDeviceAnnounce(key: ListenerKey, callback: (data: eventdata.DeviceAnnounce) => void): void {
|
||||
this.on('deviceAnnounce', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceInterview(data: eventdata.DeviceInterview): void {
|
||||
this.emitter.emit('deviceInterview', data);
|
||||
}
|
||||
public onDeviceInterview(key: ListenerKey, callback: (data: eventdata.DeviceInterview) => void): void {
|
||||
this.on('deviceInterview', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceJoined(data: eventdata.DeviceJoined): void {
|
||||
this.emitter.emit('deviceJoined', data);
|
||||
}
|
||||
public onDeviceJoined(key: ListenerKey, callback: (data: eventdata.DeviceJoined) => void): void {
|
||||
this.on('deviceJoined', callback, key);
|
||||
}
|
||||
|
||||
public emitEntityOptionsChanged(data: eventdata.EntityOptionsChanged): void {
|
||||
this.emitter.emit('entityOptionsChanged', data);
|
||||
}
|
||||
public onEntityOptionsChanged(key: ListenerKey, callback: (data: eventdata.EntityOptionsChanged) => void): void {
|
||||
this.on('entityOptionsChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceLeave(data: eventdata.DeviceLeave): void {
|
||||
this.emitter.emit('deviceLeave', data);
|
||||
}
|
||||
public onDeviceLeave(key: ListenerKey, callback: (data: eventdata.DeviceLeave) => void): void {
|
||||
this.on('deviceLeave', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceMessage(data: eventdata.DeviceMessage): void {
|
||||
this.emitter.emit('deviceMessage', data);
|
||||
}
|
||||
public onDeviceMessage(key: ListenerKey, callback: (data: eventdata.DeviceMessage) => void): void {
|
||||
this.on('deviceMessage', callback, key);
|
||||
}
|
||||
|
||||
public emitMQTTMessage(data: eventdata.MQTTMessage): void {
|
||||
this.emitter.emit('mqttMessage', data);
|
||||
}
|
||||
public onMQTTMessage(key: ListenerKey, callback: (data: eventdata.MQTTMessage) => void): void {
|
||||
this.on('mqttMessage', callback, key);
|
||||
}
|
||||
|
||||
public emitMQTTMessagePublished(data: eventdata.MQTTMessagePublished): void {
|
||||
this.emitter.emit('mqttMessagePublished', data);
|
||||
}
|
||||
public onMQTTMessagePublished(key: ListenerKey, callback: (data: eventdata.MQTTMessagePublished) => void): void {
|
||||
this.on('mqttMessagePublished', callback, key);
|
||||
}
|
||||
|
||||
public emitPublishEntityState(data: eventdata.PublishEntityState): void {
|
||||
this.emitter.emit('publishEntityState', data);
|
||||
}
|
||||
public onPublishEntityState(key: ListenerKey, callback: (data: eventdata.PublishEntityState) => void): void {
|
||||
this.on('publishEntityState', callback, key);
|
||||
}
|
||||
|
||||
public emitGroupMembersChanged(data: eventdata.GroupMembersChanged): void {
|
||||
this.emitter.emit('groupMembersChanged', data);
|
||||
}
|
||||
public onGroupMembersChanged(key: ListenerKey, callback: (data: eventdata.GroupMembersChanged) => void): void {
|
||||
this.on('groupMembersChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitDevicesChanged(): void {
|
||||
this.emitter.emit('devicesChanged');
|
||||
}
|
||||
public onDevicesChanged(key: ListenerKey, callback: () => void): void {
|
||||
this.on('devicesChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitScenesChanged(): void {
|
||||
this.emitter.emit('scenesChanged');
|
||||
}
|
||||
public onScenesChanged(key: ListenerKey, callback: () => void): void {
|
||||
this.on('scenesChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitReconfigure(data: eventdata.Reconfigure): void {
|
||||
this.emitter.emit('reconfigure', data);
|
||||
}
|
||||
public onReconfigure(key: ListenerKey, callback: (data: eventdata.Reconfigure) => void): void {
|
||||
this.on('reconfigure', callback, key);
|
||||
}
|
||||
|
||||
public emitStateChange(data: eventdata.StateChange): void {
|
||||
this.emitter.emit('stateChange', data);
|
||||
}
|
||||
public onStateChange(key: ListenerKey, callback: (data: eventdata.StateChange) => void): void {
|
||||
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 removeListeners(key: ListenerKey): void {
|
||||
this.callbacksByExtension[key.constructor.name]?.forEach(
|
||||
(e) => this.emitter.removeListener(e.event, e.callback));
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
const logger = require('../util/logger');
|
||||
const settings = require('../util/settings');
|
||||
const utils = require('../util/utils');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const Extension = require('./extension');
|
||||
|
||||
// Pingable end devices, some end devices should be pinged
|
||||
// e.g. E11-G13 https://github.com/Koenkk/zigbee2mqtt/issues/775#issuecomment-453683846
|
||||
const pingableEndDevices = [
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'E11-G13'),
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === '53170161'),
|
||||
];
|
||||
|
||||
const Hours25 = 1000 * 60 * 60 * 25;
|
||||
|
||||
/**
|
||||
* This extensions pings devices to check if they are online.
|
||||
*/
|
||||
class Availability extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
|
||||
this.availability_timeout = settings.get().advanced.availability_timeout;
|
||||
this.timers = {};
|
||||
this.state = {};
|
||||
|
||||
this.eventBus.on('deviceRemoved', (data) => this.onDeviceRemoved(data.resolvedEntity), this.constructor.name);
|
||||
|
||||
this.blocklist = settings.get().advanced.availability_blocklist
|
||||
.concat(settings.get().advanced.availability_blacklist)
|
||||
.map((e) => settings.getEntity(e).ID);
|
||||
|
||||
this.passlist = settings.get().advanced.availability_passlist
|
||||
.concat(settings.get().advanced.availability_whitelist)
|
||||
.map((e) => settings.getEntity(e).ID);
|
||||
}
|
||||
|
||||
onDeviceRemoved(resolvedEntity) {
|
||||
this.mqtt.publish(`${resolvedEntity.name}/availability`, null, {retain: true, qos: 0});
|
||||
delete this.state[resolvedEntity.device.ieeeAddr];
|
||||
clearTimeout(this.timers[resolvedEntity.device.ieeeAddr]);
|
||||
}
|
||||
|
||||
inPasslistOrNotInBlocklist(device) {
|
||||
const ieeeAddr = device.ieeeAddr;
|
||||
const deviceSettings = settings.getDevice(ieeeAddr);
|
||||
const name = deviceSettings.friendlyName;
|
||||
|
||||
// Passlist is not empty and device is in it, enable availability
|
||||
if (this.passlist.length > 0) {
|
||||
return this.passlist.includes(ieeeAddr) || (name && this.passlist.includes(name));
|
||||
}
|
||||
|
||||
// Device is on blocklist, disable availability
|
||||
if (this.blocklist.includes(ieeeAddr) || (name && this.blocklist.includes(name))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
isPingable(device) {
|
||||
if (pingableEndDevices.find((d) => d.hasOwnProperty('zigbeeModel') && d.zigbeeModel.includes(device.modelID))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Device is a mains powered router
|
||||
return device.type === 'Router' && device.powerSource !== 'Battery';
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
// Mark all devices as online on start
|
||||
const ieeeAddr = device.ieeeAddr;
|
||||
this.publishAvailability(device, this.state.hasOwnProperty(ieeeAddr) ? this.state[ieeeAddr] : true, true);
|
||||
|
||||
if (this.inPasslistOrNotInBlocklist(device)) {
|
||||
if (this.isPingable(device)) {
|
||||
this.setTimerPingable(device);
|
||||
} else {
|
||||
this.timers[ieeeAddr] = setInterval(() => {
|
||||
this.handleIntervalNotPingable(device);
|
||||
}, utils.secondsToMilliseconds(300));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleIntervalPingable(device) {
|
||||
// When a device is already unavailable, log the ping failed on 'debug' instead of 'error'.
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device.ieeeAddr);
|
||||
if (!resolvedEntity) {
|
||||
logger.debug(`Stop pinging '${device.ieeeAddr}', device is not known anymore`);
|
||||
return;
|
||||
}
|
||||
|
||||
const level = this.state.hasOwnProperty(device.ieeeAddr) && !this.state[device.ieeeAddr] ? 'debug' : 'error';
|
||||
try {
|
||||
await device.ping();
|
||||
this.publishAvailability(device, true);
|
||||
logger.debug(`Successfully pinged '${resolvedEntity.name}'`);
|
||||
} catch (error) {
|
||||
this.publishAvailability(device, false);
|
||||
logger[level](`Failed to ping '${resolvedEntity.name}'`);
|
||||
} finally {
|
||||
this.setTimerPingable(device);
|
||||
}
|
||||
}
|
||||
|
||||
async handleIntervalNotPingable(device) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device.ieeeAddr);
|
||||
if (!resolvedEntity || !device.lastSeen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ago = Date.now() - resolvedEntity.device.lastSeen;
|
||||
logger.debug(`Non-pingable device '${resolvedEntity.name}' was last seen '${ago / 1000}' seconds ago.`);
|
||||
|
||||
if (ago > Hours25) {
|
||||
this.publishAvailability(device, false);
|
||||
}
|
||||
}
|
||||
|
||||
setTimerPingable(device) {
|
||||
clearTimeout(this.timers[device.ieeeAddr]);
|
||||
this.timers[device.ieeeAddr] = setTimeout(async () => {
|
||||
await this.handleIntervalPingable(device);
|
||||
}, utils.secondsToMilliseconds(this.availability_timeout));
|
||||
}
|
||||
|
||||
async stop() {
|
||||
super.stop();
|
||||
for (const timer of Object.values(this.timers)) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
this.zigbee.getClients().forEach((device) => this.publishAvailability(device, false));
|
||||
}
|
||||
|
||||
async onReconnect(device) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
if (resolvedEntity && resolvedEntity.definition) {
|
||||
const used = [];
|
||||
try {
|
||||
for (const key of ['state', 'brightness', 'color', 'color_temp']) {
|
||||
const converter = resolvedEntity.definition.toZigbee.find((tz) => tz.key.includes(key));
|
||||
if (converter && !used.includes(converter)) {
|
||||
await converter.convertGet(device.endpoints[0], key, {});
|
||||
used.push(converter);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read state of '${resolvedEntity.name}' after reconnect`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publishAvailability(device, available, force=false) {
|
||||
const ieeeAddr = device.ieeeAddr;
|
||||
if (this.state.hasOwnProperty(ieeeAddr) && !this.state[ieeeAddr] && available) {
|
||||
this.onReconnect(device);
|
||||
}
|
||||
|
||||
const deviceSettings = settings.getDevice(ieeeAddr);
|
||||
const name = deviceSettings ? deviceSettings.friendlyName : ieeeAddr;
|
||||
const topic = `${name}/availability`;
|
||||
const payload = available ? 'online' : 'offline';
|
||||
if (this.state[ieeeAddr] !== available || force) {
|
||||
this.state[ieeeAddr] = available;
|
||||
this.mqtt.publish(topic, payload, {retain: true, qos: 0});
|
||||
}
|
||||
}
|
||||
|
||||
onZigbeeEvent(type, data, resolvedEntity) {
|
||||
const device = data.device;
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.inPasslistOrNotInBlocklist(device)) {
|
||||
this.publishAvailability(data.device, true);
|
||||
|
||||
if (this.isPingable(device)) {
|
||||
// When a zigbee message from a device is received we know the device is still alive.
|
||||
// => reset the timer.
|
||||
this.setTimerPingable(device);
|
||||
|
||||
const online = this.state.hasOwnProperty(device.ieeeAddr) && this.state[device.ieeeAddr];
|
||||
if (online && type === 'deviceAnnounce' && !utils.isIkeaTradfriDevice(device)) {
|
||||
/**
|
||||
* In case the device is powered off AND on within the availability timeout,
|
||||
* zigbee2qmtt does not detect the device as offline (device is still marked online).
|
||||
* When a device is turned on again the state could be out of sync.
|
||||
* https://github.com/Koenkk/zigbee2mqtt/issues/1383#issuecomment-489412168
|
||||
* deviceAnnounce is typically send when a device comes online.
|
||||
*
|
||||
* This isn't needed for TRADFRI devices as they already send the state themself.
|
||||
*/
|
||||
this.onReconnect(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Availability;
|
||||
@@ -0,0 +1,214 @@
|
||||
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';
|
||||
|
||||
const retrieveOnReconnect = [
|
||||
{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} = {};
|
||||
private pingQueue: Device[] = [];
|
||||
private pingQueueExecuting = false;
|
||||
|
||||
private getTimeout(device: Device): number {
|
||||
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);
|
||||
}
|
||||
|
||||
private isActiveDevice(device: Device): boolean {
|
||||
return (device.zh.type === 'Router' && device.zh.powerSource !== 'Battery') ||
|
||||
device.zh.powerSource === 'Mains (single phase)';
|
||||
}
|
||||
|
||||
private isAvailable(entity: Device | Group): boolean {
|
||||
if (entity.isDevice()) {
|
||||
const ago = Date.now() - entity.zh.lastSeen;
|
||||
return ago < this.getTimeout(entity);
|
||||
} else {
|
||||
return entity.membersDevices().length === 0 ||
|
||||
entity.membersDevices().map((d) => this.availabilityCache[d.ieeeAddr]).includes(true);
|
||||
}
|
||||
}
|
||||
|
||||
private resetTimer(device: Device): void {
|
||||
clearTimeout(this.timers[device.ieeeAddr]);
|
||||
|
||||
// If the timer triggers, the device is not avaiable 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 addToPingQueue(device: Device): void {
|
||||
this.pingQueue.push(device);
|
||||
this.pingQueueExecuteNext();
|
||||
}
|
||||
|
||||
private removeFromPingQueue(device: Device): void {
|
||||
const index = this.pingQueue.findIndex((d) => d.ieeeAddr === device.ieeeAddr);
|
||||
index != -1 && this.pingQueue.splice(index, 1);
|
||||
}
|
||||
|
||||
private async pingQueueExecuteNext(): Promise<void> {
|
||||
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);
|
||||
const attempts = available ? 2 : 1;
|
||||
for (let i = 0; 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(`Succesfully pinged '${device.name}' (attempt ${i + 1}/${attempts})`);
|
||||
break;
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to ping '${device.name}' (attempt ${i + 1}/${attempts}, ${error.message})`);
|
||||
// Try again in 3 seconds.
|
||||
const lastAttempt = i - 1 === attempts;
|
||||
!lastAttempt && await utils.sleep(3);
|
||||
}
|
||||
}
|
||||
|
||||
this.publishAvailability(device, !pingedSuccessfully);
|
||||
this.resetTimer(device);
|
||||
this.removeFromPingQueue(device);
|
||||
|
||||
// Sleep 2 seconds before executing next ping
|
||||
await utils.sleep(2);
|
||||
this.pingQueueExecuting = false;
|
||||
this.pingQueueExecuteNext();
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onEntityRenamed(this, (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);
|
||||
}
|
||||
});
|
||||
|
||||
this.eventBus.onDeviceRemoved(this, (data) => clearTimeout(this.timers[data.ieeeAddr]));
|
||||
this.eventBus.onDeviceLeave(this, (data) => clearTimeout(this.timers[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);
|
||||
|
||||
if (entity.isDevice()) {
|
||||
this.resetTimer(entity);
|
||||
|
||||
// If an active device is initially unavailable, ping it.
|
||||
if (this.isActiveDevice(entity) && !this.isAvailable(entity)) {
|
||||
this.addToPingQueue(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private publishAvailability(entity: Device | Group, logLastSeen: boolean,
|
||||
forcePublish=false, skipGroups=false): void {
|
||||
if (logLastSeen && entity.isDevice()) {
|
||||
const ago = Date.now() - entity.zh.lastSeen;
|
||||
if (this.isActiveDevice(entity)) {
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
|
||||
const available = this.isAvailable(entity);
|
||||
if (!forcePublish && this.availabilityCache[entity.ID] == available) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entity.isDevice() && entity.ieeeAddr in this.availabilityCache && available &&
|
||||
this.availabilityCache[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});
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@bind private onLastSeenChanged(data: eventdata.LastSeenChanged): 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);
|
||||
}
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
Object.values(this.timers).forEach((t) => clearTimeout(t));
|
||||
super.stop();
|
||||
}
|
||||
|
||||
private retrieveState(device: Device): void {
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
this.retrieveStateDebouncers[device.ieeeAddr]?.();
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const utils = require('../util/utils');
|
||||
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 Extension = require('./extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
const clusterCandidates = ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl', 'closuresWindowCovering'];
|
||||
|
||||
// See zigbee-herdsman-converters devices.js
|
||||
const defaultBindGroup = {type: 'group_number', ID: 901};
|
||||
|
||||
class Bind extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.legacyApi = settings.get().advanced.legacy_api;
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
/* istanbul ignore else */
|
||||
if (this.legacyApi) {
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/bind/#`);
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/unbind/#`);
|
||||
}
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().experimental.new_api) {
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/device/bind`);
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/device/unbind`);
|
||||
}
|
||||
}
|
||||
|
||||
parseMQTTMessage(topic, message) {
|
||||
let type = null;
|
||||
let sourceKey = null;
|
||||
let targetKey = null;
|
||||
let clusters = null;
|
||||
|
||||
if (this.legacyApi && topic.match(legacyTopicRegex)) {
|
||||
topic = topic.replace(`${settings.get().mqtt.base_topic}/bridge/`, '');
|
||||
type = topic.split('/')[0];
|
||||
sourceKey = topic.replace(`${type}/`, '');
|
||||
targetKey = message;
|
||||
} else if (settings.get().experimental.new_api && topic.match(topicRegex)) {
|
||||
type = topic.endsWith('unbind') ? 'unbind' : 'bind';
|
||||
message = JSON.parse(message);
|
||||
sourceKey = message.from;
|
||||
targetKey = message.to;
|
||||
clusters = message.clusters;
|
||||
}
|
||||
|
||||
return {type, sourceKey, targetKey, clusters};
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
const {type, sourceKey, targetKey, clusters} = this.parseMQTTMessage(topic, message);
|
||||
if (!type) return null;
|
||||
|
||||
let error = null;
|
||||
const source = this.zigbee.resolveEntity(sourceKey);
|
||||
const target = targetKey === 'default_bind_group' ? defaultBindGroup : this.zigbee.resolveEntity(targetKey);
|
||||
const responseData = {from: sourceKey, to: targetKey};
|
||||
|
||||
if (!source || source.type !== 'device') {
|
||||
error = `Source device '${sourceKey}' does not exist`;
|
||||
} else if (!target) {
|
||||
error = `Target device or group '${targetKey}' does not exist`;
|
||||
} else {
|
||||
const sourceName = source.settings.friendlyName;
|
||||
const targetName = targetKey === 'default_bind_group' ? targetKey : target.settings.friendlyName;
|
||||
const successfulClusters = [];
|
||||
const failedClusters = [];
|
||||
const attemptedClusters = [];
|
||||
|
||||
// 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;
|
||||
|
||||
const targetValid = target.type === 'group' || target.type === 'group_number' ||
|
||||
target.device.type === 'Coordinator' || target.endpoint.supportsInputCluster(cluster);
|
||||
|
||||
if (source.endpoint.supportsOutputCluster(cluster) && targetValid) {
|
||||
logger.debug(`${type}ing cluster '${cluster}' from '${sourceName}' to '${targetName}'`);
|
||||
attemptedClusters.push(cluster);
|
||||
|
||||
try {
|
||||
let bindTarget = null;
|
||||
if (target.type === 'group') bindTarget = target.group;
|
||||
else if (target.type === 'group_number') bindTarget = target.ID;
|
||||
else bindTarget = target.endpoint;
|
||||
|
||||
if (type === 'bind') {
|
||||
await source.endpoint.bind(cluster, bindTarget);
|
||||
} else {
|
||||
await source.endpoint.unbind(cluster, bindTarget);
|
||||
}
|
||||
|
||||
successfulClusters.push(cluster);
|
||||
logger.info(
|
||||
`Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from ` +
|
||||
`'${sourceName}' to '${targetName}'`,
|
||||
);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_${type}`,
|
||||
message: {from: sourceName, to: targetName, cluster}}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
failedClusters.push(cluster);
|
||||
logger.error(
|
||||
`Failed to ${type} cluster '${cluster}' from '${sourceName}' to ` +
|
||||
`'${targetName}' (${error})`,
|
||||
);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_${type}_failed`,
|
||||
message: {from: sourceName, to: targetName, cluster}}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attemptedClusters.length === 0) {
|
||||
logger.error(`Nothing to ${type} from '${sourceName}' to '${targetName}'`);
|
||||
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: sourceName, to: targetName}}),
|
||||
);
|
||||
}
|
||||
} else if (failedClusters.length === attemptedClusters.length) {
|
||||
error = `Failed to ${type}`;
|
||||
}
|
||||
|
||||
responseData[`clusters`] = successfulClusters;
|
||||
responseData[`failed`] = failedClusters;
|
||||
}
|
||||
|
||||
const triggeredViaLegacyApi = 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.emit(`devicesChanged`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Bind;
|
||||
@@ -0,0 +1,487 @@
|
||||
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 zigbeeHersdman from 'zigbee-herdsman/dist';
|
||||
import bind from 'bind-decorator';
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
|
||||
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'];
|
||||
|
||||
// See zigbee-herdsman-converters
|
||||
const defaultBindGroup = {type: 'group_number', ID: 901, name: 'default_bind_group'};
|
||||
|
||||
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 value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number;
|
||||
return {
|
||||
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': [
|
||||
{
|
||||
attribute: 'colorTemperature', ...defaultReportConfiguration,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
},
|
||||
{
|
||||
attribute: 'currentX', ...defaultReportConfiguration,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
{
|
||||
attribute: 'currentY', ...defaultReportConfiguration,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
],
|
||||
'closuresWindowCovering': [
|
||||
{attribute: 'currentPositionLiftPercentage', ...defaultReportConfiguration},
|
||||
{attribute: 'currentPositionTiltPercentage', ...defaultReportConfiguration},
|
||||
],
|
||||
};
|
||||
|
||||
type PollOnMessage = {
|
||||
cluster: {[s: string]: {type: string, data: KeyValue}[]}
|
||||
read: {cluster: string, attributes: string[], attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise<string[]>}
|
||||
manufacturerIDs: number[]
|
||||
}[];
|
||||
|
||||
const pollOnMessage: PollOnMessage = [
|
||||
{
|
||||
// On messages that have the cluster and type of below
|
||||
cluster: {
|
||||
manuSpecificPhilips: [
|
||||
{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: {}},
|
||||
],
|
||||
},
|
||||
// Read the following attributes
|
||||
read: {cluster: 'genLevelCtrl', attributes: ['currentLevel']},
|
||||
// When the bound devices/members of group have the following manufacturerIDs
|
||||
manufacturerIDs: [
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.Philips,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.ATMEL,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.MUELLER_LICHT_INT,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.TELINK,
|
||||
],
|
||||
},
|
||||
{
|
||||
cluster: {
|
||||
genLevelCtrl: [
|
||||
{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: {}},
|
||||
],
|
||||
manuSpecificPhilips: [
|
||||
{type: 'commandHueNotification', data: {button: 1}},
|
||||
{type: 'commandHueNotification', data: {button: 4}},
|
||||
],
|
||||
},
|
||||
read: {cluster: 'genOnOff', attributes: ['onOff']},
|
||||
manufacturerIDs: [
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.Philips,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.ATMEL,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.MUELLER_LICHT_INT,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.TELINK,
|
||||
],
|
||||
},
|
||||
{
|
||||
cluster: {
|
||||
genScenes: [
|
||||
{type: 'commandRecall', data: {}},
|
||||
],
|
||||
},
|
||||
read: {
|
||||
cluster: 'lightingColorCtrl',
|
||||
attributes: [] as string[],
|
||||
// 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[]> => {
|
||||
const supportedAttrs = await getColorCapabilities(endpoint);
|
||||
const readAttrs: string[] = [];
|
||||
supportedAttrs.colorXY && readAttrs.push('currentX', 'currentY');
|
||||
supportedAttrs.colorTemperature && readAttrs.push('colorTemperature');
|
||||
return readAttrs;
|
||||
},
|
||||
},
|
||||
manufacturerIDs: [
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.Philips,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.ATMEL,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.MUELLER_LICHT_INT,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.TELINK,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface ParsedMQTTMessage {
|
||||
type: 'bind' | 'unbind', sourceKey: string, targetKey: string, clusters: string[], skipDisableReporting: boolean
|
||||
}
|
||||
|
||||
export default class Bind extends Extension {
|
||||
private pollDebouncers: {[s: string]: () => void} = {};
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return {type, sourceKey, targetKey, clusters, skipDisableReporting};
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
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 (!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 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);
|
||||
|
||||
// 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;
|
||||
|
||||
const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' ||
|
||||
(target as Device).zh.type === 'Coordinator';
|
||||
|
||||
if (!anyClusterValid && utils.isEndpoint(bindTarget)) {
|
||||
matchingClusters = ((bindTarget.supportsInputCluster(cluster) &&
|
||||
bindSource.supportsOutputCluster(cluster)) ||
|
||||
(bindSource.supportsInputCluster(cluster) &&
|
||||
bindTarget.supportsOutputCluster(cluster)) );
|
||||
}
|
||||
|
||||
const sourceValid = bindSource.supportsInputCluster(cluster) ||
|
||||
bindSource.supportsOutputCluster(cluster);
|
||||
|
||||
if ( sourceValid && (anyClusterValid || matchingClusters)) {
|
||||
logger.debug(`${type}ing cluster '${cluster}' from '${source.name}' to '${target.name}'`);
|
||||
attemptedClusters.push(cluster);
|
||||
|
||||
try {
|
||||
if (type === 'bind') {
|
||||
await bindSource.bind(cluster, bindTarget);
|
||||
} else {
|
||||
await bindSource.unbind(cluster, bindTarget);
|
||||
}
|
||||
|
||||
successfulClusters.push(cluster);
|
||||
logger.info(
|
||||
`Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from ` +
|
||||
`'${source.name}' to '${target.name}'`,
|
||||
);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_${type}`,
|
||||
message: {from: source.name, to: target.name, cluster}}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
failedClusters.push(cluster);
|
||||
logger.error(
|
||||
`Failed to ${type} cluster '${cluster}' from '${source.name}' to ` +
|
||||
`'${target.name}' (${error})`,
|
||||
);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_${type}_failed`,
|
||||
message: {from: source.name, to: target.name, cluster}}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attemptedClusters.length === 0) {
|
||||
logger.error(`Nothing to ${type} from '${source.name}' to '${target.name}'`);
|
||||
error = `Nothing to ${type}`;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
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
|
||||
if (!data.skipDisableReporting) {
|
||||
await this.disableUnnecessaryReportings(data.endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSetupReportingEndpoints(bind: zh.Bind, coordinatorEp: zh.Endpoint): zh.Endpoint[] {
|
||||
const endpoints = utils.isEndpoint(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);
|
||||
});
|
||||
}
|
||||
|
||||
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(`Succesfully setup reporting for '${entity}' cluster '${bind.cluster.name}'`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to setup reporting for '${entity}' cluster '${bind.cluster.name}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.emitDevicesChanged();
|
||||
}
|
||||
|
||||
async disableUnnecessaryReportings(target: zh.Group | zh.Endpoint): Promise<void> {
|
||||
const coordinator = this.zigbee.firstCoordinatorEndpoint();
|
||||
const endpoints = utils.isEndpoint(target) ? [target] : target.members;
|
||||
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);
|
||||
|
||||
for (const cluster of boundClusters.filter((c) => !requiredClusters.includes(c) && c in reportClusters)) {
|
||||
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});
|
||||
}
|
||||
}
|
||||
|
||||
await endpoint.configureReporting(cluster, items);
|
||||
logger.info(`Succesfully disabled reporting for '${entity}' cluster '${cluster}'`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to disable reporting for '${entity}' cluster '${cluster}'`);
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.emitReconfigure({device});
|
||||
}
|
||||
}
|
||||
|
||||
@bind async poll(data: eventdata.DeviceMessage): Promise<void> {
|
||||
/**
|
||||
* This method poll bound endpoints and group members for state changes.
|
||||
*
|
||||
* A use case is e.g. a Hue Dimmer switch bound to a Hue bulb.
|
||||
* Hue bulbs only report their on/off state.
|
||||
* 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)));
|
||||
|
||||
if (polls.length) {
|
||||
const toPoll: Set<zh.Endpoint> = new Set();
|
||||
// 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') {
|
||||
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));
|
||||
}
|
||||
|
||||
for (const endpoint of toPoll) {
|
||||
for (const poll of polls) {
|
||||
if (!poll.manufacturerIDs.includes(endpoint.getDevice().manufacturerID) ||
|
||||
!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)}`;
|
||||
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}`);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
this.pollDebouncers[key]();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,512 +0,0 @@
|
||||
const logger = require('../util/logger');
|
||||
const utils = require('../util/utils');
|
||||
const Extension = require('./extension');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const settings = require('../util/settings');
|
||||
const Transport = require('winston-transport');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const objectAssignDeep = require(`object-assign-deep`);
|
||||
|
||||
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
|
||||
|
||||
class Bridge extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.enableDisableExtension = enableDisableExtension;
|
||||
this.lastJoinedDeviceIeeeAddr = null;
|
||||
this.setupMQTTLogging();
|
||||
|
||||
this.requestLookup = {
|
||||
'device/options': this.deviceOptions.bind(this),
|
||||
'device/remove': this.deviceRemove.bind(this),
|
||||
'device/rename': this.deviceRename.bind(this),
|
||||
'group/add': this.groupAdd.bind(this),
|
||||
'group/options': this.groupOptions.bind(this),
|
||||
'group/remove': this.groupRemove.bind(this),
|
||||
'group/rename': this.groupRename.bind(this),
|
||||
'permit_join': this.permitJoin.bind(this),
|
||||
'config/last_seen': this.configLastSeen.bind(this),
|
||||
'config/homeassistant': this.configHomeAssistant.bind(this),
|
||||
'config/elapsed': this.configElapsed.bind(this),
|
||||
'config/log_level': this.configLogLevel.bind(this),
|
||||
'touchlink/factory_reset': this.touchlinkFactoryReset.bind(this),
|
||||
'touchlink/identify': this.touchlinkIdentify.bind(this),
|
||||
'touchlink/scan': this.touchlinkScan.bind(this),
|
||||
'health_check': this.healthCheck.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
async onMQTTConnected() {
|
||||
this.zigbee2mqttVersion = await utils.getZigbee2mqttVersion();
|
||||
this.coordinatorVersion = await this.zigbee.getCoordinatorVersion();
|
||||
|
||||
this.eventBus.on(`groupMembersChanged`, () => this.publishGroups());
|
||||
this.eventBus.on(`devicesChanged`, () => this.publishDevices());
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/#`);
|
||||
await this.publishInfo();
|
||||
await this.publishDevices();
|
||||
await this.publishGroups();
|
||||
}
|
||||
|
||||
setupMQTTLogging() {
|
||||
const mqtt = this.mqtt;
|
||||
class EventTransport extends Transport {
|
||||
log(info, callback) {
|
||||
const payload = stringify({message: info.message, level: info.level});
|
||||
mqtt.publish(`bridge/logging`, payload, {}, settings.get().mqtt.base_topic, true);
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
logger.add(new EventTransport());
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
const match = topic.match(requestRegex);
|
||||
if (match && this.requestLookup[match[1].toLowerCase()]) {
|
||||
message = utils.parseJSON(message, message);
|
||||
|
||||
try {
|
||||
const response = await this.requestLookup[match[1].toLowerCase()](message);
|
||||
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
|
||||
} catch (error) {
|
||||
logger.error(`Request '${topic}' failed with error: '${error.message}'`);
|
||||
const response = utils.getResponse(message, {}, error.message);
|
||||
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onZigbeeEvent(type, data, resolvedEntity) {
|
||||
if (type === 'deviceJoined' && resolvedEntity) {
|
||||
this.lastJoinedDeviceIeeeAddr = resolvedEntity.device.ieeeAddr;
|
||||
}
|
||||
|
||||
if (['deviceJoined', 'deviceLeave', 'deviceInterview', 'deviceAnnounce'].includes(type)) {
|
||||
let payload;
|
||||
const ieeeAddress = data.device ? data.device.ieeeAddr : data.ieeeAddr;
|
||||
if (type === 'deviceJoined') {
|
||||
payload = {friendly_name: resolvedEntity.settings.friendlyName, ieee_address: ieeeAddress};
|
||||
} else if (type === 'deviceInterview') {
|
||||
payload = {
|
||||
friendly_name: resolvedEntity.settings.friendlyName, status: data.status, ieee_address: ieeeAddress,
|
||||
};
|
||||
if (data.status === 'successful') {
|
||||
payload.supported = !!resolvedEntity.definition;
|
||||
payload.definition = resolvedEntity.definition ? {
|
||||
model: resolvedEntity.definition.model,
|
||||
vendor: resolvedEntity.definition.vendor,
|
||||
description: resolvedEntity.definition.description,
|
||||
supports: resolvedEntity.definition.supports,
|
||||
} : null;
|
||||
}
|
||||
} else if (type === 'deviceAnnounce') {
|
||||
payload = {
|
||||
friendly_name: resolvedEntity.settings.friendlyName, ieee_address: ieeeAddress,
|
||||
};
|
||||
} else payload = {ieee_address: ieeeAddress}; // deviceLeave
|
||||
|
||||
await this.mqtt.publish(
|
||||
'bridge/event',
|
||||
stringify({type: utils.toSnakeCase(type), data: payload}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
}
|
||||
|
||||
if ('deviceLeave' === type || ('deviceInterview' === type && data.status !== 'started')) {
|
||||
await this.publishDevices();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests
|
||||
*/
|
||||
|
||||
async deviceOptions(message) {
|
||||
return this.changeEntityOptions('device', message);
|
||||
}
|
||||
|
||||
async groupOptions(message) {
|
||||
return this.changeEntityOptions('group', message);
|
||||
}
|
||||
|
||||
async deviceRemove(message) {
|
||||
return this.removeEntity('device', message);
|
||||
}
|
||||
|
||||
async groupRemove(message) {
|
||||
return this.removeEntity('group', message);
|
||||
}
|
||||
|
||||
async healthCheck(message) {
|
||||
return utils.getResponse(message, {healthy: true}, null);
|
||||
}
|
||||
|
||||
async groupAdd(message) {
|
||||
if (typeof message === 'object' && !message.hasOwnProperty('friendly_name')) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const friendlyName = typeof message === 'object' ? message.friendly_name : message;
|
||||
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : null;
|
||||
const group = settings.addGroup(friendlyName, ID);
|
||||
this.zigbee.createGroup(group.ID);
|
||||
this.publishGroups();
|
||||
return utils.getResponse(message, {friendly_name: group.friendlyName, id: group.ID}, null);
|
||||
}
|
||||
|
||||
async deviceRename(message) {
|
||||
return this.renameEntity('device', message);
|
||||
}
|
||||
|
||||
async groupRename(message) {
|
||||
return this.renameEntity('group', message);
|
||||
}
|
||||
|
||||
async permitJoin(message) {
|
||||
const value = this.getValue(message);
|
||||
await this.zigbee.permitJoin(value);
|
||||
await this.publishInfo();
|
||||
return utils.getResponse(message, {value: value}, null);
|
||||
}
|
||||
|
||||
configLastSeen(message) {
|
||||
const allowed = ['disable', 'ISO_8601', 'epoch', 'ISO_8601_local'];
|
||||
const value = this.getValue(message);
|
||||
if (!allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
settings.set(['advanced', 'last_seen'], message);
|
||||
this.publishInfo();
|
||||
return utils.getResponse(message, {value}, null);
|
||||
}
|
||||
|
||||
configHomeAssistant(message) {
|
||||
const allowed = [true, false];
|
||||
const value = this.getValue(message);
|
||||
if (!allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
this.enableDisableExtension(value, 'HomeAssistant');
|
||||
settings.set(['homeassistant'], value);
|
||||
this.publishInfo();
|
||||
return utils.getResponse(message, {value}, null);
|
||||
}
|
||||
|
||||
configElapsed(message) {
|
||||
const allowed = [true, false];
|
||||
const value = this.getValue(message);
|
||||
if (!allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
settings.set(['advanced', 'elapsed'], value);
|
||||
this.publishInfo();
|
||||
return utils.getResponse(message, {value}, null);
|
||||
}
|
||||
|
||||
configLogLevel(message) {
|
||||
const allowed = ['error', 'warn', 'info', 'debug'];
|
||||
const value = this.getValue(message);
|
||||
if (!allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
logger.setLevel(value);
|
||||
this.publishInfo();
|
||||
return utils.getResponse(message, {value}, null);
|
||||
}
|
||||
|
||||
async touchlinkIdentify(message) {
|
||||
if (typeof message !== 'object' || !message.hasOwnProperty('ieee_address') ||
|
||||
!message.hasOwnProperty('channel')) {
|
||||
throw new Error('Invalid payload');
|
||||
}
|
||||
|
||||
logger.info(`Start Touchlink identify of '${message.ieee_address}' on channel ${message.channel}`);
|
||||
await this.zigbee.touchlinkIdentify(message.ieee_address, message.channel);
|
||||
return utils.getResponse(message, {ieee_address: message.ieee_address, channel: message.channel}, null);
|
||||
}
|
||||
|
||||
async touchlinkFactoryReset(message) {
|
||||
let result = false;
|
||||
const payload = {};
|
||||
if (typeof message === 'object' && message.hasOwnProperty('ieee_address') &&
|
||||
message.hasOwnProperty('channel')) {
|
||||
logger.info(`Start Touchlink factory reset of '${message.ieee_address}' on channel ${message.channel}`);
|
||||
result = await this.zigbee.touchlinkFactoryReset(message.ieee_address, message.channel);
|
||||
payload.ieee_address = message.ieee_address;
|
||||
payload.channel = message.channel;
|
||||
} else {
|
||||
logger.info('Start Touchlink factory reset of first found device');
|
||||
result = await this.zigbee.touchlinkFactoryResetFirst();
|
||||
}
|
||||
|
||||
if (result) {
|
||||
logger.info('Successfully factory reset device through Touchlink');
|
||||
return utils.getResponse(message, payload, null);
|
||||
} else {
|
||||
logger.error('Failed to factory reset device through Touchlink');
|
||||
throw new Error('Failed to factory reset device through Touchlink');
|
||||
}
|
||||
}
|
||||
|
||||
async touchlinkScan(message) {
|
||||
logger.info('Start Touchlink scan');
|
||||
const result = await this.zigbee.touchlinkScan();
|
||||
const found = result.map((r) => {
|
||||
return {ieee_address: r.ieeeAddr, channel: r.channel};
|
||||
});
|
||||
logger.info('Finished Touchlink scan');
|
||||
return utils.getResponse(message, {found}, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utils
|
||||
*/
|
||||
|
||||
getValue(message) {
|
||||
if (typeof message === 'object') {
|
||||
if (!message.hasOwnProperty('value')) {
|
||||
throw new Error('No value given');
|
||||
}
|
||||
|
||||
return message.value;
|
||||
} else {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
changeEntityOptions(entityType, message) {
|
||||
if (typeof message !== 'object' || !message.hasOwnProperty('id') || !message.hasOwnProperty('options')) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const ID = message.id;
|
||||
const entity = this.getEntity(entityType, ID);
|
||||
settings.changeEntityOptions(ID, message.options);
|
||||
const cleanup = (o) => {
|
||||
delete o.friendlyName; delete o.friendly_name; delete o.ID; delete o.type; delete o.devices;
|
||||
return o;
|
||||
};
|
||||
const oldOptions = cleanup(entity.settings);
|
||||
const newOptions = cleanup(settings.getEntity(ID));
|
||||
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID}, null);
|
||||
}
|
||||
|
||||
renameEntity(entityType, message) {
|
||||
const deviceAndHasLast = entityType === 'device' && typeof message === 'object' && message.last === true;
|
||||
if (typeof message !== 'object' || (!message.hasOwnProperty('from') && !deviceAndHasLast) ||
|
||||
!message.hasOwnProperty('to')) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
if (deviceAndHasLast && !this.lastJoinedDeviceIeeeAddr) {
|
||||
throw new Error('No device has joined since start');
|
||||
}
|
||||
|
||||
const validationErrors = utils.validateFriendlyName(message.to);
|
||||
if (validationErrors.length !== 0) {
|
||||
throw new Error(validationErrors[0]);
|
||||
}
|
||||
|
||||
const from = deviceAndHasLast ? this.lastJoinedDeviceIeeeAddr : message.from;
|
||||
const to = message.to;
|
||||
const homeAssisantRename = message.hasOwnProperty('homeassistant_rename') ?
|
||||
message.homeassistant_rename : false;
|
||||
const entity = this.getEntity(entityType, from);
|
||||
|
||||
settings.changeFriendlyName(from, to);
|
||||
|
||||
// Clear retained messages
|
||||
this.mqtt.publish(entity.name, '', {retain: true});
|
||||
|
||||
if (entity.type === 'device') {
|
||||
this.publishDevices();
|
||||
this.eventBus.emit(`deviceRenamed`, {device: entity.device, homeAssisantRename});
|
||||
} else {
|
||||
this.publishGroups();
|
||||
this.eventBus.emit(`groupRenamed`, {group: entity.group, homeAssisantRename});
|
||||
}
|
||||
|
||||
return utils.getResponse(
|
||||
message,
|
||||
{from: entity.settings.friendlyName, to, homeassistant_rename: homeAssisantRename},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
async removeEntity(entityType, message) {
|
||||
const ID = typeof message === 'object' ? message.id : message.trim();
|
||||
const entity = this.getEntity(entityType, ID);
|
||||
|
||||
let block = false;
|
||||
let force = false;
|
||||
let blockForceLog = '';
|
||||
|
||||
if (entityType === 'device' && typeof message === 'object') {
|
||||
block = !!message.block;
|
||||
force = !!message.force;
|
||||
blockForceLog = ` (block: ${block}, force: ${force})`;
|
||||
} else if (entityType === 'group' && typeof message === 'object') {
|
||||
force = !!message.force;
|
||||
blockForceLog = ` (force: ${force})`;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`Removing ${entity.type} '${entity.settings.friendlyName}'${blockForceLog}`);
|
||||
if (entity.type === 'device') {
|
||||
if (block) {
|
||||
settings.blockDevice(entity.settings.ID);
|
||||
}
|
||||
|
||||
if (force) {
|
||||
await entity.device.removeFromDatabase();
|
||||
} else {
|
||||
await entity.device.removeFromNetwork();
|
||||
}
|
||||
} else {
|
||||
if (force) {
|
||||
await entity.group.removeFromDatabase();
|
||||
} else {
|
||||
await entity.group.removeFromNetwork();
|
||||
}
|
||||
}
|
||||
|
||||
// Fire event
|
||||
if (entity.type === 'device') {
|
||||
this.eventBus.emit('deviceRemoved', {resolvedEntity: entity});
|
||||
}
|
||||
|
||||
// Remove from configuration.yaml
|
||||
if (entity.type === 'device') {
|
||||
settings.removeDevice(entity.settings.ID);
|
||||
} else {
|
||||
settings.removeGroup(entity.settings.ID);
|
||||
}
|
||||
|
||||
// Remove from state
|
||||
this.state.remove(entity.settings.ID);
|
||||
|
||||
// Clear any retained messages
|
||||
this.mqtt.publish(entity.name, '', {retain: true});
|
||||
|
||||
logger.info(`Successfully removed ${entity.type} '${entity.settings.friendlyName}'${blockForceLog}`);
|
||||
|
||||
if (entity.type === 'device') {
|
||||
this.publishDevices();
|
||||
return utils.getResponse(message, {id: ID, block, force}, null);
|
||||
} else {
|
||||
this.publishGroups();
|
||||
return utils.getResponse(message, {id: ID, force: force}, null);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to remove ${entity.type} '${entity.settings.friendlyName}'${blockForceLog} (${error})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getEntity(type, ID) {
|
||||
const entity = this.zigbee.resolveEntity(ID);
|
||||
if (!entity || entity.type !== type) {
|
||||
throw new Error(`${utils.capitalize(type)} '${ID}' does not exist`);
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
async publishInfo() {
|
||||
const config = objectAssignDeep.noMutate({}, settings.get());
|
||||
delete config.advanced.network_key;
|
||||
const payload = {
|
||||
version: this.zigbee2mqttVersion.version,
|
||||
commit: this.zigbee2mqttVersion.commitHash,
|
||||
coordinator: this.coordinatorVersion,
|
||||
network: utils.toSnakeCase(await this.zigbee.getNetworkParameters()),
|
||||
log_level: logger.getLevel(),
|
||||
permit_join: await this.zigbee.getPermitJoin(),
|
||||
config,
|
||||
};
|
||||
|
||||
await this.mqtt.publish('bridge/info', stringify(payload), {retain: true, qos: 0});
|
||||
}
|
||||
|
||||
async publishDevices() {
|
||||
const devices = this.zigbee.getDevices().map((device) => {
|
||||
const definition = zigbeeHerdsmanConverters.findByDevice(device);
|
||||
const resolved = this.zigbee.resolveEntity(device);
|
||||
const definitionPayload = definition ? {
|
||||
model: definition.model,
|
||||
vendor: definition.vendor,
|
||||
description: definition.description,
|
||||
supports: definition.supports,
|
||||
} : null;
|
||||
|
||||
const endpoints = {};
|
||||
for (const endpoint of device.endpoints) {
|
||||
const data = {
|
||||
bindings: [],
|
||||
clusters: {
|
||||
input: endpoint.getInputClusters().map((c) => c.name),
|
||||
output: endpoint.getOutputClusters().map((c) => c.name),
|
||||
},
|
||||
};
|
||||
|
||||
for (const bind of endpoint.binds) {
|
||||
let target;
|
||||
|
||||
if (bind.target.constructor.name === 'Endpoint') {
|
||||
target = {
|
||||
type: 'endpoint', ieee_address: bind.target.getDevice().ieeeAddr, endpoint: bind.target.ID,
|
||||
};
|
||||
} else {
|
||||
target = {type: 'group', id: bind.target.groupID};
|
||||
}
|
||||
|
||||
data.bindings.push({cluster: bind.cluster.name, target});
|
||||
}
|
||||
|
||||
endpoints[endpoint.ID] = data;
|
||||
}
|
||||
|
||||
return {
|
||||
ieee_address: device.ieeeAddr,
|
||||
type: device.type,
|
||||
network_address: device.networkAddress,
|
||||
supported: !!definition,
|
||||
friendly_name: resolved.name,
|
||||
definition: definitionPayload,
|
||||
power_source: device.powerSource,
|
||||
software_build_id: device.softwareBuildID,
|
||||
date_code: device.dateCode,
|
||||
interviewing: device.interviewing,
|
||||
interview_completed: device.interviewCompleted,
|
||||
endpoints,
|
||||
};
|
||||
});
|
||||
|
||||
await this.mqtt.publish('bridge/devices', stringify(devices), {retain: true, qos: 0});
|
||||
}
|
||||
|
||||
async publishGroups() {
|
||||
const groups = this.zigbee.getGroups().map((group) => {
|
||||
const resolved = this.zigbee.resolveEntity(group);
|
||||
return {
|
||||
id: group.groupID,
|
||||
friendly_name: resolved.name,
|
||||
members: group.members.map((m) => {
|
||||
return {
|
||||
ieee_address: m.deviceIeeeAddress,
|
||||
endpoint: m.ID,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
await this.mqtt.publish('bridge/groups', stringify(groups), {retain: true, qos: 0});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Bridge;
|
||||
@@ -0,0 +1,719 @@
|
||||
/* eslint-disable camelcase */
|
||||
import logger from '../util/logger';
|
||||
import utils from '../util/utils';
|
||||
import * as settings from '../util/settings';
|
||||
import Transport from 'winston-transport';
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import {detailedDiff} from 'deep-object-diff';
|
||||
import Extension from './extension';
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
import data from '../util/data';
|
||||
import JSZip from 'jszip';
|
||||
import fs from 'fs';
|
||||
|
||||
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
|
||||
|
||||
type Scene = {id: number, name: string};
|
||||
type DefinitionPayload = {
|
||||
model: string, vendor: string, description: string, exposes: zhc.DefinitionExpose[], supports_ota:
|
||||
boolean, icon: string, options: zhc.DefinitionExpose[],
|
||||
};
|
||||
|
||||
export default class Bridge extends Extension {
|
||||
private zigbee2mqttVersion: {commitHash: string, version: string};
|
||||
private coordinatorVersion: zh.CoordinatorVersion;
|
||||
private restartRequired = false;
|
||||
private lastJoinedDeviceIeeeAddr: string;
|
||||
private requestLookup: {[key: string]: (message: KeyValue | string) => Promise<MQTTResponse>};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.requestLookup = {
|
||||
'device/options': this.deviceOptions,
|
||||
'device/configure_reporting': this.deviceConfigureReporting,
|
||||
'device/remove': this.deviceRemove,
|
||||
'device/rename': this.deviceRename,
|
||||
'group/add': this.groupAdd,
|
||||
'group/options': this.groupOptions,
|
||||
'group/remove': this.groupRemove,
|
||||
'group/rename': this.groupRename,
|
||||
'permit_join': this.permitJoin,
|
||||
'restart': this.restart,
|
||||
'backup': this.backup,
|
||||
'touchlink/factory_reset': this.touchlinkFactoryReset,
|
||||
'touchlink/identify': this.touchlinkIdentify,
|
||||
'touchlink/scan': this.touchlinkScan,
|
||||
'health_check': this.healthCheck,
|
||||
'options': this.bridgeOptions,
|
||||
// Below are deprecated
|
||||
'config/last_seen': this.configLastSeen,
|
||||
'config/homeassistant': this.configHomeAssistant,
|
||||
'config/elapsed': this.configElapsed,
|
||||
'config/log_level': this.configLogLevel,
|
||||
};
|
||||
|
||||
const mqtt = this.mqtt;
|
||||
class EventTransport extends Transport {
|
||||
log(info: {message: string, level: string}, callback: () => void): void {
|
||||
const payload = stringify({message: info.message, level: info.level});
|
||||
mqtt.publish(`bridge/logging`, payload, {}, settings.get().mqtt.base_topic, true);
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
logger.addTransport(new EventTransport());
|
||||
|
||||
this.zigbee2mqttVersion = await utils.getZigbee2MQTTVersion();
|
||||
this.coordinatorVersion = await this.zigbee.getCoordinatorVersion();
|
||||
|
||||
this.eventBus.onEntityRenamed(this, () => this.publishInfo());
|
||||
this.eventBus.onGroupMembersChanged(this, () => this.publishGroups());
|
||||
this.eventBus.onDevicesChanged(this, () => this.publishDevices() && this.publishInfo());
|
||||
this.eventBus.onPermitJoinChanged(this, () => !this.zigbee.isStopping() && this.publishInfo());
|
||||
this.eventBus.onScenesChanged(this, () => {
|
||||
this.publishDevices();
|
||||
this.publishGroups();
|
||||
});
|
||||
|
||||
// Zigbee events
|
||||
const publishEvent = (type: string, data: KeyValue): Promise<void> =>
|
||||
this.mqtt.publish('bridge/event', stringify({type, data}), {retain: false, qos: 0});
|
||||
this.eventBus.onDeviceJoined(this, (data) => {
|
||||
this.lastJoinedDeviceIeeeAddr = data.device.ieeeAddr;
|
||||
this.publishDevices();
|
||||
publishEvent('device_joined', {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr});
|
||||
});
|
||||
this.eventBus.onDeviceLeave(this, (data) => {
|
||||
this.publishDevices();
|
||||
publishEvent('device_leave', {ieee_address: data.ieeeAddr, friendly_name: data.name});
|
||||
});
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, () => this.publishDevices());
|
||||
this.eventBus.onDeviceInterview(this, (data) => {
|
||||
this.publishDevices();
|
||||
const payload: KeyValue =
|
||||
{friendly_name: data.device.name, status: data.status, ieee_address: data.device.ieeeAddr};
|
||||
if (data.status === 'successful') {
|
||||
payload.supported = !!data.device.definition;
|
||||
payload.definition = this.getDefinitionPayload(data.device);
|
||||
}
|
||||
publishEvent('device_interview', payload);
|
||||
});
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => {
|
||||
this.publishDevices();
|
||||
publishEvent('device_announce', {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr});
|
||||
});
|
||||
|
||||
await this.publishInfo();
|
||||
await this.publishDevices();
|
||||
await this.publishGroups();
|
||||
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(requestRegex);
|
||||
const key = match?.[1]?.toLowerCase();
|
||||
if (key in this.requestLookup) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
|
||||
try {
|
||||
const response = await this.requestLookup[key](message);
|
||||
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
|
||||
} catch (error) {
|
||||
logger.error(`Request '${data.topic}' failed with error: '${error.message}'`);
|
||||
logger.debug(error.stack);
|
||||
const response = utils.getResponse(message, {}, error.message);
|
||||
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests
|
||||
*/
|
||||
|
||||
@bind async deviceOptions(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
return this.changeEntityOptions('device', message);
|
||||
}
|
||||
|
||||
@bind async groupOptions(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
return this.changeEntityOptions('group', message);
|
||||
}
|
||||
|
||||
@bind async bridgeOptions(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
if (typeof message !== 'object' || typeof message.options !== 'object') {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const diff: KeyValue = detailedDiff(settings.get(), message.options);
|
||||
|
||||
// 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(message.options, diff.deleted);
|
||||
|
||||
const newSettings = objectAssignDeep({}, diff.added, diff.updated, 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(message.options, newSettings);
|
||||
|
||||
const restartRequired = settings.apply(newSettings);
|
||||
if (restartRequired) this.restartRequired = true;
|
||||
|
||||
// Apply some settings on-the-fly.
|
||||
if (newSettings.hasOwnProperty('permit_join')) {
|
||||
await this.zigbee.permitJoin(newSettings.permit_join);
|
||||
}
|
||||
|
||||
if (newSettings.hasOwnProperty('homeassistant')) {
|
||||
await this.enableDisableExtension(newSettings.homeassistant, 'HomeAssistant');
|
||||
}
|
||||
|
||||
if (newSettings.hasOwnProperty('advanced') && newSettings.advanced.hasOwnProperty('log_level')) {
|
||||
logger.setLevel(newSettings.advanced.log_level);
|
||||
}
|
||||
|
||||
logger.info('Succesfully changed options');
|
||||
this.publishInfo();
|
||||
return utils.getResponse(message, {restart_required: this.restartRequired}, null);
|
||||
}
|
||||
|
||||
@bind async deviceRemove(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return this.removeEntity('device', message);
|
||||
}
|
||||
|
||||
@bind async groupRemove(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return this.removeEntity('group', message);
|
||||
}
|
||||
|
||||
@bind async healthCheck(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return utils.getResponse(message, {healthy: true}, null);
|
||||
}
|
||||
|
||||
@bind async groupAdd(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
if (typeof message === 'object' && !message.hasOwnProperty('friendly_name')) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const friendlyName = typeof message === 'object' ? message.friendly_name : message;
|
||||
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : null;
|
||||
const group = settings.addGroup(friendlyName, ID);
|
||||
this.zigbee.createGroup(group.ID);
|
||||
this.publishGroups();
|
||||
return utils.getResponse(message, {friendly_name: group.friendly_name, id: group.ID}, null);
|
||||
}
|
||||
|
||||
@bind async deviceRename(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return this.renameEntity('device', message);
|
||||
}
|
||||
|
||||
@bind async groupRename(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return this.renameEntity('group', message);
|
||||
}
|
||||
|
||||
@bind async restart(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
// Wait 500 ms before restarting so response can be send.
|
||||
setTimeout(this.restartCallback, 500);
|
||||
logger.info('Restarting Zigbee2MQTT');
|
||||
return utils.getResponse(message, {}, null);
|
||||
}
|
||||
|
||||
@bind async backup(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
await this.zigbee.backup();
|
||||
const dataPath = data.getPath();
|
||||
const files = utils.getAllFiles(dataPath).map((f) => [f, f.substring(dataPath.length + 1)])
|
||||
.filter((f) => !f[1].startsWith('log'));
|
||||
const zip = new JSZip();
|
||||
files.forEach((f) => zip.file(f[1], fs.readFileSync(f[0])));
|
||||
const base64Zip = await zip.generateAsync({type: 'base64'});
|
||||
return utils.getResponse(message, {zip: base64Zip}, null);
|
||||
}
|
||||
|
||||
@bind async permitJoin(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
if (typeof message === 'object' && !message.hasOwnProperty('value')) {
|
||||
throw new Error('Invalid payload');
|
||||
}
|
||||
|
||||
let value: boolean | string;
|
||||
let time: number;
|
||||
let device: Device = null;
|
||||
if (typeof message === 'object') {
|
||||
value = message.value;
|
||||
time = message.time;
|
||||
if (message.device) {
|
||||
const resolved = this.zigbee.resolveEntity(message.device);
|
||||
if (resolved instanceof Device) {
|
||||
device = resolved;
|
||||
} else {
|
||||
throw new Error(`Device '${message.device}' does not exist`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
value = message;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
value = value.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
await this.zigbee.permitJoin(value, device, time);
|
||||
const response: {value: boolean, device?: string, time?: number} = {value};
|
||||
if (device && typeof message === 'object') response.device = message.device;
|
||||
if (time && typeof message === 'object') response.time = message.time;
|
||||
return utils.getResponse(message, response, null);
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
@bind async configLastSeen(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
const allowed = ['disable', 'ISO_8601', 'epoch', 'ISO_8601_local'];
|
||||
const value = this.getValue(message);
|
||||
if (typeof value !== 'string' || !allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
settings.set(['advanced', 'last_seen'], value);
|
||||
this.publishInfo();
|
||||
return utils.getResponse(message, {value}, null);
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
@bind async configHomeAssistant(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
const allowed = [true, false];
|
||||
const value = this.getValue(message);
|
||||
if (typeof value !== 'boolean' || !allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
await this.enableDisableExtension(value, 'HomeAssistant');
|
||||
settings.set(['homeassistant'], value);
|
||||
this.publishInfo();
|
||||
return utils.getResponse(message, {value}, null);
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
@bind async configElapsed(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
const allowed = [true, false];
|
||||
const value = this.getValue(message);
|
||||
if (typeof value !== 'boolean' || !allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
settings.set(['advanced', 'elapsed'], value);
|
||||
this.publishInfo();
|
||||
return utils.getResponse(message, {value}, null);
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
@bind async configLogLevel(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
const allowed = ['error', 'warn', 'info', 'debug'];
|
||||
const value = this.getValue(message) as 'error' | 'warn' | 'info' | 'debug';
|
||||
if (typeof value !== 'string' || !allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
logger.setLevel(value);
|
||||
this.publishInfo();
|
||||
return utils.getResponse(message, {value}, null);
|
||||
}
|
||||
|
||||
@bind async touchlinkIdentify(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
if (typeof message !== 'object' || !message.hasOwnProperty('ieee_address') ||
|
||||
!message.hasOwnProperty('channel')) {
|
||||
throw new Error('Invalid payload');
|
||||
}
|
||||
|
||||
logger.info(`Start Touchlink identify of '${message.ieee_address}' on channel ${message.channel}`);
|
||||
await this.zigbee.touchlinkIdentify(message.ieee_address, message.channel);
|
||||
return utils.getResponse(message, {ieee_address: message.ieee_address, channel: message.channel}, null);
|
||||
}
|
||||
|
||||
@bind async touchlinkFactoryReset(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
let result = false;
|
||||
const payload: {ieee_address?: string, channel?: number} = {};
|
||||
if (typeof message === 'object' && message.hasOwnProperty('ieee_address') &&
|
||||
message.hasOwnProperty('channel')) {
|
||||
logger.info(`Start Touchlink factory reset of '${message.ieee_address}' on channel ${message.channel}`);
|
||||
result = await this.zigbee.touchlinkFactoryReset(message.ieee_address, message.channel);
|
||||
payload.ieee_address = message.ieee_address;
|
||||
payload.channel = message.channel;
|
||||
} else {
|
||||
logger.info('Start Touchlink factory reset of first found device');
|
||||
result = await this.zigbee.touchlinkFactoryResetFirst();
|
||||
}
|
||||
|
||||
if (result) {
|
||||
logger.info('Successfully factory reset device through Touchlink');
|
||||
return utils.getResponse(message, payload, null);
|
||||
} else {
|
||||
logger.error('Failed to factory reset device through Touchlink');
|
||||
throw new Error('Failed to factory reset device through Touchlink');
|
||||
}
|
||||
}
|
||||
|
||||
@bind async touchlinkScan(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
logger.info('Start Touchlink scan');
|
||||
const result = await this.zigbee.touchlinkScan();
|
||||
const found = result.map((r) => {
|
||||
return {ieee_address: r.ieeeAddr, channel: r.channel};
|
||||
});
|
||||
logger.info('Finished Touchlink scan');
|
||||
return utils.getResponse(message, {found}, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utils
|
||||
*/
|
||||
|
||||
getValue(message: KeyValue | string): string | boolean | number {
|
||||
if (typeof message === 'object') {
|
||||
if (!message.hasOwnProperty('value')) {
|
||||
throw new Error('No value given');
|
||||
}
|
||||
|
||||
return message.value;
|
||||
} else {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
async changeEntityOptions(entityType: 'device' | 'group', message: KeyValue | string): Promise<MQTTResponse> {
|
||||
if (typeof message !== 'object' || !message.hasOwnProperty('id') || !message.hasOwnProperty('options')) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const cleanup = (o: KeyValue): KeyValue => {
|
||||
delete o.friendlyName; delete o.friendly_name; delete o.ID; delete o.type; delete o.devices;
|
||||
return o;
|
||||
};
|
||||
|
||||
const ID = message.id;
|
||||
const entity = this.getEntity(entityType, ID);
|
||||
const oldOptions = objectAssignDeep({}, cleanup(entity.options));
|
||||
settings.changeEntityOptions(ID, message.options);
|
||||
const newOptions = cleanup(entity.options);
|
||||
await this.publishInfo();
|
||||
|
||||
logger.info(`Changed config for ${entityType} ${ID}`);
|
||||
|
||||
this.eventBus.emitEntityOptionsChanged({from: oldOptions, to: newOptions, entity});
|
||||
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID}, null);
|
||||
}
|
||||
|
||||
@bind async deviceConfigureReporting(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
if (typeof message !== 'object' || !message.hasOwnProperty('id') || !message.hasOwnProperty('cluster') ||
|
||||
!message.hasOwnProperty('maximum_report_interval') || !message.hasOwnProperty('minimum_report_interval') ||
|
||||
!message.hasOwnProperty('reportable_change') || !message.hasOwnProperty('attribute')) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const parsedID = utils.parseEntityID(message.id);
|
||||
const endpoint = (this.getEntity('device', parsedID.ID) as Device).endpoint(parsedID.endpoint);
|
||||
|
||||
const coordinatorEndpoint = this.zigbee.firstCoordinatorEndpoint();
|
||||
await endpoint.bind(message.cluster, coordinatorEndpoint);
|
||||
|
||||
await endpoint.configureReporting(message.cluster, [{
|
||||
attribute: message.attribute, minimumReportInterval: message.minimum_report_interval,
|
||||
maximumReportInterval: message.maximum_report_interval, reportableChange: message.reportable_change,
|
||||
}], message.options);
|
||||
|
||||
this.publishDevices();
|
||||
|
||||
logger.info(`Configured reporting for '${message.id}', '${message.cluster}.${message.attribute}'`);
|
||||
|
||||
return utils.getResponse(message, {
|
||||
id: message.id, cluster: message.cluster, maximum_report_interval: message.maximum_report_interval,
|
||||
minimum_report_interval: message.minimum_report_interval, reportable_change: message.reportable_change,
|
||||
attribute: message.attribute,
|
||||
}, null);
|
||||
}
|
||||
|
||||
async renameEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> {
|
||||
const deviceAndHasLast = entityType === 'device' && typeof message === 'object' && message.last === true;
|
||||
if (typeof message !== 'object' || (!message.hasOwnProperty('from') && !deviceAndHasLast) ||
|
||||
!message.hasOwnProperty('to')) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
if (deviceAndHasLast && !this.lastJoinedDeviceIeeeAddr) {
|
||||
throw new Error('No device has joined since start');
|
||||
}
|
||||
|
||||
const from = deviceAndHasLast ? this.lastJoinedDeviceIeeeAddr : message.from;
|
||||
const to = message.to;
|
||||
const homeAssisantRename = message.hasOwnProperty('homeassistant_rename') ?
|
||||
message.homeassistant_rename : false;
|
||||
const entity = this.getEntity(entityType, from);
|
||||
const oldFriendlyName = entity.options.friendly_name;
|
||||
|
||||
settings.changeFriendlyName(from, to);
|
||||
|
||||
// Clear retained messages
|
||||
this.mqtt.publish(oldFriendlyName, '', {retain: true});
|
||||
|
||||
this.eventBus.emitEntityRenamed({entity: entity, homeAssisantRename, from: oldFriendlyName, to});
|
||||
|
||||
if (entity instanceof Device) {
|
||||
this.publishDevices();
|
||||
} else {
|
||||
this.publishGroups();
|
||||
this.publishInfo();
|
||||
}
|
||||
|
||||
// Repulish entity state
|
||||
this.publishEntityState(entity, {});
|
||||
|
||||
return utils.getResponse(
|
||||
message,
|
||||
{from: oldFriendlyName, to, homeassistant_rename: homeAssisantRename},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
async removeEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> {
|
||||
const ID = typeof message === 'object' ? message.id : message.trim();
|
||||
const entity = this.getEntity(entityType, ID);
|
||||
const friendlyName = entity.name;
|
||||
const entityID = entity.ID;
|
||||
|
||||
let block = false;
|
||||
let force = false;
|
||||
let blockForceLog = '';
|
||||
|
||||
if (entityType === 'device' && typeof message === 'object') {
|
||||
block = !!message.block;
|
||||
force = !!message.force;
|
||||
blockForceLog = ` (block: ${block}, force: ${force})`;
|
||||
} else if (entityType === 'group' && typeof message === 'object') {
|
||||
force = !!message.force;
|
||||
blockForceLog = ` (force: ${force})`;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`Removing ${entityType} '${entity.name}'${blockForceLog}`);
|
||||
const ieeeAddr = entity.isDevice() && entity.ieeeAddr;
|
||||
const name = entity.name;
|
||||
|
||||
if (entity instanceof Device) {
|
||||
if (block) {
|
||||
settings.blockDevice(entity.ieeeAddr);
|
||||
}
|
||||
|
||||
if (force) {
|
||||
await entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
} else {
|
||||
if (force) {
|
||||
entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
}
|
||||
|
||||
// Fire event
|
||||
if (entity instanceof Device) {
|
||||
this.eventBus.emitDeviceRemoved({ieeeAddr, name});
|
||||
}
|
||||
|
||||
// Remove from configuration.yaml
|
||||
if (entity instanceof Device) {
|
||||
settings.removeDevice(entityID as string);
|
||||
} else {
|
||||
settings.removeGroup(entityID);
|
||||
}
|
||||
|
||||
// Remove from state
|
||||
this.state.remove(entityID);
|
||||
|
||||
// Clear any retained messages
|
||||
this.mqtt.publish(friendlyName, '', {retain: true});
|
||||
|
||||
logger.info(`Successfully removed ${entityType} '${friendlyName}'${blockForceLog}`);
|
||||
|
||||
if (entity instanceof Device) {
|
||||
this.publishGroups();
|
||||
this.publishDevices();
|
||||
return utils.getResponse(message, {id: ID, block, force}, null);
|
||||
} else {
|
||||
this.publishGroups();
|
||||
return utils.getResponse(message, {id: ID, force: force}, null);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to remove ${entityType} '${friendlyName}'${blockForceLog} (${error})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getEntity(type: 'group' | 'device', ID: string): Device | Group {
|
||||
const entity = this.zigbee.resolveEntity(ID);
|
||||
if (!entity || entity.constructor.name.toLowerCase() !== type) {
|
||||
throw new Error(`${utils.capitalize(type)} '${ID}' does not exist`);
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
async publishInfo(): Promise<void> {
|
||||
const config = objectAssignDeep({}, settings.get());
|
||||
delete config.advanced.network_key;
|
||||
delete config.mqtt.password;
|
||||
config.frontend && delete config.frontend.auth_token;
|
||||
const payload = {
|
||||
version: this.zigbee2mqttVersion.version,
|
||||
commit: this.zigbee2mqttVersion.commitHash,
|
||||
coordinator: {
|
||||
ieee_address: this.zigbee.firstCoordinatorEndpoint().getDevice().ieeeAddr,
|
||||
...this.coordinatorVersion,
|
||||
},
|
||||
network: utils.toSnakeCase(await this.zigbee.getNetworkParameters()),
|
||||
log_level: logger.getLevel(),
|
||||
permit_join: this.zigbee.getPermitJoin(),
|
||||
permit_join_timeout: this.zigbee.getPermitJoinTimeout(),
|
||||
restart_required: this.restartRequired,
|
||||
config,
|
||||
config_schema: settings.schema,
|
||||
};
|
||||
|
||||
await this.mqtt.publish(
|
||||
'bridge/info', stringify(payload), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
}
|
||||
|
||||
private getScenes(entity: zh.Endpoint | zh.Group): Scene[] {
|
||||
const scenes: {[id: number]: Scene} = {};
|
||||
const endpoints = utils.isEndpoint(entity) ? [entity] : entity.members;
|
||||
const groupID = utils.isEndpoint(entity) ? 0 : entity.groupID;
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
for (const [key, data] of Object.entries(endpoint.meta?.scenes || {})) {
|
||||
const split = key.split('_');
|
||||
const sceneID = parseInt(split[0], 10);
|
||||
const sceneGroupID = parseInt(split[1], 10);
|
||||
if (sceneGroupID === groupID) {
|
||||
scenes[sceneID] = {id: sceneID, name: (data as KeyValue).name || `Scene ${sceneID}`};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(scenes);
|
||||
}
|
||||
|
||||
async publishDevices(): Promise<void> {
|
||||
interface Data {
|
||||
bindings: {cluster: string, target: {type: string, endpoint?: number, ieee_address?: string, id?: number}}[]
|
||||
configured_reportings: {cluster: string, attribute: string | number,
|
||||
minimum_report_interval: number, maximum_report_interval: number, reportable_change: number}[],
|
||||
clusters: {input: string[], output: string[]}, scenes: Scene[]
|
||||
}
|
||||
|
||||
const devices = this.zigbee.devices().map((device) => {
|
||||
const endpoints: {[s: number]: Data} = {};
|
||||
for (const endpoint of device.zh.endpoints) {
|
||||
const data: Data = {
|
||||
scenes: this.getScenes(endpoint),
|
||||
bindings: [],
|
||||
configured_reportings: [],
|
||||
clusters: {
|
||||
input: endpoint.getInputClusters().map((c) => c.name),
|
||||
output: endpoint.getOutputClusters().map((c) => c.name),
|
||||
},
|
||||
};
|
||||
|
||||
for (const bind of endpoint.binds) {
|
||||
const target = utils.isEndpoint(bind.target) ?
|
||||
{type: 'endpoint', ieee_address: bind.target.getDevice().ieeeAddr, endpoint: bind.target.ID} :
|
||||
{type: 'group', id: bind.target.groupID};
|
||||
data.bindings.push({cluster: bind.cluster.name, target});
|
||||
}
|
||||
|
||||
for (const configuredReporting of endpoint.configuredReportings) {
|
||||
data.configured_reportings.push({
|
||||
cluster: configuredReporting.cluster.name,
|
||||
attribute: configuredReporting.attribute.name || configuredReporting.attribute.ID,
|
||||
minimum_report_interval: configuredReporting.minimumReportInterval,
|
||||
maximum_report_interval: configuredReporting.maximumReportInterval,
|
||||
reportable_change: configuredReporting.reportableChange,
|
||||
});
|
||||
}
|
||||
|
||||
endpoints[endpoint.ID] = data;
|
||||
}
|
||||
|
||||
return {
|
||||
ieee_address: device.ieeeAddr,
|
||||
type: device.zh.type,
|
||||
network_address: device.zh.networkAddress,
|
||||
supported: !!device.definition,
|
||||
friendly_name: device.name,
|
||||
description: device.options.description,
|
||||
definition: this.getDefinitionPayload(device),
|
||||
power_source: device.zh.powerSource,
|
||||
software_build_id: device.zh.softwareBuildID,
|
||||
date_code: device.zh.dateCode,
|
||||
model_id: device.zh.modelID,
|
||||
interviewing: device.zh.interviewing,
|
||||
interview_completed: device.zh.interviewCompleted,
|
||||
manufacturer: device.zh.manufacturerName,
|
||||
endpoints,
|
||||
};
|
||||
});
|
||||
|
||||
await this.mqtt.publish('bridge/devices', stringify(devices),
|
||||
{retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
}
|
||||
|
||||
async publishGroups(): Promise<void> {
|
||||
const groups = this.zigbee.groups().map((g) => {
|
||||
return {
|
||||
id: g.ID,
|
||||
friendly_name: g.ID === 901 ? 'default_bind_group' : g.name,
|
||||
description: g.options.description,
|
||||
scenes: this.getScenes(g.zh),
|
||||
members: g.zh.members.map((e) => {
|
||||
return {ieee_address: e.getDevice().ieeeAddr, endpoint: e.ID};
|
||||
}),
|
||||
};
|
||||
});
|
||||
await this.mqtt.publish(
|
||||
'bridge/groups', stringify(groups), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
}
|
||||
|
||||
getDefinitionPayload(device: Device): DefinitionPayload {
|
||||
if (!device.definition) return null;
|
||||
let icon = device.options.icon ? device.options.icon : device.definition.icon;
|
||||
if (icon) {
|
||||
icon = icon.replace('${zigbeeModel}', utils.sanitizeImageParameter(device.zh.modelID));
|
||||
icon = icon.replace('${model}', utils.sanitizeImageParameter(device.definition.model));
|
||||
}
|
||||
|
||||
return {
|
||||
model: device.definition.model,
|
||||
vendor: device.definition.vendor,
|
||||
description: device.definition.description,
|
||||
exposes: device.exposes(),
|
||||
supports_ota: !!device.definition.ota,
|
||||
options: device.definition.options,
|
||||
icon,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
const settings = require('../util/settings');
|
||||
const utils = require('../util/utils');
|
||||
const logger = require('../util/logger');
|
||||
const Extension = require('./extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
/**
|
||||
* This extension calls the zigbee-herdsman-converters definition configure() method
|
||||
*/
|
||||
class Configure extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
|
||||
this.configuring = new Set();
|
||||
this.onReportingDisabled = this.onReportingDisabled.bind(this);
|
||||
this.attempts = {};
|
||||
|
||||
this.topic = `${settings.get().mqtt.base_topic}/bridge/request/device/configure`;
|
||||
this.legacyTopic = `${settings.get().mqtt.base_topic}/bridge/configure`;
|
||||
this.eventBus.on(`reportingDisabled`, this.onReportingDisabled);
|
||||
}
|
||||
|
||||
onReportingDisabled(data) {
|
||||
// Disabling reporting unbinds some cluster which could be bound by configure, re-setup.
|
||||
const device = data.device;
|
||||
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
if (resolvedEntity.device.meta && resolvedEntity.device.meta.hasOwnProperty('configured')) {
|
||||
delete device.meta.configured;
|
||||
device.save();
|
||||
}
|
||||
|
||||
if (this.shouldConfigure(resolvedEntity)) {
|
||||
this.configure(resolvedEntity);
|
||||
}
|
||||
}
|
||||
|
||||
shouldConfigure(resolvedEntity) {
|
||||
if (!resolvedEntity || !resolvedEntity.definition || !resolvedEntity.definition.configure) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (resolvedEntity.device.meta &&
|
||||
resolvedEntity.device.meta.hasOwnProperty('configured') &&
|
||||
resolvedEntity.device.meta.configured === resolvedEntity.definition.meta.configureKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (resolvedEntity.device.interviewing === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
this.mqtt.subscribe(this.legacyTopic);
|
||||
}
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().experimental.new_api) {
|
||||
this.mqtt.subscribe(this.topic);
|
||||
}
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
if (topic === this.legacyTopic) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(message);
|
||||
if (!resolvedEntity || resolvedEntity.type !== 'device') {
|
||||
logger.error(`Device '${message}' does not exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resolvedEntity.definition || !resolvedEntity.definition.configure) {
|
||||
logger.warn(`Skipping configure of '${resolvedEntity.name}', device does not require this.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.configure(resolvedEntity, true);
|
||||
} else if (topic === this.topic) {
|
||||
message = utils.parseJSON(message, message);
|
||||
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message;
|
||||
let error = null;
|
||||
|
||||
const resolvedEntity = this.zigbee.resolveEntity(ID);
|
||||
if (!resolvedEntity || resolvedEntity.type !== 'device') {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!resolvedEntity.definition || !resolvedEntity.definition.configure) {
|
||||
error = `Device '${resolvedEntity.name}' cannot be configured`;
|
||||
} else {
|
||||
try {
|
||||
await this.configure(resolvedEntity, true, true);
|
||||
} catch (e) {
|
||||
error = `Failed to configure (${e.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = utils.getResponse(message, {id: ID}, error);
|
||||
await this.mqtt.publish(`bridge/response/device/configure`, stringify(response));
|
||||
}
|
||||
}
|
||||
|
||||
async onZigbeeStarted() {
|
||||
this.coordinatorEndpoint = this.zigbee.getDevicesByType('Coordinator')[0].getEndpoint(1);
|
||||
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
if (this.shouldConfigure(resolvedEntity)) {
|
||||
await this.configure(resolvedEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onZigbeeEvent(type, data, resolvedEntity) {
|
||||
const device = data.device;
|
||||
|
||||
if (type === 'deviceJoined' && device.meta.hasOwnProperty('configured')) {
|
||||
delete device.meta.configured;
|
||||
device.save();
|
||||
}
|
||||
|
||||
if (this.shouldConfigure(resolvedEntity)) {
|
||||
this.configure(resolvedEntity);
|
||||
}
|
||||
}
|
||||
|
||||
async configure(resolvedEntity, force=false, thowError=false) {
|
||||
const device = resolvedEntity.device;
|
||||
if (this.configuring.has(device.ieeeAddr) || (this.attempts[device.ieeeAddr] >= 3 && !force)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.configuring.add(device.ieeeAddr);
|
||||
|
||||
if (!this.attempts.hasOwnProperty(device.ieeeAddr)) {
|
||||
this.attempts[device.ieeeAddr] = 0;
|
||||
}
|
||||
|
||||
logger.info(`Configuring '${resolvedEntity.name}'`);
|
||||
try {
|
||||
await resolvedEntity.definition.configure(device, this.coordinatorEndpoint);
|
||||
logger.info(`Successfully configured '${resolvedEntity.name}'`);
|
||||
device.meta.configured = resolvedEntity.definition.meta.configureKey;
|
||||
device.save();
|
||||
} catch (error) {
|
||||
this.attempts[device.ieeeAddr]++;
|
||||
const attempt = this.attempts[device.ieeeAddr];
|
||||
const msg = `Failed to configure '${resolvedEntity.name}', attempt ${attempt} (${error.stack})`;
|
||||
logger.error(msg);
|
||||
|
||||
if (thowError) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
this.configuring.delete(device.ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Configure;
|
||||
@@ -0,0 +1,134 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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 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')) {
|
||||
delete data.device.zh.meta.configured;
|
||||
data.device.zh.save();
|
||||
}
|
||||
|
||||
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 (!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`;
|
||||
} else {
|
||||
try {
|
||||
await this.configure(device, 'mqtt_message', true, true);
|
||||
} catch (e) {
|
||||
error = `Failed to configure (${e.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = utils.getResponse(message, {id: ID}, error);
|
||||
await this.mqtt.publish(`bridge/response/device/configure`, stringify(response));
|
||||
}
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
await this.configure(device, 'started');
|
||||
}
|
||||
|
||||
this.eventBus.onDeviceJoined(this, (data) => {
|
||||
if (data.device.zh.meta.hasOwnProperty('configured')) {
|
||||
delete data.device.zh.meta.configured;
|
||||
data.device.zh.save();
|
||||
}
|
||||
|
||||
this.configure(data.device, 'zigbee_event');
|
||||
});
|
||||
this.eventBus.onDeviceInterview(this, (data) => this.configure(data.device, 'zigbee_event'));
|
||||
this.eventBus.onLastSeenChanged(this, (data) => this.configure(data.device, 'zigbee_event'));
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.eventBus.onReconfigure(this, this.onReconfigure);
|
||||
}
|
||||
|
||||
private async configure(device: Device, event: 'started' | 'zigbee_event' | 'reporting_disabled' | 'mqtt_message',
|
||||
force=false, thowError=false): Promise<void> {
|
||||
if (!force) {
|
||||
if (!device.definition?.configure || !device.zh.interviewCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.zh.meta?.hasOwnProperty('configured') &&
|
||||
device.zh.meta.configured === zhc.getConfigureKey(device.definition)) {
|
||||
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') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.configuring.has(device.ieeeAddr) || (this.attempts[device.ieeeAddr] >= 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);
|
||||
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})`;
|
||||
logger.error(msg);
|
||||
|
||||
if (thowError) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
this.configuring.delete(device.ieeeAddr);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
class Extension {
|
||||
/**
|
||||
* Besides intializing 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, mqtt, state, publishEntityState, eventBus) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.state = state;
|
||||
this.publishEntityState = publishEntityState;
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called by the controller once Zigbee has been started.
|
||||
*/
|
||||
// onZigbeeStarted() {}
|
||||
|
||||
/**
|
||||
* This method is called by the controller once connected to the MQTT server.
|
||||
*/
|
||||
// onMQTTConnected() {}
|
||||
|
||||
/**
|
||||
* Is called when a Zigbee message from a device is received.
|
||||
* @param {string} type Type of the message
|
||||
* @param {Object} data Data of the message
|
||||
* @param {Object?} resolvedEntity Resolved entity returned from this.zigbee.resolveEntity()
|
||||
* @param {Object?} settingsDevice Device settings
|
||||
*/
|
||||
// onZigbeeEvent(type, data, resolvedEntity) {}
|
||||
|
||||
/**
|
||||
* Is called when a MQTT message is received
|
||||
* @param {string} topic Topic on which the message was received
|
||||
* @param {Object} message The received message
|
||||
* @return {boolean} if the message was handled
|
||||
*/
|
||||
// onMQTTMessage(topic, message) {}
|
||||
|
||||
/**
|
||||
* Is called once the extension has to stop
|
||||
*/
|
||||
stop() {
|
||||
this.eventBus.removeListenersExtension(this.constructor.name);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Extension;
|
||||
@@ -0,0 +1,53 @@
|
||||
abstract class Extension {
|
||||
protected zigbee: Zigbee;
|
||||
protected mqtt: MQTT;
|
||||
protected state: State;
|
||||
protected publishEntityState: PublishEntityState;
|
||||
protected eventBus: EventBus;
|
||||
protected enableDisableExtension: (enable: boolean, name: string) => Promise<void>;
|
||||
protected restartCallback: () => void;
|
||||
protected addExtension: (extension: Extension) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Besides intializing 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
|
||||
* @param {enableDisableExtension} enableDisableExtension Enable/disable extension method
|
||||
* @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>) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.state = state;
|
||||
this.publishEntityState = publishEntityState;
|
||||
this.eventBus = eventBus;
|
||||
this.enableDisableExtension = enableDisableExtension;
|
||||
this.restartCallback = restartCallback;
|
||||
this.addExtension = addExtension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is called once the extension has to start
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
async start(): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Is called once the extension has to stop
|
||||
*/
|
||||
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 {}
|
||||
}
|
||||
|
||||
export default Extension;
|
||||
@@ -1,18 +0,0 @@
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const settings = require('../util/settings');
|
||||
const Extension = require('./extension');
|
||||
const utils = require('../util/utils');
|
||||
|
||||
class ExternalConverters extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
|
||||
for (const definition of utils.getExternalConvertersDefinitions(settings)) {
|
||||
const toAdd = {...definition};
|
||||
delete toAdd['homeassistant'];
|
||||
zigbeeHerdsmanConverters.addDeviceDefinition(toAdd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ExternalConverters;
|
||||
@@ -0,0 +1,18 @@
|
||||
import zhc from 'zigbee-herdsman-converters';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
|
||||
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);
|
||||
|
||||
for (const definition of utils.getExternalConvertersDefinitions(settings.get())) {
|
||||
const toAdd = {...definition};
|
||||
delete toAdd['homeassistant'];
|
||||
zhc.addDeviceDefinition(toAdd);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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 extensonFilePath = path.join(basePath, fileName);
|
||||
return {'name': fileName, 'code': fs.readFileSync(extensonFilePath, '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) as typeof Extension;
|
||||
await this.loadExtension(ModuleConstructor);
|
||||
const basePath = this.getExtensionsBasePath();
|
||||
/* istanbul ignore else */
|
||||
if (!fs.existsSync(basePath)) {
|
||||
fs.mkdirSync(basePath);
|
||||
}
|
||||
const extensonFilePath = path.join(basePath, path.basename(name));
|
||||
fs.writeFileSync(extensonFilePath, 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}) => utils.loadModuleFromText(code))
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
const http = require('http');
|
||||
const httpProxy = require('http-proxy');
|
||||
const serveStatic = require('serve-static');
|
||||
const finalhandler = require('finalhandler');
|
||||
const Extension = require('./extension');
|
||||
const logger = require('../util/logger');
|
||||
const frontend = require('zigbee2mqtt-frontend');
|
||||
const WebSocket = require('ws');
|
||||
const url = require('url');
|
||||
const settings = require('../util/settings');
|
||||
const utils = require('../util/utils');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
/**
|
||||
* This extension servers the frontend
|
||||
*/
|
||||
class Frontend extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.onRequest = this.onRequest.bind(this);
|
||||
this.onUpgrade = this.onUpgrade.bind(this);
|
||||
this.mqttBaseTopic = settings.get().mqtt.base_topic;
|
||||
this.onMQTTPublishedMessage = this.onMQTTPublishedMessage.bind(this);
|
||||
this.mqtt.on('publishedMessage', this.onMQTTPublishedMessage);
|
||||
this.onWebSocketConnection = this.onWebSocketConnection.bind(this);
|
||||
this.server = http.createServer(this.onRequest);
|
||||
this.server.on('upgrade', this.onUpgrade);
|
||||
this.developmentServer = settings.get().frontend.development_server;
|
||||
this.development = !!this.developmentServer;
|
||||
this.port = settings.get().frontend.port || 8080;
|
||||
this.retainedMessages = new Map();
|
||||
|
||||
if (this.development) {
|
||||
this.proxy = httpProxy.createProxyServer({ws: true});
|
||||
} else {
|
||||
this.fileServer = serveStatic(frontend.getPath());
|
||||
}
|
||||
|
||||
this.wss = new WebSocket.Server({noServer: true});
|
||||
this.wss.on('connection', this.onWebSocketConnection);
|
||||
}
|
||||
|
||||
onZigbeeStarted() {
|
||||
if (this.development) {
|
||||
logger.info(`Running frontend in development mode (${this.developmentServer})`);
|
||||
}
|
||||
|
||||
this.server.listen(this.port);
|
||||
logger.info(`Started frontend on port ${this.port}`);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
for (const client of this.wss.clients) {
|
||||
client.close();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.server.close(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
onRequest(request, response) {
|
||||
if (this.development) {
|
||||
this.proxy.web(request, response, {target: `http://${this.developmentServer}`});
|
||||
} else {
|
||||
this.fileServer(request, response, finalhandler(request, response));
|
||||
}
|
||||
}
|
||||
|
||||
onUpgrade(request, socket, head) {
|
||||
const pathname = url.parse(request.url).pathname;
|
||||
if (pathname === '/api') {
|
||||
const wss = this.wss;
|
||||
wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws, request));
|
||||
} else if (this.development && pathname === '/sockjs-node') {
|
||||
this.proxy.ws(request, socket, head, {target: `ws://${this.developmentServer}`});
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
onWebSocketConnection(ws) {
|
||||
ws.on('message', (message) => {
|
||||
if (message) {
|
||||
const {topic, payload} = utils.parseJSON(message, message);
|
||||
this.mqtt.onMessage(`${this.mqttBaseTopic}/${topic}`, stringify(payload));
|
||||
}
|
||||
});
|
||||
|
||||
for (const [key, value] of this.retainedMessages) {
|
||||
ws.send(stringify({topic: key, payload: value}));
|
||||
}
|
||||
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
if (this.state.exists(device.ieeeAddr)) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
ws.send(stringify({topic: resolvedEntity.name, payload: this.state.get(device.ieeeAddr)}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMQTTPublishedMessage(data) {
|
||||
let {topic, payload, options} = data;
|
||||
if (topic.startsWith(`${this.mqttBaseTopic}/`)) {
|
||||
// Send topic without base_topic
|
||||
topic = topic.substring(this.mqttBaseTopic.length + 1);
|
||||
payload = utils.parseJSON(payload, payload);
|
||||
if (options.retain) {
|
||||
this.retainedMessages.set(topic, payload);
|
||||
}
|
||||
|
||||
for (const client of this.wss.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(stringify({topic, payload}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Frontend;
|
||||
@@ -0,0 +1,137 @@
|
||||
import http from 'http';
|
||||
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 * 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';
|
||||
|
||||
/**
|
||||
* 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 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;
|
||||
|
||||
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);
|
||||
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.server = http.createServer(this.onRequest);
|
||||
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');
|
||||
}
|
||||
},
|
||||
};
|
||||
this.fileServer = gzipStatic(frontend.getPath(), options);
|
||||
this.wss = new WebSocket.Server({noServer: true});
|
||||
this.wss.on('connection', this.onWebSocketConnection);
|
||||
|
||||
this.server.listen(this.port, this.host);
|
||||
logger.info(`Started frontend on port ${this.host}:${this.port}`);
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
super.stop();
|
||||
for (const client of this.wss.clients) {
|
||||
client.send(stringify({topic: 'bridge/state', payload: 'offline'}));
|
||||
client.terminate();
|
||||
}
|
||||
this.wss.close();
|
||||
return new Promise((cb: () => void) => this.server.close(cb));
|
||||
}
|
||||
|
||||
@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 {
|
||||
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
this.authenticate(request, (isAuthentificated) => {
|
||||
if (isAuthentificated) {
|
||||
this.wss.emit('connection', ws, request);
|
||||
} else {
|
||||
ws.close(4401, 'Unauthorized');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@bind private onWebSocketConnection(ws: WebSocket): void {
|
||||
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));
|
||||
}
|
||||
});
|
||||
|
||||
for (const [key, value] of this.retainedMessages) {
|
||||
ws.send(stringify({topic: key, payload: value}));
|
||||
}
|
||||
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
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 (device.zh.linkquality !== undefined) {
|
||||
payload.linkquality = device.zh.linkquality;
|
||||
}
|
||||
|
||||
ws.send(stringify({topic: device.name, payload}));
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
if (this.wss) {
|
||||
for (const client of this.wss.clients) {
|
||||
/* istanbul ignore else */
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(stringify({topic, payload}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const Extension = require('./extension');
|
||||
const utils = require('../util/utils');
|
||||
const postfixes = utils.getEndpointNames();
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
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$`);
|
||||
|
||||
class Groups extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.onStateChange = this.onStateChange.bind(this);
|
||||
this.legacyApi = settings.get().advanced.legacy_api;
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
/* istanbul ignore else */
|
||||
if (this.legacyApi) {
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/group/remove_all`);
|
||||
|
||||
for (let step = 1; step < 20; step++) {
|
||||
const topic = `${settings.get().mqtt.base_topic}/bridge/group/${'+/'.repeat(step)}`;
|
||||
this.mqtt.subscribe(`${topic}remove`);
|
||||
this.mqtt.subscribe(`${topic}add`);
|
||||
this.mqtt.subscribe(`${topic}remove_all`);
|
||||
}
|
||||
}
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().experimental.new_api) {
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/group/members/add`);
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/group/members/remove`);
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/group/members/remove_all`);
|
||||
}
|
||||
}
|
||||
|
||||
async onZigbeeStarted() {
|
||||
this.eventBus.on('stateChange', this.onStateChange);
|
||||
await this.syncGroupsWithSettings();
|
||||
}
|
||||
|
||||
async syncGroupsWithSettings() {
|
||||
const settingsGroups = settings.getGroups();
|
||||
const zigbeeGroups = this.zigbee.getGroups();
|
||||
|
||||
const addRemoveFromGroup = async (action, deviceName, groupName, endpoint, group) => {
|
||||
try {
|
||||
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} '${deviceName}' to group '${groupName}'`);
|
||||
if (action === 'remove') {
|
||||
await endpoint.removeFromGroup(group);
|
||||
} else {
|
||||
await endpoint.addToGroup(group);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${action} '${deviceName}' from '${groupName}'`);
|
||||
}
|
||||
};
|
||||
|
||||
for (const settingGroup of settingsGroups) {
|
||||
const groupID = settingGroup.ID;
|
||||
const zigbeeGroup = zigbeeGroups.find((g) => g.groupID === groupID) || this.zigbee.createGroup(groupID);
|
||||
const settingsEntity = settingGroup.devices.map((d) => {
|
||||
const entity = this.zigbee.resolveEntity(d);
|
||||
if (!entity) logger.error(`Cannot find '${d}' of group '${settingGroup.friendlyName}'`);
|
||||
return entity;
|
||||
}).filter((e) => e != null);
|
||||
|
||||
// In settings but not in zigbee
|
||||
for (const entity of settingsEntity) {
|
||||
if (!zigbeeGroup.hasMember(entity.endpoint)) {
|
||||
addRemoveFromGroup('add', entity.name, settingGroup.friendlyName, entity.endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
|
||||
// In zigbee but not in settings
|
||||
for (const endpoint of zigbeeGroup.members) {
|
||||
if (!settingsEntity.find((e) => e.endpoint === endpoint)) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendlyName;
|
||||
addRemoveFromGroup('remove', deviceName, settingGroup.friendlyName, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const zigbeeGroup of zigbeeGroups) {
|
||||
if (!settingsGroups.find((g) => g.ID === zigbeeGroup.groupID)) {
|
||||
for (const endpoint of zigbeeGroup.members) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendlyName;
|
||||
addRemoveFromGroup('remove', deviceName, zigbeeGroup.groupID, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onStateChange(data) {
|
||||
const reason = 'group_optimistic';
|
||||
if (data.reason === reason) {
|
||||
return;
|
||||
}
|
||||
|
||||
const properties = ['state', 'brightness', 'color_temp', 'color'];
|
||||
const payload = {};
|
||||
|
||||
properties.forEach((prop) => {
|
||||
if (data.to.hasOwnProperty(prop)) {
|
||||
payload[prop] = data.to[prop];
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(payload).length) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(data.ID);
|
||||
const zigbeeGroups = this.zigbee.getGroups().filter((zigbeeGroup) => {
|
||||
const settingsGroup = settings.getGroup(zigbeeGroup.groupID);
|
||||
return settingsGroup && settingsGroup.optimistic;
|
||||
});
|
||||
|
||||
if (resolvedEntity.type === 'device') {
|
||||
for (const zigbeeGroup of zigbeeGroups) {
|
||||
if (zigbeeGroup.hasMember(resolvedEntity.endpoint)) {
|
||||
if (!payload || payload.state !== 'OFF' || this.areAllMembersOff(zigbeeGroup)) {
|
||||
await this.publishEntityState(zigbeeGroup.groupID, payload, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const groupIDsToPublish = new Set();
|
||||
for (const member of resolvedEntity.group.members) {
|
||||
await this.publishEntityState(member.getDevice().ieeeAddr, payload, reason);
|
||||
for (const zigbeeGroup of zigbeeGroups) {
|
||||
if (zigbeeGroup.hasMember(member)) {
|
||||
if (!payload || payload.state !== 'OFF' || this.areAllMembersOff(zigbeeGroup)) {
|
||||
groupIDsToPublish.add(zigbeeGroup.groupID);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
groupIDsToPublish.delete(resolvedEntity.group.groupID);
|
||||
for (const groupID of groupIDsToPublish) {
|
||||
await this.publishEntityState(groupID, payload, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
areAllMembersOff(zigbeeGroup) {
|
||||
for (const member of zigbeeGroup.members) {
|
||||
const device = member.getDevice();
|
||||
if (this.state.exists(device.ieeeAddr)) {
|
||||
const state = this.state.get(device.ieeeAddr);
|
||||
if (state && state.state === 'ON') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
parseMQTTMessage(topic, message) {
|
||||
let type = null;
|
||||
let resolvedEntityGroup = null;
|
||||
let resolvedEntityDevice = null;
|
||||
let hasEndpointName = null;
|
||||
let error = null;
|
||||
let groupKey = null;
|
||||
let deviceKey = null;
|
||||
let triggeredViaLegacyApi = false;
|
||||
|
||||
/* istanbul ignore else */
|
||||
const topicRegexMatch = topic.match(topicRegex);
|
||||
const legacyTopicRegexRemoveAllMatch = topic.match(legacyTopicRegexRemoveAll);
|
||||
const legacyTopicRegexMatch = topic.match(legacyTopicRegex);
|
||||
|
||||
if (this.legacyApi && (legacyTopicRegexMatch || legacyTopicRegexRemoveAllMatch)) {
|
||||
triggeredViaLegacyApi = true;
|
||||
if (legacyTopicRegexMatch) {
|
||||
resolvedEntityGroup = this.zigbee.resolveEntity(legacyTopicRegexMatch[1]);
|
||||
type = legacyTopicRegexMatch[2];
|
||||
|
||||
if (!resolvedEntityGroup || resolvedEntityGroup.type !== 'group') {
|
||||
logger.error(`Group '${legacyTopicRegexMatch[1]}' does not exist`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {
|
||||
friendly_name: message, group: legacyTopicRegexMatch[1], error: 'group doesn\'t exists',
|
||||
};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_${type}_failed`, message: payload}),
|
||||
);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
} else {
|
||||
type = 'remove_all';
|
||||
}
|
||||
|
||||
resolvedEntityDevice = this.zigbee.resolveEntity(message);
|
||||
if (!resolvedEntityDevice || !resolvedEntityDevice.type === 'device') {
|
||||
logger.error(`Device '${message}' does not exist`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {
|
||||
friendly_name: message, group: legacyTopicRegexMatch[1], error: 'entity doesn\'t exists',
|
||||
};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_${type}_failed`, message: payload}),
|
||||
);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
hasEndpointName = postfixes.find((p) => message.endsWith(`/${p}`));
|
||||
} else if (topicRegexMatch) {
|
||||
type = topicRegexMatch[1];
|
||||
message = JSON.parse(message);
|
||||
deviceKey = message.device;
|
||||
|
||||
if (type !== 'remove_all') {
|
||||
groupKey = message.group;
|
||||
resolvedEntityGroup = this.zigbee.resolveEntity(message.group);
|
||||
if (!resolvedEntityGroup || resolvedEntityGroup.type !== 'group') {
|
||||
error = `Group '${message.group}' does not exist`;
|
||||
}
|
||||
}
|
||||
|
||||
resolvedEntityDevice = this.zigbee.resolveEntity(message.device);
|
||||
if (!error && (!resolvedEntityDevice || !resolvedEntityDevice.type === 'device')) {
|
||||
error = `Device '${message.device}' does not exist`;
|
||||
}
|
||||
|
||||
hasEndpointName = postfixes.find((p) => message.device.endsWith(`/${p}`));
|
||||
}
|
||||
|
||||
return {
|
||||
resolvedEntityGroup, resolvedEntityDevice, type, hasEndpointName, error, groupKey, deviceKey,
|
||||
triggeredViaLegacyApi,
|
||||
};
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
let {
|
||||
resolvedEntityGroup, resolvedEntityDevice, type, hasEndpointName, error, triggeredViaLegacyApi,
|
||||
groupKey, deviceKey,
|
||||
} = this.parseMQTTMessage(topic, message);
|
||||
if (!type) return;
|
||||
|
||||
const responseData = {device: deviceKey};
|
||||
if (groupKey) {
|
||||
responseData.group = groupKey;
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
const keys = [
|
||||
`${resolvedEntityDevice.device.ieeeAddr}/${resolvedEntityDevice.endpoint.ID}`,
|
||||
`${resolvedEntityDevice.name}/${resolvedEntityDevice.endpoint.ID}`,
|
||||
];
|
||||
|
||||
const definition = resolvedEntityDevice.definition;
|
||||
const endpoints = definition && definition.endpoint ?
|
||||
definition.endpoint(resolvedEntityDevice.device) : null;
|
||||
const endpointName = endpoints ?
|
||||
Object.entries(endpoints).find((e) => e[1] === resolvedEntityDevice.endpoint.ID)[0] : null;
|
||||
|
||||
if (endpointName) {
|
||||
keys.push(`${resolvedEntityDevice.device.ieeeAddr}/${endpointName}`);
|
||||
keys.push(`${resolvedEntityDevice.name}/${endpointName}`);
|
||||
}
|
||||
|
||||
if (!hasEndpointName) {
|
||||
keys.push(resolvedEntityDevice.name);
|
||||
keys.push(resolvedEntityDevice.device.ieeeAddr);
|
||||
}
|
||||
|
||||
if (type === 'add') {
|
||||
logger.info(`Adding '${resolvedEntityDevice.name}' to '${resolvedEntityGroup.name}'`);
|
||||
await resolvedEntityDevice.endpoint.addToGroup(resolvedEntityGroup.group);
|
||||
settings.addDeviceToGroup(resolvedEntityGroup.settings.ID, keys);
|
||||
|
||||
/* 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 resolvedEntityDevice.endpoint.removeFromGroup(resolvedEntityGroup.group);
|
||||
settings.removeDeviceFromGroup(resolvedEntityGroup.settings.ID, keys);
|
||||
|
||||
/* 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`);
|
||||
await resolvedEntityDevice.endpoint.removeFromAllGroups();
|
||||
for (const settingsGroup of settings.getGroups()) {
|
||||
settings.removeDeviceFromGroup(settingsGroup.ID, 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})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
await this.mqtt.publish(`bridge/response/group/members/${type}`, stringify(response));
|
||||
}
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
} else {
|
||||
this.eventBus.emit('groupMembersChanged', {group: resolvedEntityGroup});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Groups;
|
||||
@@ -0,0 +1,385 @@
|
||||
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';
|
||||
|
||||
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')) )),
|
||||
};
|
||||
|
||||
interface ParsedMQTTMessage {
|
||||
type: 'remove' | 'add' | 'remove_all', resolvedEntityGroup: Group, resolvedEntityDevice: Device,
|
||||
error: string, groupKey: string, deviceKey: string, triggeredViaLegacyApi: boolean,
|
||||
skipDisableReporting: boolean, resolvedEntityEndpoint: zh.Endpoint,
|
||||
}
|
||||
|
||||
export default class Groups extends Extension {
|
||||
private legacyApi = settings.get().advanced.legacy_api;
|
||||
private lastOptimisticState: {[s: string]: KeyValue} = {};
|
||||
|
||||
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') {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: KeyValue = {};
|
||||
|
||||
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) {
|
||||
payload[prop] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length) {
|
||||
const entity = data.entity;
|
||||
const groups = this.zigbee.groups().filter((g) => {
|
||||
return g.options && (!g.options.hasOwnProperty('optimistic') || g.options.optimistic);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Invalidate the last optimistic group state when group state is changed directly.
|
||||
delete this.lastOptimisticState[entity.ID];
|
||||
|
||||
const groupsToPublish: Set<Group> = new Set();
|
||||
for (const member of entity.zh.members) {
|
||||
const device = this.zigbee.resolveEntity(member.getDevice()) as Device;
|
||||
const exposes = device.exposes();
|
||||
const memberPayload: KeyValue = {};
|
||||
Object.keys(payload).forEach((key) => {
|
||||
if (stateProperties[key](payload[key], exposes)) {
|
||||
memberPayload[key] = payload[key];
|
||||
}
|
||||
});
|
||||
|
||||
const endpointName = device.endpointName(member);
|
||||
if (endpointName) {
|
||||
Object.keys(memberPayload).forEach((key) => {
|
||||
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)) {
|
||||
groupsToPublish.add(zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
groupsToPublish.delete(entity);
|
||||
for (const group of groupsToPublish) {
|
||||
await this.publishEntityState(group, payload, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private areAllMembersOff(group: Group): boolean {
|
||||
for (const member of group.zh.members) {
|
||||
const device = this.zigbee.resolveEntity(member.getDevice());
|
||||
if (this.state.exists(device)) {
|
||||
const state = this.state.get(device);
|
||||
if (state.state === 'ON') {
|
||||
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;
|
||||
|
||||
/* istanbul ignore else */
|
||||
const topicRegexMatch = data.topic.match(topicRegex);
|
||||
const legacyTopicRegexRemoveAllMatch = data.topic.match(legacyTopicRegexRemoveAll);
|
||||
const legacyTopicRegexMatch = data.topic.match(legacyTopicRegex);
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
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`);
|
||||
|
||||
/* 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}),
|
||||
);
|
||||
}
|
||||
|
||||
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 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`;
|
||||
}
|
||||
if (!error) {
|
||||
resolvedEntityEndpoint = resolvedEntityDevice.endpoint(parsed.endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
resolvedEntityGroup, resolvedEntityDevice, type, error, groupKey, deviceKey,
|
||||
triggeredViaLegacyApi, skipDisableReporting, resolvedEntityEndpoint,
|
||||
};
|
||||
}
|
||||
|
||||
@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 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 (error) {
|
||||
logger.error(error);
|
||||
} else {
|
||||
for (const group of changedGroups) {
|
||||
this.eventBus.emitGroupMembersChanged({
|
||||
group, action: type, endpoint: resolvedEntityEndpoint, skipDisableReporting});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,21 @@
|
||||
const settings = require('../../util/settings');
|
||||
const logger = require('../../util/logger');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const utils = require('../../util/utils');
|
||||
const assert = require('assert');
|
||||
const Extension = require('../extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
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'];
|
||||
|
||||
class BridgeLegacy extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
export default class BridgeLegacy extends Extension {
|
||||
private lastJoinedDeviceName: string = null;
|
||||
private supportedOptions: {[s: string]: (topic: string, message: string) => Promise<void> | void};
|
||||
|
||||
// Bind functions
|
||||
this.permitJoin = this.permitJoin.bind(this);
|
||||
this.lastSeen = this.lastSeen.bind(this);
|
||||
this.elapsed = this.elapsed.bind(this);
|
||||
this.reset = this.reset.bind(this);
|
||||
this.logLevel = this.logLevel.bind(this);
|
||||
this.devices = this.devices.bind(this);
|
||||
this.groups = this.groups.bind(this);
|
||||
this.rename = this.rename.bind(this);
|
||||
this.renameLast = this.renameLast.bind(this);
|
||||
this.remove = this.remove.bind(this);
|
||||
this.forceRemove = this.forceRemove.bind(this);
|
||||
this.ban = this.ban.bind(this);
|
||||
this.deviceOptions = this.deviceOptions.bind(this);
|
||||
this.addGroup = this.addGroup.bind(this);
|
||||
this.removeGroup = this.removeGroup.bind(this);
|
||||
this.whitelist = this.whitelist.bind(this);
|
||||
this.touchlinkFactoryReset = this.touchlinkFactoryReset.bind(this);
|
||||
|
||||
this.lastJoinedDeviceName = null;
|
||||
|
||||
// Set supported options
|
||||
override async start(): Promise<void> {
|
||||
this.supportedOptions = {
|
||||
'permit_join': this.permitJoin,
|
||||
'last_seen': this.lastSeen,
|
||||
@@ -57,24 +37,32 @@ class BridgeLegacy extends Extension {
|
||||
'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();
|
||||
}
|
||||
|
||||
whitelist(topic, message) {
|
||||
@bind whitelist(topic: string, message: string): void {
|
||||
try {
|
||||
const entity = settings.getEntity(message);
|
||||
const entity = settings.getDevice(message);
|
||||
assert(entity, `Entity '${message}' does not exist`);
|
||||
settings.whitelistDevice(entity.ID);
|
||||
logger.info(`Whitelisted '${entity.friendlyName}'`);
|
||||
settings.addDeviceToPasslist(entity.ID.toString());
|
||||
logger.info(`Whitelisted '${entity.friendly_name}'`);
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: 'device_whitelisted', message: {friendly_name: entity.friendlyName}}),
|
||||
stringify({type: 'device_whitelisted', message: {friendly_name: entity.friendly_name}}),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to whitelist '${message}' '${error}'`);
|
||||
}
|
||||
}
|
||||
|
||||
deviceOptions(topic, message) {
|
||||
@bind deviceOptions(topic: string, message: string): void {
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(message);
|
||||
@@ -88,18 +76,18 @@ class BridgeLegacy extends Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
const entity = settings.getEntity(json.friendly_name);
|
||||
const entity = settings.getDevice(json.friendly_name);
|
||||
assert(entity, `Entity '${json.friendly_name}' does not exist`);
|
||||
settings.changeEntityOptions(entity.ID, json.options);
|
||||
settings.changeEntityOptions(entity.ID.toString(), json.options);
|
||||
logger.info(`Changed device specific options of '${json.friendly_name}' (${stringify(json.options)})`);
|
||||
}
|
||||
|
||||
async permitJoin(topic, message) {
|
||||
@bind async permitJoin(topic: string, message: string): Promise<void> {
|
||||
await this.zigbee.permitJoin(message.toLowerCase() === 'true');
|
||||
this.publish();
|
||||
}
|
||||
|
||||
async reset(topic, message) {
|
||||
@bind async reset(): Promise<void> {
|
||||
try {
|
||||
await this.zigbee.reset('soft');
|
||||
logger.info('Soft resetted ZNP');
|
||||
@@ -108,7 +96,7 @@ class BridgeLegacy extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
lastSeen(topic, message) {
|
||||
@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}`);
|
||||
@@ -119,7 +107,7 @@ class BridgeLegacy extends Extension {
|
||||
logger.info(`Set last_seen to ${message}`);
|
||||
}
|
||||
|
||||
elapsed(topic, 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}`);
|
||||
@@ -130,8 +118,8 @@ class BridgeLegacy extends Extension {
|
||||
logger.info(`Set elapsed to ${message}`);
|
||||
}
|
||||
|
||||
logLevel(topic, message) {
|
||||
const level = message.toLowerCase();
|
||||
@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);
|
||||
@@ -142,30 +130,29 @@ class BridgeLegacy extends Extension {
|
||||
this.publish();
|
||||
}
|
||||
|
||||
async devices(topic, message) {
|
||||
@bind async devices(topic: string): Promise<void> {
|
||||
const coordinator = await this.zigbee.getCoordinatorVersion();
|
||||
const devices = this.zigbee.getDevices().map((device) => {
|
||||
const payload = {
|
||||
const devices = this.zigbee.devices().map((device) => {
|
||||
const payload: KeyValue = {
|
||||
ieeeAddr: device.ieeeAddr,
|
||||
type: device.type,
|
||||
networkAddress: device.networkAddress,
|
||||
type: device.zh.type,
|
||||
networkAddress: device.zh.networkAddress,
|
||||
};
|
||||
|
||||
if (device.type !== 'Coordinator') {
|
||||
const definition = zigbeeHerdsmanConverters.findByDevice(device);
|
||||
const friendlyDevice = settings.getDevice(device.ieeeAddr);
|
||||
payload.model = definition ? definition.model : device.modelID;
|
||||
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 = friendlyDevice ? friendlyDevice.friendly_name : device.ieeeAddr;
|
||||
payload.manufacturerID = device.manufacturerID;
|
||||
payload.manufacturerName = device.manufacturerName;
|
||||
payload.powerSource = device.powerSource;
|
||||
payload.modelID = device.modelID;
|
||||
payload.hardwareVersion = device.hardwareVersion;
|
||||
payload.softwareBuildID = device.softwareBuildID;
|
||||
payload.dateCode = device.dateCode;
|
||||
payload.lastSeen = device.lastSeen;
|
||||
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;
|
||||
@@ -177,23 +164,23 @@ class BridgeLegacy extends Extension {
|
||||
});
|
||||
|
||||
if (topic.split('/').pop() == 'get') {
|
||||
this.mqtt.publish(`bridge/config/devices`, stringify(devices), {});
|
||||
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}));
|
||||
}
|
||||
}
|
||||
|
||||
groups(topic, message) {
|
||||
@bind groups(): void {
|
||||
const payload = settings.getGroups().map((g) => {
|
||||
const group = {...g};
|
||||
delete group.friendlyName;
|
||||
return group;
|
||||
return {...g, ID: Number(g.ID)};
|
||||
});
|
||||
|
||||
this.mqtt.publish('bridge/log', stringify({type: 'groups', message: payload}));
|
||||
}
|
||||
|
||||
rename(topic, message) {
|
||||
@bind rename(topic: string, message: string): void {
|
||||
const invalid =
|
||||
`Invalid rename message format expected {"old": "friendly_name", "new": "new_name"} got ${message}`;
|
||||
|
||||
@@ -214,7 +201,7 @@ class BridgeLegacy extends Extension {
|
||||
this._renameInternal(json.old, json.new);
|
||||
}
|
||||
|
||||
renameLast(topic, message) {
|
||||
@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;
|
||||
@@ -223,15 +210,15 @@ class BridgeLegacy extends Extension {
|
||||
this._renameInternal(this.lastJoinedDeviceName, message);
|
||||
}
|
||||
|
||||
_renameInternal(from, to) {
|
||||
_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);
|
||||
const eventData = isGroup ? {group: entity.group} : {device: entity.device};
|
||||
eventData.homeAssisantRename = false;
|
||||
this.eventBus.emit(`${isGroup ? 'group' : 'device'}Renamed`, eventData);
|
||||
if (entity.isDevice()) {
|
||||
this.eventBus.emitEntityRenamed({homeAssisantRename: false, from, to, entity});
|
||||
}
|
||||
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
@@ -242,7 +229,7 @@ class BridgeLegacy extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
addGroup(topic, message) {
|
||||
@bind addGroup(topic: string, message: string): void {
|
||||
let id = null;
|
||||
let name = null;
|
||||
try {
|
||||
@@ -271,15 +258,15 @@ class BridgeLegacy extends Extension {
|
||||
logger.info(`Added group '${name}'`);
|
||||
}
|
||||
|
||||
removeGroup(topic, message) {
|
||||
@bind removeGroup(topic: string, message: string): void {
|
||||
const name = message;
|
||||
const entity = this.zigbee.resolveEntity(message);
|
||||
assert(entity && entity.type === 'group', `Group '${message}' does not exist`);
|
||||
const entity = this.zigbee.resolveEntity(message) as Group;
|
||||
assert(entity && entity.isGroup(), `Group '${message}' does not exist`);
|
||||
|
||||
if (topic.includes('force')) {
|
||||
entity.group.removeFromDatabase();
|
||||
entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
entity.group.removeFromNetwork();
|
||||
entity.zh.removeFromNetwork();
|
||||
}
|
||||
settings.removeGroup(message);
|
||||
|
||||
@@ -287,21 +274,21 @@ class BridgeLegacy extends Extension {
|
||||
logger.info(`Removed group '${name}'`);
|
||||
}
|
||||
|
||||
async forceRemove(topic, message) {
|
||||
@bind async forceRemove(topic: string, message: string): Promise<void> {
|
||||
await this.removeForceRemoveOrBan('force_remove', message);
|
||||
}
|
||||
|
||||
async remove(topic, message) {
|
||||
@bind async remove(topic: string, message: string): Promise<void> {
|
||||
await this.removeForceRemoveOrBan('remove', message);
|
||||
}
|
||||
|
||||
async ban(topic, message) {
|
||||
@bind async ban(topic: string, message: string): Promise<void> {
|
||||
await this.removeForceRemoveOrBan('ban', message);
|
||||
}
|
||||
|
||||
async removeForceRemoveOrBan(action, message) {
|
||||
const entity = this.zigbee.resolveEntity(message.trim());
|
||||
const lookup = {
|
||||
@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'],
|
||||
@@ -314,66 +301,64 @@ class BridgeLegacy extends Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
const ieeeAddr = entity.ieeeAddr;
|
||||
const name = entity.name;
|
||||
|
||||
const cleanup = (): void => {
|
||||
// Fire event
|
||||
this.eventBus.emit('deviceRemoved', {resolvedEntity: entity});
|
||||
this.eventBus.emitDeviceRemoved({ieeeAddr, name});
|
||||
|
||||
// Remove from configuration.yaml
|
||||
settings.removeDevice(entity.settings.ID);
|
||||
settings.removeDevice(entity.ieeeAddr);
|
||||
|
||||
// Remove from state
|
||||
this.state.remove(entity.settings.ID);
|
||||
this.state.remove(ieeeAddr);
|
||||
|
||||
logger.info(`Successfully ${lookup[action][0]} ${entity.settings.friendlyName}`);
|
||||
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.settings.friendlyName}'`);
|
||||
logger.info(`${lookup[action][1]} '${entity.name}'`);
|
||||
if (action === 'force_remove') {
|
||||
await entity.device.removeFromDatabase();
|
||||
await entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
await entity.device.removeFromNetwork();
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${lookup[action][2]} ${entity.settings.friendlyName} (${error})`);
|
||||
logger.error(`Failed to ${lookup[action][2]} ${entity.name} (${error})`);
|
||||
// eslint-disable-next-line
|
||||
logger.error(`See https://www.zigbee2mqtt.io/information/mqtt_topics_and_message_structure.html#zigbee2mqttbridgeconfigremove for more info`);
|
||||
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.banDevice(entity.settings.ID);
|
||||
settings.blockDevice(ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
async onMQTTConnected() {
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/config/+`);
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/config/+/+`);
|
||||
await this.publish();
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const {topic, message} = data;
|
||||
if (!topic.match(configRegex)) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
const option = topic.match(configRegex)[1];
|
||||
|
||||
if (!this.supportedOptions.hasOwnProperty(option)) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
await this.supportedOptions[option](topic, message);
|
||||
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
async publish() {
|
||||
const info = await utils.getZigbee2mqttVersion();
|
||||
async publish(): Promise<void> {
|
||||
const info = await utils.getZigbee2MQTTVersion();
|
||||
const coordinator = await this.zigbee.getCoordinatorVersion();
|
||||
const topic = `bridge/config`;
|
||||
const payload = {
|
||||
@@ -382,13 +367,13 @@ class BridgeLegacy extends Extension {
|
||||
coordinator,
|
||||
network: await this.zigbee.getNetworkParameters(),
|
||||
log_level: logger.getLevel(),
|
||||
permit_join: await this.zigbee.getPermitJoin(),
|
||||
permit_join: this.zigbee.getPermitJoin(),
|
||||
};
|
||||
|
||||
await this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0});
|
||||
}
|
||||
|
||||
onZigbeeEvent(type, data, resolvedEntity) {
|
||||
onZigbeeEvent_(type: string, data: KeyValue, resolvedEntity: Device): void {
|
||||
if (type === 'deviceJoined' && resolvedEntity) {
|
||||
this.lastJoinedDeviceName = resolvedEntity.name;
|
||||
}
|
||||
@@ -436,7 +421,7 @@ class BridgeLegacy extends Extension {
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (type === 'deviceLeave') {
|
||||
const name = resolvedEntity ? resolvedEntity.name : data.ieeeAddr;
|
||||
const name = data.ieeeAddr;
|
||||
const meta = {friendly_name: name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
@@ -446,13 +431,13 @@ class BridgeLegacy extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
async touchlinkFactoryReset() {
|
||||
@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.touchlinkFactoryReset();
|
||||
const result = await this.zigbee.touchlinkFactoryResetFirst();
|
||||
|
||||
if (result) {
|
||||
logger.info('Successfully factory reset device through Touchlink');
|
||||
@@ -469,5 +454,3 @@ class BridgeLegacy extends Extension {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BridgeLegacy;
|
||||
@@ -1,57 +0,0 @@
|
||||
/* istanbul ignore file */
|
||||
const settings = require('../../util/settings');
|
||||
const logger = require('../../util/logger');
|
||||
const Extension = require('../extension');
|
||||
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/device/(.+)/get_group_membership$`);
|
||||
|
||||
class DeviceGroupMembership extends Extension {
|
||||
onMQTTConnected() {
|
||||
for (let step = 1; step < 20; step++) {
|
||||
const topic = `${settings.get().mqtt.base_topic}/bridge/device/${'+/'.repeat(step)}get_group_membership`;
|
||||
this.mqtt.subscribe(topic);
|
||||
}
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
const match = topic.match(topicRegex);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entity = this.zigbee.resolveEntity(match[1]);
|
||||
if (!entity || entity.type !== 'device') {
|
||||
logger.error(`Device '${match[1]}' does not exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await entity.endpoint.command(
|
||||
`genGroups`, 'getMembership', {groupcount: 0, grouplist: []}, {}, true,
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
logger.warn(`Couldn't get group membership of ${entity.device.ieeeAddr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let {grouplist, capacity} = response;
|
||||
|
||||
grouplist = grouplist.map((gid) => {
|
||||
const g = settings.getGroup(gid);
|
||||
return g ? g.friendlyName : gid;
|
||||
});
|
||||
|
||||
const msgGroupList = `${entity.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(entity.device.ieeeAddr, {group_list: grouplist, group_capacity: capacity});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DeviceGroupMembership;
|
||||
@@ -0,0 +1,56 @@
|
||||
/* 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});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
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,33 +1,35 @@
|
||||
/* istanbul ignore file */
|
||||
// DEPRECATED
|
||||
const settings = require('../../util/settings');
|
||||
const logger = require('../../util/logger');
|
||||
const utils = require('../../util/utils');
|
||||
const Extension = require('../extension');
|
||||
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.
|
||||
*/
|
||||
class SoftReset extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.timer = null;
|
||||
this.timeout = utils.secondsToMilliseconds(settings.get().advanced.soft_reset_timeout);
|
||||
}
|
||||
export default class SoftReset extends Extension {
|
||||
private timer: NodeJS.Timer = null;
|
||||
private timeout = utils.seconds(settings.get().advanced.soft_reset_timeout);
|
||||
|
||||
onZigbeeStarted() {
|
||||
logger.debug(`Soft reset timeout set to ${utils.millisecondsToSeconds(this.timeout)} seconds`);
|
||||
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());
|
||||
}
|
||||
|
||||
clearTimer() {
|
||||
private clearTimer(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
resetTimer() {
|
||||
private resetTimer(): void {
|
||||
if (this.timeout === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -36,7 +38,7 @@ class SoftReset extends Extension {
|
||||
this.timer = setTimeout(() => this.handleTimeout(), this.timeout);
|
||||
}
|
||||
|
||||
async handleTimeout() {
|
||||
private async handleTimeout(): Promise<void> {
|
||||
logger.warn('Soft reset timeout triggered');
|
||||
|
||||
try {
|
||||
@@ -57,10 +59,4 @@ class SoftReset extends Extension {
|
||||
|
||||
this.resetTimer();
|
||||
}
|
||||
|
||||
onZigbeeEvent() {
|
||||
this.resetTimer();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SoftReset;
|
||||
@@ -1,27 +1,36 @@
|
||||
const settings = require('../util/settings');
|
||||
const utils = require('../util/utils');
|
||||
const logger = require('../util/logger');
|
||||
const Extension = require('./extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
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';
|
||||
|
||||
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[],
|
||||
}
|
||||
|
||||
/**
|
||||
* This extension creates a network map
|
||||
*/
|
||||
class NetworkMap extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
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};
|
||||
|
||||
this.legacyApi = settings.get().advanced.legacy_api;
|
||||
this.legacyTopic = `${settings.get().mqtt.base_topic}/bridge/networkmap`;
|
||||
this.legacyTopicRoutes = `${settings.get().mqtt.base_topic}/bridge/networkmap/routes`;
|
||||
this.topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
|
||||
|
||||
// Bind
|
||||
this.raw = this.raw.bind(this);
|
||||
this.graphviz = this.graphviz.bind(this);
|
||||
this.plantuml = this.plantuml.bind(this);
|
||||
|
||||
// Set supported formats
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.supportedFormats = {
|
||||
'raw': this.raw,
|
||||
'graphviz': this.graphviz,
|
||||
@@ -29,33 +38,22 @@ class NetworkMap extends Extension {
|
||||
};
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
/* istanbul ignore else */
|
||||
if (this.legacyApi) {
|
||||
this.mqtt.subscribe(this.legacyTopic);
|
||||
this.mqtt.subscribe(this.legacyTopicRoutes);
|
||||
}
|
||||
|
||||
if (settings.get().experimental.new_api) {
|
||||
this.mqtt.subscribe(this.topic);
|
||||
}
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
/* istanbul ignore else */
|
||||
if (this.legacyApi) {
|
||||
if ((topic === this.legacyTopic || topic === this.legacyTopicRoutes) &&
|
||||
this.supportedFormats.hasOwnProperty(message)) {
|
||||
const includeRoutes = topic === this.legacyTopicRoutes;
|
||||
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);
|
||||
const converted = this.supportedFormats[message](topology);
|
||||
this.mqtt.publish(`bridge/networkmap/${message}`, converted, {});
|
||||
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 (settings.get().experimental.new_api && topic === this.topic) {
|
||||
if (data.topic === this.topic) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
try {
|
||||
message = utils.parseJSON(message, message);
|
||||
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)}`);
|
||||
@@ -77,11 +75,11 @@ class NetworkMap extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
raw(topology) {
|
||||
return stringify(topology);
|
||||
@bind raw(topology: Topology): KeyValue {
|
||||
return topology;
|
||||
}
|
||||
|
||||
graphviz(topology) {
|
||||
@bind graphviz(topology: Topology): string {
|
||||
const colors = settings.get().map_options.graphviz.colors;
|
||||
|
||||
let text = 'digraph G {\nnode[shape=record];\n';
|
||||
@@ -95,7 +93,7 @@ class NetworkMap extends Extension {
|
||||
|
||||
// Add the device short network address, ieeaddr and scan note (if any)
|
||||
labels.push(
|
||||
`${node.ieeeAddr} (${node.networkAddress})` +
|
||||
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})` +
|
||||
((node.failed && node.failed.length) ? `failed: ${node.failed.join(',')}` : ''),
|
||||
);
|
||||
|
||||
@@ -113,7 +111,7 @@ class NetworkMap extends Extension {
|
||||
let lastSeen = 'unknown';
|
||||
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen;
|
||||
if (date) {
|
||||
lastSeen = utils.formatDate(date, 'relative');
|
||||
lastSeen = utils.formatDate(date, 'relative') as string;
|
||||
}
|
||||
|
||||
labels.push(lastSeen);
|
||||
@@ -143,7 +141,7 @@ class NetworkMap extends Extension {
|
||||
(!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) => r.destinationAddress);
|
||||
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}"`;
|
||||
@@ -156,7 +154,7 @@ class NetworkMap extends Extension {
|
||||
return text.replace(/\0/g, '');
|
||||
}
|
||||
|
||||
plantuml(topology) {
|
||||
@bind plantuml(topology: Topology): string {
|
||||
const text = [];
|
||||
|
||||
text.push(`' paste into: https://www.planttext.com/`);
|
||||
@@ -171,14 +169,14 @@ class NetworkMap extends Extension {
|
||||
|
||||
// Add the device short network address, ieeaddr and scan note (if any)
|
||||
text.push(
|
||||
`${node.ieeeAddr} (${node.networkAddress})` +
|
||||
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})` +
|
||||
((node.failed && node.failed.length) ? ` failed: ${node.failed.join(',')}` : ''),
|
||||
);
|
||||
|
||||
// Add the device model
|
||||
if (node.type !== 'Coordinator') {
|
||||
text.push(`---`);
|
||||
const definition = this.zigbee.resolveEntity(node.ieeeAddr).definition;
|
||||
const definition = (this.zigbee.resolveEntity(node.ieeeAddr) as Device).definition;
|
||||
if (definition) {
|
||||
text.push(`${definition.vendor} ${definition.description} (${definition.model})`);
|
||||
} else {
|
||||
@@ -191,7 +189,7 @@ class NetworkMap extends Extension {
|
||||
let lastSeen = 'unknown';
|
||||
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen;
|
||||
if (date) {
|
||||
lastSeen = utils.formatDate(date, 'relative');
|
||||
lastSeen = utils.formatDate(date, 'relative') as string;
|
||||
}
|
||||
text.push(`---`);
|
||||
text.push(lastSeen);
|
||||
@@ -214,54 +212,72 @@ class NetworkMap extends Extension {
|
||||
return text.join(`\n`);
|
||||
}
|
||||
|
||||
async networkScan(includeRoutes) {
|
||||
async networkScan(includeRoutes: boolean): Promise<Topology> {
|
||||
logger.info(`Starting network scan (includeRoutes '${includeRoutes}')`);
|
||||
const devices = this.zigbee.getDevices().filter((d) => d.type !== 'GreenPower');
|
||||
const lqis = new Map();
|
||||
const routingTables = new Map();
|
||||
const failed = new Map();
|
||||
const devices = this.zigbee.devices().filter((d) => d.zh.type !== 'GreenPower');
|
||||
const lqis: Map<Device, zh.LQI> = new Map();
|
||||
const routingTables: Map<Device, zh.RoutingTable> = new Map();
|
||||
const failed: Map<Device, string[]> = new Map();
|
||||
|
||||
for (const device of devices.filter((d) => d.type != 'EndDevice')) {
|
||||
for (const device of devices.filter((d) => d.zh.type != 'EndDevice')) {
|
||||
failed.set(device, []);
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
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 device.lqi();
|
||||
const result = await doRequest<zh.LQI>(async () => device.zh.lqi());
|
||||
lqis.set(device, result);
|
||||
logger.debug(`LQI succeeded for '${resolvedEntity.name}'`);
|
||||
logger.debug(`LQI succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
failed.get(device).push('lqi');
|
||||
logger.error(`Failed to execute LQI for '${resolvedEntity.name}'`);
|
||||
logger.error(`Failed to execute LQI for '${device.name}'`);
|
||||
logger.debug(error.stack);
|
||||
}
|
||||
|
||||
if (includeRoutes) {
|
||||
try {
|
||||
const result = await device.routingTable();
|
||||
const result = await doRequest(async () => device.zh.routingTable());
|
||||
routingTables.set(device, result);
|
||||
logger.debug(`Routing table succeeded for '${resolvedEntity.name}'`);
|
||||
logger.debug(`Routing table succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
failed.get(device).push('routingTable');
|
||||
logger.error(`Failed to execute routing table for '${resolvedEntity.name}'`);
|
||||
logger.error(`Failed to execute routing table for '${device.name}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Network scan finished`);
|
||||
|
||||
const networkMap = {nodes: [], links: []};
|
||||
const topology: Topology = {nodes: [], links: []};
|
||||
// Add nodes
|
||||
for (const device of devices) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
const definition = resolvedEntity.definition ? {
|
||||
model: resolvedEntity.definition.model,
|
||||
vendor: resolvedEntity.definition.vendor,
|
||||
description: resolvedEntity.definition.description,
|
||||
supports: resolvedEntity.definition.supports,
|
||||
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;
|
||||
|
||||
networkMap.nodes.push({
|
||||
ieeeAddr: device.ieeeAddr, friendlyName: resolvedEntity.name, type: device.type,
|
||||
networkAddress: device.networkAddress, manufacturerName: device.manufacturerName,
|
||||
modelID: device.modelID, failed: failed.get(device), lastSeen: device.lastSeen,
|
||||
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,
|
||||
definition,
|
||||
});
|
||||
}
|
||||
@@ -274,9 +290,16 @@ class NetworkMap extends Extension {
|
||||
continue;
|
||||
}
|
||||
|
||||
const link = {
|
||||
// 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;
|
||||
}
|
||||
|
||||
const link: Link = {
|
||||
source: {ieeeAddr: neighbor.ieeeAddr, networkAddress: neighbor.networkAddress},
|
||||
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.networkAddress},
|
||||
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.zh.networkAddress},
|
||||
linkquality: neighbor.linkquality, depth: neighbor.depth, routes: [],
|
||||
// DEPRECATED:
|
||||
sourceIeeeAddr: neighbor.ieeeAddr, targetIeeeAddr: device.ieeeAddr,
|
||||
@@ -290,12 +313,10 @@ class NetworkMap extends Extension {
|
||||
.filter((t) => t.status === 'ACTIVE' && t.nextHop === neighbor.networkAddress);
|
||||
}
|
||||
|
||||
networkMap.links.push(link);
|
||||
topology.links.push(link);
|
||||
}
|
||||
});
|
||||
|
||||
return networkMap;
|
||||
return topology;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NetworkMap;
|
||||
@@ -1,38 +0,0 @@
|
||||
const Extension = require('./extension');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
|
||||
/**
|
||||
* This extension calls the zigbee-herdsman-converters onEvent.
|
||||
*/
|
||||
class OnEvent extends Extension {
|
||||
async onZigbeeStarted() {
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
this.callOnEvent(resolvedEntity, 'start', {});
|
||||
}
|
||||
}
|
||||
|
||||
onZigbeeEvent(type, data, resolvedEntity) {
|
||||
if (resolvedEntity && resolvedEntity.type === 'device') {
|
||||
this.callOnEvent(resolvedEntity, type, data);
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
super.stop();
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
this.callOnEvent(resolvedEntity, 'stop', {});
|
||||
}
|
||||
}
|
||||
|
||||
callOnEvent(resolvedEntity, type, data) {
|
||||
zigbeeHerdsmanConverters.onEvent(type, data, resolvedEntity.device, resolvedEntity.settings);
|
||||
|
||||
if (resolvedEntity.definition && resolvedEntity.definition.onEvent) {
|
||||
resolvedEntity.definition.onEvent(type, data, resolvedEntity.device, resolvedEntity.settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OnEvent;
|
||||
@@ -0,0 +1,50 @@
|
||||
import zhc from 'zigbee-herdsman-converters';
|
||||
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', {});
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private convertData(data: KeyValue): KeyValue {
|
||||
return {...data, device: data.device.zh};
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
super.stop();
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
await this.callOnEvent(device, 'stop', {});
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (device.definition?.onEvent) {
|
||||
await device.definition.onEvent(type, data, device.zh, device.options, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const utils = require('../util/utils');
|
||||
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');
|
||||
|
||||
const Extension = require('./extension');
|
||||
const MINUTES_10 = 1000 * 60 * 10;
|
||||
|
||||
class OTAUpdate extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.inProgress = new Set();
|
||||
this.lastChecked = {};
|
||||
this.legacyApi = settings.get().advanced.legacy_api;
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
/* istanbul ignore else */
|
||||
if (this.legacyApi) {
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/ota_update/check`);
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/ota_update/update`);
|
||||
}
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().experimental.new_api) {
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/check`);
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/update`);
|
||||
}
|
||||
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
// In case Zigbee2MQTT is restared during an update, progress and remaining values are still in state.
|
||||
// remove them.
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
}
|
||||
}
|
||||
|
||||
removeProgressAndRemainingFromState(device) {
|
||||
this.state.removeKey(device.ieeeAddr, ['update', 'progress']);
|
||||
this.state.removeKey(device.ieeeAddr, ['update', 'remaining']);
|
||||
}
|
||||
|
||||
async onZigbeeEvent(type, data, resolvedEntity) {
|
||||
if (data.type !== 'commandQueryNextImageRequest' || !resolvedEntity || !resolvedEntity.definition) return;
|
||||
|
||||
const supportsOTA = resolvedEntity.definition.hasOwnProperty('ota');
|
||||
if (supportsOTA) {
|
||||
// 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' make sense to check for a new update
|
||||
// each time.
|
||||
const check = this.lastChecked.hasOwnProperty(data.device.ieeeAddr) ?
|
||||
(Date.now() - this.lastChecked[data.device.ieeeAddr]) > MINUTES_10 : true;
|
||||
if (!check || this.inProgress.has(data.device.ieeeAddr)) return;
|
||||
|
||||
this.lastChecked[data.device.ieeeAddr] = Date.now();
|
||||
const available = await resolvedEntity.definition.ota.isUpdateAvailable(data.device, logger, data.data);
|
||||
const payload = this.getEntityPublishPayload(available ? 'available' : 'idle');
|
||||
this.publishEntityState(data.device.ieeeAddr, payload);
|
||||
|
||||
if (available) {
|
||||
const message = `Update available for '${resolvedEntity.settings.friendly_name}'`;
|
||||
logger.info(message);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: 'available', device: resolvedEntity.settings.friendly_name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message, meta}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Respond to the OTA request:
|
||||
// - In case we don't support OTA: respond with NO_IMAGE_AVAILABLE (0x98) (so the client stops requesting OTAs)
|
||||
// - In case we do support OTA: respond with ABORT (0x95) as we don't want to update now.
|
||||
const endpoint = data.device.endpoints.find((e) => e.supportsOutputCluster('genOta'));
|
||||
if (endpoint) {
|
||||
// Some devices send OTA requests without defining OTA cluster as input cluster.
|
||||
await endpoint.commandResponse('genOta', 'queryNextImageResponse', {status: supportsOTA ? 0x95 : 0x98});
|
||||
}
|
||||
}
|
||||
|
||||
async readSoftwareBuildIDAndDateCode(device, update) {
|
||||
try {
|
||||
const endpoint = device.endpoints.find((e) => e.supportsInputCluster('genBasic'));
|
||||
const result = await endpoint.read('genBasic', ['dateCode', 'swBuildId']);
|
||||
|
||||
if (update) {
|
||||
device.softwareBuildID = result.swBuildId;
|
||||
device.dateCode = result.dateCode;
|
||||
device.save();
|
||||
}
|
||||
|
||||
return {softwareBuildID: result.swBuildId, dateCode: result.dateCode};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getEntityPublishPayload(state, progress=null, remaining=null) {
|
||||
const payload = {};
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (this.legacyApi) {
|
||||
payload.update_available = state === 'available';
|
||||
}
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().experimental.new_api) {
|
||||
payload.update = {state};
|
||||
if (progress !== null) payload.update.progress = progress;
|
||||
if (remaining !== null) payload.update.remaining = Math.round(remaining);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
if ((!this.legacyApi || !topic.match(legacyTopicRegex)) && !topic.match(topicRegex)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
message = utils.parseJSON(message, message);
|
||||
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message;
|
||||
const resolvedEntity = this.zigbee.resolveEntity(ID);
|
||||
const type = topic.substring(topic.lastIndexOf('/') + 1);
|
||||
const responseData = {id: ID};
|
||||
let error = null;
|
||||
|
||||
if (!resolvedEntity || resolvedEntity.type !== 'device') {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!resolvedEntity.definition || !resolvedEntity.definition.ota) {
|
||||
error = `Device '${resolvedEntity.name}' does not support OTA updates`;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `not_supported`, device: resolvedEntity.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: error, meta}),
|
||||
);
|
||||
}
|
||||
} else if (this.inProgress.has(resolvedEntity.device.ieeeAddr)) {
|
||||
error = `Update or check for update already in progress for '${resolvedEntity.name}'`;
|
||||
} else {
|
||||
this.inProgress.add(resolvedEntity.device.ieeeAddr);
|
||||
|
||||
if (type === 'check') {
|
||||
const msg = `Checking if update available for '${resolvedEntity.name}'`;
|
||||
logger.info(msg);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `checking_if_available`, device: resolvedEntity.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: msg, meta}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const available = await resolvedEntity.definition.ota.isUpdateAvailable(
|
||||
resolvedEntity.device, logger,
|
||||
);
|
||||
const msg = `${available ? 'Update' : 'No update'} available for '${resolvedEntity.name}'`;
|
||||
logger.info(msg);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: available ? 'available' : 'not_available', device: resolvedEntity.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: msg, meta}),
|
||||
);
|
||||
}
|
||||
|
||||
const payload = this.getEntityPublishPayload(available ? 'available' : 'idle');
|
||||
this.publishEntityState(resolvedEntity.device.ieeeAddr, payload);
|
||||
this.lastChecked[resolvedEntity.device.ieeeAddr] = Date.now();
|
||||
responseData.updateAvailable = available;
|
||||
} catch (e) {
|
||||
error = `Failed to check if update available for '${resolvedEntity.name}' (${e.message})`;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `check_failed`, device: resolvedEntity.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: error, meta}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else { // type === 'update'
|
||||
const msg = `Updating '${resolvedEntity.name}' to latest firmware`;
|
||||
logger.info(msg);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_in_progress`, device: resolvedEntity.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, msg, meta}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const onProgress = (progress, remaining) => {
|
||||
let msg = `Update of '${resolvedEntity.name}' at ${progress.toFixed(2)}%`;
|
||||
if (remaining) {
|
||||
msg += `, +- ${Math.round(remaining / 60)} minutes remaining`;
|
||||
}
|
||||
|
||||
logger.info(msg);
|
||||
|
||||
const payload = this.getEntityPublishPayload('updating', progress, remaining);
|
||||
this.publishEntityState(resolvedEntity.device.ieeeAddr, payload);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_progress`, device: resolvedEntity.name, progress};
|
||||
this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
|
||||
}
|
||||
};
|
||||
|
||||
const from_ = await this.readSoftwareBuildIDAndDateCode(resolvedEntity.device, false);
|
||||
await resolvedEntity.definition.ota.updateToLatest(resolvedEntity.device, logger, onProgress);
|
||||
const to = await this.readSoftwareBuildIDAndDateCode(resolvedEntity.device, true);
|
||||
const [fromS, toS] = [stringify(from_), stringify(to)];
|
||||
const msg = `Finished update of '${resolvedEntity.name}'` +
|
||||
(to ? `, from '${fromS}' to '${toS}'` : ``);
|
||||
logger.info(msg);
|
||||
this.removeProgressAndRemainingFromState(resolvedEntity.device);
|
||||
const payload = this.getEntityPublishPayload('idle');
|
||||
this.publishEntityState(resolvedEntity.device.ieeeAddr, payload);
|
||||
responseData.from = from_ ? utils.toSnakeCase(from_) : null;
|
||||
responseData.to = to ? utils.toSnakeCase(to) : null;
|
||||
this.eventBus.emit(`devicesChanged`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_succeeded`, device: resolvedEntity.name, from: from_, to};
|
||||
this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message, meta}));
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Update of '${resolvedEntity.name}' failed (${e.message})`;
|
||||
|
||||
this.removeProgressAndRemainingFromState(resolvedEntity.device);
|
||||
const payload = this.getEntityPublishPayload('available');
|
||||
this.publishEntityState(resolvedEntity.device.ieeeAddr, payload);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_failed`, device: resolvedEntity.name};
|
||||
this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.inProgress.delete(resolvedEntity.device.ieeeAddr);
|
||||
}
|
||||
|
||||
const triggeredViaLegacyApi = topic.match(legacyTopicRegex);
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
await this.mqtt.publish(`bridge/response/device/ota_update/${type}`, stringify(response));
|
||||
}
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OTAUpdate;
|
||||
@@ -0,0 +1,306 @@
|
||||
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';
|
||||
|
||||
function isValidUrl(url: string): boolean {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = URI.parse(url);
|
||||
} catch (_) {
|
||||
// istanbul ignore next
|
||||
return false;
|
||||
}
|
||||
return parsed.scheme === 'http' || parsed.scheme === 'https';
|
||||
}
|
||||
|
||||
type UpdateState = 'updating' | 'idle' | 'available';
|
||||
interface UpdatePayload {
|
||||
// eslint-disable-next-line camelcase
|
||||
update: {progress?: number, remaining?: number, state: UpdateState}, update_available?: boolean}
|
||||
|
||||
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/ota_update/.+$`);
|
||||
const topicRegex =
|
||||
new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check)`, 'i');
|
||||
|
||||
export default class OTAUpdate extends Extension {
|
||||
private inProgress = new Set();
|
||||
private lastChecked: {[s: string]: number} = {};
|
||||
private legacyApi = settings.get().advanced.legacy_api;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@bind private async onZigbeeEvent(data: eventdata.DeviceMessage): Promise<void> {
|
||||
if (data.type !== 'commandQueryNextImageRequest' || !data.device.definition ||
|
||||
this.inProgress.has(data.device.ieeeAddr)) return;
|
||||
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;
|
||||
|
||||
this.lastChecked[data.device.ieeeAddr] = Date.now();
|
||||
let available = false;
|
||||
try {
|
||||
available = 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})`);
|
||||
}
|
||||
|
||||
const payload = this.getEntityPublishPayload(available ? 'available' : 'idle');
|
||||
this.publishEntityState(data.device, payload);
|
||||
|
||||
if (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}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Respond to the OTA request:
|
||||
// - In case we don't support OTA: respond with NO_IMAGE_AVAILABLE (0x98) (so the client stops requesting OTAs)
|
||||
// - In case we do support OTA: respond with ABORT (0x95) as we don't want to update now.
|
||||
const endpoint = data.device.zh.endpoints.find((e) => e.supportsOutputCluster('genOta'));
|
||||
if (endpoint) {
|
||||
// Some devices send OTA requests without defining OTA cluster as input cluster.
|
||||
await endpoint.commandResponse('genOta', 'queryNextImageResponse', {status: supportsOTA ? 0x95 : 0x98});
|
||||
}
|
||||
}
|
||||
|
||||
private async readSoftwareBuildIDAndDateCode(device: Device, sendWhen: 'active' | 'immediate'):
|
||||
Promise<{softwareBuildID: string, dateCode: string}> {
|
||||
try {
|
||||
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster('genBasic'));
|
||||
const result = await endpoint.read('genBasic', ['dateCode', 'swBuildId'], {sendWhen});
|
||||
return {softwareBuildID: result.swBuildId, dateCode: result.dateCode};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getEntityPublishPayload(state: UpdateState, progress: number=null, remaining: number=null): UpdatePayload {
|
||||
const payload: UpdatePayload = {update: {state}};
|
||||
if (progress !== null) payload.update.progress = progress;
|
||||
if (remaining !== null) payload.update.remaining = Math.round(remaining);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (this.legacyApi) {
|
||||
payload.update_available = state === 'available';
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
if ((!this.legacyApi || !data.topic.match(legacyTopicRegex)) && !data.topic.match(topicRegex)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
const ID = (typeof message === 'object' && message.hasOwnProperty('id') ? 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;
|
||||
|
||||
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 {
|
||||
this.inProgress.add(device.ieeeAddr);
|
||||
|
||||
if (type === 'check') {
|
||||
const msg = `Checking if update available for '${device.name}'`;
|
||||
logger.info(msg);
|
||||
|
||||
/* 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}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const available = await device.definition.ota.isUpdateAvailable(device.zh, logger);
|
||||
const msg = `${available ? 'Update' : 'No update'} available for '${device.name}'`;
|
||||
logger.info(msg);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: available ? 'available' : 'not_available', device: device.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: msg, meta}),
|
||||
);
|
||||
}
|
||||
|
||||
const payload = this.getEntityPublishPayload(available ? 'available' : 'idle');
|
||||
this.publishEntityState(device, payload);
|
||||
this.lastChecked[device.ieeeAddr] = Date.now();
|
||||
responseData.updateAvailable = available;
|
||||
} catch (e) {
|
||||
error = `Failed to check if update available for '${device.name}' (${e.message})`;
|
||||
errorStack = e.stack;
|
||||
|
||||
/* 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}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} 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}),
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
logger.info(msg);
|
||||
|
||||
const payload = this.getEntityPublishPayload('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 from_ = await this.readSoftwareBuildIDAndDateCode(device, 'immediate');
|
||||
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('idle');
|
||||
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();
|
||||
|
||||
/* 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}));
|
||||
}
|
||||
} 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('available');
|
||||
this.publishEntityState(device, payload);
|
||||
|
||||
/* 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}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.inProgress.delete(device.ieeeAddr);
|
||||
}
|
||||
|
||||
const triggeredViaLegacyApi = data.topic.match(legacyTopicRegex);
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
await this.mqtt.publish(`bridge/response/device/ota_update/${type}`, stringify(response));
|
||||
}
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
errorStack && logger.debug(errorStack);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
|
||||
const settings = require('../util/settings');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const logger = require('../util/logger');
|
||||
const utils = require('../util/utils');
|
||||
const assert = require('assert');
|
||||
const Extension = require('./extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
const topicRegex = new RegExp(`^(.+?)(?:/(${utils.getEndpointNames().join('|')}))?/(get|set)(?:/(.+))?`);
|
||||
const stateValues = ['on', 'off', 'toggle', 'open', 'close', 'stop', 'lock', 'unlock'];
|
||||
|
||||
const groupConverters = [
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_onoff_brightness,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_color_colortemp,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_alert,
|
||||
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,
|
||||
];
|
||||
|
||||
class EntityPublish extends Extension {
|
||||
onMQTTConnected() {
|
||||
// Subscribe to topics.
|
||||
const baseTopic = settings.get().mqtt.base_topic;
|
||||
for (let step = 1; step < 20; step++) {
|
||||
const topic = `${baseTopic}/${'+/'.repeat(step)}`;
|
||||
this.mqtt.subscribe(`${topic}set`);
|
||||
this.mqtt.subscribe(`${topic}set/+`);
|
||||
this.mqtt.subscribe(`${topic}get`);
|
||||
this.mqtt.subscribe(`${topic}get/+`);
|
||||
}
|
||||
}
|
||||
|
||||
parseTopic(topic) {
|
||||
const match = topic.match(topicRegex);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return {ID: ID, endpointName: match[2] || '', type: match[3], attribute: match[4]};
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
topic = this.parseTopic(topic);
|
||||
if (!topic) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entityKey = `${topic.ID}` + (topic.endpointName ? `/${topic.endpointName}` : '');
|
||||
const resolvedEntity = this.zigbee.resolveEntity(entityKey);
|
||||
|
||||
if (!resolvedEntity) {
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const message = {friendly_name: entityKey};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `entity_not_found`, message}),
|
||||
);
|
||||
}
|
||||
|
||||
logger.error(`Entity '${entityKey}' is unknown`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get entity details
|
||||
let converters = null;
|
||||
let target = null;
|
||||
let options = {};
|
||||
let device = null;
|
||||
let definition = null;
|
||||
let membersState = null;
|
||||
|
||||
assert(resolvedEntity.type === 'device' || resolvedEntity.type === 'group');
|
||||
if (resolvedEntity.type === 'device') {
|
||||
if (!resolvedEntity.definition) {
|
||||
logger.warn(`Device with modelID '${resolvedEntity.device.modelID}' is not supported.`);
|
||||
logger.warn(`Please see: https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html`);
|
||||
return;
|
||||
}
|
||||
|
||||
device = resolvedEntity.device;
|
||||
definition = resolvedEntity.definition;
|
||||
target = resolvedEntity.endpoint;
|
||||
converters = resolvedEntity.definition.toZigbee;
|
||||
options = resolvedEntity.settings;
|
||||
} else {
|
||||
target = resolvedEntity.group;
|
||||
options = resolvedEntity.settings;
|
||||
definition = resolvedEntity.group.members
|
||||
.map((e) => zigbeeHerdsmanConverters.findByDevice(e.getDevice())).filter((d) => d);
|
||||
converters = new Set(groupConverters);
|
||||
for (const d of definition) {
|
||||
d.toZigbee.forEach(converters.add, converters);
|
||||
}
|
||||
converters = [...converters];
|
||||
membersState = {};
|
||||
for (const member of resolvedEntity.group.members) {
|
||||
const ieeeAddr = member.getDevice().ieeeAddr;
|
||||
membersState[ieeeAddr] = this.state.get(ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the MQTT message to a Zigbee message.
|
||||
let json = {};
|
||||
if (topic.hasOwnProperty('attribute') && topic.attribute) {
|
||||
try {
|
||||
json[topic.attribute] = JSON.parse(message);
|
||||
} catch (e) {
|
||||
json[topic.attribute] = message;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
json = JSON.parse(message);
|
||||
} catch (e) {
|
||||
if (stateValues.includes(message.toLowerCase())) {
|
||||
json = {state: message};
|
||||
} else {
|
||||
logger.error(`Invalid JSON '${message}', skipping...`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!json) {
|
||||
logger.error(`Invalid JSON '${message}', skipping...`);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 unecessary.
|
||||
*/
|
||||
const entityState = this.state.get(resolvedEntity.settings.ID) || {};
|
||||
if (settings.get().homeassistant) {
|
||||
const hasColorTemp = json.hasOwnProperty('color_temp');
|
||||
const hasColor = json.hasOwnProperty('color');
|
||||
const hasBrightness = json.hasOwnProperty('brightness');
|
||||
const isOn = entityState && entityState.state === 'ON' ? true : false;
|
||||
if (isOn && (hasColorTemp || hasColor) && !hasBrightness) {
|
||||
delete json.state;
|
||||
logger.debug('Skipping state because of Home Assistant');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Order state & brightness based on current bulb state
|
||||
*
|
||||
* Not all bulbs support setting the color/color_temp while it is off
|
||||
* this results in inconsistant behavior between different vendors.
|
||||
*
|
||||
* bulb on => move state & brightness to the back
|
||||
* bulb off => move state & brightness to the front
|
||||
*/
|
||||
const entries = Object.entries(json);
|
||||
const sorter = typeof json.state === 'string' && json.state.toLowerCase() === 'off' ? 1 : -1;
|
||||
entries.sort((a, b) => (['state', 'brightness', 'brightness_percent'].includes(a[0]) ? sorter : sorter * -1));
|
||||
|
||||
// For each attribute call the corresponding converter
|
||||
const usedConverters = {};
|
||||
for (let [key, value] of entries) {
|
||||
let endpointName = topic.endpointName;
|
||||
let actualTarget = target;
|
||||
|
||||
// When the key has a endpointName included (e.g. state_right), this will override the target.
|
||||
if (resolvedEntity.type === 'device' && key.includes('_')) {
|
||||
const underscoreIndex = key.lastIndexOf('_');
|
||||
const possibleEndpointName = key.substring(underscoreIndex + 1, key.length);
|
||||
if (utils.getEndpointNames().includes(possibleEndpointName)) {
|
||||
endpointName = possibleEndpointName;
|
||||
key = key.substring(0, underscoreIndex);
|
||||
const device = target.getDevice();
|
||||
actualTarget = device.getEndpoint(definition.endpoint(device)[endpointName]);
|
||||
|
||||
if (!actualTarget) {
|
||||
logger.error(`Device '${resolvedEntity.name}' has no endpoint '${endpointName}'`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const endpointOrGroupID = actualTarget.constructor.name == 'Group' ? actualTarget.groupID : actualTarget.ID;
|
||||
if (!usedConverters.hasOwnProperty(endpointOrGroupID)) usedConverters[endpointOrGroupID] = [];
|
||||
const converter = converters.find((c) => c.key.includes(key));
|
||||
|
||||
if (usedConverters[endpointOrGroupID].includes(converter)) {
|
||||
// Use a converter only once (e.g. light_onoff_brightness converters can convert state and brightness)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!converter) {
|
||||
logger.error(`No converter available for '${key}' (${json[key]})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Converter didn't return a result, skip
|
||||
const meta = {
|
||||
endpoint_name: endpointName,
|
||||
options,
|
||||
message: json,
|
||||
logger,
|
||||
device,
|
||||
state: entityState,
|
||||
membersState,
|
||||
mapped: definition,
|
||||
};
|
||||
|
||||
try {
|
||||
if (topic.type === 'set' && converter.convertSet) {
|
||||
logger.debug(`Publishing '${topic.type}' '${key}' to '${resolvedEntity.name}'`);
|
||||
const result = await converter.convertSet(actualTarget, key, value, meta);
|
||||
if (result && result.state) {
|
||||
const msg = result.state;
|
||||
|
||||
if (endpointName) {
|
||||
for (const key of ['state', 'brightness', 'color', 'color_temp']) {
|
||||
if (msg.hasOwnProperty(key)) {
|
||||
msg[`${key}_${endpointName}`] = msg[key];
|
||||
delete msg[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.publishEntityState(resolvedEntity.settings.ID, msg);
|
||||
}
|
||||
|
||||
if (result && result.membersState) {
|
||||
for (const [ieeeAddr, state] of Object.entries(result.membersState)) {
|
||||
this.publishEntityState(ieeeAddr, state);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (
|
||||
resolvedEntity.type === 'device' && result && result.hasOwnProperty('readAfterWriteTime') &&
|
||||
resolvedEntity.settings.retrieve_state
|
||||
) {
|
||||
setTimeout(() => converter.convertGet(actualTarget, key, meta), result.readAfterWriteTime);
|
||||
}
|
||||
} else if (topic.type === 'get' && converter.convertGet) {
|
||||
logger.debug(`Publishing get '${topic.type}' '${key}' to '${resolvedEntity.name}'`);
|
||||
await converter.convertGet(actualTarget, key, meta);
|
||||
} else {
|
||||
logger.error(`No converter available for '${topic.type}' '${key}' (${json[key]})`);
|
||||
continue;
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
`Publish '${topic.type}' '${key}' to '${resolvedEntity.name}' failed: '${error}'`;
|
||||
logger.error(message);
|
||||
logger.debug(error.stack);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {friendly_name: resolvedEntity.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `zigbee_publish_error`, message, meta}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
usedConverters[endpointOrGroupID].push(converter);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EntityPublish;
|
||||
@@ -0,0 +1,301 @@
|
||||
|
||||
import * as settings from '../util/settings';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
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';
|
||||
|
||||
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'];
|
||||
|
||||
// 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,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.effect,
|
||||
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,
|
||||
];
|
||||
|
||||
interface ParsedTopic {ID: string, endpoint: string, attribute: string, type: 'get' | 'set'}
|
||||
|
||||
export default class Publish extends Extension {
|
||||
async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
parseTopic(topic: string): ParsedTopic | null {
|
||||
const match = topic.match(topicRegex);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return {ID: ID, endpoint: match[2], type: match[3] as 'get' | 'set', attribute: match[4]};
|
||||
}
|
||||
|
||||
parseMessage(parsedTopic: ParsedTopic, data: eventdata.MQTTMessage): KeyValue | null {
|
||||
if (parsedTopic.attribute) {
|
||||
try {
|
||||
return {[parsedTopic.attribute]: JSON.parse(data.message)};
|
||||
} catch (e) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 == decprecated
|
||||
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 unecessary.
|
||||
*/
|
||||
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) {
|
||||
delete message.state;
|
||||
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;
|
||||
|
||||
const re = this.zigbee.resolveEntity(parsedTopic.ID);
|
||||
if (re == null) {
|
||||
this.legacyLog({type: `entity_not_found`, message: {friendly_name: parsedTopic.ID}});
|
||||
logger.error(`Entity '${parsedTopic.ID}' is unknown`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get entity details
|
||||
const definition = re instanceof Device ? re.definition : re.membersDefinitions();
|
||||
const target = re instanceof Group ? re.zh : re.endpoint(parsedTopic.endpoint);
|
||||
if (target == null) {
|
||||
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) {
|
||||
logger.error(`Invalid message '${message}', skipping...`);
|
||||
return;
|
||||
}
|
||||
this.updateMessageHomeAssistant(message, entityState);
|
||||
|
||||
/**
|
||||
* Order state & brightness based on current bulb state
|
||||
*
|
||||
* Not all bulbs support setting the color/color_temp while it is off
|
||||
* this results in inconsistant behavior between different vendors.
|
||||
*
|
||||
* bulb on => move state & brightness to the back
|
||||
* 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));
|
||||
|
||||
// For each attribute call the corresponding converter
|
||||
const usedConverters: {[s: number]: zhc.ToZigbeeConverter[]} = {};
|
||||
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) {
|
||||
let endpointName = parsedTopic.endpoint;
|
||||
let localTarget = target;
|
||||
let endpointOrGroupID = utils.isEndpoint(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;
|
||||
}
|
||||
endpointOrGroupID = localTarget.ID;
|
||||
}
|
||||
|
||||
if (!usedConverters.hasOwnProperty(endpointOrGroupID)) usedConverters[endpointOrGroupID] = [];
|
||||
const converter = converters.find((c) => c.key.includes(key));
|
||||
|
||||
if (parsedTopic.type === 'set' && 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])})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the endpoint_name name is a nubmer, try to map it to a friendlyName
|
||||
if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isEndpoint(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};
|
||||
|
||||
// Strip endpoint name from meta.message properties.
|
||||
if (endpointName) {
|
||||
for (const [key, value] of Object.entries(meta.message)) {
|
||||
if (key.endsWith(endpointName)) {
|
||||
delete meta.message[key];
|
||||
const keyWithoutEndpoint = key.substring(0, key.length - endpointName.length - 1);
|
||||
meta.message[keyWithoutEndpoint] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
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 msg = result.state;
|
||||
|
||||
if (endpointName) {
|
||||
for (const key of Object.keys(msg)) {
|
||||
msg[`${key}_${endpointName}`] = msg[key];
|
||||
delete msg[key];
|
||||
}
|
||||
}
|
||||
|
||||
// filter out attribute listed in filtered_optimistic
|
||||
utils.filterProperties(entitySettings.filtered_optimistic, msg);
|
||||
|
||||
addToToPublish(re, msg);
|
||||
}
|
||||
|
||||
if (result && result.membersState && optimistic) {
|
||||
for (const [ieeeAddr, state] of Object.entries(result.membersState)) {
|
||||
addToToPublish(this.zigbee.resolveEntity(ieeeAddr), state);
|
||||
}
|
||||
}
|
||||
|
||||
this.legacyRetrieveState(re, converter, result, localTarget, key, meta);
|
||||
} else if (parsedTopic.type === 'get' && converter.convertGet) {
|
||||
logger.debug(`Publishing get '${parsedTopic.type}' '${key}' to '${re.name}'`);
|
||||
await converter.convertGet(localTarget, key, meta);
|
||||
} else {
|
||||
logger.error(`No converter available for '${parsedTopic.type}' '${key}' (${message[key]})`);
|
||||
continue;
|
||||
}
|
||||
} catch (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}});
|
||||
}
|
||||
|
||||
usedConverters[endpointOrGroupID].push(converter);
|
||||
}
|
||||
|
||||
for (const [ID, payload] of Object.entries(toPublish)) {
|
||||
if (Object.keys(payload).length != 0) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const utils = require('../util/utils');
|
||||
const debounce = require('debounce');
|
||||
const Extension = require('./extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
class Receive extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.elapsed = {};
|
||||
this.debouncers = {};
|
||||
this.eventBus.on('publishEntityState', (data) => this.onPublishEntityState(data));
|
||||
}
|
||||
|
||||
async onZigbeeStarted() {
|
||||
this.coordinator = this.zigbee.getDevicesByType('Coordinator')[0];
|
||||
}
|
||||
|
||||
async onPublishEntityState(data) {
|
||||
/**
|
||||
* 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.type === 'device' && this.debouncers[data.entity.device.ieeeAddr] &&
|
||||
data.stateChangeReason !== 'publishDebounce') {
|
||||
for (const key of Object.keys(data.payload)) {
|
||||
delete this.debouncers[data.entity.device.ieeeAddr].payload[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publishDebounce(ieeeAddr, payload, time, debounceIgnore) {
|
||||
if (!this.debouncers[ieeeAddr]) {
|
||||
this.debouncers[ieeeAddr] = {
|
||||
payload: {},
|
||||
publish: debounce(() => {
|
||||
this.publishEntityState(ieeeAddr, this.debouncers[ieeeAddr].payload, 'publishDebounce');
|
||||
this.debouncers[ieeeAddr].payload = {};
|
||||
}, time * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isPayloadConflicted(payload, this.debouncers[ieeeAddr].payload, debounceIgnore)) {
|
||||
// publish previous payload immediately
|
||||
this.debouncers[ieeeAddr].publish.flush();
|
||||
}
|
||||
|
||||
// extend debounced payload with current
|
||||
this.debouncers[ieeeAddr].payload = {...this.debouncers[ieeeAddr].payload, ...payload};
|
||||
this.debouncers[ieeeAddr].publish();
|
||||
}
|
||||
|
||||
// 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, oldPayload, debounceIgnore) {
|
||||
let result = false;
|
||||
Object.keys(oldPayload)
|
||||
.filter((key) => (debounceIgnore || []).includes(key))
|
||||
.forEach((key) => {
|
||||
if (typeof newPayload[key] !== 'undefined' && newPayload[key] !== oldPayload[key]) {
|
||||
result = true;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
shouldProcess(type, data, resolvedEntity) {
|
||||
if (type !== 'message' || !resolvedEntity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.device.ieeeAddr === this.coordinator.ieeeAddr) {
|
||||
logger.debug('Ignoring message from coordinator');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!resolvedEntity.definition) {
|
||||
if (data.device.interviewing) {
|
||||
logger.debug(`Skipping message, definition is undefined and still interviewing`);
|
||||
} else {
|
||||
logger.warn(`Received message from unsupported device with Zigbee model '${data.device.modelID}'`);
|
||||
logger.warn(`Please see: https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html.`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onZigbeeEvent(type, data, resolvedEntity) {
|
||||
/**
|
||||
* Handling of re-transmitted Xiaomi messages.
|
||||
* https://github.com/Koenkk/zigbee2mqtt/issues/1238
|
||||
* https://github.com/Koenkk/zigbee2mqtt/issues/3592
|
||||
*
|
||||
* Some Xiaomi router devices re-transmit messages from Xiaomi end devices.
|
||||
* The network address of these message is set to the one of the Xiaomi router.
|
||||
* Therefore it looks like if the message came from the Xiaomi router, while in
|
||||
* fact it came from the end device.
|
||||
* Handling these message would result in false state updates.
|
||||
* The group ID attribute of these message defines the network address of the end device.
|
||||
*/
|
||||
if (type === 'message' && utils.isXiaomiDevice(data.device) && data.device.type === 'Router' && data.groupID) {
|
||||
logger.debug('Handling re-transmitted Xiaomi message');
|
||||
data.device = this.zigbee.getDeviceByNetworkAddress(data.groupID);
|
||||
resolvedEntity = this.zigbee.resolveEntity(data.device);
|
||||
}
|
||||
|
||||
if (!this.shouldProcess(type, data, resolvedEntity)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const converters = resolvedEntity.definition.fromZigbee.filter((c) => {
|
||||
const type = Array.isArray(c.type) ? c.type.includes(data.type) : c.type === data.type;
|
||||
return c.cluster === data.cluster && type;
|
||||
});
|
||||
|
||||
// Check if there is an available converter, genOta messages are not interesting.
|
||||
if (!converters.length && data.cluster !== 'genOta' && data.cluster !== 'genTime') {
|
||||
logger.debug(
|
||||
`No converter available for '${resolvedEntity.definition.model}' with cluster '${data.cluster}' ` +
|
||||
`and type '${data.type}' and data '${stringify(data.data)}'`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert this Zigbee message to a MQTT message.
|
||||
// Get payload for the message.
|
||||
// - 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) => {
|
||||
// Add device linkquality.
|
||||
if (data.hasOwnProperty('linkquality')) {
|
||||
payload.linkquality = data.linkquality;
|
||||
}
|
||||
|
||||
if (settings.get().advanced.elapsed) {
|
||||
const now = Date.now();
|
||||
if (this.elapsed[data.device.ieeeAddr]) {
|
||||
payload.elapsed = now - this.elapsed[data.device.ieeeAddr];
|
||||
}
|
||||
|
||||
this.elapsed[data.device.ieeeAddr] = now;
|
||||
}
|
||||
|
||||
// Check if we have to debounce
|
||||
if (resolvedEntity.settings.debounce) {
|
||||
this.publishDebounce(
|
||||
data.device.ieeeAddr, payload, resolvedEntity.settings.debounce,
|
||||
resolvedEntity.settings.debounce_ignore,
|
||||
);
|
||||
} else {
|
||||
this.publishEntityState(data.device.ieeeAddr, payload);
|
||||
}
|
||||
};
|
||||
|
||||
const meta = {device: data.device, logger, state: this.state.get(data.device.ieeeAddr)};
|
||||
let payload = {};
|
||||
converters.forEach((converter) => {
|
||||
const converted = converter.convert(
|
||||
resolvedEntity.definition, data, publish, resolvedEntity.settings, meta,
|
||||
);
|
||||
if (converted) {
|
||||
payload = {...payload, ...converted};
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(payload).length) {
|
||||
publish(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Receive;
|
||||
@@ -0,0 +1,161 @@
|
||||
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';
|
||||
|
||||
type DebounceFunction = (() => void) & { clear(): void; } & { flush(): void; };
|
||||
|
||||
export default class Receive extends Extension {
|
||||
private elapsed: {[s: string]: number} = {};
|
||||
private debouncers: {[s: string]: {payload: KeyValue, publish: DebounceFunction }} = {};
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.eventBus.onPublishEntityState(this, this.onPublishEntityState);
|
||||
this.eventBus.onDeviceMessage(this, this.onDeviceMessage);
|
||||
}
|
||||
|
||||
@bind async onPublishEntityState(data: eventdata.PublishEntityState): Promise<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') {
|
||||
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 {
|
||||
if (!this.debouncers[device.ieeeAddr]) {
|
||||
this.debouncers[device.ieeeAddr] = {
|
||||
payload: {},
|
||||
publish: debounce(() => {
|
||||
this.publishEntityState(device, this.debouncers[device.ieeeAddr].payload, 'publishDebounce');
|
||||
this.debouncers[device.ieeeAddr].payload = {};
|
||||
}, time * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isPayloadConflicted(payload, this.debouncers[device.ieeeAddr].payload, debounceIgnore)) {
|
||||
// publish previous payload immediately
|
||||
this.debouncers[device.ieeeAddr].publish.flush();
|
||||
}
|
||||
|
||||
// extend debounced payload with current
|
||||
this.debouncers[device.ieeeAddr].payload = {...this.debouncers[device.ieeeAddr].payload, ...payload};
|
||||
this.debouncers[device.ieeeAddr].publish();
|
||||
}
|
||||
|
||||
// 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 {
|
||||
let result = false;
|
||||
Object.keys(oldPayload)
|
||||
.filter((key) => (debounceIgnore || []).includes(key))
|
||||
.forEach((key) => {
|
||||
if (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 */
|
||||
if (!data.device) return;
|
||||
|
||||
if (!this.shouldProcess(data)) {
|
||||
utils.publishLastSeen({device: data.device, reason: 'messageEmitted'},
|
||||
settings.get(), true, this.publishEntityState);
|
||||
return;
|
||||
}
|
||||
|
||||
const converters = data.device.definition.fromZigbee.filter((c) => {
|
||||
const type = Array.isArray(c.type) ? c.type.includes(data.type) : c.type === data.type;
|
||||
return c.cluster === data.cluster && type;
|
||||
});
|
||||
|
||||
// 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);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert this Zigbee message to a MQTT message.
|
||||
// Get payload for the message.
|
||||
// - 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 => {
|
||||
if (settings.get().advanced.elapsed) {
|
||||
const now = Date.now();
|
||||
if (this.elapsed[data.device.ieeeAddr]) {
|
||||
payload.elapsed = now - this.elapsed[data.device.ieeeAddr];
|
||||
}
|
||||
|
||||
this.elapsed[data.device.ieeeAddr] = now;
|
||||
}
|
||||
|
||||
// Check if we have to debounce
|
||||
if (data.device.options.debounce) {
|
||||
this.publishDebounce(data.device, payload, data.device.options.debounce,
|
||||
data.device.options.debounce_ignore);
|
||||
} else {
|
||||
this.publishEntityState(data.device, payload);
|
||||
}
|
||||
};
|
||||
|
||||
const meta = {device: data.device.zh, logger, state: this.state.get(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);
|
||||
if (converted) {
|
||||
payload = {...payload, ...converted};
|
||||
}
|
||||
} catch (error) /* istanbul ignore next */ {
|
||||
logger.error(`Exception while calling fromZigbee converter: ${error.message}}`);
|
||||
logger.debug(error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length) {
|
||||
publish(payload);
|
||||
} else {
|
||||
utils.publishLastSeen({device: data.device, reason: 'messageEmitted'},
|
||||
settings.get(), true, this.publishEntityState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const logger = require('../util/logger');
|
||||
const settings = require('../util/settings');
|
||||
const ZNLDP12LM = zigbeeHerdsmanConverters.devices.find((d) => d.model === 'ZNLDP12LM');
|
||||
const utils = require('../util/utils');
|
||||
const Extension = require('./extension');
|
||||
const debounce = require('debounce');
|
||||
const ZigbeeHerdsman = require('zigbee-herdsman');
|
||||
|
||||
const defaultConfiguration = {
|
||||
minimumReportInterval: 3, maximumReportInterval: 300, reportableChange: 1,
|
||||
};
|
||||
|
||||
const devicesNotSupportingReporting = [
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'CC2530.ROUTER'),
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'BASICZBR3'),
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'ZM-CSW032-D'),
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'TS0001'),
|
||||
];
|
||||
|
||||
const reportKey = 1;
|
||||
|
||||
const getColorCapabilities = async (endpoint) => {
|
||||
if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') === undefined) {
|
||||
await endpoint.read('lightingColorCtrl', ['colorCapabilities']);
|
||||
}
|
||||
|
||||
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities');
|
||||
return {
|
||||
colorTemperature: (value & 1<<4) > 0,
|
||||
colorXY: (value & 1<<3) > 0,
|
||||
};
|
||||
};
|
||||
|
||||
const clusters = {
|
||||
'genOnOff': [
|
||||
{attribute: 'onOff', ...defaultConfiguration, minimumReportInterval: 0, reportableChange: 0},
|
||||
],
|
||||
'genLevelCtrl': [
|
||||
{attribute: 'currentLevel', ...defaultConfiguration},
|
||||
],
|
||||
'lightingColorCtrl': [
|
||||
{
|
||||
attribute: 'colorTemperature', ...defaultConfiguration,
|
||||
condition: async (endpoint) => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
},
|
||||
{
|
||||
attribute: 'currentX', ...defaultConfiguration,
|
||||
condition: async (endpoint) => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
{
|
||||
attribute: 'currentY', ...defaultConfiguration,
|
||||
condition: async (endpoint) => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
],
|
||||
'closuresWindowCovering': [
|
||||
{attribute: 'currentPositionLiftPercentage', ...defaultConfiguration},
|
||||
{attribute: 'currentPositionTiltPercentage', ...defaultConfiguration},
|
||||
],
|
||||
};
|
||||
|
||||
const pollOnMessage = [
|
||||
{
|
||||
// Key is used this.pollDebouncers uniqueness
|
||||
key: 1,
|
||||
// On messages that have the cluster and type of below
|
||||
cluster: {
|
||||
manuSpecificPhilips: [
|
||||
{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: {}},
|
||||
],
|
||||
},
|
||||
// Read the following attributes
|
||||
read: {cluster: 'genLevelCtrl', attributes: ['currentLevel']},
|
||||
// When the bound devices/members of group have the following manufacturerID
|
||||
manufacturerID: ZigbeeHerdsman.Zcl.ManufacturerCode.Philips,
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
cluster: {
|
||||
genOnOff: [
|
||||
{type: 'commandOn', data: {}},
|
||||
{type: 'commandOff', data: {}},
|
||||
{type: 'commandOffWithEffect', data: {}},
|
||||
],
|
||||
manuSpecificPhilips: [
|
||||
{type: 'commandHueNotification', data: {button: 1}},
|
||||
{type: 'commandHueNotification', data: {button: 4}},
|
||||
],
|
||||
},
|
||||
read: {cluster: 'genOnOff', attributes: ['onOff']},
|
||||
manufacturerID: ZigbeeHerdsman.Zcl.ManufacturerCode.Philips,
|
||||
},
|
||||
];
|
||||
|
||||
class Report extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.queue = new Set();
|
||||
this.failed = new Set();
|
||||
this.pollDebouncers = {};
|
||||
this.enabled = settings.get().advanced.report;
|
||||
}
|
||||
|
||||
shouldIgnoreClusterForDevice(cluster, definition) {
|
||||
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(resolvedEntity) {
|
||||
const {device, definition} = resolvedEntity;
|
||||
|
||||
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.endpoints) {
|
||||
for (const [cluster, configuration] of Object.entries(clusters)) {
|
||||
if (ep.supportsInputCluster(cluster) && !this.shouldIgnoreClusterForDevice(cluster, 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.coordinatorEndpoint) :
|
||||
await ep.unbind(cluster, this.coordinatorEndpoint);
|
||||
|
||||
await ep.configureReporting(cluster, items);
|
||||
logger.info(
|
||||
`Successfully ${term2} reporting for '${device.ieeeAddr}' - ${ep.ID} - ${cluster}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.enabled) {
|
||||
device.meta.reporting = reportKey;
|
||||
} else {
|
||||
delete device.meta.reporting;
|
||||
this.eventBus.emit('reportingDisabled', {device});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to ${term1.toLowerCase()} reporting for '${device.ieeeAddr}' - ${error.stack}`,
|
||||
);
|
||||
|
||||
this.failed.add(device.ieeeAddr);
|
||||
}
|
||||
|
||||
device.save();
|
||||
this.queue.delete(device.ieeeAddr);
|
||||
}
|
||||
|
||||
shouldSetupReporting(resolvedEntity, messageType) {
|
||||
if (!resolvedEntity || !resolvedEntity.device || !resolvedEntity.definition ||
|
||||
messageType === 'deviceLeave') return false;
|
||||
|
||||
const {device, definition} = resolvedEntity;
|
||||
// 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.
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/966
|
||||
if (this.enabled && messageType === 'deviceAnnounce' && utils.isIkeaTradfriDevice(device)) 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.manufacturerName === 'Philips' && philipsIgnoreSw.includes(device.softwareBuildID)) return false;
|
||||
|
||||
if (resolvedEntity.device.interviewing === true) return false;
|
||||
if (device.type !== 'Router' || device.powerSource === 'Battery') return false;
|
||||
// Gledopto devices don't support reporting.
|
||||
if (devicesNotSupportingReporting.includes(definition) || definition.vendor === 'Gledopto') return false;
|
||||
|
||||
if (this.enabled && device.meta.hasOwnProperty('reporting') && device.meta.reporting === reportKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.enabled && !device.meta.hasOwnProperty('reporting')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async onZigbeeStarted() {
|
||||
this.coordinatorEndpoint = this.zigbee.getDevicesByType('Coordinator')[0].getEndpoint(1);
|
||||
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
if (this.shouldSetupReporting(resolvedEntity, null)) {
|
||||
await this.setupReporting(resolvedEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onZigbeeEvent(type, data, resolvedEntity) {
|
||||
if (this.shouldSetupReporting(resolvedEntity, type)) {
|
||||
await this.setupReporting(resolvedEntity);
|
||||
}
|
||||
|
||||
if (type === 'message') {
|
||||
this.poll(data);
|
||||
}
|
||||
}
|
||||
|
||||
poll(message) {
|
||||
/**
|
||||
* This method poll bound endpoints and group members for state changes.
|
||||
*
|
||||
* A use case is e.g. a Hue Dimmer switch bound to a Hue bulb.
|
||||
* Hue bulbs only report their on/off state.
|
||||
* 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[message.cluster] && p.cluster[message.cluster].find((c) => c.type === message.type &&
|
||||
utils.equalsPartial(message.data, c.data)),
|
||||
);
|
||||
|
||||
if (polls.length) {
|
||||
let toPoll = [];
|
||||
|
||||
// Add bound devices
|
||||
toPoll = toPoll.concat([].concat(...message.device.endpoints.map((e) =>
|
||||
e.binds.map((e) => e).filter((e) => e.target))));
|
||||
toPoll = toPoll.filter((e) => e.target.constructor.name === 'Endpoint');
|
||||
toPoll = toPoll.filter((e) => e.target.getDevice().type !== 'Coordinator');
|
||||
toPoll = toPoll.map((e) => e.target);
|
||||
|
||||
// If message is published to a group, add members of the group
|
||||
const group = message.groupID !== 0 ? this.zigbee.getGroupByID(message.groupID) : null;
|
||||
if (group) {
|
||||
toPoll = toPoll.concat(group.members);
|
||||
}
|
||||
|
||||
toPoll = new Set(toPoll);
|
||||
|
||||
for (const endpoint of toPoll) {
|
||||
for (const poll of polls) {
|
||||
if (poll.manufacturerID !== endpoint.getDevice().manufacturerID) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${endpoint.deviceIeeeAddress}_${endpoint.ID}_${poll.key}`;
|
||||
if (!this.pollDebouncers[key]) {
|
||||
this.pollDebouncers[key] = debounce(async () => {
|
||||
await endpoint.read(poll.read.cluster, poll.read.attributes);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
this.pollDebouncers[key]();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Report;
|
||||
@@ -0,0 +1,79 @@
|
||||
/* eslint-disable brace-style */
|
||||
import * as settings from '../util/settings';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
|
||||
export default class Device {
|
||||
public zh: zh.Device;
|
||||
private _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 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;
|
||||
}
|
||||
|
||||
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);
|
||||
} else {
|
||||
return this.definition.exposes;
|
||||
}
|
||||
}
|
||||
|
||||
ensureInSettings(): void {
|
||||
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];
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
/* istanbul ignore next */
|
||||
return name === 'default' ? null : name;
|
||||
}
|
||||
|
||||
isIkeaTradfri(): boolean {return this.zh.manufacturerID === 4476;}
|
||||
|
||||
isDevice(): this is Device {return true;}
|
||||
/* istanbul ignore next */
|
||||
isGroup(): this is Group {return false;}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/* eslint-disable brace-style */
|
||||
import * as settings from '../util/settings';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
|
||||
export default class Group {
|
||||
public zh: zh.Group;
|
||||
private resolveDevice: (ieeeAddr: string) => Device;
|
||||
|
||||
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();}
|
||||
|
||||
constructor(group: zh.Group, resolveDevice: (ieeeAddr: string) => Device) {
|
||||
this.zh = group;
|
||||
this.resolveDevice = resolveDevice;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
membersDefinitions(): zhc.Definition[] {
|
||||
return this.zh.members.map((m) =>
|
||||
zigbeeHerdsmanConverters.findByDevice(m.getDevice())).filter((d) => d) as zhc.Definition[];
|
||||
}
|
||||
|
||||
isDevice(): this is Device {return false;}
|
||||
isGroup(): this is Group {return true;}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
const mqtt = require('mqtt');
|
||||
const logger = require('./util/logger');
|
||||
const settings = require('./util/settings');
|
||||
const fs = require('fs');
|
||||
const events = require('events');
|
||||
|
||||
class MQTT extends events.EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const mqttSettings = settings.get().mqtt;
|
||||
logger.info(`Connecting to MQTT server at ${mqttSettings.server}`);
|
||||
|
||||
const options = {
|
||||
will: {
|
||||
topic: `${settings.get().mqtt.base_topic}/bridge/state`,
|
||||
payload: 'offline',
|
||||
retain: true,
|
||||
},
|
||||
};
|
||||
|
||||
if (mqttSettings.version) {
|
||||
options.protocolVersion = mqttSettings.version;
|
||||
}
|
||||
|
||||
if (mqttSettings.keepalive) {
|
||||
logger.debug(`Using MQTT keepalive: ${mqttSettings.keepalive}`);
|
||||
options.keepalive = mqttSettings.keepalive;
|
||||
}
|
||||
|
||||
if (mqttSettings.ca) {
|
||||
logger.debug(`MQTT SSL/TLS: Path to CA certificate = ${mqttSettings.ca}`);
|
||||
options.ca = fs.readFileSync(mqttSettings.ca);
|
||||
}
|
||||
|
||||
if (mqttSettings.key && mqttSettings.cert) {
|
||||
logger.debug(`MQTT SSL/TLS: Path to client key = ${mqttSettings.key}`);
|
||||
logger.debug(`MQTT SSL/TLS: Path to client certificate = ${mqttSettings.cert}`);
|
||||
options.key = fs.readFileSync(mqttSettings.key);
|
||||
options.cert = fs.readFileSync(mqttSettings.cert);
|
||||
}
|
||||
|
||||
if (mqttSettings.user && mqttSettings.password) {
|
||||
options.username = mqttSettings.user;
|
||||
options.password = mqttSettings.password;
|
||||
}
|
||||
|
||||
if (mqttSettings.client_id) {
|
||||
logger.debug(`Using MQTT client ID: '${mqttSettings.client_id}'`);
|
||||
options.clientId = mqttSettings.client_id;
|
||||
}
|
||||
|
||||
if (mqttSettings.hasOwnProperty('reject_unauthorized') && !mqttSettings.reject_unauthorized) {
|
||||
logger.debug(`MQTT reject_unauthorized set false, ignoring certificate warnings.`);
|
||||
options.rejectUnauthorized = false;
|
||||
}
|
||||
|
||||
// Set timer at interval to check if connected to MQTT server.
|
||||
const interval = 10 * 1000; // seconds * 1000.
|
||||
this.connectionTimer = setInterval(() => {
|
||||
if (this.client.reconnecting) {
|
||||
logger.error('Not connected to MQTT server!');
|
||||
}
|
||||
}, interval);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.client = mqtt.connect(mqttSettings.server, options);
|
||||
|
||||
this.client.on('connect', () => {
|
||||
logger.info('Connected to MQTT server');
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.client.on('message', this.onMessage);
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
clearTimeout(this.connectionTimer);
|
||||
this.connectionTimer = null;
|
||||
await this.publish('bridge/state', 'offline', {retain: true, qos: 0});
|
||||
logger.info('Disconnecting from MQTT server');
|
||||
this.client.end();
|
||||
}
|
||||
|
||||
subscribe(topic) {
|
||||
this.client.subscribe(topic);
|
||||
}
|
||||
|
||||
onMessage(topic, message) {
|
||||
this.emit('message', {topic, message: message + ''});
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this.client && !this.client.reconnecting;
|
||||
}
|
||||
|
||||
async publish(topic, payload, options, base=settings.get().mqtt.base_topic, skipLog=false) {
|
||||
topic = `${base}/${topic}`;
|
||||
options = {qos: 0, retain: false, ...options};
|
||||
|
||||
if (!this.isConnected()) {
|
||||
if (!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}'`);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.client.publish(topic, payload, options, () => {
|
||||
this.emit('publishedMessage', {topic, payload, options});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MQTT;
|
||||
@@ -0,0 +1,192 @@
|
||||
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';
|
||||
|
||||
export default class MQTT {
|
||||
private publishedTopics: Set<string> = new Set();
|
||||
private connectionTimer: NodeJS.Timeout;
|
||||
private client: mqtt.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}} = {};
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
const mqttSettings = settings.get().mqtt;
|
||||
logger.info(`Connecting to MQTT server at ${mqttSettings.server}`);
|
||||
|
||||
const options: mqtt.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,
|
||||
qos: 1,
|
||||
},
|
||||
};
|
||||
|
||||
if (mqttSettings.version) {
|
||||
options.protocolVersion = mqttSettings.version;
|
||||
}
|
||||
|
||||
if (mqttSettings.keepalive) {
|
||||
logger.debug(`Using MQTT keepalive: ${mqttSettings.keepalive}`);
|
||||
options.keepalive = mqttSettings.keepalive;
|
||||
}
|
||||
|
||||
if (mqttSettings.ca) {
|
||||
logger.debug(`MQTT SSL/TLS: Path to CA certificate = ${mqttSettings.ca}`);
|
||||
options.ca = fs.readFileSync(mqttSettings.ca);
|
||||
}
|
||||
|
||||
if (mqttSettings.key && mqttSettings.cert) {
|
||||
logger.debug(`MQTT SSL/TLS: Path to client key = ${mqttSettings.key}`);
|
||||
logger.debug(`MQTT SSL/TLS: Path to client certificate = ${mqttSettings.cert}`);
|
||||
options.key = fs.readFileSync(mqttSettings.key);
|
||||
options.cert = fs.readFileSync(mqttSettings.cert);
|
||||
}
|
||||
|
||||
if (mqttSettings.user && mqttSettings.password) {
|
||||
logger.debug(`Using MQTT login with username: ${mqttSettings.user}`);
|
||||
options.username = mqttSettings.user;
|
||||
options.password = mqttSettings.password;
|
||||
} else {
|
||||
logger.debug(`Using MQTT anonymous login`);
|
||||
}
|
||||
|
||||
if (mqttSettings.client_id) {
|
||||
logger.debug(`Using MQTT client ID: '${mqttSettings.client_id}'`);
|
||||
options.clientId = mqttSettings.client_id;
|
||||
}
|
||||
|
||||
if (mqttSettings.hasOwnProperty('reject_unauthorized') && !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());
|
||||
|
||||
const onConnect = this.onConnect;
|
||||
this.client.on('connect', async () => {
|
||||
await onConnect();
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => reject(err));
|
||||
this.client.on('message', this.onMessage);
|
||||
});
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
this.initialConnect = false;
|
||||
this.subscribe(`${settings.get().mqtt.base_topic}/#`);
|
||||
}
|
||||
|
||||
async publishStateOnline(): Promise<void> {
|
||||
await this.publish('bridge/state', utils.availabilityPayload('online', settings.get()), {retain: true, qos: 0});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
subscribe(topic: string): void {
|
||||
this.client.subscribe(topic);
|
||||
}
|
||||
|
||||
@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 + ''});
|
||||
}
|
||||
|
||||
if (this.republishRetainedTimer && topic == `${settings.get().mqtt.base_topic}/bridge/state`) {
|
||||
clearTimeout(this.republishRetainedTimer);
|
||||
this.republishRetainedTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.client && !this.client.reconnecting;
|
||||
}
|
||||
|
||||
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}`;
|
||||
|
||||
if (skipReceive) {
|
||||
this.publishedTopics.add(topic);
|
||||
}
|
||||
|
||||
if (options.retain) {
|
||||
if (payload) {
|
||||
this.retainedMessages[topic] =
|
||||
{payload, options, skipReceive, skipLog, topic: topic.substring(base.length + 1), base};
|
||||
} else {
|
||||
delete this.retainedMessages[topic];
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.emitMQTTMessagePublished({topic, payload, options: {...defaultOptions, ...options}});
|
||||
|
||||
if (!this.isConnected()) {
|
||||
if (!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};
|
||||
if (settings.get().mqtt.force_disable_retain) {
|
||||
actualOptions.retain = false;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.client.publish(topic, payload, actualOptions, () => resolve());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
const logger = require('./util/logger');
|
||||
const data = require('./util/data');
|
||||
const settings = require('./util/settings');
|
||||
const fs = require('fs');
|
||||
const objectAssignDeep = require('object-assign-deep');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
const saveInterval = 1000 * 60 * 5; // 5 minutes
|
||||
|
||||
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',
|
||||
];
|
||||
|
||||
class State {
|
||||
constructor(eventBus) {
|
||||
this.state = {};
|
||||
this.file = data.joinPath('state.json');
|
||||
this.timer = null;
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
start() {
|
||||
this._load();
|
||||
|
||||
// Save the state on every interval
|
||||
this.clearTimer();
|
||||
this.timer = setInterval(() => this.save(), saveInterval);
|
||||
}
|
||||
|
||||
clearTimer() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.clearTimer();
|
||||
this.save();
|
||||
}
|
||||
|
||||
_load() {
|
||||
if (fs.existsSync(this.file)) {
|
||||
try {
|
||||
this.state = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
||||
logger.debug(`Loaded state from file ${this.file}`);
|
||||
} catch (e) {
|
||||
logger.debug(`Failed to load state from file ${this.file} (corrupt file?)`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Can't load state from file ${this.file} (doesn't exist)`);
|
||||
}
|
||||
}
|
||||
|
||||
save() {
|
||||
if (settings.get().advanced.cache_state_persistent) {
|
||||
logger.debug(`Saving state to file ${this.file}`);
|
||||
const json = stringify(this.state, null, 4);
|
||||
try {
|
||||
fs.writeFileSync(this.file, json, 'utf8');
|
||||
} catch (e) {
|
||||
logger.error(`Failed to write state to '${this.file}' (${e.message})`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Not saving state`);
|
||||
}
|
||||
}
|
||||
|
||||
exists(ID) {
|
||||
return this.state.hasOwnProperty(ID);
|
||||
}
|
||||
|
||||
get(ID) {
|
||||
return this.state[ID];
|
||||
}
|
||||
|
||||
set(ID, state, reason=null) {
|
||||
const toState = objectAssignDeep.noMutate(state);
|
||||
|
||||
for (const property of Object.keys(toState)) {
|
||||
if (dontCacheProperties.find((p) => property.match(p))) {
|
||||
delete toState[property];
|
||||
}
|
||||
}
|
||||
|
||||
const fromState = this.state[ID];
|
||||
|
||||
this.state[ID] = toState;
|
||||
|
||||
this.eventBus.emit('stateChange', {ID, from: fromState, to: toState, reason});
|
||||
}
|
||||
|
||||
removeKey(ID, path) {
|
||||
if (this.exists(ID)) {
|
||||
let state = this.state[ID];
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const key = path[i];
|
||||
if (i === path.length - 1) {
|
||||
delete state[key];
|
||||
} else {
|
||||
if (state[key]) {
|
||||
state = state[key];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(ID) {
|
||||
if (this.exists(ID)) {
|
||||
delete this.state[ID];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = State;
|
||||
@@ -0,0 +1,93 @@
|
||||
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';
|
||||
|
||||
const saveInterval = 1000 * 60 * 5; // 5 minutes
|
||||
|
||||
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',
|
||||
];
|
||||
|
||||
class State {
|
||||
private state: {[s: string | number]: KeyValue} = {};
|
||||
private file = data.joinPath('state.json');
|
||||
private timer: NodeJS.Timer = null;
|
||||
private eventBus: EventBus;
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.load();
|
||||
|
||||
// Save the state on every interval
|
||||
this.timer = setInterval(() => this.save(), saveInterval);
|
||||
this.eventBus.onDeviceLeave(this, (data) => delete this.state[data.ieeeAddr]);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.eventBus.removeListeners(this);
|
||||
clearTimeout(this.timer);
|
||||
this.save();
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
if (fs.existsSync(this.file)) {
|
||||
try {
|
||||
this.state = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
||||
logger.debug(`Loaded state from file ${this.file}`);
|
||||
} catch (e) {
|
||||
logger.debug(`Failed to load state from file ${this.file} (corrupt file?)`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Can't load state from file ${this.file} (doesn't exist)`);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
fs.writeFileSync(this.file, json, 'utf8');
|
||||
} catch (e) {
|
||||
logger.error(`Failed to write state to '${this.file}' (${e.message})`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Not saving state`);
|
||||
}
|
||||
}
|
||||
|
||||
exists(entity: Device | Group): boolean {
|
||||
return this.state.hasOwnProperty(entity.ID);
|
||||
}
|
||||
|
||||
get(entity: Group | Device): KeyValue {
|
||||
return this.state[entity.ID] || {};
|
||||
}
|
||||
|
||||
set(entity: Group | Device, update: KeyValue, reason: string=null): KeyValue {
|
||||
const fromState = this.state[entity.ID] || {};
|
||||
const toState = objectAssignDeep({}, fromState, update);
|
||||
const newCache = {...toState};
|
||||
const entityDontCacheProperties = entity.options.filtered_cache || [];
|
||||
|
||||
utils.filterProperties(dontCacheProperties.concat(entityDontCacheProperties), newCache);
|
||||
|
||||
this.state[entity.ID] = newCache;
|
||||
this.eventBus.emitStateChange({entity, from: fromState, to: toState, reason, update});
|
||||
return toState;
|
||||
}
|
||||
|
||||
remove(ID: string | number): void {
|
||||
delete this.state[ID];
|
||||
}
|
||||
}
|
||||
|
||||
export default State;
|
||||
@@ -0,0 +1,3 @@
|
||||
declare module 'json-stable-stringify-without-jsonify' {
|
||||
export default function (obj: unknown): string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
declare module 'mkdir-recursive' {
|
||||
export function mkdirSync(path: string): void;
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/* eslint-disable camelcase */
|
||||
import type {
|
||||
Device as ZHDevice,
|
||||
Group as ZHGroup,
|
||||
Endpoint as ZHEndpoint,
|
||||
} from 'zigbee-herdsman/dist/controller/model';
|
||||
|
||||
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';
|
||||
|
||||
declare global {
|
||||
// Define some class types as global
|
||||
type EventBus = TypeEventBus;
|
||||
type MQTT = TypeMQTT;
|
||||
type Zigbee = TypeZigbee;
|
||||
type Group = TypeGroup;
|
||||
type Device = TypeDevice;
|
||||
type State = TypeState;
|
||||
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}
|
||||
|
||||
// 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;
|
||||
interface Bind {
|
||||
cluster: zh.Cluster;
|
||||
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,
|
||||
access: number, property: string, unit?: string,
|
||||
value_min?: number, value_max?: 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<boolean>;
|
||||
updateToLatest: (device: zh.Device, logger: Logger,
|
||||
onProgress: (progress: number, remaining: number) => void) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 StateChange = {
|
||||
entity: Device | Group, from: KeyValue, to: KeyValue, reason: string | null, 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 DeviceMessage = {
|
||||
type: ZHEvents.MessagePayloadType;
|
||||
device: Device;
|
||||
endpoint: zh.Endpoint;
|
||||
linkquality: number;
|
||||
groupID: number;
|
||||
cluster: string | number;
|
||||
data: KeyValue | Array<string | number>;
|
||||
meta: {zclTransactionSequenceNumber?: number;};
|
||||
};
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
interface DeviceOptions {
|
||||
ID?: string,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
declare module 'zigbee2mqtt-frontend' {
|
||||
export function getPath(): string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
const path = require('path');
|
||||
|
||||
let dataPath = null;
|
||||
|
||||
function load() {
|
||||
if (process.env.ZIGBEE2MQTT_DATA) {
|
||||
dataPath = process.env.ZIGBEE2MQTT_DATA;
|
||||
} else {
|
||||
dataPath = path.join(__dirname, '..', '..', 'data');
|
||||
dataPath = path.normalize(dataPath);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
module.exports = {
|
||||
joinPath: (file) => path.join(dataPath, file),
|
||||
getPath: () => dataPath,
|
||||
|
||||
// For test only.
|
||||
_reload: () => load(),
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import path from '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);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
function joinPath(file: string): string {
|
||||
return path.join(dataPath, file);
|
||||
}
|
||||
|
||||
function getPath(): string {
|
||||
return dataPath;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
function testingOnlyReload(): void {
|
||||
load();
|
||||
}
|
||||
|
||||
export default {joinPath, getPath, testingOnlyReload};
|
||||
@@ -1,140 +0,0 @@
|
||||
const winston = require('winston');
|
||||
const moment = require('moment');
|
||||
const settings = require('./settings');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fx = require('mkdir-recursive');
|
||||
const rimraf = require('rimraf');
|
||||
const colorizer = winston.format.colorize();
|
||||
const assert = require('assert');
|
||||
|
||||
// What transports to enable
|
||||
const output = settings.get().advanced.log_output;
|
||||
|
||||
// Directory to log to
|
||||
const timestamp = moment(Date.now()).format('YYYY-MM-DD.HH-mm-ss');
|
||||
const directory = settings.get().advanced.log_directory.replace('%TIMESTAMP%', timestamp);
|
||||
const logFilename = settings.get().advanced.log_file.replace('%TIMESTAMP%', timestamp);
|
||||
|
||||
// Make sure that log directoy exsists when not logging to stdout only
|
||||
if (output.includes('file')) {
|
||||
fx.mkdirSync(directory);
|
||||
}
|
||||
|
||||
// Determine the log level.
|
||||
const level = settings.get().advanced.log_level;
|
||||
const validLevels = ['info', 'error', 'warn', 'debug'];
|
||||
assert(validLevels.includes(level), `'${level}' is not a valid log_level, use one of '${validLevels.join(', ')}'`);
|
||||
|
||||
const levelWithCompensatedLength = {
|
||||
'info': 'info ',
|
||||
'error': 'error',
|
||||
'warn': 'warn ',
|
||||
'debug': 'debug',
|
||||
};
|
||||
|
||||
/* istanbul ignore next */
|
||||
const timestampFormat = () => moment().format(settings.get().advanced.timestamp_format);
|
||||
|
||||
// Setup default console logger
|
||||
const transportsToUse = [
|
||||
new winston.transports.Console({
|
||||
level,
|
||||
silent: !output.includes('console'),
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({format: timestampFormat}),
|
||||
winston.format.printf(/* istanbul ignore next */(info) => {
|
||||
let {timestamp, level, message} = info;
|
||||
level = level === 'warning' ? 'warn' : level;
|
||||
const prefix = colorizer.colorize(level, `Zigbee2MQTT:${levelWithCompensatedLength[level]}`);
|
||||
return `${prefix} ${timestamp.split('.')[0]}: ${message}`;
|
||||
}),
|
||||
),
|
||||
}),
|
||||
];
|
||||
|
||||
// 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 = {
|
||||
filename: path.join(directory, logFilename),
|
||||
json: false,
|
||||
level,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({format: timestampFormat}),
|
||||
winston.format.printf(/* istanbul ignore next */(info) => {
|
||||
let {timestamp, level, message} = info;
|
||||
level = level === 'warning' ? 'warn' : level;
|
||||
return `${levelWithCompensatedLength[level]} ${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')) {
|
||||
transportsToUse.push(new winston.transports.File(transportFileOptions));
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (output.includes('syslog')) {
|
||||
require('winston-syslog').Syslog;
|
||||
const options = {
|
||||
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();
|
||||
transportsToUse.push(new winston.transports.Syslog(options));
|
||||
}
|
||||
|
||||
// Create logger
|
||||
const logger = winston.createLogger({transports: transportsToUse, levels: winston.config.syslog.levels});
|
||||
|
||||
// Cleanup any old log directory.
|
||||
function cleanup() {
|
||||
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};
|
||||
});
|
||||
|
||||
directories.sort((a, b) => b.birth - a.birth);
|
||||
directories = directories.slice(10, directories.length);
|
||||
directories.forEach((dir) => {
|
||||
logger.debug(`Removing old log directory '${dir.path}'`);
|
||||
rimraf.sync(dir.path);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.cleanup = cleanup;
|
||||
logger.getLevel = () => transportsToUse[0].level;
|
||||
logger.setLevel = (level) => {
|
||||
transportsToUse.forEach((transport) => transport.level = level);
|
||||
};
|
||||
|
||||
// Print to user what logging is enabled
|
||||
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}`);
|
||||
}
|
||||
logger.cleanup();
|
||||
} else if (output.includes('console')) {
|
||||
logger.info(`Logging to console only'`);
|
||||
}
|
||||
|
||||
// winston.config.syslog.levels doesnt have warn, but is required for syslog.
|
||||
/* istanbul ignore next */
|
||||
logger.warn = (message) => logger.warning(message);
|
||||
|
||||
module.exports = logger;
|
||||
@@ -0,0 +1,225 @@
|
||||
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 rimraf from 'rimraf';
|
||||
import assert from 'assert';
|
||||
|
||||
const colorizer = winston.format.colorize();
|
||||
|
||||
type Z2MLogLevel = 'warn' | 'debug' | 'info' | 'error';
|
||||
type WinstonLogLevel = 'warning' | 'debug' | 'info' | 'error';
|
||||
|
||||
const z2mToWinstonLevel = (level: Z2MLogLevel): WinstonLogLevel => level === 'warn' ? 'warning' : level;
|
||||
const winstonToZ2mLevel = (level: WinstonLogLevel): Z2MLogLevel => level === 'warning' ? 'warn' : level;
|
||||
|
||||
const levelWithCompensatedLength: {[s: string]: string} = {
|
||||
'info': 'info ',
|
||||
'error': 'error',
|
||||
'warn': 'warn ',
|
||||
'debug': 'debug',
|
||||
};
|
||||
|
||||
let logger: winston.Logger;
|
||||
let fileTransport : winston.transport;
|
||||
let output: string[];
|
||||
let directory: string;
|
||||
let logFilename: string;
|
||||
let transportsToUse: winston.transport[];
|
||||
|
||||
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 directoy exsists 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'),
|
||||
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}`;
|
||||
}),
|
||||
),
|
||||
}),
|
||||
];
|
||||
|
||||
// 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};
|
||||
});
|
||||
|
||||
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}'`);
|
||||
rimraf.sync(dir.path);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
cleanup();
|
||||
} else if (output.includes('console')) {
|
||||
logger.info(`Logging to console only'`);
|
||||
}
|
||||
}
|
||||
|
||||
function addTransport(transport: winston.transport): void {
|
||||
transport.level = transportsToUse[0].level;
|
||||
logger.add(transport);
|
||||
}
|
||||
|
||||
function getLevel(): Z2MLogLevel {
|
||||
return winstonToZ2mLevel(transportsToUse[0].level as WinstonLogLevel);
|
||||
}
|
||||
|
||||
function setLevel(level: Z2MLogLevel): void {
|
||||
logger.transports.forEach((transport) => transport.level = z2mToWinstonLevel(level as Z2MLogLevel));
|
||||
}
|
||||
|
||||
function warn(message: string): void {
|
||||
// winston.config.syslog.levels doesnt have warn, but is required for syslog.
|
||||
logger.warning(message);
|
||||
}
|
||||
|
||||
function warning(message: string): void {
|
||||
logger.warning(message);
|
||||
}
|
||||
|
||||
function info(message: string): void {
|
||||
logger.info(message);
|
||||
}
|
||||
|
||||
function debug(message: string): void {
|
||||
logger.debug(message);
|
||||
}
|
||||
|
||||
function error(message: string): void {
|
||||
logger.error(message);
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
init, logOutput, warn, warning, error, info, debug, setLevel, getLevel, cleanup, addTransport, end,
|
||||
winston: (): winston.Logger => logger,
|
||||
};
|
||||
@@ -1,794 +0,0 @@
|
||||
const data = require('./data');
|
||||
const utils = require('./utils');
|
||||
const file = process.env.ZIGBEE2MQTT_CONFIG || data.joinPath('configuration.yaml');
|
||||
const objectAssignDeep = require(`object-assign-deep`);
|
||||
const path = require('path');
|
||||
const yaml = require('./yaml');
|
||||
const Ajv = require('ajv');
|
||||
const ajv = new Ajv({allErrors: true});
|
||||
|
||||
const defaults = {
|
||||
passlist: [],
|
||||
blocklist: [],
|
||||
// Deprecated: use block/passlist
|
||||
whitelist: [],
|
||||
ban: [],
|
||||
permit_join: false,
|
||||
mqtt: {
|
||||
include_device_information: false,
|
||||
},
|
||||
serial: {
|
||||
disable_led: false,
|
||||
},
|
||||
device_options: {},
|
||||
map_options: {
|
||||
graphviz: {
|
||||
colors: {
|
||||
fill: {
|
||||
enddevice: '#fff8ce',
|
||||
coordinator: '#e04e5d',
|
||||
router: '#4ea3e0',
|
||||
},
|
||||
font: {
|
||||
coordinator: '#ffffff',
|
||||
router: '#ffffff',
|
||||
enddevice: '#000000',
|
||||
},
|
||||
line: {
|
||||
active: '#009900',
|
||||
inactive: '#994444',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
// json or attribute or attribute_and_json
|
||||
output: 'json',
|
||||
new_api: false,
|
||||
},
|
||||
advanced: {
|
||||
legacy_api: true,
|
||||
log_rotation: true,
|
||||
log_output: ['console', 'file'],
|
||||
log_directory: path.join(data.getPath(), 'log', '%TIMESTAMP%'),
|
||||
log_file: 'log.txt',
|
||||
log_level: /* istanbul ignore next */ process.env.DEBUG ? 'debug' : 'info',
|
||||
log_syslog: {},
|
||||
soft_reset_timeout: 0,
|
||||
pan_id: 0x1a62,
|
||||
ext_pan_id: [0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD],
|
||||
channel: 11,
|
||||
adapter_concurrent: null,
|
||||
|
||||
// Availability timeout in seconds, disabled by default.
|
||||
availability_timeout: 0,
|
||||
availability_blocklist: [],
|
||||
availability_passlist: [],
|
||||
// Deprecated, use block/passlist
|
||||
availability_blacklist: [],
|
||||
availability_whitelist: [],
|
||||
|
||||
/**
|
||||
* Home Assistant requires ALL attributes to be present in ALL MQTT messages send by the device.
|
||||
* https://community.home-assistant.io/t/missing-value-with-mqtt-only-last-data-set-is-shown/47070/9
|
||||
*
|
||||
* Therefore Zigbee2MQTT BY DEFAULT caches all values and resend it with every message.
|
||||
* advanced.cache_state in configuration.yaml allows to configure this.
|
||||
* https://www.zigbee2mqtt.io/configuration/configuration.html
|
||||
*/
|
||||
cache_state: true,
|
||||
cache_state_persistent: true,
|
||||
cache_state_send_on_startup: true,
|
||||
|
||||
/**
|
||||
* Add a last_seen attribute to mqtt messages, contains date/time of zigbee message arrival
|
||||
* "ISO_8601": ISO 8601 format
|
||||
* "ISO_8601_local": Local ISO 8601 format (instead of UTC-based)
|
||||
* "epoch": milliseconds elapsed since the UNIX epoch
|
||||
* "disable": no last_seen attribute (default)
|
||||
*/
|
||||
last_seen: 'disable',
|
||||
|
||||
// Optional: Add an elapsed attribute to MQTT messages, contains milliseconds since the previous msg
|
||||
elapsed: false,
|
||||
|
||||
/**
|
||||
* https://github.com/Koenkk/zigbee2mqtt/issues/685#issuecomment-449112250
|
||||
*
|
||||
* Network key will serve as the encryption key of your network.
|
||||
* Changing this will require you to repair your devices.
|
||||
*/
|
||||
network_key: [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13],
|
||||
|
||||
/**
|
||||
* Enables reporting feature
|
||||
*/
|
||||
report: false,
|
||||
|
||||
/**
|
||||
* Home Assistant discovery topic
|
||||
*/
|
||||
homeassistant_discovery_topic: 'homeassistant',
|
||||
|
||||
/**
|
||||
* Home Assistant status topic
|
||||
*/
|
||||
homeassistant_status_topic: 'hass/status',
|
||||
|
||||
/**
|
||||
* Home Assistant legacy triggers, when enabled:
|
||||
* - Zigbee2mqt will send an empty 'action' or 'click' after one has been send
|
||||
* - A 'sensor_action' and 'sensor_click' will be discoverd
|
||||
*/
|
||||
homeassistant_legacy_triggers: true,
|
||||
|
||||
/**
|
||||
* Configurable timestampFormat
|
||||
* https://github.com/Koenkk/zigbee2mqtt/commit/44db557a0c83f419d66755d14e460cd78bd6204e
|
||||
*/
|
||||
timestamp_format: 'YYYY-MM-DD HH:mm:ss',
|
||||
},
|
||||
external_converters: [],
|
||||
};
|
||||
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
device_options: {type: 'object'},
|
||||
homeassistant: {type: 'boolean'},
|
||||
permit_join: {type: 'boolean'},
|
||||
mqtt: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
base_topic: {type: 'string'},
|
||||
server: {type: 'string'},
|
||||
keepalive: {type: 'number'},
|
||||
ca: {type: 'string'},
|
||||
key: {type: 'string'},
|
||||
cert: {type: 'string'},
|
||||
user: {type: 'string'},
|
||||
password: {type: 'string'},
|
||||
client_id: {type: 'string'},
|
||||
reject_unauthorized: {type: 'boolean'},
|
||||
include_device_information: {type: 'boolean'},
|
||||
version: {type: 'number'},
|
||||
},
|
||||
required: ['base_topic', 'server'],
|
||||
},
|
||||
serial: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
port: {type: ['string', 'null']},
|
||||
disable_led: {type: 'boolean'},
|
||||
adapter: {type: 'string', enum: ['deconz', 'zstack']},
|
||||
},
|
||||
},
|
||||
blocklist: {type: 'array', items: {type: 'string'}},
|
||||
passlist: {type: 'array', items: {type: 'string'}},
|
||||
// Deprecated: use block/passlist
|
||||
whitelist: {type: 'array', items: {type: 'string'}},
|
||||
ban: {type: 'array', items: {type: 'string'}},
|
||||
experimental: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
transmit_power: {type: 'number'},
|
||||
output: {type: 'string', enum: ['attribute_and_json', 'attribute', 'json']},
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
legacy_api: {type: 'boolean'},
|
||||
pan_id: {type: 'number'},
|
||||
ext_pan_id: {type: 'array', items: {type: 'number'}},
|
||||
channel: {type: 'number', minimum: 11, maximum: 26},
|
||||
cache_state: {type: 'boolean'},
|
||||
cache_state_persistent: {type: 'boolean'},
|
||||
cache_state_send_on_startup: {type: 'boolean'},
|
||||
log_rotation: {type: 'boolean'},
|
||||
log_level: {type: 'string', enum: ['info', 'warn', 'error', 'debug']},
|
||||
log_output: {type: 'array', items: {type: 'string'}},
|
||||
log_directory: {type: 'string'},
|
||||
log_file: {type: 'string'},
|
||||
baudrate: {type: 'number'},
|
||||
rtscts: {type: 'boolean'},
|
||||
soft_reset_timeout: {type: 'number', minimum: 0},
|
||||
network_key: {type: ['array', 'string'], items: {type: 'number'}},
|
||||
last_seen: {type: 'string', enum: ['disable', 'ISO_8601', 'ISO_8601_local', 'epoch']},
|
||||
elapsed: {type: 'boolean'},
|
||||
availability_timeout: {type: 'number', minimum: 0},
|
||||
availability_blocklist: {type: 'array', items: {type: 'string'}},
|
||||
availability_passlist: {type: 'array', items: {type: 'string'}},
|
||||
// Deprecated, use block/passlist
|
||||
availability_blacklist: {type: 'array', items: {type: 'string'}},
|
||||
availability_whitelist: {type: 'array', items: {type: 'string'}},
|
||||
report: {type: 'boolean'},
|
||||
homeassistant_discovery_topic: {type: 'string'},
|
||||
homeassistant_status_topic: {type: 'string'},
|
||||
timestamp_format: {type: 'string'},
|
||||
adapter_concurrent: {type: 'number'},
|
||||
},
|
||||
},
|
||||
map_options: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
graphviz: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
colors: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
fill: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
enddevice: {type: 'string'},
|
||||
coordinator: {type: 'string'},
|
||||
router: {type: 'string'},
|
||||
},
|
||||
},
|
||||
font: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
enddevice: {type: 'string'},
|
||||
coordinator: {type: 'string'},
|
||||
router: {type: 'string'},
|
||||
},
|
||||
},
|
||||
line: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
active: {type: 'string'},
|
||||
inactive: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
type: 'object',
|
||||
propertyNames: {
|
||||
pattern: '^0x[\\d\\w]{16}$',
|
||||
},
|
||||
patternProperties: {
|
||||
'^.*$': {
|
||||
type: 'object',
|
||||
properties: {
|
||||
friendly_name: {type: 'string'},
|
||||
retain: {type: 'boolean'},
|
||||
retention: {type: 'number'},
|
||||
qos: {type: 'number'},
|
||||
filtered_attributes: {type: 'array', items: {type: 'string'}},
|
||||
},
|
||||
required: ['friendly_name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
type: 'object',
|
||||
propertyNames: {
|
||||
pattern: '^[\\w].*$',
|
||||
},
|
||||
patternProperties: {
|
||||
'^.*$': {
|
||||
type: 'object',
|
||||
properties: {
|
||||
friendly_name: {type: 'string'},
|
||||
retain: {type: 'boolean'},
|
||||
devices: {type: 'array', items: {type: 'string'}},
|
||||
optimistic: {type: 'boolean'},
|
||||
qos: {type: 'number'},
|
||||
filtered_attributes: {type: 'array', items: {type: 'string'}},
|
||||
},
|
||||
required: ['friendly_name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
external_converters: {
|
||||
type: 'array',
|
||||
items: {type: 'string'},
|
||||
},
|
||||
required: ['homeassistant', 'permit_join', 'mqtt'],
|
||||
};
|
||||
|
||||
|
||||
let _settings;
|
||||
let _settingsWithDefaults;
|
||||
|
||||
function write() {
|
||||
const settings = get();
|
||||
const toWrite = objectAssignDeep.noMutate(settings);
|
||||
|
||||
// Read settings to check if we have to split devices/groups into separate file.
|
||||
const actual = yaml.read(file);
|
||||
|
||||
// In case the setting is defined in a separte file (e.g. !secret network_key) update it there.
|
||||
for (const path of [['mqtt', 'user'], ['mqtt', 'password'], ['advanced', 'network_key']]) {
|
||||
if (actual[path[0]] && actual[path[0]][path[1]]) {
|
||||
const match = /!(.*) (.*)/g.exec(actual[path[0]][path[1]]);
|
||||
if (match) {
|
||||
yaml.updateIfChanged(data.joinPath(`${match[1]}.yaml`), match[2], toWrite[path[0]][path[1]]);
|
||||
toWrite[path[0]][path[1]] = actual[path[0]][path[1]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof actual.devices === 'string') {
|
||||
yaml.writeIfChanged(data.joinPath(actual.devices), settings.devices);
|
||||
toWrite.devices = actual.devices;
|
||||
}
|
||||
|
||||
if (typeof actual.groups === 'string') {
|
||||
yaml.writeIfChanged(data.joinPath(actual.groups), settings.groups);
|
||||
toWrite.groups = actual.groups;
|
||||
}
|
||||
|
||||
yaml.writeIfChanged(file, toWrite);
|
||||
|
||||
_settings = read();
|
||||
_settingsWithDefaults = objectAssignDeep.noMutate(defaults, get());
|
||||
}
|
||||
|
||||
function validate() {
|
||||
try {
|
||||
get();
|
||||
} catch (error) {
|
||||
if (error.name === 'YAMLException') {
|
||||
return [
|
||||
`Your YAML file: '${error.file}' is invalid ` +
|
||||
`(use https://jsonformatter.org/yaml-validator to find and fix the issue)`,
|
||||
];
|
||||
}
|
||||
|
||||
return [error.message];
|
||||
}
|
||||
|
||||
const validate = ajv.compile(schema);
|
||||
validate(_settings);
|
||||
if (validate.errors) {
|
||||
return validate.errors.map((v) => `${v.dataPath.substring(1)} ${v.message}`);
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
if (_settings.advanced && _settings.advanced.network_key && typeof _settings.advanced.network_key === 'string' &&
|
||||
_settings.advanced.network_key !== 'GENERATE') {
|
||||
errors.push(`advanced.network_key: should be array or 'GENERATE' (is '${_settings.advanced.network_key}')`);
|
||||
}
|
||||
|
||||
// Verify that all friendly names are unique
|
||||
const names = [];
|
||||
const check = (name) => {
|
||||
if (names.includes(name)) errors.push(`Duplicate friendly_name '${name}' found`);
|
||||
errors.push(...utils.validateFriendlyName(name));
|
||||
names.push(name);
|
||||
};
|
||||
|
||||
const settingsWithDefaults = getWithDefaults();
|
||||
Object.values(settingsWithDefaults.devices).forEach((d) => check(d.friendly_name));
|
||||
Object.values(settingsWithDefaults.groups).forEach((g) => check(g.friendly_name));
|
||||
|
||||
if (settingsWithDefaults.mqtt.version !== 5) {
|
||||
for (const device of Object.values(settingsWithDefaults.devices)) {
|
||||
if (device.retention) {
|
||||
errors.push('MQTT retention requires protocol version 5');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkAvailabilityList = (list, type) => {
|
||||
list.forEach((e) => {
|
||||
if (!getEntity(e)) {
|
||||
errors.push(`Non-existing entity '${e}' specified in '${type}'`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
checkAvailabilityList(settingsWithDefaults.advanced.availability_blacklist, 'availability_blacklist');
|
||||
checkAvailabilityList(settingsWithDefaults.advanced.availability_whitelist, 'availability_whitelist');
|
||||
checkAvailabilityList(settingsWithDefaults.advanced.availability_blocklist, 'availability_blocklist');
|
||||
checkAvailabilityList(settingsWithDefaults.advanced.availability_passlist, 'availability_passlist');
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function read() {
|
||||
const s = yaml.read(file);
|
||||
|
||||
// Read !secret MQTT username and password if set
|
||||
const interpetValue = (value) => {
|
||||
const re = /!(.*) (.*)/g;
|
||||
const match = re.exec(value);
|
||||
if (match) {
|
||||
const file = data.joinPath(`${match[1]}.yaml`);
|
||||
const key = match[2];
|
||||
return yaml.read(file)[key];
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
if (s.mqtt && s.mqtt.user && s.mqtt.password) {
|
||||
s.mqtt.user = interpetValue(s.mqtt.user);
|
||||
s.mqtt.password = interpetValue(s.mqtt.password);
|
||||
}
|
||||
|
||||
if (s.advanced && s.advanced.network_key) {
|
||||
s.advanced.network_key = interpetValue(s.advanced.network_key);
|
||||
}
|
||||
|
||||
// Read devices/groups configuration from separate file.
|
||||
if (typeof s.devices === 'string') {
|
||||
const file = data.joinPath(s.devices);
|
||||
s.devices = yaml.readIfExists(file) || {};
|
||||
}
|
||||
|
||||
if (typeof s.groups === 'string') {
|
||||
const file = data.joinPath(s.groups);
|
||||
s.groups = yaml.readIfExists(file) || {};
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
function applyEnvironmentVariables(settings) {
|
||||
const iterate = (obj, path) => {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (key !== 'type') {
|
||||
if (key !== 'properties') {
|
||||
const type = (obj[key].type || 'object').toString();
|
||||
const envPart = path.reduce((acc, val) => `${acc}${val}_`, '');
|
||||
const envVariableName = (`ZIGBEE2MQTT_CONFIG_${envPart}${key}`).toUpperCase();
|
||||
if (process.env[envVariableName]) {
|
||||
const setting = path.reduce((acc, val, index) => {
|
||||
acc[val] = acc[val] || {};
|
||||
return acc[val];
|
||||
}, settings);
|
||||
|
||||
if (type.indexOf('object') >= 0 || type.indexOf('array') >= 0) {
|
||||
setting[key] = JSON.parse(process.env[envVariableName]);
|
||||
}
|
||||
if (type.indexOf('number') >= 0) {
|
||||
setting[key] = process.env[envVariableName] * 1;
|
||||
}
|
||||
if (type.indexOf('boolean') >= 0) {
|
||||
setting[key] = process.env[envVariableName].toLowerCase() === 'true';
|
||||
}
|
||||
if (type.indexOf('string') >= 0) {
|
||||
setting[key] = process.env[envVariableName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof obj[key] === 'object') {
|
||||
const newPath = [...path];
|
||||
if (key !== 'properties') {
|
||||
newPath.push(key);
|
||||
}
|
||||
iterate(obj[key], newPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
iterate(schema.properties, []);
|
||||
}
|
||||
|
||||
function get() {
|
||||
if (!_settings) {
|
||||
_settings = read();
|
||||
applyEnvironmentVariables(_settings);
|
||||
}
|
||||
|
||||
return _settings;
|
||||
}
|
||||
|
||||
function getWithDefaults() {
|
||||
if (!_settingsWithDefaults) {
|
||||
_settingsWithDefaults = objectAssignDeep.noMutate(defaults, get());
|
||||
}
|
||||
|
||||
if (!_settingsWithDefaults.devices) {
|
||||
_settingsWithDefaults.devices = {};
|
||||
}
|
||||
|
||||
if (!_settingsWithDefaults.groups) {
|
||||
_settingsWithDefaults.groups = {};
|
||||
}
|
||||
|
||||
return _settingsWithDefaults;
|
||||
}
|
||||
|
||||
function set(path, value) {
|
||||
let settings = get();
|
||||
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const key = path[i];
|
||||
if (i === path.length - 1) {
|
||||
settings[key] = value;
|
||||
} else {
|
||||
if (!settings[key]) {
|
||||
settings[key] = {};
|
||||
}
|
||||
|
||||
settings = settings[key];
|
||||
}
|
||||
}
|
||||
|
||||
write();
|
||||
}
|
||||
|
||||
function getGroup(IDorName) {
|
||||
const settings = getWithDefaults();
|
||||
const byID = settings.groups[IDorName];
|
||||
if (byID) {
|
||||
return {optimistic: true, devices: [], ...byID, ID: Number(IDorName), friendlyName: byID.friendly_name};
|
||||
}
|
||||
|
||||
for (const [ID, group] of Object.entries(settings.groups)) {
|
||||
if (group.friendly_name === IDorName) {
|
||||
return {optimistic: true, devices: [], ...group, ID: Number(ID), friendlyName: group.friendly_name};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getGroups() {
|
||||
const settings = getWithDefaults();
|
||||
return Object.entries(settings.groups).map(([ID, group]) => {
|
||||
return {optimistic: true, devices: [], ...group, ID: Number(ID), friendlyName: group.friendly_name};
|
||||
});
|
||||
}
|
||||
|
||||
function getGroupThrowIfNotExists(IDorName) {
|
||||
const group = getGroup(IDorName);
|
||||
if (!group) {
|
||||
throw new Error(`Group '${IDorName}' does not exist`);
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
function getDevice(IDorName) {
|
||||
const settings = getWithDefaults();
|
||||
const byID = settings.devices[IDorName];
|
||||
if (byID) {
|
||||
return {...byID, ID: IDorName, friendlyName: byID.friendly_name};
|
||||
}
|
||||
|
||||
for (const [ID, device] of Object.entries(settings.devices)) {
|
||||
if (device.friendly_name === IDorName) {
|
||||
return {...device, ID, friendlyName: device.friendly_name};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDeviceThrowIfNotExists(IDorName) {
|
||||
const device = getDevice(IDorName);
|
||||
if (!device) {
|
||||
throw new Error(`Device '${IDorName}' does not exist`);
|
||||
}
|
||||
|
||||
return device;
|
||||
}
|
||||
|
||||
function getEntity(IDorName) {
|
||||
const device = getDevice(IDorName);
|
||||
if (device) {
|
||||
return {...device, type: 'device'};
|
||||
}
|
||||
|
||||
const group = getGroup(IDorName);
|
||||
if (group) {
|
||||
return {...group, type: 'group'};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function addDevice(ID) {
|
||||
if (getDevice(ID)) {
|
||||
throw new Error(`Device '${ID}' already exists`);
|
||||
}
|
||||
|
||||
const settings = get();
|
||||
|
||||
if (!settings.devices) {
|
||||
settings.devices = {};
|
||||
}
|
||||
|
||||
settings.devices[ID] = {friendly_name: ID};
|
||||
write();
|
||||
return getDevice(ID);
|
||||
}
|
||||
|
||||
// Legacy: can be removed after bridgeLegacy has been removed
|
||||
function whitelistDevice(ID) {
|
||||
const settings = get();
|
||||
if (!settings.whitelist) {
|
||||
settings.whitelist = [];
|
||||
}
|
||||
|
||||
if (settings.whitelist.includes(ID)) {
|
||||
throw new Error(`Device '${ID}' already whitelisted`);
|
||||
}
|
||||
|
||||
settings.whitelist.push(ID);
|
||||
write();
|
||||
}
|
||||
|
||||
function blockDevice(ID) {
|
||||
const settings = get();
|
||||
if (!settings.blocklist) {
|
||||
settings.blocklist = [];
|
||||
}
|
||||
|
||||
settings.blocklist.push(ID);
|
||||
write();
|
||||
}
|
||||
|
||||
function banDevice(ID) {
|
||||
const settings = get();
|
||||
if (!settings.ban) {
|
||||
settings.ban = [];
|
||||
}
|
||||
|
||||
settings.ban.push(ID);
|
||||
write();
|
||||
}
|
||||
|
||||
function removeDevice(IDorName) {
|
||||
const device = getDeviceThrowIfNotExists(IDorName);
|
||||
const settings = get();
|
||||
delete settings.devices[device.ID];
|
||||
write();
|
||||
}
|
||||
|
||||
function addGroup(name, ID=null) {
|
||||
if (getGroup(name) || getDevice(name)) {
|
||||
throw new Error(`friendly_name '${name}' is already in use`);
|
||||
}
|
||||
|
||||
const settings = get();
|
||||
if (!settings.groups) {
|
||||
settings.groups = {};
|
||||
}
|
||||
|
||||
if (ID == null) {
|
||||
// look for free ID
|
||||
ID = '1';
|
||||
while (settings.groups.hasOwnProperty(ID)) {
|
||||
ID = (Number.parseInt(ID) + 1).toString();
|
||||
}
|
||||
} else {
|
||||
// ensure provided ID is not in use
|
||||
ID = ID.toString();
|
||||
if (settings.groups.hasOwnProperty(ID)) {
|
||||
throw new Error(`group id '${ID}' is already in use`);
|
||||
}
|
||||
}
|
||||
|
||||
settings.groups[ID] = {friendly_name: name};
|
||||
write();
|
||||
|
||||
return getGroup(ID);
|
||||
}
|
||||
|
||||
function groupHasDevice(group, keys) {
|
||||
for (const device of group.devices) {
|
||||
const index = keys.indexOf(device);
|
||||
if (index != -1) {
|
||||
return keys[index];
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function addDeviceToGroup(groupIDorName, keys) {
|
||||
const groupID = getGroupThrowIfNotExists(groupIDorName).ID;
|
||||
const settings = get();
|
||||
|
||||
const group = settings.groups[groupID];
|
||||
if (!group.devices) {
|
||||
group.devices = [];
|
||||
}
|
||||
|
||||
if (!groupHasDevice(group, keys)) {
|
||||
group.devices.push(keys[0]);
|
||||
write();
|
||||
}
|
||||
}
|
||||
|
||||
function removeDeviceFromGroup(groupIDorName, keys) {
|
||||
const groupID = getGroupThrowIfNotExists(groupIDorName).ID;
|
||||
const settings = get();
|
||||
const group = settings.groups[groupID];
|
||||
if (!group.devices) {
|
||||
group.devices = [];
|
||||
}
|
||||
|
||||
const key = groupHasDevice(group, keys);
|
||||
if (key) {
|
||||
group.devices = group.devices.filter((d) => d != key);
|
||||
write();
|
||||
}
|
||||
}
|
||||
|
||||
function removeGroup(groupIDorName) {
|
||||
const groupID = getGroupThrowIfNotExists(groupIDorName).ID;
|
||||
const settings = get();
|
||||
delete settings.groups[groupID];
|
||||
write();
|
||||
}
|
||||
|
||||
function changeEntityOptions(IDorName, newOptions) {
|
||||
const settings = get();
|
||||
delete newOptions.friendly_name;
|
||||
delete newOptions.devices;
|
||||
if (getDevice(IDorName)) {
|
||||
objectAssignDeep(settings.devices[getDevice(IDorName).ID], newOptions);
|
||||
} else if (getGroup(IDorName)) {
|
||||
objectAssignDeep(settings.groups[getGroup(IDorName).ID], newOptions);
|
||||
} else {
|
||||
throw new Error(`Device or group '${IDorName}' does not exist`);
|
||||
}
|
||||
|
||||
write();
|
||||
}
|
||||
|
||||
function changeFriendlyName(IDorName, newName) {
|
||||
if (getGroup(newName) || getDevice(newName)) {
|
||||
throw new Error(`friendly_name '${newName}' is already in use`);
|
||||
}
|
||||
|
||||
const settings = get();
|
||||
if (getDevice(IDorName)) {
|
||||
settings.devices[getDevice(IDorName).ID].friendly_name = newName;
|
||||
} else if (getGroup(IDorName)) {
|
||||
settings.groups[getGroup(IDorName).ID].friendly_name = newName;
|
||||
} else {
|
||||
throw new Error(`Device or group '${IDorName}' does not exist`);
|
||||
}
|
||||
|
||||
write();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
get: getWithDefaults,
|
||||
set,
|
||||
getDevice,
|
||||
getGroup,
|
||||
getGroups,
|
||||
getEntity,
|
||||
whitelistDevice,
|
||||
banDevice,
|
||||
blockDevice,
|
||||
addDevice,
|
||||
removeDevice,
|
||||
addGroup,
|
||||
removeGroup,
|
||||
addDeviceToGroup,
|
||||
removeDeviceFromGroup,
|
||||
changeEntityOptions,
|
||||
changeFriendlyName,
|
||||
|
||||
// For tests only
|
||||
_write: write,
|
||||
_reRead: () => {
|
||||
_settings = null;
|
||||
get();
|
||||
_settingsWithDefaults = null;
|
||||
getWithDefaults();
|
||||
},
|
||||
_clear: () => {
|
||||
_settings = null;
|
||||
_settingsWithDefaults = null;
|
||||
},
|
||||
_getDefaults: () => defaults,
|
||||
};
|
||||
@@ -0,0 +1,900 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"homeassistant": {
|
||||
"title": "Home Assistant integration",
|
||||
"requiresRestart": true,
|
||||
"description": "Home Assistant integration (MQTT discovery)",
|
||||
"default": false,
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant (simple)"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Home Assistant (advanced)",
|
||||
"properties": {
|
||||
"legacy_triggers": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant legacy triggers",
|
||||
"description": "Home Assistant legacy triggers, when enabled Zigbee2mqt will send an empty 'action' or 'click' after one has been send. A 'sensor_action' and 'sensor_click' will be discoverd",
|
||||
"default": true
|
||||
},
|
||||
"discovery_topic": {
|
||||
"type": "string",
|
||||
"title": "Homeassistant discovery topic",
|
||||
"description": "Home Assistant discovery topic",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant"]
|
||||
},
|
||||
"legacy_entity_attributes": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant legacy entity attributes",
|
||||
"description": "Home Assistant legacy entity attributes, when enabled Zigbee2MQTT will add state attributes to each entity, additional to the separate entities and devices it already creates",
|
||||
"default": true
|
||||
},
|
||||
"status_topic": {
|
||||
"type": "string",
|
||||
"title": "Home Assistant status topic",
|
||||
"description": "Home Assistant status topic",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant/status"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"permit_join": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"title": "Permit join",
|
||||
"description": "Allow new devices to join (re-applied at restart)"
|
||||
},
|
||||
"availability": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"title": "Availability (simple)"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Availability (advanced)",
|
||||
"properties": {
|
||||
"active": {
|
||||
"type": "object",
|
||||
"title": "Active",
|
||||
"requiresRestart": true,
|
||||
"description": "Options for active devices (routers/mains powered)",
|
||||
"properties": {
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"requiresRestart": true,
|
||||
"default": 10,
|
||||
"description": "Time after which an active device will be marked as offline in minutes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"passive": {
|
||||
"type": "object",
|
||||
"title": "Passive",
|
||||
"requiresRestart": true,
|
||||
"description": "Options for passive devices (mostly battery powered)",
|
||||
"properties": {
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"requiresRestart": true,
|
||||
"default": 1500,
|
||||
"description": "Time after which an passive device will be marked as offline in minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"title": "Availability",
|
||||
"requiresRestart": true,
|
||||
"description": "Checks whether devices are online/offline"
|
||||
},
|
||||
"external_converters": {
|
||||
"type": "array",
|
||||
"title": "External converters",
|
||||
"description": "You can define external converters to e.g. add support for a DiY device",
|
||||
"requiresRestart": true,
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": ["DIYRuZ_FreePad.js"]
|
||||
},
|
||||
"mqtt": {
|
||||
"type": "object",
|
||||
"title": "MQTT",
|
||||
"properties": {
|
||||
"base_topic": {
|
||||
"type": "string",
|
||||
"title": "Base topic",
|
||||
"default": "zigbee2mqtt",
|
||||
"requiresRestart": true,
|
||||
"description": "MQTT base topic for Zigbee2MQTT MQTT messages",
|
||||
"examples": ["zigbee2mqtt"]
|
||||
},
|
||||
"server": {
|
||||
"type": "string",
|
||||
"title": "MQTT server",
|
||||
"requiresRestart": true,
|
||||
"description": "MQTT server URL (use mqtts:// for SSL/TLS connection)",
|
||||
"examples": ["mqtt://localhost:1883"]
|
||||
},
|
||||
"keepalive": {
|
||||
"type": "number",
|
||||
"title": "Keepalive",
|
||||
"requiresRestart": true,
|
||||
"description": "MQTT keepalive in second",
|
||||
"default": 60
|
||||
},
|
||||
"ca": {
|
||||
"type": "string",
|
||||
"title": "Certificate authority",
|
||||
"requiresRestart": true,
|
||||
"description": "Absolute path to SSL/TLS certificate of CA used to sign server and client certificates",
|
||||
"examples": ["/etc/ssl/mqtt-ca.crt"]
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"title": "SSL/TLS key",
|
||||
"requiresRestart": true,
|
||||
"description": "Absolute path to SSL/TLS key for client-authentication",
|
||||
"examples": ["/etc/ssl/mqtt-client.key"]
|
||||
},
|
||||
"cert": {
|
||||
"type": "string",
|
||||
"title": "SSL/TLS certificate",
|
||||
"description": "Absolute path to SSL/TLS certificate for client-authentication",
|
||||
"requiresRestart": true,
|
||||
"examples": ["/etc/ssl/mqtt-client.crt"]
|
||||
},
|
||||
"user": {
|
||||
"type": "string",
|
||||
"title": "User",
|
||||
"requiresRestart": true,
|
||||
"description": "MQTT server authentication user",
|
||||
"examples": ["johnnysilverhand"]
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"title": "Password",
|
||||
"requiresRestart": true,
|
||||
"description": "MQTT server authentication password",
|
||||
"examples": ["ILOVEPELMENI"]
|
||||
},
|
||||
"client_id": {
|
||||
"type": "string",
|
||||
"title": "Client ID",
|
||||
"requiresRestart": true,
|
||||
"description": "MQTT client ID",
|
||||
"examples": ["MY_CLIENT_ID"]
|
||||
},
|
||||
"reject_unauthorized": {
|
||||
"type": "boolean",
|
||||
"title": "Reject unauthorized",
|
||||
"requiresRestart": true,
|
||||
"description": "Disable self-signed SSL certificate",
|
||||
"default": true
|
||||
},
|
||||
"include_device_information": {
|
||||
"type": "boolean",
|
||||
"title": "Include device information",
|
||||
"description": "Include device information to mqtt messages",
|
||||
"default": false
|
||||
},
|
||||
"version": {
|
||||
"type": ["number", "null"],
|
||||
"title": "Version",
|
||||
"requiresRestart": true,
|
||||
"description": "MQTT protocol version",
|
||||
"default": 4,
|
||||
"examples": [5]
|
||||
},
|
||||
"force_disable_retain": {
|
||||
"type": "boolean",
|
||||
"title": "Force disable retain",
|
||||
"requiresRestart": true,
|
||||
"description": "Disable retain for all send messages. ONLY enable if you MQTT broker doesn't support retained message (e.g. AWS IoT core, Azure IoT Hub, Google Cloud IoT core, IBM Watson IoT Platform). Enabling will break the Home Assistant integration",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["server"]
|
||||
},
|
||||
"serial": {
|
||||
"type": "object",
|
||||
"title": "Serial",
|
||||
"properties": {
|
||||
"port": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Port",
|
||||
"requiresRestart": true,
|
||||
"description": "Location of the adapter. To autodetect the port, set null",
|
||||
"examples": ["/dev/ttyACM0"]
|
||||
},
|
||||
"disable_led": {
|
||||
"type": "boolean",
|
||||
"title": "Disable led",
|
||||
"requiresRestart": true,
|
||||
"description": "Disable LED of the adapter if supported",
|
||||
"default": false
|
||||
},
|
||||
"adapter": {
|
||||
"type": ["string"],
|
||||
"enum": ["deconz", "zstack", "zigate", "ezsp", "auto"],
|
||||
"title": "Adapter",
|
||||
"default": "auto",
|
||||
"requiresRestart": true,
|
||||
"description": "Adapter type, not needed unless you are experiencing problems"
|
||||
},
|
||||
"baudrate": {
|
||||
"type": "number",
|
||||
"title": "Baudrate",
|
||||
"requiresRestart": true,
|
||||
"description": "Baud rate speed for serial port, this can be anything firmware support but default is 115200 for Z-Stack and EZSP, 38400 for Deconz, however note that some EZSP firmware need 57600",
|
||||
"examples": [38400, 57600, 115200]
|
||||
},
|
||||
"rtscts": {
|
||||
"type": "boolean",
|
||||
"title": "RTS / CTS",
|
||||
"requiresRestart": true,
|
||||
"description": "RTS / CTS Hardware Flow Control for serial port"
|
||||
}
|
||||
}
|
||||
},
|
||||
"blocklist": {
|
||||
"title": "Blocklist",
|
||||
"requiresRestart": true,
|
||||
"description": "Block devices from the network (by ieeeAddr)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"passlist": {
|
||||
"title": "Passlist",
|
||||
"requiresRestart": true,
|
||||
"description": "Allow only certain devices to join the network (by ieeeAddr). Note that all devices not on the passlist will be removed from the network!",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"map_options": {
|
||||
"type": "object",
|
||||
"title": "Networkmap",
|
||||
"properties": {
|
||||
"graphviz": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"colors": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fill": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enddevice": {
|
||||
"type": "string"
|
||||
},
|
||||
"coordinator": {
|
||||
"type": "string"
|
||||
},
|
||||
"router": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enddevice": {
|
||||
"type": "string"
|
||||
},
|
||||
"coordinator": {
|
||||
"type": "string"
|
||||
},
|
||||
"router": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"line": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active": {
|
||||
"type": "string"
|
||||
},
|
||||
"inactive": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ota": {
|
||||
"type": "object",
|
||||
"title": "OTA updates",
|
||||
"properties": {
|
||||
"update_check_interval": {
|
||||
"type": "number",
|
||||
"title": "Update check interval",
|
||||
"description": "Your device may request a check for a new firmware update. This value determines how frequently third party servers may actually be contacted to look for firmware updates. The value is set in minutes, and the default is 1 day.",
|
||||
"default": 1440
|
||||
},
|
||||
"disable_automatic_update_check": {
|
||||
"type": "boolean",
|
||||
"title": "Disable automatic update check",
|
||||
"description": "Zigbee devices may request a firmware update, and do so frequently, causing Zigbee2MQTT to reach out to third party servers. If you disable these device initiated checks, you can still initiate a firmware update check manually.",
|
||||
"default": false
|
||||
},
|
||||
"ikea_ota_use_test_url": {
|
||||
"type": "boolean",
|
||||
"title": "IKEA TRADFRI OTA use test url",
|
||||
"requiresRestart": true,
|
||||
"description": "Use IKEA TRADFRI OTA test server, see OTA updates documentation",
|
||||
"default": false
|
||||
},
|
||||
"zigbee_ota_override_index_location": {
|
||||
"type": "string",
|
||||
"title": "OTA index override file name",
|
||||
"requiresRestart": true,
|
||||
"description": "Location of override OTA index file",
|
||||
"examples": ["index.json"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"title": "Frontend (simple)"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Frontend (advanced)",
|
||||
"properties": {
|
||||
"port": {
|
||||
"type": "number",
|
||||
"title": "Port",
|
||||
"description": "Frontend binding port",
|
||||
"default": 8080,
|
||||
"requiresRestart": true
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"title": "Bind host",
|
||||
"description": "Frontend binding host",
|
||||
"default": "0.0.0.0",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"auth_token": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Auth token",
|
||||
"description": "Enables authentication, disabled by default",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"url": {
|
||||
"type": ["string", "null"],
|
||||
"title": "URL",
|
||||
"description": "URL on which the frontend can be reached, currently only used for the Home Assistant device configuration page",
|
||||
"requiresRestart": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"title": "Frontend",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"devices": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"pattern": "^0x[\\d\\w]{16}$"
|
||||
},
|
||||
"patternProperties": {
|
||||
"^.*$": {
|
||||
"$ref": "#/definitions/device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"pattern": "^[\\w].*$"
|
||||
},
|
||||
"patternProperties": {
|
||||
"^.*$": {
|
||||
"$ref": "#/definitions/group"
|
||||
}
|
||||
}
|
||||
},
|
||||
"device_options": {
|
||||
"type": "object",
|
||||
"title": "Options that are applied to all devices"
|
||||
},
|
||||
"advanced": {
|
||||
"type": "object",
|
||||
"title": "Advanced",
|
||||
"properties": {
|
||||
"legacy_api": {
|
||||
"type": "boolean",
|
||||
"title": "Legacy API",
|
||||
"requiresRestart": true,
|
||||
"description": "Disables the legacy api (false = disable)",
|
||||
"default": true
|
||||
},
|
||||
"legacy_availability_payload": {
|
||||
"type": "boolean",
|
||||
"title": "Legacy availability payload",
|
||||
"requiresRestart": true,
|
||||
"description": "Payload to be used for device availabilty and bridge/state topics. true = text, false = JSON",
|
||||
"default": true
|
||||
},
|
||||
"log_rotation": {
|
||||
"type": "boolean",
|
||||
"title": "Log rotation",
|
||||
"requiresRestart": true,
|
||||
"description": "Log rotation",
|
||||
"default": true
|
||||
},
|
||||
"log_symlink_current": {
|
||||
"type": "boolean",
|
||||
"title": "Log symlink current",
|
||||
"requiresRestart": true,
|
||||
"description": "Create symlink to current logs in the log directory",
|
||||
"default": false
|
||||
},
|
||||
"log_output": {
|
||||
"type": "array",
|
||||
"requiresRestart": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["console", "file", "syslog"]
|
||||
},
|
||||
"title": "Log output",
|
||||
"description": "Output location of the log, leave empty to suppress logging"
|
||||
},
|
||||
"log_directory": {
|
||||
"type": "string",
|
||||
"title": "Log directory",
|
||||
"requiresRestart": true,
|
||||
"description": "Location of log directory",
|
||||
"examples": ["data/log/%TIMESTAMP%"]
|
||||
},
|
||||
"log_file": {
|
||||
"type": "string",
|
||||
"title": "Log file",
|
||||
"requiresRestart": true,
|
||||
"description": "Log file name, can also contain timestamp",
|
||||
"examples": ["zigbee2mqtt_%TIMESTAMP%.log"],
|
||||
"default": "log.txt"
|
||||
},
|
||||
"log_level": {
|
||||
"type": "string",
|
||||
"enum": ["info", "warn", "error", "debug"],
|
||||
"title": "Log level",
|
||||
"description": "Logging level",
|
||||
"default": "info"
|
||||
},
|
||||
"log_syslog": {
|
||||
"type": "object",
|
||||
"title": "syslog",
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"title": "Host",
|
||||
"description": "The host running syslogd, defaults to localhost.",
|
||||
"default": "localhost"
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"title": "Port",
|
||||
"description": "The port on the host that syslog is running on, defaults to syslogd's default port.",
|
||||
"default": 123
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"title": "Protocol",
|
||||
"description": "The network protocol to log over (e.g. tcp4, udp4, tls4, unix, unix-connect, etc).",
|
||||
"default": "tcp4",
|
||||
"examples": [
|
||||
"udp4",
|
||||
"tls4",
|
||||
"unix",
|
||||
"unix-connect"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"title": "Path",
|
||||
"description": "The path to the syslog dgram socket (i.e. /dev/log or /var/run/syslog for OS X).",
|
||||
"default": "/dev/log",
|
||||
"examples": [
|
||||
"/var/run/syslog"
|
||||
]
|
||||
},
|
||||
"pid": {
|
||||
"type": "string",
|
||||
"title": "PID",
|
||||
"description": "PID of the process that log messages are coming from (Default process.pid).",
|
||||
"default": "process.pid"
|
||||
},
|
||||
"localhost": {
|
||||
"type": "string",
|
||||
"title": "Localhost",
|
||||
"description": "Host to indicate that log messages are coming from (Default: localhost).",
|
||||
"default": "localhost"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"title": "Type",
|
||||
"description": "The type of the syslog protocol to use (Default: BSD, also valid: 5424).",
|
||||
"default": "5424"
|
||||
},
|
||||
"app_name": {
|
||||
"type": "string",
|
||||
"title": "Localhost",
|
||||
"description": "The name of the application (Default: Zigbee2MQTT).",
|
||||
"default": "Zigbee2MQTT"
|
||||
},
|
||||
"eol": {
|
||||
"type": "string",
|
||||
"title": "eol",
|
||||
"description": "The end of line character to be added to the end of the message (Default: Message without modifications).",
|
||||
"default": "/n"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pan_id": {
|
||||
"oneOf": [{
|
||||
"type": "string",
|
||||
"title": "Pan ID (string)"
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"title": "Pan ID (number)"
|
||||
}
|
||||
],
|
||||
"title": "Pan ID",
|
||||
"requiresRestart": true,
|
||||
"description": "ZigBee pan ID, changing requires repairing all devices!"
|
||||
},
|
||||
"ext_pan_id": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number"
|
||||
},
|
||||
"title": "Ext Pan ID",
|
||||
"requiresRestart": true,
|
||||
"description": "Zigbee extended pan ID, changing requires repairing all devices!"
|
||||
},
|
||||
"channel": {
|
||||
"type": "number",
|
||||
"minimum": 11,
|
||||
"maximum": 26,
|
||||
"default": 11,
|
||||
"title": "ZigBee channel",
|
||||
"requiresRestart": true,
|
||||
"description": "Zigbee channel, changing requires repairing all devices! (Note: use a ZLL channel: 11, 15, 20, or 25 to avoid Problems)",
|
||||
"examples": [15, 20, 25]
|
||||
},
|
||||
"adapter_concurrent": {
|
||||
"title": "Adapter concurrency",
|
||||
"requiresRestart": true,
|
||||
"type": ["number", "null"],
|
||||
"description": "Adapter concurrency (e.g. 2 for CC2531 or 16 for CC26X2R1) (default: null, uses recommended value)"
|
||||
},
|
||||
"adapter_delay": {
|
||||
"type": ["number", "null"],
|
||||
"requiresRestart": true,
|
||||
"title": "Adapter delay",
|
||||
"description": "Adapter delay"
|
||||
},
|
||||
"cache_state": {
|
||||
"type": "boolean",
|
||||
"title": "Cache state",
|
||||
"description": "MQTT message payload will contain all attributes, not only changed ones. Has to be true when integrating via Home Assistant",
|
||||
"default": true
|
||||
},
|
||||
"cache_state_persistent": {
|
||||
"type": "boolean",
|
||||
"title": "Persist cache state",
|
||||
"description": "Persist cached state, only used when cache_state: true",
|
||||
"default": true
|
||||
},
|
||||
"cache_state_send_on_startup": {
|
||||
"type": "boolean",
|
||||
"title": "Send cached state on startup",
|
||||
"description": "Send cached state on startup, only used when cache_state: true",
|
||||
"default": true
|
||||
},
|
||||
"last_seen": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "ISO_8601", "ISO_8601_local", "epoch"],
|
||||
"title": "Last seen",
|
||||
"description": "Add a last_seen attribute to MQTT messages, contains date/time of last Zigbee message",
|
||||
"default": "disable"
|
||||
},
|
||||
"elapsed": {
|
||||
"type": "boolean",
|
||||
"title": "Elapsed",
|
||||
"description": "Add an elapsed attribute to MQTT messages, contains milliseconds since the previous msg",
|
||||
"default": false
|
||||
},
|
||||
"network_key": {
|
||||
"oneOf": [{
|
||||
"type": "string",
|
||||
"title": "Network key(string)"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number"
|
||||
},
|
||||
"title": "Network key(array)"
|
||||
}
|
||||
],
|
||||
"title": "Network key",
|
||||
"requiresRestart": true,
|
||||
"description": "Network encryption key, changing requires repairing all devices!"
|
||||
},
|
||||
"timestamp_format": {
|
||||
"type": "string",
|
||||
"title": "Timestamp format",
|
||||
"requiresRestart": true,
|
||||
"description": "Log timestamp format",
|
||||
"examples": ["YYYY-MM-DD HH:mm:ss"]
|
||||
},
|
||||
"transmit_power": {
|
||||
"type": ["number", "null"],
|
||||
"title": "Transmit power",
|
||||
"requiresRestart": true,
|
||||
"description": "Transmit power of adapter, only available for Z-Stack (CC253*/CC2652/CC1352) adapters, CC2652 = 5dbm, CC1352 max is = 20dbm (5dbm default)"
|
||||
},
|
||||
"output": {
|
||||
"type": "string",
|
||||
"enum": ["attribute_and_json", "attribute", "json"],
|
||||
"title": "MQTT output type",
|
||||
"description": "Examples when 'state' of a device is published json: topic: 'zigbee2mqtt/my_bulb' payload '{\"state\": \"ON\"}' attribute: topic 'zigbee2mqtt/my_bulb/state' payload 'ON' attribute_and_json: both json and attribute (see above)"
|
||||
},
|
||||
"homeassistant_discovery_topic": {
|
||||
"type": "string",
|
||||
"title": "Homeassistant discovery topic",
|
||||
"description": "Home Assistant discovery topic",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant"]
|
||||
},
|
||||
"homeassistant_legacy_entity_attributes": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant legacy entity attributes",
|
||||
"description": "Home Assistant legacy entity attributes, when enabled Zigbee2MQTT will add state attributes to each entity, additional to the separate entities and devices it already creates",
|
||||
"default": true
|
||||
},
|
||||
"homeassistant_status_topic": {
|
||||
"type": "string",
|
||||
"title": "Home Assistant status topic",
|
||||
"description": "Home Assistant status topic",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant/status"]
|
||||
},
|
||||
"homeassistant_legacy_triggers": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant legacy triggers",
|
||||
"description": "Home Assistant legacy triggers, when enabled Zigbee2mqt will send an empty 'action' or 'click' after one has been send. A 'sensor_action' and 'sensor_click' will be discovered",
|
||||
"default": true
|
||||
},
|
||||
"soft_reset_timeout": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"requiresRestart": true,
|
||||
"title": "Soft reset timeout (deprecated)",
|
||||
"description": "Soft reset ZNP after timeout",
|
||||
"readOnly": true
|
||||
},
|
||||
"report": {
|
||||
"type": "boolean",
|
||||
"title": "Reporting",
|
||||
"requiresRestart": true,
|
||||
"readOnly": true,
|
||||
"description": "Enables report feature (deprecated)"
|
||||
},
|
||||
"baudrate": {
|
||||
"type": "number",
|
||||
"title": "Baudrate (deprecated)",
|
||||
"requiresRestart": true,
|
||||
"description": "Baud rate speed for serial port, this can be anything firmware support but default is 115200 for Z-Stack and EZSP, 38400 for Deconz, however note that some EZSP firmware need 57600",
|
||||
"examples": [38400, 57600, 115200]
|
||||
},
|
||||
"rtscts": {
|
||||
"type": "boolean",
|
||||
"title": "RTS / CTS (deprecated)",
|
||||
"requiresRestart": true,
|
||||
"description": "RTS / CTS Hardware Flow Control for serial port"
|
||||
},
|
||||
"ikea_ota_use_test_url": {
|
||||
"type": "boolean",
|
||||
"title": "IKEA TRADFRI OTA use test url (deprecated)",
|
||||
"requiresRestart": true,
|
||||
"description": "Use IKEA TRADFRI OTA test server, see OTA updates documentation",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"experimental": {
|
||||
"type": "object",
|
||||
"title": "Experimental (deprecated)",
|
||||
"properties": {
|
||||
"transmit_power": {
|
||||
"type": ["number", "null"],
|
||||
"title": "Transmit power",
|
||||
"requiresRestart": true,
|
||||
"description": "Transmit power of adapter, only available for Z-Stack (CC253*/CC2652/CC1352) adapters, CC2652 = 5dbm, CC1352 max is = 20dbm (5dbm default)"
|
||||
},
|
||||
"output": {
|
||||
"type": "string",
|
||||
"enum": ["attribute_and_json", "attribute", "json"],
|
||||
"title": "MQTT output type",
|
||||
"description": "Examples when 'state' of a device is published json: topic: 'zigbee2mqtt/my_bulb' payload '{\"state\": \"ON\"}' attribute: topic 'zigbee2mqtt/my_bulb/state' payload 'ON' attribute_and_json: both json and attribute (see above)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"whitelist": {
|
||||
"readOnly": true,
|
||||
"type": "array",
|
||||
"requiresRestart": true,
|
||||
"title": "Whitelist (deprecated, use passlist)",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"ban": {
|
||||
"readOnly": true,
|
||||
"type": "array",
|
||||
"requiresRestart": true,
|
||||
"title": "Ban (deprecated, use blocklist)",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["mqtt"],
|
||||
"definitions": {
|
||||
"device": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"friendly_name": {
|
||||
"type": "string",
|
||||
"title": "Friendly name",
|
||||
"description": "Used in the MQTT topic of a device. By default this is the device ID",
|
||||
"readOnly": true
|
||||
},
|
||||
"retain": {
|
||||
"type": "boolean",
|
||||
"title": "Retain",
|
||||
"description": "Retain MQTT messages of this device"
|
||||
},
|
||||
"retention": {
|
||||
"type": "number",
|
||||
"title": "Retention",
|
||||
"description": "Sets the MQTT Message Expiry in seconds, Make sure to set mqtt.version to 5"
|
||||
},
|
||||
"qos": {
|
||||
"type": "number",
|
||||
"title": "QoS",
|
||||
"description": "QoS level for MQTT messages of this device"
|
||||
},
|
||||
"debounce": {
|
||||
"type": "number",
|
||||
"title": "Debounce",
|
||||
"description": "Debounces messages of this device"
|
||||
},
|
||||
"debounce_ignore": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": ["action"],
|
||||
"title": "Ignore debounce",
|
||||
"description": "Protects unique payload values of specified payload properties from overriding within debounce time"
|
||||
},
|
||||
"optimistic": {
|
||||
"type": "boolean",
|
||||
"title": "Optimistic",
|
||||
"description": "Publish optimistic state after set",
|
||||
"default": true
|
||||
},
|
||||
"filtered_attributes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": ["^temperature$", "^battery$", "^action$"],
|
||||
"title": "Filtered publish attributes",
|
||||
"description": "Filter attributes with regex from published payload."
|
||||
},
|
||||
"filtered_cache": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": ["^input_actions$"],
|
||||
"title": "Filtered attributes from cache",
|
||||
"description": "Filter attributes with regex from being added to the cache, this prevents the attribute from being in the published payload when the value didn't change."
|
||||
},
|
||||
"filtered_optimistic": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": ["^color_(mode|temp)$", "color"],
|
||||
"title": "Filtered optimistic attributes",
|
||||
"description": "Filter attributes with regex from optimistic publish payload when calling /set. (This has no effect if optimistic is set to false)."
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"title": "Icon",
|
||||
"description": "The user-defined device icon for the frontend. It can be a link to an image (not a path to a file) or base64 encoded data URL like: image/svg+xml;base64,PHN2ZyB3aW....R0aD"
|
||||
},
|
||||
"homeassistant": {
|
||||
"type": ["object", "null"],
|
||||
"title": "Home Assistant",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Home Assistant name",
|
||||
"description": "Name of the device in Home Assistant"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["friendly_name"]
|
||||
},
|
||||
"group": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"friendly_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"retain": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"devices": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"optimistic": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"qos": {
|
||||
"type": "number"
|
||||
},
|
||||
"off_state": {
|
||||
"type": ["string"],
|
||||
"enum": ["all_members_off", "last_member_state"],
|
||||
"title": "Group off state",
|
||||
"default": "auto",
|
||||
"requiresRestart": true,
|
||||
"description": "Control when to publish state OFF for a group. 'all_members_off': only publish state OFF when all group memebers are in state OFF, 'last_member_state': publish state OFF whenever one of its members changes to OFF"
|
||||
},
|
||||
"filtered_attributes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["friendly_name"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
import data from './data';
|
||||
import utils from './utils';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import path from 'path';
|
||||
import yaml from './yaml';
|
||||
import Ajv from 'ajv';
|
||||
import schemaJson from './settings.schema.json';
|
||||
export let schema = schemaJson;
|
||||
// @ts-ignore
|
||||
schema = {};
|
||||
objectAssignDeep(schema, schemaJson);
|
||||
|
||||
// Remove legacy settings from schema
|
||||
{
|
||||
delete schema.properties.advanced.properties.homeassistant_discovery_topic;
|
||||
delete schema.properties.advanced.properties.homeassistant_legacy_entity_attributes;
|
||||
delete schema.properties.advanced.properties.homeassistant_legacy_triggers;
|
||||
delete schema.properties.advanced.properties.homeassistant_status_topic;
|
||||
delete schema.properties.advanced.properties.soft_reset_timeout;
|
||||
delete schema.properties.advanced.properties.report;
|
||||
delete schema.properties.advanced.properties.baudrate;
|
||||
delete schema.properties.advanced.properties.rtscts;
|
||||
delete schema.properties.advanced.properties.ikea_ota_use_test_url;
|
||||
delete schema.properties.experimental;
|
||||
delete schemaJson.properties.whitelist;
|
||||
delete schemaJson.properties.ban;
|
||||
}
|
||||
|
||||
// DEPRECATED ZIGBEE2MQTT_CONFIG: https://github.com/Koenkk/zigbee2mqtt/issues/4697
|
||||
const file = process.env.ZIGBEE2MQTT_CONFIG ?? data.joinPath('configuration.yaml');
|
||||
const ajvSetting = new Ajv({allErrors: true}).addKeyword('requiresRestart').compile(schemaJson);
|
||||
const ajvRestartRequired = new Ajv({allErrors: true})
|
||||
.addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s}).compile(schemaJson);
|
||||
|
||||
const defaults: RecursivePartial<Settings> = {
|
||||
permit_join: false,
|
||||
external_converters: [],
|
||||
mqtt: {
|
||||
base_topic: 'zigbee2mqtt',
|
||||
include_device_information: false,
|
||||
force_disable_retain: false,
|
||||
},
|
||||
serial: {
|
||||
disable_led: false,
|
||||
},
|
||||
passlist: [],
|
||||
blocklist: [],
|
||||
map_options: {
|
||||
graphviz: {
|
||||
colors: {
|
||||
fill: {
|
||||
enddevice: '#fff8ce',
|
||||
coordinator: '#e04e5d',
|
||||
router: '#4ea3e0',
|
||||
},
|
||||
font: {
|
||||
coordinator: '#ffffff',
|
||||
router: '#ffffff',
|
||||
enddevice: '#000000',
|
||||
},
|
||||
line: {
|
||||
active: '#009900',
|
||||
inactive: '#994444',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ota: {
|
||||
update_check_interval: 24 * 60,
|
||||
disable_automatic_update_check: false,
|
||||
},
|
||||
device_options: {},
|
||||
advanced: {
|
||||
legacy_api: true,
|
||||
legacy_availability_payload: true,
|
||||
log_rotation: true,
|
||||
log_symlink_current: false,
|
||||
log_output: ['console', 'file'],
|
||||
log_directory: path.join(data.getPath(), 'log', '%TIMESTAMP%'),
|
||||
log_file: 'log.txt',
|
||||
log_level: /* istanbul ignore next */ process.env.DEBUG ? 'debug' : 'info',
|
||||
log_syslog: {},
|
||||
pan_id: 0x1a62,
|
||||
ext_pan_id: [0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD],
|
||||
channel: 11,
|
||||
adapter_concurrent: null,
|
||||
adapter_delay: null,
|
||||
cache_state: true,
|
||||
cache_state_persistent: true,
|
||||
cache_state_send_on_startup: true,
|
||||
last_seen: 'disable',
|
||||
elapsed: false,
|
||||
network_key: [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13],
|
||||
timestamp_format: 'YYYY-MM-DD HH:mm:ss',
|
||||
output: 'json',
|
||||
// Everything below is deprecated
|
||||
availability_blocklist: [],
|
||||
availability_passlist: [],
|
||||
availability_blacklist: [],
|
||||
availability_whitelist: [],
|
||||
soft_reset_timeout: 0,
|
||||
report: false,
|
||||
},
|
||||
};
|
||||
|
||||
let _settings: Partial<Settings>;
|
||||
let _settingsWithDefaults: Settings;
|
||||
|
||||
function loadSettingsWithDefaults(): void {
|
||||
_settingsWithDefaults = objectAssignDeep({}, defaults, getInternalSettings()) as Settings;
|
||||
|
||||
if (!_settingsWithDefaults.devices) {
|
||||
_settingsWithDefaults.devices = {};
|
||||
}
|
||||
|
||||
if (!_settingsWithDefaults.groups) {
|
||||
_settingsWithDefaults.groups = {};
|
||||
}
|
||||
|
||||
if (_settingsWithDefaults.homeassistant) {
|
||||
const defaults = {discovery_topic: 'homeassistant', status_topic: 'hass/status',
|
||||
legacy_entity_attributes: true, legacy_triggers: true};
|
||||
const sLegacy = {};
|
||||
if (_settingsWithDefaults.advanced) {
|
||||
for (const key of ['homeassistant_legacy_triggers', 'homeassistant_discovery_topic',
|
||||
'homeassistant_legacy_entity_attributes', 'homeassistant_status_topic']) {
|
||||
// @ts-ignore
|
||||
if (_settingsWithDefaults.advanced[key] !== undefined) {
|
||||
// @ts-ignore
|
||||
sLegacy[key.replace('homeassistant_', '')] = _settingsWithDefaults.advanced[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const s = typeof _settingsWithDefaults.homeassistant === 'object' ? _settingsWithDefaults.homeassistant : {};
|
||||
// @ts-ignore
|
||||
_settingsWithDefaults.homeassistant = {};
|
||||
objectAssignDeep(_settingsWithDefaults.homeassistant, defaults, sLegacy, s);
|
||||
}
|
||||
|
||||
if (_settingsWithDefaults.availability || _settingsWithDefaults.advanced?.availability_timeout) {
|
||||
const defaults = {};
|
||||
const s = typeof _settingsWithDefaults.availability === 'object' ? _settingsWithDefaults.availability : {};
|
||||
// @ts-ignore
|
||||
_settingsWithDefaults.availability = {};
|
||||
objectAssignDeep(_settingsWithDefaults.availability, defaults, s);
|
||||
}
|
||||
|
||||
if (_settingsWithDefaults.frontend) {
|
||||
const defaults = {port: 8080, auth_token: false, host: '0.0.0.0'};
|
||||
const s = typeof _settingsWithDefaults.frontend === 'object' ? _settingsWithDefaults.frontend : {};
|
||||
// @ts-ignore
|
||||
_settingsWithDefaults.frontend = {};
|
||||
objectAssignDeep(_settingsWithDefaults.frontend, defaults, s);
|
||||
}
|
||||
|
||||
if (_settings.advanced?.hasOwnProperty('baudrate') && _settings.serial?.baudrate == null) {
|
||||
// @ts-ignore
|
||||
_settingsWithDefaults.serial.baudrate = _settings.advanced.baudrate;
|
||||
}
|
||||
|
||||
if (_settings.advanced?.hasOwnProperty('rtscts') && _settings.serial?.rtscts == null) {
|
||||
// @ts-ignore
|
||||
_settingsWithDefaults.serial.rtscts = _settings.advanced.rtscts;
|
||||
}
|
||||
|
||||
if (_settings.advanced?.hasOwnProperty('ikea_ota_use_test_url') && _settings.ota?.ikea_ota_use_test_url == null) {
|
||||
// @ts-ignore
|
||||
_settingsWithDefaults.ota.ikea_ota_use_test_url = _settings.advanced.ikea_ota_use_test_url;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if (_settings.experimental?.hasOwnProperty('transmit_power') && _settings.advanced?.transmit_power == null) {
|
||||
// @ts-ignore
|
||||
_settingsWithDefaults.advanced.transmit_power = _settings.experimental.transmit_power;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if (_settings.experimental?.hasOwnProperty('output') && _settings.advanced?.output == null) {
|
||||
// @ts-ignore
|
||||
_settingsWithDefaults.advanced.output = _settings.experimental.output;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
_settingsWithDefaults.ban && _settingsWithDefaults.blocklist.push(..._settingsWithDefaults.ban);
|
||||
// @ts-ignore
|
||||
_settingsWithDefaults.whitelist && _settingsWithDefaults.passlist.push(..._settingsWithDefaults.whitelist);
|
||||
}
|
||||
|
||||
function write(): void {
|
||||
const settings = getInternalSettings();
|
||||
const toWrite: KeyValue = objectAssignDeep({}, settings);
|
||||
|
||||
// Read settings to check if we have to split devices/groups into separate file.
|
||||
const actual = yaml.read(file);
|
||||
|
||||
// In case the setting is defined in a separte file (e.g. !secret network_key) update it there.
|
||||
for (const path of [
|
||||
['mqtt', 'server'],
|
||||
['mqtt', 'user'],
|
||||
['mqtt', 'password'],
|
||||
['advanced', 'network_key'],
|
||||
['frontend', 'auth_token'],
|
||||
]) {
|
||||
if (actual[path[0]] && actual[path[0]][path[1]]) {
|
||||
const match = /!(.*) (.*)/g.exec(actual[path[0]][path[1]]);
|
||||
if (match) {
|
||||
yaml.updateIfChanged(data.joinPath(`${match[1]}.yaml`), match[2], toWrite[path[0]][path[1]]);
|
||||
toWrite[path[0]][path[1]] = actual[path[0]][path[1]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write devices/groups to separate file if required.
|
||||
const writeDevicesOrGroups = (type: 'devices' | 'groups'): void => {
|
||||
if (typeof actual[type] === 'string' || Array.isArray(actual[type])) {
|
||||
const fileToWrite = Array.isArray(actual[type]) ? actual[type][0] : actual[type];
|
||||
const content = objectAssignDeep({}, settings[type]);
|
||||
|
||||
// If an array, only write to first file and only devices which are not in the other files.
|
||||
if (Array.isArray(actual[type])) {
|
||||
actual[type].filter((f: string, i: number) => i !== 0)
|
||||
.map((f: string) => yaml.readIfExists(data.joinPath(f), {}))
|
||||
.map((c: KeyValue) => Object.keys(c))
|
||||
.forEach((k: string) => delete content[k]);
|
||||
}
|
||||
|
||||
yaml.writeIfChanged(data.joinPath(fileToWrite), content);
|
||||
toWrite[type] = actual[type];
|
||||
}
|
||||
};
|
||||
|
||||
writeDevicesOrGroups('devices');
|
||||
writeDevicesOrGroups('groups');
|
||||
|
||||
yaml.writeIfChanged(file, toWrite);
|
||||
|
||||
_settings = read();
|
||||
loadSettingsWithDefaults();
|
||||
}
|
||||
|
||||
export function validate(): string[] {
|
||||
try {
|
||||
getInternalSettings();
|
||||
} catch (error) {
|
||||
if (error.name === 'YAMLException') {
|
||||
return [
|
||||
`Your YAML file: '${error.file}' is invalid ` +
|
||||
`(use https://jsonformatter.org/yaml-validator to find and fix the issue)`,
|
||||
];
|
||||
}
|
||||
|
||||
return [error.message];
|
||||
}
|
||||
|
||||
if (!ajvSetting(_settings)) {
|
||||
return ajvSetting.errors.map((v) => `${v.instancePath.substring(1)} ${v.message}`);
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
if (_settings.advanced && _settings.advanced.network_key && typeof _settings.advanced.network_key === 'string' &&
|
||||
_settings.advanced.network_key !== 'GENERATE') {
|
||||
errors.push(`advanced.network_key: should be array or 'GENERATE' (is '${_settings.advanced.network_key}')`);
|
||||
}
|
||||
|
||||
if (_settings.advanced && _settings.advanced.pan_id && typeof _settings.advanced.pan_id === 'string' &&
|
||||
_settings.advanced.pan_id !== 'GENERATE') {
|
||||
errors.push(`advanced.pan_id: should be number or 'GENERATE' (is '${_settings.advanced.pan_id}')`);
|
||||
}
|
||||
|
||||
// Verify that all friendly names are unique
|
||||
const names: string[] = [];
|
||||
const check = (e: DeviceOptions | GroupOptions): void => {
|
||||
if (names.includes(e.friendly_name)) errors.push(`Duplicate friendly_name '${e.friendly_name}' found`);
|
||||
errors.push(...utils.validateFriendlyName(e.friendly_name));
|
||||
names.push(e.friendly_name);
|
||||
if (e.qos != null && ![0, 1, 2].includes(e.qos)) {
|
||||
errors.push(`QOS for '${e.friendly_name}' not valid, should be 0, 1 or 2 got ${e.qos}`);
|
||||
}
|
||||
};
|
||||
|
||||
const settingsWithDefaults = get();
|
||||
Object.values(settingsWithDefaults.devices).forEach((d) => check(d));
|
||||
Object.values(settingsWithDefaults.groups).forEach((g) => check(g));
|
||||
|
||||
if (settingsWithDefaults.mqtt.version !== 5) {
|
||||
for (const device of Object.values(settingsWithDefaults.devices)) {
|
||||
if (device.retention) {
|
||||
errors.push('MQTT retention requires protocol version 5');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkAvailabilityList = (list: string[], type: string): void => {
|
||||
list.forEach((e) => {
|
||||
if (!getDevice(e)) {
|
||||
errors.push(`Non-existing entity '${e}' specified in '${type}'`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
checkAvailabilityList(settingsWithDefaults.advanced.availability_blacklist, 'availability_blacklist');
|
||||
checkAvailabilityList(settingsWithDefaults.advanced.availability_whitelist, 'availability_whitelist');
|
||||
checkAvailabilityList(settingsWithDefaults.advanced.availability_blocklist, 'availability_blocklist');
|
||||
checkAvailabilityList(settingsWithDefaults.advanced.availability_passlist, 'availability_passlist');
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function read(): Settings {
|
||||
const s = yaml.read(file) as Settings;
|
||||
|
||||
// Read !secret MQTT username and password if set
|
||||
// eslint-disable-next-line
|
||||
const interpetValue = (value: any): any => {
|
||||
const re = /!(.*) (.*)/g;
|
||||
const match = re.exec(value);
|
||||
if (match) {
|
||||
const file = data.joinPath(`${match[1]}.yaml`);
|
||||
const key = match[2];
|
||||
return yaml.read(file)[key];
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
if (s.mqtt?.user) {
|
||||
s.mqtt.user = interpetValue(s.mqtt.user);
|
||||
}
|
||||
|
||||
if (s.mqtt?.password) {
|
||||
s.mqtt.password = interpetValue(s.mqtt.password);
|
||||
}
|
||||
|
||||
if (s.mqtt?.server) {
|
||||
s.mqtt.server = interpetValue(s.mqtt.server);
|
||||
}
|
||||
|
||||
if (s.advanced?.network_key) {
|
||||
s.advanced.network_key = interpetValue(s.advanced.network_key);
|
||||
}
|
||||
|
||||
if (s.frontend?.auth_token) {
|
||||
s.frontend.auth_token = interpetValue(s.frontend.auth_token);
|
||||
}
|
||||
|
||||
// Read devices/groups configuration from separate file if specified.
|
||||
const readDevicesOrGroups = (type: 'devices' | 'groups'): void => {
|
||||
if (typeof s[type] === 'string' || Array.isArray(s[type])) {
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
const files: string[] = Array.isArray(s[type]) ? s[type] : [s[type]];
|
||||
s[type] = {};
|
||||
for (const file of files) {
|
||||
const content = yaml.readIfExists(data.joinPath(file), {});
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
s[type] = objectAssignDeep.noMutate(s[type], content);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
readDevicesOrGroups('devices');
|
||||
readDevicesOrGroups('groups');
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
function applyEnvironmentVariables(settings: Partial<Settings>): void {
|
||||
const iterate = (obj: KeyValue, path: string[]): void => {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (key !== 'type') {
|
||||
if (key !== 'properties' && obj[key]) {
|
||||
const type = (obj[key].type || 'object').toString();
|
||||
const envPart = path.reduce((acc, val) => `${acc}${val}_`, '');
|
||||
const envVariableName = (`ZIGBEE2MQTT_CONFIG_${envPart}${key}`).toUpperCase();
|
||||
if (process.env[envVariableName]) {
|
||||
const setting = path.reduce((acc, val) => {
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
acc[val] = acc[val] || {};
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
return acc[val];
|
||||
}, settings);
|
||||
|
||||
if (type.indexOf('object') >= 0 || type.indexOf('array') >= 0) {
|
||||
try {
|
||||
setting[key] = JSON.parse(process.env[envVariableName]);
|
||||
} catch (error) {
|
||||
setting[key] = process.env[envVariableName];
|
||||
}
|
||||
} else if (type.indexOf('number') >= 0) {
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
setting[key] = process.env[envVariableName] * 1;
|
||||
} else if (type.indexOf('boolean') >= 0) {
|
||||
setting[key] = process.env[envVariableName].toLowerCase() === 'true';
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (type.indexOf('string') >= 0) {
|
||||
setting[key] = process.env[envVariableName];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof obj[key] === 'object' && obj[key]) {
|
||||
const newPath = [...path];
|
||||
if (key !== 'properties' && key !== 'oneOf' && !Number.isInteger(Number(key))) {
|
||||
newPath.push(key);
|
||||
}
|
||||
iterate(obj[key], newPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
iterate(schemaJson.properties, []);
|
||||
}
|
||||
|
||||
function getInternalSettings(): Partial<Settings> {
|
||||
if (!_settings) {
|
||||
_settings = read();
|
||||
applyEnvironmentVariables(_settings);
|
||||
}
|
||||
|
||||
return _settings;
|
||||
}
|
||||
|
||||
export function get(): Settings {
|
||||
if (!_settingsWithDefaults) {
|
||||
loadSettingsWithDefaults();
|
||||
}
|
||||
|
||||
return _settingsWithDefaults;
|
||||
}
|
||||
|
||||
export function set(path: string[], value: string | number | boolean | KeyValue): void {
|
||||
/* eslint-disable-next-line */
|
||||
let settings: any = getInternalSettings();
|
||||
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const key = path[i];
|
||||
if (i === path.length - 1) {
|
||||
settings[key] = value;
|
||||
} else {
|
||||
if (!settings[key]) {
|
||||
settings[key] = {};
|
||||
}
|
||||
|
||||
settings = settings[key];
|
||||
}
|
||||
}
|
||||
|
||||
write();
|
||||
}
|
||||
|
||||
export function apply(newSettings: Record<string, unknown>): boolean {
|
||||
ajvSetting(newSettings);
|
||||
const errors = ajvSetting.errors && ajvSetting.errors.filter((e) => e.keyword !== 'required');
|
||||
if (errors.length) {
|
||||
const error = errors[0];
|
||||
throw new Error(`${error.instancePath.substring(1)} ${error.message}`);
|
||||
}
|
||||
|
||||
getInternalSettings(); // Ensure _settings is intialized.
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
_settings = objectAssignDeep.noMutate(_settings, newSettings);
|
||||
write();
|
||||
|
||||
ajvRestartRequired(newSettings);
|
||||
const restartRequired = ajvRestartRequired.errors &&
|
||||
!!ajvRestartRequired.errors.find((e) => e.keyword === 'requiresRestart');
|
||||
return restartRequired;
|
||||
}
|
||||
|
||||
export function getGroup(IDorName: string | number): GroupOptions {
|
||||
const settings = get();
|
||||
const byID = settings.groups[IDorName];
|
||||
if (byID) {
|
||||
return {devices: [], ...byID, ID: Number(IDorName)};
|
||||
}
|
||||
|
||||
for (const [ID, group] of Object.entries(settings.groups)) {
|
||||
if (group.friendly_name === IDorName) {
|
||||
return {devices: [], ...group, ID: Number(ID)};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getGroups(): GroupOptions[] {
|
||||
const settings = get();
|
||||
return Object.entries(settings.groups).map(([ID, group]) => {
|
||||
return {devices: [], ...group, ID: Number(ID)};
|
||||
});
|
||||
}
|
||||
|
||||
function getGroupThrowIfNotExists(IDorName: string): GroupOptions {
|
||||
const group = getGroup(IDorName);
|
||||
if (!group) {
|
||||
throw new Error(`Group '${IDorName}' does not exist`);
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
export function getDevice(IDorName: string): DeviceOptions {
|
||||
const settings = get();
|
||||
const byID = settings.devices[IDorName];
|
||||
if (byID) {
|
||||
return {...byID, ID: IDorName};
|
||||
}
|
||||
|
||||
for (const [ID, device] of Object.entries(settings.devices)) {
|
||||
if (device.friendly_name === IDorName) {
|
||||
return {...device, ID};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDeviceThrowIfNotExists(IDorName: string): DeviceOptions {
|
||||
const device = getDevice(IDorName);
|
||||
if (!device) {
|
||||
throw new Error(`Device '${IDorName}' does not exist`);
|
||||
}
|
||||
|
||||
return device;
|
||||
}
|
||||
|
||||
export function addDevice(ID: string): DeviceOptions {
|
||||
if (getDevice(ID)) {
|
||||
throw new Error(`Device '${ID}' already exists`);
|
||||
}
|
||||
|
||||
const settings = getInternalSettings();
|
||||
|
||||
if (!settings.devices) {
|
||||
settings.devices = {};
|
||||
}
|
||||
|
||||
settings.devices[ID] = {friendly_name: ID};
|
||||
write();
|
||||
return getDevice(ID);
|
||||
}
|
||||
|
||||
export function addDeviceToPasslist(ID: string): void {
|
||||
const settings = getInternalSettings();
|
||||
if (!settings.passlist) {
|
||||
settings.passlist = [];
|
||||
}
|
||||
|
||||
if (settings.passlist.includes(ID)) {
|
||||
throw new Error(`Device '${ID}' already in passlist`);
|
||||
}
|
||||
|
||||
settings.passlist.push(ID);
|
||||
write();
|
||||
}
|
||||
|
||||
export function blockDevice(ID: string): void {
|
||||
const settings = getInternalSettings();
|
||||
if (!settings.blocklist) {
|
||||
settings.blocklist = [];
|
||||
}
|
||||
|
||||
settings.blocklist.push(ID);
|
||||
write();
|
||||
}
|
||||
|
||||
export function removeDevice(IDorName: string): void {
|
||||
const device = getDeviceThrowIfNotExists(IDorName);
|
||||
const settings = getInternalSettings();
|
||||
delete settings.devices[device.ID];
|
||||
|
||||
// Remove device from groups
|
||||
if (settings.groups) {
|
||||
const regex =
|
||||
new RegExp(`^(${device.friendly_name}|${device.ID})(/(\\d|${utils.endpointNames.join('|')}))?$`);
|
||||
for (const group of Object.values(settings.groups).filter((g) => g.devices)) {
|
||||
group.devices = group.devices.filter((device) => !device.match(regex));
|
||||
}
|
||||
}
|
||||
|
||||
write();
|
||||
}
|
||||
|
||||
export function addGroup(name: string, ID?: string): GroupOptions {
|
||||
utils.validateFriendlyName(name, true);
|
||||
if (getGroup(name) || getDevice(name)) {
|
||||
throw new Error(`friendly_name '${name}' is already in use`);
|
||||
}
|
||||
|
||||
const settings = getInternalSettings();
|
||||
if (!settings.groups) {
|
||||
settings.groups = {};
|
||||
}
|
||||
|
||||
if (ID == null) {
|
||||
// look for free ID
|
||||
ID = '1';
|
||||
while (settings.groups.hasOwnProperty(ID)) {
|
||||
ID = (Number.parseInt(ID) + 1).toString();
|
||||
}
|
||||
} else {
|
||||
// ensure provided ID is not in use
|
||||
ID = ID.toString();
|
||||
if (settings.groups.hasOwnProperty(ID)) {
|
||||
throw new Error(`Group ID '${ID}' is already in use`);
|
||||
}
|
||||
}
|
||||
|
||||
settings.groups[ID] = {friendly_name: name};
|
||||
write();
|
||||
|
||||
return getGroup(ID);
|
||||
}
|
||||
|
||||
function groupGetDevice(group: {devices?: string[]}, keys: string[]): string {
|
||||
for (const device of group.devices ?? []) {
|
||||
if (keys.includes(device)) return device;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function addDeviceToGroup(IDorName: string, keys: string[]): void {
|
||||
const groupID = getGroupThrowIfNotExists(IDorName).ID;
|
||||
const settings = getInternalSettings();
|
||||
|
||||
const group = settings.groups[groupID];
|
||||
if (!groupGetDevice(group, keys)) {
|
||||
if (!group.devices) group.devices = [];
|
||||
group.devices.push(keys[0]);
|
||||
write();
|
||||
}
|
||||
}
|
||||
|
||||
export function removeDeviceFromGroup(IDorName: string, keys: string[]): void {
|
||||
const groupID = getGroupThrowIfNotExists(IDorName).ID;
|
||||
const settings = getInternalSettings();
|
||||
const group = settings.groups[groupID];
|
||||
if (!group.devices) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = groupGetDevice(group, keys);
|
||||
if (key) {
|
||||
group.devices = group.devices.filter((d) => d != key);
|
||||
write();
|
||||
}
|
||||
}
|
||||
|
||||
export function removeGroup(IDorName: string | number): void {
|
||||
const groupID = getGroupThrowIfNotExists(IDorName.toString()).ID;
|
||||
const settings = getInternalSettings();
|
||||
delete settings.groups[groupID];
|
||||
write();
|
||||
}
|
||||
|
||||
export function changeEntityOptions(IDorName: string, newOptions: KeyValue): void {
|
||||
const settings = getInternalSettings();
|
||||
delete newOptions.friendly_name;
|
||||
delete newOptions.devices;
|
||||
if (getDevice(IDorName)) {
|
||||
objectAssignDeep(settings.devices[getDevice(IDorName).ID], newOptions);
|
||||
utils.removeNullPropertiesFromObject(settings.devices[getDevice(IDorName).ID]);
|
||||
} else if (getGroup(IDorName)) {
|
||||
objectAssignDeep(settings.groups[getGroup(IDorName).ID], newOptions);
|
||||
utils.removeNullPropertiesFromObject(settings.groups[getGroup(IDorName).ID]);
|
||||
} else {
|
||||
throw new Error(`Device or group '${IDorName}' does not exist`);
|
||||
}
|
||||
|
||||
write();
|
||||
}
|
||||
|
||||
export function changeFriendlyName(IDorName: string, newName: string): void {
|
||||
utils.validateFriendlyName(newName, true);
|
||||
if (getGroup(newName) || getDevice(newName)) {
|
||||
throw new Error(`friendly_name '${newName}' is already in use`);
|
||||
}
|
||||
|
||||
const settings = getInternalSettings();
|
||||
if (getDevice(IDorName)) {
|
||||
settings.devices[getDevice(IDorName).ID].friendly_name = newName;
|
||||
} else if (getGroup(IDorName)) {
|
||||
settings.groups[getGroup(IDorName).ID].friendly_name = newName;
|
||||
} else {
|
||||
throw new Error(`Device or group '${IDorName}' does not exist`);
|
||||
}
|
||||
|
||||
write();
|
||||
}
|
||||
|
||||
export function reRead(): void {
|
||||
_settings = null;
|
||||
getInternalSettings();
|
||||
_settingsWithDefaults = null;
|
||||
get();
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
write,
|
||||
clear: (): void => {
|
||||
_settings = null;
|
||||
_settingsWithDefaults = null;
|
||||
},
|
||||
defaults,
|
||||
};
|
||||
@@ -1,202 +0,0 @@
|
||||
const equals = require('fast-deep-equal/es6');
|
||||
const humanizeDuration = require('humanize-duration');
|
||||
const data = require('./data');
|
||||
|
||||
// Xiaomi uses 4151 and 4447 (lumi.plug) as manufacturer ID.
|
||||
const xiaomiManufacturerID = [4151, 4447];
|
||||
const ikeaTradfriManufacturerID = [4476];
|
||||
|
||||
// construct a local ISO8601 string (instead of UTC-based)
|
||||
// Example:
|
||||
// - ISO8601 (UTC) = 2019-03-01T15:32:45.941+0000
|
||||
// - ISO8601 (local) = 2019-03-01T16:32:45.941+0100 (for timezone GMT+1)
|
||||
function toLocalISOString(dDate) {
|
||||
const tzOffset = -dDate.getTimezoneOffset();
|
||||
const plusOrMinus = tzOffset >= 0 ? '+' : '-';
|
||||
const pad = function(num) {
|
||||
const norm = Math.floor(Math.abs(num));
|
||||
return (norm < 10 ? '0' : '') + norm;
|
||||
};
|
||||
|
||||
return dDate.getFullYear() +
|
||||
'-' + pad(dDate.getMonth() + 1) +
|
||||
'-' + pad(dDate.getDate()) +
|
||||
'T' + pad(dDate.getHours()) +
|
||||
':' + pad(dDate.getMinutes()) +
|
||||
':' + pad(dDate.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',
|
||||
'l1', 'l2', 'l3', 'l4', 'l5', 'l6', 'l7', 'l8',
|
||||
'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',
|
||||
];
|
||||
|
||||
function capitalize(s) {
|
||||
return s[0].toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
async function getZigbee2mqttVersion() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const git = require('git-last-commit');
|
||||
const packageJSON = require('../../package.json');
|
||||
const version = packageJSON.version;
|
||||
|
||||
git.getLastCommit((err, commit) => {
|
||||
let commitHash = null;
|
||||
|
||||
if (err) {
|
||||
try {
|
||||
commitHash = require('../../.hash.json').hash;
|
||||
} catch (error) {
|
||||
commitHash = 'unknown';
|
||||
}
|
||||
} else {
|
||||
commitHash = commit.shortHash;
|
||||
}
|
||||
|
||||
resolve({commitHash, version});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(date, type) {
|
||||
let result;
|
||||
|
||||
switch (type) {
|
||||
case 'ISO_8601':
|
||||
result = new Date(date).toISOString();
|
||||
break;
|
||||
case 'ISO_8601_local':
|
||||
result = toLocalISOString(new Date(date));
|
||||
break;
|
||||
case 'epoch':
|
||||
result = date;
|
||||
break;
|
||||
case 'relative':
|
||||
// https://github.com/EvanHahn/HumanizeDuration.js#options
|
||||
result = humanizeDuration(Date.now() - date, {language: 'en', largest: 2, round: true}) + ' ago';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported type '${type}'`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function objectHasProperties(object, properties) {
|
||||
for (const property of properties) {
|
||||
if (!object.hasOwnProperty(property)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function equalsPartial(object, expected) {
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
if (!equals(object[key], value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getObjectProperty(object, key, defaultValue) {
|
||||
return object.hasOwnProperty(key) ? object[key] : defaultValue;
|
||||
}
|
||||
|
||||
function getResponse(request, data, error) {
|
||||
const response = {data, status: error ? 'error' : 'ok'};
|
||||
if (error) response.error = error;
|
||||
if (typeof request === 'object' && request.hasOwnProperty('transaction')) {
|
||||
response.transaction = request.transaction;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function parseJSON(value, failedReturnValue) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return failedReturnValue;
|
||||
}
|
||||
}
|
||||
|
||||
function* getExternalConvertersDefinitions(settings) {
|
||||
const externalConverters = settings.get().external_converters;
|
||||
|
||||
for (const moduleName of externalConverters) {
|
||||
let converterModule = moduleName;
|
||||
|
||||
if (moduleName.endsWith('.js')) {
|
||||
converterModule = data.joinPath(moduleName.split('.')[0]);
|
||||
}
|
||||
|
||||
const converter = require(converterModule);
|
||||
if (Array.isArray(converter)) {
|
||||
for (const item of converter) {
|
||||
yield item;
|
||||
}
|
||||
} else {
|
||||
yield converter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toSnakeCase(value) {
|
||||
if (typeof value === 'object') {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
function validateFriendlyName(name) {
|
||||
const errors = [];
|
||||
if (endpointNames.includes(name)) errors.push(`Following friendly_name are not allowed: '${endpointNames}'`);
|
||||
if (name.match(/.*\/\d*$/)) errors.push(`Friendly name cannot end with a "/DIGIT" ('${name}')`);
|
||||
if (name.includes('#') || name.includes('+')) {
|
||||
errors.push(`MQTT wildcard (+ and #) not allowed in friendly_name ('${name}')`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
millisecondsToSeconds: (milliseconds) => milliseconds / 1000,
|
||||
secondsToMilliseconds: (seconds) => seconds * 1000,
|
||||
getZigbee2mqttVersion,
|
||||
objectHasProperties,
|
||||
toSnakeCase,
|
||||
getObjectProperty,
|
||||
getEndpointNames: () => endpointNames,
|
||||
isXiaomiDevice: (device) => {
|
||||
return device.modelID !== 'lumi.router' && xiaomiManufacturerID.includes(device.manufacturerID) &&
|
||||
(!device.manufacturerName || !device.manufacturerName.startsWith('Trust'));
|
||||
},
|
||||
isIkeaTradfriDevice: (device) => ikeaTradfriManufacturerID.includes(device.manufacturerID),
|
||||
formatDate: (date, type) => formatDate(date, type),
|
||||
equalsPartial,
|
||||
getResponse,
|
||||
capitalize,
|
||||
parseJSON,
|
||||
getExternalConvertersDefinitions,
|
||||
validateFriendlyName,
|
||||
};
|
||||
@@ -0,0 +1,361 @@
|
||||
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';
|
||||
|
||||
// construct a local ISO8601 string (instead of UTC-based)
|
||||
// Example:
|
||||
// - ISO8601 (UTC) = 2019-03-01T15:32:45.941+0000
|
||||
// - 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;
|
||||
};
|
||||
|
||||
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');
|
||||
|
||||
if (!includeCommitHash) {
|
||||
return {version: packageJSON.version, commitHash: null};
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const version = packageJSON.version;
|
||||
|
||||
git.getLastCommit((err: Error, commit: {shortHash: string}) => {
|
||||
let commitHash = null;
|
||||
|
||||
if (err) {
|
||||
try {
|
||||
commitHash = fs.readFileSync(path.join(__dirname, '..', '..', 'dist', '.hash'), 'utf-8');
|
||||
} catch (error) {
|
||||
/* istanbul ignore next */
|
||||
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(__dirname, '..', '..', 'node_modules', depend, 'package.json'));
|
||||
const version = packageJSON.version;
|
||||
return {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 objectHasProperties(object: {[s: string]: unknown}, properties: string[]): boolean {
|
||||
for (const property of properties) {
|
||||
if (!object.hasOwnProperty(property)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function equalsPartial(object: KeyValue, expected: KeyValue): boolean {
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
if (!equals(object[key], value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getObjectProperty(object: KeyValue, key: string, defaultValue: unknown): unknown {
|
||||
return object && object.hasOwnProperty(key) ? 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')) {
|
||||
response.transaction = request.transaction;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function parseJSON(value: string, fallback: string): KeyValue | string {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function loadModuleFromText(moduleCode: string): unknown {
|
||||
const moduleFakePath = path.join(__dirname, '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 {
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = obj[key];
|
||||
if (value == null) {
|
||||
delete obj[key];
|
||||
} else if (typeof value === 'object') {
|
||||
removeNullPropertiesFromObject(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
function charRange(start: string, stop: string): number[] {
|
||||
const result = [];
|
||||
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'),
|
||||
];
|
||||
|
||||
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)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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);
|
||||
} else {
|
||||
result.push(...getAllFiles(item));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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.match(/.*\/\d*$/)) errors.push(`Friendly name cannot end with a "/DIGIT" ('${name}')`);
|
||||
if (name.includes('#') || name.includes('+')) {
|
||||
errors.push(`MQTT wildcard (+ and #) not allowed in friendly_name ('${name}')`);
|
||||
}
|
||||
|
||||
if (throwFirstError && errors.length) {
|
||||
throw new Error(errors[0]);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function sleep(seconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function isAvailabilityEnabledForEntity(entity: Device | Group, settings: Settings): boolean {
|
||||
if (entity.isGroup()) {
|
||||
return !entity.membersDevices().map((d) => isAvailabilityEnabledForEntity(d, settings)).includes(false);
|
||||
}
|
||||
|
||||
if (entity.options.hasOwnProperty('availability')) {
|
||||
return !!entity.options.availability;
|
||||
}
|
||||
|
||||
// availability_timeout = deprecated
|
||||
const enabledGlobal = settings.advanced.availability_timeout || settings.availability;
|
||||
if (!enabledGlobal) 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);
|
||||
}
|
||||
|
||||
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 isZHGroup(obj: unknown): obj is zh.Group {
|
||||
return obj.constructor.name.toLowerCase() === 'group';
|
||||
}
|
||||
|
||||
function availabilityPayload(state: 'online' | 'offline', settings: Settings): string {
|
||||
return settings.advanced.legacy_availability_payload ? state : JSON.stringify({state});
|
||||
}
|
||||
|
||||
const hours = (hours: number): number => 1000 * 60 * 60 * hours;
|
||||
const minutes = (minutes: number): number => 1000 * 60 * minutes;
|
||||
const seconds = (seconds: number): number => 1000 * seconds;
|
||||
|
||||
function publishLastSeen(data: eventdata.LastSeenChanged, settings: Settings, allowMessageEmitted: boolean,
|
||||
publishEntityState: PublishEntityState): 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
|
||||
* message based on the received zigbee message. In this case allowMessageEmitted has to be true.
|
||||
* - 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');
|
||||
}
|
||||
}
|
||||
|
||||
function filterProperties(filter: string[], data: KeyValue): void {
|
||||
if (filter) {
|
||||
for (const property of Object.keys(data)) {
|
||||
if (filter.find((p) => property.match(`^${p}$`))) {
|
||||
delete data[property];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
const yaml = require('js-yaml');
|
||||
const fs = require('fs');
|
||||
const equals = require('fast-deep-equal/es6');
|
||||
|
||||
function read(file) {
|
||||
try {
|
||||
return yaml.safeLoad(fs.readFileSync(file, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.name === 'YAMLException') {
|
||||
error.file = file;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function readIfExists(file) {
|
||||
return fs.existsSync(file) ? read(file) : null;
|
||||
}
|
||||
|
||||
function writeIfChanged(file, content) {
|
||||
const before = readIfExists(file);
|
||||
if (!equals(before, content)) {
|
||||
fs.writeFileSync(file, yaml.safeDump(content));
|
||||
}
|
||||
}
|
||||
|
||||
function updateIfChanged(file, key, value) {
|
||||
const content = read(file);
|
||||
if (content[key] !== value) {
|
||||
content[key] = value;
|
||||
writeIfChanged(file, content);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {read, readIfExists, writeIfChanged, updateIfChanged};
|
||||
@@ -0,0 +1,36 @@
|
||||
import yaml from 'js-yaml';
|
||||
import fs from 'fs';
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
|
||||
function read(file: string): KeyValue {
|
||||
try {
|
||||
return yaml.load(fs.readFileSync(file, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.name === 'YAMLException') {
|
||||
error.file = file;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function readIfExists(file: string, default_?: KeyValue): KeyValue {
|
||||
return fs.existsSync(file) ? read(file) : default_;
|
||||
}
|
||||
|
||||
function writeIfChanged(file: string, content: KeyValue): void {
|
||||
const before = readIfExists(file);
|
||||
if (!equals(before, content)) {
|
||||
fs.writeFileSync(file, yaml.dump(content));
|
||||
}
|
||||
}
|
||||
|
||||
function updateIfChanged(file: string, key: string, value: KeyValue): void {
|
||||
const content = read(file);
|
||||
if (content[key] !== value) {
|
||||
content[key] = value;
|
||||
writeIfChanged(file, content);
|
||||
}
|
||||
}
|
||||
|
||||
export default {read, readIfExists, updateIfChanged, writeIfChanged};
|
||||
@@ -1,307 +0,0 @@
|
||||
const ZigbeeHerdsman = require('zigbee-herdsman');
|
||||
const logger = require('./util/logger');
|
||||
const settings = require('./util/settings');
|
||||
const data = require('./util/data');
|
||||
const assert = require('assert');
|
||||
const utils = require('./util/utils');
|
||||
const events = require('events');
|
||||
const objectAssignDeep = require('object-assign-deep');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
const endpointNames = utils.getEndpointNames();
|
||||
const keyEndpointByNumber = new RegExp(`.*/([0-9]*)$`);
|
||||
|
||||
class Zigbee extends events.EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.acceptJoiningDeviceHandler = this.acceptJoiningDeviceHandler.bind(this);
|
||||
}
|
||||
|
||||
async start() {
|
||||
logger.info(`Starting zigbee-herdsman...`);
|
||||
const herdsmanSettings = {
|
||||
network: {
|
||||
panID: settings.get().advanced.pan_id,
|
||||
extendedPanID: settings.get().advanced.ext_pan_id,
|
||||
channelList: [settings.get().advanced.channel],
|
||||
networkKey: settings.get().advanced.network_key,
|
||||
},
|
||||
databasePath: data.joinPath('database.db'),
|
||||
databaseBackupPath: data.joinPath('database.db.backup'),
|
||||
backupPath: data.joinPath('coordinator_backup.json'),
|
||||
serialPort: {
|
||||
baudRate: settings.get().advanced.baudrate,
|
||||
rtscts: settings.get().advanced.rtscts,
|
||||
path: settings.get().serial.port,
|
||||
adapter: settings.get().serial.adapter,
|
||||
},
|
||||
adapter: {
|
||||
concurrent: settings.get().advanced.adapter_concurrent,
|
||||
},
|
||||
};
|
||||
|
||||
const herdsmanSettingsLog = objectAssignDeep.noMutate(herdsmanSettings);
|
||||
herdsmanSettingsLog.network.networkKey = 'HIDDEN';
|
||||
logger.debug(`Using zigbee-herdsman with settings: '${stringify(herdsmanSettingsLog)}'`);
|
||||
|
||||
if (herdsmanSettings.network.networkKey === 'GENERATE') {
|
||||
const newKey = Array.from({length: 16}, () => Math.floor(Math.random() * 255));
|
||||
settings.set(['advanced', 'network_key'], newKey);
|
||||
herdsmanSettings.network.networkKey = newKey;
|
||||
}
|
||||
|
||||
try {
|
||||
herdsmanSettings.acceptJoiningDeviceHandler = this.acceptJoiningDeviceHandler;
|
||||
this.herdsman = new ZigbeeHerdsman.Controller(herdsmanSettings);
|
||||
await this.herdsman.start();
|
||||
} catch (error) {
|
||||
logger.error(`Error while starting zigbee-herdsman`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.herdsman.on('adapterDisconnected', () => this.emit('adapterDisconnected'));
|
||||
this.herdsman.on('deviceAnnounce', (data) => this.emit('event', 'deviceAnnounce', data));
|
||||
this.herdsman.on('deviceInterview', (data) => this.emit('event', 'deviceInterview', data));
|
||||
this.herdsman.on('deviceJoined', (data) => this.emit('event', 'deviceJoined', data));
|
||||
this.herdsman.on('deviceLeave', (data) => this.emit('event', 'deviceLeave', data));
|
||||
this.herdsman.on('message', (data) => this.emit('event', 'message', data));
|
||||
|
||||
logger.info('zigbee-herdsman started');
|
||||
logger.info(`Coordinator firmware version: '${stringify(await this.getCoordinatorVersion())}'`);
|
||||
logger.debug(`Zigbee network parameters: ${stringify(await this.herdsman.getNetworkParameters())}`);
|
||||
|
||||
for (const device of this.getClients()) {
|
||||
// If a passlist is used, all other device will be removed from the network.
|
||||
const passlist = settings.get().passlist.concat(settings.get().whitelist);
|
||||
const blocklist = settings.get().blocklist.concat(settings.get().ban);
|
||||
if (passlist.length > 0) {
|
||||
if (!passlist.includes(device.ieeeAddr)) {
|
||||
logger.warn(`Device which is not on passlist connected (${device.ieeeAddr}), removing...`);
|
||||
device.removeFromNetwork();
|
||||
}
|
||||
} else if (blocklist.includes(device.ieeeAddr)) {
|
||||
logger.warn(`Device on blocklist is connected (${device.ieeeAddr}), removing...`);
|
||||
device.removeFromNetwork();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have to turn off the led
|
||||
if (settings.get().serial.disable_led) {
|
||||
await this.herdsman.setLED(false);
|
||||
}
|
||||
|
||||
// Check if we have to set a transmit power
|
||||
if (settings.get().experimental.hasOwnProperty('transmit_power')) {
|
||||
const transmitPower = settings.get().experimental.transmit_power;
|
||||
await this.herdsman.setTransmitPower(transmitPower);
|
||||
logger.info(`Set transmit power to '${transmitPower}'`);
|
||||
}
|
||||
}
|
||||
|
||||
async getCoordinatorVersion() {
|
||||
return this.herdsman.getCoordinatorVersion();
|
||||
}
|
||||
|
||||
async getNetworkParameters() {
|
||||
return this.herdsman.getNetworkParameters();
|
||||
}
|
||||
|
||||
async reset(type) {
|
||||
await this.herdsman.reset(type);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await this.herdsman.stop();
|
||||
logger.info('zigbee-herdsman stopped');
|
||||
}
|
||||
|
||||
async permitJoin(permit) {
|
||||
permit ?
|
||||
logger.info('Zigbee: allowing new devices to join.') :
|
||||
logger.info('Zigbee: disabling joining new devices.');
|
||||
|
||||
await this.herdsman.permitJoin(permit);
|
||||
}
|
||||
|
||||
async getPermitJoin() {
|
||||
return this.herdsman.getPermitJoin();
|
||||
}
|
||||
|
||||
getClients() {
|
||||
return this.herdsman.getDevices().filter((device) => device.type !== 'Coordinator');
|
||||
}
|
||||
|
||||
getDevices() {
|
||||
return this.herdsman.getDevices();
|
||||
}
|
||||
|
||||
getDeviceByIeeeAddr(ieeeAddr) {
|
||||
return this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
|
||||
}
|
||||
|
||||
getDeviceByNetworkAddress(networkAddress) {
|
||||
return this.herdsman.getDeviceByNetworkAddress(networkAddress);
|
||||
}
|
||||
|
||||
getDevicesByType(type) {
|
||||
return this.herdsman.getDevicesByType(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @return {object} {
|
||||
* type: device | coordinator
|
||||
* device|group: zigbee-herdsman entity
|
||||
* endpoint: selected endpoint (only if type === device)
|
||||
* settings: from configuration.yaml
|
||||
* name: name of the entity
|
||||
* definition: zigbee-herdsman-converters definition (only if type === device)
|
||||
* }
|
||||
*/
|
||||
resolveEntity(key) {
|
||||
assert(
|
||||
typeof key === 'string' || typeof key === 'number' ||
|
||||
key.constructor.name === 'Device' || key.constructor.name === 'Group',
|
||||
`Wrong type '${typeof key}'`,
|
||||
);
|
||||
|
||||
const deviceOptions = settings.get().device_options;
|
||||
if (typeof key === 'string' || typeof key === 'number') {
|
||||
if (typeof key === 'number') {
|
||||
key = key.toString();
|
||||
}
|
||||
|
||||
if (typeof key === 'string' && key.toLowerCase() === 'coordinator') {
|
||||
const coordinator = this.getDevicesByType('Coordinator')[0];
|
||||
return {
|
||||
type: 'device',
|
||||
device: coordinator,
|
||||
endpoint: coordinator.getEndpoint(1),
|
||||
settings: {friendlyName: 'Coordinator'},
|
||||
name: 'Coordinator',
|
||||
};
|
||||
}
|
||||
|
||||
let endpointKey = endpointNames.find((p) => key.endsWith(`/${p}`));
|
||||
const endpointByNumber = key.match(keyEndpointByNumber);
|
||||
if (!endpointKey && endpointByNumber) {
|
||||
endpointKey = Number(endpointByNumber[1]);
|
||||
}
|
||||
if (endpointKey) {
|
||||
key = key.replace(`/${endpointKey}`, '');
|
||||
}
|
||||
|
||||
const entity = settings.getEntity(key);
|
||||
if (!entity) {
|
||||
return null;
|
||||
} else if (entity.type === 'device') {
|
||||
const device = this.getDeviceByIeeeAddr(entity.ID);
|
||||
if (!device) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definition = zigbeeHerdsmanConverters.findByDevice(device);
|
||||
const endpoints = definition && definition.endpoint ? definition.endpoint(device) : null;
|
||||
let endpoint;
|
||||
if (endpointKey) {
|
||||
if (endpointByNumber) {
|
||||
endpoint = device.getEndpoint(endpointKey);
|
||||
} else {
|
||||
assert(definition != null, `Endpoint name '${endpointKey}' is given but device is unsupported`);
|
||||
assert(endpoints != null, `Endpoint name '${endpointKey}' is given but no endpoints defined`);
|
||||
const endpointID = endpoints[endpointKey];
|
||||
assert(endpointID, `Endpoint name '${endpointKey}' is given but device has no such endpoint`);
|
||||
endpoint = device.getEndpoint(endpointID);
|
||||
}
|
||||
} else if (endpoints && endpoints['default']) {
|
||||
endpoint = device.getEndpoint(endpoints['default']);
|
||||
} else {
|
||||
endpoint = device.endpoints[0];
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'device', device, endpoint, settings: {...deviceOptions, ...entity},
|
||||
name: entity.friendlyName, definition,
|
||||
};
|
||||
} else {
|
||||
let group = this.getGroupByID(entity.ID);
|
||||
if (!group) group = this.createGroup(entity.ID);
|
||||
return {type: 'group', group, settings: {...deviceOptions, ...entity}, name: entity.friendlyName};
|
||||
}
|
||||
} else if (key.constructor.name === 'Device') {
|
||||
const setting = settings.getEntity(key.ieeeAddr);
|
||||
return {
|
||||
type: 'device',
|
||||
device: key,
|
||||
endpoint: key.endpoints[0],
|
||||
settings: {...deviceOptions, ...(setting || {})},
|
||||
name: setting ? setting.friendlyName : (key.type === 'Coordinator' ? 'Coordinator' : key.ieeeAddr),
|
||||
definition: zigbeeHerdsmanConverters.findByDevice(key),
|
||||
};
|
||||
} else { // Group
|
||||
const setting = settings.getEntity(key.groupID);
|
||||
return {
|
||||
type: 'group',
|
||||
group: key,
|
||||
settings: {...deviceOptions, ...(setting || {})},
|
||||
name: setting ? setting.friendlyName : key.groupID,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getGroupByID(ID) {
|
||||
return this.herdsman.getGroupByID(ID);
|
||||
}
|
||||
|
||||
getGroups() {
|
||||
return this.herdsman.getGroups();
|
||||
}
|
||||
|
||||
createGroup(groupID) {
|
||||
return this.herdsman.createGroup(groupID);
|
||||
}
|
||||
|
||||
acceptJoiningDeviceHandler(ieeeAddr) {
|
||||
// If passlist is set, all devices not on passlist will be rejected to join the network
|
||||
const passlist = settings.get().passlist.concat(settings.get().whitelist);
|
||||
const blocklist = settings.get().blocklist.concat(settings.get().ban);
|
||||
if (passlist.length > 0) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async touchlinkFactoryResetFirst() {
|
||||
return this.herdsman.touchlinkFactoryResetFirst();
|
||||
}
|
||||
|
||||
async touchlinkFactoryReset(ieeeAddr, channel) {
|
||||
return this.herdsman.touchlinkFactoryReset(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async touchlinkIdentify(ieeeAddr, channel) {
|
||||
await this.herdsman.touchlinkIdentify(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async touchlinkScan() {
|
||||
return this.herdsman.touchlinkScan();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Zigbee;
|
||||