mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 07:08:56 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2b5229f76 | ||
|
|
0c605688af | ||
|
|
6876e19008 | ||
|
|
437b1dfb3e | ||
|
|
fda1f23829 | ||
|
|
ed9d09a629 | ||
|
|
d298412a1d | ||
|
|
e7447cf1e3 | ||
|
|
c90171d2cc | ||
|
|
d2cf0dd8aa | ||
|
|
d415f57e30 | ||
|
|
56998f72f5 | ||
|
|
97bd090ad6 | ||
|
|
df182be21f | ||
|
|
290a0312d4 | ||
|
|
4d432658e9 | ||
|
|
c2d9ffa7ad | ||
|
|
82f5ddb748 | ||
|
|
1069ec2a1a | ||
|
|
cd4ac0c174 | ||
|
|
134b80ccf6 | ||
|
|
b07450d413 | ||
|
|
65f7fc0532 | ||
|
|
0a7a477281 | ||
|
|
a170f304c7 | ||
|
|
35ed5154c8 | ||
|
|
641853e6d5 | ||
|
|
2af9f21961 | ||
|
|
51321affcf | ||
|
|
5fca6fbd09 | ||
|
|
070065a248 | ||
|
|
9a8f204683 | ||
|
|
167c05681e | ||
|
|
01d7bd25be | ||
|
|
372ec315b5 | ||
|
|
df0543d4b7 | ||
|
|
a9e3fb3358 | ||
|
|
678e19fd7e | ||
|
|
9128838534 | ||
|
|
35876616ef | ||
|
|
0bc3129b7a | ||
|
|
5e3b18a053 | ||
|
|
ad3ff7fa40 | ||
|
|
008d6f9aa0 | ||
|
|
854353fb71 | ||
|
|
e35ba0f83c | ||
|
|
a154e18326 | ||
|
|
c793cc83ad | ||
|
|
a51626e357 | ||
|
|
0f3315063f | ||
|
|
1065916a01 | ||
|
|
a980b4dff5 | ||
|
|
825c7465ae | ||
|
|
97521478c6 | ||
|
|
6279c83e65 | ||
|
|
91479fc0ec | ||
|
|
9dbe6f0a6c | ||
|
|
5d415aa299 | ||
|
|
6a75e0d4ee | ||
|
|
13e9616c92 | ||
|
|
cc03be46df | ||
|
|
64154e0cb2 | ||
|
|
b2eaa3141e | ||
|
|
d192bbd28f | ||
|
|
9582808b09 | ||
|
|
623f999ec8 | ||
|
|
3141a14fec | ||
|
|
7e2a2bdd34 | ||
|
|
2f503bf39a | ||
|
|
945b72b990 | ||
|
|
3bfc7f31cf | ||
|
|
1f60edff99 | ||
|
|
3249ab00ab | ||
|
|
8c8f907fd3 | ||
|
|
170ef40982 | ||
|
|
e547062702 | ||
|
|
6ca8d35183 | ||
|
|
420bae4259 | ||
|
|
97dacd7623 | ||
|
|
8ef82fe23f | ||
|
|
cd3df10a54 | ||
|
|
d5ccd9221c | ||
|
|
fc9abfe53b | ||
|
|
01ea46b280 | ||
|
|
ab629d52ea | ||
|
|
9e216011d4 | ||
|
|
91d90bdcf1 | ||
|
|
6ba743cbed | ||
|
|
2399ec040f | ||
|
|
f151e1d371 | ||
|
|
55802ef648 | ||
|
|
89af770d96 | ||
|
|
0efd0cfe17 | ||
|
|
c852434dde | ||
|
|
57e93a48ff | ||
|
|
181944d2ff | ||
|
|
a09aa4243e | ||
|
|
932ee433c4 | ||
|
|
20b6fd42d3 | ||
|
|
178da2db1d | ||
|
|
e504da33d4 | ||
|
|
fa1775d8b4 | ||
|
|
2612de3c34 | ||
|
|
416f4fa548 | ||
|
|
ee57b0ce0b | ||
|
|
9729a72d33 | ||
|
|
23719df7ac | ||
|
|
ef6bf0a8f9 | ||
|
|
b3e9afaf3e | ||
|
|
5709898c94 | ||
|
|
68b5be0ce7 | ||
|
|
62bf76c11f | ||
|
|
243d333526 | ||
|
|
09383877b0 | ||
|
|
359af6f9ff | ||
|
|
cf68507ec3 | ||
|
|
78a3e4a371 | ||
|
|
11753a7078 | ||
|
|
512b785427 | ||
|
|
0a1fccf6b3 | ||
|
|
9e4b65b188 | ||
|
|
39c0b522d9 | ||
|
|
109c62bb4d | ||
|
|
a2e3f60de3 | ||
|
|
bbf05b5c95 | ||
|
|
a5413b9254 | ||
|
|
d6890b5fe4 | ||
|
|
b1c1637547 | ||
|
|
ed8ca293a3 | ||
|
|
abd4b23024 | ||
|
|
7a198ce4a3 | ||
|
|
e0c3ea28c0 | ||
|
|
d137d1975b | ||
|
|
e7bc8b9a9a | ||
|
|
72ffe4d118 | ||
|
|
e81d197979 | ||
|
|
1dc36f4cc1 | ||
|
|
252994968d | ||
|
|
7fed9e7334 | ||
|
|
bef5bff637 | ||
|
|
498a5b3f32 | ||
|
|
b08ce534c8 | ||
|
|
56812e7844 | ||
|
|
d19c60be07 | ||
|
|
387378e935 | ||
|
|
16c3f912b1 | ||
|
|
3f0a2fed9d | ||
|
|
c8e3c9b5a2 | ||
|
|
b665106d44 | ||
|
|
88314f1c6a | ||
|
|
8c12551ac8 | ||
|
|
011d02d0e6 | ||
|
|
8ac3989ada | ||
|
|
caa22c559e | ||
|
|
7469e29877 | ||
|
|
c647e87641 | ||
|
|
de825014c0 | ||
|
|
ca7bb0cd4d | ||
|
|
158341a9ff | ||
|
|
eaad39a199 | ||
|
|
ca255d3aa3 | ||
|
|
97d15b8d15 | ||
|
|
549ccf036e | ||
|
|
8ae4909836 | ||
|
|
bfb2427a6c | ||
|
|
3571937060 | ||
|
|
f7c0b46bef | ||
|
|
ad5cda4d40 | ||
|
|
d18455b596 | ||
|
|
0ae7bd0a2d | ||
|
|
8fac172ced | ||
|
|
cff4ea726e | ||
|
|
749381bf2f | ||
|
|
6b380526ae | ||
|
|
e8dd29e183 | ||
|
|
b58e123594 | ||
|
|
899383d79e | ||
|
|
de7b120ed2 | ||
|
|
4608de5d57 | ||
|
|
a362e6c99d | ||
|
|
ed8a863c30 | ||
|
|
7086bc3e9d | ||
|
|
2144e9b622 | ||
|
|
ff5411a328 | ||
|
|
47297983c2 | ||
|
|
a774b43d63 | ||
|
|
7f8632437d | ||
|
|
6977235200 | ||
|
|
4e520bb042 | ||
|
|
45f096ec0a | ||
|
|
0ea74e18b2 | ||
|
|
ab5d368a4e | ||
|
|
ebd20e30dc | ||
|
|
c2d4335e45 | ||
|
|
38cbfa4e91 | ||
|
|
f7fd0ce825 | ||
|
|
ea550a8fcf | ||
|
|
9907e59fa7 | ||
|
|
3b0a906a97 | ||
|
|
c88f643407 | ||
|
|
89d2b0088d | ||
|
|
d03c5e2372 | ||
|
|
90d20f936e | ||
|
|
6f9371e4b0 | ||
|
|
4082763f59 | ||
|
|
42497f3176 | ||
|
|
7ea29eadf4 | ||
|
|
db8d0ecf20 | ||
|
|
bd216d7a24 | ||
|
|
23b87755cf | ||
|
|
7415e8416d | ||
|
|
8e2c79f85d | ||
|
|
ab3a9da630 | ||
|
|
7ccf781b65 | ||
|
|
30badb62e7 | ||
|
|
a0512a489d | ||
|
|
edb145a8c3 | ||
|
|
45a4978ca8 | ||
|
|
fb96551beb | ||
|
|
2fb59f8efa | ||
|
|
c28731957a | ||
|
|
0282011255 | ||
|
|
045ee573a0 | ||
|
|
839e280159 | ||
|
|
44922120a6 | ||
|
|
c7b9c06c89 | ||
|
|
258326a88c | ||
|
|
4ec828d68e | ||
|
|
d9735f5981 | ||
|
|
fa8011983d | ||
|
|
7f98771e99 | ||
|
|
7d6d71dcbe | ||
|
|
8d9f14e30b | ||
|
|
06d30d1036 | ||
|
|
f517b85a75 | ||
|
|
738b241a31 | ||
|
|
2605d48557 | ||
|
|
918d8e2f88 | ||
|
|
0dfadb3a10 | ||
|
|
27a7bdb14a | ||
|
|
5829663b95 | ||
|
|
a8eec889f5 | ||
|
|
6888394600 | ||
|
|
57b4d678d5 | ||
|
|
59dd6e6bff | ||
|
|
89dea86b3a | ||
|
|
74ee5581fe | ||
|
|
aaad76e323 | ||
|
|
7557c10095 | ||
|
|
58957a30e4 | ||
|
|
32ddcc5e61 | ||
|
|
6369fe1528 | ||
|
|
187dde436c | ||
|
|
80b35ba728 | ||
|
|
41b1d53723 | ||
|
|
9f12ef7370 | ||
|
|
7f4a9c2a9b | ||
|
|
48de2fa647 | ||
|
|
27b36b316c | ||
|
|
ca3c6ce32f | ||
|
|
0d3998f369 | ||
|
|
b929dcbeb8 | ||
|
|
b558bbdc85 | ||
|
|
bb5be4df25 | ||
|
|
eca5492a8e | ||
|
|
ac44a50d3d | ||
|
|
32b127d280 | ||
|
|
8c0827afe3 | ||
|
|
7a97c67f74 | ||
|
|
bb1cbf3de7 | ||
|
|
75d2fe393d | ||
|
|
fb081316e2 | ||
|
|
feff729eb6 | ||
|
|
69e3deb7ac | ||
|
|
c1eb56aa30 | ||
|
|
cadcdc3525 | ||
|
|
d140d2b4b2 | ||
|
|
22bba95a70 | ||
|
|
291383fe2f | ||
|
|
7e5814aaad | ||
|
|
22a607da7b | ||
|
|
d07333c70d | ||
|
|
3c87803c79 | ||
|
|
02a508270f | ||
|
|
4a51e0c0c9 | ||
|
|
edf14b80d2 | ||
|
|
3708b314d1 | ||
|
|
7da50f9248 | ||
|
|
e59f347d68 | ||
|
|
b112184b65 | ||
|
|
1a514b824f | ||
|
|
a2653037e9 | ||
|
|
cf019195af | ||
|
|
b8c245b9f3 | ||
|
|
31327f7e1b | ||
|
|
e050a763f7 | ||
|
|
4125ae0888 | ||
|
|
c299d9b632 | ||
|
|
eb5750d968 | ||
|
|
d8432b502e | ||
|
|
15685cacf8 | ||
|
|
d92bdf1c57 | ||
|
|
4f7264e565 | ||
|
|
05b19117cf | ||
|
|
991b937451 | ||
|
|
9d60f9b64f | ||
|
|
9e3afbf48a | ||
|
|
17b2315d89 | ||
|
|
7122137639 | ||
|
|
a9e4745075 | ||
|
|
fd7351ff61 | ||
|
|
22430631d2 | ||
|
|
7b8c5944a0 | ||
|
|
09198d5ad5 | ||
|
|
352ebceadd | ||
|
|
3774a78134 | ||
|
|
5b6b4c1736 | ||
|
|
8bb296f3f7 | ||
|
|
6741e43e32 | ||
|
|
d78bf2a3d2 | ||
|
|
13e757429d | ||
|
|
f21516a2f2 | ||
|
|
ccc91b499a | ||
|
|
1234c5a3e0 | ||
|
|
9129f95c39 | ||
|
|
b18373b669 | ||
|
|
5e2e8f3eb3 | ||
|
|
d7ae59886e | ||
|
|
8f75dd8652 | ||
|
|
d07f1dab31 | ||
|
|
9e8dcb2784 | ||
|
|
59f6455f13 | ||
|
|
69d51c95db | ||
|
|
dffc58aa6b | ||
|
|
c7bf3324dc | ||
|
|
cb47efe3fe | ||
|
|
94cfad445b | ||
|
|
eebc92f931 | ||
|
|
f9451ffe22 | ||
|
|
288c0b795c | ||
|
|
c134d4e975 | ||
|
|
fb390544af | ||
|
|
c93b9300da | ||
|
|
486333d342 | ||
|
|
773de25657 | ||
|
|
726462caed | ||
|
|
fe24391de8 | ||
|
|
05deca6261 | ||
|
|
755e8d3866 | ||
|
|
ffc48131de | ||
|
|
0d5d5a696a | ||
|
|
a8545d08b6 | ||
|
|
e5f20f342d | ||
|
|
c18a698e6b | ||
|
|
f74047d090 | ||
|
|
17dc22fdb0 | ||
|
|
5a2f262fe5 | ||
|
|
61e133b894 | ||
|
|
fc2e8304f5 | ||
|
|
a3f22833cd | ||
|
|
a41c00faea | ||
|
|
632f9f579c | ||
|
|
6dcae7b83e | ||
|
|
5c8fa5cb48 | ||
|
|
500642d42c | ||
|
|
bee7474b8f | ||
|
|
911c3d285e | ||
|
|
86743179a2 | ||
|
|
108694276e | ||
|
|
3d27a63b5a | ||
|
|
950498898c | ||
|
|
fab62e10fe | ||
|
|
08fa79d6d8 | ||
|
|
1b2398f13f | ||
|
|
0685ade693 | ||
|
|
10615e29f3 | ||
|
|
a380df4f21 | ||
|
|
86d92a9591 | ||
|
|
33ee9f7bd7 | ||
|
|
056e07a411 | ||
|
|
62d763614a | ||
|
|
7db4e1b8d0 | ||
|
|
db61a623d5 | ||
|
|
c813eca843 | ||
|
|
ceb63071c8 | ||
|
|
70891eec39 | ||
|
|
e637eca3ea | ||
|
|
30f80c08d2 | ||
|
|
745951f38d | ||
|
|
30ff15b7dc | ||
|
|
be2689ee04 | ||
|
|
cee9eb1e26 | ||
|
|
1e5e3bef97 | ||
|
|
2457b7ee27 | ||
|
|
6068cd8541 | ||
|
|
ffea1ed97d | ||
|
|
05a535cf30 | ||
|
|
b1ee119575 | ||
|
|
cd033c8995 | ||
|
|
bf29fa8164 | ||
|
|
26ade49513 | ||
|
|
f9b8217996 | ||
|
|
35c2ffc0e5 | ||
|
|
3653edeac3 | ||
|
|
6e84c149fe | ||
|
|
236b751bd1 | ||
|
|
b09f20e62e | ||
|
|
f74f5fd638 | ||
|
|
7279bc0e3c | ||
|
|
0e498c6d49 | ||
|
|
f571938efc | ||
|
|
402a9f6d95 | ||
|
|
aae288b3fe | ||
|
|
7b65dc631b | ||
|
|
321b34721f | ||
|
|
eaaa83dbe5 | ||
|
|
7675dd1bcb | ||
|
|
46e2a772fe | ||
|
|
6242da009c | ||
|
|
bf97e668b0 | ||
|
|
c6fdb253c6 | ||
|
|
51f4348e41 | ||
|
|
b172c49bf3 | ||
|
|
f8927a94ec | ||
|
|
07ff52a56f | ||
|
|
a1c0bf2a85 | ||
|
|
0fd25cddb2 | ||
|
|
f8742344c9 | ||
|
|
be246fa841 | ||
|
|
515599ad13 | ||
|
|
a2bf9c5d01 | ||
|
|
0974cbe08d | ||
|
|
465d47c163 | ||
|
|
c4936da726 | ||
|
|
0a2a8dd563 | ||
|
|
3e005170b4 | ||
|
|
685c707d90 | ||
|
|
589af545af | ||
|
|
9edbe5a2a8 | ||
|
|
60a1cf3727 | ||
|
|
4e9863aa76 | ||
|
|
679d231743 | ||
|
|
8c929cf94b | ||
|
|
486ae903a3 | ||
|
|
fbf9a58fff | ||
|
|
d67649e458 | ||
|
|
08c5758aee | ||
|
|
c85987459a | ||
|
|
47882ab933 | ||
|
|
a76c13c461 | ||
|
|
67391615d9 | ||
|
|
53ae10e17b | ||
|
|
223ba7fdee | ||
|
|
6e1d6b5051 | ||
|
|
96657e1a7d | ||
|
|
82767a2a18 | ||
|
|
f8a978d1d8 | ||
|
|
fe1aa084fb | ||
|
|
cd1a4573b1 | ||
|
|
0df8663c8b | ||
|
|
1985e24517 | ||
|
|
234e39eaea | ||
|
|
41b67fdd07 | ||
|
|
112a731812 | ||
|
|
be36316355 | ||
|
|
2afd9ab68b | ||
|
|
4cfa22501c | ||
|
|
519a6e91f4 | ||
|
|
1587dc7b84 | ||
|
|
523b315ab1 | ||
|
|
1c6730ea5f | ||
|
|
a4eb3a7a0b | ||
|
|
6898f9c54a | ||
|
|
dadda2de96 | ||
|
|
c27c300324 | ||
|
|
06a30f7ae9 | ||
|
|
74c2b1c252 | ||
|
|
7b7f511dc7 | ||
|
|
0c45812c69 | ||
|
|
ac855963cb | ||
|
|
02e4abbff2 | ||
|
|
76a0cd507f | ||
|
|
91c16812df | ||
|
|
922f5024ff | ||
|
|
8dc7ad8dcc | ||
|
|
c8fdfc77f2 | ||
|
|
ce311c93b1 | ||
|
|
04c4c77d1e | ||
|
|
1c8fd479d0 | ||
|
|
497ca1800b | ||
|
|
7f995e964d | ||
|
|
3b3dae51b5 | ||
|
|
c314e8d267 | ||
|
|
8dab33dee9 | ||
|
|
217ce2212a | ||
|
|
4ccee02fe3 | ||
|
|
ee7bdf4ae2 | ||
|
|
fa8593c898 | ||
|
|
000884b080 | ||
|
|
9eb1fe61e7 | ||
|
|
741b936680 | ||
|
|
61b57aceae | ||
|
|
7b13b2d213 | ||
|
|
0401a13d5d | ||
|
|
95d39a3ff6 | ||
|
|
34e286c261 | ||
|
|
0b19d01e6d | ||
|
|
5e7387fa9c | ||
|
|
51fd598e32 | ||
|
|
269dac2bf8 | ||
|
|
9781c4352f | ||
|
|
9bd46932c3 | ||
|
|
ac5ebae639 | ||
|
|
a608c3984b | ||
|
|
c5081559cf | ||
|
|
0ea5f14ab7 | ||
|
|
9f02eb3359 | ||
|
|
89cb4f9536 | ||
|
|
fbf34f51ec | ||
|
|
337c1fdef3 | ||
|
|
e0b9e3e738 | ||
|
|
34bc82ddd6 | ||
|
|
cae4e1ff7c | ||
|
|
e1bc0b53a2 | ||
|
|
ae4a332946 | ||
|
|
15a7bab5f3 | ||
|
|
c6e2e875d5 | ||
|
|
2e739cdc07 | ||
|
|
bd64be2b5d | ||
|
|
fff76e6013 | ||
|
|
3d00338bc8 | ||
|
|
7d7ef3c7eb | ||
|
|
9941be1d1a | ||
|
|
7185741881 | ||
|
|
a996f5ca34 | ||
|
|
f41789e8ba | ||
|
|
fc22702bc1 | ||
|
|
2e8a9d9050 | ||
|
|
6e3822caa2 | ||
|
|
085983d4cd | ||
|
|
4af5314cd5 | ||
|
|
3ef1f6a726 | ||
|
|
85d397b1a5 | ||
|
|
2faa252f88 | ||
|
|
5195f5f248 | ||
|
|
597a5a4925 | ||
|
|
ab84000040 | ||
|
|
ab466d3073 | ||
|
|
792883c3db | ||
|
|
6ca931207d | ||
|
|
70f5838c85 | ||
|
|
49942df205 | ||
|
|
888cc7eeee | ||
|
|
8344622815 | ||
|
|
dd9caff60f | ||
|
|
55916d7cd8 | ||
|
|
7671a983bf | ||
|
|
891e7029fc | ||
|
|
7bdf52d824 | ||
|
|
c171e02cc9 | ||
|
|
45aa352578 | ||
|
|
92cef6726a | ||
|
|
0d91b9474c | ||
|
|
c50547e465 | ||
|
|
74bf357757 | ||
|
|
669489fe97 | ||
|
|
9d0dd59c6c | ||
|
|
638233263d | ||
|
|
5aa1d21ed4 | ||
|
|
f20429664f | ||
|
|
3557779efc | ||
|
|
fe8d20f86e | ||
|
|
39b68aa07d | ||
|
|
55ce381d28 | ||
|
|
05b9252a4b | ||
|
|
766c632c30 | ||
|
|
333db4ca77 | ||
|
|
f799550095 | ||
|
|
fb7be8684f | ||
|
|
82f69beb38 | ||
|
|
d8dc4e79fe | ||
|
|
8f07addade | ||
|
|
4666ee7261 | ||
|
|
a6736c4cd5 | ||
|
|
7447b61841 | ||
|
|
723121d671 | ||
|
|
fe8098c8b2 | ||
|
|
e6b317b762 | ||
|
|
56397e2027 | ||
|
|
6bbe738f91 | ||
|
|
a893cac951 | ||
|
|
c27249b676 | ||
|
|
1ec33bdd8a | ||
|
|
d74865b5f1 | ||
|
|
548589cb70 | ||
|
|
febcb2b1c7 | ||
|
|
beacbd3496 | ||
|
|
a77e857868 | ||
|
|
6c6956f1c1 | ||
|
|
504917bfa6 | ||
|
|
37f773a5ee | ||
|
|
aace96a96c | ||
|
|
b2a64ea066 | ||
|
|
baeac6fa68 | ||
|
|
a35faa9554 | ||
|
|
700e9c0c3a | ||
|
|
2dc409bf38 | ||
|
|
7fa04cf183 | ||
|
|
b76e63d222 | ||
|
|
3393ac1035 | ||
|
|
2886d90dde | ||
|
|
e3deb03331 | ||
|
|
e64b7d2e86 | ||
|
|
2fe085d409 | ||
|
|
a1983e2a32 | ||
|
|
4f495a0ed7 | ||
|
|
f2e39af12c | ||
|
|
111d4d70bd | ||
|
|
fae2e4851c | ||
|
|
43ff2daebe | ||
|
|
19373d815f | ||
|
|
e9a5ec1a58 | ||
|
|
ec97931ee4 | ||
|
|
20c0712aba | ||
|
|
2e5e5beb83 | ||
|
|
54ab0a3f98 | ||
|
|
d61d6e28f2 | ||
|
|
1d8b37eee0 | ||
|
|
2b488ccff4 | ||
|
|
cedbd1fd4c | ||
|
|
f27e5efe0f | ||
|
|
7eeaeafbd0 | ||
|
|
e4d868652a | ||
|
|
71d77ac27c | ||
|
|
1f80a05477 | ||
|
|
a02c8d242a | ||
|
|
edc18e3376 | ||
|
|
1f797e5e3a | ||
|
|
aee4e96619 | ||
|
|
7631c191c5 | ||
|
|
8802354ca9 | ||
|
|
627b94e5ab | ||
|
|
8ed842603c | ||
|
|
6904020f7d | ||
|
|
d3fd3bcacc | ||
|
|
366c5fcb4d | ||
|
|
2c44f4ede9 | ||
|
|
3f0ef8e08f | ||
|
|
01af249e95 | ||
|
|
a1e6439a5c | ||
|
|
ba305ad18b | ||
|
|
95dd6c485c | ||
|
|
dbfdbcba4a | ||
|
|
14cc33cd44 | ||
|
|
9a53b954f8 | ||
|
|
615026984c | ||
|
|
bd660dee82 | ||
|
|
75dc72550f | ||
|
|
c7443191ff | ||
|
|
dff4af94f5 | ||
|
|
a1fc606d99 | ||
|
|
89ceaac3d1 | ||
|
|
8df8ef629e | ||
|
|
07262bd62f | ||
|
|
5963f3b7f4 | ||
|
|
7e861aa3bb | ||
|
|
a4da036266 | ||
|
|
487ab0648d | ||
|
|
e91a23b3ed | ||
|
|
d5d16d1616 | ||
|
|
e81ac11466 | ||
|
|
93c0a8a7ba | ||
|
|
42a4596be6 | ||
|
|
53772e5536 | ||
|
|
2446e173b6 | ||
|
|
30efb3e824 | ||
|
|
ab0ae8ce91 | ||
|
|
172689976e | ||
|
|
bf47282ea7 | ||
|
|
ec8348854b | ||
|
|
348c562f97 | ||
|
|
92bd2166e3 | ||
|
|
d6a1d2deb5 | ||
|
|
f98d71ba6e | ||
|
|
abd8a09264 | ||
|
|
9bfbd25bd8 | ||
|
|
afc2b2ea95 | ||
|
|
6bf27b9976 | ||
|
|
29c8a179e3 | ||
|
|
be906fec76 | ||
|
|
9aacf32ac7 | ||
|
|
010a140835 | ||
|
|
0b72de7056 | ||
|
|
ddbfa02d5c | ||
|
|
cd563bffd5 | ||
|
|
306e1b6391 | ||
|
|
f8f79d3d18 | ||
|
|
58bc53683d | ||
|
|
41a4dea208 | ||
|
|
e56f6b602e | ||
|
|
fda0270343 | ||
|
|
0493cf60da | ||
|
|
98b98ddf8f | ||
|
|
171e87d6ba | ||
|
|
6d19579a70 | ||
|
|
766ab452cc | ||
|
|
add5179fc8 | ||
|
|
e66e2cbc04 | ||
|
|
9c6ed20d6c | ||
|
|
b6085fceae | ||
|
|
326823ee00 | ||
|
|
4d2eaa882b | ||
|
|
f6e7bec357 | ||
|
|
9b1772824b | ||
|
|
840e97edd9 | ||
|
|
a2f847e370 | ||
|
|
c255625131 | ||
|
|
5df8d1c4c0 | ||
|
|
04ebe253bb | ||
|
|
3541db891d | ||
|
|
fb4f88295c | ||
|
|
8c6bb7383c | ||
|
|
5a37400408 | ||
|
|
1acccc002a | ||
|
|
144fdb43da | ||
|
|
a17f3b1c5a | ||
|
|
a9b5d5ea58 | ||
|
|
b85825ba4a | ||
|
|
06a14c5b20 | ||
|
|
1f09b853d2 | ||
|
|
953e0e7f12 | ||
|
|
7b54d188f4 | ||
|
|
39239d1416 | ||
|
|
036aab2e2e | ||
|
|
eb930ac544 | ||
|
|
29343f8e9c | ||
|
|
0a2466e55b | ||
|
|
07c02f94da | ||
|
|
f70e1b343d | ||
|
|
26d4f3d70b | ||
|
|
581145eae2 | ||
|
|
50cbc85da6 | ||
|
|
df670a872e | ||
|
|
6163265323 | ||
|
|
20e60ba812 | ||
|
|
00c2894bb4 | ||
|
|
f10b0e3070 | ||
|
|
ece9e03fba | ||
|
|
87f0408bd5 | ||
|
|
46848cb5c9 | ||
|
|
0ca834cc70 | ||
|
|
0d60ae2f58 | ||
|
|
2712d98871 | ||
|
|
377356b9c1 | ||
|
|
6fa5f75510 | ||
|
|
199d970947 | ||
|
|
42f2bd496d | ||
|
|
5508b6cc60 | ||
|
|
92a374e63c | ||
|
|
e5a9c947aa | ||
|
|
c97fce71f7 | ||
|
|
1a266637c5 | ||
|
|
b9c69f96fd | ||
|
|
5ed30eb005 | ||
|
|
7e52087c73 | ||
|
|
4100ebb670 | ||
|
|
d77231b99f | ||
|
|
1341973f07 | ||
|
|
fb2ab44e3e | ||
|
|
827f9cdcdf |
+2
-1
@@ -1,5 +1,5 @@
|
||||
.eslintignore
|
||||
.eslintrc.json
|
||||
.eslintrc.js
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
@@ -13,4 +13,5 @@ images
|
||||
node_modules
|
||||
scripts
|
||||
test
|
||||
coverage
|
||||
update.sh
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
|
||||
module.exports = {
|
||||
'env': {
|
||||
'jest/globals': true,
|
||||
'es6': true,
|
||||
'node': true,
|
||||
},
|
||||
'extends': ['eslint:recommended', 'google', 'plugin:jest/recommended', 'plugin:jest/style'],
|
||||
'parserOptions': {
|
||||
'ecmaVersion': 2018,
|
||||
'sourceType': 'module',
|
||||
},
|
||||
'rules': {
|
||||
'require-jsdoc': 'off',
|
||||
'indent': ['error', 4],
|
||||
'max-len': ['error', {'code': 120}],
|
||||
'no-prototype-builtins': 'off',
|
||||
},
|
||||
'plugins': [
|
||||
'jest',
|
||||
],
|
||||
'overrides': [{
|
||||
files: ['*.ts'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: ['plugin:@typescript-eslint/recommended'],
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'error',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
'@typescript-eslint/semi': ['error'],
|
||||
'array-bracket-spacing': ['error', 'never'],
|
||||
'indent': ['error', 4],
|
||||
'max-len': ['error', {'code': 120}],
|
||||
'no-return-await': 'error',
|
||||
'object-curly-spacing': ['error', 'never'],
|
||||
},
|
||||
}],
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"env": {
|
||||
"jest/globals": true,
|
||||
"es6": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "google", "plugin:jest/recommended", "plugin:jest/style"],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2018,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"require-jsdoc": "off",
|
||||
"indent": ["error", 4],
|
||||
"max-len": ["error", { "code": 120 }],
|
||||
"no-prototype-builtins": "off"
|
||||
},
|
||||
"plugins": [
|
||||
"jest"
|
||||
]
|
||||
}
|
||||
+3
-1
@@ -1,2 +1,4 @@
|
||||
github: [koenkk]
|
||||
custom: https://www.paypal.me/koenkk
|
||||
custom:
|
||||
- https://www.paypal.me/koenkk
|
||||
- https://www.buymeacoffee.com/koenkk
|
||||
|
||||
@@ -9,25 +9,25 @@ Rules (don't ignore these, your issue will be closed without further notice):
|
||||
- Make sure you are running the latest firmware https://github.com/koenkk/z-stack-firmware.
|
||||
|
||||
Did you read the FAQ?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html
|
||||
- https://www.zigbee2mqtt.io/guide/faq
|
||||
|
||||
Zigbee2MQTT fails to start?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html#help-zigbee2mqtt-fails-to-start
|
||||
- https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html
|
||||
|
||||
Having issues when using a CC2531?
|
||||
- Make sure the CC2531 is connected through a USB extension cable
|
||||
- Try the source routing firmware: https://github.com/Koenkk/Z-Stack-firmware/tree/master/coordinator/Z-Stack_Home_1.2/bin/source_routing
|
||||
- With larger networks (30/40+) devices, your CC2531 might not be powerful enough. This will cause weird issues, in this case it's advised to use a more powerful adapter: https://www.zigbee2mqtt.io/information/supported_adapters.html#texas-instruments-cc26x2r1
|
||||
- With larger networks (30/40+) devices, your CC2531 might not be powerful enough. This will cause weird issues, in this case it's advised to use a more powerful adapter: https://www.zigbee2mqtt.io/guide/adapters/
|
||||
|
||||
Unsupported device?
|
||||
- https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html
|
||||
- https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html
|
||||
|
||||
Device does not pair or interview fails?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html#why-does-my-device-not-or-fail-to-pair
|
||||
- https://www.zigbee2mqtt.io/guide/faq/#why-does-my-device-not-or-fail-to-pair
|
||||
|
||||
Bug report?
|
||||
- If applicable, provide steps how to reproduce the problem.
|
||||
- Provide the herdsman debug logging: https://www.zigbee2mqtt.io/information/debug.html#zigbee-herdsman-debug-logging
|
||||
- Provide the herdsman debug logging: https://www.zigbee2mqtt.io/guide/usage/debug.html
|
||||
|
||||
-->
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ contact_links:
|
||||
url: https://github.com/zigbee2mqtt/hassio-zigbee2mqtt/issues
|
||||
about: Issues related to the Home Assistant addon.
|
||||
- name: FAQ
|
||||
url: https://www.zigbee2mqtt.io/information/FAQ.html
|
||||
url: https://www.zigbee2mqtt.io/guide/faq
|
||||
about: Frequently asked questions.
|
||||
- name: Support Chat
|
||||
url: https://discord.gg/NyseBeK
|
||||
|
||||
@@ -8,7 +8,7 @@ assignees: ''
|
||||
---
|
||||
|
||||
<!--
|
||||
VERY IMPORTANT: Before submitting a request, first (try to) follow: https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html
|
||||
VERY IMPORTANT: Before submitting a request, first (try to) follow: https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html
|
||||
|
||||
Dumping just the info about this device without actually attemping adding support yourself will likely lead into this issue being ignored.
|
||||
Clearly indicate what you tried and where you got stuck.
|
||||
|
||||
@@ -18,25 +18,25 @@ Rules (don't ignore these, your issue will be closed without further notice):
|
||||
- Make sure you are running the latest firmware https://github.com/koenkk/z-stack-firmware.
|
||||
|
||||
Did you read the FAQ?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html
|
||||
- https://www.zigbee2mqtt.io/guide/faq/
|
||||
|
||||
Zigbee2MQTT fails to start?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html#help-zigbee2mqtt-fails-to-start
|
||||
- https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html
|
||||
|
||||
Having issues when using a CC2531?
|
||||
- Make sure the CC2531 is connected through a USB extension cable
|
||||
- Try the source routing firmware: https://github.com/Koenkk/Z-Stack-firmware/tree/master/coordinator/Z-Stack_Home_1.2/bin/source_routing
|
||||
- With larger networks (30/40+) devices, your CC2531 might not be powerful enough. This will cause weird issues, in this case it's advised to use a more powerful adapter: https://www.zigbee2mqtt.io/information/supported_adapters.html#texas-instruments-cc26x2r1
|
||||
- With larger networks (30/40+) devices, your CC2531 might not be powerful enough. This will cause weird issues, in this case it's advised to use a more powerful adapter: https://www.zigbee2mqtt.io/guide/adapters/
|
||||
|
||||
Unsupported device?
|
||||
- https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html
|
||||
- https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html
|
||||
|
||||
Device does not pair or interview fails?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html#why-does-my-device-not-or-fail-to-pair
|
||||
- https://www.zigbee2mqtt.io/guide/faq/#why-does-my-device-not-or-fail-to-pair
|
||||
|
||||
Bug report?
|
||||
- If applicable, provide steps how to reproduce the problem.
|
||||
- Provide the herdsman debug logging: https://www.zigbee2mqtt.io/information/debug.html#zigbee-herdsman-debug-logging
|
||||
- Provide the herdsman debug logging: https://www.zigbee2mqtt.io/guide/usage/debug.html
|
||||
|
||||
-->
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2-beta
|
||||
with:
|
||||
node-version: 12
|
||||
node-version: 14
|
||||
registry-url: https://registry.npmjs.org/
|
||||
- name: Get NodeJS Version
|
||||
id: nodeversion
|
||||
@@ -24,6 +24,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Test
|
||||
run: npm run test-with-coverage
|
||||
- name: Lint
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, dev ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ master ]
|
||||
schedule:
|
||||
- cron: '28 12 * * 3'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
||||
# Learn more:
|
||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
@@ -14,10 +14,10 @@ jobs:
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
- uses: actions/setup-node@v2-beta
|
||||
with:
|
||||
node-version: 12
|
||||
- run: npm install
|
||||
- run: npm ls --depth 0 --prod --parseable | tail -n +2 | grep -v "zigbee" | xargs -I '{}' basename "{}" | xargs -I '{}' npm install '{}'@latest
|
||||
- run: rm -rf node_modules npm-shrinkwrap.json && npm install && npm shrinkwrap --dev
|
||||
node-version: 14
|
||||
- run: rm -rf npm-shrinkwrap.json
|
||||
- run: npx npm-check-updates -u
|
||||
- run: npm install && npm shrinkwrap --dev
|
||||
- uses: peter-evans/create-pull-request@v3
|
||||
with:
|
||||
commit-message: Update dependencies
|
||||
|
||||
@@ -13,10 +13,18 @@ jobs:
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
- uses: actions/setup-node@v2-beta
|
||||
with:
|
||||
node-version: 12
|
||||
node-version: 14
|
||||
- run: npm install zigbee2mqtt-frontend@${{ github.event.client_payload.version }} --save-exact
|
||||
- uses: peter-evans/create-pull-request@v3
|
||||
id: cpr
|
||||
with:
|
||||
commit-message: Update zigbee2mqtt-frontend to ${{ github.event.client_payload.version }}
|
||||
branch: "update_frontend"
|
||||
title: Update zigbee-frontend to ${{ github.event.client_payload.version }}
|
||||
- run: sleep 5 # Otherwise pull request may not exist yet causing automerge to fail
|
||||
- uses: peter-evans/enable-pull-request-automerge@v1
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
with:
|
||||
token: ${{ secrets.CR_PAT }}
|
||||
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
merge-method: squash
|
||||
|
||||
@@ -13,10 +13,18 @@ jobs:
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
- uses: actions/setup-node@v2-beta
|
||||
with:
|
||||
node-version: 12
|
||||
node-version: 14
|
||||
- run: npm install zigbee-herdsman@${{ github.event.client_payload.version }} --save-exact
|
||||
- uses: peter-evans/create-pull-request@v3
|
||||
id: cpr
|
||||
with:
|
||||
commit-message: Update zigbee-herdsman to ${{ github.event.client_payload.version }}
|
||||
branch: "update_zh"
|
||||
title: Update zigbee-herdsman to ${{ github.event.client_payload.version }}
|
||||
- run: sleep 5 # Otherwise pull request may not exist yet causing automerge to fail
|
||||
- uses: peter-evans/enable-pull-request-automerge@v1
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
with:
|
||||
token: ${{ secrets.CR_PAT }}
|
||||
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
merge-method: squash
|
||||
@@ -13,10 +13,18 @@ jobs:
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
- uses: actions/setup-node@v2-beta
|
||||
with:
|
||||
node-version: 12
|
||||
node-version: 14
|
||||
- run: npm install zigbee-herdsman-converters@${{ github.event.client_payload.version }} --save-exact
|
||||
- uses: peter-evans/create-pull-request@v3
|
||||
id: cpr
|
||||
with:
|
||||
commit-message: Update zigbee-herdsman-converters to ${{ github.event.client_payload.version }}
|
||||
branch: "update_zhc"
|
||||
title: Update zigbee-herdsman-converters to ${{ github.event.client_payload.version }}
|
||||
- run: sleep 5 # Otherwise pull request may not exist yet causing automerge to fail
|
||||
- uses: peter-evans/enable-pull-request-automerge@v1
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
with:
|
||||
token: ${{ secrets.CR_PAT }}
|
||||
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
merge-method: squash
|
||||
+7
-1
@@ -11,6 +11,9 @@ pids
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Compiled source
|
||||
dist/*
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
@@ -72,5 +75,8 @@ data/extension
|
||||
# MacOS indexing file
|
||||
.DS_Store
|
||||
|
||||
# IDE specific folders
|
||||
.idea
|
||||
|
||||
# Ignore config
|
||||
data/*.yaml
|
||||
data/*.yaml
|
||||
|
||||
@@ -7,6 +7,7 @@ coverage
|
||||
.jenkins.yml
|
||||
.codeclimate.yml
|
||||
.github
|
||||
babel.config.js
|
||||
|
||||
#linters
|
||||
.jscsrc
|
||||
@@ -14,11 +15,13 @@ coverage
|
||||
.eslintrc*
|
||||
.dockerignore
|
||||
.eslintignore
|
||||
.gitignore
|
||||
|
||||
#editor settings
|
||||
.vscode
|
||||
|
||||
#src
|
||||
lib
|
||||
docs
|
||||
docker
|
||||
images
|
||||
|
||||
+2
-2
@@ -3,6 +3,6 @@
|
||||
Everybody is invited and welcome to contribute to Zigbee2MQTT. Zigbee2MQTT is writen in JavaScript and is based upon [zigbee-herdsman](https://github.com/koenkk/zigbee-herdsman) and [zigbee-herdsman-converters](https://github.com/koenkk/zigbee-herdsman-converters). Zigbee-herdsman-converters contains all device definition, zigbee-herdsman is responsible for handling all communication with the adapter.
|
||||
|
||||
- Pull requests are always created against the [**dev**](https://github.com/Koenkk/zigbee2mqtt/tree/dev) branch.
|
||||
- Easiest way to start developing Zigbee2MQTT is by setting up a development environment (aka bare-metal installation). You can follow this [guide](https://www.zigbee2mqtt.io/getting_started/running_zigbee2mqtt.html#running-zigbee2mqtt) to do this.
|
||||
- Easiest way to start developing Zigbee2MQTT is by setting up a development environment (aka bare-metal installation). You can follow this [guide](https://www.zigbee2mqtt.io/guide/installation/01_linux.html) to do this.
|
||||
- You can run the tests locally by executing `npm test`. Zigbee2MQTT enforces 100% code coverage, in case you add new code check if you code is covered by running `npm run test-with-coverage`. The coverage report can be found under `coverage/lcov-report/index.html`. Linting is also enforced and can be run with `npm run eslint`.
|
||||
- When you want to add support for a new devices no changes to Zigbee2MQTT have to be made, only to zigbee-herdsman-converters. You can find a guide for it [here](https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html).
|
||||
- When you want to add support for a new devices no changes to Zigbee2MQTT have to be made, only to zigbee-herdsman-converters. You can find a guide for it [here](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html).
|
||||
|
||||
@@ -35,26 +35,26 @@
|
||||
</div>
|
||||
<h1>Zigbee2MQTT 🌉 🐝</h1>
|
||||
<p>
|
||||
Allows you to use your Zigbee devices <b>without</b> the vendors bridge or gateway.
|
||||
Allows you to use your Zigbee devices <b>without</b> the vendor's bridge or gateway.
|
||||
</p>
|
||||
<p>
|
||||
It bridges events and allows you to control your Zigbee devices via MQTT. In this way you can integrate your Zigbee devices with whatever smart home infrastructure you are using.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## [Getting started](https://www.zigbee2mqtt.io/#getting-started)
|
||||
## [Getting started](https://www.zigbee2mqtt.io/guide/getting-started)
|
||||
The [documentation](https://www.zigbee2mqtt.io/) provides you all the information needed to get up and running! Make sure you don't skip sections if this is your first visit, as there might be important details in there for you.
|
||||
|
||||
If you aren't familiar with **Zigbee** terminology make sure you [read this](https://www.zigbee2mqtt.io/information/zigbee_network.html) to help you out.
|
||||
If you aren't familiar with **Zigbee** terminology make sure you [read this](https://www.zigbee2mqtt.io/advanced/zigbee/01_zigbee_network.html) to help you out.
|
||||
|
||||
## [Integrations](https://www.zigbee2mqtt.io/#integration)
|
||||
## [Integrations](https://www.zigbee2mqtt.io/guide/usage/integrations.html)
|
||||
Zigbee2MQTT integrates well with (almost) every home automation solution because it uses MQTT. However the following integrations are worth mentioning:
|
||||
|
||||
<img align="left" height="100px" width="100px" src="https://user-images.githubusercontent.com/7738048/40914297-49e6e560-6800-11e8-8904-36cce896e5a8.png">
|
||||
|
||||
### [Home Assistant](https://www.home-assistant.io/)
|
||||
- [Home Assistant](https://www.home-assistant.io/hassio/): Using [the official addon](https://github.com/zigbee2mqtt/hassio-zigbee2mqtt)
|
||||
- Generic install or Hassbian: Using instructions [here](https://www.zigbee2mqtt.io/integration/home_assistant.html)
|
||||
- Generic install or Hassbian: Using instructions [here](https://www.zigbee2mqtt.io/guide/usage/integrations/home_assistant.html)
|
||||
|
||||
<img align="left" height="100px" width="100px" src="https://user-images.githubusercontent.com/2734836/47615848-b8dd8700-dabd-11e8-9d77-175002dd8987.png">
|
||||
|
||||
@@ -67,12 +67,15 @@ Zigbee2MQTT integrates well with (almost) every home automation solution because
|
||||

|
||||
|
||||
### Internal Architecture
|
||||
Zigbee2MQTT is made up of three modules, each developed in its own Github project. Starting from the hardware (adapter) and moving up; [zigbee-herdsman](https://github.com/koenkk/zigbee-herdsman) connects to your Zigbee adapter an makes an API available to the higher levels of the stack. For e.g. Texas Instruments hardware, zigbee-herdsman uses the [TI zStack monitoring and test API](https://github.com/koenkk/zigbee-herdsman/raw/master/docs/Z-Stack%20Monitor%20and%20Test%20API.pdf) to communicate with the adapter. Zigbee-herdsman handles the core Zigbee communication. The module [zigbee-herdsman-converters](https://github.com/koenkk/zigbee-herdsman-converters) handles the mapping from individual device models to the Zigbee clusters they support. [Zigbee clusters](https://github.com/Koenkk/zigbee-herdsman/raw/master/docs/Zigbee%20Cluster%20Library%20Specification%20v7.pdf) are the layers of the Zigbee protocol on top of the base protocol that define things like how lights, sensors and switches talk to each other over the Zigbee network. Finally, the Zigbee2MQTT module drives zigbee-herdsman and maps the zigbee messages to MQTT messages. Zigbee2MQTT also keeps track of the state of the system. It uses a `database.db` file to store this state; a text file with a JSON database of connected devices and their capabilities.
|
||||
Zigbee2MQTT is made up of three modules, each developed in its own Github project. Starting from the hardware (adapter) and moving up; [zigbee-herdsman](https://github.com/koenkk/zigbee-herdsman) connects to your Zigbee adapter and makes an API available to the higher levels of the stack. For e.g. Texas Instruments hardware, zigbee-herdsman uses the [TI zStack monitoring and test API](https://github.com/koenkk/zigbee-herdsman/raw/master/docs/Z-Stack%20Monitor%20and%20Test%20API.pdf) to communicate with the adapter. Zigbee-herdsman handles the core Zigbee communication. The module [zigbee-herdsman-converters](https://github.com/koenkk/zigbee-herdsman-converters) handles the mapping from individual device models to the Zigbee clusters they support. [Zigbee clusters](https://github.com/Koenkk/zigbee-herdsman/blob/master/docs/07-5123-08-Zigbee-Cluster-Library.pdf) are the layers of the Zigbee protocol on top of the base protocol that define things like how lights, sensors and switches talk to each other over the Zigbee network. Finally, the Zigbee2MQTT module drives zigbee-herdsman and maps the zigbee messages to MQTT messages. Zigbee2MQTT also keeps track of the state of the system. It uses a `database.db` file to store this state; a text file with a JSON database of connected devices and their capabilities.
|
||||
|
||||
### Developing
|
||||
Zigbee2MQTT uses TypeScript (partially for now). Therefore after making changes to files in the `lib/` directory you need to recompile Zigbee2MQTT. This can be done by executing `npm run build`. For faster development instead of running `npm run build` you can run `npm run build-watch` in another terminal session, this will recompile as you change files.
|
||||
|
||||
## Supported devices
|
||||
See [Supported devices](https://www.zigbee2mqtt.io/information/supported_devices.html) to check whether your device is supported. There is quite an extensive list, including devices from vendors like Xiaomi, Ikea, Philips, OSRAM and more.
|
||||
See [Supported devices](https://www.zigbee2mqtt.io/supported-devices) to check whether your device is supported. There is quite an extensive list, including devices from vendors like Xiaomi, Ikea, Philips, OSRAM and more.
|
||||
|
||||
If it's not listed in [Supported devices](https://www.zigbee2mqtt.io/information/supported_devices.html), support can be added (fairly) easy, see [How to support new devices](https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html).
|
||||
If it's not listed in [Supported devices](https://www.zigbee2mqtt.io/supported-devices), support can be added (fairly) easily, see [How to support new devices](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html).
|
||||
|
||||
## Support & help
|
||||
If you need assistance you can check [opened issues](https://github.com/Koenkk/zigbee2mqtt/issues). Feel free to help with Pull Requests when you were able to fix things or add new devices or just share the love on social media.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['@babel/preset-env', {targets: {node: 'current'}}],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
assumptions: {setPublicClassFields: true},
|
||||
plugins: [
|
||||
['@babel/plugin-proposal-decorators', {'legacy': true}],
|
||||
['@babel/plugin-proposal-class-properties'],
|
||||
],
|
||||
};
|
||||
+13
-9
@@ -1,24 +1,28 @@
|
||||
FROM node:14-alpine3.12 as base
|
||||
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache tzdata eudev
|
||||
RUN apk add --no-cache tzdata eudev tini
|
||||
|
||||
COPY package.json ./
|
||||
|
||||
# Dependencies
|
||||
FROM base as dependencies
|
||||
# Dependencies and build
|
||||
FROM base as dependencies_and_build
|
||||
|
||||
COPY npm-shrinkwrap.json ./
|
||||
COPY npm-shrinkwrap.json tsconfig.json index.js ./
|
||||
COPY lib ./lib
|
||||
|
||||
RUN apk add --no-cache --virtual .buildtools make gcc g++ python3 linux-headers git && \
|
||||
npm ci --production && \
|
||||
npm ci --no-audit --no-optional --no-update-notifier && \
|
||||
npm run build && \
|
||||
rm -rf node_modules && \
|
||||
npm ci --production --no-audit --no-optional --no-update-notifier && \
|
||||
apk del .buildtools
|
||||
|
||||
# Release
|
||||
FROM base as release
|
||||
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY lib ./lib
|
||||
COPY --from=dependencies_and_build /app/node_modules ./node_modules
|
||||
COPY --from=dependencies_and_build /app/dist ./dist
|
||||
COPY LICENSE index.js data/configuration.yaml ./
|
||||
|
||||
COPY docker/docker-entrypoint.sh /usr/local/bin/
|
||||
@@ -27,7 +31,7 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
RUN mkdir /app/data
|
||||
|
||||
ARG COMMIT
|
||||
RUN echo "{\"hash\": \"$COMMIT\"}" > .hash.json
|
||||
RUN echo "$COMMIT" > dist/.hash
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["npm", "start"]
|
||||
CMD [ "/sbin/tini", "--", "node", "index.js"]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
qemu-aarch64-static and qemu-arm-static are taken from https://github.com/resin-io/qemu/releases/tag/v2.9.0%2Bresin1
|
||||
@@ -1,10 +1,19 @@
|
||||
require('core-js/features/object/from-entries');
|
||||
require('core-js/features/array/flat');
|
||||
const semver = require('semver');
|
||||
const engines = require('./package.json').engines;
|
||||
const indexJsRestart = 'indexjs.restart';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {exec} = require('child_process');
|
||||
const rimraf = require('rimraf');
|
||||
require('source-map-support').install();
|
||||
|
||||
let controller;
|
||||
let stopping = false;
|
||||
|
||||
const hashFile = path.join(__dirname, 'dist', '.hash');
|
||||
|
||||
async function restart() {
|
||||
await stop(indexJsRestart);
|
||||
await start();
|
||||
@@ -16,14 +25,60 @@ async function exit(code, reason) {
|
||||
}
|
||||
}
|
||||
|
||||
async function currentHash() {
|
||||
const git = require('git-last-commit');
|
||||
return new Promise((resolve) => {
|
||||
git.getLastCommit((err, commit) => {
|
||||
if (err) resolve('unknown');
|
||||
else resolve(commit.shortHash);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function writeHash() {
|
||||
const hash = await currentHash();
|
||||
fs.writeFileSync(hashFile, hash);
|
||||
}
|
||||
|
||||
async function build(reason) {
|
||||
return new Promise((resolve, reject) => {
|
||||
process.stdout.write(`Building Zigbee2MQTT... (${reason})`);
|
||||
rimraf.sync('dist');
|
||||
exec('npm run build', {cwd: __dirname}, async (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
process.stdout.write(', failed\n');
|
||||
reject(err);
|
||||
} else {
|
||||
process.stdout.write(', finished\n');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function checkDist() {
|
||||
if (!fs.existsSync(hashFile)) {
|
||||
await build('initial build');
|
||||
}
|
||||
|
||||
const distHash = fs.readFileSync(hashFile, 'utf-8');
|
||||
const hash = await currentHash();
|
||||
if (hash !== 'unknown' && distHash !== hash) {
|
||||
await build('hash changed');
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
await checkDist();
|
||||
|
||||
const version = engines.node;
|
||||
if (!semver.satisfies(process.version, version)) {
|
||||
console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`); // eslint-disable-line
|
||||
}
|
||||
|
||||
// Validate settings
|
||||
const settings = require('./lib/util/settings');
|
||||
const settings = require('./dist/util/settings');
|
||||
settings.reRead();
|
||||
const errors = settings.validate();
|
||||
if (errors.length > 0) {
|
||||
console.log(`\n\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`);
|
||||
@@ -32,12 +87,12 @@ async function start() {
|
||||
for (const error of errors) {
|
||||
console.log(`- ${error}`);
|
||||
}
|
||||
console.log(`\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/information/configuration.html`); // eslint-disable-line
|
||||
console.log(`\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration`); // eslint-disable-line
|
||||
console.log(`\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n`);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const Controller = require('./lib/controller');
|
||||
const Controller = require('./dist/controller');
|
||||
controller = new Controller(restart, exit);
|
||||
await controller.start();
|
||||
}
|
||||
@@ -53,7 +108,10 @@ async function handleQuit() {
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', handleQuit);
|
||||
process.on('SIGTERM', handleQuit);
|
||||
|
||||
start();
|
||||
if (process.argv.length === 3 && process.argv[2] === 'writehash') {
|
||||
writeHash();
|
||||
} else {
|
||||
process.on('SIGINT', handleQuit);
|
||||
process.on('SIGTERM', handleQuit);
|
||||
start();
|
||||
}
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
const MQTT = require('./mqtt');
|
||||
const Zigbee = require('./zigbee');
|
||||
const EventBus = require('./eventBus');
|
||||
const State = require('./state');
|
||||
const logger = require('./util/logger');
|
||||
const settings = require('./util/settings');
|
||||
const objectAssignDeep = require('object-assign-deep');
|
||||
const utils = require('./util/utils');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const assert = require('assert');
|
||||
|
||||
// Extensions
|
||||
const ExtensionFrontend = require('./extension/frontend');
|
||||
const ExtensionPublish = require('./extension/publish');
|
||||
const ExtensionReceive = require('./extension/receive');
|
||||
const ExtensionNetworkMap = require('./extension/networkMap');
|
||||
const ExtensionSoftReset = require('./extension/legacy/softReset');
|
||||
const ExtensionHomeAssistant = require('./extension/homeassistant');
|
||||
const ExtensionConfigure = require('./extension/configure');
|
||||
const ExtensionDeviceGroupMembership = require('./extension/legacy/deviceGroupMembership');
|
||||
const ExtensionBridgeLegacy = require('./extension/legacy/bridgeLegacy');
|
||||
const ExtensionBridge = require('./extension/bridge');
|
||||
const ExtensionGroups = require('./extension/groups');
|
||||
const ExtensionAvailability = require('./extension/availability');
|
||||
const ExtensionBind = require('./extension/bind');
|
||||
const ExtensionReport = require('./extension/legacy/report');
|
||||
const ExtensionOnEvent = require('./extension/onEvent');
|
||||
const ExtensionOTAUpdate = require('./extension/otaUpdate');
|
||||
const ExtensionExternalConverters = require('./extension/externalConverters');
|
||||
const ExtensionExternalExtension = require('./extension/externalExtension');
|
||||
|
||||
const AllExtensions = [
|
||||
ExtensionPublish, ExtensionReceive, ExtensionNetworkMap, ExtensionSoftReset, ExtensionHomeAssistant,
|
||||
ExtensionConfigure, ExtensionDeviceGroupMembership, ExtensionBridgeLegacy, ExtensionBridge, ExtensionGroups,
|
||||
ExtensionAvailability, ExtensionBind, ExtensionReport, ExtensionOnEvent, ExtensionOTAUpdate,
|
||||
ExtensionExternalConverters, ExtensionFrontend, ExtensionExternalExtension,
|
||||
];
|
||||
|
||||
class Controller {
|
||||
constructor(restartCallback, exitCallback) {
|
||||
this.zigbee = new Zigbee();
|
||||
this.mqtt = new MQTT();
|
||||
this.eventBus = new EventBus();
|
||||
this.state = new State(this.eventBus);
|
||||
this.restartCallback = restartCallback;
|
||||
this.exitCallback = exitCallback;
|
||||
|
||||
this.publishEntityState = this.publishEntityState.bind(this);
|
||||
this.enableDisableExtension = this.enableDisableExtension.bind(this);
|
||||
this.onZigbeeAdapterDisconnected = this.onZigbeeAdapterDisconnected.bind(this);
|
||||
this.addExtension = this.addExtension.bind(this);
|
||||
|
||||
// Initialize extensions.
|
||||
const args = [this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus];
|
||||
this.extensions = [
|
||||
new ExtensionBridge(...args, this.enableDisableExtension, this.restartCallback),
|
||||
new ExtensionPublish(...args),
|
||||
new ExtensionReceive(...args),
|
||||
new ExtensionDeviceGroupMembership(...args),
|
||||
new ExtensionConfigure(...args),
|
||||
new ExtensionNetworkMap(...args),
|
||||
new ExtensionGroups(...args),
|
||||
new ExtensionBind(...args),
|
||||
new ExtensionOnEvent(...args),
|
||||
new ExtensionOTAUpdate(...args),
|
||||
new ExtensionReport(...args),
|
||||
];
|
||||
|
||||
if (settings.get().frontend) {
|
||||
this.extensions.push(new ExtensionFrontend(...args));
|
||||
}
|
||||
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
this.extensions.push(new ExtensionBridgeLegacy(...args));
|
||||
}
|
||||
|
||||
if (settings.get().external_converters.length) {
|
||||
this.extensions.push(new ExtensionExternalConverters(...args));
|
||||
}
|
||||
|
||||
if (settings.get().homeassistant) {
|
||||
this.extensions.push(new ExtensionHomeAssistant(...args));
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (settings.get().advanced.soft_reset_timeout !== 0) {
|
||||
this.extensions.push(new ExtensionSoftReset(...args));
|
||||
}
|
||||
|
||||
if (settings.get().advanced.availability_timeout) {
|
||||
this.extensions.push(new ExtensionAvailability(...args));
|
||||
}
|
||||
this.extensions.push(new ExtensionExternalExtension(...args, this.addExtension, this.enableDisableExtension));
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.state.start();
|
||||
logger.logOutput();
|
||||
|
||||
const info = await utils.getZigbee2mqttVersion();
|
||||
logger.info(`Starting Zigbee2MQTT version ${info.version} (commit #${info.commitHash})`);
|
||||
|
||||
// Start zigbee
|
||||
try {
|
||||
await this.zigbee.start();
|
||||
this.callExtensionMethod('onZigbeeStarted', []);
|
||||
this.zigbee.on('event', this.onZigbeeEvent.bind(this));
|
||||
this.zigbee.on('adapterDisconnected', this.onZigbeeAdapterDisconnected);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start zigbee');
|
||||
logger.error('Exiting...');
|
||||
logger.error(error.stack);
|
||||
this.exitCallback(1);
|
||||
}
|
||||
|
||||
// Log zigbee clients on startup
|
||||
const devices = this.zigbee.getClients();
|
||||
logger.info(`Currently ${devices.length} devices are joined:`);
|
||||
for (const device of devices) {
|
||||
const entity = this.zigbee.resolveEntity(device);
|
||||
const model = entity.definition ?
|
||||
`${entity.definition.model} - ${entity.definition.vendor} ${entity.definition.description}` :
|
||||
'Not supported';
|
||||
logger.info(`${entity.name} (${entity.device.ieeeAddr}): ${model} (${entity.device.type})`);
|
||||
}
|
||||
|
||||
// Enable zigbee join.
|
||||
if (settings.get().permit_join) {
|
||||
logger.warn('`permit_join` set to `true` in configuration.yaml.');
|
||||
logger.warn('Allowing new devices to join.');
|
||||
logger.warn('Set `permit_join` to `false` once you joined all devices.');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.zigbee.permitJoin(settings.get().permit_join);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to set permit join to ${settings.get().permit_join}`);
|
||||
}
|
||||
|
||||
// MQTT
|
||||
this.mqtt.on('message', this.onMQTTMessage.bind(this));
|
||||
await this.mqtt.connect();
|
||||
|
||||
// Send all cached states.
|
||||
if (settings.get().advanced.cache_state_send_on_startup && settings.get().advanced.cache_state) {
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
if (this.state.exists(device.ieeeAddr)) {
|
||||
this.publishEntityState(device.ieeeAddr, this.state.get(device.ieeeAddr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add devices which are in database but not in configuration to configuration
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
if (!settings.getDevice(device.ieeeAddr)) {
|
||||
settings.addDevice(device.ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
// Call extensions
|
||||
await this.callExtensionMethod('onMQTTConnected', []);
|
||||
}
|
||||
|
||||
async enableDisableExtension(enable, name) {
|
||||
if (!enable) {
|
||||
const extension = this.extensions.find((e) => e.constructor.name === name);
|
||||
if (extension) {
|
||||
await this.callExtensionMethod('stop', [], [extension]);
|
||||
this.extensions.splice(this.extensions.indexOf(extension), 1);
|
||||
}
|
||||
} else {
|
||||
const Extension = AllExtensions.find((e) => e.name === name);
|
||||
assert(Extension, `Extension '${name}' does not exist`);
|
||||
const extension = new Extension(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus);
|
||||
this.extensions.push(extension);
|
||||
this.callExtensionMethod('onZigbeeStarted', [], [extension]);
|
||||
this.callExtensionMethod('onMQTTConnected', [], [extension]);
|
||||
}
|
||||
}
|
||||
|
||||
addExtension(extension) {
|
||||
this.extensions.push(extension);
|
||||
}
|
||||
|
||||
async stop(reason=null) {
|
||||
// Call extensions
|
||||
await this.callExtensionMethod('stop', []);
|
||||
|
||||
// Wrap-up
|
||||
this.state.stop();
|
||||
await this.mqtt.disconnect();
|
||||
|
||||
try {
|
||||
await this.zigbee.stop();
|
||||
logger.info('Stopped Zigbee2MQTT');
|
||||
this.exitCallback(0, reason);
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop Zigbee2MQTT');
|
||||
this.exitCallback(1, reason);
|
||||
}
|
||||
}
|
||||
|
||||
async onZigbeeAdapterDisconnected() {
|
||||
logger.error('Adapter disconnected, stopping');
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
async onZigbeeEvent(type, data) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(data.device || data.ieeeAddr);
|
||||
if (data.device && data.device.type === 'Coordinator') {
|
||||
logger.debug('Ignoring message from coordinator');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.device && !settings.getDevice(data.device.ieeeAddr)) {
|
||||
// Only deviceLeave doesn't have a device (not interesting to add to settings)
|
||||
resolvedEntity.settings = {...settings.get().device_options, ...settings.addDevice(data.device.ieeeAddr)};
|
||||
}
|
||||
|
||||
const name = resolvedEntity && resolvedEntity.settings ? resolvedEntity.settings.friendlyName : null;
|
||||
|
||||
if (type === 'message') {
|
||||
logger.debug(
|
||||
`Received Zigbee message from '${name}', type '${data.type}', cluster '${data.cluster}'` +
|
||||
`, data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}` +
|
||||
(data.hasOwnProperty('groupID') ? ` with groupID ${data.groupID}` : ``),
|
||||
);
|
||||
} else if (type === 'deviceJoined') {
|
||||
logger.info(`Device '${name}' joined`);
|
||||
} else if (type === 'deviceInterview') {
|
||||
if (data.status === 'successful') {
|
||||
logger.info(`Successfully interviewed '${name}', device has successfully been paired`);
|
||||
|
||||
if (resolvedEntity.definition) {
|
||||
const {vendor, description, model} = resolvedEntity.definition;
|
||||
logger.info(`Device '${name}' is supported, identified as: ${vendor} ${description} (${model})`);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Device '${name}' with Zigbee model '${data.device.modelID}' and manufacturer name ` +
|
||||
`'${data.device.manufacturerName}' is NOT supported, ` +
|
||||
`please follow https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html`,
|
||||
);
|
||||
}
|
||||
} else if (data.status === 'failed') {
|
||||
logger.error(`Failed to interview '${name}', device has not successfully been paired`);
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (data.status === 'started') {
|
||||
logger.info(`Starting interview of '${name}'`);
|
||||
}
|
||||
}
|
||||
} else if (type === 'deviceAnnounce') {
|
||||
logger.debug(`Device '${name}' announced itself`);
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (type === 'deviceLeave') {
|
||||
logger.warn(`Device '${name || data.ieeeAddr}' left the network`);
|
||||
this.state.remove(data.ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
// Call extensions
|
||||
this.callExtensionMethod('onZigbeeEvent', [type, data, resolvedEntity]);
|
||||
}
|
||||
|
||||
onMQTTMessage(payload) {
|
||||
const {topic, message} = payload;
|
||||
logger.debug(`Received MQTT message on '${topic}' with data '${message}'`);
|
||||
|
||||
// Call extensions
|
||||
this.callExtensionMethod('onMQTTMessage', [topic, message]);
|
||||
}
|
||||
|
||||
async publishEntityState(IDorName, payload, stateChangeReason=null) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(IDorName);
|
||||
if (!resolvedEntity || !resolvedEntity.settings) {
|
||||
logger.error(`'${IDorName}' does not exist, skipping publish`);
|
||||
return;
|
||||
}
|
||||
|
||||
let messagePayload = {...payload};
|
||||
const currentState = this.state.exists(resolvedEntity.settings.ID) ?
|
||||
this.state.get(resolvedEntity.settings.ID) : {};
|
||||
const newState = objectAssignDeep.noMutate(currentState, payload);
|
||||
|
||||
// Update state cache with new state.
|
||||
this.state.set(resolvedEntity.settings.ID, newState, stateChangeReason);
|
||||
|
||||
if (settings.get().advanced.cache_state) {
|
||||
// Add cached state to payload
|
||||
messagePayload = newState;
|
||||
}
|
||||
|
||||
const options = {
|
||||
retain: utils.getObjectProperty(resolvedEntity.settings, 'retain', false),
|
||||
qos: utils.getObjectProperty(resolvedEntity.settings, 'qos', 0),
|
||||
};
|
||||
|
||||
const retention = utils.getObjectProperty(resolvedEntity.settings, 'retention', false);
|
||||
if (retention !== false) {
|
||||
options.properties = {messageExpiryInterval: retention};
|
||||
}
|
||||
|
||||
const isDevice = resolvedEntity.type === 'device';
|
||||
if (isDevice && settings.get().mqtt.include_device_information) {
|
||||
const attributes = [
|
||||
'ieeeAddr', 'networkAddress', 'type', 'manufacturerID', 'manufacturerName', 'powerSource',
|
||||
'applicationVersion', 'stackVersion', 'zclVersion', 'hardwareVersion', 'dateCode', 'softwareBuildID',
|
||||
];
|
||||
|
||||
messagePayload.device = {
|
||||
friendlyName: resolvedEntity.name,
|
||||
model: resolvedEntity.definition ? resolvedEntity.definition.model : 'unknown',
|
||||
};
|
||||
|
||||
attributes.forEach((a) => messagePayload.device[a] = resolvedEntity.device[a]);
|
||||
}
|
||||
|
||||
// Add lastseen
|
||||
const lastSeen = settings.get().advanced.last_seen;
|
||||
if (isDevice && lastSeen !== 'disable' && resolvedEntity.device.lastSeen) {
|
||||
messagePayload.last_seen = utils.formatDate(resolvedEntity.device.lastSeen, lastSeen);
|
||||
}
|
||||
|
||||
// Add device linkquality.
|
||||
if (resolvedEntity.type === 'device' && resolvedEntity.device.linkquality !== undefined) {
|
||||
messagePayload.linkquality = resolvedEntity.device.linkquality;
|
||||
}
|
||||
|
||||
// filter mqtt message attributes
|
||||
if (resolvedEntity.settings.filtered_attributes) {
|
||||
resolvedEntity.settings.filtered_attributes.forEach((a) => delete messagePayload[a]);
|
||||
}
|
||||
|
||||
if (Object.entries(messagePayload).length) {
|
||||
const output = settings.get().experimental.output;
|
||||
if (output === 'attribute_and_json' || output === 'json') {
|
||||
await this.mqtt.publish(resolvedEntity.name, stringify(messagePayload), options);
|
||||
}
|
||||
|
||||
if (output === 'attribute_and_json' || output === 'attribute') {
|
||||
await this.iteratePayloadAttributeOutput(`${resolvedEntity.name}/`, messagePayload, options);
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.emit('publishEntityState', {messagePayload, entity: resolvedEntity, stateChangeReason, payload});
|
||||
}
|
||||
|
||||
async iteratePayloadAttributeOutput(topicRoot, payload, options) {
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
let subPayload = value;
|
||||
let message = null;
|
||||
|
||||
// Special cases
|
||||
if (key === 'color' && utils.objectHasProperties(subPayload, ['r', 'g', 'b'])) {
|
||||
subPayload = [subPayload.r, subPayload.g, subPayload.b];
|
||||
}
|
||||
|
||||
// Check Array first, since it is also an Object
|
||||
if (subPayload === null || subPayload === undefined) {
|
||||
message = '';
|
||||
} else if (Array.isArray(subPayload)) {
|
||||
message = subPayload.map((x) => `${x}`).join(',');
|
||||
} else if (typeof subPayload === 'object') {
|
||||
this.iteratePayloadAttributeOutput(`${topicRoot}${key}-`, subPayload, options);
|
||||
} else {
|
||||
message = typeof subPayload === 'string' ? subPayload : stringify(subPayload);
|
||||
}
|
||||
|
||||
if (message !== null) {
|
||||
await this.mqtt.publish(`${topicRoot}${key}`, message, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async callExtensionMethod(method, parameters, extensions=null) {
|
||||
for (const extension of extensions || this.extensions) {
|
||||
if (extension[method]) {
|
||||
try {
|
||||
await extension[method](...parameters);
|
||||
} catch (error) {
|
||||
/* istanbul ignore next */
|
||||
logger.error(`Failed to call '${extension.constructor.name}' '${method}' (${error.stack})`);
|
||||
/* istanbul ignore next */
|
||||
if (process.env.JEST_WORKER_ID !== undefined) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Controller;
|
||||
@@ -0,0 +1,319 @@
|
||||
import MQTT from './mqtt';
|
||||
import Zigbee from './zigbee';
|
||||
import EventBus from './eventBus';
|
||||
import State from './state';
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import utils from './util/utils';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import assert from 'assert';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
// Extensions
|
||||
import ExtensionFrontend from './extension/frontend';
|
||||
import ExtensionPublish from './extension/publish';
|
||||
import ExtensionReceive from './extension/receive';
|
||||
import ExtensionNetworkMap from './extension/networkMap';
|
||||
import ExtensionSoftReset from './extension/legacy/softReset';
|
||||
import ExtensionHomeAssistant from './extension/homeassistant';
|
||||
import ExtensionConfigure from './extension/configure';
|
||||
import ExtensionDeviceGroupMembership from './extension/legacy/deviceGroupMembership';
|
||||
import ExtensionBridgeLegacy from './extension/legacy/bridgeLegacy';
|
||||
import ExtensionBridge from './extension/bridge';
|
||||
import ExtensionGroups from './extension/groups';
|
||||
import ExtensionAvailability from './extension/availability';
|
||||
import ExtensionBind from './extension/bind';
|
||||
import ExtensionReport from './extension/legacy/report';
|
||||
import ExtensionOnEvent from './extension/onEvent';
|
||||
import ExtensionOTAUpdate from './extension/otaUpdate';
|
||||
import ExtensionExternalConverters from './extension/externalConverters';
|
||||
import ExtensionExternalExtension from './extension/externalExtension';
|
||||
|
||||
const AllExtensions = [
|
||||
ExtensionPublish, ExtensionReceive, ExtensionNetworkMap, ExtensionSoftReset, ExtensionHomeAssistant,
|
||||
ExtensionConfigure, ExtensionDeviceGroupMembership, ExtensionBridgeLegacy, ExtensionBridge, ExtensionGroups,
|
||||
ExtensionBind, ExtensionReport, ExtensionOnEvent, ExtensionOTAUpdate,
|
||||
ExtensionExternalConverters, ExtensionFrontend, ExtensionExternalExtension, ExtensionAvailability,
|
||||
];
|
||||
|
||||
type ExtensionArgs = [Zigbee, MQTT, State, PublishEntityState, EventBus,
|
||||
(enable: boolean, name: string) => Promise<void>, () => void, (extension: Extension) => Promise<void>];
|
||||
|
||||
class Controller {
|
||||
private eventBus: EventBus;
|
||||
private zigbee: Zigbee;
|
||||
private state: State;
|
||||
private mqtt: MQTT;
|
||||
private restartCallback: () => void;
|
||||
private exitCallback: (code: number) => void;
|
||||
private extensions: Extension[];
|
||||
private extensionArgs: ExtensionArgs;
|
||||
|
||||
constructor(restartCallback: () => void, exitCallback: (code: number) => void) {
|
||||
this.eventBus = new EventBus( /* istanbul ignore next */ (error) => {
|
||||
logger.error(`Error: ${error.message}`);
|
||||
logger.debug(error.stack);
|
||||
});
|
||||
this.zigbee = new Zigbee(this.eventBus);
|
||||
this.mqtt = new MQTT(this.eventBus);
|
||||
this.state = new State(this.eventBus);
|
||||
this.restartCallback = restartCallback;
|
||||
this.exitCallback = exitCallback;
|
||||
|
||||
// Initialize extensions.
|
||||
this.extensionArgs = [this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus,
|
||||
this.enableDisableExtension, this.restartCallback, this.addExtension];
|
||||
|
||||
this.extensions = [
|
||||
new ExtensionBridge(...this.extensionArgs),
|
||||
new ExtensionPublish(...this.extensionArgs),
|
||||
new ExtensionReceive(...this.extensionArgs),
|
||||
new ExtensionDeviceGroupMembership(...this.extensionArgs),
|
||||
new ExtensionConfigure(...this.extensionArgs),
|
||||
new ExtensionNetworkMap(...this.extensionArgs),
|
||||
new ExtensionGroups(...this.extensionArgs),
|
||||
new ExtensionBind(...this.extensionArgs),
|
||||
new ExtensionOnEvent(...this.extensionArgs),
|
||||
new ExtensionOTAUpdate(...this.extensionArgs),
|
||||
new ExtensionReport(...this.extensionArgs),
|
||||
new ExtensionExternalExtension(...this.extensionArgs),
|
||||
new ExtensionAvailability(...this.extensionArgs),
|
||||
settings.get().frontend && new ExtensionFrontend(...this.extensionArgs),
|
||||
settings.get().advanced.legacy_api && new ExtensionBridgeLegacy(...this.extensionArgs),
|
||||
settings.get().external_converters.length && new ExtensionExternalConverters(...this.extensionArgs),
|
||||
settings.get().homeassistant && new ExtensionHomeAssistant(...this.extensionArgs),
|
||||
/* istanbul ignore next */
|
||||
settings.get().advanced.soft_reset_timeout !== 0 && new ExtensionSoftReset(...this.extensionArgs),
|
||||
].filter((n) => n);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.state.start();
|
||||
logger.logOutput();
|
||||
|
||||
const info = await utils.getZigbee2MQTTVersion();
|
||||
logger.info(`Starting Zigbee2MQTT version ${info.version} (commit #${info.commitHash})`);
|
||||
|
||||
// Start zigbee
|
||||
let startResult;
|
||||
try {
|
||||
startResult = await this.zigbee.start();
|
||||
this.eventBus.onAdapterDisconnected(this, this.onZigbeeAdapterDisconnected);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start zigbee');
|
||||
logger.error('Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html for possible solutions'); /* eslint-disable-line max-len */
|
||||
logger.error('Exiting...');
|
||||
logger.error(error.stack);
|
||||
this.exitCallback(1);
|
||||
}
|
||||
|
||||
// Disable some legacy options on new network creation
|
||||
if (startResult === 'reset') {
|
||||
settings.set(['advanced', 'homeassistant_legacy_entity_attributes'], false);
|
||||
settings.set(['advanced', 'legacy_api'], false);
|
||||
settings.set(['device_options', 'legacy'], false);
|
||||
this.enableDisableExtension(false, 'BridgeLegacy');
|
||||
}
|
||||
|
||||
// Log zigbee clients on startup
|
||||
const devices = this.zigbee.devices(false);
|
||||
logger.info(`Currently ${devices.length} devices are joined:`);
|
||||
for (const device of devices) {
|
||||
const model = device.definition ?
|
||||
`${device.definition.model} - ${device.definition.vendor} ${device.definition.description}` :
|
||||
'Not supported';
|
||||
logger.info(`${device.name} (${device.ieeeAddr}): ${model} (${device.zh.type})`);
|
||||
}
|
||||
|
||||
// Enable zigbee join
|
||||
try {
|
||||
if (settings.get().permit_join) {
|
||||
logger.warn('`permit_join` set to `true` in configuration.yaml.');
|
||||
logger.warn('Allowing new devices to join.');
|
||||
logger.warn('Set `permit_join` to `false` once you joined all devices.');
|
||||
}
|
||||
|
||||
await this.zigbee.permitJoin(settings.get().permit_join);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to set permit join to ${settings.get().permit_join}`);
|
||||
}
|
||||
|
||||
// MQTT
|
||||
try {
|
||||
await this.mqtt.connect();
|
||||
} catch (error) {
|
||||
logger.error(`MQTT failed to connect: ${error.message}`);
|
||||
logger.error('Exiting...');
|
||||
await this.zigbee.stop();
|
||||
this.exitCallback(1);
|
||||
}
|
||||
|
||||
// Call extensions
|
||||
await this.callExtensions('start', [...this.extensions]);
|
||||
|
||||
// Send all cached states.
|
||||
if (settings.get().advanced.cache_state_send_on_startup && settings.get().advanced.cache_state) {
|
||||
for (const device of devices) {
|
||||
if (this.state.exists(device)) {
|
||||
this.publishEntityState(device, this.state.get(device));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.onLastSeenChanged(this,
|
||||
(data) => utils.publishLastSeen(data, settings.get(), false, this.publishEntityState));
|
||||
}
|
||||
|
||||
@bind async enableDisableExtension(enable: boolean, name: string): Promise<void> {
|
||||
if (!enable) {
|
||||
const extension = this.extensions.find((e) => e.constructor.name === name);
|
||||
if (extension) {
|
||||
await this.callExtensions('stop', [extension]);
|
||||
this.extensions.splice(this.extensions.indexOf(extension), 1);
|
||||
}
|
||||
} else {
|
||||
const Extension = AllExtensions.find((e) => e.name === name);
|
||||
assert(Extension, `Extension '${name}' does not exist`);
|
||||
const extension = new Extension(...this.extensionArgs);
|
||||
this.extensions.push(extension);
|
||||
await this.callExtensions('start', [extension]);
|
||||
}
|
||||
}
|
||||
|
||||
@bind async addExtension(extension: Extension): Promise<void> {
|
||||
this.extensions.push(extension);
|
||||
await this.callExtensions('start', [extension]);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
// Call extensions
|
||||
await this.callExtensions('stop', this.extensions);
|
||||
this.eventBus.removeListeners(this);
|
||||
|
||||
// Wrap-up
|
||||
this.state.stop();
|
||||
await this.mqtt.disconnect();
|
||||
|
||||
try {
|
||||
await this.zigbee.stop();
|
||||
logger.info('Stopped Zigbee2MQTT');
|
||||
this.exitCallback(0);
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop Zigbee2MQTT');
|
||||
this.exitCallback(1);
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onZigbeeAdapterDisconnected(): Promise<void> {
|
||||
logger.error('Adapter disconnected, stopping');
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
@bind async publishEntityState(entity: Group | Device, payload: KeyValue,
|
||||
stateChangeReason?: StateChangeReason): Promise<void> {
|
||||
let message = {...payload};
|
||||
|
||||
// Update state cache with new state.
|
||||
const newState = this.state.set(entity, payload, stateChangeReason);
|
||||
|
||||
if (settings.get().advanced.cache_state) {
|
||||
// Add cached state to payload
|
||||
message = newState;
|
||||
}
|
||||
|
||||
const options: MQTTOptions = {
|
||||
retain: utils.getObjectProperty(entity.settings, 'retain', false) as boolean,
|
||||
qos: utils.getObjectProperty(entity.settings, 'qos', 0) as 0 | 1 | 2,
|
||||
};
|
||||
|
||||
const retention = utils.getObjectProperty(entity.settings, 'retention', false);
|
||||
if (retention !== false) {
|
||||
options.properties = {messageExpiryInterval: retention as number};
|
||||
}
|
||||
|
||||
if (entity.isDevice() && settings.get().mqtt.include_device_information) {
|
||||
message.device = {
|
||||
friendlyName: entity.name, model: entity.definition ? entity.definition.model : 'unknown',
|
||||
ieeeAddr: entity.ieeeAddr, networkAddress: entity.zh.networkAddress, type: entity.zh.type,
|
||||
manufacturerID: entity.zh.manufacturerID, manufacturerName: entity.zh.manufacturerName,
|
||||
powerSource: entity.zh.powerSource, applicationVersion: entity.zh.applicationVersion,
|
||||
stackVersion: entity.zh.stackVersion, zclVersion: entity.zh.zclVersion,
|
||||
hardwareVersion: entity.zh.hardwareVersion, dateCode: entity.zh.dateCode,
|
||||
softwareBuildID: entity.zh.softwareBuildID,
|
||||
};
|
||||
}
|
||||
|
||||
// Add lastseen
|
||||
const lastSeen = settings.get().advanced.last_seen;
|
||||
if (entity.isDevice() && lastSeen !== 'disable' && entity.zh.lastSeen) {
|
||||
message.last_seen = utils.formatDate(entity.zh.lastSeen, lastSeen);
|
||||
}
|
||||
|
||||
// Add device linkquality.
|
||||
if (entity.isDevice() && entity.zh.linkquality !== undefined) {
|
||||
message.linkquality = entity.zh.linkquality;
|
||||
}
|
||||
|
||||
// filter mqtt message attributes
|
||||
if (entity.settings.filtered_attributes) {
|
||||
entity.settings.filtered_attributes.forEach((a) => delete message[a]);
|
||||
}
|
||||
|
||||
for (const extension of this.extensions) {
|
||||
extension.adjustMessageBeforePublish?.(entity, message);
|
||||
}
|
||||
|
||||
if (Object.entries(message).length) {
|
||||
const output = settings.get().experimental.output;
|
||||
if (output === 'attribute_and_json' || output === 'json') {
|
||||
await this.mqtt.publish(entity.name, stringify(message), options);
|
||||
}
|
||||
|
||||
if (output === 'attribute_and_json' || output === 'attribute') {
|
||||
await this.iteratePayloadAttributeOutput(`${entity.name}/`, message, options);
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.emitPublishEntityState({entity, message, stateChangeReason, payload});
|
||||
}
|
||||
|
||||
async iteratePayloadAttributeOutput(topicRoot: string, payload: KeyValue, options: MQTTOptions): Promise<void> {
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
let subPayload = value;
|
||||
let message = null;
|
||||
|
||||
// Special cases
|
||||
if (key === 'color' && utils.objectHasProperties(subPayload, ['r', 'g', 'b'])) {
|
||||
subPayload = [subPayload.r, subPayload.g, subPayload.b];
|
||||
}
|
||||
|
||||
// Check Array first, since it is also an Object
|
||||
if (subPayload === null || subPayload === undefined) {
|
||||
message = '';
|
||||
} else if (Array.isArray(subPayload)) {
|
||||
message = subPayload.map((x) => `${x}`).join(',');
|
||||
} else if (typeof subPayload === 'object') {
|
||||
await this.iteratePayloadAttributeOutput(`${topicRoot}${key}-`, subPayload, options);
|
||||
} else {
|
||||
message = typeof subPayload === 'string' ? subPayload : stringify(subPayload);
|
||||
}
|
||||
|
||||
if (message !== null) {
|
||||
await this.mqtt.publish(`${topicRoot}${key}`, message, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async callExtensions(method: 'start' | 'stop', extensions: Extension[]): Promise<void> {
|
||||
for (const extension of extensions) {
|
||||
try {
|
||||
await extension[method]?.();
|
||||
} catch (error) {
|
||||
/* istanbul ignore next */
|
||||
logger.error(`Failed to call '${extension.constructor.name}' '${method}' (${error.stack})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Controller;
|
||||
@@ -1,43 +0,0 @@
|
||||
const events = require('events');
|
||||
const assert = require('assert');
|
||||
|
||||
const allowedEvents = [
|
||||
'deviceRemoved', // Device has been removed
|
||||
'deviceRenamed', // Device has been renamed
|
||||
'groupRenamed', // Group has been renamed
|
||||
'publishEntityState', // Entity state will be published
|
||||
'stateChange', // Entity changes its state
|
||||
'groupMembersChanged', // Members of a group has been changed
|
||||
'reportingDisabled', // Reporting is disabled for a device
|
||||
'devicesChanged', // Devices changed
|
||||
];
|
||||
|
||||
class EventBus extends events.EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.callbackByExtension = {};
|
||||
}
|
||||
|
||||
emit(event, data) {
|
||||
assert(allowedEvents.includes(event), `Event '${event}' not supported`);
|
||||
super.emit(event, data);
|
||||
}
|
||||
|
||||
on(event, callback, extension=null) {
|
||||
assert(allowedEvents.includes(event), `Event '${event}' not supported`);
|
||||
if (extension) {
|
||||
if (!this.callbackByExtension[extension]) this.callbackByExtension[extension] = [];
|
||||
this.callbackByExtension[extension].push({event, callback});
|
||||
}
|
||||
|
||||
super.on(event, callback);
|
||||
}
|
||||
|
||||
removeListenersExtension(extension) {
|
||||
for (const entry of this.callbackByExtension[extension] || []) {
|
||||
super.removeListener(entry.event, entry.callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EventBus;
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import events from 'events';
|
||||
events.captureRejections = true;
|
||||
|
||||
// eslint-disable-next-line
|
||||
type ListenerKey = object;
|
||||
|
||||
export default class EventBus {
|
||||
private callbacksByExtension: { [s: string]: { event: string, callback: (...args: unknown[]) => void }[] } = {};
|
||||
private emitter = new events.EventEmitter();
|
||||
|
||||
constructor(onError: (error: Error) => void) {
|
||||
this.emitter.setMaxListeners(100);
|
||||
this.emitter.on('error', onError);
|
||||
}
|
||||
|
||||
public emitAdapterDisconnected(): void {
|
||||
this.emitter.emit('adapterDisconnected');
|
||||
}
|
||||
public onAdapterDisconnected(key: ListenerKey, callback: () => void): void {
|
||||
this.on('adapterDisconnected', callback, key);
|
||||
}
|
||||
|
||||
public emitPermitJoinChanged(data: eventdata.PermitJoinChanged): void {
|
||||
this.emitter.emit('permitJoinChanged', data);
|
||||
}
|
||||
public onPermitJoinChanged(key: ListenerKey, callback: (data: eventdata.PermitJoinChanged) => void): void {
|
||||
this.on('permitJoinChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceRenamed(data: eventdata.DeviceRenamed): void {
|
||||
this.emitter.emit('deviceRenamed', data);
|
||||
}
|
||||
public onDeviceRenamed(key: ListenerKey, callback: (data: eventdata.DeviceRenamed) => void): void {
|
||||
this.on('deviceRenamed', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceRemoved(data: eventdata.DeviceRemoved): void {
|
||||
this.emitter.emit('deviceRemoved', data);
|
||||
}
|
||||
public onDeviceRemoved(key: ListenerKey, callback: (data: eventdata.DeviceRemoved) => void): void {
|
||||
this.on('deviceRemoved', callback, key);
|
||||
}
|
||||
|
||||
public emitLastSeenChanged(data: eventdata.LastSeenChanged): void {
|
||||
this.emitter.emit('lastSeenChanged', data);
|
||||
}
|
||||
public onLastSeenChanged(key: ListenerKey, callback: (data: eventdata.LastSeenChanged) => void): void {
|
||||
this.on('lastSeenChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceNetworkAddressChanged(data: eventdata.DeviceNetworkAddressChanged): void {
|
||||
this.emitter.emit('deviceNetworkAddressChanged', data);
|
||||
}
|
||||
public onDeviceNetworkAddressChanged(
|
||||
key: ListenerKey, callback: (data: eventdata.DeviceNetworkAddressChanged) => void): void {
|
||||
this.on('deviceNetworkAddressChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceAnnounce(data: eventdata.DeviceAnnounce): void {
|
||||
this.emitter.emit('deviceAnnounce', data);
|
||||
}
|
||||
public onDeviceAnnounce(key: ListenerKey, callback: (data: eventdata.DeviceAnnounce) => void): void {
|
||||
this.on('deviceAnnounce', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceInterview(data: eventdata.DeviceInterview): void {
|
||||
this.emitter.emit('deviceInterview', data);
|
||||
}
|
||||
public onDeviceInterview(key: ListenerKey, callback: (data: eventdata.DeviceInterview) => void): void {
|
||||
this.on('deviceInterview', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceJoined(data: eventdata.DeviceJoined): void {
|
||||
this.emitter.emit('deviceJoined', data);
|
||||
}
|
||||
public onDeviceJoined(key: ListenerKey, callback: (data: eventdata.DeviceJoined) => void): void {
|
||||
this.on('deviceJoined', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceLeave(data: eventdata.DeviceLeave): void {
|
||||
this.emitter.emit('deviceLeave', data);
|
||||
}
|
||||
public onDeviceLeave(key: ListenerKey, callback: (data: eventdata.DeviceLeave) => void): void {
|
||||
this.on('deviceLeave', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceMessage(data: eventdata.DeviceMessage): void {
|
||||
this.emitter.emit('deviceMessage', data);
|
||||
}
|
||||
public onDeviceMessage(key: ListenerKey, callback: (data: eventdata.DeviceMessage) => void): void {
|
||||
this.on('deviceMessage', callback, key);
|
||||
}
|
||||
|
||||
public emitMQTTMessage(data: eventdata.MQTTMessage): void {
|
||||
this.emitter.emit('mqttMessage', data);
|
||||
}
|
||||
public onMQTTMessage(key: ListenerKey, callback: (data: eventdata.MQTTMessage) => void): void {
|
||||
this.on('mqttMessage', callback, key);
|
||||
}
|
||||
|
||||
public emitMQTTMessagePublished(data: eventdata.MQTTMessagePublished): void {
|
||||
this.emitter.emit('mqttMessagePublished', data);
|
||||
}
|
||||
public onMQTTMessagePublished(key: ListenerKey, callback: (data: eventdata.MQTTMessagePublished) => void): void {
|
||||
this.on('mqttMessagePublished', callback, key);
|
||||
}
|
||||
|
||||
public emitPublishEntityState(data: eventdata.PublishEntityState): void {
|
||||
this.emitter.emit('publishEntityState', data);
|
||||
}
|
||||
public onPublishEntityState(key: ListenerKey, callback: (data: eventdata.PublishEntityState) => void): void {
|
||||
this.on('publishEntityState', callback, key);
|
||||
}
|
||||
|
||||
public emitGroupMembersChanged(data: eventdata.GroupMembersChanged): void {
|
||||
this.emitter.emit('groupMembersChanged', data);
|
||||
}
|
||||
public onGroupMembersChanged(key: ListenerKey, callback: (data: eventdata.GroupMembersChanged) => void): void {
|
||||
this.on('groupMembersChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitDevicesChanged(): void {
|
||||
this.emitter.emit('devicesChanged');
|
||||
}
|
||||
public onDevicesChanged(key: ListenerKey, callback: () => void): void {
|
||||
this.on('devicesChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitScenesChanged(): void {
|
||||
this.emitter.emit('scenesChanged');
|
||||
}
|
||||
public onScenesChanged(key: ListenerKey, callback: () => void): void {
|
||||
this.on('scenesChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitReportingDisabled(data: eventdata.ReportingDisabled): void {
|
||||
this.emitter.emit('reportingDisabled', data);
|
||||
}
|
||||
public onReportingDisabled(key: ListenerKey, callback: (data: eventdata.ReportingDisabled) => void): void {
|
||||
this.on('reportingDisabled', callback, key);
|
||||
}
|
||||
|
||||
public emitStateChange(data: eventdata.StateChange): void {
|
||||
this.emitter.emit('stateChange', data);
|
||||
}
|
||||
public onStateChange(key: ListenerKey, callback: (data: eventdata.StateChange) => void): void {
|
||||
this.on('stateChange', callback, key);
|
||||
}
|
||||
|
||||
private on(event: string, callback: (...args: unknown[]) => void, key: ListenerKey): void {
|
||||
if (!this.callbacksByExtension[key.constructor.name]) this.callbacksByExtension[key.constructor.name] = [];
|
||||
this.callbacksByExtension[key.constructor.name].push({event, callback});
|
||||
this.emitter.on(event, callback);
|
||||
}
|
||||
|
||||
public removeListeners(key: ListenerKey): void {
|
||||
this.callbacksByExtension[key.constructor.name]?.forEach(
|
||||
(e) => this.emitter.removeListener(e.event, e.callback));
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
const logger = require('../util/logger');
|
||||
const settings = require('../util/settings');
|
||||
const utils = require('../util/utils');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const Extension = require('./extension');
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/(.*)/availability`);
|
||||
|
||||
// Pingable end devices, some end devices should be pinged
|
||||
// e.g. E11-G13 https://github.com/Koenkk/zigbee2mqtt/issues/775#issuecomment-453683846
|
||||
const pingableEndDevices = [
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'E11-G13'),
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'E11-N1EA'),
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === '53170161'),
|
||||
];
|
||||
|
||||
const Hours25 = 1000 * 60 * 60 * 25;
|
||||
const AvailabilityLagRatio = 0.1;
|
||||
|
||||
|
||||
function timeoutLag(timeout, ratio) {
|
||||
const lag = timeout * ratio;
|
||||
return Math.floor(Math.random() * Math.floor(lag));
|
||||
}
|
||||
|
||||
/**
|
||||
* This extensions pings devices to check if they are online.
|
||||
*/
|
||||
class Availability extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
|
||||
this.availability_timeout = settings.get().advanced.availability_timeout;
|
||||
this.timers = {};
|
||||
this.state = {};
|
||||
|
||||
this.eventBus.on('deviceRemoved', (data) => this.onDeviceRemoved(data.resolvedEntity), this.constructor.name);
|
||||
this.eventBus.on('deviceRenamed', (data) => this.onDeviceRenamed(data), this.constructor.name);
|
||||
|
||||
this.blocklist = settings.get().advanced.availability_blocklist
|
||||
.concat(settings.get().advanced.availability_blacklist)
|
||||
.map((e) => settings.getEntity(e).ID);
|
||||
|
||||
this.passlist = settings.get().advanced.availability_passlist
|
||||
.concat(settings.get().advanced.availability_whitelist)
|
||||
.map((e) => settings.getEntity(e).ID);
|
||||
}
|
||||
|
||||
onDeviceRenamed(data) {
|
||||
this.mqtt.publish(`${data.from}/availability`, null, {retain: true, qos: 0});
|
||||
}
|
||||
|
||||
onDeviceRemoved(resolvedEntity) {
|
||||
this.mqtt.publish(`${resolvedEntity.name}/availability`, null, {retain: true, qos: 0});
|
||||
delete this.state[resolvedEntity.device.ieeeAddr];
|
||||
clearTimeout(this.timers[resolvedEntity.device.ieeeAddr]);
|
||||
}
|
||||
|
||||
inPasslistOrNotInBlocklist(device) {
|
||||
const ieeeAddr = device.ieeeAddr;
|
||||
const deviceSettings = settings.getDevice(ieeeAddr);
|
||||
const name = deviceSettings.friendlyName;
|
||||
|
||||
// Passlist is not empty and device is in it, enable availability
|
||||
if (this.passlist.length > 0) {
|
||||
return this.passlist.includes(ieeeAddr) || (name && this.passlist.includes(name));
|
||||
}
|
||||
|
||||
// Device is on blocklist, disable availability
|
||||
if (this.blocklist.includes(ieeeAddr) || (name && this.blocklist.includes(name))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
isPingable(device) {
|
||||
if (pingableEndDevices.find((d) => d.hasOwnProperty('zigbeeModel') && d.zigbeeModel.includes(device.modelID))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Device is a mains powered router
|
||||
return device.type === 'Router' && device.powerSource !== 'Battery';
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
// Mark all devices as online on start
|
||||
const ieeeAddr = device.ieeeAddr;
|
||||
this.publishAvailability(device, this.state.hasOwnProperty(ieeeAddr) ? this.state[ieeeAddr] : true, true);
|
||||
|
||||
if (this.inPasslistOrNotInBlocklist(device)) {
|
||||
if (this.isPingable(device)) {
|
||||
this.setTimerPingable(device);
|
||||
} else {
|
||||
this.timers[ieeeAddr] = setInterval(() => {
|
||||
this.handleIntervalNotPingable(device);
|
||||
}, utils.secondsToMilliseconds(300));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
// Clear topics for non-existing devices
|
||||
const match = topic.match(topicRegex);
|
||||
if (match && (!this.zigbee.resolveEntity(match[1]) || this.zigbee.resolveEntity(match[1]).name !== match[1])) {
|
||||
this.mqtt.publish(`${match[1]}/availability`, null, {retain: true, qos: 0});
|
||||
}
|
||||
}
|
||||
|
||||
async handleIntervalPingable(device) {
|
||||
// When a device is already unavailable, log the ping failed on 'debug' instead of 'error'.
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device.ieeeAddr);
|
||||
if (!resolvedEntity) {
|
||||
logger.debug(`Stop pinging '${device.ieeeAddr}', device is not known anymore`);
|
||||
return;
|
||||
}
|
||||
|
||||
const level = this.state.hasOwnProperty(device.ieeeAddr) && !this.state[device.ieeeAddr] ? 'debug' : 'error';
|
||||
try {
|
||||
await device.ping();
|
||||
this.publishAvailability(device, true);
|
||||
logger.debug(`Successfully pinged '${resolvedEntity.name}'`);
|
||||
} catch (error) {
|
||||
this.publishAvailability(device, false);
|
||||
logger[level](`Failed to ping '${resolvedEntity.name}'`);
|
||||
} finally {
|
||||
this.setTimerPingable(device);
|
||||
}
|
||||
}
|
||||
|
||||
async handleIntervalNotPingable(device) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device.ieeeAddr);
|
||||
if (!resolvedEntity || !device.lastSeen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ago = Date.now() - resolvedEntity.device.lastSeen;
|
||||
logger.debug(`Non-pingable device '${resolvedEntity.name}' was last seen '${ago / 1000}' seconds ago.`);
|
||||
|
||||
if (ago > Hours25) {
|
||||
this.publishAvailability(device, false);
|
||||
}
|
||||
}
|
||||
|
||||
setTimerPingable(device) {
|
||||
const timeout = this.availability_timeout + timeoutLag(this.availability_timeout, AvailabilityLagRatio);
|
||||
clearTimeout(this.timers[device.ieeeAddr]);
|
||||
this.timers[device.ieeeAddr] = setTimeout(async () => {
|
||||
await this.handleIntervalPingable(device);
|
||||
}, utils.secondsToMilliseconds(timeout));
|
||||
}
|
||||
|
||||
async stop() {
|
||||
super.stop();
|
||||
for (const timer of Object.values(this.timers)) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
this.zigbee.getClients().forEach((device) => this.publishAvailability(device, false));
|
||||
}
|
||||
|
||||
async onReconnect(device) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
if (resolvedEntity && resolvedEntity.definition) {
|
||||
try {
|
||||
for (const key of ['state', 'brightness', 'color', 'color_temp']) {
|
||||
const converter = resolvedEntity.definition.toZigbee.find((tz) => tz.key.includes(key));
|
||||
if (converter) {
|
||||
await converter.convertGet(device.endpoints[0], key, {});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read state of '${resolvedEntity.name}' after reconnect`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publishAvailability(device, available, force=false) {
|
||||
const ieeeAddr = device.ieeeAddr;
|
||||
if (this.state.hasOwnProperty(ieeeAddr) && !this.state[ieeeAddr] && available) {
|
||||
this.onReconnect(device);
|
||||
}
|
||||
|
||||
const deviceSettings = settings.getDevice(ieeeAddr);
|
||||
const name = deviceSettings ? deviceSettings.friendlyName : ieeeAddr;
|
||||
const topic = `${name}/availability`;
|
||||
const payload = available ? 'online' : 'offline';
|
||||
if (this.state[ieeeAddr] !== available || force) {
|
||||
this.state[ieeeAddr] = available;
|
||||
this.mqtt.publish(topic, payload, {retain: true, qos: 0});
|
||||
}
|
||||
}
|
||||
|
||||
onZigbeeEvent(type, data, resolvedEntity) {
|
||||
const device = data.device;
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.inPasslistOrNotInBlocklist(device)) {
|
||||
this.publishAvailability(data.device, true);
|
||||
|
||||
if (this.isPingable(device)) {
|
||||
// When a zigbee message from a device is received we know the device is still alive.
|
||||
// => reset the timer.
|
||||
this.setTimerPingable(device);
|
||||
|
||||
const online = this.state.hasOwnProperty(device.ieeeAddr) && this.state[device.ieeeAddr];
|
||||
if (online && type === 'deviceAnnounce' && !utils.isIkeaTradfriDevice(device)) {
|
||||
/**
|
||||
* In case the device is powered off AND on within the availability timeout,
|
||||
* zigbee2qmtt does not detect the device as offline (device is still marked online).
|
||||
* When a device is turned on again the state could be out of sync.
|
||||
* https://github.com/Koenkk/zigbee2mqtt/issues/1383#issuecomment-489412168
|
||||
* deviceAnnounce is typically send when a device comes online.
|
||||
*
|
||||
* This isn't needed for TRADFRI devices as they already send the state themself.
|
||||
*/
|
||||
this.onReconnect(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Availability;
|
||||
@@ -0,0 +1,188 @@
|
||||
import Extension from './extension';
|
||||
import logger from '../util/logger';
|
||||
import utils from '../util/utils';
|
||||
import * as settings from '../util/settings';
|
||||
import debounce from 'debounce';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
export default class Availability extends Extension {
|
||||
private timers: {[s: string]: NodeJS.Timeout} = {};
|
||||
private availabilityCache: {[s: string]: boolean} = {};
|
||||
private retrieveStateDebouncers: {[s: string]: () => void} = {};
|
||||
private pingQueue: Device[] = [];
|
||||
private pingQueueExecuting = false;
|
||||
|
||||
private getTimeout(device: Device): number {
|
||||
if (typeof device.settings.availability === 'object' && device.settings.availability?.timeout != null) {
|
||||
return utils.minutes(device.settings.availability.timeout);
|
||||
}
|
||||
|
||||
const key = this.isActiveDevice(device) ? 'active' : 'passive';
|
||||
const availabilitySettings = settings.get().availability;
|
||||
if (typeof availabilitySettings === 'object' && availabilitySettings[key]?.timeout != null) {
|
||||
return utils.minutes(availabilitySettings[key]?.timeout);
|
||||
}
|
||||
|
||||
return key === 'active' ? utils.minutes(10) : utils.hours(25);
|
||||
}
|
||||
|
||||
private isActiveDevice(device: Device): boolean {
|
||||
return (device.zh.type === 'Router' && device.zh.powerSource !== 'Battery') ||
|
||||
device.zh.powerSource === 'Mains (single phase)';
|
||||
}
|
||||
|
||||
private isAvailable(device: Device): boolean {
|
||||
const ago = Date.now() - device.zh.lastSeen;
|
||||
return ago < this.getTimeout(device);
|
||||
}
|
||||
|
||||
private resetTimer(device: Device): void {
|
||||
clearTimeout(this.timers[device.ieeeAddr]);
|
||||
|
||||
// If the timer triggers, the device is not avaiable anymore otherwise resetTimer already have been called
|
||||
if (this.isActiveDevice(device)) {
|
||||
// If device did not check in, ping it, if that fails it will be marked as offline
|
||||
this.timers[device.ieeeAddr] = setTimeout(
|
||||
() => this.addToPingQueue(device), this.getTimeout(device) + utils.seconds(1));
|
||||
} else {
|
||||
this.timers[device.ieeeAddr] = setTimeout(
|
||||
() => this.publishAvailability(device, true), this.getTimeout(device) + utils.seconds(1));
|
||||
}
|
||||
}
|
||||
|
||||
private addToPingQueue(device: Device): void {
|
||||
this.pingQueue.push(device);
|
||||
this.pingQueueExecuteNext();
|
||||
}
|
||||
|
||||
private removeFromPingQueue(device: Device): void {
|
||||
const index = this.pingQueue.findIndex((d) => d.ieeeAddr === device.ieeeAddr);
|
||||
index != -1 && this.pingQueue.splice(index, 1);
|
||||
}
|
||||
|
||||
private async pingQueueExecuteNext(): Promise<void> {
|
||||
if (this.pingQueue.length === 0 || this.pingQueueExecuting) return;
|
||||
this.pingQueueExecuting = true;
|
||||
|
||||
const device = this.pingQueue[0];
|
||||
let pingedSuccessfully = false;
|
||||
const available = this.availabilityCache[device.ieeeAddr] || this.isAvailable(device);
|
||||
const attempts = available ? 2 : 1;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
// Enable recovery if device is marked as available and first ping fails.
|
||||
const disableRecovery = !(i == 1 && available);
|
||||
await device.zh.ping(disableRecovery);
|
||||
pingedSuccessfully = true;
|
||||
logger.debug(`Succesfully pinged '${device.name}' (attempt ${i + 1}/${attempts})`);
|
||||
break;
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to ping '${device.name}' (attempt ${i + 1}/${attempts}, ${error.message})`);
|
||||
// Try again in 3 seconds.
|
||||
const lastAttempt = i - 1 === attempts;
|
||||
!lastAttempt && await utils.sleep(3);
|
||||
}
|
||||
}
|
||||
|
||||
this.publishAvailability(device, !pingedSuccessfully);
|
||||
this.resetTimer(device);
|
||||
this.removeFromPingQueue(device);
|
||||
|
||||
// Sleep 2 seconds before executing next ping
|
||||
await utils.sleep(2);
|
||||
this.pingQueueExecuting = false;
|
||||
this.pingQueueExecuteNext();
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
logger.warn('Using experimental new availability feature');
|
||||
|
||||
this.eventBus.onDeviceRenamed(this, (data) => this.publishAvailability(data.device, false, true));
|
||||
this.eventBus.onDeviceRemoved(this, (data) => clearTimeout(this.timers[data.ieeeAddr]));
|
||||
this.eventBus.onDeviceLeave(this, (data) => clearTimeout(this.timers[data.ieeeAddr]));
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => this.retrieveState(data.device));
|
||||
this.eventBus.onLastSeenChanged(this, this.onLastSeenChanged);
|
||||
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
if (utils.isAvailabilityEnabledForDevice(device, settings.get())) {
|
||||
// Publish initial availablility
|
||||
this.publishAvailability(device, true);
|
||||
|
||||
this.resetTimer(device);
|
||||
|
||||
// If an active device is initially unavailable, ping it.
|
||||
if (this.isActiveDevice(device) && !this.isAvailable(device)) {
|
||||
this.addToPingQueue(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private publishAvailability(device: Device, logLastSeen: boolean, forcePublish=false): void {
|
||||
if (logLastSeen) {
|
||||
const ago = Date.now() - device.zh.lastSeen;
|
||||
if (this.isActiveDevice(device)) {
|
||||
logger.debug(`Active device '${device.name}' was last seen ` +
|
||||
`'${(ago / utils.minutes(1)).toFixed(2)}' minutes ago.`);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Passive device '${device.name}' was last seen '${(ago / utils.hours(1)).toFixed(2)}' hours ago.`);
|
||||
}
|
||||
}
|
||||
|
||||
const available = this.isAvailable(device);
|
||||
if (!forcePublish && this.availabilityCache[device.ieeeAddr] == available) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.ieeeAddr in this.availabilityCache && available &&
|
||||
this.availabilityCache[device.ieeeAddr] === false) {
|
||||
logger.debug(`Device '${device.name}' reconnected`);
|
||||
this.retrieveState(device);
|
||||
}
|
||||
|
||||
const topic = `${device.name}/availability`;
|
||||
const payload = available ? 'online' : 'offline';
|
||||
this.availabilityCache[device.ieeeAddr] = available;
|
||||
this.mqtt.publish(topic, payload, {retain: true, qos: 0});
|
||||
}
|
||||
|
||||
@bind private onLastSeenChanged(data: eventdata.LastSeenChanged): void {
|
||||
if (utils.isAvailabilityEnabledForDevice(data.device, settings.get())) {
|
||||
// Remove from ping queue, not necessary anymore since we know the device is online.
|
||||
this.removeFromPingQueue(data.device);
|
||||
this.resetTimer(data.device);
|
||||
this.publishAvailability(data.device, false);
|
||||
}
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
Object.values(this.timers).forEach((t) => clearTimeout(t));
|
||||
super.stop();
|
||||
}
|
||||
|
||||
private retrieveState(device: Device): void {
|
||||
/**
|
||||
* Retrieve state of a device in a debounced manner, this function is called on a 'deviceAnnounce' which a
|
||||
* device can send multiple times after each other.
|
||||
*/
|
||||
if (device.definition && !device.zh.interviewing && !this.retrieveStateDebouncers[device.ieeeAddr]) {
|
||||
this.retrieveStateDebouncers[device.ieeeAddr] = debounce(async () => {
|
||||
try {
|
||||
logger.debug(`Retrieving state of '${device.name}' after reconnect`);
|
||||
// Color and color temperature converters do both, only needs to be called once.
|
||||
const keySet = [['state'], ['brightness'], ['color', 'color_temp']];
|
||||
for (const keys of keySet) {
|
||||
const converter = device.definition.toZigbee.find((c) => c.key.find((k) => keys.includes(k)));
|
||||
await converter?.convertGet?.(device.endpoint(), keys[0],
|
||||
{message: this.state.get(device) || {}, mapped: device.definition});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read state of '${device.name}' after reconnect (${error.message})`);
|
||||
}
|
||||
}, utils.seconds(2));
|
||||
}
|
||||
|
||||
this.retrieveStateDebouncers[device.ieeeAddr]?.();
|
||||
}
|
||||
}
|
||||
Regular → Executable
+200
-130
@@ -1,35 +1,43 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const utils = require('../util/utils');
|
||||
import * as settings from '../util/settings';
|
||||
import logger from '../util/logger';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import debounce from 'debounce';
|
||||
import * as zigbeeHersdman from 'zigbee-herdsman/dist';
|
||||
import bind from 'bind-decorator';
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
|
||||
const legacyApi = settings.get().advanced.legacy_api;
|
||||
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/(bind|unbind)/.+$`);
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
|
||||
const Extension = require('./extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const debounce = require('debounce');
|
||||
const zigbeeHersdman = require('zigbee-herdsman');
|
||||
const clusterCandidates = ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl', 'closuresWindowCovering',
|
||||
'hvacThermostat', 'msTemperatureMeasurement'];
|
||||
|
||||
const clusterCandidates = ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl', 'closuresWindowCovering'];
|
||||
|
||||
// See zigbee-herdsman-converters devices.js
|
||||
const defaultBindGroup = {type: 'group_number', ID: 901};
|
||||
// See zigbee-herdsman-converters
|
||||
const defaultBindGroup = {type: 'group_number', ID: 901, name: 'default_bind_group'};
|
||||
|
||||
const defaultReportConfiguration = {
|
||||
minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1,
|
||||
};
|
||||
|
||||
const getColorCapabilities = async (endpoint) => {
|
||||
const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean, colorXY: boolean}> => {
|
||||
if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') == null) {
|
||||
await endpoint.read('lightingColorCtrl', ['colorCapabilities']);
|
||||
}
|
||||
|
||||
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities');
|
||||
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number;
|
||||
return {
|
||||
colorTemperature: (value & 1<<4) > 0,
|
||||
colorXY: (value & 1<<3) > 0,
|
||||
};
|
||||
};
|
||||
|
||||
const reportClusters = {
|
||||
const reportClusters: {[s: string]:
|
||||
{attribute: string, minimumReportInterval: number, maximumReportInterval: number, reportableChange: number
|
||||
condition?: (endpoint: zh.Endpoint) => Promise<boolean>}[]} =
|
||||
{
|
||||
'genOnOff': [
|
||||
{attribute: 'onOff', ...defaultReportConfiguration, minimumReportInterval: 0, reportableChange: 0},
|
||||
],
|
||||
@@ -39,15 +47,15 @@ const reportClusters = {
|
||||
'lightingColorCtrl': [
|
||||
{
|
||||
attribute: 'colorTemperature', ...defaultReportConfiguration,
|
||||
condition: async (endpoint) => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
},
|
||||
{
|
||||
attribute: 'currentX', ...defaultReportConfiguration,
|
||||
condition: async (endpoint) => (await getColorCapabilities(endpoint)).colorXY,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
{
|
||||
attribute: 'currentY', ...defaultReportConfiguration,
|
||||
condition: async (endpoint) => (await getColorCapabilities(endpoint)).colorXY,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
],
|
||||
'closuresWindowCovering': [
|
||||
@@ -56,7 +64,13 @@ const reportClusters = {
|
||||
],
|
||||
};
|
||||
|
||||
const pollOnMessage = [
|
||||
type PollOnMessage = {
|
||||
cluster: {[s: string]: {type: string, data: KeyValue}[]}
|
||||
read: {cluster: string, attributes: string[], attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise<string[]>}
|
||||
manufacturerIDs: number[]
|
||||
}[];
|
||||
|
||||
const pollOnMessage: PollOnMessage = [
|
||||
{
|
||||
// On messages that have the cluster and type of below
|
||||
cluster: {
|
||||
@@ -73,6 +87,9 @@ const pollOnMessage = [
|
||||
{type: 'commandMove', data: {}},
|
||||
{type: 'commandMoveToLevelWithOnOff', data: {}},
|
||||
],
|
||||
genScenes: [
|
||||
{type: 'commandRecall', data: {}},
|
||||
],
|
||||
},
|
||||
// Read the following attributes
|
||||
read: {cluster: 'genLevelCtrl', attributes: ['currentLevel']},
|
||||
@@ -98,6 +115,9 @@ const pollOnMessage = [
|
||||
{type: 'commandOffWithEffect', data: {}},
|
||||
{type: 'commandToggle', data: {}},
|
||||
],
|
||||
genScenes: [
|
||||
{type: 'commandRecall', data: {}},
|
||||
],
|
||||
manuSpecificPhilips: [
|
||||
{type: 'commandHueNotification', data: {button: 1}},
|
||||
{type: 'commandHueNotification', data: {button: 4}},
|
||||
@@ -111,86 +131,133 @@ const pollOnMessage = [
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.MUELLER_LICHT_INT,
|
||||
],
|
||||
},
|
||||
{
|
||||
cluster: {
|
||||
genScenes: [
|
||||
{type: 'commandRecall', data: {}},
|
||||
],
|
||||
},
|
||||
read: {
|
||||
cluster: 'lightingColorCtrl',
|
||||
attributes: [] as string[],
|
||||
// Since not all devices support the same attributes they need to be calculated dynamically
|
||||
// depending on the capabilities of the endpoint.
|
||||
attributesForEndpoint: async (endpoint): Promise<string[]> => {
|
||||
const supportedAttrs = await getColorCapabilities(endpoint);
|
||||
const readAttrs: string[] = [];
|
||||
supportedAttrs.colorXY && readAttrs.push('currentX', 'currentY');
|
||||
supportedAttrs.colorTemperature && readAttrs.push('colorTemperature');
|
||||
return readAttrs;
|
||||
},
|
||||
},
|
||||
manufacturerIDs: [
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.Philips,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.ATMEL,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.MUELLER_LICHT_INT,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
class Bind extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.legacyApi = settings.get().advanced.legacy_api;
|
||||
this.eventBus.on(`groupMembersChanged`, (d) => this.groupMembersChanged(d));
|
||||
this.pollDebouncers = {};
|
||||
interface ParsedMQTTMessage {
|
||||
type: 'bind' | 'unbind', sourceKey: string, targetKey: string, clusters: string[], skipDisableReporting: boolean
|
||||
}
|
||||
|
||||
export default class Bind extends Extension {
|
||||
private pollDebouncers: {[s: string]: () => void} = {};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onDeviceMessage(this, this.poll);
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.eventBus.onGroupMembersChanged(this, this.onGroupMembersChanged);
|
||||
}
|
||||
|
||||
parseMQTTMessage(topic, message) {
|
||||
let type = null;
|
||||
private parseMQTTMessage(data: eventdata.MQTTMessage): ParsedMQTTMessage {
|
||||
let type: 'bind' | 'unbind' = null;
|
||||
let sourceKey = null;
|
||||
let targetKey = null;
|
||||
let clusters = null;
|
||||
let skipDisableReporting = false;
|
||||
|
||||
if (this.legacyApi && topic.match(legacyTopicRegex)) {
|
||||
topic = topic.replace(`${settings.get().mqtt.base_topic}/bridge/`, '');
|
||||
type = topic.split('/')[0];
|
||||
if (legacyApi && data.topic.match(legacyTopicRegex)) {
|
||||
const topic = data.topic.replace(`${settings.get().mqtt.base_topic}/bridge/`, '');
|
||||
type = topic.split('/')[0] as 'bind' | 'unbind';
|
||||
sourceKey = topic.replace(`${type}/`, '');
|
||||
targetKey = message;
|
||||
} else if (topic.match(topicRegex)) {
|
||||
type = topic.endsWith('unbind') ? 'unbind' : 'bind';
|
||||
message = JSON.parse(message);
|
||||
targetKey = data.message;
|
||||
} else if (data.topic.match(topicRegex)) {
|
||||
type = data.topic.endsWith('unbind') ? 'unbind' : 'bind';
|
||||
const message = JSON.parse(data.message);
|
||||
sourceKey = message.from;
|
||||
targetKey = message.to;
|
||||
clusters = message.clusters;
|
||||
skipDisableReporting = 'skip_disable_reporting' in message ? message.skip_disable_reporting : false;
|
||||
}
|
||||
|
||||
return {type, sourceKey, targetKey, clusters};
|
||||
return {type, sourceKey, targetKey, clusters, skipDisableReporting};
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
const {type, sourceKey, targetKey, clusters} = this.parseMQTTMessage(topic, message);
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const {type, sourceKey, targetKey, clusters, skipDisableReporting} = this.parseMQTTMessage(data);
|
||||
if (!type) return null;
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
|
||||
let error = null;
|
||||
const source = this.zigbee.resolveEntity(sourceKey);
|
||||
const target = targetKey === 'default_bind_group' ? defaultBindGroup : this.zigbee.resolveEntity(targetKey);
|
||||
const responseData = {from: sourceKey, to: targetKey};
|
||||
const parsedSource = utils.parseEntityID(sourceKey);
|
||||
const parsedTarget = utils.parseEntityID(targetKey);
|
||||
const source = this.zigbee.resolveEntity(parsedSource.ID);
|
||||
const target = targetKey === 'default_bind_group' ?
|
||||
defaultBindGroup : this.zigbee.resolveEntity(parsedTarget.ID);
|
||||
const responseData: KeyValue = {from: sourceKey, to: targetKey};
|
||||
|
||||
if (!source || source.type !== 'device') {
|
||||
if (!source || !(source instanceof Device)) {
|
||||
error = `Source device '${sourceKey}' does not exist`;
|
||||
} else if (!target) {
|
||||
error = `Target device or group '${targetKey}' does not exist`;
|
||||
} else {
|
||||
const sourceName = source.settings.friendlyName;
|
||||
const targetName = targetKey === 'default_bind_group' ? targetKey : target.settings.friendlyName;
|
||||
const successfulClusters = [];
|
||||
const successfulClusters: string[] = [];
|
||||
const failedClusters = [];
|
||||
const attemptedClusters = [];
|
||||
|
||||
let bindTarget = null;
|
||||
if (target.type === 'group') bindTarget = target.group;
|
||||
else if (target.type === 'group_number') bindTarget = target.ID;
|
||||
else bindTarget = target.endpoint;
|
||||
const bindSource: zh.Endpoint = source.endpoint(parsedSource.endpoint);
|
||||
let bindTarget: number | zh.Group | zh.Endpoint = null;
|
||||
if (target instanceof Device) bindTarget = target.endpoint(parsedTarget.endpoint);
|
||||
else if (target instanceof Group) bindTarget = target.zh;
|
||||
else bindTarget = Number(target.ID);
|
||||
|
||||
// Find which clusters are supported by both the source and target.
|
||||
// Groups are assumed to support all clusters.
|
||||
for (const cluster of clusterCandidates) {
|
||||
if (clusters && !clusters.includes(cluster)) continue;
|
||||
let matchingClusters = false;
|
||||
|
||||
const targetValid = target.type === 'group' || target.type === 'group_number' ||
|
||||
target.device.type === 'Coordinator' || target.endpoint.supportsInputCluster(cluster);
|
||||
const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' ||
|
||||
(target as Device).zh.type === 'Coordinator';
|
||||
|
||||
if (source.endpoint.supportsOutputCluster(cluster) && targetValid) {
|
||||
logger.debug(`${type}ing cluster '${cluster}' from '${sourceName}' to '${targetName}'`);
|
||||
if (!anyClusterValid && utils.isEndpoint(bindTarget)) {
|
||||
matchingClusters = ((bindTarget.supportsInputCluster(cluster) &&
|
||||
bindSource.supportsOutputCluster(cluster)) ||
|
||||
(bindSource.supportsInputCluster(cluster) &&
|
||||
bindTarget.supportsOutputCluster(cluster)) );
|
||||
}
|
||||
|
||||
const sourceValid = bindSource.supportsInputCluster(cluster) ||
|
||||
bindSource.supportsOutputCluster(cluster);
|
||||
|
||||
if ( sourceValid && (anyClusterValid || matchingClusters)) {
|
||||
logger.debug(`${type}ing cluster '${cluster}' from '${source.name}' to '${target.name}'`);
|
||||
attemptedClusters.push(cluster);
|
||||
|
||||
try {
|
||||
if (type === 'bind') {
|
||||
await source.endpoint.bind(cluster, bindTarget);
|
||||
await bindSource.bind(cluster, bindTarget);
|
||||
} else {
|
||||
await source.endpoint.unbind(cluster, bindTarget);
|
||||
await bindSource.unbind(cluster, bindTarget);
|
||||
}
|
||||
|
||||
successfulClusters.push(cluster);
|
||||
logger.info(
|
||||
`Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from ` +
|
||||
`'${sourceName}' to '${targetName}'`,
|
||||
`'${source.name}' to '${target.name}'`,
|
||||
);
|
||||
|
||||
/* istanbul ignore else */
|
||||
@@ -198,14 +265,14 @@ class Bind extends Extension {
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_${type}`,
|
||||
message: {from: sourceName, to: targetName, cluster}}),
|
||||
message: {from: source.name, to: target.name, cluster}}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
failedClusters.push(cluster);
|
||||
logger.error(
|
||||
`Failed to ${type} cluster '${cluster}' from '${sourceName}' to ` +
|
||||
`'${targetName}' (${error})`,
|
||||
`Failed to ${type} cluster '${cluster}' from '${source.name}' to ` +
|
||||
`'${target.name}' (${error})`,
|
||||
);
|
||||
|
||||
/* istanbul ignore else */
|
||||
@@ -213,7 +280,7 @@ class Bind extends Extension {
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_${type}_failed`,
|
||||
message: {from: sourceName, to: targetName, cluster}}),
|
||||
message: {from: source.name, to: target.name, cluster}}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -221,14 +288,14 @@ class Bind extends Extension {
|
||||
}
|
||||
|
||||
if (attemptedClusters.length === 0) {
|
||||
logger.error(`Nothing to ${type} from '${sourceName}' to '${targetName}'`);
|
||||
logger.error(`Nothing to ${type} from '${source.name}' to '${target.name}'`);
|
||||
error = `Nothing to ${type}`;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_${type}_failed`, message: {from: sourceName, to: targetName}}),
|
||||
stringify({type: `device_${type}_failed`, message: {from: source.name, to: target.name}}),
|
||||
);
|
||||
}
|
||||
} else if (failedClusters.length === attemptedClusters.length) {
|
||||
@@ -240,15 +307,15 @@ class Bind extends Extension {
|
||||
|
||||
if (successfulClusters.length !== 0) {
|
||||
if (type === 'bind') {
|
||||
await this.setupReporting(source.endpoint.binds.filter((b) =>
|
||||
await this.setupReporting(bindSource.binds.filter((b) =>
|
||||
successfulClusters.includes(b.cluster.name) && b.target === bindTarget));
|
||||
} else if (target.type !== 'group_number') {
|
||||
} else if ((typeof bindTarget !== 'number') && !skipDisableReporting) {
|
||||
await this.disableUnnecessaryReportings(bindTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const triggeredViaLegacyApi = topic.match(legacyTopicRegex);
|
||||
const triggeredViaLegacyApi = data.topic.match(legacyTopicRegex);
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
await this.mqtt.publish(`bridge/response/device/${type}`, stringify(response));
|
||||
@@ -257,23 +324,25 @@ class Bind extends Extension {
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
} else {
|
||||
this.eventBus.emit(`devicesChanged`);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
}
|
||||
}
|
||||
|
||||
async groupMembersChanged(data) {
|
||||
const bindsToGroup = this.zigbee.getClients().map((c) => c.endpoints).flat().map((e) => e.binds)
|
||||
.flat().filter((b) => b.target === data.group.group);
|
||||
|
||||
@bind async onGroupMembersChanged(data: eventdata.GroupMembersChanged): Promise<void> {
|
||||
if (data.action === 'add') {
|
||||
const bindsToGroup = this.zigbee.devices(false).map((c) => c.zh.endpoints)
|
||||
.reduce((a, v) => a.concat(v)).map((e) => e.binds)
|
||||
.reduce((a, v) => a.concat(v)).filter((b) => b.target === data.group.zh);
|
||||
await this.setupReporting(bindsToGroup);
|
||||
} else { // action === remove/remove_all
|
||||
await this.disableUnnecessaryReportings(data.endpoint);
|
||||
if (!data.skipDisableReporting) {
|
||||
await this.disableUnnecessaryReportings(data.endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSetupReportingEndpoints(bind, coordinatorEp) {
|
||||
const endpoints = bind.target.constructor.name === 'Group' ? bind.target.members : [bind.target];
|
||||
getSetupReportingEndpoints(bind: zh.Bind, coordinatorEp: zh.Endpoint): zh.Endpoint[] {
|
||||
const endpoints = utils.isEndpoint(bind.target) ? [bind.target] : bind.target.members;
|
||||
return endpoints.filter((e) => {
|
||||
const supportsInputCluster = e.supportsInputCluster(bind.cluster.name);
|
||||
const hasConfiguredReporting = !!e.configuredReportings.find((c) => c.cluster.name === bind.cluster.name);
|
||||
@@ -282,22 +351,23 @@ class Bind extends Extension {
|
||||
});
|
||||
}
|
||||
|
||||
async setupReporting(binds) {
|
||||
const coordinator = this.zigbee.getDevicesByType('Coordinator')[0];
|
||||
const coordinatorEndpoint = coordinator.getEndpoint(1);
|
||||
async setupReporting(binds: zh.Bind[]): Promise<void> {
|
||||
const coordinatorEndpoint = this.zigbee.firstCoordinatorEndpoint();
|
||||
for (const bind of binds.filter((b) => b.cluster.name in reportClusters)) {
|
||||
for (const endpoint of this.getSetupReportingEndpoints(bind, coordinatorEndpoint)) {
|
||||
const entity = `${this.zigbee.resolveEntity(endpoint.getDevice()).name}/${endpoint.ID}`;
|
||||
try {
|
||||
await endpoint.bind(bind.cluster.name, coordinatorEndpoint);
|
||||
const items = [];
|
||||
for (const c of reportClusters[bind.cluster.name]) {
|
||||
/* istanbul ignore else */
|
||||
if (!c.condition || await c.condition(endpoint)) {
|
||||
const i = {...c};
|
||||
delete i.condition;
|
||||
items.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const items = await reportClusters[bind.cluster.name]
|
||||
.filter(async (a) => !a.condition || await a.condition(endpoint))
|
||||
.map((a) => {
|
||||
const result = {...a};
|
||||
delete result.condition;
|
||||
return result;
|
||||
});
|
||||
await endpoint.configureReporting(bind.cluster.name, items);
|
||||
logger.info(`Succesfully setup reporting for '${entity}' cluster '${bind.cluster.name}'`);
|
||||
} catch (error) {
|
||||
@@ -306,36 +376,40 @@ class Bind extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.emit(`devicesChanged`);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
}
|
||||
|
||||
async disableUnnecessaryReportings(target) {
|
||||
const coordinator = this.zigbee.getDevicesByType('Coordinator')[0];
|
||||
const coordinatorEndpoint = coordinator.getEndpoint(1);
|
||||
const endpoints = target.constructor.name === 'Group' ? target.members : [target];
|
||||
async disableUnnecessaryReportings(target: zh.Group | zh.Endpoint): Promise<void> {
|
||||
const coordinator = this.zigbee.firstCoordinatorEndpoint();
|
||||
const endpoints = utils.isEndpoint(target) ? [target] : target.members;
|
||||
for (const endpoint of endpoints) {
|
||||
const entity = `${this.zigbee.resolveEntity(endpoint.getDevice()).name}/${endpoint.ID}`;
|
||||
const boundClusters = endpoint.binds.filter((b) => b.target === coordinatorEndpoint)
|
||||
const device = this.zigbee.resolveEntity(endpoint.getDevice()) as Device;
|
||||
const entity = `${device.name}/${endpoint.ID}`;
|
||||
const boundClusters = endpoint.binds.filter((b) => b.target === coordinator)
|
||||
.map((b) => b.cluster.name);
|
||||
const requiredClusters = this.zigbee.getClients().map((c) => c.endpoints).flat().map((e) => e.binds)
|
||||
.flat().filter((bind) => {
|
||||
if (bind.target.constructor.name === 'Group') {
|
||||
return bind.target.members.includes(endpoint);
|
||||
} else {
|
||||
const requiredClusters = this.zigbee.devices(false).map((c) => c.zh.endpoints)
|
||||
.reduce((a, v) => a.concat(v))
|
||||
.map((e) => e.binds).reduce((a, v) => a.concat(v)).filter((bind) => {
|
||||
if (utils.isEndpoint(bind.target)) {
|
||||
return bind.target === endpoint;
|
||||
} else {
|
||||
return bind.target.members.includes(endpoint);
|
||||
}
|
||||
}).map((b) => b.cluster.name).filter((v, i, a) => a.indexOf(v) === i);
|
||||
|
||||
for (const cluster of boundClusters.filter((c) => !requiredClusters.includes(c) && c in reportClusters)) {
|
||||
try {
|
||||
await endpoint.unbind(cluster, coordinatorEndpoint);
|
||||
const items = await reportClusters[cluster]
|
||||
.filter(async (a) => !a.condition || await a.condition(endpoint))
|
||||
.map((a) => {
|
||||
const result = {...a, maximumReportInterval: 0xFFFF};
|
||||
delete result.condition;
|
||||
return result;
|
||||
});
|
||||
await endpoint.unbind(cluster, coordinator);
|
||||
const items = [];
|
||||
for (const item of reportClusters[cluster]) {
|
||||
/* istanbul ignore else */
|
||||
if (!item.condition || await item.condition(endpoint)) {
|
||||
const i = {...item};
|
||||
delete i.condition;
|
||||
items.push({...i, maximumReportInterval: 0xFFFF});
|
||||
}
|
||||
}
|
||||
|
||||
await endpoint.configureReporting(cluster, items);
|
||||
logger.info(`Succesfully disabled reporting for '${entity}' cluster '${cluster}'`);
|
||||
} catch (error) {
|
||||
@@ -343,17 +417,11 @@ class Bind extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.emit('reportingDisabled', {device: endpoint.getDevice()});
|
||||
this.eventBus.emitReportingDisabled({device});
|
||||
}
|
||||
}
|
||||
|
||||
async onZigbeeEvent(type, data, resolvedEntity) {
|
||||
if (type === 'message') {
|
||||
this.poll(data);
|
||||
}
|
||||
}
|
||||
|
||||
poll(message) {
|
||||
@bind async poll(data: eventdata.DeviceMessage): Promise<void> {
|
||||
/**
|
||||
* This method poll bound endpoints and group members for state changes.
|
||||
*
|
||||
@@ -363,38 +431,42 @@ class Bind extends Extension {
|
||||
* When we receive a message from a Hue dimmer we read the brightness from the bulb (if bound).
|
||||
*/
|
||||
const polls = pollOnMessage.filter((p) =>
|
||||
p.cluster[message.cluster] && p.cluster[message.cluster].find((c) => c.type === message.type &&
|
||||
utils.equalsPartial(message.data, c.data)),
|
||||
);
|
||||
p.cluster[data.cluster]?.find((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)));
|
||||
|
||||
if (polls.length) {
|
||||
let toPoll = [];
|
||||
|
||||
const toPoll: Set<zh.Endpoint> = new Set();
|
||||
// Add bound devices
|
||||
toPoll = toPoll.concat([].concat(...message.device.endpoints.map((e) =>
|
||||
e.binds.map((e) => e).filter((e) => e.target))));
|
||||
toPoll = toPoll.filter((e) => e.target.constructor.name === 'Endpoint');
|
||||
toPoll = toPoll.filter((e) => e.target.getDevice().type !== 'Coordinator');
|
||||
toPoll = toPoll.map((e) => e.target);
|
||||
|
||||
// If message is published to a group, add members of the group
|
||||
const group = message.groupID !== 0 ? this.zigbee.getGroupByID(message.groupID) : null;
|
||||
if (group) {
|
||||
toPoll = toPoll.concat(group.members);
|
||||
for (const endpoint of data.device.zh.endpoints) {
|
||||
for (const bind of endpoint.binds) {
|
||||
if (utils.isEndpoint(bind.target) && bind.target.getDevice().type !== 'Coordinator') {
|
||||
toPoll.add(bind.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toPoll = new Set(toPoll);
|
||||
// If message is published to a group, add members of the group
|
||||
const group = data.groupID && data.groupID !== 0 && this.zigbee.groupByID(data.groupID);
|
||||
if (group) {
|
||||
group.zh.members.forEach((m) => toPoll.add(m));
|
||||
}
|
||||
|
||||
for (const endpoint of toPoll) {
|
||||
for (const poll of polls) {
|
||||
if (!poll.manufacturerIDs.includes(endpoint.getDevice().manufacturerID)) {
|
||||
if (!poll.manufacturerIDs.includes(endpoint.getDevice().manufacturerID) ||
|
||||
!endpoint.supportsInputCluster(poll.read.cluster)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${endpoint.deviceIeeeAddress}_${endpoint.ID}_${pollOnMessage.indexOf(poll)}`;
|
||||
let readAttrs = poll.read.attributes;
|
||||
if (poll.read.attributesForEndpoint) {
|
||||
const attrsForEndpoint = await poll.read.attributesForEndpoint(endpoint);
|
||||
readAttrs = [...poll.read.attributes, ...attrsForEndpoint];
|
||||
}
|
||||
|
||||
const key = `${endpoint.getDevice().ieeeAddr}_${endpoint.ID}_${pollOnMessage.indexOf(poll)}`;
|
||||
if (!this.pollDebouncers[key]) {
|
||||
this.pollDebouncers[key] = debounce(async () => {
|
||||
await endpoint.read(poll.read.cluster, poll.read.attributes);
|
||||
await endpoint.read(poll.read.cluster, readAttrs);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
@@ -404,5 +476,3 @@ class Bind extends Extension {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Bind;
|
||||
@@ -1,73 +1,58 @@
|
||||
const logger = require('../util/logger');
|
||||
const utils = require('../util/utils');
|
||||
const Extension = require('./extension');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const settings = require('../util/settings');
|
||||
const Transport = require('winston-transport');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const objectAssignDeep = require(`object-assign-deep`);
|
||||
const {updatedDiff, addedDiff} = require('deep-object-diff');
|
||||
/* eslint-disable camelcase */
|
||||
import logger from '../util/logger';
|
||||
import utils from '../util/utils';
|
||||
import * as settings from '../util/settings';
|
||||
import Transport from 'winston-transport';
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import {detailedDiff} from 'deep-object-diff';
|
||||
import Extension from './extension';
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
|
||||
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
|
||||
|
||||
class Bridge extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.enableDisableExtension = enableDisableExtension;
|
||||
this.restartCallback = restartCallback;
|
||||
this.lastJoinedDeviceIeeeAddr = null;
|
||||
this.setupMQTTLogging();
|
||||
this.restartRequired = false;
|
||||
type Scene = {id: number, name: string};
|
||||
type DefinitionPayload = {
|
||||
model: string, vendor: string, description: string, exposes: zhc.DefinitionExpose[], supports_ota:
|
||||
boolean, icon: string, options: zhc.DefinitionExpose[],
|
||||
};
|
||||
|
||||
export default class Bridge extends Extension {
|
||||
private zigbee2mqttVersion: {commitHash: string, version: string};
|
||||
private coordinatorVersion: zh.CoordinatorVersion;
|
||||
private restartRequired = false;
|
||||
private lastJoinedDeviceIeeeAddr: string;
|
||||
private requestLookup: {[key: string]: (message: KeyValue | string) => Promise<MQTTResponse>};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.requestLookup = {
|
||||
'device/options': this.deviceOptions.bind(this),
|
||||
'device/configure_reporting': this.deviceConfigureReporting.bind(this),
|
||||
'device/remove': this.deviceRemove.bind(this),
|
||||
'device/rename': this.deviceRename.bind(this),
|
||||
'group/add': this.groupAdd.bind(this),
|
||||
'group/options': this.groupOptions.bind(this),
|
||||
'group/remove': this.groupRemove.bind(this),
|
||||
'group/rename': this.groupRename.bind(this),
|
||||
'permit_join': this.permitJoin.bind(this),
|
||||
'restart': this.restart.bind(this),
|
||||
'touchlink/factory_reset': this.touchlinkFactoryReset.bind(this),
|
||||
'touchlink/identify': this.touchlinkIdentify.bind(this),
|
||||
'touchlink/scan': this.touchlinkScan.bind(this),
|
||||
'health_check': this.healthCheck.bind(this),
|
||||
'options': this.bridgeOptions.bind(this),
|
||||
'device/options': this.deviceOptions,
|
||||
'device/configure_reporting': this.deviceConfigureReporting,
|
||||
'device/remove': this.deviceRemove,
|
||||
'device/rename': this.deviceRename,
|
||||
'group/add': this.groupAdd,
|
||||
'group/options': this.groupOptions,
|
||||
'group/remove': this.groupRemove,
|
||||
'group/rename': this.groupRename,
|
||||
'permit_join': this.permitJoin,
|
||||
'restart': this.restart,
|
||||
'touchlink/factory_reset': this.touchlinkFactoryReset,
|
||||
'touchlink/identify': this.touchlinkIdentify,
|
||||
'touchlink/scan': this.touchlinkScan,
|
||||
'health_check': this.healthCheck,
|
||||
'options': this.bridgeOptions,
|
||||
// Below are deprecated
|
||||
'config/last_seen': this.configLastSeen.bind(this),
|
||||
'config/homeassistant': this.configHomeAssistant.bind(this),
|
||||
'config/elapsed': this.configElapsed.bind(this),
|
||||
'config/log_level': this.configLogLevel.bind(this),
|
||||
'config/last_seen': this.configLastSeen,
|
||||
'config/homeassistant': this.configHomeAssistant,
|
||||
'config/elapsed': this.configElapsed,
|
||||
'config/log_level': this.configLogLevel,
|
||||
};
|
||||
}
|
||||
|
||||
async onMQTTConnected() {
|
||||
this.zigbee2mqttVersion = await utils.getZigbee2mqttVersion();
|
||||
this.coordinatorVersion = await this.zigbee.getCoordinatorVersion();
|
||||
|
||||
this.eventBus.on(`groupMembersChanged`, () => this.publishGroups());
|
||||
this.eventBus.on(`devicesChanged`, () => {
|
||||
this.publishDevices();
|
||||
this.publishInfo();
|
||||
});
|
||||
this.eventBus.on(`deviceRenamed`, () => {
|
||||
this.publishInfo();
|
||||
});
|
||||
this.eventBus.on(`groupRenamed`, () => {
|
||||
this.publishInfo();
|
||||
});
|
||||
this.zigbee.on('permitJoinChanged', (data) => this.permitJoinChanged(data));
|
||||
await this.publishInfo();
|
||||
await this.publishDevices();
|
||||
await this.publishGroups();
|
||||
}
|
||||
|
||||
setupMQTTLogging() {
|
||||
const mqtt = this.mqtt;
|
||||
class EventTransport extends Transport {
|
||||
log(info, callback) {
|
||||
log(info: {message: string, level: string}, callback: () => void): void {
|
||||
const payload = stringify({message: info.message, level: info.level});
|
||||
mqtt.publish(`bridge/logging`, payload, {}, settings.get().mqtt.base_topic, true);
|
||||
callback();
|
||||
@@ -75,90 +60,107 @@ class Bridge extends Extension {
|
||||
}
|
||||
|
||||
logger.addTransport(new EventTransport());
|
||||
|
||||
this.zigbee2mqttVersion = await utils.getZigbee2MQTTVersion();
|
||||
this.coordinatorVersion = await this.zigbee.getCoordinatorVersion();
|
||||
|
||||
this.eventBus.onDeviceRenamed(this, () => this.publishInfo());
|
||||
this.eventBus.onGroupMembersChanged(this, () => this.publishGroups());
|
||||
this.eventBus.onDevicesChanged(this, () => this.publishDevices() && this.publishInfo());
|
||||
this.eventBus.onPermitJoinChanged(this, () => !this.zigbee.isStopping() && this.publishInfo());
|
||||
this.eventBus.onScenesChanged(this, () => {
|
||||
this.publishDevices();
|
||||
this.publishGroups();
|
||||
});
|
||||
|
||||
// Zigbee events
|
||||
const publishEvent = (type: string, data: KeyValue): Promise<void> =>
|
||||
this.mqtt.publish('bridge/event', stringify({type, data}), {retain: false, qos: 0});
|
||||
this.eventBus.onDeviceJoined(this, (data) => {
|
||||
this.lastJoinedDeviceIeeeAddr = data.device.ieeeAddr;
|
||||
this.publishDevices();
|
||||
publishEvent('device_joined', {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr});
|
||||
});
|
||||
this.eventBus.onDeviceLeave(this, (data) => {
|
||||
this.publishDevices();
|
||||
publishEvent('device_leave', {ieee_address: data.ieeeAddr, friendly_name: data.name});
|
||||
});
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, () => this.publishDevices());
|
||||
this.eventBus.onDeviceInterview(this, (data) => {
|
||||
this.publishDevices();
|
||||
const payload: KeyValue =
|
||||
{friendly_name: data.device.name, status: data.status, ieee_address: data.device.ieeeAddr};
|
||||
if (data.status === 'successful') {
|
||||
payload.supported = !!data.device.definition;
|
||||
payload.definition = this.getDefinitionPayload(data.device);
|
||||
}
|
||||
publishEvent('device_interview', payload);
|
||||
});
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => {
|
||||
this.publishDevices();
|
||||
publishEvent('device_announce', {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr});
|
||||
});
|
||||
|
||||
await this.publishInfo();
|
||||
await this.publishDevices();
|
||||
await this.publishGroups();
|
||||
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
permitJoinChanged(data) {
|
||||
if (!this.zigbee.isStopping()) {
|
||||
this.publishInfo();
|
||||
}
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
const match = topic.match(requestRegex);
|
||||
if (match && this.requestLookup[match[1].toLowerCase()]) {
|
||||
message = utils.parseJSON(message, message);
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(requestRegex);
|
||||
const key = match?.[1]?.toLowerCase();
|
||||
if (key in this.requestLookup) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
|
||||
try {
|
||||
const response = await this.requestLookup[match[1].toLowerCase()](message);
|
||||
const response = await this.requestLookup[key](message);
|
||||
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
|
||||
} catch (error) {
|
||||
logger.error(`Request '${topic}' failed with error: '${error.message}'`);
|
||||
logger.error(`Request '${data.topic}' failed with error: '${error.message}'`);
|
||||
logger.debug(error.stack);
|
||||
const response = utils.getResponse(message, {}, error.message);
|
||||
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onZigbeeEvent(type, data, resolvedEntity) {
|
||||
if (type === 'deviceJoined' && resolvedEntity) {
|
||||
this.lastJoinedDeviceIeeeAddr = resolvedEntity.device.ieeeAddr;
|
||||
}
|
||||
|
||||
if (['deviceJoined', 'deviceLeave', 'deviceInterview', 'deviceAnnounce'].includes(type)) {
|
||||
let payload;
|
||||
const ieeeAddress = data.device ? data.device.ieeeAddr : data.ieeeAddr;
|
||||
if (type === 'deviceJoined') {
|
||||
payload = {friendly_name: resolvedEntity.settings.friendlyName, ieee_address: ieeeAddress};
|
||||
} else if (type === 'deviceInterview') {
|
||||
payload = {
|
||||
friendly_name: resolvedEntity.settings.friendlyName, status: data.status, ieee_address: ieeeAddress,
|
||||
};
|
||||
if (data.status === 'successful') {
|
||||
const definition = resolvedEntity.definition;
|
||||
payload.supported = !!definition;
|
||||
payload.definition = this.getDefinitionPayload(definition);
|
||||
}
|
||||
} else if (type === 'deviceAnnounce') {
|
||||
payload = {
|
||||
friendly_name: resolvedEntity.settings.friendlyName, ieee_address: ieeeAddress,
|
||||
};
|
||||
} else payload = {ieee_address: ieeeAddress}; // deviceLeave
|
||||
|
||||
await this.mqtt.publish(
|
||||
'bridge/event',
|
||||
stringify({type: utils.toSnakeCase(type), data: payload}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
}
|
||||
|
||||
if ('deviceLeave' === type || ('deviceInterview' === type && data.status !== 'started')) {
|
||||
await this.publishDevices();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests
|
||||
*/
|
||||
|
||||
async deviceOptions(message) {
|
||||
@bind async deviceOptions(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
return this.changeEntityOptions('device', message);
|
||||
}
|
||||
|
||||
async groupOptions(message) {
|
||||
@bind async groupOptions(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
return this.changeEntityOptions('group', message);
|
||||
}
|
||||
|
||||
async bridgeOptions(message) {
|
||||
@bind async bridgeOptions(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
if (typeof message !== 'object' || typeof message.options !== 'object') {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const diffUpdated = updatedDiff(settings.get(), message.options);
|
||||
const diffAdded = addedDiff(settings.get(), message.options);
|
||||
const newSettings = objectAssignDeep.noMutate(diffUpdated, diffAdded);
|
||||
const diff: KeyValue = detailedDiff(settings.get(), message.options);
|
||||
|
||||
// Remove any settings that are in the deleted.diff but not in the passed options
|
||||
const cleanupDeleted = (options: KeyValue, deleted: KeyValue): void => {
|
||||
for (const key of Object.keys(deleted)) {
|
||||
if (!(key in options)) {
|
||||
delete deleted[key];
|
||||
} else if (!Array.isArray(options[key])) {
|
||||
cleanupDeleted(options[key], deleted[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
cleanupDeleted(message.options, diff.deleted);
|
||||
|
||||
const newSettings = objectAssignDeep({}, diff.added, diff.updated, diff.deleted);
|
||||
|
||||
// deep-object-diff converts arrays to objects, set original array back here
|
||||
const convertBackArray = (before, after) => {
|
||||
const convertBackArray = (before: KeyValue, after: KeyValue): void => {
|
||||
for (const [key, afterValue] of Object.entries(after)) {
|
||||
const beforeValue = before[key];
|
||||
if (Array.isArray(beforeValue)) {
|
||||
@@ -179,7 +181,7 @@ class Bridge extends Extension {
|
||||
}
|
||||
|
||||
if (newSettings.hasOwnProperty('homeassistant')) {
|
||||
this.enableDisableExtension(newSettings.homeassistant, 'HomeAssistant');
|
||||
await this.enableDisableExtension(newSettings.homeassistant, 'HomeAssistant');
|
||||
}
|
||||
|
||||
if (newSettings.hasOwnProperty('advanced') && newSettings.advanced.hasOwnProperty('log_level')) {
|
||||
@@ -191,19 +193,19 @@ class Bridge extends Extension {
|
||||
return utils.getResponse(message, {restart_required: this.restartRequired}, null);
|
||||
}
|
||||
|
||||
async deviceRemove(message) {
|
||||
@bind async deviceRemove(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return this.removeEntity('device', message);
|
||||
}
|
||||
|
||||
async groupRemove(message) {
|
||||
@bind async groupRemove(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return this.removeEntity('group', message);
|
||||
}
|
||||
|
||||
async healthCheck(message) {
|
||||
@bind async healthCheck(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return utils.getResponse(message, {healthy: true}, null);
|
||||
}
|
||||
|
||||
async groupAdd(message) {
|
||||
@bind async groupAdd(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
if (typeof message === 'object' && !message.hasOwnProperty('friendly_name')) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
@@ -213,38 +215,40 @@ class Bridge extends Extension {
|
||||
const group = settings.addGroup(friendlyName, ID);
|
||||
this.zigbee.createGroup(group.ID);
|
||||
this.publishGroups();
|
||||
return utils.getResponse(message, {friendly_name: group.friendlyName, id: group.ID}, null);
|
||||
return utils.getResponse(message, {friendly_name: group.friendly_name, id: group.ID}, null);
|
||||
}
|
||||
|
||||
async deviceRename(message) {
|
||||
@bind async deviceRename(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return this.renameEntity('device', message);
|
||||
}
|
||||
|
||||
async groupRename(message) {
|
||||
@bind async groupRename(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return this.renameEntity('group', message);
|
||||
}
|
||||
|
||||
async restart(message) {
|
||||
@bind async restart(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
// Wait 500 ms before restarting so response can be send.
|
||||
setTimeout(this.restartCallback, 500);
|
||||
logger.info('Restarting Zigbee2MQTT');
|
||||
return utils.getResponse(message, {}, null);
|
||||
}
|
||||
|
||||
async permitJoin(message) {
|
||||
@bind async permitJoin(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
if (typeof message === 'object' && !message.hasOwnProperty('value')) {
|
||||
throw new Error('Invalid payload');
|
||||
}
|
||||
|
||||
let value;
|
||||
let time;
|
||||
let resolvedEntity;
|
||||
let value: boolean | string;
|
||||
let time: number;
|
||||
let device: Device = null;
|
||||
if (typeof message === 'object') {
|
||||
value = message.value;
|
||||
time = message.time;
|
||||
if (message.device) {
|
||||
resolvedEntity = this.zigbee.resolveEntity(message.device);
|
||||
if (!resolvedEntity || resolvedEntity.type !== 'device') {
|
||||
const resolved = this.zigbee.resolveEntity(message.device);
|
||||
if (resolved instanceof Device) {
|
||||
device = resolved;
|
||||
} else {
|
||||
throw new Error(`Device '${message.device}' does not exist`);
|
||||
}
|
||||
}
|
||||
@@ -256,18 +260,18 @@ class Bridge extends Extension {
|
||||
value = value.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
await this.zigbee.permitJoin(value, resolvedEntity, time);
|
||||
const response = {value};
|
||||
if (resolvedEntity) response.device = message.device;
|
||||
if (time) response.time = message.time;
|
||||
await this.zigbee.permitJoin(value, device, time);
|
||||
const response: {value: boolean, device?: string, time?: number} = {value};
|
||||
if (device && typeof message === 'object') response.device = message.device;
|
||||
if (time && typeof message === 'object') response.time = message.time;
|
||||
return utils.getResponse(message, response, null);
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
configLastSeen(message) {
|
||||
@bind async configLastSeen(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
const allowed = ['disable', 'ISO_8601', 'epoch', 'ISO_8601_local'];
|
||||
const value = this.getValue(message);
|
||||
if (!allowed.includes(value)) {
|
||||
if (typeof value !== 'string' || !allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
@@ -277,24 +281,24 @@ class Bridge extends Extension {
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
configHomeAssistant(message) {
|
||||
@bind async configHomeAssistant(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
const allowed = [true, false];
|
||||
const value = this.getValue(message);
|
||||
if (!allowed.includes(value)) {
|
||||
if (typeof value !== 'boolean' || !allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
this.enableDisableExtension(value, 'HomeAssistant');
|
||||
await this.enableDisableExtension(value, 'HomeAssistant');
|
||||
settings.set(['homeassistant'], value);
|
||||
this.publishInfo();
|
||||
return utils.getResponse(message, {value}, null);
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
configElapsed(message) {
|
||||
@bind async configElapsed(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
const allowed = [true, false];
|
||||
const value = this.getValue(message);
|
||||
if (!allowed.includes(value)) {
|
||||
if (typeof value !== 'boolean' || !allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
@@ -304,10 +308,10 @@ class Bridge extends Extension {
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
configLogLevel(message) {
|
||||
@bind async configLogLevel(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
const allowed = ['error', 'warn', 'info', 'debug'];
|
||||
const value = this.getValue(message);
|
||||
if (!allowed.includes(value)) {
|
||||
const value = this.getValue(message) as 'error' | 'warn' | 'info' | 'debug';
|
||||
if (typeof value !== 'string' || !allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
@@ -316,7 +320,7 @@ class Bridge extends Extension {
|
||||
return utils.getResponse(message, {value}, null);
|
||||
}
|
||||
|
||||
async touchlinkIdentify(message) {
|
||||
@bind async touchlinkIdentify(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
if (typeof message !== 'object' || !message.hasOwnProperty('ieee_address') ||
|
||||
!message.hasOwnProperty('channel')) {
|
||||
throw new Error('Invalid payload');
|
||||
@@ -327,9 +331,9 @@ class Bridge extends Extension {
|
||||
return utils.getResponse(message, {ieee_address: message.ieee_address, channel: message.channel}, null);
|
||||
}
|
||||
|
||||
async touchlinkFactoryReset(message) {
|
||||
@bind async touchlinkFactoryReset(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
let result = false;
|
||||
const payload = {};
|
||||
const payload: {ieee_address?: string, channel?: number} = {};
|
||||
if (typeof message === 'object' && message.hasOwnProperty('ieee_address') &&
|
||||
message.hasOwnProperty('channel')) {
|
||||
logger.info(`Start Touchlink factory reset of '${message.ieee_address}' on channel ${message.channel}`);
|
||||
@@ -350,7 +354,7 @@ class Bridge extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
async touchlinkScan(message) {
|
||||
@bind async touchlinkScan(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
logger.info('Start Touchlink scan');
|
||||
const result = await this.zigbee.touchlinkScan();
|
||||
const found = result.map((r) => {
|
||||
@@ -364,7 +368,7 @@ class Bridge extends Extension {
|
||||
* Utils
|
||||
*/
|
||||
|
||||
getValue(message) {
|
||||
getValue(message: KeyValue | string): string | boolean | number {
|
||||
if (typeof message === 'object') {
|
||||
if (!message.hasOwnProperty('value')) {
|
||||
throw new Error('No value given');
|
||||
@@ -376,20 +380,21 @@ class Bridge extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
async changeEntityOptions(entityType, message) {
|
||||
async changeEntityOptions(entityType: 'device' | 'group', message: KeyValue | string): Promise<MQTTResponse> {
|
||||
if (typeof message !== 'object' || !message.hasOwnProperty('id') || !message.hasOwnProperty('options')) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const ID = message.id;
|
||||
const entity = this.getEntity(entityType, ID);
|
||||
settings.changeEntityOptions(ID, message.options);
|
||||
const cleanup = (o) => {
|
||||
const cleanup = (o: KeyValue): KeyValue => {
|
||||
delete o.friendlyName; delete o.friendly_name; delete o.ID; delete o.type; delete o.devices;
|
||||
return o;
|
||||
};
|
||||
const oldOptions = cleanup(entity.settings);
|
||||
const newOptions = cleanup(settings.getEntity(ID));
|
||||
|
||||
const ID = message.id;
|
||||
const entity = this.getEntity(entityType, ID);
|
||||
const oldOptions = objectAssignDeep({}, cleanup(entity.settings));
|
||||
settings.changeEntityOptions(ID, message.options);
|
||||
const newOptions = cleanup(entity.settings);
|
||||
await this.publishInfo();
|
||||
|
||||
logger.info(`Changed config for ${entityType} ${ID}`);
|
||||
@@ -397,18 +402,17 @@ class Bridge extends Extension {
|
||||
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID}, null);
|
||||
}
|
||||
|
||||
async deviceConfigureReporting(message) {
|
||||
@bind async deviceConfigureReporting(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
if (typeof message !== 'object' || !message.hasOwnProperty('id') || !message.hasOwnProperty('cluster') ||
|
||||
!message.hasOwnProperty('maximum_report_interval') || !message.hasOwnProperty('minimum_report_interval') ||
|
||||
!message.hasOwnProperty('reportable_change') || !message.hasOwnProperty('attribute')) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const ID = message.id;
|
||||
const endpoint = this.getEntity('device', ID).endpoint;
|
||||
const parsedID = utils.parseEntityID(message.id);
|
||||
const endpoint = (this.getEntity('device', parsedID.ID) as Device).endpoint(parsedID.endpoint);
|
||||
|
||||
const coordinator = this.zigbee.getDevicesByType('Coordinator')[0];
|
||||
const coordinatorEndpoint = coordinator.getEndpoint(1);
|
||||
const coordinatorEndpoint = this.zigbee.firstCoordinatorEndpoint();
|
||||
await endpoint.bind(message.cluster, coordinatorEndpoint);
|
||||
|
||||
await endpoint.configureReporting(message.cluster, [{
|
||||
@@ -427,7 +431,7 @@ class Bridge extends Extension {
|
||||
}, null);
|
||||
}
|
||||
|
||||
renameEntity(entityType, message) {
|
||||
async renameEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> {
|
||||
const deviceAndHasLast = entityType === 'device' && typeof message === 'object' && message.last === true;
|
||||
if (typeof message !== 'object' || (!message.hasOwnProperty('from') && !deviceAndHasLast) ||
|
||||
!message.hasOwnProperty('to')) {
|
||||
@@ -443,23 +447,23 @@ class Bridge extends Extension {
|
||||
const homeAssisantRename = message.hasOwnProperty('homeassistant_rename') ?
|
||||
message.homeassistant_rename : false;
|
||||
const entity = this.getEntity(entityType, from);
|
||||
const oldFriendlyName = entity.settings.friendly_name;
|
||||
|
||||
settings.changeFriendlyName(from, to);
|
||||
|
||||
// Clear retained messages
|
||||
this.mqtt.publish(entity.name, '', {retain: true});
|
||||
this.mqtt.publish(oldFriendlyName, '', {retain: true});
|
||||
|
||||
const oldFriendlyName = entity.settings.friendlyName;
|
||||
if (entity.type === 'device') {
|
||||
if (entity instanceof Device) {
|
||||
this.publishDevices();
|
||||
this.eventBus.emit(`deviceRenamed`, {device: entity.device, homeAssisantRename, from: oldFriendlyName, to});
|
||||
this.eventBus.emitDeviceRenamed({device: entity, homeAssisantRename, from: oldFriendlyName, to});
|
||||
} else {
|
||||
this.publishGroups();
|
||||
this.eventBus.emit(`groupRenamed`, {group: entity.group, homeAssisantRename, from: oldFriendlyName, to});
|
||||
this.publishInfo();
|
||||
}
|
||||
|
||||
// Repulish entity state
|
||||
this.publishEntityState(to, {});
|
||||
this.publishEntityState(entity, {});
|
||||
|
||||
return utils.getResponse(
|
||||
message,
|
||||
@@ -468,9 +472,11 @@ class Bridge extends Extension {
|
||||
);
|
||||
}
|
||||
|
||||
async removeEntity(entityType, message) {
|
||||
async removeEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> {
|
||||
const ID = typeof message === 'object' ? message.id : message.trim();
|
||||
const entity = this.getEntity(entityType, ID);
|
||||
const friendlyName = entity.name;
|
||||
const entityID = entity.ID;
|
||||
|
||||
let block = false;
|
||||
let force = false;
|
||||
@@ -486,46 +492,49 @@ class Bridge extends Extension {
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`Removing ${entity.type} '${entity.settings.friendlyName}'${blockForceLog}`);
|
||||
if (entity.type === 'device') {
|
||||
logger.info(`Removing ${entityType} '${entity.name}'${blockForceLog}`);
|
||||
const ieeeAddr = entity.isDevice() && entity.ieeeAddr;
|
||||
const name = entity.name;
|
||||
|
||||
if (entity instanceof Device) {
|
||||
if (block) {
|
||||
settings.blockDevice(entity.settings.ID);
|
||||
settings.blockDevice(entity.ieeeAddr);
|
||||
}
|
||||
|
||||
if (force) {
|
||||
await entity.device.removeFromDatabase();
|
||||
await entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
await entity.device.removeFromNetwork();
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
} else {
|
||||
if (force) {
|
||||
await entity.group.removeFromDatabase();
|
||||
entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
await entity.group.removeFromNetwork();
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
}
|
||||
|
||||
// Fire event
|
||||
if (entity.type === 'device') {
|
||||
this.eventBus.emit('deviceRemoved', {resolvedEntity: entity});
|
||||
if (entity instanceof Device) {
|
||||
this.eventBus.emitDeviceRemoved({ieeeAddr, name});
|
||||
}
|
||||
|
||||
// Remove from configuration.yaml
|
||||
if (entity.type === 'device') {
|
||||
settings.removeDevice(entity.settings.ID);
|
||||
if (entity instanceof Device) {
|
||||
settings.removeDevice(entityID as string);
|
||||
} else {
|
||||
settings.removeGroup(entity.settings.ID);
|
||||
settings.removeGroup(entityID);
|
||||
}
|
||||
|
||||
// Remove from state
|
||||
this.state.remove(entity.settings.ID);
|
||||
this.state.remove(entityID);
|
||||
|
||||
// Clear any retained messages
|
||||
this.mqtt.publish(entity.name, '', {retain: true});
|
||||
this.mqtt.publish(friendlyName, '', {retain: true});
|
||||
|
||||
logger.info(`Successfully removed ${entity.type} '${entity.settings.friendlyName}'${blockForceLog}`);
|
||||
logger.info(`Successfully removed ${entityType} '${friendlyName}'${blockForceLog}`);
|
||||
|
||||
if (entity.type === 'device') {
|
||||
if (entity instanceof Device) {
|
||||
this.publishGroups();
|
||||
this.publishDevices();
|
||||
return utils.getResponse(message, {id: ID, block, force}, null);
|
||||
@@ -535,21 +544,21 @@ class Bridge extends Extension {
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to remove ${entity.type} '${entity.settings.friendlyName}'${blockForceLog} (${error})`,
|
||||
`Failed to remove ${entityType} '${friendlyName}'${blockForceLog} (${error})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getEntity(type, ID) {
|
||||
getEntity(type: 'group' | 'device', ID: string): Device | Group {
|
||||
const entity = this.zigbee.resolveEntity(ID);
|
||||
if (!entity || entity.type !== type) {
|
||||
if (!entity || entity.constructor.name.toLowerCase() !== type) {
|
||||
throw new Error(`${utils.capitalize(type)} '${ID}' does not exist`);
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
async publishInfo() {
|
||||
const config = objectAssignDeep.noMutate({}, settings.get());
|
||||
async publishInfo(): Promise<void> {
|
||||
const config = objectAssignDeep({}, settings.get());
|
||||
delete config.advanced.network_key;
|
||||
delete config.mqtt.password;
|
||||
config.frontend && delete config.frontend.auth_token;
|
||||
@@ -559,7 +568,8 @@ class Bridge extends Extension {
|
||||
coordinator: this.coordinatorVersion,
|
||||
network: utils.toSnakeCase(await this.zigbee.getNetworkParameters()),
|
||||
log_level: logger.getLevel(),
|
||||
permit_join: await this.zigbee.getPermitJoin(),
|
||||
permit_join: this.zigbee.getPermitJoin(),
|
||||
permit_join_timeout: this.zigbee.getPermitJoinTimeout(),
|
||||
restart_required: this.restartRequired,
|
||||
config,
|
||||
config_schema: settings.schema,
|
||||
@@ -569,13 +579,38 @@ class Bridge extends Extension {
|
||||
'bridge/info', stringify(payload), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
}
|
||||
|
||||
async publishDevices() {
|
||||
const devices = this.zigbee.getDevices().map((device) => {
|
||||
const definition = zigbeeHerdsmanConverters.findByDevice(device);
|
||||
const resolved = this.zigbee.resolveEntity(device);
|
||||
const endpoints = {};
|
||||
for (const endpoint of device.endpoints) {
|
||||
const data = {
|
||||
private getScenes(entity: zh.Endpoint | zh.Group): Scene[] {
|
||||
const scenes: {[id: number]: Scene} = {};
|
||||
const endpoints = utils.isEndpoint(entity) ? [entity] : entity.members;
|
||||
const groupID = utils.isEndpoint(entity) ? 0 : entity.groupID;
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
for (const [key, data] of Object.entries(endpoint.meta?.scenes || {})) {
|
||||
const split = key.split('_');
|
||||
const sceneID = parseInt(split[0], 10);
|
||||
const sceneGroupID = parseInt(split[1], 10);
|
||||
if (sceneGroupID === groupID) {
|
||||
scenes[sceneID] = {id: sceneID, name: (data as KeyValue).name || `Scene ${sceneID}`};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(scenes);
|
||||
}
|
||||
|
||||
async publishDevices(): Promise<void> {
|
||||
interface Data {
|
||||
bindings: {cluster: string, target: {type: string, endpoint?: number, ieee_address?: string, id?: number}}[]
|
||||
configured_reportings: {cluster: string, attribute: string | number,
|
||||
minimum_report_interval: number, maximum_report_interval: number, reportable_change: number}[],
|
||||
clusters: {input: string[], output: string[]}, scenes: Scene[]
|
||||
}
|
||||
|
||||
const devices = this.zigbee.devices().map((device) => {
|
||||
const endpoints: {[s: number]: Data} = {};
|
||||
for (const endpoint of device.zh.endpoints) {
|
||||
const data: Data = {
|
||||
scenes: this.getScenes(endpoint),
|
||||
bindings: [],
|
||||
configured_reportings: [],
|
||||
clusters: {
|
||||
@@ -585,23 +620,16 @@ class Bridge extends Extension {
|
||||
};
|
||||
|
||||
for (const bind of endpoint.binds) {
|
||||
let target;
|
||||
|
||||
if (bind.target.constructor.name === 'Endpoint') {
|
||||
target = {
|
||||
type: 'endpoint', ieee_address: bind.target.getDevice().ieeeAddr, endpoint: bind.target.ID,
|
||||
};
|
||||
} else {
|
||||
target = {type: 'group', id: bind.target.groupID};
|
||||
}
|
||||
|
||||
const target = utils.isEndpoint(bind.target) ?
|
||||
{type: 'endpoint', ieee_address: bind.target.getDevice().ieeeAddr, endpoint: bind.target.ID} :
|
||||
{type: 'group', id: bind.target.groupID};
|
||||
data.bindings.push({cluster: bind.cluster.name, target});
|
||||
}
|
||||
|
||||
for (const configuredReporting of endpoint.configuredReportings) {
|
||||
data.configured_reportings.push({
|
||||
cluster: configuredReporting.cluster.name,
|
||||
attribute: configuredReporting.attribute.name,
|
||||
attribute: configuredReporting.attribute.name || configuredReporting.attribute.ID,
|
||||
minimum_report_interval: configuredReporting.minimumReportInterval,
|
||||
maximum_report_interval: configuredReporting.maximumReportInterval,
|
||||
reportable_change: configuredReporting.reportableChange,
|
||||
@@ -613,57 +641,57 @@ class Bridge extends Extension {
|
||||
|
||||
return {
|
||||
ieee_address: device.ieeeAddr,
|
||||
type: device.type,
|
||||
network_address: device.networkAddress,
|
||||
supported: !!definition,
|
||||
friendly_name: resolved.name,
|
||||
definition: this.getDefinitionPayload(definition),
|
||||
power_source: device.powerSource,
|
||||
software_build_id: device.softwareBuildID,
|
||||
date_code: device.dateCode,
|
||||
model_id: device.modelID,
|
||||
interviewing: device.interviewing,
|
||||
interview_completed: device.interviewCompleted,
|
||||
type: device.zh.type,
|
||||
network_address: device.zh.networkAddress,
|
||||
supported: !!device.definition,
|
||||
friendly_name: device.name,
|
||||
definition: this.getDefinitionPayload(device),
|
||||
power_source: device.zh.powerSource,
|
||||
software_build_id: device.zh.softwareBuildID,
|
||||
date_code: device.zh.dateCode,
|
||||
model_id: device.zh.modelID,
|
||||
interviewing: device.zh.interviewing,
|
||||
interview_completed: device.zh.interviewCompleted,
|
||||
manufacturer: device.zh.manufacturerName,
|
||||
endpoints,
|
||||
};
|
||||
});
|
||||
|
||||
await this.mqtt.publish(
|
||||
'bridge/devices', stringify(devices), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
await this.mqtt.publish('bridge/devices', stringify(devices),
|
||||
{retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
}
|
||||
|
||||
async publishGroups() {
|
||||
const groups = this.zigbee.getGroups().map((group) => {
|
||||
const resolved = this.zigbee.resolveEntity(group);
|
||||
async publishGroups(): Promise<void> {
|
||||
const groups = this.zigbee.groups().map((g) => {
|
||||
return {
|
||||
id: group.groupID,
|
||||
friendly_name: group.groupID === 901 ? 'default_bind_group' : resolved.name,
|
||||
members: group.members.map((m) => {
|
||||
return {
|
||||
ieee_address: m.deviceIeeeAddress,
|
||||
endpoint: m.ID,
|
||||
};
|
||||
id: g.ID,
|
||||
friendly_name: g.ID === 901 ? 'default_bind_group' : g.name,
|
||||
scenes: this.getScenes(g.zh),
|
||||
members: g.zh.members.map((e) => {
|
||||
return {ieee_address: e.getDevice().ieeeAddr, endpoint: e.ID};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
await this.mqtt.publish(
|
||||
'bridge/groups', stringify(groups), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
}
|
||||
|
||||
getDefinitionPayload(definition) {
|
||||
if (definition) {
|
||||
return {
|
||||
model: definition.model,
|
||||
vendor: definition.vendor,
|
||||
description: definition.description,
|
||||
exposes: definition.exposes,
|
||||
supports_ota: !!definition.ota,
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
getDefinitionPayload(device: Device): DefinitionPayload {
|
||||
if (!device.definition) return null;
|
||||
let icon = device.settings.icon ? device.settings.icon : device.definition.icon;
|
||||
if (icon) {
|
||||
icon = icon.replace('${zigbeeModel}', utils.sanitizeImageParameter(device.zh.modelID));
|
||||
icon = icon.replace('${model}', utils.sanitizeImageParameter(device.definition.model));
|
||||
}
|
||||
|
||||
return {
|
||||
model: device.definition.model,
|
||||
vendor: device.definition.vendor,
|
||||
description: device.definition.description,
|
||||
exposes: device.definition.exposes,
|
||||
supports_ota: !!device.definition.ota,
|
||||
options: device.definition.options,
|
||||
icon,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Bridge;
|
||||
@@ -1,151 +0,0 @@
|
||||
const settings = require('../util/settings');
|
||||
const utils = require('../util/utils');
|
||||
const logger = require('../util/logger');
|
||||
const Extension = require('./extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
/**
|
||||
* This extension calls the zigbee-herdsman-converters definition configure() method
|
||||
*/
|
||||
class Configure extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
|
||||
this.configuring = new Set();
|
||||
this.onReportingDisabled = this.onReportingDisabled.bind(this);
|
||||
this.attempts = {};
|
||||
|
||||
this.topic = `${settings.get().mqtt.base_topic}/bridge/request/device/configure`;
|
||||
this.legacyTopic = `${settings.get().mqtt.base_topic}/bridge/configure`;
|
||||
this.eventBus.on(`reportingDisabled`, this.onReportingDisabled);
|
||||
}
|
||||
|
||||
onReportingDisabled(data) {
|
||||
// Disabling reporting unbinds some cluster which could be bound by configure, re-setup.
|
||||
const device = data.device;
|
||||
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
if (resolvedEntity.device.meta && resolvedEntity.device.meta.hasOwnProperty('configured')) {
|
||||
delete device.meta.configured;
|
||||
device.save();
|
||||
}
|
||||
|
||||
if (this.shouldConfigure(resolvedEntity)) {
|
||||
this.configure(resolvedEntity);
|
||||
}
|
||||
}
|
||||
|
||||
shouldConfigure(resolvedEntity) {
|
||||
if (!resolvedEntity || !resolvedEntity.definition || !resolvedEntity.definition.configure) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (resolvedEntity.device.meta &&
|
||||
resolvedEntity.device.meta.hasOwnProperty('configured') &&
|
||||
resolvedEntity.device.meta.configured === resolvedEntity.definition.meta.configureKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (resolvedEntity.device.interviewing === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
if (topic === this.legacyTopic) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(message);
|
||||
if (!resolvedEntity || resolvedEntity.type !== 'device') {
|
||||
logger.error(`Device '${message}' does not exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resolvedEntity.definition || !resolvedEntity.definition.configure) {
|
||||
logger.warn(`Skipping configure of '${resolvedEntity.name}', device does not require this.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.configure(resolvedEntity, true);
|
||||
} else if (topic === this.topic) {
|
||||
message = utils.parseJSON(message, message);
|
||||
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message;
|
||||
let error = null;
|
||||
|
||||
const resolvedEntity = this.zigbee.resolveEntity(ID);
|
||||
if (!resolvedEntity || resolvedEntity.type !== 'device') {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!resolvedEntity.definition || !resolvedEntity.definition.configure) {
|
||||
error = `Device '${resolvedEntity.name}' cannot be configured`;
|
||||
} else {
|
||||
try {
|
||||
await this.configure(resolvedEntity, true, true);
|
||||
} catch (e) {
|
||||
error = `Failed to configure (${e.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = utils.getResponse(message, {id: ID}, error);
|
||||
await this.mqtt.publish(`bridge/response/device/configure`, stringify(response));
|
||||
}
|
||||
}
|
||||
|
||||
async onZigbeeStarted() {
|
||||
this.coordinatorEndpoint = this.zigbee.getDevicesByType('Coordinator')[0].getEndpoint(1);
|
||||
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
if (this.shouldConfigure(resolvedEntity)) {
|
||||
await this.configure(resolvedEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onZigbeeEvent(type, data, resolvedEntity) {
|
||||
const device = data.device;
|
||||
|
||||
if (type === 'deviceJoined' && device.meta.hasOwnProperty('configured')) {
|
||||
delete device.meta.configured;
|
||||
device.save();
|
||||
}
|
||||
|
||||
if (this.shouldConfigure(resolvedEntity)) {
|
||||
this.configure(resolvedEntity);
|
||||
}
|
||||
}
|
||||
|
||||
async configure(resolvedEntity, force=false, thowError=false) {
|
||||
const device = resolvedEntity.device;
|
||||
if (this.configuring.has(device.ieeeAddr) || (this.attempts[device.ieeeAddr] >= 3 && !force)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.configuring.add(device.ieeeAddr);
|
||||
|
||||
if (!this.attempts.hasOwnProperty(device.ieeeAddr)) {
|
||||
this.attempts[device.ieeeAddr] = 0;
|
||||
}
|
||||
|
||||
logger.info(`Configuring '${resolvedEntity.name}'`);
|
||||
try {
|
||||
await resolvedEntity.definition.configure(device, this.coordinatorEndpoint, logger);
|
||||
logger.info(`Successfully configured '${resolvedEntity.name}'`);
|
||||
device.meta.configured = resolvedEntity.definition.meta.configureKey;
|
||||
device.save();
|
||||
this.eventBus.emit(`devicesChanged`);
|
||||
} catch (error) {
|
||||
this.attempts[device.ieeeAddr]++;
|
||||
const attempt = this.attempts[device.ieeeAddr];
|
||||
const msg = `Failed to configure '${resolvedEntity.name}', attempt ${attempt} (${error.stack})`;
|
||||
logger.error(msg);
|
||||
|
||||
if (thowError) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
this.configuring.delete(device.ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Configure;
|
||||
@@ -0,0 +1,133 @@
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import logger from '../util/logger';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import zhc from 'zigbee-herdsman-converters';
|
||||
import Extension from './extension';
|
||||
import bind from 'bind-decorator';
|
||||
import Device from '../model/device';
|
||||
|
||||
/**
|
||||
* This extension calls the zigbee-herdsman-converters definition configure() method
|
||||
*/
|
||||
export default class Configure extends Extension {
|
||||
private configuring = new Set();
|
||||
private attempts: {[s: string]: number} = {};
|
||||
private topic = `${settings.get().mqtt.base_topic}/bridge/request/device/configure`;
|
||||
private legacyTopic = `${settings.get().mqtt.base_topic}/bridge/configure`;
|
||||
|
||||
@bind private async onReportingDisabled(data: eventdata.ReportingDisabled): Promise<void> {
|
||||
// Disabling reporting unbinds some cluster which could be bound by configure, re-setup.
|
||||
if (data.device.zh.meta?.hasOwnProperty('configured')) {
|
||||
delete data.device.zh.meta.configured;
|
||||
data.device.zh.save();
|
||||
}
|
||||
|
||||
await this.configure(data.device, 'reporting_disabled');
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
if (data.topic === this.legacyTopic) {
|
||||
const device = this.zigbee.resolveEntity(data.message);
|
||||
if (!device || !(device instanceof Device)) {
|
||||
logger.error(`Device '${data.message}' does not exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!device.definition || !device.definition.configure) {
|
||||
logger.warn(`Skipping configure of '${device.name}', device does not require this.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.configure(device, 'mqtt_message', true);
|
||||
} else if (data.topic === this.topic) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message;
|
||||
let error = null;
|
||||
|
||||
const device = this.zigbee.resolveEntity(ID);
|
||||
if (!device || !(device instanceof Device)) {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!device.definition || !device.definition.configure) {
|
||||
error = `Device '${device.name}' cannot be configured`;
|
||||
} else {
|
||||
try {
|
||||
await this.configure(device, 'mqtt_message', true, true);
|
||||
} catch (e) {
|
||||
error = `Failed to configure (${e.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = utils.getResponse(message, {id: ID}, error);
|
||||
await this.mqtt.publish(`bridge/response/device/configure`, stringify(response));
|
||||
}
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
await this.configure(device, 'started');
|
||||
}
|
||||
|
||||
this.eventBus.onDeviceJoined(this, (data) => {
|
||||
if (data.device.zh.meta.hasOwnProperty('configured')) {
|
||||
delete data.device.zh.meta.configured;
|
||||
data.device.zh.save();
|
||||
}
|
||||
|
||||
this.configure(data.device, 'zigbee_event');
|
||||
});
|
||||
this.eventBus.onDeviceInterview(this, (data) => this.configure(data.device, 'zigbee_event'));
|
||||
this.eventBus.onLastSeenChanged(this, (data) => this.configure(data.device, 'zigbee_event'));
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.eventBus.onReportingDisabled(this, this.onReportingDisabled);
|
||||
}
|
||||
|
||||
private async configure(device: Device, event: 'started' | 'zigbee_event' | 'reporting_disabled' | 'mqtt_message',
|
||||
force=false, thowError=false): Promise<void> {
|
||||
if (!force) {
|
||||
if (!device.definition?.configure || device.zh.interviewing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.zh.meta?.hasOwnProperty('configured') &&
|
||||
device.zh.meta.configured === zhc.getConfigureKey(device.definition)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only configure end devices when it is active, otherwise it will likely fails as they are sleeping.
|
||||
if (device.zh.type === 'EndDevice' && event !== 'zigbee_event') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.configuring.has(device.ieeeAddr) || (this.attempts[device.ieeeAddr] >= 3 && !force)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.configuring.add(device.ieeeAddr);
|
||||
|
||||
if (!this.attempts.hasOwnProperty(device.ieeeAddr)) {
|
||||
this.attempts[device.ieeeAddr] = 0;
|
||||
}
|
||||
|
||||
logger.info(`Configuring '${device.name}'`);
|
||||
try {
|
||||
await device.definition.configure(device.zh, this.zigbee.firstCoordinatorEndpoint(), logger);
|
||||
logger.info(`Successfully configured '${device.name}'`);
|
||||
device.zh.meta.configured = zhc.getConfigureKey(device.definition);
|
||||
device.zh.save();
|
||||
this.eventBus.emitDevicesChanged();
|
||||
} catch (error) {
|
||||
this.attempts[device.ieeeAddr]++;
|
||||
const attempt = this.attempts[device.ieeeAddr];
|
||||
const msg = `Failed to configure '${device.name}', attempt ${attempt} (${error.stack})`;
|
||||
logger.error(msg);
|
||||
|
||||
if (thowError) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
this.configuring.delete(device.ieeeAddr);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
class Extension {
|
||||
/**
|
||||
* Besides intializing variables, the constructor should do nothing!
|
||||
*
|
||||
* @param {Zigbee} zigbee Zigbee controller
|
||||
* @param {MQTT} mqtt MQTT controller
|
||||
* @param {State} state State controller
|
||||
* @param {Function} publishEntityState Method to publish device state to MQTT.
|
||||
* @param {EventBus} eventBus The event bus
|
||||
*/
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.state = state;
|
||||
this.publishEntityState = publishEntityState;
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called by the controller once Zigbee has been started.
|
||||
*/
|
||||
// onZigbeeStarted() {}
|
||||
|
||||
/**
|
||||
* This method is called by the controller once connected to the MQTT server.
|
||||
*/
|
||||
// onMQTTConnected() {}
|
||||
|
||||
/**
|
||||
* Is called when a Zigbee message from a device is received.
|
||||
* @param {string} type Type of the message
|
||||
* @param {Object} data Data of the message
|
||||
* @param {Object?} resolvedEntity Resolved entity returned from this.zigbee.resolveEntity()
|
||||
* @param {Object?} settingsDevice Device settings
|
||||
*/
|
||||
// onZigbeeEvent(type, data, resolvedEntity) {}
|
||||
|
||||
/**
|
||||
* Is called when a MQTT message is received
|
||||
* @param {string} topic Topic on which the message was received
|
||||
* @param {Object} message The received message
|
||||
* @return {boolean} if the message was handled
|
||||
*/
|
||||
// onMQTTMessage(topic, message) {}
|
||||
|
||||
/**
|
||||
* Is called once the extension has to stop
|
||||
*/
|
||||
stop() {
|
||||
this.eventBus.removeListenersExtension(this.constructor.name);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Extension;
|
||||
@@ -0,0 +1,53 @@
|
||||
abstract class Extension {
|
||||
protected zigbee: Zigbee;
|
||||
protected mqtt: MQTT;
|
||||
protected state: State;
|
||||
protected publishEntityState: PublishEntityState;
|
||||
protected eventBus: EventBus;
|
||||
protected enableDisableExtension: (enable: boolean, name: string) => Promise<void>;
|
||||
protected restartCallback: () => void;
|
||||
protected addExtension: (extension: Extension) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Besides intializing variables, the constructor should do nothing!
|
||||
*
|
||||
* @param {Zigbee} zigbee Zigbee controller
|
||||
* @param {MQTT} mqtt MQTT controller
|
||||
* @param {State} state State controller
|
||||
* @param {Function} publishEntityState Method to publish device state to MQTT.
|
||||
* @param {EventBus} eventBus The event bus
|
||||
* @param {enableDisableExtension} enableDisableExtension Enable/disable extension method
|
||||
* @param {restartCallback} restartCallback Restart Zigbee2MQTT
|
||||
* @param {addExtension} addExtension Add an extension
|
||||
*/
|
||||
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => void, addExtension: (extension: Extension) => Promise<void>) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.state = state;
|
||||
this.publishEntityState = publishEntityState;
|
||||
this.eventBus = eventBus;
|
||||
this.enableDisableExtension = enableDisableExtension;
|
||||
this.restartCallback = restartCallback;
|
||||
this.addExtension = addExtension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is called once the extension has to start
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
async start(): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Is called once the extension has to stop
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
this.eventBus.removeListeners(this);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public adjustMessageBeforePublish(entity: Group | Device, message: KeyValue): void {}
|
||||
}
|
||||
|
||||
export default Extension;
|
||||
@@ -1,18 +0,0 @@
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const settings = require('../util/settings');
|
||||
const Extension = require('./extension');
|
||||
const utils = require('../util/utils');
|
||||
|
||||
class ExternalConverters extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
|
||||
for (const definition of utils.getExternalConvertersDefinitions(settings)) {
|
||||
const toAdd = {...definition};
|
||||
delete toAdd['homeassistant'];
|
||||
zigbeeHerdsmanConverters.addDeviceDefinition(toAdd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ExternalConverters;
|
||||
@@ -0,0 +1,18 @@
|
||||
import zhc from 'zigbee-herdsman-converters';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
|
||||
export default class ExternalConverters extends Extension {
|
||||
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => void, addExtension: (extension: Extension) => Promise<void>) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
|
||||
|
||||
for (const definition of utils.getExternalConvertersDefinitions(settings.get())) {
|
||||
const toAdd = {...definition};
|
||||
delete toAdd['homeassistant'];
|
||||
zhc.addDeviceDefinition(toAdd);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,30 @@
|
||||
const settings = require('../util/settings');
|
||||
const Extension = require('./extension');
|
||||
const utils = require('../util/utils');
|
||||
const fs = require('fs');
|
||||
const data = require('./../util/data');
|
||||
const path = require('path');
|
||||
const logger = require('./../util/logger');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import fs from 'fs';
|
||||
import data from './../util/data';
|
||||
import path from 'path';
|
||||
import logger from './../util/logger';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import bind from 'bind-decorator';
|
||||
import Extension from './extension';
|
||||
|
||||
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/extension/(save|remove)`);
|
||||
|
||||
class ExternalExtension extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus, addExtension, enableDisableExtension) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.args = [zigbee, mqtt, state, publishEntityState, eventBus];
|
||||
this.addExtension = addExtension;
|
||||
this.enableDisableExtension = enableDisableExtension;
|
||||
this.extensionsBaseDir = 'extension';
|
||||
this.loadExtension = this.loadExtension.bind(this);
|
||||
this.requestLookup = {
|
||||
'save': this.saveExtension.bind(this),
|
||||
'remove': this.removeExtension.bind(this),
|
||||
};
|
||||
export default class ExternalExtension extends Extension {
|
||||
private requestLookup: {[s: string]: (message: KeyValue) => Promise<MQTTResponse>};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.requestLookup = {'save': this.saveExtension, 'remove': this.removeExtension};
|
||||
this.loadUserDefinedExtensions();
|
||||
}
|
||||
getExtensionsBasePath() {
|
||||
return data.joinPath(this.extensionsBaseDir);
|
||||
await this.publishExtensions();
|
||||
}
|
||||
|
||||
getListOfUserDefinedExtensions() {
|
||||
private getExtensionsBasePath(): string {
|
||||
return data.joinPath('extension');
|
||||
}
|
||||
|
||||
private getListOfUserDefinedExtensions(): {name: string, code: string}[] {
|
||||
const basePath = this.getExtensionsBasePath();
|
||||
if (fs.existsSync(basePath)) {
|
||||
return fs.readdirSync(basePath).filter((f) => f.endsWith('.js')).map((fileName) => {
|
||||
@@ -37,16 +35,17 @@ class ExternalExtension extends Extension {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
removeExtension(message) {
|
||||
|
||||
@bind private async removeExtension(message: KeyValue): Promise<MQTTResponse> {
|
||||
const {name} = message;
|
||||
const extensions = this.getListOfUserDefinedExtensions();
|
||||
const extensionToBeRemoved = extensions.find((e) => e.name === name);
|
||||
|
||||
if (extensionToBeRemoved) {
|
||||
this.enableDisableExtension(false, extensionToBeRemoved.name);
|
||||
await this.enableDisableExtension(false, extensionToBeRemoved.name);
|
||||
const basePath = this.getExtensionsBasePath();
|
||||
const extensonFilePath = path.join(basePath, path.basename(name));
|
||||
fs.unlinkSync(extensonFilePath);
|
||||
const extensionFilePath = path.join(basePath, path.basename(name));
|
||||
fs.unlinkSync(extensionFilePath);
|
||||
this.publishExtensions();
|
||||
logger.info(`Extension ${name} removed`);
|
||||
return utils.getResponse(message, {}, null);
|
||||
@@ -54,10 +53,11 @@ class ExternalExtension extends Extension {
|
||||
return utils.getResponse(message, {}, `Extension ${name} doesn't exists`);
|
||||
}
|
||||
}
|
||||
saveExtension(message) {
|
||||
|
||||
@bind private async saveExtension(message: KeyValue): Promise<MQTTResponse> {
|
||||
const {name, code} = message;
|
||||
const ModuleConstructor = utils.loadModuleFromText(code);
|
||||
this.loadExtension(ModuleConstructor);
|
||||
const ModuleConstructor = utils.loadModuleFromText(code) as typeof Extension;
|
||||
await this.loadExtension(ModuleConstructor);
|
||||
const basePath = this.getExtensionsBasePath();
|
||||
/* istanbul ignore else */
|
||||
if (!fs.existsSync(basePath)) {
|
||||
@@ -70,38 +70,36 @@ class ExternalExtension extends Extension {
|
||||
return utils.getResponse(message, {}, null);
|
||||
}
|
||||
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
const match = topic.match(requestRegex);
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(requestRegex);
|
||||
if (match && this.requestLookup[match[1].toLowerCase()]) {
|
||||
message = utils.parseJSON(message, message);
|
||||
const message = utils.parseJSON(data.message, data.message) as KeyValue;
|
||||
try {
|
||||
const response = await this.requestLookup[match[1].toLowerCase()](message);
|
||||
await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response));
|
||||
} catch (error) {
|
||||
logger.error(`Request '${topic}' failed with error: '${error.message}'`);
|
||||
logger.error(`Request '${data.topic}' failed with error: '${error.message}'`);
|
||||
const response = utils.getResponse(message, {}, error.message);
|
||||
await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadExtension(ConstructorClass) {
|
||||
this.enableDisableExtension(false, ConstructorClass.name);
|
||||
this.addExtension(new ConstructorClass(...this.args, settings, logger));
|
||||
@bind private async loadExtension(ConstructorClass: typeof Extension): Promise<void> {
|
||||
await this.enableDisableExtension(false, ConstructorClass.name);
|
||||
// @ts-ignore
|
||||
await this.addExtension(new ConstructorClass(this.zigbee, this.mqtt, this.state, this.publishEntityState,
|
||||
this.eventBus, settings, logger));
|
||||
}
|
||||
|
||||
loadUserDefinedExtensions() {
|
||||
private loadUserDefinedExtensions(): void {
|
||||
const extensions = this.getListOfUserDefinedExtensions();
|
||||
extensions
|
||||
.map(({code}) => utils.loadModuleFromText(code))
|
||||
.map(this.loadExtension);
|
||||
}
|
||||
async onMQTTConnected() {
|
||||
this.publishExtensions();
|
||||
}
|
||||
|
||||
async publishExtensions() {
|
||||
private async publishExtensions(): Promise<void> {
|
||||
const extensions = this.getListOfUserDefinedExtensions();
|
||||
await this.mqtt.publish('bridge/extensions', stringify(extensions), {
|
||||
retain: true,
|
||||
@@ -109,5 +107,3 @@ class ExternalExtension extends Extension {
|
||||
}, settings.get().mqtt.base_topic, true);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ExternalExtension;
|
||||
@@ -1,128 +0,0 @@
|
||||
const http = require('http');
|
||||
const serveStatic = require('serve-static');
|
||||
const finalhandler = require('finalhandler');
|
||||
const Extension = require('./extension');
|
||||
const logger = require('../util/logger');
|
||||
const frontend = require('zigbee2mqtt-frontend');
|
||||
const WebSocket = require('ws');
|
||||
const url = require('url');
|
||||
const settings = require('../util/settings');
|
||||
const utils = require('../util/utils');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
/**
|
||||
* This extension servers the frontend
|
||||
*/
|
||||
class Frontend extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.onRequest = this.onRequest.bind(this);
|
||||
this.onUpgrade = this.onUpgrade.bind(this);
|
||||
this.mqttBaseTopic = settings.get().mqtt.base_topic;
|
||||
this.onMQTTPublishedMessage = this.onMQTTPublishedMessage.bind(this);
|
||||
this.mqtt.on('publishedMessage', this.onMQTTPublishedMessage);
|
||||
this.onWebSocketConnection = this.onWebSocketConnection.bind(this);
|
||||
this.server = http.createServer(this.onRequest);
|
||||
this.server.on('upgrade', this.onUpgrade);
|
||||
this.host = settings.get().frontend.host || '0.0.0.0';
|
||||
this.port = settings.get().frontend.port || 8080;
|
||||
this.authToken = settings.get().frontend.auth_token || false;
|
||||
this.retainedMessages = new Map();
|
||||
/* istanbul ignore next */
|
||||
const options = {setHeaders: (res, path) => {
|
||||
if (path.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
}};
|
||||
this.fileServer = serveStatic(frontend.getPath(), options);
|
||||
this.wss = new WebSocket.Server({noServer: true});
|
||||
this.wss.on('connection', this.onWebSocketConnection);
|
||||
}
|
||||
|
||||
onZigbeeStarted() {
|
||||
this.server.listen(this.port, this.host);
|
||||
logger.info(`Started frontend on port ${this.host}:${this.port}`);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
for (const client of this.wss.clients) {
|
||||
client.send(stringify({topic: 'bridge/state', payload: 'offline'}));
|
||||
client.close();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.server.close(resolve);
|
||||
});
|
||||
}
|
||||
onRequest(request, response) {
|
||||
this.fileServer(request, response, finalhandler(request, response));
|
||||
}
|
||||
authenticate(request, cb) {
|
||||
const {query} = url.parse(request.url, true);
|
||||
cb(!this.authToken || this.authToken === query.token);
|
||||
}
|
||||
|
||||
onUpgrade(request, socket, head) {
|
||||
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
this.authenticate(request, (isAuthentificated) => {
|
||||
if (isAuthentificated) {
|
||||
this.wss.emit('connection', ws, request);
|
||||
} else {
|
||||
ws.close(4401, 'Unauthorized');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onWebSocketConnection(ws) {
|
||||
ws.on('message', (message) => {
|
||||
if (message) {
|
||||
const {topic, payload} = utils.parseJSON(message, message);
|
||||
this.mqtt.onMessage(`${this.mqttBaseTopic}/${topic}`, stringify(payload));
|
||||
}
|
||||
});
|
||||
|
||||
for (const [key, value] of this.retainedMessages) {
|
||||
ws.send(stringify({topic: key, payload: value}));
|
||||
}
|
||||
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
let payload = {};
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
if (this.state.exists(device.ieeeAddr)) {
|
||||
payload = {...payload, ...this.state.get(device.ieeeAddr)};
|
||||
}
|
||||
|
||||
const lastSeen = settings.get().advanced.last_seen;
|
||||
if (lastSeen !== 'disable') {
|
||||
payload.last_seen = utils.formatDate(resolvedEntity.device.lastSeen, lastSeen);
|
||||
}
|
||||
|
||||
if (resolvedEntity.device.linkquality !== undefined) {
|
||||
payload.linkquality = resolvedEntity.device.linkquality;
|
||||
}
|
||||
|
||||
ws.send(stringify({topic: resolvedEntity.name, payload}));
|
||||
}
|
||||
}
|
||||
|
||||
onMQTTPublishedMessage(data) {
|
||||
let {topic, payload, options} = data;
|
||||
if (topic.startsWith(`${this.mqttBaseTopic}/`)) {
|
||||
// Send topic without base_topic
|
||||
topic = topic.substring(this.mqttBaseTopic.length + 1);
|
||||
payload = utils.parseJSON(payload, payload);
|
||||
if (options.retain) {
|
||||
this.retainedMessages.set(topic, payload);
|
||||
}
|
||||
|
||||
for (const client of this.wss.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(stringify({topic, payload}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Frontend;
|
||||
@@ -0,0 +1,141 @@
|
||||
import http from 'http';
|
||||
import gzipStatic, {RequestHandler} from 'connect-gzip-static';
|
||||
import finalhandler from 'finalhandler';
|
||||
import logger from '../util/logger';
|
||||
import frontend from 'zigbee2mqtt-frontend';
|
||||
import WebSocket from 'ws';
|
||||
import net from 'net';
|
||||
import url from 'url';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import Extension from './extension';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
/**
|
||||
* This extension servers the frontend
|
||||
*/
|
||||
export default class Frontend extends Extension {
|
||||
private mqttBaseTopic = settings.get().mqtt.base_topic;
|
||||
private host = settings.get().frontend.host || '0.0.0.0';
|
||||
private port = settings.get().frontend.port || 8080;
|
||||
private authToken = settings.get().frontend.auth_token || false;
|
||||
private retainedMessages = new Map();
|
||||
private server: http.Server;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private fileServer: RequestHandler;
|
||||
private wss: WebSocket.Server = null;
|
||||
|
||||
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => void, addExtension: (extension: Extension) => Promise<void>) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
|
||||
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.server = http.createServer(this.onRequest);
|
||||
this.server.on('upgrade', this.onUpgrade);
|
||||
|
||||
/* istanbul ignore next */
|
||||
const options = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setHeaders: (res: any, path: string): void => {
|
||||
if (path.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
},
|
||||
};
|
||||
this.fileServer = gzipStatic(frontend.getPath(), options);
|
||||
this.wss = new WebSocket.Server({noServer: true});
|
||||
this.wss.on('connection', this.onWebSocketConnection);
|
||||
|
||||
this.server.listen(this.port, this.host);
|
||||
logger.info(`Started frontend on port ${this.host}:${this.port}`);
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
super.stop();
|
||||
for (const client of this.wss.clients) {
|
||||
client.send(stringify({topic: 'bridge/state', payload: 'offline'}));
|
||||
client.terminate();
|
||||
}
|
||||
this.wss.close();
|
||||
return new Promise((cb: () => void) => this.server.close(cb));
|
||||
}
|
||||
|
||||
@bind private onRequest(request: http.IncomingMessage, response: http.ServerResponse): void {
|
||||
// @ts-ignore
|
||||
this.fileServer(request, response, finalhandler(request, response));
|
||||
}
|
||||
|
||||
private authenticate(request: http.IncomingMessage, cb: (authenticate: boolean) => void): void {
|
||||
const {query} = url.parse(request.url, true);
|
||||
cb(!this.authToken || this.authToken === query.token);
|
||||
}
|
||||
|
||||
@bind private onUpgrade(request: http.IncomingMessage, socket: net.Socket, head: Buffer): void {
|
||||
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
this.authenticate(request, (isAuthentificated) => {
|
||||
if (isAuthentificated) {
|
||||
this.wss.emit('connection', ws, request);
|
||||
} else {
|
||||
ws.close(4401, 'Unauthorized');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@bind private onWebSocketConnection(ws: WebSocket): void {
|
||||
ws.on('message', (data: Buffer, isBinary: boolean) => {
|
||||
if (!isBinary && data) {
|
||||
const message = data.toString();
|
||||
const {topic, payload} = JSON.parse(message);
|
||||
this.mqtt.onMessage(`${this.mqttBaseTopic}/${topic}`, stringify(payload));
|
||||
}
|
||||
});
|
||||
|
||||
for (const [key, value] of this.retainedMessages) {
|
||||
ws.send(stringify({topic: key, payload: value}));
|
||||
}
|
||||
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
let payload: KeyValue = {};
|
||||
if (this.state.exists(device)) {
|
||||
payload = {...payload, ...this.state.get(device)};
|
||||
}
|
||||
|
||||
const lastSeen = settings.get().advanced.last_seen;
|
||||
/* istanbul ignore if */
|
||||
if (lastSeen !== 'disable') {
|
||||
payload.last_seen = utils.formatDate(device.zh.lastSeen, lastSeen);
|
||||
}
|
||||
|
||||
if (device.zh.linkquality !== undefined) {
|
||||
payload.linkquality = device.zh.linkquality;
|
||||
}
|
||||
|
||||
ws.send(stringify({topic: device.name, payload}));
|
||||
}
|
||||
}
|
||||
|
||||
@bind private onMQTTPublishMessage(data: eventdata.MQTTMessagePublished): void {
|
||||
if (data.topic.startsWith(`${this.mqttBaseTopic}/`)) {
|
||||
// Send topic without base_topic
|
||||
const topic = data.topic.substring(this.mqttBaseTopic.length + 1);
|
||||
const payload = utils.parseJSON(data.payload, data.payload);
|
||||
if (data.options.retain) {
|
||||
this.retainedMessages.set(topic, payload);
|
||||
}
|
||||
|
||||
if (this.wss) {
|
||||
for (const client of this.wss.clients) {
|
||||
/* istanbul ignore else */
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(stringify({topic, payload}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const Extension = require('./extension');
|
||||
const utils = require('../util/utils');
|
||||
const postfixes = utils.getEndpointNames();
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const equals = require('fast-deep-equal/es6');
|
||||
|
||||
const topicRegex =
|
||||
new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
|
||||
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/(.+)/(remove|add|remove_all)$`);
|
||||
const legacyTopicRegexRemoveAll = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/remove_all$`);
|
||||
|
||||
class Groups extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.onStateChange = this.onStateChange.bind(this);
|
||||
this.legacyApi = settings.get().advanced.legacy_api;
|
||||
this.lastOptimisticState = {};
|
||||
}
|
||||
|
||||
async onZigbeeStarted() {
|
||||
this.eventBus.on('stateChange', this.onStateChange);
|
||||
await this.syncGroupsWithSettings();
|
||||
}
|
||||
|
||||
async syncGroupsWithSettings() {
|
||||
const settingsGroups = settings.getGroups();
|
||||
const zigbeeGroups = this.zigbee.getGroups();
|
||||
|
||||
const addRemoveFromGroup = async (action, deviceName, groupName, endpoint, group) => {
|
||||
try {
|
||||
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} '${deviceName}' to group '${groupName}'`);
|
||||
if (action === 'remove') {
|
||||
await endpoint.removeFromGroup(group);
|
||||
} else {
|
||||
await endpoint.addToGroup(group);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${action} '${deviceName}' from '${groupName}'`);
|
||||
}
|
||||
};
|
||||
|
||||
for (const settingGroup of settingsGroups) {
|
||||
const groupID = settingGroup.ID;
|
||||
const zigbeeGroup = zigbeeGroups.find((g) => g.groupID === groupID) || this.zigbee.createGroup(groupID);
|
||||
const settingsEntity = settingGroup.devices.map((d) => {
|
||||
const entity = this.zigbee.resolveEntity(d);
|
||||
if (!entity) logger.error(`Cannot find '${d}' of group '${settingGroup.friendlyName}'`);
|
||||
return entity;
|
||||
}).filter((e) => e != null);
|
||||
|
||||
// In settings but not in zigbee
|
||||
for (const entity of settingsEntity) {
|
||||
if (!zigbeeGroup.hasMember(entity.endpoint)) {
|
||||
addRemoveFromGroup('add', entity.name, settingGroup.friendlyName, entity.endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
|
||||
// In zigbee but not in settings
|
||||
for (const endpoint of zigbeeGroup.members) {
|
||||
if (!settingsEntity.find((e) => e.endpoint === endpoint)) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendlyName;
|
||||
addRemoveFromGroup('remove', deviceName, settingGroup.friendlyName, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const zigbeeGroup of zigbeeGroups) {
|
||||
if (!settingsGroups.find((g) => g.ID === zigbeeGroup.groupID)) {
|
||||
for (const endpoint of zigbeeGroup.members) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendlyName;
|
||||
addRemoveFromGroup('remove', deviceName, zigbeeGroup.groupID, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onStateChange(data) {
|
||||
const reason = 'group_optimistic';
|
||||
if (data.reason === reason) {
|
||||
return;
|
||||
}
|
||||
|
||||
const properties = ['state', 'brightness', 'color_temp', 'color'];
|
||||
const payload = {};
|
||||
|
||||
properties.forEach((prop) => {
|
||||
if (data.to.hasOwnProperty(prop)) {
|
||||
payload[prop] = data.to[prop];
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(payload).length) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(data.ID);
|
||||
const zigbeeGroups = this.zigbee.getGroups().filter((zigbeeGroup) => {
|
||||
const settingsGroup = settings.getGroup(zigbeeGroup.groupID);
|
||||
return settingsGroup && settingsGroup.optimistic;
|
||||
});
|
||||
|
||||
if (resolvedEntity.type === 'device') {
|
||||
for (const zigbeeGroup of zigbeeGroups) {
|
||||
if (zigbeeGroup.hasMember(resolvedEntity.endpoint) &&
|
||||
!equals(this.lastOptimisticState[zigbeeGroup.groupID], payload)) {
|
||||
if (!payload || payload.state !== 'OFF' || this.areAllMembersOff(zigbeeGroup)) {
|
||||
this.lastOptimisticState[zigbeeGroup.groupID] = payload;
|
||||
await this.publishEntityState(zigbeeGroup.groupID, payload, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Invalidate the last optimistic group state when group state is changed directly.
|
||||
delete this.lastOptimisticState[resolvedEntity.group.groupID];
|
||||
|
||||
const groupIDsToPublish = new Set();
|
||||
for (const member of resolvedEntity.group.members) {
|
||||
await this.publishEntityState(member.getDevice().ieeeAddr, payload, reason);
|
||||
for (const zigbeeGroup of zigbeeGroups) {
|
||||
if (zigbeeGroup.hasMember(member)) {
|
||||
if (!payload || payload.state !== 'OFF' || this.areAllMembersOff(zigbeeGroup)) {
|
||||
groupIDsToPublish.add(zigbeeGroup.groupID);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
groupIDsToPublish.delete(resolvedEntity.group.groupID);
|
||||
for (const groupID of groupIDsToPublish) {
|
||||
await this.publishEntityState(groupID, payload, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
areAllMembersOff(zigbeeGroup) {
|
||||
for (const member of zigbeeGroup.members) {
|
||||
const device = member.getDevice();
|
||||
if (this.state.exists(device.ieeeAddr)) {
|
||||
const state = this.state.get(device.ieeeAddr);
|
||||
if (state && state.state === 'ON') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
parseMQTTMessage(topic, message) {
|
||||
let type = null;
|
||||
let resolvedEntityGroup = null;
|
||||
let resolvedEntityDevice = null;
|
||||
let hasEndpointName = null;
|
||||
let error = null;
|
||||
let groupKey = null;
|
||||
let deviceKey = null;
|
||||
let triggeredViaLegacyApi = false;
|
||||
|
||||
/* istanbul ignore else */
|
||||
const topicRegexMatch = topic.match(topicRegex);
|
||||
const legacyTopicRegexRemoveAllMatch = topic.match(legacyTopicRegexRemoveAll);
|
||||
const legacyTopicRegexMatch = topic.match(legacyTopicRegex);
|
||||
|
||||
if (this.legacyApi && (legacyTopicRegexMatch || legacyTopicRegexRemoveAllMatch)) {
|
||||
triggeredViaLegacyApi = true;
|
||||
if (legacyTopicRegexMatch) {
|
||||
resolvedEntityGroup = this.zigbee.resolveEntity(legacyTopicRegexMatch[1]);
|
||||
type = legacyTopicRegexMatch[2];
|
||||
|
||||
if (!resolvedEntityGroup || resolvedEntityGroup.type !== 'group') {
|
||||
logger.error(`Group '${legacyTopicRegexMatch[1]}' does not exist`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {
|
||||
friendly_name: message, group: legacyTopicRegexMatch[1], error: 'group doesn\'t exists',
|
||||
};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_${type}_failed`, message: payload}),
|
||||
);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
} else {
|
||||
type = 'remove_all';
|
||||
}
|
||||
|
||||
resolvedEntityDevice = this.zigbee.resolveEntity(message);
|
||||
if (!resolvedEntityDevice || !resolvedEntityDevice.type === 'device') {
|
||||
logger.error(`Device '${message}' does not exist`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {
|
||||
friendly_name: message, group: legacyTopicRegexMatch[1], error: 'entity doesn\'t exists',
|
||||
};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_${type}_failed`, message: payload}),
|
||||
);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
hasEndpointName = postfixes.find((p) => message.endsWith(`/${p}`));
|
||||
} else if (topicRegexMatch) {
|
||||
type = topicRegexMatch[1];
|
||||
message = JSON.parse(message);
|
||||
deviceKey = message.device;
|
||||
|
||||
if (type !== 'remove_all') {
|
||||
groupKey = message.group;
|
||||
resolvedEntityGroup = this.zigbee.resolveEntity(message.group);
|
||||
if (!resolvedEntityGroup || resolvedEntityGroup.type !== 'group') {
|
||||
error = `Group '${message.group}' does not exist`;
|
||||
}
|
||||
}
|
||||
|
||||
resolvedEntityDevice = this.zigbee.resolveEntity(message.device);
|
||||
if (!error && (!resolvedEntityDevice || !resolvedEntityDevice.type === 'device')) {
|
||||
error = `Device '${message.device}' does not exist`;
|
||||
}
|
||||
|
||||
hasEndpointName = postfixes.find((p) => message.device.endsWith(`/${p}`));
|
||||
}
|
||||
|
||||
return {
|
||||
resolvedEntityGroup, resolvedEntityDevice, type, hasEndpointName, error, groupKey, deviceKey,
|
||||
triggeredViaLegacyApi,
|
||||
};
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
let {
|
||||
resolvedEntityGroup, resolvedEntityDevice, type, hasEndpointName, error, triggeredViaLegacyApi,
|
||||
groupKey, deviceKey,
|
||||
} = this.parseMQTTMessage(topic, message);
|
||||
if (!type) return;
|
||||
|
||||
const responseData = {device: deviceKey};
|
||||
if (groupKey) {
|
||||
responseData.group = groupKey;
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
const keys = [
|
||||
`${resolvedEntityDevice.device.ieeeAddr}/${resolvedEntityDevice.endpoint.ID}`,
|
||||
`${resolvedEntityDevice.name}/${resolvedEntityDevice.endpoint.ID}`,
|
||||
];
|
||||
|
||||
const definition = resolvedEntityDevice.definition;
|
||||
const endpoints = definition && definition.endpoint ?
|
||||
definition.endpoint(resolvedEntityDevice.device) : null;
|
||||
const endpointName = endpoints ?
|
||||
Object.entries(endpoints).find((e) => e[1] === resolvedEntityDevice.endpoint.ID)[0] : null;
|
||||
|
||||
if (endpointName) {
|
||||
keys.push(`${resolvedEntityDevice.device.ieeeAddr}/${endpointName}`);
|
||||
keys.push(`${resolvedEntityDevice.name}/${endpointName}`);
|
||||
}
|
||||
|
||||
if (!hasEndpointName) {
|
||||
keys.push(resolvedEntityDevice.name);
|
||||
keys.push(resolvedEntityDevice.device.ieeeAddr);
|
||||
}
|
||||
|
||||
if (type === 'add') {
|
||||
logger.info(`Adding '${resolvedEntityDevice.name}' to '${resolvedEntityGroup.name}'`);
|
||||
await resolvedEntityDevice.endpoint.addToGroup(resolvedEntityGroup.group);
|
||||
settings.addDeviceToGroup(resolvedEntityGroup.settings.ID, keys);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_add`, message: payload}),
|
||||
);
|
||||
}
|
||||
} else if (type === 'remove') {
|
||||
logger.info(`Removing '${resolvedEntityDevice.name}' from '${resolvedEntityGroup.name}'`);
|
||||
await resolvedEntityDevice.endpoint.removeFromGroup(resolvedEntityGroup.group);
|
||||
settings.removeDeviceFromGroup(resolvedEntityGroup.settings.ID, keys);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_remove`, message: payload}),
|
||||
);
|
||||
}
|
||||
} else { // remove_all
|
||||
logger.info(`Removing '${resolvedEntityDevice.name}' from all groups`);
|
||||
await resolvedEntityDevice.endpoint.removeFromAllGroups();
|
||||
for (const settingsGroup of settings.getGroups()) {
|
||||
settings.removeDeviceFromGroup(settingsGroup.ID, keys);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {friendly_name: resolvedEntityDevice.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_remove_all`, message: payload}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Failed to ${type} from group (${e.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
await this.mqtt.publish(`bridge/response/group/members/${type}`, stringify(response));
|
||||
}
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
} else {
|
||||
this.eventBus.emit('groupMembersChanged',
|
||||
{group: resolvedEntityGroup, action: type, endpoint: resolvedEntityDevice.endpoint});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Groups;
|
||||
@@ -0,0 +1,373 @@
|
||||
import * as settings from '../util/settings';
|
||||
import logger from '../util/logger';
|
||||
import utils from '../util/utils';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import bind from 'bind-decorator';
|
||||
import Extension from './extension';
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
|
||||
const topicRegex =
|
||||
new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
|
||||
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/(.+)/(remove|add|remove_all)$`);
|
||||
const legacyTopicRegexRemoveAll = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/remove_all$`);
|
||||
|
||||
const stateProperties: {[s: string]: (value: string, definition: zhc.Definition) => boolean} = {
|
||||
'state': () => true,
|
||||
'brightness': (value, definition) =>
|
||||
!!definition.exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'brightness')),
|
||||
'color_temp': (value, definition) =>
|
||||
!!definition.exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'color_temp')),
|
||||
'color': (value, definition) =>
|
||||
!!definition.exposes.find((e) => e.type === 'light' &&
|
||||
e.features.find((f) => f.name === 'color_xy' || f.name === 'color_hs')),
|
||||
'color_mode': (value, definition) =>
|
||||
!!definition.exposes.find((e) => e.type === 'light' && (
|
||||
(e.features.find((f) => f.name === `color_${value}`)) ||
|
||||
(value === 'color_temp' && e.features.find((f) => f.name === 'color_temp')) )),
|
||||
};
|
||||
|
||||
interface ParsedMQTTMessage {
|
||||
type: 'remove' | 'add' | 'remove_all', resolvedEntityGroup: Group, resolvedEntityDevice: Device,
|
||||
error: string, groupKey: string, deviceKey: string, triggeredViaLegacyApi: boolean,
|
||||
skipDisableReporting: boolean, resolvedEntityEndpoint: zh.Endpoint,
|
||||
}
|
||||
|
||||
export default class Groups extends Extension {
|
||||
private legacyApi = settings.get().advanced.legacy_api;
|
||||
private lastOptimisticState: {[s: string]: KeyValue} = {};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onStateChange(this, this.onStateChange);
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
await this.syncGroupsWithSettings();
|
||||
}
|
||||
|
||||
private async syncGroupsWithSettings(): Promise<void> {
|
||||
const settingsGroups = settings.getGroups();
|
||||
const zigbeeGroups = this.zigbee.groups();
|
||||
|
||||
const addRemoveFromGroup = async (action: 'add' | 'remove', deviceName: string,
|
||||
groupName: string | number, endpoint: zh.Endpoint, group: Group): Promise<void> => {
|
||||
try {
|
||||
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} '${deviceName}' to group '${groupName}'`);
|
||||
if (action === 'remove') {
|
||||
await endpoint.removeFromGroup(group.zh);
|
||||
} else {
|
||||
await endpoint.addToGroup(group.zh);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${action} '${deviceName}' from '${groupName}'`);
|
||||
logger.debug(error.stack);
|
||||
}
|
||||
};
|
||||
|
||||
for (const settingGroup of settingsGroups) {
|
||||
const groupID = settingGroup.ID;
|
||||
const zigbeeGroup = zigbeeGroups.find((g) => g.ID === groupID) || this.zigbee.createGroup(groupID);
|
||||
const settingsEndpoint = settingGroup.devices.map((d) => {
|
||||
const parsed = utils.parseEntityID(d);
|
||||
const entity = this.zigbee.resolveEntity(parsed.ID) as Device;
|
||||
if (!entity) logger.error(`Cannot find '${d}' of group '${settingGroup.friendly_name}'`);
|
||||
return {'endpoint': entity?.endpoint(parsed.endpoint), 'name': entity?.name};
|
||||
}).filter((e) => e.endpoint != null);
|
||||
|
||||
// In settings but not in zigbee
|
||||
for (const entity of settingsEndpoint) {
|
||||
if (!zigbeeGroup.zh.hasMember(entity.endpoint)) {
|
||||
addRemoveFromGroup('add', entity.name, settingGroup.friendly_name, entity.endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
|
||||
// In zigbee but not in settings
|
||||
for (const endpoint of zigbeeGroup.zh.members) {
|
||||
if (!settingsEndpoint.find((e) => e.endpoint === endpoint)) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name;
|
||||
addRemoveFromGroup('remove', deviceName, settingGroup.friendly_name, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const zigbeeGroup of zigbeeGroups) {
|
||||
if (!settingsGroups.find((g) => g.ID === zigbeeGroup.ID)) {
|
||||
for (const endpoint of zigbeeGroup.zh.members) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name;
|
||||
addRemoveFromGroup('remove', deviceName, zigbeeGroup.ID, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onStateChange(data: eventdata.StateChange): Promise<void> {
|
||||
const reason = 'groupOptimistic';
|
||||
if (data.reason === reason) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: KeyValue = {};
|
||||
|
||||
let endpointName: string = null;
|
||||
for (let [prop, value] of Object.entries(data.update)) {
|
||||
const endpointNameMatch = utils.endpointNames.find((n) => prop.endsWith(`_${n}`));
|
||||
if (endpointNameMatch) {
|
||||
prop = prop.substring(0, prop.length - endpointNameMatch.length - 1);
|
||||
endpointName = endpointNameMatch;
|
||||
}
|
||||
|
||||
if (prop in stateProperties) {
|
||||
payload[prop] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length) {
|
||||
const entity = data.entity;
|
||||
const groups = this.zigbee.groups().filter((g) => {
|
||||
return g.settings && (!g.settings.hasOwnProperty('optimistic') || g.settings.optimistic);
|
||||
});
|
||||
|
||||
if (entity instanceof Device) {
|
||||
for (const group of groups) {
|
||||
if (group.zh.hasMember(entity.endpoint(endpointName)) &&
|
||||
!equals(this.lastOptimisticState[group.ID], payload)) {
|
||||
if (!payload || payload.state !== 'OFF' || this.areAllMembersOff(group)) {
|
||||
this.lastOptimisticState[group.ID] = payload;
|
||||
await this.publishEntityState(group, payload, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Invalidate the last optimistic group state when group state is changed directly.
|
||||
delete this.lastOptimisticState[entity.ID];
|
||||
|
||||
const groupsToPublish: Set<Group> = new Set();
|
||||
for (const member of entity.zh.members) {
|
||||
const device = this.zigbee.resolveEntity(member.getDevice()) as Device;
|
||||
const memberPayload: KeyValue = {};
|
||||
Object.keys(payload).forEach((key) => {
|
||||
if (stateProperties[key](payload[key], device.definition)) {
|
||||
memberPayload[key] = payload[key];
|
||||
}
|
||||
});
|
||||
|
||||
const endpointName = device.endpointName(member);
|
||||
if (endpointName) {
|
||||
Object.keys(memberPayload).forEach((key) => {
|
||||
memberPayload[`${key}_${endpointName}`] = memberPayload[key];
|
||||
delete memberPayload[key];
|
||||
});
|
||||
}
|
||||
|
||||
await this.publishEntityState(device, memberPayload, reason);
|
||||
for (const zigbeeGroup of groups) {
|
||||
if (zigbeeGroup.zh.hasMember(member)) {
|
||||
if (!payload || payload.state !== 'OFF' || this.areAllMembersOff(zigbeeGroup)) {
|
||||
groupsToPublish.add(zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
groupsToPublish.delete(entity);
|
||||
for (const group of groupsToPublish) {
|
||||
await this.publishEntityState(group, payload, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private areAllMembersOff(group: Group): boolean {
|
||||
for (const member of group.zh.members) {
|
||||
const device = this.zigbee.resolveEntity(member.getDevice());
|
||||
if (this.state.exists(device)) {
|
||||
const state = this.state.get(device);
|
||||
if (state && state.state === 'ON') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private parseMQTTMessage(data: eventdata.MQTTMessage): ParsedMQTTMessage {
|
||||
let type: 'remove' | 'add' | 'remove_all' = null;
|
||||
let resolvedEntityGroup: Group = null;
|
||||
let resolvedEntityDevice: Device = null;
|
||||
let resolvedEntityEndpoint: zh.Endpoint = null;
|
||||
let error: string = null;
|
||||
let groupKey: string = null;
|
||||
let deviceKey: string = null;
|
||||
let triggeredViaLegacyApi = false;
|
||||
let skipDisableReporting = false;
|
||||
|
||||
/* istanbul ignore else */
|
||||
const topicRegexMatch = data.topic.match(topicRegex);
|
||||
const legacyTopicRegexRemoveAllMatch = data.topic.match(legacyTopicRegexRemoveAll);
|
||||
const legacyTopicRegexMatch = data.topic.match(legacyTopicRegex);
|
||||
|
||||
if (this.legacyApi && (legacyTopicRegexMatch || legacyTopicRegexRemoveAllMatch)) {
|
||||
triggeredViaLegacyApi = true;
|
||||
if (legacyTopicRegexMatch) {
|
||||
resolvedEntityGroup = this.zigbee.resolveEntity(legacyTopicRegexMatch[1]) as Group;
|
||||
type = legacyTopicRegexMatch[2] as 'remove' | 'remove_all' | 'add';
|
||||
|
||||
if (!resolvedEntityGroup || !(resolvedEntityGroup instanceof Group)) {
|
||||
logger.error(`Group '${legacyTopicRegexMatch[1]}' does not exist`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {friendly_name: data.message,
|
||||
group: legacyTopicRegexMatch[1], error: 'group doesn\'t exists'};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_${type}_failed`, message: payload}),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
type = 'remove_all';
|
||||
}
|
||||
|
||||
const parsedEntity = utils.parseEntityID(data.message);
|
||||
resolvedEntityDevice = this.zigbee.resolveEntity(parsedEntity.ID) as Device;
|
||||
if (!resolvedEntityDevice || !(resolvedEntityDevice instanceof Device)) {
|
||||
logger.error(`Device '${data.message}' does not exist`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {
|
||||
friendly_name: data.message, group: legacyTopicRegexMatch[1], error: 'entity doesn\'t exists',
|
||||
};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_${type}_failed`, message: payload}),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
resolvedEntityEndpoint = resolvedEntityDevice.endpoint(parsedEntity.endpoint);
|
||||
} else if (topicRegexMatch) {
|
||||
type = topicRegexMatch[1] as 'remove' | 'add' | 'remove_all';
|
||||
const message = JSON.parse(data.message);
|
||||
deviceKey = message.device;
|
||||
skipDisableReporting = 'skip_disable_reporting' in message ? message.skip_disable_reporting : false;
|
||||
|
||||
if (type !== 'remove_all') {
|
||||
groupKey = message.group;
|
||||
resolvedEntityGroup = this.zigbee.resolveEntity(message.group) as Group;
|
||||
if (!resolvedEntityGroup || !(resolvedEntityGroup instanceof Group)) {
|
||||
error = `Group '${message.group}' does not exist`;
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = utils.parseEntityID(message.device);
|
||||
resolvedEntityDevice = this.zigbee.resolveEntity(parsed.ID) as Device;
|
||||
if (!error && (!resolvedEntityDevice || !(resolvedEntityDevice instanceof Device))) {
|
||||
error = `Device '${message.device}' does not exist`;
|
||||
}
|
||||
if (!error) {
|
||||
resolvedEntityEndpoint = resolvedEntityDevice.endpoint(parsed.endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
resolvedEntityGroup, resolvedEntityDevice, type, error, groupKey, deviceKey,
|
||||
triggeredViaLegacyApi, skipDisableReporting, resolvedEntityEndpoint,
|
||||
};
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const parsed = this.parseMQTTMessage(data);
|
||||
if (!parsed || !parsed.type) return;
|
||||
let {
|
||||
resolvedEntityGroup, resolvedEntityDevice, type, error, triggeredViaLegacyApi,
|
||||
groupKey, deviceKey, skipDisableReporting, resolvedEntityEndpoint,
|
||||
} = parsed;
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
|
||||
const responseData: KeyValue = {device: deviceKey};
|
||||
if (groupKey) {
|
||||
responseData.group = groupKey;
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
const keys = [
|
||||
`${resolvedEntityDevice.ieeeAddr}/${resolvedEntityEndpoint.ID}`,
|
||||
`${resolvedEntityDevice.name}/${resolvedEntityEndpoint.ID}`,
|
||||
];
|
||||
|
||||
const endpointNameLocal = resolvedEntityDevice.endpointName(resolvedEntityEndpoint);
|
||||
if (endpointNameLocal) {
|
||||
keys.push(`${resolvedEntityDevice.ieeeAddr}/${endpointNameLocal}`);
|
||||
keys.push(`${resolvedEntityDevice.name}/${endpointNameLocal}`);
|
||||
}
|
||||
|
||||
if (!endpointNameLocal) {
|
||||
keys.push(resolvedEntityDevice.name);
|
||||
keys.push(resolvedEntityDevice.ieeeAddr);
|
||||
}
|
||||
|
||||
if (type === 'add') {
|
||||
logger.info(`Adding '${resolvedEntityDevice.name}' to '${resolvedEntityGroup.name}'`);
|
||||
await resolvedEntityEndpoint.addToGroup(resolvedEntityGroup.zh);
|
||||
settings.addDeviceToGroup(resolvedEntityGroup.ID.toString(), keys);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_add`, message: payload}),
|
||||
);
|
||||
}
|
||||
} else if (type === 'remove') {
|
||||
logger.info(`Removing '${resolvedEntityDevice.name}' from '${resolvedEntityGroup.name}'`);
|
||||
await resolvedEntityEndpoint.removeFromGroup(resolvedEntityGroup.zh);
|
||||
settings.removeDeviceFromGroup(resolvedEntityGroup.ID.toString(), keys);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_remove`, message: payload}),
|
||||
);
|
||||
}
|
||||
} else { // remove_all
|
||||
logger.info(`Removing '${resolvedEntityDevice.name}' from all groups`);
|
||||
await resolvedEntityEndpoint.removeFromAllGroups();
|
||||
for (const settingsGroup of settings.getGroups()) {
|
||||
settings.removeDeviceFromGroup(settingsGroup.ID.toString(), keys);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {friendly_name: resolvedEntityDevice.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_remove_all`, message: payload}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Failed to ${type} from group (${e.message})`;
|
||||
logger.debug(e.stack);
|
||||
}
|
||||
}
|
||||
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
await this.mqtt.publish(`bridge/response/group/members/${type}`, stringify(response));
|
||||
}
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
} else {
|
||||
this.eventBus.emitGroupMembersChanged({
|
||||
group: resolvedEntityGroup, action: type, endpoint: resolvedEntityEndpoint, skipDisableReporting});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,926 +0,0 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const utils = require('../util/utils');
|
||||
const zigbee2mqttVersion = require('../../package.json').version;
|
||||
const Extension = require('./extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const assert = require('assert');
|
||||
|
||||
const sensorClick = {
|
||||
type: 'sensor',
|
||||
object_id: 'click',
|
||||
discovery_payload: {
|
||||
icon: 'mdi:toggle-switch',
|
||||
value_template: '{{ value_json.click }}',
|
||||
},
|
||||
};
|
||||
|
||||
const defaultStatusTopic = 'homeassistant/status';
|
||||
|
||||
/**
|
||||
* This extensions handles integration with HomeAssistant
|
||||
*/
|
||||
class HomeAssistant extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
|
||||
// A map of all discoverd devices
|
||||
this.discovered = {};
|
||||
this.mapping = {};
|
||||
this.discoveredTriggers = {};
|
||||
this.legacyApi = settings.get().advanced.legacy_api;
|
||||
|
||||
if (!settings.get().advanced.cache_state) {
|
||||
logger.warn('In order for HomeAssistant integration to work properly set `cache_state: true');
|
||||
}
|
||||
|
||||
if (settings.get().experimental.output === 'attribute') {
|
||||
throw new Error('Home Assitant integration is not possible with attribute output!');
|
||||
}
|
||||
|
||||
this.discoveryTopic = settings.get().advanced.homeassistant_discovery_topic;
|
||||
this.statusTopic = settings.get().advanced.homeassistant_status_topic;
|
||||
|
||||
this.eventBus.on('deviceRemoved', (data) => this.onDeviceRemoved(data.resolvedEntity), this.constructor.name);
|
||||
this.eventBus.on('publishEntityState', (data) => this.onPublishEntityState(data), this.constructor.name);
|
||||
this.eventBus.on('deviceRenamed', (data) =>
|
||||
this.onDeviceRenamed(data.device, data.homeAssisantRename), this.constructor.name,
|
||||
);
|
||||
|
||||
this.populateMapping();
|
||||
}
|
||||
|
||||
populateMapping() {
|
||||
for (const def of zigbeeHerdsmanConverters.definitions) {
|
||||
this.mapping[def.model] = [];
|
||||
|
||||
if (['WXKG01LM', 'HS1EB/HS1EB-E', 'ICZB-KPD14S', 'TERNCY-SD01', 'TERNCY-PP01', 'ICZB-KPD18S',
|
||||
'E1766', 'ZWallRemote0', 'ptvo.switch', '2AJZ4KPKEY', 'ZGRC-KEY-013', 'HGZB-02S', 'HGZB-045',
|
||||
'HGZB-1S', 'AV2010/34', 'IM6001-BTP01', 'WXKG11LM', 'WXKG03LM', 'WXKG02LM_rev1', 'WXKG02LM_rev2',
|
||||
'QBKG04LM', 'QBKG03LM', 'QBKG11LM', 'QBKG21LM', 'QBKG22LM', 'WXKG12LM', 'QBKG12LM',
|
||||
'E1743'].includes(def.model)) {
|
||||
// deprecated
|
||||
this.mapping[def.model].push(sensorClick);
|
||||
}
|
||||
|
||||
if (['ICTC-G-1'].includes(def.model)) {
|
||||
// deprecated
|
||||
this.mapping[def.model].push({
|
||||
type: 'sensor',
|
||||
object_id: 'brightness',
|
||||
discovery_payload: {
|
||||
unit_of_measurement: 'brightness',
|
||||
icon: 'mdi:brightness-5',
|
||||
value_template: '{{ value_json.brightness }}',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const expose of def.exposes) {
|
||||
let discoveryEntry = null;
|
||||
/* istanbul ignore else */
|
||||
if (expose.type === 'light') {
|
||||
const supportsXY = !!expose.features.find((e) => e.name === 'color_xy');
|
||||
const supportsHS = !!expose.features.find((e) => e.name === 'color_hs');
|
||||
const colorTemp = expose.features.find((e) => e.name === 'color_temp');
|
||||
discoveryEntry = {
|
||||
type: 'light',
|
||||
object_id: expose.endpoint ? `light_${expose.endpoint}` : 'light',
|
||||
discovery_payload: {
|
||||
brightness: !!expose.features.find((e) => e.name === 'brightness'),
|
||||
color_temp: !!colorTemp,
|
||||
xy: supportsXY,
|
||||
hs: !supportsXY && supportsHS,
|
||||
schema: 'json',
|
||||
command_topic: true,
|
||||
brightness_scale: 254,
|
||||
command_topic_prefix: expose.endpoint ? expose.endpoint : undefined,
|
||||
state_topic_postfix: expose.endpoint ? expose.endpoint : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
if (colorTemp) {
|
||||
discoveryEntry.discovery_payload.max_mireds = colorTemp.value_max;
|
||||
discoveryEntry.discovery_payload.min_mireds = colorTemp.value_min;
|
||||
}
|
||||
|
||||
const effect = def.exposes.find((e) => e.type === 'enum' && e.name === 'effect');
|
||||
if (effect) {
|
||||
discoveryEntry.discovery_payload.effect = true;
|
||||
discoveryEntry.discovery_payload.effect_list = effect.values;
|
||||
}
|
||||
} else if (expose.type === 'switch') {
|
||||
const state = expose.features.find((f) => f.name === 'state');
|
||||
discoveryEntry = {
|
||||
type: 'switch',
|
||||
object_id: expose.endpoint ? `switch_${expose.endpoint}` : 'switch',
|
||||
discovery_payload: {
|
||||
payload_off: state.value_off,
|
||||
payload_on: state.value_on,
|
||||
value_template: `{{ value_json.${state.property} }}`,
|
||||
command_topic: true,
|
||||
command_topic_prefix: expose.endpoint ? expose.endpoint : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const different = ['valve_detection', 'window_detection', 'auto_lock', 'away_mode'];
|
||||
if (different.includes(state.property)) {
|
||||
discoveryEntry.discovery_payload.command_topic_postfix = state.property;
|
||||
discoveryEntry.discovery_payload.state_off = state.value_off;
|
||||
discoveryEntry.discovery_payload.state_on = state.value_on;
|
||||
discoveryEntry.discovery_payload.state_topic = true;
|
||||
discoveryEntry.object_id = state.property;
|
||||
|
||||
if (state.property === 'window_detection') {
|
||||
discoveryEntry.discovery_payload.icon = 'mdi:window-open-variant';
|
||||
}
|
||||
}
|
||||
} else if (expose.type === 'climate') {
|
||||
const setpointProperties = ['occupied_heating_setpoint', 'current_heating_setpoint'];
|
||||
const setpoint = expose.features.find((f) => setpointProperties.includes(f.name));
|
||||
assert(setpoint, 'No setpoint found');
|
||||
const temperature = expose.features.find((f) => f.name === 'local_temperature');
|
||||
assert(temperature, 'No temperature found');
|
||||
|
||||
discoveryEntry = {
|
||||
type: 'climate',
|
||||
object_id: expose.endpoint ? `climate_${expose.endpoint}` : 'climate',
|
||||
discovery_payload: {
|
||||
// Static
|
||||
state_topic: false,
|
||||
temperature_unit: 'C',
|
||||
// Setpoint
|
||||
temp_step: setpoint.value_step,
|
||||
min_temp: setpoint.value_min.toString(),
|
||||
max_temp: setpoint.value_max.toString(),
|
||||
// Temperature
|
||||
current_temperature_topic: true,
|
||||
current_temperature_template: `{{ value_json.${temperature.property} }}`,
|
||||
},
|
||||
};
|
||||
|
||||
const mode = expose.features.find((f) => f.name === 'system_mode');
|
||||
if (mode) {
|
||||
discoveryEntry.discovery_payload.mode_state_topic = true;
|
||||
discoveryEntry.discovery_payload.mode_state_template = `{{ value_json.${mode.property} }}`;
|
||||
discoveryEntry.discovery_payload.modes = mode.values;
|
||||
discoveryEntry.discovery_payload.mode_command_topic = true;
|
||||
}
|
||||
|
||||
const state = expose.features.find((f) => f.name === 'running_state');
|
||||
if (state) {
|
||||
discoveryEntry.discovery_payload.action_topic = true;
|
||||
discoveryEntry.discovery_payload.action_template = `{% set values = ` +
|
||||
`{'idle':'off','heat':'heating','cool':'cooling','fan only':'fan'}` +
|
||||
` %}{{ values[value_json.${state.property}] }}`;
|
||||
}
|
||||
|
||||
const coolingSetpoint = expose.features.find((f) => f.name === 'occupied_cooling_setpoint');
|
||||
if (coolingSetpoint) {
|
||||
discoveryEntry.discovery_payload.temperature_low_command_topic = setpoint.name;
|
||||
discoveryEntry.discovery_payload.temperature_low_state_template =
|
||||
`{{ value_json.${setpoint.property} }}`;
|
||||
discoveryEntry.discovery_payload.temperature_low_state_topic = true;
|
||||
discoveryEntry.discovery_payload.temperature_high_command_topic = coolingSetpoint.name;
|
||||
discoveryEntry.discovery_payload.temperature_high_state_template =
|
||||
`{{ value_json.${coolingSetpoint.property} }}`;
|
||||
discoveryEntry.discovery_payload.temperature_high_state_topic = true;
|
||||
} else {
|
||||
discoveryEntry.discovery_payload.temperature_command_topic = setpoint.name;
|
||||
discoveryEntry.discovery_payload.temperature_state_template =
|
||||
`{{ value_json.${setpoint.property} }}`;
|
||||
discoveryEntry.discovery_payload.temperature_state_topic = true;
|
||||
}
|
||||
|
||||
const fanMode = expose.features.find((f) => f.name === 'fan_mode');
|
||||
if (fanMode) {
|
||||
discoveryEntry.discovery_payload.fan_modes = fanMode.values;
|
||||
discoveryEntry.discovery_payload.fan_mode_command_topic = true;
|
||||
discoveryEntry.discovery_payload.fan_mode_state_template =
|
||||
`{{ value_json.${fanMode.property} }}`;
|
||||
discoveryEntry.discovery_payload.fan_mode_state_topic = true;
|
||||
}
|
||||
|
||||
const preset = expose.features.find((f) => f.name === 'preset');
|
||||
if (preset) {
|
||||
discoveryEntry.discovery_payload.hold_modes = preset.values;
|
||||
discoveryEntry.discovery_payload.hold_command_topic = true;
|
||||
discoveryEntry.discovery_payload.hold_state_template =
|
||||
`{{ value_json.${preset.property} }}`;
|
||||
discoveryEntry.discovery_payload.hold_state_topic = true;
|
||||
}
|
||||
|
||||
const awayMode = expose.features.find((f) => f.name === 'away_mode');
|
||||
if (awayMode) {
|
||||
discoveryEntry.discovery_payload.away_mode_command_topic = true;
|
||||
discoveryEntry.discovery_payload.away_mode_state_topic = true;
|
||||
discoveryEntry.discovery_payload.away_mode_state_template =
|
||||
`{{ value_json.${awayMode.property} }}`;
|
||||
}
|
||||
|
||||
if (expose.endpoint) {
|
||||
discoveryEntry.discovery_payload.state_topic_postfix = expose.endpoint;
|
||||
}
|
||||
} else if (expose.type === 'lock') {
|
||||
assert(!expose.endpoint, `Endpoint not supported for lock type`);
|
||||
const state = expose.features.find((f) => f.name === 'state');
|
||||
assert(state, 'No state found');
|
||||
discoveryEntry = {
|
||||
type: 'lock',
|
||||
object_id: 'lock',
|
||||
discovery_payload: {
|
||||
command_topic: true,
|
||||
value_template: `{{ value_json.${state.property} }}`,
|
||||
},
|
||||
};
|
||||
|
||||
if (state.property === 'keypad_lockout') {
|
||||
// deprecated: keypad_lockout is messy, but changing is breaking
|
||||
discoveryEntry.discovery_payload.payload_lock = state.value_on;
|
||||
discoveryEntry.discovery_payload.payload_unlock = state.value_off;
|
||||
discoveryEntry.discovery_payload.state_topic = true;
|
||||
discoveryEntry.object_id = 'keypad_lock';
|
||||
} else if (state.property === 'child_lock') {
|
||||
// deprecated: child_lock is messy, but changing is breaking
|
||||
discoveryEntry.discovery_payload.payload_lock = state.value_on;
|
||||
discoveryEntry.discovery_payload.payload_unlock = state.value_off;
|
||||
discoveryEntry.discovery_payload.state_locked = 'LOCKED';
|
||||
discoveryEntry.discovery_payload.state_unlocked = 'UNLOCKED';
|
||||
discoveryEntry.discovery_payload.state_topic = true;
|
||||
discoveryEntry.object_id = 'child_lock';
|
||||
} else {
|
||||
discoveryEntry.discovery_payload.state_locked = state.value_on;
|
||||
discoveryEntry.discovery_payload.state_unlocked = state.value_off;
|
||||
}
|
||||
|
||||
if (state.property !== 'state') {
|
||||
discoveryEntry.discovery_payload.command_topic_postfix = state.property;
|
||||
}
|
||||
} else if (expose.type === 'cover') {
|
||||
assert(!expose.endpoint, `Endpoint not supported for cover type`);
|
||||
const hasPosition = expose.features.find((e) => e.name === 'position');
|
||||
const hasTilt = expose.features.find((e) => e.name === 'tilt');
|
||||
|
||||
discoveryEntry = {
|
||||
type: 'cover',
|
||||
object_id: 'cover',
|
||||
discovery_payload: {
|
||||
command_topic: true,
|
||||
state_topic: !hasPosition,
|
||||
},
|
||||
};
|
||||
|
||||
if (!hasPosition && !hasTilt) {
|
||||
discoveryEntry.discovery_payload.optimistic = true;
|
||||
}
|
||||
|
||||
if (hasPosition) {
|
||||
discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload,
|
||||
value_template: '{{ value_json.position }}',
|
||||
set_position_template: '{ "position": {{ position }} }',
|
||||
set_position_topic: true,
|
||||
position_topic: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasTilt) {
|
||||
discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload,
|
||||
tilt_command_topic: true,
|
||||
tilt_status_topic: true,
|
||||
tilt_status_template: '{{ value_json.tilt }}',
|
||||
};
|
||||
}
|
||||
} else if (expose.type === 'fan') {
|
||||
assert(!expose.endpoint, `Endpoint not supported for fan type`);
|
||||
discoveryEntry = {
|
||||
type: 'fan',
|
||||
object_id: 'fan',
|
||||
discovery_payload: {
|
||||
state_topic: true,
|
||||
state_value_template: '{{ value_json.fan_state }}',
|
||||
command_topic: true,
|
||||
command_topic_postfix: 'fan_state',
|
||||
},
|
||||
};
|
||||
|
||||
const speed = expose.features.find((e) => e.name === 'mode');
|
||||
if (speed) {
|
||||
discoveryEntry.discovery_payload.speed_state_topic = true;
|
||||
discoveryEntry.discovery_payload.speed_command_topic = true;
|
||||
discoveryEntry.discovery_payload.speed_value_template = '{{ value_json.fan_mode }}';
|
||||
discoveryEntry.discovery_payload.speeds = speed.values;
|
||||
}
|
||||
} else if (expose.type === 'binary') {
|
||||
const lookup = {
|
||||
occupancy: {device_class: 'motion'},
|
||||
battery_low: {device_class: 'battery'},
|
||||
water_leak: {device_class: 'moisture'},
|
||||
vibration: {device_class: 'vibration'},
|
||||
contact: {device_class: 'door'},
|
||||
smoke: {device_class: 'smoke'},
|
||||
gas: {device_class: 'gas'},
|
||||
carbon_monoxide: {device_class: 'safety'},
|
||||
presence: {device_class: 'presence'},
|
||||
};
|
||||
|
||||
discoveryEntry = {
|
||||
type: 'binary_sensor',
|
||||
object_id: expose.endpoint ? `${expose.name}_${expose.endpoint}` : `${expose.name}`,
|
||||
discovery_payload: {
|
||||
value_template: `{{ value_json.${expose.property} }}`,
|
||||
payload_on: expose.value_on,
|
||||
payload_off: expose.value_off,
|
||||
...(lookup[expose.name] || {}),
|
||||
},
|
||||
};
|
||||
} else if (expose.type === 'numeric') {
|
||||
const lookup = {
|
||||
battery: {device_class: 'battery'},
|
||||
temperature: {device_class: 'temperature'},
|
||||
humidity: {device_class: 'humidity'},
|
||||
illuminance_lux: {device_class: 'illuminance'},
|
||||
illuminance: {device_class: 'illuminance'},
|
||||
soil_moisture: {icon: 'mdi:water-percent'},
|
||||
position: {icon: 'mdi:valve'},
|
||||
pressure: {device_class: 'pressure'},
|
||||
power: {device_class: 'power'},
|
||||
linkquality: {icon: 'mdi:signal'},
|
||||
current: {device_class: 'current'},
|
||||
voltage: {device_class: 'voltage'},
|
||||
current_phase_b: {device_class: 'current'},
|
||||
voltage_phase_b: {device_class: 'voltage'},
|
||||
current_phase_c: {device_class: 'current'},
|
||||
voltage_phase_c: {device_class: 'voltage'},
|
||||
energy: {device_class: 'energy'},
|
||||
smoke_density: {icon: 'mdi:google-circles-communities'},
|
||||
gas_density: {icon: 'mdi:google-circles-communities'},
|
||||
pm25: {icon: 'mdi:air-filter'},
|
||||
pm10: {icon: 'mdi:air-filter'},
|
||||
voc: {icon: 'mdi:air-filter'},
|
||||
aqi: {icon: 'mdi:air-filter'},
|
||||
hcho: {icon: 'mdi:air-filter'},
|
||||
requested_brightness_level: {icon: 'mdi:brightness-5'},
|
||||
requested_brightness_percent: {icon: 'mdi:brightness-5'},
|
||||
eco2: {icon: 'mdi:molecule-co2'},
|
||||
co2: {icon: 'mdi:molecule-co2'},
|
||||
local_temperature: {device_class: 'temperature'},
|
||||
x_axis: {icon: 'mdi:axis-x-arrow'},
|
||||
y_axis: {icon: 'mdi:axis-y-arrow'},
|
||||
z_axis: {icon: 'mdi:axis-z-arrow'},
|
||||
};
|
||||
|
||||
discoveryEntry = {
|
||||
type: 'sensor',
|
||||
object_id: expose.endpoint ? `${expose.name}_${expose.endpoint}` : `${expose.name}`,
|
||||
discovery_payload: {
|
||||
value_template: `{{ value_json.${expose.property} }}`,
|
||||
...(expose.unit && {unit_of_measurement: expose.unit}),
|
||||
...lookup[expose.name],
|
||||
},
|
||||
};
|
||||
} else if (expose.type === 'enum' || expose.type === 'text' || expose.type === 'composite') {
|
||||
const ACCESS_STATE = 1;
|
||||
if (expose.access & ACCESS_STATE) {
|
||||
const lookup = {
|
||||
action: {icon: 'mdi:gesture-double-tap'},
|
||||
};
|
||||
|
||||
discoveryEntry = {
|
||||
type: 'sensor',
|
||||
object_id: expose.property,
|
||||
discovery_payload: {
|
||||
value_template: `{{ value_json.${expose.property} }}`,
|
||||
...lookup[expose.name],
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unsupported exposes type: '${expose.type}'`);
|
||||
}
|
||||
|
||||
if (discoveryEntry) {
|
||||
this.mapping[def.model].push(discoveryEntry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const definition of utils.getExternalConvertersDefinitions(settings)) {
|
||||
if (definition.hasOwnProperty('homeassistant')) {
|
||||
this.mapping[definition.model] = definition.homeassistant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDeviceRemoved(resolvedEntity) {
|
||||
logger.debug(`Clearing Home Assistant discovery topic for '${resolvedEntity.name}'`);
|
||||
delete this.discovered[resolvedEntity.device.ieeeAddr];
|
||||
for (const config of this.getConfigs(resolvedEntity)) {
|
||||
const topic = this.getDiscoveryTopic(config, resolvedEntity.device);
|
||||
this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
async onPublishEntityState(data) {
|
||||
/**
|
||||
* In case we deal with a lightEndpoint configuration Zigbee2MQTT publishes
|
||||
* e.g. {state_l1: ON, brightness_l1: 250} to zigbee2mqtt/mydevice.
|
||||
* As the Home Assistant MQTT JSON light cannot be configured to use state_l1/brightness_l1
|
||||
* as the state variables, the state topic is set to zigbee2mqtt/mydevice/l1.
|
||||
* Here we retrieve all the attributes with the _l1 values and republish them on
|
||||
* zigbee2mqtt/mydevice/l1.
|
||||
*/
|
||||
if (data.entity.definition && this.mapping[data.entity.definition.model]) {
|
||||
for (const config of this.mapping[data.entity.definition.model]) {
|
||||
const match = /light_(.*)/.exec(config['object_id']);
|
||||
if (match) {
|
||||
const endpoint = match[1];
|
||||
const endpointRegExp = new RegExp(`(.*)_${endpoint}`);
|
||||
const payload = {};
|
||||
for (const key of Object.keys(data.messagePayload)) {
|
||||
const keyMatch = endpointRegExp.exec(key);
|
||||
if (keyMatch) {
|
||||
payload[keyMatch[1]] = data.messagePayload[key];
|
||||
}
|
||||
}
|
||||
|
||||
await this.mqtt.publish(
|
||||
`${data.entity.name}/${endpoint}`, stringify(payload), {},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
if (settings.get().advanced.homeassistant_legacy_triggers) {
|
||||
const keys = ['action', 'click'].filter((k) =>
|
||||
data.messagePayload.hasOwnProperty(k) && data.messagePayload[k] !== '');
|
||||
for (const key of keys) {
|
||||
this.publishEntityState(data.entity.device.ieeeAddr, {[key]: ''});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the MQTT device trigger (https://www.home-assistant.io/integrations/device_trigger.mqtt/)
|
||||
* The MQTT device trigger does not support JSON parsing, so it cannot listen to zigbee2mqtt/my_device
|
||||
* Whenever a device publish an {action: *} we discover an MQTT device trigger sensor
|
||||
* and republish it to zigbee2mqtt/my_devic/action
|
||||
*/
|
||||
if (data.entity.definition) {
|
||||
const keys = ['action', 'click'].filter((k) => data.messagePayload[k] && data.messagePayload[k] !== '');
|
||||
for (const key of keys) {
|
||||
const value = data.messagePayload[key].toString();
|
||||
await this.publishDeviceTriggerDiscover(data.entity, key, value);
|
||||
await this.mqtt.publish(`${data.entity.name}/${key}`, value, {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a value for update_available (if not there yet) to prevent Home Assistant generating warnings of
|
||||
* this value not being available.
|
||||
*/
|
||||
const supportsOTA = data.entity.definition && data.entity.definition.hasOwnProperty('ota');
|
||||
const mockedValues = [
|
||||
{
|
||||
property: 'update_available',
|
||||
condition: supportsOTA && this.legacyApi,
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
property: 'update',
|
||||
condition: supportsOTA,
|
||||
value: {state: 'idle'},
|
||||
},
|
||||
{
|
||||
property: 'water_leak',
|
||||
condition: data.entity.device && data.entity.definition && this.mapping[data.entity.definition.model] &&
|
||||
this.mapping[data.entity.definition.model].filter((c) => c.object_id === 'water_leak').length === 1,
|
||||
value: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const entry of mockedValues) {
|
||||
if (entry.condition && !data.messagePayload.hasOwnProperty(entry.property)) {
|
||||
logger.debug(`Mocking '${entry.property}' value for Home Assistant`);
|
||||
this.publishEntityState(data.entity.device.ieeeAddr, {[entry.property]: entry.value});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDeviceRenamed(device, homeAssisantRename) {
|
||||
logger.debug(`Refreshing Home Assistant discovery topic for '${device.ieeeAddr}'`);
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
|
||||
// Clear before rename so Home Assistant uses new friendly_name
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/4096#issuecomment-674044916
|
||||
if (homeAssisantRename) {
|
||||
for (const config of this.getConfigs(resolvedEntity)) {
|
||||
const topic = this.getDiscoveryTopic(config, device);
|
||||
this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
this.discover(resolvedEntity, true);
|
||||
|
||||
if (this.discoveredTriggers[device.ieeeAddr]) {
|
||||
for (const config of this.discoveredTriggers[device.ieeeAddr]) {
|
||||
const key = config.substring(0, config.indexOf('_'));
|
||||
const value = config.substring(config.indexOf('_') + 1);
|
||||
this.publishDeviceTriggerDiscover(resolvedEntity, key, value, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onMQTTConnected() {
|
||||
this.mqtt.subscribe(this.statusTopic);
|
||||
this.mqtt.subscribe(defaultStatusTopic);
|
||||
this.mqtt.subscribe(`${this.discoveryTopic}/#`);
|
||||
|
||||
// MQTT discovery of all paired devices on startup.
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
this.discover(resolvedEntity, true);
|
||||
}
|
||||
}
|
||||
|
||||
getConfigs(resolvedEntity) {
|
||||
if (!resolvedEntity || !resolvedEntity.definition || !this.mapping[resolvedEntity.definition.model]) return [];
|
||||
|
||||
let configs = this.mapping[resolvedEntity.definition.model].slice();
|
||||
if (resolvedEntity.definition.hasOwnProperty('ota')) {
|
||||
const updateStateSensor = {
|
||||
type: 'sensor',
|
||||
object_id: 'update_state',
|
||||
discovery_payload: {
|
||||
icon: 'mdi:update',
|
||||
value_template: `{{ value_json['update']['state'] }}`,
|
||||
},
|
||||
};
|
||||
|
||||
configs.push(updateStateSensor);
|
||||
if (this.legacyApi) {
|
||||
const updateAvailableSensor = {
|
||||
type: 'binary_sensor',
|
||||
object_id: 'update_available',
|
||||
discovery_payload: {
|
||||
payload_on: true,
|
||||
payload_off: false,
|
||||
value_template: '{{ value_json.update_available}}',
|
||||
},
|
||||
};
|
||||
configs.push(updateAvailableSensor);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedEntity.settings.hasOwnProperty('legacy') && !resolvedEntity.settings.legacy) {
|
||||
configs = configs.filter((c) => c !== sensorClick);
|
||||
}
|
||||
|
||||
if (!settings.get().advanced.homeassistant_legacy_triggers) {
|
||||
configs = configs.filter((c) => c.object_id !== 'action' && c.object_id !== 'click');
|
||||
}
|
||||
|
||||
// deep clone of the config objects
|
||||
configs = JSON.parse(JSON.stringify(configs));
|
||||
|
||||
if (resolvedEntity.settings.homeassistant) {
|
||||
const s = resolvedEntity.settings.homeassistant;
|
||||
configs = configs.filter((config) => !s.hasOwnProperty(config.object_id) || s[config.object_id] != null);
|
||||
configs.forEach((config) => {
|
||||
const configOverride = s[config.object_id];
|
||||
if (configOverride) {
|
||||
config.object_id = configOverride.object_id || config.object_id;
|
||||
config.type = configOverride.type || config.type;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
discover(resolvedEntity, force=false) {
|
||||
// Check if already discoverd and check if there are configs.
|
||||
const {device, definition} = resolvedEntity;
|
||||
const discover = force || !this.discovered[device.ieeeAddr];
|
||||
if (!discover || !device || !definition || !this.mapping[definition.model] || device.interviewing ||
|
||||
(resolvedEntity.settings.hasOwnProperty('homeassistant') && !resolvedEntity.settings.homeassistant)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const friendlyName = resolvedEntity.settings.friendlyName;
|
||||
this.getConfigs(resolvedEntity).forEach((config) => {
|
||||
const payload = {...config.discovery_payload};
|
||||
let stateTopic = `${settings.get().mqtt.base_topic}/${friendlyName}`;
|
||||
if (payload.state_topic_postfix) {
|
||||
stateTopic += `/${payload.state_topic_postfix}`;
|
||||
delete payload.state_topic_postfix;
|
||||
}
|
||||
|
||||
if (!payload.hasOwnProperty('state_topic') || payload.state_topic) {
|
||||
payload.state_topic = stateTopic;
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (payload.hasOwnProperty('state_topic')) {
|
||||
delete payload.state_topic;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.position_topic) {
|
||||
payload.position_topic = stateTopic;
|
||||
}
|
||||
|
||||
if (payload.tilt_status_topic) {
|
||||
payload.tilt_status_topic = stateTopic;
|
||||
}
|
||||
|
||||
payload.json_attributes_topic = stateTopic;
|
||||
|
||||
// Set (unique) name, separate by space if friendlyName contains space.
|
||||
const nameSeparator = friendlyName.includes('_') ? '_' : ' ';
|
||||
payload.name = friendlyName;
|
||||
if (config.object_id.startsWith(config.type) && config.object_id.includes('_')) {
|
||||
payload.name += `${nameSeparator}${config.object_id.split(/_(.+)/)[1]}`;
|
||||
} else if (!config.object_id.startsWith(config.type)) {
|
||||
payload.name += `${nameSeparator}${config.object_id.replace(/_/g, nameSeparator)}`;
|
||||
}
|
||||
|
||||
// Set unique_id
|
||||
payload.unique_id = `${resolvedEntity.settings.ID}_${config.object_id}_${settings.get().mqtt.base_topic}`;
|
||||
|
||||
// Attributes for device registry
|
||||
payload.device = this.getDevicePayload(resolvedEntity);
|
||||
|
||||
// Availability payload
|
||||
payload.availability = [{topic: `${settings.get().mqtt.base_topic}/bridge/state`}];
|
||||
if (settings.get().advanced.availability_timeout) {
|
||||
payload.availability.push({topic: `${settings.get().mqtt.base_topic}/${friendlyName}/availability`});
|
||||
}
|
||||
|
||||
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}/`;
|
||||
delete payload.command_topic_prefix;
|
||||
}
|
||||
|
||||
payload.command_topic += 'set';
|
||||
|
||||
if (payload.command_topic_postfix) {
|
||||
payload.command_topic += `/${payload.command_topic_postfix}`;
|
||||
delete payload.command_topic_postfix;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.set_position_topic && payload.command_topic) {
|
||||
payload.set_position_topic = payload.command_topic;
|
||||
}
|
||||
|
||||
if (payload.tilt_command_topic && payload.command_topic) {
|
||||
// Home Assistant does not support templates to set tilt (as of 2019-08-17),
|
||||
// so we (have to) use a subtopic.
|
||||
payload.tilt_command_topic = payload.command_topic + '/tilt';
|
||||
}
|
||||
|
||||
if (payload.mode_state_topic) {
|
||||
payload.mode_state_topic = stateTopic;
|
||||
}
|
||||
|
||||
if (payload.mode_command_topic) {
|
||||
payload.mode_command_topic = `${stateTopic}/set/system_mode`;
|
||||
}
|
||||
|
||||
if (payload.hold_command_topic) {
|
||||
payload.hold_command_topic = `${stateTopic}/set/preset`;
|
||||
}
|
||||
|
||||
if (payload.hold_state_topic) {
|
||||
payload.hold_state_topic = stateTopic;
|
||||
}
|
||||
|
||||
if (payload.away_mode_state_topic) {
|
||||
payload.away_mode_state_topic = stateTopic;
|
||||
}
|
||||
|
||||
if (payload.away_mode_command_topic) {
|
||||
payload.away_mode_command_topic = `${stateTopic}/set/away_mode`;
|
||||
}
|
||||
|
||||
if (payload.current_temperature_topic) {
|
||||
payload.current_temperature_topic = stateTopic;
|
||||
}
|
||||
|
||||
if (payload.temperature_state_topic) {
|
||||
payload.temperature_state_topic = stateTopic;
|
||||
}
|
||||
|
||||
if (payload.temperature_low_state_topic) {
|
||||
payload.temperature_low_state_topic = stateTopic;
|
||||
}
|
||||
|
||||
if (payload.temperature_high_state_topic) {
|
||||
payload.temperature_high_state_topic = stateTopic;
|
||||
}
|
||||
|
||||
if (payload.speed_state_topic) {
|
||||
payload.speed_state_topic = stateTopic;
|
||||
}
|
||||
|
||||
if (payload.temperature_command_topic) {
|
||||
payload.temperature_command_topic = `${stateTopic}/set/${payload.temperature_command_topic}`;
|
||||
}
|
||||
|
||||
if (payload.temperature_low_command_topic) {
|
||||
payload.temperature_low_command_topic = `${stateTopic}/set/${payload.temperature_low_command_topic}`;
|
||||
}
|
||||
|
||||
if (payload.temperature_high_command_topic) {
|
||||
payload.temperature_high_command_topic = `${stateTopic}/set/${payload.temperature_high_command_topic}`;
|
||||
}
|
||||
|
||||
if (payload.fan_mode_state_topic) {
|
||||
payload.fan_mode_state_topic = stateTopic;
|
||||
}
|
||||
|
||||
if (payload.fan_mode_command_topic) {
|
||||
payload.fan_mode_command_topic = `${stateTopic}/set/fan_mode`;
|
||||
}
|
||||
|
||||
if (payload.speed_command_topic) {
|
||||
payload.speed_command_topic = `${stateTopic}/set/fan_mode`;
|
||||
}
|
||||
|
||||
if (payload.action_topic) {
|
||||
payload.action_topic = stateTopic;
|
||||
}
|
||||
|
||||
// Override configuration with user settings.
|
||||
if (resolvedEntity.settings.hasOwnProperty('homeassistant')) {
|
||||
const add = (obj) => {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (['type', 'object_id'].includes(key)) {
|
||||
return;
|
||||
} else if (['number', 'string', 'boolean'].includes(typeof obj[key])) {
|
||||
payload[key] = obj[key];
|
||||
} else if (obj[key] === null) {
|
||||
delete payload[key];
|
||||
} else if (key === 'device' && typeof obj[key] === 'object') {
|
||||
Object.keys(obj['device']).forEach((key) => {
|
||||
payload['device'][key] = obj['device'][key];
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
add(resolvedEntity.settings.homeassistant);
|
||||
|
||||
if (resolvedEntity.settings.homeassistant.hasOwnProperty(config.object_id)) {
|
||||
add(resolvedEntity.settings.homeassistant[config.object_id]);
|
||||
}
|
||||
}
|
||||
|
||||
const topic = this.getDiscoveryTopic(config, device);
|
||||
this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0}, this.discoveryTopic, false, false);
|
||||
});
|
||||
|
||||
this.discovered[device.ieeeAddr] = true;
|
||||
}
|
||||
|
||||
onMQTTMessage(topic, message) {
|
||||
const discoveryRegex = new RegExp(`${this.discoveryTopic}/(.*)/(.*)/(.*)/config`);
|
||||
const discoveryMatch = topic.match(discoveryRegex);
|
||||
const isDeviceAutomation = discoveryMatch && discoveryMatch[1] === 'device_automation';
|
||||
if (discoveryMatch) {
|
||||
// Clear outdated discovery configs and remember already discoverd device_automations
|
||||
try {
|
||||
message = JSON.parse(message);
|
||||
const baseTopic = settings.get().mqtt.base_topic + '/';
|
||||
if (isDeviceAutomation && (!message.topic || !message.topic.startsWith(baseTopic))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDeviceAutomation &&
|
||||
(!message.availability || !message.availability[0].topic.startsWith(baseTopic))) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ieeeAddr = discoveryMatch[2];
|
||||
const resolvedEntity = this.zigbee.resolveEntity(ieeeAddr);
|
||||
let clear = !resolvedEntity || !resolvedEntity.definition;
|
||||
|
||||
// Only save when topic matches otherwise config is not updated when renamed by editing configuration.yaml
|
||||
if (resolvedEntity) {
|
||||
const key = `${discoveryMatch[3].substring(0, discoveryMatch[3].indexOf('_'))}`;
|
||||
const triggerTopic = `${settings.get().mqtt.base_topic}/${resolvedEntity.name}/${key}`;
|
||||
if (isDeviceAutomation && message.topic === triggerTopic) {
|
||||
if (!this.discoveredTriggers[ieeeAddr]) {
|
||||
this.discoveredTriggers[ieeeAddr] = new Set();
|
||||
}
|
||||
this.discoveredTriggers[ieeeAddr].add(discoveryMatch[3]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!clear && !isDeviceAutomation) {
|
||||
const type = discoveryMatch[1];
|
||||
const objectID = discoveryMatch[3];
|
||||
clear = !this.getConfigs(resolvedEntity).find((c) => c.type === type && c.object_id === objectID);
|
||||
}
|
||||
// Device was flagged to be excluded from homeassistant discovery
|
||||
clear = clear || (resolvedEntity.settings.hasOwnProperty('homeassistant') &&
|
||||
!resolvedEntity.settings.homeassistant);
|
||||
|
||||
if (clear) {
|
||||
logger.debug(`Clearing Home Assistant config '${topic}'`);
|
||||
topic = topic.substring(this.discoveryTopic.length + 1);
|
||||
this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic, false, false);
|
||||
}
|
||||
} else if ((topic === this.statusTopic || topic === defaultStatusTopic) && message.toLowerCase() === 'online') {
|
||||
const timer = setTimeout(async () => {
|
||||
// Publish all device states.
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
if (this.state.exists(device.ieeeAddr)) {
|
||||
this.publishEntityState(device.ieeeAddr, this.state.get(device.ieeeAddr));
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(timer);
|
||||
}, 30000);
|
||||
}
|
||||
}
|
||||
|
||||
onZigbeeEvent(type, data, resolvedEntity) {
|
||||
if (resolvedEntity && type !== 'deviceLeave' && this.mqtt.isConnected()) {
|
||||
this.discover(resolvedEntity);
|
||||
}
|
||||
}
|
||||
|
||||
getDevicePayload(resolvedEntity) {
|
||||
return {
|
||||
identifiers: [`zigbee2mqtt_${resolvedEntity.settings.ID}`],
|
||||
name: resolvedEntity.settings.friendlyName,
|
||||
sw_version: `Zigbee2MQTT ${zigbee2mqttVersion}`,
|
||||
model: `${resolvedEntity.definition.description} (${resolvedEntity.definition.model})`,
|
||||
manufacturer: resolvedEntity.definition.vendor,
|
||||
};
|
||||
}
|
||||
|
||||
getDiscoveryTopic(config, device) {
|
||||
return `${config.type}/${device.ieeeAddr}/${config.object_id}/config`;
|
||||
}
|
||||
|
||||
async publishDeviceTriggerDiscover(entity, key, value, force=false) {
|
||||
if (entity.settings.hasOwnProperty('homeassistant') &&
|
||||
(entity.settings.homeassistant == null || entity.settings.homeassistant.device_automation == null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const device = entity.device;
|
||||
if (!this.discoveredTriggers[device.ieeeAddr]) {
|
||||
this.discoveredTriggers[device.ieeeAddr] = new Set();
|
||||
}
|
||||
|
||||
const discoveredKey = `${key}_${value}`;
|
||||
if (this.discoveredTriggers[device.ieeeAddr].has(discoveredKey) && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = {
|
||||
type: 'device_automation',
|
||||
object_id: `${key}_${value}`,
|
||||
discovery_payload: {
|
||||
automation_type: 'trigger',
|
||||
type: key,
|
||||
},
|
||||
};
|
||||
|
||||
const topic = this.getDiscoveryTopic(config, device);
|
||||
const payload = {
|
||||
...config.discovery_payload,
|
||||
subtype: value,
|
||||
payload: value,
|
||||
topic: `${settings.get().mqtt.base_topic}/${entity.name}/${key}`,
|
||||
device: this.getDevicePayload(entity),
|
||||
};
|
||||
|
||||
await this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0}, this.discoveryTopic, false, false);
|
||||
this.discoveredTriggers[device.ieeeAddr].add(discoveredKey);
|
||||
}
|
||||
|
||||
// Only for homeassistant.test.js
|
||||
_getMapping() {
|
||||
return this.mapping;
|
||||
}
|
||||
|
||||
_clearDiscoveredTrigger() {
|
||||
this.discoveredTriggers = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HomeAssistant;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,41 +1,21 @@
|
||||
const settings = require('../../util/settings');
|
||||
const logger = require('../../util/logger');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const utils = require('../../util/utils');
|
||||
const assert = require('assert');
|
||||
const Extension = require('../extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
import * as settings from '../../util/settings';
|
||||
import logger from '../../util/logger';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
import utils from '../../util/utils';
|
||||
import assert from 'assert';
|
||||
import Extension from '../extension';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
const configRegex =
|
||||
new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/((?:\\w+/get)|(?:\\w+/factory_reset)|(?:\\w+))`);
|
||||
const allowedLogLevels = ['error', 'warn', 'info', 'debug'];
|
||||
|
||||
class BridgeLegacy extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
export default class BridgeLegacy extends Extension {
|
||||
private lastJoinedDeviceName: string = null;
|
||||
private supportedOptions: {[s: string]: (topic: string, message: string) => Promise<void> | void};
|
||||
|
||||
// Bind functions
|
||||
this.permitJoin = this.permitJoin.bind(this);
|
||||
this.lastSeen = this.lastSeen.bind(this);
|
||||
this.elapsed = this.elapsed.bind(this);
|
||||
this.reset = this.reset.bind(this);
|
||||
this.logLevel = this.logLevel.bind(this);
|
||||
this.devices = this.devices.bind(this);
|
||||
this.groups = this.groups.bind(this);
|
||||
this.rename = this.rename.bind(this);
|
||||
this.renameLast = this.renameLast.bind(this);
|
||||
this.remove = this.remove.bind(this);
|
||||
this.forceRemove = this.forceRemove.bind(this);
|
||||
this.ban = this.ban.bind(this);
|
||||
this.deviceOptions = this.deviceOptions.bind(this);
|
||||
this.addGroup = this.addGroup.bind(this);
|
||||
this.removeGroup = this.removeGroup.bind(this);
|
||||
this.whitelist = this.whitelist.bind(this);
|
||||
this.touchlinkFactoryReset = this.touchlinkFactoryReset.bind(this);
|
||||
|
||||
this.lastJoinedDeviceName = null;
|
||||
|
||||
// Set supported options
|
||||
override async start(): Promise<void> {
|
||||
this.supportedOptions = {
|
||||
'permit_join': this.permitJoin,
|
||||
'last_seen': this.lastSeen,
|
||||
@@ -57,24 +37,32 @@ class BridgeLegacy extends Extension {
|
||||
'whitelist': this.whitelist,
|
||||
'touchlink/factory_reset': this.touchlinkFactoryReset,
|
||||
};
|
||||
|
||||
this.eventBus.onDeviceJoined(this, (data) => this.onZigbeeEvent_('deviceJoined', data, data.device));
|
||||
this.eventBus.onDeviceInterview(this, (data) => this.onZigbeeEvent_('deviceInterview', data, data.device));
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => this.onZigbeeEvent_('deviceAnnounce', data, data.device));
|
||||
this.eventBus.onDeviceLeave(this, (data) => this.onZigbeeEvent_('deviceLeave', data, null));
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
|
||||
await this.publish();
|
||||
}
|
||||
|
||||
whitelist(topic, message) {
|
||||
@bind whitelist(topic: string, message: string): void {
|
||||
try {
|
||||
const entity = settings.getEntity(message);
|
||||
const entity = settings.getDevice(message);
|
||||
assert(entity, `Entity '${message}' does not exist`);
|
||||
settings.whitelistDevice(entity.ID);
|
||||
logger.info(`Whitelisted '${entity.friendlyName}'`);
|
||||
settings.whitelistDevice(entity.ID.toString());
|
||||
logger.info(`Whitelisted '${entity.friendly_name}'`);
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: 'device_whitelisted', message: {friendly_name: entity.friendlyName}}),
|
||||
stringify({type: 'device_whitelisted', message: {friendly_name: entity.friendly_name}}),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to whitelist '${message}' '${error}'`);
|
||||
}
|
||||
}
|
||||
|
||||
deviceOptions(topic, message) {
|
||||
@bind deviceOptions(topic: string, message: string): void {
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(message);
|
||||
@@ -88,18 +76,18 @@ class BridgeLegacy extends Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
const entity = settings.getEntity(json.friendly_name);
|
||||
const entity = settings.getDevice(json.friendly_name);
|
||||
assert(entity, `Entity '${json.friendly_name}' does not exist`);
|
||||
settings.changeEntityOptions(entity.ID, json.options);
|
||||
settings.changeEntityOptions(entity.ID.toString(), json.options);
|
||||
logger.info(`Changed device specific options of '${json.friendly_name}' (${stringify(json.options)})`);
|
||||
}
|
||||
|
||||
async permitJoin(topic, message) {
|
||||
@bind async permitJoin(topic: string, message: string): Promise<void> {
|
||||
await this.zigbee.permitJoin(message.toLowerCase() === 'true');
|
||||
this.publish();
|
||||
}
|
||||
|
||||
async reset(topic, message) {
|
||||
@bind async reset(): Promise<void> {
|
||||
try {
|
||||
await this.zigbee.reset('soft');
|
||||
logger.info('Soft resetted ZNP');
|
||||
@@ -108,7 +96,7 @@ class BridgeLegacy extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
lastSeen(topic, message) {
|
||||
@bind lastSeen(topic: string, message: string): void {
|
||||
const allowed = ['disable', 'ISO_8601', 'epoch', 'ISO_8601_local'];
|
||||
if (!allowed.includes(message)) {
|
||||
logger.error(`${message} is not an allowed value, possible: ${allowed}`);
|
||||
@@ -119,7 +107,7 @@ class BridgeLegacy extends Extension {
|
||||
logger.info(`Set last_seen to ${message}`);
|
||||
}
|
||||
|
||||
elapsed(topic, message) {
|
||||
@bind elapsed(topic: string, message: string): void {
|
||||
const allowed = ['true', 'false'];
|
||||
if (!allowed.includes(message)) {
|
||||
logger.error(`${message} is not an allowed value, possible: ${allowed}`);
|
||||
@@ -130,8 +118,8 @@ class BridgeLegacy extends Extension {
|
||||
logger.info(`Set elapsed to ${message}`);
|
||||
}
|
||||
|
||||
logLevel(topic, message) {
|
||||
const level = message.toLowerCase();
|
||||
@bind logLevel(topic: string, message: string): void {
|
||||
const level = message.toLowerCase() as 'error' | 'warn' | 'info' | 'debug';
|
||||
if (allowedLogLevels.includes(level)) {
|
||||
logger.info(`Switching log level to '${level}'`);
|
||||
logger.setLevel(level);
|
||||
@@ -142,30 +130,29 @@ class BridgeLegacy extends Extension {
|
||||
this.publish();
|
||||
}
|
||||
|
||||
async devices(topic, message) {
|
||||
@bind async devices(topic: string): Promise<void> {
|
||||
const coordinator = await this.zigbee.getCoordinatorVersion();
|
||||
const devices = this.zigbee.getDevices().map((device) => {
|
||||
const payload = {
|
||||
const devices = this.zigbee.devices().map((device) => {
|
||||
const payload: KeyValue = {
|
||||
ieeeAddr: device.ieeeAddr,
|
||||
type: device.type,
|
||||
networkAddress: device.networkAddress,
|
||||
type: device.zh.type,
|
||||
networkAddress: device.zh.networkAddress,
|
||||
};
|
||||
|
||||
if (device.type !== 'Coordinator') {
|
||||
const definition = zigbeeHerdsmanConverters.findByDevice(device);
|
||||
const friendlyDevice = settings.getDevice(device.ieeeAddr);
|
||||
payload.model = definition ? definition.model : device.modelID;
|
||||
if (device.zh.type !== 'Coordinator') {
|
||||
const definition = zigbeeHerdsmanConverters.findByDevice(device.zh);
|
||||
payload.model = definition ? definition.model : device.zh.modelID;
|
||||
payload.vendor = definition ? definition.vendor : '-';
|
||||
payload.description = definition ? definition.description : '-';
|
||||
payload.friendly_name = friendlyDevice ? friendlyDevice.friendly_name : device.ieeeAddr;
|
||||
payload.manufacturerID = device.manufacturerID;
|
||||
payload.manufacturerName = device.manufacturerName;
|
||||
payload.powerSource = device.powerSource;
|
||||
payload.modelID = device.modelID;
|
||||
payload.hardwareVersion = device.hardwareVersion;
|
||||
payload.softwareBuildID = device.softwareBuildID;
|
||||
payload.dateCode = device.dateCode;
|
||||
payload.lastSeen = device.lastSeen;
|
||||
payload.friendly_name = device.name;
|
||||
payload.manufacturerID = device.zh.manufacturerID;
|
||||
payload.manufacturerName = device.zh.manufacturerName;
|
||||
payload.powerSource = device.zh.powerSource;
|
||||
payload.modelID = device.zh.modelID;
|
||||
payload.hardwareVersion = device.zh.hardwareVersion;
|
||||
payload.softwareBuildID = device.zh.softwareBuildID;
|
||||
payload.dateCode = device.zh.dateCode;
|
||||
payload.lastSeen = device.zh.lastSeen;
|
||||
} else {
|
||||
payload.friendly_name = 'Coordinator';
|
||||
payload.softwareBuildID = coordinator.type;
|
||||
@@ -185,17 +172,15 @@ class BridgeLegacy extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
groups(topic, message) {
|
||||
@bind groups(): void {
|
||||
const payload = settings.getGroups().map((g) => {
|
||||
const group = {...g};
|
||||
delete group.friendlyName;
|
||||
return group;
|
||||
return {...g, ID: Number(g.ID)};
|
||||
});
|
||||
|
||||
this.mqtt.publish('bridge/log', stringify({type: 'groups', message: payload}));
|
||||
}
|
||||
|
||||
rename(topic, message) {
|
||||
@bind rename(topic: string, message: string): void {
|
||||
const invalid =
|
||||
`Invalid rename message format expected {"old": "friendly_name", "new": "new_name"} got ${message}`;
|
||||
|
||||
@@ -216,7 +201,7 @@ class BridgeLegacy extends Extension {
|
||||
this._renameInternal(json.old, json.new);
|
||||
}
|
||||
|
||||
renameLast(topic, message) {
|
||||
@bind renameLast(topic: string, message: string): void {
|
||||
if (!this.lastJoinedDeviceName) {
|
||||
logger.error(`Cannot rename last joined device, no device has joined during this session`);
|
||||
return;
|
||||
@@ -225,15 +210,15 @@ class BridgeLegacy extends Extension {
|
||||
this._renameInternal(this.lastJoinedDeviceName, message);
|
||||
}
|
||||
|
||||
_renameInternal(from, to) {
|
||||
_renameInternal(from: string, to: string): void {
|
||||
try {
|
||||
const isGroup = settings.getGroup(from) !== null;
|
||||
settings.changeFriendlyName(from, to);
|
||||
logger.info(`Successfully renamed - ${from} to ${to} `);
|
||||
const entity = this.zigbee.resolveEntity(to);
|
||||
const eventData = isGroup ? {group: entity.group} : {device: entity.device, from, to};
|
||||
eventData.homeAssisantRename = false;
|
||||
this.eventBus.emit(`${isGroup ? 'group' : 'device'}Renamed`, eventData);
|
||||
if (entity.isDevice()) {
|
||||
this.eventBus.emitDeviceRenamed({homeAssisantRename: false, from, to, device: entity});
|
||||
}
|
||||
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
@@ -244,7 +229,7 @@ class BridgeLegacy extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
addGroup(topic, message) {
|
||||
@bind addGroup(topic: string, message: string): void {
|
||||
let id = null;
|
||||
let name = null;
|
||||
try {
|
||||
@@ -273,15 +258,15 @@ class BridgeLegacy extends Extension {
|
||||
logger.info(`Added group '${name}'`);
|
||||
}
|
||||
|
||||
removeGroup(topic, message) {
|
||||
@bind removeGroup(topic: string, message: string): void {
|
||||
const name = message;
|
||||
const entity = this.zigbee.resolveEntity(message);
|
||||
assert(entity && entity.type === 'group', `Group '${message}' does not exist`);
|
||||
const entity = this.zigbee.resolveEntity(message) as Group;
|
||||
assert(entity && entity.isGroup(), `Group '${message}' does not exist`);
|
||||
|
||||
if (topic.includes('force')) {
|
||||
entity.group.removeFromDatabase();
|
||||
entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
entity.group.removeFromNetwork();
|
||||
entity.zh.removeFromNetwork();
|
||||
}
|
||||
settings.removeGroup(message);
|
||||
|
||||
@@ -289,21 +274,21 @@ class BridgeLegacy extends Extension {
|
||||
logger.info(`Removed group '${name}'`);
|
||||
}
|
||||
|
||||
async forceRemove(topic, message) {
|
||||
@bind async forceRemove(topic: string, message: string): Promise<void> {
|
||||
await this.removeForceRemoveOrBan('force_remove', message);
|
||||
}
|
||||
|
||||
async remove(topic, message) {
|
||||
@bind async remove(topic: string, message: string): Promise<void> {
|
||||
await this.removeForceRemoveOrBan('remove', message);
|
||||
}
|
||||
|
||||
async ban(topic, message) {
|
||||
@bind async ban(topic: string, message: string): Promise<void> {
|
||||
await this.removeForceRemoveOrBan('ban', message);
|
||||
}
|
||||
|
||||
async removeForceRemoveOrBan(action, message) {
|
||||
const entity = this.zigbee.resolveEntity(message.trim());
|
||||
const lookup = {
|
||||
@bind async removeForceRemoveOrBan(action: string, message: string): Promise<void> {
|
||||
const entity = this.zigbee.resolveEntity(message.trim()) as Device;
|
||||
const lookup: KeyValue = {
|
||||
ban: ['banned', 'Banning', 'ban'],
|
||||
force_remove: ['force_removed', 'Force removing', 'force remove'],
|
||||
remove: ['removed', 'Removing', 'remove'],
|
||||
@@ -316,64 +301,64 @@ class BridgeLegacy extends Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
const ieeeAddr = entity.ieeeAddr;
|
||||
const name = entity.name;
|
||||
|
||||
const cleanup = (): void => {
|
||||
// Fire event
|
||||
this.eventBus.emit('deviceRemoved', {resolvedEntity: entity});
|
||||
this.eventBus.emitDeviceRemoved({ieeeAddr, name});
|
||||
|
||||
// Remove from configuration.yaml
|
||||
settings.removeDevice(entity.settings.ID);
|
||||
settings.removeDevice(entity.ieeeAddr);
|
||||
|
||||
// Remove from state
|
||||
this.state.remove(entity.settings.ID);
|
||||
this.state.remove(ieeeAddr);
|
||||
|
||||
logger.info(`Successfully ${lookup[action][0]} ${entity.settings.friendlyName}`);
|
||||
logger.info(`Successfully ${lookup[action][0]} ${entity.name}`);
|
||||
this.mqtt.publish('bridge/log', stringify({type: `device_${lookup[action][0]}`, message}));
|
||||
};
|
||||
|
||||
try {
|
||||
logger.info(`${lookup[action][1]} '${entity.settings.friendlyName}'`);
|
||||
logger.info(`${lookup[action][1]} '${entity.name}'`);
|
||||
if (action === 'force_remove') {
|
||||
await entity.device.removeFromDatabase();
|
||||
await entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
await entity.device.removeFromNetwork();
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${lookup[action][2]} ${entity.settings.friendlyName} (${error})`);
|
||||
logger.error(`Failed to ${lookup[action][2]} ${entity.name} (${error})`);
|
||||
// eslint-disable-next-line
|
||||
logger.error(`See https://www.zigbee2mqtt.io/information/mqtt_topics_and_message_structure.html#zigbee2mqttbridgeconfigremove for more info`);
|
||||
logger.error(`See https://www.zigbee2mqtt.io/guide/usage/mqtt_topics_and_messages.html#zigbee2mqtt-bridge-request for more info`);
|
||||
|
||||
this.mqtt.publish('bridge/log', stringify({type: `device_${lookup[action][0]}_failed`, message}));
|
||||
}
|
||||
|
||||
if (action === 'ban') {
|
||||
settings.banDevice(entity.settings.ID);
|
||||
settings.banDevice(ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
async onMQTTConnected() {
|
||||
await this.publish();
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const {topic, message} = data;
|
||||
if (!topic.match(configRegex)) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
const option = topic.match(configRegex)[1];
|
||||
|
||||
if (!this.supportedOptions.hasOwnProperty(option)) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
await this.supportedOptions[option](topic, message);
|
||||
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
async publish() {
|
||||
const info = await utils.getZigbee2mqttVersion();
|
||||
async publish(): Promise<void> {
|
||||
const info = await utils.getZigbee2MQTTVersion();
|
||||
const coordinator = await this.zigbee.getCoordinatorVersion();
|
||||
const topic = `bridge/config`;
|
||||
const payload = {
|
||||
@@ -382,13 +367,13 @@ class BridgeLegacy extends Extension {
|
||||
coordinator,
|
||||
network: await this.zigbee.getNetworkParameters(),
|
||||
log_level: logger.getLevel(),
|
||||
permit_join: await this.zigbee.getPermitJoin(),
|
||||
permit_join: this.zigbee.getPermitJoin(),
|
||||
};
|
||||
|
||||
await this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0});
|
||||
}
|
||||
|
||||
onZigbeeEvent(type, data, resolvedEntity) {
|
||||
onZigbeeEvent_(type: string, data: KeyValue, resolvedEntity: Device): void {
|
||||
if (type === 'deviceJoined' && resolvedEntity) {
|
||||
this.lastJoinedDeviceName = resolvedEntity.name;
|
||||
}
|
||||
@@ -436,7 +421,7 @@ class BridgeLegacy extends Extension {
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (type === 'deviceLeave') {
|
||||
const name = resolvedEntity ? resolvedEntity.name : data.ieeeAddr;
|
||||
const name = data.ieeeAddr;
|
||||
const meta = {friendly_name: name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
@@ -446,7 +431,7 @@ class BridgeLegacy extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
async touchlinkFactoryReset() {
|
||||
@bind async touchlinkFactoryReset(): Promise<void> {
|
||||
logger.info('Starting touchlink factory reset...');
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
@@ -469,5 +454,3 @@ class BridgeLegacy extends Extension {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BridgeLegacy;
|
||||
@@ -1,50 +0,0 @@
|
||||
/* istanbul ignore file */
|
||||
const settings = require('../../util/settings');
|
||||
const logger = require('../../util/logger');
|
||||
const Extension = require('../extension');
|
||||
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/device/(.+)/get_group_membership$`);
|
||||
|
||||
class DeviceGroupMembership extends Extension {
|
||||
async onMQTTMessage(topic, message) {
|
||||
const match = topic.match(topicRegex);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entity = this.zigbee.resolveEntity(match[1]);
|
||||
if (!entity || entity.type !== 'device') {
|
||||
logger.error(`Device '${match[1]}' does not exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await entity.endpoint.command(
|
||||
`genGroups`, 'getMembership', {groupcount: 0, grouplist: []}, {}, true,
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
logger.warn(`Couldn't get group membership of ${entity.device.ieeeAddr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let {grouplist, capacity} = response;
|
||||
|
||||
grouplist = grouplist.map((gid) => {
|
||||
const g = settings.getGroup(gid);
|
||||
return g ? g.friendlyName : gid;
|
||||
});
|
||||
|
||||
const msgGroupList = `${entity.device.ieeeAddr} is in groups [${grouplist}]`;
|
||||
let msgCapacity;
|
||||
if (capacity === 254) {
|
||||
msgCapacity = 'it can be a part of at least 1 more group';
|
||||
} else {
|
||||
msgCapacity = `its remaining group capacity is ${capacity === 255 ? 'unknown' : capacity}`;
|
||||
}
|
||||
logger.info(`${msgGroupList} and ${msgCapacity}`);
|
||||
|
||||
this.publishEntityState(entity.device.ieeeAddr, {group_list: grouplist, group_capacity: capacity});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DeviceGroupMembership;
|
||||
@@ -0,0 +1,56 @@
|
||||
/* istanbul ignore file */
|
||||
import * as settings from '../../util/settings';
|
||||
import logger from '../../util/logger';
|
||||
import utils from '../../util/utils';
|
||||
import Extension from '../extension';
|
||||
import bind from 'bind-decorator';
|
||||
import Device from '../../model/device';
|
||||
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/device/(.+)/get_group_membership$`);
|
||||
|
||||
export default class DeviceGroupMembership extends Extension {
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(topicRegex);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = utils.parseEntityID(match[1]);
|
||||
const device = this.zigbee.resolveEntity(parsed.ID) as Device;
|
||||
if (!device || !(device instanceof Device)) {
|
||||
logger.error(`Device '${match[1]}' does not exist`);
|
||||
return;
|
||||
}
|
||||
const endpoint = device.endpoint(parsed.endpoint);
|
||||
const response = await endpoint.command(
|
||||
`genGroups`, 'getMembership', {groupcount: 0, grouplist: []}, {},
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
logger.warn(`Couldn't get group membership of ${device.ieeeAddr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let {grouplist, capacity} = response;
|
||||
|
||||
grouplist = grouplist.map((gid: string) => {
|
||||
const g = settings.getGroup(gid);
|
||||
return g ? g.friendly_name : gid;
|
||||
});
|
||||
|
||||
const msgGroupList = `${device.ieeeAddr} is in groups [${grouplist}]`;
|
||||
let msgCapacity;
|
||||
if (capacity === 254) {
|
||||
msgCapacity = 'it can be a part of at least 1 more group';
|
||||
} else {
|
||||
msgCapacity = `its remaining group capacity is ${capacity === 255 ? 'unknown' : capacity}`;
|
||||
}
|
||||
logger.info(`${msgGroupList} and ${msgCapacity}`);
|
||||
|
||||
this.publishEntityState(device, {group_list: grouplist, group_capacity: capacity});
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,40 @@
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const logger = require('../../util/logger');
|
||||
const settings = require('../../util/settings');
|
||||
const ZNLDP12LM = zigbeeHerdsmanConverters.devices.find((d) => d.model === 'ZNLDP12LM');
|
||||
const utils = require('../../util/utils');
|
||||
const Extension = require('../extension');
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
import logger from '../../util/logger';
|
||||
import * as settings from '../../util/settings';
|
||||
import Extension from '../extension';
|
||||
|
||||
const defaultConfiguration = {
|
||||
minimumReportInterval: 3, maximumReportInterval: 300, reportableChange: 1,
|
||||
};
|
||||
|
||||
const ZNLDP12LM = zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'ZNLDP12LM');
|
||||
|
||||
const devicesNotSupportingReporting = [
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'CC2530.ROUTER'),
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'BASICZBR3'),
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'ZM-CSW032-D'),
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'TS0001'),
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'TS0115'),
|
||||
zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'CC2530.ROUTER'),
|
||||
zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'BASICZBR3'),
|
||||
zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'ZM-CSW032-D'),
|
||||
zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'TS0001'),
|
||||
zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'TS0115'),
|
||||
];
|
||||
|
||||
const reportKey = 1;
|
||||
|
||||
const getColorCapabilities = async (endpoint) => {
|
||||
const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean, colorXY: boolean}> => {
|
||||
if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') === undefined) {
|
||||
await endpoint.read('lightingColorCtrl', ['colorCapabilities']);
|
||||
}
|
||||
|
||||
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities');
|
||||
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number;
|
||||
return {
|
||||
colorTemperature: (value & 1<<4) > 0,
|
||||
colorXY: (value & 1<<3) > 0,
|
||||
};
|
||||
};
|
||||
|
||||
const clusters = {
|
||||
const clusters: {[s: string]:
|
||||
{attribute: string, minimumReportInterval: number, maximumReportInterval: number, reportableChange: number
|
||||
condition?: (endpoint: zh.Endpoint) => Promise<boolean>}[]} =
|
||||
{
|
||||
'genOnOff': [
|
||||
{attribute: 'onOff', ...defaultConfiguration, minimumReportInterval: 0, reportableChange: 0},
|
||||
],
|
||||
@@ -41,15 +44,15 @@ const clusters = {
|
||||
'lightingColorCtrl': [
|
||||
{
|
||||
attribute: 'colorTemperature', ...defaultConfiguration,
|
||||
condition: async (endpoint) => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
},
|
||||
{
|
||||
attribute: 'currentX', ...defaultConfiguration,
|
||||
condition: async (endpoint) => (await getColorCapabilities(endpoint)).colorXY,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
{
|
||||
attribute: 'currentY', ...defaultConfiguration,
|
||||
condition: async (endpoint) => (await getColorCapabilities(endpoint)).colorXY,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
],
|
||||
'closuresWindowCovering': [
|
||||
@@ -58,15 +61,12 @@ const clusters = {
|
||||
],
|
||||
};
|
||||
|
||||
class Report extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.queue = new Set();
|
||||
this.failed = new Set();
|
||||
this.enabled = settings.get().advanced.report;
|
||||
}
|
||||
export default class Report extends Extension {
|
||||
private queue: Set<string> = new Set();
|
||||
private failed: Set<string> = new Set();
|
||||
private enabled = settings.get().advanced.report;
|
||||
|
||||
shouldIgnoreClusterForDevice(cluster, definition) {
|
||||
shouldIgnoreClusterForDevice(cluster: string, definition: zhc.Definition): boolean {
|
||||
if (definition === ZNLDP12LM && cluster === 'closuresWindowCovering') {
|
||||
// Device announces it but doesn't support it
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/2611
|
||||
@@ -76,9 +76,7 @@ class Report extends Extension {
|
||||
return false;
|
||||
}
|
||||
|
||||
async setupReporting(resolvedEntity) {
|
||||
const {device, definition} = resolvedEntity;
|
||||
|
||||
async setupReporting(device: Device): Promise<void> {
|
||||
if (this.queue.has(device.ieeeAddr) || this.failed.has(device.ieeeAddr)) return;
|
||||
this.queue.add(device.ieeeAddr);
|
||||
|
||||
@@ -86,9 +84,10 @@ class Report extends Extension {
|
||||
const term2 = this.enabled ? 'setup' : 'disabled';
|
||||
|
||||
try {
|
||||
for (const ep of device.endpoints) {
|
||||
for (const ep of device.zh.endpoints) {
|
||||
for (const [cluster, configuration] of Object.entries(clusters)) {
|
||||
if (ep.supportsInputCluster(cluster) && !this.shouldIgnoreClusterForDevice(cluster, definition)) {
|
||||
if (ep.supportsInputCluster(cluster) &&
|
||||
!this.shouldIgnoreClusterForDevice(cluster, device.definition)) {
|
||||
logger.debug(`${term1} reporting for '${device.ieeeAddr}' - ${ep.ID} - ${cluster}`);
|
||||
|
||||
const items = [];
|
||||
@@ -102,8 +101,8 @@ class Report extends Extension {
|
||||
}
|
||||
|
||||
this.enabled ?
|
||||
await ep.bind(cluster, this.coordinatorEndpoint) :
|
||||
await ep.unbind(cluster, this.coordinatorEndpoint);
|
||||
await ep.bind(cluster, this.zigbee.firstCoordinatorEndpoint()) :
|
||||
await ep.unbind(cluster, this.zigbee.firstCoordinatorEndpoint());
|
||||
|
||||
await ep.configureReporting(cluster, items);
|
||||
logger.info(
|
||||
@@ -114,13 +113,13 @@ class Report extends Extension {
|
||||
}
|
||||
|
||||
if (this.enabled) {
|
||||
device.meta.reporting = reportKey;
|
||||
device.zh.meta.reporting = reportKey;
|
||||
} else {
|
||||
delete device.meta.reporting;
|
||||
this.eventBus.emit('reportingDisabled', {device});
|
||||
delete device.zh.meta.reporting;
|
||||
this.eventBus.emitReportingDisabled({device});
|
||||
}
|
||||
|
||||
this.eventBus.emit(`devicesChanged`);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to ${term1.toLowerCase()} reporting for '${device.ieeeAddr}' - ${error.stack}`,
|
||||
@@ -129,15 +128,13 @@ class Report extends Extension {
|
||||
this.failed.add(device.ieeeAddr);
|
||||
}
|
||||
|
||||
device.save();
|
||||
device.zh.save();
|
||||
this.queue.delete(device.ieeeAddr);
|
||||
}
|
||||
|
||||
shouldSetupReporting(resolvedEntity, messageType) {
|
||||
if (!resolvedEntity || !resolvedEntity.device || !resolvedEntity.definition ||
|
||||
messageType === 'deviceLeave') return false;
|
||||
shouldSetupReporting(device: Device, messageType: string): boolean {
|
||||
if (!device || !device.zh || !device.definition) return false;
|
||||
|
||||
const {device, definition} = resolvedEntity;
|
||||
// Handle messages of type endDeviceAnnce and devIncoming.
|
||||
// This message is typically send when a device comes online after being powered off
|
||||
// Ikea TRADFRI tend to forget their reporting after powered off.
|
||||
@@ -146,48 +143,52 @@ class Report extends Extension {
|
||||
// else reconfigure is done in zigbee-herdsman-converters ikea.js/bulbOnEvent
|
||||
// configuredReportings are saved since Zigbee2MQTT 1.17.0
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/966
|
||||
if (this.enabled && messageType === 'deviceAnnounce' && utils.isIkeaTradfriDevice(device) &&
|
||||
device.endpoints.filter((e) => e.configuredReportings.length === 0).length === device.endpoints.length) {
|
||||
if (this.enabled && messageType === 'deviceAnnounce' && device.isIkeaTradfri() &&
|
||||
device.zh.endpoints.filter((e) => e.configuredReportings.length === 0).length ===
|
||||
device.zh.endpoints.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// These do not support reproting.
|
||||
// https://github.com/Koenkk/zigbee-herdsman/issues/110
|
||||
const philipsIgnoreSw = ['5.127.1.26581', '5.130.1.30000'];
|
||||
if (device.manufacturerName === 'Philips' && philipsIgnoreSw.includes(device.softwareBuildID)) return false;
|
||||
if (device.zh.manufacturerName === 'Philips' &&
|
||||
philipsIgnoreSw.includes(device.zh.softwareBuildID)) return false;
|
||||
|
||||
if (resolvedEntity.device.interviewing === true) return false;
|
||||
if (device.type !== 'Router' || device.powerSource === 'Battery') return false;
|
||||
if (device.zh.interviewing === true) return false;
|
||||
if (device.zh.type !== 'Router' || device.zh.powerSource === 'Battery') return false;
|
||||
// Gledopto devices don't support reporting.
|
||||
if (devicesNotSupportingReporting.includes(definition) || definition.vendor === 'Gledopto') return false;
|
||||
if (devicesNotSupportingReporting.includes(device.definition) ||
|
||||
device.definition.vendor === 'Gledopto') return false;
|
||||
|
||||
if (this.enabled && device.meta.hasOwnProperty('reporting') && device.meta.reporting === reportKey) {
|
||||
if (this.enabled && device.zh.meta.hasOwnProperty('reporting') &&
|
||||
device.zh.meta.reporting === reportKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.enabled && !device.meta.hasOwnProperty('reporting')) {
|
||||
if (!this.enabled && !device.zh.meta.hasOwnProperty('reporting')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async onZigbeeStarted() {
|
||||
this.coordinatorEndpoint = this.zigbee.getDevicesByType('Coordinator')[0].getEndpoint(1);
|
||||
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
if (this.shouldSetupReporting(resolvedEntity, null)) {
|
||||
await this.setupReporting(resolvedEntity);
|
||||
override async start(): Promise<void> {
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
if (this.shouldSetupReporting(device, null)) {
|
||||
await this.setupReporting(device);
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => this.onZigbeeEvent_('deviceAnnounce', data.device));
|
||||
this.eventBus.onDeviceMessage(this, (data) => this.onZigbeeEvent_('dummy', data.device));
|
||||
this.eventBus.onDeviceJoined(this, (data) => this.onZigbeeEvent_('dummy', data.device));
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, (data) => this.onZigbeeEvent_('dummy', data.device));
|
||||
}
|
||||
|
||||
async onZigbeeEvent(type, data, resolvedEntity) {
|
||||
if (this.shouldSetupReporting(resolvedEntity, type)) {
|
||||
await this.setupReporting(resolvedEntity);
|
||||
async onZigbeeEvent_(type: string, device: Device): Promise<void> {
|
||||
if (this.shouldSetupReporting(device, type)) {
|
||||
await this.setupReporting(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Report;
|
||||
@@ -1,33 +1,35 @@
|
||||
/* istanbul ignore file */
|
||||
// DEPRECATED
|
||||
const settings = require('../../util/settings');
|
||||
const logger = require('../../util/logger');
|
||||
const utils = require('../../util/utils');
|
||||
const Extension = require('../extension');
|
||||
import * as settings from '../../util/settings';
|
||||
import logger from '../../util/logger';
|
||||
import utils from '../../util/utils';
|
||||
import Extension from '../extension';
|
||||
|
||||
/**
|
||||
* This extensions soft resets the ZNP after a certain timeout.
|
||||
*/
|
||||
class SoftReset extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.timer = null;
|
||||
this.timeout = utils.secondsToMilliseconds(settings.get().advanced.soft_reset_timeout);
|
||||
}
|
||||
export default class SoftReset extends Extension {
|
||||
private timer: NodeJS.Timer = null;
|
||||
private timeout = utils.seconds(settings.get().advanced.soft_reset_timeout);
|
||||
|
||||
onZigbeeStarted() {
|
||||
logger.debug(`Soft reset timeout set to ${utils.millisecondsToSeconds(this.timeout)} seconds`);
|
||||
override async start(): Promise<void> {
|
||||
logger.debug(`Soft reset timeout set to ${this.timeout / 1000} seconds`);
|
||||
this.resetTimer();
|
||||
this.eventBus.onDeviceMessage(this, () => this.resetTimer());
|
||||
this.eventBus.onDeviceAnnounce(this, () => this.resetTimer());
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, () => this.resetTimer());
|
||||
this.eventBus.onDeviceJoined(this, () => this.resetTimer());
|
||||
this.eventBus.onDeviceInterview(this, () => this.resetTimer());
|
||||
}
|
||||
|
||||
clearTimer() {
|
||||
private clearTimer(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
resetTimer() {
|
||||
private resetTimer(): void {
|
||||
if (this.timeout === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -36,7 +38,7 @@ class SoftReset extends Extension {
|
||||
this.timer = setTimeout(() => this.handleTimeout(), this.timeout);
|
||||
}
|
||||
|
||||
async handleTimeout() {
|
||||
private async handleTimeout(): Promise<void> {
|
||||
logger.warn('Soft reset timeout triggered');
|
||||
|
||||
try {
|
||||
@@ -57,10 +59,4 @@ class SoftReset extends Extension {
|
||||
|
||||
this.resetTimer();
|
||||
}
|
||||
|
||||
onZigbeeEvent() {
|
||||
this.resetTimer();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SoftReset;
|
||||
@@ -1,27 +1,36 @@
|
||||
const settings = require('../util/settings');
|
||||
const utils = require('../util/utils');
|
||||
const logger = require('../util/logger');
|
||||
const Extension = require('./extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import logger from '../util/logger';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import Extension from './extension';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
interface Link {
|
||||
source: {ieeeAddr: string, networkAddress: number}, target: {ieeeAddr: string, networkAddress: number},
|
||||
linkquality: number, depth: number, routes: zh.RoutingTableEntry[],
|
||||
sourceIeeeAddr: string, targetIeeeAddr: string, sourceNwkAddr: number, lqi: number, relationship: number,
|
||||
}
|
||||
|
||||
interface Topology {
|
||||
nodes: {
|
||||
ieeeAddr: string, friendlyName: string, type: string, networkAddress: number, manufacturerName: string,
|
||||
modelID: string, failed: string[], lastSeen: number,
|
||||
definition: {model: string, vendor: string, supports: string, description: string}}[],
|
||||
links: Link[],
|
||||
}
|
||||
|
||||
/**
|
||||
* This extension creates a network map
|
||||
*/
|
||||
class NetworkMap extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
export default class NetworkMap extends Extension {
|
||||
private legacyApi = settings.get().advanced.legacy_api;
|
||||
private legacyTopic = `${settings.get().mqtt.base_topic}/bridge/networkmap`;
|
||||
private legacyTopicRoutes = `${settings.get().mqtt.base_topic}/bridge/networkmap/routes`;
|
||||
private topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
|
||||
private supportedFormats: {[s: string]: (topology: Topology) => KeyValue | string};
|
||||
|
||||
this.legacyApi = settings.get().advanced.legacy_api;
|
||||
this.legacyTopic = `${settings.get().mqtt.base_topic}/bridge/networkmap`;
|
||||
this.legacyTopicRoutes = `${settings.get().mqtt.base_topic}/bridge/networkmap/routes`;
|
||||
this.topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
|
||||
|
||||
// Bind
|
||||
this.raw = this.raw.bind(this);
|
||||
this.graphviz = this.graphviz.bind(this);
|
||||
this.plantuml = this.plantuml.bind(this);
|
||||
|
||||
// Set supported formats
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.supportedFormats = {
|
||||
'raw': this.raw,
|
||||
'graphviz': this.graphviz,
|
||||
@@ -29,22 +38,22 @@ class NetworkMap extends Extension {
|
||||
};
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
/* istanbul ignore else */
|
||||
if (this.legacyApi) {
|
||||
if ((topic === this.legacyTopic || topic === this.legacyTopicRoutes) &&
|
||||
this.supportedFormats.hasOwnProperty(message)) {
|
||||
const includeRoutes = topic === this.legacyTopicRoutes;
|
||||
if ((data.topic === this.legacyTopic || data.topic === this.legacyTopicRoutes) &&
|
||||
this.supportedFormats.hasOwnProperty(data.message)) {
|
||||
const includeRoutes = data.topic === this.legacyTopicRoutes;
|
||||
const topology = await this.networkScan(includeRoutes);
|
||||
let converted = this.supportedFormats[message](topology);
|
||||
converted = message === 'raw' ? stringify(converted) : converted;
|
||||
this.mqtt.publish(`bridge/networkmap/${message}`, converted, {});
|
||||
let converted = this.supportedFormats[data.message](topology);
|
||||
converted = data.message === 'raw' ? stringify(converted) : converted;
|
||||
this.mqtt.publish(`bridge/networkmap/${data.message}`, converted as string, {});
|
||||
}
|
||||
}
|
||||
|
||||
if (topic === this.topic) {
|
||||
if (data.topic === this.topic) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
try {
|
||||
message = utils.parseJSON(message, message);
|
||||
const type = typeof message === 'object' ? message.type : message;
|
||||
if (!this.supportedFormats.hasOwnProperty(type)) {
|
||||
throw new Error(`Type '${type}' not supported, allowed are: ${Object.keys(this.supportedFormats)}`);
|
||||
@@ -66,11 +75,11 @@ class NetworkMap extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
raw(topology) {
|
||||
@bind raw(topology: Topology): KeyValue {
|
||||
return topology;
|
||||
}
|
||||
|
||||
graphviz(topology) {
|
||||
@bind graphviz(topology: Topology): string {
|
||||
const colors = settings.get().map_options.graphviz.colors;
|
||||
|
||||
let text = 'digraph G {\nnode[shape=record];\n';
|
||||
@@ -84,7 +93,7 @@ class NetworkMap extends Extension {
|
||||
|
||||
// Add the device short network address, ieeaddr and scan note (if any)
|
||||
labels.push(
|
||||
`${node.ieeeAddr} (${node.networkAddress})` +
|
||||
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})` +
|
||||
((node.failed && node.failed.length) ? `failed: ${node.failed.join(',')}` : ''),
|
||||
);
|
||||
|
||||
@@ -102,7 +111,7 @@ class NetworkMap extends Extension {
|
||||
let lastSeen = 'unknown';
|
||||
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen;
|
||||
if (date) {
|
||||
lastSeen = utils.formatDate(date, 'relative');
|
||||
lastSeen = utils.formatDate(date, 'relative') as string;
|
||||
}
|
||||
|
||||
labels.push(lastSeen);
|
||||
@@ -132,7 +141,7 @@ class NetworkMap extends Extension {
|
||||
(!e.routes.length) ? 'penwidth=0.5, ' : 'penwidth=2, ';
|
||||
const lineWeight = (!e.routes.length) ? `weight=0, color="${colors.line.inactive}", ` :
|
||||
`weight=1, color="${colors.line.active}", `;
|
||||
const textRoutes = e.routes.map((r) => r.destinationAddress);
|
||||
const textRoutes = e.routes.map((r) => utils.toNetworkAddressHex(r.destinationAddress));
|
||||
const lineLabels = (!e.routes.length) ? `label="${e.linkquality}"` :
|
||||
`label="${e.linkquality} (routes: ${textRoutes.join(',')})"`;
|
||||
text += ` "${node.ieeeAddr}" -> "${e.target.ieeeAddr}"`;
|
||||
@@ -145,7 +154,7 @@ class NetworkMap extends Extension {
|
||||
return text.replace(/\0/g, '');
|
||||
}
|
||||
|
||||
plantuml(topology) {
|
||||
@bind plantuml(topology: Topology): string {
|
||||
const text = [];
|
||||
|
||||
text.push(`' paste into: https://www.planttext.com/`);
|
||||
@@ -160,14 +169,14 @@ class NetworkMap extends Extension {
|
||||
|
||||
// Add the device short network address, ieeaddr and scan note (if any)
|
||||
text.push(
|
||||
`${node.ieeeAddr} (${node.networkAddress})` +
|
||||
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})` +
|
||||
((node.failed && node.failed.length) ? ` failed: ${node.failed.join(',')}` : ''),
|
||||
);
|
||||
|
||||
// Add the device model
|
||||
if (node.type !== 'Coordinator') {
|
||||
text.push(`---`);
|
||||
const definition = this.zigbee.resolveEntity(node.ieeeAddr).definition;
|
||||
const definition = (this.zigbee.resolveEntity(node.ieeeAddr) as Device).definition;
|
||||
if (definition) {
|
||||
text.push(`${definition.vendor} ${definition.description} (${definition.model})`);
|
||||
} else {
|
||||
@@ -180,7 +189,7 @@ class NetworkMap extends Extension {
|
||||
let lastSeen = 'unknown';
|
||||
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen;
|
||||
if (date) {
|
||||
lastSeen = utils.formatDate(date, 'relative');
|
||||
lastSeen = utils.formatDate(date, 'relative') as string;
|
||||
}
|
||||
text.push(`---`);
|
||||
text.push(lastSeen);
|
||||
@@ -203,59 +212,71 @@ class NetworkMap extends Extension {
|
||||
return text.join(`\n`);
|
||||
}
|
||||
|
||||
async networkScan(includeRoutes) {
|
||||
async networkScan(includeRoutes: boolean): Promise<Topology> {
|
||||
logger.info(`Starting network scan (includeRoutes '${includeRoutes}')`);
|
||||
const devices = this.zigbee.getDevices().filter((d) => d.type !== 'GreenPower');
|
||||
const lqis = new Map();
|
||||
const routingTables = new Map();
|
||||
const failed = new Map();
|
||||
const devices = this.zigbee.devices().filter((d) => d.zh.type !== 'GreenPower');
|
||||
const lqis: Map<Device, zh.LQI> = new Map();
|
||||
const routingTables: Map<Device, zh.RoutingTable> = new Map();
|
||||
const failed: Map<Device, string[]> = new Map();
|
||||
|
||||
for (const device of devices.filter((d) => d.type != 'EndDevice')) {
|
||||
for (const device of devices.filter((d) => d.zh.type != 'EndDevice')) {
|
||||
failed.set(device, []);
|
||||
await utils.sleep(1); // sleep 1 second between each scan to reduce stress on network.
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
|
||||
const doRequest = async <T>(request: () => Promise<T>, firstAttempt = true): Promise<T> => {
|
||||
try {
|
||||
return await request();
|
||||
} catch (error) {
|
||||
if (!firstAttempt) {
|
||||
throw error;
|
||||
} else {
|
||||
// Network is possibly congested, sleep 5 seconds to let the network settle.
|
||||
await utils.sleep(5);
|
||||
return doRequest(request, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await device.lqi();
|
||||
const result = await doRequest<zh.LQI>(async () => device.zh.lqi());
|
||||
lqis.set(device, result);
|
||||
logger.debug(`LQI succeeded for '${resolvedEntity.name}'`);
|
||||
logger.debug(`LQI succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
failed.get(device).push('lqi');
|
||||
logger.error(`Failed to execute LQI for '${resolvedEntity.name}'`);
|
||||
logger.error(`Failed to execute LQI for '${device.name}'`);
|
||||
}
|
||||
|
||||
if (includeRoutes) {
|
||||
try {
|
||||
const result = await device.routingTable();
|
||||
const result = await doRequest(async () => device.zh.routingTable());
|
||||
routingTables.set(device, result);
|
||||
logger.debug(`Routing table succeeded for '${resolvedEntity.name}'`);
|
||||
logger.debug(`Routing table succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
failed.get(device).push('routingTable');
|
||||
logger.error(`Failed to execute routing table for '${resolvedEntity.name}'`);
|
||||
logger.error(`Failed to execute routing table for '${device.name}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Network scan finished`);
|
||||
|
||||
const networkMap = {nodes: [], links: []};
|
||||
const topology: Topology = {nodes: [], links: []};
|
||||
// Add nodes
|
||||
for (const device of devices) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
const definition = resolvedEntity.definition ? {
|
||||
model: resolvedEntity.definition.model,
|
||||
vendor: resolvedEntity.definition.vendor,
|
||||
description: resolvedEntity.definition.description,
|
||||
supports: Array.from(new Set((resolvedEntity.definition.exposes).map((e) => {
|
||||
return e.hasOwnProperty('name') ?
|
||||
e.name :
|
||||
const definition = device.definition ? {
|
||||
model: device.definition.model,
|
||||
vendor: device.definition.vendor,
|
||||
description: device.definition.description,
|
||||
supports: Array.from(new Set((device.definition.exposes).map((e) => {
|
||||
return e.hasOwnProperty('name') ? e.name :
|
||||
`${e.type} (${e.features.map((f) => f.name).join(', ')})`;
|
||||
}))).join(', '),
|
||||
} : null;
|
||||
|
||||
networkMap.nodes.push({
|
||||
ieeeAddr: device.ieeeAddr, friendlyName: resolvedEntity.name, type: device.type,
|
||||
networkAddress: device.networkAddress, manufacturerName: device.manufacturerName,
|
||||
modelID: device.modelID, failed: failed.get(device), lastSeen: device.lastSeen,
|
||||
topology.nodes.push({
|
||||
ieeeAddr: device.ieeeAddr, friendlyName: device.name, type: device.zh.type,
|
||||
networkAddress: device.zh.networkAddress, manufacturerName: device.zh.manufacturerName,
|
||||
modelID: device.zh.modelID, failed: failed.get(device), lastSeen: device.zh.lastSeen,
|
||||
definition,
|
||||
});
|
||||
}
|
||||
@@ -270,14 +291,14 @@ class NetworkMap extends Extension {
|
||||
|
||||
// Some Xiaomi devices return 0x00 as the neighbor ieeeAddr (obviously not correct).
|
||||
// Determine the correct ieeeAddr based on the networkAddress.
|
||||
const neighborDevice = this.zigbee.getDeviceByNetworkAddress(neighbor.networkAddress);
|
||||
const neighborDevice = this.zigbee.deviceByNetworkAddress(neighbor.networkAddress);
|
||||
if (neighbor.ieeeAddr === '0x0000000000000000' && neighborDevice) {
|
||||
neighbor.ieeeAddr = neighborDevice.ieeeAddr;
|
||||
}
|
||||
|
||||
const link = {
|
||||
const link: Link = {
|
||||
source: {ieeeAddr: neighbor.ieeeAddr, networkAddress: neighbor.networkAddress},
|
||||
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.networkAddress},
|
||||
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.zh.networkAddress},
|
||||
linkquality: neighbor.linkquality, depth: neighbor.depth, routes: [],
|
||||
// DEPRECATED:
|
||||
sourceIeeeAddr: neighbor.ieeeAddr, targetIeeeAddr: device.ieeeAddr,
|
||||
@@ -291,12 +312,10 @@ class NetworkMap extends Extension {
|
||||
.filter((t) => t.status === 'ACTIVE' && t.nextHop === neighbor.networkAddress);
|
||||
}
|
||||
|
||||
networkMap.links.push(link);
|
||||
topology.links.push(link);
|
||||
}
|
||||
});
|
||||
|
||||
return networkMap;
|
||||
return topology;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NetworkMap;
|
||||
@@ -1,38 +0,0 @@
|
||||
const Extension = require('./extension');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
|
||||
/**
|
||||
* This extension calls the zigbee-herdsman-converters onEvent.
|
||||
*/
|
||||
class OnEvent extends Extension {
|
||||
async onZigbeeStarted() {
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
this.callOnEvent(resolvedEntity, 'start', {});
|
||||
}
|
||||
}
|
||||
|
||||
onZigbeeEvent(type, data, resolvedEntity) {
|
||||
if (resolvedEntity && resolvedEntity.type === 'device') {
|
||||
this.callOnEvent(resolvedEntity, type, data);
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
super.stop();
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
this.callOnEvent(resolvedEntity, 'stop', {});
|
||||
}
|
||||
}
|
||||
|
||||
callOnEvent(resolvedEntity, type, data) {
|
||||
zigbeeHerdsmanConverters.onEvent(type, data, resolvedEntity.device, resolvedEntity.settings);
|
||||
|
||||
if (resolvedEntity.definition && resolvedEntity.definition.onEvent) {
|
||||
resolvedEntity.definition.onEvent(type, data, resolvedEntity.device, resolvedEntity.settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OnEvent;
|
||||
@@ -0,0 +1,42 @@
|
||||
import zhc from 'zigbee-herdsman-converters';
|
||||
import Extension from './extension';
|
||||
|
||||
/**
|
||||
* This extension calls the zigbee-herdsman-converters onEvent.
|
||||
*/
|
||||
export default class OnEvent extends Extension {
|
||||
override async start(): Promise<void> {
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
this.callOnEvent(device, 'start', {});
|
||||
}
|
||||
|
||||
this.eventBus.onDeviceMessage(this, (data) => this.callOnEvent(data.device, 'message', this.convertData(data)));
|
||||
this.eventBus.onDeviceJoined(this,
|
||||
(data) => this.callOnEvent(data.device, 'deviceJoined', this.convertData(data)));
|
||||
this.eventBus.onDeviceInterview(this,
|
||||
(data) => this.callOnEvent(data.device, 'deviceInterview', this.convertData(data)));
|
||||
this.eventBus.onDeviceAnnounce(this,
|
||||
(data) => this.callOnEvent(data.device, 'deviceAnnounce', this.convertData(data)));
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this,
|
||||
(data) => this.callOnEvent(data.device, 'deviceNetworkAddressChanged', this.convertData(data)));
|
||||
}
|
||||
|
||||
private convertData(data: KeyValue): KeyValue {
|
||||
return {...data, device: data.device.zh};
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
super.stop();
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
await this.callOnEvent(device, 'stop', {});
|
||||
}
|
||||
}
|
||||
|
||||
private async callOnEvent(device: Device, type: string, data: KeyValue): Promise<void> {
|
||||
zhc.onEvent(type, data, device.zh);
|
||||
|
||||
if (device.definition?.onEvent) {
|
||||
await device.definition.onEvent(type, data, device.zh, device.settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +1,79 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const utils = require('../util/utils');
|
||||
import * as settings from '../util/settings';
|
||||
import logger from '../util/logger';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import utils from '../util/utils';
|
||||
import tradfriOTA from 'zigbee-herdsman-converters/lib/ota/tradfri';
|
||||
import Extension from './extension';
|
||||
import bind from 'bind-decorator';
|
||||
import Device from '../model/device';
|
||||
|
||||
type UpdateState = 'updating' | 'idle' | 'available';
|
||||
interface UpdatePayload {
|
||||
// eslint-disable-next-line camelcase
|
||||
update: {progress?: number, remaining?: number, state: UpdateState}, update_available?: boolean}
|
||||
|
||||
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/ota_update/.+$`);
|
||||
const tradfriOTA = require('zigbee-herdsman-converters/lib/ota/tradfri');
|
||||
const topicRegex =
|
||||
new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check)`, 'i');
|
||||
|
||||
const Extension = require('./extension');
|
||||
|
||||
class OTAUpdate extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.inProgress = new Set();
|
||||
this.lastChecked = {};
|
||||
this.legacyApi = settings.get().advanced.legacy_api;
|
||||
export default class OTAUpdate extends Extension {
|
||||
private inProgress = new Set();
|
||||
private lastChecked: {[s: string]: number} = {};
|
||||
private legacyApi = settings.get().advanced.legacy_api;
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.eventBus.onDeviceMessage(this, this.onZigbeeEvent);
|
||||
if (settings.get().advanced.ikea_ota_use_test_url) {
|
||||
tradfriOTA.useTestURL();
|
||||
}
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
// In case Zigbee2MQTT is restared during an update, progress and remaining values are still in state.
|
||||
// remove them.
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
}
|
||||
}
|
||||
|
||||
removeProgressAndRemainingFromState(device) {
|
||||
this.state.removeKey(device.ieeeAddr, ['update', 'progress']);
|
||||
this.state.removeKey(device.ieeeAddr, ['update', 'remaining']);
|
||||
private removeProgressAndRemainingFromState(device: Device): void {
|
||||
delete this.state.get(device)?.update?.progress;
|
||||
delete this.state.get(device)?.update?.remaining;
|
||||
}
|
||||
|
||||
async onZigbeeEvent(type, data, resolvedEntity) {
|
||||
@bind private async onZigbeeEvent(data: eventdata.DeviceMessage): Promise<void> {
|
||||
if (settings.get().ota.disable_automatic_update_check) return;
|
||||
if (data.type !== 'commandQueryNextImageRequest' || !resolvedEntity || !resolvedEntity.definition) return;
|
||||
if (data.type !== 'commandQueryNextImageRequest' || !data.device.definition) return;
|
||||
logger.debug(`Device '${data.device.name}' requested OTA`);
|
||||
|
||||
const supportsOTA = resolvedEntity.definition.hasOwnProperty('ota');
|
||||
let supportsOTA = data.device.definition.hasOwnProperty('ota');
|
||||
if (supportsOTA) {
|
||||
// When a device does a next image request, it will usually do it a few times after each other
|
||||
// with only 10 - 60 seconds inbetween. It doesn't make sense to check for a new update
|
||||
// each time, so this interval can be set by the user. The default is 10 minutes.
|
||||
// each time, so this interval can be set by the user. The default is 1,440 minutes (one day).
|
||||
const updateCheckInterval = settings.get().ota.update_check_interval * 1000 * 60;
|
||||
const check = this.lastChecked.hasOwnProperty(data.device.ieeeAddr) ?
|
||||
(Date.now() - this.lastChecked[data.device.ieeeAddr]) > updateCheckInterval : true;
|
||||
if (!check || this.inProgress.has(data.device.ieeeAddr)) return;
|
||||
|
||||
this.lastChecked[data.device.ieeeAddr] = Date.now();
|
||||
const available = await resolvedEntity.definition.ota.isUpdateAvailable(data.device, logger, data.data);
|
||||
let available = false;
|
||||
try {
|
||||
available = await data.device.definition.ota.isUpdateAvailable(data.device.zh, logger, data.data);
|
||||
} catch (e) {
|
||||
supportsOTA = false;
|
||||
logger.debug(`Failed to check if update available for '${data.device.name}' (${e.message})`);
|
||||
}
|
||||
|
||||
const payload = this.getEntityPublishPayload(available ? 'available' : 'idle');
|
||||
this.publishEntityState(data.device.ieeeAddr, payload);
|
||||
this.publishEntityState(data.device, payload);
|
||||
|
||||
if (available) {
|
||||
const message = `Update available for '${resolvedEntity.settings.friendly_name}'`;
|
||||
const message = `Update available for '${data.device.name}'`;
|
||||
logger.info(message);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: 'available', device: resolvedEntity.settings.friendly_name};
|
||||
const meta = {status: 'available', device: data.device.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message, meta}),
|
||||
@@ -71,22 +85,23 @@ class OTAUpdate extends Extension {
|
||||
// Respond to the OTA request:
|
||||
// - In case we don't support OTA: respond with NO_IMAGE_AVAILABLE (0x98) (so the client stops requesting OTAs)
|
||||
// - In case we do support OTA: respond with ABORT (0x95) as we don't want to update now.
|
||||
const endpoint = data.device.endpoints.find((e) => e.supportsOutputCluster('genOta'));
|
||||
const endpoint = data.device.zh.endpoints.find((e) => e.supportsOutputCluster('genOta'));
|
||||
if (endpoint) {
|
||||
// Some devices send OTA requests without defining OTA cluster as input cluster.
|
||||
await endpoint.commandResponse('genOta', 'queryNextImageResponse', {status: supportsOTA ? 0x95 : 0x98});
|
||||
}
|
||||
}
|
||||
|
||||
async readSoftwareBuildIDAndDateCode(device, update) {
|
||||
private async readSoftwareBuildIDAndDateCode(device: Device, update: boolean):
|
||||
Promise<{softwareBuildID: string, dateCode: string}> {
|
||||
try {
|
||||
const endpoint = device.endpoints.find((e) => e.supportsInputCluster('genBasic'));
|
||||
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster('genBasic'));
|
||||
const result = await endpoint.read('genBasic', ['dateCode', 'swBuildId']);
|
||||
|
||||
if (update) {
|
||||
device.softwareBuildID = result.swBuildId;
|
||||
device.dateCode = result.dateCode;
|
||||
device.save();
|
||||
device.zh.softwareBuildID = result.swBuildId;
|
||||
device.zh.dateCode = result.dateCode;
|
||||
device.zh.save();
|
||||
}
|
||||
|
||||
return {softwareBuildID: result.swBuildId, dateCode: result.dateCode};
|
||||
@@ -95,8 +110,8 @@ class OTAUpdate extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
getEntityPublishPayload(state, progress=null, remaining=null) {
|
||||
const payload = {update: {state}};
|
||||
private getEntityPublishPayload(state: UpdateState, progress: number=null, remaining: number=null): UpdatePayload {
|
||||
const payload: UpdatePayload = {update: {state}};
|
||||
if (progress !== null) payload.update.progress = progress;
|
||||
if (remaining !== null) payload.update.remaining = Math.round(remaining);
|
||||
|
||||
@@ -108,43 +123,44 @@ class OTAUpdate extends Extension {
|
||||
return payload;
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
if ((!this.legacyApi || !topic.match(legacyTopicRegex)) && !topic.match(topicRegex)) {
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
if ((!this.legacyApi || !data.topic.match(legacyTopicRegex)) && !data.topic.match(topicRegex)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
message = utils.parseJSON(message, message);
|
||||
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message;
|
||||
const resolvedEntity = this.zigbee.resolveEntity(ID);
|
||||
const type = topic.substring(topic.lastIndexOf('/') + 1);
|
||||
const responseData = {id: ID};
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
const ID = (typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message) as string;
|
||||
const device = this.zigbee.resolveEntity(ID);
|
||||
const type = data.topic.substring(data.topic.lastIndexOf('/') + 1);
|
||||
const responseData: {id: string, updateAvailable?: boolean, from?: string, to?: string}= {id: ID};
|
||||
let error = null;
|
||||
let errorStack = null;
|
||||
|
||||
if (!resolvedEntity || resolvedEntity.type !== 'device') {
|
||||
if (!(device instanceof Device)) {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!resolvedEntity.definition || !resolvedEntity.definition.ota) {
|
||||
error = `Device '${resolvedEntity.name}' does not support OTA updates`;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `not_supported`, device: resolvedEntity.name};
|
||||
const meta = {status: `not_supported`, device: device.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: error, meta}),
|
||||
);
|
||||
}
|
||||
} else if (this.inProgress.has(resolvedEntity.device.ieeeAddr)) {
|
||||
error = `Update or check for update already in progress for '${resolvedEntity.name}'`;
|
||||
} else if (this.inProgress.has(device.ieeeAddr)) {
|
||||
error = `Update or check for update already in progress for '${device.name}'`;
|
||||
} else {
|
||||
this.inProgress.add(resolvedEntity.device.ieeeAddr);
|
||||
this.inProgress.add(device.ieeeAddr);
|
||||
|
||||
if (type === 'check') {
|
||||
const msg = `Checking if update available for '${resolvedEntity.name}'`;
|
||||
const msg = `Checking if update available for '${device.name}'`;
|
||||
logger.info(msg);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `checking_if_available`, device: resolvedEntity.name};
|
||||
const meta = {status: `checking_if_available`, device: device.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: msg, meta}),
|
||||
@@ -152,15 +168,13 @@ class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
try {
|
||||
const available = await resolvedEntity.definition.ota.isUpdateAvailable(
|
||||
resolvedEntity.device, logger,
|
||||
);
|
||||
const msg = `${available ? 'Update' : 'No update'} available for '${resolvedEntity.name}'`;
|
||||
const available = await device.definition.ota.isUpdateAvailable(device.zh, logger);
|
||||
const msg = `${available ? 'Update' : 'No update'} available for '${device.name}'`;
|
||||
logger.info(msg);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: available ? 'available' : 'not_available', device: resolvedEntity.name};
|
||||
const meta = {status: available ? 'available' : 'not_available', device: device.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: msg, meta}),
|
||||
@@ -168,15 +182,16 @@ class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
const payload = this.getEntityPublishPayload(available ? 'available' : 'idle');
|
||||
this.publishEntityState(resolvedEntity.device.ieeeAddr, payload);
|
||||
this.lastChecked[resolvedEntity.device.ieeeAddr] = Date.now();
|
||||
this.publishEntityState(device, payload);
|
||||
this.lastChecked[device.ieeeAddr] = Date.now();
|
||||
responseData.updateAvailable = available;
|
||||
} catch (e) {
|
||||
error = `Failed to check if update available for '${resolvedEntity.name}' (${e.message})`;
|
||||
error = `Failed to check if update available for '${device.name}' (${e.message})`;
|
||||
errorStack = e.stack;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `check_failed`, device: resolvedEntity.name};
|
||||
const meta = {status: `check_failed`, device: device.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: error, meta}),
|
||||
@@ -184,12 +199,12 @@ class OTAUpdate extends Extension {
|
||||
}
|
||||
}
|
||||
} else { // type === 'update'
|
||||
const msg = `Updating '${resolvedEntity.name}' to latest firmware`;
|
||||
const msg = `Updating '${device.name}' to latest firmware`;
|
||||
logger.info(msg);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_in_progress`, device: resolvedEntity.name};
|
||||
const meta = {status: `update_in_progress`, device: device.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: msg, meta}),
|
||||
@@ -197,8 +212,8 @@ class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
try {
|
||||
const onProgress = (progress, remaining) => {
|
||||
let msg = `Update of '${resolvedEntity.name}' at ${progress.toFixed(2)}%`;
|
||||
const onProgress = (progress: number, remaining: number): void => {
|
||||
let msg = `Update of '${device.name}' at ${progress.toFixed(2)}%`;
|
||||
if (remaining) {
|
||||
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
|
||||
}
|
||||
@@ -206,53 +221,55 @@ class OTAUpdate extends Extension {
|
||||
logger.info(msg);
|
||||
|
||||
const payload = this.getEntityPublishPayload('updating', progress, remaining);
|
||||
this.publishEntityState(resolvedEntity.device.ieeeAddr, payload);
|
||||
this.publishEntityState(device, payload);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_progress`, device: resolvedEntity.name, progress};
|
||||
const meta = {status: `update_progress`, device: device.name, progress};
|
||||
this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
|
||||
}
|
||||
};
|
||||
|
||||
const from_ = await this.readSoftwareBuildIDAndDateCode(resolvedEntity.device, false);
|
||||
await resolvedEntity.definition.ota.updateToLatest(resolvedEntity.device, logger, onProgress);
|
||||
const to = await this.readSoftwareBuildIDAndDateCode(resolvedEntity.device, true);
|
||||
const from_ = await this.readSoftwareBuildIDAndDateCode(device, false);
|
||||
await device.definition.ota.updateToLatest(device.zh, logger, onProgress);
|
||||
const to = await this.readSoftwareBuildIDAndDateCode(device, true);
|
||||
const [fromS, toS] = [stringify(from_), stringify(to)];
|
||||
const msg = `Finished update of '${resolvedEntity.name}'` +
|
||||
const msg = `Finished update of '${device.name}'` +
|
||||
(to ? `, from '${fromS}' to '${toS}'` : ``);
|
||||
logger.info(msg);
|
||||
this.removeProgressAndRemainingFromState(resolvedEntity.device);
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
const payload = this.getEntityPublishPayload('idle');
|
||||
this.publishEntityState(resolvedEntity.device.ieeeAddr, payload);
|
||||
this.publishEntityState(device, payload);
|
||||
responseData.from = from_ ? utils.toSnakeCase(from_) : null;
|
||||
responseData.to = to ? utils.toSnakeCase(to) : null;
|
||||
this.eventBus.emit(`devicesChanged`);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_succeeded`, device: resolvedEntity.name, from: from_, to};
|
||||
const meta = {status: `update_succeeded`, device: device.name, from: from_, to};
|
||||
this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message, meta}));
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Update of '${resolvedEntity.name}' failed (${e.message})`;
|
||||
logger.debug(`Update of '${device.name}' failed (${e})`);
|
||||
error = `Update of '${device.name}' failed (${e.message})`;
|
||||
errorStack = e.stack;
|
||||
|
||||
this.removeProgressAndRemainingFromState(resolvedEntity.device);
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
const payload = this.getEntityPublishPayload('available');
|
||||
this.publishEntityState(resolvedEntity.device.ieeeAddr, payload);
|
||||
this.publishEntityState(device, payload);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_failed`, device: resolvedEntity.name};
|
||||
const meta = {status: `update_failed`, device: device.name};
|
||||
this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.inProgress.delete(resolvedEntity.device.ieeeAddr);
|
||||
this.inProgress.delete(device.ieeeAddr);
|
||||
}
|
||||
|
||||
const triggeredViaLegacyApi = topic.match(legacyTopicRegex);
|
||||
const triggeredViaLegacyApi = data.topic.match(legacyTopicRegex);
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
await this.mqtt.publish(`bridge/response/device/ota_update/${type}`, stringify(response));
|
||||
@@ -260,8 +277,7 @@ class OTAUpdate extends Extension {
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
errorStack && logger.debug(errorStack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OTAUpdate;
|
||||
@@ -1,293 +0,0 @@
|
||||
|
||||
const settings = require('../util/settings');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const logger = require('../util/logger');
|
||||
const utils = require('../util/utils');
|
||||
const assert = require('assert');
|
||||
const Extension = require('./extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
const topicRegex = new RegExp(`^(.+?)(?:/(${utils.getEndpointNames().join('|')}))?/(get|set)(?:/(.+))?`);
|
||||
const stateValues = ['on', 'off', 'toggle', 'open', 'close', 'stop', 'lock', 'unlock'];
|
||||
|
||||
const groupConverters = [
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_onoff_brightness,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_color_colortemp,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.effect,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.ignore_transition,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.cover_position_tilt,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.thermostat_occupied_heating_setpoint,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.tint_scene,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_brightness_move,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_brightness_step,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_colortemp_step,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_colortemp_move,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_hue_saturation_move,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_hue_saturation_step,
|
||||
];
|
||||
|
||||
class EntityPublish extends Extension {
|
||||
parseTopic(topic) {
|
||||
const match = topic.match(topicRegex);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ID = match[1].replace(`${settings.get().mqtt.base_topic}/`, '');
|
||||
// If we didn't replace base_topic we received something we don't care about
|
||||
if (ID === match[1] || ID.match(/bridge/)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {ID: ID, endpointName: match[2] || '', type: match[3], attribute: match[4]};
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
topic = this.parseTopic(topic);
|
||||
if (!topic) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entityKey = `${topic.ID}` + (topic.endpointName ? `/${topic.endpointName}` : '');
|
||||
const resolvedEntity = this.zigbee.resolveEntity(entityKey);
|
||||
|
||||
if (!resolvedEntity) {
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const message = {friendly_name: entityKey};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `entity_not_found`, message}),
|
||||
);
|
||||
}
|
||||
|
||||
logger.error(`Entity '${entityKey}' is unknown`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get entity details
|
||||
let converters = null;
|
||||
let target = null;
|
||||
let options = {};
|
||||
let device = null;
|
||||
let definition = null;
|
||||
let membersState = null;
|
||||
|
||||
assert(resolvedEntity.type === 'device' || resolvedEntity.type === 'group');
|
||||
if (resolvedEntity.type === 'device') {
|
||||
if (!resolvedEntity.definition) {
|
||||
logger.warn(`Device with modelID '${resolvedEntity.device.modelID}' is not supported.`);
|
||||
logger.warn(`Please see: https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html`);
|
||||
return;
|
||||
}
|
||||
|
||||
device = resolvedEntity.device;
|
||||
definition = resolvedEntity.definition;
|
||||
target = resolvedEntity.endpoint;
|
||||
converters = resolvedEntity.definition.toZigbee;
|
||||
options = resolvedEntity.settings;
|
||||
} else {
|
||||
target = resolvedEntity.group;
|
||||
options = resolvedEntity.settings;
|
||||
definition = resolvedEntity.group.members
|
||||
.map((e) => zigbeeHerdsmanConverters.findByDevice(e.getDevice())).filter((d) => d);
|
||||
converters = new Set(groupConverters);
|
||||
for (const d of definition) {
|
||||
d.toZigbee.forEach(converters.add, converters);
|
||||
}
|
||||
converters = [...converters];
|
||||
membersState = {};
|
||||
for (const member of resolvedEntity.group.members) {
|
||||
const ieeeAddr = member.getDevice().ieeeAddr;
|
||||
membersState[ieeeAddr] = this.state.get(ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the MQTT message to a Zigbee message.
|
||||
let json = {};
|
||||
if (topic.hasOwnProperty('attribute') && topic.attribute) {
|
||||
try {
|
||||
json[topic.attribute] = JSON.parse(message);
|
||||
} catch (e) {
|
||||
json[topic.attribute] = message;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
json = JSON.parse(message);
|
||||
} catch (e) {
|
||||
if (stateValues.includes(message.toLowerCase())) {
|
||||
json = {state: message};
|
||||
} else {
|
||||
logger.error(`Invalid JSON '${message}', skipping...`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!json) {
|
||||
logger.error(`Invalid JSON '${message}', skipping...`);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Home Assistant always publishes 'state', even when e.g. only setting
|
||||
* the color temperature. This would lead to 2 zigbee publishes, where the first one
|
||||
* (state) is probably unecessary.
|
||||
*/
|
||||
const entityState = this.state.get(resolvedEntity.settings.ID) || {};
|
||||
if (settings.get().homeassistant) {
|
||||
const hasColorTemp = json.hasOwnProperty('color_temp');
|
||||
const hasColor = json.hasOwnProperty('color');
|
||||
const hasBrightness = json.hasOwnProperty('brightness');
|
||||
const isOn = entityState && entityState.state === 'ON' ? true : false;
|
||||
if (isOn && (hasColorTemp || hasColor) && !hasBrightness) {
|
||||
delete json.state;
|
||||
logger.debug('Skipping state because of Home Assistant');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Order state & brightness based on current bulb state
|
||||
*
|
||||
* Not all bulbs support setting the color/color_temp while it is off
|
||||
* this results in inconsistant behavior between different vendors.
|
||||
*
|
||||
* bulb on => move state & brightness to the back
|
||||
* bulb off => move state & brightness to the front
|
||||
*/
|
||||
const entries = Object.entries(json);
|
||||
const sorter = typeof json.state === 'string' && json.state.toLowerCase() === 'off' ? 1 : -1;
|
||||
entries.sort((a, b) => (['state', 'brightness', 'brightness_percent'].includes(a[0]) ? sorter : sorter * -1));
|
||||
|
||||
// For each attribute call the corresponding converter
|
||||
const usedConverters = {};
|
||||
for (let [key, value] of entries) {
|
||||
let endpointName = topic.endpointName;
|
||||
let actualTarget = target;
|
||||
|
||||
// When the key has a endpointName included (e.g. state_right), this will override the target.
|
||||
if (resolvedEntity.type === 'device' && key.includes('_')) {
|
||||
const endpointNameMatch = utils.getEndpointNames().find((n) => key.endsWith(`_${n}`));
|
||||
if (endpointNameMatch) {
|
||||
endpointName = endpointNameMatch;
|
||||
key = key.replace(`_${endpointNameMatch}`, '');
|
||||
|
||||
const device = target.getDevice();
|
||||
actualTarget = device.getEndpoint(definition.endpoint(device)[endpointName]);
|
||||
if (!actualTarget) {
|
||||
logger.error(`Device '${resolvedEntity.name}' has no endpoint '${endpointName}'`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const endpointOrGroupID = actualTarget.constructor.name == 'Group' ? actualTarget.groupID : actualTarget.ID;
|
||||
if (!usedConverters.hasOwnProperty(endpointOrGroupID)) usedConverters[endpointOrGroupID] = [];
|
||||
const converter = converters.find((c) => c.key.includes(key));
|
||||
|
||||
if (topic.type === 'set' && usedConverters[endpointOrGroupID].includes(converter)) {
|
||||
// Use a converter for set only once
|
||||
// (e.g. light_onoff_brightness converters can convert state and brightness)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!converter) {
|
||||
logger.error(`No converter available for '${key}' (${json[key]})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Converter didn't return a result, skip
|
||||
const meta = {
|
||||
endpoint_name: endpointName,
|
||||
options,
|
||||
message: {...json},
|
||||
logger,
|
||||
device,
|
||||
state: entityState,
|
||||
membersState,
|
||||
mapped: definition,
|
||||
};
|
||||
|
||||
// Strip endpoint name from meta.message properties.
|
||||
if (meta.endpoint_name) {
|
||||
for (const [key, value] of Object.entries(meta.message)) {
|
||||
if (key.endsWith(meta.endpoint_name)) {
|
||||
delete meta.message[key];
|
||||
const keyWithoutEndpoint = key.substring(0, key.length - meta.endpoint_name.length - 1);
|
||||
meta.message[keyWithoutEndpoint] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (topic.type === 'set' && converter.convertSet) {
|
||||
logger.debug(`Publishing '${topic.type}' '${key}' to '${resolvedEntity.name}'`);
|
||||
const result = await converter.convertSet(actualTarget, key, value, meta);
|
||||
const isGroup = actualTarget.constructor.name == 'Group';
|
||||
// Optimistic behaviour of groups and devices is different.
|
||||
const publishOptimistic = isGroup || (!options.hasOwnProperty('optimistic') || options.optimistic);
|
||||
if (result && result.state && publishOptimistic) {
|
||||
const msg = result.state;
|
||||
|
||||
if (endpointName) {
|
||||
for (const key of ['state', 'brightness', 'color', 'color_temp']) {
|
||||
if (msg.hasOwnProperty(key)) {
|
||||
msg[`${key}_${endpointName}`] = msg[key];
|
||||
delete msg[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.publishEntityState(resolvedEntity.settings.ID, msg);
|
||||
}
|
||||
|
||||
if (result && result.membersState && publishOptimistic) {
|
||||
for (const [ieeeAddr, state] of Object.entries(result.membersState)) {
|
||||
this.publishEntityState(ieeeAddr, state);
|
||||
}
|
||||
}
|
||||
|
||||
// It's possible for devices to get out of sync when writing an attribute that's not reportable.
|
||||
// So here we re-read the value after a specified timeout, this timeout could for example be the
|
||||
// transition time of a color change or for forcing a state read for devices that don't
|
||||
// automatically report a new state when set.
|
||||
// When reporting is requested for a device (report: true in device-specific settings) we won't
|
||||
// ever issue a read here, as we assume the device will properly report changes.
|
||||
// Only do this when the retrieve_state option is enabled for this device.
|
||||
// retrieve_state == decprecated
|
||||
if (
|
||||
resolvedEntity.type === 'device' && result && result.hasOwnProperty('readAfterWriteTime') &&
|
||||
resolvedEntity.settings.retrieve_state
|
||||
) {
|
||||
setTimeout(() => converter.convertGet(actualTarget, key, meta), result.readAfterWriteTime);
|
||||
}
|
||||
} else if (topic.type === 'get' && converter.convertGet) {
|
||||
logger.debug(`Publishing get '${topic.type}' '${key}' to '${resolvedEntity.name}'`);
|
||||
await converter.convertGet(actualTarget, key, meta);
|
||||
} else {
|
||||
logger.error(`No converter available for '${topic.type}' '${key}' (${json[key]})`);
|
||||
continue;
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
`Publish '${topic.type}' '${key}' to '${resolvedEntity.name}' failed: '${error}'`;
|
||||
logger.error(message);
|
||||
logger.debug(error.stack);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {friendly_name: resolvedEntity.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `zigbee_publish_error`, message, meta}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
usedConverters[endpointOrGroupID].push(converter);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EntityPublish;
|
||||
@@ -0,0 +1,301 @@
|
||||
|
||||
import * as settings from '../util/settings';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
import logger from '../util/logger';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import Group from '../model/group';
|
||||
import Device from '../model/device';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
const topicRegex = new RegExp(`^(.+?)(?:/(${utils.endpointNames.join('|')}|\\d+))?/(get|set)(?:/(.+))?`);
|
||||
const propertyEndpointRegex = new RegExp(`^(.*)_(${utils.endpointNames.join('|')}|\\d+)$`);
|
||||
const stateValues = ['on', 'off', 'toggle', 'open', 'close', 'stop', 'lock', 'unlock'];
|
||||
const sceneConverterKeys = ['scene_store', 'scene_add', 'scene_remove', 'scene_remove_all'];
|
||||
|
||||
// Legacy: don't provide default converters anymore, this is required by older z2m installs not saving group members
|
||||
const defaultGroupConverters = [
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_onoff_brightness,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_color_colortemp,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.effect,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.ignore_transition,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.cover_position_tilt,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.thermostat_occupied_heating_setpoint,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.tint_scene,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_brightness_move,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_brightness_step,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_colortemp_step,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_colortemp_move,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_hue_saturation_move,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_hue_saturation_step,
|
||||
];
|
||||
|
||||
interface ParsedTopic {ID: string, endpoint: string, attribute: string, type: 'get' | 'set'}
|
||||
|
||||
export default class Publish extends Extension {
|
||||
async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
parseTopic(topic: string): ParsedTopic | null {
|
||||
const match = topic.match(topicRegex);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ID = match[1].replace(`${settings.get().mqtt.base_topic}/`, '');
|
||||
// If we didn't replace base_topic we received something we don't care about
|
||||
if (ID === match[1] || ID.match(/bridge/)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {ID: ID, endpoint: match[2], type: match[3] as 'get' | 'set', attribute: match[4]};
|
||||
}
|
||||
|
||||
parseMessage(parsedTopic: ParsedTopic, data: eventdata.MQTTMessage): KeyValue | null {
|
||||
if (parsedTopic.attribute) {
|
||||
try {
|
||||
return {[parsedTopic.attribute]: JSON.parse(data.message)};
|
||||
} catch (e) {
|
||||
return {[parsedTopic.attribute]: data.message};
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return JSON.parse(data.message);
|
||||
} catch (e) {
|
||||
if (stateValues.includes(data.message.toLowerCase())) {
|
||||
return {state: data.message};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
legacyLog(payload: KeyValue): void {
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
this.mqtt.publish('bridge/log', stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
legacyRetrieveState(re: Device | Group, converter: zhc.ToZigbeeConverter, result: zhc.ToZigbeeConverterResult,
|
||||
target: zh.Endpoint | zh.Group, key: string, meta: zhc.ToZigbeeConverterGetMeta): void {
|
||||
// It's possible for devices to get out of sync when writing an attribute that's not reportable.
|
||||
// So here we re-read the value after a specified timeout, this timeout could for example be the
|
||||
// transition time of a color change or for forcing a state read for devices that don't
|
||||
// automatically report a new state when set.
|
||||
// When reporting is requested for a device (report: true in device-specific settings) we won't
|
||||
// ever issue a read here, as we assume the device will properly report changes.
|
||||
// Only do this when the retrieve_state option is enabled for this device.
|
||||
// retrieve_state == decprecated
|
||||
if (re instanceof Device && result && result.hasOwnProperty('readAfterWriteTime') &&
|
||||
re.settings.retrieve_state
|
||||
) {
|
||||
setTimeout(() => converter.convertGet(target, key, meta), result.readAfterWriteTime);
|
||||
}
|
||||
}
|
||||
|
||||
updateMessageHomeAssistant(message: KeyValue, entityState: KeyValue): void {
|
||||
/**
|
||||
* Home Assistant always publishes 'state', even when e.g. only setting
|
||||
* the color temperature. This would lead to 2 zigbee publishes, where the first one
|
||||
* (state) is probably unecessary.
|
||||
*/
|
||||
if (settings.get().homeassistant) {
|
||||
const hasColorTemp = message.hasOwnProperty('color_temp');
|
||||
const hasColor = message.hasOwnProperty('color');
|
||||
const hasBrightness = message.hasOwnProperty('brightness');
|
||||
const isOn = entityState.state === 'ON' ? true : false;
|
||||
if (isOn && (hasColorTemp || hasColor) && !hasBrightness) {
|
||||
delete message.state;
|
||||
logger.debug('Skipping state because of Home Assistant');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const parsedTopic = this.parseTopic(data.topic);
|
||||
if (!parsedTopic) return;
|
||||
|
||||
const re = this.zigbee.resolveEntity(parsedTopic.ID);
|
||||
if (re == null) {
|
||||
this.legacyLog({type: `entity_not_found`, message: {friendly_name: parsedTopic.ID}});
|
||||
logger.error(`Entity '${parsedTopic.ID}' is unknown`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get entity details
|
||||
const definition = re instanceof Device ? re.definition : re.membersDefinitions();
|
||||
if (definition == null) {
|
||||
logger.error(`Cannot publish to unsupported device '${re.name}'`);
|
||||
return;
|
||||
}
|
||||
const target = re instanceof Group ? re.zh : re.endpoint(parsedTopic.endpoint);
|
||||
if (target == null) {
|
||||
logger.error(`Device '${re.name}' has no endpoint '${parsedTopic.endpoint}'`);
|
||||
return;
|
||||
}
|
||||
const device = re instanceof Device ? re.zh : null;
|
||||
const entitySettings = re.settings;
|
||||
const entityState = this.state.get(re) || {};
|
||||
const membersState = re instanceof Group ?
|
||||
Object.fromEntries(re.zh.members.map((e) => [e.getDevice().ieeeAddr,
|
||||
this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr))])) : null;
|
||||
let converters: zhc.ToZigbeeConverter[];
|
||||
{
|
||||
if (Array.isArray(definition)) {
|
||||
const c = new Set(definition.map((d) => d.toZigbee).flat());
|
||||
if (c.size == 0) converters = defaultGroupConverters;
|
||||
else converters = Array.from(c);
|
||||
} else {
|
||||
converters = definition.toZigbee;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the MQTT message to a Zigbee message.
|
||||
const message = this.parseMessage(parsedTopic, data);
|
||||
if (message == null) {
|
||||
logger.error(`Invalid message '${message}', skipping...`);
|
||||
return;
|
||||
}
|
||||
this.updateMessageHomeAssistant(message, entityState);
|
||||
|
||||
/**
|
||||
* Order state & brightness based on current bulb state
|
||||
*
|
||||
* Not all bulbs support setting the color/color_temp while it is off
|
||||
* this results in inconsistant behavior between different vendors.
|
||||
*
|
||||
* bulb on => move state & brightness to the back
|
||||
* bulb off => move state & brightness to the front
|
||||
*/
|
||||
const entries = Object.entries(message);
|
||||
const sorter = typeof message.state === 'string' && message.state.toLowerCase() === 'off' ? 1 : -1;
|
||||
entries.sort((a) => (['state', 'brightness', 'brightness_percent'].includes(a[0]) ? sorter : sorter * -1));
|
||||
|
||||
// For each attribute call the corresponding converter
|
||||
const usedConverters: {[s: number]: zhc.ToZigbeeConverter[]} = {};
|
||||
const toPublish: {[s: number | string]: KeyValue} = {};
|
||||
const toPublishEntity: {[s: number | string]: Device | Group} = {};
|
||||
const addToToPublish = (entity: Device | Group, payload: KeyValue): void => {
|
||||
const ID = entity.ID;
|
||||
if (!(ID in toPublish)) {
|
||||
toPublish[ID] = {};
|
||||
toPublishEntity[ID] = entity;
|
||||
}
|
||||
toPublish[ID] = {...toPublish[ID], ...payload};
|
||||
};
|
||||
|
||||
for (let [key, value] of entries) {
|
||||
let endpointName = parsedTopic.endpoint;
|
||||
let localTarget = target;
|
||||
let endpointOrGroupID = utils.isEndpoint(target) ? target.ID : target.groupID;
|
||||
|
||||
// When the key has a endpointName included (e.g. state_right), this will override the target.
|
||||
const propertyEndpointMatch = key.match(propertyEndpointRegex);
|
||||
if (re instanceof Device && propertyEndpointMatch) {
|
||||
endpointName = propertyEndpointMatch[2];
|
||||
key = propertyEndpointMatch[1];
|
||||
localTarget = re.endpoint(endpointName);
|
||||
if (localTarget == null) {
|
||||
logger.error(`Device '${re.name}' has no endpoint '${endpointName}'`);
|
||||
continue;
|
||||
}
|
||||
endpointOrGroupID = localTarget.ID;
|
||||
}
|
||||
|
||||
if (!usedConverters.hasOwnProperty(endpointOrGroupID)) usedConverters[endpointOrGroupID] = [];
|
||||
const converter = converters.find((c) => c.key.includes(key));
|
||||
|
||||
if (parsedTopic.type === 'set' && usedConverters[endpointOrGroupID].includes(converter)) {
|
||||
// Use a converter for set only once
|
||||
// (e.g. light_onoff_brightness converters can convert state and brightness)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!converter) {
|
||||
logger.error(`No converter available for '${key}' (${message[key]})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the endpoint_name name is a nubmer, try to map it to a friendlyName
|
||||
if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isEndpoint(localTarget) &&
|
||||
re.endpointName(localTarget)) {
|
||||
endpointName = re.endpointName(localTarget);
|
||||
}
|
||||
|
||||
// Converter didn't return a result, skip
|
||||
const meta = {endpoint_name: endpointName, options: entitySettings, message: {...message}, logger, device,
|
||||
state: entityState, membersState, mapped: definition};
|
||||
|
||||
// Strip endpoint name from meta.message properties.
|
||||
if (endpointName) {
|
||||
for (const [key, value] of Object.entries(meta.message)) {
|
||||
if (key.endsWith(endpointName)) {
|
||||
delete meta.message[key];
|
||||
const keyWithoutEndpoint = key.substring(0, key.length - endpointName.length - 1);
|
||||
meta.message[keyWithoutEndpoint] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (parsedTopic.type === 'set' && converter.convertSet) {
|
||||
logger.debug(`Publishing '${parsedTopic.type}' '${key}' to '${re.name}'`);
|
||||
const result = await converter.convertSet(localTarget, key, value, meta);
|
||||
const optimistic = !entitySettings.hasOwnProperty('optimistic') || entitySettings.optimistic;
|
||||
if (result && result.state && optimistic) {
|
||||
const msg = result.state;
|
||||
|
||||
if (endpointName) {
|
||||
for (const key of Object.keys(msg)) {
|
||||
msg[`${key}_${endpointName}`] = msg[key];
|
||||
delete msg[key];
|
||||
}
|
||||
}
|
||||
|
||||
// filter out attribute listed in filtered_optimistic
|
||||
entitySettings.filtered_optimistic?.forEach((a) => delete msg[a]);
|
||||
addToToPublish(re, msg);
|
||||
}
|
||||
|
||||
if (result && result.membersState && optimistic) {
|
||||
for (const [ieeeAddr, state] of Object.entries(result.membersState)) {
|
||||
addToToPublish(this.zigbee.resolveEntity(ieeeAddr), state);
|
||||
}
|
||||
}
|
||||
|
||||
this.legacyRetrieveState(re, converter, result, localTarget, key, meta);
|
||||
} else if (parsedTopic.type === 'get' && converter.convertGet) {
|
||||
logger.debug(`Publishing get '${parsedTopic.type}' '${key}' to '${re.name}'`);
|
||||
await converter.convertGet(localTarget, key, meta);
|
||||
} else {
|
||||
logger.error(`No converter available for '${parsedTopic.type}' '${key}' (${message[key]})`);
|
||||
continue;
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
`Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`;
|
||||
logger.error(message);
|
||||
logger.debug(error.stack);
|
||||
this.legacyLog({type: `zigbee_publish_error`, message, meta: {friendly_name: re.name}});
|
||||
}
|
||||
|
||||
usedConverters[endpointOrGroupID].push(converter);
|
||||
}
|
||||
|
||||
for (const [ID, payload] of Object.entries(toPublish)) {
|
||||
if (Object.keys(payload).length != 0) {
|
||||
this.publishEntityState(toPublishEntity[ID], payload);
|
||||
}
|
||||
}
|
||||
|
||||
const scenesChanged = Object.values(usedConverters)
|
||||
.some((cl) => cl.some((c) => c.key.some((k) => sceneConverterKeys.includes(k))));
|
||||
if (scenesChanged) {
|
||||
this.eventBus.emitScenesChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const utils = require('../util/utils');
|
||||
const debounce = require('debounce');
|
||||
const Extension = require('./extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
class Receive extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.elapsed = {};
|
||||
this.debouncers = {};
|
||||
this.eventBus.on('publishEntityState', (data) => this.onPublishEntityState(data));
|
||||
}
|
||||
|
||||
async onZigbeeStarted() {
|
||||
this.coordinator = this.zigbee.getDevicesByType('Coordinator')[0];
|
||||
}
|
||||
|
||||
async onPublishEntityState(data) {
|
||||
/**
|
||||
* Prevent that outdated properties are being published.
|
||||
* In case that e.g. the state is currently held back by a debounce and a new state is published
|
||||
* remove it from the to be send debounced message.
|
||||
*/
|
||||
if (data.entity.type === 'device' && this.debouncers[data.entity.device.ieeeAddr] &&
|
||||
data.stateChangeReason !== 'publishDebounce') {
|
||||
for (const key of Object.keys(data.payload)) {
|
||||
delete this.debouncers[data.entity.device.ieeeAddr].payload[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publishDebounce(ieeeAddr, payload, time, debounceIgnore) {
|
||||
if (!this.debouncers[ieeeAddr]) {
|
||||
this.debouncers[ieeeAddr] = {
|
||||
payload: {},
|
||||
publish: debounce(() => {
|
||||
this.publishEntityState(ieeeAddr, this.debouncers[ieeeAddr].payload, 'publishDebounce');
|
||||
this.debouncers[ieeeAddr].payload = {};
|
||||
}, time * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isPayloadConflicted(payload, this.debouncers[ieeeAddr].payload, debounceIgnore)) {
|
||||
// publish previous payload immediately
|
||||
this.debouncers[ieeeAddr].publish.flush();
|
||||
}
|
||||
|
||||
// extend debounced payload with current
|
||||
this.debouncers[ieeeAddr].payload = {...this.debouncers[ieeeAddr].payload, ...payload};
|
||||
this.debouncers[ieeeAddr].publish();
|
||||
}
|
||||
|
||||
// if debounce_ignore are specified (Array of strings)
|
||||
// then all newPayload values with key present in debounce_ignore
|
||||
// should equal or be undefined in oldPayload
|
||||
// otherwise payload is conflicted
|
||||
isPayloadConflicted(newPayload, oldPayload, debounceIgnore) {
|
||||
let result = false;
|
||||
Object.keys(oldPayload)
|
||||
.filter((key) => (debounceIgnore || []).includes(key))
|
||||
.forEach((key) => {
|
||||
if (typeof newPayload[key] !== 'undefined' && newPayload[key] !== oldPayload[key]) {
|
||||
result = true;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
shouldProcess(type, data, resolvedEntity) {
|
||||
if (type !== 'message' || !resolvedEntity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!resolvedEntity.definition) {
|
||||
if (data.device.interviewing) {
|
||||
logger.debug(`Skipping message, definition is undefined and still interviewing`);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Received message from unsupported device with Zigbee model '${data.device.modelID}' ` +
|
||||
`and manufacturer name '${data.device.manufacturerName}'`);
|
||||
logger.warn(`Please see: https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html.`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onZigbeeEvent(type, data, resolvedEntity) {
|
||||
/**
|
||||
* Handling of re-transmitted Xiaomi messages.
|
||||
* https://github.com/Koenkk/zigbee2mqtt/issues/1238
|
||||
* https://github.com/Koenkk/zigbee2mqtt/issues/3592
|
||||
*
|
||||
* Some Xiaomi router devices re-transmit messages from Xiaomi end devices.
|
||||
* The network address of these message is set to the one of the Xiaomi router.
|
||||
* Therefore it looks like if the message came from the Xiaomi router, while in
|
||||
* fact it came from the end device.
|
||||
* Handling these message would result in false state updates.
|
||||
* The group ID attribute of these message defines the network address of the end device.
|
||||
*/
|
||||
if (type === 'message' && utils.isXiaomiDevice(data.device) && data.device.type === 'Router' && data.groupID) {
|
||||
logger.debug('Handling re-transmitted Xiaomi message');
|
||||
data.device = this.zigbee.getDeviceByNetworkAddress(data.groupID);
|
||||
resolvedEntity = this.zigbee.resolveEntity(data.device);
|
||||
}
|
||||
|
||||
if (!this.shouldProcess(type, data, resolvedEntity)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const converters = resolvedEntity.definition.fromZigbee.filter((c) => {
|
||||
const type = Array.isArray(c.type) ? c.type.includes(data.type) : c.type === data.type;
|
||||
return c.cluster === data.cluster && type;
|
||||
});
|
||||
|
||||
// Check if there is an available converter, genOta messages are not interesting.
|
||||
if (!converters.length && !['genOta', 'genTime', 'genBasic'].includes(data.cluster)) {
|
||||
logger.debug(
|
||||
`No converter available for '${resolvedEntity.definition.model}' with cluster '${data.cluster}' ` +
|
||||
`and type '${data.type}' and data '${stringify(data.data)}'`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert this Zigbee message to a MQTT message.
|
||||
// Get payload for the message.
|
||||
// - If a payload is returned publish it to the MQTT broker
|
||||
// - If NO payload is returned do nothing. This is for non-standard behaviour
|
||||
// for e.g. click switches where we need to count number of clicks and detect long presses.
|
||||
const publish = (payload) => {
|
||||
if (settings.get().advanced.elapsed) {
|
||||
const now = Date.now();
|
||||
if (this.elapsed[data.device.ieeeAddr]) {
|
||||
payload.elapsed = now - this.elapsed[data.device.ieeeAddr];
|
||||
}
|
||||
|
||||
this.elapsed[data.device.ieeeAddr] = now;
|
||||
}
|
||||
|
||||
// Check if we have to debounce
|
||||
if (resolvedEntity.settings.debounce) {
|
||||
this.publishDebounce(
|
||||
data.device.ieeeAddr, payload, resolvedEntity.settings.debounce,
|
||||
resolvedEntity.settings.debounce_ignore,
|
||||
);
|
||||
} else {
|
||||
this.publishEntityState(data.device.ieeeAddr, payload);
|
||||
}
|
||||
};
|
||||
|
||||
const meta = {device: data.device, logger, state: this.state.get(data.device.ieeeAddr)};
|
||||
let payload = {};
|
||||
converters.forEach((converter) => {
|
||||
const converted = converter.convert(
|
||||
resolvedEntity.definition, data, publish, resolvedEntity.settings, meta,
|
||||
);
|
||||
if (converted) {
|
||||
payload = {...payload, ...converted};
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(payload).length) {
|
||||
publish(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Receive;
|
||||
Executable
+176
@@ -0,0 +1,176 @@
|
||||
import * as settings from '../util/settings';
|
||||
import logger from '../util/logger';
|
||||
import debounce from 'debounce';
|
||||
import Extension from './extension';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import bind from 'bind-decorator';
|
||||
import utils from '../util/utils';
|
||||
|
||||
type DebounceFunction = (() => void) & { clear(): void; } & { flush(): void; };
|
||||
|
||||
export default class Receive extends Extension {
|
||||
private elapsed: {[s: string]: number} = {};
|
||||
private debouncers: {[s: string]: {payload: KeyValue, publish: DebounceFunction }} = {};
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.eventBus.onPublishEntityState(this, this.onPublishEntityState);
|
||||
this.eventBus.onDeviceMessage(this, this.onDeviceMessage);
|
||||
}
|
||||
|
||||
@bind async onPublishEntityState(data: eventdata.PublishEntityState): Promise<void> {
|
||||
/**
|
||||
* Prevent that outdated properties are being published.
|
||||
* In case that e.g. the state is currently held back by a debounce and a new state is published
|
||||
* remove it from the to be send debounced message.
|
||||
*/
|
||||
if (data.entity.isDevice() && this.debouncers[data.entity.ieeeAddr] &&
|
||||
data.stateChangeReason !== 'publishDebounce' && data.stateChangeReason !== 'lastSeenChanged') {
|
||||
for (const key of Object.keys(data.payload)) {
|
||||
delete this.debouncers[data.entity.ieeeAddr].payload[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publishDebounce(device: Device, payload: KeyValue, time: number, debounceIgnore: string[]): void {
|
||||
if (!this.debouncers[device.ieeeAddr]) {
|
||||
this.debouncers[device.ieeeAddr] = {
|
||||
payload: {},
|
||||
publish: debounce(() => {
|
||||
this.publishEntityState(device, this.debouncers[device.ieeeAddr].payload, 'publishDebounce');
|
||||
this.debouncers[device.ieeeAddr].payload = {};
|
||||
}, time * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isPayloadConflicted(payload, this.debouncers[device.ieeeAddr].payload, debounceIgnore)) {
|
||||
// publish previous payload immediately
|
||||
this.debouncers[device.ieeeAddr].publish.flush();
|
||||
}
|
||||
|
||||
// extend debounced payload with current
|
||||
this.debouncers[device.ieeeAddr].payload = {...this.debouncers[device.ieeeAddr].payload, ...payload};
|
||||
this.debouncers[device.ieeeAddr].publish();
|
||||
}
|
||||
|
||||
// if debounce_ignore are specified (Array of strings)
|
||||
// then all newPayload values with key present in debounce_ignore
|
||||
// should equal or be undefined in oldPayload
|
||||
// otherwise payload is conflicted
|
||||
isPayloadConflicted(newPayload: KeyValue, oldPayload: KeyValue, debounceIgnore: string[] | null): boolean {
|
||||
let result = false;
|
||||
Object.keys(oldPayload)
|
||||
.filter((key) => (debounceIgnore || []).includes(key))
|
||||
.forEach((key) => {
|
||||
if (typeof newPayload[key] !== 'undefined' && newPayload[key] !== oldPayload[key]) {
|
||||
result = true;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
shouldProcess(data: eventdata.DeviceMessage): boolean {
|
||||
if (!data.device.definition) {
|
||||
if (data.device.zh.interviewing) {
|
||||
logger.debug(`Skipping message, definition is undefined and still interviewing`);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Received message from unsupported device with Zigbee model '${data.device.zh.modelID}' ` +
|
||||
`and manufacturer name '${data.device.zh.manufacturerName}'`);
|
||||
// eslint-disable-next-line max-len
|
||||
logger.warn(`Please see: https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@bind async onDeviceMessage(data: eventdata.DeviceMessage): Promise<void> {
|
||||
/* istanbul ignore next */
|
||||
if (!data.device) return;
|
||||
|
||||
/**
|
||||
* Handling of re-transmitted Xiaomi messages.
|
||||
* https://github.com/Koenkk/zigbee2mqtt/issues/1238
|
||||
* https://github.com/Koenkk/zigbee2mqtt/issues/3592
|
||||
*
|
||||
* Some Xiaomi router devices re-transmit messages from Xiaomi end devices.
|
||||
* The network address of these message is set to the one of the Xiaomi router.
|
||||
* Therefore it looks like if the message came from the Xiaomi router, while in
|
||||
* fact it came from the end device.
|
||||
* Handling these message would result in false state updates.
|
||||
* The group ID attribute of these message defines the network address of the end device.
|
||||
*/
|
||||
if (data.device.isXiaomi() && data.device.zh.type === 'Router' && data.groupID) {
|
||||
logger.debug('Handling re-transmitted Xiaomi message');
|
||||
data = {...data, device: this.zigbee.deviceByNetworkAddress(data.groupID)};
|
||||
}
|
||||
|
||||
if (!this.shouldProcess(data)) {
|
||||
utils.publishLastSeen({device: data.device, reason: 'messageEmitted'},
|
||||
settings.get(), true, this.publishEntityState);
|
||||
return;
|
||||
}
|
||||
|
||||
const converters = data.device.definition.fromZigbee.filter((c) => {
|
||||
const type = Array.isArray(c.type) ? c.type.includes(data.type) : c.type === data.type;
|
||||
return c.cluster === data.cluster && type;
|
||||
});
|
||||
|
||||
// Check if there is an available converter, genOta messages are not interesting.
|
||||
const ignoreClusters: (string | number)[] = ['genOta', 'genTime', 'genBasic', 'genPollCtrl'];
|
||||
if (converters.length == 0 && !ignoreClusters.includes(data.cluster)) {
|
||||
logger.debug(`No converter available for '${data.device.definition.model}' with ` +
|
||||
`cluster '${data.cluster}' and type '${data.type}' and data '${stringify(data.data)}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert this Zigbee message to a MQTT message.
|
||||
// Get payload for the message.
|
||||
// - If a payload is returned publish it to the MQTT broker
|
||||
// - If NO payload is returned do nothing. This is for non-standard behaviour
|
||||
// for e.g. click switches where we need to count number of clicks and detect long presses.
|
||||
const publish = (payload: KeyValue): void => {
|
||||
if (settings.get().advanced.elapsed) {
|
||||
const now = Date.now();
|
||||
if (this.elapsed[data.device.ieeeAddr]) {
|
||||
payload.elapsed = now - this.elapsed[data.device.ieeeAddr];
|
||||
}
|
||||
|
||||
this.elapsed[data.device.ieeeAddr] = now;
|
||||
}
|
||||
|
||||
// Check if we have to debounce
|
||||
if (data.device.settings.debounce) {
|
||||
this.publishDebounce(data.device, payload, data.device.settings.debounce,
|
||||
data.device.settings.debounce_ignore);
|
||||
} else {
|
||||
this.publishEntityState(data.device, payload);
|
||||
}
|
||||
};
|
||||
|
||||
const meta = {device: data.device.zh, logger, state: this.state.get(data.device)};
|
||||
let payload: KeyValue = {};
|
||||
for (const converter of converters) {
|
||||
try {
|
||||
const converted = await converter.convert(
|
||||
data.device.definition, data, publish, data.device.settings, meta);
|
||||
if (converted) {
|
||||
payload = {...payload, ...converted};
|
||||
}
|
||||
} catch (error) {
|
||||
// istanbul ignore next
|
||||
logger.error(`Exception while calling fromZigbee converter: ${error.message}}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length) {
|
||||
publish(payload);
|
||||
} else {
|
||||
utils.publishLastSeen({device: data.device, reason: 'messageEmitted'},
|
||||
settings.get(), true, this.publishEntityState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/* eslint-disable brace-style */
|
||||
import * as settings from '../util/settings';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
|
||||
export default class Device {
|
||||
public zh: zh.Device;
|
||||
private _definition: zhc.Definition;
|
||||
|
||||
get ieeeAddr(): string {return this.zh.ieeeAddr;}
|
||||
get ID(): string {return this.zh.ieeeAddr;}
|
||||
get settings(): DeviceSettings {return {...settings.get().device_options, ...settings.getDevice(this.ieeeAddr)};}
|
||||
get name(): string {
|
||||
return this.zh.type === 'Coordinator' ? 'Coordinator' : this.settings?.friendly_name || this.ieeeAddr;
|
||||
}
|
||||
get definition(): zhc.Definition {
|
||||
if (!this._definition && !this.zh.interviewing) {
|
||||
this._definition = zigbeeHerdsmanConverters.findByDevice(this.zh);
|
||||
}
|
||||
return this._definition;
|
||||
}
|
||||
|
||||
constructor(device: zh.Device) {
|
||||
this.zh = device;
|
||||
}
|
||||
|
||||
ensureInSettings(): void {
|
||||
if (this.zh.type !== 'Coordinator' && !settings.getDevice(this.zh.ieeeAddr)) {
|
||||
settings.addDevice(this.zh.ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
endpoint(key?: string | number): zh.Endpoint {
|
||||
let endpoint: zh.Endpoint;
|
||||
if (key == null || key == '') key = 'default';
|
||||
|
||||
if (!isNaN(Number(key))) {
|
||||
endpoint = this.zh.getEndpoint(Number(key));
|
||||
} else if (this.definition?.endpoint) {
|
||||
const ID = this.definition?.endpoint?.(this.zh)[key];
|
||||
if (ID) endpoint = this.zh.getEndpoint(ID);
|
||||
else if (key === 'default') endpoint = this.zh.endpoints[0];
|
||||
else return null;
|
||||
} else {
|
||||
/* istanbul ignore next */
|
||||
if (key !== 'default') return null;
|
||||
endpoint = this.zh.endpoints[0];
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
endpointName(endpoint: zh.Endpoint): string {
|
||||
let name = null;
|
||||
if (this.definition?.endpoint) {
|
||||
name = Object.entries(this.definition?.endpoint(this.zh)).find((e) => e[1] == endpoint.ID)[0];
|
||||
}
|
||||
/* istanbul ignore next */
|
||||
return name === 'default' ? null : name;
|
||||
}
|
||||
|
||||
isXiaomi(): boolean {
|
||||
const xiaomiManufacturerID = [4151, 4447];
|
||||
/* istanbul ignore next */
|
||||
return this.zh.modelID !== 'lumi.router' && xiaomiManufacturerID.includes(this.zh.manufacturerID) &&
|
||||
(!this.zh.manufacturerName || !this.zh.manufacturerName.startsWith('Trust'));
|
||||
}
|
||||
|
||||
isIkeaTradfri(): boolean {return this.zh.manufacturerID === 4476;}
|
||||
|
||||
isDevice(): this is Device {return true;}
|
||||
/* istanbul ignore next */
|
||||
isGroup(): this is Group {return false;}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/* eslint-disable brace-style */
|
||||
import * as settings from '../util/settings';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
|
||||
export default class Group {
|
||||
public zh: zh.Group;
|
||||
|
||||
get ID(): number {return this.zh.groupID;}
|
||||
get settings(): GroupSettings {return settings.getGroup(this.ID);}
|
||||
get name(): string {return this.settings?.friendly_name || this.ID.toString();}
|
||||
|
||||
constructor(group: zh.Group) {
|
||||
this.zh = group;
|
||||
}
|
||||
|
||||
membersDefinitions(): zhc.Definition[] {
|
||||
return this.zh.members.map((m) =>
|
||||
zigbeeHerdsmanConverters.findByDevice(m.getDevice())).filter((d) => d) as zhc.Definition[];
|
||||
}
|
||||
|
||||
isDevice(): this is Device {return false;}
|
||||
isGroup(): this is Group {return true;}
|
||||
}
|
||||
+52
-41
@@ -1,25 +1,30 @@
|
||||
const mqtt = require('mqtt');
|
||||
const logger = require('./util/logger');
|
||||
const settings = require('./util/settings');
|
||||
const fs = require('fs');
|
||||
const events = require('events');
|
||||
import mqtt from 'mqtt';
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import utils from './util/utils';
|
||||
import fs from 'fs';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
class MQTT extends events.EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.publishedTopics = new Set();
|
||||
export default class MQTT {
|
||||
private publishedTopics: Set<string> = new Set();
|
||||
private connectionTimer: NodeJS.Timeout;
|
||||
private client: mqtt.MqttClient;
|
||||
private eventBus: EventBus;
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
async connect(): Promise<void> {
|
||||
const mqttSettings = settings.get().mqtt;
|
||||
logger.info(`Connecting to MQTT server at ${mqttSettings.server}`);
|
||||
|
||||
const options = {
|
||||
const options: mqtt.IClientOptions = {
|
||||
will: {
|
||||
topic: `${settings.get().mqtt.base_topic}/bridge/state`,
|
||||
payload: 'offline',
|
||||
retain: settings.get().mqtt.force_disable_retain ? false : true,
|
||||
qos: 1,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -62,60 +67,71 @@ class MQTT extends events.EventEmitter {
|
||||
options.rejectUnauthorized = false;
|
||||
}
|
||||
|
||||
// Set timer at interval to check if connected to MQTT server.
|
||||
const interval = 10 * 1000; // seconds * 1000.
|
||||
this.connectionTimer = setInterval(() => {
|
||||
if (this.client.reconnecting) {
|
||||
logger.error('Not connected to MQTT server!');
|
||||
}
|
||||
}, interval);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client = mqtt.connect(mqttSettings.server, options);
|
||||
// @ts-ignore https://github.com/Koenkk/zigbee2mqtt/issues/9822
|
||||
this.client.stream.setMaxListeners(0);
|
||||
|
||||
const self = this;
|
||||
this.client.on('connect', () => {
|
||||
logger.info('Connected to MQTT server');
|
||||
self.subscribe(`${settings.get().mqtt.base_topic}/#`);
|
||||
self.publish('bridge/state', 'online', {retain: true, qos: 0});
|
||||
const onConnect = this.onConnect;
|
||||
this.client.on('connect', async () => {
|
||||
await onConnect();
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => reject(err));
|
||||
this.client.on('message', this.onMessage);
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
@bind private async onConnect(): Promise<void> {
|
||||
// Set timer at interval to check if connected to MQTT server.
|
||||
clearTimeout(this.connectionTimer);
|
||||
this.connectionTimer = setInterval(() => {
|
||||
if (this.client.reconnecting) {
|
||||
logger.error('Not connected to MQTT server!');
|
||||
}
|
||||
}, utils.seconds(10));
|
||||
|
||||
logger.info('Connected to MQTT server');
|
||||
this.subscribe(`${settings.get().mqtt.base_topic}/#`);
|
||||
await this.publish('bridge/state', 'online', {retain: true, qos: 0});
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
clearTimeout(this.connectionTimer);
|
||||
this.connectionTimer = null;
|
||||
await this.publish('bridge/state', 'offline', {retain: true, qos: 0});
|
||||
logger.info('Disconnecting from MQTT server');
|
||||
this.client.end();
|
||||
}
|
||||
|
||||
subscribe(topic) {
|
||||
subscribe(topic: string): void {
|
||||
this.client.subscribe(topic);
|
||||
}
|
||||
|
||||
onMessage(topic, message) {
|
||||
@bind public onMessage(topic: string, message: string): void {
|
||||
// Since we subscribe to zigbee2mqtt/# we also receive the message we send ourselves, skip these.
|
||||
if (!this.publishedTopics.has(topic)) {
|
||||
this.emit('message', {topic, message: message + ''});
|
||||
logger.debug(`Received MQTT message on '${topic}' with data '${message}'`);
|
||||
this.eventBus.emitMQTTMessage({topic, message: message + ''});
|
||||
}
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
isConnected(): boolean {
|
||||
return this.client && !this.client.reconnecting;
|
||||
}
|
||||
|
||||
async publish(topic, payload, options, base=settings.get().mqtt.base_topic, skipLog=false, skipReceive=true) {
|
||||
async publish(topic: string, payload: string, options: MQTTOptions={},
|
||||
base=settings.get().mqtt.base_topic, skipLog=false, skipReceive=true,
|
||||
): Promise<void> {
|
||||
const defaultOptions: {qos: mqtt.QoS, retain: boolean} = {qos: 0, retain: false};
|
||||
topic = `${base}/${topic}`;
|
||||
options = {qos: 0, retain: false, ...options};
|
||||
|
||||
if (skipReceive) {
|
||||
this.publishedTopics.add(topic);
|
||||
}
|
||||
|
||||
this.eventBus.emitMQTTMessagePublished({topic, payload, options: {...defaultOptions, ...options}});
|
||||
|
||||
if (!this.isConnected()) {
|
||||
if (!skipLog) {
|
||||
logger.error(`Not connected to MQTT server!`);
|
||||
@@ -128,18 +144,13 @@ class MQTT extends events.EventEmitter {
|
||||
logger.info(`MQTT publish: topic '${topic}', payload '${payload}'`);
|
||||
}
|
||||
|
||||
const actualOptions = {...options};
|
||||
const actualOptions: mqtt.IClientPublishOptions = {...defaultOptions, ...options};
|
||||
if (settings.get().mqtt.force_disable_retain) {
|
||||
actualOptions.retain = false;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.client.publish(topic, payload, actualOptions, () => {
|
||||
this.emit('publishedMessage', {topic, payload, options});
|
||||
resolve();
|
||||
});
|
||||
this.client.publish(topic, payload, actualOptions, () => resolve());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MQTT;
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
const logger = require('./util/logger');
|
||||
const data = require('./util/data');
|
||||
const settings = require('./util/settings');
|
||||
const fs = require('fs');
|
||||
const objectAssignDeep = require('object-assign-deep');
|
||||
|
||||
const saveInterval = 1000 * 60 * 5; // 5 minutes
|
||||
|
||||
const dontCacheProperties = [
|
||||
'action', 'action_.*', 'button', 'button_left', 'button_right', 'click', 'forgotten', 'keyerror',
|
||||
'step_size', 'transition_time', 'group_list', 'group_capacity', 'no_occupancy_since',
|
||||
'step_mode', 'transition_time', 'duration', 'elapsed', 'from_side', 'to_side',
|
||||
];
|
||||
|
||||
class State {
|
||||
constructor(eventBus) {
|
||||
this.state = {};
|
||||
this.file = data.joinPath('state.json');
|
||||
this.timer = null;
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
start() {
|
||||
this._load();
|
||||
|
||||
// Save the state on every interval
|
||||
this.clearTimer();
|
||||
this.timer = setInterval(() => this.save(), saveInterval);
|
||||
}
|
||||
|
||||
clearTimer() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.clearTimer();
|
||||
this.save();
|
||||
}
|
||||
|
||||
_load() {
|
||||
if (fs.existsSync(this.file)) {
|
||||
try {
|
||||
this.state = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
||||
logger.debug(`Loaded state from file ${this.file}`);
|
||||
} catch (e) {
|
||||
logger.debug(`Failed to load state from file ${this.file} (corrupt file?)`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Can't load state from file ${this.file} (doesn't exist)`);
|
||||
}
|
||||
}
|
||||
|
||||
save() {
|
||||
if (settings.get().advanced.cache_state_persistent) {
|
||||
logger.debug(`Saving state to file ${this.file}`);
|
||||
const json = JSON.stringify(this.state, null, 4);
|
||||
try {
|
||||
fs.writeFileSync(this.file, json, 'utf8');
|
||||
} catch (e) {
|
||||
logger.error(`Failed to write state to '${this.file}' (${e.message})`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Not saving state`);
|
||||
}
|
||||
}
|
||||
|
||||
exists(ID) {
|
||||
return this.state.hasOwnProperty(ID);
|
||||
}
|
||||
|
||||
get(ID) {
|
||||
return this.state[ID];
|
||||
}
|
||||
|
||||
set(ID, state, reason=null) {
|
||||
const toState = objectAssignDeep.noMutate(state);
|
||||
|
||||
for (const property of Object.keys(toState)) {
|
||||
if (dontCacheProperties.find((p) => property.match(p))) {
|
||||
delete toState[property];
|
||||
}
|
||||
}
|
||||
|
||||
const fromState = this.state[ID];
|
||||
|
||||
this.state[ID] = toState;
|
||||
|
||||
this.eventBus.emit('stateChange', {ID, from: fromState, to: state, reason});
|
||||
}
|
||||
|
||||
removeKey(ID, path) {
|
||||
if (this.exists(ID)) {
|
||||
let state = this.state[ID];
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const key = path[i];
|
||||
if (i === path.length - 1) {
|
||||
delete state[key];
|
||||
} else {
|
||||
if (state[key]) {
|
||||
state = state[key];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(ID) {
|
||||
if (this.exists(ID)) {
|
||||
delete this.state[ID];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = State;
|
||||
@@ -0,0 +1,95 @@
|
||||
import logger from './util/logger';
|
||||
import data from './util/data';
|
||||
import * as settings from './util/settings';
|
||||
import fs from 'fs';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
|
||||
const saveInterval = 1000 * 60 * 5; // 5 minutes
|
||||
|
||||
const dontCacheProperties = [
|
||||
'^action$', '^action_.*$', '^button$', '^button_left$', '^button_right$', '^click$', '^forgotten$', '^keyerror$',
|
||||
'^step_size$', '^transition_time$', '^group_list$', '^group_capacity$', '^no_occupancy_since$',
|
||||
'^step_mode$', '^transition_time$', '^duration$', '^elapsed$', '^from_side$', '^to_side$',
|
||||
];
|
||||
|
||||
class State {
|
||||
private state: {[s: string | number]: KeyValue} = {};
|
||||
private file = data.joinPath('state.json');
|
||||
private timer: NodeJS.Timer = null;
|
||||
private eventBus: EventBus;
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.load();
|
||||
|
||||
// Save the state on every interval
|
||||
this.timer = setInterval(() => this.save(), saveInterval);
|
||||
this.eventBus.onDeviceLeave(this, (data) => delete this.state[data.ieeeAddr]);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.eventBus.removeListeners(this);
|
||||
clearTimeout(this.timer);
|
||||
this.save();
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
if (fs.existsSync(this.file)) {
|
||||
try {
|
||||
this.state = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
||||
logger.debug(`Loaded state from file ${this.file}`);
|
||||
} catch (e) {
|
||||
logger.debug(`Failed to load state from file ${this.file} (corrupt file?)`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Can't load state from file ${this.file} (doesn't exist)`);
|
||||
}
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
if (settings.get().advanced.cache_state_persistent) {
|
||||
logger.debug(`Saving state to file ${this.file}`);
|
||||
const json = JSON.stringify(this.state, null, 4);
|
||||
try {
|
||||
fs.writeFileSync(this.file, json, 'utf8');
|
||||
} catch (e) {
|
||||
logger.error(`Failed to write state to '${this.file}' (${e.message})`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Not saving state`);
|
||||
}
|
||||
}
|
||||
|
||||
exists(entity: Device | Group): boolean {
|
||||
return this.state.hasOwnProperty(entity.ID);
|
||||
}
|
||||
|
||||
get(entity: Group | Device): KeyValue {
|
||||
return this.state[entity.ID];
|
||||
}
|
||||
|
||||
set(entity: Group | Device, update: KeyValue, reason: string=null): KeyValue {
|
||||
const fromState = this.state[entity.ID] || {};
|
||||
const toState = objectAssignDeep({}, fromState, update);
|
||||
const result = {...toState};
|
||||
|
||||
for (const property of Object.keys(toState)) {
|
||||
if (dontCacheProperties.find((p) => property.match(p))) {
|
||||
delete toState[property];
|
||||
}
|
||||
}
|
||||
|
||||
this.state[entity.ID] = toState;
|
||||
this.eventBus.emitStateChange({entity, from: fromState, to: toState, reason, update});
|
||||
return result;
|
||||
}
|
||||
|
||||
remove(ID: string | number): void {
|
||||
delete this.state[ID];
|
||||
}
|
||||
}
|
||||
|
||||
export default State;
|
||||
@@ -0,0 +1,3 @@
|
||||
declare module 'json-stable-stringify-without-jsonify' {
|
||||
export default function (obj: unknown): string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
declare module 'mkdir-recursive' {
|
||||
export function mkdirSync(path: string): void;
|
||||
}
|
||||
Vendored
+300
@@ -0,0 +1,300 @@
|
||||
/* eslint-disable camelcase */
|
||||
import type {
|
||||
Device as ZHDevice,
|
||||
Group as ZHGroup,
|
||||
Endpoint as ZHEndpoint,
|
||||
} from 'zigbee-herdsman/dist/controller/model';
|
||||
|
||||
import type {
|
||||
NetworkParameters as ZHNetworkParameters,
|
||||
CoordinatorVersion as ZHCoordinatorVersion,
|
||||
LQI as ZHLQI,
|
||||
RoutingTable as ZHRoutingTable,
|
||||
RoutingTableEntry as ZHRoutingTableEntry,
|
||||
} from 'zigbee-herdsman/dist/adapter/tstype';
|
||||
|
||||
import type {
|
||||
Cluster as ZHCluster,
|
||||
} from 'zigbee-herdsman/dist/zcl/tstype';
|
||||
|
||||
import type * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
|
||||
|
||||
import type TypeEventBus from 'lib/eventBus';
|
||||
import type TypeMQTT from 'lib/mqtt';
|
||||
import type TypeState from 'lib/state';
|
||||
import type TypeZigbee from 'lib/zigbee';
|
||||
import type TypeDevice from 'lib/model/device';
|
||||
import type TypeGroup from 'lib/model/group';
|
||||
import type TypeExtension from 'lib/extension/extension';
|
||||
|
||||
import type mqtt from 'mqtt';
|
||||
|
||||
declare global {
|
||||
// Define some class types as global
|
||||
type EventBus = TypeEventBus;
|
||||
type MQTT = TypeMQTT;
|
||||
type Zigbee = TypeZigbee;
|
||||
type Group = TypeGroup;
|
||||
type Device = TypeDevice;
|
||||
type State = TypeState;
|
||||
type Extension = TypeExtension;
|
||||
|
||||
// Types
|
||||
interface MQTTResponse {data: KeyValue, status: 'error' | 'ok', error?: string, transaction?: string}
|
||||
interface MQTTOptions {qos?: mqtt.QoS, retain?: boolean, properties?: {messageExpiryInterval: number}}
|
||||
type StateChangeReason = 'publishDebounce' | 'groupOptimistic' | 'lastSeenChanged';
|
||||
type PublishEntityState = (entity: Device | Group, payload: KeyValue,
|
||||
stateChangeReason?: StateChangeReason) => Promise<void>;
|
||||
type RecursivePartial<T> = {[P in keyof T]?: RecursivePartial<T[P]>;};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
interface KeyValue {[s: string]: any}
|
||||
|
||||
// zigbee-herdsman
|
||||
namespace zh {
|
||||
type Endpoint = ZHEndpoint;
|
||||
type Device = ZHDevice;
|
||||
type Group = ZHGroup;
|
||||
type LQI = ZHLQI;
|
||||
type RoutingTable = ZHRoutingTable;
|
||||
type RoutingTableEntry = ZHRoutingTableEntry;
|
||||
type CoordinatorVersion = ZHCoordinatorVersion;
|
||||
type NetworkParameters = ZHNetworkParameters;
|
||||
type Cluster = ZHCluster;
|
||||
interface Bind {
|
||||
cluster: zh.Cluster;
|
||||
target: zh.Endpoint | zh.Group;
|
||||
}
|
||||
}
|
||||
|
||||
// zigbee-herdsman-converters
|
||||
namespace zhc {
|
||||
interface Logger {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
debug: (message: string) => void;
|
||||
}
|
||||
|
||||
interface ToZigbeeConverterGetMeta {message?: KeyValue, mapped?: Definition | Definition[]}
|
||||
|
||||
interface ToZigbeeConverterResult {state: KeyValue,
|
||||
membersState: {[s: string]: KeyValue}, readAfterWriteTime?: number}
|
||||
|
||||
interface ToZigbeeConverter {
|
||||
key: string[],
|
||||
convertGet?: (entity: zh.Endpoint | zh.Group, key: string, meta: ToZigbeeConverterGetMeta) => Promise<void>
|
||||
convertSet?: (entity: zh.Endpoint | zh.Group, key: string, value: KeyValue | string | number,
|
||||
meta: {state: KeyValue}) => Promise<ToZigbeeConverterResult>
|
||||
}
|
||||
|
||||
interface FromZigbeeConverter {
|
||||
cluster: string,
|
||||
type: string[] | string,
|
||||
convert: (model: Definition, msg: KeyValue, publish: (payload: KeyValue) => void, options: KeyValue,
|
||||
meta: {state: KeyValue, logger: Logger, device: zh.Device}) => Promise<KeyValue>,
|
||||
}
|
||||
|
||||
interface DefinitionExposeFeature {name: string, endpoint?: string,
|
||||
property: string, value_max?: number, value_min?: number, unit?: string,
|
||||
value_off?: string, value_on?: string, value_step?: number, values: string[], access: number}
|
||||
|
||||
interface DefinitionExpose {
|
||||
type: string, name?: string, features?: DefinitionExposeFeature[],
|
||||
endpoint?: string, values?: string[], value_off?: string, value_on?: string,
|
||||
access: number, property: string, unit?: string,
|
||||
value_min?: number, value_max?: number}
|
||||
|
||||
interface Definition {
|
||||
model: string,
|
||||
zigbeeModel: string[],
|
||||
endpoint?: (device: zh.Device) => {[s: string]: number}
|
||||
toZigbee: ToZigbeeConverter[]
|
||||
fromZigbee: FromZigbeeConverter[]
|
||||
icon?: string
|
||||
description: string
|
||||
options: zhc.DefinitionExpose[],
|
||||
vendor: string
|
||||
exposes: DefinitionExpose[]
|
||||
configure?: (device: zh.Device, coordinatorEndpoint: zh.Endpoint, logger: Logger) => Promise<void>;
|
||||
onEvent?: (type: string, data: KeyValue, device: zh.Device, settings: KeyValue) => Promise<void>;
|
||||
ota?: {
|
||||
isUpdateAvailable: (device: zh.Device, logger: Logger, data?: KeyValue) => Promise<boolean>;
|
||||
updateToLatest: (device: zh.Device, logger: Logger,
|
||||
onProgress: (progress: number, remaining: number) => void) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ExternalDefinition = Definition & {homeassistant: any};
|
||||
}
|
||||
|
||||
namespace eventdata {
|
||||
type DeviceRenamed = { device: Device, homeAssisantRename: boolean, from: string, to: string };
|
||||
type DeviceRemoved = { ieeeAddr: string, name: string };
|
||||
type MQTTMessage = { topic: string, message: string };
|
||||
type MQTTMessagePublished = { topic: string, payload: string, options: {retain: boolean, qos: number} };
|
||||
type StateChange = {
|
||||
entity: Device | Group, from: KeyValue, to: KeyValue, reason: string | null, update: KeyValue };
|
||||
type PermitJoinChanged = ZHEvents.PermitJoinChangedPayload;
|
||||
type LastSeenChanged = { device: Device,
|
||||
reason: 'deviceAnnounce' | 'networkAddress' | 'deviceJoined' | 'messageEmitted' | 'messageNonEmitted'; };
|
||||
type DeviceNetworkAddressChanged = { device: Device };
|
||||
type DeviceAnnounce = { device: Device };
|
||||
type DeviceInterview = { device: Device, status: 'started' | 'successful' | 'failed' };
|
||||
type DeviceJoined = { device: Device };
|
||||
type ReportingDisabled = { device: Device };
|
||||
type DeviceLeave = { ieeeAddr: string, name: string };
|
||||
type GroupMembersChanged = {group: Group, action: 'remove' | 'add' | 'remove_all',
|
||||
endpoint: zh.Endpoint, skipDisableReporting: boolean };
|
||||
type PublishEntityState = {entity: Group | Device, message: KeyValue, stateChangeReason: StateChangeReason,
|
||||
payload: KeyValue};
|
||||
type DeviceMessage = {
|
||||
type: ZHEvents.MessagePayloadType;
|
||||
device: Device;
|
||||
endpoint: zh.Endpoint;
|
||||
linkquality: number;
|
||||
groupID: number;
|
||||
cluster: string | number;
|
||||
data: KeyValue | Array<string | number>;
|
||||
meta: {zclTransactionSequenceNumber?: number;};
|
||||
};
|
||||
}
|
||||
|
||||
// Settings
|
||||
// eslint-disable camelcase
|
||||
interface Settings {
|
||||
homeassistant?: boolean,
|
||||
devices?: {[s: string]: DeviceSettings},
|
||||
groups?: {[s: string]: GroupSettings},
|
||||
passlist: string[],
|
||||
blocklist: string[],
|
||||
whitelist: string[],
|
||||
ban: string[],
|
||||
availability?: boolean | {
|
||||
active?: {timeout?: number},
|
||||
passive?: {timeout?: number}
|
||||
},
|
||||
permit_join: boolean,
|
||||
frontend?: {
|
||||
auth_token?: string,
|
||||
host?: string,
|
||||
port?: number,
|
||||
url?: string,
|
||||
},
|
||||
mqtt: {
|
||||
base_topic: string,
|
||||
include_device_information: boolean,
|
||||
force_disable_retain: boolean
|
||||
version?: number,
|
||||
user?: string,
|
||||
password?: string,
|
||||
server: string,
|
||||
ca?: string,
|
||||
keepalive?: number,
|
||||
key?: string,
|
||||
cert?: string,
|
||||
client_id?: string,
|
||||
reject_unauthorized?: boolean,
|
||||
},
|
||||
serial: {
|
||||
disable_led?: boolean,
|
||||
port?: string,
|
||||
adapter?: 'deconz' | 'zstack' | 'ezsp' | 'zigate'
|
||||
},
|
||||
device_options: KeyValue,
|
||||
map_options: {
|
||||
graphviz: {
|
||||
colors: {
|
||||
fill: {
|
||||
enddevice: string,
|
||||
coordinator: string,
|
||||
router: string,
|
||||
},
|
||||
font: {
|
||||
coordinator: string,
|
||||
router: string,
|
||||
enddevice: string,
|
||||
},
|
||||
line: {
|
||||
active: string,
|
||||
inactive: string,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
output: 'json' | 'attribute' | 'attribute_and_json',
|
||||
transmit_power?: number,
|
||||
},
|
||||
advanced: {
|
||||
legacy_api: boolean,
|
||||
log_rotation: boolean,
|
||||
log_symlink_current: boolean,
|
||||
log_output: ('console' | 'file' | 'syslog')[],
|
||||
log_directory: string,
|
||||
log_file: string,
|
||||
log_level: 'debug' | 'info' | 'error' | 'warn',
|
||||
log_syslog: KeyValue,
|
||||
soft_reset_timeout: number,
|
||||
pan_id: number | 'GENERATE',
|
||||
ext_pan_id: number[],
|
||||
channel: number,
|
||||
adapter_concurrent: number | null,
|
||||
adapter_delay: number | null,
|
||||
cache_state: boolean,
|
||||
cache_state_persistent: boolean,
|
||||
cache_state_send_on_startup: boolean,
|
||||
last_seen: 'disable' | 'ISO_8601' | 'ISO_8601_local' | 'epoch',
|
||||
elapsed: boolean,
|
||||
network_key: number[] | 'GENERATE',
|
||||
report: boolean,
|
||||
homeassistant_discovery_topic: string,
|
||||
homeassistant_status_topic: string,
|
||||
homeassistant_legacy_entity_attributes: boolean,
|
||||
homeassistant_legacy_triggers: boolean,
|
||||
timestamp_format: string,
|
||||
baudrate?: number,
|
||||
rtscts?: boolean,
|
||||
ikea_ota_use_test_url?: boolean,
|
||||
// below are deprecated
|
||||
availability_timeout?: number,
|
||||
availability_blocklist?: string[],
|
||||
availability_passlist?: string[],
|
||||
availability_blacklist?: string[],
|
||||
availability_whitelist?: string[],
|
||||
},
|
||||
ota: {
|
||||
update_check_interval: number,
|
||||
disable_automatic_update_check: boolean
|
||||
},
|
||||
external_converters: string[],
|
||||
}
|
||||
|
||||
interface DeviceSettings {
|
||||
ID?: string,
|
||||
retention?: number,
|
||||
availability?: boolean | {timeout: number},
|
||||
optimistic?: boolean,
|
||||
retrieve_state?: boolean,
|
||||
debounce?: number,
|
||||
debounce_ignore?: string[],
|
||||
filtered_optimistic?: string[],
|
||||
icon?: string,
|
||||
homeassistant?: KeyValue,
|
||||
legacy?: boolean,
|
||||
filtered_attributes?: string[],
|
||||
friendly_name: string,
|
||||
}
|
||||
|
||||
interface GroupSettings {
|
||||
devices?: string[],
|
||||
ID?: number,
|
||||
optimistic?: boolean,
|
||||
filtered_optimistic?: string[],
|
||||
retrieve_state?: boolean,
|
||||
homeassistant?: KeyValue,
|
||||
filtered_attributes?: string[],
|
||||
friendly_name: string,
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
declare module 'zigbee-herdsman-converters' {
|
||||
export function findByDevice(device: zh.Device): zhc.Definition;
|
||||
export function getConfigureKey(definition: zhc.Definition): string | number;
|
||||
export const toZigbeeConverters: {[s: string]: zhc.ToZigbeeConverter};
|
||||
export const definitions: zhc.Definition[];
|
||||
export function addDeviceDefinition(definition: zhc.Definition): Promise<void>;
|
||||
export function onEvent(type: string, data: KeyValue, device: zh.Device): Promise<void>;
|
||||
}
|
||||
|
||||
declare module 'zigbee-herdsman-converters/lib/ota/tradfri' {
|
||||
export function useTestURL(): void;
|
||||
}
|
||||
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
declare module 'zigbee2mqtt-frontend' {
|
||||
export function getPath(): string;
|
||||
}
|
||||
|
||||
declare module 'connect-gzip-static' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type RequestHandler = (req: any, res: any) => void;
|
||||
export default function gzipStatic(root: string, options?: Record<string, unknown>): RequestHandler;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
const path = require('path');
|
||||
|
||||
let dataPath = null;
|
||||
|
||||
function load() {
|
||||
if (process.env.ZIGBEE2MQTT_DATA) {
|
||||
dataPath = process.env.ZIGBEE2MQTT_DATA;
|
||||
} else {
|
||||
dataPath = path.join(__dirname, '..', '..', 'data');
|
||||
dataPath = path.normalize(dataPath);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
module.exports = {
|
||||
joinPath: (file) => path.join(dataPath, file),
|
||||
getPath: () => dataPath,
|
||||
|
||||
// For test only.
|
||||
_reload: () => load(),
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import path from 'path';
|
||||
|
||||
let dataPath: string = null;
|
||||
|
||||
function load(): void {
|
||||
if (process.env.ZIGBEE2MQTT_DATA) {
|
||||
dataPath = process.env.ZIGBEE2MQTT_DATA;
|
||||
} else {
|
||||
dataPath = path.join(__dirname, '..', '..', 'data');
|
||||
dataPath = path.normalize(dataPath);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
function joinPath(file: string): string {
|
||||
return path.join(dataPath, file);
|
||||
}
|
||||
|
||||
function getPath(): string {
|
||||
return dataPath;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
function testingOnlyReload(): void {
|
||||
load();
|
||||
}
|
||||
|
||||
export default {joinPath, getPath, testingOnlyReload};
|
||||
@@ -1,12 +1,13 @@
|
||||
const winston = require('winston');
|
||||
const moment = require('moment');
|
||||
const settings = require('./settings');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fx = require('mkdir-recursive');
|
||||
const rimraf = require('rimraf');
|
||||
import winston from 'winston';
|
||||
import moment from 'moment';
|
||||
import * as settings from './settings';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import fx from 'mkdir-recursive';
|
||||
import rimraf from 'rimraf';
|
||||
import assert from 'assert';
|
||||
|
||||
const colorizer = winston.format.colorize();
|
||||
const assert = require('assert');
|
||||
|
||||
// What transports to enable
|
||||
const output = settings.get().advanced.log_output;
|
||||
@@ -19,38 +20,49 @@ const logFilename = settings.get().advanced.log_file.replace('%TIMESTAMP%', time
|
||||
// Make sure that log directoy exsists when not logging to stdout only
|
||||
if (output.includes('file')) {
|
||||
fx.mkdirSync(directory);
|
||||
|
||||
if (settings.get().advanced.log_symlink_current) {
|
||||
const current = settings.get().advanced.log_directory.replace('%TIMESTAMP%', 'current');
|
||||
const actual = './' + timestamp;
|
||||
if (fs.existsSync(current)) {
|
||||
fs.unlinkSync(current);
|
||||
}
|
||||
fs.symlinkSync(actual, current);
|
||||
}
|
||||
}
|
||||
|
||||
const z2mToWinstonLevel = (level) => level === 'warn' ? 'warning' : level;
|
||||
const winstonToZ2mLevel = (level) => level === 'warning' ? 'warn' : level;
|
||||
type Z2MLogLevel = 'warn' | 'debug' | 'info' | 'error';
|
||||
type WinstonLogLevel = 'warning' | 'debug' | 'info' | 'error';
|
||||
|
||||
const z2mToWinstonLevel = (level: Z2MLogLevel): WinstonLogLevel => level === 'warn' ? 'warning' : level;
|
||||
const winstonToZ2mLevel = (level: WinstonLogLevel): Z2MLogLevel => level === 'warning' ? 'warn' : level;
|
||||
|
||||
// Determine the log level.
|
||||
let level = settings.get().advanced.log_level;
|
||||
const z2mLevel = settings.get().advanced.log_level;
|
||||
const validLevels = ['info', 'error', 'warn', 'debug'];
|
||||
assert(validLevels.includes(level), `'${level}' is not a valid log_level, use one of '${validLevels.join(', ')}'`);
|
||||
level = z2mToWinstonLevel(level);
|
||||
assert(validLevels.includes(z2mLevel), `'${z2mLevel}' is not valid log_level, use one of '${validLevels.join(', ')}'`);
|
||||
const level = z2mToWinstonLevel(z2mLevel);
|
||||
|
||||
const levelWithCompensatedLength = {
|
||||
const levelWithCompensatedLength: {[s: string]: string} = {
|
||||
'info': 'info ',
|
||||
'error': 'error',
|
||||
'warn': 'warn ',
|
||||
'debug': 'debug',
|
||||
};
|
||||
|
||||
/* istanbul ignore next */
|
||||
const timestampFormat = () => moment().format(settings.get().advanced.timestamp_format);
|
||||
const timestampFormat = (): string => moment().format(settings.get().advanced.timestamp_format);
|
||||
|
||||
// Setup default console logger
|
||||
const transportsToUse = [
|
||||
const transportsToUse: winston.transport[] = [
|
||||
new winston.transports.Console({
|
||||
level,
|
||||
silent: !output.includes('console'),
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({format: timestampFormat}),
|
||||
winston.format.printf(/* istanbul ignore next */(info) => {
|
||||
let {timestamp, level, message} = info;
|
||||
level = winstonToZ2mLevel(level);
|
||||
const prefix = colorizer.colorize(level, `Zigbee2MQTT:${levelWithCompensatedLength[level]}`);
|
||||
const {timestamp, level, message} = info;
|
||||
const l = winstonToZ2mLevel(level as WinstonLogLevel);
|
||||
const prefix = colorizer.colorize(l, `Zigbee2MQTT:${levelWithCompensatedLength[l]}`);
|
||||
return `${prefix} ${timestamp.split('.')[0]}: ${message}`;
|
||||
}),
|
||||
),
|
||||
@@ -59,16 +71,16 @@ const transportsToUse = [
|
||||
|
||||
// Add file logger when enabled
|
||||
// NOTE: the initiation of the logger, even when not added as transport tries to create the logging directory
|
||||
const transportFileOptions = {
|
||||
const transportFileOptions: KeyValue = {
|
||||
filename: path.join(directory, logFilename),
|
||||
json: false,
|
||||
level,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({format: timestampFormat}),
|
||||
winston.format.printf(/* istanbul ignore next */(info) => {
|
||||
let {timestamp, level, message} = info;
|
||||
level = winstonToZ2mLevel(level);
|
||||
return `${levelWithCompensatedLength[level]} ${timestamp.split('.')[0]}: ${message}`;
|
||||
const {timestamp, level, message} = info;
|
||||
const l = winstonToZ2mLevel(level as WinstonLogLevel);
|
||||
return `${levelWithCompensatedLength[l]} ${timestamp.split('.')[0]}: ${message}`;
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -85,8 +97,9 @@ if (output.includes('file')) {
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (output.includes('syslog')) {
|
||||
// eslint-disable-next-line
|
||||
require('winston-syslog').Syslog;
|
||||
const options = {
|
||||
const options: KeyValue = {
|
||||
app_name: 'Zigbee2MQTT',
|
||||
format: winston.format.printf(/* istanbul ignore next */(info) => {
|
||||
return `${info.message}`;
|
||||
@@ -94,6 +107,7 @@ if (output.includes('syslog')) {
|
||||
...settings.get().advanced.log_syslog,
|
||||
};
|
||||
if (options.hasOwnProperty('type')) options.type = options.type.toString();
|
||||
// @ts-ignore
|
||||
transportsToUse.push(new winston.transports.Syslog(options));
|
||||
}
|
||||
|
||||
@@ -101,7 +115,7 @@ if (output.includes('syslog')) {
|
||||
const logger = winston.createLogger({transports: transportsToUse, levels: winston.config.syslog.levels});
|
||||
|
||||
// Cleanup any old log directory.
|
||||
function cleanup() {
|
||||
function cleanup(): void {
|
||||
if (settings.get().advanced.log_directory.includes('%TIMESTAMP%')) {
|
||||
const rootDirectory = path.join(directory, '..');
|
||||
|
||||
@@ -110,7 +124,7 @@ function cleanup() {
|
||||
return {path: d, birth: fs.statSync(d).mtime};
|
||||
});
|
||||
|
||||
directories.sort((a, b) => b.birth - a.birth);
|
||||
directories.sort((a: KeyValue, b: KeyValue) => b.birth - a.birth);
|
||||
directories = directories.slice(10, directories.length);
|
||||
directories.forEach((dir) => {
|
||||
logger.debug(`Removing old log directory '${dir.path}'`);
|
||||
@@ -120,33 +134,53 @@ function cleanup() {
|
||||
}
|
||||
|
||||
// Print to user what logging is enabled
|
||||
function logOutput() {
|
||||
function logOutput(): void {
|
||||
if (output.includes('file')) {
|
||||
if (output.includes('console')) {
|
||||
logger.info(`Logging to console and directory: '${directory}' filename: ${logFilename}`);
|
||||
} else {
|
||||
logger.info(`Logging to directory: '${directory}' filename: ${logFilename}`);
|
||||
}
|
||||
logger.cleanup();
|
||||
cleanup();
|
||||
} else if (output.includes('console')) {
|
||||
logger.info(`Logging to console only'`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.addTransport = (transport) => {
|
||||
function addTransport(transport: winston.transport): void {
|
||||
transport.level = transportsToUse[0].level;
|
||||
logger.add(transport);
|
||||
};
|
||||
logger.cleanup = cleanup;
|
||||
logger.logOutput = logOutput;
|
||||
logger.getLevel = () => winstonToZ2mLevel(transportsToUse[0].level);
|
||||
logger.setLevel = (level) => {
|
||||
level = z2mToWinstonLevel(level);
|
||||
logger.transports.forEach((transport) => transport.level = level);
|
||||
};
|
||||
}
|
||||
|
||||
// winston.config.syslog.levels doesnt have warn, but is required for syslog.
|
||||
/* istanbul ignore next */
|
||||
logger.warn = (message) => logger.warning(message);
|
||||
function getLevel(): Z2MLogLevel {
|
||||
return winstonToZ2mLevel(transportsToUse[0].level as WinstonLogLevel);
|
||||
}
|
||||
|
||||
module.exports = logger;
|
||||
function setLevel(level: Z2MLogLevel): void {
|
||||
logger.transports.forEach((transport) => transport.level = z2mToWinstonLevel(level as Z2MLogLevel));
|
||||
}
|
||||
|
||||
function warn(message: string): void {
|
||||
// winston.config.syslog.levels doesnt have warn, but is required for syslog.
|
||||
logger.warning(message);
|
||||
}
|
||||
|
||||
function warning(message: string): void {
|
||||
logger.warning(message);
|
||||
}
|
||||
|
||||
function info(message: string): void {
|
||||
logger.info(message);
|
||||
}
|
||||
|
||||
function debug(message: string): void {
|
||||
logger.debug(message);
|
||||
}
|
||||
|
||||
function error(message: string): void {
|
||||
logger.error(message);
|
||||
}
|
||||
|
||||
export default {
|
||||
logOutput, warn, warning, error, info, debug, setLevel, getLevel, cleanup, addTransport, winston: logger,
|
||||
};
|
||||
@@ -26,6 +26,53 @@
|
||||
},
|
||||
"examples": ["DIYRuZ_FreePad.js"]
|
||||
},
|
||||
"availability": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"title": "Availability (boolean)"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Availability (object)",
|
||||
"properties": {
|
||||
"active": {
|
||||
"type": "object",
|
||||
"title": "Active",
|
||||
"requiresRestart": true,
|
||||
"description": "Options for active devices (routers/mains powered)",
|
||||
"properties": {
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"requiresRestart": true,
|
||||
"default": 10,
|
||||
"description": "Time after which an active device will be marked as offline in minutes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"passive": {
|
||||
"type": "object",
|
||||
"title": "Active",
|
||||
"requiresRestart": true,
|
||||
"description": "Options for passive devices (mostly battery powered)",
|
||||
"properties": {
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"requiresRestart": true,
|
||||
"default": 1500,
|
||||
"description": "Time after which an passive device will be marked as offline in minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"title": "Availability",
|
||||
"requiresRestart": true,
|
||||
"description": "Checks whether devices are online/offline"
|
||||
},
|
||||
"mqtt": {
|
||||
"type": "object",
|
||||
"title": "MQTT",
|
||||
@@ -112,7 +159,7 @@
|
||||
"requiresRestart": true,
|
||||
"description": "MQTT protocol version",
|
||||
"default": 4,
|
||||
"examples": [4, 5]
|
||||
"examples": [5]
|
||||
},
|
||||
"force_disable_retain": {
|
||||
"type": "boolean",
|
||||
@@ -143,9 +190,10 @@
|
||||
"default": false
|
||||
},
|
||||
"adapter": {
|
||||
"type": ["string", "null"],
|
||||
"enum": ["deconz", "zstack", "zigate"],
|
||||
"type": ["string"],
|
||||
"enum": ["deconz", "zstack", "zigate", "ezsp", "auto"],
|
||||
"title": "Adapter",
|
||||
"default": "auto",
|
||||
"requiresRestart": true,
|
||||
"description": "Adapter type, not needed unless you are experiencing problems"
|
||||
}
|
||||
@@ -247,7 +295,7 @@
|
||||
"title": "ZigBee channel",
|
||||
"requiresRestart": true,
|
||||
"description": "Zigbee channel, changing requires repairing all devices! (Note: use a ZLL channel: 11, 15, 20, or 25 to avoid Problems)",
|
||||
"examples": [11, 15, 20, 25]
|
||||
"examples": [15, 20, 25]
|
||||
},
|
||||
"cache_state": {
|
||||
"type": "boolean",
|
||||
@@ -274,6 +322,13 @@
|
||||
"description": "Log rotation",
|
||||
"default": true
|
||||
},
|
||||
"log_symlink_current": {
|
||||
"type": "boolean",
|
||||
"title": "Log symlink current",
|
||||
"requiresRestart": true,
|
||||
"description": "Create symlink to current logs in the log directory",
|
||||
"default": false
|
||||
},
|
||||
"log_level": {
|
||||
"type": "string",
|
||||
"enum": ["info", "warn", "error", "debug"],
|
||||
@@ -310,8 +365,8 @@
|
||||
"type": "number",
|
||||
"title": "Baudrate",
|
||||
"requiresRestart": true,
|
||||
"description": "Baudrate for serial port, default: 115200 for Z-Stack, 38400 for Deconz",
|
||||
"examples": [38400, 115200]
|
||||
"description": "Baud rate speed for serial port, this can be anything firmware support but default is 115200 for Z-Stack and EZSP, 38400 for Deconz, however note that some EZSP firmware need 57600",
|
||||
"examples": [38400, 57600, 115200]
|
||||
},
|
||||
"rtscts": {
|
||||
"type": "boolean",
|
||||
@@ -357,50 +412,6 @@
|
||||
"description": "Add an elapsed attribute to MQTT messages, contains milliseconds since the previous msg",
|
||||
"default": false
|
||||
},
|
||||
"availability_timeout": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"requiresRestart": true,
|
||||
"title": "Availability Timeout",
|
||||
"description": "Availability timeout in seconds when enabled, devices will be checked if they are still online. Only AC powered routers are checked for availability"
|
||||
},
|
||||
"availability_blocklist": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"requiresRestart": true,
|
||||
"title": "Availability Blocklist",
|
||||
"description": "Prevent devices from being checked for availability"
|
||||
},
|
||||
"availability_passlist": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"requiresRestart": true,
|
||||
"title": "Availability passlist",
|
||||
"description": "Only enable availability check for certain devices"
|
||||
},
|
||||
"availability_blacklist": {
|
||||
"type": "array",
|
||||
"readOnly": true,
|
||||
"requiresRestart": true,
|
||||
"title": "Availability blacklist (deprecated, use availability_blocklist)",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"availability_whitelist": {
|
||||
"type": "array",
|
||||
"readOnly": true,
|
||||
"requiresRestart": true,
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Availability whitelist (deprecated, use passlist)"
|
||||
},
|
||||
"report": {
|
||||
"type": "boolean",
|
||||
"title": "Reporting",
|
||||
@@ -415,6 +426,12 @@
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant"]
|
||||
},
|
||||
"homeassistant_legacy_entity_attributes": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant legacy entity attributes",
|
||||
"description": "Home Assistant legacy entity attributes, when enabled Zigbee2MQTT will add state attributes to each entity, additional to the separate entities and devices it already creates",
|
||||
"default": true
|
||||
},
|
||||
"homeassistant_status_topic": {
|
||||
"type": "string",
|
||||
"title": "Home Assistant status topic",
|
||||
@@ -476,7 +493,6 @@
|
||||
"description": "The network protocol to log over (e.g. tcp4, udp4, tls4, unix, unix-connect, etc).",
|
||||
"default": "tcp4",
|
||||
"examples": [
|
||||
"tcp4",
|
||||
"udp4",
|
||||
"tls4",
|
||||
"unix",
|
||||
@@ -489,7 +505,6 @@
|
||||
"description": "The path to the syslog dgram socket (i.e. /dev/log or /var/run/syslog for OS X).",
|
||||
"default": "/dev/log",
|
||||
"examples": [
|
||||
"/dev/log",
|
||||
"/var/run/syslog"
|
||||
]
|
||||
},
|
||||
@@ -589,8 +604,8 @@
|
||||
"update_check_interval": {
|
||||
"type": "number",
|
||||
"title": "Update check interval",
|
||||
"description": "Your device may request a check for a new firmware update. This value determines how frequently third party servers may actually be contacted to look for firmware updates. The value is set in minutes, and the default is 10.",
|
||||
"default": 10
|
||||
"description": "Your device may request a check for a new firmware update. This value determines how frequently third party servers may actually be contacted to look for firmware updates. The value is set in minutes, and the default is 1 day.",
|
||||
"default": 1440
|
||||
},
|
||||
"disable_automatic_update_check": {
|
||||
"type": "boolean",
|
||||
@@ -637,7 +652,7 @@
|
||||
"type": "string",
|
||||
"title": "Bind host",
|
||||
"description": "Frontend binding host",
|
||||
"default":" 0.0.0.0",
|
||||
"default": "0.0.0.0",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"auth_token": {
|
||||
@@ -673,7 +688,7 @@
|
||||
"qos": {
|
||||
"type": "number",
|
||||
"title": "QoS",
|
||||
"descritption": "QoS level for MQTT messages of this device"
|
||||
"description": "QoS level for MQTT messages of this device"
|
||||
},
|
||||
"debounce": {
|
||||
"type": "number",
|
||||
@@ -692,7 +707,17 @@
|
||||
"optimistic": {
|
||||
"type": "boolean",
|
||||
"title": "Optimistic",
|
||||
"description": "Publish optimistic state after set (default true)"
|
||||
"description": "Publish optimistic state after set",
|
||||
"default": true
|
||||
},
|
||||
"filtered_optimistic": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": ["color_mode", "color_temp", "color"],
|
||||
"title": "Filtered optimistic attributes",
|
||||
"description": "Filter attributes from optimistic publish payload when calling /set. (This has no effect if optimistic is set to false)."
|
||||
},
|
||||
"filtered_attributes": {
|
||||
"type": "array",
|
||||
@@ -700,8 +725,13 @@
|
||||
"type": "string"
|
||||
},
|
||||
"examples": ["temperature", "battery", "action"],
|
||||
"title": "Filtered attributes",
|
||||
"description": "Allows to prevent certain attributes from being published"
|
||||
"title": "Filtered publish attributes",
|
||||
"description": "Filter attributes from publish payload."
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"title": "Icon",
|
||||
"description": "The user-defined device icon for the frontend. It can be a link to an image (not a path to a file) or base64 encoded data URL like: image/svg+xml;base64,PHN2ZyB3aW....R0aD"
|
||||
}
|
||||
},
|
||||
"required": ["friendly_name"]
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
const data = require('./data');
|
||||
const utils = require('./utils');
|
||||
// DEPRECATED ZIGBEE2MQTT_CONFIG: https://github.com/Koenkk/zigbee2mqtt/issues/4697
|
||||
const file = process.env.ZIGBEE2MQTT_CONFIG || data.joinPath('configuration.yaml');
|
||||
const objectAssignDeep = require(`object-assign-deep`);
|
||||
const path = require('path');
|
||||
const yaml = require('./yaml');
|
||||
const Ajv = require('ajv');
|
||||
const schema = require('./settings.schema.json');
|
||||
const ajvSetting = new Ajv({allErrors: true}).compile(schema);
|
||||
const ajvRestartRequired = new Ajv({allErrors: true})
|
||||
.addKeyword('requiresRestart', {validate: (v) => !v}).compile(schema);
|
||||
import data from './data';
|
||||
import utils from './utils';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import path from 'path';
|
||||
import yaml from './yaml';
|
||||
import Ajv from 'ajv';
|
||||
import schemaJson from './settings.schema.json';
|
||||
export const schema = schemaJson;
|
||||
|
||||
const defaults = {
|
||||
// DEPRECATED ZIGBEE2MQTT_CONFIG: https://github.com/Koenkk/zigbee2mqtt/issues/4697
|
||||
const file = process.env.ZIGBEE2MQTT_CONFIG ?? data.joinPath('configuration.yaml');
|
||||
const ajvSetting = new Ajv({allErrors: true}).addKeyword('requiresRestart').compile(schema);
|
||||
const ajvRestartRequired = new Ajv({allErrors: true})
|
||||
.addKeyword({keyword: 'requiresRestart', validate: (schema: unknown) => !schema}).compile(schema);
|
||||
|
||||
const defaults: RecursivePartial<Settings> = {
|
||||
passlist: [],
|
||||
blocklist: [],
|
||||
// Deprecated: use block/passlist
|
||||
@@ -57,6 +59,7 @@ const defaults = {
|
||||
advanced: {
|
||||
legacy_api: true,
|
||||
log_rotation: true,
|
||||
log_symlink_current: false,
|
||||
log_output: ['console', 'file'],
|
||||
log_directory: path.join(data.getPath(), 'log', '%TIMESTAMP%'),
|
||||
log_file: 'log.txt',
|
||||
@@ -70,7 +73,6 @@ const defaults = {
|
||||
adapter_delay: null,
|
||||
|
||||
// Availability timeout in seconds, disabled by default.
|
||||
availability_timeout: 0,
|
||||
availability_blocklist: [],
|
||||
availability_passlist: [],
|
||||
// Deprecated, use block/passlist
|
||||
@@ -83,7 +85,7 @@ const defaults = {
|
||||
*
|
||||
* 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
|
||||
* https://www.zigbee2mqtt.io/guide/configuration/
|
||||
*/
|
||||
cache_state: true,
|
||||
cache_state_persistent: true,
|
||||
@@ -124,6 +126,15 @@ const defaults = {
|
||||
*/
|
||||
homeassistant_status_topic: 'hass/status',
|
||||
|
||||
/**
|
||||
* Home Assistant legacy entity attributes, when enabled:
|
||||
* Zigbee2MQTT will send additional states as attributes with each entity.
|
||||
* For example, A temperature & humidity sensor will have 2 entities for
|
||||
* the temperature and humidity, with this setting enabled both entities
|
||||
* will also have an temperature and humidity attribute.
|
||||
*/
|
||||
homeassistant_legacy_entity_attributes: true,
|
||||
|
||||
/**
|
||||
* Home Assistant legacy triggers, when enabled:
|
||||
* - Zigbee2mqt will send an empty 'action' or 'click' after one has been send
|
||||
@@ -141,7 +152,7 @@ const defaults = {
|
||||
/**
|
||||
* Minimal time delta in minutes between polling third party server for potential firmware updates
|
||||
*/
|
||||
update_check_interval: 10,
|
||||
update_check_interval: 24 * 60,
|
||||
/**
|
||||
* Completely disallow Zigbee devices to initiate a search for a potential firmware update.
|
||||
* If set to true, only a user-initiated update search will be possible.
|
||||
@@ -151,12 +162,12 @@ const defaults = {
|
||||
external_converters: [],
|
||||
};
|
||||
|
||||
let _settings;
|
||||
let _settingsWithDefaults;
|
||||
let _settings: Partial<Settings>;
|
||||
let _settingsWithDefaults: Settings;
|
||||
|
||||
function write() {
|
||||
const settings = get();
|
||||
const toWrite = objectAssignDeep.noMutate(settings);
|
||||
function write(): void {
|
||||
const settings = getInternalSettings();
|
||||
const toWrite: KeyValue = objectAssignDeep({}, settings);
|
||||
|
||||
// Read settings to check if we have to split devices/groups into separate file.
|
||||
const actual = yaml.read(file);
|
||||
@@ -178,17 +189,17 @@ function write() {
|
||||
}
|
||||
|
||||
// Write devices/groups to separate file if required.
|
||||
const writeDevicesOrGroups = (type) => {
|
||||
const writeDevicesOrGroups = (type: 'devices' | 'groups'): void => {
|
||||
if (typeof actual[type] === 'string' || Array.isArray(actual[type])) {
|
||||
const fileToWrite = Array.isArray(actual[type]) ? actual[type][0] : actual[type];
|
||||
const content = objectAssignDeep.noMutate(settings[type]);
|
||||
const content = objectAssignDeep({}, settings[type]);
|
||||
|
||||
// If an array, only write to first file and only devices which are not in the other files.
|
||||
if (Array.isArray(actual[type])) {
|
||||
actual[type].filter((f, i) => i !== 0)
|
||||
.map((f) => yaml.readIfExists(data.joinPath(f), {}))
|
||||
.map((c) => Object.keys(c))
|
||||
.forEach((k) => delete content[k]);
|
||||
actual[type].filter((f: string, i: number) => i !== 0)
|
||||
.map((f: string) => yaml.readIfExists(data.joinPath(f), {}))
|
||||
.map((c: KeyValue) => Object.keys(c))
|
||||
.forEach((k: string) => delete content[k]);
|
||||
}
|
||||
|
||||
yaml.writeIfChanged(data.joinPath(fileToWrite), content);
|
||||
@@ -202,12 +213,12 @@ function write() {
|
||||
yaml.writeIfChanged(file, toWrite);
|
||||
|
||||
_settings = read();
|
||||
_settingsWithDefaults = objectAssignDeep.noMutate(defaults, get());
|
||||
_settingsWithDefaults = objectAssignDeep({}, defaults, getInternalSettings()) as Settings;
|
||||
}
|
||||
|
||||
function validate() {
|
||||
export function validate(): string[] {
|
||||
try {
|
||||
get();
|
||||
getInternalSettings();
|
||||
} catch (error) {
|
||||
if (error.name === 'YAMLException') {
|
||||
return [
|
||||
@@ -220,7 +231,7 @@ function validate() {
|
||||
}
|
||||
|
||||
if (!ajvSetting(_settings)) {
|
||||
return ajvSetting.errors.map((v) => `${v.dataPath.substring(1)} ${v.message}`);
|
||||
return ajvSetting.errors.map((v) => `${v.instancePath.substring(1)} ${v.message}`);
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
@@ -235,14 +246,14 @@ function validate() {
|
||||
}
|
||||
|
||||
// Verify that all friendly names are unique
|
||||
const names = [];
|
||||
const check = (name) => {
|
||||
const names: string[] = [];
|
||||
const check = (name: string): void => {
|
||||
if (names.includes(name)) errors.push(`Duplicate friendly_name '${name}' found`);
|
||||
errors.push(...utils.validateFriendlyName(name));
|
||||
names.push(name);
|
||||
};
|
||||
|
||||
const settingsWithDefaults = getWithDefaults();
|
||||
const settingsWithDefaults = get();
|
||||
Object.values(settingsWithDefaults.devices).forEach((d) => check(d.friendly_name));
|
||||
Object.values(settingsWithDefaults.groups).forEach((g) => check(g.friendly_name));
|
||||
|
||||
@@ -254,9 +265,9 @@ function validate() {
|
||||
}
|
||||
}
|
||||
|
||||
const checkAvailabilityList = (list, type) => {
|
||||
const checkAvailabilityList = (list: string[], type: string): void => {
|
||||
list.forEach((e) => {
|
||||
if (!getEntity(e)) {
|
||||
if (!getDevice(e)) {
|
||||
errors.push(`Non-existing entity '${e}' specified in '${type}'`);
|
||||
}
|
||||
});
|
||||
@@ -270,11 +281,12 @@ function validate() {
|
||||
return errors;
|
||||
}
|
||||
|
||||
function read() {
|
||||
const s = yaml.read(file);
|
||||
function read(): Settings {
|
||||
const s = yaml.read(file) as Settings;
|
||||
|
||||
// Read !secret MQTT username and password if set
|
||||
const interpetValue = (value) => {
|
||||
// eslint-disable-next-line
|
||||
const interpetValue = (value: any): any => {
|
||||
const re = /!(.*) (.*)/g;
|
||||
const match = re.exec(value);
|
||||
if (match) {
|
||||
@@ -286,26 +298,28 @@ function read() {
|
||||
}
|
||||
};
|
||||
|
||||
if (s.mqtt && s.mqtt.user && s.mqtt.password) {
|
||||
if (s.mqtt?.user && s.mqtt?.password) {
|
||||
s.mqtt.user = interpetValue(s.mqtt.user);
|
||||
s.mqtt.password = interpetValue(s.mqtt.password);
|
||||
}
|
||||
|
||||
if (s.advanced && s.advanced.network_key) {
|
||||
if (s.advanced?.network_key) {
|
||||
s.advanced.network_key = interpetValue(s.advanced.network_key);
|
||||
}
|
||||
|
||||
if (s.frontend && s.frontend.auth_token) {
|
||||
if (s.frontend?.auth_token) {
|
||||
s.frontend.auth_token = interpetValue(s.frontend.auth_token);
|
||||
}
|
||||
|
||||
// Read devices/groups configuration from separate file if specified.
|
||||
const readDevicesOrGroups = (type) => {
|
||||
const readDevicesOrGroups = (type: 'devices' | 'groups'): void => {
|
||||
if (typeof s[type] === 'string' || Array.isArray(s[type])) {
|
||||
const files = Array.isArray(s[type]) ? s[type] : [s[type]];
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
const files: string[] = Array.isArray(s[type]) ? s[type] : [s[type]];
|
||||
s[type] = {};
|
||||
for (const file of files) {
|
||||
const content = yaml.readIfExists(data.joinPath(file), {});
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
s[type] = objectAssignDeep.noMutate(s[type], content);
|
||||
}
|
||||
}
|
||||
@@ -317,23 +331,26 @@ function read() {
|
||||
return s;
|
||||
}
|
||||
|
||||
function applyEnvironmentVariables(settings) {
|
||||
const iterate = (obj, path) => {
|
||||
function applyEnvironmentVariables(settings: Partial<Settings>): void {
|
||||
const iterate = (obj: KeyValue, path: string[]): void => {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (key !== 'type') {
|
||||
if (key !== 'properties') {
|
||||
if (key !== 'properties' && obj[key]) {
|
||||
const type = (obj[key].type || 'object').toString();
|
||||
const envPart = path.reduce((acc, val) => `${acc}${val}_`, '');
|
||||
const envVariableName = (`ZIGBEE2MQTT_CONFIG_${envPart}${key}`).toUpperCase();
|
||||
if (process.env[envVariableName]) {
|
||||
const setting = path.reduce((acc, val, index) => {
|
||||
const setting = path.reduce((acc, val) => {
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
acc[val] = acc[val] || {};
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
return acc[val];
|
||||
}, settings);
|
||||
|
||||
if (type.indexOf('object') >= 0 || type.indexOf('array') >= 0) {
|
||||
setting[key] = JSON.parse(process.env[envVariableName]);
|
||||
} else if (type.indexOf('number') >= 0) {
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
setting[key] = process.env[envVariableName] * 1;
|
||||
} else if (type.indexOf('boolean') >= 0) {
|
||||
setting[key] = process.env[envVariableName].toLowerCase() === 'true';
|
||||
@@ -346,7 +363,7 @@ function applyEnvironmentVariables(settings) {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof obj[key] === 'object') {
|
||||
if (typeof obj[key] === 'object' && obj[key]) {
|
||||
const newPath = [...path];
|
||||
if (key !== 'properties') {
|
||||
newPath.push(key);
|
||||
@@ -359,7 +376,7 @@ function applyEnvironmentVariables(settings) {
|
||||
iterate(schema.properties, []);
|
||||
}
|
||||
|
||||
function get() {
|
||||
function getInternalSettings(): Partial<Settings> {
|
||||
if (!_settings) {
|
||||
_settings = read();
|
||||
applyEnvironmentVariables(_settings);
|
||||
@@ -368,9 +385,9 @@ function get() {
|
||||
return _settings;
|
||||
}
|
||||
|
||||
function getWithDefaults() {
|
||||
export function get(): Settings {
|
||||
if (!_settingsWithDefaults) {
|
||||
_settingsWithDefaults = objectAssignDeep.noMutate(defaults, get());
|
||||
_settingsWithDefaults = objectAssignDeep({}, defaults, getInternalSettings()) as Settings;
|
||||
}
|
||||
|
||||
if (!_settingsWithDefaults.devices) {
|
||||
@@ -384,8 +401,9 @@ function getWithDefaults() {
|
||||
return _settingsWithDefaults;
|
||||
}
|
||||
|
||||
function set(path, value) {
|
||||
let settings = get();
|
||||
export function set(path: string[], value: string | number | boolean | KeyValue): void {
|
||||
/* eslint-disable-next-line */
|
||||
let settings: any = getInternalSettings();
|
||||
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const key = path[i];
|
||||
@@ -403,15 +421,16 @@ function set(path, value) {
|
||||
write();
|
||||
}
|
||||
|
||||
function apply(newSettings) {
|
||||
export function apply(newSettings: Record<string, unknown>): boolean {
|
||||
ajvSetting(newSettings);
|
||||
const errors = ajvSetting.errors && ajvSetting.errors.filter((e) => e.keyword !== 'required');
|
||||
if (errors.length) {
|
||||
const error = errors[0];
|
||||
throw new Error(`${error.dataPath.substring(1)} ${error.message}`);
|
||||
throw new Error(`${error.instancePath.substring(1)} ${error.message}`);
|
||||
}
|
||||
|
||||
get(); // Ensure _settings is intialized.
|
||||
getInternalSettings(); // Ensure _settings is intialized.
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
_settings = objectAssignDeep.noMutate(_settings, newSettings);
|
||||
write();
|
||||
|
||||
@@ -421,30 +440,30 @@ function apply(newSettings) {
|
||||
return restartRequired;
|
||||
}
|
||||
|
||||
function getGroup(IDorName) {
|
||||
const settings = getWithDefaults();
|
||||
export function getGroup(IDorName: string | number): GroupSettings {
|
||||
const settings = get();
|
||||
const byID = settings.groups[IDorName];
|
||||
if (byID) {
|
||||
return {optimistic: true, devices: [], ...byID, ID: Number(IDorName), friendlyName: byID.friendly_name};
|
||||
return {devices: [], ...byID, ID: Number(IDorName)};
|
||||
}
|
||||
|
||||
for (const [ID, group] of Object.entries(settings.groups)) {
|
||||
if (group.friendly_name === IDorName) {
|
||||
return {optimistic: true, devices: [], ...group, ID: Number(ID), friendlyName: group.friendly_name};
|
||||
return {devices: [], ...group, ID: Number(ID)};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getGroups() {
|
||||
const settings = getWithDefaults();
|
||||
export function getGroups(): GroupSettings[] {
|
||||
const settings = get();
|
||||
return Object.entries(settings.groups).map(([ID, group]) => {
|
||||
return {optimistic: true, devices: [], ...group, ID: Number(ID), friendlyName: group.friendly_name};
|
||||
return {devices: [], ...group, ID: Number(ID)};
|
||||
});
|
||||
}
|
||||
|
||||
function getGroupThrowIfNotExists(IDorName) {
|
||||
function getGroupThrowIfNotExists(IDorName: string): GroupSettings {
|
||||
const group = getGroup(IDorName);
|
||||
if (!group) {
|
||||
throw new Error(`Group '${IDorName}' does not exist`);
|
||||
@@ -453,23 +472,23 @@ function getGroupThrowIfNotExists(IDorName) {
|
||||
return group;
|
||||
}
|
||||
|
||||
function getDevice(IDorName) {
|
||||
const settings = getWithDefaults();
|
||||
export function getDevice(IDorName: string): DeviceSettings {
|
||||
const settings = get();
|
||||
const byID = settings.devices[IDorName];
|
||||
if (byID) {
|
||||
return {...byID, ID: IDorName, friendlyName: byID.friendly_name};
|
||||
return {...byID, ID: IDorName};
|
||||
}
|
||||
|
||||
for (const [ID, device] of Object.entries(settings.devices)) {
|
||||
if (device.friendly_name === IDorName) {
|
||||
return {...device, ID, friendlyName: device.friendly_name};
|
||||
return {...device, ID};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDeviceThrowIfNotExists(IDorName) {
|
||||
function getDeviceThrowIfNotExists(IDorName: string): DeviceSettings {
|
||||
const device = getDevice(IDorName);
|
||||
if (!device) {
|
||||
throw new Error(`Device '${IDorName}' does not exist`);
|
||||
@@ -478,26 +497,12 @@ function getDeviceThrowIfNotExists(IDorName) {
|
||||
return device;
|
||||
}
|
||||
|
||||
function getEntity(IDorName) {
|
||||
const device = getDevice(IDorName);
|
||||
if (device) {
|
||||
return {...device, type: 'device'};
|
||||
}
|
||||
|
||||
const group = getGroup(IDorName);
|
||||
if (group) {
|
||||
return {...group, type: 'group'};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function addDevice(ID) {
|
||||
export function addDevice(ID: string): DeviceSettings {
|
||||
if (getDevice(ID)) {
|
||||
throw new Error(`Device '${ID}' already exists`);
|
||||
}
|
||||
|
||||
const settings = get();
|
||||
const settings = getInternalSettings();
|
||||
|
||||
if (!settings.devices) {
|
||||
settings.devices = {};
|
||||
@@ -508,9 +513,8 @@ function addDevice(ID) {
|
||||
return getDevice(ID);
|
||||
}
|
||||
|
||||
// Legacy: can be removed after bridgeLegacy has been removed
|
||||
function whitelistDevice(ID) {
|
||||
const settings = get();
|
||||
export function whitelistDevice(ID: string): void {
|
||||
const settings = getInternalSettings();
|
||||
if (!settings.whitelist) {
|
||||
settings.whitelist = [];
|
||||
}
|
||||
@@ -523,8 +527,8 @@ function whitelistDevice(ID) {
|
||||
write();
|
||||
}
|
||||
|
||||
function blockDevice(ID) {
|
||||
const settings = get();
|
||||
export function blockDevice(ID: string): void {
|
||||
const settings = getInternalSettings();
|
||||
if (!settings.blocklist) {
|
||||
settings.blocklist = [];
|
||||
}
|
||||
@@ -533,8 +537,8 @@ function blockDevice(ID) {
|
||||
write();
|
||||
}
|
||||
|
||||
function banDevice(ID) {
|
||||
const settings = get();
|
||||
export function banDevice(ID: string): void {
|
||||
const settings = getInternalSettings();
|
||||
if (!settings.ban) {
|
||||
settings.ban = [];
|
||||
}
|
||||
@@ -543,20 +547,30 @@ function banDevice(ID) {
|
||||
write();
|
||||
}
|
||||
|
||||
function removeDevice(IDorName) {
|
||||
export function removeDevice(IDorName: string): void {
|
||||
const device = getDeviceThrowIfNotExists(IDorName);
|
||||
const settings = get();
|
||||
const settings = getInternalSettings();
|
||||
delete settings.devices[device.ID];
|
||||
|
||||
// Remove device from groups
|
||||
if (settings.groups) {
|
||||
const regex =
|
||||
new RegExp(`^(${device.friendly_name}|${device.ID})(/(\\d|${utils.endpointNames.join('|')}))?$`);
|
||||
for (const group of Object.values(settings.groups).filter((g) => g.devices)) {
|
||||
group.devices = group.devices.filter((device) => !device.match(regex));
|
||||
}
|
||||
}
|
||||
|
||||
write();
|
||||
}
|
||||
|
||||
function addGroup(name, ID=null) {
|
||||
export function addGroup(name: string, ID?: string): GroupSettings {
|
||||
utils.validateFriendlyName(name, true);
|
||||
if (getGroup(name) || getDevice(name)) {
|
||||
throw new Error(`friendly_name '${name}' is already in use`);
|
||||
}
|
||||
|
||||
const settings = get();
|
||||
const settings = getInternalSettings();
|
||||
if (!settings.groups) {
|
||||
settings.groups = {};
|
||||
}
|
||||
@@ -571,7 +585,7 @@ function addGroup(name, ID=null) {
|
||||
// ensure provided ID is not in use
|
||||
ID = ID.toString();
|
||||
if (settings.groups.hasOwnProperty(ID)) {
|
||||
throw new Error(`group id '${ID}' is already in use`);
|
||||
throw new Error(`Group ID '${ID}' is already in use`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,62 +595,58 @@ function addGroup(name, ID=null) {
|
||||
return getGroup(ID);
|
||||
}
|
||||
|
||||
function groupHasDevice(group, keys) {
|
||||
for (const device of group.devices) {
|
||||
const index = keys.indexOf(device);
|
||||
if (index != -1) {
|
||||
return keys[index];
|
||||
}
|
||||
function groupGetDevice(group: {devices?: string[]}, keys: string[]): string {
|
||||
for (const device of group.devices ?? []) {
|
||||
if (keys.includes(device)) return device;
|
||||
}
|
||||
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function addDeviceToGroup(groupIDorName, keys) {
|
||||
const groupID = getGroupThrowIfNotExists(groupIDorName).ID;
|
||||
const settings = get();
|
||||
export function addDeviceToGroup(IDorName: string, keys: string[]): void {
|
||||
const groupID = getGroupThrowIfNotExists(IDorName).ID;
|
||||
const settings = getInternalSettings();
|
||||
|
||||
const group = settings.groups[groupID];
|
||||
if (!group.devices) {
|
||||
group.devices = [];
|
||||
}
|
||||
|
||||
if (!groupHasDevice(group, keys)) {
|
||||
if (!groupGetDevice(group, keys)) {
|
||||
if (!group.devices) group.devices = [];
|
||||
group.devices.push(keys[0]);
|
||||
write();
|
||||
}
|
||||
}
|
||||
|
||||
function removeDeviceFromGroup(groupIDorName, keys) {
|
||||
const groupID = getGroupThrowIfNotExists(groupIDorName).ID;
|
||||
const settings = get();
|
||||
export function removeDeviceFromGroup(IDorName: string, keys: string[]): void {
|
||||
const groupID = getGroupThrowIfNotExists(IDorName).ID;
|
||||
const settings = getInternalSettings();
|
||||
const group = settings.groups[groupID];
|
||||
if (!group.devices) {
|
||||
group.devices = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const key = groupHasDevice(group, keys);
|
||||
const key = groupGetDevice(group, keys);
|
||||
if (key) {
|
||||
group.devices = group.devices.filter((d) => d != key);
|
||||
write();
|
||||
}
|
||||
}
|
||||
|
||||
function removeGroup(groupIDorName) {
|
||||
const groupID = getGroupThrowIfNotExists(groupIDorName).ID;
|
||||
const settings = get();
|
||||
export function removeGroup(IDorName: string | number): void {
|
||||
const groupID = getGroupThrowIfNotExists(IDorName.toString()).ID;
|
||||
const settings = getInternalSettings();
|
||||
delete settings.groups[groupID];
|
||||
write();
|
||||
}
|
||||
|
||||
function changeEntityOptions(IDorName, newOptions) {
|
||||
const settings = get();
|
||||
export function changeEntityOptions(IDorName: string, newOptions: KeyValue): void {
|
||||
const settings = getInternalSettings();
|
||||
delete newOptions.friendly_name;
|
||||
delete newOptions.devices;
|
||||
if (getDevice(IDorName)) {
|
||||
objectAssignDeep(settings.devices[getDevice(IDorName).ID], newOptions);
|
||||
utils.removeNullPropertiesFromObject(settings.devices[getDevice(IDorName).ID]);
|
||||
} else if (getGroup(IDorName)) {
|
||||
objectAssignDeep(settings.groups[getGroup(IDorName).ID], newOptions);
|
||||
utils.removeNullPropertiesFromObject(settings.groups[getGroup(IDorName).ID]);
|
||||
} else {
|
||||
throw new Error(`Device or group '${IDorName}' does not exist`);
|
||||
}
|
||||
@@ -644,13 +654,13 @@ function changeEntityOptions(IDorName, newOptions) {
|
||||
write();
|
||||
}
|
||||
|
||||
function changeFriendlyName(IDorName, newName) {
|
||||
export function changeFriendlyName(IDorName: string, newName: string): void {
|
||||
utils.validateFriendlyName(newName, true);
|
||||
if (getGroup(newName) || getDevice(newName)) {
|
||||
throw new Error(`friendly_name '${newName}' is already in use`);
|
||||
}
|
||||
|
||||
const settings = get();
|
||||
const settings = getInternalSettings();
|
||||
if (getDevice(IDorName)) {
|
||||
settings.devices[getDevice(IDorName).ID].friendly_name = newName;
|
||||
} else if (getGroup(IDorName)) {
|
||||
@@ -662,39 +672,18 @@ function changeFriendlyName(IDorName, newName) {
|
||||
write();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
get: getWithDefaults,
|
||||
set,
|
||||
apply,
|
||||
getDevice,
|
||||
getGroup,
|
||||
getGroups,
|
||||
getEntity,
|
||||
whitelistDevice,
|
||||
banDevice,
|
||||
blockDevice,
|
||||
addDevice,
|
||||
removeDevice,
|
||||
addGroup,
|
||||
removeGroup,
|
||||
addDeviceToGroup,
|
||||
removeDeviceFromGroup,
|
||||
changeEntityOptions,
|
||||
changeFriendlyName,
|
||||
schema,
|
||||
export function reRead(): void {
|
||||
_settings = null;
|
||||
getInternalSettings();
|
||||
_settingsWithDefaults = null;
|
||||
get();
|
||||
}
|
||||
|
||||
// For tests only
|
||||
_write: write,
|
||||
_reRead: () => {
|
||||
_settings = null;
|
||||
get();
|
||||
_settingsWithDefaults = null;
|
||||
getWithDefaults();
|
||||
},
|
||||
_clear: () => {
|
||||
export const testing = {
|
||||
write,
|
||||
clear: (): void => {
|
||||
_settings = null;
|
||||
_settingsWithDefaults = null;
|
||||
},
|
||||
_getDefaults: () => defaults,
|
||||
defaults,
|
||||
};
|
||||
@@ -1,254 +0,0 @@
|
||||
const equals = require('fast-deep-equal/es6');
|
||||
const humanizeDuration = require('humanize-duration');
|
||||
const data = require('./data');
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Xiaomi uses 4151 and 4447 (lumi.plug) as manufacturer ID.
|
||||
const xiaomiManufacturerID = [4151, 4447];
|
||||
const ikeaTradfriManufacturerID = [4476];
|
||||
|
||||
// construct a local ISO8601 string (instead of UTC-based)
|
||||
// Example:
|
||||
// - ISO8601 (UTC) = 2019-03-01T15:32:45.941+0000
|
||||
// - ISO8601 (local) = 2019-03-01T16:32:45.941+0100 (for timezone GMT+1)
|
||||
function toLocalISOString(dDate) {
|
||||
const tzOffset = -dDate.getTimezoneOffset();
|
||||
const plusOrMinus = tzOffset >= 0 ? '+' : '-';
|
||||
const pad = function(num) {
|
||||
const norm = Math.floor(Math.abs(num));
|
||||
return (norm < 10 ? '0' : '') + norm;
|
||||
};
|
||||
|
||||
return dDate.getFullYear() +
|
||||
'-' + pad(dDate.getMonth() + 1) +
|
||||
'-' + pad(dDate.getDate()) +
|
||||
'T' + pad(dDate.getHours()) +
|
||||
':' + pad(dDate.getMinutes()) +
|
||||
':' + pad(dDate.getSeconds()) +
|
||||
plusOrMinus + pad(tzOffset / 60) +
|
||||
':' + pad(tzOffset % 60);
|
||||
}
|
||||
|
||||
const endpointNames = [
|
||||
'left', 'right', 'center', 'bottom_left', 'bottom_right', 'default',
|
||||
'top_left', 'top_right', 'white', 'rgb', 'cct', 'system', 'top', 'bottom', 'center_left', 'center_right',
|
||||
'ep1', 'ep2', 'row_1', 'row_2', 'row_3', 'row_4', 'relay',
|
||||
'l1', 'l2', 'l3', 'l4', 'l5', 'l6', 'l7', 'l8',
|
||||
'l9', 'l10', 'l11', 'l12', 'l13', 'l14', 'l15', 'l16',
|
||||
'button_1', 'button_2', 'button_3', 'button_4', 'button_5',
|
||||
'button_6', 'button_7', 'button_8', 'button_9', 'button_10',
|
||||
'button_11', 'button_12', 'button_13', 'button_14', 'button_15',
|
||||
'button_16', 'button_17', 'button_18', 'button_19', 'button_20',
|
||||
'button_light', 'button_fan_high', 'button_fan_med', 'button_fan_low',
|
||||
'heat', 'cool', 'water', 'meter',
|
||||
];
|
||||
|
||||
function capitalize(s) {
|
||||
return s[0].toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
async function getZigbee2mqttVersion() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const git = require('git-last-commit');
|
||||
const packageJSON = require('../../package.json');
|
||||
const version = packageJSON.version;
|
||||
|
||||
git.getLastCommit((err, commit) => {
|
||||
let commitHash = null;
|
||||
|
||||
if (err) {
|
||||
try {
|
||||
commitHash = require('../../.hash.json').hash;
|
||||
} catch (error) {
|
||||
commitHash = 'unknown';
|
||||
}
|
||||
} else {
|
||||
commitHash = commit.shortHash;
|
||||
}
|
||||
|
||||
resolve({commitHash, version});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getDependencyVersion(depend) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const packageJSON = require('../../node_modules/'+depend+'/package.json');
|
||||
const version = packageJSON.version;
|
||||
resolve({version});
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(date, type) {
|
||||
let result;
|
||||
|
||||
switch (type) {
|
||||
case 'ISO_8601':
|
||||
result = new Date(date).toISOString();
|
||||
break;
|
||||
case 'ISO_8601_local':
|
||||
result = toLocalISOString(new Date(date));
|
||||
break;
|
||||
case 'epoch':
|
||||
result = date;
|
||||
break;
|
||||
case 'relative':
|
||||
// https://github.com/EvanHahn/HumanizeDuration.js#options
|
||||
result = humanizeDuration(Date.now() - date, {language: 'en', largest: 2, round: true}) + ' ago';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported type '${type}'`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function objectHasProperties(object, properties) {
|
||||
for (const property of properties) {
|
||||
if (!object.hasOwnProperty(property)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function equalsPartial(object, expected) {
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
if (!equals(object[key], value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getObjectProperty(object, key, defaultValue) {
|
||||
return object.hasOwnProperty(key) ? object[key] : defaultValue;
|
||||
}
|
||||
|
||||
function getResponse(request, data, error) {
|
||||
const response = {data, status: error ? 'error' : 'ok'};
|
||||
if (error) response.error = error;
|
||||
if (typeof request === 'object' && request.hasOwnProperty('transaction')) {
|
||||
response.transaction = request.transaction;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function parseJSON(value, failedReturnValue) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return failedReturnValue;
|
||||
}
|
||||
}
|
||||
|
||||
function loadModuleFromText(moduleCode) {
|
||||
const moduleFakePath = path.join(__dirname, 'externally-loaded.js');
|
||||
const sandbox = {
|
||||
require: require,
|
||||
module: {},
|
||||
console,
|
||||
};
|
||||
vm.runInNewContext(moduleCode, sandbox, moduleFakePath);
|
||||
return sandbox.module.exports;
|
||||
}
|
||||
|
||||
function loadModuleFromFile(modulePath) {
|
||||
const moduleCode = fs.readFileSync(modulePath, {encoding: 'utf8'});
|
||||
return loadModuleFromText(moduleCode);
|
||||
}
|
||||
|
||||
function* getExternalConvertersDefinitions(settings) {
|
||||
const externalConverters = settings.get().external_converters;
|
||||
|
||||
for (const moduleName of externalConverters) {
|
||||
let converter;
|
||||
|
||||
if (moduleName.endsWith('.js')) {
|
||||
converter = loadModuleFromFile(data.joinPath(moduleName));
|
||||
} else {
|
||||
converter = require(moduleName);
|
||||
}
|
||||
|
||||
if (Array.isArray(converter)) {
|
||||
for (const item of converter) {
|
||||
yield item;
|
||||
}
|
||||
} else {
|
||||
yield converter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toSnakeCase(value) {
|
||||
if (typeof value === 'object') {
|
||||
value = {...value};
|
||||
for (const key of Object.keys(value)) {
|
||||
const keySnakeCase = toSnakeCase(key);
|
||||
if (key !== keySnakeCase) {
|
||||
value[keySnakeCase] = value[key];
|
||||
delete value[key];
|
||||
}
|
||||
}
|
||||
return value;
|
||||
} else {
|
||||
return value.replace(/\.?([A-Z])/g, (x, y) => '_' + y.toLowerCase()).replace(/^_/, '').replace('_i_d', '_id');
|
||||
}
|
||||
}
|
||||
|
||||
function validateFriendlyName(name, throwFirstError=false) {
|
||||
const errors = [];
|
||||
for (const endpointName of endpointNames) {
|
||||
if (name.toLowerCase().endsWith('/' + endpointName)) {
|
||||
errors.push(`friendly_name is not allowed to end with: '/${endpointName}'`);
|
||||
}
|
||||
}
|
||||
|
||||
if (name.length === 0) errors.push(`friendly_name must be at least 1 char long`);
|
||||
if (name.endsWith('/') || name.startsWith('/')) errors.push(`friendly_name is not allowed to end or start with /`);
|
||||
if (name.endsWith(String.fromCharCode(0))) errors.push(`friendly_name is not allowed to contain null char`);
|
||||
if (endpointNames.includes(name)) errors.push(`Following friendly_name are not allowed: '${endpointNames}'`);
|
||||
if (name.match(/.*\/\d*$/)) errors.push(`Friendly name cannot end with a "/DIGIT" ('${name}')`);
|
||||
if (name.includes('#') || name.includes('+')) {
|
||||
errors.push(`MQTT wildcard (+ and #) not allowed in friendly_name ('${name}')`);
|
||||
}
|
||||
|
||||
if (throwFirstError && errors.length) {
|
||||
throw new Error(errors[0]);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
function sleep(seconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
millisecondsToSeconds: (milliseconds) => milliseconds / 1000,
|
||||
secondsToMilliseconds: (seconds) => seconds * 1000,
|
||||
getZigbee2mqttVersion,
|
||||
getDependencyVersion,
|
||||
objectHasProperties,
|
||||
toSnakeCase,
|
||||
sleep,
|
||||
getObjectProperty,
|
||||
getEndpointNames: () => endpointNames,
|
||||
isXiaomiDevice: (device) => {
|
||||
return device.modelID !== 'lumi.router' && xiaomiManufacturerID.includes(device.manufacturerID) &&
|
||||
(!device.manufacturerName || !device.manufacturerName.startsWith('Trust'));
|
||||
},
|
||||
isIkeaTradfriDevice: (device) => ikeaTradfriManufacturerID.includes(device.manufacturerID),
|
||||
formatDate: (date, type) => formatDate(date, type),
|
||||
equalsPartial,
|
||||
getResponse,
|
||||
capitalize,
|
||||
parseJSON,
|
||||
getExternalConvertersDefinitions,
|
||||
validateFriendlyName,
|
||||
loadModuleFromFile,
|
||||
loadModuleFromText,
|
||||
};
|
||||
@@ -0,0 +1,306 @@
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import humanizeDuration from 'humanize-duration';
|
||||
import data from './data';
|
||||
import vm from 'vm';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// construct a local ISO8601 string (instead of UTC-based)
|
||||
// Example:
|
||||
// - ISO8601 (UTC) = 2019-03-01T15:32:45.941+0000
|
||||
// - ISO8601 (local) = 2019-03-01T16:32:45.941+0100 (for timezone GMT+1)
|
||||
function toLocalISOString(date: Date): string {
|
||||
const tzOffset = -date.getTimezoneOffset();
|
||||
const plusOrMinus = tzOffset >= 0 ? '+' : '-';
|
||||
const pad = (num: number): string => {
|
||||
const norm = Math.floor(Math.abs(num));
|
||||
return (norm < 10 ? '0' : '') + norm;
|
||||
};
|
||||
|
||||
return date.getFullYear() +
|
||||
'-' + pad(date.getMonth() + 1) +
|
||||
'-' + pad(date.getDate()) +
|
||||
'T' + pad(date.getHours()) +
|
||||
':' + pad(date.getMinutes()) +
|
||||
':' + pad(date.getSeconds()) +
|
||||
plusOrMinus + pad(tzOffset / 60) +
|
||||
':' + pad(tzOffset % 60);
|
||||
}
|
||||
|
||||
const endpointNames = [
|
||||
'left', 'right', 'center', 'bottom_left', 'bottom_right', 'default',
|
||||
'top_left', 'top_right', 'white', 'rgb', 'cct', 'system', 'top', 'bottom', 'center_left', 'center_right',
|
||||
'ep1', 'ep2', 'row_1', 'row_2', 'row_3', 'row_4', 'relay', 'usb',
|
||||
'l1', 'l2', 'l3', 'l4', 'l5', 'l6', 'l7', 'l8',
|
||||
'l9', 'l10', 'l11', 'l12', 'l13', 'l14', 'l15', 'l16',
|
||||
'button_1', 'button_2', 'button_3', 'button_4', 'button_5',
|
||||
'button_6', 'button_7', 'button_8', 'button_9', 'button_10',
|
||||
'button_11', 'button_12', 'button_13', 'button_14', 'button_15',
|
||||
'button_16', 'button_17', 'button_18', 'button_19', 'button_20',
|
||||
'button_light', 'button_fan_high', 'button_fan_med', 'button_fan_low',
|
||||
'heat', 'cool', 'water', 'meter', 'wifi',
|
||||
];
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s[0].toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
async function getZigbee2MQTTVersion(includeCommitHash=true): Promise<{commitHash: string, version: string}> {
|
||||
const git = await import('git-last-commit');
|
||||
const packageJSON = await import('../..' + '/package.json');
|
||||
|
||||
if (!includeCommitHash) {
|
||||
return {version: packageJSON.version, commitHash: null};
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const version = packageJSON.version;
|
||||
|
||||
git.getLastCommit((err: Error, commit: {shortHash: string}) => {
|
||||
let commitHash = null;
|
||||
|
||||
if (err) {
|
||||
try {
|
||||
commitHash = fs.readFileSync(path.join(__dirname, '..', '..', 'dist', '.hash'), 'utf-8');
|
||||
} catch (error) {
|
||||
/* istanbul ignore next */
|
||||
commitHash = 'unknown';
|
||||
}
|
||||
} else {
|
||||
commitHash = commit.shortHash;
|
||||
}
|
||||
|
||||
commitHash = commitHash.trim();
|
||||
resolve({commitHash, version});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getDependencyVersion(depend: string): Promise<{version: string}> {
|
||||
const packageJSON = await import(path.join(__dirname, '..', '..', 'node_modules', depend, 'package.json'));
|
||||
const version = packageJSON.version;
|
||||
return {version};
|
||||
}
|
||||
|
||||
function formatDate(time: number, type: 'ISO_8601' | 'ISO_8601_local' | 'epoch' | 'relative'): string | number {
|
||||
if (type === 'ISO_8601') return new Date(time).toISOString();
|
||||
else if (type === 'ISO_8601_local') return toLocalISOString(new Date(time));
|
||||
else if (type === 'epoch') return time;
|
||||
else { // relative
|
||||
return humanizeDuration(Date.now() - time, {language: 'en', largest: 2, round: true}) + ' ago';
|
||||
}
|
||||
}
|
||||
|
||||
function objectHasProperties(object: {[s: string]: unknown}, properties: string[]): boolean {
|
||||
for (const property of properties) {
|
||||
if (!object.hasOwnProperty(property)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function equalsPartial(object: KeyValue, expected: KeyValue): boolean {
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
if (!equals(object[key], value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getObjectProperty(object: KeyValue, key: string, defaultValue: unknown): unknown {
|
||||
return object && object.hasOwnProperty(key) ? object[key] : defaultValue;
|
||||
}
|
||||
|
||||
function getResponse(request: KeyValue | string, data: KeyValue, error: string): MQTTResponse {
|
||||
const response: MQTTResponse = {data, status: error ? 'error' : 'ok'};
|
||||
if (error) response.error = error;
|
||||
if (typeof request === 'object' && request.hasOwnProperty('transaction')) {
|
||||
response.transaction = request.transaction;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function parseJSON(value: string, fallback: string): KeyValue | string {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function loadModuleFromText(moduleCode: string): unknown {
|
||||
const moduleFakePath = path.join(__dirname, 'externally-loaded.js');
|
||||
const sandbox = {
|
||||
require: require,
|
||||
module: {},
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
setImmediate,
|
||||
clearImmediate,
|
||||
};
|
||||
vm.runInNewContext(moduleCode, sandbox, moduleFakePath);
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
return sandbox.module.exports;
|
||||
}
|
||||
|
||||
function loadModuleFromFile(modulePath: string): unknown {
|
||||
const moduleCode = fs.readFileSync(modulePath, {encoding: 'utf8'});
|
||||
return loadModuleFromText(moduleCode);
|
||||
}
|
||||
|
||||
function* getExternalConvertersDefinitions(settings: Settings): Generator<zhc.ExternalDefinition> {
|
||||
const externalConverters = settings.external_converters;
|
||||
|
||||
for (const moduleName of externalConverters) {
|
||||
let converter;
|
||||
|
||||
if (moduleName.endsWith('.js')) {
|
||||
converter = loadModuleFromFile(data.joinPath(moduleName));
|
||||
} else {
|
||||
converter = require(moduleName);
|
||||
}
|
||||
|
||||
if (Array.isArray(converter)) {
|
||||
for (const item of converter) {
|
||||
yield item;
|
||||
}
|
||||
} else {
|
||||
yield converter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeNullPropertiesFromObject(obj: KeyValue): void {
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = obj[key];
|
||||
if (value == null) {
|
||||
delete obj[key];
|
||||
} else if (typeof value === 'object') {
|
||||
removeNullPropertiesFromObject(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toNetworkAddressHex(value: number): string {
|
||||
const hex = value.toString(16);
|
||||
return `0x${'0'.repeat(4 - hex.length)}${hex}`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
function toSnakeCase(value: string | KeyValue): any {
|
||||
if (typeof value === 'object') {
|
||||
value = {...value};
|
||||
for (const key of Object.keys(value)) {
|
||||
const keySnakeCase = toSnakeCase(key);
|
||||
if (key !== keySnakeCase) {
|
||||
value[keySnakeCase] = value[key];
|
||||
delete value[key];
|
||||
}
|
||||
}
|
||||
return value;
|
||||
} else {
|
||||
return value.replace(/\.?([A-Z])/g, (x, y) => '_' + y.toLowerCase()).replace(/^_/, '').replace('_i_d', '_id');
|
||||
}
|
||||
}
|
||||
|
||||
function validateFriendlyName(name: string, throwFirstError=false): string[] {
|
||||
const errors = [];
|
||||
for (const endpointName of endpointNames) {
|
||||
if (name.toLowerCase().endsWith('/' + endpointName)) {
|
||||
errors.push(`friendly_name is not allowed to end with: '/${endpointName}'`);
|
||||
}
|
||||
}
|
||||
|
||||
if (name.length === 0) errors.push(`friendly_name must be at least 1 char long`);
|
||||
if (name.endsWith('/') || name.startsWith('/')) errors.push(`friendly_name is not allowed to end or start with /`);
|
||||
if (name.endsWith(String.fromCharCode(0))) errors.push(`friendly_name is not allowed to contain null char`);
|
||||
if (endpointNames.includes(name)) errors.push(`Following friendly_name are not allowed: '${endpointNames}'`);
|
||||
if (name.match(/.*\/\d*$/)) errors.push(`Friendly name cannot end with a "/DIGIT" ('${name}')`);
|
||||
if (name.includes('#') || name.includes('+')) {
|
||||
errors.push(`MQTT wildcard (+ and #) not allowed in friendly_name ('${name}')`);
|
||||
}
|
||||
|
||||
if (throwFirstError && errors.length) {
|
||||
throw new Error(errors[0]);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function sleep(seconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
||||
}
|
||||
|
||||
function sanitizeImageParameter(parameter: string): string {
|
||||
const replaceByDash = [/\?/g, /&/g, /[^a-z\d\- _./:]/gi];
|
||||
let sanitized = parameter;
|
||||
replaceByDash.forEach((r) => sanitized = sanitized.replace(r, '-'));
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function isAvailabilityEnabledForDevice(device: Device, settings: Settings): boolean {
|
||||
if (device.settings.hasOwnProperty('availability')) {
|
||||
return !!device.settings.availability;
|
||||
}
|
||||
|
||||
// availability_timeout = deprecated
|
||||
const enabledGlobal = settings.advanced.availability_timeout || settings.availability;
|
||||
if (!enabledGlobal) return false;
|
||||
|
||||
const passlist = settings.advanced.availability_passlist.concat(settings.advanced.availability_whitelist);
|
||||
if (passlist.length > 0) {
|
||||
return passlist.includes(device.name) || passlist.includes(device.ieeeAddr);
|
||||
}
|
||||
|
||||
const blocklist = settings.advanced.availability_blacklist.concat(settings.advanced.availability_blocklist);
|
||||
return !blocklist.includes(device.name) && !blocklist.includes(device.ieeeAddr);
|
||||
}
|
||||
|
||||
const entityIDRegex = new RegExp(`^(.+?)(?:/(${endpointNames.join('|')}|\\d+))?$`);
|
||||
function parseEntityID(ID: string): {ID: string, endpoint: string} {
|
||||
const match = ID.match(entityIDRegex);
|
||||
return match && {ID: match[1], endpoint: match[2]};
|
||||
}
|
||||
|
||||
function isEndpoint(obj: unknown): obj is zh.Endpoint {
|
||||
return obj.constructor.name.toLowerCase() === 'endpoint';
|
||||
}
|
||||
|
||||
function isZHGroup(obj: unknown): obj is zh.Group {
|
||||
return obj.constructor.name.toLowerCase() === 'group';
|
||||
}
|
||||
|
||||
const hours = (hours: number): number => 1000 * 60 * 60 * hours;
|
||||
const minutes = (minutes: number): number => 1000 * 60 * minutes;
|
||||
const seconds = (seconds: number): number => 1000 * seconds;
|
||||
|
||||
function publishLastSeen(data: eventdata.LastSeenChanged, settings: Settings, allowMessageEmitted: boolean,
|
||||
publishEntityState: PublishEntityState): void {
|
||||
/**
|
||||
* Prevent 2 MQTT publishes when 1 message event is received;
|
||||
* - In case reason == messageEmitted, receive.ts will only call this when it did not publish a
|
||||
* message based on the received zigbee message. In this case allowMessageEmitted has to be true.
|
||||
* - In case reason !== messageEmitted, controller.ts will call this based on the zigbee-herdsman
|
||||
* lastSeenChanged event.
|
||||
*/
|
||||
const allow = data.reason !== 'messageEmitted' || (data.reason === 'messageEmitted' && allowMessageEmitted);
|
||||
if (settings.advanced.last_seen && settings.advanced.last_seen !== 'disable' && allow) {
|
||||
publishEntityState(data.device, {}, 'lastSeenChanged');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default {
|
||||
endpointNames, capitalize, getZigbee2MQTTVersion, getDependencyVersion, formatDate, objectHasProperties,
|
||||
equalsPartial, getObjectProperty, getResponse, parseJSON, loadModuleFromText, loadModuleFromFile,
|
||||
getExternalConvertersDefinitions, removeNullPropertiesFromObject, toNetworkAddressHex, toSnakeCase,
|
||||
parseEntityID, isEndpoint, isZHGroup, hours, minutes, seconds, validateFriendlyName, sleep,
|
||||
sanitizeImageParameter, isAvailabilityEnabledForDevice, publishLastSeen,
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
const yaml = require('js-yaml');
|
||||
const fs = require('fs');
|
||||
const equals = require('fast-deep-equal/es6');
|
||||
|
||||
function read(file) {
|
||||
try {
|
||||
return yaml.safeLoad(fs.readFileSync(file, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.name === 'YAMLException') {
|
||||
error.file = file;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function readIfExists(file, default_=null) {
|
||||
return fs.existsSync(file) ? read(file) : default_;
|
||||
}
|
||||
|
||||
function writeIfChanged(file, content) {
|
||||
const before = readIfExists(file);
|
||||
if (!equals(before, content)) {
|
||||
fs.writeFileSync(file, yaml.safeDump(content));
|
||||
}
|
||||
}
|
||||
|
||||
function updateIfChanged(file, key, value) {
|
||||
const content = read(file);
|
||||
if (content[key] !== value) {
|
||||
content[key] = value;
|
||||
writeIfChanged(file, content);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {read, readIfExists, writeIfChanged, updateIfChanged};
|
||||
@@ -0,0 +1,36 @@
|
||||
import yaml from 'js-yaml';
|
||||
import fs from 'fs';
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
|
||||
function read(file: string): KeyValue {
|
||||
try {
|
||||
return yaml.load(fs.readFileSync(file, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.name === 'YAMLException') {
|
||||
error.file = file;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function readIfExists(file: string, default_?: KeyValue): KeyValue {
|
||||
return fs.existsSync(file) ? read(file) : default_;
|
||||
}
|
||||
|
||||
function writeIfChanged(file: string, content: KeyValue): void {
|
||||
const before = readIfExists(file);
|
||||
if (!equals(before, content)) {
|
||||
fs.writeFileSync(file, yaml.dump(content));
|
||||
}
|
||||
}
|
||||
|
||||
function updateIfChanged(file: string, key: string, value: KeyValue): void {
|
||||
const content = read(file);
|
||||
if (content[key] !== value) {
|
||||
content[key] = value;
|
||||
writeIfChanged(file, content);
|
||||
}
|
||||
}
|
||||
|
||||
export default {read, readIfExists, updateIfChanged, writeIfChanged};
|
||||
-324
@@ -1,324 +0,0 @@
|
||||
const ZigbeeHerdsman = require('zigbee-herdsman');
|
||||
const logger = require('./util/logger');
|
||||
const settings = require('./util/settings');
|
||||
const data = require('./util/data');
|
||||
const assert = require('assert');
|
||||
const utils = require('./util/utils');
|
||||
const events = require('events');
|
||||
const objectAssignDeep = require('object-assign-deep');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
const endpointNames = utils.getEndpointNames();
|
||||
const keyEndpointByNumber = new RegExp(`.*/([0-9]*)$`);
|
||||
|
||||
class Zigbee extends events.EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.acceptJoiningDeviceHandler = this.acceptJoiningDeviceHandler.bind(this);
|
||||
}
|
||||
|
||||
async start() {
|
||||
const infoHerdsman = await utils.getDependencyVersion('zigbee-herdsman');
|
||||
logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`);
|
||||
const herdsmanSettings = {
|
||||
network: {
|
||||
panID: settings.get().advanced.pan_id,
|
||||
extendedPanID: settings.get().advanced.ext_pan_id,
|
||||
channelList: [settings.get().advanced.channel],
|
||||
networkKey: settings.get().advanced.network_key,
|
||||
},
|
||||
databasePath: data.joinPath('database.db'),
|
||||
databaseBackupPath: data.joinPath('database.db.backup'),
|
||||
backupPath: data.joinPath('coordinator_backup.json'),
|
||||
serialPort: {
|
||||
baudRate: settings.get().advanced.baudrate,
|
||||
rtscts: settings.get().advanced.rtscts,
|
||||
path: settings.get().serial.port,
|
||||
adapter: settings.get().serial.adapter,
|
||||
},
|
||||
adapter: {
|
||||
concurrent: settings.get().advanced.adapter_concurrent,
|
||||
delay: settings.get().advanced.adapter_delay,
|
||||
},
|
||||
};
|
||||
|
||||
const herdsmanSettingsLog = objectAssignDeep.noMutate(herdsmanSettings);
|
||||
herdsmanSettingsLog.network.networkKey = 'HIDDEN';
|
||||
logger.debug(`Using zigbee-herdsman with settings: '${stringify(herdsmanSettingsLog)}'`);
|
||||
|
||||
if (herdsmanSettings.network.networkKey === 'GENERATE') {
|
||||
const newKey = Array.from({length: 16}, () => Math.floor(Math.random() * 255));
|
||||
settings.set(['advanced', 'network_key'], newKey);
|
||||
herdsmanSettings.network.networkKey = newKey;
|
||||
}
|
||||
|
||||
if (herdsmanSettings.network.panID === 'GENERATE') {
|
||||
const newPanID = Math.floor(Math.random() * (0xFFFF - 2)) + 1;
|
||||
settings.set(['advanced', 'pan_id'], newPanID);
|
||||
herdsmanSettings.network.panID = newPanID;
|
||||
}
|
||||
|
||||
try {
|
||||
herdsmanSettings.acceptJoiningDeviceHandler = this.acceptJoiningDeviceHandler;
|
||||
this.herdsman = new ZigbeeHerdsman.Controller(herdsmanSettings, logger);
|
||||
await this.herdsman.start();
|
||||
} catch (error) {
|
||||
logger.error(`Error while starting zigbee-herdsman`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.herdsman.on('adapterDisconnected', () => this.emit('adapterDisconnected'));
|
||||
this.herdsman.on('deviceAnnounce', (data) => this.emit('event', 'deviceAnnounce', data));
|
||||
this.herdsman.on('deviceInterview', (data) => this.emit('event', 'deviceInterview', data));
|
||||
this.herdsman.on('deviceJoined', (data) => this.emit('event', 'deviceJoined', data));
|
||||
this.herdsman.on('deviceLeave', (data) => this.emit('event', 'deviceLeave', data));
|
||||
this.herdsman.on('message', (data) => this.emit('event', 'message', data));
|
||||
this.herdsman.on('permitJoinChanged', (data) => this.emit('permitJoinChanged', data));
|
||||
|
||||
logger.info('zigbee-herdsman started');
|
||||
logger.info(`Coordinator firmware version: '${stringify(await this.getCoordinatorVersion())}'`);
|
||||
logger.debug(`Zigbee network parameters: ${stringify(await this.herdsman.getNetworkParameters())}`);
|
||||
|
||||
for (const device of this.getClients()) {
|
||||
// If a passlist is used, all other device will be removed from the network.
|
||||
const passlist = settings.get().passlist.concat(settings.get().whitelist);
|
||||
const blocklist = settings.get().blocklist.concat(settings.get().ban);
|
||||
if (passlist.length > 0) {
|
||||
if (!passlist.includes(device.ieeeAddr)) {
|
||||
logger.warn(`Device which is not on passlist connected (${device.ieeeAddr}), removing...`);
|
||||
device.removeFromNetwork();
|
||||
}
|
||||
} else if (blocklist.includes(device.ieeeAddr)) {
|
||||
logger.warn(`Device on blocklist is connected (${device.ieeeAddr}), removing...`);
|
||||
device.removeFromNetwork();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have to turn off the led
|
||||
if (settings.get().serial.disable_led) {
|
||||
await this.herdsman.setLED(false);
|
||||
}
|
||||
|
||||
// Check if we have to set a transmit power
|
||||
if (settings.get().experimental.hasOwnProperty('transmit_power')) {
|
||||
const transmitPower = settings.get().experimental.transmit_power;
|
||||
await this.herdsman.setTransmitPower(transmitPower);
|
||||
logger.info(`Set transmit power to '${transmitPower}'`);
|
||||
}
|
||||
}
|
||||
|
||||
async getCoordinatorVersion() {
|
||||
return this.herdsman.getCoordinatorVersion();
|
||||
}
|
||||
|
||||
isStopping() {
|
||||
return this.herdsman.isStopping();
|
||||
}
|
||||
|
||||
async getNetworkParameters() {
|
||||
return this.herdsman.getNetworkParameters();
|
||||
}
|
||||
|
||||
async reset(type) {
|
||||
await this.herdsman.reset(type);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await this.herdsman.stop();
|
||||
logger.info('zigbee-herdsman stopped');
|
||||
}
|
||||
|
||||
async permitJoin(permit, resolvedEntity, time=undefined) {
|
||||
permit ?
|
||||
logger.info(`Zigbee: allowing new devices to join${resolvedEntity ? ` via ${resolvedEntity.name}` : ''}.`) :
|
||||
logger.info('Zigbee: disabling joining new devices.');
|
||||
|
||||
if (resolvedEntity && permit) {
|
||||
await this.herdsman.permitJoin(permit, resolvedEntity.device, time);
|
||||
} else {
|
||||
await this.herdsman.permitJoin(permit, undefined, time);
|
||||
}
|
||||
}
|
||||
|
||||
async getPermitJoin() {
|
||||
return this.herdsman.getPermitJoin();
|
||||
}
|
||||
|
||||
getClients() {
|
||||
return this.herdsman.getDevices().filter((device) => device.type !== 'Coordinator');
|
||||
}
|
||||
|
||||
getDevices() {
|
||||
return this.herdsman.getDevices();
|
||||
}
|
||||
|
||||
getDeviceByIeeeAddr(ieeeAddr) {
|
||||
return this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
|
||||
}
|
||||
|
||||
getDeviceByNetworkAddress(networkAddress) {
|
||||
return this.herdsman.getDeviceByNetworkAddress(networkAddress);
|
||||
}
|
||||
|
||||
getDevicesByType(type) {
|
||||
return this.herdsman.getDevicesByType(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @return {object} {
|
||||
* type: device | coordinator
|
||||
* device|group: zigbee-herdsman entity
|
||||
* endpoint: selected endpoint (only if type === device)
|
||||
* settings: from configuration.yaml
|
||||
* name: name of the entity
|
||||
* definition: zigbee-herdsman-converters definition (only if type === device)
|
||||
* }
|
||||
*/
|
||||
resolveEntity(key) {
|
||||
assert(
|
||||
typeof key === 'string' || typeof key === 'number' ||
|
||||
key.constructor.name === 'Device' || key.constructor.name === 'Group',
|
||||
`Wrong type '${typeof key}'`,
|
||||
);
|
||||
|
||||
const deviceOptions = settings.get().device_options;
|
||||
if (typeof key === 'string' || typeof key === 'number') {
|
||||
if (typeof key === 'number') {
|
||||
key = key.toString();
|
||||
}
|
||||
|
||||
if (typeof key === 'string' && key.toLowerCase() === 'coordinator') {
|
||||
const coordinator = this.getDevicesByType('Coordinator')[0];
|
||||
return {
|
||||
type: 'device',
|
||||
device: coordinator,
|
||||
endpoint: coordinator.getEndpoint(1),
|
||||
settings: {friendlyName: 'Coordinator'},
|
||||
name: 'Coordinator',
|
||||
};
|
||||
}
|
||||
|
||||
let endpointKey = endpointNames.find((p) => key.endsWith(`/${p}`));
|
||||
const endpointByNumber = key.match(keyEndpointByNumber);
|
||||
if (!endpointKey && endpointByNumber) {
|
||||
endpointKey = Number(endpointByNumber[1]);
|
||||
}
|
||||
if (endpointKey) {
|
||||
key = key.replace(`/${endpointKey}`, '');
|
||||
}
|
||||
|
||||
const entity = settings.getEntity(key);
|
||||
if (!entity) {
|
||||
return null;
|
||||
} else if (entity.type === 'device') {
|
||||
const device = this.getDeviceByIeeeAddr(entity.ID);
|
||||
if (!device) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definition = zigbeeHerdsmanConverters.findByDevice(device);
|
||||
const endpoints = definition && definition.endpoint ? definition.endpoint(device) : null;
|
||||
let endpoint;
|
||||
if (endpointKey) {
|
||||
if (endpointByNumber) {
|
||||
endpoint = device.getEndpoint(endpointKey);
|
||||
} else {
|
||||
assert(definition != null, `Endpoint name '${endpointKey}' is given but device is unsupported`);
|
||||
assert(endpoints != null, `Endpoint name '${endpointKey}' is given but no endpoints defined`);
|
||||
const endpointID = endpoints[endpointKey];
|
||||
assert(endpointID, `Endpoint name '${endpointKey}' is given but device has no such endpoint`);
|
||||
endpoint = device.getEndpoint(endpointID);
|
||||
}
|
||||
} else if (endpoints && endpoints['default']) {
|
||||
endpoint = device.getEndpoint(endpoints['default']);
|
||||
} else {
|
||||
endpoint = device.endpoints[0];
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'device', device, endpoint, settings: {...deviceOptions, ...entity},
|
||||
name: entity.friendlyName, definition,
|
||||
};
|
||||
} else {
|
||||
let group = this.getGroupByID(entity.ID);
|
||||
if (!group) group = this.createGroup(entity.ID);
|
||||
return {type: 'group', group, settings: {...deviceOptions, ...entity}, name: entity.friendlyName};
|
||||
}
|
||||
} else if (key.constructor.name === 'Device') {
|
||||
const setting = settings.getEntity(key.ieeeAddr);
|
||||
return {
|
||||
type: 'device',
|
||||
device: key,
|
||||
endpoint: key.endpoints[0],
|
||||
settings: {...deviceOptions, ...(setting || {})},
|
||||
name: setting ? setting.friendlyName : (key.type === 'Coordinator' ? 'Coordinator' : key.ieeeAddr),
|
||||
definition: zigbeeHerdsmanConverters.findByDevice(key),
|
||||
};
|
||||
} else { // Group
|
||||
const setting = settings.getEntity(key.groupID);
|
||||
return {
|
||||
type: 'group',
|
||||
group: key,
|
||||
settings: {...deviceOptions, ...(setting || {})},
|
||||
name: setting ? setting.friendlyName : key.groupID,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getGroupByID(ID) {
|
||||
return this.herdsman.getGroupByID(ID);
|
||||
}
|
||||
|
||||
getGroups() {
|
||||
return this.herdsman.getGroups();
|
||||
}
|
||||
|
||||
createGroup(groupID) {
|
||||
return this.herdsman.createGroup(groupID);
|
||||
}
|
||||
|
||||
acceptJoiningDeviceHandler(ieeeAddr) {
|
||||
// If passlist is set, all devices not on passlist will be rejected to join the network
|
||||
const passlist = settings.get().passlist.concat(settings.get().whitelist);
|
||||
const blocklist = settings.get().blocklist.concat(settings.get().ban);
|
||||
if (passlist.length > 0) {
|
||||
if (passlist.includes(ieeeAddr)) {
|
||||
logger.info(`Accepting joining device which is on passlist '${ieeeAddr}'`);
|
||||
return true;
|
||||
} else {
|
||||
logger.info(`Rejecting joining not in passlist device '${ieeeAddr}'`);
|
||||
return false;
|
||||
}
|
||||
} else if (blocklist.length > 0) {
|
||||
if (blocklist.includes(ieeeAddr)) {
|
||||
logger.info(`Rejecting joining device which is on blocklist '${ieeeAddr}'`);
|
||||
return false;
|
||||
} else {
|
||||
logger.info(`Accepting joining not in blocklist device '${ieeeAddr}'`);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async touchlinkFactoryResetFirst() {
|
||||
return this.herdsman.touchlinkFactoryResetFirst();
|
||||
}
|
||||
|
||||
async touchlinkFactoryReset(ieeeAddr, channel) {
|
||||
return this.herdsman.touchlinkFactoryReset(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async touchlinkIdentify(ieeeAddr, channel) {
|
||||
await this.herdsman.touchlinkIdentify(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async touchlinkScan() {
|
||||
return this.herdsman.touchlinkScan();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Zigbee;
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
import {Controller} from 'zigbee-herdsman';
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import data from './util/data';
|
||||
import utils from './util/utils';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import Device from './model/device';
|
||||
import Group from './model/group';
|
||||
import * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
export default class Zigbee {
|
||||
private herdsman: Controller;
|
||||
private eventBus: EventBus;
|
||||
private groupLookup: {[s: number]: Group} = {};
|
||||
private deviceLookup: {[s: string]: Device} = {};
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
async start(): Promise<'reset' | 'resumed' | 'restored'> {
|
||||
const infoHerdsman = await utils.getDependencyVersion('zigbee-herdsman');
|
||||
logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`);
|
||||
const herdsmanSettings = {
|
||||
network: {
|
||||
panID: settings.get().advanced.pan_id === 'GENERATE' ?
|
||||
this.generatePanID() : settings.get().advanced.pan_id as number,
|
||||
extendedPanID: settings.get().advanced.ext_pan_id,
|
||||
channelList: [settings.get().advanced.channel],
|
||||
networkKey: settings.get().advanced.network_key === 'GENERATE' ?
|
||||
this.generateNetworkKey() : settings.get().advanced.network_key as number[],
|
||||
},
|
||||
databasePath: data.joinPath('database.db'),
|
||||
databaseBackupPath: data.joinPath('database.db.backup'),
|
||||
backupPath: data.joinPath('coordinator_backup.json'),
|
||||
serialPort: {
|
||||
baudRate: settings.get().advanced.baudrate,
|
||||
rtscts: settings.get().advanced.rtscts,
|
||||
path: settings.get().serial.port,
|
||||
adapter: settings.get().serial.adapter,
|
||||
},
|
||||
adapter: {
|
||||
concurrent: settings.get().advanced.adapter_concurrent,
|
||||
delay: settings.get().advanced.adapter_delay,
|
||||
disableLED: settings.get().serial.disable_led,
|
||||
},
|
||||
acceptJoiningDeviceHandler: this.acceptJoiningDeviceHandler,
|
||||
};
|
||||
|
||||
const herdsmanSettingsLog = objectAssignDeep({}, herdsmanSettings, {network: {networkKey: 'HIDDEN'}});
|
||||
logger.debug(`Using zigbee-herdsman with settings: '${stringify(herdsmanSettingsLog)}'`);
|
||||
|
||||
let startResult;
|
||||
try {
|
||||
this.herdsman = new Controller(herdsmanSettings, logger);
|
||||
startResult = await this.herdsman.start();
|
||||
} catch (error) {
|
||||
logger.error(`Error while starting zigbee-herdsman`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.herdsman.on('adapterDisconnected', () => this.eventBus.emitAdapterDisconnected());
|
||||
this.herdsman.on('lastSeenChanged', (data: ZHEvents.LastSeenChangedPayload) => {
|
||||
this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr), reason: data.reason});
|
||||
});
|
||||
this.herdsman.on('permitJoinChanged', (data: ZHEvents.PermitJoinChangedPayload) => {
|
||||
this.eventBus.emitPermitJoinChanged(data);
|
||||
});
|
||||
this.herdsman.on('deviceNetworkAddressChanged', (data: ZHEvents.DeviceNetworkAddressChangedPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
logger.debug(`Device '${device.name}' changed network address`);
|
||||
this.eventBus.emitDeviceNetworkAddressChanged({device});
|
||||
});
|
||||
this.herdsman.on('deviceAnnounce', (data: ZHEvents.DeviceAnnouncePayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
logger.debug(`Device '${device.name}' announced itself`);
|
||||
this.eventBus.emitDeviceAnnounce({device});
|
||||
});
|
||||
this.herdsman.on('deviceInterview', (data: ZHEvents.DeviceInterviewPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
const d = {device, status: data.status};
|
||||
this.logDeviceInterview(d);
|
||||
this.eventBus.emitDeviceInterview(d);
|
||||
});
|
||||
this.herdsman.on('deviceJoined', (data: ZHEvents.DeviceJoinedPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
logger.info(`Device '${device.name}' joined`);
|
||||
this.eventBus.emitDeviceJoined({device});
|
||||
});
|
||||
this.herdsman.on('deviceLeave', (data: ZHEvents.DeviceLeavePayload) => {
|
||||
const name = settings.getDevice(data.ieeeAddr)?.friendly_name || data.ieeeAddr;
|
||||
logger.warn(`Device '${name}' left the network`);
|
||||
this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name});
|
||||
});
|
||||
this.herdsman.on('message', (data: ZHEvents.MessagePayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
logger.debug(`Received Zigbee message from '${device.name}', type '${data.type}', ` +
|
||||
`cluster '${data.cluster}', data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}` +
|
||||
(data.hasOwnProperty('groupID') ? ` with groupID ${data.groupID}` : ``) +
|
||||
(device.zh.type === 'Coordinator' ? `, ignoring since it is from coordinator` : ``));
|
||||
if (device.zh.type === 'Coordinator') return;
|
||||
this.eventBus.emitDeviceMessage({...data, device});
|
||||
});
|
||||
|
||||
logger.info(`zigbee-herdsman started (${startResult})`);
|
||||
logger.info(`Coordinator firmware version: '${stringify(await this.getCoordinatorVersion())}'`);
|
||||
logger.debug(`Zigbee network parameters: ${stringify(await this.herdsman.getNetworkParameters())}`);
|
||||
|
||||
for (const device of this.devices(false)) {
|
||||
// If a passlist is used, all other device will be removed from the network.
|
||||
const passlist = settings.get().passlist.concat(settings.get().whitelist);
|
||||
const blocklist = settings.get().blocklist.concat(settings.get().ban);
|
||||
const remove = async (device: Device): Promise<void> => {
|
||||
try {
|
||||
await device.zh.removeFromNetwork();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove '${device.ieeeAddr}' (${error.message})`);
|
||||
}
|
||||
};
|
||||
if (passlist.length > 0) {
|
||||
if (!passlist.includes(device.ieeeAddr)) {
|
||||
logger.warn(`Device which is not on passlist connected (${device.ieeeAddr}), removing...`);
|
||||
await remove(device);
|
||||
}
|
||||
} else if (blocklist.includes(device.ieeeAddr)) {
|
||||
logger.warn(`Device on blocklist is connected (${device.ieeeAddr}), removing...`);
|
||||
await remove(device);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have to set a transmit power
|
||||
if (settings.get().experimental.hasOwnProperty('transmit_power')) {
|
||||
const transmitPower = settings.get().experimental.transmit_power;
|
||||
await this.herdsman.setTransmitPower(transmitPower);
|
||||
logger.info(`Set transmit power to '${transmitPower}'`);
|
||||
}
|
||||
|
||||
return startResult;
|
||||
}
|
||||
|
||||
private logDeviceInterview(data: eventdata.DeviceInterview): void {
|
||||
const name = data.device.name;
|
||||
if (data.status === 'successful') {
|
||||
logger.info(`Successfully interviewed '${name}', device has successfully been paired`);
|
||||
|
||||
if (data.device.definition) {
|
||||
const {vendor, description, model} = data.device.definition;
|
||||
logger.info(`Device '${name}' is supported, identified as: ${vendor} ${description} (${model})`);
|
||||
} else {
|
||||
logger.warn(`Device '${name}' with Zigbee model '${data.device.zh.modelID}' and manufacturer name ` +
|
||||
`'${data.device.zh.manufacturerName}' is NOT supported, ` +
|
||||
// eslint-disable-next-line max-len
|
||||
`please follow https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`);
|
||||
}
|
||||
} else if (data.status === 'failed') {
|
||||
logger.error(`Failed to interview '${name}', device has not successfully been paired`);
|
||||
} else { // data.status === 'started'
|
||||
logger.info(`Starting interview of '${name}'`);
|
||||
}
|
||||
}
|
||||
|
||||
private generateNetworkKey(): number[] {
|
||||
const key = Array.from({length: 16}, () => Math.floor(Math.random() * 255));
|
||||
settings.set(['advanced', 'network_key'], key);
|
||||
return key;
|
||||
}
|
||||
|
||||
private generatePanID(): number {
|
||||
const panID = Math.floor(Math.random() * (0xFFFF - 2)) + 1;
|
||||
settings.set(['advanced', 'pan_id'], panID);
|
||||
return panID;
|
||||
}
|
||||
|
||||
async getCoordinatorVersion(): Promise<zh.CoordinatorVersion> {
|
||||
return this.herdsman.getCoordinatorVersion();
|
||||
}
|
||||
|
||||
isStopping(): boolean {
|
||||
return this.herdsman.isStopping();
|
||||
}
|
||||
|
||||
async getNetworkParameters(): Promise<zh.NetworkParameters> {
|
||||
return this.herdsman.getNetworkParameters();
|
||||
}
|
||||
|
||||
async reset(type: 'soft' | 'hard'): Promise<void> {
|
||||
await this.herdsman.reset(type);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
logger.info('Stopping zigbee-herdsman...');
|
||||
await this.herdsman.stop();
|
||||
logger.info('Stopped zigbee-herdsman');
|
||||
}
|
||||
|
||||
getPermitJoin(): boolean {
|
||||
return this.herdsman.getPermitJoin();
|
||||
}
|
||||
|
||||
getPermitJoinTimeout(): number {
|
||||
return this.herdsman.getPermitJoinTimeout();
|
||||
}
|
||||
|
||||
async permitJoin(permit: boolean, device?: Device, time: number=undefined): Promise<void> {
|
||||
if (permit) {
|
||||
logger.info(`Zigbee: allowing new devices to join${device ? ` via ${device.name}` : ''}.`);
|
||||
} else {
|
||||
logger.info('Zigbee: disabling joining new devices.');
|
||||
}
|
||||
|
||||
if (device && permit) {
|
||||
await this.herdsman.permitJoin(permit, device.zh, time);
|
||||
} else {
|
||||
await this.herdsman.permitJoin(permit, undefined, time);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveDevice(ieeeAddr: string): Device {
|
||||
if (!this.deviceLookup[ieeeAddr]) {
|
||||
const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
|
||||
device && (this.deviceLookup[ieeeAddr] = new Device(device));
|
||||
}
|
||||
|
||||
const device = this.deviceLookup[ieeeAddr];
|
||||
if (device && !device.zh.isDeleted) {
|
||||
device.ensureInSettings();
|
||||
return device;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveGroup(groupID: number): Group {
|
||||
const group = this.herdsman.getGroupByID(Number(groupID));
|
||||
if (group && !this.groupLookup[groupID]) {
|
||||
this.groupLookup[groupID] = new Group(group);
|
||||
}
|
||||
|
||||
return this.groupLookup[groupID];
|
||||
}
|
||||
|
||||
resolveEntity(key: string | number | zh.Device): Device | Group {
|
||||
if (typeof key === 'object') {
|
||||
return this.resolveDevice(key.ieeeAddr);
|
||||
} else if (typeof key === 'string' && key.toLowerCase() === 'coordinator') {
|
||||
return this.resolveDevice(this.herdsman.getDevicesByType('Coordinator')[0].ieeeAddr);
|
||||
} else {
|
||||
const settingsDevice = settings.getDevice(key.toString());
|
||||
if (settingsDevice) return this.resolveDevice(settingsDevice.ID);
|
||||
|
||||
const groupSettings = settings.getGroup(key);
|
||||
if (groupSettings) {
|
||||
const group = this.resolveGroup(groupSettings.ID);
|
||||
// If group does not exist, create it (since it's already in configuration.yaml)
|
||||
return group ? group : this.createGroup(groupSettings.ID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
firstCoordinatorEndpoint(): zh.Endpoint {
|
||||
return this.herdsman.getDevicesByType('Coordinator')[0].endpoints[0];
|
||||
}
|
||||
|
||||
groups(): Group[] {
|
||||
return this.herdsman.getGroups().map((g) => this.resolveGroup(g.groupID));
|
||||
}
|
||||
|
||||
devices(includeCoordinator=true): Device[] {
|
||||
return this.herdsman.getDevices()
|
||||
.map((d) => this.resolveDevice(d.ieeeAddr))
|
||||
.filter((d) => includeCoordinator || d.zh.type !== 'Coordinator');
|
||||
}
|
||||
|
||||
@bind private async acceptJoiningDeviceHandler(ieeeAddr: string): Promise<boolean> {
|
||||
// If passlist is set, all devices not on passlist will be rejected to join the network
|
||||
const passlist = settings.get().passlist.concat(settings.get().whitelist);
|
||||
const blocklist = settings.get().blocklist.concat(settings.get().ban);
|
||||
if (passlist.length > 0) {
|
||||
if (passlist.includes(ieeeAddr)) {
|
||||
logger.info(`Accepting joining device which is on passlist '${ieeeAddr}'`);
|
||||
return true;
|
||||
} else {
|
||||
logger.info(`Rejecting joining not in passlist device '${ieeeAddr}'`);
|
||||
return false;
|
||||
}
|
||||
} else if (blocklist.length > 0) {
|
||||
if (blocklist.includes(ieeeAddr)) {
|
||||
logger.info(`Rejecting joining device which is on blocklist '${ieeeAddr}'`);
|
||||
return false;
|
||||
} else {
|
||||
logger.info(`Accepting joining not in blocklist device '${ieeeAddr}'`);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async touchlinkFactoryResetFirst(): Promise<boolean> {
|
||||
return this.herdsman.touchlinkFactoryResetFirst();
|
||||
}
|
||||
|
||||
async touchlinkFactoryReset(ieeeAddr: string, channel: number): Promise<boolean> {
|
||||
return this.herdsman.touchlinkFactoryReset(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async touchlinkIdentify(ieeeAddr: string, channel: number): Promise<void> {
|
||||
await this.herdsman.touchlinkIdentify(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async touchlinkScan(): Promise<{ieeeAddr: string, channel: number}[]> {
|
||||
return this.herdsman.touchlinkScan();
|
||||
}
|
||||
|
||||
createGroup(ID: number): Group {
|
||||
this.herdsman.createGroup(ID);
|
||||
return this.resolveGroup(ID);
|
||||
}
|
||||
|
||||
deviceByNetworkAddress(networkAddress: number): Device {
|
||||
const device = this.herdsman.getDeviceByNetworkAddress(networkAddress);
|
||||
return device && this.resolveDevice(device.ieeeAddr);
|
||||
}
|
||||
|
||||
groupByID(ID: number): Group {
|
||||
return this.resolveGroup(ID);
|
||||
}
|
||||
}
|
||||
Generated
+9715
-14660
File diff suppressed because it is too large
Load Diff
+46
-24
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "1.18.1",
|
||||
"version": "1.22.1",
|
||||
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
|
||||
"main": "index.js",
|
||||
"repository": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"url": "git+https://github.com/Koenkk/zigbee2mqtt.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^14 || ^15"
|
||||
"node": "^10 || ^12 || ^14 || ^15 || ^16"
|
||||
},
|
||||
"keywords": [
|
||||
"xiaomi",
|
||||
@@ -20,9 +20,11 @@
|
||||
"cc2531"
|
||||
],
|
||||
"scripts": {
|
||||
"test-with-coverage": "jest test --coverage",
|
||||
"eslint": "node_modules/.bin/eslint lib/",
|
||||
"build": "tsc && node index.js writehash",
|
||||
"build-watch": "tsc --watch",
|
||||
"eslint": "eslint lib/ --max-warnings=0",
|
||||
"start": "node index.js",
|
||||
"test-with-coverage": "jest test --coverage",
|
||||
"test": "jest test",
|
||||
"test-watch": "jest test --watch"
|
||||
},
|
||||
@@ -33,35 +35,54 @@
|
||||
},
|
||||
"homepage": "https://koenkk.github.io/zigbee2mqtt",
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.4",
|
||||
"debounce": "^1.2.0",
|
||||
"ajv": "^8.8.2",
|
||||
"bind-decorator": "^1.0.11",
|
||||
"connect-gzip-static": "^2.1.1",
|
||||
"core-js": "^3.19.1",
|
||||
"debounce": "^1.2.1",
|
||||
"deep-object-diff": "^1.1.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"finalhandler": "^1.1.2",
|
||||
"git-last-commit": "^1.0.0",
|
||||
"humanize-duration": "^3.23.1",
|
||||
"js-yaml": "^3.14.0",
|
||||
"json-stable-stringify-without-jsonify": "=1.0.1",
|
||||
"git-last-commit": "^1.0.1",
|
||||
"humanize-duration": "^3.27.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"mkdir-recursive": "^0.4.0",
|
||||
"moment": "^2.27.0",
|
||||
"mqtt": "4.2.1",
|
||||
"moment": "^2.29.1",
|
||||
"mqtt": "4.2.8",
|
||||
"object-assign-deep": "^0.4.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"semver": "^7.3.2",
|
||||
"serve-static": "^1.14.1",
|
||||
"semver": "^7.3.5",
|
||||
"source-map-support": "^0.5.21",
|
||||
"winston": "^3.3.3",
|
||||
"winston-syslog": "^2.4.4",
|
||||
"ws": "^7.3.1",
|
||||
"zigbee-herdsman": "0.13.71",
|
||||
"zigbee-herdsman-converters": "14.0.74-0",
|
||||
"zigbee2mqtt-frontend": "0.3.76"
|
||||
"ws": "^8.3.0",
|
||||
"zigbee-herdsman": "0.13.176",
|
||||
"zigbee-herdsman-converters": "14.0.336",
|
||||
"zigbee2mqtt-frontend": "0.6.46"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "*",
|
||||
"eslint-config-google": "*",
|
||||
"eslint-plugin-jest": "*",
|
||||
"jest": "*",
|
||||
"tmp": "*"
|
||||
"@babel/core": "^7.16.0",
|
||||
"@babel/plugin-proposal-decorators": "^7.16.4",
|
||||
"@babel/preset-env": "^7.16.4",
|
||||
"@babel/preset-typescript": "^7.16.0",
|
||||
"@types/debounce": "^1.2.1",
|
||||
"@types/finalhandler": "^1.1.1",
|
||||
"@types/humanize-duration": "^3.27.0",
|
||||
"@types/jest": "^27.0.3",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
"@types/object-assign-deep": "^0.4.0",
|
||||
"@types/rimraf": "^3.0.2",
|
||||
"@types/ws": "^8.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.4.0",
|
||||
"@typescript-eslint/parser": "^5.4.0",
|
||||
"babel-jest": "^27.3.1",
|
||||
"eslint": "^8.3.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-plugin-jest": "^25.3.0",
|
||||
"jest": "^27.3.1",
|
||||
"tmp": "^0.2.1",
|
||||
"typescript": "^4.5.2"
|
||||
},
|
||||
"jest": {
|
||||
"coverageThreshold": {
|
||||
@@ -73,7 +94,8 @@
|
||||
}
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"lib/**/*.js"
|
||||
"lib/**/*.js",
|
||||
"lib/**/*.ts"
|
||||
]
|
||||
},
|
||||
"bin": {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
const HomeAssistant = require('../lib/extension/homeassistant');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const homeassistant = new HomeAssistant(null, null, null, null, {on: () => {}});
|
||||
const mapping = Object.entries(homeassistant._getMapping());
|
||||
mapping.sort((a, b) => {
|
||||
if (a[0] > b[0]) {
|
||||
return -1;
|
||||
}
|
||||
if (b[0] > a[0]) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
for (const map of mapping) {
|
||||
map[1].sort((a, b) => {
|
||||
return JSON.stringify(a).localeCompare(JSON.stringify(b));
|
||||
});
|
||||
|
||||
const toDelete = [];
|
||||
for (const entry of map[1]) {
|
||||
if (entry.type === 'light') {
|
||||
if (entry.discovery_payload.brightness === false) delete entry.discovery_payload.brightness;
|
||||
if (entry.discovery_payload.color_temp === false) delete entry.discovery_payload.color_temp;
|
||||
if (entry.discovery_payload.xy === false) delete entry.discovery_payload.xy;
|
||||
if (entry.discovery_payload.hs === false) delete entry.discovery_payload.hs;
|
||||
} else if (entry.object_id === 'linkquality') {
|
||||
toDelete.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
toDelete.forEach((d) => map[1].splice(map[1].indexOf(d), 1));
|
||||
|
||||
console.log(`${map[0]} - ${stringify(map[1])}`);
|
||||
}
|
||||
@@ -64,11 +64,11 @@ class ZStackNvMemEraser {
|
||||
if (needOsal) {
|
||||
await this.znp.request(Subsystem.SYS, 'osalNvDelete',
|
||||
{id: id, len: len},
|
||||
null, [ZnpCommandStatus.SUCCESS, ZnpCommandStatus.NV_ITEM_INITIALIZED]);
|
||||
null, null, [ZnpCommandStatus.SUCCESS, ZnpCommandStatus.NV_ITEM_INITIALIZED]);
|
||||
} else {
|
||||
await this.znp.request(Subsystem.SYS, 'nvDelete',
|
||||
{sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0},
|
||||
null, [ZnpCommandStatus.SUCCESS, ZnpCommandStatus.NV_ITEM_INITIALIZED]);
|
||||
null, null, [ZnpCommandStatus.SUCCESS, ZnpCommandStatus.NV_ITEM_INITIALIZED]);
|
||||
}
|
||||
deletedCount++;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ function handle_opts {
|
||||
|
||||
-v|--verbose)
|
||||
verbose=1; shift; continue ;;
|
||||
|
||||
|
||||
-m|--man)
|
||||
makemanual ;;
|
||||
|
||||
@@ -84,7 +84,6 @@ port=""
|
||||
function main {
|
||||
handle_opts "$@"
|
||||
|
||||
# TODO: Check IP-address valid?
|
||||
if [[ "$ip" = "" ]]; then
|
||||
echo "IP-address not specified"
|
||||
exit 5
|
||||
@@ -161,7 +160,7 @@ function usage {
|
||||
-v, --verbose Print more information
|
||||
-m, --man Make and display manual
|
||||
-h, --help Display this help message
|
||||
|
||||
|
||||
\rOriginal concept by JFLN\
|
||||
"
|
||||
exit 0
|
||||
|
||||
@@ -4,7 +4,7 @@ class Example {
|
||||
this.mqtt.publish('example/extension', 'call from constructor')
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
start() {
|
||||
this.mqtt.publish('example/extension', 'test')
|
||||
}
|
||||
}
|
||||
|
||||
+256
-397
@@ -1,440 +1,299 @@
|
||||
const data = require('./stub/data');
|
||||
const logger = require('./stub/logger');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
zigbeeHerdsman.returnDevices.push('0x000b57fffec6a5b3');
|
||||
zigbeeHerdsman.returnDevices.push('0x00124b00120144ae');
|
||||
zigbeeHerdsman.returnDevices.push('0x0017880104e45553');
|
||||
zigbeeHerdsman.returnDevices.push('0x0017880104e45517');
|
||||
import data from './stub/data';
|
||||
import logger from './stub/logger';
|
||||
import MQTT from './stub/mqtt';
|
||||
import zigbeeHerdsman from './stub/zigbeeHerdsman';
|
||||
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const flushPromises = () => new Promise(setImmediate);
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
|
||||
import utils from '../lib/util/utils';
|
||||
import * as settings from '../lib/util/settings';
|
||||
import Controller from '../lib/controller';
|
||||
import flushPromises from './lib/flushPromises';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
const mocksClear = [MQTT.publish, logger.warn, logger.debug];
|
||||
const mocks = [MQTT.publish, logger.warn, logger.debug];
|
||||
const devices = zigbeeHerdsman.devices;
|
||||
zigbeeHerdsman.returnDevices.push(
|
||||
...[devices.bulb_color.ieeeAddr, devices.bulb_color_2.ieeeAddr, devices.coordinator.ieeeAddr, devices.remote.ieeeAddr])
|
||||
|
||||
describe('Availability', () => {
|
||||
let controller;
|
||||
|
||||
function getExtension() {
|
||||
return controller.extensions.find((e) => e.constructor.name === 'Availability');
|
||||
let resetExtension = async () => {
|
||||
await controller.enableDisableExtension(false, 'Availability');
|
||||
await controller.enableDisableExtension(true, 'Availability');
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings._reRead();
|
||||
data.writeEmptyState();
|
||||
jest.useFakeTimers();
|
||||
settings.set(['advanced', 'availability_timeout'], 10);
|
||||
const advancedTime = async (value) => {
|
||||
jest.setSystemTime(Date.now() + value);
|
||||
jest.advanceTimersByTime(value);
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(utils, 'sleep').mockImplementation(async (seconds) => {});
|
||||
jest.useFakeTimers('modern');
|
||||
settings.reRead();
|
||||
settings.set(['availability'], true);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers('modern').setSystemTime(utils.minutes(1));
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
settings.set(['availability'], true);
|
||||
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], false);
|
||||
Object.values(devices).forEach(d => d.lastSeen = utils.minutes(1));
|
||||
mocks.forEach((m) => m.mockClear());
|
||||
await resetExtension();
|
||||
Object.values(devices).forEach((d) => d.ping.mockClear());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await controller.stop();
|
||||
await flushPromises();
|
||||
Object.values(devices).forEach(d => d.lastSeen = utils.minutes(1));
|
||||
})
|
||||
|
||||
it('Should publish availabilty on startup', async () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color/availability',
|
||||
'online',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
})
|
||||
|
||||
it('Should publish availabilty on startup for device where it is enabled for', async () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
|
||||
'online', {retain: true, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability',
|
||||
'online', {retain: true, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bulb_color_2/availability',
|
||||
'online', {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should publish availabilty offline when ping fails', async () => {
|
||||
it('Should publish offline for active device when not seen for 10 minutes', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
logger.error.mockClear();
|
||||
logger.debug.mockClear();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
device.ping.mockImplementationOnce(() => {throw new Error('failed')});
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenNthCalledWith(1,
|
||||
'zigbee2mqtt/bulb_color/availability',
|
||||
'offline',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenCalledWith("Failed to ping 'bulb_color'");
|
||||
device.ping.mockImplementationOnce(() => {throw new Error('failed')});
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(logger.debug).toHaveBeenCalledTimes(3);
|
||||
expect(logger.debug).toHaveBeenCalledWith("Failed to ping 'bulb_color'");
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenNthCalledWith(1,
|
||||
'zigbee2mqtt/bulb_color/availability',
|
||||
'offline',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
|
||||
await advancedTime(utils.minutes(5));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await advancedTime(utils.minutes(7));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
|
||||
'offline', {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should publish availabilty online and query state on reconnect', async () => {
|
||||
const device = zigbeeHerdsman.devices.E11_G13;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
device.ping.mockImplementationOnce(() => {throw new Error('failed')});
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
it('Shouldnt do anything for a device when availability: false is set for device', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_enddevice/availability',
|
||||
'online',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color_2}); // Coverage satisfaction
|
||||
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
await advancedTime(utils.minutes(12));
|
||||
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should fail gracefully when quering state after reconnect fails', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
it('Should publish offline for passive device when not seen for 25 hours', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
await advancedTime(utils.hours(26));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability',
|
||||
'offline', {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should reset ping timer when device last seen changes for active device', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await advancedTime(utils.minutes(5));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color});
|
||||
|
||||
await advancedTime(utils.minutes(7));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await advancedTime(utils.minutes(10));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
|
||||
'offline', {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should ping again when first ping fails', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await advancedTime(utils.minutes(5));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color});
|
||||
|
||||
await advancedTime(utils.minutes(7));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
devices.bulb_color.ping.mockImplementationOnce(() => {throw new Error('failed')});
|
||||
devices.bulb_color.lastSeen = Date.now() + utils.minutes(10);
|
||||
await advancedTime(utils.minutes(10));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
|
||||
'offline', {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should reset ping timer when device last seen changes for passive device', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await advancedTime(utils.hours(24));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.remote});
|
||||
|
||||
await advancedTime(utils.hours(25));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
devices.remote.ping.mockImplementationOnce(() => {throw new Error('failed')});
|
||||
await advancedTime(utils.hours(3));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability',
|
||||
'offline', {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should immediately mark device as online when it lastSeen changes', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await advancedTime(utils.minutes(15));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
|
||||
'offline', {retain: true, qos: 0}, expect.any(Function));
|
||||
|
||||
devices.bulb_color.lastSeen = Date.now();
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
|
||||
'online', {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should allow to change availability timeout via device options', async () => {
|
||||
settings.set(['devices', '0x000b57fffec6a5b3', 'availability'], {timeout: 40});
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await advancedTime(utils.minutes(25));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await advancedTime(utils.minutes(17));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should allow to change availability timeout via avaiability options', async () => {
|
||||
settings.set(['availability'], {active: {timeout: 30}});
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await advancedTime(utils.minutes(25));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await advancedTime(utils.minutes(7));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should stop pinging device when it leaves', async () => {
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await advancedTime(utils.minutes(9));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await zigbeeHerdsman.events.deviceLeave({ieeeAddr: devices.bulb_color.ieeeAddr});
|
||||
await flushPromises();
|
||||
|
||||
await advancedTime(utils.minutes(3));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should stop pinging device when it is removed', async () => {
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await advancedTime(utils.minutes(9));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', stringify({id: "bulb_color"}));
|
||||
await flushPromises();
|
||||
|
||||
await advancedTime(utils.minutes(3));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should allow to be disabled', async () => {
|
||||
settings.set(['availability'], false);
|
||||
await resetExtension();
|
||||
|
||||
await advancedTime(utils.minutes(12));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should retrieve device state when it reconnects', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
const endpoint = devices.bulb_color.getEndpoint(1);
|
||||
endpoint.read.mockClear();
|
||||
endpoint.read.mockImplementationOnce(() => {throw new Error('Device timedout')});
|
||||
device.ping.mockImplementationOnce(() => {throw new Error('failed')});
|
||||
logger.debug.mockClear();
|
||||
jest.advanceTimersByTime(11 * 2000);
|
||||
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
await advancedTime(utils.seconds(1));
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(0);
|
||||
await advancedTime(utils.seconds(2));
|
||||
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('lightingColorCtrl',
|
||||
['colorMode', 'currentX', 'currentY', 'enhancedCurrentHue', 'currentSaturation', 'colorTemperature']);
|
||||
|
||||
// Should stop when one request fails
|
||||
endpoint.read.mockClear();
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
endpoint.read.mockImplementationOnce(() => {throw new Error('')});
|
||||
await advancedTime(utils.seconds(3));
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
expect(logger.error).toHaveBeenCalledWith(`Failed to read state of 'bulb_color' after reconnect`);
|
||||
});
|
||||
|
||||
it('Shouldnt ping again when still pinging', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
device.ping.mockClear();
|
||||
device.ping.mockImplementationOnce(async () => {await wait(100000000)});
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(1);
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should mark device online when receiving message while offline', async () => {
|
||||
it('Should republish availability when device is renamed', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
device.ping.mockImplementationOnce(() => {throw new Error('failed')});
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({from: 'bulb_color', to: 'bulb_new_name'}));
|
||||
await flushPromises();
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
const data = {modelID: 'test'}
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenNthCalledWith(1,
|
||||
'zigbee2mqtt/bulb_color/availability',
|
||||
'online',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability',
|
||||
'online', {retain: true, qos: 0}, expect.any(Function));
|
||||
await advancedTime(utils.hours(12));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability',
|
||||
'offline', {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should retrieve the state when device is turned on/off within availability timeout', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
getExtension().state = {};
|
||||
const payload = {device: zigbeeHerdsman.devices.bulb_color};
|
||||
await zigbeeHerdsman.events.deviceJoined(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color/availability',
|
||||
'online',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
it('Deprecated - should allow to block via advanced.availability_blocklist', async () => {
|
||||
settings.set(['advanced', 'availability_blocklist'], [devices.bulb_color.ieeeAddr]);
|
||||
await resetExtension();
|
||||
|
||||
await advancedTime(utils.minutes(12));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should retrieve the state when device is turned on/off within availability timeout on deviceAnnounce', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
endpoint.read.mockClear();
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
it('Deprecated - should allow to pass certain devices via availability_passlist', async () => {
|
||||
settings.set(['advanced', 'availability_passlist'], [devices.bulb_color_2.ieeeAddr]);
|
||||
settings.changeEntityOptions(devices.bulb_color_2.ieeeAddr, {availability: null});
|
||||
await resetExtension();
|
||||
|
||||
await advancedTime(utils.minutes(12));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should not retrieve the state when device is turned on/off within availability timeout on deviceJoined', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
const device = zigbeeHerdsman.devices.E11_G13;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
endpoint.read.mockClear();
|
||||
await zigbeeHerdsman.events.deviceJoined({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
it('Deprecated - should allow to enable via availability_timeout', async () => {
|
||||
settings.set(['availability'], false);
|
||||
settings.set(['advanced', 'availability_timeout'], 60);
|
||||
await resetExtension();
|
||||
|
||||
it('Should not do anything when message has no device', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const payload = {ieeeAddr: device.ieeeAddr};
|
||||
await zigbeeHerdsman.events.deviceLeave(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish.mock.calls.find((c) => c[0].includes('availability'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Should not ping devices on blocklist by friendly name', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
settings.set(['advanced', 'availability_blocklist'], ['bulb_color'])
|
||||
await controller.stop();
|
||||
await flushPromises();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
device.ping.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should not ping devices on blacklist by friendly name', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
settings.set(['advanced', 'availability_blacklist'], ['bulb_color'])
|
||||
await controller.stop();
|
||||
await flushPromises();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
device.ping.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should not ping devices on blocklist by IEEE address', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
settings.set(['advanced', 'availability_blocklist'], [device.ieeeAddr]);
|
||||
await controller.stop();
|
||||
await flushPromises();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
device.ping.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should ping forced pingable devices', async () => {
|
||||
const device = zigbeeHerdsman.devices.E11_G13;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const data = {modelID: 'test'}
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
device.ping.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should ping devices on passlist by friendly name if availability_passlist is set', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
settings.set(['advanced', 'availability_passlist'], ['bulb_color']);
|
||||
await controller.stop();
|
||||
await flushPromises();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
device.ping.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should ping devices on whitelist by friendly name if availability_whitelist is set', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
settings.set(['advanced', 'availability_whitelist'], ['bulb_color']);
|
||||
await controller.stop();
|
||||
await flushPromises();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
device.ping.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should ping devices on passlist by IEEE address if availability_passlist is set', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
settings.set(['advanced', 'availability_passlist'], [device.ieeeAddr]);
|
||||
await controller.stop();
|
||||
await flushPromises();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
device.ping.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should not ping devices not in passlist if availability_passlist is set', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
getExtension().state[device.ieeeAddr] = false;
|
||||
settings.set(['advanced', 'availability_passlist'], ['0x000b57fffec6a5b3'])
|
||||
await controller.stop();
|
||||
await flushPromises();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(0);
|
||||
MQTT.publish.mockClear();
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device});
|
||||
});
|
||||
|
||||
it('Should not read when device has no modelID and reconnects', async () => {
|
||||
const device = zigbeeHerdsman.devices.nomodel;
|
||||
getExtension().state[device.ieeeAddr] = true;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should not read when device has is unsupported', async () => {
|
||||
const device = zigbeeHerdsman.devices.unsupported_router;
|
||||
getExtension().state[device.ieeeAddr] = true;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should stop pinging device when removed', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
device.ping.mockClear();
|
||||
const endpoint = device.getEndpoint(1);
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(1);
|
||||
settings.removeDevice(device.ieeeAddr);
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should publish availability when end device joins', async () => {
|
||||
const device = zigbeeHerdsman.devices.WXKG02LM_rev1;
|
||||
const payload = {device};
|
||||
MQTT.publish.mockClear();
|
||||
await zigbeeHerdsman.events.deviceJoined(payload);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(0);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/button_double_key/availability',
|
||||
'online',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Should mark non-pingable device as non-available when offline for longer than 24 hours', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const defaultLastSeen = device.lastSeen;
|
||||
device.lastSeen = Date.now();
|
||||
MQTT.publish.mockClear();
|
||||
jest.advanceTimersByTime(1000 * 60 * 60 * 1); // 1 hours
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
device.lastSeen = device.lastSeen - (1000 * 60 * 60 * 25);
|
||||
jest.advanceTimersByTime(1000 * 60 * 60 * 1); // 1 hours
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/remote/availability',
|
||||
'offline',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
|
||||
// Shouldn't do anything more when device is removed
|
||||
settings.removeDevice(device.ieeeAddr);
|
||||
jest.advanceTimersByTime(1000 * 60 * 60 * 1); // 1 hours
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
|
||||
device.lastSeen = defaultLastSeen;
|
||||
});
|
||||
|
||||
it('Should republish existing state on MQTT connected', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
await controller.stop();
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
getExtension().state[device.ieeeAddr] = false;
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color/availability',
|
||||
'offline',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Should clear retained availability topic when device is remove', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', 'bulb_color');
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color/availability',
|
||||
null,
|
||||
{retain: true, qos: 0}, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Should clear retained availability topic when device is renamed', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({"from": "bulb_color", "to": "bulb_color_new_name"}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color/availability',
|
||||
null,
|
||||
{retain: true, qos: 0}, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Should clear retained availability topic when device does not exist', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/not_existing_hahaha/availability', 'offline');
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/not_existing_hahaha/availability', null, {retain: true, qos: 0}, expect.any(Function));
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/0x000b57fffec6a5b3/availability', 'offline');
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/0x000b57fffec6a5b3/availability', null, {retain: true, qos: 0}, expect.any(Function));
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bulb_color/availability', 'offline');
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
await advancedTime(utils.minutes(12));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
+97
-10
@@ -4,7 +4,7 @@ const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const flushPromises = () => new Promise(setImmediate);
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
jest.mock('debounce', () => jest.fn(fn => fn));
|
||||
const debounce = require('debounce');
|
||||
@@ -12,7 +12,7 @@ const debounce = require('debounce');
|
||||
describe('Bind', () => {
|
||||
let controller;
|
||||
|
||||
mockClear = (device) => {
|
||||
const mockClear = (device) => {
|
||||
for (const endpoint of device.endpoints) {
|
||||
endpoint.read.mockClear();
|
||||
endpoint.write.mockClear();
|
||||
@@ -23,22 +23,34 @@ describe('Bind', () => {
|
||||
}
|
||||
}
|
||||
|
||||
let resetExtension = async () => {
|
||||
await controller.enableDisableExtension(false, 'Bind');
|
||||
await controller.enableDisableExtension(true, 'Bind');
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings._reRead();
|
||||
data.writeEmptyState();
|
||||
settings.reRead();
|
||||
zigbeeHerdsman.groups.group_1.members = [];
|
||||
zigbeeHerdsman.devices.bulb_color.getEndpoint(1).configureReporting.mockClear();
|
||||
zigbeeHerdsman.devices.bulb_color.getEndpoint(1).bind.mockClear();
|
||||
zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read.mockClear();
|
||||
debounce.mockClear();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
this.coordinatorEndoint = zigbeeHerdsman.devices.coordinator.getEndpoint(1);
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
})
|
||||
|
||||
it('Should bind to device and configure reporting', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
|
||||
@@ -53,7 +65,7 @@ describe('Bind', () => {
|
||||
mockClear(device);
|
||||
target.configureReporting.mockImplementationOnce(() => {throw new Error("timeout")});
|
||||
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color'}));
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: "1234", from: 'remote', to: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', [ 'colorCapabilities' ]);
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(4);
|
||||
@@ -67,7 +79,7 @@ describe('Bind', () => {
|
||||
expect(target.configureReporting).toHaveBeenCalledWith("lightingColorCtrl",[{"attribute":"colorTemperature","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1},{"attribute":"currentX","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1},{"attribute":"currentY","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1}]);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({"data":{"from":"remote","to":"bulb_color","clusters":["genScenes","genOnOff","genLevelCtrl", "lightingColorCtrl"],"failed":[]},"status":"ok"}),
|
||||
stringify({"transaction": "1234","data":{"from":"remote","to":"bulb_color","clusters":["genScenes","genOnOff","genLevelCtrl", "lightingColorCtrl"],"failed":[]},"status":"ok"}),
|
||||
{retain: false, qos: 0}, expect.any(Function)
|
||||
);
|
||||
|
||||
@@ -229,6 +241,52 @@ describe('Bind', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('Should unbind from group with skip_disable_reporting=true', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.groups.group_1;
|
||||
const target1Member = zigbeeHerdsman.devices.bulb_2.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
target.members.push(target1Member);
|
||||
|
||||
// The device unbind mock doesn't remove binds, therefore remove them here already otherwise configure reporiting is not disabled.
|
||||
const originalBinds = endpoint.binds;
|
||||
endpoint.binds = [];
|
||||
|
||||
target1Member.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}, {cluster: {name: 'genOnOff'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}];
|
||||
target1Member.configureReporting.mockClear();
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: true}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
// with skip_disable_reporting set to false, we don't expect it to reconfigure reporting
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledTimes(0);
|
||||
endpoint.binds = originalBinds;
|
||||
});
|
||||
|
||||
it('Should unbind from group with skip_disable_reporting=false', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.groups.group_1;
|
||||
const target1Member = zigbeeHerdsman.devices.bulb_2.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
target.members.push(target1Member);
|
||||
|
||||
// The device unbind mock doesn't remove binds, therefore remove them here already otherwise configure reporiting is not disabled.
|
||||
const originalBinds = endpoint.binds;
|
||||
endpoint.binds = [];
|
||||
|
||||
target1Member.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}, {cluster: {name: 'genOnOff'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}];
|
||||
target1Member.configureReporting.mockClear();
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: false}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
// with skip_disable_reporting set, we expect it to reconfigure reporting
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [{"attribute": "currentLevel", "maximumReportInterval": 65535, "minimumReportInterval": 5, "reportableChange": 1}])
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [{"attribute": "onOff", "maximumReportInterval": 65535, "minimumReportInterval": 0, "reportableChange": 0}])
|
||||
endpoint.binds = originalBinds;
|
||||
});
|
||||
|
||||
it('Should bind to group by number', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.groups.group_1;
|
||||
@@ -279,6 +337,22 @@ describe('Bind', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind server clusters to client clusters', async () => {
|
||||
const device = zigbeeHerdsman.devices.temperature_sensor;
|
||||
const target = zigbeeHerdsman.devices.heating_actuator.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'temperature_sensor', to: 'heating_actuator'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("msTemperatureMeasurement", target);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({"data":{"from":"temperature_sensor","to":"heating_actuator","clusters":["msTemperatureMeasurement"],"failed":[]},"status":"ok"}),
|
||||
{retain: false, qos: 0}, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind to default endpoint returned by endpoints()', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.QBKG04LM.getEndpoint(2);
|
||||
@@ -511,6 +585,19 @@ describe('Bind', () => {
|
||||
expect(zigbeeHerdsman.devices.bulb_color.getEndpoint(1).read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]);
|
||||
});
|
||||
|
||||
it('Should poll bounded Hue bulb when receiving message from scene controller', async () => {
|
||||
const remote = zigbeeHerdsman.devices.bj_scene_switch;
|
||||
const data = {"action": "recall_2_row_1"};
|
||||
const payload = {data, cluster: 'genScenes', device: remote, endpoint: remote.getEndpoint(10), type: 'commandRecall', linkquality: 10, groupID: 0};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
// Calls to three clusters are expected in this case
|
||||
expect(debounce).toHaveBeenCalledTimes(3);
|
||||
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith("genOnOff", ["onOff"]);
|
||||
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]);
|
||||
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith("lightingColorCtrl", ["currentX", "currentY", "colorTemperature"]);
|
||||
});
|
||||
|
||||
it('Should poll grouped Hue bulb when receiving message from TRADFRI remote', async () => {
|
||||
zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read.mockClear();
|
||||
zigbeeHerdsman.devices.bulb_2.getEndpoint(1).read.mockClear();
|
||||
|
||||
+153
-35
File diff suppressed because one or more lines are too long
+75
-53
@@ -4,7 +4,7 @@ const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const flushPromises = () => new Promise(setImmediate);
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
@@ -12,13 +12,14 @@ const mocksClear = [MQTT.publish, logger.warn, logger.debug];
|
||||
|
||||
describe('Configure', () => {
|
||||
let controller;
|
||||
let coordinatorEndpoint;
|
||||
|
||||
expectRemoteConfigured = () => {
|
||||
const expectRemoteConfigured = () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const endpoint1 = device.getEndpoint(1);
|
||||
expect(endpoint1.bind).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint1.bind).toHaveBeenCalledWith('genOnOff', this.coordinatorEndoint);
|
||||
expect(endpoint1.bind).toHaveBeenCalledWith('genLevelCtrl', this.coordinatorEndoint);
|
||||
expect(endpoint1.bind).toHaveBeenCalledWith('genOnOff', coordinatorEndpoint);
|
||||
expect(endpoint1.bind).toHaveBeenCalledWith('genLevelCtrl', coordinatorEndpoint);
|
||||
|
||||
const endpoint2 = device.getEndpoint(2);
|
||||
expect(endpoint2.write).toHaveBeenCalledTimes(1);
|
||||
@@ -26,13 +27,27 @@ describe('Configure', () => {
|
||||
expect(device.meta.configured).toBe(1);
|
||||
}
|
||||
|
||||
expectRemoteNotConfigured = () => {
|
||||
const expectBulbConfigured = () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const endpoint1 = device.getEndpoint(1);
|
||||
expect(endpoint1.read).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
|
||||
expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', [ 'colorTempPhysicalMin', 'colorTempPhysicalMax' ]);
|
||||
}
|
||||
|
||||
const expectBulbNotConfigured = () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const endpoint1 = device.getEndpoint(1);
|
||||
expect(endpoint1.read).toHaveBeenCalledTimes(0);
|
||||
}
|
||||
|
||||
const expectRemoteNotConfigured = () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const endpoint1 = device.getEndpoint(1);
|
||||
expect(endpoint1.bind).toHaveBeenCalledTimes(0);
|
||||
}
|
||||
|
||||
mockClear = (device) => {
|
||||
const mockClear = (device) => {
|
||||
for (const endpoint of device.endpoints) {
|
||||
endpoint.read.mockClear();
|
||||
endpoint.write.mockClear();
|
||||
@@ -41,68 +56,79 @@ describe('Configure', () => {
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useRealTimers();
|
||||
data.writeDefaultConfiguration();
|
||||
settings._reRead();
|
||||
data.writeEmptyState();
|
||||
let resetExtension = async () => {
|
||||
await controller.enableDisableExtension(false, 'Configure');
|
||||
await controller.enableDisableExtension(true, 'Configure');
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
await flushPromises();
|
||||
this.coordinatorEndoint = zigbeeHerdsman.devices.coordinator.getEndpoint(1);
|
||||
});
|
||||
|
||||
it('Should configure on startup', async () => {
|
||||
expectRemoteConfigured();
|
||||
beforeEach(async () => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
coordinatorEndpoint = zigbeeHerdsman.devices.coordinator.getEndpoint(1);
|
||||
await resetExtension();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
})
|
||||
|
||||
it('Should configure Router on startup', async () => {
|
||||
expectBulbConfigured();
|
||||
});
|
||||
|
||||
it('Should not configure EndDevice on startup', async () => {
|
||||
expectRemoteNotConfigured();
|
||||
});
|
||||
|
||||
it('Should re-configure when device rejoins', async () => {
|
||||
expectRemoteConfigured();
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
expectBulbConfigured();
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
await flushPromises();
|
||||
mockClear(device);
|
||||
const payload = {device};
|
||||
zigbeeHerdsman.events.deviceJoined(payload);
|
||||
await flushPromises();
|
||||
expectRemoteConfigured();
|
||||
expectBulbConfigured();
|
||||
});
|
||||
|
||||
it('Should reconfigure reporting on reportingDisabled event', async () => {
|
||||
expectRemoteConfigured();
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
mockClear(device);
|
||||
expectRemoteNotConfigured();
|
||||
controller.eventBus.emit('reportingDisabled', {device})
|
||||
expectBulbConfigured();
|
||||
const device = controller.zigbee.resolveEntity(zigbeeHerdsman.devices.bulb);
|
||||
mockClear(device.zh);
|
||||
expectBulbNotConfigured();
|
||||
controller.eventBus.emitReportingDisabled({device});
|
||||
await flushPromises();
|
||||
expectRemoteConfigured();
|
||||
expectBulbConfigured();
|
||||
});
|
||||
|
||||
it('Should not configure twice', async () => {
|
||||
expectRemoteConfigured();
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
expectBulbConfigured();
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
mockClear(device);
|
||||
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.deviceInterview({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(0);
|
||||
expectBulbNotConfigured();
|
||||
});
|
||||
|
||||
it('Should configure on zigbee message when not configured yet', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
delete device.meta.configured;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expectRemoteConfigured();
|
||||
expectBulbConfigured();
|
||||
});
|
||||
|
||||
it('Should allow to configure via MQTT', async () => {
|
||||
expectRemoteConfigured();
|
||||
mockClear(zigbeeHerdsman.devices.remote);
|
||||
expectRemoteNotConfigured();
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', 'remote');
|
||||
@@ -137,11 +163,11 @@ describe('Configure', () => {
|
||||
});
|
||||
|
||||
it('Fail to configure via MQTT when device has no configure', async () => {
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: "bulb_enddevice", transaction: 20}));
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: "0x90fd9ffffe4b64ax", transaction: 20}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/configure',
|
||||
stringify({"data":{"id": "bulb_enddevice"},"status":"error","error": "Device 'bulb_enddevice' cannot be configured","transaction":20}),
|
||||
stringify({"data":{"id": "0x90fd9ffffe4b64ax"},"status":"error","error": "Device 'ZNLDP12LM' cannot be configured","transaction":20}),
|
||||
{retain: false, qos: 0}, expect.any(Function)
|
||||
);
|
||||
});
|
||||
@@ -161,9 +187,9 @@ describe('Configure', () => {
|
||||
});
|
||||
|
||||
it('Legacy api: Should skip reconfigure when device does not require this', async () => {
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/configure', '0x0017880104e45553');
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/configure', '0x90fd9ffffe4b64ax');
|
||||
await flushPromises();
|
||||
expect(logger.warn).toHaveBeenCalledWith(`Skipping configure of 'bulb_enddevice', device does not require this.`)
|
||||
expect(logger.warn).toHaveBeenCalledWith(`Skipping configure of 'ZNLDP12LM', device does not require this.`)
|
||||
});
|
||||
|
||||
it('Should not configure when interviewing', async () => {
|
||||
@@ -172,8 +198,7 @@ describe('Configure', () => {
|
||||
device.interviewing = true;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expectRemoteNotConfigured();
|
||||
device.interviewing = false;
|
||||
@@ -185,8 +210,7 @@ describe('Configure', () => {
|
||||
device.interviewCompleted = false;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expectRemoteConfigured();
|
||||
device.interviewCompleted = true;
|
||||
@@ -198,11 +222,10 @@ describe('Configure', () => {
|
||||
const endpoint = device.getEndpoint(1);
|
||||
endpoint.bind.mockImplementationOnce(async () => await wait(500));
|
||||
mockClear(device);
|
||||
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -214,20 +237,19 @@ describe('Configure', () => {
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
|
||||
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
+99
-53
@@ -6,7 +6,7 @@ const path = require('path');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const flushPromises = () => new Promise(setImmediate);
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const tmp = require('tmp');
|
||||
const mocksClear = [
|
||||
zigbeeHerdsman.permitJoin, MQTT.end, zigbeeHerdsman.stop, logger.debug,
|
||||
@@ -20,21 +20,29 @@ describe('Controller', () => {
|
||||
let controller;
|
||||
let mockExit;
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
MQTT.restoreOnMock();
|
||||
zigbeeHerdsman.returnDevices.splice(0);
|
||||
mockExit = jest.fn();
|
||||
controller = new Controller(jest.fn(), mockExit);
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
data.writeDefaultConfiguration();
|
||||
settings._reRead();
|
||||
settings.reRead();
|
||||
data.writeDefaultState();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
})
|
||||
|
||||
it('Start controller', async () => {
|
||||
await controller.start();
|
||||
expect(zigbeeHerdsman.constructor).toHaveBeenCalledWith({"network":{"panID":6754,"extendedPanID":[221,221,221,221,221,221,221,221],"channelList":[11],"networkKey":[1,3,5,7,9,11,13,15,0,2,4,6,8,10,12,13]},"databasePath":path.join(data.mockDir, "database.db"), "databaseBackupPath":path.join(data.mockDir, "database.db.backup"),"backupPath":path.join(data.mockDir, "coordinator_backup.json"),"acceptJoiningDeviceHandler": expect.any(Function),adapter: {concurrent: null, delay: null}, "serialPort":{"baudRate":undefined,"rtscts":undefined,"path":"/dev/dummy"}}, logger);
|
||||
expect(zigbeeHerdsman.constructor).toHaveBeenCalledWith({"network":{"panID":6754,"extendedPanID":[221,221,221,221,221,221,221,221],"channelList":[11],"networkKey":[1,3,5,7,9,11,13,15,0,2,4,6,8,10,12,13]},"databasePath":path.join(data.mockDir, "database.db"), "databaseBackupPath":path.join(data.mockDir, "database.db.backup"),"backupPath":path.join(data.mockDir, "coordinator_backup.json"),"acceptJoiningDeviceHandler": expect.any(Function),adapter: {concurrent: null, delay: null, disableLED: false}, "serialPort":{"baudRate":undefined,"rtscts":undefined,"path":"/dev/dummy"}}, logger);
|
||||
expect(zigbeeHerdsman.start).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.setLED).toHaveBeenCalledTimes(0);
|
||||
expect(zigbeeHerdsman.setTransmitPower).toHaveBeenCalledTimes(0);
|
||||
expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledWith(true, undefined, undefined);
|
||||
@@ -43,7 +51,7 @@ describe('Controller', () => {
|
||||
expect(logger.info).toHaveBeenCalledWith('remote (0x0017880104e45517): 324131092621 - Philips Hue dimmer switch (EndDevice)');
|
||||
expect(logger.info).toHaveBeenCalledWith('0x0017880104e45518 (0x0017880104e45518): Not supported (EndDevice)');
|
||||
expect(MQTT.connect).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.connect).toHaveBeenCalledWith("mqtt://localhost", {"will": {"payload": "offline", "retain": true, "topic": "zigbee2mqtt/bridge/state"}});
|
||||
expect(MQTT.connect).toHaveBeenCalledWith("mqtt://localhost", {"will": {"payload": "offline", "retain": true, "topic": "zigbee2mqtt/bridge/state", "qos": 1}});
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}),{ retain: true, qos: 0 }, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote', stringify({"brightness":255}), { retain: true, qos: 0 }, expect.any(Function));
|
||||
});
|
||||
@@ -79,7 +87,7 @@ describe('Controller', () => {
|
||||
await flushPromises();
|
||||
expect(MQTT.connect).toHaveBeenCalledTimes(1);
|
||||
const expected = {
|
||||
"will": {"payload": "offline", "retain": true, "topic": "zigbee2mqtt/bridge/state"},
|
||||
"will": {"payload": "offline", "retain": true, "topic": "zigbee2mqtt/bridge/state", "qos": 1},
|
||||
keepalive: 30,
|
||||
ca: Buffer.from([99, 97]),
|
||||
key: Buffer.from([107, 101, 121]),
|
||||
@@ -132,14 +140,12 @@ describe('Controller', () => {
|
||||
});
|
||||
|
||||
it('Log when MQTT client is unavailable', async () => {
|
||||
jest.useFakeTimers();
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
logger.error.mockClear();
|
||||
controller.mqtt.client.reconnecting = true;
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenCalledWith("Not connected to MQTT server!");
|
||||
controller.mqtt.client.reconnecting = false;
|
||||
});
|
||||
@@ -149,7 +155,8 @@ describe('Controller', () => {
|
||||
await flushPromises();
|
||||
logger.error.mockClear();
|
||||
controller.mqtt.client.reconnecting = true;
|
||||
await controller.publishEntityState('bulb', {state: 'ON', brightness: 50, color_temp: 370, color: {r: 100, g: 50, b: 10}, dummy: {1: 'yes', 2: 'no'}});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON', brightness: 50, color_temp: 370, color: {r: 100, g: 50, b: 10}, dummy: {1: 'yes', 2: 'no'}});
|
||||
await flushPromises();
|
||||
expect(logger.error).toHaveBeenCalledTimes(2);
|
||||
expect(logger.error).toHaveBeenCalledWith("Not connected to MQTT server!");
|
||||
@@ -166,6 +173,7 @@ describe('Controller', () => {
|
||||
|
||||
it('Should remove device not on passlist on startup', async () => {
|
||||
settings.set(['passlist'], [zigbeeHerdsman.devices.bulb_color.ieeeAddr]);
|
||||
zigbeeHerdsman.devices.bulb.removeFromNetwork.mockImplementationOnce(() => {throw new Error("dummy")});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
expect(zigbeeHerdsman.devices.bulb_color.removeFromNetwork).toHaveBeenCalledTimes(0);
|
||||
@@ -202,6 +210,17 @@ describe('Controller', () => {
|
||||
expect(mockExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Start controller fails due to MQTT', async () => {
|
||||
MQTT.on.mockImplementation((type, handler) => {
|
||||
if (type === 'error') handler({message: 'addr not found'});
|
||||
});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
expect(logger.error).toHaveBeenCalledWith('MQTT failed to connect: addr not found');
|
||||
expect(mockExit).toHaveBeenCalledTimes(1);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('Start controller with permit join true', async () => {
|
||||
settings.set(['permit_join'], false);
|
||||
await controller.start();
|
||||
@@ -209,13 +228,6 @@ describe('Controller', () => {
|
||||
expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledWith(false, undefined, undefined);
|
||||
});
|
||||
|
||||
it('Start controller with disable_led', async () => {
|
||||
settings.set(['serial', 'disable_led'], true);
|
||||
await controller.start();
|
||||
expect(zigbeeHerdsman.setLED).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.setLED).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('Start controller with transmit power', async () => {
|
||||
settings.set(['experimental', 'transmit_power'], 14);
|
||||
await controller.start();
|
||||
@@ -229,7 +241,7 @@ describe('Controller', () => {
|
||||
expect(MQTT.end).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.stop).toHaveBeenCalledTimes(1);
|
||||
expect(mockExit).toHaveBeenCalledTimes(1);
|
||||
expect(mockExit).toHaveBeenCalledWith(0, null);
|
||||
expect(mockExit).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('Start controller and stop', async () => {
|
||||
@@ -239,7 +251,7 @@ describe('Controller', () => {
|
||||
expect(MQTT.end).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.stop).toHaveBeenCalledTimes(1);
|
||||
expect(mockExit).toHaveBeenCalledTimes(1);
|
||||
expect(mockExit).toHaveBeenCalledWith(1, null);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('Start controller adapter disconnects', async () => {
|
||||
@@ -250,7 +262,7 @@ describe('Controller', () => {
|
||||
expect(MQTT.end).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.stop).toHaveBeenCalledTimes(1);
|
||||
expect(mockExit).toHaveBeenCalledTimes(1);
|
||||
expect(mockExit).toHaveBeenCalledWith(1, null);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('Handle mqtt message', async () => {
|
||||
@@ -295,17 +307,6 @@ describe('Controller', () => {
|
||||
expect(settings.getDevice(device.ieeeAddr)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('On zigbee event message from unkown device should create it', async () => {
|
||||
await controller.start();
|
||||
const device = zigbeeHerdsman.devices.notInSettings;
|
||||
settings.removeDevice(device.ieeeAddr);
|
||||
expect(settings.getDevice(device.ieeeAddr)).toBeNull();
|
||||
const payload = {device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10, groupID: 0, cluster: 'genBasic', data: {modelId: device.modelID}};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(settings.getDevice(device.ieeeAddr)).toStrictEqual({"ID": "0x0017880104e45519", "friendlyName": "0x0017880104e45519", "friendly_name": "0x0017880104e45519"});
|
||||
});
|
||||
|
||||
it('On zigbee deviceJoined', async () => {
|
||||
await controller.start();
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
@@ -446,7 +447,8 @@ describe('Controller', () => {
|
||||
await controller.start();
|
||||
settings.set(['experimental', 'output'], 'attribute');
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {dummy: {1: 'yes', 2: 'no'}, color: {r: 100, g: 50, b: 10}, state: 'ON', test: undefined, test1: null, color_temp: 370, brightness: 50});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {dummy: {1: 'yes', 2: 'no'}, color: {r: 100, g: 50, b: 10}, state: 'ON', test: undefined, test1: null, color_temp: 370, brightness: 50});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/brightness", "50", {"qos": 0, "retain": true}, expect.any(Function));
|
||||
@@ -462,7 +464,8 @@ describe('Controller', () => {
|
||||
await controller.start();
|
||||
settings.set(['experimental', 'output'], 'attribute_and_json');
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(5);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function));
|
||||
@@ -478,7 +481,8 @@ describe('Controller', () => {
|
||||
settings.set(['experimental', 'output'], 'attribute_and_json');
|
||||
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'filtered_attributes'], ['color_temp', 'linkquality']);
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(3);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function));
|
||||
@@ -491,7 +495,8 @@ describe('Controller', () => {
|
||||
settings.set(['experimental', 'output'], 'attribute_and_json');
|
||||
settings.set(['device_options', 'filtered_attributes'], ['color_temp', 'linkquality']);
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(3);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function));
|
||||
@@ -503,12 +508,14 @@ describe('Controller', () => {
|
||||
await controller.start();
|
||||
settings.set(['mqtt', 'include_device_information'], true);
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON'});
|
||||
let device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON'});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"device":{"friendlyName":"bulb","model":"LED1545G12","ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369,"type":"Router","manufacturerID":4476,"powerSource":"Mains (single phase)","dateCode":null, "softwareBuildID": null}}), {"qos": 0, "retain": true}, expect.any(Function));
|
||||
|
||||
// Unsupported device should have model "unknown"
|
||||
await controller.publishEntityState('unsupported2', {state: 'ON'});
|
||||
device = controller.zigbee.resolveEntity('unsupported2');
|
||||
await controller.publishEntityState(device, {state: 'ON'});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/unsupported2', stringify({"state":"ON","device":{"friendlyName":"unsupported2","model":"unknown","ieeeAddr":"0x0017880104e45529","networkAddress":6536,"type":"EndDevice","manufacturerID":0,"powerSource":"Battery","dateCode":null, "softwareBuildID": null}}), {"qos": 0, "retain": false}, expect.any(Function));
|
||||
});
|
||||
@@ -517,7 +524,8 @@ describe('Controller', () => {
|
||||
await controller.start();
|
||||
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'retain'], false);
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON'});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON'});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}), {"qos": 0, "retain": false}, expect.any(Function));
|
||||
});
|
||||
@@ -526,7 +534,8 @@ describe('Controller', () => {
|
||||
await controller.start();
|
||||
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'retain'], true);
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON'});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON'});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}), {"qos": 0, "retain": true}, expect.any(Function));
|
||||
});
|
||||
@@ -537,7 +546,8 @@ describe('Controller', () => {
|
||||
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'retain'], true);
|
||||
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'retention'], 37);
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON'});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON'});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}), {"qos": 0, "retain": true, "properties": {messageExpiryInterval: 37}}, expect.any(Function));
|
||||
});
|
||||
@@ -546,7 +556,8 @@ describe('Controller', () => {
|
||||
data.writeEmptyState();
|
||||
await controller.start();
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -556,8 +567,9 @@ describe('Controller', () => {
|
||||
data.removeState();
|
||||
await controller.start();
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON'});
|
||||
await controller.publishEntityState('bulb', {brightness: 200});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON'});
|
||||
await controller.publishEntityState(device, {brightness: 200});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(2);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({state: "ON"}), {"qos": 0, "retain": true}, expect.any(Function));
|
||||
@@ -580,22 +592,15 @@ describe('Controller', () => {
|
||||
data.writeEmptyState();
|
||||
await controller.start();
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON'});
|
||||
await controller.publishEntityState('bulb', {brightness: 200});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON'});
|
||||
await controller.publishEntityState(device, {brightness: 200});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(2);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"state":"ON"}), {"qos": 0, "retain": true}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"brightness":200}), {"qos": 0, "retain": true}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Publish should not do anything for unknown entity', async () => {
|
||||
await controller.start();
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb-unknown', {brightness: 200});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should start when state is corrupted', async () => {
|
||||
fs.writeFileSync(path.join(data.mockDir, 'state.json'), 'corrupted');
|
||||
await controller.start();
|
||||
@@ -609,7 +614,7 @@ describe('Controller', () => {
|
||||
await flushPromises();
|
||||
expect(MQTT.connect).toHaveBeenCalledTimes(1);
|
||||
const expected = {
|
||||
"will": { "payload": "offline", "retain": false, "topic": "zigbee2mqtt/bridge/state" },
|
||||
"will": { "payload": "offline", "retain": false, "topic": "zigbee2mqtt/bridge/state", "qos": 1 },
|
||||
}
|
||||
expect(MQTT.connect).toHaveBeenCalledWith("mqtt://localhost", expected);
|
||||
});
|
||||
@@ -623,4 +628,45 @@ describe('Controller', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/fo', 'bar', { retain: false, qos: 0 }, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should disable legacy options on new network start', async () => {
|
||||
expect(settings.get().advanced.homeassistant_legacy_entity_attributes).toBeTruthy();
|
||||
expect(settings.get().advanced.legacy_api).toBeTruthy();
|
||||
zigbeeHerdsman.start.mockReturnValueOnce('reset');
|
||||
await controller.start();
|
||||
expect(settings.get().advanced.homeassistant_legacy_entity_attributes).toBeFalsy();
|
||||
expect(settings.get().advanced.legacy_api).toBeFalsy();
|
||||
});
|
||||
|
||||
it('Should publish last seen changes', async () => {
|
||||
settings.set(['advanced', 'last_seen'], 'epoch');
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device, reason: 'deviceAnnounce'});
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/remote', stringify({"brightness":255,"last_seen":1000}), { qos: 0, retain: true }, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should not publish last seen changes when reason is messageEmitted', async () => {
|
||||
settings.set(['advanced', 'last_seen'], 'epoch');
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device, reason: 'messageEmitted'});
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Ignore messages from coordinator', async () => {
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/9218
|
||||
await controller.start();
|
||||
const device = zigbeeHerdsman.devices.coordinator;
|
||||
const payload = {device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10, cluster: 'genBasic', data: {modelId: device.modelID}};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(logger.debug).toHaveBeenCalledWith(`Received Zigbee message from 'Coordinator', type 'attributeReport', cluster 'genBasic', data '{"modelId":null}' from endpoint 1, ignoring since it is from coordinator`);
|
||||
});
|
||||
});
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
const logger = require('./stub/logger');
|
||||
const data = require('../lib/util/data.js');
|
||||
const data = require('../lib/util/data').default;
|
||||
const path = require('path');
|
||||
const tmp = require('tmp');
|
||||
const fs = require('fs');
|
||||
@@ -15,12 +15,12 @@ describe('Data', () => {
|
||||
it('Should return correct path when ZIGBEE2MQTT_DATA set', () => {
|
||||
const expected = tmp.dirSync().name;
|
||||
process.env.ZIGBEE2MQTT_DATA = expected;
|
||||
data._reload();
|
||||
data.testingOnlyReload();
|
||||
const actual = data.getPath();
|
||||
expect(actual).toBe(expected);
|
||||
expect(data.joinPath('test')).toStrictEqual(path.join(expected, 'test'));
|
||||
delete process.env.ZIGBEE2MQTT_DATA;
|
||||
data._reload();
|
||||
data.testingOnlyReload();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const flushPromises = () => new Promise(setImmediate);
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -41,20 +41,32 @@ jest.mock(
|
||||
describe('Loads external converters', () => {
|
||||
let controller;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useRealTimers();
|
||||
let resetExtension = async () => {
|
||||
await controller.enableDisableExtension(false, 'ExternalConverters');
|
||||
await controller.enableDisableExtension(true, 'ExternalConverters');
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
data.writeDefaultConfiguration();
|
||||
data.writeEmptyState();
|
||||
settings._reRead();
|
||||
settings.reRead();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
await resetExtension();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('Does not load external converters', async () => {
|
||||
settings.set(['external_converters'], []);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
expect(zigbeeHerdsmanConverters.addDeviceDefinition).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
@@ -62,9 +74,7 @@ describe('Loads external converters', () => {
|
||||
fs.copyFileSync(path.join(__dirname, 'assets', 'mock-external-converter.js'), path.join(data.mockDir, 'mock-external-converter.js'));
|
||||
const devicesCount = zigbeeHerdsman.devices.lenght;
|
||||
settings.set(['external_converters'], ['mock-external-converter.js']);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
expect(zigbeeHerdsmanConverters.addDeviceDefinition).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsmanConverters.addDeviceDefinition).toHaveBeenCalledWith({
|
||||
mock: true,
|
||||
@@ -82,9 +92,7 @@ describe('Loads external converters', () => {
|
||||
fs.copyFileSync(path.join(__dirname, 'assets', 'mock-external-converter-multiple.js'), path.join(data.mockDir, 'mock-external-converter-multiple.js'));
|
||||
const devicesCount = zigbeeHerdsman.devices.lenght;
|
||||
settings.set(['external_converters'], ['mock-external-converter-multiple.js']);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
expect(zigbeeHerdsmanConverters.addDeviceDefinition).toHaveBeenCalledTimes(2);
|
||||
expect(zigbeeHerdsmanConverters.addDeviceDefinition).toHaveBeenNthCalledWith(1, {
|
||||
mock: 1,
|
||||
@@ -110,9 +118,7 @@ describe('Loads external converters', () => {
|
||||
|
||||
it('Loads external converters from package', async () => {
|
||||
settings.set(['external_converters'], ['mock-external-converter-module']);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
expect(zigbeeHerdsmanConverters.addDeviceDefinition).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsmanConverters.addDeviceDefinition).toHaveBeenCalledWith({
|
||||
mock: true
|
||||
@@ -121,9 +127,7 @@ describe('Loads external converters', () => {
|
||||
|
||||
it('Loads multiple external converters from package', async () => {
|
||||
settings.set(['external_converters'], ['mock-multiple-external-converter-module']);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
expect(zigbeeHerdsmanConverters.addDeviceDefinition).toHaveBeenCalledTimes(2);
|
||||
expect(zigbeeHerdsmanConverters.addDeviceDefinition).toHaveBeenNthCalledWith(1, {
|
||||
mock: 1
|
||||
|
||||
@@ -3,11 +3,11 @@ const logger = require('./stub/logger');
|
||||
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const path = require('path');
|
||||
const rimraf = require('rimraf');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const flushPromises = () => new Promise(setImmediate);
|
||||
const tmp = require('tmp');
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const mocksClear = [
|
||||
zigbeeHerdsman.permitJoin, MQTT.end, zigbeeHerdsman.stop, logger.debug,
|
||||
MQTT.publish, MQTT.connect, zigbeeHerdsman.devices.bulb_color.removeFromNetwork,
|
||||
@@ -20,20 +20,32 @@ const unlinkSyncSpy = jest.spyOn(fs, 'unlinkSync');
|
||||
|
||||
describe('User extensions', () => {
|
||||
let controller;
|
||||
let mockExit;
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
zigbeeHerdsman.returnDevices.splice(0);
|
||||
mockExit = jest.fn();
|
||||
controller = new Controller(jest.fn(), mockExit);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
data.writeDefaultConfiguration();
|
||||
settings._reRead();
|
||||
settings.reRead();
|
||||
data.writeDefaultState();
|
||||
});
|
||||
afterEach(() => {
|
||||
const extensionPath = path.join(data.mockDir, 'extension');
|
||||
fs.rmdirSync(extensionPath, {recursive: true});
|
||||
rimraf.sync(extensionPath);
|
||||
})
|
||||
|
||||
it('Load user extension', async () => {
|
||||
|
||||
+26
-16
@@ -5,7 +5,7 @@ const MQTT = require('./stub/mqtt');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const flushPromises = () => new Promise(setImmediate);
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
jest.spyOn(process, 'exit').mockImplementation(() => {});
|
||||
|
||||
@@ -31,6 +31,7 @@ const mockWS = {
|
||||
cb(mockWSocket)
|
||||
}),
|
||||
emit: jest.fn(),
|
||||
close: jest.fn(),
|
||||
},
|
||||
variables: {},
|
||||
events: {},
|
||||
@@ -49,7 +50,7 @@ jest.mock('http', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("serve-static", () =>
|
||||
jest.mock("connect-gzip-static", () =>
|
||||
jest.fn().mockImplementation((path) => {
|
||||
mockNodeStatic.variables.path = path
|
||||
return mockNodeStatic.implementation
|
||||
@@ -70,16 +71,24 @@ jest.mock('ws', () => ({
|
||||
describe('Frontend', () => {
|
||||
let controller;
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockWS.implementation.clients = [];
|
||||
data.writeDefaultConfiguration();
|
||||
data.writeDefaultState();
|
||||
settings._reRead();
|
||||
settings.reRead();
|
||||
settings.set(['frontend'], {port: 8081, host: "127.0.0.1"});
|
||||
settings.set(['homeassistant'], true);
|
||||
zigbeeHerdsman.devices.bulb.linkquality = 10;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(async() => {
|
||||
delete zigbeeHerdsman.devices.bulb.linkquality;
|
||||
});
|
||||
@@ -92,15 +101,16 @@ describe('Frontend', () => {
|
||||
|
||||
const mockWSClient = {
|
||||
implementation: {
|
||||
close: jest.fn(),
|
||||
terminate: jest.fn(),
|
||||
send: jest.fn(),
|
||||
},
|
||||
events: {},
|
||||
};
|
||||
mockWS.implementation.clients.push(mockWSClient.implementation);
|
||||
await controller.stop();
|
||||
expect(mockWSClient.implementation.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockWSClient.implementation.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(mockHTTP.implementation.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockWS.implementation.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Websocket interaction', async () => {
|
||||
@@ -120,33 +130,33 @@ describe('Frontend', () => {
|
||||
await mockWS.events.connection(mockWSClient.implementation);
|
||||
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'bridge/state', payload: 'online'}));
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic:"remote", payload:{brightness:255, update:{state: "idle"}, update_available: false}}));
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic:"remote", payload:{brightness:255}}));
|
||||
|
||||
// Message
|
||||
MQTT.publish.mockClear();
|
||||
mockWSClient.implementation.send.mockClear();
|
||||
mockWSClient.events.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}))
|
||||
mockWSClient.events.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}), false)
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(4);
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color',
|
||||
stringify({state: 'ON'}),
|
||||
stringify({state: 'ON', linkquality: null, update_available: null}),
|
||||
{ retain: false, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
mockWSClient.events.message(undefined);
|
||||
mockWSClient.events.message("");
|
||||
mockWSClient.events.message(null);
|
||||
mockWSClient.events.message(undefined, false);
|
||||
mockWSClient.events.message("", false);
|
||||
mockWSClient.events.message(null, false);
|
||||
await flushPromises();
|
||||
|
||||
// Received message on socket
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledTimes(4);
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'bulb_color', payload: {state: 'ON'}}));
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledTimes(1);
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'bulb_color', payload: {state: 'ON', linkquality: null, update_available: null}}));
|
||||
|
||||
// Shouldnt set when not ready
|
||||
mockWSClient.implementation.send.mockClear();
|
||||
mockWSClient.implementation.readyState = 'close';
|
||||
mockWSClient.events.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}))
|
||||
mockWSClient.events.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}), false)
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Send last seen on connect
|
||||
@@ -155,7 +165,7 @@ describe('Frontend', () => {
|
||||
settings.set(['advanced'], {last_seen: 'ISO_8601'});
|
||||
mockWS.implementation.clients.push(mockWSClient.implementation);
|
||||
await mockWS.events.connection(mockWSClient.implementation);
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic:"remote", payload:{brightness:255, last_seen: "1970-01-01T00:00:01.000Z", update:{state: "idle"}, update_available: false}}));
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic:"remote", payload:{brightness:255, last_seen: "1970-01-01T00:00:01.000Z"}}));
|
||||
});
|
||||
|
||||
it('onReques/onUpgrade', async () => {
|
||||
|
||||
+171
-96
@@ -9,30 +9,46 @@ zigbeeHerdsman.returnDevices.push('0x000b57fffec6a5b2');
|
||||
zigbeeHerdsman.returnDevices.push('0x0017880104e45542');
|
||||
zigbeeHerdsman.returnDevices.push('0x000b57fffec6a5b4');
|
||||
zigbeeHerdsman.returnDevices.push('0x000b57fffec6a5b7');
|
||||
zigbeeHerdsman.returnDevices.push('0x0017880104e45724');
|
||||
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const Controller = require('../lib/controller');
|
||||
const flushPromises = () => new Promise(setImmediate);
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const settings = require('../lib/util/settings');
|
||||
|
||||
describe('Groups', () => {
|
||||
let controller;
|
||||
|
||||
beforeEach(() => {
|
||||
data.writeEmptyState();
|
||||
let resetExtension = async () => {
|
||||
await controller.enableDisableExtension(false, 'Groups');
|
||||
await controller.enableDisableExtension(true, 'Groups');
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
Object.values(zigbeeHerdsman.groups).forEach((g) => g.members = []);
|
||||
data.writeDefaultConfiguration();
|
||||
settings._reRead();
|
||||
settings.reRead();
|
||||
MQTT.publish.mockClear();
|
||||
zigbeeHerdsman.groups.gledopto_group.command.mockClear();
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.__clearStore__();
|
||||
controller.state.state = {};
|
||||
})
|
||||
|
||||
it('Apply group updates add', async () => {
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['bulb', 'bulb_color']}});
|
||||
zigbeeHerdsman.groups.group_1.members.push(zigbeeHerdsman.devices.bulb.getEndpoint(1))
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([
|
||||
zigbeeHerdsman.devices.bulb.getEndpoint(1),
|
||||
zigbeeHerdsman.devices.bulb_color.getEndpoint(1)
|
||||
@@ -44,8 +60,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false,}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([]);
|
||||
});
|
||||
|
||||
@@ -56,8 +71,7 @@ describe('Groups', () => {
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false,}});
|
||||
logger.error.mockClear();
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
expect(logger.error).toHaveBeenCalledWith(`Failed to remove 'bulb_color' from 'group_1'`);
|
||||
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([endpoint]);
|
||||
});
|
||||
@@ -68,32 +82,28 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'3': {friendly_name: 'group_3', retain: false, devices: [device.ieeeAddr]}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('Add non standard endpoint to group with name', async () => {
|
||||
const QBKG03LM = zigbeeHerdsman.devices.QBKG03LM;
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['0x0017880104e45542/right']}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([QBKG03LM.getEndpoint(3)]);
|
||||
});
|
||||
|
||||
it('Add non standard endpoint to group with number', async () => {
|
||||
const QBKG03LM = zigbeeHerdsman.devices.QBKG03LM;
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['wall_switch_double/2']}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([QBKG03LM.getEndpoint(2)]);
|
||||
});
|
||||
|
||||
it('Shouldnt crash on non-existing devices', async () => {
|
||||
logger.error.mockClear();
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['not_existing_bla']}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([]);
|
||||
expect(logger.error).toHaveBeenCalledWith("Cannot find 'not_existing_bla' of group 'group_1'");
|
||||
});
|
||||
@@ -101,8 +111,7 @@ describe('Groups', () => {
|
||||
it('Should resolve device friendly names', async () => {
|
||||
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'friendly_name'], 'bulb_friendly_name');
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['bulb_friendly_name', 'bulb_color']}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([
|
||||
zigbeeHerdsman.devices.bulb.getEndpoint(1),
|
||||
zigbeeHerdsman.devices.bulb_color.getEndpoint(1)
|
||||
@@ -115,8 +124,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: []}});
|
||||
expect(group.members.length).toBe(0);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/group/group_1/add', 'bulb_color');
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([endpoint]);
|
||||
@@ -130,8 +138,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups["group/with/slashes"];
|
||||
settings.set(['groups'], {'99': {friendly_name: 'group/with/slashes', retain: false, devices: []}});
|
||||
expect(group.members.length).toBe(0);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/group/group/with/slashes/add', 'bulb_color');
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([endpoint]);
|
||||
@@ -144,8 +151,7 @@ describe('Groups', () => {
|
||||
const endpoint = device.getEndpoint(3);
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
expect(group.members.length).toBe(0);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/add', 'wall_switch_double/right');
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([endpoint]);
|
||||
@@ -157,8 +163,7 @@ describe('Groups', () => {
|
||||
const endpoint = device.getEndpoint(3);
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
expect(group.members.length).toBe(0);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/add', 'wall_switch_double/right');
|
||||
await flushPromises();
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/add', '0x0017880104e45542/3');
|
||||
@@ -173,8 +178,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', 'bulb_color');
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
@@ -188,8 +192,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['dummy']}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', 'bulb_color');
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
@@ -202,8 +205,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/right`]}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', '0x0017880104e45542/3');
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
@@ -216,8 +218,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`0x0017880104e45542/right`]}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', 'wall_switch_double/3');
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
@@ -230,8 +231,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', '0x0017880104e45542/right');
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
@@ -244,8 +244,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/group/remove_all', '0x0017880104e45542/right');
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
@@ -259,8 +258,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove_all', '0x0017880104e45542/right');
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
@@ -268,8 +266,7 @@ describe('Groups', () => {
|
||||
});
|
||||
|
||||
it('Legacy api: Log when adding to non-existing group', async () => {
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
logger.error.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/group/group_1_not_existing/add', 'bulb_color');
|
||||
await flushPromises();
|
||||
@@ -277,8 +274,7 @@ describe('Groups', () => {
|
||||
});
|
||||
|
||||
it('Legacy api: Log when adding to non-existing device', async () => {
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
logger.error.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/group/group_1/add', 'bulb_color_not_existing');
|
||||
await flushPromises();
|
||||
@@ -291,8 +287,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
const payload = {data: {onOff: 1}, cluster: 'genOnOff', device, endpoint, type: 'attributeReport', linkquality: 10};
|
||||
@@ -308,18 +303,19 @@ describe('Groups', () => {
|
||||
const device1 = zigbeeHerdsman.devices.bulb_2;
|
||||
const device2 = zigbeeHerdsman.devices.bulb_color_2;
|
||||
const group = zigbeeHerdsman.groups.group_tradfri_remote;
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
await zigbeeHerdsman.events.message({data: {onOff: 1}, cluster: 'genOnOff', device: device1, endpoint: device1.getEndpoint(1), type: 'attributeReport', linkquality: 10});
|
||||
await zigbeeHerdsman.events.message({data: {onOff: 1}, cluster: 'genOnOff', device: device2, endpoint: device2.getEndpoint(1), type: 'attributeReport', linkquality: 10});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(4);
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(6);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_tradfri_remote", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_2", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color_2", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_with_tradfri", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/ha_discovery_group", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/switch_group", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should publish state change of all members when a group changes its state', async () => {
|
||||
@@ -328,8 +324,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
@@ -346,8 +341,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'ON'}));
|
||||
@@ -371,14 +365,40 @@ describe('Groups', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should publish state of device with endpoint name', async () => {
|
||||
const group = zigbeeHerdsman.groups.gledopto_group;
|
||||
await resetExtension();
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
await MQTT.events.message('zigbee2mqtt/gledopto_group/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(2);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/GLEDOPTO_2ID", stringify({"state_cct":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/gledopto_group", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
expect(group.command).toHaveBeenCalledTimes(1);
|
||||
expect(group.command).toHaveBeenCalledWith("genOnOff", "on", {}, {});
|
||||
});
|
||||
|
||||
it('Should publish state of group when specific state of specific endpoint is changed', async () => {
|
||||
const group = zigbeeHerdsman.groups.gledopto_group;
|
||||
await resetExtension();
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
await MQTT.events.message('zigbee2mqtt/GLEDOPTO_2ID/set', stringify({state_cct: 'ON'}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(2);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/GLEDOPTO_2ID", stringify({"state_cct":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/gledopto_group", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
expect(group.command).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should publish state change of all members when a group changes its state, filtered', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, filtered_attributes: ['brightness'], devices: [device.ieeeAddr]}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON', brightness: 100}));
|
||||
@@ -394,8 +414,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', devices: [device.ieeeAddr], optimistic: false, retain: false}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
const payload = {data: {onOff: 1}, cluster: 'genOnOff', device, endpoint, type: 'attributeReport', linkquality: 10};
|
||||
@@ -416,8 +435,7 @@ describe('Groups', () => {
|
||||
'2': {friendly_name: 'group_2', retain: false, devices: [device.ieeeAddr]},
|
||||
'3': {friendly_name: 'group_3', retain: false, devices: []}
|
||||
});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
@@ -439,8 +457,7 @@ describe('Groups', () => {
|
||||
settings.set(['groups'], {
|
||||
'1': {friendly_name: 'group_1', devices: [device_1.ieeeAddr, device_2.ieeeAddr], retain: false}
|
||||
});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
|
||||
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
@@ -464,8 +481,7 @@ describe('Groups', () => {
|
||||
'1': {friendly_name: 'group_1', devices: [device_1.ieeeAddr, device_2.ieeeAddr], retain: false},
|
||||
'2': {friendly_name: 'group_2', retain: false, devices: [device_1.ieeeAddr]},
|
||||
});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
|
||||
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
@@ -489,8 +505,7 @@ describe('Groups', () => {
|
||||
settings.set(['groups'], {
|
||||
'1': {friendly_name: 'group_1', devices: [device_1.ieeeAddr, device_2.ieeeAddr], retain: false}
|
||||
});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
|
||||
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
@@ -505,6 +520,33 @@ describe('Groups', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"state":"OFF"}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should only update group state with changed properties', async () => {
|
||||
const device_1 = zigbeeHerdsman.devices.bulb_color;
|
||||
const device_2 = zigbeeHerdsman.devices.bulb;
|
||||
const endpoint_1 = device_1.getEndpoint(1);
|
||||
const endpoint_2 = device_2.getEndpoint(1);
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint_1);
|
||||
group.members.push(endpoint_2);
|
||||
settings.set(['groups'], {
|
||||
'1': {friendly_name: 'group_1', devices: [device_1.ieeeAddr, device_2.ieeeAddr], retain: false}
|
||||
});
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'OFF', color_temp: 200}));
|
||||
await MQTT.events.message('zigbee2mqtt/bulb/set', stringify({state: 'ON', color_temp: 250}));
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({color_temp: 300}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(3);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"color_mode": "color_temp","color_temp":300,"state":"OFF"}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"color_mode": "color_temp","color_temp":300,"state":"ON"}), {"retain": true, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"color_mode": "color_temp","color_temp":300,"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should publish state change off even when missing current state', async () => {
|
||||
const device_1 = zigbeeHerdsman.devices.bulb_color;
|
||||
const device_2 = zigbeeHerdsman.devices.bulb;
|
||||
@@ -516,8 +558,7 @@ describe('Groups', () => {
|
||||
settings.set(['groups'], {
|
||||
'1': {friendly_name: 'group_1', devices: [device_1.ieeeAddr, device_2.ieeeAddr], retain: false}
|
||||
});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
|
||||
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
@@ -538,17 +579,16 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: []}});
|
||||
expect(group.members.length).toBe(0);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', device: 'bulb_color'}));
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', stringify({transaction: "123", group: 'group_1', device: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([endpoint]);
|
||||
expect(settings.getGroup('group_1').devices).toStrictEqual([`${device.ieeeAddr}/1`]);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({"data":{"device":"bulb_color","group":"group_1"},"status":"ok"}),
|
||||
stringify({"data":{"device":"bulb_color","group":"group_1"},"transaction": "123", "status":"ok"}),
|
||||
{retain: false, qos: 0}, expect.any(Function)
|
||||
);
|
||||
});
|
||||
@@ -559,7 +599,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: []}});
|
||||
expect(group.members.length).toBe(0);
|
||||
await controller.start();
|
||||
await resetExtension();
|
||||
endpoint.addToGroup.mockImplementationOnce(() => {throw new Error('timeout')});
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
@@ -581,8 +621,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups["group/with/slashes"];
|
||||
settings.set(['groups'], {'99': {friendly_name: 'group/with/slashes', retain: false, devices: []}});
|
||||
expect(group.members.length).toBe(0);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group/with/slashes', device: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
@@ -601,8 +640,7 @@ describe('Groups', () => {
|
||||
const endpoint = device.getEndpoint(3);
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
expect(group.members.length).toBe(0);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', device: 'wall_switch_double/right'}));
|
||||
await flushPromises();
|
||||
@@ -621,8 +659,7 @@ describe('Groups', () => {
|
||||
const endpoint = device.getEndpoint(3);
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
expect(group.members.length).toBe(0);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', device: 'wall_switch_double/right'}));
|
||||
await flushPromises();
|
||||
@@ -644,8 +681,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
@@ -659,14 +695,33 @@ describe('Groups', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('Remove from group via MQTT keeping device reporting', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}});
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: 'bulb_color', skip_disable_reporting: true}));
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
expect(settings.getGroup('group_1').devices).toStrictEqual([]);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/remove',
|
||||
stringify({"data":{"device":"bulb_color","group":"group_1"},"status":"ok"}),
|
||||
{retain: false, qos: 0}, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Remove from group via MQTT when in zigbee but not in settings', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['dummy']}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
@@ -686,8 +741,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/right`]}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: '0x0017880104e45542/3'}));
|
||||
await flushPromises();
|
||||
@@ -707,8 +761,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`0x0017880104e45542/right`]}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: 'wall_switch_double/3'}));
|
||||
await flushPromises();
|
||||
@@ -728,8 +781,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: '0x0017880104e45542/right'}));
|
||||
await flushPromises();
|
||||
@@ -749,8 +801,7 @@ describe('Groups', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove_all', stringify({device: '0x0017880104e45542/right'}));
|
||||
await flushPromises();
|
||||
@@ -765,8 +816,7 @@ describe('Groups', () => {
|
||||
});
|
||||
|
||||
it('Error when adding to non-existing group', async () => {
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
logger.error.mockClear();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1_not_existing', device: 'bulb_color'}));
|
||||
@@ -780,8 +830,7 @@ describe('Groups', () => {
|
||||
});
|
||||
|
||||
it('Error when adding to non-existing device', async () => {
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
logger.error.mockClear();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', device: 'bulb_color_not_existing'}));
|
||||
@@ -793,4 +842,30 @@ describe('Groups', () => {
|
||||
{retain: false, qos: 0}, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Should only include relevant properties when publishing member states', async () => {
|
||||
const bulbColor = zigbeeHerdsman.devices.bulb_color;
|
||||
const bulbColorTemp = zigbeeHerdsman.devices.bulb;
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
group.members.push(bulbColor.getEndpoint(1));
|
||||
group.members.push(bulbColorTemp.getEndpoint(1));
|
||||
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [bulbColor.ieeeAddr, bulbColorTemp.ieeeAddr]}});
|
||||
await resetExtension();
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({color_temp: 50}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(3);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"color_mode":"color_temp","color_temp":50}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"color_mode":"color_temp","color_temp":50}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"color_mode":"color_temp","color_temp":50}), {"retain": true, qos: 0}, expect.any(Function));
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({color: {x: 0.5, y: 0.3}}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(3);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"color":{"x":0.5,"y":0.3},"color_mode":"xy","color_temp":548}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"color":{"x":0.5,"y":0.3},"color_mode":"xy","color_temp":548}), {"retain": false, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"color_mode":"color_temp","color_temp":548}), {"retain": true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
+443
-195
File diff suppressed because it is too large
Load Diff
@@ -7,30 +7,35 @@ const path = require('path');
|
||||
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
|
||||
const settings = require('../../lib/util/settings');
|
||||
const Controller = require('../../lib/controller');
|
||||
const flushPromises = () => new Promise(setImmediate);
|
||||
const flushPromises = require('../lib/flushPromises');
|
||||
|
||||
|
||||
describe('Bridge legacy', () => {
|
||||
let controller;
|
||||
let version;
|
||||
|
||||
beforeAll(async () => {
|
||||
this.version = await require('../../lib/util/utils').getZigbee2mqttVersion();
|
||||
jest.useFakeTimers();
|
||||
version = await require('../../lib/util/utils').default.getZigbee2MQTTVersion();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings._reRead();
|
||||
data.writeDefaultState();
|
||||
settings.reRead();
|
||||
logger.info.mockClear();
|
||||
logger.warn.mockClear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('Should publish bridge configuration on startup', async () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/config',
|
||||
stringify({"version":this.version.version,"commit":this.version.commitHash,"coordinator":{"type":"z-Stack","meta":{"version":1, "revision": 20190425}},"network":{"panID":5674,"extendedPanID":[0,11,22],"channel":15},"log_level":'info',"permit_join":false}),
|
||||
stringify({"version":version.version,"commit":version.commitHash,"coordinator":{"type":"z-Stack","meta":{"version":1, "revision": 20190425}},"network":{"panID":5674,"extendedPanID":[0,11,22],"channel":15},"log_level":'info',"permit_join":false}),
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
@@ -84,37 +89,37 @@ describe('Bridge legacy', () => {
|
||||
it('Should allow changing device options', async () => {
|
||||
const bulb_color = zigbeeHerdsman.devices.bulb_color;
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual(
|
||||
{"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "retain": false}
|
||||
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": false}
|
||||
);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', options: {retain: true}}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual(
|
||||
{"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "retain": true}
|
||||
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": true}
|
||||
);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', optionswrong: {retain: true}}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual(
|
||||
{"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "retain": true}
|
||||
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": true}
|
||||
);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', "{friendly_name: 'bulb_color'malformed: {retain: true}}");
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual(
|
||||
{"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "retain": true}
|
||||
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": true}
|
||||
);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', options: {random_setting: true}}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual(
|
||||
{"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "random_setting": true, "retain": true}
|
||||
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "random_setting": true, "retain": true}
|
||||
);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', options: {options: {random_1: true}}}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual(
|
||||
{"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "random_setting": true, "retain": true, options: {random_1: true}}
|
||||
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "random_setting": true, "retain": true, options: {random_1: true}}
|
||||
);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', options: {options: {random_2: false}}}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual(
|
||||
{"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "random_setting": true, "retain": true, options: {random_1: true, random_2: false}}
|
||||
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "random_setting": true, "retain": true, options: {random_1: true, random_2: false}}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -188,13 +193,13 @@ describe('Bridge legacy', () => {
|
||||
await flushPromises();
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
const payload = JSON.parse(MQTT.publish.mock.calls[0][1]);
|
||||
expect(payload).toStrictEqual({"message":[{"ID":1,"devices":[],"friendly_name":"group_1","optimistic":true,"retain":false},{"ID":2,"devices":[],"friendly_name":"group_2","optimistic":true,"retain":false},{"ID":11,"devices":["bulb_2"],"friendly_name":"group_with_tradfri","optimistic":true,"retain":false},{"ID":12,"devices":["TS0601_thermostat"],"friendly_name":"thermostat_group","optimistic":true,"retain":false},{"ID":14,"devices":["power_plug"],"friendly_name":"switch_group","optimistic":true,"retain":false},{"ID":15071,"devices":["bulb_color_2","bulb_2"],"friendly_name":"group_tradfri_remote","optimistic":true,"retain":false}],"type":"groups"});
|
||||
expect(payload).toStrictEqual({"message":[{"ID":1,"devices":[],"friendly_name":"group_1","retain":false},{"ID":2,"devices":[],"friendly_name":"group_2","retain":false},{"ID":9,"devices":["bulb_color_2","bulb_2","wall_switch_double/right"],"friendly_name":"ha_discovery_group"},{"ID":11,"devices":["bulb_2"],"friendly_name":"group_with_tradfri","retain":false},{"ID":12,"devices":["TS0601_thermostat"],"friendly_name":"thermostat_group","retain":false},{"ID":14,"devices":["power_plug","bulb_2"],"friendly_name":"switch_group","retain":false},{"ID":21,"devices":["GLEDOPTO_2ID/cct"],"friendly_name":"gledopto_group"},{"ID":15071,"devices":["bulb_color_2","bulb_2"],"friendly_name":"group_tradfri_remote","retain":false}],"type":"groups"});
|
||||
});
|
||||
|
||||
it('Should allow rename devices', async () => {
|
||||
const bulb_color2 = {"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color2", "friendly_name": "bulb_color2", "retain": false};
|
||||
const bulb_color2 = {"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color2", "retain": false};
|
||||
MQTT.publish.mockClear();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "retain": false});
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": false});
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/rename', stringify({old: 'bulb_color', new: 'bulb_color2'}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual(null);
|
||||
@@ -221,10 +226,10 @@ describe('Bridge legacy', () => {
|
||||
|
||||
it('Should allow rename groups', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
expect(settings.getGroup(1)).toStrictEqual({"ID": 1, devices: [], friendlyName: "group_1", "friendly_name": "group_1", optimistic: true, retain: false});
|
||||
expect(settings.getGroup(1)).toStrictEqual({"ID": 1, devices: [], "friendly_name": "group_1", retain: false});
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/rename', stringify({old: 'group_1', new: 'group_1_renamed'}));
|
||||
await flushPromises();
|
||||
expect(settings.getGroup(1)).toStrictEqual({"ID": 1, devices: [], friendlyName: "group_1_renamed", "friendly_name": "group_1_renamed", optimistic: true, retain: false});
|
||||
expect(settings.getGroup(1)).toStrictEqual({"ID": 1, devices: [], "friendly_name": "group_1_renamed", retain: false});
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'group_renamed', message: {from: 'group_1', to: 'group_1_renamed'}}),
|
||||
@@ -238,10 +243,10 @@ describe('Bridge legacy', () => {
|
||||
const payload = {device};
|
||||
await zigbeeHerdsman.events.deviceJoined(payload);
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendlyName).toStrictEqual('bulb');
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendly_name).toStrictEqual('bulb');
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/rename_last', 'bulb_new_name');
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendlyName).toStrictEqual('bulb_new_name');
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendly_name).toStrictEqual('bulb_new_name');
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'device_renamed', message: {from: 'bulb', to: 'bulb_new_name'}}),
|
||||
@@ -254,10 +259,10 @@ describe('Bridge legacy', () => {
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendlyName).toStrictEqual('bulb');
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendly_name).toStrictEqual('bulb');
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/rename_last', 'bulb_new_name');
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendlyName).toStrictEqual('bulb');
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendly_name).toStrictEqual('bulb');
|
||||
});
|
||||
|
||||
it('Should allow to add groups', async () => {
|
||||
@@ -270,7 +275,7 @@ describe('Bridge legacy', () => {
|
||||
{qos: 0, retain: false},
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(settings.getGroup('new_group')).toStrictEqual({"ID": 3, "friendlyName": "new_group", "friendly_name": "new_group", devices: [], optimistic: true});
|
||||
expect(settings.getGroup('new_group')).toStrictEqual({"ID": 3, "friendly_name": "new_group", devices: []});
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(3);
|
||||
});
|
||||
@@ -279,7 +284,7 @@ describe('Bridge legacy', () => {
|
||||
zigbeeHerdsman.createGroup.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"friendly_name": "new_group"}');
|
||||
await flushPromises();
|
||||
expect(settings.getGroup('new_group')).toStrictEqual({"ID": 3, "friendlyName": "new_group", "friendly_name": "new_group", devices: [], optimistic: true});
|
||||
expect(settings.getGroup('new_group')).toStrictEqual({"ID": 3, "friendly_name": "new_group", devices: []});
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(3);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
@@ -294,7 +299,7 @@ describe('Bridge legacy', () => {
|
||||
zigbeeHerdsman.createGroup.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"friendly_name": "new_group", "id": 42}');
|
||||
await flushPromises();
|
||||
expect(settings.getGroup('new_group')).toStrictEqual({"ID": 42, "friendlyName": "new_group", "friendly_name": "new_group", devices: [], optimistic: true});
|
||||
expect(settings.getGroup('new_group')).toStrictEqual({"ID": 42, "friendly_name": "new_group", devices: []});
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(42);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
@@ -309,7 +314,7 @@ describe('Bridge legacy', () => {
|
||||
zigbeeHerdsman.createGroup.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"id": 42}');
|
||||
await flushPromises();
|
||||
expect(settings.getGroup('group_42')).toStrictEqual({"ID": 42, "friendlyName": "group_42", "friendly_name": "group_42", devices: [], optimistic: true});
|
||||
expect(settings.getGroup('group_42')).toStrictEqual({"ID": 42, "friendly_name": "group_42", devices: []});
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(42)
|
||||
});
|
||||
@@ -438,7 +443,7 @@ describe('Bridge legacy', () => {
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "retain": false})
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": false})
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -452,7 +457,7 @@ describe('Bridge legacy', () => {
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "retain": false})
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": false})
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
+26
-21
@@ -13,13 +13,14 @@ zigbeeHerdsman.returnDevices.push('0x90fd9ffffe4b64ax');
|
||||
const MQTT = require('../stub/mqtt');
|
||||
const settings = require('../../lib/util/settings');
|
||||
const Controller = require('../../lib/controller');
|
||||
const flushPromises = () => new Promise(setImmediate);
|
||||
const flushPromises = require('../lib/flushPromises');
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const mocksClear = [MQTT.publish, logger.warn, logger.debug];
|
||||
|
||||
describe('Report', () => {
|
||||
let controller;
|
||||
let extension;
|
||||
|
||||
function expectOnOffBrightnessColorReport(endpoint, colorXY) {
|
||||
const coordinatorEndpoint = zigbeeHerdsman.devices.coordinator.getEndpoint(1);
|
||||
@@ -59,7 +60,7 @@ describe('Report', () => {
|
||||
}
|
||||
}
|
||||
|
||||
mockClear = (device) => {
|
||||
const mockClear = (device) => {
|
||||
for (const endpoint of device.endpoints) {
|
||||
endpoint.read.mockClear();
|
||||
endpoint.write.mockClear();
|
||||
@@ -69,23 +70,34 @@ describe('Report', () => {
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings._reRead();
|
||||
data.writeEmptyState();
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
settings.set(['advanced', 'report'], true);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
extension = controller.extensions.find((e) => e.constructor.name === 'Report');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
extension.enabled = true;
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
for (const device of Object.values(zigbeeHerdsman.devices)) {
|
||||
mockClear(device);
|
||||
delete device.meta.reporting;
|
||||
}
|
||||
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
await flushPromises();
|
||||
extension.queue = new Set();
|
||||
extension.failed = new Set();
|
||||
await extension.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('Should configure reporting on startup', async () => {
|
||||
await extension.start();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
expectOnOffBrightnessColorReport(endpoint, true);
|
||||
@@ -96,10 +108,8 @@ describe('Report', () => {
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
delete device.meta.report;
|
||||
settings.set(['advanced', 'report'], false);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
extension.enabled = false;
|
||||
await extension.start();
|
||||
expect(device.meta.reporting).toBe(undefined);
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -108,11 +118,9 @@ describe('Report', () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
device.meta.reporting = 1;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
settings.set(['advanced', 'report'], false);
|
||||
extension.enabled = false;
|
||||
mockClear(device);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await extension.start();
|
||||
expectOnOffBrightnessColorReportDisabled(endpoint, true);
|
||||
});
|
||||
|
||||
@@ -131,7 +139,6 @@ describe('Report', () => {
|
||||
});
|
||||
|
||||
it('Should not configure reporting when still configuring', async () => {
|
||||
jest.useFakeTimers();
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
endpoint.bind.mockImplementationOnce(async () => await wait(1000));
|
||||
@@ -143,8 +150,6 @@ describe('Report', () => {
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
jest.runAllTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('Should not mark as configured when reporting setup fails', async () => {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
const globalSetImmediate = setImmediate;
|
||||
module.exports = () => new Promise(globalSetImmediate);
|
||||
+53
-20
@@ -14,7 +14,7 @@ describe('Logger', () => {
|
||||
jest.resetModules();
|
||||
settings = require('../lib/util/settings');
|
||||
settings.set(['advanced', 'log_directory'], dir.name + '/%TIMESTAMP%');
|
||||
settings._reRead();
|
||||
settings.reRead();
|
||||
stdOutWriteOriginal = console._stdout.write;
|
||||
console._stdout.write = () => {};
|
||||
});
|
||||
@@ -24,14 +24,14 @@ describe('Logger', () => {
|
||||
});
|
||||
|
||||
it('Create log directory', () => {
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
const dirs = fs.readdirSync(dir.name);
|
||||
expect(dirs.length).toBe(1);
|
||||
});
|
||||
|
||||
it('Should cleanup', () => {
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
@@ -48,7 +48,7 @@ describe('Logger', () => {
|
||||
})
|
||||
|
||||
it('Should not cleanup when there is no timestamp set', () => {
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
for (let i = 30; i < 40; i++) {
|
||||
fs.mkdirSync(path.join(dir.name, `log_${i}`));
|
||||
@@ -61,7 +61,7 @@ describe('Logger', () => {
|
||||
})
|
||||
|
||||
it('Set and get log level', () => {
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
logger.setLevel('debug');
|
||||
expect(logger.getLevel()).toBe('debug');
|
||||
@@ -73,24 +73,24 @@ describe('Logger', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const logger = require('../lib/util/logger.js');
|
||||
expect(logger.transports.length).toBe(2);
|
||||
const logger = require('../lib/util/logger').default;
|
||||
expect(logger.winston.transports.length).toBe(2);
|
||||
logger.addTransport(new DummyTransport());
|
||||
expect(logger.transports.length).toBe(3);
|
||||
expect(logger.winston.transports.length).toBe(3);
|
||||
});
|
||||
|
||||
it('Set and get log level warn <-> warning', () => {
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
logger.setLevel('warn');
|
||||
expect(logger.transports[0].level).toBe('warning');
|
||||
expect(logger.winston.transports[0].level).toBe('warning');
|
||||
expect(logger.getLevel()).toBe('warn');
|
||||
});
|
||||
|
||||
it('Logger should be console and file by default', () => {
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
const pipes = logger._readableState.pipes;
|
||||
const pipes = logger.winston._readableState.pipes;
|
||||
expect(pipes.length).toBe(2);
|
||||
expect(pipes[0].constructor.name).toBe('Console');
|
||||
expect(pipes[0].silent).toBe(false);
|
||||
@@ -100,9 +100,9 @@ describe('Logger', () => {
|
||||
|
||||
it('Logger can be file only', () => {
|
||||
settings.set(['advanced', 'log_output'], ['file']);
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
const pipes = logger._readableState.pipes;
|
||||
const pipes = logger.winston._readableState.pipes;
|
||||
expect(pipes.length).toBe(2);
|
||||
expect(pipes[0].constructor.name).toBe('Console');
|
||||
expect(pipes[0].silent).toBe(true);
|
||||
@@ -112,30 +112,63 @@ describe('Logger', () => {
|
||||
|
||||
it('Logger can be console only', () => {
|
||||
settings.set(['advanced', 'log_output'], ['console']);
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
const pipes = logger._readableState.pipes;
|
||||
const pipes = logger.winston._readableState.pipes;
|
||||
expect(pipes.constructor.name).toBe('Console');
|
||||
expect(pipes.silent).toBe(false);
|
||||
});
|
||||
|
||||
it('Logger can be nothing', () => {
|
||||
settings.set(['advanced', 'log_output'], []);
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
const pipes = logger._readableState.pipes;
|
||||
const pipes = logger.winston._readableState.pipes;
|
||||
expect(pipes.constructor.name).toBe('Console');
|
||||
expect(pipes.silent).toBe(true);
|
||||
});
|
||||
|
||||
it('Should allow to disable log rotation', () => {
|
||||
settings.set(['advanced', 'log_rotation'], false);
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
const pipes = logger._readableState.pipes;
|
||||
const pipes = logger.winston._readableState.pipes;
|
||||
expect(pipes[1].constructor.name).toBe('File');
|
||||
expect(pipes[1].maxFiles).toBeNull();
|
||||
expect(pipes[1].tailable).toBeFalsy();
|
||||
expect(pipes[1].maxsize).toBeNull();
|
||||
});
|
||||
|
||||
it('Should allow to symlink logs to current directory', () => {
|
||||
settings.set(['advanced', 'log_symlink_current'], true);
|
||||
let logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
expect(fs.readdirSync(dir.name).includes('current')).toBeTruthy()
|
||||
|
||||
jest.resetModules();
|
||||
logger = require('../lib/util/logger').default;
|
||||
});
|
||||
|
||||
it('Log', () => {
|
||||
const logger = require('../lib/util/logger').default;
|
||||
const warn = jest.spyOn(logger.winston, 'warning');
|
||||
logger.warn('warn');
|
||||
expect(warn).toHaveBeenCalledWith('warn');
|
||||
|
||||
const debug = jest.spyOn(logger.winston, 'debug');
|
||||
logger.debug('debug');
|
||||
expect(debug).toHaveBeenCalledWith('debug');
|
||||
|
||||
const warning = jest.spyOn(logger.winston, 'warning');
|
||||
logger.warning('warning');
|
||||
expect(warning).toHaveBeenCalledWith('warning');
|
||||
|
||||
const info = jest.spyOn(logger.winston, 'info');
|
||||
logger.info('info');
|
||||
expect(info).toHaveBeenCalledWith('info');
|
||||
|
||||
const error = jest.spyOn(logger.winston, 'error');
|
||||
logger.error('error');
|
||||
expect(error).toHaveBeenCalledWith('error');
|
||||
});
|
||||
});
|
||||
|
||||
+26
-21
@@ -16,9 +16,7 @@ zigbeeHerdsman.returnDevices.push(external_converter_device.ieeeAddr)
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const flushPromises = () => new Promise(setImmediate);
|
||||
Date.now = jest.fn()
|
||||
Date.now.mockReturnValue(10000);
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const mocksClear = [MQTT.publish, logger.warn, logger.debug];
|
||||
const setTimeoutNative = setTimeout;
|
||||
|
||||
@@ -26,8 +24,11 @@ describe('Networkmap', () => {
|
||||
let controller;
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
Date.now = jest.fn()
|
||||
Date.now.mockReturnValue(10000);
|
||||
data.writeDefaultConfiguration();
|
||||
settings._reRead();
|
||||
settings.reRead();
|
||||
data.writeEmptyState();
|
||||
fs.copyFileSync(path.join(__dirname, 'assets', 'mock-external-converter.js'), path.join(data.mockDir, 'mock-external-converter.js'));
|
||||
settings.set(['external_converters'], ['mock-external-converter.js']);
|
||||
@@ -50,6 +51,10 @@ describe('Networkmap', () => {
|
||||
global.setTimeout = setTimeoutNative;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function mock() {
|
||||
/**
|
||||
* Topology
|
||||
@@ -97,7 +102,7 @@ describe('Networkmap', () => {
|
||||
let call = MQTT.publish.mock.calls[0];
|
||||
expect(call[0]).toStrictEqual('zigbee2mqtt/bridge/networkmap/raw');
|
||||
|
||||
const expected = {"links":[{"depth":1,"linkquality":120,"lqi":120,"relationship":2,"routes":[],"source":{"ieeeAddr":"0x000b57fffec6a5b3","networkAddress":40399},"sourceIeeeAddr":"0x000b57fffec6a5b3","sourceNwkAddr":40399,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":1,"linkquality":92,"lqi":92,"relationship":2,"routes":[{"destinationAddress":6540,"nextHop":40369,"status":"ACTIVE"}],"source":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"sourceIeeeAddr":"0x000b57fffec6a5b2","sourceNwkAddr":40369,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":1,"linkquality":92,"lqi":92,"relationship":2,"routes":[],"source":{"ieeeAddr":"0x0017880104e45511","networkAddress":1114},"sourceIeeeAddr":"0x0017880104e45511","sourceNwkAddr":1114,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":2,"linkquality":110,"lqi":110,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x000b57fffec6a5b3","networkAddress":40399},"sourceIeeeAddr":"0x000b57fffec6a5b3","sourceNwkAddr":40399,"target":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"targetIeeeAddr":"0x000b57fffec6a5b2"},{"depth":2,"linkquality":100,"lqi":100,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x0017880104e45559","networkAddress":6540},"sourceIeeeAddr":"0x0017880104e45559","sourceNwkAddr":6540,"target":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"targetIeeeAddr":"0x000b57fffec6a5b2"},{"depth":2,"linkquality":130,"lqi":130,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x0017880104e45521","networkAddress":6538},"sourceIeeeAddr":"0x0017880104e45521","sourceNwkAddr":6538,"target":{"ieeeAddr":"0x0017880104e45559","networkAddress":6540},"targetIeeeAddr":"0x0017880104e45559"}],"nodes":[{"definition":null,"failed":[],"friendlyName":"Coordinator","ieeeAddr":"0x00124b00120144ae","lastSeen":1000,"modelID":null,"networkAddress":0,"type":"Coordinator"},{"definition":{"description":"TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white","model":"LED1545G12","supports":"light (state, brightness, color_temp, color_temp_startup), effect, linkquality","vendor":"IKEA"},"failed":[],"friendlyName":"bulb","ieeeAddr":"0x000b57fffec6a5b2","lastSeen":1000,"modelID":"TRADFRI bulb E27 WS opal 980lm","networkAddress":40369,"type":"Router"},{"definition":{"description":"Hue Go","model":"7146060PH","supports":"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), effect, linkquality","vendor":"Philips"},"failed":[],"friendlyName":"bulb_color","ieeeAddr":"0x000b57fffec6a5b3","lastSeen":1000,"modelID":"LLC020","networkAddress":40399,"type":"Router"},{"definition":{"description":"Aqara double key wireless wall switch (2016 model)","model":"WXKG02LM_rev1","supports":"battery, action, voltage, linkquality","vendor":"Xiaomi"},"friendlyName":"button_double_key","ieeeAddr":"0x0017880104e45521","lastSeen":1000,"modelID":"lumi.sensor_86sw2.es1","networkAddress":6538,"type":"EndDevice"},{"definition":null,"failed":["lqi","routingTable"],"friendlyName":"0x0017880104e45525","ieeeAddr":"0x0017880104e45525","lastSeen":1000,"manufacturerName":"Boef","modelID":"notSupportedModelID","networkAddress":6536,"type":"Router"},{"definition":{"description":"[CC2530 router](http://ptvo.info/cc2530-based-zigbee-coordinator-and-router-112/)","model":"CC2530.ROUTER","supports":"led, linkquality","vendor":"Custom devices (DiY)"},"failed":[],"friendlyName":"cc2530_router","ieeeAddr":"0x0017880104e45559","lastSeen":1000,"modelID":"lumi.router","networkAddress":6540,"type":"Router"},{"definition":{"description": "external","model":"external_converter_device","supports":"","vendor":"external"},"friendlyName":"0x0017880104e45511","ieeeAddr":"0x0017880104e45511","lastSeen":1000,"modelID":"external_converter_device","networkAddress":1114,"type":"EndDevice"}]};
|
||||
const expected = {"links":[{"depth":1,"linkquality":120,"lqi":120,"relationship":2,"routes":[],"source":{"ieeeAddr":"0x000b57fffec6a5b3","networkAddress":40399},"sourceIeeeAddr":"0x000b57fffec6a5b3","sourceNwkAddr":40399,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":1,"linkquality":92,"lqi":92,"relationship":2,"routes":[{"destinationAddress":6540,"nextHop":40369,"status":"ACTIVE"}],"source":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"sourceIeeeAddr":"0x000b57fffec6a5b2","sourceNwkAddr":40369,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":1,"linkquality":92,"lqi":92,"relationship":2,"routes":[],"source":{"ieeeAddr":"0x0017880104e45511","networkAddress":1114},"sourceIeeeAddr":"0x0017880104e45511","sourceNwkAddr":1114,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":2,"linkquality":110,"lqi":110,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x000b57fffec6a5b3","networkAddress":40399},"sourceIeeeAddr":"0x000b57fffec6a5b3","sourceNwkAddr":40399,"target":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"targetIeeeAddr":"0x000b57fffec6a5b2"},{"depth":2,"linkquality":100,"lqi":100,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x0017880104e45559","networkAddress":6540},"sourceIeeeAddr":"0x0017880104e45559","sourceNwkAddr":6540,"target":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"targetIeeeAddr":"0x000b57fffec6a5b2"},{"depth":2,"linkquality":130,"lqi":130,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x0017880104e45521","networkAddress":6538},"sourceIeeeAddr":"0x0017880104e45521","sourceNwkAddr":6538,"target":{"ieeeAddr":"0x0017880104e45559","networkAddress":6540},"targetIeeeAddr":"0x0017880104e45559"}],"nodes":[{"definition":null,"failed":[],"friendlyName":"Coordinator","ieeeAddr":"0x00124b00120144ae","lastSeen":1000,"modelID":null,"networkAddress":0,"type":"Coordinator"},{"definition":{"description":"TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white","model":"LED1545G12","supports":"light (state, brightness, color_temp, color_temp_startup), effect, power_on_behavior, linkquality","vendor":"IKEA"},"failed":[],"friendlyName":"bulb","ieeeAddr":"0x000b57fffec6a5b2","lastSeen":1000,"modelID":"TRADFRI bulb E27 WS opal 980lm","networkAddress":40369,"type":"Router"},{"definition":{"description":"Hue Go","model":"7146060PH","supports":"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), effect, linkquality","vendor":"Philips"},"failed":[],"friendlyName":"bulb_color","ieeeAddr":"0x000b57fffec6a5b3","lastSeen":1000,"modelID":"LLC020","networkAddress":40399,"type":"Router"},{"definition":{"description":"Aqara double key wireless wall switch (2016 model)","model":"WXKG02LM_rev1","supports":"battery, action, voltage, linkquality","vendor":"Xiaomi"},"friendlyName":"button_double_key","ieeeAddr":"0x0017880104e45521","lastSeen":1000,"modelID":"lumi.sensor_86sw2.es1","networkAddress":6538,"type":"EndDevice"},{"definition":null,"failed":["lqi","routingTable"],"friendlyName":"0x0017880104e45525","ieeeAddr":"0x0017880104e45525","lastSeen":1000,"manufacturerName":"Boef","modelID":"notSupportedModelID","networkAddress":6536,"type":"Router"},{"definition":{"description":"[CC2530 router](http://ptvo.info/cc2530-based-zigbee-coordinator-and-router-112/)","model":"CC2530.ROUTER","supports":"led, linkquality","vendor":"Custom devices (DiY)"},"failed":[],"friendlyName":"cc2530_router","ieeeAddr":"0x0017880104e45559","lastSeen":1000,"modelID":"lumi.router","networkAddress":6540,"type":"Router"},{"definition":{"description": "external","model":"external_converter_device","supports":"linkquality","vendor":"external"},"friendlyName":"0x0017880104e45511","ieeeAddr":"0x0017880104e45511","lastSeen":1000,"modelID":"external_converter_device","networkAddress":1114,"type":"EndDevice"}]};
|
||||
expect(JSON.parse(call[1])).toStrictEqual(expected);
|
||||
|
||||
/**
|
||||
@@ -137,18 +142,18 @@ describe('Networkmap', () => {
|
||||
|
||||
const expected = `digraph G {
|
||||
node[shape=record];
|
||||
"0x00124b00120144ae" [style="bold, filled", fillcolor="#e04e5d", fontcolor="#ffffff", label="{Coordinator|0x00124b00120144ae (0)|0 seconds ago}"];
|
||||
"0x000b57fffec6a5b2" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb|0x000b57fffec6a5b2 (40369)|IKEA TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white (LED1545G12)|9 seconds ago}"];
|
||||
"0x000b57fffec6a5b2" -> "0x00124b00120144ae" [penwidth=2, weight=1, color="#009900", label="92 (routes: 6540)"]
|
||||
"0x000b57fffec6a5b3" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb_color|0x000b57fffec6a5b3 (40399)|Philips Hue Go (7146060PH)|unknown}"];
|
||||
"0x00124b00120144ae" [style="bold, filled", fillcolor="#e04e5d", fontcolor="#ffffff", label="{Coordinator|0x00124b00120144ae (0x0000)|0 seconds ago}"];
|
||||
"0x000b57fffec6a5b2" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb|0x000b57fffec6a5b2 (0x9db1)|IKEA TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white (LED1545G12)|9 seconds ago}"];
|
||||
"0x000b57fffec6a5b2" -> "0x00124b00120144ae" [penwidth=2, weight=1, color="#009900", label="92 (routes: 0x198c)"]
|
||||
"0x000b57fffec6a5b3" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb_color|0x000b57fffec6a5b3 (0x9dcf)|Philips Hue Go (7146060PH)|unknown}"];
|
||||
"0x000b57fffec6a5b3" -> "0x00124b00120144ae" [penwidth=0.5, weight=0, color="#994444", label="120"]
|
||||
"0x000b57fffec6a5b3" -> "0x000b57fffec6a5b2" [penwidth=0.5, weight=0, color="#994444", label="110"]
|
||||
"0x0017880104e45521" [style="rounded, dashed, filled", fillcolor="#fff8ce", fontcolor="#000000", label="{button_double_key|0x0017880104e45521 (6538)|Xiaomi Aqara double key wireless wall switch (2016 model) (WXKG02LM_rev1)|9 seconds ago}"];
|
||||
"0x0017880104e45521" [style="rounded, dashed, filled", fillcolor="#fff8ce", fontcolor="#000000", label="{button_double_key|0x0017880104e45521 (0x198a)|Xiaomi Aqara double key wireless wall switch (2016 model) (WXKG02LM_rev1)|9 seconds ago}"];
|
||||
"0x0017880104e45521" -> "0x0017880104e45559" [penwidth=1, weight=0, color="#994444", label="130"]
|
||||
"0x0017880104e45525" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{0x0017880104e45525|0x0017880104e45525 (6536)failed: lqi,routingTable|Boef notSupportedModelID|9 seconds ago}"];
|
||||
"0x0017880104e45559" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{cc2530_router|0x0017880104e45559 (6540)|Custom devices (DiY) [CC2530 router](http://ptvo.info/cc2530-based-zigbee-coordinator-and-router-112/) (CC2530.ROUTER)|9 seconds ago}"];
|
||||
"0x0017880104e45525" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{0x0017880104e45525|0x0017880104e45525 (0x1988)failed: lqi,routingTable|Boef notSupportedModelID|9 seconds ago}"];
|
||||
"0x0017880104e45559" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{cc2530_router|0x0017880104e45559 (0x198c)|Custom devices (DiY) [CC2530 router](http://ptvo.info/cc2530-based-zigbee-coordinator-and-router-112/) (CC2530.ROUTER)|9 seconds ago}"];
|
||||
"0x0017880104e45559" -> "0x000b57fffec6a5b2" [penwidth=0.5, weight=0, color="#994444", label="100"]
|
||||
"0x0017880104e45511" [style="rounded, dashed, filled", fillcolor="#fff8ce", fontcolor="#000000", label="{0x0017880104e45511|0x0017880104e45511 (1114)|external external (external_converter_device)|9 seconds ago}"];
|
||||
"0x0017880104e45511" [style="rounded, dashed, filled", fillcolor="#fff8ce", fontcolor="#000000", label="{0x0017880104e45511|0x0017880104e45511 (0x045a)|external external (external_converter_device)|9 seconds ago}"];
|
||||
"0x0017880104e45511" -> "0x00124b00120144ae" [penwidth=1, weight=0, color="#994444", label="92"]
|
||||
}`;
|
||||
|
||||
@@ -180,7 +185,7 @@ describe('Networkmap', () => {
|
||||
card 0x0017880104e45511 [
|
||||
0x0017880104e45511
|
||||
---
|
||||
0x0017880104e45511 (1114)
|
||||
0x0017880104e45511 (0x045a)
|
||||
---
|
||||
external external (external_converter_device)
|
||||
---
|
||||
@@ -190,7 +195,7 @@ describe('Networkmap', () => {
|
||||
card 0x0017880104e45525 [
|
||||
0x0017880104e45525
|
||||
---
|
||||
0x0017880104e45525 (6536) failed: lqi,routingTable
|
||||
0x0017880104e45525 (0x1988) failed: lqi,routingTable
|
||||
---
|
||||
Boef notSupportedModelID
|
||||
---
|
||||
@@ -200,7 +205,7 @@ describe('Networkmap', () => {
|
||||
card 0x000b57fffec6a5b2 [
|
||||
bulb
|
||||
---
|
||||
0x000b57fffec6a5b2 (40369)
|
||||
0x000b57fffec6a5b2 (0x9db1)
|
||||
---
|
||||
IKEA TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white (LED1545G12)
|
||||
---
|
||||
@@ -210,7 +215,7 @@ describe('Networkmap', () => {
|
||||
card 0x000b57fffec6a5b3 [
|
||||
bulb_color
|
||||
---
|
||||
0x000b57fffec6a5b3 (40399)
|
||||
0x000b57fffec6a5b3 (0x9dcf)
|
||||
---
|
||||
Philips Hue Go (7146060PH)
|
||||
---
|
||||
@@ -220,7 +225,7 @@ describe('Networkmap', () => {
|
||||
card 0x0017880104e45521 [
|
||||
button_double_key
|
||||
---
|
||||
0x0017880104e45521 (6538)
|
||||
0x0017880104e45521 (0x198a)
|
||||
---
|
||||
Xiaomi Aqara double key wireless wall switch (2016 model) (WXKG02LM_rev1)
|
||||
---
|
||||
@@ -230,7 +235,7 @@ describe('Networkmap', () => {
|
||||
card 0x0017880104e45559 [
|
||||
cc2530_router
|
||||
---
|
||||
0x0017880104e45559 (6540)
|
||||
0x0017880104e45559 (0x198c)
|
||||
---
|
||||
Custom devices (DiY) [CC2530 router](http://ptvo.info/cc2530-based-zigbee-coordinator-and-router-112/) (CC2530.ROUTER)
|
||||
---
|
||||
@@ -240,7 +245,7 @@ describe('Networkmap', () => {
|
||||
card 0x00124b00120144ae [
|
||||
Coordinator
|
||||
---
|
||||
0x00124b00120144ae (0)
|
||||
0x00124b00120144ae (0x0000)
|
||||
---
|
||||
0 seconds ago
|
||||
]
|
||||
@@ -271,7 +276,7 @@ describe('Networkmap', () => {
|
||||
let call = MQTT.publish.mock.calls[0];
|
||||
expect(call[0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
|
||||
|
||||
const expected = {"data":{"routes":true,"type":"raw","value":{"links":[{"depth":1,"linkquality":120,"lqi":120,"relationship":2,"routes":[],"source":{"ieeeAddr":"0x000b57fffec6a5b3","networkAddress":40399},"sourceIeeeAddr":"0x000b57fffec6a5b3","sourceNwkAddr":40399,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":1,"linkquality":92,"lqi":92,"relationship":2,"routes":[{"destinationAddress":6540,"nextHop":40369,"status":"ACTIVE"}],"source":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"sourceIeeeAddr":"0x000b57fffec6a5b2","sourceNwkAddr":40369,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":1,"linkquality":92,"lqi":92,"relationship":2,"routes":[],"source":{"ieeeAddr":"0x0017880104e45511","networkAddress":1114},"sourceIeeeAddr":"0x0017880104e45511","sourceNwkAddr":1114,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":2,"linkquality":110,"lqi":110,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x000b57fffec6a5b3","networkAddress":40399},"sourceIeeeAddr":"0x000b57fffec6a5b3","sourceNwkAddr":40399,"target":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"targetIeeeAddr":"0x000b57fffec6a5b2"},{"depth":2,"linkquality":100,"lqi":100,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x0017880104e45559","networkAddress":6540},"sourceIeeeAddr":"0x0017880104e45559","sourceNwkAddr":6540,"target":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"targetIeeeAddr":"0x000b57fffec6a5b2"},{"depth":2,"linkquality":130,"lqi":130,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x0017880104e45521","networkAddress":6538},"sourceIeeeAddr":"0x0017880104e45521","sourceNwkAddr":6538,"target":{"ieeeAddr":"0x0017880104e45559","networkAddress":6540},"targetIeeeAddr":"0x0017880104e45559"}],"nodes":[{"definition":null,"failed":[],"friendlyName":"Coordinator","ieeeAddr":"0x00124b00120144ae","lastSeen":1000,"modelID":null,"networkAddress":0,"type":"Coordinator"},{"definition":{"description":"TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white","model":"LED1545G12","supports":"light (state, brightness, color_temp, color_temp_startup), effect, linkquality","vendor":"IKEA"},"failed":[],"friendlyName":"bulb","ieeeAddr":"0x000b57fffec6a5b2","lastSeen":1000,"modelID":"TRADFRI bulb E27 WS opal 980lm","networkAddress":40369,"type":"Router"},{"definition":{"description":"Hue Go","model":"7146060PH","supports":"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), effect, linkquality","vendor":"Philips"},"failed":[],"friendlyName":"bulb_color","ieeeAddr":"0x000b57fffec6a5b3","lastSeen":1000,"modelID":"LLC020","networkAddress":40399,"type":"Router"},{"definition":{"description":"Aqara double key wireless wall switch (2016 model)","model":"WXKG02LM_rev1","supports":"battery, action, voltage, linkquality","vendor":"Xiaomi"},"friendlyName":"button_double_key","ieeeAddr":"0x0017880104e45521","lastSeen":1000,"modelID":"lumi.sensor_86sw2.es1","networkAddress":6538,"type":"EndDevice"},{"definition":null,"failed":["lqi","routingTable"],"friendlyName":"0x0017880104e45525","ieeeAddr":"0x0017880104e45525","lastSeen":1000,"manufacturerName":"Boef","modelID":"notSupportedModelID","networkAddress":6536,"type":"Router"},{"definition":{"description":"[CC2530 router](http://ptvo.info/cc2530-based-zigbee-coordinator-and-router-112/)","model":"CC2530.ROUTER","supports":"led, linkquality","vendor":"Custom devices (DiY)"},"failed":[],"friendlyName":"cc2530_router","ieeeAddr":"0x0017880104e45559","lastSeen":1000,"modelID":"lumi.router","networkAddress":6540,"type":"Router"},{"definition":{"description":"external","model":"external_converter_device","supports":"","vendor":"external"},"friendlyName":"0x0017880104e45511","ieeeAddr":"0x0017880104e45511","lastSeen":1000,"modelID":"external_converter_device","networkAddress":1114,"type":"EndDevice"}]}},"status":"ok"};
|
||||
const expected = {"data":{"routes":true,"type":"raw","value":{"links":[{"depth":1,"linkquality":120,"lqi":120,"relationship":2,"routes":[],"source":{"ieeeAddr":"0x000b57fffec6a5b3","networkAddress":40399},"sourceIeeeAddr":"0x000b57fffec6a5b3","sourceNwkAddr":40399,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":1,"linkquality":92,"lqi":92,"relationship":2,"routes":[{"destinationAddress":6540,"nextHop":40369,"status":"ACTIVE"}],"source":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"sourceIeeeAddr":"0x000b57fffec6a5b2","sourceNwkAddr":40369,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":1,"linkquality":92,"lqi":92,"relationship":2,"routes":[],"source":{"ieeeAddr":"0x0017880104e45511","networkAddress":1114},"sourceIeeeAddr":"0x0017880104e45511","sourceNwkAddr":1114,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":2,"linkquality":110,"lqi":110,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x000b57fffec6a5b3","networkAddress":40399},"sourceIeeeAddr":"0x000b57fffec6a5b3","sourceNwkAddr":40399,"target":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"targetIeeeAddr":"0x000b57fffec6a5b2"},{"depth":2,"linkquality":100,"lqi":100,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x0017880104e45559","networkAddress":6540},"sourceIeeeAddr":"0x0017880104e45559","sourceNwkAddr":6540,"target":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"targetIeeeAddr":"0x000b57fffec6a5b2"},{"depth":2,"linkquality":130,"lqi":130,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x0017880104e45521","networkAddress":6538},"sourceIeeeAddr":"0x0017880104e45521","sourceNwkAddr":6538,"target":{"ieeeAddr":"0x0017880104e45559","networkAddress":6540},"targetIeeeAddr":"0x0017880104e45559"}],"nodes":[{"definition":null,"failed":[],"friendlyName":"Coordinator","ieeeAddr":"0x00124b00120144ae","lastSeen":1000,"modelID":null,"networkAddress":0,"type":"Coordinator"},{"definition":{"description":"TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white","model":"LED1545G12","supports":"light (state, brightness, color_temp, color_temp_startup), effect, power_on_behavior, linkquality","vendor":"IKEA"},"failed":[],"friendlyName":"bulb","ieeeAddr":"0x000b57fffec6a5b2","lastSeen":1000,"modelID":"TRADFRI bulb E27 WS opal 980lm","networkAddress":40369,"type":"Router"},{"definition":{"description":"Hue Go","model":"7146060PH","supports":"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), effect, linkquality","vendor":"Philips"},"failed":[],"friendlyName":"bulb_color","ieeeAddr":"0x000b57fffec6a5b3","lastSeen":1000,"modelID":"LLC020","networkAddress":40399,"type":"Router"},{"definition":{"description":"Aqara double key wireless wall switch (2016 model)","model":"WXKG02LM_rev1","supports":"battery, action, voltage, linkquality","vendor":"Xiaomi"},"friendlyName":"button_double_key","ieeeAddr":"0x0017880104e45521","lastSeen":1000,"modelID":"lumi.sensor_86sw2.es1","networkAddress":6538,"type":"EndDevice"},{"definition":null,"failed":["lqi","routingTable"],"friendlyName":"0x0017880104e45525","ieeeAddr":"0x0017880104e45525","lastSeen":1000,"manufacturerName":"Boef","modelID":"notSupportedModelID","networkAddress":6536,"type":"Router"},{"definition":{"description":"[CC2530 router](http://ptvo.info/cc2530-based-zigbee-coordinator-and-router-112/)","model":"CC2530.ROUTER","supports":"led, linkquality","vendor":"Custom devices (DiY)"},"failed":[],"friendlyName":"cc2530_router","ieeeAddr":"0x0017880104e45559","lastSeen":1000,"modelID":"lumi.router","networkAddress":6540,"type":"Router"},{"definition":{"description":"external","model":"external_converter_device","supports":"linkquality","vendor":"external"},"friendlyName":"0x0017880104e45511","ieeeAddr":"0x0017880104e45511","lastSeen":1000,"modelID":"external_converter_device","networkAddress":1114,"type":"EndDevice"}]}},"status":"ok"};
|
||||
const actual = JSON.parse(call[1]);
|
||||
expect(actual).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
+16
-12
@@ -6,7 +6,7 @@ zigbeeHerdsman.returnDevices.push('0x0017880104e45560');
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const flushPromises = () => new Promise(setImmediate);
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
|
||||
|
||||
const mocksClear = [MQTT.publish, logger.warn, logger.debug];
|
||||
@@ -22,14 +22,24 @@ describe('On event', () => {
|
||||
const device = zigbeeHerdsman.devices.LIVOLO;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
data.writeDefaultConfiguration();
|
||||
settings._reRead();
|
||||
data.writeEmptyState();
|
||||
settings.reRead();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
controller.state.state = {};
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
zigbeeHerdsmanConverters.onEvent.mockClear();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('Should call with start event', async () => {
|
||||
@@ -56,10 +66,7 @@ describe('On event', () => {
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device});
|
||||
await flushPromises();
|
||||
expect(mockOnEvent).toHaveBeenCalledTimes(1);
|
||||
const call = mockOnEvent.mock.calls[0];
|
||||
expect(call[0]).toBe('deviceAnnounce')
|
||||
expect(call[1]).toStrictEqual({device})
|
||||
expect(call[2]).toBe(device);
|
||||
expect(mockOnEvent).toHaveBeenCalledWith('deviceAnnounce', {device}, device, settings.getDevice(device.ieeeAddr));
|
||||
});
|
||||
|
||||
it('Should call index onEvent with zigbee event', async () => {
|
||||
@@ -67,9 +74,6 @@ describe('On event', () => {
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device});
|
||||
await flushPromises();
|
||||
expect(zigbeeHerdsmanConverters.onEvent).toHaveBeenCalledTimes(1);
|
||||
const call = zigbeeHerdsmanConverters.onEvent.mock.calls[0];
|
||||
expect(call[0]).toBe('deviceAnnounce')
|
||||
expect(call[1]).toStrictEqual({device})
|
||||
expect(call[2]).toBe(device);
|
||||
expect(zigbeeHerdsmanConverters.onEvent).toHaveBeenCalledWith('deviceAnnounce', {device}, device);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user