mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 07:08:56 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e26ad2aca2 | ||
|
|
eb69ea6808 | ||
|
|
d0242fbd54 | ||
|
|
bd8801698f | ||
|
|
b77be8e673 | ||
|
|
1189d16839 | ||
|
|
b00d4d55fa | ||
|
|
4f7e3a179f | ||
|
|
1bdbeae144 | ||
|
|
48fc7d8d3b | ||
|
|
8ee3158783 | ||
|
|
7d04185f2c | ||
|
|
377e9d50dc | ||
|
|
9628cf2c95 | ||
|
|
4d4a63f913 | ||
|
|
22bed9a347 | ||
|
|
d96c3da8d5 | ||
|
|
6abe7d2fd7 | ||
|
|
a62d6aad91 | ||
|
|
c55691daec | ||
|
|
c9f56f0bbf | ||
|
|
c53c4c894f | ||
|
|
6ae307d893 | ||
|
|
511f9406e2 | ||
|
|
9e63a4646e | ||
|
|
da5ca2f0bf | ||
|
|
ecbee780db | ||
|
|
a604fd6345 | ||
|
|
36e52ba4fb | ||
|
|
f67eeffed6 | ||
|
|
5e55e1655f | ||
|
|
519eb4eecf | ||
|
|
fd3019dce8 | ||
|
|
1ca1be2ea2 | ||
|
|
967bf1083c | ||
|
|
c1e8dd990f | ||
|
|
fcb6c37ef0 | ||
|
|
c9ec198c1f | ||
|
|
a60fdd75dc | ||
|
|
7853f41a0e | ||
|
|
3ed5705253 | ||
|
|
5254eea6e0 | ||
|
|
92e930ede3 | ||
|
|
ac3b924065 | ||
|
|
dd8a55cf08 | ||
|
|
3ae2d2b946 | ||
|
|
31b8352f5a | ||
|
|
503fef807c | ||
|
|
4aab81d9e3 | ||
|
|
8d705ade39 | ||
|
|
d3e97953c3 | ||
|
|
1b859c4dc2 | ||
|
|
225919bb23 | ||
|
|
74b8340afb | ||
|
|
08c0f2b614 | ||
|
|
61b54c50a5 | ||
|
|
6400f52a28 | ||
|
|
97f9f83181 | ||
|
|
90c3fc0320 | ||
|
|
94a7cd8ec9 | ||
|
|
1df43bdbc4 | ||
|
|
3daf97283c | ||
|
|
aca0c39291 | ||
|
|
9624384c3f | ||
|
|
3b4ee3bc2e | ||
|
|
7a19c8876a | ||
|
|
51df6b7e8a | ||
|
|
7cabe3a523 | ||
|
|
7b2522f712 | ||
|
|
5a132ad34b | ||
|
|
362b910e48 | ||
|
|
241b300f7b | ||
|
|
0e9931af6c | ||
|
|
f4f297e8a2 | ||
|
|
5934efa41e | ||
|
|
38a1aec823 | ||
|
|
3c398df658 | ||
|
|
91d415404c | ||
|
|
ef01346776 | ||
|
|
801e97143c | ||
|
|
304b2efa48 | ||
|
|
a36ed2032d | ||
|
|
17d01e937e | ||
|
|
110f80ec84 | ||
|
|
b28da0265e | ||
|
|
1c529c4873 | ||
|
|
3981c1c6e4 | ||
|
|
c48f73e36b | ||
|
|
94e03d4189 | ||
|
|
e30ff9e722 | ||
|
|
1f63a32170 | ||
|
|
e9b5a588cc | ||
|
|
4357064203 | ||
|
|
86a65ebec0 | ||
|
|
259ba9d658 | ||
|
|
9fe76c29ef | ||
|
|
dd49a76e5c | ||
|
|
6976f8e70a | ||
|
|
82dc8437a1 | ||
|
|
fb12d15d9d | ||
|
|
852950fc09 | ||
|
|
b663e37fd9 | ||
|
|
70fbe714e3 | ||
|
|
ee8161b807 | ||
|
|
8ad6aee43a | ||
|
|
4497214588 | ||
|
|
08e55e469f | ||
|
|
c29bfe1455 | ||
|
|
82ed260d47 | ||
|
|
caff94559e | ||
|
|
c2b4d63e12 | ||
|
|
825f096434 | ||
|
|
4b14222bda | ||
|
|
927c4dbc05 | ||
|
|
c4e9ea208a | ||
|
|
af09624cff | ||
|
|
e4a50b662d | ||
|
|
22b64d09f2 | ||
|
|
dc028d5046 | ||
|
|
0352a738a7 | ||
|
|
6adfdacb3c | ||
|
|
18913a1577 | ||
|
|
1e04c229dd | ||
|
|
31d21546b9 | ||
|
|
7f9104367b | ||
|
|
92d0002bbb | ||
|
|
0074c32136 | ||
|
|
faa6aa41a9 | ||
|
|
3ed9bbde1a | ||
|
|
8f39ae9d63 | ||
|
|
a09da4fe10 | ||
|
|
9cc82701c1 | ||
|
|
190dda5104 | ||
|
|
3d74a6e031 | ||
|
|
6b7546547d | ||
|
|
3649fb1bc9 | ||
|
|
eb2c4cbdee | ||
|
|
f93a3e1c07 | ||
|
|
ee9584121a | ||
|
|
ba1eca82eb | ||
|
|
68d2d0a73d | ||
|
|
d8d78af4ec | ||
|
|
80e682688d | ||
|
|
c6617fb7d9 | ||
|
|
26d8d27e85 | ||
|
|
87232988e7 | ||
|
|
a3d26ceeb2 | ||
|
|
1f6e64b4c0 | ||
|
|
919b8a9b4c | ||
|
|
31088255ab | ||
|
|
39fe50600e | ||
|
|
a54e256b40 | ||
|
|
4472c01076 | ||
|
|
801be951a4 | ||
|
|
cb16ba2f71 | ||
|
|
0d587f304e | ||
|
|
21dc1a8c8e | ||
|
|
32b76edb7c | ||
|
|
0eaf0ad5d6 | ||
|
|
1ebd774d6f | ||
|
|
950b3c7ace | ||
|
|
f8106feb18 | ||
|
|
69e9dbc5b2 | ||
|
|
75718b360e | ||
|
|
beb2713cdd | ||
|
|
2cdbe49385 | ||
|
|
d0e66ed1da | ||
|
|
39d6a8e218 | ||
|
|
2f187ae282 | ||
|
|
3a5bdffd4e | ||
|
|
a1b43d12f6 | ||
|
|
310f9892d6 | ||
|
|
c2edaed866 | ||
|
|
2d4c447ee1 | ||
|
|
b13d87d461 | ||
|
|
da9425290e | ||
|
|
811692fed8 | ||
|
|
78ff9b1937 | ||
|
|
c6011c0911 | ||
|
|
9f16f99424 | ||
|
|
08ad3a877d | ||
|
|
24780e6eac | ||
|
|
2554cf8e8b | ||
|
|
4aa5877797 | ||
|
|
61942f8411 | ||
|
|
00ebd441a0 | ||
|
|
4843d85fe9 | ||
|
|
32db1fd07a | ||
|
|
cee35c66e6 | ||
|
|
76d7861671 | ||
|
|
35fd1a68fc | ||
|
|
8828ee60ae | ||
|
|
55c3edfa95 | ||
|
|
2f52b851a9 | ||
|
|
91bc56a09f | ||
|
|
c737f569f9 | ||
|
|
de4f9f44fd | ||
|
|
21714855bf | ||
|
|
50986591c1 | ||
|
|
e10999f0f2 | ||
|
|
7015741a08 | ||
|
|
64fa02abab | ||
|
|
7e6dc67861 | ||
|
|
3c5727b320 | ||
|
|
9ef4d2b810 | ||
|
|
e96dac2596 | ||
|
|
9a15e8c118 | ||
|
|
25408769e6 | ||
|
|
2328ecb2ee | ||
|
|
b2a453bec3 | ||
|
|
20d9bac317 | ||
|
|
40596bab6a | ||
|
|
69d04d0648 | ||
|
|
363f4e1c3b | ||
|
|
008fda4d90 | ||
|
|
c64158b75d | ||
|
|
1ea694cb1a | ||
|
|
e537784d34 | ||
|
|
0277591085 | ||
|
|
7b2480be52 | ||
|
|
70c30736a1 | ||
|
|
24cf72f166 | ||
|
|
e5081b2856 | ||
|
|
4048cb81a4 | ||
|
|
2af2a8ea05 | ||
|
|
1b066d9f59 | ||
|
|
5c9b31b2fc | ||
|
|
61d85af3fd | ||
|
|
789ec0cc90 | ||
|
|
b97f5d1afa | ||
|
|
4cb0c0bbb0 | ||
|
|
6d24a49062 | ||
|
|
bee722c9c4 | ||
|
|
36bba0154d | ||
|
|
a2a90bcdbd | ||
|
|
1043053812 | ||
|
|
e6044da604 | ||
|
|
a8cf925fcb | ||
|
|
ad835e090b | ||
|
|
3a96473e2b | ||
|
|
661f79ce93 | ||
|
|
7461fec28b | ||
|
|
8b4b9bea19 | ||
|
|
fdcf2e8c3d | ||
|
|
b39e195763 | ||
|
|
f2cdbeab38 | ||
|
|
f37d52b870 | ||
|
|
969585c84e | ||
|
|
d4d85633d2 | ||
|
|
f54f3bd40f | ||
|
|
03ca8ec24d | ||
|
|
a65fbd6e2c | ||
|
|
ac28ca2841 | ||
|
|
79d11e295b | ||
|
|
9f31cd86a3 | ||
|
|
c722b611dd | ||
|
|
3c4fa40a32 | ||
|
|
d81a972f1b | ||
|
|
0f9333f8b9 | ||
|
|
6e60063337 | ||
|
|
438626691e | ||
|
|
a978b26001 | ||
|
|
7cec70aba8 | ||
|
|
df1825d65e | ||
|
|
532b3b2484 | ||
|
|
84a5277d45 | ||
|
|
acbab01b7e | ||
|
|
6a4073b305 | ||
|
|
18aee4803b | ||
|
|
e5ca977579 | ||
|
|
1e1e0e4ca9 | ||
|
|
3b1fae1452 | ||
|
|
cf2f10394a | ||
|
|
9eed7e271e | ||
|
|
07765093be | ||
|
|
5f19d9cc82 | ||
|
|
e2ebe5e1ff | ||
|
|
a93838be15 | ||
|
|
044e8466ed | ||
|
|
c0bdf2e3a2 | ||
|
|
9321a04ecc | ||
|
|
184eef72b0 | ||
|
|
bb6c226d55 | ||
|
|
e892a54013 | ||
|
|
2c6176ff10 | ||
|
|
83b8fc9778 | ||
|
|
f0e9ebe4ed | ||
|
|
8902bd1d60 | ||
|
|
1857ac525b | ||
|
|
6427da244f | ||
|
|
339816c160 | ||
|
|
2a3670d3d7 | ||
|
|
889e972176 | ||
|
|
e213356c40 | ||
|
|
1cde620d70 | ||
|
|
ae14fa5cd6 | ||
|
|
672a0cac4f | ||
|
|
4ea55fe22a | ||
|
|
05769ce1a3 | ||
|
|
61116ec670 | ||
|
|
709046db2a | ||
|
|
bef8a682ca | ||
|
|
8f0794cb8f | ||
|
|
cd2fb54615 | ||
|
|
7893f9256f | ||
|
|
09f26a104c | ||
|
|
5ffae5a09a | ||
|
|
f7ceb62eec | ||
|
|
b8f689fe6e | ||
|
|
09e0fe8e3f | ||
|
|
fdf97180b0 | ||
|
|
5a9aef6d3b | ||
|
|
863230eaa3 | ||
|
|
cb0cc149a1 | ||
|
|
516bd704c6 | ||
|
|
46e75374b6 | ||
|
|
ee167e603a | ||
|
|
9b12933a99 | ||
|
|
ea7a4b1955 | ||
|
|
147a130628 | ||
|
|
1fda02ae7c | ||
|
|
4a664cf230 | ||
|
|
1a0baee788 | ||
|
|
1287a596f9 | ||
|
|
1862c8b035 | ||
|
|
8c848b8368 | ||
|
|
bdfabeb3da | ||
|
|
ec0ee53c7a | ||
|
|
14f5c4d3c9 | ||
|
|
f3e13f16f3 | ||
|
|
061285bd17 | ||
|
|
25448f823e | ||
|
|
99006895b5 | ||
|
|
26bed2d998 | ||
|
|
cc45413e2d | ||
|
|
ba77860489 | ||
|
|
14377c04a0 | ||
|
|
3238687f60 | ||
|
|
0632f83baf | ||
|
|
259a3417e2 | ||
|
|
a20ec7caa1 | ||
|
|
b08bf33ccf | ||
|
|
577bb0d12c | ||
|
|
92d88b680a | ||
|
|
00900958ef | ||
|
|
11a6e36a82 | ||
|
|
d6b9627146 | ||
|
|
15cad740ee | ||
|
|
ab14cca0ee | ||
|
|
87889c0688 | ||
|
|
17a6fff3ce | ||
|
|
f171d54e4b | ||
|
|
9faeb53ddb | ||
|
|
21a782d2c6 | ||
|
|
88cec5dfa6 | ||
|
|
a62f4289a7 | ||
|
|
1b5e010cd3 | ||
|
|
0f053425f3 | ||
|
|
04e68014da | ||
|
|
54dbb9732f | ||
|
|
8703847337 | ||
|
|
a85c68cca0 | ||
|
|
4592b00fe1 | ||
|
|
d9ddce5797 | ||
|
|
b3310c49ca | ||
|
|
781b542f92 | ||
|
|
43726d8bb1 | ||
|
|
8137b6f417 | ||
|
|
c439508129 | ||
|
|
e5233494af | ||
|
|
a2d378cc86 | ||
|
|
3d151298ae | ||
|
|
5554579683 | ||
|
|
ce2455510b | ||
|
|
bf526f6f50 | ||
|
|
004f4a5eb9 | ||
|
|
059e4719c6 | ||
|
|
a0278b7577 | ||
|
|
0f89053d05 | ||
|
|
e1ff6bdac5 | ||
|
|
73221f50f5 | ||
|
|
37405ee079 | ||
|
|
52fb19360e | ||
|
|
e47b672bcb | ||
|
|
63c88cba5b | ||
|
|
f0d0a62471 | ||
|
|
a497cf0457 | ||
|
|
ed5675960e | ||
|
|
ba5bf3c209 | ||
|
|
e40a3ba3bf | ||
|
|
ecf1b9ded4 | ||
|
|
314ab1b705 | ||
|
|
b6fda868ed | ||
|
|
443a14e40a | ||
|
|
c68790f2c4 | ||
|
|
c9d00875c0 | ||
|
|
0120284356 | ||
|
|
aa9e81d64d | ||
|
|
f4bf504642 | ||
|
|
5fc7760db6 | ||
|
|
ffa4fe35d9 | ||
|
|
778720b1b5 | ||
|
|
0826db07d0 | ||
|
|
7642b295e1 | ||
|
|
c9c599b60b | ||
|
|
c0a87ff520 | ||
|
|
4fbb6667ef | ||
|
|
1357b06185 | ||
|
|
6d16875a37 | ||
|
|
f7f9a1699b | ||
|
|
46b96640f3 | ||
|
|
51c91e685e | ||
|
|
8a6582739f | ||
|
|
c569840d6c | ||
|
|
2302240897 | ||
|
|
60349171d2 | ||
|
|
35f0def29f | ||
|
|
a512ca2bd5 | ||
|
|
96dcb93859 | ||
|
|
37d516d770 | ||
|
|
6c59bff223 | ||
|
|
f16616d3a6 | ||
|
|
959e7458a4 | ||
|
|
5828ee9989 | ||
|
|
054892641b | ||
|
|
c0326ec2bb | ||
|
|
189c0f360e | ||
|
|
dc70840c8a | ||
|
|
0b3c8df810 | ||
|
|
4405e71610 | ||
|
|
fd4a88acd0 | ||
|
|
3963a8d26f | ||
|
|
720addd906 | ||
|
|
4558bffd41 | ||
|
|
89c66094c6 | ||
|
|
ae08deb2fd | ||
|
|
ff02e54f25 | ||
|
|
963064dc22 | ||
|
|
0d9741e511 | ||
|
|
7d9c21ba24 | ||
|
|
b8744ce890 | ||
|
|
dd8ee60535 | ||
|
|
d5c6e39e7d | ||
|
|
588124b971 | ||
|
|
a7eea5ecad | ||
|
|
e13bdf1a31 | ||
|
|
30823a5a0c | ||
|
|
5bee8ccafe | ||
|
|
8ffd7653ac | ||
|
|
99a29293dd | ||
|
|
9d61184013 | ||
|
|
4f300aa72d | ||
|
|
cf7e0d1aa9 | ||
|
|
17874cfdc1 | ||
|
|
e12a371093 | ||
|
|
7e74b8c549 | ||
|
|
cdf64823ef | ||
|
|
5d195fc299 | ||
|
|
fd932926e7 | ||
|
|
38159a536c | ||
|
|
7e2ca5a97e | ||
|
|
b95dcd0f9b | ||
|
|
73f0f8fe43 | ||
|
|
16f3e884fc | ||
|
|
c5e6b9dd13 | ||
|
|
cae5d1a004 | ||
|
|
85375ffbae | ||
|
|
43fc66c436 | ||
|
|
5718cd0157 | ||
|
|
dd6e34a152 | ||
|
|
1ddf60bef0 | ||
|
|
6ff3954b71 | ||
|
|
6dd2926a1d | ||
|
|
5970e498f3 | ||
|
|
dcd81cbbf2 | ||
|
|
9e5fb04314 | ||
|
|
d324a91a5f | ||
|
|
fcfeac81e4 | ||
|
|
c44816efee | ||
|
|
86f19affa1 | ||
|
|
e21bfc1567 | ||
|
|
329c290505 | ||
|
|
b624a8ccad | ||
|
|
a43cd5a864 | ||
|
|
5f6b37bf83 | ||
|
|
653dfc7b6f | ||
|
|
aa87443c95 | ||
|
|
f5a5668359 | ||
|
|
5f2e93f64d | ||
|
|
7cd30a3a05 | ||
|
|
34a3e3c00b | ||
|
|
28e32884d9 | ||
|
|
dda2e064b2 | ||
|
|
9605e724e1 | ||
|
|
6912f8108d | ||
|
|
3c0e784afb | ||
|
|
be50d650a4 | ||
|
|
a5057ac101 | ||
|
|
72a74d7dc1 | ||
|
|
dccdc5aafa | ||
|
|
6dd60c09bf | ||
|
|
4ec905efca | ||
|
|
f62eabcb6d | ||
|
|
fd2cf41f0f | ||
|
|
755d101738 | ||
|
|
6fdd58be8a | ||
|
|
26083a62d9 | ||
|
|
30b35e7272 | ||
|
|
f6c01a02c9 | ||
|
|
35b9e6204d | ||
|
|
84f9d09b3f | ||
|
|
f260bf974b | ||
|
|
a2ea9c548f | ||
|
|
932a66a5c4 | ||
|
|
3d0541a32d | ||
|
|
3bb1f0f10b | ||
|
|
85b042ac25 | ||
|
|
f1fe1edb49 | ||
|
|
70d177485d | ||
|
|
fd44537984 | ||
|
|
dc20816043 | ||
|
|
c6d09df01a | ||
|
|
74cdde23b9 | ||
|
|
57aefe477d | ||
|
|
d5a3ce3871 | ||
|
|
92de1ca942 | ||
|
|
6f477c2923 | ||
|
|
5453c082c4 | ||
|
|
07fbc1b9a3 | ||
|
|
afeed4f372 | ||
|
|
2a197a4ba8 | ||
|
|
5e4e6d81de | ||
|
|
faede151f3 | ||
|
|
ea4aaf2cfa | ||
|
|
ac94b95bba | ||
|
|
708c497614 | ||
|
|
bd8aaea767 | ||
|
|
ac4ca88b8a | ||
|
|
8651fd7162 | ||
|
|
03ff4e6e5a | ||
|
|
3bc2ec8c79 | ||
|
|
c4804e574e | ||
|
|
5d3461c4c6 | ||
|
|
9380bbcadf | ||
|
|
3a0fe60b22 | ||
|
|
a1eea76dd4 | ||
|
|
69e805c882 | ||
|
|
0fe3a9b45c | ||
|
|
ecf64c8058 | ||
|
|
d3f705162d | ||
|
|
bd8282a41a | ||
|
|
82e87d563e | ||
|
|
d6c2b536ee | ||
|
|
919178840f | ||
|
|
84593a55ee | ||
|
|
1b8154bac1 | ||
|
|
8a133de1d1 | ||
|
|
75dddf462e | ||
|
|
91bbb0d4a8 | ||
|
|
d25d093a1a | ||
|
|
0bf91fd300 | ||
|
|
bf285681b4 | ||
|
|
cf9a5b7553 | ||
|
|
c2a0a6b2d1 | ||
|
|
eaff35421f | ||
|
|
ec20bfdd15 | ||
|
|
1f2d3ec850 | ||
|
|
d9b418cdde | ||
|
|
56ea61be3e | ||
|
|
b59afbd10d | ||
|
|
21fc5f0275 | ||
|
|
9d6f6186b0 | ||
|
|
590a897e9b | ||
|
|
753e8c3da7 | ||
|
|
ef57083ae9 | ||
|
|
1fa13f3f96 | ||
|
|
0fb84294f0 | ||
|
|
828df38e09 | ||
|
|
a13bd85ef3 | ||
|
|
65526e7f68 | ||
|
|
81d04d3dae | ||
|
|
16c222d3b3 | ||
|
|
0e61618935 | ||
|
|
bede1032e6 | ||
|
|
0786f22ce6 | ||
|
|
c9c4fad7df | ||
|
|
a52d3ea073 | ||
|
|
dcc410f6c5 | ||
|
|
c7743e6734 | ||
|
|
cc56141c39 | ||
|
|
60c2593669 | ||
|
|
db945c90be | ||
|
|
9df1065634 | ||
|
|
65f23171e6 | ||
|
|
920d1c91c7 | ||
|
|
698b40554e | ||
|
|
cb9beaa2ad | ||
|
|
d652856e90 | ||
|
|
7d09d065c5 | ||
|
|
352fa2db50 | ||
|
|
349ebad659 | ||
|
|
8fefa16fbc | ||
|
|
99b330e85e | ||
|
|
3f49e2e596 | ||
|
|
dcb75b19d3 | ||
|
|
c213a96133 | ||
|
|
78984a65ab | ||
|
|
6c059c3898 | ||
|
|
ac6eb19aff | ||
|
|
6df4e39f18 | ||
|
|
b0d3c2f964 | ||
|
|
f797a12250 | ||
|
|
081d3c3dfe | ||
|
|
7ca966ce39 | ||
|
|
fc664bdc68 | ||
|
|
2cb27b14f9 | ||
|
|
86a102e081 | ||
|
|
9119e54c2c | ||
|
|
f379bc0391 | ||
|
|
2e736bf501 | ||
|
|
1e7f985d4e | ||
|
|
61c4ff33e0 | ||
|
|
73ae48f387 | ||
|
|
0a1c60b2cf | ||
|
|
4eb56319c4 | ||
|
|
d8ea36af29 | ||
|
|
d0cf61bf10 | ||
|
|
ddb83cc6e4 | ||
|
|
e54de4bea7 | ||
|
|
97cc56b691 | ||
|
|
c9df5c8221 | ||
|
|
7f13e2c3e6 | ||
|
|
0aab565083 | ||
|
|
153f8bcfb5 | ||
|
|
c04ed81adb | ||
|
|
a1cf75456b | ||
|
|
b063e67e24 | ||
|
|
a4da482225 | ||
|
|
98302bebd1 | ||
|
|
2197328db5 | ||
|
|
c5b9176614 | ||
|
|
0322354c37 | ||
|
|
b692c94fe9 | ||
|
|
1496861811 | ||
|
|
5459152f01 | ||
|
|
1db926173d | ||
|
|
da9ee71d80 | ||
|
|
d9f02230a0 | ||
|
|
bf68f4575f | ||
|
|
33ea08ad22 | ||
|
|
3a17db7c79 | ||
|
|
e55252b9ff | ||
|
|
71d12c3c71 | ||
|
|
bc7b2bd5e0 | ||
|
|
50d58d3dff | ||
|
|
4180bd8933 | ||
|
|
172d11677b | ||
|
|
ab5537e18c | ||
|
|
b06e3b61f2 | ||
|
|
03d913defc | ||
|
|
e4bcac5411 | ||
|
|
5ed29d045b | ||
|
|
75acffa8c7 | ||
|
|
ade84dc6c0 |
+1
-2
@@ -1,2 +1 @@
|
||||
node_modules/*
|
||||
support/*
|
||||
node_modules/*
|
||||
+6
-4
@@ -1,19 +1,21 @@
|
||||
{
|
||||
"env": {
|
||||
"jest/globals": true,
|
||||
"es6": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "google"],
|
||||
"extends": ["eslint:recommended", "google", "plugin:jest/recommended", "plugin:jest/style"],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 6,
|
||||
"ecmaVersion": 2018,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"require-jsdoc": "off",
|
||||
"indent": ["error", 4],
|
||||
"max-len": ["error", { "code": 120 }],
|
||||
"mocha/no-exclusive-tests": "error"
|
||||
"no-prototype-builtins": "off"
|
||||
},
|
||||
"plugins": [
|
||||
"mocha"
|
||||
"jest"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
github: [koenkk]
|
||||
custom: https://www.paypal.me/koenkk
|
||||
@@ -1,11 +1,30 @@
|
||||
Unsupported device
|
||||
First check: https://github.com/Koenkk/zigbee2mqtt/wiki/How-to-support-new-devices
|
||||
<!--
|
||||
Did you read the FAQ?
|
||||
https://www.zigbee2mqtt.io/information/FAQ.html
|
||||
|
||||
Unsupported device?
|
||||
https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html
|
||||
|
||||
Bug report
|
||||
- First make sure your on the latest version of zigbee2mqtt.
|
||||
A question?
|
||||
Read the documentation: https://www.zigbee2mqtt.io/
|
||||
|
||||
Bug report:
|
||||
- First make sure you are on the latest version of zigbee2mqtt.
|
||||
- Make sure you are running the latest CC253X firmware from https://github.com/koenkk/z-stack-firmware.
|
||||
- Provide a clear description of the problem.
|
||||
- If applicable, provide steps how to reproduce the problem.
|
||||
- Provide a log with `log_level` set to `debug`, see https://github.com/Koenkk/zigbee2mqtt/wiki/Configuration
|
||||
- Provide a log with `log_level` set to `debug`, see https://www.zigbee2mqtt.io/configuration/configuration.html
|
||||
- DON'T copy logs directly here, post a link to https://hastebin.com/ or https://pastebin.com/.
|
||||
-->
|
||||
|
||||
<!--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:
|
||||
CC253X firmware version:
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- enhancement
|
||||
- bug
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: stale
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
|
||||
+4
-1
@@ -62,4 +62,7 @@ data/database.db
|
||||
data/config.json
|
||||
data/log*.txt
|
||||
data/state.json
|
||||
data/log
|
||||
data/log
|
||||
data-backup/
|
||||
data/coordinator_backup.json
|
||||
data/.storage
|
||||
+7
-3
@@ -6,19 +6,23 @@ services:
|
||||
- docker
|
||||
|
||||
node_js:
|
||||
- "8"
|
||||
- "10"
|
||||
|
||||
before_install:
|
||||
- "echo '{\"experimental\": true}' | sudo tee /etc/docker/daemon.json"
|
||||
- sudo apt update -y
|
||||
- sudo apt install --only-upgrade docker-ce -y
|
||||
- sudo service docker restart
|
||||
|
||||
install:
|
||||
- npm install
|
||||
|
||||
before_script:
|
||||
- npm test
|
||||
- npm run verify-homeassistant-mapping
|
||||
- npm run eslint
|
||||
|
||||
script:
|
||||
- ".travis/docker.sh"
|
||||
- ".travis/wiki.sh"
|
||||
- ".travis/trigger-downstream.sh"
|
||||
|
||||
env:
|
||||
|
||||
+32
-7
@@ -10,10 +10,27 @@ tag_push() {
|
||||
}
|
||||
|
||||
build_and_push() {
|
||||
docker build --build-arg COMMIT=$(git rev-parse --short HEAD) -t $DOCKER_USERNAME/zigbee2mqtt:$1 -f $2 .
|
||||
docker build --platform=$3 --build-arg COMMIT=$(git rev-parse --short HEAD) -t $DOCKER_USERNAME/zigbee2mqtt:$1 -f $2 .
|
||||
docker push $DOCKER_USERNAME/zigbee2mqtt:$1
|
||||
}
|
||||
|
||||
push_manifest() {
|
||||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
|
||||
docker manifest create $DOCKER_USERNAME/zigbee2mqtt:latest$1 $DOCKER_USERNAME/zigbee2mqtt:latest$1 $DOCKER_USERNAME/zigbee2mqtt:arm32v6$1 $DOCKER_USERNAME/zigbee2mqtt:arm64v8$1
|
||||
|
||||
docker manifest annotate $DOCKER_USERNAME/zigbee2mqtt:latest$1 $DOCKER_USERNAME/zigbee2mqtt:arm32v6$1 --os linux --arch arm --variant v6
|
||||
docker manifest annotate $DOCKER_USERNAME/zigbee2mqtt:latest$1 $DOCKER_USERNAME/zigbee2mqtt:arm64v8$1 --os linux --arch arm64 --variant v8
|
||||
|
||||
docker manifest inspect $DOCKER_USERNAME/zigbee2mqtt:latest$1
|
||||
|
||||
docker manifest push -p $DOCKER_USERNAME/zigbee2mqtt:latest$1
|
||||
|
||||
docker run --rm mplatform/mquery $DOCKER_USERNAME/zigbee2mqtt:latest$1
|
||||
|
||||
export DOCKER_CLI_EXPERIMENTAL=disabled
|
||||
}
|
||||
|
||||
# Only update Docker images for:
|
||||
# - dev branch
|
||||
# - version release
|
||||
@@ -23,21 +40,29 @@ then
|
||||
login
|
||||
|
||||
# Push versioned images
|
||||
build_and_push "$TRAVIS_TAG" docker/Dockerfile.amd64
|
||||
build_and_push "$TRAVIS_TAG-arm32v6" docker/Dockerfile.arm32v6
|
||||
build_and_push "$TRAVIS_TAG-arm64v8" docker/Dockerfile.arm64v8
|
||||
build_and_push "$TRAVIS_TAG" docker/Dockerfile.amd64 amd64
|
||||
build_and_push "$TRAVIS_TAG-arm32v6" docker/Dockerfile.arm32v6 arm
|
||||
build_and_push "$TRAVIS_TAG-arm64v8" docker/Dockerfile.arm64v8 arm64
|
||||
|
||||
# Push latest images.
|
||||
tag_push "$TRAVIS_TAG-arm32v6" "arm32v6"
|
||||
tag_push "$TRAVIS_TAG-arm64v8" "arm64v8"
|
||||
tag_push "$TRAVIS_TAG" latest
|
||||
|
||||
# Push manifest
|
||||
push_manifest ""
|
||||
|
||||
elif [ "$TRAVIS_BRANCH" = "dev" -a "$TRAVIS_PULL_REQUEST" = "false" ]
|
||||
then
|
||||
echo "Updating docker images for dev branch!"
|
||||
login
|
||||
build_and_push latest-dev docker/Dockerfile.amd64
|
||||
build_and_push arm32v6-dev docker/Dockerfile.arm32v6
|
||||
build_and_push arm64v8-dev docker/Dockerfile.arm64v8
|
||||
build_and_push latest-dev docker/Dockerfile.amd64 amd64
|
||||
build_and_push arm32v6-dev docker/Dockerfile.arm32v6 arm
|
||||
build_and_push arm64v8-dev docker/Dockerfile.arm64v8 arm64
|
||||
|
||||
# Push manifest
|
||||
push_manifest "-dev"
|
||||
|
||||
else
|
||||
echo "Not updating docker images, triggered by pull request or not on master/dev branch"
|
||||
fi
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/bin/sh -e
|
||||
|
||||
setup_git() {
|
||||
git config --global user.email "travis@travis-ci.org"
|
||||
git config --global user.name "Travis CI"
|
||||
}
|
||||
|
||||
update_wiki() {
|
||||
rm -rf zigbee2mqtt.wiki
|
||||
git clone https://${GH_TOKEN}@github.com/Koenkk/zigbee2mqtt.wiki.git
|
||||
npm run docgen zigbee2mqtt.wiki
|
||||
cd zigbee2mqtt.wiki
|
||||
git add -A
|
||||
git diff-index --quiet HEAD || git commit -m "Travis CI: update wiki"
|
||||
git push origin
|
||||
cd ..
|
||||
rm -rf zigbee2mqtt.wiki
|
||||
}
|
||||
|
||||
# Only update wiki if on master branch and not pull request
|
||||
if [ "$TRAVIS_BRANCH" = "master" -a "$TRAVIS_PULL_REQUEST" = "false" ]
|
||||
then
|
||||
echo "Updating wiki!"
|
||||
setup_git
|
||||
update_wiki
|
||||
else
|
||||
echo "Not updating wiki, triggered by pull request or not on master branch"
|
||||
fi
|
||||
@@ -1,44 +1,66 @@
|
||||
[](https://api.travis-ci.org/Koenkk/zigbee2mqtt.svg)
|
||||
<div align="center">
|
||||
<a href="https://github.com/koenkk/zigbee2mqtt">
|
||||
<img width="150" height="150" src="images/logo.png">
|
||||
</a>
|
||||
<br>
|
||||
<br>
|
||||
<div style="display: flex;">
|
||||
<a href="https://travis-ci.org/Koenkk/zigbee2mqtt/">
|
||||
<img src="https://img.shields.io/travis/Koenkk/zigbee2mqtt.svg">
|
||||
</a>
|
||||
<a href="https://github.com/Koenkk/zigbee2mqtt/releases">
|
||||
<img src="https://img.shields.io/github/release/koenkk/zigbee2mqtt.svg">
|
||||
</a>
|
||||
<a href="https://github.com/Koenkk/zigbee2mqtt/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/koenkk/zigbee2mqtt.svg">
|
||||
</a>
|
||||
<a href="https://www.paypal.me/koenkk">
|
||||
<img src="https://img.shields.io/badge/donate-PayPal-blue.svg">
|
||||
</a>
|
||||
<a href="https://discord.gg/dadfWYE">
|
||||
<img src="https://img.shields.io/discord/556563650429583360.svg">
|
||||
</a>
|
||||
<a href="http://zigbee2mqtt.discourse.group/">
|
||||
<img src="https://img.shields.io/discourse/https/zigbee2mqtt.discourse.group/status.svg">
|
||||
</a>
|
||||
</div>
|
||||
<h1>Zigbee2mqtt 🌉 🐝</h1>
|
||||
<p>
|
||||
Allows you to use your Zigbee devices <b>without</b> the vendors 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>
|
||||
|
||||
# zigbee2mqtt :bridge_at_night: :honeybee:
|
||||

|
||||
<img align="right" height="40px" src="https://user-images.githubusercontent.com/7738048/40915536-1993dd32-6805-11e8-9f22-4dfca52133c0.png">
|
||||
## [Getting started](https://www.zigbee2mqtt.io/#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.
|
||||
|
||||
Allows you to use your Zigbee devices **without** the vendors (Xiaomi/TRADFRI/Hue) bridge or gateway.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## [Getting started](https://github.com/Koenkk/zigbee2mqtt/wiki/Getting-started)
|
||||
The [wiki](https://github.com/Koenkk/zigbee2mqtt/wiki) 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.
|
||||
|
||||
[The documentation](https://github.com/Koenkk/zigbee2mqtt/wiki) will provide information on how to prepare, configure and start **zigbee2mqtt**, but also on how to customize the setup to fit your needs.
|
||||
|
||||
If you aren't familiar with **zigbee** terminology make sure you [read this](https://github.com/Koenkk/zigbee2mqtt/wiki/ZigBee-network) to help you out.
|
||||
|
||||
## Integrations
|
||||
## [Integrations](https://www.zigbee2mqtt.io/#integration)
|
||||
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/)
|
||||
- [Hassio](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://github.com/Koenkk/zigbee2mqtt/wiki/Integrating-with-Home-Assistant)
|
||||
- Generic install or Hassbian: Using instructions [here](https://www.zigbee2mqtt.io/integration/home_assistant.html)
|
||||
|
||||
## Architecture
|
||||

|
||||
<img align="left" height="100px" width="100px" src="https://user-images.githubusercontent.com/2734836/47615848-b8dd8700-dabd-11e8-9d77-175002dd8987.png">
|
||||
|
||||
### [Domoticz](https://www.domoticz.com/)
|
||||
- Integration implemented in [domoticz-zigbee2mqtt-plugin](https://github.com/stas-demydiuk/domoticz-zigbee2mqtt-plugin)
|
||||
|
||||
<br>
|
||||
|
||||
## Architecture
|
||||

|
||||
|
||||
## Supported devices
|
||||
See [Supported devices](https://github.com/Koenkk/zigbee2mqtt/wiki/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.
|
||||
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.
|
||||
|
||||
If it's not listed in [Supported devices](https://github.com/Koenkk/zigbee2mqtt/wiki/Supported-devices), support can be added (fairly) easy, see [How to support new devices](https://github.com/Koenkk/zigbee2mqtt/wiki/How-to-support-new-devices).
|
||||
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).
|
||||
|
||||
## 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.
|
||||
|
||||
## Contributors
|
||||
* [AndrewLinden](https://github.com/AndrewLinden)
|
||||
* [oskarn97](https://github.com/oskarn97)
|
||||
* [dgomes](https://github.com/dgomes)
|
||||
* [Koenkk](https://github.com/Koenk)
|
||||
* [kirovilya](https://github.com/kirovilya)
|
||||
* [ciotlosm](https://github.com/ciotlosm)
|
||||
If you need assistance you can check [opened issues](https://github.com/Koenkk/zigbee2mqtt/issues). Feel free to help with Pull Requests when you were able to fix things or add new devices or just share the love on social media.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.7
|
||||
FROM alpine:3.9
|
||||
|
||||
# Copy files
|
||||
ADD . /app
|
||||
@@ -12,7 +12,7 @@ ARG COMMIT
|
||||
RUN echo "{\"hash\": \"$COMMIT\"}" > .hash.json
|
||||
|
||||
# Install dependencies
|
||||
RUN apk add --update --no-cache make gcc g++ python linux-headers udev nodejs git && \
|
||||
RUN apk add --update --no-cache make gcc g++ python linux-headers udev nodejs npm git && \
|
||||
npm install --unsafe-perm && \
|
||||
apk del make gcc g++ python linux-headers udev git
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM arm32v6/alpine:3.7
|
||||
FROM arm32v6/alpine:3.9
|
||||
|
||||
# Setup cross-build
|
||||
ENV QEMU_EXECVE 1
|
||||
@@ -14,7 +14,7 @@ ADD . /app
|
||||
RUN [ "qemu-arm-static", "/bin/sh", "-c", \
|
||||
"cp /app/data/configuration.yaml /app && \
|
||||
cp /app/docker/run.sh /app && chmod +x /app/run.sh && \
|
||||
apk add --update --no-cache make gcc g++ python linux-headers udev nodejs git && \
|
||||
apk add --update --no-cache make gcc g++ python linux-headers udev nodejs npm git && \
|
||||
npm install --unsafe-perm && \
|
||||
apk del make gcc g++ python linux-headers udev git"]
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM arm64v8/alpine:3.7
|
||||
FROM arm64v8/alpine:3.9
|
||||
|
||||
# Setup cross-build
|
||||
ENV QEMU_EXECVE 1
|
||||
@@ -14,7 +14,7 @@ ADD . /app
|
||||
RUN [ "qemu-aarch64-static", "/bin/sh", "-c", \
|
||||
"cp /app/data/configuration.yaml /app && \
|
||||
cp /app/docker/run.sh /app && chmod +x /app/run.sh && \
|
||||
apk add --update --no-cache make gcc g++ python linux-headers udev nodejs git && \
|
||||
apk add --update --no-cache make gcc g++ python linux-headers udev nodejs npm git && \
|
||||
npm install --unsafe-perm && \
|
||||
apk del make gcc g++ python linux-headers udev git"]
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
version: '3'
|
||||
services:
|
||||
zigbee2mqtt:
|
||||
container_name: zigbee2mqtt
|
||||
image: koenkk/zigbee2mqtt
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
devices:
|
||||
# CC251
|
||||
#- /dev/ttyUSB_cc2531:/dev/ttyACM0
|
||||
# CC2530 / GBAN GB2530S
|
||||
#- /dev/ttyUSB_cc2530:/dev/ttyACM0
|
||||
restart: always
|
||||
network_mode: host
|
||||
+1
-5
@@ -13,8 +13,4 @@ if [ ! -f "$DATA/configuration.yaml" ]; then
|
||||
cp /app/configuration.yaml "$DATA/configuration.yaml"
|
||||
fi
|
||||
|
||||
if [ ! -z "$DEBUG" ]; then
|
||||
npm start
|
||||
else
|
||||
DEBUG="$DEBUG" npm start
|
||||
fi
|
||||
exec npm start
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
theme: jekyll-theme-cayman
|
||||
@@ -0,0 +1 @@
|
||||
Moved to [https://www.zigbee2mqtt.io](https://www.zigbee2mqtt.io)
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
@@ -1,10 +1,23 @@
|
||||
const Controller = require('./lib/controller');
|
||||
const semver = require('semver');
|
||||
const engines = require('./package.json').engines;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const Controller = require('./lib/controller');
|
||||
const controller = new Controller();
|
||||
controller.start();
|
||||
|
||||
process.on('SIGINT', handleQuit);
|
||||
process.on('SIGTERM', handleQuit);
|
||||
|
||||
let stopping = false;
|
||||
|
||||
function handleQuit() {
|
||||
controller.stop(() => process.exit());
|
||||
if (!stopping) {
|
||||
stopping = true;
|
||||
controller.stop(() => process.exit());
|
||||
}
|
||||
}
|
||||
|
||||
+236
-551
@@ -3,197 +3,195 @@ const Zigbee = require('./zigbee');
|
||||
const State = require('./state');
|
||||
const logger = require('./util/logger');
|
||||
const settings = require('./util/settings');
|
||||
const ExtensionNetworkMap = require('./extension/networkMap');
|
||||
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
|
||||
const homeassistant = require('./homeassistant');
|
||||
const objectAssignDeep = require(`object-assign-deep`);
|
||||
const objectAssignDeep = require('object-assign-deep');
|
||||
const utils = require('./util/utils');
|
||||
|
||||
const mqttConfigRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/\\w+`, 'g');
|
||||
const mqttDeviceRegex = new RegExp(`${settings.get().mqtt.base_topic}/[\\w\\s\\d.-]+/set`, 'g');
|
||||
const mqttDevicePrefixRegex = new RegExp(`${settings.get().mqtt.base_topic}/[\\w\\s\\d.-]
|
||||
+/[\\w\\s\\d.-]+/set`, 'g');
|
||||
|
||||
const pollInterval = 60 * 1000; // seconds * 1000.
|
||||
const softResetTimeout = 3600 * 1000; // seconds * 1000.
|
||||
|
||||
const allowedLogLevels = ['error', 'warn', 'info', 'debug'];
|
||||
|
||||
/**
|
||||
* 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://github.com/Koenkk/zigbee2mqtt/wiki/Configuration
|
||||
*/
|
||||
const cacheState = settings.get().advanced && settings.get().advanced.cache_state === false ? false : true;
|
||||
if (settings.get().homeassistant && !cacheState) {
|
||||
logger.warn('In order for Home Assistant integration to work properly set `cache_state: true');
|
||||
}
|
||||
// Extensions
|
||||
const ExtensionNetworkMap = require('./extension/networkMap');
|
||||
const ExtensionSoftReset = require('./extension/softReset');
|
||||
const ExtensionXiaomi = require('./extension/xiaomi');
|
||||
const ExtensionDevicePublish = require('./extension/devicePublish');
|
||||
const ExtensionHomeAssistant = require('./extension/homeassistant');
|
||||
const ExtensionDeviceConfigure = require('./extension/deviceConfigure');
|
||||
const ExtensionDeviceGroupMembership = require('./extension/deviceGroupMembership');
|
||||
const ExtensionDeviceReceive = require('./extension/deviceReceive');
|
||||
const ExtensionBridgeConfig = require('./extension/bridgeConfig');
|
||||
const ExtensionGroups = require('./extension/groups');
|
||||
const ExtensionDeviceAvailability = require('./extension/deviceAvailability');
|
||||
const ExtensionDeviceBind = require('./extension/deviceBind');
|
||||
const ExtensionDeviceReport = require('./extension/deviceReport');
|
||||
const ExtensionLivolo = require('./extension/livolo');
|
||||
const ExtensionResponder = require('./extension/responder');
|
||||
|
||||
class Controller {
|
||||
constructor() {
|
||||
this.zigbee = new Zigbee();
|
||||
this.mqtt = new MQTT();
|
||||
this.state = new State();
|
||||
this.configured = [];
|
||||
this.handleZigbeeMessage = this.handleZigbeeMessage.bind(this);
|
||||
this.handleMQTTMessage = this.handleMQTTMessage.bind(this);
|
||||
}
|
||||
|
||||
start() {
|
||||
this.startupLogVersion(() => {
|
||||
this.zigbee.start(this.handleZigbeeMessage, (error) => {
|
||||
if (error) {
|
||||
logger.error('Failed to start', error);
|
||||
} else {
|
||||
// Log zigbee clients on startup and configure.
|
||||
const devices = this.zigbee.getAllClients();
|
||||
logger.info(`Currently ${devices.length} devices are joined:`);
|
||||
devices.forEach((device) => {
|
||||
logger.info(this.getDeviceStartupLogMessage(device));
|
||||
this.configureDevice(device);
|
||||
});
|
||||
|
||||
// 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.');
|
||||
this.zigbee.permitJoin(true);
|
||||
}
|
||||
|
||||
// Start timers.
|
||||
this.pollTimer(true);
|
||||
this.softResetTimeout(true);
|
||||
|
||||
// Connect to MQTT broker
|
||||
const subscriptions = [
|
||||
`${settings.get().mqtt.base_topic}/+/set`,
|
||||
`${settings.get().mqtt.base_topic}/+/+/set`,
|
||||
`${settings.get().mqtt.base_topic}/bridge/config/+`,
|
||||
];
|
||||
|
||||
if (settings.get().homeassistant) {
|
||||
subscriptions.push('hass/status');
|
||||
}
|
||||
|
||||
this.mqtt.connect(this.handleMQTTMessage, subscriptions, () => this.handleMQTTConnected());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleMQTTConnected() {
|
||||
// Home Assistant MQTT discovery on MQTT connected.
|
||||
if (settings.get().homeassistant) {
|
||||
// MQTT discovery of all paired devices on startup.
|
||||
this.zigbee.getAllClients().forEach((device) => {
|
||||
const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
|
||||
if (mappedModel) {
|
||||
homeassistant.discover(device.ieeeAddr, mappedModel.model, this.mqtt, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Bind methods
|
||||
this.onMQTTConnected = this.onMQTTConnected.bind(this);
|
||||
this.onZigbeeMessage = this.onZigbeeMessage.bind(this);
|
||||
this.onMQTTMessage = this.onMQTTMessage.bind(this);
|
||||
this.publishEntityState = this.publishEntityState.bind(this);
|
||||
|
||||
// Initialize extensions.
|
||||
this.extensions = [
|
||||
new ExtensionNetworkMap(this.zigbee, this.mqtt, this.state),
|
||||
new ExtensionDeviceReceive(this.zigbee, this.mqtt, this.state, this.publishEntityState),
|
||||
new ExtensionDeviceGroupMembership(this.zigbee, this.mqtt, this.publishEntityState),
|
||||
new ExtensionDeviceConfigure(this.zigbee, this.mqtt, this.state, this.publishEntityState),
|
||||
new ExtensionDevicePublish(this.zigbee, this.mqtt, this.state, this.publishEntityState),
|
||||
new ExtensionNetworkMap(this.zigbee, this.mqtt, this.state, this.publishEntityState),
|
||||
new ExtensionXiaomi(this.zigbee, this.mqtt, this.state, this.publishEntityState),
|
||||
new ExtensionBridgeConfig(this.zigbee, this.mqtt, this.state, this.publishEntityState),
|
||||
new ExtensionGroups(this.zigbee, this.mqtt, this.state, this.publishEntityState),
|
||||
new ExtensionDeviceBind(this.zigbee, this.mqtt, this.state, this.publishEntityState),
|
||||
new ExtensionResponder(this.zigbee, this.mqtt, this.state, this.publishEntityState),
|
||||
];
|
||||
|
||||
// Resend all cached states.
|
||||
this.sendAllCachedStates();
|
||||
if (settings.get().advanced.report) {
|
||||
this.extensions.push(new ExtensionDeviceReport(
|
||||
this.zigbee, this.mqtt, this.state, this.publishEntityState
|
||||
));
|
||||
}
|
||||
|
||||
if (settings.get().homeassistant) {
|
||||
this.extensions.push(new ExtensionHomeAssistant(
|
||||
this.zigbee, this.mqtt, this.state, this.publishEntityState
|
||||
));
|
||||
}
|
||||
|
||||
if (settings.get().advanced.soft_reset_timeout !== 0) {
|
||||
this.extensions.push(new ExtensionSoftReset(
|
||||
this.zigbee, this.mqtt, this.state, this.publishEntityState
|
||||
));
|
||||
}
|
||||
|
||||
if (settings.get().advanced.availability_timeout) {
|
||||
this.extensions.push(new ExtensionDeviceAvailability(
|
||||
this.zigbee, this.mqtt, this.state, this.publishEntityState
|
||||
));
|
||||
}
|
||||
|
||||
if (settings.get().experimental.livolo) {
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/592
|
||||
this.extensions.push(new ExtensionLivolo(
|
||||
this.zigbee, this.mqtt, this.state, this.publishEntityState
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
sendAllCachedStates() {
|
||||
this.zigbee.getAllClients().forEach((device) => {
|
||||
if (this.state.exists(device.ieeeAddr)) {
|
||||
this.mqttPublishDeviceState(device.ieeeAddr, this.state.get(device.ieeeAddr), false);
|
||||
}
|
||||
onMQTTConnected() {
|
||||
// Resend all cached states.
|
||||
this.sendAllCachedStates();
|
||||
|
||||
// Call extensions
|
||||
this.extensions.filter((e) => e.onMQTTConnected).forEach((e) => e.onMQTTConnected());
|
||||
}
|
||||
|
||||
onZigbeeStarted() {
|
||||
// Log zigbee clients on startup and configure.
|
||||
const devices = this.zigbee.getAllClients();
|
||||
logger.info(`Currently ${devices.length} devices are joined:`);
|
||||
devices.forEach((device) => {
|
||||
logger.info(this.getDeviceStartupLogMessage(device));
|
||||
});
|
||||
|
||||
// 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.');
|
||||
}
|
||||
|
||||
this.zigbee.permitJoin(settings.get().permit_join);
|
||||
|
||||
// Connect to MQTT broker
|
||||
this.mqtt.connect(this.onMQTTMessage, this.onMQTTConnected);
|
||||
|
||||
// Call extensions
|
||||
this.extensions.filter((e) => e.onZigbeeStarted).forEach((e) => e.onZigbeeStarted());
|
||||
}
|
||||
|
||||
onZigbeeMessage(message) {
|
||||
// Variables
|
||||
let device = null;
|
||||
let mappedDevice = null;
|
||||
|
||||
// Check if message has a device
|
||||
if (message.endpoints && message.endpoints.length && message.endpoints[0].device) {
|
||||
device = message.endpoints[0].device;
|
||||
}
|
||||
|
||||
// Retrieve modelId from message
|
||||
if (device && device.modelId) {
|
||||
mappedDevice = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
|
||||
}
|
||||
|
||||
// Log
|
||||
logger.debug(
|
||||
`Received zigbee message of type '${message.type}' with data '${JSON.stringify(message.data)}'` +
|
||||
(device ? ` of device '${device.modelId}' (${device.ieeeAddr})` : '') +
|
||||
(message.groupid ? ` with groupID ${message.groupid}` : '') +
|
||||
(message.endpoints && message.endpoints.length ? ` of endpoint ${message.endpoints[0].epId}` : '')
|
||||
);
|
||||
|
||||
// Call extensions.
|
||||
this.extensions
|
||||
.filter((e) => e.onZigbeeMessage)
|
||||
.forEach((e) => e.onZigbeeMessage(message, device, mappedDevice));
|
||||
}
|
||||
|
||||
onMQTTMessage(topic, message) {
|
||||
logger.debug(`Received MQTT message on '${topic}' with data '${message}'`);
|
||||
|
||||
// Call extensions
|
||||
const results = this.extensions
|
||||
.filter((e) => e.onMQTTMessage)
|
||||
.map((e) => e.onMQTTMessage(topic, message));
|
||||
|
||||
if (!results.includes(true)) {
|
||||
logger.warn(`Cannot handle MQTT message on '${topic}' with data '${message}'`);
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
logger.info(`Logging to directory: '${logger.directory}'`);
|
||||
|
||||
logger.cleanup();
|
||||
|
||||
this.state.start();
|
||||
|
||||
this.startupLogVersion(() => {
|
||||
this.zigbee.start(this.onZigbeeMessage, (error) => {
|
||||
if (error) {
|
||||
logger.error('Failed to start', error);
|
||||
logger.error('Exiting...');
|
||||
process.exit(1);
|
||||
} else {
|
||||
this.onZigbeeStarted();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
softResetTimeout(start) {
|
||||
if (this._softResetTimer) {
|
||||
clearTimeout(this._softResetTimer);
|
||||
this._softResetTimer = null;
|
||||
}
|
||||
|
||||
if (start) {
|
||||
this._softResetTimer = setTimeout(() => {
|
||||
this.zigbee.softReset((error) => {
|
||||
if (error) {
|
||||
logger.warn('Soft reset error', error);
|
||||
this.zigbee.stop((error) => {
|
||||
logger.warn('Zigbee stopped');
|
||||
this.zigbee.start(this.handleZigbeeMessage, (error) => {
|
||||
if (error) {
|
||||
logger.error('Failed to restart!');
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
logger.warn('Soft resetted zigbee');
|
||||
}
|
||||
|
||||
this.softResetTimeout(true);
|
||||
});
|
||||
}, softResetTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
pollTimer(start) {
|
||||
// Some routers need polling to prevent them from sleeping.
|
||||
if (start && !this._pollTimer) {
|
||||
this._pollTimer = setInterval(() => {
|
||||
const devices = this.zigbee.getAllClients().filter((d) => {
|
||||
const power = d.powerSource ? d.powerSource.toLowerCase().split(' ')[0] : 'unknown';
|
||||
return power !== 'battery' && power !== 'unknown' && d.type === 'Router';
|
||||
});
|
||||
|
||||
devices.forEach((d) => this.zigbee.ping(d.ieeeAddr));
|
||||
}, pollInterval);
|
||||
} else if (!start && this._pollTimer) {
|
||||
clearTimeout(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
stop(callback) {
|
||||
this.state.save();
|
||||
// Call extensions
|
||||
this.extensions.filter((e) => e.stop).forEach((e) => e.stop());
|
||||
|
||||
// Wrap-up
|
||||
this.state.stop();
|
||||
|
||||
this.mqtt.disconnect();
|
||||
this.pollTimer(false);
|
||||
this.softResetTimeout(false);
|
||||
this.zigbee.stop(callback);
|
||||
}
|
||||
|
||||
configureDevice(device) {
|
||||
let friendlyName = 'unknown';
|
||||
const ieeeAddr = device.ieeeAddr;
|
||||
if (settings.getDevice(ieeeAddr)) {
|
||||
friendlyName = settings.getDevice(ieeeAddr).friendly_name;
|
||||
}
|
||||
if (ieeeAddr && device.modelId && !this.configured.includes(ieeeAddr)) {
|
||||
const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
|
||||
|
||||
// Call configure function of device.
|
||||
if (mappedModel && mappedModel.configure) {
|
||||
mappedModel.configure(ieeeAddr, this.zigbee.shepherd, this.zigbee.getCoordinator(), (ok, msg) => {
|
||||
if (ok) {
|
||||
logger.info(`Succesfully configured ${friendlyName} ${ieeeAddr}`);
|
||||
} else {
|
||||
logger.error(`Failed to configure ${friendlyName} ${ieeeAddr}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Setup an OnAfIncomingMsg handler if needed.
|
||||
if (mappedModel && mappedModel.onAfIncomingMsg) {
|
||||
mappedModel.onAfIncomingMsg.forEach((ep) => this.zigbee.registerOnAfIncomingMsg(ieeeAddr, ep));
|
||||
}
|
||||
|
||||
this.configured.push(ieeeAddr);
|
||||
}
|
||||
startupLogVersion(callback) {
|
||||
utils.getZigbee2mqttVersion((info) => {
|
||||
logger.info(`Starting zigbee2mqtt version ${info.version} (commit #${info.commitHash})`);
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
||||
getDeviceStartupLogMessage(device) {
|
||||
@@ -210,417 +208,104 @@ class Controller {
|
||||
}
|
||||
|
||||
if (device.type) {
|
||||
type = device.type;
|
||||
type = utils.correctDeviceType(device);
|
||||
}
|
||||
|
||||
return `${friendlyName} (${device.ieeeAddr}): ${friendlyDevice.model} - ` +
|
||||
`${friendlyDevice.vendor} ${friendlyDevice.description} (${type})`;
|
||||
}
|
||||
|
||||
handleZigbeeMessage(message) {
|
||||
// Zigbee message receieved, reset soft reset timeout.
|
||||
this.softResetTimeout(true);
|
||||
|
||||
// Log the message.
|
||||
let logMessage = `Recieved zigbee message of type '${message.type}' ` +
|
||||
`with data '${JSON.stringify(message.data)}'`;
|
||||
if (message.endpoints && message.endpoints[0].device) {
|
||||
const device = message.endpoints[0].device;
|
||||
logMessage += ` of device '${device.modelId}' (${device.ieeeAddr})`;
|
||||
}
|
||||
logger.debug(logMessage);
|
||||
|
||||
if (message.type == 'devInterview' && !settings.getDevice(message.data)) {
|
||||
logger.info('Connecting with device...');
|
||||
this.mqtt.log('pairing', 'connecting with device');
|
||||
}
|
||||
|
||||
if (message.type == 'devIncoming') {
|
||||
logger.info('Device incoming...');
|
||||
this.mqtt.log('pairing', 'device incoming');
|
||||
}
|
||||
|
||||
// We dont handle messages without endpoints.
|
||||
if (!message.endpoints) {
|
||||
return;
|
||||
}
|
||||
|
||||
const device = message.endpoints[0].device;
|
||||
|
||||
if (!device) {
|
||||
logger.warn('Message without device!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a new device.
|
||||
if (!settings.getDevice(device.ieeeAddr)) {
|
||||
logger.info(`New device with address ${device.ieeeAddr} connected!`);
|
||||
settings.addDevice(device.ieeeAddr);
|
||||
this.mqtt.log('device_connected', device.ieeeAddr);
|
||||
}
|
||||
|
||||
// We can't handle devices without modelId.
|
||||
if (!device.modelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Map Zigbee modelID to vendor modelID.
|
||||
const modelID = message.endpoints[0].device.modelId;
|
||||
const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(modelID);
|
||||
|
||||
if (!mappedModel) {
|
||||
logger.warn(`Device with modelID '${modelID}' is not supported.`);
|
||||
logger.warn(`Please see: https://github.com/Koenkk/zigbee2mqtt/wiki/How-to-support-new-devices`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Configure device.
|
||||
this.configureDevice(device);
|
||||
|
||||
// Home Assistant MQTT discovery
|
||||
if (settings.get().homeassistant) {
|
||||
homeassistant.discover(device.ieeeAddr, mappedModel.model, this.mqtt, false);
|
||||
}
|
||||
|
||||
// After this point we cant handle message withoud cid anymore.
|
||||
if (!message.data || (!message.data.cid && !message.data.cmdId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find a conveter for this message.
|
||||
const cid = message.data.cid;
|
||||
const cmdId = message.data.cmdId;
|
||||
const converters = mappedModel.fromZigbee.filter((c) => {
|
||||
if (cid) {
|
||||
return c.cid === cid && c.type === message.type;
|
||||
} else if (cmdId) {
|
||||
return c.cmd === cmdId;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!converters.length) {
|
||||
if (cid) {
|
||||
logger.warn(
|
||||
`No converter available for '${mappedModel.model}' with cid '${cid}', ` +
|
||||
`type '${message.type}' and data '${JSON.stringify(message.data)}'`
|
||||
);
|
||||
} else if (cmdId) {
|
||||
logger.warn(
|
||||
`No converter available for '${mappedModel.model}' with cmd '${cmdId}' ` +
|
||||
`and data '${JSON.stringify(message.data)}'`
|
||||
);
|
||||
}
|
||||
|
||||
logger.warn(`Please see: https://github.com/Koenkk/zigbee2mqtt/wiki/How-to-support-new-devices.`);
|
||||
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.
|
||||
converters.forEach((converter) => {
|
||||
const publish = (payload) => {
|
||||
// Don't cache messages with following properties:
|
||||
const dontCacheProperties = ['click', 'action', 'button', 'button_left', 'button_right'];
|
||||
let cache = true;
|
||||
dontCacheProperties.forEach((property) => {
|
||||
if (payload.hasOwnProperty(property)) {
|
||||
cache = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Add device linkquality.
|
||||
if (message.hasOwnProperty('linkquality')) {
|
||||
payload.linkquality = message.linkquality;
|
||||
}
|
||||
|
||||
this.mqttPublishDeviceState(device.ieeeAddr, payload, cache);
|
||||
};
|
||||
|
||||
const payload = converter.convert(mappedModel, message, publish, settings.getDevice(device.ieeeAddr));
|
||||
|
||||
if (payload) {
|
||||
publish(payload);
|
||||
sendAllCachedStates() {
|
||||
this.zigbee.getAllClients().forEach((device) => {
|
||||
if (this.state.exists(device.ieeeAddr)) {
|
||||
this.publishEntityState(device.ieeeAddr, this.state.get(device.ieeeAddr));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleMQTTMessage(topic, message) {
|
||||
logger.debug(`Recieved mqtt message on topic '${topic}' with data '${message}'`);
|
||||
publishEntityState(entityID, payload) {
|
||||
const entity = settings.resolveEntity(entityID);
|
||||
const appSettings = settings.get();
|
||||
let messagePayload = {...payload};
|
||||
|
||||
// Find extensions that could handle this.
|
||||
const extensions = this.extensions.filter((e) => e.handleMQTTMessage);
|
||||
const currentState = this.state.exists(entityID) ? this.state.get(entityID) : {};
|
||||
const newState = objectAssignDeep.noMutate(currentState, payload);
|
||||
|
||||
// Call extensions.
|
||||
const extensionResults = extensions.map((e) => e.handleMQTTMessage(topic, message));
|
||||
// Update state cache with new state.
|
||||
this.state.set(entityID, newState);
|
||||
|
||||
if (topic.match(mqttConfigRegex)) {
|
||||
this.handleMQTTMessageConfig(topic, message);
|
||||
} else if (topic.match(mqttDeviceRegex) || topic.match(mqttDevicePrefixRegex)) {
|
||||
this.handleMQTTMessageDevice(topic, message, topic.match(mqttDevicePrefixRegex));
|
||||
} else if (topic === 'hass/status') {
|
||||
if (message.toString().toLowerCase() === 'online') {
|
||||
const timer = setTimeout(() => {
|
||||
this.sendAllCachedStates();
|
||||
clearTimeout(timer);
|
||||
}, 20000);
|
||||
}
|
||||
} else if (!extensionResults.includes(true)) {
|
||||
logger.warn(`Cannot handle MQTT message with topic '${topic}' and message '${message}'`);
|
||||
}
|
||||
}
|
||||
|
||||
handleMQTTMessageConfig(topic, message) {
|
||||
const option = topic.split('/').slice(-1)[0];
|
||||
|
||||
if (option === 'permit_join') {
|
||||
this.zigbee.permitJoin(message.toString().toLowerCase() === 'true');
|
||||
} else if (option === 'log_level') {
|
||||
const level = message.toString().toLowerCase();
|
||||
if (allowedLogLevels.includes(level)) {
|
||||
logger.info(`Switching log level to '${level}'`);
|
||||
logger.transports.console.level = level;
|
||||
logger.transports.file.level = level;
|
||||
} else {
|
||||
logger.error(`Could not set log level to '${level}'. Allowed level: '${allowedLogLevels.join(',')}'`);
|
||||
}
|
||||
} else if (option === 'devices') {
|
||||
const devices = this.zigbee.getAllClients().map((device) => {
|
||||
const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
|
||||
const friendlyDevice = settings.getDevice(device.ieeeAddr);
|
||||
|
||||
return {
|
||||
ieeeAddr: device.ieeeAddr,
|
||||
type: device.type,
|
||||
model: mappedModel ? mappedModel.model : device.modelId,
|
||||
friendly_name: friendlyDevice ? friendlyDevice.friendly_name : device.ieeeAddr,
|
||||
};
|
||||
});
|
||||
|
||||
this.mqtt.log('devices', devices);
|
||||
} else if (option === 'remove') {
|
||||
message = message.toString();
|
||||
const IDByFriendlyName = settings.getIDByFriendlyName(message);
|
||||
const deviceID = IDByFriendlyName ? IDByFriendlyName : message;
|
||||
const device = this.zigbee.getDevice(deviceID);
|
||||
|
||||
const cleanup = () => {
|
||||
// Clear Home Assistant MQTT discovery message
|
||||
if (settings.get().homeassistant && device) {
|
||||
const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
|
||||
if (mappedModel) {
|
||||
homeassistant.clear(deviceID, mappedModel.model, this.mqtt);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from configuration.yaml
|
||||
settings.removeDevice(deviceID);
|
||||
|
||||
// Remove from state
|
||||
this.state.remove(deviceID);
|
||||
|
||||
logger.info(`Successfully removed ${deviceID}`);
|
||||
this.mqtt.log('device_removed', message);
|
||||
};
|
||||
|
||||
// Remove from zigbee network.
|
||||
if (device) {
|
||||
this.zigbee.removeDevice(deviceID, (error) => {
|
||||
if (!error) {
|
||||
cleanup();
|
||||
} else {
|
||||
logger.error(`Failed to remove ${deviceID}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
} else if (option === 'rename') {
|
||||
const invalid = `Invalid rename message format expected {old: 'friendly_name', new: 'new_name} ` +
|
||||
`got ${message.toString()}`;
|
||||
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(message.toString());
|
||||
} catch (e) {
|
||||
logger.error(invalid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate message
|
||||
if (!json.new || !json.old) {
|
||||
logger.error(invalid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.changeFriendlyName(json.old, json.new)) {
|
||||
logger.info(`Successfully renamed - ${json.old} to ${json.new} `);
|
||||
} else {
|
||||
logger.error(`Failed to renamed - ${json.old} to ${json.new}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Homeassistant rediscover
|
||||
if (settings.get().homeassistant) {
|
||||
const ID = settings.getIDByFriendlyName(json.new);
|
||||
const device = this.zigbee.getDevice(ID);
|
||||
const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
|
||||
if (mappedModel) {
|
||||
homeassistant.discover(device.ieeeAddr, mappedModel.model, this.mqtt, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Cannot handle MQTT config option '${option}' with message '${message}'`);
|
||||
}
|
||||
}
|
||||
|
||||
handleMQTTMessageDevice(topic, message, withPrefix) {
|
||||
const friendlyName = topic.split('/').slice(-2)[0];
|
||||
const topicPrefix = withPrefix ? topic.split('/').slice(-3)[0] : '';
|
||||
|
||||
// Map friendlyName to deviceID.
|
||||
const deviceID = settings.getIDByFriendlyName(friendlyName);
|
||||
|
||||
if (!deviceID) {
|
||||
logger.error(`Cannot handle '${topic}' because deviceID of '${friendlyName}' cannot be found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert the MQTT message to a Zigbee message.
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(message);
|
||||
} catch (e) {
|
||||
// Cannot be parsed to JSON, assume state message.
|
||||
json = {state: message.toString()};
|
||||
}
|
||||
|
||||
// Find ep for this device
|
||||
const device = this.zigbee.getDevice(deviceID);
|
||||
if (!device) {
|
||||
logger.error(`Failed to find device with deviceID ${deviceID}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
|
||||
if (!mappedModel) {
|
||||
logger.warn(`Device with modelID '${device.modelId}' is not supported.`);
|
||||
logger.warn(`Please see: https://github.com/Koenkk/zigbee2mqtt/wiki/How-to-support-new-devices`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ep = mappedModel.ep && mappedModel.ep[topicPrefix] ? mappedModel.ep[topicPrefix] : null;
|
||||
const published = [];
|
||||
|
||||
Object.keys(json).forEach((key) => {
|
||||
// Find converter for this key.
|
||||
const converter = mappedModel.toZigbee.find((c) => c.key === key);
|
||||
|
||||
if (!converter) {
|
||||
logger.error(`No converter available for '${key}' (${json[key]})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = converter.convert(json[key], json);
|
||||
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callback = (error) => {
|
||||
// Devices do not report when they go off, this ensures state (on/off) is always in sync.
|
||||
if (!error && key.startsWith('state')) {
|
||||
const msg = {};
|
||||
const _key = topicPrefix ? `${key}_${topicPrefix}` : key;
|
||||
msg[_key] = json[key];
|
||||
this.mqttPublishDeviceState(deviceID, msg, true);
|
||||
}
|
||||
};
|
||||
|
||||
this.zigbee.publish(deviceID, message.cid, message.cmd, message.zclData, ep, message.type, callback);
|
||||
|
||||
published.push({message: message, converter: converter});
|
||||
});
|
||||
|
||||
/**
|
||||
* After publishing a command to a zigbee device we want to monitor the changed attribute(s) so that
|
||||
* everything stays in sync.
|
||||
*/
|
||||
published.forEach((p) => {
|
||||
let counter = 0;
|
||||
let secondsToMonitor = 1;
|
||||
|
||||
// In case of a transition we need to monitor for the whole transition time.
|
||||
if (p.message.zclData.hasOwnProperty('transtime')) {
|
||||
// Note that: transtime 10 = 0.1 seconds, 100 = 1 seconds, etc.
|
||||
secondsToMonitor = (p.message.zclData.transtime / 10) + 1;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
counter++;
|
||||
|
||||
// Doing a 'read' will result in the device sending a zigbee message with the current attribute value.
|
||||
// which will be handled by this.handleZigbeeMessage.
|
||||
p.converter.attr.forEach((attribute) => {
|
||||
this.zigbee.read(deviceID, p.message.cid, attribute, ep, () => null);
|
||||
});
|
||||
|
||||
if (counter >= secondsToMonitor) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
mqttPublishDeviceState(deviceID, payload, cache) {
|
||||
if (cacheState) {
|
||||
if (settings.get().advanced.cache_state) {
|
||||
// Add cached state to payload
|
||||
if (this.state.exists(deviceID)) {
|
||||
payload = objectAssignDeep.noMutate(this.state.get(deviceID), payload);
|
||||
}
|
||||
|
||||
// Update state cache with new state.
|
||||
if (cache) {
|
||||
this.state.set(deviceID, payload);
|
||||
}
|
||||
messagePayload = newState;
|
||||
}
|
||||
|
||||
const deviceSettings = settings.getDevice(deviceID);
|
||||
const entitySettings = entity.type === 'device' ? settings.getDevice(entityID) : settings.getGroup(entityID);
|
||||
const options = {
|
||||
retain: deviceSettings.retain,
|
||||
qos: deviceSettings.qos ? deviceSettings.qos : 0,
|
||||
retain: entitySettings ? entitySettings.retain : false,
|
||||
qos: entitySettings && entitySettings.qos ? entitySettings.qos : 0,
|
||||
};
|
||||
|
||||
this.mqtt.publish(deviceSettings.friendly_name, JSON.stringify(payload), options);
|
||||
if (entity.type === 'device' && appSettings.mqtt.include_device_information) {
|
||||
messagePayload.device = this.getDeviceInfoForMqtt(entityID);
|
||||
}
|
||||
|
||||
if (Object.entries(messagePayload).length) {
|
||||
if (settings.get().experimental.output === 'json') {
|
||||
this.mqtt.publish(entity.friendlyName, JSON.stringify(messagePayload), options);
|
||||
} else if (settings.get().experimental.output === 'attribute') {
|
||||
this.iteratePayloadForAttrOutput(entity.friendlyName+'/', messagePayload, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startupLogVersion(callback) {
|
||||
const git = require('git-last-commit');
|
||||
const packageJSON = require('../package.json');
|
||||
const version = packageJSON.version;
|
||||
iteratePayloadForAttrOutput(topicRoot, payload, options) {
|
||||
Object.keys(payload).forEach((key) => {
|
||||
let subPayload = payload[key];
|
||||
let message;
|
||||
|
||||
git.getLastCommit((err, commit) => {
|
||||
let commitHash = null;
|
||||
|
||||
if (err) {
|
||||
try {
|
||||
commitHash = require('../.hash.json').hash;
|
||||
} catch (error) {
|
||||
commitHash = 'unknown';
|
||||
}
|
||||
} else {
|
||||
commitHash = commit.shortHash;
|
||||
// Special cases
|
||||
if (key === 'color' &&
|
||||
subPayload.r !== undefined &&
|
||||
subPayload.g !== undefined &&
|
||||
subPayload.b !== undefined) {
|
||||
subPayload = [subPayload.r, subPayload.g, subPayload.b];
|
||||
}
|
||||
|
||||
logger.info(`Starting zigbee2mqtt version ${version} (commit #${commitHash})`);
|
||||
// Check Array first, since it is also an Object
|
||||
if (Array.isArray(subPayload)) {
|
||||
message = subPayload.map((x) => `${x}`).join(',');
|
||||
} else if (typeof subPayload === 'object') {
|
||||
return this.iteratePayloadForAttrOutput(topicRoot+key+'-', subPayload, options);
|
||||
} else {
|
||||
message = typeof subPayload === 'string' ? subPayload : JSON.stringify(subPayload);
|
||||
}
|
||||
|
||||
callback();
|
||||
this.mqtt.publish(`${topicRoot}${key}`, message, options);
|
||||
});
|
||||
}
|
||||
|
||||
getDeviceInfoForMqtt(ieeeAddr) {
|
||||
const device = this.zigbee.getDevice(ieeeAddr);
|
||||
const {
|
||||
type, nwkAddr, manufId, manufName, powerSource, modelId, hwVersion, swBuildId, status, dateCode,
|
||||
} = device;
|
||||
const deviceSettings = settings.getDevice(device.ieeeAddr);
|
||||
|
||||
return {
|
||||
ieeeAddr,
|
||||
friendlyName: deviceSettings ? (deviceSettings.friendly_name || '') : '',
|
||||
type,
|
||||
nwkAddr,
|
||||
manufId,
|
||||
manufName,
|
||||
powerSource,
|
||||
modelId,
|
||||
hwVersion: hwVersion ? hwVersion : 'unknown',
|
||||
swBuildId: swBuildId ? swBuildId : 'unknown',
|
||||
dateCode: dateCode ? dateCode : 'unknown',
|
||||
status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Controller;
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
|
||||
const utils = require('../util/utils');
|
||||
|
||||
const configRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/((?:\\w+/get)|(?:\\w+))`);
|
||||
const allowedLogLevels = ['error', 'warn', 'info', 'debug'];
|
||||
|
||||
class BridgeConfig {
|
||||
constructor(zigbee, mqtt, state, publishEntityState) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.state = state;
|
||||
this.publishEntityState = publishEntityState;
|
||||
|
||||
// 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.remove = this.remove.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);
|
||||
|
||||
// Set supported options
|
||||
this.supportedOptions = {
|
||||
'permit_join': this.permitJoin,
|
||||
'last_seen': this.lastSeen,
|
||||
'elapsed': this.elapsed,
|
||||
'reset': this.reset,
|
||||
'log_level': this.logLevel,
|
||||
'devices': this.devices,
|
||||
'groups': this.groups,
|
||||
'devices/get': this.devices,
|
||||
'rename': this.rename,
|
||||
'remove': this.remove,
|
||||
'ban': this.ban,
|
||||
'device_options': this.deviceOptions,
|
||||
'add_group': this.addGroup,
|
||||
'remove_group': this.removeGroup,
|
||||
'whitelist': this.whitelist,
|
||||
};
|
||||
}
|
||||
|
||||
whitelist(topic, message) {
|
||||
message = message.toString();
|
||||
const IDByFriendlyName = settings.getIeeeAddrByFriendlyName(message);
|
||||
const deviceID = IDByFriendlyName ? IDByFriendlyName : message;
|
||||
const whitelisted = settings.whitelistDevice(deviceID);
|
||||
whitelisted ? logger.info(`Whitelisted '${deviceID}'`) : logger.error(`Failed to whitelist '${deviceID}'`);
|
||||
this.mqtt.log('device_whitelisted', deviceID);
|
||||
}
|
||||
|
||||
deviceOptions(topic, message) {
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(message.toString());
|
||||
} catch (e) {
|
||||
logger.error('Failed to parse message as JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!json.hasOwnProperty('friendly_name') || !json.hasOwnProperty('options')) {
|
||||
logger.error('Invalid JSON message, should contain "friendly_name" and "options"');
|
||||
return;
|
||||
}
|
||||
|
||||
const ieeeAddr = settings.getIeeeAddrByFriendlyName(json.friendly_name);
|
||||
if (!ieeeAddr) {
|
||||
logger.error(`Failed to find device '${json.friendly_name}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
settings.changeDeviceOptions(ieeeAddr, json.options);
|
||||
logger.info(`Changed device specific options of '${json.friendly_name}' (${JSON.stringify(json.options)})`);
|
||||
}
|
||||
|
||||
permitJoin(topic, message) {
|
||||
this.zigbee.permitJoin(message.toString().toLowerCase() === 'true', () => this.publish());
|
||||
}
|
||||
|
||||
reset(topic, message) {
|
||||
this.zigbee.softReset((error) => {
|
||||
if (error) {
|
||||
logger.error('Soft reset failed');
|
||||
} else {
|
||||
logger.info('Soft resetted ZNP');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
lastSeen(topic, message) {
|
||||
const allowed = ['disable', 'ISO_8601', 'epoch', 'ISO_8601_local'];
|
||||
message = message.toString();
|
||||
|
||||
if (!allowed.includes(message)) {
|
||||
logger.error(`${message} is not an allowed value, possible: ${allowed}`);
|
||||
return;
|
||||
}
|
||||
|
||||
settings.set(['advanced', 'last_seen'], message);
|
||||
logger.info(`Set last_seen to ${message}`);
|
||||
}
|
||||
|
||||
elapsed(topic, message) {
|
||||
const allowed = ['true', 'false'];
|
||||
message = message.toString();
|
||||
|
||||
if (!allowed.includes(message)) {
|
||||
logger.error(`${message} is not an allowed value, possible: ${allowed}`);
|
||||
return;
|
||||
}
|
||||
|
||||
settings.set(['advanced', 'elapsed'], message === 'true');
|
||||
logger.info(`Set elapsed to ${message}`);
|
||||
}
|
||||
|
||||
logLevel(topic, message) {
|
||||
const level = message.toString().toLowerCase();
|
||||
if (allowedLogLevels.includes(level)) {
|
||||
logger.info(`Switching log level to '${level}'`);
|
||||
logger.transports.console.level = level;
|
||||
logger.transports.file.level = level;
|
||||
} else {
|
||||
logger.error(`Could not set log level to '${level}'. Allowed level: '${allowedLogLevels.join(',')}'`);
|
||||
}
|
||||
|
||||
this.publish();
|
||||
}
|
||||
|
||||
devices(topic, message) {
|
||||
const devices = this.zigbee.getDevices().map((device) => {
|
||||
const payload = {
|
||||
ieeeAddr: device.ieeeAddr,
|
||||
type: device.type,
|
||||
};
|
||||
|
||||
if (device.type !== 'Coordinator') {
|
||||
const mappedDevice = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
|
||||
const friendlyDevice = settings.getDevice(device.ieeeAddr);
|
||||
payload.model = mappedDevice ? mappedDevice.model : device.modelId;
|
||||
payload.friendly_name = friendlyDevice ? friendlyDevice.friendly_name : device.ieeeAddr;
|
||||
payload.nwkAddr = device.nwkAddr;
|
||||
payload.manufId = device.manufId;
|
||||
payload.manufName = device.manufName;
|
||||
payload.powerSource = device.powerSource;
|
||||
payload.modelId = device.modelId;
|
||||
payload.hwVersion = device.hwVersion;
|
||||
payload.swBuildId = device.swBuildId;
|
||||
payload.dateCode = device.dateCode;
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
|
||||
if (topic.split('/').pop() == 'get') {
|
||||
this.mqtt.publish(`bridge/config/devices`, JSON.stringify(devices), {});
|
||||
} else {
|
||||
this.mqtt.log('devices', devices);
|
||||
}
|
||||
}
|
||||
|
||||
groups(topic, message) {
|
||||
this.mqtt.log('groups', settings.getGroups());
|
||||
}
|
||||
|
||||
rename(topic, message) {
|
||||
const invalid = `Invalid rename message format expected {old: 'friendly_name', new: 'new_name} ` +
|
||||
`got ${message.toString()}`;
|
||||
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(message.toString());
|
||||
} catch (e) {
|
||||
logger.error(invalid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate message
|
||||
if (!json.new || !json.old) {
|
||||
logger.error(invalid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.changeFriendlyName(json.old, json.new)) {
|
||||
logger.info(`Successfully renamed - ${json.old} to ${json.new} `);
|
||||
this.mqtt.log('device_renamed', {from: json.old, to: json.new});
|
||||
} else {
|
||||
logger.error(`Failed to renamed - ${json.old} to ${json.new}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
addGroup(topic, message) {
|
||||
const name = message.toString();
|
||||
const added = settings.addGroup(name);
|
||||
added ? logger.info(`Added group '${name}'`) : logger.error(`Failed to add group '${name}'`);
|
||||
}
|
||||
|
||||
removeGroup(topic, message) {
|
||||
const name = message.toString();
|
||||
const removed = settings.removeGroup(name);
|
||||
removed ? logger.info(`Removed group '${name}'`) : logger.error(`Failed to remove group '${name}'`);
|
||||
}
|
||||
|
||||
remove(topic, message) {
|
||||
this.removeOrBan(false, message);
|
||||
}
|
||||
|
||||
ban(topic, message) {
|
||||
this.removeOrBan(true, message);
|
||||
}
|
||||
|
||||
removeOrBan(ban, message) {
|
||||
message = message.toString();
|
||||
const IDByFriendlyName = settings.getIeeeAddrByFriendlyName(message);
|
||||
const deviceID = IDByFriendlyName ? IDByFriendlyName : message;
|
||||
const device = this.zigbee.getDevice(deviceID);
|
||||
|
||||
const cleanup = () => {
|
||||
// Remove from configuration.yaml
|
||||
settings.removeDevice(deviceID);
|
||||
|
||||
// Remove from state
|
||||
this.state.remove(deviceID);
|
||||
|
||||
logger.info(`Successfully ${ban ? 'banned' : 'removed'} ${deviceID}`);
|
||||
this.mqtt.log(ban ? 'device_banned' : 'device_removed', message);
|
||||
};
|
||||
|
||||
// Remove from zigbee network.
|
||||
if (device) {
|
||||
this.zigbee.removeDevice(deviceID, ban, (error) => {
|
||||
if (!error) {
|
||||
cleanup();
|
||||
} else {
|
||||
logger.error(`Failed to ${ban ? 'ban' : 'remove'} ${deviceID}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/config/+`);
|
||||
this.publish();
|
||||
}
|
||||
|
||||
onMQTTMessage(topic, message) {
|
||||
if (!topic.match(configRegex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const option = topic.match(configRegex)[1];
|
||||
|
||||
if (!this.supportedOptions.hasOwnProperty(option)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.supportedOptions[option](topic, message);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
publish() {
|
||||
utils.getZigbee2mqttVersion((info) => {
|
||||
const topic = `bridge/config`;
|
||||
const payload = {
|
||||
version: info.version,
|
||||
commit: info.commitHash,
|
||||
coordinator: this.zigbee.getFirmwareVersion(),
|
||||
log_level: logger.transports.console.level,
|
||||
permit_join: this.zigbee.getPermitJoin(),
|
||||
};
|
||||
|
||||
this.mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 0}, null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BridgeConfig;
|
||||
@@ -0,0 +1,182 @@
|
||||
const logger = require('../util/logger');
|
||||
const settings = require('../util/settings');
|
||||
const utils = require('../util/utils');
|
||||
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
|
||||
|
||||
// Some EndDevices should be pinged
|
||||
// e.g. E11-G13 https://github.com/Koenkk/zigbee2mqtt/issues/775#issuecomment-453683846
|
||||
const pingableDevices = [
|
||||
zigbeeShepherdConverters.devices.find((d) => d.model === 'E11-G13'),
|
||||
];
|
||||
|
||||
const toZigbeeCandidates = ['state', 'color', 'color_temp'];
|
||||
|
||||
/**
|
||||
* This extensions pings devices to check if they are online.
|
||||
*/
|
||||
class DeviceAvailability {
|
||||
constructor(zigbee, mqtt, state, publishEntityState) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.availability_timeout = settings.get().advanced.availability_timeout;
|
||||
this.timers = {};
|
||||
this.pending = [];
|
||||
this.state = {};
|
||||
|
||||
// Initialize blacklist
|
||||
this.blacklist = settings.get().advanced.availability_blacklist.map((e) => {
|
||||
return settings.getIeeeAddrByFriendlyName(e) || e;
|
||||
});
|
||||
}
|
||||
|
||||
isPingable(device) {
|
||||
logger.trace(`Checking if ${device.ieeeAddr} is pingable`);
|
||||
|
||||
if (this.blacklist.includes(device.ieeeAddr)) {
|
||||
logger.trace(`${device.ieeeAddr} is not pingable because of blacklist`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pingableDevices.find((d) => d.zigbeeModel.includes(device.modelId))) {
|
||||
logger.trace(`${device.ieeeAddr} is pingable because in pingable devices`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = utils.isRouter(device) && !utils.isBatteryPowered(device);
|
||||
logger.trace(`${device.ieeeAddr} is pingable (${result}) not router or battery powered`);
|
||||
return result;
|
||||
}
|
||||
|
||||
getAllPingableDevices() {
|
||||
return this.zigbee.getAllClients().filter((d) => this.isPingable(d));
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
// As some devices are not checked for availability (e.g. battery powered devices)
|
||||
// we mark all device as online by default.
|
||||
this.zigbee.getDevices()
|
||||
.filter((d) => d.type !== 'Coordinator')
|
||||
.forEach((device) => this.publishAvailability(device.ieeeAddr, true));
|
||||
|
||||
// Start timers for all devices
|
||||
this.getAllPingableDevices().forEach((device) => this.setTimer(device));
|
||||
}
|
||||
|
||||
handleInterval(device) {
|
||||
// Check if a job is already pending.
|
||||
// This avoids overflowing of the queue in case the queue is not able to catch-up with the jobs being added.
|
||||
const ieeeAddr = device.ieeeAddr;
|
||||
if (this.pending.includes(ieeeAddr)) {
|
||||
logger.debug(`Skipping ping for ${ieeeAddr} becuase job is already in queue`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pending.push(ieeeAddr);
|
||||
|
||||
// When a device is already unavailable, log the ping failed on 'debug' instead of 'error'.
|
||||
const errorLogLevel = this.state.hasOwnProperty(ieeeAddr) && !this.state[ieeeAddr] ? 'debug' : 'error';
|
||||
const mechanism = utils.isXiaomiDevice(device) ? 'basic' : 'default';
|
||||
|
||||
this.zigbee.ping(ieeeAddr, errorLogLevel, (error) => {
|
||||
this.publishAvailability(ieeeAddr, !error);
|
||||
|
||||
// Remove from pending jobs.
|
||||
const index = this.pending.indexOf(ieeeAddr);
|
||||
if (index !== -1) {
|
||||
this.pending.splice(index, 1);
|
||||
}
|
||||
|
||||
this.setTimer(device);
|
||||
}, mechanism);
|
||||
}
|
||||
|
||||
setTimer(device) {
|
||||
if (this.timers[device.ieeeAddr]) {
|
||||
clearTimeout(this.timers[device.ieeeAddr]);
|
||||
}
|
||||
|
||||
this.timers[device.ieeeAddr] = setTimeout(() => {
|
||||
this.handleInterval(device);
|
||||
}, utils.secondsToMilliseconds(this.availability_timeout));
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.zigbee.getDevices()
|
||||
.filter((d) => d.type !== 'Coordinator')
|
||||
.forEach((device) => this.publishAvailability(device.ieeeAddr, false));
|
||||
}
|
||||
|
||||
onReconnect(ieeeAddr) {
|
||||
const device = this.zigbee.getDevice(ieeeAddr);
|
||||
let mappedDevice = null;
|
||||
|
||||
if (device && device.modelId) {
|
||||
mappedDevice = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
|
||||
}
|
||||
|
||||
if (mappedDevice) {
|
||||
const converters = mappedDevice.toZigbee.filter((tz) => {
|
||||
return tz.key.find((k) => toZigbeeCandidates.includes(k));
|
||||
});
|
||||
|
||||
converters.forEach((converter) => {
|
||||
const convertedResults = converter.convert(null, null, null, 'get');
|
||||
if (convertedResults && convertedResults.length) {
|
||||
convertedResults.forEach((converted) => {
|
||||
this.zigbee.publish(
|
||||
ieeeAddr, 'device', converted.cid, converted.cmd, converted.cmdType,
|
||||
converted.zclData, converted.cfg, null, () => {}
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
publishAvailability(ieeeAddr, available) {
|
||||
if (this.state.hasOwnProperty(ieeeAddr) && !this.state[ieeeAddr] && available) {
|
||||
this.onReconnect(ieeeAddr);
|
||||
}
|
||||
|
||||
this.state[ieeeAddr] = available;
|
||||
const deviceSettings = settings.getDevice(ieeeAddr);
|
||||
const name = deviceSettings ? deviceSettings.friendly_name : ieeeAddr;
|
||||
const topic = `${name}/availability`;
|
||||
const payload = available ? 'online' : 'offline';
|
||||
this.mqtt.publish(topic, payload, {retain: true, qos: 0});
|
||||
}
|
||||
|
||||
onZigbeeMessage(message, device, mappedDevice) {
|
||||
if (device && this.isPingable(this.zigbee.getDevice(device.ieeeAddr))) {
|
||||
// When a zigbee message from a device is received we know the device is still alive.
|
||||
// => reset the timer.
|
||||
this.setTimer(device);
|
||||
|
||||
const online = this.state.hasOwnProperty(device.ieeeAddr) && this.state[device.ieeeAddr];
|
||||
const offline = this.state.hasOwnProperty(device.ieeeAddr) && !this.state[device.ieeeAddr];
|
||||
|
||||
if (!online && !offline) {
|
||||
// A new device has been connected
|
||||
this.publishAvailability(device.ieeeAddr, true);
|
||||
} else if (offline) {
|
||||
// When a message is received and the device is marked as offline, mark it online.
|
||||
this.publishAvailability(device.ieeeAddr, true);
|
||||
} else if (online) {
|
||||
/**
|
||||
* 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
|
||||
* endDeviceAnnce is typically send when a device comes online.
|
||||
*
|
||||
* This isn't needed for TRADFRI devices as they already send the state themself.
|
||||
*/
|
||||
if (message.type === 'endDeviceAnnce' && !utils.isIkeaTradfriDevice(device)) {
|
||||
this.onReconnect(device.ieeeAddr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DeviceAvailability;
|
||||
@@ -0,0 +1,128 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const utils = require('../util/utils');
|
||||
|
||||
const postfixes = utils.getPostfixes();
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/(bind|unbind)/.+$`);
|
||||
|
||||
const allowedClusters = [
|
||||
5, // genScenes
|
||||
6, // genOnOff
|
||||
8, // genLevelCtrl
|
||||
768, // lightingColorCtrl
|
||||
];
|
||||
|
||||
class DeviceBind {
|
||||
constructor(zigbee, mqtt, state, publishEntityState) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.state = state;
|
||||
this.publishEntityState = publishEntityState;
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/bind/+`);
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/bind/+/+`);
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/unbind/+`);
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/unbind/+/+`);
|
||||
}
|
||||
|
||||
getIDAndPostfix(topic) {
|
||||
let postfix = null;
|
||||
if (postfixes.find((p) => topic.endsWith(`/${p}`))) {
|
||||
postfix = topic.substr(topic.lastIndexOf('/') + 1, topic.length);
|
||||
|
||||
// Remove postfix from topic
|
||||
topic = topic.replace(`/${postfix}`, '');
|
||||
}
|
||||
|
||||
return {ID: topic, postfix};
|
||||
}
|
||||
|
||||
parseTopic(topic) {
|
||||
if (!topic.match(topicRegex)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove base from topic
|
||||
topic = topic.replace(`${settings.get().mqtt.base_topic}/bridge/`, '');
|
||||
|
||||
// Parse type from topic
|
||||
const type = topic.split('/')[0];
|
||||
|
||||
// Remove type from topic
|
||||
topic = topic.replace(`${type}/`, '');
|
||||
return {type, ...this.getIDAndPostfix(topic)};
|
||||
}
|
||||
|
||||
onMQTTMessage(topic, message) {
|
||||
topic = this.parseTopic(topic);
|
||||
|
||||
if (!topic) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find source; can only be a device.
|
||||
const sourceEntity = settings.resolveEntity(topic.ID);
|
||||
const source = utils.getEndpointByEntityID(this.zigbee, sourceEntity.ID, topic.postfix);
|
||||
|
||||
const targetEntityIDPostfix= this.getIDAndPostfix(message.toString());
|
||||
const targetEntity = settings.resolveEntity(targetEntityIDPostfix.ID);
|
||||
let target = null;
|
||||
if (targetEntity.type === 'device') {
|
||||
target = utils.getEndpointByEntityID(this.zigbee, targetEntity.ID, targetEntityIDPostfix.postfix);
|
||||
} else if (targetEntity.type === 'group') {
|
||||
target = targetEntity.ID;
|
||||
}
|
||||
|
||||
if (!source || !target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find which clusters are supported by both the source and target.
|
||||
// Groups are assumed to support all clusters (as we don't know which devices are in)
|
||||
let supported = [];
|
||||
if (targetEntity.type === 'device') {
|
||||
supported = target.getSimpleDesc().inClusterList.filter((cluster) => {
|
||||
return allowedClusters.includes(cluster);
|
||||
});
|
||||
} else if (targetEntity.type === 'group') {
|
||||
supported = allowedClusters;
|
||||
}
|
||||
|
||||
const clusters = source.getSimpleDesc().outClusterList.filter((cluster) => {
|
||||
return supported.includes(cluster);
|
||||
});
|
||||
|
||||
// Bind
|
||||
clusters.forEach((cluster) => {
|
||||
logger.debug(
|
||||
`${topic.type}ing cluster '${cluster}' from '${sourceEntity.ID}' ` +
|
||||
`(${source.epId}) to '${targetEntity.ID}'`
|
||||
);
|
||||
|
||||
this.zigbee[topic.type](source, cluster, target, (error) => {
|
||||
if (error) {
|
||||
logger.error(
|
||||
`Failed to ${topic.type} cluster '${cluster}' from '${sourceEntity.ID}' to ` +
|
||||
`'${targetEntity.ID}' (${error})`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`Successfully ${topic.type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from ` +
|
||||
`'${sourceEntity.ID}' to '${targetEntity.ID}'`
|
||||
);
|
||||
|
||||
this.mqtt.log(
|
||||
`device_${topic.type}`,
|
||||
{from: sourceEntity.ID, to: targetEntity.ID, cluster}
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DeviceBind;
|
||||
@@ -0,0 +1,89 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
|
||||
const Queue = require('queue');
|
||||
|
||||
/**
|
||||
* This extensions handles configuration of devices.
|
||||
*/
|
||||
class DeviceConfigure {
|
||||
constructor(zigbee, mqtt, state, publishEntityState) {
|
||||
this.zigbee = zigbee;
|
||||
this.configured = {};
|
||||
this.attempts = {};
|
||||
|
||||
this.queue = new Queue();
|
||||
this.queue.concurrency = 1;
|
||||
this.queue.autostart = true;
|
||||
}
|
||||
|
||||
onZigbeeStarted() {
|
||||
this.zigbee.getAllClients().forEach((device) => {
|
||||
const mappedDevice = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
|
||||
|
||||
if (mappedDevice) {
|
||||
this.configure(device.ieeeAddr, mappedDevice);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onZigbeeMessage(message, device, mappedDevice) {
|
||||
if (device && mappedDevice) {
|
||||
const ieeeAddr = device.ieeeAddr;
|
||||
if (!this.configured[ieeeAddr]) {
|
||||
this.configure(ieeeAddr, mappedDevice);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
configure(ieeeAddr, mappedDevice) {
|
||||
if (!this.attempts.hasOwnProperty(ieeeAddr)) {
|
||||
this.attempts[ieeeAddr] = 0;
|
||||
}
|
||||
|
||||
if (mappedDevice.configure) {
|
||||
const friendlyName = settings.getDevice(ieeeAddr) ? settings.getDevice(ieeeAddr).friendly_name : 'unknown';
|
||||
// mark device as currently being configured to prevent race conditions
|
||||
this.configured[ieeeAddr] = 'configuring';
|
||||
|
||||
this.queue.push((queueCallback) => {
|
||||
logger.debug(`Configuring ${friendlyName} (${ieeeAddr}) ...`);
|
||||
|
||||
// Call configure function of this device.
|
||||
mappedDevice.configure(ieeeAddr, this.zigbee.shepherd, this.zigbee.getCoordinator(), (ok, msg) => {
|
||||
if (ok) {
|
||||
logger.info(`Successfully configured ${friendlyName} (${ieeeAddr})`);
|
||||
this.configured[ieeeAddr] = 'configured';
|
||||
} else {
|
||||
// If a device is not joined in the network the configure command may fail
|
||||
// (especially, immediately after zigbee start)
|
||||
// also, this command may fail if the network is busy or lost data packets
|
||||
if (this.attempts[ieeeAddr] > 1) {
|
||||
// Give up after 3 attempts.
|
||||
this.configured[ieeeAddr] = 'failed';
|
||||
} else {
|
||||
// we're not done yet so mark not busy
|
||||
delete this.configured[ieeeAddr];
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Failed to configure ${friendlyName} (${ieeeAddr}) ('${msg}')` +
|
||||
` (attempt #${this.attempts[ieeeAddr] + 1})`
|
||||
);
|
||||
|
||||
logger.warn(`This can be ignored if the device is working properly`);
|
||||
|
||||
this.attempts[ieeeAddr] += 1;
|
||||
}
|
||||
|
||||
queueCallback();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// no need to configure anyting. So just mark as done
|
||||
this.configured[ieeeAddr] = 'configured';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DeviceConfigure;
|
||||
@@ -0,0 +1,73 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/device/.+/get_group_membership$`);
|
||||
|
||||
class DeviceGroupMembership {
|
||||
constructor(zigbee, mqtt, publishEntityState) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.publishEntityState = publishEntityState;
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/device/+/get_group_membership`);
|
||||
}
|
||||
|
||||
parseTopic(topic) {
|
||||
if (!topic.match(topicRegex)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove base from topic
|
||||
topic = topic.replace(`${settings.get().mqtt.base_topic}/bridge/device/`, '');
|
||||
|
||||
// Remove command from topic
|
||||
topic = topic.replace(`/get_group_membership`, '');
|
||||
|
||||
return {friendlyName: topic};
|
||||
}
|
||||
|
||||
onMQTTMessage(topic, message) {
|
||||
topic = this.parseTopic(topic);
|
||||
|
||||
if (!topic) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Map message to ieeeAddr and check if device exist.
|
||||
const friendlyName = topic.friendlyName;
|
||||
const ieeeAddr = settings.getIeeeAddrByFriendlyName(friendlyName) || friendlyName;
|
||||
if (!this.zigbee.getDevice(ieeeAddr)) {
|
||||
logger.error(`Failed to find device '${friendlyName}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
const callback = (error, rsp) => {
|
||||
if (error) {
|
||||
logger.error(`Failed to get membership of ${ieeeAddr}`);
|
||||
} else {
|
||||
const {grouplist, capacity} = rsp;
|
||||
const msgGroupList = `${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(ieeeAddr, {group_list: grouplist, group_capacity: capacity});
|
||||
}
|
||||
};
|
||||
|
||||
this.zigbee.publish(
|
||||
ieeeAddr, 'device', 'genGroups', 'getMembership', 'functional',
|
||||
{groupcount: 0, grouplist: []}, null, null, callback,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DeviceGroupMembership;
|
||||
@@ -0,0 +1,235 @@
|
||||
|
||||
const settings = require('../util/settings');
|
||||
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
|
||||
const logger = require('../util/logger');
|
||||
const utils = require('../util/utils');
|
||||
const objectAssignDeep = require(`object-assign-deep`);
|
||||
|
||||
const postfixes = utils.getPostfixes();
|
||||
const topicRegex = new RegExp(`^(.+?)(?:/(${postfixes.join('|')}))?/(get|set)(?:/(.+))?`);
|
||||
|
||||
const maxDepth = 20;
|
||||
|
||||
const groupConverters = [
|
||||
zigbeeShepherdConverters.toZigbeeConverters.light_onoff_brightness,
|
||||
zigbeeShepherdConverters.toZigbeeConverters.light_colortemp,
|
||||
zigbeeShepherdConverters.toZigbeeConverters.light_color,
|
||||
zigbeeShepherdConverters.toZigbeeConverters.light_alert,
|
||||
zigbeeShepherdConverters.toZigbeeConverters.ignore_transition,
|
||||
];
|
||||
|
||||
class DevicePublish {
|
||||
constructor(zigbee, mqtt, state, publishEntityState) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.state = state;
|
||||
this.publishEntityState = publishEntityState;
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
// Subscribe to topics.
|
||||
const baseTopic = settings.get().mqtt.base_topic;
|
||||
for (let step = 1; step < maxDepth; 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 repalce base_topic we received something we don't care about
|
||||
if (ID === match[1] || ID.match(/bridge\/config/)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {ID: ID, postfix: match[2] || '', type: match[3], attribute: match[4]};
|
||||
}
|
||||
|
||||
handlePublishError(entity, message, error) {
|
||||
const meta = {
|
||||
entity,
|
||||
message: message.toString(),
|
||||
};
|
||||
|
||||
this.mqtt.log('zigbee_publish_error', error.toString(), meta);
|
||||
}
|
||||
|
||||
handlePublished(entity, topic, converter, converted, key, value) {
|
||||
if (topic.type === 'set' && converted.hasOwnProperty('newState') && converted.newState) {
|
||||
const msg = converted.newState;
|
||||
|
||||
if (topic.postfix) {
|
||||
msg[`state_${topic.postfix}`] = msg.state;
|
||||
delete msg.state;
|
||||
}
|
||||
|
||||
if (settings.get().advanced.last_seen !== 'disable') {
|
||||
msg.last_seen = utils.formatDate(Date.now(), settings.get().advanced.last_seen);
|
||||
}
|
||||
|
||||
this.publishEntityState(entity.ID, msg);
|
||||
}
|
||||
}
|
||||
|
||||
onMQTTMessage(topic, message) {
|
||||
topic = this.parseTopic(topic);
|
||||
|
||||
if (!topic) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolve the entity
|
||||
const entity = settings.resolveEntity(topic.ID);
|
||||
|
||||
// Get entity details
|
||||
let endpoint = null;
|
||||
let converters = null;
|
||||
let device = null;
|
||||
let options = {};
|
||||
|
||||
if (entity.type === 'device') {
|
||||
device = this.zigbee.getDevice(entity.ID);
|
||||
if (!device) {
|
||||
logger.error(`Failed to find device with ieeeAddr: '${entity.ID}'`);
|
||||
this.mqtt.log('entity_not_found', entity.ID);
|
||||
return;
|
||||
}
|
||||
|
||||
// Map device to a model
|
||||
const model = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
|
||||
if (!model) {
|
||||
logger.warn(`Device with modelID '${device.modelId}' is not supported.`);
|
||||
logger.warn(`Please see: https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine endpoint to publish to.
|
||||
if (model.hasOwnProperty('ep')) {
|
||||
const eps = model.ep(device);
|
||||
endpoint = eps.hasOwnProperty(topic.postfix) ? eps[topic.postfix] : null;
|
||||
if (endpoint === null && eps.hasOwnProperty('default')) {
|
||||
endpoint = eps['default'];
|
||||
}
|
||||
}
|
||||
|
||||
converters = model.toZigbee;
|
||||
|
||||
const deviceSettings = settings.getDevice(entity.ID);
|
||||
options = objectAssignDeep.noMutate({}, model.options, deviceSettings);
|
||||
} else if (entity.type === 'group') {
|
||||
converters = groupConverters;
|
||||
options = objectAssignDeep.noMutate({}, settings.getGroup(entity.ID));
|
||||
}
|
||||
|
||||
// Convert the MQTT message to a Zigbee message.
|
||||
let json = {};
|
||||
if (topic.hasOwnProperty('attribute') && topic.attribute) {
|
||||
json[topic.attribute] = message.toString();
|
||||
} else {
|
||||
try {
|
||||
json = JSON.parse(message);
|
||||
} catch (e) {
|
||||
// Cannot be parsed to JSON, assume state message.
|
||||
json = {state: message.toString()};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 deviceState = this.state.get(entity.ID);
|
||||
const hasColorTemp = json.hasOwnProperty('color_temp');
|
||||
const hasColor = json.hasOwnProperty('color');
|
||||
const hasBrightness = json.hasOwnProperty('brightness');
|
||||
const isOn = deviceState && deviceState.state === 'ON' ? true : false;
|
||||
if (isOn && (hasColorTemp || hasColor) && !hasBrightness) {
|
||||
delete json.state;
|
||||
logger.debug('Skipping state because of Home Assistant');
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure that state and brightness are executed before other commands.
|
||||
const keys = Object.keys(json);
|
||||
keys.sort((a, b) => (['state', 'brightness'].includes(a) ? -1 : 1));
|
||||
|
||||
// For each key in the JSON message find the matching converter.
|
||||
const usedConverters = [];
|
||||
keys.forEach((key) => {
|
||||
const converter = converters.find((c) => c.key.includes(key));
|
||||
|
||||
if (usedConverters.includes(converter)) {
|
||||
// Use a converter only once (e.g. light_onoff_brightness converters can convert state and brightness)
|
||||
return;
|
||||
}
|
||||
|
||||
if (!converter) {
|
||||
logger.error(`No converter available for '${key}' (${json[key]})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Converter didn't return a result, skip
|
||||
const convertedResults = converter.convert(key, json[key], json, topic.type, topic.postfix, options);
|
||||
if (!convertedResults || !convertedResults.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
convertedResults.forEach((converted) => {
|
||||
this.zigbee.publish(
|
||||
entity.ID,
|
||||
entity.type,
|
||||
converted.cid,
|
||||
converted.cmd,
|
||||
converted.cmdType,
|
||||
converted.zclData,
|
||||
converted.cfg,
|
||||
endpoint,
|
||||
(error, rsp) => {
|
||||
if (!error) {
|
||||
this.handlePublished(entity, topic, converter, converted, key, json[key]);
|
||||
} else {
|
||||
this.handlePublishError(entity, message, error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 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.
|
||||
const deviceSettings = settings.getDevice(entity.ID);
|
||||
if (topic.type === 'set' && entity.type === 'device' &&
|
||||
converted.hasOwnProperty('readAfterWriteTime') && deviceSettings && deviceSettings.retrieve_state) {
|
||||
const getConvertedResults = converter.convert(key, json[key], json, 'get', '', options);
|
||||
getConvertedResults.forEach((getConverted) => {
|
||||
setTimeout(() => {
|
||||
this.zigbee.publish(
|
||||
entity.ID, entity.type, getConverted.cid, getConverted.cmd, getConverted.cmdType,
|
||||
getConverted.zclData, getConverted.cfg, endpoint, () => {}
|
||||
);
|
||||
}, converted.readAfterWriteTime);
|
||||
});
|
||||
}
|
||||
|
||||
usedConverters.push(converter);
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DevicePublish;
|
||||
@@ -0,0 +1,212 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const utils = require('../util/utils');
|
||||
const debounce = require('debounce');
|
||||
|
||||
/**
|
||||
* This extensions handles messages received from devices.
|
||||
*/
|
||||
class DeviceReceive {
|
||||
constructor(zigbee, mqtt, state, publishEntityState) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.state = state;
|
||||
this.publishEntityState = publishEntityState;
|
||||
this.coordinator = null;
|
||||
this.elapsed = {};
|
||||
this.debouncers = {};
|
||||
}
|
||||
|
||||
onZigbeeStarted() {
|
||||
this.coordinator = this.zigbee.getCoordinator().device.ieeeAddr;
|
||||
}
|
||||
|
||||
publishDebounce(ieeeAddr, payload, time) {
|
||||
if (!this.debouncers[ieeeAddr]) {
|
||||
this.debouncers[ieeeAddr] = {
|
||||
payload: {},
|
||||
publish: debounce(() => {
|
||||
this.publishEntityState(ieeeAddr, this.debouncers[ieeeAddr].payload);
|
||||
this.debouncers[ieeeAddr].payload = {};
|
||||
}, time * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
this.debouncers[ieeeAddr].payload = {...this.debouncers[ieeeAddr].payload, ...payload};
|
||||
this.debouncers[ieeeAddr].publish();
|
||||
}
|
||||
|
||||
onZigbeeMessage(message, device, mappedDevice) {
|
||||
if (message.type == 'devInterview') {
|
||||
if (!settings.getDevice(message.data)) {
|
||||
logger.info('Connecting with device...');
|
||||
this.mqtt.log('pairing', 'connecting with device');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type == 'devIncoming') {
|
||||
logger.info('Device incoming...');
|
||||
this.mqtt.log('pairing', 'device incoming');
|
||||
}
|
||||
|
||||
if (!device) {
|
||||
logger.warn('Message without device!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.ieeeAddr === this.coordinator) {
|
||||
logger.debug('Ignoring message from coordinator');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a new device.
|
||||
const settingsDevice = settings.getDevice(device.ieeeAddr);
|
||||
if (!settingsDevice) {
|
||||
logger.info(`New device '${device.modelId}' with address ${device.ieeeAddr} connected!`);
|
||||
settings.addDevice(device.ieeeAddr);
|
||||
this.mqtt.log('device_connected', device.ieeeAddr, {modelID: device.modelId});
|
||||
}
|
||||
|
||||
if (!mappedDevice) {
|
||||
logger.warn(`Device with modelID '${device.modelId}' is not supported.`);
|
||||
logger.warn(`Please see: https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html`);
|
||||
return;
|
||||
}
|
||||
|
||||
// After this point we cant handle message withoud cid or cmdId anymore.
|
||||
if (!message.data || (!message.data.cid && !message.data.cmdId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Don't handle re-transmitted Xiaomi messages.
|
||||
* https://github.com/Koenkk/zigbee2mqtt/issues/1238
|
||||
*
|
||||
* Some Xiaomi router devices re-transmit messages from Xiaomi end devices.
|
||||
* The source 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 source address of the end device.
|
||||
* As the same message is also received directly from the end device, it makes no sense
|
||||
* to handle these messages.
|
||||
*/
|
||||
const hasGroupID = message.hasOwnProperty('groupid') && message.groupid != 0;
|
||||
if (utils.isXiaomiDevice(device) && utils.isRouter(device) && hasGroupID) {
|
||||
logger.debug('Skipping re-transmitted Xiaomi message');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find a conveter for this message.
|
||||
const cid = message.data.cid;
|
||||
const cmdId = message.data.cmdId;
|
||||
const converters = mappedDevice.fromZigbee.filter((c) => {
|
||||
if (cid) {
|
||||
// readRsp messages have the same structure as attReport messages.
|
||||
// search for attReport converters on readRsp.
|
||||
if (c.cid === cid) {
|
||||
if (c.type instanceof Array) {
|
||||
return c.type.includes(message.type);
|
||||
} else {
|
||||
return c.type === message.type;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} else if (cmdId) {
|
||||
return c.cmd === cmdId;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Check if there is an available converter
|
||||
if (!converters.length) {
|
||||
if (cid) {
|
||||
// Don't log readRsp messages, they are not interesting most of the time.
|
||||
if (message.type === 'readRsp') {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`No converter available for '${mappedDevice.model}' with cid '${cid}', ` +
|
||||
`type '${message.type}' and data '${JSON.stringify(message.data)}'`
|
||||
);
|
||||
} else if (cmdId) {
|
||||
logger.warn(
|
||||
`No converter available for '${mappedDevice.model}' with cmd '${cmdId}' ` +
|
||||
`and data '${JSON.stringify(message.data)}'`
|
||||
);
|
||||
}
|
||||
|
||||
logger.warn(`Please see: https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html.`);
|
||||
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 (message.hasOwnProperty('linkquality')) {
|
||||
payload.linkquality = message.linkquality;
|
||||
}
|
||||
|
||||
// Add last seen timestamp
|
||||
const now = Date.now();
|
||||
if (settings.get().advanced.last_seen !== 'disable') {
|
||||
payload.last_seen = utils.formatDate(now, settings.get().advanced.last_seen);
|
||||
}
|
||||
|
||||
if (settings.get().advanced.elapsed) {
|
||||
if (this.elapsed[device.ieeeAddr]) {
|
||||
payload.elapsed = now - this.elapsed[device.ieeeAddr];
|
||||
}
|
||||
|
||||
this.elapsed[device.ieeeAddr] = now;
|
||||
}
|
||||
|
||||
// Check if we have to debounce
|
||||
if (settingsDevice && settingsDevice.hasOwnProperty('debounce')) {
|
||||
this.publishDebounce(device.ieeeAddr, payload, settingsDevice.debounce);
|
||||
} else {
|
||||
this.publishEntityState(device.ieeeAddr, payload);
|
||||
|
||||
if (settings.get().homeassistant) {
|
||||
/**
|
||||
* Publish an empty value for click and action payload, in this way Home Assistant
|
||||
* can use Home Assistant entities in automations.
|
||||
* https://github.com/Koenkk/zigbee2mqtt/issues/959#issuecomment-480341347
|
||||
*/
|
||||
Object.keys(payload).forEach((key) => {
|
||||
if (['action', 'click'].includes(key)) {
|
||||
const counterPayload = {};
|
||||
counterPayload[key] = '';
|
||||
this.publishEntityState(device.ieeeAddr, counterPayload);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let payload = {};
|
||||
converters.forEach((converter) => {
|
||||
const options = {...settings.get().device_options, ...settings.getDevice(device.ieeeAddr)};
|
||||
const converted = converter.convert(mappedDevice, message, publish, options);
|
||||
|
||||
if (converted) {
|
||||
payload = {...payload, ...converted};
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(payload).length) {
|
||||
publish(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DeviceReceive;
|
||||
@@ -0,0 +1,120 @@
|
||||
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
|
||||
const logger = require('../util/logger');
|
||||
const CC2530Router = zigbeeShepherdConverters.devices.find((d) => d.model === 'CC2530.ROUTER');
|
||||
const utils = require('../util/utils');
|
||||
|
||||
const candidates = {
|
||||
'genOnOff': {
|
||||
attrs: ['onOff'],
|
||||
reportIntervalMin: 3,
|
||||
reportIntervalMax: 300,
|
||||
reportableChange: 0,
|
||||
},
|
||||
'genLevelCtrl': {
|
||||
attrs: ['currentLevel'],
|
||||
},
|
||||
'lightingColorCtrl': {
|
||||
attrs: ['colorTemperature', 'currentX', 'currentY'],
|
||||
},
|
||||
'closuresWindowCovering': {
|
||||
attrs: ['currentPositionLiftPercentage', 'currentPositionTiltPercentage'],
|
||||
reportIntervalMin: 2,
|
||||
reportIntervalMax: 300,
|
||||
},
|
||||
};
|
||||
|
||||
const reportInterval = {
|
||||
min: 3,
|
||||
max: 3600,
|
||||
};
|
||||
|
||||
const reportableChange = 1;
|
||||
|
||||
class DeviceReport {
|
||||
constructor(zigbee, mqtt, state, publishEntityState) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.state = state;
|
||||
this.publishEntityState = publishEntityState;
|
||||
}
|
||||
|
||||
setupReporting(mappedDevice, device) {
|
||||
let epId = null;
|
||||
|
||||
if (mappedDevice === CC2530Router) {
|
||||
logger.debug('Not setting up reporting for CC2530 router');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this device uses a different epId.
|
||||
if (mappedDevice.hasOwnProperty('ep')) {
|
||||
const eps = mappedDevice.ep(device);
|
||||
epId = eps[''] || null;
|
||||
}
|
||||
|
||||
const endpoint = this.zigbee.getEndpoint(device.ieeeAddr, epId);
|
||||
|
||||
if (!endpoint) {
|
||||
logger.error(`Failed to setup reporting for ${device.ieeeAddr}, endpoint not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Setting up reporting for ${device.ieeeAddr}`);
|
||||
Object.values(endpoint.clusters).filter((c) => c).forEach((c) => {
|
||||
const cluster = c.attrs.cid;
|
||||
if (candidates[cluster]) {
|
||||
const candidate = candidates[cluster];
|
||||
let attributeNames = candidate.attrs.filter((a) => c.attrs.hasOwnProperty(a));
|
||||
|
||||
// Sometimes a cluster has no attributes, in this case setup reporting for all attributes.
|
||||
attributeNames = attributeNames.length ? attributeNames : candidate.attrs;
|
||||
|
||||
const attributes = attributeNames.map((attribute) => {
|
||||
return {
|
||||
attr: attribute,
|
||||
min: candidate.hasOwnProperty('reportIntervalMin')?
|
||||
candidate.reportIntervalMin:reportInterval.min,
|
||||
max: candidate.hasOwnProperty('reportIntervalMax')?
|
||||
candidate.reportIntervalMax:reportInterval.max,
|
||||
change: candidate.hasOwnProperty('reportableChange')?
|
||||
candidate.reportableChange:reportableChange,
|
||||
};
|
||||
});
|
||||
|
||||
if (attributes.length > 0) {
|
||||
this.zigbee.report(endpoint, cluster, attributes);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
shouldSetupReporting(device) {
|
||||
return utils.isRouter(device) && !utils.isBatteryPowered(device);
|
||||
}
|
||||
|
||||
onZigbeeStarted() {
|
||||
this.zigbee.getAllClients()
|
||||
.filter((d) => this.shouldSetupReporting(d))
|
||||
.forEach((device) => {
|
||||
const mappedDevice = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
|
||||
|
||||
if (mappedDevice) {
|
||||
this.setupReporting(mappedDevice, device);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onZigbeeMessage(message, device, mappedDevice) {
|
||||
// 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 (device && mappedDevice && ['endDeviceAnnce', 'devIncoming'].includes(message.type) &&
|
||||
this.shouldSetupReporting(device)) {
|
||||
this.setupReporting(mappedDevice, device);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DeviceReport;
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* This extensions is for documentation purposes only.
|
||||
* It describes all methods that are called by the controller.
|
||||
*/
|
||||
class ExtensionTemplate {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
constructor(zigbee, mqtt, state, publishEntityState) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.state = state;
|
||||
this.publishEntityState = publishEntityState;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {Object?} message The received message (can be null)
|
||||
* @param {Object?} device The device of the message (can be null)
|
||||
* @param {Object?} mappedDevice The mapped device (can be null)
|
||||
*/
|
||||
onZigbeeMessage(message, device, mappedDevice) {}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is called once the extension has to stop
|
||||
*/
|
||||
stop() {}
|
||||
}
|
||||
|
||||
module.exports = ExtensionTemplate;
|
||||
@@ -0,0 +1,241 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const data = require('../util/data');
|
||||
const utils = require('../util/utils');
|
||||
const fs = require('fs');
|
||||
const diff = require('deep-diff');
|
||||
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/.+/(remove|add|remove_all)$`);
|
||||
const topicRegexRemoveAll = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/(remove|add|remove_all)$`);
|
||||
|
||||
class Groups {
|
||||
constructor(zigbee, mqtt, state, publishEntityState) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.state = state;
|
||||
this.publishEntityState = publishEntityState;
|
||||
this.onStateChange = this.onStateChange.bind(this);
|
||||
|
||||
this.groupsCacheFile = data.joinPathStorage('groups_cache.json');
|
||||
this.groupsCache = this.readGroupsCache();
|
||||
}
|
||||
|
||||
readGroupsCache() {
|
||||
return fs.existsSync(this.groupsCacheFile) ? JSON.parse(fs.readFileSync(this.groupsCacheFile, 'utf8')) : {};
|
||||
}
|
||||
|
||||
writeGroupsCache() {
|
||||
fs.writeFileSync(this.groupsCacheFile, JSON.stringify(this.groupsCache), 'utf8');
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/group/+/remove`);
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/group/+/add`);
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/group/+/remove_all`);
|
||||
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/group/remove_all`);
|
||||
}
|
||||
|
||||
apply(from, to) {
|
||||
const sortGroups = (obj) => Object.keys(obj).forEach((key) => obj[key] = obj[key].sort());
|
||||
|
||||
sortGroups(from);
|
||||
sortGroups(to);
|
||||
|
||||
const differences = diff(from, to);
|
||||
if (differences) {
|
||||
differences.forEach((diff) => {
|
||||
const groupID = diff.path[0];
|
||||
|
||||
if (diff.kind === 'N') {
|
||||
diff.rhs.forEach((ID) => this.updateDeviceGroup(ID, 'add', groupID));
|
||||
} else if (diff.kind === 'A') {
|
||||
if (diff.item.lhs) {
|
||||
this.updateDeviceGroup(diff.item.lhs, 'remove', groupID);
|
||||
} else {
|
||||
this.updateDeviceGroup(diff.item.rhs, 'add', groupID);
|
||||
}
|
||||
} else if (diff.kind === 'D') {
|
||||
diff.lhs.forEach((ID) => this.updateDeviceGroup(ID, 'remove', groupID));
|
||||
} else if (diff.kind === 'E') {
|
||||
this.updateDeviceGroup(diff.rhs, 'add', groupID);
|
||||
this.updateDeviceGroup(diff.lhs, 'remove', groupID);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
parseID(ID) {
|
||||
let entityID = ID;
|
||||
let endpointID = null;
|
||||
const postfix = utils.getPostfixes().find((p) => entityID.endsWith(`/${p}`));
|
||||
if (postfix) {
|
||||
// Found a postfix, retrieve the endpoint which correspodns to the postfix
|
||||
entityID = entityID.substring(0, entityID.length - (postfix.length + 1));
|
||||
const endpoint = utils.getEndpointByEntityID(this.zigbee, entityID, postfix);
|
||||
|
||||
if (!endpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
endpointID = endpoint.epId;
|
||||
}
|
||||
|
||||
return {endpointID, entityID};
|
||||
}
|
||||
|
||||
getGroupsOfDevice(entityID) {
|
||||
return Object.keys(settings.getGroups()).filter((groupID) => {
|
||||
return settings.getGroup(groupID).devices.includes(entityID);
|
||||
});
|
||||
}
|
||||
|
||||
onStateChange(ieeeAddr, from, to) {
|
||||
const properties = ['state', 'brightness', 'color_temp', 'color'];
|
||||
const payload = {};
|
||||
|
||||
properties.forEach((prop) => {
|
||||
if (to.hasOwnProperty(prop) && (!from || from[prop] != to[prop])) {
|
||||
payload[prop] = to[prop];
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(payload)) {
|
||||
const groups = this.getGroupsOfDevice(ieeeAddr);
|
||||
groups.forEach((groupID) => {
|
||||
this.publishEntityState(groupID, payload);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onZigbeeStarted() {
|
||||
this.state.registerOnStateChangeListener(this.onStateChange);
|
||||
|
||||
const settingsGroups = {};
|
||||
Object.keys(settings.getGroups()).forEach((groupID) => {
|
||||
settingsGroups[groupID] = settings.getGroup(groupID).devices;
|
||||
});
|
||||
|
||||
this.apply(this.groupsCache, settingsGroups);
|
||||
}
|
||||
|
||||
parseTopic(topic) {
|
||||
if (!topic.match(topicRegex) && !topic.match(topicRegexRemoveAll)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove base from topic
|
||||
topic = topic.replace(`${settings.get().mqtt.base_topic}/bridge/group/`, '');
|
||||
|
||||
// Parse type from topic
|
||||
const type = topic.substr(topic.lastIndexOf('/') + 1, topic.length);
|
||||
|
||||
// Remove type from topic
|
||||
topic = topic.replace(`/${type}`, '');
|
||||
|
||||
return {friendly_name: type === 'remove_all' ? null : topic, type};
|
||||
}
|
||||
|
||||
updateDeviceGroup(ID, cmd, groupID) {
|
||||
let payload = null;
|
||||
if (cmd === 'add') {
|
||||
payload = {groupid: groupID, groupname: ''};
|
||||
cmd = 'add';
|
||||
} else if (cmd === 'remove') {
|
||||
payload = {groupid: groupID};
|
||||
cmd = 'remove';
|
||||
} else if (cmd === 'remove_all') {
|
||||
payload = {};
|
||||
cmd = 'removeAll';
|
||||
}
|
||||
|
||||
const {entityID, endpointID} = this.parseID(ID);
|
||||
const ieeeAddr = settings.resolveEntity(entityID).ID;
|
||||
|
||||
const cb = (error, rsp) => {
|
||||
if (error) {
|
||||
logger.error(`Failed to ${cmd} ${ieeeAddr} from ${groupID}`);
|
||||
} else {
|
||||
logger.info(`Successfully ${cmd} ${ieeeAddr} to ${groupID}`);
|
||||
|
||||
// Log to MQTT
|
||||
const lookup = {
|
||||
'add': 'device_group_add',
|
||||
'remove': 'device_group_remove',
|
||||
'removeAll': 'device_group_remove_all',
|
||||
};
|
||||
|
||||
this.mqtt.log({
|
||||
type: lookup[cmd],
|
||||
message: {
|
||||
device: settings.getDevice(ieeeAddr).friendly_name,
|
||||
group: groupID,
|
||||
},
|
||||
});
|
||||
|
||||
// Update group cache
|
||||
if (cmd === 'add') {
|
||||
if (!this.groupsCache[groupID]) {
|
||||
this.groupsCache[groupID] = [];
|
||||
}
|
||||
|
||||
if (!this.groupsCache[groupID].includes(ieeeAddr)) {
|
||||
this.groupsCache[groupID].push(ieeeAddr);
|
||||
}
|
||||
} else if (cmd === 'remove') {
|
||||
if (this.groupsCache[groupID]) {
|
||||
this.groupsCache[groupID] = this.groupsCache[groupID].filter((device) => device != ieeeAddr);
|
||||
}
|
||||
} else if (cmd === 'removeAll') {
|
||||
Object.keys(this.groupsCache).forEach((groupID_) => {
|
||||
this.groupsCache[groupID_] = this.groupsCache[groupID_].filter((device) => device != ieeeAddr);
|
||||
});
|
||||
}
|
||||
|
||||
this.writeGroupsCache();
|
||||
|
||||
// Update settings
|
||||
if (cmd === 'add') {
|
||||
settings.addDeviceToGroup(groupID, ieeeAddr);
|
||||
} else if (cmd === 'remove') {
|
||||
settings.removeDeviceFromGroup(groupID, ieeeAddr);
|
||||
} else if (cmd === 'removeAll') {
|
||||
Object.keys(settings.get().groups).forEach((groupID_) => {
|
||||
settings.removeDeviceFromGroup(groupID_, ieeeAddr);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.zigbee.publish(
|
||||
ieeeAddr, 'device', 'genGroups', cmd, 'functional',
|
||||
payload, null, endpointID, cb,
|
||||
);
|
||||
}
|
||||
|
||||
onMQTTMessage(topic, message) {
|
||||
topic = this.parseTopic(topic);
|
||||
|
||||
if (!topic) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find ID of this group.
|
||||
let groupID = null;
|
||||
if (topic.type !== 'remove_all') {
|
||||
groupID = settings.getGroupIDByFriendlyName(topic.friendly_name);
|
||||
if (!groupID) {
|
||||
logger.error(`Group with friendly_name '${topic.friendly_name}' doesn't exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
groupID = groupID.toString();
|
||||
}
|
||||
|
||||
// Send command to the device.
|
||||
this.updateDeviceGroup(message.toString(), topic.type, groupID);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Groups;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
const utils = require('../util/utils');
|
||||
const interval = utils.secondsToMilliseconds(1);
|
||||
const logger = require('../util/logger');
|
||||
|
||||
const foundationCfg = {manufSpec: 0, disDefaultRsp: 0};
|
||||
|
||||
/**
|
||||
* Extension required for Livolo device support.
|
||||
*/
|
||||
class Livolo {
|
||||
constructor(zigbee, mqtt, state, publishEntityState) {
|
||||
this.zigbee = zigbee;
|
||||
this.timer = null;
|
||||
this.configured = {};
|
||||
}
|
||||
|
||||
onZigbeeStarted() {
|
||||
this.startTimer();
|
||||
}
|
||||
|
||||
_resetDeviceState(ieeeAddr) {
|
||||
this.configured[ieeeAddr] = {
|
||||
stage: 0,
|
||||
retry: 0,
|
||||
waitresp: false,
|
||||
};
|
||||
}
|
||||
|
||||
onZigbeeMessage(message, device, mappedDevice) {
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((message.type == 'devInterview') ||
|
||||
(message.type == 'devIncoming') ||
|
||||
(message.type == 'endDeviceAnnce') ||
|
||||
(message.type == 'devStatus')) {
|
||||
if (this.configured.hasOwnProperty(device.ieeeAddr)) {
|
||||
logger.info(`LIVOLO ${device.ieeeAddr}. (Re)joins in the network (after power off?)`);
|
||||
this._resetDeviceState(device.ieeeAddr);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
startTimer() {
|
||||
this.clearTimer();
|
||||
this.timer = setInterval(() => this.handleInterval(), interval);
|
||||
}
|
||||
|
||||
clearTimer() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
_handleCommandRespSimple(err, rsp) {
|
||||
this.ext.configured[this.ieeeAddr].waitresp = false;
|
||||
|
||||
if (err) {
|
||||
if (this.ctype === 'toggle') {
|
||||
logger.debug(`LIVOLO ${this.ieeeAddr}. Toggle command error:`, err.message);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`LIVOLO ${this.ieeeAddr}. Sucessfully configured`, rsp);
|
||||
this.ext.configured[this.ieeeAddr].stage = 1; // sucessfully send command
|
||||
this.ext.configured[this.ieeeAddr].retry = 0;
|
||||
this.device.status = 'online';
|
||||
}
|
||||
}
|
||||
|
||||
_handleCommandRespWithData(err, rsp) {
|
||||
this.ext.configured[this.ieeeAddr].waitresp = false;
|
||||
|
||||
if (err) {
|
||||
logger.info(`LIVOLO ${this.ieeeAddr}. ${this.cid}.${this.ctype} response error:`, err.message);
|
||||
if (this.ext.configured[this.ieeeAddr].retry >= 3) {
|
||||
// errors in three sequental reads, stop polling, wait for a device message
|
||||
this.device.status = 'offline';
|
||||
logger.info(`LIVOLO ${this.ieeeAddr}. Stopped polling after 3 unsuccessful attempts`);
|
||||
}
|
||||
} else {
|
||||
this.ext.configured[this.ieeeAddr].retry = 0;
|
||||
this.device.status = 'online';
|
||||
if (this.ext.zigbee) {
|
||||
this.ext.zigbee.shepherd.emit('ind:reported', this.ep, this.cid, rsp, this.ep.last_af_msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// msg: { groupid, clusterid, srcaddr, srcendpoint, dstendpoint, wasbroadcast,
|
||||
// linkquality, securityuse, timestamp, transseqnumber, len, data }
|
||||
_handleAfMessage(msg, ep) {
|
||||
ep.linkquality = msg.linkquality;
|
||||
ep.last_af_msg = msg;
|
||||
}
|
||||
|
||||
_sendToggle(zdev, ieeeAddr, ep, retry) {
|
||||
this.zigbee.queue.push(ieeeAddr, (queueCallback) => {
|
||||
const cfg = {};
|
||||
logger.debug(`LIVOLO ${ieeeAddr}. Sending the 'toggle' command. Retry: ${retry}`);
|
||||
ep.functional('genOnOff', 'toggle', [cfg], foundationCfg,
|
||||
this._handleCommandRespSimple.bind({
|
||||
device: zdev,
|
||||
ieeeAddr,
|
||||
cid: 'genOnOff',
|
||||
ctype: 'toggle',
|
||||
ext: this,
|
||||
}));
|
||||
|
||||
queueCallback();
|
||||
});
|
||||
}
|
||||
|
||||
_sendPoll(zdev, ieeeAddr, ep, retry) {
|
||||
this.zigbee.queue.push(ieeeAddr, (queueCallback) => {
|
||||
ep.foundation('genOnOff', 'read', [{
|
||||
attrId: 0, // onOff
|
||||
}], this._handleCommandRespWithData.bind({
|
||||
device: zdev,
|
||||
ieeeAddr,
|
||||
ep,
|
||||
cid: 'genOnOff',
|
||||
ctype: 'read',
|
||||
ext: this,
|
||||
}));
|
||||
|
||||
queueCallback();
|
||||
});
|
||||
}
|
||||
|
||||
handleInterval() {
|
||||
this.zigbee.getAllClients()
|
||||
.filter((d) => d.manufName && d.manufName.startsWith('LIVOLO')) // LIVOLO
|
||||
.filter((d) => d.type === 'EndDevice') // Filter end devices
|
||||
.filter((d) => d.powerSource && d.powerSource !== 'Battery') // Remove battery powered devices
|
||||
.forEach((d) => {
|
||||
const zdev = this.zigbee.shepherd._findDevByAddr(d.ieeeAddr);
|
||||
if (zdev && zdev.endpoints) {
|
||||
const eplist = Object.keys(zdev.endpoints).filter((epId) => {
|
||||
const ep2 = zdev.getEndpoint(epId);
|
||||
const clist = ep2.getClusterList();
|
||||
return clist && clist.includes(6); // 6 - genOnOff
|
||||
});
|
||||
|
||||
if (eplist.length > 0) {
|
||||
const ep = zdev.getEndpoint(eplist[0]);
|
||||
|
||||
ep.onAfIncomingMsg = this._handleAfMessage;
|
||||
|
||||
if (!this.configured.hasOwnProperty(d.ieeeAddr)) {
|
||||
this._resetDeviceState(d.ieeeAddr);
|
||||
}
|
||||
|
||||
const state = this.configured[d.ieeeAddr];
|
||||
if (state.waitresp) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.retry < 3) {
|
||||
if (state.stage === 0) {
|
||||
state.retry += 1;
|
||||
state.waitresp = true;
|
||||
this._sendToggle(zdev, d.ieeeAddr, ep, state.retry);
|
||||
} else if (state.stage === 1) {
|
||||
state.retry += 1;
|
||||
state.waitresp = true;
|
||||
this._sendPoll(zdev, d.ieeeAddr, ep, state.retry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Livolo;
|
||||
+109
-15
@@ -1,15 +1,21 @@
|
||||
|
||||
const settings = require('../util/settings');
|
||||
const utils = require('../util/utils');
|
||||
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
|
||||
|
||||
class NetworkMap {
|
||||
constructor(zigbee, mqtt, state) {
|
||||
constructor(zigbee, mqtt, state, publishEntityState) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.state = state;
|
||||
this.lastSeenMap = {};
|
||||
|
||||
// Subscribe to topic.
|
||||
this.topic = `${settings.get().mqtt.base_topic}/bridge/networkmap`;
|
||||
this.mqtt.subscribe(this.topic);
|
||||
this.topicRoutes = `${settings.get().mqtt.base_topic}/bridge/networkmap/routes`;
|
||||
|
||||
// Bind
|
||||
this.raw = this.raw.bind(this);
|
||||
this.graphviz = this.graphviz.bind(this);
|
||||
|
||||
// Set supported formats
|
||||
this.supportedFormats = {
|
||||
@@ -18,33 +24,121 @@ class NetworkMap {
|
||||
};
|
||||
}
|
||||
|
||||
handleMQTTMessage(topic, message) {
|
||||
message = message.toString();
|
||||
onZigbeeMessage(message, device, mappedDevice) {
|
||||
if (device) {
|
||||
this.lastSeenMap[device.ieeeAddr] = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
if (topic === this.topic && this.supportedFormats.hasOwnProperty(message)) {
|
||||
this.zigbee.networkScan((result)=> {
|
||||
const converted = this.supportedFormats[message](result);
|
||||
onMQTTConnected() {
|
||||
this.mqtt.subscribe(this.topic);
|
||||
this.mqtt.subscribe(this.topicRoutes);
|
||||
}
|
||||
|
||||
onMQTTMessage(topic, message) {
|
||||
message = message.toString();
|
||||
const includeRoutes = (topic === this.topicRoutes) ? 1: 0;
|
||||
if ((topic === this.topic || topic === this.topicRoutes) &&
|
||||
this.supportedFormats.hasOwnProperty(message)) {
|
||||
this.zigbee.networkScan(includeRoutes, (result)=> {
|
||||
const converted = this.supportedFormats[message](this.zigbee, result);
|
||||
this.mqtt.publish(`bridge/networkmap/${message}`, converted, {});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
raw(topology) {
|
||||
raw(zigbee, topology) {
|
||||
return JSON.stringify(topology);
|
||||
}
|
||||
|
||||
graphviz(topology) {
|
||||
let text = 'digraph G {\n';
|
||||
topology.forEach((item) => {
|
||||
text += ` "${item.ieeeAddr}" [label="${item.ieeeAddr} (${item.status})"];\n`;
|
||||
text += ` "${item.ieeeAddr}" -> "${item.parent}" [label="${item.lqi}"]\n`;
|
||||
graphviz(zigbee, topology) {
|
||||
const colors = settings.get().map_options.graphviz.colors;
|
||||
|
||||
let text = 'digraph G {\nnode[shape=record];\n';
|
||||
let devStyle = '';
|
||||
|
||||
topology.nodes.forEach((device) => {
|
||||
const labels = [];
|
||||
|
||||
// Add friendly name
|
||||
labels.push(`${device.friendlyName}`);
|
||||
|
||||
// Add the device short network address and scan note (if any)
|
||||
let scanNote = '';
|
||||
if (device.scanfailed.includes('lqi')) {
|
||||
scanNote += ' no lqi';
|
||||
}
|
||||
if (device.scanfailed.includes('rtg')) {
|
||||
scanNote += ' no routes';
|
||||
}
|
||||
labels.push(`0x${device.nwkAddr.toString(16)} ${scanNote}`);
|
||||
|
||||
// Add the device model
|
||||
const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
|
||||
if (mappedModel) {
|
||||
labels.push(`${mappedModel.vendor} ${mappedModel.description} (${mappedModel.model})`);
|
||||
} else {
|
||||
// This model is not supported by zigbee-shepherd-converters, add zigbee model information, if available
|
||||
const zigbeeModel = [device.manufName, device.modelId].filter((a) => a).join(' ');
|
||||
labels.push(zigbeeModel ? zigbeeModel : 'No model information available');
|
||||
}
|
||||
|
||||
// Add the device status (online/offline) and last_seen timestamp
|
||||
let lastSeen = 'unknown';
|
||||
if (device.type == 'Coordinator') {
|
||||
lastSeen = utils.formatDate(Date.now(), settings.get().advanced.last_seen,
|
||||
utils.toLocalISOString(new Date(Date.now())));
|
||||
} else {
|
||||
if (this.lastSeenMap[device.ieeeAddr]) {
|
||||
const lastSeenAgo =
|
||||
`${new Date(Date.now() - this.lastSeenMap[device.ieeeAddr]).toISOString().substr(11, 8)}s ago`;
|
||||
lastSeen = utils.formatDate(this.lastSeenMap[device.ieeeAddr],
|
||||
settings.get().advanced.last_seen, lastSeenAgo);
|
||||
}
|
||||
}
|
||||
labels.push(`${device.status} (${lastSeen})`);
|
||||
|
||||
// Shape the record according to device type
|
||||
if (device.type == 'Coordinator') {
|
||||
devStyle = `style="bold, filled", fillcolor="${colors.fill.coordinator}", ` +
|
||||
`fontcolor="${colors.font.coordinator}"`;
|
||||
} else if (device.type == 'Router') {
|
||||
devStyle = `style="rounded, filled", fillcolor="${colors.fill.router}", ` +
|
||||
`fontcolor="${colors.font.router}"`;
|
||||
} else {
|
||||
devStyle = `style="rounded, dashed, filled", fillcolor="${colors.fill.enddevice}", ` +
|
||||
`fontcolor="${colors.font.enddevice}"`;
|
||||
}
|
||||
|
||||
// Add the device with its labels to the graph as a node.
|
||||
text += ` "${device.ieeeAddr}" [`+devStyle+`, label="{${labels.join('|')}}"];\n`;
|
||||
|
||||
/**
|
||||
* Add an edge between the device and its child to the graph
|
||||
* NOTE: There are situations where a device is NOT in the topology, this can be e.g.
|
||||
* due to not responded to the lqi scan. In that case we do not add an edge for this device.
|
||||
*/
|
||||
topology.links.filter((e) => (e.sourceIeeeAddr === device.ieeeAddr) || (e.SourceNwkAddr === device.nwkAddr))
|
||||
.forEach((e) => {
|
||||
const lineStyle = (device.type=='EndDevice') ? 'penwidth=1, ' : (!e.routes.length) ?
|
||||
'penwidth=0.5, ' : 'penwidth=2, ';
|
||||
const lineWeight = (!e.routes.length) ? `weight=0, color="${colors.line.inactive}", ` :
|
||||
`weight=1, color="${colors.line.active}", `;
|
||||
const textRoutes = e.routes.map((r) => `0x${r.toString(16)}`);
|
||||
const lineLabels = (!e.routes.length) ? `label="${e.lqi}"` :
|
||||
`label="${e.lqi}\\n[${textRoutes.join(']\\n[')}]"`;
|
||||
text += ` "${device.ieeeAddr}" -> "${e.targetIeeeAddr}"`;
|
||||
text += ` [${lineStyle}${lineWeight}${lineLabels}]\n`;
|
||||
});
|
||||
});
|
||||
|
||||
text += '}';
|
||||
|
||||
return text;
|
||||
return text.replace(/\0/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
const Zcl = require('zigbee-herdsman/dist/zcl');
|
||||
|
||||
const OneJanuary2000 = new Date('January 01, 2000 00:00:00').getTime();
|
||||
|
||||
/**
|
||||
* This extensions is reponsible for respondig to device requests.
|
||||
*/
|
||||
class ExtensionResponder {
|
||||
constructor(zigbee, mqtt, state, publishEntityState) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.state = state;
|
||||
this.publishEntityState = publishEntityState;
|
||||
|
||||
this.onZclFoundation = this.onZclFoundation.bind(this);
|
||||
this.configured = [];
|
||||
}
|
||||
|
||||
setupOnZclFoundation(device) {
|
||||
if (device && device.epList && !this.configured.includes(device.ieeeAddr)) {
|
||||
this.configured.push(device.ieeeAddr);
|
||||
|
||||
device.epList.forEach((epID) => {
|
||||
const ep = this.zigbee.getEndpoint(device.ieeeAddr, epID);
|
||||
ep.onZclFoundation = this.onZclFoundation;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onZigbeeStarted() {
|
||||
this.zigbee.getAllClients().forEach((device) => this.setupOnZclFoundation(device));
|
||||
}
|
||||
|
||||
onZigbeeMessage(message, device, mappedDevice) {
|
||||
this.setupOnZclFoundation(device);
|
||||
}
|
||||
|
||||
readResponse(message, endpoint) {
|
||||
const clusterID = message.clusterid;
|
||||
const cluster = Zcl.getClusterLegacy(clusterID).key;
|
||||
const attributes = message.zclMsg.payload.map((p) => Zcl.getAttributeLegacy(message.clusterid, p.attrId));
|
||||
const response = [];
|
||||
|
||||
attributes.forEach((attribute) => {
|
||||
if (cluster === 'genTime' && attribute.key === 'time') {
|
||||
const time = Math.round(((new Date()).getTime() - OneJanuary2000) / 1000);
|
||||
response.push(this.createReadResponseRec(clusterID, attribute.value, time));
|
||||
}
|
||||
});
|
||||
|
||||
this.zigbee.publish(
|
||||
endpoint.device.ieeeAddr, 'device', cluster, 'readRsp', 'foundation', response,
|
||||
{direction: 1, seqNum: message.zclMsg.seqNum, disDefaultRsp: 1}, endpoint.epId,
|
||||
);
|
||||
}
|
||||
|
||||
createReadResponseRec(cId, attrId, value) {
|
||||
return {
|
||||
attrId: attrId,
|
||||
status: 0,
|
||||
attrData: value,
|
||||
dataType: Zcl.getAttributeTypeLegacy(cId, attrId).value,
|
||||
};
|
||||
}
|
||||
|
||||
onZclFoundation(message, endpoint) {
|
||||
const cmd = message.zclMsg.cmdId;
|
||||
|
||||
if (cmd === 'read') {
|
||||
this.readResponse(message, endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ExtensionResponder;
|
||||
@@ -0,0 +1,63 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const utils = require('../util/utils');
|
||||
|
||||
/**
|
||||
* This extensions soft resets the ZNP after a certain timeout.
|
||||
*/
|
||||
class SoftReset {
|
||||
constructor(zigbee, mqtt, state, publishEntityState) {
|
||||
this.zigbee = zigbee;
|
||||
this.timer = null;
|
||||
this.timeout = utils.secondsToMilliseconds(settings.get().advanced.soft_reset_timeout);
|
||||
}
|
||||
|
||||
onZigbeeStarted() {
|
||||
logger.debug(`Soft reset timeout set to ${utils.millisecondsToSeconds(this.timeout)} seconds`);
|
||||
this.resetTimer();
|
||||
}
|
||||
|
||||
clearTimer() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
resetTimer() {
|
||||
if (this.timeout === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearTimer();
|
||||
this.timer = setTimeout(() => this.handleTimeout(), this.timeout);
|
||||
}
|
||||
|
||||
handleTimeout() {
|
||||
logger.warn('Soft reset timeout triggered');
|
||||
|
||||
this.zigbee.softReset((error) => {
|
||||
if (error) {
|
||||
logger.warn('Soft reset failed, trying stop/start');
|
||||
this.zigbee.stop((error) => {
|
||||
logger.warn('Zigbee stopped');
|
||||
this.zigbee.start((error) => {
|
||||
if (error) {
|
||||
logger.error('Failed to restart!');
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
logger.warn('Soft resetted ZNP due to timeout');
|
||||
}
|
||||
|
||||
this.resetTimer();
|
||||
});
|
||||
}
|
||||
|
||||
onZigbeeMessage(message, device, mappedDevice) {
|
||||
this.resetTimer();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SoftReset;
|
||||
@@ -0,0 +1,70 @@
|
||||
const utils = require('../util/utils');
|
||||
const interval = utils.secondsToMilliseconds(60);
|
||||
const logger = require('../util/logger');
|
||||
const settings = require('../util/settings');
|
||||
|
||||
/**
|
||||
* This extensions handles Xiaomi devices.
|
||||
* - Marks Xiaomi devices as online.
|
||||
* - Polls Xiaomi routers to keep them awake.
|
||||
*/
|
||||
class Xiaomi {
|
||||
constructor(zigbee, mqtt, state, publishEntityState) {
|
||||
this.zigbee = zigbee;
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
onZigbeeStarted() {
|
||||
// Set all Xiaomi devices to be online, so shepherd won't try
|
||||
// to query info from devices (which would fail because they go to sleep).
|
||||
const devices = this.zigbee.getAllClients();
|
||||
devices.forEach((d) => {
|
||||
if (utils.isXiaomiDevice(d)) {
|
||||
const device = this.zigbee.shepherd.find(d.ieeeAddr, 1);
|
||||
if (device) {
|
||||
device.getDevice().update({
|
||||
status: 'online',
|
||||
joinTime: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Device availability already pings Xiaomi devices.
|
||||
*/
|
||||
if (!settings.get().advanced.availability_timeout) {
|
||||
this.startTimer();
|
||||
} else {
|
||||
logger.debug('Disabled pinging of Xiaomi devices in Xiaomi extension because availability is enabled');
|
||||
}
|
||||
}
|
||||
|
||||
startTimer() {
|
||||
this.clearTimer();
|
||||
this.timer = setInterval(() => this.handleInterval(), interval);
|
||||
}
|
||||
|
||||
clearTimer() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
ping(ieeeAddr) {
|
||||
this.zigbee.ping(ieeeAddr, 'error', null, 'basic');
|
||||
}
|
||||
|
||||
handleInterval() {
|
||||
this.zigbee.getAllClients()
|
||||
.filter((d) => utils.isXiaomiDevice(d) && utils.isRouter(d) && !utils.isBatteryPowered(d))
|
||||
.forEach((d) => this.ping(d.ieeeAddr));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Xiaomi;
|
||||
@@ -1,408 +0,0 @@
|
||||
const settings = require('./util/settings');
|
||||
|
||||
const configurations = {
|
||||
// Binary sensor
|
||||
'binary_sensor_occupancy': {
|
||||
type: 'binary_sensor',
|
||||
object_id: 'occupancy',
|
||||
discovery_payload: {
|
||||
payload_on: true,
|
||||
payload_off: false,
|
||||
value_template: '{{ value_json.occupancy }}',
|
||||
device_class: 'motion',
|
||||
json_attributes: ['battery', 'voltage'],
|
||||
},
|
||||
},
|
||||
'binary_sensor_contact': {
|
||||
type: 'binary_sensor',
|
||||
object_id: 'contact',
|
||||
discovery_payload: {
|
||||
payload_on: false,
|
||||
payload_off: true,
|
||||
value_template: '{{ value_json.contact }}',
|
||||
device_class: 'door',
|
||||
json_attributes: ['battery', 'voltage'],
|
||||
},
|
||||
},
|
||||
'binary_sensor_water_leak': {
|
||||
type: 'binary_sensor',
|
||||
object_id: 'water_leak',
|
||||
discovery_payload: {
|
||||
payload_on: true,
|
||||
payload_off: false,
|
||||
value_template: '{{ value_json.water_leak }}',
|
||||
device_class: 'moisture',
|
||||
json_attributes: ['battery', 'voltage'],
|
||||
},
|
||||
},
|
||||
'binary_sensor_smoke': {
|
||||
type: 'binary_sensor',
|
||||
object_id: 'smoke',
|
||||
discovery_payload: {
|
||||
payload_on: true,
|
||||
payload_off: false,
|
||||
value_template: '{{ value_json.smoke }}',
|
||||
device_class: 'smoke',
|
||||
json_attributes: ['battery', 'voltage'],
|
||||
},
|
||||
},
|
||||
'binary_sensor_gas': {
|
||||
type: 'binary_sensor',
|
||||
object_id: 'gas',
|
||||
discovery_payload: {
|
||||
payload_on: true,
|
||||
payload_off: false,
|
||||
value_template: '{{ value_json.gas }}',
|
||||
device_class: 'gas',
|
||||
json_attributes: [],
|
||||
},
|
||||
},
|
||||
'binary_sensor_router': {
|
||||
type: 'binary_sensor',
|
||||
object_id: 'router',
|
||||
discovery_payload: {
|
||||
payload_on: true,
|
||||
payload_off: false,
|
||||
value_template: '{{ value_json.state }}',
|
||||
device_class: 'connectivity',
|
||||
json_attributes: ['description', 'type', 'rssi'],
|
||||
},
|
||||
},
|
||||
|
||||
// Sensor
|
||||
'sensor_illuminance': {
|
||||
type: 'sensor',
|
||||
object_id: 'illuminance',
|
||||
discovery_payload: {
|
||||
unit_of_measurement: 'lx',
|
||||
icon: 'mdi:theme-light-dark',
|
||||
value_template: '{{ value_json.illuminance }}',
|
||||
json_attributes: ['battery', 'voltage'],
|
||||
},
|
||||
},
|
||||
'sensor_humidity': {
|
||||
type: 'sensor',
|
||||
object_id: 'humidity',
|
||||
discovery_payload: {
|
||||
unit_of_measurement: '%',
|
||||
icon: 'mdi:water-percent',
|
||||
value_template: '{{ value_json.humidity }}',
|
||||
json_attributes: ['battery', 'voltage'],
|
||||
},
|
||||
},
|
||||
'sensor_temperature': {
|
||||
type: 'sensor',
|
||||
object_id: 'temperature',
|
||||
discovery_payload: {
|
||||
unit_of_measurement: '°C',
|
||||
icon: 'mdi:temperature-celsius',
|
||||
value_template: '{{ value_json.temperature }}',
|
||||
json_attributes: ['battery', 'voltage'],
|
||||
},
|
||||
},
|
||||
'sensor_pressure': {
|
||||
type: 'sensor',
|
||||
object_id: 'pressure',
|
||||
discovery_payload: {
|
||||
unit_of_measurement: 'Pa',
|
||||
icon: 'mdi:speedometer',
|
||||
value_template: '{{ value_json.pressure }}',
|
||||
json_attributes: ['battery', 'voltage'],
|
||||
},
|
||||
},
|
||||
'sensor_click': {
|
||||
type: 'sensor',
|
||||
object_id: 'click',
|
||||
discovery_payload: {
|
||||
icon: 'mdi:toggle-switch',
|
||||
value_template: '{{ value_json.click }}',
|
||||
json_attributes: ['battery', 'voltage', 'action', 'duration'],
|
||||
force_update: true,
|
||||
},
|
||||
},
|
||||
'sensor_power': {
|
||||
type: 'sensor',
|
||||
object_id: 'power',
|
||||
discovery_payload: {
|
||||
unit_of_measurement: 'Watt',
|
||||
icon: 'mdi:flash',
|
||||
value_template: '{{ value_json.power }}',
|
||||
json_attributes: ['voltage', 'temperature', 'consumption', 'current', 'power_factor'],
|
||||
},
|
||||
},
|
||||
'sensor_action': {
|
||||
type: 'sensor',
|
||||
object_id: 'action',
|
||||
discovery_payload: {
|
||||
icon: 'mdi:gesture-double-tap',
|
||||
value_template: '{{ value_json.action }}',
|
||||
json_attributes: ['battery', 'voltage', 'angle', 'side', 'from_side', 'to_side', 'brightness'],
|
||||
force_update: true,
|
||||
},
|
||||
},
|
||||
'sensor_brightness': {
|
||||
type: 'sensor',
|
||||
object_id: 'brightness',
|
||||
discovery_payload: {
|
||||
unit_of_measurement: 'brightness',
|
||||
icon: 'mdi:brightness-5',
|
||||
value_template: '{{ value_json.brightness }}',
|
||||
json_attributes: [],
|
||||
},
|
||||
},
|
||||
'sensor_lock': {
|
||||
type: 'sensor',
|
||||
object_id: 'lock',
|
||||
discovery_payload: {
|
||||
icon: 'mdi:lock',
|
||||
value_template: '{{ value_json.inserted }}',
|
||||
json_attributes: ['forgotten', 'keyerror'],
|
||||
},
|
||||
},
|
||||
|
||||
// Light
|
||||
'light_brightness_colortemp_xy': {
|
||||
type: 'light',
|
||||
object_id: 'light',
|
||||
discovery_payload: {
|
||||
brightness: true,
|
||||
color_temp: true,
|
||||
xy: true,
|
||||
platform: 'mqtt_json',
|
||||
command_topic: true,
|
||||
},
|
||||
},
|
||||
'light_brightness_xy': {
|
||||
type: 'light',
|
||||
object_id: 'light',
|
||||
discovery_payload: {
|
||||
brightness: true,
|
||||
xy: true,
|
||||
platform: 'mqtt_json',
|
||||
command_topic: true,
|
||||
},
|
||||
},
|
||||
'light_brightness_colortemp': {
|
||||
type: 'light',
|
||||
object_id: 'light',
|
||||
discovery_payload: {
|
||||
brightness: true,
|
||||
color_temp: true,
|
||||
platform: 'mqtt_json',
|
||||
command_topic: true,
|
||||
},
|
||||
},
|
||||
'light_brightness': {
|
||||
type: 'light',
|
||||
object_id: 'light',
|
||||
discovery_payload: {
|
||||
brightness: true,
|
||||
platform: 'mqtt_json',
|
||||
command_topic: true,
|
||||
},
|
||||
},
|
||||
|
||||
// Switch
|
||||
'switch': {
|
||||
type: 'switch',
|
||||
object_id: 'switch',
|
||||
discovery_payload: {
|
||||
payload_off: 'OFF',
|
||||
payload_on: 'ON',
|
||||
value_template: '{{ value_json.state }}',
|
||||
command_topic: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const switchWithPostfix = (postfix) => {
|
||||
return {
|
||||
type: 'switch',
|
||||
object_id: `switch_${postfix}`,
|
||||
discovery_payload: {
|
||||
payload_off: 'OFF',
|
||||
payload_on: 'ON',
|
||||
value_template: `{{ value_json.state_${postfix} }}`,
|
||||
command_topic: true,
|
||||
command_topic_prefix: postfix,
|
||||
json_attributes: [`button_${postfix}`],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Map homeassitant configurations to devices.
|
||||
const mapping = {
|
||||
'WXKG01LM': [configurations.sensor_click],
|
||||
'WXKG11LM': [configurations.sensor_click],
|
||||
'WXKG12LM': [configurations.sensor_click],
|
||||
'WXKG03LM': [configurations.sensor_click],
|
||||
'WXKG02LM': [configurations.sensor_click],
|
||||
'QBKG04LM': [configurations.switch],
|
||||
'QBKG03LM': [switchWithPostfix('left'), switchWithPostfix('right')],
|
||||
'WSDCGQ01LM': [configurations.sensor_temperature, configurations.sensor_humidity],
|
||||
'WSDCGQ11LM': [configurations.sensor_temperature, configurations.sensor_humidity, configurations.sensor_pressure],
|
||||
'RTCGQ01LM': [configurations.binary_sensor_occupancy],
|
||||
'RTCGQ11LM': [configurations.binary_sensor_occupancy, configurations.sensor_illuminance],
|
||||
'MCCGQ01LM': [configurations.binary_sensor_contact],
|
||||
'MCCGQ11LM': [configurations.binary_sensor_contact],
|
||||
'SJCGQ11LM': [configurations.binary_sensor_water_leak],
|
||||
'MFKZQ01LM': [configurations.sensor_action],
|
||||
'ZNCZ02LM': [configurations.switch, configurations.sensor_power],
|
||||
'QBCZ11LM': [configurations.switch, configurations.sensor_power],
|
||||
'LED1545G12': [configurations.light_brightness_colortemp],
|
||||
'LED1623G12': [configurations.light_brightness],
|
||||
'LED1622G12': [configurations.light_brightness],
|
||||
'LED1537R6': [configurations.light_brightness_colortemp],
|
||||
'LED1650R5': [configurations.light_brightness],
|
||||
'LED1536G5': [configurations.light_brightness_colortemp],
|
||||
'7299760PH': [configurations.light_brightness_xy],
|
||||
'7146060PH': [configurations.light_brightness_colortemp_xy],
|
||||
'F7C033': [configurations.light_brightness],
|
||||
'JTYJ-GD-01LM/BW': [configurations.binary_sensor_smoke],
|
||||
'PLUG EDP RE:DY': [configurations.switch, configurations.sensor_power],
|
||||
'CC2530.ROUTER': [configurations.binary_sensor_router],
|
||||
'AA70155': [configurations.light_brightness_colortemp],
|
||||
'AA69697': [configurations.light_brightness_colortemp_xy],
|
||||
'HALIGHTDIMWWE27': [configurations.light_brightness],
|
||||
'AB3257001NJ': [configurations.switch],
|
||||
'8718696449691': [configurations.light_brightness],
|
||||
'RB 185 C': [configurations.light_brightness_colortemp_xy],
|
||||
'9290012573A': [configurations.light_brightness_colortemp_xy],
|
||||
'LED1624G9': [configurations.light_brightness_xy],
|
||||
'73742': [configurations.light_brightness_colortemp],
|
||||
'73740': [configurations.light_brightness_colortemp],
|
||||
'22670': [configurations.light_brightness],
|
||||
'ICTC-G-1': [configurations.sensor_brightness],
|
||||
'ICPSHC24-30EU-IL-1': [configurations.light_brightness],
|
||||
'45852GE': [configurations.light_brightness],
|
||||
'E11-G13': [configurations.light_brightness],
|
||||
'LED1649C5': [configurations.light_brightness],
|
||||
'ICPSHC24-10EU-IL-1': [configurations.light_brightness],
|
||||
'LED1546G12': [configurations.light_brightness_colortemp],
|
||||
'L1527': [configurations.light_brightness_colortemp],
|
||||
'L1529': [configurations.light_brightness_colortemp],
|
||||
'L1528': [configurations.light_brightness_colortemp],
|
||||
'RB 165': [configurations.light_brightness],
|
||||
'RB 175 W': [configurations.light_brightness],
|
||||
'RS 125': [configurations.light_brightness],
|
||||
'RB 145': [configurations.light_brightness],
|
||||
'PL 110': [configurations.light_brightness],
|
||||
'ST 110': [configurations.light_brightness],
|
||||
'UC 110': [configurations.light_brightness],
|
||||
'DL 110 N': [configurations.light_brightness],
|
||||
'DL 110 W': [configurations.light_brightness],
|
||||
'SL 110 N': [configurations.light_brightness],
|
||||
'SL 110 M': [configurations.light_brightness],
|
||||
'SL 110 W': [configurations.light_brightness],
|
||||
'AA68199': [configurations.light_brightness_colortemp],
|
||||
'QBKG11LM': [configurations.switch, configurations.sensor_power],
|
||||
'QBKG12LM': [switchWithPostfix('left'), switchWithPostfix('right'), configurations.sensor_power],
|
||||
'K2RGBW01': [configurations.light_brightness_colortemp_xy],
|
||||
'9290011370': [configurations.light_brightness],
|
||||
'DNCKATSW001': [configurations.switch],
|
||||
'Z809A': [configurations.switch, configurations.sensor_power],
|
||||
'NL08-0800': [configurations.light_brightness],
|
||||
'915005106701': [configurations.light_brightness_colortemp_xy],
|
||||
'AB32840': [configurations.light_brightness_colortemp],
|
||||
'8718696485880': [configurations.light_brightness_colortemp_xy],
|
||||
'8718696598283': [configurations.light_brightness_colortemp],
|
||||
'73693': [configurations.light_brightness_colortemp_xy],
|
||||
'324131092621': [configurations.sensor_action],
|
||||
'9290012607': [
|
||||
configurations.binary_sensor_occupancy, configurations.sensor_temperature,
|
||||
configurations.sensor_illuminance,
|
||||
],
|
||||
'GL-C-008': [configurations.light_brightness_colortemp_xy],
|
||||
'STSS-MULT-001': [configurations.binary_sensor_contact],
|
||||
'E11-G23': [configurations.light_brightness],
|
||||
'AC03845': [configurations.light_brightness_colortemp_xy],
|
||||
'AC03641': [configurations.light_brightness],
|
||||
'FB56+ZSW05HG1.2': [configurations.switch],
|
||||
'72922-A': [configurations.switch],
|
||||
'AC03642': [configurations.light_brightness_colortemp],
|
||||
'DNCKATSW002': [switchWithPostfix('left'), switchWithPostfix('right')],
|
||||
'DNCKATSW003': [switchWithPostfix('left'), switchWithPostfix('right'), switchWithPostfix('center')],
|
||||
'DNCKATSW004': [
|
||||
switchWithPostfix('bottom_left'), switchWithPostfix('bottom_right'),
|
||||
switchWithPostfix('top_left'), switchWithPostfix('top_right'),
|
||||
],
|
||||
'BY 165': [configurations.light_brightness],
|
||||
'ZLED-2709': [configurations.light_brightness],
|
||||
'8718696548738': [configurations.light_brightness_colortemp],
|
||||
'4052899926110': [configurations.light_brightness_colortemp_xy],
|
||||
'Z01-CIA19NAE26': [configurations.light_brightness],
|
||||
'E11-N1EA': [configurations.light_brightness_colortemp_xy],
|
||||
'74283': [configurations.light_brightness],
|
||||
'JTQJ-BF-01LM/BW': [configurations.binary_sensor_gas],
|
||||
'50045': [configurations.light_brightness],
|
||||
'AV2010/22': [configurations.binary_sensor_occupancy],
|
||||
'3210-L': [configurations.switch],
|
||||
'7299355PH': [configurations.light_brightness_colortemp_xy],
|
||||
'A6121': [configurations.sensor_lock],
|
||||
'433714': [configurations.light_brightness],
|
||||
'3261030P7': [configurations.light_brightness_colortemp],
|
||||
};
|
||||
|
||||
// A map of all discoverd devices
|
||||
const discovered = {};
|
||||
|
||||
function discover(deviceID, model, mqtt, force) {
|
||||
// Check if already discoverd and check if there are configs.
|
||||
const discover = force || !discovered[deviceID];
|
||||
if (!discover || !mapping[model] || !settings.getDevice(deviceID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const friendlyName = settings.getDevice(deviceID).friendly_name;
|
||||
|
||||
mapping[model].forEach((config) => {
|
||||
const topic = `${config.type}/${deviceID}/${config.object_id}/config`;
|
||||
const payload = config.discovery_payload;
|
||||
payload.state_topic = `${settings.get().mqtt.base_topic}/${friendlyName}`;
|
||||
payload.availability_topic = `${settings.get().mqtt.base_topic}/bridge/state`;
|
||||
|
||||
// Set unique names in cases this device produces multiple entities in homeassistant.
|
||||
payload.name = mapping[model].length > 1 ? `${friendlyName}_${config.object_id}` : friendlyName;
|
||||
|
||||
// Only set unique_id when user did not set a friendly_name yet,
|
||||
// see https://github.com/Koenkk/zigbee2mqtt/issues/138
|
||||
if (deviceID === friendlyName) {
|
||||
payload.unique_id = `${deviceID}_${config.object_id}_${settings.get().mqtt.base_topic}`;
|
||||
}
|
||||
|
||||
if (payload.command_topic) {
|
||||
payload.command_topic = `${settings.get().mqtt.base_topic}/${friendlyName}/`;
|
||||
|
||||
if (payload.command_topic_prefix) {
|
||||
payload.command_topic += `${payload.command_topic_prefix}/`;
|
||||
}
|
||||
|
||||
payload.command_topic += 'set';
|
||||
}
|
||||
|
||||
mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 0}, null, 'homeassistant');
|
||||
});
|
||||
|
||||
discovered[deviceID] = true;
|
||||
}
|
||||
|
||||
function clear(deviceID, model, mqtt) {
|
||||
// Check if there are configs.
|
||||
if (!mapping[model]) {
|
||||
return;
|
||||
}
|
||||
|
||||
mapping[model].forEach((config) => {
|
||||
const topic = `${config.type}/${deviceID}/${config.object_id}/config`;
|
||||
const payload = '';
|
||||
mqtt.publish(topic, payload, {retain: true, qos: 0}, null, 'homeassistant');
|
||||
});
|
||||
|
||||
discovered[deviceID] = false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mapping: mapping,
|
||||
discover: (deviceID, model, mqtt, force) => discover(deviceID, model, mqtt, force),
|
||||
clear: (deviceID, model, mqtt) => clear(deviceID, model, mqtt),
|
||||
};
|
||||
+48
-22
@@ -1,25 +1,53 @@
|
||||
const mqtt = require('mqtt');
|
||||
const logger = require('./util/logger');
|
||||
const settings = require('./util/settings');
|
||||
const fs = require('fs');
|
||||
|
||||
class MQTT {
|
||||
constructor() {
|
||||
this.handleConnect = this.handleConnect.bind(this);
|
||||
this.handleMessage = this.handleMessage.bind(this);
|
||||
this.onConnect = this.onConnect.bind(this);
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.messageHandler = null;
|
||||
}
|
||||
|
||||
connect(onMessage, subscriptions, callback) {
|
||||
connect(messageHandler, callback) {
|
||||
const mqttSettings = settings.get().mqtt;
|
||||
logger.info(`Connecting to MQTT server at ${mqttSettings.server}`);
|
||||
|
||||
const options = {
|
||||
will: {
|
||||
topic: this.prefixTopic('bridge/state'),
|
||||
topic: `${settings.get().mqtt.base_topic}/bridge/state`,
|
||||
payload: 'offline',
|
||||
retain: true,
|
||||
},
|
||||
};
|
||||
|
||||
if (mqttSettings.ca) {
|
||||
logger.debug(`MQTT SSL/TLS: Path to CA certificate = ${mqttSettings.ca}`);
|
||||
const ca = fs.readFileSync(mqttSettings.ca);
|
||||
|
||||
if (ca) {
|
||||
options.ca = ca;
|
||||
} else {
|
||||
logger.error(`Error loading CA certificate for MQTT SSL/TLS configuration.`);
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
const key = fs.readFileSync(mqttSettings.key);
|
||||
const cert = fs.readFileSync(mqttSettings.cert);
|
||||
|
||||
if (key && cert) {
|
||||
options.key = key;
|
||||
options.cert = cert;
|
||||
} else {
|
||||
logger.error(`Error loading key and/or certificate for MQTT SSL/TLS client authentication.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (mqttSettings.user && mqttSettings.password) {
|
||||
options.username = mqttSettings.user;
|
||||
options.password = mqttSettings.password;
|
||||
@@ -39,11 +67,11 @@ class MQTT {
|
||||
|
||||
// Register callbacks.
|
||||
this.client.on('connect', () => {
|
||||
this.handleConnect();
|
||||
this.onConnect();
|
||||
callback();
|
||||
});
|
||||
|
||||
this.client.on('message', this.handleMessage);
|
||||
this.client.on('message', this.onMessage);
|
||||
|
||||
// Set timer at interval to check if connected to MQTT server.
|
||||
const interval = 10 * 1000; // seconds * 1000.
|
||||
@@ -53,8 +81,7 @@ class MQTT {
|
||||
}
|
||||
}, interval);
|
||||
|
||||
this.onMessage = onMessage;
|
||||
this.subscriptions = subscriptions;
|
||||
this.messageHandler = messageHandler;
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
@@ -67,24 +94,23 @@ class MQTT {
|
||||
});
|
||||
}
|
||||
|
||||
handleConnect() {
|
||||
onConnect() {
|
||||
logger.info('Connected to MQTT server');
|
||||
this.publish('bridge/state', 'online', {retain: true, qos: 0});
|
||||
this.subscriptions.forEach((topic) => this.subscribe(topic));
|
||||
}
|
||||
|
||||
subscribe(topic) {
|
||||
this.client.subscribe(topic);
|
||||
}
|
||||
|
||||
handleMessage(topic, message) {
|
||||
if (this.onMessage) {
|
||||
this.onMessage(topic, message);
|
||||
onMessage(topic, message) {
|
||||
if (this.messageHandler) {
|
||||
this.messageHandler(topic, message);
|
||||
}
|
||||
}
|
||||
|
||||
publish(topic, payload, options, callback, baseTopic) {
|
||||
topic = this.prefixTopic(topic, baseTopic);
|
||||
publish(topic, payload, options, callback, base=settings.get().mqtt.base_topic) {
|
||||
topic = `${base}/${topic}`;
|
||||
|
||||
if (!this.client || this.client.reconnecting) {
|
||||
logger.error(`Not connected to MQTT server!`);
|
||||
@@ -92,17 +118,17 @@ class MQTT {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`MQTT publish, topic: '${topic}', payload: '${payload}'`);
|
||||
logger.info(`MQTT publish: topic '${topic}', payload '${payload}'`);
|
||||
this.client.publish(topic, payload, options, callback);
|
||||
}
|
||||
|
||||
prefixTopic(topic, baseTopic) {
|
||||
baseTopic = baseTopic ? baseTopic : settings.get().mqtt.base_topic;
|
||||
return `${baseTopic}/${topic}`;
|
||||
}
|
||||
log(type, message, meta=null) {
|
||||
const payload = {type, message};
|
||||
|
||||
if (meta) {
|
||||
payload.meta = meta;
|
||||
}
|
||||
|
||||
log(type, message) {
|
||||
const payload = {type: type, message: message};
|
||||
this.publish('bridge/log', JSON.stringify(payload), {retain: false});
|
||||
}
|
||||
}
|
||||
|
||||
+74
-10
@@ -1,17 +1,71 @@
|
||||
const logger = require('./util/logger');
|
||||
const data = require('./util/data');
|
||||
const fs = require('fs');
|
||||
const settings = require('./util/settings');
|
||||
const objectAssignDeep = require('object-assign-deep');
|
||||
|
||||
const saveInterval = 1000 * 60 * 5; // 5 minutes
|
||||
|
||||
const dontCacheProperties = [
|
||||
'action', 'button', 'button_left', 'button_right', 'click', 'forgotten', 'keyerror',
|
||||
'step_size', 'transition_time', 'action_color_temperature', 'action_color',
|
||||
'action_group', 'group_list', 'group_capacity', 'no_occupancy_since',
|
||||
'step_mode', 'transition_time', 'duration', 'elapsed',
|
||||
];
|
||||
|
||||
class State {
|
||||
constructor() {
|
||||
this.state = {};
|
||||
this.file = data.joinPath('state.json');
|
||||
this.timer = null;
|
||||
this.stateChangeListeners = [];
|
||||
|
||||
this.handleSettingsChanged = this.handleSettingsChanged.bind(this);
|
||||
}
|
||||
|
||||
start() {
|
||||
this._load();
|
||||
|
||||
// Save the state on every interval
|
||||
this.clearTimer();
|
||||
this.timer = setInterval(() => this.save(), saveInterval);
|
||||
|
||||
// Listen for on settings changed events.
|
||||
settings.addOnChangeHandler(this.handleSettingsChanged);
|
||||
|
||||
this.checkLastSeen();
|
||||
}
|
||||
|
||||
handleSettingsChanged() {
|
||||
this.checkLastSeen();
|
||||
}
|
||||
|
||||
registerOnStateChangeListener(listener) {
|
||||
this.stateChangeListeners.push(listener);
|
||||
}
|
||||
|
||||
checkLastSeen() {
|
||||
if (settings.get().advanced.last_seen === 'disable') {
|
||||
Object.values(this.state).forEach((s) => {
|
||||
if (s.hasOwnProperty('last_seen')) {
|
||||
delete s.last_seen;
|
||||
}
|
||||
});
|
||||
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
clearTimer() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.clearTimer();
|
||||
this.save();
|
||||
}
|
||||
|
||||
_load() {
|
||||
@@ -23,7 +77,7 @@ class State {
|
||||
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 exsist)`);
|
||||
logger.debug(`Can't load state from file ${this.file} (doesn't exist)`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,21 +87,31 @@ class State {
|
||||
fs.writeFileSync(this.file, json, 'utf8');
|
||||
}
|
||||
|
||||
exists(ID) {
|
||||
return this.state.hasOwnProperty(ID);
|
||||
exists(ieeeAddr) {
|
||||
return this.state.hasOwnProperty(ieeeAddr);
|
||||
}
|
||||
|
||||
get(ID) {
|
||||
return this.state[ID];
|
||||
get(ieeeAddr) {
|
||||
return this.state[ieeeAddr];
|
||||
}
|
||||
|
||||
set(ID, state) {
|
||||
this.state[ID] = state;
|
||||
set(ieeeAddr, state) {
|
||||
const toState = objectAssignDeep.noMutate(state);
|
||||
dontCacheProperties.forEach((property) => {
|
||||
if (toState.hasOwnProperty(property)) {
|
||||
delete toState[property];
|
||||
}
|
||||
});
|
||||
|
||||
const fromState = this.state[ieeeAddr];
|
||||
this.stateChangeListeners.forEach((listener) => listener(ieeeAddr, fromState, toState));
|
||||
|
||||
this.state[ieeeAddr] = toState;
|
||||
}
|
||||
|
||||
remove(ID) {
|
||||
if (this.exists(ID)) {
|
||||
delete this.state[ID];
|
||||
remove(ieeeAddr) {
|
||||
if (this.exists(ieeeAddr)) {
|
||||
delete this.state[ieeeAddr];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-5
@@ -1,15 +1,33 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
let dataPath = null;
|
||||
|
||||
if (process.env.ZIGBEE2MQTT_DATA) {
|
||||
dataPath = process.env.ZIGBEE2MQTT_DATA;
|
||||
} else {
|
||||
dataPath = path.join(__dirname, '..', '..', 'data');
|
||||
dataPath = path.normalize(dataPath);
|
||||
function load() {
|
||||
if (process.env.ZIGBEE2MQTT_DATA) {
|
||||
dataPath = process.env.ZIGBEE2MQTT_DATA;
|
||||
} else {
|
||||
dataPath = path.join(__dirname, '..', '..', 'data');
|
||||
dataPath = path.normalize(dataPath);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
function joinPathStorage(file) {
|
||||
const storagePath = path.join(dataPath, '.storage');
|
||||
if (!fs.existsSync(storagePath)) {
|
||||
fs.mkdirSync(storagePath);
|
||||
}
|
||||
|
||||
return path.join(storagePath, file);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
joinPath: (file) => path.join(dataPath, file),
|
||||
joinPathStorage: (file) => joinPathStorage(file),
|
||||
getPath: () => dataPath,
|
||||
|
||||
// For test only.
|
||||
_reload: () => load(),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
const yaml = require('js-yaml');
|
||||
const fs = require('fs');
|
||||
|
||||
function readYaml(file) {
|
||||
return yaml.safeLoad(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
|
||||
function readYamlIfExists(file) {
|
||||
return fs.existsSync(file) ? readYaml(file) : null;
|
||||
}
|
||||
|
||||
function writeYaml(file, content) {
|
||||
fs.writeFileSync(file, yaml.safeDump(content));
|
||||
}
|
||||
|
||||
module.exports = {readYaml, readYamlIfExists, writeYaml};
|
||||
+46
-22
@@ -16,47 +16,71 @@ const directory = settings.get().advanced.log_directory.replace('%TIMESTAMP%', t
|
||||
// Make sure that log directoy exsists
|
||||
fx.mkdirSync(directory);
|
||||
|
||||
// Custom level
|
||||
const levels = {
|
||||
levels: {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
debug: 3,
|
||||
trace: 4,
|
||||
},
|
||||
colors: {
|
||||
error: 'red',
|
||||
warn: 'yellow',
|
||||
info: 'green',
|
||||
debug: 'blue',
|
||||
trace: 'magenta',
|
||||
},
|
||||
};
|
||||
|
||||
// Create logger
|
||||
const logger = new (winston.Logger)({
|
||||
const logger = new winston.Logger({
|
||||
levels: levels.levels,
|
||||
transports: [
|
||||
new (winston.transports.File)({
|
||||
new winston.transports.File({
|
||||
filename: path.join(directory, 'log.txt'),
|
||||
json: false,
|
||||
level: level,
|
||||
level,
|
||||
maxFiles: 3, // Keep last 3 files
|
||||
maxsize: 10000000, // 10MB
|
||||
timestamp: () => new Date().toLocaleString(),
|
||||
}),
|
||||
new (winston.transports.Console)({
|
||||
new winston.transports.Console({
|
||||
timestamp: () => new Date().toLocaleString(),
|
||||
formatter: function(options) {
|
||||
return winston.config.colorize(options.level, ' zigbee2mqtt:' + options.level.toLowerCase()) + ' ' +
|
||||
options.timestamp() + ' ' + (options.message ? options.message : '') +
|
||||
(options.meta && Object.keys(options.meta).length ? '\n\t'+ JSON.stringify(options.meta) : '' );
|
||||
},
|
||||
formatter: (options) =>
|
||||
winston.config.colorize(options.level, ' zigbee2mqtt:' + options.level.toLowerCase()) + ' ' +
|
||||
options.timestamp() + ' ' + (options.message ? options.message : '') +
|
||||
(options.meta && Object.keys(options.meta).length ? '\n\t'+ JSON.stringify(options.meta) : '' ),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
logger.info(`Logging to directory: '${directory}'`);
|
||||
// Add colors
|
||||
winston.addColors(levels.colors);
|
||||
|
||||
logger.transports.console.level = level;
|
||||
|
||||
// Cleanup any old log directory.
|
||||
if (settings.get().advanced.log_directory.includes('%TIMESTAMP%')) {
|
||||
const rootDirectory = path.join(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).birthtimeMs};
|
||||
});
|
||||
let directories = fs.readdirSync(rootDirectory).map((d) => {
|
||||
d = path.join(rootDirectory, d);
|
||||
return {path: d, birth: fs.statSync(d).birthtimeMs};
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
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.directory = directory;
|
||||
|
||||
module.exports = logger;
|
||||
|
||||
+369
-25
@@ -1,76 +1,420 @@
|
||||
const yaml = require('js-yaml');
|
||||
const fs = require('fs');
|
||||
const data = require('./data');
|
||||
const file = data.joinPath('configuration.yaml');
|
||||
const objectAssignDeep = require(`object-assign-deep`);
|
||||
const path = require('path');
|
||||
const fs = require('./fs');
|
||||
const onChangeHandlers = [];
|
||||
|
||||
const defaults = {
|
||||
whitelist: [],
|
||||
ban: [],
|
||||
permit_join: false,
|
||||
mqtt: {
|
||||
include_device_information: false,
|
||||
},
|
||||
groups: {},
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
queue: {
|
||||
delay: 250,
|
||||
simultaneously: 5,
|
||||
},
|
||||
experimental: {
|
||||
livolo: false,
|
||||
// json or attribute
|
||||
output: 'json',
|
||||
},
|
||||
advanced: {
|
||||
log_directory: path.join(data.getPath(), 'log', '%TIMESTAMP%'),
|
||||
log_level: process.env.DEBUG ? 'debug' : 'info',
|
||||
soft_reset_timeout: 0,
|
||||
pan_id: 0x1a62,
|
||||
ext_pan_id: [0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD],
|
||||
channel: 11,
|
||||
baudrate: 115200,
|
||||
rtscts: true,
|
||||
|
||||
// Availability timeout in seconds, disabled by default.
|
||||
availability_timeout: 0,
|
||||
availability_blacklist: [],
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
/**
|
||||
* 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',
|
||||
},
|
||||
};
|
||||
|
||||
let settings = read();
|
||||
let _settings;
|
||||
|
||||
function getSettings() {
|
||||
if (!_settings) {
|
||||
_settings = read();
|
||||
}
|
||||
|
||||
if (_settings.hasOwnProperty('advanced') && !_settings.advanced) {
|
||||
delete _settings.advanced;
|
||||
}
|
||||
|
||||
return _settings;
|
||||
}
|
||||
|
||||
function get() {
|
||||
const _settings = objectAssignDeep.noMutate(defaults, getSettings());
|
||||
|
||||
if (!_settings.whitelist) {
|
||||
_settings.whitelist = [];
|
||||
}
|
||||
|
||||
return _settings;
|
||||
}
|
||||
|
||||
function writeRead() {
|
||||
write();
|
||||
settings = read();
|
||||
_settings = read();
|
||||
onChangeHandlers.forEach((handler) => handler());
|
||||
}
|
||||
|
||||
function write() {
|
||||
fs.writeFileSync(file, yaml.safeDump(settings));
|
||||
const settings = getSettings();
|
||||
const toWrite = objectAssignDeep.noMutate(settings);
|
||||
|
||||
// Read settings to check if we have to split devices/groups into separate file.
|
||||
const actual = fs.readYaml(file);
|
||||
if (typeof actual.devices === 'string') {
|
||||
fs.writeYaml(data.joinPath(actual.devices), settings.devices);
|
||||
toWrite.devices = actual.devices;
|
||||
}
|
||||
|
||||
if (typeof actual.groups === 'string') {
|
||||
fs.writeYaml(data.joinPath(actual.groups), settings.groups);
|
||||
toWrite.groups = actual.groups;
|
||||
}
|
||||
|
||||
fs.writeYaml(file, toWrite);
|
||||
}
|
||||
|
||||
function read() {
|
||||
return yaml.safeLoad(fs.readFileSync(file, 'utf8'));
|
||||
const s = fs.readYaml(file);
|
||||
|
||||
// Read devices/groups configuration from separate file.
|
||||
if (typeof s.devices === 'string') {
|
||||
const file = data.joinPath(s.devices);
|
||||
s.devices = fs.readYamlIfExists(file);
|
||||
}
|
||||
|
||||
if (typeof s.groups === 'string') {
|
||||
const file = data.joinPath(s.groups);
|
||||
s.groups = fs.readYamlIfExists(file);
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
function addDevice(id) {
|
||||
function set(path, value) {
|
||||
let obj = getSettings();
|
||||
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const key = path[i];
|
||||
if (i === path.length - 1) {
|
||||
obj[key] = value;
|
||||
} else {
|
||||
if (!obj[key]) {
|
||||
obj[key] = {};
|
||||
}
|
||||
|
||||
obj = obj[key];
|
||||
}
|
||||
}
|
||||
|
||||
writeRead();
|
||||
}
|
||||
|
||||
const getDevices = () => getSettings().devices || [];
|
||||
|
||||
const getDevice = (ieeeAddr) => getDevices()[ieeeAddr];
|
||||
|
||||
const getGroups = () => getSettings().groups || {};
|
||||
|
||||
const getGroup = (ID) => {
|
||||
const group = getGroups()[ID];
|
||||
|
||||
if (group && !group.hasOwnProperty('devices')) {
|
||||
group.devices = [];
|
||||
}
|
||||
|
||||
return group;
|
||||
};
|
||||
|
||||
|
||||
function addDevice(ieeeAddr) {
|
||||
const settings = getSettings();
|
||||
if (!settings.devices) {
|
||||
settings.devices = {};
|
||||
}
|
||||
|
||||
settings.devices[id] = {friendly_name: id, retain: false};
|
||||
settings.devices[ieeeAddr] = {friendly_name: ieeeAddr, retain: false};
|
||||
writeRead();
|
||||
}
|
||||
|
||||
function removeDevice(id) {
|
||||
if (settings.devices && settings.devices[id]) {
|
||||
delete settings.devices[id];
|
||||
function whitelistDevice(ieeeAddr) {
|
||||
const settings = getSettings();
|
||||
if (!settings.whitelist) {
|
||||
settings.whitelist = [];
|
||||
}
|
||||
|
||||
settings.whitelist.push(ieeeAddr);
|
||||
|
||||
writeRead();
|
||||
}
|
||||
|
||||
function banDevice(ieeeAddr) {
|
||||
const settings = getSettings();
|
||||
if (!settings.ban) {
|
||||
settings.ban = [];
|
||||
}
|
||||
|
||||
settings.ban.push(ieeeAddr);
|
||||
|
||||
writeRead();
|
||||
}
|
||||
|
||||
function removeDevice(ieeeAddr) {
|
||||
const settings = getSettings();
|
||||
if (!settings.devices || !settings.devices[ieeeAddr]) return;
|
||||
|
||||
delete settings.devices[ieeeAddr];
|
||||
writeRead();
|
||||
}
|
||||
|
||||
function addGroup(groupName) {
|
||||
const settings = getSettings();
|
||||
if (!settings.groups) {
|
||||
settings.groups = {};
|
||||
}
|
||||
|
||||
let ID = '1';
|
||||
while (settings.groups.hasOwnProperty(ID)) {
|
||||
ID = (Number.parseInt(ID) + 1).toString();
|
||||
}
|
||||
|
||||
if (Object.values(settings.groups).find((g) => g.friendly_name === groupName)) {
|
||||
return false; // Duplicate group names are not allowed
|
||||
}
|
||||
|
||||
settings.groups[ID] = {friendly_name: groupName};
|
||||
writeRead();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function addDeviceToGroup(ID, ieeeAddr) {
|
||||
const settings = getSettings();
|
||||
const group = settings.groups[ID];
|
||||
if (!group.devices) {
|
||||
group.devices = [];
|
||||
}
|
||||
|
||||
if (!group.devices.includes(ieeeAddr)) {
|
||||
group.devices.push(ieeeAddr);
|
||||
writeRead();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getIDByFriendlyName(friendlyName) {
|
||||
if (!settings.devices) {
|
||||
return null;
|
||||
function removeDeviceFromGroup(ID, ieeeAddr) {
|
||||
const settings = getSettings();
|
||||
const group = settings.groups[ID];
|
||||
|
||||
if (group.devices && group.devices.includes(ieeeAddr)) {
|
||||
group.devices = group.devices.filter((d) => d != ieeeAddr);
|
||||
writeRead();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeGroup(name) {
|
||||
const settings = getSettings();
|
||||
if (!settings.groups) return;
|
||||
|
||||
const ID = Object.keys(settings.groups).find((key) => {
|
||||
return settings.groups[key].friendly_name === name;
|
||||
});
|
||||
|
||||
if (ID) {
|
||||
delete settings.groups[ID];
|
||||
writeRead();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getIeeeAddrByFriendlyName(friendlyName) {
|
||||
const entry = Object.entries(getDevices()).find(([ieeeAddr, device]) =>
|
||||
device.friendly_name === friendlyName
|
||||
);
|
||||
return entry && entry[0];
|
||||
}
|
||||
|
||||
function getGroupIDByFriendlyName(friendlyName) {
|
||||
const entry = Object.entries(getGroups()).find(([ID, group]) =>
|
||||
group.friendly_name === friendlyName
|
||||
);
|
||||
return entry && entry[0];
|
||||
}
|
||||
|
||||
function changeDeviceOptions(ieeeAddr, newOptions) {
|
||||
const settings = getSettings();
|
||||
const currentOptions = settings.devices[ieeeAddr];
|
||||
|
||||
if (!currentOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Object.keys(settings.devices).find((id) =>
|
||||
settings.devices[id].friendly_name === friendlyName
|
||||
);
|
||||
Object.keys(currentOptions).forEach((key) => {
|
||||
if (newOptions[key]) {
|
||||
currentOptions[key] = newOptions[key];
|
||||
}
|
||||
});
|
||||
|
||||
writeRead();
|
||||
}
|
||||
|
||||
function changeFriendlyName(old, new_) {
|
||||
const ID = getIDByFriendlyName(old);
|
||||
const settings = getSettings();
|
||||
const ieeeAddr = getIeeeAddrByFriendlyName(old);
|
||||
|
||||
if (!ID) {
|
||||
if (!ieeeAddr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
settings.devices[ID].friendly_name = new_;
|
||||
settings.devices[ieeeAddr].friendly_name = new_;
|
||||
writeRead();
|
||||
return true;
|
||||
}
|
||||
|
||||
// An entity can be either a group or a device.
|
||||
function resolveEntity(ID) {
|
||||
let type = null;
|
||||
let friendlyName = null;
|
||||
|
||||
if (module.exports.getIeeeAddrByFriendlyName(ID)) {
|
||||
// Check if the ID is a friendly_name of a device.
|
||||
friendlyName = ID;
|
||||
ID = module.exports.getIeeeAddrByFriendlyName(ID);
|
||||
type = 'device';
|
||||
} else if (module.exports.getGroupIDByFriendlyName(ID)) {
|
||||
// Check if the ID is a friendly_name of a group.
|
||||
friendlyName = ID;
|
||||
ID = Number(module.exports.getGroupIDByFriendlyName(ID));
|
||||
type = 'group';
|
||||
} else if (module.exports.getGroup(ID)) {
|
||||
friendlyName = module.exports.getGroup(ID).friendly_name;
|
||||
ID = Number(ID);
|
||||
type = 'group';
|
||||
} else {
|
||||
// By default it is a device with ID as ID.
|
||||
type = 'device';
|
||||
const device = module.exports.getDevice(ID);
|
||||
friendlyName = device ? device.friendly_name : ID;
|
||||
}
|
||||
|
||||
return {ID, type, friendlyName};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
get: () => objectAssignDeep.noMutate(defaults, settings),
|
||||
get: () => get(),
|
||||
write: () => write(),
|
||||
getDevice: (id) => settings.devices ? settings.devices[id] : null,
|
||||
addDevice: (id) => addDevice(id),
|
||||
removeDevice: (id) => removeDevice(id),
|
||||
getIDByFriendlyName: (friendlyName) => getIDByFriendlyName(friendlyName),
|
||||
set: (path, value) => set(path, value),
|
||||
|
||||
getDevice,
|
||||
getGroup,
|
||||
getGroups,
|
||||
getDevices,
|
||||
whitelistDevice: (ieeeAddr) => whitelistDevice(ieeeAddr),
|
||||
banDevice: (ieeeAddr) => banDevice(ieeeAddr),
|
||||
addDevice: (ieeeAddr) => addDevice(ieeeAddr),
|
||||
removeDevice: (ieeeAddr) => removeDevice(ieeeAddr),
|
||||
addGroup: (name) => addGroup(name),
|
||||
removeGroup: (name) => removeGroup(name),
|
||||
addDeviceToGroup: (ID, ieeeAddr) => addDeviceToGroup(ID, ieeeAddr),
|
||||
removeDeviceFromGroup: (ID, ieeeAddr) => removeDeviceFromGroup(ID, ieeeAddr),
|
||||
|
||||
getIeeeAddrByFriendlyName: (friendlyName) => getIeeeAddrByFriendlyName(friendlyName),
|
||||
getGroupIDByFriendlyName: (friendlyName) => getGroupIDByFriendlyName(friendlyName),
|
||||
changeFriendlyName: (old, new_) => changeFriendlyName(old, new_),
|
||||
changeDeviceOptions: (ieeeAddr, options) => changeDeviceOptions(ieeeAddr, options),
|
||||
resolveEntity,
|
||||
|
||||
addOnChangeHandler: (handler) => onChangeHandlers.push(handler),
|
||||
|
||||
// For test
|
||||
_getDefaults: () => {
|
||||
return objectAssignDeep.noMutate(defaults);
|
||||
},
|
||||
_clear: () => _settings = undefined,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
|
||||
const logger = require('../util/logger');
|
||||
|
||||
// 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 postfixes = [
|
||||
'left', 'right', 'center', 'bottom_left', 'bottom_right', 'l1', 'l2',
|
||||
'top_left', 'top_right', 'white', 'rgb', 'system', 'top', 'bottom', 'center_left', 'center_right',
|
||||
];
|
||||
|
||||
function flatten(arr) {
|
||||
return arr.reduce((flat, toFlatten) => {
|
||||
return flat.concat(Array.isArray(toFlatten) ? flatten(toFlatten) : toFlatten);
|
||||
}, []);
|
||||
}
|
||||
|
||||
const forceEndDevice = flatten(
|
||||
['QBKG03LM', 'QBKG04LM', 'ZNMS13LM']
|
||||
.map((model) => zigbeeShepherdConverters.devices.find((d) => d.model === model))
|
||||
.map((mappedModel) => mappedModel.zigbeeModel));
|
||||
|
||||
function getEndpointByEntityID(zigbee, entityID, epName) {
|
||||
const device = zigbee.getDevice(entityID);
|
||||
if (!device) {
|
||||
logger.error(`Failed to find device with entity ID '${entityID}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedDevice = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
|
||||
if (!mappedDevice) {
|
||||
logger.error(`Device with model ID ${device.modelId} is not supported`);
|
||||
return;
|
||||
}
|
||||
|
||||
let epID = null;
|
||||
if (epName) {
|
||||
if (!mappedDevice.ep) {
|
||||
logger.error(`Device ${mappedDevice.model} doesn't define eps`);
|
||||
return;
|
||||
}
|
||||
|
||||
epID = mappedDevice.ep(device)[epName];
|
||||
if (!epID) {
|
||||
logger.error(`Device ${mappedDevice.model} doesn't have ep named '${epName}'`);
|
||||
return;
|
||||
}
|
||||
} else if (mappedDevice.hasOwnProperty('ep')) {
|
||||
const eps = mappedDevice.ep(device);
|
||||
epID = eps[''] || null;
|
||||
}
|
||||
|
||||
const endpoint = zigbee.getEndpoint(entityID, epID);
|
||||
if (!endpoint) {
|
||||
logger.error(`Failed to retrieve for entity ID ${entityID} and endpoint ID ${epID}`);
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
function getZigbee2mqttVersion(callback) {
|
||||
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;
|
||||
}
|
||||
|
||||
callback({commitHash, version});
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(date, type, _default) {
|
||||
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;
|
||||
default:
|
||||
result = _default;
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
millisecondsToSeconds: (milliseconds) => milliseconds / 1000,
|
||||
secondsToMilliseconds: (seconds) => seconds * 1000,
|
||||
isXiaomiDevice: (device) => {
|
||||
return device.modelId !== 'lumi.router' && xiaomiManufacturerID.includes(device.manufId) &&
|
||||
(!device.manufName || !device.manufName.startsWith('Trust'));
|
||||
},
|
||||
isIkeaTradfriDevice: (device) => ikeaTradfriManufacturerID.includes(device.manufId),
|
||||
isRouter: (device) => device.type === 'Router' && !forceEndDevice.includes(device.modelId),
|
||||
isBatteryPowered: (device) => device.powerSource && device.powerSource === 'Battery',
|
||||
isNumeric: (string) => /^\d+$/.test(string),
|
||||
toLocalISOString: (dDate) => toLocalISOString(dDate),
|
||||
getPostfixes: () => postfixes,
|
||||
formatDate: (date, type, _default=null) => formatDate(date, type, _default),
|
||||
correctDeviceType: (device) => {
|
||||
if (device) {
|
||||
if (forceEndDevice.includes(device.modelId)) {
|
||||
return 'EndDevice';
|
||||
}
|
||||
}
|
||||
|
||||
return device.type;
|
||||
},
|
||||
getEndpointByEntityID,
|
||||
getZigbee2mqttVersion,
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
const settings = require('./settings');
|
||||
|
||||
const maxSimultaneouslyRunning = settings.get().queue.simultaneously;
|
||||
const delay = settings.get().queue.delay;
|
||||
const error17Retries = 3;
|
||||
const logger = require('./logger');
|
||||
|
||||
class ZigbeeQueue {
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
this.active = [];
|
||||
this.timer = null;
|
||||
this.running = false;
|
||||
this.ID = 0;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.running = true;
|
||||
this.resetTimer();
|
||||
}
|
||||
|
||||
push(entityID, func) {
|
||||
this.ID++;
|
||||
this.queue.push({entityID, func, attempts: 0, ID: this.ID});
|
||||
this.log(`Added new job with ID ${this.ID} for ${entityID}`);
|
||||
}
|
||||
|
||||
log(message) {
|
||||
logger.trace(`zigbeeQueue: ${message}`);
|
||||
}
|
||||
|
||||
stopTimer() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
resetTimer() {
|
||||
this.stopTimer();
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
this.executeNext();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopTimer();
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
handleJobComplete(job, error) {
|
||||
this.log(`Completed job with ID ${job.ID} for ${job.entityID}${error ? ` with error ${error}` : ''}`);
|
||||
|
||||
if (error && error.message === 'rsp error: 17' && job.attempts < error17Retries) {
|
||||
// Error 17 means that the buffer of the ZNP was full,
|
||||
// retry this for a maximum of 3 times.
|
||||
job.attempts++;
|
||||
this.active.splice(this.active.indexOf(job), 1);
|
||||
} else {
|
||||
this.active.splice(this.active.indexOf(job), 1);
|
||||
this.queue.splice(this.queue.indexOf(job), 1);
|
||||
}
|
||||
}
|
||||
|
||||
executeNext() {
|
||||
if (!this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = this.getNext();
|
||||
|
||||
if (next) {
|
||||
this.active.push(next);
|
||||
this.log(`Executing job with ID ${next.ID} for ${next.entityID}`);
|
||||
next.func((error) => this.handleJobComplete(next, error));
|
||||
}
|
||||
}
|
||||
|
||||
getNext() {
|
||||
if (this.active.length > (maxSimultaneouslyRunning - 1)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.queue.length; i++) {
|
||||
const job = this.queue[i];
|
||||
const activeDeviceJob = this.active.find((j) => j.entityID === job.entityID);
|
||||
if (!activeDeviceJob) {
|
||||
return job;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ZigbeeQueue;
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* This is a fake cie app which makes it possible for some iAS devices to join the network.
|
||||
* Based on: https://github.com/zigbeer/zapp-cie
|
||||
*/
|
||||
|
||||
const Ziee = require('zigbee-herdsman/dist/deprecated/ziee');
|
||||
const Zive = require('zigbee-herdsman/dist/deprecated/zive');
|
||||
const cieClusters = new Ziee();
|
||||
// Direction-Value 1: input, 2: output, 3: input/output
|
||||
|
||||
// IasZone Cluster-Init Direction
|
||||
cieClusters.init('ssIasZone', 'dir', {value: 2}); // Client Side(Output)
|
||||
|
||||
// Init Command Response Handler
|
||||
cieClusters.init('ssIasZone', 'cmdRsps', {
|
||||
enrollReq: (zapp, argObj, cb) => {},
|
||||
statusChangeNotification: (zapp, argObj, cb) => {},
|
||||
});
|
||||
|
||||
// Create Zive with profId: 0x0104 (Decimal: 260 / HA), devId: 0x0400 (Decimal: 1024 / iasControlIndicatingEquipment)
|
||||
// Check the Values by https://github.com/zigbeer/zcl-id/wiki#5-table-of-identifiers
|
||||
const cieApp = new Zive({profId: 0x0104, devId: 0x0400, discCmds: []}, cieClusters);
|
||||
module.exports = cieApp;
|
||||
+527
-125
@@ -1,260 +1,662 @@
|
||||
const ZShepherd = require('zigbee-shepherd');
|
||||
const ZShepherd = require('zigbee-herdsman');
|
||||
const logger = require('./util/logger');
|
||||
const settings = require('./util/settings');
|
||||
const data = require('./util/data');
|
||||
const zclPacket = require('zcl-packet');
|
||||
const utils = require('./util/utils');
|
||||
const ZigbeeQueue = require('./util/zigbeeQueue');
|
||||
const cieApp = require('./zapp/cie');
|
||||
const objectAssignDeep = require('object-assign-deep');
|
||||
const Zcl = require('zigbee-herdsman/dist/zcl');
|
||||
|
||||
const advancedSettings = settings.get().advanced;
|
||||
|
||||
if (advancedSettings.channel < 11 || advancedSettings.channel > 26) {
|
||||
throw new Error(`'${advancedSettings.channel}' is an invalid channel, use a channel between 11 - 26.`);
|
||||
}
|
||||
|
||||
const shepherdSettings = {
|
||||
net: {
|
||||
panId: advancedSettings && advancedSettings.pan_id ? advancedSettings.pan_id : 0x1a62,
|
||||
channelList: [advancedSettings && advancedSettings.channel ? advancedSettings.channel : 11],
|
||||
panId: advancedSettings.pan_id,
|
||||
extPanId: advancedSettings.ext_pan_id,
|
||||
channelList: [advancedSettings.channel],
|
||||
precfgkey: settings.get().advanced.network_key,
|
||||
},
|
||||
dbPath: data.joinPath('database.db'),
|
||||
coordBackupPath: data.joinPath('coordinator_backup.json'),
|
||||
sp: {
|
||||
baudRate: advancedSettings && advancedSettings.baudrate ? advancedSettings.baudrate : 115200,
|
||||
rtscts: advancedSettings && (typeof(advancedSettings.rtscts) === 'boolean') ? advancedSettings.rtscts : true,
|
||||
baudRate: advancedSettings.baudrate,
|
||||
rtscts: advancedSettings.rtscts,
|
||||
},
|
||||
};
|
||||
|
||||
logger.debug(`Using zigbee-shepherd with settings: '${JSON.stringify(shepherdSettings)}'`);
|
||||
const defaultCfg = {
|
||||
manufSpec: 0,
|
||||
disDefaultRsp: 0,
|
||||
};
|
||||
|
||||
// Don't print network key.
|
||||
const shepherdSettingsLog = objectAssignDeep.noMutate(shepherdSettings);
|
||||
shepherdSettingsLog.net.precfgkey = 'HIDDEN';
|
||||
|
||||
class Zigbee {
|
||||
constructor() {
|
||||
this.handleReady = this.handleReady.bind(this);
|
||||
this.handleMessage = this.handleMessage.bind(this);
|
||||
this.handleError = this.handleError.bind(this);
|
||||
this.onReady = this.onReady.bind(this);
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.onError = this.onError.bind(this);
|
||||
this.messageHandler = null;
|
||||
this.permitJoinTimer = null;
|
||||
|
||||
this.queue = new ZigbeeQueue();
|
||||
}
|
||||
|
||||
start(onMessage, callback) {
|
||||
start(messageHandler, callback) {
|
||||
logger.info(`Starting zigbee-shepherd`);
|
||||
logger.debug(`Using zigbee-shepherd with settings: '${JSON.stringify(shepherdSettingsLog)}'`);
|
||||
|
||||
this.messageHandler = messageHandler;
|
||||
this.shepherd = new ZShepherd(settings.get().serial.port, shepherdSettings);
|
||||
|
||||
this.shepherd.start((error) => {
|
||||
if (error) {
|
||||
logger.info('Error while starting zigbee-shepherd, attemping to fix... (takes 60 seconds)');
|
||||
logger.info(`Error while starting zigbee-shepherd, attempting to fix... (takes 60 seconds) (${error})`);
|
||||
this.shepherd.controller._znp.close((() => null));
|
||||
|
||||
setTimeout(() => {
|
||||
logger.info(`Starting zigbee-shepherd`);
|
||||
this.shepherd.start((error) => {
|
||||
if (error) {
|
||||
logger.error('Error while starting zigbee-shepherd!');
|
||||
logger.error(`Error while starting zigbee-shepherd! (${error})`);
|
||||
logger.error(
|
||||
'Press the reset button on the stick (the one closest to the USB) and start again'
|
||||
);
|
||||
callback(error);
|
||||
} else {
|
||||
this._logStartupInfo();
|
||||
this._handleStarted();
|
||||
callback(null);
|
||||
}
|
||||
});
|
||||
}, 60 * 1000);
|
||||
}, utils.secondsToMilliseconds(60));
|
||||
} else {
|
||||
this._logStartupInfo();
|
||||
this._handleStarted();
|
||||
callback(null);
|
||||
}
|
||||
});
|
||||
|
||||
// Register callbacks.
|
||||
this.shepherd.on('ready', this.handleReady);
|
||||
this.shepherd.on('ind', this.handleMessage);
|
||||
this.shepherd.on('error', this.handleError);
|
||||
|
||||
this.onMessage = onMessage;
|
||||
this.shepherd.on('ready', this.onReady);
|
||||
this.shepherd.on('ind', this.onMessage);
|
||||
this.shepherd.on('error', this.onError);
|
||||
this._acceptDevIncoming = this._acceptDevIncoming.bind(this);
|
||||
this.shepherd.acceptDevIncoming = this._acceptDevIncoming;
|
||||
}
|
||||
|
||||
_logStartupInfo() {
|
||||
_handleStarted() {
|
||||
this.logStartupInfo();
|
||||
|
||||
this.getAllClients().forEach((device) => {
|
||||
// If set whitelist devices, all other device will be ban or reject to join the network
|
||||
if (settings.get().whitelist.size>0) {
|
||||
if (!settings.get().whitelist.includes(device.ieeeAddr)) {
|
||||
logger.warn(`Blacklist device is connected (${device.ieeeAddr}), removing...`);
|
||||
this.removeDevice(device.ieeeAddr, true, () => {});
|
||||
}
|
||||
} else {
|
||||
if (settings.get().ban.includes(device.ieeeAddr)) {
|
||||
logger.warn(`Banned device is connected (${device.ieeeAddr}), removing...`);
|
||||
this.removeDevice(device.ieeeAddr, false, () => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.shepherd.backupCoordinator(() => {});
|
||||
}
|
||||
|
||||
_acceptDevIncoming(devInfo, callback) {
|
||||
logger.debug(
|
||||
`Accept device incoming with ieeeAddr '${devInfo.ieeeAddr}' permit join is '${this.getPermitJoin()}'`
|
||||
);
|
||||
// If set whitelist devices, all other device will be ban or reject to join the network
|
||||
if (settings.get().whitelist.size>0) {
|
||||
if (settings.get().whitelist.includes(devInfo.ieeeAddr)) {
|
||||
logger.info(`whitelist device tried to connect (${devInfo.ieeeAddr})`);
|
||||
callback(null, true);
|
||||
} else {
|
||||
logger.debug(`Not allowing device '${devInfo.ieeeAddr}' to join`);
|
||||
callback(null, false);
|
||||
}
|
||||
} else {
|
||||
if (settings.get().ban.includes(devInfo.ieeeAddr)) {
|
||||
logger.info(`Banned device tried to connect (${devInfo.ieeeAddr})`);
|
||||
callback(null, false);
|
||||
} else {
|
||||
logger.debug(`Allowing device '${devInfo.ieeeAddr}' to join`);
|
||||
callback(null, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logStartupInfo() {
|
||||
logger.info('zigbee-shepherd started');
|
||||
logger.info(`Coordinator firmware version: '${this.shepherd.info().firmware.revision}'`);
|
||||
logger.info(`Coordinator firmware version: '${this.getFirmwareVersion()}'`);
|
||||
logger.debug(`zigbee-shepherd info: ${JSON.stringify(this.shepherd.info())}`);
|
||||
}
|
||||
|
||||
getFirmwareVersion() {
|
||||
return this.shepherd.info().firmware.revision;
|
||||
}
|
||||
|
||||
softReset(callback) {
|
||||
this.shepherd.reset('soft', callback);
|
||||
}
|
||||
|
||||
stop(callback) {
|
||||
this.shepherd.stop((error) => {
|
||||
logger.info('zigbee-shepherd stopped');
|
||||
callback(error);
|
||||
if (this.permitJoinTimer) {
|
||||
clearInterval(this.permitJoinTimer);
|
||||
}
|
||||
|
||||
this.queue.stop();
|
||||
|
||||
// Backup coordinator
|
||||
this.shepherd.backupCoordinator(() => {
|
||||
this.shepherd.stop((error) => {
|
||||
logger.info('zigbee-shepherd stopped');
|
||||
callback(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleReady() {
|
||||
// Set all Xiaomi devices (manufId === 4151) to be online, so shepherd won't try
|
||||
// to query info from devices (which would fail because they go tosleep).
|
||||
// Xiaomi lumi.plug has manufId === 4447 and can be in the sleep mode too
|
||||
const devices = this.getAllClients();
|
||||
devices.forEach((d) => {
|
||||
if ((d.manufId === 4151) || (d.manufId === 4447)) {
|
||||
const device = this.shepherd.find(d.ieeeAddr, 1);
|
||||
if (device) {
|
||||
device.getDevice().update({
|
||||
status: 'online',
|
||||
joinTime: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
onReady() {
|
||||
// Mount cieApp
|
||||
this.shepherd.mount(cieApp, (err, epId) => {
|
||||
if (!err) {
|
||||
logger.debug(`Mounted the cieApp (epId ${epId})`);
|
||||
} else {
|
||||
logger.error(`Failed to mount the cieApp`);
|
||||
}
|
||||
});
|
||||
|
||||
// Check if we have to turn of the led
|
||||
// Check if we have to turn off the led
|
||||
if (settings.get().serial.disable_led) {
|
||||
this.shepherd.controller.request('UTIL', 'ledControl', {ledid: 3, mode: 0});
|
||||
}
|
||||
|
||||
// Wait some time before we start the queue, many calls skip this queue which hangs the stick
|
||||
setTimeout(() => {
|
||||
this.queue.start();
|
||||
}, 2000);
|
||||
|
||||
logger.info('zigbee-shepherd ready');
|
||||
}
|
||||
|
||||
handleError(message) {
|
||||
onError(message) {
|
||||
// This event may appear if zigbee-shepherd cannot decode bad packets (invalid checksum).
|
||||
logger.error(message);
|
||||
}
|
||||
|
||||
permitJoin(permit) {
|
||||
permitJoin(permit, callback) {
|
||||
if (permit) {
|
||||
logger.info('Zigbee: allowing new devices to join.');
|
||||
} else {
|
||||
logger.info('Zigbee: disabling joining new devices.');
|
||||
}
|
||||
|
||||
// In zigbee 3.0 a network automatically closes after 254 seconds.
|
||||
// As a workaround, we enable joining again.
|
||||
if (this.permitJoinTimer) {
|
||||
clearInterval(this.permitJoinTimer);
|
||||
}
|
||||
|
||||
if (permit) {
|
||||
this.permitJoinTimer = setInterval(() => {
|
||||
this.shepherd.permitJoin(255, (error) => {
|
||||
if (error) {
|
||||
logger.error('Failed to reenable joining');
|
||||
} else {
|
||||
logger.info('Successfully reenabled joining');
|
||||
}
|
||||
});
|
||||
}, utils.secondsToMilliseconds(160));
|
||||
}
|
||||
|
||||
this.shepherd.permitJoin(permit ? 255 : 0, (error) => {
|
||||
if (error) {
|
||||
logger.info(error);
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getAllClients() {
|
||||
return this.shepherd.list().filter((device) => device.type !== 'Coordinator');
|
||||
getPermitJoin() {
|
||||
return this.shepherd.controller._permitJoinTime === 255;
|
||||
}
|
||||
|
||||
removeDevice(deviceID, callback) {
|
||||
this.shepherd.remove(deviceID, (error) => {
|
||||
getAllClients() {
|
||||
return this.getDevices().filter((device) => device.type !== 'Coordinator');
|
||||
}
|
||||
|
||||
removeDevice(deviceID, ban, callback) {
|
||||
if (ban) {
|
||||
settings.banDevice(deviceID);
|
||||
}
|
||||
|
||||
const friendlyName = this.getDeviceFriendlyName(deviceID);
|
||||
|
||||
this.shepherd.remove(deviceID, {reJoin: true}, (error) => {
|
||||
if (error) {
|
||||
logger.warn(`Failed to remove '${deviceID}', trying force remove...`);
|
||||
this._forceRemove(deviceID, callback);
|
||||
logger.warn(`Failed to remove '${friendlyName}', trying force remove...`);
|
||||
this.forceRemove(deviceID, callback);
|
||||
} else {
|
||||
logger.info(`Removed ${friendlyName}`);
|
||||
callback(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_forceRemove(deviceID, callback) {
|
||||
forceRemove(deviceID, callback) {
|
||||
const device = this.shepherd._findDevByAddr(deviceID);
|
||||
|
||||
if (device) {
|
||||
return this.shepherd._unregisterDev(device, (error) => callback(error));
|
||||
const friendlyName = this.getDeviceFriendlyName(deviceID);
|
||||
return this.shepherd._unregisterDev(device, (error) => {
|
||||
logger.info(`Force removed ${friendlyName}`);
|
||||
callback(error);
|
||||
});
|
||||
} else {
|
||||
logger.warn(`Could not find ${deviceID} for force removal`);
|
||||
callback(true);
|
||||
}
|
||||
}
|
||||
|
||||
ping(deviceID) {
|
||||
let friendlyName = 'unknown';
|
||||
const device = this.shepherd._findDevByAddr(deviceID);
|
||||
const ieeeAddr = device.ieeeAddr;
|
||||
if (settings.getDevice(ieeeAddr)) {
|
||||
friendlyName = settings.getDevice(ieeeAddr).friendly_name;
|
||||
}
|
||||
|
||||
if (device) {
|
||||
// Note: checkOnline has the callback argument but does not call callback
|
||||
logger.debug(`Check online ${friendlyName} ${deviceID}`);
|
||||
this.shepherd.controller.checkOnline(device);
|
||||
onMessage(message) {
|
||||
if (this.messageHandler) {
|
||||
this.messageHandler(message);
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(message) {
|
||||
if (this.onMessage) {
|
||||
this.onMessage(message);
|
||||
}
|
||||
getDevices() {
|
||||
return this.shepherd.list();
|
||||
}
|
||||
|
||||
getDevice(deviceID) {
|
||||
return this.shepherd.list().find((d) => d.ieeeAddr === deviceID);
|
||||
getDevice(ieeeAddr) {
|
||||
return this.getDevices().find((d) => d.ieeeAddr === ieeeAddr);
|
||||
}
|
||||
|
||||
getDeviceFriendlyName(ieeeAddr) {
|
||||
const device = settings.getDevice(ieeeAddr);
|
||||
return device ? device.friendly_name || ieeeAddr : ieeeAddr;
|
||||
}
|
||||
|
||||
getCoordinator() {
|
||||
const device = this.shepherd.list().find((d) => d.type === 'Coordinator');
|
||||
const device = this.getDevices().find((d) => d.type === 'Coordinator');
|
||||
return this.shepherd.find(device.ieeeAddr, 1);
|
||||
}
|
||||
|
||||
publish(deviceID, cid, cmd, zclData, ep, type, callback) {
|
||||
const device = this._findDevice(deviceID, ep);
|
||||
if (!device) {
|
||||
logger.error(`Zigbee cannot publish message to device because '${deviceID}' not known by zigbee-shepherd`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Zigbee publish to '${deviceID}', ${cid} - ${cmd} - ${JSON.stringify(zclData)} - ${ep}`);
|
||||
|
||||
const callback_ = (error) => {
|
||||
if (error) {
|
||||
logger.error(
|
||||
`Zigbee publish to '${deviceID}', ${cid} - ${cmd} - ${JSON.stringify(zclData)} - ${ep} ` +
|
||||
`failed with error ${error}`);
|
||||
}
|
||||
|
||||
callback(error);
|
||||
};
|
||||
|
||||
if (type === 'functional') {
|
||||
device.functional(cid, cmd, zclData, callback_);
|
||||
} else if (type === 'foundation') {
|
||||
device.foundation(cid, cmd, [zclData], callback_);
|
||||
} else {
|
||||
logger.error(`Unknown zigbee publish type ${type}`);
|
||||
}
|
||||
getScanable() {
|
||||
return this.getDevices().filter((d) => d.type != 'EndDevice');
|
||||
}
|
||||
|
||||
read(deviceID, cid, attr, ep, callback) {
|
||||
const device = this._findDevice(deviceID, ep);
|
||||
if (!device) {
|
||||
logger.error(`Zigbee cannot read attribute from device because '${deviceID}' not known by zigbee-shepherd`);
|
||||
return;
|
||||
}
|
||||
|
||||
device.read(cid, attr, callback);
|
||||
getGroup(ID) {
|
||||
return this.shepherd.getGroup(ID);
|
||||
}
|
||||
|
||||
networkScan(callback) {
|
||||
logger.info('Starting network scan...');
|
||||
this.shepherd.lqiScan().then((result) => {
|
||||
logger.info('Network scan completed');
|
||||
callback(result);
|
||||
});
|
||||
getGroupFriendlyName(ID) {
|
||||
let friendlyName = null;
|
||||
friendlyName = settings.getGroup(ID).friendly_name;
|
||||
return (friendlyName ? friendlyName : ID);
|
||||
}
|
||||
|
||||
registerOnAfIncomingMsg(ieeeAddr, ep) {
|
||||
const device = this._findDevice(ieeeAddr, ep);
|
||||
device.onAfIncomingMsg = (message) => {
|
||||
// Parse the message
|
||||
zclPacket.parse(message.data, message.clusterid, (error, zclData) => {
|
||||
const message = {
|
||||
endpoints: [device],
|
||||
data: zclData,
|
||||
};
|
||||
networkScan(includeRoutes, callback) {
|
||||
logger.info(`Starting network scan includeRoutes='${includeRoutes}'...`);
|
||||
const lqiScanList = new Set();
|
||||
const rtgScanList = new Set();
|
||||
const linkMap = [];
|
||||
const routeMap = [];
|
||||
|
||||
this.handleMessage(message);
|
||||
// Gather the lqi and the route info into separate lists and only collate them when done.
|
||||
const collateMap = () => {
|
||||
linkMap.sort();
|
||||
logger.debug(`Link map: %j`, linkMap);
|
||||
logger.debug(`Route map: %j`, routeMap);
|
||||
// Merge the routes into the linkMap by matching on 'source|target' link short addresses
|
||||
linkMap.forEach((link) => {
|
||||
routeMap.filter((e) => e.key === link.key).forEach((e) => {
|
||||
link.routes.push(e.destAddr);
|
||||
});
|
||||
link.routes.sort(function(a, b) {
|
||||
return a-b;
|
||||
});
|
||||
delete link.key;
|
||||
});
|
||||
const networkMap = {nodes: [], links: linkMap};
|
||||
this.getDevices().forEach((device) => {
|
||||
const friendlyDevice = settings.getDevice(device.ieeeAddr);
|
||||
const friendlyName = friendlyDevice ? friendlyDevice.friendly_name : device.ieeeAddr;
|
||||
const deviceType = utils.correctDeviceType(device);
|
||||
const scanfailed = [];
|
||||
if (lqiScanList.has(device.ieeeAddr)) {
|
||||
scanfailed.push('lqi');
|
||||
}
|
||||
if (rtgScanList.has(device.ieeeAddr)) {
|
||||
scanfailed.push('rtg');
|
||||
}
|
||||
networkMap.nodes.push({ieeeAddr: device.ieeeAddr, friendlyName: friendlyName, type: deviceType,
|
||||
nwkAddr: device.nwkAddr, manufName: device.manufName, modelId: device.modelId,
|
||||
status: device.status, scanfailed: scanfailed});
|
||||
});
|
||||
// Clear remaining devices so they don't process when/if they eventually complete
|
||||
lqiScanList.clear();
|
||||
rtgScanList.clear();
|
||||
logger.debug(`Merged map: %j`, networkMap);
|
||||
callback(networkMap);
|
||||
};
|
||||
|
||||
const processLqiResponse = (error, rsp, targetIeeeAddr, targetNwkAddr) => {
|
||||
if (error) {
|
||||
logger.warn(`Failed network lqi scan for device: '${targetIeeeAddr}' with error: '${error}'`);
|
||||
} else {
|
||||
if (lqiScanList.has(targetIeeeAddr)) {
|
||||
// Haven't processed this one yet
|
||||
if (rsp && rsp.status === 0 && rsp.neighborlqilist) {
|
||||
logger.debug(`lqi scan: '${targetIeeeAddr}' with '${rsp.neighborlqilistcount}' neighbors`);
|
||||
rsp.neighborlqilist.forEach(function(neighbor) {
|
||||
// only include active relationships
|
||||
if (neighbor.relationship <= 3) {
|
||||
// lqi is measured at receiver so link is from neighbor (source) to scanned router
|
||||
const key = neighbor.nwkAddr + '|' + targetNwkAddr;
|
||||
linkMap.push({
|
||||
key: key, sourceIeeeAddr: neighbor.extAddr, targetIeeeAddr: targetIeeeAddr,
|
||||
sourceNwkAddr: neighbor.nwkAddr, lqi: neighbor.lqi, depth: neighbor.depth,
|
||||
relationship: neighbor.relationship, routes: []});
|
||||
}
|
||||
});
|
||||
// Remove from scan list and if both lists are done return the completed network map
|
||||
lqiScanList.delete(targetIeeeAddr);
|
||||
if (lqiScanList.size === 0 && rtgScanList.size === 0) {
|
||||
logger.info('Network scan completed');
|
||||
collateMap();
|
||||
} else {
|
||||
logger.debug(`Outstanding network lqi scans for devices: '${[...lqiScanList].join(' ')}'`);
|
||||
logger.debug(`Outstanding network rtg scans for devices: '${[...rtgScanList].join(' ')}'`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Empty network lqi scan result for: '${targetIeeeAddr}'`);
|
||||
}
|
||||
} else {
|
||||
// This target has already had timeout so don't add to result network map
|
||||
logger.warn(`Ignoring late network lqi scan result for: '${targetIeeeAddr}'`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processRtgResponse = (error, rsp, sourceIeeeAddr, sourceNwkAddr) => {
|
||||
if (error) {
|
||||
logger.warn(`Failed network rtg scan for device: '${sourceIeeeAddr}' with error: '${error}'`);
|
||||
} else {
|
||||
if (rtgScanList.has(sourceIeeeAddr)) {
|
||||
// Haven't processed this one yet
|
||||
if (rsp && rsp.status === 0 && rsp.routingtablelist) {
|
||||
logger.debug(`rtg scan: '${sourceIeeeAddr}' with '${rsp.routingtablelistcount}' entries`);
|
||||
rsp.routingtablelist.forEach(function(route) {
|
||||
if (route.routeStatus === 0) {
|
||||
const key = sourceNwkAddr + '|' + route.nextHopNwkAddr;
|
||||
routeMap.push({
|
||||
key: key, destAddr: route.destNwkAddr});
|
||||
}
|
||||
});
|
||||
// Remove from scan list and if both lists are done return the completed network map
|
||||
rtgScanList.delete(sourceIeeeAddr);
|
||||
if (lqiScanList.size === 0 && rtgScanList.size === 0) {
|
||||
logger.info('Network scan completed');
|
||||
collateMap();
|
||||
} else {
|
||||
logger.debug(`Outstanding network lqi scans for devices: '${[...lqiScanList].join(' ')}'`);
|
||||
logger.debug(`Outstanding network rtg scans for devices: '${[...rtgScanList].join(' ')}'`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Empty network rtg scan result for: '${sourceIeeeAddr}'`);
|
||||
}
|
||||
} else {
|
||||
// This source has already had timeout so don't add to result network map
|
||||
logger.warn(`Ignoring late network rtg scan result for: '${sourceIeeeAddr}'`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Queue up an lqi scan and an rtg scan for coordinator and each router
|
||||
this.getScanable().forEach((dev) => {
|
||||
logger.debug(`Queing network scans for device: '${dev.ieeeAddr}'`);
|
||||
lqiScanList.add(dev.ieeeAddr);
|
||||
this.queue.push(dev.ieeeAddr, (queueCallback) => {
|
||||
this.shepherd.controller.request('ZDO', 'mgmtLqiReq', {dstaddr: dev.nwkAddr, startindex: 0},
|
||||
(error, rsp, ieeeAddr, nwkAddr) => {
|
||||
processLqiResponse(error, rsp, dev.ieeeAddr, dev.nwkAddr);
|
||||
queueCallback(error);
|
||||
});
|
||||
});
|
||||
if (includeRoutes) {
|
||||
rtgScanList.add(dev.ieeeAddr);
|
||||
this.queue.push(dev.ieeeAddr, (queueCallback) => {
|
||||
this.shepherd.controller.request('ZDO', 'mgmtRtgReq', {dstaddr: dev.nwkAddr, startindex: 0},
|
||||
(error, rsp, ieeeAddr, nwkAddr) => {
|
||||
processRtgResponse(error, rsp, dev.ieeeAddr, dev.nwkAddr);
|
||||
queueCallback(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all device scans before forcing map with whatever results are already in
|
||||
setTimeout(() => {
|
||||
if (lqiScanList.size === 0 && rtgScanList.size === 0) {
|
||||
logger.info('Network scan timeout no outstanding requests');
|
||||
} else {
|
||||
logger.warn(`Network scan timeout, skipping outstanding lqi scans for '${[...lqiScanList].join(' ')}'`);
|
||||
logger.warn(`Network scan timeout, skipping outstanding rtg scans for '${[...rtgScanList].join(' ')}'`);
|
||||
collateMap();
|
||||
}
|
||||
}, lqiScanList.size * 1000);
|
||||
}
|
||||
|
||||
_findDevice(deviceID, ep) {
|
||||
getEndpoint(ieeeAddr, ep) {
|
||||
// If no ep is given, the first endpoint will be returned
|
||||
// Find device in zigbee-shepherd
|
||||
let device = this.getDevice(deviceID);
|
||||
const device = this.getDevice(ieeeAddr);
|
||||
if (!device || !device.epList || !device.epList.length) {
|
||||
logger.error(`Zigbee cannot determine endpoint for '${deviceID}'`);
|
||||
logger.error(`Zigbee cannot determine endpoint for '${ieeeAddr}'`);
|
||||
return null;
|
||||
}
|
||||
|
||||
ep = ep ? ep : device.epList[0];
|
||||
device = this.shepherd.find(deviceID, ep);
|
||||
return device;
|
||||
const endpoint = this.shepherd.find(ieeeAddr, ep);
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
publish(entityID, entityType, cid, cmd, cmdType, zclData, cfg=defaultCfg, ep, callback) {
|
||||
let entity = null;
|
||||
let friendlyName = null;
|
||||
if (entityType === 'device') {
|
||||
entity = this.getEndpoint(entityID, ep);
|
||||
friendlyName = this.getDeviceFriendlyName(entityID);
|
||||
} else if (entityType === 'group') {
|
||||
entity = this.getGroup(entityID);
|
||||
friendlyName = this.getGroupFriendlyName(entityID);
|
||||
}
|
||||
|
||||
if (!entity) {
|
||||
logger.error(
|
||||
`Cannot publish message to ${entityType} because '${entityID}' is not known by zigbee-shepherd`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.queue.push(entityID, (queueCallback) => {
|
||||
logger.info(
|
||||
`Zigbee publish to ${entityType} '${friendlyName}', ${cid} - ${cmd} - ` +
|
||||
`${JSON.stringify(zclData)} - ${JSON.stringify(cfg)} - ${entity.epId}`
|
||||
);
|
||||
|
||||
const callback_ = (error, rsp) => {
|
||||
if (error) {
|
||||
logger.error(
|
||||
`Zigbee publish to ${entityType} '${friendlyName}', ${cid} ` +
|
||||
`- ${cmd} - ${JSON.stringify(zclData)} ` +
|
||||
`- ${JSON.stringify(cfg)} - ${ep} ` +
|
||||
`failed with error ${error}`);
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
callback(error, rsp);
|
||||
}
|
||||
|
||||
queueCallback(error);
|
||||
};
|
||||
|
||||
if (cmdType === 'functional' && entity.functional) {
|
||||
entity.functional(cid, cmd, zclData, cfg, callback_);
|
||||
} else if (cmdType === 'foundation' && entity.foundation) {
|
||||
entity.foundation(cid, cmd, zclData, cfg, callback_);
|
||||
} else {
|
||||
logger.error(`Unknown zigbee publish cmdType ${cmdType}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ping(ieeeAddr, errorLogLevel='error', cb, mechanism='default') {
|
||||
const friendlyName = this.getDeviceFriendlyName(ieeeAddr);
|
||||
const callback = (error) => {
|
||||
if (error) {
|
||||
logger[errorLogLevel](`Failed to ping '${friendlyName}'`);
|
||||
} else {
|
||||
logger.debug(`Successfully pinged '${friendlyName}'`);
|
||||
}
|
||||
|
||||
if (cb) {
|
||||
cb(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (mechanism === 'default') {
|
||||
const device = this.shepherd._findDevByAddr(ieeeAddr);
|
||||
if (device) {
|
||||
logger.debug(`Ping ${ieeeAddr} (default)`);
|
||||
this.queue.push(ieeeAddr, (queueCallback) => {
|
||||
this.shepherd.controller.checkOnline(device, (error) => {
|
||||
callback(error);
|
||||
queueCallback(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
} else if (mechanism === 'basic') {
|
||||
const endpoint = this.getEndpoint(ieeeAddr, null);
|
||||
if (endpoint) {
|
||||
logger.debug(`Ping ${ieeeAddr} (basic)`);
|
||||
this.queue.push(ieeeAddr, (queueCallback) => {
|
||||
endpoint.foundation('genBasic', 'read', [{attrId: 0}], (error) => {
|
||||
callback(error);
|
||||
queueCallback(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bind(ep, cluster, target, callback) {
|
||||
const friendlyName = this.getDeviceFriendlyName(ep.device.ieeeAddr);
|
||||
const log = ` '${friendlyName}' - ${cluster}`;
|
||||
target = !target ? this.getCoordinator() : target;
|
||||
|
||||
this.queue.push(ep.device.ieeeAddr, (queueCallback) => {
|
||||
logger.debug(`Binding ${log}`);
|
||||
ep.bind(cluster, target, (error) => {
|
||||
if (error) {
|
||||
logger.error(`Failed to bind ${log} - (${error})`);
|
||||
} else {
|
||||
logger.debug(`Successfully bound ${log}`);
|
||||
}
|
||||
|
||||
callback(error);
|
||||
queueCallback(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
unbind(ep, cluster, target, callback) {
|
||||
const friendlyName = this.getDeviceFriendlyName(ep.device.ieeeAddr);
|
||||
const log = ` '${friendlyName}' - ${cluster}`;
|
||||
target = !target ? this.getCoordinator() : target;
|
||||
|
||||
this.queue.push(ep.device.ieeeAddr, (queueCallback) => {
|
||||
logger.debug(`Unbinding ${log}`);
|
||||
ep.unbind(cluster, target, (error) => {
|
||||
if (error) {
|
||||
logger.error(`Failed to unbind ${log} - (${error})`);
|
||||
} else {
|
||||
logger.debug(`Successfully unbound ${log}`);
|
||||
}
|
||||
|
||||
callback(error);
|
||||
queueCallback(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Setup reporting.
|
||||
* Attributes is an array of attribute objects.
|
||||
* each attribute object should contain the following properties:
|
||||
* attr the attribute name,
|
||||
* min the minimal time between reports in seconds,
|
||||
* max the maximum time between reports in seconds,
|
||||
* change the minimum amount of change before sending a report
|
||||
*/
|
||||
report(ep, cluster, attributes) {
|
||||
const friendlyName = this.getDeviceFriendlyName(ep.device.ieeeAddr);
|
||||
const cfgArr = attributes.map((attribute) => {
|
||||
const attrId = Zcl.getAttributeLegacy(cluster, attribute.attr).value;
|
||||
const dataType = Zcl.getAttributeTypeLegacy(cluster, attribute.attr).value;
|
||||
return {
|
||||
direction: 0,
|
||||
attrId,
|
||||
dataType,
|
||||
minRepIntval: attribute.min,
|
||||
maxRepIntval: attribute.max,
|
||||
repChange: attribute.change,
|
||||
};
|
||||
});
|
||||
|
||||
const log=`for '${friendlyName}' - ${cluster} - ${attributes.length}`;
|
||||
|
||||
const configReport = () => {
|
||||
this.queue.push(ep.device.ieeeAddr, (queueCallback) => {
|
||||
ep.foundation(cluster, 'configReport', cfgArr, defaultCfg, (error) => {
|
||||
if (error) {
|
||||
logger.error(`Failed to setup reporting ${log} - (${error})`);
|
||||
} else {
|
||||
logger.debug(`Successfully setup reporting ${log}`);
|
||||
}
|
||||
|
||||
queueCallback(error);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
this.queue.push(ep.device.ieeeAddr, (queueCallback) => {
|
||||
logger.debug(`Setup reporting ${log}`);
|
||||
|
||||
ep.bind(cluster, this.getCoordinator(), (error) => {
|
||||
if (error) {
|
||||
logger.error(`Failed to bind for reporting ${log} - (${error})`);
|
||||
} else {
|
||||
// Only if binding succeeds, setting-up reporting makes sense.
|
||||
configReport();
|
||||
}
|
||||
|
||||
queueCallback(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+17012
-3687
File diff suppressed because it is too large
Load Diff
+16
-16
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "0.1.5",
|
||||
"version": "1.6.0",
|
||||
"description": "Zigbee to MQTT bridge using zigbee-shepherd",
|
||||
"main": "index.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Koenkk/zigbee2mqtt.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": "10"
|
||||
},
|
||||
"keywords": [
|
||||
"xiaomi",
|
||||
"tradfri",
|
||||
@@ -17,41 +20,38 @@
|
||||
"cc2531"
|
||||
],
|
||||
"scripts": {
|
||||
"coverage": "nyc --all report --reporter=text mocha --recursive --timeout=3000",
|
||||
"docgen": "node support/docgen.js",
|
||||
"coverage": "jest --collect-coverage",
|
||||
"eslint": "node_modules/.bin/eslint .",
|
||||
"start": "node index.js",
|
||||
"test": "mocha --recursive",
|
||||
"verify-homeassistant-mapping": "node support/verify-homeassistant-mapping.js"
|
||||
"test": "jest"
|
||||
},
|
||||
"author": "Koen Kanters",
|
||||
"license": "GPL-3.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/Koenkk/zigbee2mqtt/issues"
|
||||
},
|
||||
"homepage": "https://github.com/Koenkk/zigbee2mqtt/wiki",
|
||||
"homepage": "https://koenkk.github.io/zigbee2mqtt",
|
||||
"dependencies": {
|
||||
"debounce": "*",
|
||||
"git-last-commit": "*",
|
||||
"js-yaml": "*",
|
||||
"json2yaml": "*",
|
||||
"mkdir-recursive": "*",
|
||||
"moment": "*",
|
||||
"mqtt": "*",
|
||||
"object-assign-deep": "*",
|
||||
"queue": "*",
|
||||
"rimraf": "*",
|
||||
"semver": "*",
|
||||
"winston": "2.4.2",
|
||||
"zcl-packet": "git+https://github.com/Koenkk/zcl-packet.git#8f17e5540946f9be198d737a879e334c24dbf20d",
|
||||
"zigbee-shepherd": "git+https://github.com/Koenkk/zigbee-shepherd.git#c096ed6719a6c941217c560b4cdcc04e4dd34c3b",
|
||||
"zigbee-shepherd-converters": "*"
|
||||
"zigbee-herdsman": "0.5.7",
|
||||
"zigbee-shepherd-converters": "10.2.5",
|
||||
"deep-diff": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chai": "*",
|
||||
"chai-spies": "*",
|
||||
"@types/jest": "^24.0.11",
|
||||
"eslint": "*",
|
||||
"eslint-config-google": "*",
|
||||
"eslint-plugin-mocha": "*",
|
||||
"mocha": "*",
|
||||
"nyc": "*",
|
||||
"proxyquire": "*"
|
||||
"eslint-plugin-jest": "^22.3.0",
|
||||
"jest": "^24.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
const fs = require('fs');
|
||||
const filename = process.argv[2];
|
||||
const text = fs.readFileSync(filename, 'utf8');
|
||||
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
|
||||
|
||||
|
||||
for (const line of text.split('\n')) {
|
||||
const model = zigbeeShepherdConverters.devices.find((d) => d.model === line);
|
||||
console.log(`- \`${line}\` ${model.vendor} ${model.description}`);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
function zigbee2mqtt-show-short-info {
|
||||
echo "Setup for Zigbee2mqtt bridge."
|
||||
}
|
||||
|
||||
function zigbee2mqtt-show-long-info {
|
||||
echo "This script installs the Zigbee2mqtt bridge"
|
||||
}
|
||||
|
||||
function zigbee2mqtt-show-copyright-info {
|
||||
echo "Original concept by Landrash <https://github.com/landrash>."
|
||||
}
|
||||
|
||||
function zigbee2mqtt-install-package {
|
||||
echo -n "Installing dependencies : "
|
||||
node=$(which npm)
|
||||
if [ -z "${node}" ]; then #Installing NodeJS if not already installed.
|
||||
printf "Downloading and installing NodeJS...\\n"
|
||||
curl -sL https://deb.nodesource.com/setup_10.x | bash -
|
||||
apt install -y nodejs
|
||||
fi
|
||||
|
||||
echo "Cloning Zigbee2mqtt git repository"
|
||||
git clone https://github.com/Koenkk/zigbee2mqtt.git /opt/zigbee2mqtt
|
||||
chown -R pi:pi /opt/zigbee2mqtt
|
||||
|
||||
echo "Running install. This might take a while and can produce som expected errors"
|
||||
cd /opt/zigbee2mqtt || exit
|
||||
su pi -c "npm install"
|
||||
|
||||
echo "Creating service file zigbee2mqtt.service"
|
||||
service_path="/etc/systemd/system/zigbee2mqtt.service"
|
||||
|
||||
echo "[Unit]
|
||||
Description=zigbee2mqtt
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/npm start
|
||||
WorkingDirectory=/opt/zigbee2mqtt
|
||||
StandardOutput=inherit
|
||||
StandardError=inherit
|
||||
Restart=always
|
||||
User=pi
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target" > $service_path
|
||||
|
||||
echo "Checking the installation..."
|
||||
if [ ! -f /opt/zigbee2mqtt/data/configuration.yaml ]; then
|
||||
validation=""
|
||||
else
|
||||
validation="ok"
|
||||
fi
|
||||
|
||||
if [ ! -z "${validation}" ]; then
|
||||
echo
|
||||
echo -e "\\e[32mInstallation done..\\e[0m"
|
||||
echo -e "Update of configuration.yaml is required found at /opt/zigbee2mqtt/data/"
|
||||
echo -e "Some further configuration is required and details can be found here https://www.zigbee2mqtt.io"
|
||||
echo
|
||||
echo -e "Service can be started after configuration but running sudo systemctl start zigbee2mqtt"
|
||||
echo
|
||||
else
|
||||
echo
|
||||
echo -e "\\e[31mInstallation failed..."
|
||||
echo
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
[[ "$_" == "$0" ]] && zigbee2mqtt-install-package
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/bin/bash
|
||||
|
||||
######################################
|
||||
## INITIALIZE ##
|
||||
######################################
|
||||
|
||||
set -o errexit -o pipefail -o noclobber -o nounset
|
||||
|
||||
! getopt --test > /dev/null
|
||||
if [[ ${PIPESTATUS[0]} -ne 4 ]]; then
|
||||
echo "Unfortunately getopt failed in this environment."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
|
||||
PROGRAM_NAME="zigbee2socat_installer"
|
||||
VERSION="1.0"
|
||||
|
||||
|
||||
######################################
|
||||
## PARSE ARGUMENTS ##
|
||||
######################################
|
||||
|
||||
OPTS="a:p:uvmhV"
|
||||
LONG="addr:,port:,uninstall,verbose,man,help,version"
|
||||
|
||||
# Concern: possible to escape getopt and execute commands as root?
|
||||
! PARSED=$(getopt -n $PROGRAM_NAME \
|
||||
-o $OPTS \
|
||||
-l $LONG \
|
||||
-- "$@")
|
||||
if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
|
||||
printf "Error parsing arguments. Try %s --help\n" "$PROGRAM_NAME"
|
||||
exit 3
|
||||
fi
|
||||
eval set -- "$PARSED" # Use remaining arguments that weren't parsed
|
||||
|
||||
|
||||
function handle_opts {
|
||||
if [[ $# = 1 ]]; then
|
||||
usage
|
||||
fi
|
||||
while true; do
|
||||
case $1 in
|
||||
-a|--addr)
|
||||
ip=$2; shift 2; continue ;;
|
||||
|
||||
-p|--port)
|
||||
port=$2; shift 2; continue ;;
|
||||
|
||||
-u|--uninstall)
|
||||
uninstall ;;
|
||||
|
||||
-v|--verbose)
|
||||
verbose=1; shift; continue ;;
|
||||
|
||||
-m|--man)
|
||||
makemanual ;;
|
||||
|
||||
-h|--help)
|
||||
usage ;;
|
||||
|
||||
-V|--version)
|
||||
version ;;
|
||||
|
||||
--) # No more arguments to parse
|
||||
shift; break ;;
|
||||
|
||||
*)
|
||||
printf "Programming error! Option: %s\n" "$1"
|
||||
exit 4 ;;
|
||||
esac;done
|
||||
}
|
||||
|
||||
|
||||
######################################
|
||||
## START OF MAIN ##
|
||||
######################################
|
||||
|
||||
verbose=0
|
||||
ip=""
|
||||
port=""
|
||||
|
||||
function main {
|
||||
handle_opts "$@"
|
||||
|
||||
# TODO: Check IP-address valid?
|
||||
if [[ "$ip" = "" ]]; then
|
||||
echo "IP-address not specified"
|
||||
exit 5
|
||||
fi
|
||||
|
||||
# Is the port valid?
|
||||
if [[ $port -lt 1 || $port -gt 65535 ]]; then
|
||||
printf "Port %s, is outside range: [1-65535]\n" "$port"
|
||||
exit 6
|
||||
fi
|
||||
|
||||
zigbee-socatvusb-install-package
|
||||
}
|
||||
|
||||
|
||||
######################################
|
||||
## FUNCTIONS BELOW ##
|
||||
######################################
|
||||
|
||||
function zigbee-socatvusb-install-package {
|
||||
echo "Installing socat:"
|
||||
sudo apt-get install socat
|
||||
echo
|
||||
|
||||
echo "Make dir for zigbee vusb"
|
||||
sudo mkdir -p /opt/zigbee2mqtt/vusb/ || die "Couldn't mkdir /opt/zigbee2mqtt/vusb/"
|
||||
sudo chown -R pi:pi /opt/zigbee2mqtt/vusb/ || die "Couldn't chown /opt/zigbee2mqtt/vusb/"
|
||||
|
||||
echo "Creating service file zigbee-socatvusb.service"
|
||||
service_path="/etc/systemd/system/zigbee-socatvusb.service"
|
||||
|
||||
[[ -f $service_path ]] && sudo rm $service_path
|
||||
echo "[Unit]
|
||||
Description=socat-vusb
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
User=pi
|
||||
ExecStart=/usr/bin/socat -d -d pty,raw,echo=0,link=/opt/zigbee2mqtt/vusb/zigbee_cc2530 tcp:$ip:$port,reuseaddr
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target" > $service_path || die "Couldn't create service /etc/systemd/system/zigbee-socatvusb.service"
|
||||
|
||||
sudo systemctl --system daemon-reload
|
||||
|
||||
echo "Installation is now complete"
|
||||
echo
|
||||
echo "Service can be started after configuration by running: sudo systemctl start zigbee-socatvusb"
|
||||
}
|
||||
|
||||
function uninstall {
|
||||
service_path="systemctl status socat-vusb.service"
|
||||
[[ -f $service_path ]] && sudo rm $service_path
|
||||
sudo systemctl --system daemon-reload
|
||||
echo "Uninstalled successfully"
|
||||
exit 0
|
||||
}
|
||||
|
||||
function makemanual {
|
||||
[[ -f "$PROGRAM_NAME.man" ]] && sudo rm $PROGRAM_NAME.man
|
||||
help2man -N ./$PROGRAM_NAME.sh > $PROGRAM_NAME.man ; man ./$PROGRAM_NAME.man
|
||||
exit 0
|
||||
}
|
||||
|
||||
function usage {
|
||||
echo -e "\
|
||||
\rSetup for development version of Zigbee2Socat
|
||||
|
||||
-a, --addr IP Listen on this IP-address
|
||||
-p, --port PORT Listen on this port
|
||||
-u, --uninstall Uninstall zigbee-socatvusb.service
|
||||
-v, --verbose Print more information
|
||||
-m, --man Make and display manual
|
||||
-h, --help Display this help message
|
||||
|
||||
\rOriginal concept by JFLN\
|
||||
"
|
||||
exit 0
|
||||
}
|
||||
|
||||
function version {
|
||||
echo "$PROGRAM_NAME $VERSION"
|
||||
exit 0
|
||||
}
|
||||
|
||||
function die {
|
||||
printf "%s\n" "$1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
main "$@" # Call main-function last
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* This script generates the supported devices page.
|
||||
* Run by executing: npm run docs
|
||||
*/
|
||||
|
||||
const deviceMapping = require('zigbee-shepherd-converters').devices;
|
||||
const fs = require('fs');
|
||||
const YAML = require('json2yaml');
|
||||
const homeassistant = require('../lib/homeassistant');
|
||||
|
||||
const outputdir = process.argv[2];
|
||||
|
||||
if (!outputdir) {
|
||||
console.error("Please specify an output directory");
|
||||
}
|
||||
|
||||
let file = 'Supported-devices.md';
|
||||
let text = '*NOTE: Automatically generated by `npm run docgen`*\n';
|
||||
text += `
|
||||
In case you own a Zigbee device which is **NOT** listed here, please see [How to support new devices](https://github.com/Koenkk/zigbee2mqtt/wiki/How-to-support-new-devices).
|
||||
\n`;
|
||||
|
||||
const logDevices = (devices) => {
|
||||
let result = '';
|
||||
result += '| Model | Description | Picture |\n';
|
||||
result += '| ------------- | ------------- | -------------------------- |\n';
|
||||
devices = new Map(devices.map((d) => [d.model, d]));
|
||||
devices.forEach((device) => {
|
||||
const pathFriendly = device.model.replace(new RegExp("/", "g"), '-').replace(new RegExp(":", "g"), '-').replace(new RegExp(" ", "g"), '-');
|
||||
result += `| ${device.model} | ${device.vendor} ${device.description} (${device.supports}) |  |\n`;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const vendors = Array.from(new Set(deviceMapping.map((d) => d.vendor)));
|
||||
vendors.sort();
|
||||
vendors.forEach((vendor) => {
|
||||
text += `### ${vendor}\n`;
|
||||
text += logDevices(deviceMapping.filter((d) => d.vendor === vendor));
|
||||
text += '\n';
|
||||
})
|
||||
|
||||
fs.writeFileSync(outputdir + '/' + file, text);
|
||||
|
||||
|
||||
file = 'Integrating-with-Home-Assistant.md';
|
||||
text = '*NOTE: Automatically generated by `npm run docgen`*\n\n';
|
||||
text += 'If you\'re hosting zigbee2mqtt using [this hassio addon-on](https://github.com/danielwelch/hassio-zigbee2mqtt) use their documentation on how to configure.\n\n'
|
||||
text += 'The easiest way to integrate zigbee2mqtt with Home Assistant is by using [MQTT discovery](https://www.home-assistant.io/docs/mqtt/discovery/).\n\n'
|
||||
text += 'To achieve the best possible integration (including MQTT discovery):\n';
|
||||
text += '- In your **zigbee2mqtt** `configuration.yaml` set `homeassistant: true`\n'
|
||||
text += '- In your **Home Assistant** `configuration.yaml`:\n';
|
||||
|
||||
text += '```yaml\n';
|
||||
text += 'mqtt:\n';
|
||||
text += ' discovery: true\n';
|
||||
text += ' broker: [YOUR MQTT BROKER] # Remove if you want to use builtin-in MQTT broker\n';
|
||||
text += ' birth_message:\n';
|
||||
text += ` topic: 'hass/status'\n`;
|
||||
text += ` payload: 'online'\n`;
|
||||
text += ' will_message:\n';
|
||||
text += ` topic: 'hass/status'\n`;
|
||||
text += ` payload: 'offline'\n`;
|
||||
text += '```';
|
||||
|
||||
text += '\n\n'
|
||||
|
||||
text += 'To respond to button clicks (e.g. WXKG01LM) you can use the following Home Assistant configuration:\n'
|
||||
text += '```yaml'
|
||||
text += `
|
||||
automation:
|
||||
- alias: Respond to button clicks
|
||||
trigger:
|
||||
platform: mqtt
|
||||
topic: 'zigbee2mqtt/<FRIENDLY_NAME'
|
||||
condition:
|
||||
condition: template
|
||||
value_template: "{{ 'single' == trigger.payload_json.click }}"
|
||||
action:
|
||||
entity_id: light.bedroom
|
||||
service: light.toggle
|
||||
`
|
||||
text += '```\n'
|
||||
text += '**When changing a `friendly_name` for a device you first have to start zigbee2mqtt and after that restart Home Assistant in order to discover the new device ID.**\n\n';
|
||||
text += 'In case you **dont** want to use Home Assistant MQTT discovery you can use the configuration below.\n\n'
|
||||
|
||||
const homeassistantConfig = (device) => {
|
||||
const payload = {
|
||||
platform: 'mqtt',
|
||||
state_topic: "zigbee2mqtt/<FRIENDLY_NAME>",
|
||||
availability_topic: "zigbee2mqtt/bridge/state",
|
||||
...device.discovery_payload,
|
||||
};
|
||||
|
||||
if (payload.command_topic) {
|
||||
if (payload.command_topic_prefix) {
|
||||
payload.command_topic = `zigbee2mqtt/<FRIENDLY_NAME>/${payload.command_topic_prefix}/set`;
|
||||
} else {
|
||||
payload.command_topic = `zigbee2mqtt/<FRIENDLY_NAME>/set`;
|
||||
}
|
||||
}
|
||||
|
||||
delete payload.command_topic_prefix;
|
||||
|
||||
let yml = YAML.stringify([payload]);
|
||||
yml = yml.replace(/(-) \n /g, '- ');
|
||||
yml = yml.replace('---', `${device.type}:`)
|
||||
return yml;
|
||||
}
|
||||
|
||||
deviceMapping.forEach((device) => {
|
||||
text += `### ${device.model}\n`;
|
||||
text += '```yaml\n'
|
||||
|
||||
const configurations = homeassistant.mapping[device.model]
|
||||
configurations.forEach((d, i) => {
|
||||
text += homeassistantConfig(d);
|
||||
if (configurations.length > 1 && i < configurations.length - 1) {
|
||||
text += '\n';
|
||||
}
|
||||
})
|
||||
|
||||
text += '```\n\n';
|
||||
});
|
||||
|
||||
fs.writeFileSync(outputdir + '/' + file, text);
|
||||
@@ -1,13 +0,0 @@
|
||||
// Verify that there are homeassistant mappings for every device.
|
||||
const devices = require('zigbee-shepherd-converters').devices;
|
||||
const homeassistant = require('../lib/homeassistant');
|
||||
|
||||
let failed = false;
|
||||
devices.forEach((d) => {
|
||||
if (!homeassistant.mapping[d.model]) {
|
||||
console.error(`Missing homeassistant mapping for '${d.model}'`);
|
||||
failed = true;
|
||||
}
|
||||
});
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,174 @@
|
||||
const BridgeConfig = require('../lib/extension/bridgeConfig');
|
||||
const settings = require('../lib/util/settings');
|
||||
const fs = require('../lib/util/fs');
|
||||
const objectAssignDeep = require('object-assign-deep');
|
||||
const data = require('../lib/util/data');
|
||||
const configurationFile = data.joinPath('configuration.yaml');
|
||||
const utils = require('./utils');
|
||||
|
||||
const mqtt = {
|
||||
subscribe: (topic) => {},
|
||||
log: (type, message) => {},
|
||||
};
|
||||
|
||||
describe('BridgeConfig', () => {
|
||||
let bridgeConfig;
|
||||
const write = (file, json) => fs.writeYaml(file, json);
|
||||
const read = (file) => fs.readYaml(file);
|
||||
const files = new Map();
|
||||
|
||||
beforeAll(() => {
|
||||
utils.stubLogger(jest);
|
||||
jest.spyOn(fs, 'readYaml').mockImplementation((file) => {
|
||||
if (files.has(file)) return objectAssignDeep.noMutate(files.get(file));
|
||||
throw new Error(`Fake file not found: ${file}`);
|
||||
});
|
||||
jest.spyOn(fs, 'readYamlIfExists').mockImplementation((file) => {
|
||||
if (files.has(file)) return objectAssignDeep.noMutate(files.get(file));
|
||||
return null;
|
||||
});
|
||||
jest.spyOn(fs, 'writeYaml').mockImplementation((file, content) => {
|
||||
files.set(file, objectAssignDeep.noMutate(content));
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
settings._clear();
|
||||
files.clear();
|
||||
bridgeConfig = new BridgeConfig(null, mqtt, null, null);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.readYaml.mockRestore();
|
||||
fs.readYamlIfExists.mockRestore();
|
||||
fs.writeYaml.mockRestore();
|
||||
});
|
||||
|
||||
it('Setting elapsed false', async () => {
|
||||
write(configurationFile, {advanced: {elapsed: true}});
|
||||
bridgeConfig.onMQTTMessage('zigbee2mqtt/bridge/config/elapsed', 'false');
|
||||
const expected = {
|
||||
advanced: {
|
||||
elapsed: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(read(configurationFile)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Add groups when there are none', async () => {
|
||||
write(configurationFile, {});
|
||||
bridgeConfig.onMQTTMessage('zigbee2mqtt/bridge/config/add_group', 'test123');
|
||||
const expected = {
|
||||
groups: {
|
||||
'1': {
|
||||
friendly_name: 'test123',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(read(configurationFile)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Add groups when there are', async () => {
|
||||
write(configurationFile, {
|
||||
groups: {
|
||||
'1': {
|
||||
friendly_name: 'test123',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
bridgeConfig.onMQTTMessage('zigbee2mqtt/bridge/config/add_group', 'test1234');
|
||||
const expected = {
|
||||
groups: {
|
||||
'1': {
|
||||
friendly_name: 'test123',
|
||||
},
|
||||
'2': {
|
||||
friendly_name: 'test1234',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(read(configurationFile)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Remove group', async () => {
|
||||
write(configurationFile, {
|
||||
groups: {
|
||||
'1': {
|
||||
friendly_name: 'test123',
|
||||
},
|
||||
'2': {
|
||||
friendly_name: 'test1234',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
bridgeConfig.onMQTTMessage('zigbee2mqtt/bridge/config/remove_group', 'test123');
|
||||
const expected = {
|
||||
groups: {
|
||||
'2': {
|
||||
friendly_name: 'test1234',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(read(configurationFile)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Get groups', async () => {
|
||||
jest.spyOn(mqtt, 'log').mockImplementation((type, message) => {
|
||||
expect(type).toBe('groups');
|
||||
expect(Object.keys(message)).toHaveLength(2);
|
||||
expect(message['1'].friendly_name).toBe('test1');
|
||||
expect(message['4711'].friendly_name).toBe('test42');
|
||||
});
|
||||
|
||||
write(configurationFile, {
|
||||
groups: {
|
||||
'1': {
|
||||
friendly_name: 'test1',
|
||||
},
|
||||
'4711': {
|
||||
friendly_name: 'test42',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
bridgeConfig.onMQTTMessage('zigbee2mqtt/bridge/config/groups', 'whatever');
|
||||
});
|
||||
|
||||
it('Rename device with é', async () => {
|
||||
jest.spyOn(mqtt, 'log').mockImplementation((type, message) => {
|
||||
expect(type).toBe('device_renamed');
|
||||
});
|
||||
|
||||
write(configurationFile, {
|
||||
devices: {
|
||||
'0x12345678': {
|
||||
friendly_name: 'test123',
|
||||
retain: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const payload = {
|
||||
old: 'test123',
|
||||
new: 'tést123',
|
||||
};
|
||||
|
||||
bridgeConfig.onMQTTMessage('zigbee2mqtt/bridge/config/rename', JSON.stringify(payload));
|
||||
const expected = {
|
||||
devices: {
|
||||
'0x12345678': {
|
||||
friendly_name: 'tést123',
|
||||
retain: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(read(configurationFile)).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
const Controller = require('../lib/controller');
|
||||
const settings = require('../lib/util/settings');
|
||||
const mqtt = require('../lib/mqtt');
|
||||
const utils = require('./utils');
|
||||
|
||||
describe('Controller', () => {
|
||||
let controller;
|
||||
let mqttPublish;
|
||||
|
||||
beforeEach(() => {
|
||||
utils.stubLogger(jest);
|
||||
jest.spyOn(settings, 'getDevice').mockReturnValue({friendly_name: 'test'});
|
||||
mqttPublish = jest.spyOn(mqtt.prototype, 'publish').mockReturnValue(undefined);
|
||||
controller = new Controller();
|
||||
controller.zigbee = {
|
||||
getDevice: () => {
|
||||
return {
|
||||
modelId: 'TRADFRI bulb E27 CWS opal 600lm',
|
||||
manufName: 'IKEA',
|
||||
hwVersion: '1.1',
|
||||
swBuildId: '2.0',
|
||||
dateCode: '2018-01-01',
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Handling zigbee messages', () => {
|
||||
it('Should handle a zigbee message', () => {
|
||||
const device = {ieeeAddr: '0x12345678', modelId: 'TRADFRI bulb E27 CWS opal 600lm'};
|
||||
const message = utils.zigbeeMessage(device, 'genOnOff', 'devChange', {onOff: 1}, 1);
|
||||
controller.onZigbeeMessage(message);
|
||||
expect(mqttPublish).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublish.mock.calls[0][1]).toBe(JSON.stringify({state: 'ON'}));
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message when include_device_information is set', () => {
|
||||
jest.spyOn(settings, 'get').mockReturnValue({
|
||||
mqtt: {
|
||||
include_device_information: true,
|
||||
},
|
||||
advanced: {
|
||||
cache_state: false,
|
||||
last_seen: 'disable',
|
||||
},
|
||||
experimental: {
|
||||
output: 'json',
|
||||
},
|
||||
});
|
||||
|
||||
const payload = {
|
||||
'state': 'ON',
|
||||
'device': {
|
||||
'ieeeAddr': '0x12345678',
|
||||
'friendlyName': 'test',
|
||||
'manufName': 'IKEA',
|
||||
'modelId': 'TRADFRI bulb E27 CWS opal 600lm',
|
||||
'hwVersion': '1.1',
|
||||
'swBuildId': '2.0',
|
||||
'dateCode': '2018-01-01',
|
||||
},
|
||||
};
|
||||
|
||||
const device = {ieeeAddr: '0x12345678', modelId: 'TRADFRI bulb E27 CWS opal 600lm'};
|
||||
const message = utils.zigbeeMessage(device, 'genOnOff', 'devChange', {onOff: 1}, 1);
|
||||
controller.onZigbeeMessage(message);
|
||||
expect(mqttPublish).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(mqttPublish.mock.calls[0][1])).toStrictEqual(payload);
|
||||
});
|
||||
|
||||
it('Should output to json by default', () => {
|
||||
const payload = {temperature: 1, humidity: 2};
|
||||
controller.publishEntityState('0x12345678', payload);
|
||||
expect(mqttPublish).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(mqttPublish.mock.calls[0][1])).toStrictEqual(payload);
|
||||
});
|
||||
|
||||
it('Should output to attribute', () => {
|
||||
jest.spyOn(settings, 'get').mockReturnValue({
|
||||
mqtt: {
|
||||
include_device_information: false,
|
||||
},
|
||||
advanced: {
|
||||
cache_state: false,
|
||||
},
|
||||
experimental: {
|
||||
output: 'attribute',
|
||||
},
|
||||
});
|
||||
|
||||
const payload = {
|
||||
temperature: 1,
|
||||
humidity: 2,
|
||||
state: 'ON',
|
||||
allowedStates: ['ON', 'OFF'],
|
||||
color: {r: 100, g: 0, b: 102, a: 0},
|
||||
nested: {
|
||||
state: 'OFF',
|
||||
color: {r: 1, g: 0, b: 2},
|
||||
},
|
||||
};
|
||||
controller.publishEntityState('0x12345678', payload);
|
||||
expect(mqttPublish.mock.calls.map((x) => [x[0], x[1]])).toEqual([
|
||||
['test/temperature', '1'],
|
||||
['test/humidity', '2'],
|
||||
['test/state', 'ON'],
|
||||
['test/allowedStates', 'ON,OFF'],
|
||||
['test/color', '100,0,102'],
|
||||
['test/nested-state', 'OFF'],
|
||||
['test/nested-color', '1,0,2'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('Should cache state', () => {
|
||||
jest.spyOn(settings, 'get').mockReturnValue({
|
||||
mqtt: {
|
||||
include_device_information: false,
|
||||
},
|
||||
advanced: {
|
||||
cache_state: true,
|
||||
},
|
||||
experimental: {
|
||||
output: 'json',
|
||||
},
|
||||
});
|
||||
|
||||
controller.publishEntityState('0x12345678', {temperature: 1});
|
||||
controller.publishEntityState('0x12345678', {humidity: 2});
|
||||
controller.publishEntityState('0x12345678', {humidity: 5, temperature: 4});
|
||||
controller.publishEntityState('0x12345678', {temperature: 9});
|
||||
controller.publishEntityState('0x12345678', {temperature: 3});
|
||||
expect(mqttPublish).toHaveBeenCalledTimes(5);
|
||||
expect(JSON.parse(mqttPublish.mock.calls[0][1])).toStrictEqual({temperature: 1});
|
||||
expect(JSON.parse(mqttPublish.mock.calls[1][1])).toStrictEqual({temperature: 1, humidity: 2});
|
||||
expect(JSON.parse(mqttPublish.mock.calls[2][1])).toStrictEqual({temperature: 4, humidity: 5});
|
||||
expect(JSON.parse(mqttPublish.mock.calls[3][1])).toStrictEqual({temperature: 9, humidity: 5});
|
||||
expect(JSON.parse(mqttPublish.mock.calls[4][1])).toStrictEqual({temperature: 3, humidity: 5});
|
||||
});
|
||||
|
||||
it('Shouldnt cache state', () => {
|
||||
jest.spyOn(settings, 'get').mockReturnValue({
|
||||
mqtt: {
|
||||
include_device_information: false,
|
||||
},
|
||||
advanced: {
|
||||
cache_state: false,
|
||||
},
|
||||
experimental: {
|
||||
output: 'json',
|
||||
},
|
||||
});
|
||||
|
||||
controller.publishEntityState('0x12345678', {temperature: 1});
|
||||
controller.publishEntityState('0x12345678', {humidity: 2});
|
||||
controller.publishEntityState('0x12345678', {temperature: 3});
|
||||
expect(mqttPublish).toHaveBeenCalledTimes(3);
|
||||
expect(JSON.parse(mqttPublish.mock.calls[0][1])).toStrictEqual({temperature: 1});
|
||||
expect(JSON.parse(mqttPublish.mock.calls[1][1])).toStrictEqual({humidity: 2});
|
||||
expect(JSON.parse(mqttPublish.mock.calls[2][1])).toStrictEqual({temperature: 3});
|
||||
});
|
||||
|
||||
it('Should not send empty messages', () => {
|
||||
jest.spyOn(settings, 'get').mockReturnValue({
|
||||
mqtt: {
|
||||
include_device_information: false,
|
||||
},
|
||||
advanced: {
|
||||
cache_state: true,
|
||||
},
|
||||
experimental: {
|
||||
output: 'json',
|
||||
},
|
||||
});
|
||||
|
||||
controller.publishEntityState('0x12345678', {});
|
||||
expect(mqttPublish).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
+13
-9
@@ -1,23 +1,27 @@
|
||||
/* global describe, it */
|
||||
|
||||
const chai = require('chai');
|
||||
const proxyquire = require('proxyquire').noPreserveCache();
|
||||
const data = () => proxyquire('../lib/util/data.js', {});
|
||||
const data = require('../lib/util/data.js');
|
||||
const path = require('path');
|
||||
const utils = require('./utils');
|
||||
|
||||
describe('Data', () => {
|
||||
describe('Get path', () => {
|
||||
beforeEach(() => {
|
||||
utils.stubLogger(jest);
|
||||
});
|
||||
|
||||
it('Should return correct path', () => {
|
||||
const expected = path.normalize(path.join(__dirname, '..', 'data'));
|
||||
const actual = data().getPath();
|
||||
chai.assert.strictEqual(actual, expected);
|
||||
const actual = data.getPath();
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it('Should return correct path when ZIGBEE2MQTT_DATA set', () => {
|
||||
const expected = path.join('var', 'zigbee2mqtt');
|
||||
process.env.ZIGBEE2MQTT_DATA = expected;
|
||||
const actual = data().getPath();
|
||||
chai.assert.strictEqual(actual, expected);
|
||||
data._reload();
|
||||
const actual = data.getPath();
|
||||
expect(actual).toBe(expected);
|
||||
delete process.env.ZIGBEE2MQTT_DATA;
|
||||
data._reload();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const Availability = require('../lib/extension/deviceAvailability');
|
||||
const utils = require('./utils');
|
||||
|
||||
describe('Availability', () => {
|
||||
let availability;
|
||||
|
||||
beforeEach(() => {
|
||||
utils.stubLogger(jest);
|
||||
availability = new Availability(null, null, null, () => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Determine pingable devices', () => {
|
||||
it('Router device should be a pingable device', () => {
|
||||
const device = {
|
||||
powerSource: 'Mains (single phase)',
|
||||
type: 'Router',
|
||||
};
|
||||
|
||||
expect(availability.isPingable(device)).toBe(true);
|
||||
});
|
||||
|
||||
it('Battery device should not be a pingable device', () => {
|
||||
const device = {
|
||||
powerSource: 'Battery',
|
||||
type: 'EndDevice',
|
||||
};
|
||||
|
||||
expect(availability.isPingable(device)).toBe(false);
|
||||
});
|
||||
|
||||
it('E11-G13 should be a pingable device', () => {
|
||||
const device = {
|
||||
powerSource: 'Mains (single phase)',
|
||||
type: 'EndDevice',
|
||||
modelId: 'E11-G13',
|
||||
manufId: 4448,
|
||||
};
|
||||
|
||||
expect(availability.isPingable(device)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
const DeviceBind = require('../lib/extension/deviceBind');
|
||||
const utils = require('./utils');
|
||||
const settings = require('../lib/util/settings');
|
||||
|
||||
const mqtt = {
|
||||
subscribe: () => {},
|
||||
log: () => {},
|
||||
};
|
||||
|
||||
const devices = {
|
||||
bulb: {
|
||||
getSimpleDesc: () => {
|
||||
return {inClusterList: [6, 8]};
|
||||
},
|
||||
},
|
||||
remote: {
|
||||
getSimpleDesc: () => {
|
||||
return {outClusterList: [5, 6, 8]};
|
||||
},
|
||||
},
|
||||
switch_ep2: {
|
||||
getSimpleDesc: () => {
|
||||
return {outClusterList: [5, 6]};
|
||||
},
|
||||
},
|
||||
switch_ep3: {
|
||||
getSimpleDesc: () => {
|
||||
return {inClusterList: [5, 6]};
|
||||
},
|
||||
},
|
||||
occupancy_sensor: {
|
||||
getSimpleDesc: () => {
|
||||
return {outClusterList: [5, 6]};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const zigbee = {
|
||||
bind: jest.fn((ep, cluster, target, callback) => {
|
||||
callback(false, null);
|
||||
}),
|
||||
unbind: jest.fn((ep, cluster, target, callback) => {
|
||||
callback(false, null);
|
||||
}),
|
||||
getEndpoint: (ID, ep) => {
|
||||
if (ID === 'bulb' || ID === '0x002') {
|
||||
return devices.bulb;
|
||||
} else if (ID === 'remote' || ID === '0x001') {
|
||||
return devices.remote;
|
||||
} else if (ep == 2 && ID === 'switch_ep2') {
|
||||
return devices.switch_ep2;
|
||||
} else if (ep == 3 && ID === 'switch_ep3') {
|
||||
return devices.switch_ep3;
|
||||
} else if (ep == 2 && ID == 'occupancy_sensor') {
|
||||
return devices.occupancy_sensor;
|
||||
}
|
||||
|
||||
throw new Error(`No mock for ${ID} and ep ${ep}`);
|
||||
},
|
||||
getDevice: (ID) => {
|
||||
const lookup = {
|
||||
'occupancy_sensor': 'SML002',
|
||||
'switch_ep2': 'lumi.sensor_86sw2.es1',
|
||||
'switch_ep3': 'DNCKAT_S003',
|
||||
'bulb': 'TRADFRI bulb E27 WS opal 980lm',
|
||||
'remote': 'TRADFRI remote control',
|
||||
'0x002': 'TRADFRI bulb E27 WS opal 980lm',
|
||||
'0x001': 'TRADFRI remote control',
|
||||
};
|
||||
|
||||
if (lookup.hasOwnProperty(ID)) {
|
||||
return {modelId: lookup[ID]};
|
||||
}
|
||||
|
||||
throw new Error(`No mock for ${ID}`);
|
||||
},
|
||||
};
|
||||
|
||||
describe('DeviceBind', () => {
|
||||
let deviceBind;
|
||||
|
||||
beforeEach(() => {
|
||||
utils.stubLogger(jest);
|
||||
deviceBind = new DeviceBind(zigbee, mqtt, null, null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
zigbee.bind.mockClear();
|
||||
zigbee.unbind.mockClear();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Bind devices', () => {
|
||||
it('Bind', async () => {
|
||||
deviceBind.onMQTTMessage('zigbee2mqtt/bridge/bind/remote', 'bulb');
|
||||
expect(zigbee.bind).toHaveBeenCalledTimes(2);
|
||||
expect(zigbee.bind).toHaveBeenNthCalledWith(1,
|
||||
devices.remote,
|
||||
6,
|
||||
devices.bulb,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(zigbee.bind).toHaveBeenNthCalledWith(2,
|
||||
devices.remote,
|
||||
8,
|
||||
devices.bulb,
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Bind by friendly name', async () => {
|
||||
jest.spyOn(settings, 'getIeeeAddrByFriendlyName').mockImplementation((friendlyName) => {
|
||||
const lookup = {
|
||||
remote: '0x001',
|
||||
bulb: '0x002',
|
||||
};
|
||||
|
||||
return lookup[friendlyName];
|
||||
});
|
||||
|
||||
deviceBind.onMQTTMessage('zigbee2mqtt/bridge/bind/remote', 'bulb');
|
||||
|
||||
expect(zigbee.bind).toHaveBeenCalledTimes(2);
|
||||
expect(zigbee.bind).toHaveBeenNthCalledWith(1,
|
||||
devices.remote,
|
||||
6,
|
||||
devices.bulb,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(zigbee.bind).toHaveBeenNthCalledWith(2,
|
||||
devices.remote,
|
||||
8,
|
||||
devices.bulb,
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Bind non default ep', async () => {
|
||||
deviceBind.onMQTTMessage('zigbee2mqtt/bridge/bind/switch_ep2/right', 'bulb');
|
||||
expect(zigbee.bind).toHaveBeenCalledTimes(1);
|
||||
expect(zigbee.bind).toHaveBeenNthCalledWith(1,
|
||||
devices.switch_ep2,
|
||||
6,
|
||||
devices.bulb,
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Bind non default ep to target with non default ep', async () => {
|
||||
deviceBind.onMQTTMessage('zigbee2mqtt/bridge/bind/switch_ep2/right', 'switch_ep3/right');
|
||||
expect(zigbee.bind).toHaveBeenCalledTimes(2);
|
||||
expect(zigbee.bind).toHaveBeenNthCalledWith(1,
|
||||
devices.switch_ep2,
|
||||
5,
|
||||
devices.switch_ep3,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(zigbee.bind).toHaveBeenNthCalledWith(2,
|
||||
devices.switch_ep2,
|
||||
6,
|
||||
devices.switch_ep3,
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Bind default ep when mapped', async () => {
|
||||
deviceBind.onMQTTMessage('zigbee2mqtt/bridge/bind/occupancy_sensor', 'bulb');
|
||||
expect(zigbee.bind).toHaveBeenCalledTimes(1);
|
||||
expect(zigbee.bind).toHaveBeenNthCalledWith(1,
|
||||
devices.occupancy_sensor,
|
||||
6,
|
||||
devices.bulb,
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unbind devices', () => {
|
||||
it('Unbind', async () => {
|
||||
deviceBind.onMQTTMessage('zigbee2mqtt/bridge/unbind/remote', 'bulb');
|
||||
expect(zigbee.unbind).toHaveBeenCalledTimes(2);
|
||||
expect(zigbee.unbind).toHaveBeenNthCalledWith(1,
|
||||
devices.remote,
|
||||
6,
|
||||
devices.bulb,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(zigbee.unbind).toHaveBeenNthCalledWith(2,
|
||||
devices.remote,
|
||||
8,
|
||||
devices.bulb,
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,278 @@
|
||||
const DeviceReceive = require('../lib/extension/deviceReceive');
|
||||
const settings = require('../lib/util/settings');
|
||||
const devices = require('zigbee-shepherd-converters').devices;
|
||||
const utils = require('./utils');
|
||||
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Devices
|
||||
const WXKG11LM = devices.find((d) => d.model === 'WXKG11LM');
|
||||
const WXKG02LM = devices.find((d) => d.model === 'WXKG02LM');
|
||||
const WSDCGQ11LM = devices.find((d) => d.model === 'WSDCGQ11LM');
|
||||
const RTCGQ11LM = devices.find((d) => d.model === 'RTCGQ11LM');
|
||||
const ZNCZ02LM = devices.find((d) => d.model === 'ZNCZ02LM');
|
||||
|
||||
const mqtt = {
|
||||
log: () => {},
|
||||
};
|
||||
|
||||
describe('DeviceReceive', () => {
|
||||
let deviceReceive;
|
||||
let publishEntityState;
|
||||
|
||||
beforeEach(() => {
|
||||
utils.stubLogger(jest);
|
||||
jest.spyOn(settings, 'addDevice').mockReturnValue(undefined);
|
||||
publishEntityState = jest.fn();
|
||||
deviceReceive = new DeviceReceive(null, mqtt, null, publishEntityState);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Handling zigbee messages', () => {
|
||||
it('Should handle a zigbee message', () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1}, 1);
|
||||
deviceReceive.onZigbeeMessage(message, device, WXKG11LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({click: 'single'});
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message and counter it when Home Assistant integration is enabled', () => {
|
||||
jest.spyOn(settings, 'get').mockReturnValue({homeassistant: true, advanced: {last_seen: 'disable'}});
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1}, 1);
|
||||
deviceReceive.onZigbeeMessage(message, device, WXKG11LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(2);
|
||||
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({click: 'single'});
|
||||
expect(publishEntityState.mock.calls[1][1]).toStrictEqual({click: ''});
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message which uses ep (left)', () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1}, 1);
|
||||
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({click: 'left'});
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message which uses ep (right)', () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1}, 2);
|
||||
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({click: 'right'});
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message with default precision', () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
const message = utils.zigbeeMessage(
|
||||
device, 'msTemperatureMeasurement', 'attReport', {measuredValue: -85}, 1
|
||||
);
|
||||
deviceReceive.onZigbeeMessage(message, device, WSDCGQ11LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({temperature: -0.85});
|
||||
});
|
||||
|
||||
it('Should debounce messages', async () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
jest.spyOn(settings, 'getDevice').mockReturnValue({debounce: 0.1});
|
||||
const message1 = utils.zigbeeMessage(
|
||||
device, 'msTemperatureMeasurement', 'attReport', {measuredValue: 8}, 1
|
||||
);
|
||||
const message2 = utils.zigbeeMessage(
|
||||
device, 'msRelativeHumidity', 'attReport', {measuredValue: 1}, 1
|
||||
);
|
||||
const message3 = utils.zigbeeMessage(
|
||||
device, 'msPressureMeasurement', 'attReport', {measuredValue: 2}, 1
|
||||
);
|
||||
deviceReceive.onZigbeeMessage(message1, device, WSDCGQ11LM);
|
||||
deviceReceive.onZigbeeMessage(message2, device, WSDCGQ11LM);
|
||||
deviceReceive.onZigbeeMessage(message3, device, WSDCGQ11LM);
|
||||
await wait(200);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
|
||||
|
||||
deviceReceive.onZigbeeMessage(message1, device, WSDCGQ11LM);
|
||||
await wait(200);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(2);
|
||||
expect(publishEntityState.mock.calls[1][1]).toStrictEqual({temperature: 0.08});
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message with 1 precision', () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
jest.spyOn(settings, 'getDevice').mockReturnValue({temperature_precision: 1});
|
||||
const message = utils.zigbeeMessage(
|
||||
device, 'msTemperatureMeasurement', 'attReport', {measuredValue: -85}, 1
|
||||
);
|
||||
deviceReceive.onZigbeeMessage(message, device, WSDCGQ11LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({temperature: -0.8});
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message with 0 precision', () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
jest.spyOn(settings, 'getDevice').mockReturnValue({temperature_precision: 0});
|
||||
const message = utils.zigbeeMessage(
|
||||
device, 'msTemperatureMeasurement', 'attReport', {measuredValue: -85}, 1
|
||||
);
|
||||
deviceReceive.onZigbeeMessage(message, device, WSDCGQ11LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({temperature: -1});
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message with 1 precision when set via device_options', () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
jest.spyOn(settings, 'get').mockReturnValue({
|
||||
device_options: {
|
||||
temperature_precision: 1,
|
||||
},
|
||||
advanced: {
|
||||
last_seen: 'disable',
|
||||
},
|
||||
});
|
||||
jest.spyOn(settings, 'getDevice').mockReturnValue({});
|
||||
const message = utils.zigbeeMessage(
|
||||
device, 'msTemperatureMeasurement', 'attReport', {measuredValue: -85}, 1
|
||||
);
|
||||
deviceReceive.onZigbeeMessage(message, device, WSDCGQ11LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({temperature: -0.8});
|
||||
}
|
||||
);
|
||||
|
||||
it('Should handle a zigbee message with 2 precision when overrides device_options', () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
jest.spyOn(settings, 'get').mockReturnValue({
|
||||
device_options: {
|
||||
temperature_precision: 1,
|
||||
},
|
||||
advanced: {
|
||||
last_seen: 'disable',
|
||||
},
|
||||
});
|
||||
jest.spyOn(settings, 'getDevice').mockReturnValue({
|
||||
temperature_precision: 2,
|
||||
});
|
||||
const message = utils.zigbeeMessage(
|
||||
device, 'msTemperatureMeasurement', 'attReport', {measuredValue: -85}, 1
|
||||
);
|
||||
deviceReceive.onZigbeeMessage(message, device, WSDCGQ11LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({temperature: -0.85});
|
||||
}
|
||||
);
|
||||
|
||||
it('Should handle a zigbee message with voltage 3010', () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
const message = utils.zigbeeMessage(device, 'genBasic', 'attReport', {'65281': {'1': 3010}}, 1);
|
||||
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
const expected = {battery: 100, voltage: 3010};
|
||||
expect(publishEntityState.mock.calls[0][1]).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message with voltage 2850', () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
const message = utils.zigbeeMessage(device, 'genBasic', 'attReport', {'65281': {'1': 2850}}, 1);
|
||||
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
const expected = {battery: 35, voltage: 2850};
|
||||
expect(publishEntityState.mock.calls[0][1]).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message with voltage 2650', () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
const message = utils.zigbeeMessage(device, 'genBasic', 'attReport', {'65281': {'1': 2650}}, 1);
|
||||
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
const expected = {battery: 14, voltage: 2650};
|
||||
expect(publishEntityState.mock.calls[0][1]).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message with voltage 2000', () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
const message = utils.zigbeeMessage(device, 'genBasic', 'attReport', {'65281': {'1': 2000}}, 1);
|
||||
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
const expected = {battery: 0, voltage: 2000};
|
||||
expect(publishEntityState.mock.calls[0][1]).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Should publish 1 message when converted twice', () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
const payload = {
|
||||
'65281': {'1': 3045, '3': 19, '4': 17320, '5': 35, '6': [0, 3], '10': 51107, '11': 381, '100': 0},
|
||||
};
|
||||
const message = utils.zigbeeMessage(device, 'genBasic', 'attReport', payload, 1);
|
||||
deviceReceive.onZigbeeMessage(message, device, RTCGQ11LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
const expected = {'battery': 100, 'illuminance': 381, 'voltage': 3045};
|
||||
expect(publishEntityState.mock.calls[0][1]).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Should publish no message when converted without result', () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
const payload = {'9999': {'1': 3045}};
|
||||
const message = utils.zigbeeMessage(device, 'genBasic', 'attReport', payload, 1);
|
||||
deviceReceive.onZigbeeMessage(message, device, RTCGQ11LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should publish last_seen epoch', () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1}, 1);
|
||||
jest.spyOn(settings, 'get').mockReturnValue({
|
||||
advanced: {
|
||||
last_seen: 'epoch',
|
||||
},
|
||||
});
|
||||
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
expect(typeof publishEntityState.mock.calls[0][1].last_seen).toBe('number');
|
||||
});
|
||||
|
||||
it('Should publish last_seen ISO_8601', () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1}, 1);
|
||||
jest.spyOn(settings, 'get').mockReturnValue({
|
||||
advanced: {
|
||||
last_seen: 'ISO_8601',
|
||||
},
|
||||
});
|
||||
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
expect(typeof publishEntityState.mock.calls[0][1].last_seen).toBe('string');
|
||||
});
|
||||
|
||||
it('Should publish last_seen ISO_8601_local', () => {
|
||||
const device = {ieeeAddr: '0x12345678'};
|
||||
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1}, 1);
|
||||
jest.spyOn(settings, 'get').mockReturnValue({
|
||||
advanced: {
|
||||
last_seen: 'ISO_8601_local',
|
||||
},
|
||||
});
|
||||
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
expect(typeof publishEntityState.mock.calls[0][1].last_seen).toBe('string');
|
||||
});
|
||||
|
||||
it('Should not handle messages forwarded Xiaomi messages', () => {
|
||||
const device = {ieeeAddr: '0x12345678', manufId: 4151, type: 'Router'};
|
||||
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1}, 1, 599);
|
||||
deviceReceive.onZigbeeMessage(message, device, ZNCZ02LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should handle messages from Xiaomi router devices', () => {
|
||||
const device = {ieeeAddr: '0x12345678', manufId: 4151, type: 'Router'};
|
||||
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1});
|
||||
deviceReceive.onZigbeeMessage(message, device, ZNCZ02LM);
|
||||
expect(publishEntityState).toHaveBeenCalledTimes(1);
|
||||
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({state: 'ON'});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
const Groups = require('../lib/extension/groups');
|
||||
const settings = require('../lib/util/settings');
|
||||
const utils = require('./utils');
|
||||
|
||||
let groupExtension = null;
|
||||
let zigbee = null;
|
||||
|
||||
describe('Groups', () => {
|
||||
beforeEach(() => {
|
||||
utils.stubLogger(jest);
|
||||
|
||||
zigbee = {
|
||||
publish: jest.fn(),
|
||||
};
|
||||
|
||||
groupExtension = new Groups(zigbee, null, null, null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('Apply group updates add', async () => {
|
||||
zigbee.publish.mockClear();
|
||||
const from = {};
|
||||
const to = {'1': ['1', '2']};
|
||||
groupExtension.apply(from, to);
|
||||
expect(zigbee.publish).toHaveBeenCalledTimes(2);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'1', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
|
||||
);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'2', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Apply group updates remove', async () => {
|
||||
zigbee.publish.mockClear();
|
||||
const from = {'1': ['1', '2', '3']};
|
||||
const to = {'1': ['1']};
|
||||
groupExtension.apply(from, to);
|
||||
expect(zigbee.publish).toHaveBeenCalledTimes(2);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'3', 'device', 'genGroups', 'remove', 'functional',
|
||||
{groupid: '1'}, null, null, expect.any(Function)
|
||||
);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'2', 'device', 'genGroups', 'remove', 'functional',
|
||||
{groupid: '1'}, null, null, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Apply group updates add 1', async () => {
|
||||
zigbee.publish.mockClear();
|
||||
const from = {'1': ['1']};
|
||||
const to = {'1': ['1', '2', '3']};
|
||||
groupExtension.apply(from, to);
|
||||
expect(zigbee.publish).toHaveBeenCalledTimes(2);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'3', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
|
||||
);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'2', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Apply group updates add and remove group', async () => {
|
||||
zigbee.publish.mockClear();
|
||||
const from = {'1': ['1', '2']};
|
||||
const to = {'2': ['1', '2']};
|
||||
groupExtension.apply(from, to);
|
||||
expect(zigbee.publish).toHaveBeenCalledTimes(4);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'1', 'device', 'genGroups', 'remove', 'functional',
|
||||
{groupid: '1'}, null, null, expect.any(Function)
|
||||
);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'2', 'device', 'genGroups', 'remove', 'functional',
|
||||
{groupid: '1'}, null, null, expect.any(Function)
|
||||
);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'1', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '2', groupname: ''}, null, null, expect.any(Function)
|
||||
);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'2', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '2', groupname: ''}, null, null, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Apply group updates change 1', async () => {
|
||||
zigbee.publish.mockClear();
|
||||
const from = {'1': ['1', '4', '2']};
|
||||
const to = {'1': ['1', '2', '3']};
|
||||
groupExtension.apply(from, to);
|
||||
expect(zigbee.publish).toHaveBeenCalledTimes(2);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'3', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
|
||||
);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'4', 'device', 'genGroups', 'remove', 'functional',
|
||||
{groupid: '1'}, null, null, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Apply group updates change 2', async () => {
|
||||
zigbee.publish.mockClear();
|
||||
const from = {'1': ['1', '2', '3']};
|
||||
const to = {'1': ['3', '1', '2', '4']};
|
||||
groupExtension.apply(from, to);
|
||||
expect(zigbee.publish).toHaveBeenCalledTimes(1);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'4', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Apply group updates change 3', async () => {
|
||||
zigbee.publish.mockClear();
|
||||
const from = {'1': ['1', '2']};
|
||||
const to = {'1': ['3', '1', '4', '2']};
|
||||
groupExtension.apply(from, to);
|
||||
expect(zigbee.publish).toHaveBeenCalledTimes(2);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'3', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
|
||||
);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'4', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Apply group updates change 4', async () => {
|
||||
zigbee.publish.mockClear();
|
||||
const from = {'1': ['1']};
|
||||
const to = {'2': ['3', '1']};
|
||||
groupExtension.apply(from, to);
|
||||
expect(zigbee.publish).toHaveBeenCalledTimes(3);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'1', 'device', 'genGroups', 'remove', 'functional',
|
||||
{groupid: '1'}, null, null, expect.any(Function)
|
||||
);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'1', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '2', groupname: ''}, null, null, expect.any(Function)
|
||||
);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'3', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '2', groupname: ''}, null, null, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Apply group updates change 5', async () => {
|
||||
zigbee.publish.mockClear();
|
||||
const from = {'1': ['1']};
|
||||
const to = {'1': ['3'], '2': ['3', '1']};
|
||||
groupExtension.apply(from, to);
|
||||
expect(zigbee.publish).toHaveBeenCalledTimes(4);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'1', 'device', 'genGroups', 'remove', 'functional',
|
||||
{groupid: '1'}, null, null, expect.any(Function)
|
||||
);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'3', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
|
||||
);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'1', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '2', groupname: ''}, null, null, expect.any(Function)
|
||||
);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'3', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '2', groupname: ''}, null, null, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Apply group updates add with postfix', async () => {
|
||||
zigbee.publish.mockClear();
|
||||
zigbee.getDevice = () => ({modelId: 'lumi.ctrl_neutral2'});
|
||||
zigbee.getEndpoint = (entityID, ep) => ({epId: ep});
|
||||
const from = {};
|
||||
const to = {'1': ['0x12345689/right']};
|
||||
groupExtension.apply(from, to);
|
||||
expect(zigbee.publish).toHaveBeenCalledTimes(1);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'0x12345689', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '1', groupname: ''}, null, 3, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Apply group updates add and remove with postfix', async () => {
|
||||
zigbee.publish.mockClear();
|
||||
zigbee.getDevice = () => ({modelId: 'lumi.ctrl_neutral2'});
|
||||
zigbee.getEndpoint = (entityID, ep) => ({epId: ep});
|
||||
const from = {'1': ['0x12345689/right']};
|
||||
const to = {'1': ['0x12345689'], '2': ['0x12345689/left']};
|
||||
groupExtension.apply(from, to);
|
||||
expect(zigbee.publish).toHaveBeenCalledTimes(3);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'0x12345689', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '2', groupname: ''}, null, 2, expect.any(Function)
|
||||
);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'0x12345689', 'device', 'genGroups', 'remove', 'functional',
|
||||
{groupid: '1'}, null, 3, expect.any(Function)
|
||||
);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'0x12345689', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Add to group via MQTT', async () => {
|
||||
zigbee.publish.mockClear();
|
||||
zigbee.getDevice = () => ({modelId: 'lumi.ctrl_neutral2'});
|
||||
zigbee.getEndpoint = (entityID, ep) => ({epId: ep});
|
||||
jest.spyOn(settings, 'getGroupIDByFriendlyName').mockReturnValue(1);
|
||||
jest.spyOn(settings, 'getIeeeAddrByFriendlyName').mockReturnValue('0x12345689');
|
||||
groupExtension.onMQTTMessage('zigbee2mqtt/bridge/group/my_group/add', 'my_switch');
|
||||
expect(zigbee.publish).toHaveBeenCalledTimes(1);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'0x12345689', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Add to group via MQTT with postfix', async () => {
|
||||
zigbee.publish.mockClear();
|
||||
zigbee.getDevice = () => ({modelId: 'lumi.ctrl_neutral2'});
|
||||
zigbee.getEndpoint = (entityID, ep) => ({epId: ep});
|
||||
jest.spyOn(settings, 'getGroupIDByFriendlyName').mockReturnValue(1);
|
||||
jest.spyOn(settings, 'getIeeeAddrByFriendlyName').mockReturnValue('0x12345689');
|
||||
groupExtension.onMQTTMessage('zigbee2mqtt/bridge/group/my_group/add', 'my_switch/right');
|
||||
expect(zigbee.publish).toHaveBeenCalledTimes(1);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'0x12345689', 'device', 'genGroups', 'add', 'functional',
|
||||
{groupid: '1', groupname: ''}, null, 3, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Remove from group via MQTT with postfix', async () => {
|
||||
zigbee.publish.mockClear();
|
||||
zigbee.getDevice = () => ({modelId: 'lumi.ctrl_neutral2'});
|
||||
zigbee.getEndpoint = (entityID, ep) => ({epId: ep});
|
||||
jest.spyOn(settings, 'getGroupIDByFriendlyName').mockReturnValue(1);
|
||||
jest.spyOn(settings, 'getIeeeAddrByFriendlyName').mockReturnValue('0x12345689');
|
||||
groupExtension.onMQTTMessage('zigbee2mqtt/bridge/group/my_group/remove', 'my_switch/left');
|
||||
expect(zigbee.publish).toHaveBeenCalledTimes(1);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'0x12345689', 'device', 'genGroups', 'remove', 'functional',
|
||||
{groupid: '1'}, null, 2, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Remove all group via MQTT', async () => {
|
||||
zigbee.publish.mockClear();
|
||||
zigbee.getDevice = () => ({modelId: 'lumi.ctrl_neutral2'});
|
||||
zigbee.getEndpoint = (entityID, ep) => ({epId: ep});
|
||||
jest.spyOn(settings, 'getIeeeAddrByFriendlyName').mockReturnValue('0x12345689');
|
||||
groupExtension.onMQTTMessage('zigbee2mqtt/bridge/group/remove_all', 'my_switch');
|
||||
expect(zigbee.publish).toHaveBeenCalledTimes(1);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'0x12345689', 'device', 'genGroups', 'removeAll', 'functional',
|
||||
{}, null, null, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Remove all group via MQTT deprecated', async () => {
|
||||
zigbee.publish.mockClear();
|
||||
zigbee.getDevice = () => ({modelId: 'lumi.ctrl_neutral2'});
|
||||
zigbee.getEndpoint = (entityID, ep) => ({epId: ep});
|
||||
jest.spyOn(settings, 'getGroupIDByFriendlyName').mockReturnValue(1);
|
||||
jest.spyOn(settings, 'getIeeeAddrByFriendlyName').mockReturnValue('0x12345689');
|
||||
groupExtension.onMQTTMessage('zigbee2mqtt/bridge/group/my_group/remove_all', 'my_switch');
|
||||
expect(zigbee.publish).toHaveBeenCalledTimes(1);
|
||||
expect(zigbee.publish).toHaveBeenCalledWith(
|
||||
'0x12345689', 'device', 'genGroups', 'removeAll', 'functional',
|
||||
{}, null, null, expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,659 @@
|
||||
const devices = require('zigbee-shepherd-converters').devices;
|
||||
const HomeassistantExtension = require('../lib/extension/homeassistant');
|
||||
const settings = require('../lib/util/settings');
|
||||
const utils = require('./utils');
|
||||
|
||||
const WSDCGQ11LM = devices.find((d) => d.model === 'WSDCGQ11LM');
|
||||
const SV01 = devices.find((d) => d.model === 'SV01');
|
||||
const FAN99432 = devices.find((d) => d.model === '99432');
|
||||
|
||||
describe('HomeAssistant extension', () => {
|
||||
let homeassistant = null;
|
||||
let mqtt = null;
|
||||
|
||||
beforeEach(() => {
|
||||
utils.stubLogger(jest);
|
||||
|
||||
mqtt = {
|
||||
publish: jest.fn(),
|
||||
};
|
||||
|
||||
homeassistant = new HomeassistantExtension(null, mqtt, null, null);
|
||||
homeassistant.zigbee2mqttVersion = 'test';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('Should have mapping for all devices supported by zigbee-shepherd-converters', () => {
|
||||
const missing = [];
|
||||
|
||||
devices.forEach((d) => {
|
||||
if (!homeassistant._getMapping()[d.model]) {
|
||||
missing.push(d.model);
|
||||
}
|
||||
});
|
||||
|
||||
expect(missing).toHaveLength(0);
|
||||
}
|
||||
);
|
||||
|
||||
it('Should discover devices', () => {
|
||||
let payload = null;
|
||||
jest.spyOn(settings, 'getDevice').mockReturnValue({friendly_name: 'my_device'});
|
||||
|
||||
homeassistant.discover('0x12345678', WSDCGQ11LM, false);
|
||||
expect(mqtt.publish).toHaveBeenCalledTimes(5);
|
||||
|
||||
// 1
|
||||
payload = {
|
||||
'unit_of_measurement': '°C',
|
||||
'device_class': 'temperature',
|
||||
'value_template': '{{ value_json.temperature }}',
|
||||
'json_attributes_topic': 'zigbee2mqtt/my_device',
|
||||
'state_topic': 'zigbee2mqtt/my_device',
|
||||
'name': 'my_device_temperature',
|
||||
'unique_id': '0x12345678_temperature_zigbee2mqtt',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x12345678'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_topic': 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[0][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[0][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[0][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[0][4]).toBe('homeassistant');
|
||||
|
||||
// 2
|
||||
payload = {
|
||||
'unit_of_measurement': '%',
|
||||
'device_class': 'humidity',
|
||||
'value_template': '{{ value_json.humidity }}',
|
||||
'json_attributes_topic': 'zigbee2mqtt/my_device',
|
||||
'state_topic': 'zigbee2mqtt/my_device',
|
||||
'name': 'my_device_humidity',
|
||||
'unique_id': '0x12345678_humidity_zigbee2mqtt',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x12345678'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_topic': 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[1][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[1][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[1][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[1][4]).toBe('homeassistant');
|
||||
|
||||
// 3
|
||||
payload = {
|
||||
'unit_of_measurement': 'hPa',
|
||||
'device_class': 'pressure',
|
||||
'value_template': '{{ value_json.pressure }}',
|
||||
'json_attributes_topic': 'zigbee2mqtt/my_device',
|
||||
'state_topic': 'zigbee2mqtt/my_device',
|
||||
'name': 'my_device_pressure',
|
||||
'unique_id': '0x12345678_pressure_zigbee2mqtt',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x12345678'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_topic': 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[2][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[2][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[2][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[2][4]).toBe('homeassistant');
|
||||
|
||||
// 4
|
||||
payload = {
|
||||
'unit_of_measurement': '%',
|
||||
'device_class': 'battery',
|
||||
'value_template': '{{ value_json.battery }}',
|
||||
'json_attributes_topic': 'zigbee2mqtt/my_device',
|
||||
'state_topic': 'zigbee2mqtt/my_device',
|
||||
'name': 'my_device_battery',
|
||||
'unique_id': '0x12345678_battery_zigbee2mqtt',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x12345678'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_topic': 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[3][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[3][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[3][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[3][4]).toBe('homeassistant');
|
||||
|
||||
// 5
|
||||
payload = {
|
||||
'unit_of_measurement': '-',
|
||||
'value_template': '{{ value_json.linkquality }}',
|
||||
'state_topic': 'zigbee2mqtt/my_device',
|
||||
'json_attributes_topic': 'zigbee2mqtt/my_device',
|
||||
'name': 'my_device_linkquality',
|
||||
'unique_id': '0x12345678_linkquality_zigbee2mqtt',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x12345678'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_topic': 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[4][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[4][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[4][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[4][4]).toBe('homeassistant');
|
||||
});
|
||||
|
||||
it('Should discover devices with precision', () => {
|
||||
let payload = null;
|
||||
jest.spyOn(settings, 'getDevice').mockReturnValue({
|
||||
friendly_name: 'my_device',
|
||||
humidity_precision: 0,
|
||||
temperature_precision: 1,
|
||||
pressure_precision: 2,
|
||||
});
|
||||
|
||||
homeassistant.discover('0x12345678', WSDCGQ11LM, false);
|
||||
expect(mqtt.publish).toHaveBeenCalledTimes(5);
|
||||
|
||||
// 1
|
||||
payload = {
|
||||
'unit_of_measurement': '°C',
|
||||
'device_class': 'temperature',
|
||||
'value_template': '{{ (value_json.temperature | float) | round(1) }}',
|
||||
'json_attributes_topic': 'zigbee2mqtt/my_device',
|
||||
'state_topic': 'zigbee2mqtt/my_device',
|
||||
'name': 'my_device_temperature',
|
||||
'unique_id': '0x12345678_temperature_zigbee2mqtt',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x12345678'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_topic': 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[0][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[0][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[0][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[0][4]).toBe('homeassistant');
|
||||
|
||||
// 2
|
||||
payload = {
|
||||
'unit_of_measurement': '%',
|
||||
'device_class': 'humidity',
|
||||
'value_template': '{{ (value_json.humidity | float) | round(0) }}',
|
||||
'json_attributes_topic': 'zigbee2mqtt/my_device',
|
||||
'state_topic': 'zigbee2mqtt/my_device',
|
||||
'name': 'my_device_humidity',
|
||||
'unique_id': '0x12345678_humidity_zigbee2mqtt',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x12345678'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_topic': 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[1][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[1][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[1][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[1][4]).toBe('homeassistant');
|
||||
|
||||
// 3
|
||||
payload = {
|
||||
'unit_of_measurement': 'hPa',
|
||||
'device_class': 'pressure',
|
||||
'value_template': '{{ (value_json.pressure | float) | round(2) }}',
|
||||
'json_attributes_topic': 'zigbee2mqtt/my_device',
|
||||
'state_topic': 'zigbee2mqtt/my_device',
|
||||
'name': 'my_device_pressure',
|
||||
'unique_id': '0x12345678_pressure_zigbee2mqtt',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x12345678'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_topic': 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[2][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[2][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[2][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[2][4]).toBe('homeassistant');
|
||||
|
||||
// 4
|
||||
payload = {
|
||||
'unit_of_measurement': '%',
|
||||
'device_class': 'battery',
|
||||
'value_template': '{{ value_json.battery }}',
|
||||
'json_attributes_topic': 'zigbee2mqtt/my_device',
|
||||
'state_topic': 'zigbee2mqtt/my_device',
|
||||
'name': 'my_device_battery',
|
||||
'unique_id': '0x12345678_battery_zigbee2mqtt',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x12345678'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_topic': 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[3][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[3][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[3][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[3][4]).toBe('homeassistant');
|
||||
|
||||
// 5
|
||||
payload = {
|
||||
'unit_of_measurement': '-',
|
||||
'value_template': '{{ value_json.linkquality }}',
|
||||
'state_topic': 'zigbee2mqtt/my_device',
|
||||
'json_attributes_topic': 'zigbee2mqtt/my_device',
|
||||
'name': 'my_device_linkquality',
|
||||
'unique_id': '0x12345678_linkquality_zigbee2mqtt',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x12345678'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_topic': 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[4][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[4][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[4][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[4][4]).toBe('homeassistant');
|
||||
});
|
||||
|
||||
it('Should discover devices with overriden user configuration', () => {
|
||||
let payload = null;
|
||||
jest.spyOn(settings, 'getDevice').mockReturnValue({
|
||||
friendly_name: 'my_device',
|
||||
homeassistant: {
|
||||
expire_after: 30,
|
||||
icon: 'mdi:test',
|
||||
temperature: {
|
||||
expire_after: 90,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
homeassistant.discover('0x12345678', WSDCGQ11LM, false);
|
||||
expect(mqtt.publish).toHaveBeenCalledTimes(5);
|
||||
|
||||
// 1
|
||||
payload = {
|
||||
'unit_of_measurement': '°C',
|
||||
'device_class': 'temperature',
|
||||
'value_template': '{{ value_json.temperature }}',
|
||||
'json_attributes_topic': 'zigbee2mqtt/my_device',
|
||||
'state_topic': 'zigbee2mqtt/my_device',
|
||||
'name': 'my_device_temperature',
|
||||
'unique_id': '0x12345678_temperature_zigbee2mqtt',
|
||||
'expire_after': 90,
|
||||
'icon': 'mdi:test',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x12345678'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_topic': 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[0][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[0][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[0][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[0][4]).toBe('homeassistant');
|
||||
|
||||
// 2
|
||||
payload = {
|
||||
'unit_of_measurement': '%',
|
||||
'device_class': 'humidity',
|
||||
'value_template': '{{ value_json.humidity }}',
|
||||
'json_attributes_topic': 'zigbee2mqtt/my_device',
|
||||
'state_topic': 'zigbee2mqtt/my_device',
|
||||
'name': 'my_device_humidity',
|
||||
'unique_id': '0x12345678_humidity_zigbee2mqtt',
|
||||
'expire_after': 30,
|
||||
'icon': 'mdi:test',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x12345678'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_topic': 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[1][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[1][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[1][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[1][4]).toBe('homeassistant');
|
||||
|
||||
// 3
|
||||
payload = {
|
||||
'unit_of_measurement': 'hPa',
|
||||
'device_class': 'pressure',
|
||||
'value_template': '{{ value_json.pressure }}',
|
||||
'json_attributes_topic': 'zigbee2mqtt/my_device',
|
||||
'state_topic': 'zigbee2mqtt/my_device',
|
||||
'name': 'my_device_pressure',
|
||||
'unique_id': '0x12345678_pressure_zigbee2mqtt',
|
||||
'expire_after': 30,
|
||||
'icon': 'mdi:test',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x12345678'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_topic': 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[2][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[2][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[2][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[2][4]).toBe('homeassistant');
|
||||
|
||||
// 4
|
||||
payload = {
|
||||
'unit_of_measurement': '%',
|
||||
'device_class': 'battery',
|
||||
'value_template': '{{ value_json.battery }}',
|
||||
'json_attributes_topic': 'zigbee2mqtt/my_device',
|
||||
'state_topic': 'zigbee2mqtt/my_device',
|
||||
'name': 'my_device_battery',
|
||||
'unique_id': '0x12345678_battery_zigbee2mqtt',
|
||||
'expire_after': 30,
|
||||
'icon': 'mdi:test',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x12345678'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_topic': 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[3][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[3][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[3][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[3][4]).toBe('homeassistant');
|
||||
|
||||
// 5
|
||||
payload = {
|
||||
'unit_of_measurement': '-',
|
||||
'value_template': '{{ value_json.linkquality }}',
|
||||
'state_topic': 'zigbee2mqtt/my_device',
|
||||
'json_attributes_topic': 'zigbee2mqtt/my_device',
|
||||
'name': 'my_device_linkquality',
|
||||
'unique_id': '0x12345678_linkquality_zigbee2mqtt',
|
||||
'expire_after': 30,
|
||||
'icon': 'mdi:test',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x12345678'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_topic': 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[4][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[4][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[4][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[4][4]).toBe('homeassistant');
|
||||
});
|
||||
|
||||
it('Should discover devices with fan', () => {
|
||||
let payload = null;
|
||||
jest.spyOn(settings, 'getDevice').mockReturnValue({friendly_name: 'my_device'});
|
||||
|
||||
homeassistant.discover('0x12345678', FAN99432, false);
|
||||
expect(mqtt.publish).toHaveBeenCalledTimes(3);
|
||||
|
||||
// 1
|
||||
payload = {
|
||||
name: 'my_device_fan',
|
||||
state_topic: 'zigbee2mqtt/my_device',
|
||||
state_value_template: '{{ value_json.fan_state }}',
|
||||
command_topic: 'zigbee2mqtt/my_device/set/fan_state',
|
||||
speed_state_topic: 'zigbee2mqtt/my_device',
|
||||
speed_value_template: '{{ value_json.fan_mode }}',
|
||||
speed_command_topic: 'zigbee2mqtt/my_device/set/fan_mode',
|
||||
unique_id: '0x12345678_fan_zigbee2mqtt',
|
||||
speeds: ['off', 'low', 'medium', 'high', 'on', 'auto', 'smart'],
|
||||
device: {
|
||||
'identifiers': ['zigbee2mqtt_0x12345678'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'manufacturer': 'Hampton Bay',
|
||||
'model': 'Universal wink enabled white ceiling fan premier remote control (99432)',
|
||||
},
|
||||
availability_topic: 'zigbee2mqtt/bridge/state',
|
||||
json_attributes_topic: 'zigbee2mqtt/my_device',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[0][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[0][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[0][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[0][4]).toBe('homeassistant');
|
||||
});
|
||||
|
||||
it('Should discover devices with cover_position', () => {
|
||||
let payload = null;
|
||||
jest.spyOn(settings, 'getDevice').mockReturnValue({friendly_name: 'my_device'});
|
||||
|
||||
homeassistant.discover('0x12345678', SV01, false);
|
||||
expect(mqtt.publish).toHaveBeenCalledTimes(5);
|
||||
|
||||
// 1
|
||||
payload = {
|
||||
name: 'my_device_cover',
|
||||
command_topic: 'zigbee2mqtt/my_device/set',
|
||||
position_topic: 'zigbee2mqtt/my_device',
|
||||
set_position_topic: 'zigbee2mqtt/my_device/set',
|
||||
set_position_template: '{ "position": {{ position }} }',
|
||||
value_template: '{{ value_json.position }}',
|
||||
unique_id: '0x12345678_cover_zigbee2mqtt',
|
||||
device: {
|
||||
'identifiers': ['zigbee2mqtt_0x12345678'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Smart vent (SV01)',
|
||||
'manufacturer': 'Keen Home',
|
||||
},
|
||||
availability_topic: 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[0][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[0][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[0][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[0][4]).toBe('homeassistant');
|
||||
});
|
||||
|
||||
it('Should discover devices with overriden user configuration in device', () => {
|
||||
let payload = null;
|
||||
jest.spyOn(settings, 'getDevice').mockReturnValue({
|
||||
friendly_name: 'my_device',
|
||||
homeassistant: {
|
||||
device: {
|
||||
identifiers: ['test123'],
|
||||
},
|
||||
temperature: {
|
||||
expire_after: 90,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
homeassistant.discover('0x12345678', WSDCGQ11LM, false);
|
||||
expect(mqtt.publish).toHaveBeenCalledTimes(5);
|
||||
|
||||
// 1
|
||||
payload = {
|
||||
'unit_of_measurement': '°C',
|
||||
'device_class': 'temperature',
|
||||
'value_template': '{{ value_json.temperature }}',
|
||||
'json_attributes_topic': 'zigbee2mqtt/my_device',
|
||||
'state_topic': 'zigbee2mqtt/my_device',
|
||||
'name': 'my_device_temperature',
|
||||
'unique_id': '0x12345678_temperature_zigbee2mqtt',
|
||||
'expire_after': 90,
|
||||
'device': {
|
||||
'identifiers': ['test123'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_topic': 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[0][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[0][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[0][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[0][4]).toBe('homeassistant');
|
||||
});
|
||||
|
||||
it('Should discover devices with overriden user configuration in device in temperature', () => {
|
||||
let payload = null;
|
||||
jest.spyOn(settings, 'getDevice').mockReturnValue({
|
||||
friendly_name: 'my_device',
|
||||
homeassistant: {
|
||||
temperature: {
|
||||
expire_after: 90,
|
||||
device: {
|
||||
identifiers: ['test'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
homeassistant.discover('0x12345678', WSDCGQ11LM, false);
|
||||
expect(mqtt.publish).toHaveBeenCalledTimes(5);
|
||||
|
||||
// 1
|
||||
payload = {
|
||||
'unit_of_measurement': '°C',
|
||||
'device_class': 'temperature',
|
||||
'value_template': '{{ value_json.temperature }}',
|
||||
'json_attributes_topic': 'zigbee2mqtt/my_device',
|
||||
'state_topic': 'zigbee2mqtt/my_device',
|
||||
'name': 'my_device_temperature',
|
||||
'unique_id': '0x12345678_temperature_zigbee2mqtt',
|
||||
'expire_after': 90,
|
||||
'device': {
|
||||
'identifiers': ['test'],
|
||||
'name': 'my_device',
|
||||
'sw_version': 'Zigbee2mqtt test',
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_topic': 'zigbee2mqtt/bridge/state',
|
||||
};
|
||||
|
||||
expect(JSON.parse(mqtt.publish.mock.calls[0][1])).toStrictEqual(payload);
|
||||
expect(mqtt.publish.mock.calls[0][2]).toStrictEqual({retain: true, qos: 0});
|
||||
expect(mqtt.publish.mock.calls[0][3]).toBeNull();
|
||||
expect(mqtt.publish.mock.calls[0][4]).toBe('homeassistant');
|
||||
});
|
||||
|
||||
it('Should discover devices with a custom discovery topic', () => {
|
||||
jest.spyOn(settings, 'get').mockReturnValue({
|
||||
mqtt: {
|
||||
base_topic: 'zigbee2mqtt',
|
||||
},
|
||||
experimental: {
|
||||
output: 'json',
|
||||
},
|
||||
advanced: {
|
||||
homeassistant_discovery_topic: 'my_custom_discovery_topic',
|
||||
},
|
||||
});
|
||||
|
||||
homeassistant = new HomeassistantExtension(null, mqtt, null, null);
|
||||
|
||||
jest.spyOn(settings, 'getDevice').mockReturnValue({
|
||||
friendly_name: 'my_device',
|
||||
homeassistant: {
|
||||
temperature: {
|
||||
expire_after: 90,
|
||||
device: {
|
||||
identifiers: ['test'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
homeassistant.discover('0x12345678', WSDCGQ11LM, false);
|
||||
|
||||
expect(mqtt.publish).toHaveBeenCalledTimes(5);
|
||||
expect(mqtt.publish.mock.calls[0][4]).toBe('my_custom_discovery_topic');
|
||||
});
|
||||
|
||||
it('Should subscribe to custom status topic', () => {
|
||||
jest.spyOn(settings, 'get').mockReturnValue({
|
||||
experimental: {
|
||||
output: 'json',
|
||||
},
|
||||
advanced: {
|
||||
homeassistant_status_topic: 'my_custom_status_topic',
|
||||
},
|
||||
});
|
||||
|
||||
const zigbee = {
|
||||
getAllClients: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
|
||||
mqtt = {
|
||||
subscribe: jest.fn(),
|
||||
};
|
||||
|
||||
|
||||
homeassistant = new HomeassistantExtension(zigbee, mqtt, null, null);
|
||||
|
||||
homeassistant.onMQTTConnected();
|
||||
|
||||
expect(mqtt.subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(mqtt.subscribe.mock.calls[0][0]).toBe('my_custom_status_topic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
const objectAssignDeep = require('object-assign-deep');
|
||||
|
||||
const data = require('../lib/util/data');
|
||||
const settings = require('../lib/util/settings.js');
|
||||
const fs = require('../lib/util/fs');
|
||||
const utils = require('./utils');
|
||||
|
||||
const configurationFile = data.joinPath('configuration.yaml');
|
||||
const devicesFile = data.joinPath('devices.yaml');
|
||||
const groupsFile = data.joinPath('groups.yaml');
|
||||
|
||||
describe('Settings', () => {
|
||||
const write = (file, json) => fs.writeYaml(file, json);
|
||||
|
||||
const read = (file) => fs.readYaml(file);
|
||||
|
||||
const files = new Map();
|
||||
|
||||
beforeAll(() => {
|
||||
utils.stubLogger(jest);
|
||||
|
||||
jest.spyOn(fs, 'readYaml').mockImplementation((file) => {
|
||||
if (files.has(file)) return objectAssignDeep.noMutate(files.get(file));
|
||||
throw new Error(`Fake file not found: ${file}`);
|
||||
});
|
||||
jest.spyOn(fs, 'readYamlIfExists').mockImplementation((file) => {
|
||||
if (files.has(file)) return objectAssignDeep.noMutate(files.get(file));
|
||||
return null;
|
||||
});
|
||||
jest.spyOn(fs, 'writeYaml').mockImplementation((file, content) => {
|
||||
files.set(file, objectAssignDeep.noMutate(content));
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
settings._clear();
|
||||
files.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.readYaml.mockRestore();
|
||||
fs.readYamlIfExists.mockRestore();
|
||||
fs.writeYaml.mockRestore();
|
||||
});
|
||||
|
||||
describe('Settings', () => {
|
||||
it('Should return default settings', () => {
|
||||
write(configurationFile, {});
|
||||
const s = settings.get();
|
||||
expect(s).toStrictEqual(settings._getDefaults());
|
||||
});
|
||||
|
||||
it('Should return settings', () => {
|
||||
write(configurationFile, {permit_join: true});
|
||||
const s = settings.get();
|
||||
const expected = settings._getDefaults();
|
||||
expected.permit_join = true;
|
||||
expect(s).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Should add devices', () => {
|
||||
write(configurationFile, {});
|
||||
settings.addDevice('0x12345678');
|
||||
|
||||
const actual = read(configurationFile);
|
||||
const expected = {
|
||||
devices: {
|
||||
'0x12345678': {
|
||||
friendly_name: '0x12345678',
|
||||
retain: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(actual).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Should read devices', () => {
|
||||
const content = {
|
||||
devices: {
|
||||
'0x12345678': {
|
||||
friendly_name: '0x12345678',
|
||||
retain: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
write(configurationFile, content);
|
||||
|
||||
const device = settings.getDevice('0x12345678');
|
||||
const expected = {
|
||||
friendly_name: '0x12345678',
|
||||
retain: false,
|
||||
};
|
||||
|
||||
expect(device).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Should read devices form a separate file', () => {
|
||||
const contentConfiguration = {
|
||||
devices: 'devices.yaml',
|
||||
};
|
||||
|
||||
const contentDevices = {
|
||||
'0x12345678': {
|
||||
friendly_name: '0x12345678',
|
||||
retain: false,
|
||||
},
|
||||
};
|
||||
|
||||
write(devicesFile, contentDevices);
|
||||
write(configurationFile, contentConfiguration);
|
||||
|
||||
const device = settings.getDevice('0x12345678');
|
||||
const expected = {
|
||||
friendly_name: '0x12345678',
|
||||
retain: false,
|
||||
};
|
||||
|
||||
expect(device).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Should add devices to a separate file', () => {
|
||||
const contentConfiguration = {
|
||||
devices: 'devices.yaml',
|
||||
};
|
||||
|
||||
const contentDevices = {
|
||||
'0x12345678': {
|
||||
friendly_name: '0x12345678',
|
||||
retain: false,
|
||||
},
|
||||
};
|
||||
|
||||
write(devicesFile, contentDevices);
|
||||
write(configurationFile, contentConfiguration);
|
||||
|
||||
settings.addDevice('0x1234');
|
||||
|
||||
expect(read(configurationFile)).toStrictEqual({devices: 'devices.yaml'});
|
||||
|
||||
const expected = {
|
||||
'0x12345678': {
|
||||
friendly_name: '0x12345678',
|
||||
retain: false,
|
||||
},
|
||||
'0x1234': {
|
||||
friendly_name: '0x1234',
|
||||
retain: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(read(devicesFile)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Should add devices to a separate file if devices.yaml doesnt exist', () => {
|
||||
const contentConfiguration = {
|
||||
devices: 'devices.yaml',
|
||||
};
|
||||
|
||||
write(configurationFile, contentConfiguration);
|
||||
|
||||
settings.addDevice('0x1234');
|
||||
|
||||
expect(read(configurationFile)).toStrictEqual({devices: 'devices.yaml'});
|
||||
|
||||
const expected = {
|
||||
'0x1234': {
|
||||
friendly_name: '0x1234',
|
||||
retain: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(read(devicesFile)).toStrictEqual(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it('Should add and remove devices to a separate file if devices.yaml doesnt exist', () => {
|
||||
const contentConfiguration = {
|
||||
devices: 'devices.yaml',
|
||||
};
|
||||
|
||||
write(configurationFile, contentConfiguration);
|
||||
|
||||
settings.addDevice('0x1234');
|
||||
expect(read(configurationFile)).toStrictEqual({devices: 'devices.yaml'});
|
||||
|
||||
settings.removeDevice('0x1234');
|
||||
expect(read(configurationFile)).toStrictEqual({devices: 'devices.yaml'});
|
||||
|
||||
expect(read(devicesFile)).toStrictEqual({});
|
||||
}
|
||||
);
|
||||
|
||||
it('Should read groups', () => {
|
||||
const content = {
|
||||
groups: {
|
||||
'1': {
|
||||
friendly_name: '123',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
write(configurationFile, content);
|
||||
|
||||
const group = settings.getGroup('1');
|
||||
const expected = {
|
||||
friendly_name: '123',
|
||||
devices: [],
|
||||
};
|
||||
|
||||
expect(group).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Should read groups from a separate file', () => {
|
||||
const contentConfiguration = {
|
||||
groups: 'groups.yaml',
|
||||
};
|
||||
|
||||
const contentGroups = {
|
||||
'1': {
|
||||
friendly_name: '123',
|
||||
},
|
||||
};
|
||||
|
||||
write(groupsFile, contentGroups);
|
||||
write(configurationFile, contentConfiguration);
|
||||
|
||||
const group = settings.getGroup('1');
|
||||
const expected = {
|
||||
friendly_name: '123',
|
||||
devices: [],
|
||||
};
|
||||
|
||||
expect(group).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Combine everything! groups and devices from separate file :)', () => {
|
||||
const contentConfiguration = {
|
||||
devices: 'devices.yaml',
|
||||
groups: 'groups.yaml',
|
||||
};
|
||||
|
||||
const contentGroups = {
|
||||
'1': {
|
||||
friendly_name: '123',
|
||||
devices: [],
|
||||
},
|
||||
};
|
||||
|
||||
write(groupsFile, contentGroups);
|
||||
write(configurationFile, contentConfiguration);
|
||||
|
||||
const expectedConfiguration = {
|
||||
devices: 'devices.yaml',
|
||||
groups: 'groups.yaml',
|
||||
};
|
||||
|
||||
expect(read(configurationFile)).toStrictEqual(expectedConfiguration);
|
||||
|
||||
settings.addDevice('0x1234');
|
||||
|
||||
expect(read(configurationFile)).toStrictEqual(expectedConfiguration);
|
||||
|
||||
const expectedDevice = {
|
||||
'0x1234': {
|
||||
friendly_name: '0x1234',
|
||||
retain: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(read(devicesFile)).toStrictEqual(expectedDevice);
|
||||
|
||||
const group = settings.getGroup('1');
|
||||
const expectedGroup = {
|
||||
friendly_name: '123',
|
||||
devices: [],
|
||||
};
|
||||
|
||||
expect(group).toStrictEqual(expectedGroup);
|
||||
|
||||
expect(read(configurationFile)).toStrictEqual(expectedConfiguration);
|
||||
|
||||
const expectedDevice2 = {
|
||||
friendly_name: '0x1234',
|
||||
retain: false,
|
||||
};
|
||||
|
||||
expect(settings.getDevice('0x1234')).toStrictEqual(expectedDevice2);
|
||||
});
|
||||
|
||||
it('Should add groups', () => {
|
||||
write(configurationFile, {});
|
||||
|
||||
const added = settings.addGroup('test123');
|
||||
const expected = {
|
||||
'1': {
|
||||
friendly_name: 'test123',
|
||||
},
|
||||
};
|
||||
|
||||
expect(added).toStrictEqual(true);
|
||||
expect(settings.get().groups).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Should not add duplicate groups', () => {
|
||||
write(configurationFile, {});
|
||||
|
||||
const added = settings.addGroup('test123');
|
||||
const added2 = settings.addGroup('test123');
|
||||
const expected = {
|
||||
'1': {
|
||||
friendly_name: 'test123',
|
||||
},
|
||||
};
|
||||
|
||||
expect(added).toStrictEqual(true);
|
||||
expect(added2).toStrictEqual(false);
|
||||
expect(settings.get().groups).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
const logger = require('../lib/util/logger');
|
||||
|
||||
module.exports = {
|
||||
stubLogger: (jest) => {
|
||||
jest.spyOn(logger, 'info').mockReturnValue(undefined);
|
||||
jest.spyOn(logger, 'warn').mockReturnValue(undefined);
|
||||
jest.spyOn(logger, 'debug').mockReturnValue(undefined);
|
||||
jest.spyOn(logger, 'error').mockReturnValue(undefined);
|
||||
jest.spyOn(logger, 'trace').mockReturnValue(undefined);
|
||||
},
|
||||
zigbeeMessage: (device, cid, type, data, epId, groupid=0) => {
|
||||
return {data: {cid, data}, type, groupid, endpoints: [{device, epId}]};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
const utils = require('../lib/util/utils.js');
|
||||
const testUtils = require('./utils');
|
||||
|
||||
describe('Utils', () => {
|
||||
beforeAll(() => {
|
||||
testUtils.stubLogger(jest);
|
||||
});
|
||||
|
||||
describe('Is xiaomi device', () => {
|
||||
it('Identify xiaomi device', () => {
|
||||
const device = {type: 'Router', manufId: 4151, manufName: 'Xiaomi'};
|
||||
expect(true).toBe(utils.isXiaomiDevice(device));
|
||||
});
|
||||
|
||||
it('Identify xiaomi device without manufName', () => {
|
||||
const device = {type: 'Router', manufId: 4447};
|
||||
expect(true).toBe(utils.isXiaomiDevice(device));
|
||||
});
|
||||
|
||||
it('Identify xiaomi device with different manufName', () => {
|
||||
const device = {type: 'Router', manufId: 4151, manufName: 'Trust International B.V.\u0000'};
|
||||
expect(false).toBe(utils.isXiaomiDevice(device));
|
||||
});
|
||||
|
||||
it('Identify QBKG03LM as enddevice', () => {
|
||||
const device = {type: 'Router', manufId: 4447, modelId: 'lumi.ctrl_neutral1'};
|
||||
expect(false).toBe(utils.isRouter(device));
|
||||
expect('EndDevice').toBe(utils.correctDeviceType(device));
|
||||
});
|
||||
|
||||
it('Identify QBKG04LM as enddevice', () => {
|
||||
const device = {type: 'Router', manufId: 4447, modelId: 'lumi.ctrl_neutral2'};
|
||||
expect(false).toBe(utils.isRouter(device));
|
||||
expect('EndDevice').toBe(utils.correctDeviceType(device));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Get endpoint by id', () => {
|
||||
it('Pick default ep', () => {
|
||||
const zigbee = {
|
||||
getDevice: (entityID) => {
|
||||
return {modelId: 'TRADFRI on/off switch'};
|
||||
},
|
||||
getEndpoint: (entityID, epId) => {
|
||||
return {epId: epId == null ? 1 : 0};
|
||||
},
|
||||
};
|
||||
const endpoint = utils.getEndpointByEntityID(zigbee, '0x12345678', null);
|
||||
expect(endpoint.epId).toBe(1);
|
||||
});
|
||||
|
||||
it('Pick default ep from mapping when default defined', () => {
|
||||
const zigbee = {
|
||||
getDevice: (entityID) => {
|
||||
return {modelId: 'SML002'};
|
||||
},
|
||||
getEndpoint: (entityID, epId) => {
|
||||
return {epId};
|
||||
},
|
||||
};
|
||||
const endpoint = utils.getEndpointByEntityID(zigbee, '0x12345678', null);
|
||||
expect(endpoint.epId).toBe(2);
|
||||
});
|
||||
|
||||
it('Pick default ep from mapping when not defined', () => {
|
||||
const zigbee = {
|
||||
getDevice: (entityID) => {
|
||||
return {modelId: 'lumi.sensor_86sw2.es1'};
|
||||
},
|
||||
getEndpoint: (entityID, epId) => {
|
||||
return {epId: epId == null ? 1 : 0};
|
||||
},
|
||||
};
|
||||
const endpoint = utils.getEndpointByEntityID(zigbee, '0x12345678', null);
|
||||
expect(endpoint.epId).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
echo "Stopping zigbee2mqtt..."
|
||||
sudo systemctl stop zigbee2mqtt
|
||||
|
||||
echo "Creating backup of configuration..."
|
||||
cp -R data data-backup
|
||||
|
||||
echo "Updating..."
|
||||
git checkout HEAD -- npm-shrinkwrap.json
|
||||
git pull
|
||||
|
||||
echo "Installing dependencies..."
|
||||
rm -rf node_modules
|
||||
npm install
|
||||
|
||||
echo "Restore configuration..."
|
||||
cp -R data-backup/* data
|
||||
rm -rf data-backup
|
||||
|
||||
echo "Starting zigbee2mqtt..."
|
||||
sudo systemctl start zigbee2mqtt
|
||||
|
||||
echo "Done!"
|
||||
Reference in New Issue
Block a user