mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-09-01 16:48:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
414c51f5b2 | ||
|
|
0c42067006 | ||
|
|
5741904e75 | ||
|
|
c60fdfd39b | ||
|
|
a7682498ef | ||
|
|
c6a3b90c2f | ||
|
|
ebd35052c0 | ||
|
|
2849f30417 | ||
|
|
e8526b7b58 | ||
|
|
3166d37096 | ||
|
|
2d2c01981e | ||
|
|
58b702e8ef | ||
|
|
6efa20fa1d | ||
|
|
bf72347468 | ||
|
|
73a6ad4cf8 | ||
|
|
1e75dd5628 | ||
|
|
bc92bd73e6 | ||
|
|
8756367666 | ||
|
|
79e5f0844a | ||
|
|
b0585172f4 | ||
|
|
89996835ed | ||
|
|
4f2a1e55e5 | ||
|
|
d488563f2a | ||
|
|
4310171abc | ||
|
|
36d7b4b022 | ||
|
|
50a6ed14d4 | ||
|
|
ee623c2484 | ||
|
|
2e6588719b | ||
|
|
fb35b52ca6 | ||
|
|
3306b63299 | ||
|
|
7a4ecadb6a | ||
|
|
511f3b09af | ||
|
|
828982d36c | ||
|
|
61bd95f8b8 | ||
|
|
74cff6a983 | ||
|
|
fdf7d10a41 | ||
|
|
1145e4b81b | ||
|
|
93f01d545e | ||
|
|
30e7385cac | ||
|
|
513314963c | ||
|
|
3c0cd8ebfa | ||
|
|
5bedc044d6 | ||
|
|
2a8a98f9a3 | ||
|
|
03f24a63d5 | ||
|
|
53003b3d0d | ||
|
|
cd5dd39971 | ||
|
|
41b150af82 | ||
|
|
3ba1f8cad0 | ||
|
|
f9aa525048 | ||
|
|
8b4be3d6ad | ||
|
|
0efe869fcb | ||
|
|
18cfbae9ca | ||
|
|
7625d261a5 | ||
|
|
6d8eea2142 | ||
|
|
2a9d22e2b3 | ||
|
|
656446c828 | ||
|
|
f1ffc1d265 | ||
|
|
2d1df8f0cb | ||
|
|
253e985388 | ||
|
|
1dd1163288 | ||
|
|
bb5627d33b | ||
|
|
38f42bb3c8 | ||
|
|
5f37145a02 | ||
|
|
a56061b36d | ||
|
|
b890b1bcb7 | ||
|
|
aa84a140e1 | ||
|
|
d875b297ad | ||
|
|
1c6ca6519c | ||
|
|
900c9f302e | ||
|
|
ea04021438 | ||
|
|
5a624ae188 | ||
|
|
42569d6c39 | ||
|
|
46e06dbbee | ||
|
|
a282221e40 | ||
|
|
bb22216d09 | ||
|
|
28cb8b08fb | ||
|
|
594a463f8b | ||
|
|
9c1a15465d | ||
|
|
22836f224d | ||
|
|
aafd937f8a | ||
|
|
d0195d0ff9 | ||
|
|
7592c627c2 | ||
|
|
98a57b4540 | ||
|
|
70b9d29ddf | ||
|
|
ac16d7ce69 | ||
|
|
d4f560d198 | ||
|
|
4136b1e539 | ||
|
|
b4af8329e7 | ||
|
|
ac0aca59a3 | ||
|
|
bc8274b9fd | ||
|
|
e5321d3f20 | ||
|
|
7a81a04b2f | ||
|
|
3b3d886121 | ||
|
|
48b790cbf3 | ||
|
|
9c93103fb7 | ||
|
|
33b35c1e61 | ||
|
|
073c7aedf7 | ||
|
|
e789514777 | ||
|
|
fd762fcf10 | ||
|
|
45b49bac0a | ||
|
|
055fba259a | ||
|
|
b206dc910f | ||
|
|
70db18f3ab | ||
|
|
cb2ef51df8 | ||
|
|
68e5b39172 | ||
|
|
b17ea2e4d9 | ||
|
|
41f9361922 | ||
|
|
dae66fd3fc | ||
|
|
d6ea54b8df | ||
|
|
c47b9c7c9b | ||
|
|
c2b5229f76 | ||
|
|
0c605688af | ||
|
|
6876e19008 | ||
|
|
437b1dfb3e | ||
|
|
fda1f23829 | ||
|
|
ed9d09a629 | ||
|
|
d298412a1d | ||
|
|
e7447cf1e3 | ||
|
|
c90171d2cc | ||
|
|
d2cf0dd8aa | ||
|
|
d415f57e30 | ||
|
|
56998f72f5 | ||
|
|
97bd090ad6 | ||
|
|
df182be21f | ||
|
|
290a0312d4 | ||
|
|
4d432658e9 | ||
|
|
c2d9ffa7ad | ||
|
|
82f5ddb748 | ||
|
|
1069ec2a1a | ||
|
|
cd4ac0c174 | ||
|
|
134b80ccf6 | ||
|
|
b07450d413 | ||
|
|
65f7fc0532 | ||
|
|
0a7a477281 | ||
|
|
a170f304c7 | ||
|
|
35ed5154c8 | ||
|
|
641853e6d5 | ||
|
|
2af9f21961 | ||
|
|
51321affcf | ||
|
|
5fca6fbd09 | ||
|
|
070065a248 | ||
|
|
9a8f204683 | ||
|
|
167c05681e | ||
|
|
01d7bd25be | ||
|
|
372ec315b5 | ||
|
|
df0543d4b7 | ||
|
|
a9e3fb3358 | ||
|
|
678e19fd7e | ||
|
|
9128838534 | ||
|
|
35876616ef | ||
|
|
0bc3129b7a | ||
|
|
5e3b18a053 | ||
|
|
ad3ff7fa40 | ||
|
|
008d6f9aa0 | ||
|
|
854353fb71 | ||
|
|
e35ba0f83c | ||
|
|
a154e18326 | ||
|
|
c793cc83ad | ||
|
|
a51626e357 | ||
|
|
0f3315063f | ||
|
|
1065916a01 | ||
|
|
a980b4dff5 | ||
|
|
825c7465ae | ||
|
|
97521478c6 | ||
|
|
6279c83e65 | ||
|
|
91479fc0ec | ||
|
|
9dbe6f0a6c | ||
|
|
5d415aa299 | ||
|
|
6a75e0d4ee | ||
|
|
13e9616c92 | ||
|
|
cc03be46df | ||
|
|
64154e0cb2 | ||
|
|
b2eaa3141e | ||
|
|
d192bbd28f | ||
|
|
9582808b09 | ||
|
|
623f999ec8 | ||
|
|
3141a14fec | ||
|
|
7e2a2bdd34 | ||
|
|
2f503bf39a | ||
|
|
945b72b990 | ||
|
|
3bfc7f31cf | ||
|
|
1f60edff99 | ||
|
|
3249ab00ab | ||
|
|
8c8f907fd3 | ||
|
|
170ef40982 | ||
|
|
e547062702 | ||
|
|
6ca8d35183 | ||
|
|
420bae4259 | ||
|
|
97dacd7623 | ||
|
|
8ef82fe23f | ||
|
|
cd3df10a54 | ||
|
|
d5ccd9221c | ||
|
|
fc9abfe53b | ||
|
|
01ea46b280 | ||
|
|
ab629d52ea | ||
|
|
9e216011d4 | ||
|
|
91d90bdcf1 | ||
|
|
6ba743cbed | ||
|
|
2399ec040f | ||
|
|
f151e1d371 | ||
|
|
55802ef648 | ||
|
|
89af770d96 | ||
|
|
0efd0cfe17 | ||
|
|
c852434dde | ||
|
|
57e93a48ff | ||
|
|
181944d2ff | ||
|
|
a09aa4243e | ||
|
|
932ee433c4 | ||
|
|
20b6fd42d3 | ||
|
|
178da2db1d | ||
|
|
e504da33d4 | ||
|
|
fa1775d8b4 | ||
|
|
2612de3c34 | ||
|
|
416f4fa548 | ||
|
|
ee57b0ce0b | ||
|
|
9729a72d33 | ||
|
|
23719df7ac | ||
|
|
ef6bf0a8f9 | ||
|
|
b3e9afaf3e | ||
|
|
5709898c94 | ||
|
|
68b5be0ce7 | ||
|
|
62bf76c11f | ||
|
|
243d333526 | ||
|
|
09383877b0 | ||
|
|
359af6f9ff | ||
|
|
cf68507ec3 | ||
|
|
78a3e4a371 | ||
|
|
11753a7078 | ||
|
|
512b785427 | ||
|
|
0a1fccf6b3 | ||
|
|
9e4b65b188 | ||
|
|
39c0b522d9 | ||
|
|
109c62bb4d | ||
|
|
a2e3f60de3 | ||
|
|
bbf05b5c95 | ||
|
|
a5413b9254 | ||
|
|
d6890b5fe4 | ||
|
|
b1c1637547 | ||
|
|
ed8ca293a3 | ||
|
|
abd4b23024 | ||
|
|
7a198ce4a3 | ||
|
|
e0c3ea28c0 | ||
|
|
d137d1975b | ||
|
|
e7bc8b9a9a | ||
|
|
72ffe4d118 | ||
|
|
e81d197979 | ||
|
|
1dc36f4cc1 | ||
|
|
252994968d | ||
|
|
7fed9e7334 | ||
|
|
bef5bff637 | ||
|
|
498a5b3f32 | ||
|
|
b08ce534c8 | ||
|
|
56812e7844 | ||
|
|
d19c60be07 | ||
|
|
387378e935 | ||
|
|
16c3f912b1 | ||
|
|
3f0a2fed9d | ||
|
|
c8e3c9b5a2 | ||
|
|
b665106d44 | ||
|
|
88314f1c6a | ||
|
|
8c12551ac8 | ||
|
|
011d02d0e6 | ||
|
|
8ac3989ada | ||
|
|
caa22c559e | ||
|
|
7469e29877 | ||
|
|
c647e87641 | ||
|
|
de825014c0 | ||
|
|
ca7bb0cd4d | ||
|
|
158341a9ff | ||
|
|
eaad39a199 | ||
|
|
ca255d3aa3 | ||
|
|
97d15b8d15 | ||
|
|
549ccf036e | ||
|
|
8ae4909836 | ||
|
|
bfb2427a6c | ||
|
|
3571937060 | ||
|
|
f7c0b46bef | ||
|
|
ad5cda4d40 | ||
|
|
d18455b596 | ||
|
|
0ae7bd0a2d | ||
|
|
8fac172ced | ||
|
|
cff4ea726e | ||
|
|
749381bf2f | ||
|
|
6b380526ae | ||
|
|
e8dd29e183 | ||
|
|
b58e123594 | ||
|
|
899383d79e | ||
|
|
de7b120ed2 | ||
|
|
4608de5d57 | ||
|
|
a362e6c99d | ||
|
|
ed8a863c30 | ||
|
|
7086bc3e9d | ||
|
|
2144e9b622 | ||
|
|
ff5411a328 | ||
|
|
47297983c2 | ||
|
|
a774b43d63 | ||
|
|
7f8632437d | ||
|
|
6977235200 | ||
|
|
4e520bb042 | ||
|
|
45f096ec0a | ||
|
|
0ea74e18b2 | ||
|
|
ab5d368a4e | ||
|
|
ebd20e30dc | ||
|
|
c2d4335e45 | ||
|
|
38cbfa4e91 | ||
|
|
f7fd0ce825 | ||
|
|
ea550a8fcf | ||
|
|
9907e59fa7 | ||
|
|
3b0a906a97 | ||
|
|
c88f643407 | ||
|
|
89d2b0088d | ||
|
|
d03c5e2372 | ||
|
|
90d20f936e | ||
|
|
6f9371e4b0 | ||
|
|
4082763f59 | ||
|
|
42497f3176 | ||
|
|
7ea29eadf4 | ||
|
|
db8d0ecf20 | ||
|
|
bd216d7a24 | ||
|
|
23b87755cf | ||
|
|
7415e8416d | ||
|
|
8e2c79f85d | ||
|
|
ab3a9da630 | ||
|
|
7ccf781b65 | ||
|
|
30badb62e7 | ||
|
|
a0512a489d | ||
|
|
edb145a8c3 | ||
|
|
45a4978ca8 | ||
|
|
fb96551beb | ||
|
|
2fb59f8efa | ||
|
|
c28731957a | ||
|
|
0282011255 | ||
|
|
045ee573a0 | ||
|
|
839e280159 |
@@ -1,46 +0,0 @@
|
||||
<!--
|
||||
Before submitting an issue make sure you've searched for a similar issue and read the documentation: https://www.zigbee2mqtt.io/.
|
||||
|
||||
Rules (don't ignore these, your issue will be closed without further notice):
|
||||
- English only
|
||||
- Make sure you are on the latest Zigbee2MQTT version
|
||||
- Provide a clear description of the problem
|
||||
- DON'T copy logs directly here, post a link to https://hastebin.com/ or https://pastebin.com/.
|
||||
- Make sure you are running the latest firmware https://github.com/koenkk/z-stack-firmware.
|
||||
|
||||
Did you read the FAQ?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html
|
||||
|
||||
Zigbee2MQTT fails to start?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html#help-zigbee2mqtt-fails-to-start
|
||||
|
||||
Having issues when using a CC2531?
|
||||
- Make sure the CC2531 is connected through a USB extension cable
|
||||
- Try the source routing firmware: https://github.com/Koenkk/Z-Stack-firmware/tree/master/coordinator/Z-Stack_Home_1.2/bin/source_routing
|
||||
- With larger networks (30/40+) devices, your CC2531 might not be powerful enough. This will cause weird issues, in this case it's advised to use a more powerful adapter: https://www.zigbee2mqtt.io/information/supported_adapters.html#texas-instruments-cc26x2r1
|
||||
|
||||
Unsupported device?
|
||||
- https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html
|
||||
|
||||
Device does not pair or interview fails?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html#why-does-my-device-not-or-fail-to-pair
|
||||
|
||||
Bug report?
|
||||
- If applicable, provide steps how to reproduce the problem.
|
||||
- Provide the herdsman debug logging: https://www.zigbee2mqtt.io/information/debug.html#zigbee-herdsman-debug-logging
|
||||
|
||||
-->
|
||||
|
||||
<!--Start your bug report below this line-->
|
||||
|
||||
# Bug Report
|
||||
## What happened
|
||||
|
||||
## What did you expect to happen
|
||||
|
||||
## How to reproduce it (minimal and precise)
|
||||
|
||||
## Debug Info
|
||||
Zigbee2MQTT version:
|
||||
Adapter hardware: CC2531, CC2530, CC26X2R1 or CC1352P-2
|
||||
Adapter firmware version:
|
||||
@@ -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
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: feature request
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Is your feature request related to a problem? Please describe
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
## Describe the solution you'd like
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
## Describe alternatives you've considered
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
## Additional context
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea for this project
|
||||
title: "[Feature request]: "
|
||||
labels: [feature request]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**IMPORTANT:** Before submitting:
|
||||
- Is your feature request related to the fronted? Then click [here](https://github.com/nurikk/zigbee2mqtt-frontend/issues/new?assignees=&labels=&template=feature_request.md&title=)
|
||||
- type: textarea
|
||||
id: textarea1
|
||||
attributes:
|
||||
label: Is your feature request related to a problem? Please describe
|
||||
placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: textarea2
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
placeholder: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: textarea3
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
placeholder: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: textarea4
|
||||
attributes:
|
||||
label: Additional context
|
||||
placeholder: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: true
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: New device support request
|
||||
about: Request support for a new device
|
||||
title: ''
|
||||
labels: new device support
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
VERY IMPORTANT: Before submitting a request, first (try to) follow: https://www.zigbee2mqtt.io/how_tos/how_to_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.
|
||||
-->
|
||||
|
||||
## Information about the device + link
|
||||
..
|
||||
|
||||
## data/database.db entry of the device
|
||||
..
|
||||
@@ -0,0 +1,54 @@
|
||||
name: New device support request
|
||||
description: Request support for a new device
|
||||
title: "[New device support]: "
|
||||
labels: [new device support]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**IMPORTANT:** Before submitting:
|
||||
- Make sure this device is not already supported in the [dev branch](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html)
|
||||
- Follow this [guide](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html)
|
||||
- If you are using the Home Assistant addon and are still on 1.18.1, check the first point of [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0)
|
||||
- type: input
|
||||
id: link
|
||||
attributes:
|
||||
label: Link
|
||||
description: Link of this device (product page)
|
||||
placeholder: https://www.linktomydevice.org
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: database
|
||||
attributes:
|
||||
label: Database entry
|
||||
description: Entry of this device in `data/database.db` after pairing it
|
||||
placeholder: '{"id":53,"type":"Router","ieeeAddr":"0x10458d00024284f69","nwkAddr":10148,"manufId":4151,"manufName":"LUMI","powerSource":"DC Source","modelId":"lumi.relay.c2acn01","epList":[1,2],"endpoints":{"1":{"profId":260,"epId":1,"devId":257,"inClusterList":[0,3,4,5,1,2,10,6,16,2820,12],"outClusterList":[25,10],"clusters":{"genBasic":{"attributes":{"modelId":"lumi.relay.c2acn01","appVersion":1,"manufacturerName":"LUMI","powerSource":4,"zclVersion":0,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020"}},"genAnalogInput":{"attributes":{"presentValue":129.04425048828125}},"genOnOff":{"attributes":{"61440":117440715,"onOff":1}}},"binds":[],"configuredReportings":[],"meta":{}},"2":{"profId":260,"epId":2,"devId":257,"inClusterList":[6,16,4,5],"outClusterList":[],"clusters":{"genOnOff":{"attributes":{"61440":237478966,"onOff":0}}},"binds":[],"configuredReportings":[],"meta":{}}},"appVersion":1,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020","zclVersion":0,"interviewCompleted":true,"meta":{},"lastSeen":1640285631405}'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
label: Comments
|
||||
placeholder: I tried to follow the supporting new device page but got stuck at...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: External converter
|
||||
description: In case you tried to create an external converter, put it below
|
||||
render: shell
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Lights
|
||||
In case this device is a light, fill in the fields below:
|
||||
- type: input
|
||||
attributes:
|
||||
label: Supported color modes
|
||||
placeholder: color temperature, color (rgb)
|
||||
- type: input
|
||||
attributes:
|
||||
label: Color temperature range
|
||||
description: In case this light supports color temperature, provide the color temperature range. [How to do this](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_3-1-retrieving-color-temperature-range-only-required-for-lights-which-support-color-temperature). This currently requires the latest [dev branch](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html).
|
||||
placeholder: '{"colorTempPhysicalMin":153,"colorTempPhysicalMax":454}'
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
name: Problem report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: problem
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
Before submitting an issue make sure you've searched for a similar issue and read the documentation: https://www.zigbee2mqtt.io/.
|
||||
|
||||
Rules (don't ignore these, your issue will be closed without further notice):
|
||||
- English only
|
||||
- Make sure you are on the latest Zigbee2MQTT version
|
||||
- Provide a clear description of the problem
|
||||
- DON'T copy logs directly here, post a link to https://hastebin.com/ or https://pastebin.com/.
|
||||
- Make sure you are running the latest firmware https://github.com/koenkk/z-stack-firmware.
|
||||
|
||||
Did you read the FAQ?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html
|
||||
|
||||
Zigbee2MQTT fails to start?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html#help-zigbee2mqtt-fails-to-start
|
||||
|
||||
Having issues when using a CC2531?
|
||||
- Make sure the CC2531 is connected through a USB extension cable
|
||||
- Try the source routing firmware: https://github.com/Koenkk/Z-Stack-firmware/tree/master/coordinator/Z-Stack_Home_1.2/bin/source_routing
|
||||
- With larger networks (30/40+) devices, your CC2531 might not be powerful enough. This will cause weird issues, in this case it's advised to use a more powerful adapter: https://www.zigbee2mqtt.io/information/supported_adapters.html#texas-instruments-cc26x2r1
|
||||
|
||||
Unsupported device?
|
||||
- https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html
|
||||
|
||||
Device does not pair or interview fails?
|
||||
- https://www.zigbee2mqtt.io/information/FAQ.html#why-does-my-device-not-or-fail-to-pair
|
||||
|
||||
Bug report?
|
||||
- If applicable, provide steps how to reproduce the problem.
|
||||
- Provide the herdsman debug logging: https://www.zigbee2mqtt.io/information/debug.html#zigbee-herdsman-debug-logging
|
||||
|
||||
-->
|
||||
|
||||
<!--Start your bug report below this line-->
|
||||
|
||||
## What happened
|
||||
|
||||
## What did you expect to happen
|
||||
|
||||
## How to reproduce it (minimal and precise)
|
||||
|
||||
## Debug info
|
||||
Zigbee2MQTT version:
|
||||
Adapter hardware: CC2531, CC2530, CC26X2R1, CC1352P-2, Conbee II
|
||||
Adapter firmware version:
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Problem report
|
||||
description: Create a report to help us improve
|
||||
title: "[Problem]: "
|
||||
labels: [problem]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**IMPORTANT:** Before submitting:
|
||||
- You read the [FAQ](https://www.zigbee2mqtt.io/guide/faq/)
|
||||
- Zigbee2MQTT fails to start? Read [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html)
|
||||
- Make sure the bug also occurs in the [dev branch](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html)
|
||||
- Make sure you are using the [latest firmware](https://www.zigbee2mqtt.io/guide/adapters/#recommended) on your adapter
|
||||
- The issue has not been [reported already](https://github.com/Koenkk/zigbee2mqtt/issues)
|
||||
- Is your issue related to the fronted? Then click [here](https://github.com/nurikk/zigbee2mqtt-frontend/issues/new?assignees=&labels=bug%2Ctriage&template=bug_report.yaml&title=%5BBug%5D%3A+)
|
||||
- If you are using the Home Assistant addon and are still on 1.18.1, check the first point of [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0)
|
||||
- type: textarea
|
||||
id: what_happend
|
||||
attributes:
|
||||
label: What happened?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expect_to_happen
|
||||
attributes:
|
||||
label: What did you expect to happen?
|
||||
placeholder: I expected that ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: How to reproduce it (minimal and precise)
|
||||
placeholder: First do this, than this..
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: z2m_version
|
||||
attributes:
|
||||
label: Zigbee2MQTT version
|
||||
description: Can be found in the frontend -> settings -> about -> Zigbee2MQTT version
|
||||
placeholder: '1.22.1'
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: adapter_fwversion
|
||||
attributes:
|
||||
label: Adapter firmware version
|
||||
description: Can be found in the frontend -> settings -> about -> coordinator revision
|
||||
placeholder: '20211210'
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: adapter
|
||||
attributes:
|
||||
label: Adapter
|
||||
description: The adapter you are using
|
||||
placeholder: Electrolama zig-a-zig-ah! (zzh!), Slaesh's CC2652RB stick, ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes:
|
||||
label: Debug log
|
||||
description: After enabling [debug logging](https://www.zigbee2mqtt.io/guide/usage/debug.html) the log can be found under `data/log`. Attach the file below
|
||||
placeholder: Click here and drag the file into it or click on "Attach files by.." below
|
||||
validations:
|
||||
required: true
|
||||
@@ -1 +0,0 @@
|
||||
preferredBranch: dev
|
||||
@@ -16,7 +16,15 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -16,7 +16,15 @@ jobs:
|
||||
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
|
||||
@@ -16,7 +16,15 @@ jobs:
|
||||
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
|
||||
+3
-3
@@ -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.
|
||||
- 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).
|
||||
- Easiest way to start developing Zigbee2MQTT is by setting up a development environment (aka bare-metal installation). You can follow this [guide](https://www.zigbee2mqtt.io/guide/installation/01_linux.html) to do this.
|
||||
- You can run the tests locally by executing `npm test`. Zigbee2MQTT enforces 100% code coverage, in case you add new code check if your code is covered by running `npm run test-with-coverage`. The coverage report can be found under `coverage/lcov-report/index.html`. Linting is also enforced and can be run with `npm run eslint`.
|
||||
- When you want to add support for a new devices no changes to Zigbee2MQTT have to be made, only to zigbee-herdsman-converters. You can find a guide for it [here](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html).
|
||||
|
||||
@@ -35,26 +35,26 @@
|
||||
</div>
|
||||
<h1>Zigbee2MQTT 🌉 🐝</h1>
|
||||
<p>
|
||||
Allows you to use your Zigbee devices <b>without</b> the vendors bridge or gateway.
|
||||
Allows you to use your Zigbee devices <b>without</b> the vendor's bridge or gateway.
|
||||
</p>
|
||||
<p>
|
||||
It bridges events and allows you to control your Zigbee devices via MQTT. In this way you can integrate your Zigbee devices with whatever smart home infrastructure you are using.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## [Getting started](https://www.zigbee2mqtt.io/#getting-started)
|
||||
## [Getting started](https://www.zigbee2mqtt.io/guide/getting-started)
|
||||
The [documentation](https://www.zigbee2mqtt.io/) provides you all the information needed to get up and running! Make sure you don't skip sections if this is your first visit, as there might be important details in there for you.
|
||||
|
||||
If you aren't familiar with **Zigbee** terminology make sure you [read this](https://www.zigbee2mqtt.io/information/zigbee_network.html) to help you out.
|
||||
If you aren't familiar with **Zigbee** terminology make sure you [read this](https://www.zigbee2mqtt.io/advanced/zigbee/01_zigbee_network.html) to help you out.
|
||||
|
||||
## [Integrations](https://www.zigbee2mqtt.io/#integration)
|
||||
## [Integrations](https://www.zigbee2mqtt.io/guide/usage/integrations.html)
|
||||
Zigbee2MQTT integrates well with (almost) every home automation solution because it uses MQTT. However the following integrations are worth mentioning:
|
||||
|
||||
<img align="left" height="100px" width="100px" src="https://user-images.githubusercontent.com/7738048/40914297-49e6e560-6800-11e8-8904-36cce896e5a8.png">
|
||||
|
||||
### [Home Assistant](https://www.home-assistant.io/)
|
||||
- [Home Assistant](https://www.home-assistant.io/hassio/): Using [the official addon](https://github.com/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,15 +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. Zigbee2MQTT provides a [web-based interface](https://github.com/nurikk/zigbee2mqtt-frontend) that allows monitoring and configuration.
|
||||
|
||||
### Developing
|
||||
Zigbee2MQTT uses TypeScript (partially for now). Therefore after making changes to files in the `lib/` directory you need to recompile Zigbee2MQTT. This can be done by executing `npm run build`. For faster development instead of running `npm run build` you can run `npm run build-watch` in another terminal session, this will recompile as you change files.
|
||||
|
||||
## Supported devices
|
||||
See [Supported devices](https://www.zigbee2mqtt.io/information/supported_devices.html) to check whether your device is supported. There is quite an extensive list, including devices from vendors like Xiaomi, Ikea, Philips, OSRAM and more.
|
||||
See [Supported devices](https://www.zigbee2mqtt.io/supported-devices) to check whether your device is supported. There is quite an extensive list, including devices from vendors like Xiaomi, Ikea, Philips, OSRAM and more.
|
||||
|
||||
If it's not listed in [Supported devices](https://www.zigbee2mqtt.io/information/supported_devices.html), support can be added (fairly) easy, see [How to support new devices](https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html).
|
||||
If it's not listed in [Supported devices](https://www.zigbee2mqtt.io/supported-devices), support can be added (fairly) easily, see [How to support new devices](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html).
|
||||
|
||||
## Support & help
|
||||
If you need assistance you can check [opened issues](https://github.com/Koenkk/zigbee2mqtt/issues). Feel free to help with Pull Requests when you were able to fix things or add new devices or just share the love on social media.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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';
|
||||
@@ -85,7 +87,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,442 +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 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 ExtensionAvailabilityLegacy = require('./extension/legacy/availability');
|
||||
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,
|
||||
ExtensionAvailabilityLegacy, ExtensionBind, ExtensionReport, ExtensionOnEvent, ExtensionOTAUpdate,
|
||||
ExtensionExternalConverters, ExtensionFrontend, ExtensionExternalExtension, ExtensionAvailability,
|
||||
];
|
||||
|
||||
class Controller {
|
||||
constructor(restartCallback, exitCallback) {
|
||||
// eslint-disable-next-line
|
||||
this.eventBus = new EventBus.default();
|
||||
// eslint-disable-next-line
|
||||
this.zigbee = new Zigbee.default(this.eventBus);
|
||||
// eslint-disable-next-line
|
||||
this.mqtt = new MQTT.default(this.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.enableDisableExtension, this.restartCallback, this.addExtension];
|
||||
this.extensions = [
|
||||
new ExtensionBridge(...args),
|
||||
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));
|
||||
}
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().experimental.availability_new) {
|
||||
this.extensions.push(new ExtensionAvailability(...args));
|
||||
} else {
|
||||
if (settings.get().advanced.availability_timeout) {
|
||||
this.extensions.push(new ExtensionAvailabilityLegacy(...args));
|
||||
}
|
||||
}
|
||||
|
||||
this.extensions.push(new ExtensionExternalExtension(...args));
|
||||
}
|
||||
|
||||
async start() {
|
||||
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.callExtensionMethod('onZigbeeStarted', []);
|
||||
this.eventBus.on('event', this.onZigbeeEvent.bind(this), this.constructor.name);
|
||||
this.eventBus.onAdapterDisconnected(this, this.onZigbeeAdapterDisconnected);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start zigbee');
|
||||
// eslint-disable-next-line
|
||||
logger.error('Check https://www.zigbee2mqtt.io/information/FAQ.html#help-zigbee2mqtt-fails-to-start for possible solutions');
|
||||
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.getClientsLegacy();
|
||||
logger.info(`Currently ${devices.length} devices are joined:`);
|
||||
for (const device of devices) {
|
||||
const entity = this.zigbee.resolveEntityLegacy(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.permitJoinLegacy(settings.get().permit_join);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to set permit join to ${settings.get().permit_join}`);
|
||||
}
|
||||
|
||||
// MQTT
|
||||
this.onMQTTMessage = this.onMQTTMessage.bind(this);
|
||||
this.eventBus.onMQTTMessage(this.constructor.name, this.onMQTTMessage);
|
||||
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.getClientsLegacy()) {
|
||||
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.getClientsLegacy()) {
|
||||
if (!settings.getDevice(device.ieeeAddr)) {
|
||||
settings.addDevice(device.ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
// Call extensions
|
||||
await this.callExtensionMethod('onMQTTConnected', []);
|
||||
await this.callExtensionMethod('start', []);
|
||||
}
|
||||
|
||||
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.enableDisableExtension, this.restartCallback, this.addExtension);
|
||||
|
||||
this.extensions.push(extension);
|
||||
await this.callExtensionMethod('onZigbeeStarted', [], [extension]);
|
||||
await this.callExtensionMethod('onMQTTConnected', [], [extension]);
|
||||
await this.callExtensionMethod('start', [], [extension]);
|
||||
}
|
||||
}
|
||||
|
||||
async addExtension(extension) {
|
||||
this.extensions.push(extension);
|
||||
await this.callExtensionMethod('onZigbeeStarted', [], [extension]);
|
||||
await this.callExtensionMethod('onMQTTConnected', [], [extension]);
|
||||
await this.callExtensionMethod('start', [], [extension]);
|
||||
}
|
||||
|
||||
async stop(reason=null) {
|
||||
// Call extensions
|
||||
await this.callExtensionMethod('stop', []);
|
||||
this.eventBus.removeListeners(this.constructor.name);
|
||||
|
||||
// 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.resolveEntityLegacy(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 if (type === 'deviceNetworkAddressChanged') {
|
||||
logger.debug(`Device '${name}' changed network address`);
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (type === 'deviceLeave') {
|
||||
const settingsDevice = settings.getDevice(data.ieeeAddr);
|
||||
logger.warn(`Device '${settingsDevice ? settingsDevice.friendlyName : data.ieeeAddr}' ` +
|
||||
`left the network`);
|
||||
this.state.remove(data.ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
// Call extensions
|
||||
await this.callExtensionMethod('onZigbeeEvent', [type, data, resolvedEntity]);
|
||||
|
||||
// In case this message is not handled by the receive extension and last_seen is enabled, publish
|
||||
// a message to update the last_seen.
|
||||
// TODO
|
||||
// if (name && !result[ExtensionReceive.prototype.constructor.name] &&
|
||||
// settings.get().advanced.last_seen !== 'disable' && type !== 'deviceLeave') {
|
||||
// this.publishEntityState(name, {});
|
||||
// }
|
||||
}
|
||||
|
||||
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.resolveEntityLegacy(IDorName);
|
||||
if (!resolvedEntity || !resolvedEntity.settings) {
|
||||
logger.error(`'${IDorName}' does not exist, skipping publish`);
|
||||
return;
|
||||
}
|
||||
|
||||
let messagePayload = {...payload};
|
||||
|
||||
// Update state cache with new state.
|
||||
const newState = this.state.set(resolvedEntity.settings.ID, payload, 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]);
|
||||
}
|
||||
|
||||
for (const extension of this.extensions) {
|
||||
if (extension.adjustMessagePayloadBeforePublish) {
|
||||
extension.adjustMessagePayloadBeforePublish(resolvedEntity, messagePayload);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
ieeeAddr: resolvedEntity.type === 'device' && resolvedEntity.device.ieeeAddr});
|
||||
}
|
||||
|
||||
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) {
|
||||
const result = {};
|
||||
for (const extension of extensions || this.extensions) {
|
||||
if (extension[method]) {
|
||||
try {
|
||||
result[extension.constructor.name] = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
for (const extension of this.extensions) {
|
||||
extension.adjustMessageBeforePublish?.(entity, message);
|
||||
}
|
||||
|
||||
// filter mqtt message attributes
|
||||
if (entity.settings.filtered_attributes) {
|
||||
entity.settings.filtered_attributes.forEach((a) => delete message[a]);
|
||||
}
|
||||
|
||||
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;
|
||||
+136
-108
@@ -1,139 +1,167 @@
|
||||
/* eslint-disable brace-style */
|
||||
import events from 'events';
|
||||
import * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
|
||||
import ExtensionTS from './extension/extensionts';
|
||||
events.captureRejections = true;
|
||||
|
||||
declare global {
|
||||
interface EventDeviceRenamed { device: Device, homeAssisantRename: boolean, from: string, to: string }
|
||||
// TODO: remove resolved entity, replace by Device
|
||||
interface EventDeviceRemoved { resolvedEntity: ResolvedEntity}
|
||||
type EventMQTTMessage = { topic: string, message: string };
|
||||
type EventMQTTMessagePublished = { topic: string, payload: string, options: {retain: boolean, qos: number} };
|
||||
type EventPermitJoinChanged = ZHEvents.PermitJoinChangedPayload;
|
||||
type EventLastSeenChanged = { device: Device };
|
||||
type EventDeviceNetworkAddressChanged = { device: Device };
|
||||
type EventDeviceAnnounce = { device: Device };
|
||||
type EventDeviceInterview = { device: Device, status: 'started' | 'successful' | 'failed' };
|
||||
type EventDeviceJoined = { device: Device };
|
||||
type EventReportingDisabled = { device: ZHDevice }; // TODO zhdevice -> device
|
||||
type EventDeviceLeave = { ieeeAddr: string };
|
||||
// TODO: remove resolved entity, replace by Group
|
||||
type EventGroupMembersChanged = { group: ResolvedEntity }; // TODO fill
|
||||
type EventPublishEntityState = {
|
||||
// TODO: remove resolved entity, replace by Device | Group and remove ieeeAddr
|
||||
messagePayload: KeyValue, entity: ResolvedEntity, stateChangeReason: 'publishDebounce', payload: KeyValue,
|
||||
ieeeAddr: string,
|
||||
};
|
||||
type EventDeviceMessage = {
|
||||
type: ZHEvents.MessagePayloadType;
|
||||
device: Device;
|
||||
endpoint: ZHEndpoint;
|
||||
linkquality: number;
|
||||
groupID: number;
|
||||
cluster: string | number;
|
||||
data: KeyValue | Array<string | number>;
|
||||
meta: {
|
||||
zclTransactionSequenceNumber?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type ListenerKey = string | ExtensionTS;
|
||||
// eslint-disable-next-line
|
||||
type ListenerKey = object;
|
||||
|
||||
export default class EventBus {
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
private callbacksByExtension: { [s: string]: { event: string, callback: (...args: any[]) => void }[] } = {};
|
||||
private callbacksByExtension: { [s: string]: { event: string, callback: (...args: unknown[]) => void }[] } = {};
|
||||
private emitter = new events.EventEmitter();
|
||||
|
||||
public emitAdapterDisconnected(): void {this.emitter.emit('adapterDisconnected');}
|
||||
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);}
|
||||
this.on('adapterDisconnected', callback, key);
|
||||
}
|
||||
|
||||
public emitPermitJoinChanged(data: EventPermitJoinChanged): void {this.emitter.emit('permitJoinChanged', data);}
|
||||
public onPermitJoinChanged(key: ListenerKey, callback: (data: EventPermitJoinChanged) => void): void {
|
||||
this.on('permitJoinChanged', 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: EventDeviceRenamed): void {this.emitter.emit('deviceRenamed', data);}
|
||||
public onDeviceRenamed(key: ListenerKey, callback: (data: EventDeviceRenamed) => void): void {
|
||||
this.on('deviceRenamed', 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: EventDeviceRemoved): void {this.emitter.emit('deviceRemoved', data);}
|
||||
public onDeviceRemoved(key: ListenerKey, callback: (data: EventDeviceRemoved) => void): void {
|
||||
this.on('deviceRemoved', 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: EventLastSeenChanged): void {this.emitter.emit('lastSeenChanged', data);}
|
||||
public onLastSeenChanged(key: ListenerKey, callback: (data: EventLastSeenChanged) => void): void {
|
||||
this.on('lastSeenChanged', 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: EventDeviceNetworkAddressChanged): void {
|
||||
this.emitter.emit('deviceNetworkAddressChanged', data);}
|
||||
public emitDeviceNetworkAddressChanged(data: eventdata.DeviceNetworkAddressChanged): void {
|
||||
this.emitter.emit('deviceNetworkAddressChanged', data);
|
||||
}
|
||||
public onDeviceNetworkAddressChanged(
|
||||
key: ListenerKey, callback: (data: EventDeviceNetworkAddressChanged) => void): void {
|
||||
this.on('deviceNetworkAddressChanged', callback, key);}
|
||||
key: ListenerKey, callback: (data: eventdata.DeviceNetworkAddressChanged) => void): void {
|
||||
this.on('deviceNetworkAddressChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceAnnounce(data: EventDeviceAnnounce): void {this.emitter.emit('deviceAnnounce', data);}
|
||||
public onDeviceAnnounce(key: ListenerKey, callback: (data: EventDeviceAnnounce) => void): void {
|
||||
this.on('deviceAnnounce', 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: EventDeviceInterview): void {this.emitter.emit('deviceInterview', data);}
|
||||
public onDeviceInterview(key: ListenerKey, callback: (data: EventDeviceInterview) => void): void {
|
||||
this.on('deviceInterview', 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: EventDeviceJoined): void {this.emitter.emit('deviceJoined', data);}
|
||||
public onDeviceJoined(key: ListenerKey, callback: (data: EventDeviceJoined) => void): void {
|
||||
this.on('deviceJoined', 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: EventDeviceLeave): void {this.emitter.emit('deviceLeave', data);}
|
||||
public onDeviceLeave(key: ListenerKey, callback: (data: EventDeviceLeave) => void): void {
|
||||
this.on('deviceLeave', callback, key);}
|
||||
public emitEntityOptionsChanged(data: eventdata.EntityOptionsChanged): void {
|
||||
this.emitter.emit('entityOptionsChanged', data);
|
||||
}
|
||||
public onEntityOptionsChanged(key: ListenerKey, callback: (data: eventdata.EntityOptionsChanged) => void): void {
|
||||
this.on('entityOptionsChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceMessage(data: EventDeviceMessage): void {this.emitter.emit('deviceMessage', data);}
|
||||
public onDeviceMessage(key: ListenerKey, callback: (data: EventDeviceMessage) => void): void {
|
||||
this.on('deviceMessage', 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 emitMQTTMessage(data: EventMQTTMessage): void {this.emitter.emit('mqttMessage', data);}
|
||||
public onMQTTMessage(key: ListenerKey, callback: (data: EventMQTTMessage) => void): void {
|
||||
this.on('mqttMessage', 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 emitMQTTMessagePublished(data: EventMQTTMessagePublished): void {
|
||||
this.emitter.emit('mqttMessagePublished', data);}
|
||||
public onMQTTMessagePublished(key: ListenerKey, callback: (data: EventMQTTMessagePublished) => void): void {
|
||||
this.on('mqttMessagePublished', 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 emitPublishEntityState(data: EventPublishEntityState): void {
|
||||
// this.emitter.emit('publishEntityState', data);}
|
||||
public onPublishEntityState(key: ListenerKey, callback: (data: EventPublishEntityState) => void): void {
|
||||
this.on('publishEntityState', 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 emitGroupMembersChanged(data: EventGroupMembersChanged): void {
|
||||
// this.emitter.emit('groupMembersChanged', data);}
|
||||
public onGroupMembersChanged(key: ListenerKey, callback: (data: EventGroupMembersChanged) => void): void {
|
||||
this.on('groupMembersChanged', 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 emitDevicesChanged(): void {this.emitter.emit('devicesChanged');}
|
||||
public onDevicesChanged(key: ListenerKey, callback: () => void): void {this.on('devicesChanged', 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 emitReportingDisabled(data: EventReportingDisabled): void {
|
||||
// this.emitter.emit('reportingDisabled', data);}
|
||||
public onReportingDisabled(key: ListenerKey, callback: (data: EventReportingDisabled) => void): void {
|
||||
this.on('reportingDisabled', callback, key);}
|
||||
public emitDevicesChanged(): void {
|
||||
this.emitter.emit('devicesChanged');
|
||||
}
|
||||
public onDevicesChanged(key: ListenerKey, callback: () => void): void {
|
||||
this.on('devicesChanged', callback, key);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
private on(event: string, callback: (...args: any[]) => void, key: ListenerKey): void {
|
||||
key = typeof key === 'string' ? key : key.constructor.name;
|
||||
if (!this.callbacksByExtension[key]) this.callbacksByExtension[key] = [];
|
||||
this.callbacksByExtension[key].push({event, callback});
|
||||
public emitScenesChanged(): void {
|
||||
this.emitter.emit('scenesChanged');
|
||||
}
|
||||
public onScenesChanged(key: ListenerKey, callback: () => void): void {
|
||||
this.on('scenesChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitReconfigure(data: eventdata.Reconfigure): void {
|
||||
this.emitter.emit('reconfigure', data);
|
||||
}
|
||||
public onReconfigure(key: ListenerKey, callback: (data: eventdata.Reconfigure) => void): void {
|
||||
this.on('reconfigure', callback, key);
|
||||
}
|
||||
|
||||
public emitStateChange(data: eventdata.StateChange): void {
|
||||
this.emitter.emit('stateChange', data);
|
||||
}
|
||||
public onStateChange(key: ListenerKey, callback: (data: eventdata.StateChange) => void): void {
|
||||
this.on('stateChange', callback, key);
|
||||
}
|
||||
|
||||
private on(event: string, callback: (...args: unknown[]) => void, key: ListenerKey): void {
|
||||
if (!this.callbacksByExtension[key.constructor.name]) this.callbacksByExtension[key.constructor.name] = [];
|
||||
this.callbacksByExtension[key.constructor.name].push({event, callback});
|
||||
this.emitter.on(event, callback);
|
||||
}
|
||||
|
||||
// TODO: remove
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
emit(event: string, ...args: any[]): void {
|
||||
this.emitter.emit(event, ...args);
|
||||
}
|
||||
|
||||
public removeListeners(key: ListenerKey): void {
|
||||
key = typeof key === 'string' ? key : key.constructor.name;
|
||||
this.callbacksByExtension[key]?.forEach((e) => this.emitter.removeListener(e.event, e.callback));
|
||||
this.callbacksByExtension[key.constructor.name]?.forEach(
|
||||
(e) => this.emitter.removeListener(e.event, e.callback));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import ExtensionTS from './extensionts';
|
||||
import Extension from './extension';
|
||||
import logger from '../util/logger';
|
||||
import {sleep, hours, minutes, seconds, isAvailabilityEnabledForDevice} from '../util/utils';
|
||||
import utils from '../util/utils';
|
||||
import * as settings from '../util/settings';
|
||||
import debounce from 'debounce';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
// TODO
|
||||
// - Enable for HA addon
|
||||
// - Add to setting schema (when old availability is removed)
|
||||
export default class Availability extends ExtensionTS {
|
||||
export default class Availability extends Extension {
|
||||
private timers: {[s: string]: NodeJS.Timeout} = {};
|
||||
private availabilityCache: {[s: string]: boolean} = {};
|
||||
private retrieveStateDebouncers: {[s: string]: () => void} = {};
|
||||
@@ -17,25 +14,25 @@ export default class Availability extends ExtensionTS {
|
||||
|
||||
private getTimeout(device: Device): number {
|
||||
if (typeof device.settings.availability === 'object' && device.settings.availability?.timeout != null) {
|
||||
return minutes(device.settings.availability.timeout);
|
||||
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 minutes(availabilitySettings[key]?.timeout);
|
||||
return utils.minutes(availabilitySettings[key]?.timeout);
|
||||
}
|
||||
|
||||
return key === 'active' ? minutes(10) : hours(25);
|
||||
return key === 'active' ? utils.minutes(10) : utils.hours(25);
|
||||
}
|
||||
|
||||
private isActiveDevice(device: Device): boolean {
|
||||
return (device.type === 'Router' && device.powerSource !== 'Battery') ||
|
||||
device.powerSource === 'Mains (single phase)';
|
||||
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.lastSeen;
|
||||
const ago = Date.now() - device.zh.lastSeen;
|
||||
return ago < this.getTimeout(device);
|
||||
}
|
||||
|
||||
@@ -46,10 +43,10 @@ export default class Availability extends ExtensionTS {
|
||||
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) + seconds(1));
|
||||
() => this.addToPingQueue(device), this.getTimeout(device) + utils.seconds(1));
|
||||
} else {
|
||||
this.timers[device.ieeeAddr] = setTimeout(
|
||||
() => this.publishAvailability(device, true), this.getTimeout(device) + seconds(1));
|
||||
() => this.publishAvailability(device, true), this.getTimeout(device) + utils.seconds(1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,15 +72,15 @@ export default class Availability extends ExtensionTS {
|
||||
try {
|
||||
// Enable recovery if device is marked as available and first ping fails.
|
||||
const disableRecovery = !(i == 1 && available);
|
||||
await device.ping(disableRecovery);
|
||||
await device.zh.ping(disableRecovery);
|
||||
pingedSuccessfully = true;
|
||||
logger.debug(`Succesfully pinged '${device.name}' (attempt ${i + 1}/${attempts})`);
|
||||
break;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ping '${device.name}' (attempt ${i + 1}/${attempts}, ${error.message})`);
|
||||
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 sleep(3);
|
||||
!lastAttempt && await utils.sleep(3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,22 +89,20 @@ export default class Availability extends ExtensionTS {
|
||||
this.removeFromPingQueue(device);
|
||||
|
||||
// Sleep 2 seconds before executing next ping
|
||||
await sleep(2);
|
||||
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: EventDeviceRenamed) =>
|
||||
this.publishAvailability(data.device, false, true));
|
||||
this.eventBus.onDeviceLeave(this, (data: EventDeviceLeave) => clearTimeout(this.timers[data.ieeeAddr]));
|
||||
this.eventBus.onDeviceAnnounce(this, (data: EventDeviceAnnounce) => this.retrieveState(data.device));
|
||||
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.getClients()) {
|
||||
if (isAvailabilityEnabledForDevice(device, settings.get())) {
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
if (utils.isAvailabilityEnabledForDevice(device, settings.get())) {
|
||||
// Publish initial availablility
|
||||
this.publishAvailability(device, true);
|
||||
|
||||
@@ -123,13 +118,13 @@ export default class Availability extends ExtensionTS {
|
||||
|
||||
private publishAvailability(device: Device, logLastSeen: boolean, forcePublish=false): void {
|
||||
if (logLastSeen) {
|
||||
const ago = Date.now() - device.lastSeen;
|
||||
const ago = Date.now() - device.zh.lastSeen;
|
||||
if (this.isActiveDevice(device)) {
|
||||
logger.debug(
|
||||
`Active device '${device.name}' was last seen '${(ago / minutes(1)).toFixed(2)}' minutes ago.`);
|
||||
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 / hours(1)).toFixed(2)}' hours ago.`);
|
||||
`Passive device '${device.name}' was last seen '${(ago / utils.hours(1)).toFixed(2)}' hours ago.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,8 +145,8 @@ export default class Availability extends ExtensionTS {
|
||||
this.mqtt.publish(topic, payload, {retain: true, qos: 0});
|
||||
}
|
||||
|
||||
@bind private onLastSeenChanged(data: EventLastSeenChanged): void {
|
||||
if (isAvailabilityEnabledForDevice(data.device, settings.get())) {
|
||||
@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);
|
||||
@@ -160,8 +155,8 @@ export default class Availability extends ExtensionTS {
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
super.stop();
|
||||
Object.values(this.timers).forEach((t) => clearTimeout(t));
|
||||
super.stop();
|
||||
}
|
||||
|
||||
private retrieveState(device: Device): void {
|
||||
@@ -169,26 +164,22 @@ export default class Availability extends ExtensionTS {
|
||||
* 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.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.ieeeAddr) || {}, mapped: device.definition});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read state of '${device.name}' after reconnect (${error.message})`);
|
||||
if (device.definition && !device.zh.interviewing && !this.retrieveStateDebouncers[device.ieeeAddr]) {
|
||||
this.retrieveStateDebouncers[device.ieeeAddr] = debounce(() => {
|
||||
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)));
|
||||
converter?.convertGet?.(device.endpoint(), keys[0],
|
||||
{message: this.state.get(device) || {}, mapped: device.definition})
|
||||
.catch((e) => {
|
||||
logger.error(`Failed to read state of '${device.name}' after reconnect (${e.message})`);
|
||||
});
|
||||
}
|
||||
}, seconds(2));
|
||||
}, utils.seconds(2));
|
||||
}
|
||||
|
||||
this.retrieveStateDebouncers[device.ieeeAddr]?.();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO_finished: : change class to export default
|
||||
module.exports = Availability;
|
||||
|
||||
@@ -1,36 +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'];
|
||||
|
||||
// See zigbee-herdsman-converters
|
||||
const defaultBindGroup = {type: 'group_number', ID: 901};
|
||||
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},
|
||||
],
|
||||
@@ -40,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': [
|
||||
@@ -57,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: {
|
||||
@@ -86,6 +99,7 @@ const pollOnMessage = [
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.ATMEL,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.MUELLER_LICHT_INT,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.TELINK,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -116,6 +130,7 @@ const pollOnMessage = [
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.ATMEL,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.MUELLER_LICHT_INT,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.TELINK,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -126,12 +141,12 @@ const pollOnMessage = [
|
||||
},
|
||||
read: {
|
||||
cluster: 'lightingColorCtrl',
|
||||
attributes: [],
|
||||
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) => {
|
||||
attributesForEndpoint: async (endpoint): Promise<string[]> => {
|
||||
const supportedAttrs = await getColorCapabilities(endpoint);
|
||||
const readAttrs = [];
|
||||
const readAttrs: string[] = [];
|
||||
supportedAttrs.colorXY && readAttrs.push('currentX', 'currentY');
|
||||
supportedAttrs.colorTemperature && readAttrs.push('colorTemperature');
|
||||
return readAttrs;
|
||||
@@ -142,33 +157,39 @@ const pollOnMessage = [
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.ATMEL,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.MUELLER_LICHT_INT,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.TELINK,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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.constructor.name);
|
||||
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;
|
||||
@@ -178,32 +199,33 @@ class Bind extends Extension {
|
||||
return {type, sourceKey, targetKey, clusters, skipDisableReporting};
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
const {type, sourceKey, targetKey, clusters, skipDisableReporting} = 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;
|
||||
message = utils.parseJSON(message, message);
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
|
||||
let error = null;
|
||||
const source = this.zigbee.resolveEntityLegacy(sourceKey);
|
||||
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.resolveEntityLegacy(targetKey);
|
||||
const responseData = {from: sourceKey, to: targetKey};
|
||||
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.
|
||||
@@ -211,34 +233,34 @@ class Bind extends Extension {
|
||||
if (clusters && !clusters.includes(cluster)) continue;
|
||||
let matchingClusters = false;
|
||||
|
||||
const anyClusterValid = target.type === 'group' || target.type === 'group_number' ||
|
||||
target.device.type === 'Coordinator';
|
||||
const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' ||
|
||||
(target as Device).zh.type === 'Coordinator';
|
||||
|
||||
if (!anyClusterValid) {
|
||||
matchingClusters = ((target.endpoint.supportsInputCluster(cluster) &&
|
||||
source.endpoint.supportsOutputCluster(cluster)) ||
|
||||
(source.endpoint.supportsInputCluster(cluster) &&
|
||||
target.endpoint.supportsOutputCluster(cluster)) );
|
||||
if (!anyClusterValid && utils.isEndpoint(bindTarget)) {
|
||||
matchingClusters = ((bindTarget.supportsInputCluster(cluster) &&
|
||||
bindSource.supportsOutputCluster(cluster)) ||
|
||||
(bindSource.supportsInputCluster(cluster) &&
|
||||
bindTarget.supportsOutputCluster(cluster)) );
|
||||
}
|
||||
|
||||
const sourceValid = source.endpoint.supportsInputCluster(cluster) ||
|
||||
source.endpoint.supportsOutputCluster(cluster);
|
||||
const sourceValid = bindSource.supportsInputCluster(cluster) ||
|
||||
bindSource.supportsOutputCluster(cluster);
|
||||
|
||||
if ( sourceValid && (anyClusterValid || matchingClusters)) {
|
||||
logger.debug(`${type}ing cluster '${cluster}' from '${sourceName}' to '${targetName}'`);
|
||||
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 */
|
||||
@@ -246,14 +268,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 */
|
||||
@@ -261,7 +283,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}}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -269,14 +291,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) {
|
||||
@@ -288,15 +310,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') && !skipDisableReporting) {
|
||||
} 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));
|
||||
@@ -305,15 +327,15 @@ class Bind extends Extension {
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
} else {
|
||||
this.eventBus.emit(`devicesChanged`);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
}
|
||||
}
|
||||
|
||||
async groupMembersChanged(data) {
|
||||
@bind async onGroupMembersChanged(data: eventdata.GroupMembersChanged): Promise<void> {
|
||||
if (data.action === 'add') {
|
||||
const bindsToGroup = this.zigbee.getClientsLegacy().map((c) => c.endpoints)
|
||||
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.group);
|
||||
.reduce((a, v) => a.concat(v)).filter((b) => b.target === data.group.zh);
|
||||
await this.setupReporting(bindsToGroup);
|
||||
} else { // action === remove/remove_all
|
||||
if (!data.skipDisableReporting) {
|
||||
@@ -322,8 +344,8 @@ class Bind extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -332,22 +354,23 @@ class Bind extends Extension {
|
||||
});
|
||||
}
|
||||
|
||||
async setupReporting(binds) {
|
||||
const coordinator = this.zigbee.getDevicesByTypeLegacy('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.resolveEntityLegacy(endpoint.getDevice()).name}/${endpoint.ID}`;
|
||||
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) {
|
||||
@@ -356,37 +379,40 @@ class Bind extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.emit(`devicesChanged`);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
}
|
||||
|
||||
async disableUnnecessaryReportings(target) {
|
||||
const coordinator = this.zigbee.getDevicesByTypeLegacy('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.resolveEntityLegacy(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.getClientsLegacy().map((c) => c.endpoints)
|
||||
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 (bind.target.constructor.name === 'Group') {
|
||||
return bind.target.members.includes(endpoint);
|
||||
} else {
|
||||
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) {
|
||||
@@ -394,17 +420,11 @@ class Bind extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.emit('reportingDisabled', {device: endpoint.getDevice()});
|
||||
this.eventBus.emitReconfigure({device});
|
||||
}
|
||||
}
|
||||
|
||||
async onZigbeeEvent(type, data, resolvedEntity) {
|
||||
if (type === 'message') {
|
||||
this.poll(data);
|
||||
}
|
||||
}
|
||||
|
||||
async poll(message) {
|
||||
@bind async poll(data: eventdata.DeviceMessage): Promise<void> {
|
||||
/**
|
||||
* This method poll bound endpoints and group members for state changes.
|
||||
*
|
||||
@@ -414,27 +434,24 @@ 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.getGroupByIDLegacy(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) {
|
||||
@@ -449,7 +466,7 @@ class Bind extends Extension {
|
||||
readAttrs = [...poll.read.attributes, ...attrsForEndpoint];
|
||||
}
|
||||
|
||||
const key = `${endpoint.deviceIeeeAddress}_${endpoint.ID}_${pollOnMessage.indexOf(poll)}`;
|
||||
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, readAttrs);
|
||||
@@ -462,5 +479,3 @@ class Bind extends Extension {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Bind;
|
||||
+80
-50
@@ -1,22 +1,27 @@
|
||||
/* eslint-disable camelcase */
|
||||
import logger from '../util/logger';
|
||||
import * as utils from '../util/utils';
|
||||
import utils from '../util/utils';
|
||||
import * as settings from '../util/settings';
|
||||
import Transport from 'winston-transport';
|
||||
import bind from 'bind-decorator';
|
||||
// @ts-ignore
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import {detailedDiff} from 'deep-object-diff';
|
||||
import ExtensionTS from './extensionts';
|
||||
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 ExtensionTS {
|
||||
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: CoordinatorVersion;
|
||||
private coordinatorVersion: zh.CoordinatorVersion;
|
||||
private restartRequired = false;
|
||||
private lastJoinedDeviceIeeeAddr: string;
|
||||
private requestLookup: {[key: string]: (message: KeyValue | string) => Promise<MQTTResponse>};
|
||||
@@ -53,7 +58,7 @@ class Bridge extends ExtensionTS {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
// @ts-ignore TODO fix typing
|
||||
|
||||
logger.addTransport(new EventTransport());
|
||||
|
||||
this.zigbee2mqttVersion = await utils.getZigbee2MQTTVersion();
|
||||
@@ -63,6 +68,10 @@ class Bridge extends ExtensionTS {
|
||||
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> =>
|
||||
@@ -74,8 +83,7 @@ class Bridge extends ExtensionTS {
|
||||
});
|
||||
this.eventBus.onDeviceLeave(this, (data) => {
|
||||
this.publishDevices();
|
||||
publishEvent('device_leave',
|
||||
{ieee_address: data.ieeeAddr, friendly_name: settings.getDevice(data.ieeeAddr)?.friendlyName});
|
||||
publishEvent('device_leave', {ieee_address: data.ieeeAddr, friendly_name: data.name});
|
||||
});
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, () => this.publishDevices());
|
||||
this.eventBus.onDeviceInterview(this, (data) => {
|
||||
@@ -97,13 +105,10 @@ class Bridge extends ExtensionTS {
|
||||
await this.publishDevices();
|
||||
await this.publishGroups();
|
||||
|
||||
|
||||
this.onMQTTMessage_ = this.onMQTTMessage_.bind(this);
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage_);
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
// TODO remove trailing _
|
||||
@bind async onMQTTMessage_(data: EventMQTTMessage): Promise<void> {
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(requestRegex);
|
||||
const key = match?.[1]?.toLowerCase();
|
||||
if (key in this.requestLookup) {
|
||||
@@ -114,6 +119,7 @@ class Bridge extends ExtensionTS {
|
||||
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
|
||||
} catch (error) {
|
||||
logger.error(`Request '${data.topic}' failed with error: '${error.message}'`);
|
||||
logger.debug(error.stack);
|
||||
const response = utils.getResponse(message, {}, error.message);
|
||||
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
|
||||
}
|
||||
@@ -179,7 +185,6 @@ class Bridge extends ExtensionTS {
|
||||
}
|
||||
|
||||
if (newSettings.hasOwnProperty('advanced') && newSettings.advanced.hasOwnProperty('log_level')) {
|
||||
// @ts-ignore fix type
|
||||
logger.setLevel(newSettings.advanced.log_level);
|
||||
}
|
||||
|
||||
@@ -210,7 +215,7 @@ class Bridge extends ExtensionTS {
|
||||
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);
|
||||
}
|
||||
|
||||
@bind async deviceRename(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
@@ -305,12 +310,11 @@ class Bridge extends ExtensionTS {
|
||||
// Deprecated
|
||||
@bind async configLogLevel(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
const allowed = ['error', 'warn', 'info', 'debug'];
|
||||
const value = this.getValue(message);
|
||||
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}`);
|
||||
}
|
||||
|
||||
// @ts-ignore TODO fix type
|
||||
logger.setLevel(value);
|
||||
this.publishInfo();
|
||||
return utils.getResponse(message, {value}, null);
|
||||
@@ -390,11 +394,12 @@ class Bridge extends ExtensionTS {
|
||||
const entity = this.getEntity(entityType, ID);
|
||||
const oldOptions = objectAssignDeep({}, cleanup(entity.settings));
|
||||
settings.changeEntityOptions(ID, message.options);
|
||||
const newOptions = cleanup(settings.getEntity(ID));
|
||||
const newOptions = cleanup(entity.settings);
|
||||
await this.publishInfo();
|
||||
|
||||
logger.info(`Changed config for ${entityType} ${ID}`);
|
||||
|
||||
this.eventBus.emitEntityOptionsChanged({from: oldOptions, to: newOptions, entity});
|
||||
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID}, null);
|
||||
}
|
||||
|
||||
@@ -408,7 +413,7 @@ class Bridge extends ExtensionTS {
|
||||
const parsedID = utils.parseEntityID(message.id);
|
||||
const endpoint = (this.getEntity('device', parsedID.ID) as Device).endpoint(parsedID.endpoint);
|
||||
|
||||
const coordinatorEndpoint = this.zigbee.getFirstCoordinatorEndpoint();
|
||||
const coordinatorEndpoint = this.zigbee.firstCoordinatorEndpoint();
|
||||
await endpoint.bind(message.cluster, coordinatorEndpoint);
|
||||
|
||||
await endpoint.configureReporting(message.cluster, [{
|
||||
@@ -443,7 +448,7 @@ class Bridge extends ExtensionTS {
|
||||
const homeAssisantRename = message.hasOwnProperty('homeassistant_rename') ?
|
||||
message.homeassistant_rename : false;
|
||||
const entity = this.getEntity(entityType, from);
|
||||
const oldFriendlyName = entity.settings.friendlyName;
|
||||
const oldFriendlyName = entity.settings.friendly_name;
|
||||
|
||||
settings.changeFriendlyName(from, to);
|
||||
|
||||
@@ -452,14 +457,14 @@ class Bridge extends ExtensionTS {
|
||||
|
||||
if (entity instanceof Device) {
|
||||
this.publishDevices();
|
||||
this.eventBus.emit(`deviceRenamed`, {device: entity, homeAssisantRename, from: oldFriendlyName, to});
|
||||
this.eventBus.emitDeviceRenamed({device: entity, homeAssisantRename, from: oldFriendlyName, to});
|
||||
} else {
|
||||
this.publishGroups();
|
||||
this.publishInfo();
|
||||
}
|
||||
|
||||
// Repulish entity state
|
||||
this.publishEntityState(to, {});
|
||||
this.publishEntityState(entity, {});
|
||||
|
||||
return utils.getResponse(
|
||||
message,
|
||||
@@ -488,28 +493,31 @@ class Bridge extends ExtensionTS {
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`Removing ${entityType} '${entity.settings.friendlyName}'${blockForceLog}`);
|
||||
logger.info(`Removing ${entityType} '${entity.name}'${blockForceLog}`);
|
||||
const ieeeAddr = entity.isDevice() && entity.ieeeAddr;
|
||||
const name = entity.name;
|
||||
|
||||
if (entity instanceof Device) {
|
||||
if (block) {
|
||||
settings.blockDevice(entity.ieeeAddr);
|
||||
}
|
||||
|
||||
if (force) {
|
||||
await entity.zhDevice.removeFromDatabase();
|
||||
await entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
await entity.zhDevice.removeFromNetwork();
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
} else {
|
||||
if (force) {
|
||||
entity.zhGroup.removeFromDatabase();
|
||||
entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
await entity.zhGroup.removeFromNetwork();
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
}
|
||||
|
||||
// Fire event
|
||||
if (entity instanceof Device) {
|
||||
this.eventBus.emit('deviceRemoved', {resolvedEntity: entity});
|
||||
this.eventBus.emitDeviceRemoved({ieeeAddr, name});
|
||||
}
|
||||
|
||||
// Remove from configuration.yaml
|
||||
@@ -558,9 +566,11 @@ class Bridge extends ExtensionTS {
|
||||
const payload = {
|
||||
version: this.zigbee2mqttVersion.version,
|
||||
commit: this.zigbee2mqttVersion.commitHash,
|
||||
coordinator: this.coordinatorVersion,
|
||||
coordinator: {
|
||||
ieee_address: this.zigbee.firstCoordinatorEndpoint().getDevice().ieeeAddr,
|
||||
...this.coordinatorVersion,
|
||||
},
|
||||
network: utils.toSnakeCase(await this.zigbee.getNetworkParameters()),
|
||||
// @ts-ignore TODO fix type
|
||||
log_level: logger.getLevel(),
|
||||
permit_join: this.zigbee.getPermitJoin(),
|
||||
permit_join_timeout: this.zigbee.getPermitJoinTimeout(),
|
||||
@@ -573,18 +583,38 @@ class Bridge extends ExtensionTS {
|
||||
'bridge/info', stringify(payload), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
}
|
||||
|
||||
private getScenes(entity: zh.Endpoint | zh.Group): Scene[] {
|
||||
const scenes: {[id: number]: Scene} = {};
|
||||
const endpoints = utils.isEndpoint(entity) ? [entity] : entity.members;
|
||||
const groupID = utils.isEndpoint(entity) ? 0 : entity.groupID;
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
for (const [key, data] of Object.entries(endpoint.meta?.scenes || {})) {
|
||||
const split = key.split('_');
|
||||
const sceneID = parseInt(split[0], 10);
|
||||
const sceneGroupID = parseInt(split[1], 10);
|
||||
if (sceneGroupID === groupID) {
|
||||
scenes[sceneID] = {id: sceneID, name: (data as KeyValue).name || `Scene ${sceneID}`};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(scenes);
|
||||
}
|
||||
|
||||
async publishDevices(): Promise<void> {
|
||||
interface Data {
|
||||
bindings: {cluster: string, target: {type: string, endpoint?: number, ieee_address?: string, id?: number}}[]
|
||||
configured_reportings: {cluster: string, attribute: string | number,
|
||||
minimum_report_interval: number, maximum_report_interval: number, reportable_change: number}[],
|
||||
clusters: {input: string[], output: string[]}
|
||||
clusters: {input: string[], output: string[]}, scenes: Scene[]
|
||||
}
|
||||
|
||||
const devices = this.zigbee.getDevices().map((device) => {
|
||||
const devices = this.zigbee.devices().map((device) => {
|
||||
const endpoints: {[s: number]: Data} = {};
|
||||
for (const endpoint of device.endpoints) {
|
||||
for (const endpoint of device.zh.endpoints) {
|
||||
const data: Data = {
|
||||
scenes: this.getScenes(endpoint),
|
||||
bindings: [],
|
||||
configured_reportings: [],
|
||||
clusters: {
|
||||
@@ -615,18 +645,18 @@ class Bridge extends ExtensionTS {
|
||||
|
||||
return {
|
||||
ieee_address: device.ieeeAddr,
|
||||
type: device.type,
|
||||
network_address: device.networkAddress,
|
||||
type: device.zh.type,
|
||||
network_address: device.zh.networkAddress,
|
||||
supported: !!device.definition,
|
||||
friendly_name: device.name,
|
||||
definition: this.getDefinitionPayload(device),
|
||||
power_source: device.powerSource,
|
||||
software_build_id: device.softwareBuildID,
|
||||
date_code: device.dateCode,
|
||||
model_id: device.modelID,
|
||||
interviewing: device.interviewing,
|
||||
interview_completed: device.interviewCompleted,
|
||||
manufacturer: device.manufacturerName,
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -636,11 +666,12 @@ class Bridge extends ExtensionTS {
|
||||
}
|
||||
|
||||
async publishGroups(): Promise<void> {
|
||||
const groups = this.zigbee.getGroups().map((g) => {
|
||||
const groups = this.zigbee.groups().map((g) => {
|
||||
return {
|
||||
id: g.ID,
|
||||
friendly_name: g.ID === 901 ? 'default_bind_group' : g.name,
|
||||
members: g.members.map((e) => {
|
||||
scenes: this.getScenes(g.zh),
|
||||
members: g.zh.members.map((e) => {
|
||||
return {ieee_address: e.getDevice().ieeeAddr, endpoint: e.ID};
|
||||
}),
|
||||
};
|
||||
@@ -649,23 +680,22 @@ class Bridge extends ExtensionTS {
|
||||
'bridge/groups', stringify(groups), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
}
|
||||
|
||||
getDefinitionPayload(device: Device):
|
||||
{model: string, vendor: string, description: string, exposes: unknown[], supports_ota: boolean, icon: string} {
|
||||
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.modelID));
|
||||
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,
|
||||
exposes: device.exposes(),
|
||||
supports_ota: !!device.definition.ota,
|
||||
options: device.definition.options,
|
||||
icon,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Bridge;
|
||||
|
||||
+23
-30
@@ -1,37 +1,32 @@
|
||||
import * as settings from '../util/settings';
|
||||
import * as utils from '../util/utils';
|
||||
import utils from '../util/utils';
|
||||
import logger from '../util/logger';
|
||||
// @ts-ignore
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
// @ts-ignore
|
||||
import zhc from 'zigbee-herdsman-converters';
|
||||
import ExtensionTS from './extensionts';
|
||||
import Extension from './extension';
|
||||
import bind from 'bind-decorator';
|
||||
import Device from '../model/device';
|
||||
|
||||
/**
|
||||
* This extension calls the zigbee-herdsman-converters definition configure() method
|
||||
*/
|
||||
class Configure extends ExtensionTS {
|
||||
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: EventReportingDisabled): Promise<void> {
|
||||
@bind private async onReconfigure(data: eventdata.Reconfigure): Promise<void> {
|
||||
// Disabling reporting unbinds some cluster which could be bound by configure, re-setup.
|
||||
const device = this.zigbee.resolveEntity(data.device) as Device; // TODO assert device
|
||||
|
||||
if (device.zhDevice.meta?.hasOwnProperty('configured')) {
|
||||
delete device.zhDevice.meta.configured;
|
||||
device.zhDevice.save();
|
||||
if (data.device.zh.meta?.hasOwnProperty('configured')) {
|
||||
delete data.device.zh.meta.configured;
|
||||
data.device.zh.save();
|
||||
}
|
||||
|
||||
await this.configure(device, 'reporting_disabled');
|
||||
await this.configure(data.device, 'reporting_disabled');
|
||||
}
|
||||
|
||||
// TODO remove trailing _
|
||||
@bind private async onMQTTMessage_(data: EventMQTTMessage): Promise<void> {
|
||||
@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)) {
|
||||
@@ -69,37 +64,38 @@ class Configure extends ExtensionTS {
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
await this.configure(device, 'started');
|
||||
}
|
||||
|
||||
this.eventBus.onDeviceJoined(this, (data) => {
|
||||
if (data.device.zhDevice.meta.hasOwnProperty('configured')) {
|
||||
delete data.device.zhDevice.meta.configured;
|
||||
data.device.zhDevice.save();
|
||||
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);
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.eventBus.onReconfigure(this, this.onReconfigure);
|
||||
}
|
||||
|
||||
private async configure(device: Device, event: 'started' | 'zigbee_event' | 'reporting_disabled' | 'mqtt_message',
|
||||
force=false, thowError=false): Promise<void> {
|
||||
if (!force) {
|
||||
if (!device.definition?.configure || device.zhDevice.interviewing) {
|
||||
if (!device.definition?.configure || device.zh.interviewing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.zhDevice.meta?.hasOwnProperty('configured') &&
|
||||
device.zhDevice.meta.configured === zhc.getConfigureKey(device.definition)) {
|
||||
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.zhDevice.type === 'EndDevice' && event !== 'zigbee_event') {
|
||||
if (device.zh.type === 'EndDevice' && event !== 'zigbee_event') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -116,12 +112,11 @@ class Configure extends ExtensionTS {
|
||||
|
||||
logger.info(`Configuring '${device.name}'`);
|
||||
try {
|
||||
await device.definition.configure(device.zhDevice, this.zigbee.getFirstCoordinatorEndpoint(), logger);
|
||||
await device.definition.configure(device.zh, this.zigbee.firstCoordinatorEndpoint(), logger);
|
||||
logger.info(`Successfully configured '${device.name}'`);
|
||||
device.zhDevice.meta.configured = zhc.getConfigureKey(device.definition);
|
||||
device.zhDevice.save();
|
||||
device.zh.meta.configured = zhc.getConfigureKey(device.definition);
|
||||
device.zh.save();
|
||||
this.eventBus.emitDevicesChanged();
|
||||
this.eventBus.emit(`devicesChanged`);
|
||||
} catch (error) {
|
||||
this.attempts[device.ieeeAddr]++;
|
||||
const attempt = this.attempts[device.ieeeAddr];
|
||||
@@ -136,5 +131,3 @@ class Configure extends ExtensionTS {
|
||||
this.configuring.delete(device.ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Configure;
|
||||
|
||||
@@ -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.resolveEntityLegacy()
|
||||
* @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.removeListeners(this.constructor.name);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Extension;
|
||||
@@ -1,13 +1,12 @@
|
||||
// TODO: tempState -> State, rename to extension
|
||||
abstract class ExtensionTS {
|
||||
abstract class Extension {
|
||||
protected zigbee: Zigbee;
|
||||
protected mqtt: MQTT;
|
||||
protected state: TempState;
|
||||
protected state: State;
|
||||
protected publishEntityState: PublishEntityState;
|
||||
protected eventBus: EventBus;
|
||||
protected enableDisableExtension: (enable: boolean, name: string) => Promise<void>;
|
||||
protected restartCallback: () => void;
|
||||
protected addExtension: (extension: ExternalConverterClass) => void;
|
||||
protected addExtension: (extension: Extension) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Besides intializing variables, the constructor should do nothing!
|
||||
@@ -21,9 +20,9 @@ abstract class ExtensionTS {
|
||||
* @param {restartCallback} restartCallback Restart Zigbee2MQTT
|
||||
* @param {addExtension} addExtension Add an extension
|
||||
*/
|
||||
constructor(zigbee: Zigbee, mqtt: MQTT, state: TempState, publishEntityState: PublishEntityState,
|
||||
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => void, addExtension: (extension: ExternalConverterClass) => void) {
|
||||
restartCallback: () => void, addExtension: (extension: Extension) => Promise<void>) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.state = state;
|
||||
@@ -46,6 +45,9 @@ abstract class ExtensionTS {
|
||||
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 ExtensionTS;
|
||||
export default Extension;
|
||||
@@ -1,17 +1,18 @@
|
||||
// @ts-ignore
|
||||
import zhc from 'zigbee-herdsman-converters';
|
||||
import * as settings from '../util/settings';
|
||||
import * as utils from '../util/utils';
|
||||
import ExtensionTS from './extensionts';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
|
||||
class ExternalConverters extends ExtensionTS {
|
||||
override async start(): Promise<void> {
|
||||
for (const definition of utils.getExternalConvertersDefinitions(settings)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ExternalConverters;
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import * as settings from '../util/settings';
|
||||
import * as utils from '../util/utils';
|
||||
import utils from '../util/utils';
|
||||
import fs from 'fs';
|
||||
import * as data from './../util/data';
|
||||
import data from './../util/data';
|
||||
import path from 'path';
|
||||
import logger from './../util/logger';
|
||||
// @ts-ignore
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import bind from 'bind-decorator';
|
||||
import ExtensionTS from './extensionts';
|
||||
import Extension from './extension';
|
||||
|
||||
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/extension/(save|remove)`);
|
||||
|
||||
class ExternalExtension extends ExtensionTS {
|
||||
private requestLookup: {[s: string]: (message: KeyValue) => MQTTResponse};
|
||||
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.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.requestLookup = {'save': this.saveExtension, 'remove': this.removeExtension};
|
||||
this.loadUserDefinedExtensions();
|
||||
await this.publishExtensions();
|
||||
@@ -37,13 +36,13 @@ class ExternalExtension extends ExtensionTS {
|
||||
}
|
||||
}
|
||||
|
||||
@bind private removeExtension(message: KeyValue): MQTTResponse {
|
||||
@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 extensionFilePath = path.join(basePath, path.basename(name));
|
||||
fs.unlinkSync(extensionFilePath);
|
||||
@@ -55,10 +54,10 @@ class ExternalExtension extends ExtensionTS {
|
||||
}
|
||||
}
|
||||
|
||||
@bind private saveExtension(message: KeyValue): MQTTResponse {
|
||||
@bind private async saveExtension(message: KeyValue): Promise<MQTTResponse> {
|
||||
const {name, code} = message;
|
||||
const ModuleConstructor = utils.loadModuleFromText(code) as ExternalConverterClass;
|
||||
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)) {
|
||||
@@ -71,12 +70,12 @@ class ExternalExtension extends ExtensionTS {
|
||||
return utils.getResponse(message, {}, null);
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage_(data: EventMQTTMessage): Promise<void> {
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(requestRegex);
|
||||
if (match && this.requestLookup[match[1].toLowerCase()]) {
|
||||
const message = utils.parseJSON(data.message, data.message) as KeyValue;
|
||||
try {
|
||||
const response = this.requestLookup[match[1].toLowerCase()](message);
|
||||
const response = await this.requestLookup[match[1].toLowerCase()](message);
|
||||
await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response));
|
||||
} catch (error) {
|
||||
logger.error(`Request '${data.topic}' failed with error: '${error.message}'`);
|
||||
@@ -86,10 +85,11 @@ class ExternalExtension extends ExtensionTS {
|
||||
}
|
||||
}
|
||||
|
||||
@bind private loadExtension(ConstructorClass: ExternalConverterClass): void {
|
||||
this.enableDisableExtension(false, ConstructorClass.name);
|
||||
this.addExtension(new ConstructorClass(
|
||||
this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus, 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));
|
||||
}
|
||||
|
||||
private loadUserDefinedExtensions(): void {
|
||||
@@ -107,5 +107,3 @@ class ExternalExtension extends ExtensionTS {
|
||||
}, settings.get().mqtt.base_topic, true);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ExternalExtension;
|
||||
|
||||
+24
-26
@@ -1,23 +1,21 @@
|
||||
import http from 'http';
|
||||
import serveStatic from 'serve-static';
|
||||
import gzipStatic, {RequestHandler} from 'connect-gzip-static';
|
||||
import finalhandler from 'finalhandler';
|
||||
import logger from '../util/logger';
|
||||
// @ts-ignore
|
||||
import frontend from 'zigbee2mqtt-frontend';
|
||||
import WebSocket from 'ws';
|
||||
import net from 'net';
|
||||
import url from 'url';
|
||||
import * as settings from '../util/settings';
|
||||
import * as utils from '../util/utils';
|
||||
// @ts-ignore
|
||||
import utils from '../util/utils';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import ExtensionTS from './extensionts';
|
||||
import Extension from './extension';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
/**
|
||||
* This extension servers the frontend
|
||||
*/
|
||||
class Frontend extends ExtensionTS {
|
||||
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;
|
||||
@@ -25,12 +23,12 @@ class Frontend extends ExtensionTS {
|
||||
private retainedMessages = new Map();
|
||||
private server: http.Server;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private fileServer: serveStatic.RequestHandler<any>;
|
||||
private fileServer: RequestHandler;
|
||||
private wss: WebSocket.Server = null;
|
||||
|
||||
constructor(zigbee: Zigbee, mqtt: MQTT, state: TempState, publishEntityState: PublishEntityState,
|
||||
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => void, addExtension: (extension: ExternalConverterClass) => void) {
|
||||
restartCallback: () => void, addExtension: (extension: Extension) => Promise<void>) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
|
||||
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
|
||||
}
|
||||
@@ -39,14 +37,16 @@ class Frontend extends ExtensionTS {
|
||||
this.server = http.createServer(this.onRequest);
|
||||
this.server.on('upgrade', this.onUpgrade);
|
||||
|
||||
/* istanbul ignore next */ // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const options = {setHeaders: (res: any, path: any): void => {
|
||||
if (path.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
}};
|
||||
|
||||
this.fileServer = serveStatic(frontend.getPath(), options);
|
||||
/* 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);
|
||||
|
||||
@@ -99,27 +99,27 @@ class Frontend extends ExtensionTS {
|
||||
ws.send(stringify({topic: key, payload: value}));
|
||||
}
|
||||
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
let payload: KeyValue = {};
|
||||
if (this.state.exists(device.ieeeAddr)) {
|
||||
payload = {...payload, ...this.state.get(device.ieeeAddr)};
|
||||
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.lastSeen, lastSeen);
|
||||
payload.last_seen = utils.formatDate(device.zh.lastSeen, lastSeen);
|
||||
}
|
||||
|
||||
if (device.zhDevice.linkquality !== undefined) {
|
||||
payload.linkquality = device.zhDevice.linkquality;
|
||||
if (device.zh.linkquality !== undefined) {
|
||||
payload.linkquality = device.zh.linkquality;
|
||||
}
|
||||
|
||||
ws.send(stringify({topic: device.name, payload}));
|
||||
}
|
||||
}
|
||||
|
||||
@bind private onMQTTPublishMessage(data: EventMQTTMessagePublished): void {
|
||||
@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);
|
||||
@@ -139,5 +139,3 @@ class Frontend extends ExtensionTS {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Frontend;
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const Extension = require('./extension');
|
||||
const utils = require('../util/utils');
|
||||
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$`);
|
||||
|
||||
const stateProperties = {
|
||||
'state': (value, definition) => 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')) )),
|
||||
};
|
||||
|
||||
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, this.constructor.name);
|
||||
await this.syncGroupsWithSettings();
|
||||
}
|
||||
|
||||
async syncGroupsWithSettings() {
|
||||
const settingsGroups = settings.getGroups();
|
||||
const zigbeeGroups = this.zigbee.getGroupsLegacy();
|
||||
|
||||
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}'`);
|
||||
logger.debug(error.stack);
|
||||
}
|
||||
};
|
||||
|
||||
for (const settingGroup of settingsGroups) {
|
||||
const groupID = settingGroup.ID;
|
||||
const zigbeeGroup = zigbeeGroups.find((g) =>
|
||||
g.groupID === groupID) || this.zigbee.createGroupLegacy(groupID);
|
||||
const settingsEntity = settingGroup.devices.map((d) => {
|
||||
const entity = this.zigbee.resolveEntityLegacy(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 payload = {};
|
||||
|
||||
let endpointName;
|
||||
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 resolvedEntity =
|
||||
this.zigbee.resolveEntityLegacy(`${data.ID}${endpointName ? `/${endpointName}` : ''}`);
|
||||
const zigbeeGroups = this.zigbee.getGroupsLegacy().filter((zigbeeGroup) => {
|
||||
const settingsGroup = settings.getGroup(zigbeeGroup.groupID);
|
||||
return settingsGroup && (!settingsGroup.hasOwnProperty('optimistic') || 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) {
|
||||
const resolvedEntity = this.zigbee.resolveEntityLegacy(member);
|
||||
const memberPayload = {};
|
||||
Object.keys(payload).forEach((key) => {
|
||||
if (stateProperties[key](payload[key], resolvedEntity.definition)) {
|
||||
memberPayload[key] = payload[key];
|
||||
}
|
||||
});
|
||||
|
||||
const endpointName = resolvedEntity.endpointName;
|
||||
if (endpointName) {
|
||||
Object.keys(memberPayload).forEach((key) => {
|
||||
memberPayload[`${key}_${endpointName}`] = memberPayload[key];
|
||||
delete memberPayload[key];
|
||||
});
|
||||
}
|
||||
|
||||
await this.publishEntityState(member.getDevice().ieeeAddr, memberPayload, 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;
|
||||
let skipDisableReporting = 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.resolveEntityLegacy(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.resolveEntityLegacy(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 = utils.endpointNames.find((p) => message.endsWith(`/${p}`));
|
||||
} else if (topicRegexMatch) {
|
||||
type = topicRegexMatch[1];
|
||||
message = JSON.parse(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.resolveEntityLegacy(message.group);
|
||||
if (!resolvedEntityGroup || resolvedEntityGroup.type !== 'group') {
|
||||
error = `Group '${message.group}' does not exist`;
|
||||
}
|
||||
}
|
||||
|
||||
resolvedEntityDevice = this.zigbee.resolveEntityLegacy(message.device);
|
||||
if (!error && (!resolvedEntityDevice || !resolvedEntityDevice.type === 'device')) {
|
||||
error = `Device '${message.device}' does not exist`;
|
||||
}
|
||||
|
||||
hasEndpointName = utils.endpointNames.find((p) => message.device.endsWith(`/${p}`));
|
||||
}
|
||||
|
||||
return {
|
||||
resolvedEntityGroup, resolvedEntityDevice, type, hasEndpointName, error, groupKey, deviceKey,
|
||||
triggeredViaLegacyApi, skipDisableReporting,
|
||||
};
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
let {
|
||||
resolvedEntityGroup, resolvedEntityDevice, type, hasEndpointName, error, triggeredViaLegacyApi,
|
||||
groupKey, deviceKey, skipDisableReporting,
|
||||
} = this.parseMQTTMessage(topic, message);
|
||||
if (!type) return;
|
||||
message = utils.parseJSON(message, message);
|
||||
|
||||
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;
|
||||
let endpointName = null;
|
||||
if (endpoints) {
|
||||
const matches = Object.entries(endpoints).find((e) => e[1] === resolvedEntityDevice.endpoint.ID);
|
||||
endpointName = matches && matches[0];
|
||||
}
|
||||
|
||||
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})`;
|
||||
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.emit('groupMembersChanged',
|
||||
{group: resolvedEntityGroup, action: type, endpoint: resolvedEntityDevice.endpoint,
|
||||
skipDisableReporting: skipDisableReporting});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Groups;
|
||||
@@ -0,0 +1,374 @@
|
||||
import * as settings from '../util/settings';
|
||||
import logger from '../util/logger';
|
||||
import utils from '../util/utils';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import bind from 'bind-decorator';
|
||||
import Extension from './extension';
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
|
||||
const topicRegex =
|
||||
new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
|
||||
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/(.+)/(remove|add|remove_all)$`);
|
||||
const legacyTopicRegexRemoveAll = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/remove_all$`);
|
||||
|
||||
const stateProperties: {[s: string]: (value: string, exposes: zhc.DefinitionExpose[]) => boolean} = {
|
||||
'state': () => true,
|
||||
'brightness': (value, exposes) =>
|
||||
!!exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'brightness')),
|
||||
'color_temp': (value, exposes) =>
|
||||
!!exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'color_temp')),
|
||||
'color': (value, exposes) =>
|
||||
!!exposes.find((e) => e.type === 'light' &&
|
||||
e.features.find((f) => f.name === 'color_xy' || f.name === 'color_hs')),
|
||||
'color_mode': (value, exposes) =>
|
||||
!!exposes.find((e) => e.type === 'light' && (
|
||||
(e.features.find((f) => f.name === `color_${value}`)) ||
|
||||
(value === 'color_temp' && e.features.find((f) => f.name === 'color_temp')) )),
|
||||
};
|
||||
|
||||
interface ParsedMQTTMessage {
|
||||
type: 'remove' | 'add' | 'remove_all', resolvedEntityGroup: Group, resolvedEntityDevice: Device,
|
||||
error: string, groupKey: string, deviceKey: string, triggeredViaLegacyApi: boolean,
|
||||
skipDisableReporting: boolean, resolvedEntityEndpoint: zh.Endpoint,
|
||||
}
|
||||
|
||||
export default class Groups extends Extension {
|
||||
private legacyApi = settings.get().advanced.legacy_api;
|
||||
private lastOptimisticState: {[s: string]: KeyValue} = {};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onStateChange(this, this.onStateChange);
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
await this.syncGroupsWithSettings();
|
||||
}
|
||||
|
||||
private async syncGroupsWithSettings(): Promise<void> {
|
||||
const settingsGroups = settings.getGroups();
|
||||
const zigbeeGroups = this.zigbee.groups();
|
||||
|
||||
const addRemoveFromGroup = async (action: 'add' | 'remove', deviceName: string,
|
||||
groupName: string | number, endpoint: zh.Endpoint, group: Group): Promise<void> => {
|
||||
try {
|
||||
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} '${deviceName}' to group '${groupName}'`);
|
||||
if (action === 'remove') {
|
||||
await endpoint.removeFromGroup(group.zh);
|
||||
} else {
|
||||
await endpoint.addToGroup(group.zh);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${action} '${deviceName}' from '${groupName}'`);
|
||||
logger.debug(error.stack);
|
||||
}
|
||||
};
|
||||
|
||||
for (const settingGroup of settingsGroups) {
|
||||
const groupID = settingGroup.ID;
|
||||
const zigbeeGroup = zigbeeGroups.find((g) => g.ID === groupID) || this.zigbee.createGroup(groupID);
|
||||
const settingsEndpoint = settingGroup.devices.map((d) => {
|
||||
const parsed = utils.parseEntityID(d);
|
||||
const entity = this.zigbee.resolveEntity(parsed.ID) as Device;
|
||||
if (!entity) logger.error(`Cannot find '${d}' of group '${settingGroup.friendly_name}'`);
|
||||
return {'endpoint': entity?.endpoint(parsed.endpoint), 'name': entity?.name};
|
||||
}).filter((e) => e.endpoint != null);
|
||||
|
||||
// In settings but not in zigbee
|
||||
for (const entity of settingsEndpoint) {
|
||||
if (!zigbeeGroup.zh.hasMember(entity.endpoint)) {
|
||||
addRemoveFromGroup('add', entity.name, settingGroup.friendly_name, entity.endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
|
||||
// In zigbee but not in settings
|
||||
for (const endpoint of zigbeeGroup.zh.members) {
|
||||
if (!settingsEndpoint.find((e) => e.endpoint === endpoint)) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name;
|
||||
addRemoveFromGroup('remove', deviceName, settingGroup.friendly_name, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const zigbeeGroup of zigbeeGroups) {
|
||||
if (!settingsGroups.find((g) => g.ID === zigbeeGroup.ID)) {
|
||||
for (const endpoint of zigbeeGroup.zh.members) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name;
|
||||
addRemoveFromGroup('remove', deviceName, zigbeeGroup.ID, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onStateChange(data: eventdata.StateChange): Promise<void> {
|
||||
const reason = 'groupOptimistic';
|
||||
if (data.reason === reason) {
|
||||
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 exposes = device.exposes();
|
||||
const memberPayload: KeyValue = {};
|
||||
Object.keys(payload).forEach((key) => {
|
||||
if (stateProperties[key](payload[key], exposes)) {
|
||||
memberPayload[key] = payload[key];
|
||||
}
|
||||
});
|
||||
|
||||
const endpointName = device.endpointName(member);
|
||||
if (endpointName) {
|
||||
Object.keys(memberPayload).forEach((key) => {
|
||||
memberPayload[`${key}_${endpointName}`] = memberPayload[key];
|
||||
delete memberPayload[key];
|
||||
});
|
||||
}
|
||||
|
||||
await this.publishEntityState(device, memberPayload, reason);
|
||||
for (const zigbeeGroup of groups) {
|
||||
if (zigbeeGroup.zh.hasMember(member)) {
|
||||
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});
|
||||
}
|
||||
}
|
||||
}
|
||||
+422
-277
File diff suppressed because it is too large
Load Diff
@@ -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 AvailabilityLegacy 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 && 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.getClientsLegacy()) {
|
||||
// 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.seconds(300));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
// Clear topics for non-existing devices
|
||||
const match = topic.match(topicRegex);
|
||||
if (match && (!this.zigbee.resolveEntityLegacy(match[1]) ||
|
||||
this.zigbee.resolveEntityLegacy(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.resolveEntityLegacy(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.resolveEntityLegacy(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.seconds(timeout));
|
||||
}
|
||||
|
||||
async stop() {
|
||||
super.stop();
|
||||
for (const timer of Object.values(this.timers)) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
this.zigbee.getClientsLegacy().forEach((device) => this.publishAvailability(device, false));
|
||||
}
|
||||
|
||||
async onReconnect(device) {
|
||||
const resolvedEntity = this.zigbee.resolveEntityLegacy(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 resolvedEntity = this.zigbee.resolveEntityLegacy(device);
|
||||
const topic = `${resolvedEntity.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 = AvailabilityLegacy;
|
||||
@@ -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) {
|
||||
await this.zigbee.permitJoinLegacy(message.toLowerCase() === 'true');
|
||||
@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.getDevicesLegacy().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.resolveEntityLegacy(to);
|
||||
const eventData = isGroup ? {group: entity.group} : {device: entity.device, from, to};
|
||||
eventData.homeAssisantRename = false;
|
||||
this.eventBus.emit(`${isGroup ? 'group' : 'device'}Renamed`, eventData);
|
||||
const entity = this.zigbee.resolveEntity(to);
|
||||
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 {
|
||||
@@ -268,20 +253,20 @@ class BridgeLegacy extends Extension {
|
||||
}
|
||||
|
||||
const group = settings.addGroup(name, id);
|
||||
this.zigbee.createGroupLegacy(group.ID);
|
||||
this.zigbee.createGroup(group.ID);
|
||||
this.mqtt.publish('bridge/log', stringify({type: `group_added`, message: name}));
|
||||
logger.info(`Added group '${name}'`);
|
||||
}
|
||||
|
||||
removeGroup(topic, message) {
|
||||
@bind removeGroup(topic: string, message: string): void {
|
||||
const name = message;
|
||||
const entity = this.zigbee.resolveEntityLegacy(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.resolveEntityLegacy(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,63 +301,63 @@ 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() {
|
||||
async publish(): Promise<void> {
|
||||
const info = await utils.getZigbee2MQTTVersion();
|
||||
const coordinator = await this.zigbee.getCoordinatorVersion();
|
||||
const topic = `bridge/config`;
|
||||
@@ -388,7 +373,7 @@ class BridgeLegacy extends Extension {
|
||||
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.resolveEntityLegacy(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.emitReconfigure({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.getDevicesByTypeLegacy('Coordinator')[0].getEndpoint(1);
|
||||
|
||||
for (const device of this.zigbee.getClientsLegacy()) {
|
||||
const resolvedEntity = this.zigbee.resolveEntityLegacy(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.seconds(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() {
|
||||
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;
|
||||
+18
-21
@@ -1,14 +1,13 @@
|
||||
import * as settings from '../util/settings';
|
||||
import * as utils from '../util/utils';
|
||||
import utils from '../util/utils';
|
||||
import logger from '../util/logger';
|
||||
// @ts-ignore
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import ExtensionTS from './extensionts';
|
||||
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: RoutingTableEntry[],
|
||||
linkquality: number, depth: number, routes: zh.RoutingTableEntry[],
|
||||
sourceIeeeAddr: string, targetIeeeAddr: string, sourceNwkAddr: number, lqi: number, relationship: number,
|
||||
}
|
||||
|
||||
@@ -23,7 +22,7 @@ interface Topology {
|
||||
/**
|
||||
* This extension creates a network map
|
||||
*/
|
||||
class NetworkMap extends ExtensionTS {
|
||||
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`;
|
||||
@@ -31,7 +30,7 @@ class NetworkMap extends ExtensionTS {
|
||||
private supportedFormats: {[s: string]: (topology: Topology) => KeyValue | string};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage_);
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.supportedFormats = {
|
||||
'raw': this.raw,
|
||||
'graphviz': this.graphviz,
|
||||
@@ -39,7 +38,7 @@ class NetworkMap extends ExtensionTS {
|
||||
};
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage_(data: EventMQTTMessage): Promise<void> {
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
/* istanbul ignore else */
|
||||
if (this.legacyApi) {
|
||||
if ((data.topic === this.legacyTopic || data.topic === this.legacyTopicRoutes) &&
|
||||
@@ -177,7 +176,7 @@ class NetworkMap extends ExtensionTS {
|
||||
// Add the device model
|
||||
if (node.type !== 'Coordinator') {
|
||||
text.push(`---`);
|
||||
const definition = this.zigbee.resolveEntityLegacy(node.ieeeAddr).definition;
|
||||
const definition = (this.zigbee.resolveEntity(node.ieeeAddr) as Device).definition;
|
||||
if (definition) {
|
||||
text.push(`${definition.vendor} ${definition.description} (${definition.model})`);
|
||||
} else {
|
||||
@@ -215,12 +214,12 @@ class NetworkMap extends ExtensionTS {
|
||||
|
||||
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: Map<Device, LQI> = new Map();
|
||||
const routingTables: Map<Device, RoutingTable> = 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.
|
||||
|
||||
@@ -239,7 +238,7 @@ class NetworkMap extends ExtensionTS {
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await doRequest<LQI>(async () => device.lqi());
|
||||
const result = await doRequest<zh.LQI>(async () => device.zh.lqi());
|
||||
lqis.set(device, result);
|
||||
logger.debug(`LQI succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
@@ -249,7 +248,7 @@ class NetworkMap extends ExtensionTS {
|
||||
|
||||
if (includeRoutes) {
|
||||
try {
|
||||
const result = await doRequest(async () => device.routingTable());
|
||||
const result = await doRequest(async () => device.zh.routingTable());
|
||||
routingTables.set(device, result);
|
||||
logger.debug(`Routing table succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
@@ -268,16 +267,16 @@ class NetworkMap extends ExtensionTS {
|
||||
model: device.definition.model,
|
||||
vendor: device.definition.vendor,
|
||||
description: device.definition.description,
|
||||
supports: Array.from(new Set((device.definition.exposes).map((e) => {
|
||||
supports: Array.from(new Set((device.exposes()).map((e) => {
|
||||
return e.hasOwnProperty('name') ? e.name :
|
||||
`${e.type} (${e.features.map((f) => f.name).join(', ')})`;
|
||||
}))).join(', '),
|
||||
} : null;
|
||||
|
||||
topology.nodes.push({
|
||||
ieeeAddr: device.ieeeAddr, friendlyName: device.name, type: device.type,
|
||||
networkAddress: device.networkAddress, manufacturerName: device.manufacturerName,
|
||||
modelID: device.modelID, failed: failed.get(device), lastSeen: device.lastSeen,
|
||||
ieeeAddr: device.ieeeAddr, friendlyName: device.name, type: device.zh.type,
|
||||
networkAddress: device.zh.networkAddress, manufacturerName: device.zh.manufacturerName,
|
||||
modelID: device.zh.modelID, failed: failed.get(device), lastSeen: device.zh.lastSeen,
|
||||
definition,
|
||||
});
|
||||
}
|
||||
@@ -299,7 +298,7 @@ class NetworkMap extends ExtensionTS {
|
||||
|
||||
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,
|
||||
@@ -320,5 +319,3 @@ class NetworkMap extends ExtensionTS {
|
||||
return topology;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NetworkMap;
|
||||
|
||||
+15
-11
@@ -1,14 +1,13 @@
|
||||
// @ts-ignore
|
||||
import zhc from 'zigbee-herdsman-converters';
|
||||
import ExtensionTS from './extensionts';
|
||||
import Extension from './extension';
|
||||
|
||||
/**
|
||||
* This extension calls the zigbee-herdsman-converters onEvent.
|
||||
*/
|
||||
class OnEvent extends ExtensionTS {
|
||||
export default class OnEvent extends Extension {
|
||||
override async start(): Promise<void> {
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
await this.callOnEvent(device, 'start', {});
|
||||
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)));
|
||||
@@ -20,26 +19,31 @@ class OnEvent extends ExtensionTS {
|
||||
(data) => this.callOnEvent(data.device, 'deviceAnnounce', this.convertData(data)));
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this,
|
||||
(data) => this.callOnEvent(data.device, 'deviceNetworkAddressChanged', this.convertData(data)));
|
||||
this.eventBus.onEntityOptionsChanged(this,
|
||||
(data) => {
|
||||
if (data.entity.isDevice()) {
|
||||
this.callOnEvent(data.entity, 'deviceOptionsChanged', data)
|
||||
.then(() => this.eventBus.emitDevicesChanged());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private convertData(data: KeyValue): KeyValue {
|
||||
return {...data, device: data.device.zhDevice};
|
||||
return {...data, device: data.device.zh};
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
super.stop();
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
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.zhDevice, device.settings);
|
||||
zhc.onEvent(type, data, device.zh);
|
||||
|
||||
if (device.definition?.onEvent) {
|
||||
await device.definition.onEvent(type, data, device.zhDevice, device.settings);
|
||||
await device.definition.onEvent(type, data, device.zh, device.settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OnEvent;
|
||||
|
||||
+32
-36
@@ -1,11 +1,9 @@
|
||||
import * as settings from '../util/settings';
|
||||
import logger from '../util/logger';
|
||||
// @ts-ignore
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import * as utils from '../util/utils';
|
||||
// @ts-ignore
|
||||
import utils from '../util/utils';
|
||||
import tradfriOTA from 'zigbee-herdsman-converters/lib/ota/tradfri';
|
||||
import ExtensionTS from './extensionts';
|
||||
import Extension from './extension';
|
||||
import bind from 'bind-decorator';
|
||||
import Device from '../model/device';
|
||||
|
||||
@@ -18,19 +16,19 @@ const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/o
|
||||
const topicRegex =
|
||||
new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check)`, 'i');
|
||||
|
||||
class OTAUpdate extends ExtensionTS {
|
||||
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.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.eventBus.onDeviceMessage(this, this.onZigbeeEvent);
|
||||
if (settings.get().advanced.ikea_ota_use_test_url) {
|
||||
tradfriOTA.useTestURL();
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -38,16 +36,16 @@ class OTAUpdate extends ExtensionTS {
|
||||
}
|
||||
|
||||
private removeProgressAndRemainingFromState(device: Device): void {
|
||||
this.state.removeKey(device.ieeeAddr, ['update', 'progress']);
|
||||
this.state.removeKey(device.ieeeAddr, ['update', 'remaining']);
|
||||
delete this.state.get(device)?.update?.progress;
|
||||
delete this.state.get(device)?.update?.remaining;
|
||||
}
|
||||
|
||||
@bind private async onZigbeeEvent(data: EventDeviceMessage): Promise<void> {
|
||||
@bind private async onZigbeeEvent(data: eventdata.DeviceMessage): Promise<void> {
|
||||
if (settings.get().ota.disable_automatic_update_check) return;
|
||||
if (data.type !== 'commandQueryNextImageRequest' || !data.device.definition) return;
|
||||
logger.debug(`Device '${data.device.name}' requested OTA`);
|
||||
|
||||
const supportsOTA = data.device.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
|
||||
@@ -58,10 +56,16 @@ class OTAUpdate extends ExtensionTS {
|
||||
if (!check || this.inProgress.has(data.device.ieeeAddr)) return;
|
||||
|
||||
this.lastChecked[data.device.ieeeAddr] = Date.now();
|
||||
const available = await data.device.definition.ota.isUpdateAvailable(
|
||||
data.device.zhDevice, 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 '${data.device.name}'`;
|
||||
@@ -81,25 +85,18 @@ class OTAUpdate extends ExtensionTS {
|
||||
// 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});
|
||||
}
|
||||
}
|
||||
|
||||
private async readSoftwareBuildIDAndDateCode(device: Device, update: boolean):
|
||||
private async readSoftwareBuildIDAndDateCode(device: Device, sendWhenActive: boolean):
|
||||
Promise<{softwareBuildID: string, dateCode: string}> {
|
||||
try {
|
||||
const endpoint = device.endpoints.find((e) => e.supportsInputCluster('genBasic'));
|
||||
const result = await endpoint.read('genBasic', ['dateCode', 'swBuildId']);
|
||||
|
||||
if (update) {
|
||||
device.zhDevice.softwareBuildID = result.swBuildId;
|
||||
device.zhDevice.dateCode = result.dateCode;
|
||||
device.zhDevice.save();
|
||||
}
|
||||
|
||||
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster('genBasic'));
|
||||
const result = await endpoint.read('genBasic', ['dateCode', 'swBuildId'], {sendWhenActive});
|
||||
return {softwareBuildID: result.swBuildId, dateCode: result.dateCode};
|
||||
} catch (e) {
|
||||
return null;
|
||||
@@ -119,7 +116,7 @@ class OTAUpdate extends ExtensionTS {
|
||||
return payload;
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage_(data: EventMQTTMessage): Promise<void> {
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
if ((!this.legacyApi || !data.topic.match(legacyTopicRegex)) && !data.topic.match(topicRegex)) {
|
||||
return null;
|
||||
}
|
||||
@@ -164,7 +161,7 @@ class OTAUpdate extends ExtensionTS {
|
||||
}
|
||||
|
||||
try {
|
||||
const available = await device.definition.ota.isUpdateAvailable(device.zhDevice, logger);
|
||||
const available = await device.definition.ota.isUpdateAvailable(device.zh, logger);
|
||||
const msg = `${available ? 'Update' : 'No update'} available for '${device.name}'`;
|
||||
logger.info(msg);
|
||||
|
||||
@@ -178,7 +175,7 @@ class OTAUpdate extends ExtensionTS {
|
||||
}
|
||||
|
||||
const payload = this.getEntityPublishPayload(available ? 'available' : 'idle');
|
||||
this.publishEntityState(device.ieeeAddr, payload);
|
||||
this.publishEntityState(device, payload);
|
||||
this.lastChecked[device.ieeeAddr] = Date.now();
|
||||
responseData.updateAvailable = available;
|
||||
} catch (e) {
|
||||
@@ -217,7 +214,7 @@ class OTAUpdate extends ExtensionTS {
|
||||
logger.info(msg);
|
||||
|
||||
const payload = this.getEntityPublishPayload('updating', progress, remaining);
|
||||
this.publishEntityState(device.ieeeAddr, payload);
|
||||
this.publishEntityState(device, payload);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
@@ -227,18 +224,19 @@ class OTAUpdate extends ExtensionTS {
|
||||
};
|
||||
|
||||
const from_ = await this.readSoftwareBuildIDAndDateCode(device, false);
|
||||
await device.definition.ota.updateToLatest(device.zhDevice, logger, onProgress);
|
||||
const to = await this.readSoftwareBuildIDAndDateCode(device, true);
|
||||
await device.definition.ota.updateToLatest(device.zh, logger, onProgress);
|
||||
const to = await this.readSoftwareBuildIDAndDateCode(device, device.zh.type === 'EndDevice');
|
||||
const [fromS, toS] = [stringify(from_), stringify(to)];
|
||||
this.eventBus.emitReconfigure({device});
|
||||
const msg = `Finished update of '${device.name}'` +
|
||||
(to ? `, from '${fromS}' to '${toS}'` : ``);
|
||||
logger.info(msg);
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
const payload = this.getEntityPublishPayload('idle');
|
||||
this.publishEntityState(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) {
|
||||
@@ -252,7 +250,7 @@ class OTAUpdate extends ExtensionTS {
|
||||
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
const payload = this.getEntityPublishPayload('available');
|
||||
this.publishEntityState(device.ieeeAddr, payload);
|
||||
this.publishEntityState(device, payload);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
@@ -277,5 +275,3 @@ class OTAUpdate extends ExtensionTS {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OTAUpdate;
|
||||
|
||||
+59
-48
@@ -1,42 +1,41 @@
|
||||
|
||||
import * as settings from '../util/settings';
|
||||
// @ts-ignore
|
||||
import zhc from 'zigbee-herdsman-converters';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
import logger from '../util/logger';
|
||||
import * as utils from '../util/utils';
|
||||
import ExtensionTS from './extensionts';
|
||||
// @ts-ignore
|
||||
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('|')}))?/(get|set)(?:/(.+))?`);
|
||||
const topicRegex = new RegExp(`^(.+?)(?:/(${utils.endpointNames.join('|')}|\\d+))?/(get|set)(?:/(.+))?`);
|
||||
const propertyEndpointRegex = new RegExp(`^(.*)_(${utils.endpointNames.join('|')})$`);
|
||||
const stateValues = ['on', 'off', 'toggle', 'open', 'close', 'stop', 'lock', 'unlock'];
|
||||
const sceneConverterKeys = ['scene_store', 'scene_add', 'scene_remove', 'scene_remove_all'];
|
||||
|
||||
// Legacy: don't provide default converters anymore, this is required by older z2m installs not saving group members
|
||||
const defaultGroupConverters = [
|
||||
zhc.toZigbeeConverters.light_onoff_brightness,
|
||||
zhc.toZigbeeConverters.light_color_colortemp,
|
||||
zhc.toZigbeeConverters.effect,
|
||||
zhc.toZigbeeConverters.ignore_transition,
|
||||
zhc.toZigbeeConverters.cover_position_tilt,
|
||||
zhc.toZigbeeConverters.thermostat_occupied_heating_setpoint,
|
||||
zhc.toZigbeeConverters.tint_scene,
|
||||
zhc.toZigbeeConverters.light_brightness_move,
|
||||
zhc.toZigbeeConverters.light_brightness_step,
|
||||
zhc.toZigbeeConverters.light_colortemp_step,
|
||||
zhc.toZigbeeConverters.light_colortemp_move,
|
||||
zhc.toZigbeeConverters.light_hue_saturation_move,
|
||||
zhc.toZigbeeConverters.light_hue_saturation_step,
|
||||
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'}
|
||||
|
||||
class Publish extends ExtensionTS {
|
||||
export default class Publish extends Extension {
|
||||
async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage_);
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
parseTopic(topic: string): ParsedTopic | null {
|
||||
@@ -54,7 +53,7 @@ class Publish extends ExtensionTS {
|
||||
return {ID: ID, endpoint: match[2], type: match[3] as 'get' | 'set', attribute: match[4]};
|
||||
}
|
||||
|
||||
parseMessage(parsedTopic: ParsedTopic, data: EventMQTTMessage): KeyValue | null {
|
||||
parseMessage(parsedTopic: ParsedTopic, data: eventdata.MQTTMessage): KeyValue | null {
|
||||
if (parsedTopic.attribute) {
|
||||
try {
|
||||
return {[parsedTopic.attribute]: JSON.parse(data.message)};
|
||||
@@ -81,8 +80,8 @@ class Publish extends ExtensionTS {
|
||||
}
|
||||
}
|
||||
|
||||
legacyRetrieveState(re: Device | Group, converter: ToZigbeeConverter, result: ToZigbeeConverterResult,
|
||||
target: ZHEndpoint | ZHGroup, key: string, meta: ToZigbeeConverterGetMeta): void {
|
||||
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
|
||||
@@ -116,8 +115,7 @@ class Publish extends ExtensionTS {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO remove trailing _
|
||||
@bind async onMQTTMessage_(data: EventMQTTMessage): Promise<void> {
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const parsedTopic = this.parseTopic(data.topic);
|
||||
if (!parsedTopic) return;
|
||||
|
||||
@@ -130,28 +128,28 @@ class Publish extends ExtensionTS {
|
||||
|
||||
// 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.zhGroup : re.endpoint(parsedTopic.endpoint);
|
||||
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.zhDevice : null;
|
||||
const device = re instanceof Device ? re.zh : null;
|
||||
const entitySettings = re.settings;
|
||||
const entityState = this.state.get(re.ID) || {};
|
||||
const entityState = this.state.get(re) || {};
|
||||
const membersState = re instanceof Group ?
|
||||
Object.fromEntries(re.membersIeeeAddr().map((e) => [e, this.state.get(e)])) : null;
|
||||
let converters: ToZigbeeConverter[];
|
||||
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 {
|
||||
} else if (definition) {
|
||||
converters = definition.toZigbee;
|
||||
} else {
|
||||
converters = [zigbeeHerdsmanConverters.toZigbeeConverters.read,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.write];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,18 +175,22 @@ class Publish extends ExtensionTS {
|
||||
entries.sort((a) => (['state', 'brightness', 'brightness_percent'].includes(a[0]) ? sorter : sorter * -1));
|
||||
|
||||
// For each attribute call the corresponding converter
|
||||
const usedConverters: {[s: number]: ToZigbeeConverter[]} = {};
|
||||
const usedConverters: {[s: number]: zhc.ToZigbeeConverter[]} = {};
|
||||
const toPublish: {[s: number | string]: KeyValue} = {};
|
||||
const addToToPublish = (ID: number | string, payload: KeyValue): void => {
|
||||
if (!(ID in toPublish)) toPublish[ID] = {};
|
||||
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;
|
||||
// @ts-ignore TODO fix
|
||||
let endpointOrGroupID = target.constructor.name === 'Endpoint' ? target.ID : target.groupID;
|
||||
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);
|
||||
@@ -213,10 +215,16 @@ class Publish extends ExtensionTS {
|
||||
}
|
||||
|
||||
if (!converter) {
|
||||
logger.error(`No converter available for '${key}' (${message[key]})`);
|
||||
logger.error(`No converter available for '${key}' (${stringify(message[key])})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the endpoint_name name is a nubmer, try to map it to a friendlyName
|
||||
if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isEndpoint(localTarget) &&
|
||||
re.endpointName(localTarget)) {
|
||||
endpointName = re.endpointName(localTarget);
|
||||
}
|
||||
|
||||
// Converter didn't return a result, skip
|
||||
const meta = {endpoint_name: endpointName, options: entitySettings, message: {...message}, logger, device,
|
||||
state: entityState, membersState, mapped: definition};
|
||||
@@ -249,12 +257,12 @@ class Publish extends ExtensionTS {
|
||||
|
||||
// filter out attribute listed in filtered_optimistic
|
||||
entitySettings.filtered_optimistic?.forEach((a) => delete msg[a]);
|
||||
addToToPublish(re.ID, msg);
|
||||
addToToPublish(re, msg);
|
||||
}
|
||||
|
||||
if (result && result.membersState && optimistic) {
|
||||
for (const [ieeeAddr, state] of Object.entries(result.membersState)) {
|
||||
addToToPublish(ieeeAddr, state);
|
||||
addToToPublish(this.zigbee.resolveEntity(ieeeAddr), state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,11 +287,14 @@ class Publish extends ExtensionTS {
|
||||
|
||||
for (const [ID, payload] of Object.entries(toPublish)) {
|
||||
if (Object.keys(payload).length != 0) {
|
||||
this.publishEntityState(ID, payload);
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO_finished: : change class to export default
|
||||
module.exports = Publish;
|
||||
|
||||
+53
-37
@@ -1,53 +1,55 @@
|
||||
import * as settings from '../util/settings';
|
||||
import logger from '../util/logger';
|
||||
import debounce from 'debounce';
|
||||
import ExtensionTS from './extensionts';
|
||||
// @ts-ignore
|
||||
import Extension from './extension';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import bind from 'bind-decorator';
|
||||
import utils from '../util/utils';
|
||||
|
||||
class Receive extends ExtensionTS {
|
||||
type DebounceFunction = (() => void) & { clear(): void; } & { flush(): void; };
|
||||
|
||||
export default class Receive extends Extension {
|
||||
private elapsed: {[s: string]: number} = {};
|
||||
// eslint-disable-next-line
|
||||
private debouncers: {[s: string]: {payload: KeyValue, publish: any}} = {}; //TODO fix type
|
||||
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: EventPublishEntityState): Promise<void> {
|
||||
@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.ieeeAddr && this.debouncers[data.ieeeAddr] && data.stateChangeReason !== 'publishDebounce') {
|
||||
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.ieeeAddr].payload[key];
|
||||
delete this.debouncers[data.entity.ieeeAddr].payload[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publishDebounce(ieeeAddr: string, payload: KeyValue, time: number, debounceIgnore: string[]): void {
|
||||
if (!this.debouncers[ieeeAddr]) {
|
||||
this.debouncers[ieeeAddr] = {
|
||||
publishDebounce(device: Device, payload: KeyValue, time: number, debounceIgnore: string[]): void {
|
||||
if (!this.debouncers[device.ieeeAddr]) {
|
||||
this.debouncers[device.ieeeAddr] = {
|
||||
payload: {},
|
||||
publish: debounce(() => {
|
||||
this.publishEntityState(ieeeAddr, this.debouncers[ieeeAddr].payload, 'publishDebounce');
|
||||
this.debouncers[ieeeAddr].payload = {};
|
||||
this.publishEntityState(device, this.debouncers[device.ieeeAddr].payload, 'publishDebounce');
|
||||
this.debouncers[device.ieeeAddr].payload = {};
|
||||
}, time * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isPayloadConflicted(payload, this.debouncers[ieeeAddr].payload, debounceIgnore)) {
|
||||
if (this.isPayloadConflicted(payload, this.debouncers[device.ieeeAddr].payload, debounceIgnore)) {
|
||||
// publish previous payload immediately
|
||||
this.debouncers[ieeeAddr].publish.flush();
|
||||
this.debouncers[device.ieeeAddr].publish.flush();
|
||||
}
|
||||
|
||||
// extend debounced payload with current
|
||||
this.debouncers[ieeeAddr].payload = {...this.debouncers[ieeeAddr].payload, ...payload};
|
||||
this.debouncers[ieeeAddr].publish();
|
||||
this.debouncers[device.ieeeAddr].payload = {...this.debouncers[device.ieeeAddr].payload, ...payload};
|
||||
this.debouncers[device.ieeeAddr].publish();
|
||||
}
|
||||
|
||||
// if debounce_ignore are specified (Array of strings)
|
||||
@@ -67,15 +69,16 @@ class Receive extends ExtensionTS {
|
||||
return result;
|
||||
}
|
||||
|
||||
shouldProcess(data: EventDeviceMessage): boolean {
|
||||
shouldProcess(data: eventdata.DeviceMessage): boolean {
|
||||
if (!data.device.definition) {
|
||||
if (data.device.interviewing) {
|
||||
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.modelID}' ` +
|
||||
`and manufacturer name '${data.device.manufacturerName}'`);
|
||||
logger.warn(`Please see: https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html.`);
|
||||
`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;
|
||||
@@ -84,7 +87,7 @@ class Receive extends ExtensionTS {
|
||||
return true;
|
||||
}
|
||||
|
||||
@bind onDeviceMessage(data: EventDeviceMessage): void {
|
||||
@bind async onDeviceMessage(data: eventdata.DeviceMessage): Promise<void> {
|
||||
/* istanbul ignore next */
|
||||
if (!data.device) return;
|
||||
|
||||
@@ -100,12 +103,16 @@ class Receive extends ExtensionTS {
|
||||
* 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.isXiaomiDevice() && data.device.isRouter() && data.groupID) {
|
||||
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)) return;
|
||||
if (!this.shouldProcess(data)) {
|
||||
utils.publishLastSeen({device: data.device, reason: 'messageEmitted'},
|
||||
settings.get(), true, this.publishEntityState);
|
||||
return;
|
||||
}
|
||||
|
||||
const converters = data.device.definition.fromZigbee.filter((c) => {
|
||||
const type = Array.isArray(c.type) ? c.type.includes(data.type) : c.type === data.type;
|
||||
@@ -113,10 +120,12 @@ class Receive extends ExtensionTS {
|
||||
});
|
||||
|
||||
// Check if there is an available converter, genOta messages are not interesting.
|
||||
const ignoreClusters: (string | number)[] = ['genOta', 'genTime', 'genBasic'];
|
||||
const ignoreClusters: (string | number)[] = ['genOta', 'genTime', 'genBasic', 'genPollCtrl'];
|
||||
if (converters.length == 0 && !ignoreClusters.includes(data.cluster)) {
|
||||
logger.debug(`No converter available for '${data.device.definition.model}' with ` +
|
||||
`cluster '${data.cluster}' and type '${data.type}' and data '${stringify(data.data)}'`);
|
||||
utils.publishLastSeen({device: data.device, reason: 'messageEmitted'},
|
||||
settings.get(), true, this.publishEntityState);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,26 +146,33 @@ class Receive extends ExtensionTS {
|
||||
|
||||
// Check if we have to debounce
|
||||
if (data.device.settings.debounce) {
|
||||
this.publishDebounce(data.device.ieeeAddr, payload, data.device.settings.debounce,
|
||||
this.publishDebounce(data.device, payload, data.device.settings.debounce,
|
||||
data.device.settings.debounce_ignore);
|
||||
} else {
|
||||
this.publishEntityState(data.device.ieeeAddr, payload);
|
||||
this.publishEntityState(data.device, payload);
|
||||
}
|
||||
};
|
||||
|
||||
const meta = {device: data.device.zhDevice, logger, state: this.state.get(data.device.ieeeAddr)};
|
||||
let payload = {};
|
||||
converters.forEach((converter) => {
|
||||
const converted = converter.convert(data.device.definition, data, publish, data.device.settings, meta);
|
||||
if (converted) {
|
||||
payload = {...payload, ...converted};
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Receive;
|
||||
|
||||
+52
-41
@@ -1,71 +1,82 @@
|
||||
/* eslint-disable brace-style */
|
||||
import * as settings from '../util/settings';
|
||||
// @ts-ignore
|
||||
import zhc from 'zigbee-herdsman-converters';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
|
||||
export default class Device {
|
||||
private device: ZHDevice;
|
||||
private _definition: Definition;
|
||||
public zh: zh.Device;
|
||||
private _definition: zhc.Definition;
|
||||
|
||||
get endpoints(): ZHEndpoint[] {return this.device.endpoints;}
|
||||
get zhDevice(): ZHDevice {return this.device;}
|
||||
get ieeeAddr(): string {return this.device.ieeeAddr;}
|
||||
get ID(): string {return this.device.ieeeAddr;}
|
||||
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.type === 'Coordinator' ? 'Coordinator' : this.settings?.friendlyName || this.ieeeAddr;}
|
||||
get lastSeen(): number {return this.device.lastSeen;}
|
||||
get modelID(): string {return this.device.modelID;}
|
||||
get softwareBuildID(): string {return this.device.softwareBuildID;}
|
||||
get dateCode(): string {return this.device.dateCode;}
|
||||
get interviewCompleted(): boolean {return this.device.interviewCompleted;}
|
||||
get networkAddress(): number {return this.device.networkAddress;}
|
||||
get manufacturerName(): string {return this.device.manufacturerName;}
|
||||
get interviewing(): boolean {return this.device.interviewing;}
|
||||
get type(): 'Coordinator' | 'Router' | 'EndDevice' | 'Unknown' | 'GreenPower' {return this.device.type;}
|
||||
get powerSource(): string {return this.device.powerSource;}
|
||||
get definition(): Definition | undefined {
|
||||
if (!this._definition && !this.device.interviewing) {
|
||||
this._definition = zhc.findByDevice(this.device);
|
||||
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: ZHDevice) {
|
||||
this.device = device;
|
||||
constructor(device: zh.Device) {
|
||||
this.zh = device;
|
||||
}
|
||||
|
||||
async ping(disableRecovery: boolean): Promise<void> {await this.device.ping(disableRecovery);}
|
||||
async removeFromNetwork(): Promise<void> {await this.device.removeFromNetwork();}
|
||||
exposes(): zhc.DefinitionExpose[] {
|
||||
/* istanbul ignore if */
|
||||
if (typeof this.definition.exposes == 'function') {
|
||||
return this.definition.exposes(this.zh, this.settings);
|
||||
} else {
|
||||
return this.definition.exposes;
|
||||
}
|
||||
}
|
||||
|
||||
endpoint(key?: string): ZHEndpoint {
|
||||
let endpoint: ZHEndpoint;
|
||||
if (key == null) key = 'default';
|
||||
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.device.getEndpoint(Number(key));
|
||||
endpoint = this.zh.getEndpoint(Number(key));
|
||||
} else if (this.definition?.endpoint) {
|
||||
const ID = this.definition?.endpoint?.(this.device)[key];
|
||||
if (ID) endpoint = this.device.getEndpoint(ID);
|
||||
else if (key === 'default') endpoint = this.device.endpoints[0];
|
||||
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.device.endpoints[0];
|
||||
endpoint = this.zh.endpoints[0];
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
isXiaomiDevice(): boolean {
|
||||
const xiaomiManufacturerID = [4151, 4447];
|
||||
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 this.zhDevice.modelID !== 'lumi.router' && xiaomiManufacturerID.includes(this.zhDevice.manufacturerID) &&
|
||||
(!this.zhDevice.manufacturerName || !this.zhDevice.manufacturerName.startsWith('Trust'));
|
||||
return name === 'default' ? null : name;
|
||||
}
|
||||
|
||||
isRouter(): boolean {return this.zhDevice.type === 'Router';}
|
||||
lqi(): Promise<LQI> {return this.zhDevice.lqi();}
|
||||
routingTable(): Promise<RoutingTable> {return this.zhDevice.routingTable();}
|
||||
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;}
|
||||
}
|
||||
|
||||
+11
-14
@@ -1,26 +1,23 @@
|
||||
/* eslint-disable brace-style */
|
||||
import * as settings from '../util/settings';
|
||||
// @ts-ignore
|
||||
import zhc from 'zigbee-herdsman-converters';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
|
||||
export default class Group {
|
||||
private group: ZHGroup;
|
||||
public zh: zh.Group;
|
||||
|
||||
get zhGroup(): ZHGroup {return this.group;}
|
||||
get ID(): number {return this.group.groupID;}
|
||||
get ID(): number {return this.zh.groupID;}
|
||||
get settings(): GroupSettings {return settings.getGroup(this.ID);}
|
||||
get name(): string {return this.settings?.friendlyName || this.ID.toString();}
|
||||
get members(): ZHEndpoint[] {return this.group.members;}
|
||||
get name(): string {return this.settings?.friendly_name || this.ID.toString();}
|
||||
|
||||
constructor(group: ZHGroup) {
|
||||
this.group = group;
|
||||
constructor(group: zh.Group) {
|
||||
this.zh = group;
|
||||
}
|
||||
|
||||
membersDefinitions(): Definition[] {
|
||||
return this.members.map((m) => zhc.findByDevice(m.getDevice())).filter((d) => d) as Definition[];
|
||||
membersDefinitions(): zhc.Definition[] {
|
||||
return this.zh.members.map((m) =>
|
||||
zigbeeHerdsmanConverters.findByDevice(m.getDevice())).filter((d) => d) as zhc.Definition[];
|
||||
}
|
||||
|
||||
membersIeeeAddr(): string[] {
|
||||
return this.members.map((m) => m.getDevice().ieeeAddr);
|
||||
}
|
||||
isDevice(): this is Device {return false;}
|
||||
isGroup(): this is Group {return true;}
|
||||
}
|
||||
|
||||
+11
-8
@@ -1,8 +1,9 @@
|
||||
import mqtt from 'mqtt';
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import {seconds} from './util/utils';
|
||||
import utils from './util/utils';
|
||||
import fs from 'fs';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
export default class MQTT {
|
||||
private publishedTopics: Set<string> = new Set();
|
||||
@@ -12,8 +13,6 @@ export default class MQTT {
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
this.eventBus = eventBus;
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.onConnect = this.onConnect.bind(this);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
@@ -68,8 +67,10 @@ export default class MQTT {
|
||||
options.rejectUnauthorized = false;
|
||||
}
|
||||
|
||||
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 onConnect = this.onConnect;
|
||||
this.client.on('connect', async () => {
|
||||
@@ -77,18 +78,19 @@ export default class MQTT {
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => reject(err));
|
||||
this.client.on('message', this.onMessage);
|
||||
});
|
||||
}
|
||||
|
||||
private async onConnect(): Promise<void> {
|
||||
@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!');
|
||||
}
|
||||
}, seconds(10));
|
||||
}, utils.seconds(10));
|
||||
|
||||
logger.info('Connected to MQTT server');
|
||||
this.subscribe(`${settings.get().mqtt.base_topic}/#`);
|
||||
@@ -106,9 +108,10 @@ export default class MQTT {
|
||||
this.client.subscribe(topic);
|
||||
}
|
||||
|
||||
public onMessage(topic: string, message: string): void {
|
||||
@bind public onMessage(topic: string, message: string): void {
|
||||
// Since we subscribe to zigbee2mqtt/# we also receive the message we send ourselves, skip these.
|
||||
if (!this.publishedTopics.has(topic)) {
|
||||
logger.debug(`Received MQTT message on '${topic}' with data '${message}'`);
|
||||
this.eventBus.emitMQTTMessage({topic, message: message + ''});
|
||||
}
|
||||
}
|
||||
@@ -117,7 +120,7 @@ export default class MQTT {
|
||||
return this.client && !this.client.reconnecting;
|
||||
}
|
||||
|
||||
async publish(topic: string, payload: string, options: {qos?: mqtt.QoS, retain?: boolean}={},
|
||||
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};
|
||||
|
||||
+31
-55
@@ -1,8 +1,8 @@
|
||||
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');
|
||||
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
|
||||
|
||||
@@ -13,34 +13,30 @@ const dontCacheProperties = [
|
||||
];
|
||||
|
||||
class State {
|
||||
constructor(eventBus) {
|
||||
this.state = {};
|
||||
this.file = data.joinPath('state.json');
|
||||
this.timer = null;
|
||||
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() {
|
||||
this._load();
|
||||
start(): void {
|
||||
this.load();
|
||||
|
||||
// Save the state on every interval
|
||||
this.clearTimer();
|
||||
this.timer = setInterval(() => this.save(), saveInterval);
|
||||
this.eventBus.onDeviceLeave(this, (data) => delete this.state[data.ieeeAddr]);
|
||||
}
|
||||
|
||||
clearTimer() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.clearTimer();
|
||||
stop(): void {
|
||||
this.eventBus.removeListeners(this);
|
||||
clearTimeout(this.timer);
|
||||
this.save();
|
||||
}
|
||||
|
||||
_load() {
|
||||
private load(): void {
|
||||
if (fs.existsSync(this.file)) {
|
||||
try {
|
||||
this.state = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
||||
@@ -53,7 +49,7 @@ class State {
|
||||
}
|
||||
}
|
||||
|
||||
save() {
|
||||
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);
|
||||
@@ -67,17 +63,17 @@ class State {
|
||||
}
|
||||
}
|
||||
|
||||
exists(ID) {
|
||||
return this.state.hasOwnProperty(ID);
|
||||
exists(entity: Device | Group): boolean {
|
||||
return this.state.hasOwnProperty(entity.ID);
|
||||
}
|
||||
|
||||
get(ID) {
|
||||
return this.state[ID];
|
||||
get(entity: Group | Device): KeyValue {
|
||||
return this.state[entity.ID];
|
||||
}
|
||||
|
||||
set(ID, update, reason=null) {
|
||||
const fromState = this.state[ID] || {};
|
||||
const toState = objectAssignDeep.noMutate(fromState, update);
|
||||
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)) {
|
||||
@@ -86,34 +82,14 @@ class State {
|
||||
}
|
||||
}
|
||||
|
||||
this.state[ID] = toState;
|
||||
this.eventBus.emit('stateChange', {ID, from: fromState, to: toState, reason, update});
|
||||
this.state[entity.ID] = toState;
|
||||
this.eventBus.emitStateChange({entity, from: fromState, to: toState, reason, update});
|
||||
return result;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
remove(ID: string | number): void {
|
||||
delete this.state[ID];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = State;
|
||||
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
+303
@@ -0,0 +1,303 @@
|
||||
/* 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[] | ((device: zh.Device, options: KeyValue) => 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 EntityOptionsChanged = { entity: Device | Group, from: KeyValue, to: KeyValue };
|
||||
type Reconfigure = { device: Device };
|
||||
type DeviceLeave = { ieeeAddr: string, name: string };
|
||||
type GroupMembersChanged = {group: Group, action: 'remove' | 'add' | 'remove_all',
|
||||
endpoint: zh.Endpoint, skipDisableReporting: boolean };
|
||||
type PublishEntityState = {entity: Group | Device, message: KeyValue, stateChangeReason: StateChangeReason,
|
||||
payload: KeyValue};
|
||||
type DeviceMessage = {
|
||||
type: ZHEvents.MessagePayloadType;
|
||||
device: Device;
|
||||
endpoint: zh.Endpoint;
|
||||
linkquality: number;
|
||||
groupID: number;
|
||||
cluster: string | number;
|
||||
data: KeyValue | Array<string | number>;
|
||||
meta: {zclTransactionSequenceNumber?: number;};
|
||||
};
|
||||
}
|
||||
|
||||
// Settings
|
||||
// eslint-disable camelcase
|
||||
interface Settings {
|
||||
homeassistant?: 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,
|
||||
qos?: 0 | 1 | 2,
|
||||
}
|
||||
|
||||
interface GroupSettings {
|
||||
devices?: string[],
|
||||
ID?: number,
|
||||
optimistic?: boolean,
|
||||
filtered_optimistic?: string[],
|
||||
retrieve_state?: boolean,
|
||||
homeassistant?: KeyValue,
|
||||
filtered_attributes?: string[],
|
||||
friendly_name: string,
|
||||
qos?: 0 | 1 | 2,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
import type {
|
||||
Device as ZZHDevice,
|
||||
Group as ZZHGroup,
|
||||
Endpoint as ZZHEndpoint,
|
||||
} from 'zigbee-herdsman/dist/controller/model';
|
||||
|
||||
import {
|
||||
NetworkParameters as ZHNetworkParameters,
|
||||
CoordinatorVersion as ZHCoordinatorVersion,
|
||||
LQI as ZHLQI,
|
||||
RoutingTable as ZHRoutingTable,
|
||||
RoutingTableEntry as ZHRoutingTableEntry,
|
||||
} from 'zigbee-herdsman/dist/adapter/tstype';
|
||||
|
||||
import * as D from 'lib/model/device';
|
||||
import * as Z from 'lib/zigbee';
|
||||
import * as E from 'lib/eventBus';
|
||||
import * as M from 'lib/mqtt';
|
||||
// TODO: check all
|
||||
|
||||
declare global {
|
||||
type RecursivePartial<T> = {
|
||||
[P in keyof T]?: RecursivePartial<T[P]>;
|
||||
};
|
||||
|
||||
type EventBus = E.default;
|
||||
|
||||
type MQTT = M.default;
|
||||
|
||||
type Zigbee = Z.default;
|
||||
|
||||
type Device = D.default;
|
||||
|
||||
type ZHEndpoint = ZZHEndpoint;
|
||||
|
||||
type ZHDevice = ZZHDevice;
|
||||
type LQI = ZHLQI;
|
||||
type RoutingTable = ZHRoutingTable;
|
||||
type RoutingTableEntry = ZHRoutingTableEntry;
|
||||
|
||||
type ZHGroup = ZZHGroup;
|
||||
|
||||
type CoordinatorVersion = ZHCoordinatorVersion;
|
||||
|
||||
type NetworkParameters = ZHNetworkParameters;
|
||||
|
||||
type ZigbeeEventType = 'deviceLeave' | 'deviceAnnounce';
|
||||
|
||||
interface ZigbeeEventData {
|
||||
ieeeAddr: string;
|
||||
}
|
||||
|
||||
/* eslint-disable */
|
||||
// Controller
|
||||
interface KeyValue {
|
||||
[s: string]: any,
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
homeassistant?: boolean,
|
||||
devices?: {[s: string]: {friendly_name: string, retention?: number}},
|
||||
groups?: {[s: string]: {friendly_name: string, devices?: string[]}},
|
||||
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,
|
||||
},
|
||||
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: {[s: string]: unknown},
|
||||
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',
|
||||
availability_new?: boolean,
|
||||
transmit_power?: number,
|
||||
},
|
||||
advanced: {
|
||||
legacy_api: boolean,
|
||||
log_rotation: boolean,
|
||||
log_symlink_current: boolean,
|
||||
log_output: ('console' | 'file')[],
|
||||
log_directory: string,
|
||||
log_file: string,
|
||||
log_level: 'debug' | 'info' | 'error' | 'warn',
|
||||
log_syslog: {},
|
||||
soft_reset_timeout: number,
|
||||
pan_id: number | 'GENERATE',
|
||||
ext_pan_id: number[],
|
||||
channel: number,
|
||||
adapter_concurrent: number | null,
|
||||
adapter_delay: number | null,
|
||||
availability_timeout: number,
|
||||
availability_blocklist: string[],
|
||||
availability_passlist: string[],
|
||||
availability_blacklist: string[],
|
||||
availability_whitelist: string[],
|
||||
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,
|
||||
},
|
||||
ota: {
|
||||
update_check_interval: number,
|
||||
disable_automatic_update_check: boolean
|
||||
},
|
||||
external_converters: string[],
|
||||
}
|
||||
|
||||
interface DeviceSettings {
|
||||
friendlyName: string,
|
||||
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,
|
||||
}
|
||||
|
||||
interface GroupSettings {
|
||||
friendlyName: string,
|
||||
devices: string[],
|
||||
ID: number,
|
||||
optimistic?: boolean,
|
||||
filtered_optimistic?: string[],
|
||||
retrieve_state?: boolean,
|
||||
homeassistant?: KeyValue,
|
||||
}
|
||||
|
||||
type EntitySettings = {
|
||||
type: 'device' | 'group'
|
||||
ID: number | string,
|
||||
friendlyName: string,
|
||||
}
|
||||
|
||||
interface ResolvedEntity {
|
||||
type: 'device' | 'group',
|
||||
device: ZHDevice,
|
||||
name: string,
|
||||
}
|
||||
|
||||
interface ToZigbeeConverterGetMeta {message: {}, mapped: Definition | Definition[]}
|
||||
|
||||
interface ToZigbeeConverterResult {state: KeyValue, membersState: {[s: string]: KeyValue}, readAfterWriteTime?: number}
|
||||
|
||||
interface ToZigbeeConverter {
|
||||
key: string[],
|
||||
convertGet?: (entity: ZHEndpoint | ZHGroup, key: string, meta: ToZigbeeConverterGetMeta) => Promise<void>
|
||||
convertSet?: (entity: ZHEndpoint | ZHGroup, key: string, value: any, meta: {state: KeyValue}) => Promise<ToZigbeeConverterResult>
|
||||
}
|
||||
|
||||
// interface Logger {
|
||||
// error: (message: string) => void;
|
||||
// warn: (message: string) => void;
|
||||
// debug: (message: string) => void;
|
||||
// info: (message: string) => void;
|
||||
// }
|
||||
|
||||
interface FromZigbeeConverter {
|
||||
cluster: string,
|
||||
type: string[] | string,
|
||||
convert: (model: Definition, msg: KeyValue, publish: (payload: KeyValue) => void, options: KeyValue,
|
||||
meta: {state: KeyValue, logger: any, device: ZHDevice}) => KeyValue,
|
||||
}
|
||||
|
||||
interface DefinitionExposeFeature {name: string, endpoint?: string, property: string, value_max?: number, value_min?: number, 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
|
||||
endpoint?: (device: ZHDevice) => {[s: string]: number}
|
||||
toZigbee: ToZigbeeConverter[]
|
||||
fromZigbee: FromZigbeeConverter[]
|
||||
icon?: string
|
||||
description: string
|
||||
vendor: string
|
||||
exposes: DefinitionExpose[] // TODO
|
||||
configure?: (device: ZHDevice, coordinatorEndpoint: ZZHEndpoint, logger: unknown) => Promise<void>;
|
||||
onEvent?: (type: string, data: KeyValue, device: ZHDevice, settings: KeyValue) => Promise<void>;
|
||||
ota?: {
|
||||
isUpdateAvailable: (device: ZHDevice, logger: unknown, data?: KeyValue) => Promise<boolean>;
|
||||
updateToLatest: (device: ZHDevice, logger: unknown, onProgress: (progress: number, remaining: number) => void) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
interface ResolvedDevice {
|
||||
type: 'device',
|
||||
definition?: Definition,
|
||||
name: string,
|
||||
endpoint: ZHEndpoint,
|
||||
device: ZHDevice,
|
||||
settings: {
|
||||
friendlyName: string,
|
||||
availability?: {timeout?: number} | boolean,
|
||||
}
|
||||
}
|
||||
|
||||
interface TempMQTT {
|
||||
publish: (topic: string, payload: string, options: {}, base?: string, skipLog?: boolean, skipReceive?: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
interface TempState {
|
||||
get: (ID: string | number) => KeyValue | null;
|
||||
remove: (ID: string | number) => void;
|
||||
removeKey: (ID: string, keys: string[]) => void;
|
||||
exists: (ID: string) => boolean;
|
||||
}
|
||||
|
||||
interface ExternalConverterClass {
|
||||
new(zigbee: Zigbee, mqtt: MQTT, state: TempState, publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus, settings: unknown, logger: unknown): ExternalConverterClass;
|
||||
}
|
||||
|
||||
interface MQTTResponse {data: KeyValue, status: string, error?: string, transaction?: string}
|
||||
|
||||
type PublishEntityState = (ID: string | number, payload: KeyValue, stateChangeReason?: 'publishDebounce') => void;
|
||||
}
|
||||
+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;
|
||||
}
|
||||
+5
-3
@@ -13,15 +13,17 @@ function load(): void {
|
||||
|
||||
load();
|
||||
|
||||
export function joinPath(file: string): string {
|
||||
function joinPath(file: string): string {
|
||||
return path.join(dataPath, file);
|
||||
}
|
||||
|
||||
export function getPath(): string {
|
||||
function getPath(): string {
|
||||
return dataPath;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
export function __testingOnly_reload(): void {
|
||||
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;
|
||||
@@ -30,36 +31,38 @@ if (output.includes('file')) {
|
||||
}
|
||||
}
|
||||
|
||||
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}`;
|
||||
}),
|
||||
),
|
||||
@@ -68,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}`;
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -94,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}`;
|
||||
@@ -103,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));
|
||||
}
|
||||
|
||||
@@ -110,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, '..');
|
||||
|
||||
@@ -119,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}'`);
|
||||
@@ -129,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": "Passive",
|
||||
"requiresRestart": true,
|
||||
"description": "Options for passive devices (mostly battery powered)",
|
||||
"properties": {
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"requiresRestart": true,
|
||||
"default": 1500,
|
||||
"description": "Time after which an passive device will be marked as offline in minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"title": "Availability",
|
||||
"requiresRestart": true,
|
||||
"description": "Checks whether devices are online/offline"
|
||||
},
|
||||
"mqtt": {
|
||||
"type": "object",
|
||||
"title": "MQTT",
|
||||
@@ -365,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",
|
||||
@@ -649,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": {
|
||||
|
||||
+24
-42
@@ -1,14 +1,12 @@
|
||||
import * as data from './data';
|
||||
import * as utils from './utils';
|
||||
import data from './data';
|
||||
import utils from './utils';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import path from 'path';
|
||||
import * as yaml from './yaml';
|
||||
import yaml from './yaml';
|
||||
import Ajv from 'ajv';
|
||||
import schemaJson from './settings.schema.json';
|
||||
export const schema = schemaJson;
|
||||
|
||||
// TODO: check all
|
||||
|
||||
// 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);
|
||||
@@ -75,7 +73,6 @@ const defaults: RecursivePartial<Settings> = {
|
||||
adapter_delay: null,
|
||||
|
||||
// Availability timeout in seconds, disabled by default.
|
||||
availability_timeout: 0,
|
||||
availability_blocklist: [],
|
||||
availability_passlist: [],
|
||||
// Deprecated, use block/passlist
|
||||
@@ -88,7 +85,7 @@ const defaults: RecursivePartial<Settings> = {
|
||||
*
|
||||
* 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,
|
||||
@@ -170,8 +167,7 @@ let _settingsWithDefaults: Settings;
|
||||
|
||||
function write(): void {
|
||||
const settings = getInternalSettings();
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
const toWrite = objectAssignDeep.noMutate(settings);
|
||||
const toWrite: KeyValue = objectAssignDeep({}, settings);
|
||||
|
||||
// Read settings to check if we have to split devices/groups into separate file.
|
||||
const actual = yaml.read(file);
|
||||
@@ -196,8 +192,7 @@ function write(): void {
|
||||
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];
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
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])) {
|
||||
@@ -218,8 +213,7 @@ function write(): void {
|
||||
yaml.writeIfChanged(file, toWrite);
|
||||
|
||||
_settings = read();
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
_settingsWithDefaults = objectAssignDeep.noMutate(defaults, getInternalSettings());
|
||||
_settingsWithDefaults = objectAssignDeep({}, defaults, getInternalSettings()) as Settings;
|
||||
}
|
||||
|
||||
export function validate(): string[] {
|
||||
@@ -253,15 +247,18 @@ export function validate(): string[] {
|
||||
|
||||
// Verify that all friendly names are unique
|
||||
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 check = (e: DeviceSettings | GroupSettings): void => {
|
||||
if (names.includes(e.friendly_name)) errors.push(`Duplicate friendly_name '${e.friendly_name}' found`);
|
||||
errors.push(...utils.validateFriendlyName(e.friendly_name));
|
||||
names.push(e.friendly_name);
|
||||
if (e.qos != null && ![0, 1, 2].includes(e.qos)) {
|
||||
errors.push(`QOS for '${e.friendly_name}' not valid, should be 0, 1 or 2 got ${e.qos}`);
|
||||
}
|
||||
};
|
||||
|
||||
const settingsWithDefaults = get();
|
||||
Object.values(settingsWithDefaults.devices).forEach((d) => check(d.friendly_name));
|
||||
Object.values(settingsWithDefaults.groups).forEach((g) => check(g.friendly_name));
|
||||
Object.values(settingsWithDefaults.devices).forEach((d) => check(d));
|
||||
Object.values(settingsWithDefaults.groups).forEach((g) => check(g));
|
||||
|
||||
if (settingsWithDefaults.mqtt.version !== 5) {
|
||||
for (const device of Object.values(settingsWithDefaults.devices)) {
|
||||
@@ -273,7 +270,7 @@ export function validate(): string[] {
|
||||
|
||||
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}'`);
|
||||
}
|
||||
});
|
||||
@@ -393,8 +390,7 @@ function getInternalSettings(): Partial<Settings> {
|
||||
|
||||
export function get(): Settings {
|
||||
if (!_settingsWithDefaults) {
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
_settingsWithDefaults = objectAssignDeep.noMutate(defaults, getInternalSettings());
|
||||
_settingsWithDefaults = objectAssignDeep({}, defaults, getInternalSettings()) as Settings;
|
||||
}
|
||||
|
||||
if (!_settingsWithDefaults.devices) {
|
||||
@@ -451,12 +447,12 @@ export function getGroup(IDorName: string | number): GroupSettings {
|
||||
const settings = get();
|
||||
const byID = settings.groups[IDorName];
|
||||
if (byID) {
|
||||
return {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 {devices: [], ...group, ID: Number(ID), friendlyName: group.friendly_name};
|
||||
return {devices: [], ...group, ID: Number(ID)};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,7 +462,7 @@ export function getGroup(IDorName: string | number): GroupSettings {
|
||||
export function getGroups(): GroupSettings[] {
|
||||
const settings = get();
|
||||
return Object.entries(settings.groups).map(([ID, group]) => {
|
||||
return {devices: [], ...group, ID: Number(ID), friendlyName: group.friendly_name};
|
||||
return {devices: [], ...group, ID: Number(ID)};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -483,12 +479,12 @@ 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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,20 +500,6 @@ function getDeviceThrowIfNotExists(IDorName: string): DeviceSettings {
|
||||
return device;
|
||||
}
|
||||
|
||||
export function getEntity(IDorName: string): EntitySettings {
|
||||
const device = getDevice(IDorName);
|
||||
if (device) {
|
||||
return {...device, type: 'device'};
|
||||
}
|
||||
|
||||
const group = getGroup(IDorName);
|
||||
if (group) {
|
||||
return {...group, type: 'group'};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function addDevice(ID: string): DeviceSettings {
|
||||
if (getDevice(ID)) {
|
||||
throw new Error(`Device '${ID}' already exists`);
|
||||
@@ -576,7 +558,7 @@ export function removeDevice(IDorName: string): void {
|
||||
// Remove device from groups
|
||||
if (settings.groups) {
|
||||
const regex =
|
||||
new RegExp(`^(${device.friendlyName}|${device.ID})(/(\\d|${utils.endpointNames.join('|')}))?$`);
|
||||
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));
|
||||
}
|
||||
|
||||
+62
-84
@@ -1,12 +1,9 @@
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import humanizeDuration from 'humanize-duration';
|
||||
import * as data from './data';
|
||||
import data from './data';
|
||||
import vm from 'vm';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {Endpoint} from 'zigbee-herdsman/dist/controller/model';
|
||||
|
||||
// TODO: check all
|
||||
|
||||
// construct a local ISO8601 string (instead of UTC-based)
|
||||
// Example:
|
||||
@@ -30,7 +27,7 @@ function toLocalISOString(date: Date): string {
|
||||
':' + pad(tzOffset % 60);
|
||||
}
|
||||
|
||||
export const endpointNames = [
|
||||
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',
|
||||
@@ -44,19 +41,18 @@ export const endpointNames = [
|
||||
'heat', 'cool', 'water', 'meter', 'wifi',
|
||||
];
|
||||
|
||||
export function capitalize(s: string): string {
|
||||
function capitalize(s: string): string {
|
||||
return s[0].toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
export async function getZigbee2MQTTVersionSimple(): Promise<string> {
|
||||
const packageJSON = await import('../..' + '/package.json');
|
||||
return packageJSON.version;
|
||||
}
|
||||
|
||||
export async function getZigbee2MQTTVersion(): Promise<{commitHash: string, version: string}> {
|
||||
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;
|
||||
|
||||
@@ -74,18 +70,19 @@ export async function getZigbee2MQTTVersion(): Promise<{commitHash: string, vers
|
||||
commitHash = commit.shortHash;
|
||||
}
|
||||
|
||||
commitHash = commitHash.trim();
|
||||
resolve({commitHash, version});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDependencyVersion(depend: string): Promise<{version: string}> {
|
||||
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};
|
||||
}
|
||||
|
||||
export function formatDate(time: number, type: 'ISO_8601' | 'ISO_8601_local' | 'epoch' | 'relative'): string | number {
|
||||
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;
|
||||
@@ -94,7 +91,7 @@ export function formatDate(time: number, type: 'ISO_8601' | 'ISO_8601_local' | '
|
||||
}
|
||||
}
|
||||
|
||||
export function objectHasProperties(object: {[s: string]: unknown}, properties: string[]): boolean {
|
||||
function objectHasProperties(object: {[s: string]: unknown}, properties: string[]): boolean {
|
||||
for (const property of properties) {
|
||||
if (!object.hasOwnProperty(property)) {
|
||||
return false;
|
||||
@@ -104,7 +101,7 @@ export function objectHasProperties(object: {[s: string]: unknown}, properties:
|
||||
return true;
|
||||
}
|
||||
|
||||
export function equalsPartial(object: {[s: string]: unknown}, expected: {[s: string]: unknown}): boolean {
|
||||
function equalsPartial(object: KeyValue, expected: KeyValue): boolean {
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
if (!equals(object[key], value)) {
|
||||
return false;
|
||||
@@ -114,13 +111,12 @@ export function equalsPartial(object: {[s: string]: unknown}, expected: {[s: str
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getObjectProperty(object: {[s: string]: unknown}, key: string, defaultValue: unknown): unknown {
|
||||
function getObjectProperty(object: KeyValue, key: string, defaultValue: unknown): unknown {
|
||||
return object && object.hasOwnProperty(key) ? object[key] : defaultValue;
|
||||
}
|
||||
|
||||
export function getResponse(request: KeyValue | string, data: KeyValue, error: string): MQTTResponse {
|
||||
const response: {data: unknown, status: string, error?: string, transaction?: string} =
|
||||
{data, status: error ? 'error' : 'ok'};
|
||||
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;
|
||||
@@ -128,15 +124,15 @@ export function getResponse(request: KeyValue | string, data: KeyValue, error: s
|
||||
return response;
|
||||
}
|
||||
|
||||
export function parseJSON(value: string, failedReturnValue: string): KeyValue | string {
|
||||
function parseJSON(value: string, fallback: string): KeyValue | string {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return failedReturnValue;
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadModuleFromText(moduleCode: string): unknown {
|
||||
function loadModuleFromText(moduleCode: string): unknown {
|
||||
const moduleFakePath = path.join(__dirname, 'externally-loaded.js');
|
||||
const sandbox = {
|
||||
require: require,
|
||||
@@ -154,14 +150,13 @@ export function loadModuleFromText(moduleCode: string): unknown {
|
||||
return sandbox.module.exports;
|
||||
}
|
||||
|
||||
export function loadModuleFromFile(modulePath: string): unknown {
|
||||
function loadModuleFromFile(modulePath: string): unknown {
|
||||
const moduleCode = fs.readFileSync(modulePath, {encoding: 'utf8'});
|
||||
return loadModuleFromText(moduleCode);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line */
|
||||
export function* getExternalConvertersDefinitions(settings: any): any {
|
||||
const externalConverters = settings.get().external_converters;
|
||||
function* getExternalConvertersDefinitions(settings: Settings): Generator<zhc.ExternalDefinition> {
|
||||
const externalConverters = settings.external_converters;
|
||||
|
||||
for (const moduleName of externalConverters) {
|
||||
let converter;
|
||||
@@ -182,7 +177,7 @@ export function* getExternalConvertersDefinitions(settings: any): any {
|
||||
}
|
||||
}
|
||||
|
||||
export function removeNullPropertiesFromObject(obj: KeyValue): void {
|
||||
function removeNullPropertiesFromObject(obj: KeyValue): void {
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = obj[key];
|
||||
if (value == null) {
|
||||
@@ -193,24 +188,13 @@ export function removeNullPropertiesFromObject(obj: KeyValue): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function getKey(
|
||||
object: KeyValue, value: unknown, fallback: unknown, convertTo: (v: unknown) => unknown): unknown {
|
||||
for (const key in object) {
|
||||
if (object[key]===value) {
|
||||
return convertTo ? convertTo(key) : key;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function toNetworkAddressHex(value: number): string {
|
||||
function toNetworkAddressHex(value: number): string {
|
||||
const hex = value.toString(16);
|
||||
return `0x${'0'.repeat(4 - hex.length)}${hex}`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
export function toSnakeCase(value: string | KeyValue): any {
|
||||
function toSnakeCase(value: string | KeyValue): any {
|
||||
if (typeof value === 'object') {
|
||||
value = {...value};
|
||||
for (const key of Object.keys(value)) {
|
||||
@@ -226,7 +210,7 @@ export function toSnakeCase(value: string | KeyValue): any {
|
||||
}
|
||||
}
|
||||
|
||||
export function validateFriendlyName(name: string, throwFirstError=false): string[] {
|
||||
function validateFriendlyName(name: string, throwFirstError=false): string[] {
|
||||
const errors = [];
|
||||
for (const endpointName of endpointNames) {
|
||||
if (name.toLowerCase().endsWith('/' + endpointName)) {
|
||||
@@ -250,21 +234,18 @@ export function validateFriendlyName(name: string, throwFirstError=false): strin
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function sleep(seconds: number): Promise<void> {
|
||||
function sleep(seconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
||||
}
|
||||
|
||||
export function sanitizeImageParameter(parameter: string): string {
|
||||
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;
|
||||
}
|
||||
|
||||
export function isAvailabilityEnabledForDevice(device: Device, settings: Settings): boolean {
|
||||
/* istanbul ignore next */
|
||||
if (!settings.experimental.availability_new) return false;
|
||||
|
||||
function isAvailabilityEnabledForDevice(device: Device, settings: Settings): boolean {
|
||||
if (device.settings.hasOwnProperty('availability')) {
|
||||
return !!device.settings.availability;
|
||||
}
|
||||
@@ -282,47 +263,44 @@ export function isAvailabilityEnabledForDevice(device: Device, settings: Setting
|
||||
return !blocklist.includes(device.name) && !blocklist.includes(device.ieeeAddr);
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
export function isAvailabilityEnabledForDeviceLegacy(rd: ResolvedDevice, settings: Settings): boolean {
|
||||
if (!settings.experimental.availability_new) return false;
|
||||
|
||||
if (rd.settings.hasOwnProperty('availability')) {
|
||||
return !!rd.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(rd.name) || passlist.includes(rd.device.ieeeAddr);
|
||||
}
|
||||
|
||||
const blocklist = settings.advanced.availability_blacklist.concat(settings.advanced.availability_blocklist);
|
||||
return !blocklist.includes(rd.name) && !blocklist.includes(rd.device.ieeeAddr);
|
||||
}
|
||||
|
||||
export function isXiaomiDevice(device: ZHDevice): boolean {
|
||||
const xiaomiManufacturerID = [4151, 4447];
|
||||
return device.modelID !== 'lumi.router' && xiaomiManufacturerID.includes(device.manufacturerID) &&
|
||||
(!device.manufacturerName || !device.manufacturerName.startsWith('Trust'));
|
||||
}
|
||||
|
||||
export function isIkeaTradfriDevice(device: ZHDevice): boolean {
|
||||
return [4476].includes(device.manufacturerID);
|
||||
}
|
||||
|
||||
const entityIDRegex = new RegExp(`^(.+?)(?:/(${endpointNames.join('|')}|\\d+))?$`);
|
||||
export function parseEntityID(ID: string): {ID: string, endpoint: string} {
|
||||
function parseEntityID(ID: string): {ID: string, endpoint: string} {
|
||||
const match = ID.match(entityIDRegex);
|
||||
return match && {ID: match[1], endpoint: match[2]};
|
||||
}
|
||||
|
||||
export function isEndpoint(obj: unknown): obj is Endpoint {
|
||||
function isEndpoint(obj: unknown): obj is zh.Endpoint {
|
||||
return obj.constructor.name.toLowerCase() === 'endpoint';
|
||||
}
|
||||
|
||||
export const hours = (hours: number): number => 1000 * 60 * 60 * hours;
|
||||
export const minutes = (minutes: number): number => 1000 * 60 * minutes;
|
||||
export const seconds = (seconds: number): number => 1000 * seconds;
|
||||
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,
|
||||
};
|
||||
|
||||
+6
-6
@@ -2,10 +2,8 @@ import yaml from 'js-yaml';
|
||||
import fs from 'fs';
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
|
||||
// TODO: check all
|
||||
export function read(file: string): KeyValue {
|
||||
function read(file: string): KeyValue {
|
||||
try {
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
return yaml.load(fs.readFileSync(file, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.name === 'YAMLException') {
|
||||
@@ -16,21 +14,23 @@ export function read(file: string): KeyValue {
|
||||
}
|
||||
}
|
||||
|
||||
export function readIfExists(file: string, default_?: KeyValue): KeyValue {
|
||||
function readIfExists(file: string, default_?: KeyValue): KeyValue {
|
||||
return fs.existsSync(file) ? read(file) : default_;
|
||||
}
|
||||
|
||||
export function writeIfChanged(file: string, content: KeyValue): void {
|
||||
function writeIfChanged(file: string, content: KeyValue): void {
|
||||
const before = readIfExists(file);
|
||||
if (!equals(before, content)) {
|
||||
fs.writeFileSync(file, yaml.dump(content));
|
||||
}
|
||||
}
|
||||
|
||||
export function updateIfChanged(file: string, key: string, value: KeyValue): void {
|
||||
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};
|
||||
|
||||
+105
-220
@@ -1,25 +1,22 @@
|
||||
import {Controller} from 'zigbee-herdsman';
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import * as data from './util/data';
|
||||
import assert from 'assert';
|
||||
import * as utils from './util/utils';
|
||||
import data from './util/data';
|
||||
import utils from './util/utils';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
// @ts-ignore
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
// @ts-ignore
|
||||
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 resolvedEntitiesLookup: {[s: string]: Device | Group} = {};
|
||||
private groupLookup: {[s: number]: Group} = {};
|
||||
private deviceLookup: {[s: string]: Device} = {};
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
this.acceptJoiningDeviceHandler = this.acceptJoiningDeviceHandler.bind(this);
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
@@ -52,9 +49,7 @@ export default class Zigbee {
|
||||
acceptJoiningDeviceHandler: this.acceptJoiningDeviceHandler,
|
||||
};
|
||||
|
||||
const herdsmanSettingsLog = objectAssignDeep({}, herdsmanSettings);
|
||||
// @ts-ignore
|
||||
herdsmanSettingsLog.network.networkKey = 'HIDDEN';
|
||||
const herdsmanSettingsLog = objectAssignDeep({}, herdsmanSettings, {network: {networkKey: 'HIDDEN'}});
|
||||
logger.debug(`Using zigbee-herdsman with settings: '${stringify(herdsmanSettingsLog)}'`);
|
||||
|
||||
let startResult;
|
||||
@@ -68,60 +63,75 @@ export default class Zigbee {
|
||||
|
||||
this.herdsman.on('adapterDisconnected', () => this.eventBus.emitAdapterDisconnected());
|
||||
this.herdsman.on('lastSeenChanged', (data: ZHEvents.LastSeenChangedPayload) => {
|
||||
this.eventBus.emitLastSeenChanged({device: this.resolveEntity(data.device) as Device});
|
||||
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) => {
|
||||
this.eventBus.emit('event', 'deviceNetworkAddressChanged', data); // TODO remove this event
|
||||
this.eventBus.emitDeviceNetworkAddressChanged({device: this.resolveEntity(data.device) as Device});
|
||||
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) => {
|
||||
this.eventBus.emit('event', 'deviceAnnounce', data); // TODO remove this event
|
||||
this.eventBus.emitDeviceAnnounce({device: this.resolveEntity(data.device) as Device});
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
logger.debug(`Device '${device.name}' announced itself`);
|
||||
this.eventBus.emitDeviceAnnounce({device});
|
||||
});
|
||||
this.herdsman.on('deviceInterview', (data: ZHEvents.DeviceInterviewPayload) => {
|
||||
this.eventBus.emit('event', 'deviceInterview', data); // TODO remove this event
|
||||
this.eventBus.emitDeviceInterview({device: this.resolveEntity(data.device) as Device, status: data.status});
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
/* istanbul ignore if */ if (!device) return; // Prevent potential race
|
||||
const d = {device, status: data.status};
|
||||
this.logDeviceInterview(d);
|
||||
this.eventBus.emitDeviceInterview(d);
|
||||
});
|
||||
this.herdsman.on('deviceJoined', (data: ZHEvents.DeviceJoinedPayload) => {
|
||||
this.eventBus.emit('event', 'deviceJoined', data); // TODO remove this event
|
||||
this.eventBus.emitDeviceJoined({device: this.resolveEntity(data.device) as Device});
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
/* istanbul ignore if */ if (!device) return; // Prevent potential race
|
||||
logger.info(`Device '${device.name}' joined`);
|
||||
this.eventBus.emitDeviceJoined({device});
|
||||
});
|
||||
this.herdsman.on('deviceLeave', (data: ZHEvents.DeviceLeavePayload) => {
|
||||
this.eventBus.emit('event', 'deviceLeave', data); // TODO remove this event
|
||||
this.eventBus.emitDeviceLeave(data);
|
||||
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) => {
|
||||
this.eventBus.emit('event', 'message', data); // TODO remove this event
|
||||
this.eventBus.emitDeviceMessage({...data, device: this.resolveEntity(data.device) as Device});
|
||||
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.getClients()) {
|
||||
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...`);
|
||||
device.removeFromNetwork();
|
||||
await remove(device);
|
||||
}
|
||||
} else if (blocklist.includes(device.ieeeAddr)) {
|
||||
logger.warn(`Device on blocklist is connected (${device.ieeeAddr}), removing...`);
|
||||
device.removeFromNetwork();
|
||||
await remove(device);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -132,6 +142,27 @@ export default class Zigbee {
|
||||
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);
|
||||
@@ -144,7 +175,7 @@ export default class Zigbee {
|
||||
return panID;
|
||||
}
|
||||
|
||||
async getCoordinatorVersion(): Promise<CoordinatorVersion> {
|
||||
async getCoordinatorVersion(): Promise<zh.CoordinatorVersion> {
|
||||
return this.herdsman.getCoordinatorVersion();
|
||||
}
|
||||
|
||||
@@ -152,7 +183,7 @@ export default class Zigbee {
|
||||
return this.herdsman.isStopping();
|
||||
}
|
||||
|
||||
async getNetworkParameters(): Promise<NetworkParameters> {
|
||||
async getNetworkParameters(): Promise<zh.NetworkParameters> {
|
||||
return this.herdsman.getNetworkParameters();
|
||||
}
|
||||
|
||||
@@ -182,67 +213,67 @@ export default class Zigbee {
|
||||
}
|
||||
|
||||
if (device && permit) {
|
||||
await this.herdsman.permitJoin(permit, device.zhDevice, time);
|
||||
await this.herdsman.permitJoin(permit, device.zh, time);
|
||||
} else {
|
||||
await this.herdsman.permitJoin(permit, undefined, time);
|
||||
}
|
||||
}
|
||||
|
||||
private addDeviceToResolvedEntitiesLookup(ieeeAddr: string): Device {
|
||||
if (!this.resolvedEntitiesLookup[ieeeAddr]) {
|
||||
private resolveDevice(ieeeAddr: string): Device {
|
||||
if (!this.deviceLookup[ieeeAddr]) {
|
||||
const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
|
||||
/* istanbul ignore else */
|
||||
if (device) this.resolvedEntitiesLookup[ieeeAddr] = new Device(device);
|
||||
device && (this.deviceLookup[ieeeAddr] = new Device(device));
|
||||
}
|
||||
|
||||
return this.resolvedEntitiesLookup[ieeeAddr] as Device;
|
||||
const device = this.deviceLookup[ieeeAddr];
|
||||
if (device && !device.zh.isDeleted) {
|
||||
device.ensureInSettings();
|
||||
return device;
|
||||
}
|
||||
}
|
||||
|
||||
private addGroupToResolvedEntitiesLookup(groupID: number): Group {
|
||||
if (!this.resolvedEntitiesLookup[groupID]) {
|
||||
let group = this.herdsman.getGroupByID(groupID);
|
||||
// Legacy: previously zigbee-herdsman did not keep track of groups, therefore create it when published to
|
||||
// note that the group is in the configuration.yaml already.
|
||||
if (group == null) group = this.herdsman.createGroup(groupID);
|
||||
this.resolvedEntitiesLookup[groupID] = new Group(group);
|
||||
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.resolvedEntitiesLookup[groupID] as Group;
|
||||
return this.groupLookup[groupID];
|
||||
}
|
||||
|
||||
resolveEntity(key: ZHDevice | string): Device | Group {
|
||||
const ID = typeof key === 'string' ? key : key.ieeeAddr;
|
||||
const entitySettings = settings.getEntity(ID);
|
||||
if (!entitySettings && !(typeof key === 'object' && key.type === 'Coordinator')) return undefined;
|
||||
|
||||
resolveEntity(key: string | number | zh.Device): Device | Group {
|
||||
if (typeof key === 'object') {
|
||||
return this.addDeviceToResolvedEntitiesLookup(key.ieeeAddr);
|
||||
return this.resolveDevice(key.ieeeAddr);
|
||||
} else if (typeof key === 'string' && key.toLowerCase() === 'coordinator') {
|
||||
return this.resolveDevice(this.herdsman.getDevicesByType('Coordinator')[0].ieeeAddr);
|
||||
} else {
|
||||
return entitySettings.type === 'device' ?
|
||||
this.addDeviceToResolvedEntitiesLookup(entitySettings.ID as string) :
|
||||
this.addGroupToResolvedEntitiesLookup(entitySettings.ID as number);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getClients(): Device[] {
|
||||
return this.herdsman.getDevices().filter((device) => device.type !== 'Coordinator')
|
||||
.map((d) => this.resolveEntity(d) as Device).filter((d) => d);
|
||||
}
|
||||
|
||||
getFirstCoordinatorEndpoint(): ZHEndpoint {
|
||||
firstCoordinatorEndpoint(): zh.Endpoint {
|
||||
return this.herdsman.getDevicesByType('Coordinator')[0].endpoints[0];
|
||||
}
|
||||
|
||||
getGroups(): Group[] {
|
||||
return this.herdsman.getGroups().map((g) => this.addGroupToResolvedEntitiesLookup(g.groupID)).filter((g) => g);
|
||||
groups(): Group[] {
|
||||
return this.herdsman.getGroups().map((g) => this.resolveGroup(g.groupID));
|
||||
}
|
||||
|
||||
getDevices(): Device[] {
|
||||
devices(includeCoordinator=true): Device[] {
|
||||
return this.herdsman.getDevices()
|
||||
.map((d) => this.addDeviceToResolvedEntitiesLookup(d.ieeeAddr)).filter((d) => d);
|
||||
.map((d) => this.resolveDevice(d.ieeeAddr))
|
||||
.filter((d) => includeCoordinator || d.zh.type !== 'Coordinator');
|
||||
}
|
||||
|
||||
private async acceptJoiningDeviceHandler(ieeeAddr: string): Promise<boolean> {
|
||||
@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);
|
||||
@@ -283,163 +314,17 @@ export default class Zigbee {
|
||||
return this.herdsman.touchlinkScan();
|
||||
}
|
||||
|
||||
createGroup(groupID: number): Group {
|
||||
this.herdsman.createGroup(groupID);
|
||||
return this.addGroupToResolvedEntitiesLookup(groupID);
|
||||
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.addDeviceToResolvedEntitiesLookup(device.ieeeAddr);
|
||||
return device && this.resolveDevice(device.ieeeAddr);
|
||||
}
|
||||
|
||||
// TODO remove all legacy below
|
||||
createGroupLegacy(groupID: number): ZHGroup {
|
||||
return this.herdsman.createGroup(groupID);
|
||||
}
|
||||
getGroupsLegacy(): ZHGroup[] {
|
||||
return this.herdsman.getGroups();
|
||||
}
|
||||
getGroupByIDLegacy(ID: number): ZHGroup {
|
||||
return this.herdsman.getGroupByID(ID);
|
||||
}
|
||||
getDevicesByTypeLegacy(type: 'Coordinator' | 'Router' | 'EndDevice'): ZHDevice[] {
|
||||
return this.herdsman.getDevicesByType(type);
|
||||
}
|
||||
getClientsLegacy(): ZHDevice[] {
|
||||
return this.herdsman.getDevices().filter((device) => device.type !== 'Coordinator');
|
||||
}
|
||||
getDevicesLegacy(): ZHDevice[] {
|
||||
return this.herdsman.getDevices();
|
||||
}
|
||||
async permitJoinLegacy(permit: boolean, resolvedEntity: ResolvedDevice, time: number=undefined): Promise<void> {
|
||||
if (permit) {
|
||||
/* istanbul ignore next */
|
||||
logger.info(`Zigbee: allowing new devices to join${resolvedEntity ? ` via ${resolvedEntity.name}` : ''}.`);
|
||||
} else {
|
||||
logger.info('Zigbee: disabling joining new devices.');
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (resolvedEntity && permit) {
|
||||
/* istanbul ignore next */
|
||||
await this.herdsman.permitJoin(permit, resolvedEntity.device, time);
|
||||
} else {
|
||||
await this.herdsman.permitJoin(permit, undefined, time);
|
||||
}
|
||||
}
|
||||
/* eslint-disable-next-line */
|
||||
resolveEntityLegacy(key: any): any {
|
||||
assert(
|
||||
typeof key === 'string' || typeof key === 'number' ||
|
||||
key.constructor.name === 'Device' || key.constructor.name === 'Group' ||
|
||||
key.constructor.name === 'Endpoint',
|
||||
`Wrong type '${typeof key}'`,
|
||||
);
|
||||
|
||||
/* eslint-disable-next-line */
|
||||
const getEndpointName = (endpointNames: any, endpoint: any) => {
|
||||
return endpoint ?
|
||||
utils.getKey(endpointNames, endpoint.ID, null, ((v) => v === 'default' ? null : v)) : null;
|
||||
};
|
||||
|
||||
const deviceOptions = settings.get().device_options;
|
||||
/* istanbul ignore else */
|
||||
if (typeof key === 'string' || typeof key === 'number') {
|
||||
if (typeof key === 'number') {
|
||||
key = key.toString();
|
||||
}
|
||||
|
||||
if (typeof key === 'string' && key.toLowerCase() === 'coordinator') {
|
||||
const coordinator = this.getDevicesByTypeLegacy('Coordinator')[0];
|
||||
return {
|
||||
type: 'device',
|
||||
device: coordinator,
|
||||
endpoint: coordinator.getEndpoint(1),
|
||||
settings: {friendlyName: 'Coordinator'},
|
||||
name: 'Coordinator',
|
||||
endpointName: null,
|
||||
};
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line */
|
||||
let endpointKey: any = utils.endpointNames.find((p) => key.endsWith(`/${p}`));
|
||||
const keyEndpointByNumber = new RegExp(`.*/([0-9]*)$`);
|
||||
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') {
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
const device = this.herdsman.getDeviceByIeeeAddr(entity.ID);
|
||||
if (!device) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definition = zigbeeHerdsmanConverters.findByDevice(device);
|
||||
const endpointNames = 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(endpointNames != null,
|
||||
`Endpoint name '${endpointKey}' is given but no endpoints defined`);
|
||||
const endpointID = endpointNames[endpointKey];
|
||||
assert(endpointID, `Endpoint name '${endpointKey}' is given but device has no such endpoint`);
|
||||
endpoint = device.getEndpoint(endpointID);
|
||||
}
|
||||
} else if (endpointNames && endpointNames['default']) {
|
||||
endpoint = device.getEndpoint(endpointNames['default']);
|
||||
} else {
|
||||
endpoint = device.endpoints[0];
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'device', device, endpoint, settings: {...deviceOptions, ...entity},
|
||||
name: entity.friendlyName, definition,
|
||||
endpointName: getEndpointName(endpointNames, endpoint),
|
||||
};
|
||||
} else {
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
let group = this.getGroupByIDLegacy(entity.ID);
|
||||
/* istanbul ignore if */ // @ts-ignore
|
||||
if (!group) group = this.createGroupLegacy(entity.ID);
|
||||
return {type: 'group', group, settings: {...deviceOptions, ...entity}, name: entity.friendlyName};
|
||||
}
|
||||
} else if (key.constructor.name === 'Device' || key.constructor.name === 'Endpoint') {
|
||||
const device = key.constructor.name === 'Endpoint' ? key.getDevice() : key;
|
||||
const setting = settings.getEntity(device.ieeeAddr);
|
||||
const definition = zigbeeHerdsmanConverters.findByDevice(device);
|
||||
const name = setting ? setting.friendlyName :
|
||||
(device.type === 'Coordinator' ? 'Coordinator' : device.ieeeAddr);
|
||||
const endpointNames = definition && definition.endpoint ? definition.endpoint(device) : null;
|
||||
|
||||
let endpoint;
|
||||
if (key.constructor.name === 'Endpoint') endpoint = key;
|
||||
else if (endpointNames && endpointNames['default']) endpoint = device.getEndpoint(endpointNames['default']);
|
||||
else endpoint = device.endpoints[0];
|
||||
|
||||
return {
|
||||
type: 'device', definition, name, device, endpoint, settings: {...deviceOptions, ...(setting || {})},
|
||||
endpointName: getEndpointName(endpointNames, endpoint),
|
||||
};
|
||||
} else { // Group
|
||||
const setting = settings.getEntity(key.groupID);
|
||||
return {
|
||||
type: 'group',
|
||||
group: key,
|
||||
settings: {...deviceOptions, ...(setting || {})},
|
||||
name: setting ? setting.friendlyName : key.groupID,
|
||||
};
|
||||
}
|
||||
groupByID(ID: number): Group {
|
||||
return this.resolveGroup(ID);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+4642
-5095
File diff suppressed because it is too large
Load Diff
+27
-26
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "1.21.2",
|
||||
"version": "1.22.2",
|
||||
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
|
||||
"main": "index.js",
|
||||
"repository": {
|
||||
@@ -35,53 +35,54 @@
|
||||
},
|
||||
"homepage": "https://koenkk.github.io/zigbee2mqtt",
|
||||
"dependencies": {
|
||||
"ajv": "^8.6.3",
|
||||
"ajv": "^8.8.2",
|
||||
"bind-decorator": "^1.0.11",
|
||||
"connect-gzip-static": "^2.1.1",
|
||||
"core-js": "^3.20.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.1",
|
||||
"humanize-duration": "^3.27.0",
|
||||
"humanize-duration": "^3.27.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"mkdir-recursive": "^0.4.0",
|
||||
"moment": "^2.29.1",
|
||||
"mqtt": "4.2.8",
|
||||
"mqtt": "4.3.1",
|
||||
"object-assign-deep": "^0.4.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"semver": "^7.3.5",
|
||||
"serve-static": "^1.14.1",
|
||||
"source-map-support": "^0.5.20",
|
||||
"source-map-support": "^0.5.21",
|
||||
"winston": "^3.3.3",
|
||||
"winston-syslog": "^2.4.4",
|
||||
"ws": "^8.2.2",
|
||||
"zigbee-herdsman": "0.13.149",
|
||||
"zigbee-herdsman-converters": "14.0.261",
|
||||
"zigbee2mqtt-frontend": "0.6.12"
|
||||
"ws": "^8.4.0",
|
||||
"zigbee-herdsman": "0.13.188",
|
||||
"zigbee-herdsman-converters": "14.0.366",
|
||||
"zigbee2mqtt-frontend": "0.6.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.15.5",
|
||||
"@babel/plugin-proposal-decorators": "^7.15.4",
|
||||
"@babel/preset-env": "^7.15.6",
|
||||
"@babel/preset-typescript": "^7.15.0",
|
||||
"@babel/core": "^7.16.5",
|
||||
"@babel/plugin-proposal-decorators": "^7.16.5",
|
||||
"@babel/preset-env": "^7.16.5",
|
||||
"@babel/preset-typescript": "^7.16.5",
|
||||
"@types/debounce": "^1.2.1",
|
||||
"@types/finalhandler": "^1.1.1",
|
||||
"@types/humanize-duration": "^3.25.1",
|
||||
"@types/jest": "^27.0.2",
|
||||
"@types/js-yaml": "^4.0.3",
|
||||
"@types/humanize-duration": "^3.27.1",
|
||||
"@types/jest": "^27.0.3",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
"@types/object-assign-deep": "^0.4.0",
|
||||
"@types/serve-static": "^1.13.10",
|
||||
"@types/ws": "^8.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.31.2",
|
||||
"@typescript-eslint/parser": "^4.31.2",
|
||||
"babel-jest": "^27.2.2",
|
||||
"eslint": "^7.32.0",
|
||||
"@types/rimraf": "^3.0.2",
|
||||
"@types/ws": "^8.2.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.8.0",
|
||||
"@typescript-eslint/parser": "^5.8.0",
|
||||
"babel-jest": "^27.4.5",
|
||||
"eslint": "^8.5.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-plugin-jest": "^24.4.2",
|
||||
"jest": "^27.2.2",
|
||||
"eslint-plugin-jest": "^25.3.0",
|
||||
"jest": "^27.4.5",
|
||||
"tmp": "^0.2.1",
|
||||
"typescript": "^4.4.3"
|
||||
"typescript": "^4.5.4"
|
||||
},
|
||||
"jest": {
|
||||
"coverageThreshold": {
|
||||
|
||||
@@ -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])}`);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ class Example {
|
||||
this.mqtt.publish('example/extension', 'call from constructor')
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
start() {
|
||||
this.mqtt.publish('example/extension', 'test')
|
||||
}
|
||||
}
|
||||
|
||||
+245
-362
@@ -1,415 +1,298 @@
|
||||
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');
|
||||
zigbeeHerdsman.returnDevices.push('0x000b57fffec6a5b2');
|
||||
zigbeeHerdsman.returnDevices.push('0x0017880104e45535');
|
||||
zigbeeHerdsman.returnDevices.push('0x0017880104e45521');
|
||||
zigbeeHerdsman.returnDevices.push('0x0017880104e45525');
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
|
||||
import data from './stub/data';
|
||||
import logger from './stub/logger';
|
||||
import MQTT from './stub/mqtt';
|
||||
import zigbeeHerdsman from './stub/zigbeeHerdsman';
|
||||
|
||||
import utils from '../lib/util/utils';
|
||||
import * as settings from '../lib/util/settings';
|
||||
import Controller from '../lib/controller';
|
||||
import flushPromises from './lib/flushPromises';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
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;
|
||||
let extension;
|
||||
|
||||
let resetExtension = async () => {
|
||||
await controller.enableDisableExtension(false, 'AvailabilityLegacy');
|
||||
await controller.enableDisableExtension(true, 'AvailabilityLegacy');
|
||||
extension = controller.extensions.find((e) => e.constructor.name === 'AvailabilityLegacy');
|
||||
await controller.enableDisableExtension(false, 'Availability');
|
||||
await controller.enableDisableExtension(true, 'Availability');
|
||||
}
|
||||
|
||||
const advancedTime = async (value) => {
|
||||
jest.setSystemTime(Date.now() + value);
|
||||
jest.advanceTimersByTime(value);
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(utils, 'sleep').mockImplementation(async (seconds) => {});
|
||||
jest.useFakeTimers('modern');
|
||||
settings.reRead();
|
||||
settings.set(['availability'], true);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers('modern').setSystemTime(utils.minutes(1));
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
settings.set(['advanced', 'availability_timeout'], 10);
|
||||
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 () => {
|
||||
Object.values(devices).forEach(d => d.lastSeen = utils.minutes(1));
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
})
|
||||
|
||||
it('Should publish availabilty on startup', async () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color/availability',
|
||||
'online',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
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).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);
|
||||
await advancedTime(utils.minutes(12));
|
||||
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should publish offline for passive device when not seen for 25 hours', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
await 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();
|
||||
|
||||
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('genOnOff', ['onOff']);
|
||||
});
|
||||
expect(endpoint.read).toHaveBeenCalledWith('lightingColorCtrl',
|
||||
['colorMode', 'currentX', 'currentY', 'enhancedCurrentHue', 'currentSaturation', 'colorTemperature']);
|
||||
|
||||
it('Should fail gracefully when quering state after reconnect fails', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.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();
|
||||
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
expect(logger.error).toHaveBeenCalledWith(`Failed to read state of 'bulb_color' after reconnect`);
|
||||
endpoint.read.mockImplementationOnce(() => {throw new Error('')});
|
||||
await advancedTime(utils.seconds(3));
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
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();
|
||||
extension.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('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('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('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'])
|
||||
it('Deprecated - should allow to block via advanced.availability_blocklist', async () => {
|
||||
settings.set(['advanced', 'availability_blocklist'], [devices.bulb_color.ieeeAddr]);
|
||||
await resetExtension();
|
||||
device.ping.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await advancedTime(utils.minutes(12));
|
||||
expect(devices.bulb_color.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'])
|
||||
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();
|
||||
device.ping.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await advancedTime(utils.minutes(12));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should not ping devices on blocklist by IEEE address', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
settings.set(['advanced', 'availability_blocklist'], [device.ieeeAddr]);
|
||||
it('Deprecated - should allow to enable via availability_timeout', async () => {
|
||||
settings.set(['availability'], false);
|
||||
settings.set(['advanced', 'availability_timeout'], 60);
|
||||
await resetExtension();
|
||||
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 resetExtension();
|
||||
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 resetExtension();
|
||||
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 resetExtension();
|
||||
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;
|
||||
device.ping.mockClear();
|
||||
extension.state[device.ieeeAddr] = false;
|
||||
settings.set(['advanced', 'availability_passlist'], ['0x000b57fffec6a5b3'])
|
||||
await resetExtension();
|
||||
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;
|
||||
extension.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;
|
||||
extension.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 () => {
|
||||
delete extension.state[zigbeeHerdsman.devices.WXKG02LM_rev1.ieeeAddr];
|
||||
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
|
||||
device.lastSeen = device.lastSeen - (1000 * 60 * 60 * 25);
|
||||
jest.advanceTimersByTime(1000 * 60 * 60 * 1); // 1 hours
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(2);
|
||||
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(2);
|
||||
|
||||
device.lastSeen = defaultLastSeen;
|
||||
});
|
||||
|
||||
it('Should republish existing state on MQTT connected', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
MQTT.publish.mockClear();
|
||||
extension.state[device.ieeeAddr] = false;
|
||||
await extension.onMQTTConnected();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
import data from './stub/data';
|
||||
import logger from './stub/logger';
|
||||
import MQTT from './stub/mqtt';
|
||||
import zigbeeHerdsman from './stub/zigbeeHerdsman';
|
||||
|
||||
import * as 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 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;
|
||||
|
||||
let resetExtension = async () => {
|
||||
await controller.enableDisableExtension(false, 'Availability');
|
||||
await controller.enableDisableExtension(true, 'Availability');
|
||||
}
|
||||
|
||||
const advancedTime = async (value) => {
|
||||
jest.setSystemTime(Date.now() + value);
|
||||
jest.advanceTimersByTime(value);
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(utils, 'sleep').mockImplementation(async (seconds: number) => {});
|
||||
jest.useFakeTimers('modern');
|
||||
settings.reRead();
|
||||
settings.set(['availability'], true);
|
||||
settings.set(['experimental', 'availability_new'], true);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers('modern').setSystemTime(utils.minutes(1));
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
settings.set(['availability'], true);
|
||||
settings.set(['experimental', 'availability_new'], true);
|
||||
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], false);
|
||||
// @ts-ignore
|
||||
Object.values(devices).forEach(d => d.lastSeen = utils.minutes(1));
|
||||
mocks.forEach((m) => m.mockClear());
|
||||
await resetExtension();
|
||||
// @ts-ignore
|
||||
Object.values(devices).forEach((d) => d.ping.mockClear());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// @ts-ignore
|
||||
Object.values(devices).forEach(d => d.lastSeen = utils.minutes(1));
|
||||
})
|
||||
|
||||
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 offline for active device when not seen for 10 minutes', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
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('Shouldnt do anything for a device when availability: false is set for device', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color_2}); // Coverage satisfaction
|
||||
|
||||
await advancedTime(utils.minutes(12));
|
||||
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should publish offline for passive device when not seen for 25 hours', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
await 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 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();
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('Should republish availability when device is renamed', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({from: 'bulb_color', to: 'bulb_new_name'}));
|
||||
await flushPromises();
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_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('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('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('Deprecated - should allow to enable via availability_timeout', async () => {
|
||||
settings.set(['availability'], false);
|
||||
settings.set(['advanced', 'availability_timeout'], 60);
|
||||
await resetExtension();
|
||||
|
||||
await advancedTime(utils.minutes(12));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+26
-24
File diff suppressed because one or more lines are too long
@@ -100,12 +100,12 @@ describe('Configure', () => {
|
||||
expectBulbConfigured();
|
||||
});
|
||||
|
||||
it('Should reconfigure reporting on reportingDisabled event', async () => {
|
||||
it('Should reconfigure reporting on reconfigure event', async () => {
|
||||
expectBulbConfigured();
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
mockClear(device);
|
||||
const device = controller.zigbee.resolveEntity(zigbeeHerdsman.devices.bulb);
|
||||
mockClear(device.zh);
|
||||
expectBulbNotConfigured();
|
||||
controller.eventBus.emit('reportingDisabled', {device})
|
||||
controller.eventBus.emitReconfigure({device});
|
||||
await flushPromises();
|
||||
expectBulbConfigured();
|
||||
});
|
||||
@@ -114,7 +114,7 @@ describe('Configure', () => {
|
||||
expectBulbConfigured();
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
mockClear(device);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await zigbeeHerdsman.events.deviceInterview({device});
|
||||
await flushPromises();
|
||||
expectBulbNotConfigured();
|
||||
});
|
||||
|
||||
+76
-45
@@ -25,6 +25,7 @@ describe('Controller', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
MQTT.restoreOnMock();
|
||||
zigbeeHerdsman.returnDevices.splice(0);
|
||||
mockExit = jest.fn();
|
||||
controller = new Controller(jest.fn(), mockExit);
|
||||
@@ -42,7 +43,6 @@ describe('Controller', () => {
|
||||
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, 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);
|
||||
@@ -155,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!");
|
||||
@@ -172,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);
|
||||
@@ -208,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();
|
||||
@@ -215,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();
|
||||
@@ -235,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 () => {
|
||||
@@ -245,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 () => {
|
||||
@@ -256,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 () => {
|
||||
@@ -301,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;
|
||||
@@ -452,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));
|
||||
@@ -468,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));
|
||||
@@ -484,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));
|
||||
@@ -497,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));
|
||||
@@ -509,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));
|
||||
});
|
||||
@@ -523,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));
|
||||
});
|
||||
@@ -532,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));
|
||||
});
|
||||
@@ -543,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));
|
||||
});
|
||||
@@ -552,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);
|
||||
});
|
||||
@@ -562,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));
|
||||
@@ -586,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();
|
||||
@@ -638,4 +637,36 @@ describe('Controller', () => {
|
||||
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');
|
||||
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.__testingOnly_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.__testingOnly_reload();
|
||||
data.testingOnlyReload();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,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
|
||||
@@ -140,7 +140,7 @@ describe('Frontend', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color',
|
||||
stringify({state: 'ON', linkquality: null}),
|
||||
stringify({state: 'ON', linkquality: null, update_available: null, update: {state: null}}),
|
||||
{ retain: false, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
@@ -151,7 +151,7 @@ describe('Frontend', () => {
|
||||
|
||||
// Received message on socket
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledTimes(1);
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'bulb_color', payload: {state: 'ON', linkquality: null}}));
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'bulb_color', payload: {state: 'ON', linkquality: null, update_available: null, update: {state: null}}}));
|
||||
|
||||
// Shouldnt set when not ready
|
||||
mockWSClient.implementation.send.mockClear();
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ describe('Groups', () => {
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
+136
-71
@@ -25,12 +25,12 @@ describe('HomeAssistant extension', () => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
data.writeEmptyState();
|
||||
controller.state._load();
|
||||
controller.state.load();
|
||||
await resetExtension();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
version = await require('../lib/util/utils').getZigbee2MQTTVersion();
|
||||
version = await require('../lib/util/utils').default.getZigbee2MQTTVersion();
|
||||
version = `Zigbee2MQTT ${version.version}`;
|
||||
jest.useFakeTimers();
|
||||
settings.set(['homeassistant'], true);
|
||||
@@ -49,14 +49,17 @@ describe('HomeAssistant extension', () => {
|
||||
it('Should not have duplicate type/object_ids in a mapping', () => {
|
||||
const duplicated = [];
|
||||
require('zigbee-herdsman-converters').devices.forEach((d) => {
|
||||
const mapping = extension._getMapping()[d.model];
|
||||
const exposes = typeof d.exposes == 'function' ? d.exposes() : d.exposes;
|
||||
const device = {definition: d, isDevice: () => true, settings: {}, exposes: () => exposes};
|
||||
const configs = extension.getConfigs(device);
|
||||
const cfg_type_object_ids = [];
|
||||
|
||||
mapping.forEach((c) => {
|
||||
if (cfg_type_object_ids.includes(c['type'] + '/' + c['object_id'])) {
|
||||
configs.forEach((c) => {
|
||||
const id = c['type'] + '/' + c['object_id'];
|
||||
if (cfg_type_object_ids.includes(id)) {
|
||||
duplicated.push(d.model);
|
||||
} else {
|
||||
cfg_type_object_ids.push(c['type'] + '/' + c['object_id']);
|
||||
cfg_type_object_ids.push(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -77,7 +80,7 @@ describe('HomeAssistant extension', () => {
|
||||
"device":{
|
||||
"identifiers":["zigbee2mqtt_1221051039810110150109113116116_9"],
|
||||
"name":"ha_discovery_group",
|
||||
"sw_version":version,
|
||||
"sw_version": version,
|
||||
},
|
||||
"max_mireds": 454,
|
||||
"min_mireds": 250,
|
||||
@@ -105,7 +108,7 @@ describe('HomeAssistant extension', () => {
|
||||
"device":{
|
||||
"identifiers":["zigbee2mqtt_1221051039810110150109113116116_9"],
|
||||
"name":"ha_discovery_group",
|
||||
"sw_version":version,
|
||||
"sw_version": version,
|
||||
},
|
||||
"json_attributes_topic":"zigbee2mqtt/ha_discovery_group",
|
||||
"name":"ha_discovery_group",
|
||||
@@ -135,7 +138,7 @@ describe('HomeAssistant extension', () => {
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
'sw_version': version,
|
||||
'sw_version': null,
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
@@ -163,7 +166,7 @@ describe('HomeAssistant extension', () => {
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
'sw_version': version,
|
||||
'sw_version': null,
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
@@ -190,7 +193,7 @@ describe('HomeAssistant extension', () => {
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
'sw_version': version,
|
||||
'sw_version': null,
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
@@ -214,10 +217,11 @@ describe('HomeAssistant extension', () => {
|
||||
'name': 'weather_sensor_battery',
|
||||
'unique_id': '0x0017880104e45522_battery_zigbee2mqtt',
|
||||
'enabled_by_default': true,
|
||||
'entity_category': 'diagnostic',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
'sw_version': version,
|
||||
'sw_version': null,
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
@@ -234,6 +238,7 @@ describe('HomeAssistant extension', () => {
|
||||
payload = {
|
||||
'icon': 'mdi:signal',
|
||||
'enabled_by_default': false,
|
||||
'entity_category': 'diagnostic',
|
||||
'unit_of_measurement': 'lqi',
|
||||
'state_class': 'measurement',
|
||||
'value_template': '{{ value_json.linkquality }}',
|
||||
@@ -244,7 +249,7 @@ describe('HomeAssistant extension', () => {
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
'sw_version': version,
|
||||
'sw_version': null,
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
@@ -268,7 +273,7 @@ describe('HomeAssistant extension', () => {
|
||||
"manufacturer":"Xiaomi",
|
||||
"model":"Aqara double key wired wall switch without neutral wire. Doesn't work as a router and doesn't support power meter (QBKG03LM)",
|
||||
"name":"wall_switch_double",
|
||||
"sw_version":version
|
||||
"sw_version": null
|
||||
},
|
||||
"json_attributes_topic":"zigbee2mqtt/wall_switch_double",
|
||||
"name":"wall_switch_double_left",
|
||||
@@ -296,7 +301,7 @@ describe('HomeAssistant extension', () => {
|
||||
"manufacturer":"Xiaomi",
|
||||
"model":"Aqara double key wired wall switch without neutral wire. Doesn't work as a router and doesn't support power meter (QBKG03LM)",
|
||||
"name":"wall_switch_double",
|
||||
"sw_version":version
|
||||
"sw_version": null
|
||||
},
|
||||
"json_attributes_topic":"zigbee2mqtt/wall_switch_double",
|
||||
"name":"wall_switch_double_right",
|
||||
@@ -330,7 +335,7 @@ describe('HomeAssistant extension', () => {
|
||||
"manufacturer":"IKEA",
|
||||
"model":"TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white (LED1545G12)",
|
||||
"name":"bulb",
|
||||
"sw_version":version,
|
||||
"sw_version": null,
|
||||
},
|
||||
"effect":true,
|
||||
"effect_list":[
|
||||
@@ -383,7 +388,7 @@ describe('HomeAssistant extension', () => {
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
'sw_version': version,
|
||||
'sw_version': null,
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
@@ -410,7 +415,7 @@ describe('HomeAssistant extension', () => {
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
'sw_version': version,
|
||||
'sw_version': null,
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
@@ -437,7 +442,7 @@ describe('HomeAssistant extension', () => {
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
'sw_version': version,
|
||||
'sw_version': null,
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
@@ -522,7 +527,7 @@ describe('HomeAssistant extension', () => {
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
'sw_version': version,
|
||||
'sw_version': null,
|
||||
'model': 'custom model',
|
||||
'manufacturer': 'Not from Xiaomi',
|
||||
},
|
||||
@@ -570,7 +575,7 @@ describe('HomeAssistant extension', () => {
|
||||
"manufacturer": "Xiaomi",
|
||||
"model": "Aqara single key wired wall switch without neutral wire. Doesn't work as a router and doesn't support power meter (QBKG04LM)",
|
||||
"name": "my_switch",
|
||||
"sw_version": version
|
||||
"sw_version": null
|
||||
},
|
||||
"json_attributes_topic": "zigbee2mqtt/my_switch",
|
||||
"name": "my_light_name_override",
|
||||
@@ -647,7 +652,7 @@ describe('HomeAssistant extension', () => {
|
||||
"zigbee2mqtt_0x0017880104e45548"
|
||||
],
|
||||
"name":"fan",
|
||||
"sw_version":version,
|
||||
"sw_version": null,
|
||||
"model":"Universal wink enabled white ceiling fan premier remote control (99432)",
|
||||
"manufacturer":"Hampton Bay"
|
||||
},
|
||||
@@ -679,7 +684,7 @@ describe('HomeAssistant extension', () => {
|
||||
"manufacturer":"TuYa",
|
||||
"model":"Radiator valve with thermostat (TS0601_thermostat)",
|
||||
"name":"TS0601_thermostat",
|
||||
"sw_version": version,
|
||||
"sw_version": null,
|
||||
},
|
||||
"hold_command_topic":"zigbee2mqtt/TS0601_thermostat/set/preset",
|
||||
"hold_modes":[
|
||||
@@ -734,7 +739,7 @@ describe('HomeAssistant extension', () => {
|
||||
{
|
||||
identifiers: [ 'zigbee2mqtt_0x0017880104e45551' ],
|
||||
name: 'smart vent',
|
||||
sw_version: version,
|
||||
sw_version: null,
|
||||
model: 'Smart vent (SV01)',
|
||||
manufacturer: 'Keen Home'
|
||||
},
|
||||
@@ -768,7 +773,7 @@ describe('HomeAssistant extension', () => {
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
'sw_version': version,
|
||||
'sw_version': null,
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
@@ -825,7 +830,7 @@ describe('HomeAssistant extension', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color',
|
||||
stringify({"color":{"hue": 0, "saturation": 100, "h": 0, "s": 100}, "color_mode": "hs", "linkquality": null, "state": null}),
|
||||
stringify({"color":{"hue": 0, "saturation": 100, "h": 0, "s": 100}, "color_mode": "hs", "linkquality": null, "state": null, "update_available": null, "update": {"state": null}}),
|
||||
{ retain: false, qos: 0 },
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -841,7 +846,7 @@ describe('HomeAssistant extension', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color',
|
||||
stringify({"color": {"x": 0.4576,"y": 0.41}, "color_mode": "xy", "linkquality": null,"state": null}),
|
||||
stringify({"color": {"x": 0.4576,"y": 0.41}, "color_mode": "xy", "linkquality": null,"state": null, "update_available": null, "update": {"state": null}}),
|
||||
{ retain: false, qos: 0 },
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -857,7 +862,7 @@ describe('HomeAssistant extension', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color',
|
||||
stringify({"linkquality": null,"state": "ON"}),
|
||||
stringify({"linkquality": null,"state": "ON", "update_available": null, "update": {"state": null}}),
|
||||
{ retain: false, qos: 0 },
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -895,7 +900,7 @@ describe('HomeAssistant extension', () => {
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
'sw_version': version,
|
||||
'sw_version': null,
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
@@ -921,7 +926,7 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
it('Should send all status when home assistant comes online (default topic)', async () => {
|
||||
data.writeDefaultState();
|
||||
extension.state._load();
|
||||
extension.state.load();
|
||||
await resetExtension();
|
||||
expect(MQTT.subscribe).toHaveBeenCalledWith('homeassistant/status');
|
||||
await flushPromises();
|
||||
@@ -932,13 +937,13 @@ describe('HomeAssistant extension', () => {
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb',
|
||||
stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"power_on_behavior":null}),
|
||||
stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"power_on_behavior":null, "update_available": null, "update": {"state": null}}),
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/remote',
|
||||
stringify({"action":null,"battery":null,"brightness":255,"linkquality":null}),
|
||||
stringify({"action":null,"action_duration":null,"battery":null,"brightness":255,"linkquality":null, "update_available": null, "update": {"state": null}}),
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
@@ -946,7 +951,7 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
it('Should send all status when home assistant comes online', async () => {
|
||||
data.writeDefaultState();
|
||||
extension.state._load();
|
||||
extension.state.load();
|
||||
await resetExtension();
|
||||
expect(MQTT.subscribe).toHaveBeenCalledWith('hass/status');
|
||||
MQTT.publish.mockClear();
|
||||
@@ -956,13 +961,13 @@ describe('HomeAssistant extension', () => {
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb',
|
||||
stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"power_on_behavior":null}),
|
||||
stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"power_on_behavior":null, "update_available": null, "update": {"state": null}}),
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/remote',
|
||||
stringify({"action":null,"battery":null,"brightness":255,"linkquality":null}),
|
||||
stringify({"action":null,"action_duration":null,"battery":null,"brightness":255,"linkquality":null, "update_available": null, "update": {"state": null}}),
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
@@ -970,7 +975,7 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
it('Shouldnt send all status when home assistant comes offline', async () => {
|
||||
data.writeDefaultState();
|
||||
extension.state._load();
|
||||
extension.state.load();
|
||||
await resetExtension();
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
@@ -983,7 +988,7 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
it('Shouldnt send all status when home assistant comes online with different topic', async () => {
|
||||
data.writeDefaultState();
|
||||
extension.state._load();
|
||||
extension.state.load();
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
await MQTT.events.message('hass/status_different', 'offline');
|
||||
@@ -994,7 +999,7 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should discover devices with availability', async () => {
|
||||
settings.set(['advanced', 'availability_timeout'], 1)
|
||||
settings.set(['availability'], true)
|
||||
await resetExtension();
|
||||
|
||||
let payload;
|
||||
@@ -1012,10 +1017,11 @@ describe('HomeAssistant extension', () => {
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
'sw_version': version,
|
||||
'sw_version': null,
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_mode': 'all',
|
||||
'availability': [{topic: 'zigbee2mqtt/bridge/state'}, {topic: 'zigbee2mqtt/weather_sensor/availability'}],
|
||||
};
|
||||
|
||||
@@ -1091,7 +1097,7 @@ describe('HomeAssistant extension', () => {
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor_renamed',
|
||||
'sw_version': version,
|
||||
'sw_version': null,
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
@@ -1125,7 +1131,7 @@ describe('HomeAssistant extension', () => {
|
||||
"zigbee2mqtt_0x0017880104e45522"
|
||||
],
|
||||
"name":"weather_sensor_renamed",
|
||||
"sw_version": version,
|
||||
"sw_version": null,
|
||||
"model":"Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)",
|
||||
"manufacturer":"Xiaomi"
|
||||
}
|
||||
@@ -1160,7 +1166,7 @@ describe('HomeAssistant extension', () => {
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor_renamed',
|
||||
'sw_version': version,
|
||||
'sw_version': null,
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
@@ -1179,8 +1185,8 @@ describe('HomeAssistant extension', () => {
|
||||
const payload = {
|
||||
"payload_on":true,
|
||||
"payload_off":false,
|
||||
"value_template":"{{ value_json.update_available}}",
|
||||
"enabled_by_default": false,
|
||||
"value_template":`{{ value_json['update']['state'] == "available" }}`,
|
||||
"enabled_by_default": true,
|
||||
"state_topic":"zigbee2mqtt/bulb",
|
||||
"json_attributes_topic":"zigbee2mqtt/bulb",
|
||||
"name":"bulb update available",
|
||||
@@ -1190,11 +1196,13 @@ describe('HomeAssistant extension', () => {
|
||||
"zigbee2mqtt_0x000b57fffec6a5b2"
|
||||
],
|
||||
"name":"bulb",
|
||||
'sw_version': version,
|
||||
'sw_version': null,
|
||||
"model":"TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white (LED1545G12)",
|
||||
"manufacturer":"IKEA"
|
||||
},
|
||||
'availability': [{topic: 'zigbee2mqtt/bridge/state'}],
|
||||
'device_class': 'update',
|
||||
'entity_category': 'diagnostic'
|
||||
};
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
@@ -1229,7 +1237,7 @@ describe('HomeAssistant extension', () => {
|
||||
"zigbee2mqtt_0x0017880104e45520"
|
||||
],
|
||||
"name":"button",
|
||||
"sw_version": version,
|
||||
"sw_version": null,
|
||||
"model":"Aqara wireless switch (WXKG11LM)",
|
||||
"manufacturer":"Xiaomi"
|
||||
}
|
||||
@@ -1253,7 +1261,7 @@ describe('HomeAssistant extension', () => {
|
||||
"zigbee2mqtt_0x0017880104e45520"
|
||||
],
|
||||
"name":"button",
|
||||
"sw_version": version,
|
||||
"sw_version": null,
|
||||
"model":"Aqara wireless switch (WXKG11LM)",
|
||||
"manufacturer":"Xiaomi"
|
||||
}
|
||||
@@ -1289,7 +1297,7 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/button',
|
||||
stringify({action: "", battery: null, linkquality: null, voltage: null}),
|
||||
stringify({action: "", battery: null, linkquality: null, voltage: null, click: null}),
|
||||
{ retain: false, qos: 0 },
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -1417,7 +1425,7 @@ describe('HomeAssistant extension', () => {
|
||||
"zigbee2mqtt_0x0017880104e45520"
|
||||
],
|
||||
"name":"button",
|
||||
"sw_version": version,
|
||||
"sw_version": null,
|
||||
"model":"Aqara wireless switch (WXKG11LM)",
|
||||
"manufacturer":"Xiaomi"
|
||||
}
|
||||
@@ -1476,33 +1484,15 @@ describe('HomeAssistant extension', () => {
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(4);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({action: 'single', battery: null, linkquality: null, voltage: null});
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({action: 'single', click: null, battery: null, linkquality: null, voltage: null});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false});
|
||||
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/button');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({action: '', battery: null, linkquality: null, voltage: null});
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({action: '', click: null, battery: null, linkquality: null, voltage: null});
|
||||
expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({"qos": 0, "retain": false});
|
||||
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('homeassistant/device_automation/0x0017880104e45520/action_single/config');
|
||||
expect(MQTT.publish.mock.calls[3][0]).toStrictEqual('zigbee2mqtt/button/action');
|
||||
});
|
||||
|
||||
it('Load Home Assistant mapping from external converters', async () => {
|
||||
fs.copyFileSync(path.join(__dirname, 'assets', 'mock-external-converter-multiple.js'), path.join(data.mockDir, 'mock-external-converter-multiple.js'));
|
||||
settings.set(['external_converters'], ['mock-external-converter-multiple.js']);
|
||||
await resetExtension();
|
||||
|
||||
const homeassistantSwitch = {
|
||||
type: 'switch',
|
||||
object_id: 'switch',
|
||||
discovery_payload: {
|
||||
payload_off: 'OFF',
|
||||
payload_on: 'ON',
|
||||
value_template: '{{ value_json.state }}',
|
||||
command_topic: true,
|
||||
},
|
||||
};
|
||||
expect(extension._getMapping()['external_converters_device_1']).toEqual([homeassistantSwitch]);
|
||||
});
|
||||
|
||||
it('Should clear outdated configs', async () => {
|
||||
// Non-existing group -> clear
|
||||
MQTT.publish.mockClear();
|
||||
@@ -1608,7 +1598,7 @@ describe('HomeAssistant extension', () => {
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
'sw_version': version,
|
||||
'sw_version': null,
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
@@ -1637,7 +1627,7 @@ describe('HomeAssistant extension', () => {
|
||||
"device":{
|
||||
"identifiers":["zigbee2mqtt_1221051039810110150109113116116_9"],
|
||||
"name":"ha_discovery_group",
|
||||
"sw_version":version,
|
||||
"sw_version": version,
|
||||
},
|
||||
"json_attributes_topic":"zigbee2mqtt/ha_discovery_group",
|
||||
"max_mireds": 454,
|
||||
@@ -1659,4 +1649,79 @@ describe('HomeAssistant extension', () => {
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should discover last_seen when enabled', async () => {
|
||||
settings.set(['advanced', 'last_seen'], 'ISO_8601');
|
||||
await resetExtension();
|
||||
|
||||
const payload = {
|
||||
"availability":[
|
||||
{
|
||||
"topic":"zigbee2mqtt/bridge/state"
|
||||
}
|
||||
],
|
||||
"device":{
|
||||
"identifiers":[
|
||||
"zigbee2mqtt_0x000b57fffec6a5b2"
|
||||
],
|
||||
"manufacturer":"IKEA",
|
||||
"model":"TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white (LED1545G12)",
|
||||
"name":"bulb",
|
||||
"sw_version": null
|
||||
},
|
||||
"enabled_by_default":false,
|
||||
"icon":"mdi:clock",
|
||||
"json_attributes_topic":"zigbee2mqtt/bulb",
|
||||
"name":"bulb last seen",
|
||||
"state_topic":"zigbee2mqtt/bulb",
|
||||
"unique_id":"0x000b57fffec6a5b2_last_seen_zigbee2mqtt",
|
||||
"value_template":"{{ value_json.last_seen }}",
|
||||
"device_class": "timestamp",
|
||||
"entity_category": "diagnostic"
|
||||
};
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/sensor/0x000b57fffec6a5b2/last_seen/config',
|
||||
stringify(payload),
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should discover devices with configuration url', async () => {
|
||||
settings.set(['frontend', 'url'], 'http://zigbee.mqtt');
|
||||
|
||||
await resetExtension();
|
||||
|
||||
let payload;
|
||||
await flushPromises();
|
||||
|
||||
payload = {
|
||||
'unit_of_measurement': '°C',
|
||||
'device_class': 'temperature',
|
||||
'state_class': 'measurement',
|
||||
'enabled_by_default': true,
|
||||
'value_template': "{{ value_json.temperature }}",
|
||||
'state_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'name': 'weather_sensor_temperature',
|
||||
'unique_id': '0x0017880104e45522_temperature_zigbee2mqtt',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
'sw_version': null,
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
'configuration_url': 'http://zigbee.mqtt/#/device/0x0017880104e45522/info'
|
||||
},
|
||||
'availability': [{topic: 'zigbee2mqtt/bridge/state'}],
|
||||
};
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/sensor/0x0017880104e45522/temperature/config',
|
||||
stringify(payload),
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('Bridge legacy', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
version = await require('../../lib/util/utils').getZigbee2MQTTVersion();
|
||||
version = await require('../../lib/util/utils').default.getZigbee2MQTTVersion();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
})
|
||||
@@ -89,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}}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -197,9 +197,9 @@ describe('Bridge legacy', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -226,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", 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", 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'}}),
|
||||
@@ -243,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'}}),
|
||||
@@ -259,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 () => {
|
||||
@@ -275,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: []});
|
||||
expect(settings.getGroup('new_group')).toStrictEqual({"ID": 3, "friendly_name": "new_group", devices: []});
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(3);
|
||||
});
|
||||
@@ -284,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: []});
|
||||
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(
|
||||
@@ -299,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: []});
|
||||
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(
|
||||
@@ -314,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: []});
|
||||
expect(settings.getGroup('group_42')).toStrictEqual({"ID": 42, "friendly_name": "group_42", devices: []});
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(42)
|
||||
});
|
||||
@@ -443,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);
|
||||
});
|
||||
|
||||
@@ -457,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);
|
||||
});
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('Report', () => {
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
extension.queue = new Set();
|
||||
extension.failed = new Set();
|
||||
await extension.onZigbeeStarted();
|
||||
await extension.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -97,7 +97,7 @@ describe('Report', () => {
|
||||
});
|
||||
|
||||
it('Should configure reporting on startup', async () => {
|
||||
await extension.onZigbeeStarted();
|
||||
await extension.start();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
expectOnOffBrightnessColorReport(endpoint, true);
|
||||
@@ -109,7 +109,7 @@ describe('Report', () => {
|
||||
mockClear(device);
|
||||
delete device.meta.report;
|
||||
extension.enabled = false;
|
||||
await extension.onZigbeeStarted();
|
||||
await extension.start();
|
||||
expect(device.meta.reporting).toBe(undefined);
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -120,7 +120,7 @@ describe('Report', () => {
|
||||
const endpoint = device.getEndpoint(1);
|
||||
extension.enabled = false;
|
||||
mockClear(device);
|
||||
await extension.onZigbeeStarted();
|
||||
await extension.start();
|
||||
expectOnOffBrightnessColorReportDisabled(endpoint, true);
|
||||
});
|
||||
|
||||
|
||||
+44
-21
@@ -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,27 +112,27 @@ 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();
|
||||
@@ -141,11 +141,34 @@ describe('Logger', () => {
|
||||
|
||||
it('Should allow to symlink logs to current directory', () => {
|
||||
settings.set(['advanced', 'log_symlink_current'], true);
|
||||
let logger = require('../lib/util/logger.js');
|
||||
let logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
expect(fs.readdirSync(dir.name).includes('current')).toBeTruthy()
|
||||
|
||||
jest.resetModules();
|
||||
logger = require('../lib/util/logger.js');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,6 @@ describe('On event', () => {
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device});
|
||||
await flushPromises();
|
||||
expect(zigbeeHerdsmanConverters.onEvent).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsmanConverters.onEvent).toHaveBeenCalledWith('deviceAnnounce', {device}, device, settings.getDevice(device.ieeeAddr));
|
||||
expect(zigbeeHerdsmanConverters.onEvent).toHaveBeenCalledWith('deviceAnnounce', {device}, device);
|
||||
});
|
||||
});
|
||||
|
||||
+26
-6
@@ -71,9 +71,8 @@ describe('OTA update', () => {
|
||||
expect(logger.info).toHaveBeenCalledWith(`Update of 'bulb' at 0.00%`);
|
||||
expect(logger.info).toHaveBeenCalledWith(`Update of 'bulb' at 10.00%, ≈ 60 minutes remaining`);
|
||||
expect(logger.info).toHaveBeenCalledWith(`Finished update of 'bulb', from '{"dateCode":"20190101","softwareBuildID":1}' to '{"dateCode":"20190102","softwareBuildID":2}'`);
|
||||
expect(device.save).toHaveBeenCalledTimes(1);
|
||||
expect(device.dateCode).toBe('20190102');
|
||||
expect(device.softwareBuildID).toBe(2);
|
||||
expect(device.save).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genBasic', ['dateCode', 'swBuildId'], {'sendWhenActive': false});
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb',
|
||||
stringify({"update_available":false,"update":{"state":"updating","progress":0}}),
|
||||
@@ -265,6 +264,28 @@ describe('OTA update', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('Should respond with NO_IMAGE_AVAILABLE when update available request fails', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
device.endpoints[0].commandResponse.mockClear();
|
||||
const data = {imageType: 12382};
|
||||
const mapped = zigbeeHerdsmanConverters.findByDevice(device)
|
||||
mockClear(mapped);
|
||||
mapped.ota.isUpdateAvailable.mockImplementationOnce(() => {throw new Error('Nothing to find here')})
|
||||
const payload = {data, cluster: 'genOta', device, endpoint: device.getEndpoint(1), type: 'commandQueryNextImageRequest', linkquality: 10};
|
||||
logger.info.mockClear();
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1);
|
||||
expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledWith(device, logger, {"imageType": 12382});
|
||||
expect(device.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
|
||||
expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {"status": 0x98});
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb',
|
||||
stringify({"update_available":false,"update":{"state":"idle"}}),
|
||||
{retain: true, qos: 0}, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Should check for update when device requests it and it is not available', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
device.endpoints[0].commandResponse.mockClear();
|
||||
@@ -350,9 +371,8 @@ describe('OTA update', () => {
|
||||
expect(logger.info).toHaveBeenCalledWith(`Update of 'bulb' at 10.00%, ≈ 60 minutes remaining`);
|
||||
expect(logger.info).toHaveBeenCalledWith(`Finished update of 'bulb', from '{"dateCode":"20190101","softwareBuildID":1}' to '{"dateCode":"20190102","softwareBuildID":2}'`);
|
||||
expect(logger.error).toHaveBeenCalledTimes(0);
|
||||
expect(device.save).toHaveBeenCalledTimes(1);
|
||||
expect(device.dateCode).toBe('20190102');
|
||||
expect(device.softwareBuildID).toBe(2);
|
||||
expect(device.save).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genBasic', ['dateCode', 'swBuildId'], {'sendWhenActive': false});
|
||||
});
|
||||
|
||||
it('Legacy api: Should handle when OTA update fails', async () => {
|
||||
|
||||
+54
-14
@@ -186,6 +186,17 @@ describe('Publish', () => {
|
||||
expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({"qos": 0, "retain": false});
|
||||
});
|
||||
|
||||
it('Should publish messages to zigbee devices with endpoint ID', async () => {
|
||||
const device = zigbeeHerdsman.devices.QBKG03LM;
|
||||
const endpoint = device.getEndpoint(3);
|
||||
await MQTT.events.message('zigbee2mqtt/wall_switch_double/3/set', stringify({state: 'OFF'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genOnOff", "off", {}, {});
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/wall_switch_double', stringify({state_right: 'OFF'}),
|
||||
{"qos": 0, "retain": false}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should publish messages to zigbee devices to non default-ep with state_[EP]', async () => {
|
||||
const device = zigbeeHerdsman.devices.QBKG03LM;
|
||||
const endpoint = device.getEndpoint(3);
|
||||
@@ -356,7 +367,7 @@ describe('Publish', () => {
|
||||
await MQTT.events.message('zigbee2mqtt/thermostat_group/set', stringify({child_lock: 'LOCK'}));
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(1);
|
||||
expect(group.command).toHaveBeenCalledWith("manuSpecificTuya", "setData", {data: [1], datatype: 1, dp: 7, length_hi: 0, length_lo: 1, status: 0, transid: expect.any(Number)}, {disableDefaultResponse: true});
|
||||
expect(group.command).toHaveBeenCalledWith("manuSpecificTuya", "dataRequest", {data: [1], datatype: 1, dp: 7, length_hi: 0, length_lo: 1, seq: expect.any(Number)}, {disableDefaultResponse: true});
|
||||
});
|
||||
|
||||
it('Should publish messages to groups with on and brightness', async () => {
|
||||
@@ -896,6 +907,33 @@ describe('Publish', () => {
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColor", {"colorx": 45940, "colory": 19595, "transtime": 30}, {}]);
|
||||
});
|
||||
|
||||
it('Should correctly toggle when transition is used', async () => {
|
||||
settings.set(['devices', zigbeeHerdsman.devices.bulb_color.ieeeAddr, 'transition'], 20);
|
||||
const endpoint = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
|
||||
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'ON', brightness: 163}));
|
||||
await flushPromises();
|
||||
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'OFF'}));
|
||||
await flushPromises();
|
||||
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'TOGGLE',}));
|
||||
await flushPromises();
|
||||
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'TOGGLE',}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(4);
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(1, "genLevelCtrl", "moveToLevelWithOnOff", {"level": 163, "transtime": 200}, {})
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(2, "genLevelCtrl", "moveToLevelWithOnOff", {"level": 0, "transtime": 200}, {})
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(3, "genLevelCtrl", "moveToLevelWithOnOff", {"level": 163, "transtime": 200}, {})
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(4, "genLevelCtrl", "moveToLevelWithOnOff", {"level": 0, "transtime": 200}, {})
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(4);
|
||||
expect(MQTT.publish).toHaveBeenNthCalledWith(1, 'zigbee2mqtt/bulb_color', stringify({"brightness":163,"state":"ON"}),
|
||||
{ retain: false, qos: 0 }, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenNthCalledWith(2, 'zigbee2mqtt/bulb_color', stringify({"brightness":163,"state":"OFF"}),
|
||||
{ retain: false, qos: 0 }, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenNthCalledWith(3, 'zigbee2mqtt/bulb_color', stringify({"brightness":163,"state":"ON"}),
|
||||
{ retain: false, qos: 0 }, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenNthCalledWith(4, 'zigbee2mqtt/bulb_color', stringify({"brightness":163,"state":"OFF"}),
|
||||
{ retain: false, qos: 0 }, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should turn device off when brightness 0 is send', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
@@ -1094,10 +1132,10 @@ describe('Publish', () => {
|
||||
|
||||
it('Home Assistant: should not set state when color temperature is also set and device is already on', async () => {
|
||||
settings.set(['homeassistant'], true);
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
controller.state.remove(device.ieeeAddr);
|
||||
controller.state.set(device.ieeeAddr, {state: 'ON'})
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const device = controller.zigbee.resolveEntity(zigbeeHerdsman.devices.bulb_color.ieeeAddr);
|
||||
controller.state.remove(device);
|
||||
controller.state.set(device, {state: 'ON'})
|
||||
const endpoint = device.zh.getEndpoint(1);
|
||||
const payload = {state: 'ON', color_temp: 100};
|
||||
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify(payload));
|
||||
await flushPromises();
|
||||
@@ -1110,10 +1148,10 @@ describe('Publish', () => {
|
||||
|
||||
it('Home Assistant: should set state when color temperature is also set and device is off', async () => {
|
||||
settings.set(['homeassistant'], true);
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
controller.state.remove(device.ieeeAddr);
|
||||
controller.state.set(device.ieeeAddr, {state: 'OFF'})
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const device = controller.zigbee.resolveEntity(zigbeeHerdsman.devices.bulb_color.ieeeAddr);
|
||||
controller.state.remove(device);
|
||||
controller.state.set(device, {state: 'OFF'})
|
||||
const endpoint = device.zh.getEndpoint(1);
|
||||
const payload = {state: 'ON', color_temp: 100};
|
||||
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify(payload));
|
||||
await flushPromises();
|
||||
@@ -1131,10 +1169,10 @@ describe('Publish', () => {
|
||||
|
||||
it('Home Assistant: should not set state when color is also set', async () => {
|
||||
settings.set(['homeassistant'], true);
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
controller.state.remove(device.ieeeAddr);
|
||||
controller.state.set(device.ieeeAddr, {state: 'ON'})
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const device = controller.zigbee.resolveEntity(zigbeeHerdsman.devices.bulb_color.ieeeAddr);
|
||||
controller.state.remove(device);
|
||||
controller.state.set(device, {state: 'ON'})
|
||||
const endpoint = device.zh.getEndpoint(1);
|
||||
const payload = {state: 'ON', color: {x: 0.41, y: 0.25}};
|
||||
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify(payload));
|
||||
await flushPromises();
|
||||
@@ -1283,7 +1321,7 @@ describe('Publish', () => {
|
||||
await MQTT.events.message('zigbee2mqtt/MKS-CM-W5/l3/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("manuSpecificTuya", "setData", {data: [1], datatype: 1, dp: 3, length_hi: 0, length_lo: 1, status: 0, transid: expect.any(Number)}, {disableDefaultResponse: true});
|
||||
expect(endpoint.command).toHaveBeenCalledWith("manuSpecificTuya", "dataRequest", {data: [1], datatype: 1, dp: 3, length_hi: 0, length_lo: 1, seq: expect.any(Number)}, {disableDefaultResponse: true});
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/MKS-CM-W5');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({state_l3: 'ON'});
|
||||
@@ -1401,6 +1439,8 @@ describe('Publish', () => {
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(1);
|
||||
expect(group.command).toHaveBeenCalledWith('genScenes', 'store', { groupid: 15071, sceneid: 1 }, {});
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
|
||||
|
||||
await MQTT.events.message('zigbee2mqtt/bulb_color_2/set', stringify({"state": "ON", "brightness": 250, "color_temp": 20}));
|
||||
await MQTT.events.message('zigbee2mqtt/bulb_2/set', stringify({"state": "ON", "brightness": 110}));
|
||||
|
||||
+4
-37
@@ -242,25 +242,6 @@ describe('Receive', () => {
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 1, "retain": false});
|
||||
});
|
||||
|
||||
it('WSDCGQ11LM pressure precision from non ZCL properties', async () => {
|
||||
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
|
||||
settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 1);
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
let payload = {data: {"65281":{"1":2985,"4":5032,"5":9,"6":[0,1],"10":0,"100":2345,"101":4608,"102":91552}}, cluster: 'genBasic', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({"battery":91,"voltage":2985,"temperature":23.5,"humidity":46.08,"pressure":915.5});
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
payload = {data: {"16":9354,"20":-1,"measuredValue":915}, cluster: 'msPressureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({"battery":91,"voltage":2985,"temperature":23.5,"humidity":46.08,"pressure":935.4});
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message with voltage 3010', async () => {
|
||||
const device = zigbeeHerdsman.devices.WXKG02LM_rev1;
|
||||
const data = {'65281': {'1': 3010}}
|
||||
@@ -330,20 +311,6 @@ describe('Receive', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
// TODO
|
||||
// it('Should publish last_seen for unhandled messages', async () => {
|
||||
// const device = zigbeeHerdsman.devices.WXKG02LM_rev1;
|
||||
// settings.set(['advanced', 'last_seen'], 'epoch');
|
||||
// const data = {onOff: 1};
|
||||
// const payload = {data, cluster: 'genRssiLocation', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
// await zigbeeHerdsman.events.message(payload);
|
||||
// await flushPromises();
|
||||
// expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
// expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
|
||||
// expect(typeof JSON.parse(MQTT.publish.mock.calls[0][1]).last_seen).toBe('number')
|
||||
// expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false});
|
||||
// });
|
||||
|
||||
it('Should publish last_seen epoch', async () => {
|
||||
const device = zigbeeHerdsman.devices.WXKG02LM_rev1;
|
||||
settings.set(['advanced', 'last_seen'], 'epoch');
|
||||
@@ -436,7 +403,7 @@ describe('Receive', () => {
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(logger.warn).toHaveBeenCalledWith(`Received message from unsupported device with Zigbee model 'notSupportedModelID' and manufacturer name 'notSupportedMfg'`);
|
||||
expect(logger.warn).toHaveBeenCalledWith(`Please see: https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html.`);
|
||||
expect(logger.warn).toHaveBeenCalledWith(`Please see: https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`);
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
@@ -454,7 +421,7 @@ describe('Receive', () => {
|
||||
it('Should handle a command', async () => {
|
||||
const device = zigbeeHerdsman.devices.E1743;
|
||||
const data = {};
|
||||
const payload = {data, cluster: 'genLevelCtrl', device, endpoint: device.getEndpoint(1), type: 'commandStopWithOnOff', linkquality: 10};
|
||||
const payload = {data, cluster: 'genLevelCtrl', device, endpoint: device.getEndpoint(1), type: 'commandStopWithOnOff', linkquality: 10, meta: {zclTransactionSequenceNumber: 1}};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
@@ -470,10 +437,10 @@ describe('Receive', () => {
|
||||
const oldNow = Date.now;
|
||||
Date.now = jest.fn()
|
||||
Date.now.mockReturnValue(new Date(150));
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.message({...payload, meta: {zclTransactionSequenceNumber: 2}});
|
||||
await flushPromises();
|
||||
Date.now.mockReturnValue(new Date(200));
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.message({...payload, meta: {zclTransactionSequenceNumber: 3}});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(2);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/ikea_onoff');
|
||||
|
||||
+13
-9
@@ -1,7 +1,7 @@
|
||||
require('./stub/logger');
|
||||
require('./stub/data');
|
||||
const data = require('../lib/util/data');
|
||||
const utils = require('../lib/util/utils');
|
||||
const utils = require('../lib/util/utils').default;
|
||||
const settings = require('../lib/util/settings.ts');
|
||||
const fs = require('fs');
|
||||
const configurationFile = data.joinPath('configuration.yaml');
|
||||
@@ -67,7 +67,6 @@ describe('Settings', () => {
|
||||
process.env['ZIGBEE2MQTT_CONFIG_SERIAL_DISABLE_LED'] = 'true';
|
||||
process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_SOFT_RESET_TIMEOUT'] = 1;
|
||||
process.env['ZIGBEE2MQTT_CONFIG_EXPERIMENTAL_OUTPUT'] = 'csvtest';
|
||||
process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_AVAILABILITY_BLOCKLIST'] = '["0x43597f0dac781b1e", "x223b0aef2ae8d1b0"]';
|
||||
process.env['ZIGBEE2MQTT_CONFIG_MAP_OPTIONS_GRAPHVIZ_COLORS_FILL'] = '{"enddevice": "#ff0000", "coordinator": "#00ff00", "router": "#0000ff"}';
|
||||
process.env['ZIGBEE2MQTT_CONFIG_MQTT_BASE_TOPIC'] = 'testtopic';
|
||||
|
||||
@@ -79,7 +78,6 @@ describe('Settings', () => {
|
||||
expected.serial.disable_led = true;
|
||||
expected.advanced.soft_reset_timeout = 1;
|
||||
expected.experimental.output = 'csvtest';
|
||||
expected.advanced.availability_blocklist = ['0x43597f0dac781b1e', 'x223b0aef2ae8d1b0'];
|
||||
expected.map_options.graphviz.colors.fill = {enddevice: '#ff0000', coordinator: '#00ff00', router: '#0000ff'};
|
||||
expected.mqtt.base_topic = 'testtopic';
|
||||
|
||||
@@ -117,7 +115,6 @@ describe('Settings', () => {
|
||||
const device = settings.getDevice('0x12345678');
|
||||
const expected = {
|
||||
ID: "0x12345678",
|
||||
friendlyName: "0x12345678",
|
||||
friendly_name: '0x12345678',
|
||||
retain: false,
|
||||
};
|
||||
@@ -190,7 +187,6 @@ describe('Settings', () => {
|
||||
const device = settings.getDevice('0x12345678');
|
||||
const expected = {
|
||||
ID: "0x12345678",
|
||||
friendlyName: "0x12345678",
|
||||
friendly_name: '0x12345678',
|
||||
retain: false,
|
||||
};
|
||||
@@ -349,7 +345,6 @@ describe('Settings', () => {
|
||||
const group = settings.getGroup('1');
|
||||
const expected = {
|
||||
ID: 1,
|
||||
friendlyName: '123',
|
||||
friendly_name: '123',
|
||||
devices: [],
|
||||
};
|
||||
@@ -374,7 +369,6 @@ describe('Settings', () => {
|
||||
const group = settings.getGroup('1');
|
||||
const expected = {
|
||||
ID: 1,
|
||||
friendlyName: '123',
|
||||
friendly_name: '123',
|
||||
devices: [],
|
||||
};
|
||||
@@ -419,7 +413,6 @@ describe('Settings', () => {
|
||||
const group = settings.getGroup('1');
|
||||
const expectedGroup = {
|
||||
ID: 1,
|
||||
friendlyName: '123',
|
||||
friendly_name: '123',
|
||||
devices: [],
|
||||
};
|
||||
@@ -430,7 +423,6 @@ describe('Settings', () => {
|
||||
|
||||
const expectedDevice2 = {
|
||||
ID: '0x1234',
|
||||
friendlyName: '0x1234',
|
||||
friendly_name: '0x1234',
|
||||
};
|
||||
|
||||
@@ -713,6 +705,18 @@ describe('Settings', () => {
|
||||
expect(settings.validate()).toEqual(expect.arrayContaining([error]));
|
||||
});
|
||||
|
||||
it('Configuration shouldnt be valid when invalid QOS value is used', async () => {
|
||||
write(configurationFile, {
|
||||
...minimalConfig,
|
||||
devices: {'0x0017880104e45519': {friendly_name: 'myname', retain: false, qos: 3}},
|
||||
});
|
||||
|
||||
settings.reRead();
|
||||
|
||||
const error = `QOS for 'myname' not valid, should be 0, 1 or 2 got 3`;
|
||||
expect(settings.validate()).toEqual(expect.arrayContaining([error]));
|
||||
});
|
||||
|
||||
it('Configuration shouldnt be valid when duplicate friendly_name are used', async () => {
|
||||
write(configurationFile, {
|
||||
...minimalConfig,
|
||||
|
||||
+1
-2
@@ -1,11 +1,10 @@
|
||||
const tmp = require('tmp');
|
||||
const yaml = require('../../lib/util/yaml');
|
||||
const yaml = require('../../lib/util/yaml').default;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
const mockDir = tmp.dirSync().name;
|
||||
const mockDirStorage = tmp.dirSync().name;
|
||||
const stateFile = path.join(mockDir, 'state.json');
|
||||
|
||||
function writeDefaultConfiguration() {
|
||||
|
||||
+15
-8
@@ -5,13 +5,8 @@ const mock = {
|
||||
end: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
reconnecting: false,
|
||||
on: (type, handler) => {
|
||||
if (type === 'connect') {
|
||||
handler();
|
||||
}
|
||||
|
||||
events[type] = handler
|
||||
},
|
||||
on: jest.fn(),
|
||||
stream: {setMaxListeners: jest.fn()}
|
||||
};
|
||||
|
||||
const mockConnect = jest.fn().mockReturnValue(mock);
|
||||
@@ -20,6 +15,18 @@ jest.mock('mqtt', () => {
|
||||
return {connect: mockConnect};
|
||||
});
|
||||
|
||||
const restoreOnMock = () => {
|
||||
mock.on.mockImplementation((type, handler) => {
|
||||
if (type === 'connect') {
|
||||
handler();
|
||||
}
|
||||
|
||||
events[type] = handler
|
||||
});
|
||||
}
|
||||
|
||||
restoreOnMock();
|
||||
|
||||
module.exports = {
|
||||
events, ...mock, connect: mockConnect, mock,
|
||||
events, ...mock, connect: mockConnect, mock, restoreOnMock
|
||||
};
|
||||
@@ -31,7 +31,7 @@ const clusters = {
|
||||
}
|
||||
|
||||
class Endpoint {
|
||||
constructor(ID, inputClusters, outputClusters, deviceIeeeAddress, binds=[], clusterValues={}, configuredReportings=[], profileID=null, deviceID=null) {
|
||||
constructor(ID, inputClusters, outputClusters, deviceIeeeAddress, binds=[], clusterValues={}, configuredReportings=[], profileID=null, deviceID=null, meta={}) {
|
||||
this.deviceIeeeAddress = deviceIeeeAddress;
|
||||
this.clusterValues = clusterValues;
|
||||
this.ID = ID;
|
||||
@@ -45,7 +45,7 @@ class Endpoint {
|
||||
this.unbind = jest.fn();
|
||||
this.save = jest.fn();
|
||||
this.configureReporting = jest.fn();
|
||||
this.meta = {};
|
||||
this.meta = meta;
|
||||
this.binds = binds;
|
||||
this.profileID = profileID;
|
||||
this.deviceID = deviceID;
|
||||
@@ -127,7 +127,7 @@ class Device {
|
||||
const returnDevices = [];
|
||||
|
||||
const bulb_color = new Device('Router', '0x000b57fffec6a5b3', 40399, 4107, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5b3', [], {lightingColorCtrl: {colorCapabilities: 254}})], true, "Mains (single phase)", "LLC020");
|
||||
const bulb_color_2 = new Device('Router', '0x000b57fffec6a5b4', 401292, 4107, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5b4', [], {lightingColorCtrl: {colorCapabilities: 254}})], true, "Mains (single phase)", "LLC020", false, 'Philips', '2019.09', '5.127.1.26581');
|
||||
const bulb_color_2 = new Device('Router', '0x000b57fffec6a5b4', 401292, 4107, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5b4', [], {lightingColorCtrl: {colorCapabilities: 254}}, [], null, null, {'scenes': {'1_0': {name: 'Chill scene', state: {state: 'ON'}}, '4_9': {state: {state: 'OFF'}}}})], true, "Mains (single phase)", "LLC020", false, 'Philips', '2019.09', '5.127.1.26581');
|
||||
const bulb_2 = new Device('Router', '0x000b57fffec6a5b7', 40369, 4476, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5b7', [], {lightingColorCtrl: {colorCapabilities: 17}})], true, "Mains (single phase)", "TRADFRI bulb E27 WS opal 980lm");
|
||||
const TS0601_thermostat = new Device('EndDevice', '0x0017882104a44559', 6544,4151, [new Endpoint(1, [], [], '0x0017882104a44559')], true, "Mains (single phase)", 'kud7u2l');
|
||||
const ZNCZ02LM = new Device('Router', '0x0017880104e45524', 6540,4151, [new Endpoint(1, [0], [], '0x0017880104e45524')], true, "Mains (single phase)", "lumi.plug");
|
||||
@@ -215,7 +215,6 @@ const mock = {
|
||||
events[type] = handler;
|
||||
},
|
||||
stop: jest.fn(),
|
||||
setLED: jest.fn(),
|
||||
getDevices: jest.fn().mockImplementation(() => {
|
||||
return Object.values(devices).filter((d) => returnDevices.length === 0 || returnDevices.includes(d.ieeeAddr));
|
||||
}),
|
||||
|
||||
+1
-24
@@ -1,25 +1,8 @@
|
||||
const utils = require('../lib/util/utils');
|
||||
const utils = require('../lib/util/utils').default;
|
||||
const version = require('../package.json').version;
|
||||
const versionHerdsman = require('../node_modules/zigbee-herdsman/package.json').version;
|
||||
|
||||
describe('Utils', () => {
|
||||
describe('Is xiaomi device', () => {
|
||||
it('Identify xiaomi device', () => {
|
||||
const device = {type: 'Router', manufacturerID: 4151, manufacturerName: 'Xiaomi'};
|
||||
expect(true).toBe(utils.isXiaomiDevice(device));
|
||||
});
|
||||
|
||||
it('Identify xiaomi device without manufacturerName', () => {
|
||||
const device = {type: 'Router', manufacturerID: 4447};
|
||||
expect(true).toBe(utils.isXiaomiDevice(device));
|
||||
});
|
||||
|
||||
it('Identify xiaomi device with different manufacturerName', () => {
|
||||
const device = {type: 'Router', manufacturerID: 4151, manufacturerName: 'Trust International B.V.\u0000'};
|
||||
expect(false).toBe(utils.isXiaomiDevice(device));
|
||||
});
|
||||
});
|
||||
|
||||
it('Object has properties', () => {
|
||||
expect(utils.objectHasProperties({a: 1, b: 2, c: 3}, ['a', 'b'])).toBeTruthy();
|
||||
expect(utils.objectHasProperties({a: 1, b: 2, c: 3}, ['a', 'b', 'd'])).toBeFalsy();
|
||||
@@ -52,10 +35,4 @@ describe('Utils', () => {
|
||||
expect(utils.formatDate(date, 'ISO_8601_local').endsWith('+01:00')).toBeTruthy();
|
||||
Date.prototype.getTimezoneOffset = getTimezoneOffset;
|
||||
})
|
||||
|
||||
it('Get key', async () => {
|
||||
expect(utils.getKey({'1': '1'}, '1', 2, null)).toBe('1');
|
||||
expect(utils.getKey({'1': '1'}, '2', 2, null)).toBe(2);
|
||||
expect(utils.getKey({'1': '1'}, '1', null, () => '3')).toBe('3');
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user