Compare commits

...
230 Commits
Author SHA1 Message Date
Koen Kanters ac3b924065 1.5.1 2019-07-16 18:31:32 +02:00
Koen Kanters dd8a55cf08 Update zigbee-herdsman to 0.1.17. https://github.com/Koenkk/zigbee2mqtt/issues/1734#issuecomment-511403606 2019-07-15 20:04:39 +02:00
clockbrain 3ae2d2b946 Network map routes selectable by mqtt topic (#1736) 2019-07-15 20:02:56 +02:00
Koen Kanters 31b8352f5a 1.5.0 2019-07-13 19:39:20 +02:00
Koen Kanters 503fef807c Add changelog generator tool for new supported devices page. 2019-07-13 19:38:23 +02:00
Koen Kanters 4aab81d9e3 Remove HS1WD (not supported anymore). 2019-07-13 19:38:00 +02:00
Koen Kanters 8d705ade39 Update zigbee-shepherd-converters. 2019-07-13 16:36:24 +02:00
Koen Kanters d3e97953c3 Update zigbee-shepherd-converters. 2019-07-11 20:22:43 +02:00
wixoff 1b859c4dc2 Support for PP-WHT-US new sensors (#1718)
* Support for PP-WHT-US new sensors

* Update homeassistant.js
2019-07-11 19:36:50 +02:00
Koen Kanters 225919bb23 Update zigbee-herdsman to 0.1.6. https://github.com/Koenkk/zigbee2mqtt/issues/1710 2019-07-10 19:04:54 +02:00
Andreas Brett 74b8340afb add TLS/SSL options for connecting to MQTT server (#1706)
* add TLS/SSL options for connecting to MQTT server

* Update mqtt.js

* gotta love travis

* Update mqtt.js

separated processing of mqtt.ca and mqtt.key/mqtt.cert in order to allow for "server-authentication only" as well as "server+client authentication"

* Update mqtt.js
2019-07-10 17:57:41 +02:00
Koen Kanters 08c0f2b614 Lint. 2019-07-08 22:04:38 +02:00
Koen Kanters 61b54c50a5 Update zigbee-shepherd-converters and zigbee-herdsman. 2019-07-08 21:42:07 +02:00
Rick 6400f52a28 Added "alert" for groups (#1704) 2019-07-08 21:06:08 +02:00
clockbrain 97f9f83181 Use penwidth instead of dotted/dashed lines (#1694) 2019-07-06 10:09:44 +02:00
Koen Kanters 90c3fc0320 Add device rename test case. https://github.com/Koenkk/zigbee2mqtt/issues/1687 2019-07-04 20:50:54 +02:00
Koen Kanters 94a7cd8ec9 Update zigbee-shepherd-converters to 10.1.1 2019-07-04 20:16:20 +02:00
Craig Stephen 1df43bdbc4 Add support for UK Hive Bulb E27 & B22 - Warm to cool white (#1683) 2019-07-03 17:38:43 +02:00
Koen Kanters 3daf97283c Fix queue settings. 2019-07-02 19:13:06 +02:00
Koen Kanters aca0c39291 Move queue from advanced. 2019-07-02 19:00:42 +02:00
Koen Kanters 9624384c3f Add queue settings. https://github.com/Koenkk/Z-Stack-firmware/issues/93 2019-07-02 18:56:03 +02:00
Koen Kanters 3b4ee3bc2e Update zigbee-shepherd-converters to 10.1.0. 2019-07-01 16:48:54 +02:00
clockbrain 7a19c8876a Network map fixes (#1676)
* Include scan failed note in network map

* Include relationship in map structure

* Clearer source and destination node naming

* eslint fixes

* More eslint fixes

* Removed empty brackets for no routes

* Reposition short address in device node

* eslint fixes
2019-07-01 16:10:50 +02:00
Koen Kanters 51df6b7e8a Correctly call convert. https://github.com/Koenkk/zigbee2mqtt/issues/1639#issuecomment-506016224 2019-06-27 19:11:21 +02:00
Andreas Brett 7cabe3a523 colors for network map (#1666)
* Update settings.js

added default color options for network map

* Update networkMap.js

- add timestamp node to indicate when the map was created
- colorize nodes by device type
- colorize edges by activity

* Update networkMap.js

make travis happy

* Update networkMap.js

* Update networkMap.js

* Update settings.js

* Refactor

* Move to graphviz.
2019-06-26 19:30:38 +02:00
Viet Dzung 7b2522f712 Support whitelist devices feature #1643 (#1654)
* Support whitelist devices feature #1643

* Control via MQTT

* Log
2019-06-25 19:38:36 +02:00
Koenkk 5a132ad34b Retrieve default ep from mapping. #1662 2019-06-25 19:13:59 +02:00
Koen Kanters 362b910e48 Lint. 2019-06-24 21:10:06 +02:00
Dennis Keitzel 241b300f7b Allow custom home-assistant status topic (#1667) 2019-06-24 20:52:47 +02:00
Koen Kanters 0e9931af6c Improve bind logging. https://github.com/Koenkk/zigbee2mqtt/issues/1662 2019-06-24 20:25:37 +02:00
Koen Kanters f4f297e8a2 Update zigbee-herdsman. 2019-06-24 20:17:07 +02:00
Koen Kanters 5934efa41e Add last_seen when device publish succeeds. https://github.com/Koenkk/zigbee2mqtt/issues/1651 2019-06-24 20:16:03 +02:00
clockbrain 38a1aec823 Network map styling improvements (#1653)
* Weight and style lines based on routes

* Refactor to make End Device case more obvious
2019-06-23 21:22:13 +02:00
Koen Kanters 3c398df658 Lint. 2019-06-23 16:10:44 +02:00
Koen Kanters 91d415404c Update zigbee-shepherd-converters and zigbee-herdsman. 2019-06-23 16:02:43 +02:00
Koen Kanters ef01346776 Update zigbee-shepherd-converters to 10.0.0 and zigbee-herdsman to 0.1.11. 2019-06-19 21:44:44 +02:00
Koen Kanters 801e97143c Fix adding groups with duplicate names. #764 2019-06-19 18:35:57 +02:00
Koen Kanters 304b2efa48 Don’t publish emtpy messages. #1599 2019-06-19 18:11:35 +02:00
Koen Kanters a36ed2032d Update zigbee-herdsman to 0.1.10. 2019-06-19 18:07:13 +02:00
Koen Kanters 17d01e937e Update zigbee-herdsman to 0.1.9. 2019-06-18 22:07:07 +02:00
Koen Kanters 110f80ec84 Lint. 2019-06-17 21:41:48 +02:00
clockbrain b28da0265e Include last seen in device boxes on graphviz map (#1633)
* Include last seen in device boxes on graphviz map

* Refactor

* Fix test cases

* Bind

* Refactor last seen label construction

* style fix
2019-06-17 21:28:27 +02:00
Koenkk 1c529c4873 Add group remove_all without group #764 2019-06-17 21:17:29 +02:00
clockbrain 3981c1c6e4 Include network routes on the map (#1641)
* Initial inclusion of routes on map

* Forgot to include zigbee.js

* Hex string for route address

* Rebaseline to dev

* Cleaner merging of scan arrays

* style fixes
2019-06-16 13:49:15 +02:00
Koen Kanters c48f73e36b Discover action sensor for WXKG03LM. https://github.com/Koenkk/zigbee2mqtt/issues/1640 2019-06-16 13:13:03 +02:00
Koen Kanters 94e03d4189 Update zigbee-herdsman 2019-06-15 22:57:35 +02:00
Koen Kanters e30ff9e722 Move to zigbee-herdsman. 2019-06-15 22:20:54 +02:00
pali 1f63a32170 [graphviz] show network address in hexastring format (#1638)
* [graphviz] show network address in hexastring format

Wireshark shows network address in hexadecimal format, so it's easier to follow messages in Wireshark if both shows address in same format.

* Update networkMap.js

* Update networkMap.js
2019-06-15 17:04:35 +02:00
Jevgeni Kiski e9b5a588cc #1588 Openhab2 compatibility (#1589) 2019-06-15 16:55:33 +02:00
Koen Kanters 4357064203 Update zigbee-shepherd-converters to 9.0.22. 2019-06-15 14:02:58 +02:00
Koen Kanters 86a65ebec0 Merge branch 'master' into dev 2019-06-13 22:10:52 +02:00
Koen Kanters 259ba9d658 Update zigbee-shepherd-converters to 9.0.21. 2019-06-13 22:01:39 +02:00
Dan Chen 9fe76c29ef Add power usage metering to GE Plug-in Smart Switch 45853GE (#1629)
* Create FUNDING.yml

* Add power usage metering to GE Plug-in Smart Switch 45853GE

* Delete FUNDING.yml
2019-06-13 21:48:04 +02:00
clockbrain dd49a76e5c Network map rewrite using queue (#1626)
* Initial rewrite of network map scan using queue

* Rename routerList to scanList

* eslist fixes

* More eslint fixes

* Timeout now dynamic based on number of scanable devices
2019-06-13 21:43:12 +02:00
Anne Meijer 6976f8e70a Added support for HEIMAN HS1CA-E (#1622)
* Create FUNDING.yml

* Adde support for HEIMAN HS1CA-E

* Fixed eslint issues

* Delete FUNDING.yml
2019-06-11 20:07:32 +02:00
Koen Kanters 82dc8437a1 Get rid of logging during test cases. 2019-06-10 00:01:48 +02:00
Koen Kanters fb12d15d9d Fix lint error. 2019-06-09 23:46:19 +02:00
Koen Kanters 852950fc09 Update zigbee-shepherd-converters to 9.0.20. 2019-06-09 23:43:40 +02:00
clockbrain b663e37fd9 Also match network map link by short address (#1611) 2019-06-09 08:16:38 +02:00
Koen Kanters 70fbe714e3 Add trace logging and add trace logging to zigbeeQueue. https://github.com/Koenkk/zigbee2mqtt/issues/1566 2019-06-08 19:56:35 +02:00
Koen Kanters ee8161b807 Don’t add state_topic to payload for thermostat. #1599 2019-06-07 23:55:20 +02:00
Koen Kanters 8ad6aee43a Identify QBKG04LM and QBKG03LM as end device. https://github.com/Koenkk/zigbee2mqtt/issues/1594 2019-06-04 19:23:58 +02:00
clockbrain 4497214588 Network map fixes (#1590)
* Remove device status from network scan topology

* Random start for each lqi scan then sort resultant link map

* Fix log statement order

* Sort network map links by composite key

* Fix typo in link sorting

* Fix eslint formatting
2019-06-02 10:05:44 +02:00
Koen Kanters 08e55e469f Update zigbee-shepherd-converters to 9.0.19. 2019-05-29 20:26:40 +02:00
Koen Kanters c29bfe1455 Allow to specify endpoint when adding device to group. (#1515)
* uu

* Update
2019-05-29 20:11:40 +02:00
Koen Kanters 82ed260d47 Update zigbee-shepherd-converters to 9.0.18. 2019-05-29 19:33:15 +02:00
rachetfoot caff94559e Iterate objects recursively and support array output (#1573)
* Iterate objects recursively and support array output

* Fix eslint errors

* Improve array handling and expand related test

* Improve tests and array support, add special case for color attributes
2019-05-28 20:01:46 +02:00
Koen Kanters c2b4d63e12 Don’t cache elapsed. https://github.com/Koenkk/zigbee2mqtt/issues/1576 2019-05-26 20:03:43 +02:00
Koen Kanters 825f096434 Update zigbee-shepherd-converters to 9.0.17. 2019-05-24 18:32:37 +02:00
clockbrain 4b14222bda Network map error handling (#1565)
* Catch errors from failing network scans

* Tidier code for process result

* Fix eslint format errors
2019-05-24 18:21:53 +02:00
Koen Kanters 927c4dbc05 Create FUNDING.yml 2019-05-24 08:27:35 +02:00
clockbrain c4e9ea208a Add debugging for network scan (#1559)
* Add debugging for network scan

* Fix eslint warning
2019-05-23 18:53:47 +02:00
Koen Kanters af09624cff Update zigbee-shepherd-converters to 9.0.16. 2019-05-22 22:09:13 +02:00
Koen Kanters e4a50b662d Update zigbee-shepherd-converters to 9.0.15. 2019-05-21 21:15:16 +02:00
clockbrain 22b64d09f2 Network map (#1546)
* Initial iterative rewrite of network scan

* Remove unused linkKey

* Restore original log message

* Cleaner array flattening

* Fix various travis warnings

* Add a timeout so partial map can be generated

* Fix further eslint issues

* Add a timeout so partial map can be generated

* Fix further eslint issues
2019-05-21 21:04:14 +02:00
Koen Kanters dc028d5046 Update zigbee-shepherd. 2019-05-21 20:54:49 +02:00
Koen Kanters 0352a738a7 Update zigbee-shepherd-converters to 9.0.14. 2019-05-20 20:53:51 +02:00
Koen Kanters 6adfdacb3c Don’t quit twice. https://github.com/Koenkk/zigbee2mqtt/issues/1550 2019-05-20 20:43:25 +02:00
Koen Kanters 18913a1577 Update zigbee-shepherd-converters to 9.0.12. 2019-05-19 19:51:27 +02:00
Koen Kanters 1e04c229dd Merge remote-tracking branch 'origin/master' into dev 2019-05-19 19:34:02 +02:00
clockbrain 31d21546b9 Simpler network map scan (#1543)
* Initial iterative rewrite of network scan

* Remove unused linkKey

* Restore original log message

* Cleaner array flattening

* Fix various travis warnings
2019-05-19 19:33:30 +02:00
Koen Kanters 7f9104367b Reduce permitJoin interval to 160 seconds to fix pairing devices after pairing E1603. https://github.com/Koenkk/zigbee2mqtt/issues/1099 2019-05-19 19:10:51 +02:00
Koen Kanters 92d0002bbb Update zigbee-shepherd-converters to 9.0.11. 2019-05-19 18:51:00 +02:00
Koen Kanters 0074c32136 Update zigbee-shepherd-converters to 9.0.10. 2019-05-14 21:57:47 +02:00
Koen Kanters faa6aa41a9 Fix AC0251100NJ discovery. 2019-05-14 21:46:55 +02:00
Koen Kanters 3ed9bbde1a Update zigbee-shepherd-converters to 9.0.9 2019-05-14 21:36:07 +02:00
Koen Kanters 8f39ae9d63 Update zigbee-shepherd-converters to 9.0.8. 2019-05-14 21:18:48 +02:00
Koen Kanters a09da4fe10 Update zigbee-shepherd-converters to 9.0.7. 2019-05-14 21:12:13 +02:00
Koen Kanters 9cc82701c1 Fix crash when device not in configuration.yaml 2019-05-14 19:25:13 +02:00
Koen Kanters 190dda5104 Update zigbee-shepherd-converters to 9.0.6 2019-05-14 18:04:47 +02:00
Andreas Brett 3d74a6e031 log friendly name when possible (#1524)
* log friendly name when possible

* make travis happy
2019-05-14 17:51:43 +02:00
Koen Kanters 6b7546547d 1.4.0 2019-05-13 18:29:51 +02:00
Koen Kanters 3649fb1bc9 Update zigbee-shepherd-converter to 9.0.5. 2019-05-12 21:09:12 +02:00
Abílio Costa eb2c4cbdee Add support for EDP redy switch (#1519)
Added in https://github.com/Koenkk/zigbee-shepherd-converters/pull/432/files
2019-05-12 21:05:07 +02:00
Andreas Brett f93a3e1c07 Minor spelling correction (#1522)
* Update devicePublish.test.js

* Update devicePublish.js
2019-05-12 21:03:27 +02:00
Koen Kanters ee9584121a Update zigbee-shepherd-converters to 9.0.4. 2019-05-10 18:25:08 +02:00
Koen Kanters ba1eca82eb Reenable joining after 200 seconds. https://github.com/Koenkk/zigbee2mqtt/issues/1429 #1445 2019-05-07 21:16:25 +02:00
Koen Kanters 68d2d0a73d Update zigbee-shepherd-converters to 9.0.3 2019-05-07 19:25:47 +02:00
Koen Kanters d8d78af4ec Query device state on endDeviceAnnce when using device availability. https://github.com/Koenkk/zigbee2mqtt/issues/1383 2019-05-06 20:29:17 +02:00
Koen Kanters 80e682688d Update zigbee-shepherd-converters to 9.0.2. 2019-05-05 20:14:30 +02:00
Koen Kanters c6617fb7d9 Always keep state. https://github.com/Koenkk/zigbee2mqtt/issues/1489 2019-05-02 20:14:44 +02:00
Koen Kanters 26d8d27e85 Update zigbee-shepherd-converters to 9.0.1. 2019-05-02 19:59:23 +02:00
Chris Price 87232988e7 Add Home Assistant support for Philips Hue PAR38 outdoor bulb (#1488) 2019-05-02 19:44:56 +02:00
Koen Kanters a3d26ceeb2 Update zigbee-shepherd-converters to 9.0.0. 2019-05-01 21:50:01 +02:00
Koen Kanters 1f6e64b4c0 Fix Docker container being killed instead of stopped. #1482 2019-05-01 20:47:53 +02:00
Tadej Štajner 919b8a9b4c Add Home Assistant autodiscovery for Paulmann 50043 switch (#1486)
Depends on https://github.com/Koenkk/zigbee-shepherd-converters/pull/424 being merged.
2019-05-01 18:33:36 +02:00
Koen Kanters 31088255ab Update zigbee-shepherd. https://github.com/Koenkk/zigbee2mqtt/issues/1429 2019-04-30 20:34:28 +02:00
Koen Kanters 39fe50600e Fix crash when there is no state yet. 2019-04-30 19:30:26 +02:00
Koen Kanters a54e256b40 Group configuration via configuration.yaml and group state (#1464)
Group configuration via configuration.yaml and group state.
2019-04-29 20:38:40 +02:00
Koen Kanters 4472c01076 Update zigbee-shepherd-converters to 8.1.5. 2019-04-28 21:14:52 +02:00
Koen Kanters 801be951a4 Discover light for 99432. https://github.com/Koenkk/zigbee-shepherd-converters/pull/416 2019-04-28 21:04:52 +02:00
Koen Kanters cb16ba2f71 Update zigbee-shepherd-converters to 8.1.4. 2019-04-26 21:57:38 +02:00
Koen Kanters 0d587f304e Respond to time requests. https://github.com/Koenkk/zigbee2mqtt/issues/1214 2019-04-26 21:33:29 +02:00
Koen Kanters 21dc1a8c8e Add fan Home Assistant configuration. https://github.com/Koenkk/zigbee-shepherd-converters/pull/416 2019-04-24 20:59:06 +02:00
Andreas Brett 32b76edb7c Improve non-JSON output (#1462)
* Update controller.js

Do not use JSON.stringify when outputting Non-JSON to MQTT. When using JSON.stringify strings are quoted in the MQTT messages but they should not be quoted (MQTT is different from JSON).

* Update controller.js

* Update controller.js

* Update controller.js
2019-04-22 16:29:47 +02:00
Koen Kanters 0eaf0ad5d6 Update zigbee-shepherd-converters. 2019-04-19 20:56:05 +02:00
Javier Rodríguez 1ebd774d6f Support for HLC610-Z Wireless Dimmable Controller (#1451) 2019-04-19 20:44:11 +02:00
Koen Kanters 950b3c7ace Update zigbee-shepherd. 2019-04-19 20:30:39 +02:00
Koen Kanters f8106feb18 Update zigbee-shepherd-converters to 8.1.2 2019-04-18 18:27:41 +02:00
Koen Kanters 69e9dbc5b2 Remove expire_after for action and click. https://github.com/Koenkk/zigbee2mqtt/issues/959 2019-04-17 20:46:27 +02:00
Koen Kanters 75718b360e Don’t identify Trust device as Xiaomi device. https://github.com/Koenkk/zigbee2mqtt/issues/1420 2019-04-17 20:03:40 +02:00
Koen Kanters beb2713cdd Update zigbee-shepherd. 2019-04-17 19:10:53 +02:00
Koen Kanters 2cdbe49385 Merge branch 'master' into dev 2019-04-16 19:57:35 +02:00
Koen Kanters d0e66ed1da Only setup reporting for non battery powered routers. #1064 2019-04-16 17:56:28 +02:00
Koen Kanters 39d6a8e218 1.3.1 2019-04-16 17:47:14 +02:00
Koen Kanters 2f187ae282 Fix device availability. https://github.com/Koenkk/zigbee2mqtt/issues/1395 2019-04-16 17:46:37 +02:00
Koen Kanters 3a5bdffd4e Downgrade zigbee-shepherd. #1378 2019-04-16 17:46:34 +02:00
Simon Rasmussen a1b43d12f6 eCozy homeassitant discovery (#1432)
* WIP

* Fixed paths

* Reverted accidentally removed lines

* Fixed linting issues
2019-04-16 16:31:05 +02:00
Koen Kanters 310f9892d6 Update zigbee-shepherd. 2019-04-14 20:36:01 +02:00
Koen Kanters c2edaed866 Backup coordinator on start. 2019-04-14 18:53:34 +02:00
Koen Kanters 2d4c447ee1 Add CC2652R backup mechanism. 2019-04-14 16:27:02 +02:00
Sembrador b13d87d461 Added support for the L1 and L2 inputs of the xiaomi switch LLKZMK11LM. (#1411)
Added support for the L1 and L2 inputs of the xiaomi switch LLKZMK11LM.
2019-04-13 17:19:05 +02:00
Koen Kanters da9425290e Update zigbee-shepherd. 2019-04-09 21:16:48 +02:00
Koen Kanters 811692fed8 Fix device availability. https://github.com/Koenkk/zigbee2mqtt/issues/1395 2019-04-09 19:17:16 +02:00
Oli 78ff9b1937 harmonize coordinator output statement (#1386)
resize the coordinator payload to be harmonize with the other output.
2019-04-09 19:01:59 +02:00
Oli c6011c0911 simplify the cies for ias (#1387)
* simplify the cies for ias

minimize the code for the cies for ias stuff to be as short as it need.
add some comments.

* fix eslint build error
2019-04-09 18:23:55 +02:00
Koen Kanters 9f16f99424 Update zigbee-shepherd. 2019-04-08 20:32:44 +02:00
Koen Kanters 08ad3a877d Update zigbee-shepherd. 2019-04-07 20:43:52 +02:00
Koen Kanters 24780e6eac Home Assistant: publish counter value for click and action payloads. #959 2019-04-07 18:11:16 +02:00
Simon Rasmussen 2554cf8e8b Improved devicePublish topic parsing (#1373)
* Refactored device publish parsing and added ability to set attributes directly

* Fixed topic subscriptions and null json object

* Fixed tests

* Added more testing of attribute

* Fixed linting issue.
2019-04-07 17:51:27 +02:00
Koen Kanters 4aa5877797 Add extra is pingable logging. #775 2019-04-07 17:47:06 +02:00
Koen Kanters 61942f8411 Update zigbee-shepherd (zigbee 3.0 support, yay!) 2019-04-07 17:43:51 +02:00
Koen Kanters 00ebd441a0 1.3.0 2019-04-07 17:33:34 +02:00
Koen Kanters 4843d85fe9 Update zigbee-shepherd-converters to 8.1.1. 2019-04-07 15:38:27 +02:00
Koen Kanters 32db1fd07a Fix acceptDevIncoming. #538 2019-04-01 20:30:27 +02:00
Koen Kanters cee35c66e6 Don’t setup reporting for CC2530 router. #1227 2019-04-01 19:51:14 +02:00
Koen Kanters 76d7861671 Only do read after write when retrieve_state is enabled. #1349 2019-03-30 21:03:54 +01:00
Koen Kanters 35fd1a68fc Update zigbee-shepherd-converters to 8.1.0 (always assume state). #1349 2019-03-30 20:50:40 +01:00
Koen Kanters 8828ee60ae Setup reporting for all attributes when cluster has no attributes. #1064 2019-03-30 18:20:07 +01:00
Koen Kanters 55c3edfa95 Home Assistant: skip state when bulb is already on when setting color or color temperature. #1349 2019-03-30 18:19:10 +01:00
Koen Kanters 2f52b851a9 Add extra acceptDevIncoming logging. #538 2019-03-30 16:48:58 +01:00
Koen Kanters 91bc56a09f Update zigbee-shepherd (cc-znp). #1343 2019-03-29 20:10:02 +01:00
Koen Kanters c737f569f9 Allow already connected devices to rejoin. #1294 #1334 2019-03-29 19:18:15 +01:00
Koen Kanters de4f9f44fd Fix bind by friendly name. #176 2019-03-29 19:10:13 +01:00
Erik van Luijk 21714855bf Pr expcliv2 (#1344)
* Add multiplatform support

Using the experimental manifest support in docker one can
create a manifest that contain references to other images/tags
build for various architectures. When someone pulls koenkk/zigbee2mqtt
correct image is automatically selected.

Please note that experimental cli support needs to be enabled.

* Update docker.sh

* Use docker cli with experimental cli support enabled only when needed
2019-03-29 18:52:45 +01:00
Koen Kanters 50986591c1 Fix tests. 2019-03-28 21:08:18 +01:00
Erik van Luijk e10999f0f2 Add multiplatform support (#1336)
* Add multiplatform support

Using the experimental manifest support in docker one can
create a manifest that contain references to other images/tags
build for various architectures. When someone pulls koenkk/zigbee2mqtt
correct image is automatically selected.

Please note that experimental cli support needs to be enabled.

* Update docker.sh
2019-03-28 20:56:40 +01:00
Koen Kanters 7015741a08 Add dateCode to getDeviceInfoForMqtt. #1213 2019-03-28 19:58:21 +01:00
Bruceforce 64fa02abab Update ISSUE_TEMPLATE.md (#1338)
Handle the "FAQ stuff" as a comment and give the user a basic issue template.
2019-03-28 16:23:41 +01:00
Koen Kanters 7e6dc67861 Update zigbee-shepherd-converters to 8.0.14. 2019-03-27 18:22:40 +01:00
Koen Kanters 3c5727b320 Add dateCode en update zigbee-shepherd. https://github.com/Koenkk/zigbee2mqtt/issues/1213 2019-03-27 18:18:49 +01:00
Koen Kanters 9ef4d2b810 Check if message has endpoints. 2019-03-27 17:31:25 +01:00
Koen Kanters e96dac2596 Log endpoint on zigbee message received. #642 2019-03-27 17:09:55 +01:00
Koen Kanters 9a15e8c118 Improve ban functionallity. #816 2019-03-26 21:40:31 +01:00
Justin Hornosty 25408769e6 Publish and respond to bridge/config/devices/get (#1329)
* - When sent a empty payload to bridge/config/devices/get , publish to
  'bridge/config/devices'
- For bridge/config/devices & bridge/config/devices/get only ever
  publish to the corresponding topic if a empty payload is sent.
- Make sure devicesPublish doesn't treat messages to
  bridge/config/devices/get as a zigbee device.

* fix eslinting errors

* Update bridgeConfig.js
2019-03-26 20:40:50 +01:00
Koen Kanters 2328ecb2ee Update zigbee-shepherd-converters to 8.0.13. 2019-03-25 21:53:39 +01:00
carlos-villanueva b2a453bec3 Add support for smartthings button (model: IM6001-BTP01) (#1312)
* Add support for smartthings button (model: IM6001-BTP01)

* Fix lint
2019-03-24 15:05:56 +01:00
Koen Kanters 20d9bac317 Don’t ping in Xiaomi extension when device availability is enabled. #1248 2019-03-24 14:46:38 +01:00
Koen Kanters 40596bab6a Always ping xiaomi devices through ‘basic’ mechanism. #1248 2019-03-24 14:38:08 +01:00
Willi Eggeling 69d04d0648 shutdown controller on SIGTERM (#1313)
Previously, the controller was only properly shutdown on SIGINT.
SIGTERM is used by kill (unix) and to stop Docker containers.
2019-03-24 14:27:49 +01:00
Koen Kanters 363f4e1c3b Merge branch 'master' into dev 2019-03-24 14:26:49 +01:00
Koen Kanters 008fda4d90 Introduce new ping mechanism (‘basic’) and use it for Xiaomi. https://github.com/Koenkk/zigbee2mqtt/issues/1248 2019-03-23 17:45:28 +01:00
Koen Kanters c64158b75d CC2530/CC2531 routers are not Xiaomi devices. https://github.com/Koenkk/zigbee2mqtt/issues/1248 2019-03-23 17:20:30 +01:00
Koen Kanters 1ea694cb1a Update zigbee-shepherd-convertes to 8.0.12. 2019-03-23 15:32:18 +01:00
Koen Kanters e537784d34 Update zigbee-shepherd-converters to 8.0.11. 2019-03-22 23:37:07 +01:00
Koen Kanters 0277591085 Update zigbee-shepherd-converters to 8.0.10. 2019-03-22 22:45:59 +01:00
Koen Kanters 7b2480be52 Update zigbee-shepherd-conveters to 8.0.9. 2019-03-21 20:34:54 +01:00
Martin Helff 70c30736a1 add more details to device list (#1296) 2019-03-21 20:00:13 +01:00
Koen Kanters 24cf72f166 Update zigbee-shepherd. https://github.com/Koenkk/zigbee2mqtt/issues/1295 2019-03-21 19:59:25 +01:00
Martin Helff e5081b2856 set reportableChange default value to 1 to avoid report flooding (#1293) 2019-03-21 19:43:15 +01:00
Fredrik Lindqvist 4048cb81a4 Update discord link to the #welcome-rules channel (#1290)
New invite link for the new channel structure.
2019-03-20 07:10:37 +01:00
Koen Kanters 2af2a8ea05 Update zigbee-shepherd-converters to 8.0.8. 2019-03-19 20:58:12 +01:00
Oli 1b066d9f59 extend controller.js function getDeviceInfoForMqtt (#1274)
* Add Discord channel. https://github.com/Koenkk/zigbee2mqtt.io/issues/31

* extend controller.js function getDeviceInfoForMqtt

extend controller.js with 
``
zclVersion,
appVersion,
stackVersion,
hwVersion,
swBuildId,
``

* Rework #2

Only Publish hwVersion and swBuildId as this seems to be valid as posible.
Map with default option 'unknown' to be in a good way.

* Fix tests

* Update README.md
2019-03-19 20:29:41 +01:00
Koen Kanters 5c9b31b2fc Don’t create state when cache_state is set to false. #1287 2019-03-19 20:02:17 +01:00
Koen Kanters 61d85af3fd Publish brightness: 0 when turn off trough brightness. https://github.com/Koenkk/zigbee-shepherd-converters/issues/353 2019-03-19 19:43:06 +01:00
Martin Helff 789ec0cc90 query groups through MQTT bridge/config/groups (#1276)
* query groups through MQTT bridge/config/groups

* linter adjustments

* linter adjustments
2019-03-19 19:18:22 +01:00
Koen Kanters b97f5d1afa Add Discourse link. 2019-03-19 19:15:58 +01:00
Koen Kanters 4cb0c0bbb0 Update zigbee-shepherd-converters to 8.0.7. 2019-03-18 18:21:21 +01:00
Stoinov 6d24a49062 Expand attribute output. #493 (#1277)
* Expand attribute output. #493

If attribute is actually an Object, expand it in the format `topic/key-subkey` in order to keep the same amount of topic levels for predictable parsing.
This only expands the first level of contained object. If more expansion are needed a more robust method should be implemented rather than just nesting `if`s.

* Fixing whitespace

fixing https://travis-ci.org/Koenkk/zigbee2mqtt/builds/507616980

* first time splitting line... obviously

Not sure what the proper approach is, but I guess we can put all params on new line.

* identation... really...

How is that `eslint`? any other requests?
2019-03-18 18:17:29 +01:00
Koenkk bee722c9c4 Skip re-transmitted Xiaomi messages. #1238 2019-03-18 17:59:36 +01:00
Koenkk 36bba0154d Fix groupid logging. 2019-03-18 17:32:53 +01:00
Oli a2a90bcdbd Log zigbee2mqtt version information to bridge/config (#1267)
* add coordinator_firmware to bridge/config

* Add zigbee2mqtt version and commit

* Whoops
2019-03-17 20:07:24 +01:00
Koen Kanters 1043053812 Update zigbee-shehperd. 2019-03-17 20:03:07 +01:00
Koen Kanters e6044da604 Implemented device debounce option. #1264 2019-03-16 23:41:46 +01:00
Koen Kanters a8cf925fcb Update zigbee-shepherd, zigbee-shepherd-converters and zcl-id. 2019-03-16 22:55:37 +01:00
Koen Kanters ad835e090b Add Discord channel. https://github.com/Koenkk/zigbee2mqtt.io/issues/31 2019-03-16 20:53:22 +01:00
Koen Kanters 3a96473e2b Improve device bind robustness. https://github.com/Koenkk/zigbee2mqtt/issues/176 2019-03-16 20:07:34 +01:00
Koen Kanters 661f79ce93 Whoops! Fix tests 2019-03-15 23:42:36 +01:00
Koen Kanters 7461fec28b Update zigbee-shepherd-converters to 8.0.4. 2019-03-15 23:33:24 +01:00
Koen Kanters 8b4b9bea19 Refactor property cache responsibility to state. 2019-03-15 23:18:19 +01:00
Koen Kanters fdcf2e8c3d Option to set Home Assistant discovery topic. #1019 2019-03-15 22:41:39 +01:00
Koen Kanters b39e195763 Update zigbee-shepherd. 2019-03-15 22:06:02 +01:00
Koen Kanters f2cdbeab38 Update zigbee-shepherd-converters to 8.0.3. 2019-03-15 21:58:49 +01:00
Koen Kanters f37d52b870 Discover click sensor for QBKG03LM, QBKG04LM, QBKG11LM and QBKG12LM. https://github.com/Koenkk/zigbee2mqtt/issues/723 2019-03-15 21:52:21 +01:00
Koen Kanters 969585c84e Allow to bind specific endpoint. #176 2019-03-15 21:19:42 +01:00
Koen Kanters d4d85633d2 Log groupID. #1238 2019-03-15 19:07:26 +01:00
Koen Kanters f54f3bd40f Add coordinator to zigbee2mqtt/bridge/config/devices. #1236 2019-03-14 20:30:06 +01:00
Koen Kanters 03ca8ec24d Update zigbee-shepherd-converters to 8.0.2. 2019-03-13 20:02:53 +01:00
Koen Kanters a65fbd6e2c Update zigbee-shepherd-converters to 8.0.1. 2019-03-13 19:48:31 +01:00
Koen Kanters ac28ca2841 Update to zigbee-shepherd-converters 8.0.0 2019-03-13 19:41:58 +01:00
Koen Kanters 79d11e295b Fix crash on empty advanced section. https://github.com/Koenkk/zigbee2mqtt/issues/1237 2019-03-12 21:58:45 +01:00
Koen Kanters 9f31cd86a3 Implemented MQTT group add and remove. https://github.com/Koenkk/zigbee2mqtt/issues/764 2019-03-12 21:19:04 +01:00
Koen Kanters c722b611dd Fix livolo queue push. #1241 2019-03-12 20:14:58 +01:00
Roger 3c4fa40a32 Add Securifi Peanut Smart Plug (#1230) 2019-03-11 17:55:25 +01:00
Koen Kanters d81a972f1b Allow to override Home Assistant device discovery payload. https://github.com/Koenkk/zigbee2mqtt/pull/1030 2019-03-10 21:48:40 +01:00
Koen Kanters 0f9333f8b9 Fix elapsed being a string value instead of bool. https://github.com/Koenkk/zigbee2mqtt/pull/1206 2019-03-10 21:34:20 +01:00
Koen Kanters 6e60063337 Update zigbee-shepherd and zigbee-shepherd-converters to 7.2.8. 2019-03-10 14:12:11 +01:00
Andrey 438626691e Allow to configure elapsed via MQTT (#1206)
* Allow to configure elapsed via MQTT

* Update bridgeConfig.js
2019-03-10 14:04:09 +01:00
qm3ster a978b26001 Use toStrictEqual for objects
Not sure why `jest-codemods` didn't do this.
Changed some primitives to use `toBe`.
2019-03-10 13:32:36 +01:00
qm3ster 7cec70aba8 Add @types/jest for editor completion 2019-03-10 13:32:36 +01:00
qm3ster df1825d65e Use mockReturnValue instead of mockImplementation where possible 2019-03-10 13:32:36 +01:00
qm3ster 532b3b2484 Use toHaveBeenNthCalledWith in some cases 2019-03-10 13:32:36 +01:00
qm3ster 84a5277d45 Replace sinon with jest builtins 2019-03-10 13:32:36 +01:00
qm3ster acbab01b7e Apply jest-codemods
`chai` ELIMINATED
2019-03-10 13:32:36 +01:00
qm3ster 6a4073b305 Use jest instead of mocha
Oh nice, coverage works now
2019-03-10 13:32:36 +01:00
Koen Kanters 18aee4803b Merge branch 'master' into dev 2019-03-09 21:45:24 +01:00
qm3ster 3b1fae1452 Remove whitespace 2019-03-09 21:09:39 +01:00
qm3ster cf2f10394a Mock fs in settings test 2019-03-09 21:09:39 +01:00
qm3ster 9eed7e271e Extract IO from settings.js 2019-03-09 21:09:39 +01:00
qm3ster 07765093be Fix all typos of separate 2019-03-09 21:09:39 +01:00
47 changed files with 13943 additions and 4522 deletions
+4 -4
View File
@@ -1,10 +1,10 @@
{
"env": {
"mocha": true,
"jest/globals": true,
"es6": true,
"node": true
},
"extends": ["eslint:recommended", "google"],
"extends": ["eslint:recommended", "google", "plugin:jest/recommended", "plugin:jest/style"],
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module"
@@ -13,9 +13,9 @@
"require-jsdoc": "off",
"indent": ["error", 4],
"max-len": ["error", { "code": 120 }],
"mocha/no-exclusive-tests": "error"
"no-prototype-builtins": "off"
},
"plugins": [
"mocha"
"jest"
]
}
+2
View File
@@ -0,0 +1,2 @@
github: [koenkk]
custom: https://www.paypal.me/koenkk
+16 -2
View File
@@ -1,5 +1,6 @@
<!--
Did you read the FAQ?
https://www.zigbee2mqtt.io/information/FAQ.md
https://www.zigbee2mqtt.io/information/FAQ.html
Unsupported device?
https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html
@@ -8,9 +9,22 @@ A question?
Read the documentation: https://www.zigbee2mqtt.io/
Bug report:
- First make sure your on the latest version of zigbee2mqtt.
- First make sure you are on the latest version of zigbee2mqtt.
- Make sure you are running the latest CC253X firmware from https://github.com/koenkk/z-stack-firmware.
- Provide a clear description of the problem.
- If applicable, provide steps how to reproduce the problem.
- Provide a log with `log_level` set to `debug`, see https://www.zigbee2mqtt.io/configuration/configuration.html
- DON'T copy logs directly here, post a link to https://hastebin.com/ or https://pastebin.com/.
-->
<!--Start your bug report below this line-->
# Bug Report
## What happened
## What did you expect to happen
## How to reproduce it (minimal and precise)
## Debug Info
zigbee2mqtt version:
CC253X firmware version:
+3 -1
View File
@@ -63,4 +63,6 @@ data/config.json
data/log*.txt
data/state.json
data/log
data-backup/
data-backup/
data/coordinator_backup.json
data/.storage
+25
View File
@@ -14,6 +14,23 @@ build_and_push() {
docker push $DOCKER_USERNAME/zigbee2mqtt:$1
}
push_manifest() {
export DOCKER_CLI_EXPERIMENTAL=enabled
docker manifest create $DOCKER_USERNAME/zigbee2mqtt:latest$1 $DOCKER_USERNAME/zigbee2mqtt:latest$1 $DOCKER_USERNAME/zigbee2mqtt:arm32v6$1 $DOCKER_USERNAME/zigbee2mqtt:arm64v8$1
docker manifest annotate $DOCKER_USERNAME/zigbee2mqtt:latest$1 $DOCKER_USERNAME/zigbee2mqtt:arm32v6$1 --os linux --arch arm --variant v6
docker manifest annotate $DOCKER_USERNAME/zigbee2mqtt:latest$1 $DOCKER_USERNAME/zigbee2mqtt:arm64v8$1 --os linux --arch arm64 --variant v8
docker manifest inspect $DOCKER_USERNAME/zigbee2mqtt:latest$1
docker manifest push -p $DOCKER_USERNAME/zigbee2mqtt:latest$1
docker run --rm mplatform/mquery $DOCKER_USERNAME/zigbee2mqtt:latest$1
export DOCKER_CLI_EXPERIMENTAL=disabled
}
# Only update Docker images for:
# - dev branch
# - version release
@@ -31,6 +48,10 @@ then
tag_push "$TRAVIS_TAG-arm32v6" "arm32v6"
tag_push "$TRAVIS_TAG-arm64v8" "arm64v8"
tag_push "$TRAVIS_TAG" latest
# Push manifest
push_manifest ""
elif [ "$TRAVIS_BRANCH" = "dev" -a "$TRAVIS_PULL_REQUEST" = "false" ]
then
echo "Updating docker images for dev branch!"
@@ -38,6 +59,10 @@ then
build_and_push latest-dev docker/Dockerfile.amd64 amd64
build_and_push arm32v6-dev docker/Dockerfile.arm32v6 arm
build_and_push arm64v8-dev docker/Dockerfile.arm64v8 arm64
# Push manifest
push_manifest "-dev"
else
echo "Not updating docker images, triggered by pull request or not on master/dev branch"
fi
+6
View File
@@ -17,6 +17,12 @@
<a href="https://www.paypal.me/koenkk">
<img src="https://img.shields.io/badge/donate-PayPal-blue.svg">
</a>
<a href="https://discord.gg/dadfWYE">
<img src="https://img.shields.io/discord/556563650429583360.svg">
</a>
<a href="http://zigbee2mqtt.discourse.group/">
<img src="https://img.shields.io/discourse/https/zigbee2mqtt.discourse.group/status.svg">
</a>
</div>
<h1>Zigbee2mqtt 🌉 🐝</h1>
<p>
+1 -5
View File
@@ -13,8 +13,4 @@ if [ ! -f "$DATA/configuration.yaml" ]; then
cp /app/configuration.yaml "$DATA/configuration.yaml"
fi
if [ ! -z "$DEBUG" ]; then
npm start
else
DEBUG="$DEBUG" npm start
fi
exec npm start
+7 -1
View File
@@ -11,7 +11,13 @@ const controller = new Controller();
controller.start();
process.on('SIGINT', handleQuit);
process.on('SIGTERM', handleQuit);
let stopping = false;
function handleQuit() {
controller.stop(() => process.exit());
if (!stopping) {
stopping = true;
controller.stop(() => process.exit());
}
}
+62 -40
View File
@@ -5,6 +5,7 @@ const logger = require('./util/logger');
const settings = require('./util/settings');
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
const objectAssignDeep = require('object-assign-deep');
const utils = require('./util/utils');
// Extensions
const ExtensionNetworkMap = require('./extension/networkMap');
@@ -21,6 +22,7 @@ const ExtensionDeviceAvailability = require('./extension/deviceAvailability');
const ExtensionDeviceBind = require('./extension/deviceBind');
const ExtensionDeviceReport = require('./extension/deviceReport');
const ExtensionLivolo = require('./extension/livolo');
const ExtensionResponder = require('./extension/responder');
class Controller {
constructor() {
@@ -45,7 +47,7 @@ class Controller {
new ExtensionBridgeConfig(this.zigbee, this.mqtt, this.state, this.publishEntityState),
new ExtensionGroups(this.zigbee, this.mqtt, this.state, this.publishEntityState),
new ExtensionDeviceBind(this.zigbee, this.mqtt, this.state, this.publishEntityState),
new ExtensionResponder(this.zigbee, this.mqtt, this.state, this.publishEntityState),
];
if (settings.get().advanced.report) {
@@ -130,7 +132,9 @@ class Controller {
// Log
logger.debug(
`Received zigbee message of type '${message.type}' with data '${JSON.stringify(message.data)}'` +
(device ? ` of device '${device.modelId}' (${device.ieeeAddr})` : '')
(device ? ` of device '${device.modelId}' (${device.ieeeAddr})` : '') +
(message.groupid ? ` with groupID ${message.groupID}` : '') +
(message.endpoints && message.endpoints.length ? ` of endpoint ${message.endpoints[0].epId}` : '')
);
// Call extensions.
@@ -153,6 +157,10 @@ class Controller {
}
start() {
logger.info(`Logging to directory: '${logger.directory}'`);
logger.cleanup();
this.state.start();
this.startupLogVersion(() => {
@@ -174,30 +182,14 @@ class Controller {
// Wrap-up
this.state.stop();
this.mqtt.disconnect();
this.zigbee.stop(callback);
}
startupLogVersion(callback) {
const git = require('git-last-commit');
const packageJSON = require('../package.json');
const version = packageJSON.version;
git.getLastCommit((err, commit) => {
let commitHash = null;
if (err) {
try {
commitHash = require('../.hash.json').hash;
} catch (error) {
commitHash = 'unknown';
}
} else {
commitHash = commit.shortHash;
}
logger.info(`Starting zigbee2mqtt version ${version} (commit #${commitHash})`);
utils.getZigbee2mqttVersion((info) => {
logger.info(`Starting zigbee2mqtt version ${info.version} (commit #${info.commitHash})`);
callback();
});
}
@@ -216,7 +208,7 @@ class Controller {
}
if (device.type) {
type = device.type;
type = utils.correctDeviceType(device);
}
return `${friendlyName} (${device.ieeeAddr}): ${friendlyDevice.model} - ` +
@@ -226,26 +218,25 @@ class Controller {
sendAllCachedStates() {
this.zigbee.getAllClients().forEach((device) => {
if (this.state.exists(device.ieeeAddr)) {
this.publishEntityState(device.ieeeAddr, this.state.get(device.ieeeAddr), false);
this.publishEntityState(device.ieeeAddr, this.state.get(device.ieeeAddr));
}
});
}
publishEntityState(entityID, payload, cache) {
publishEntityState(entityID, payload) {
const entity = settings.resolveEntity(entityID);
const appSettings = settings.get();
let messagePayload = {...payload};
if (appSettings.advanced.cache_state) {
// Add cached state to payload
if (this.state.exists(entityID)) {
messagePayload = objectAssignDeep.noMutate(this.state.get(entityID), payload);
}
const currentState = this.state.exists(entityID) ? this.state.get(entityID) : {};
const newState = objectAssignDeep.noMutate(currentState, payload);
// Update state cache with new state.
if (cache) {
this.state.set(entityID, messagePayload);
}
// Update state cache with new state.
this.state.set(entityID, newState);
if (settings.get().advanced.cache_state) {
// Add cached state to payload
messagePayload = newState;
}
const entitySettings = entity.type === 'device' ? settings.getDevice(entityID) : settings.getGroup(entityID);
@@ -258,18 +249,46 @@ class Controller {
messagePayload.device = this.getDeviceInfoForMqtt(entityID);
}
if (settings.get().experimental.output === 'json') {
this.mqtt.publish(entity.friendlyName, JSON.stringify(messagePayload), options);
} else if (settings.get().experimental.output === 'attribute') {
Object.keys(messagePayload).forEach((key) => {
this.mqtt.publish(`${entity.friendlyName}/${key}`, JSON.stringify(messagePayload[key]), options);
});
if (Object.entries(messagePayload).length) {
if (settings.get().experimental.output === 'json') {
this.mqtt.publish(entity.friendlyName, JSON.stringify(messagePayload), options);
} else if (settings.get().experimental.output === 'attribute') {
this.iteratePayloadForAttrOutput(entity.friendlyName+'/', messagePayload, options);
}
}
}
iteratePayloadForAttrOutput(topicRoot, payload, options) {
Object.keys(payload).forEach((key) => {
let subPayload = payload[key];
let message;
// Special cases
if (key === 'color' &&
subPayload.r !== undefined &&
subPayload.g !== undefined &&
subPayload.b !== undefined) {
subPayload = [subPayload.r, subPayload.g, subPayload.b];
}
// Check Array first, since it is also an Object
if (Array.isArray(subPayload)) {
message = subPayload.map((x) => `${x}`).join(',');
} else if (typeof subPayload === 'object') {
return this.iteratePayloadForAttrOutput(topicRoot+key+'-', subPayload, options);
} else {
message = typeof subPayload === 'string' ? subPayload : JSON.stringify(subPayload);
}
this.mqtt.publish(`${topicRoot}${key}`, message, options);
});
}
getDeviceInfoForMqtt(ieeeAddr) {
const device = this.zigbee.getDevice(ieeeAddr);
const {type, nwkAddr, manufId, manufName, powerSource, modelId, status} = device;
const {
type, nwkAddr, manufId, manufName, powerSource, modelId, hwVersion, swBuildId, status, dateCode,
} = device;
const deviceSettings = settings.getDevice(device.ieeeAddr);
return {
@@ -281,6 +300,9 @@ class Controller {
manufName,
powerSource,
modelId,
hwVersion: hwVersion ? hwVersion : 'unknown',
swBuildId: swBuildId ? swBuildId : 'unknown',
dateCode: dateCode ? dateCode : 'unknown',
status,
};
}
+87 -16
View File
@@ -1,8 +1,9 @@
const settings = require('../util/settings');
const logger = require('../util/logger');
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
const utils = require('../util/utils');
const configRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/\\w+`, 'g');
const configRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/((?:\\w+/get)|(?:\\w+))`);
const allowedLogLevels = ['error', 'warn', 'info', 'debug'];
class BridgeConfig {
@@ -15,28 +16,48 @@ class BridgeConfig {
// Bind functions
this.permitJoin = this.permitJoin.bind(this);
this.lastSeen = this.lastSeen.bind(this);
this.elapsed = this.elapsed.bind(this);
this.reset = this.reset.bind(this);
this.logLevel = this.logLevel.bind(this);
this.devices = this.devices.bind(this);
this.groups = this.groups.bind(this);
this.rename = this.rename.bind(this);
this.remove = this.remove.bind(this);
this.ban = this.ban.bind(this);
this.deviceOptions = this.deviceOptions.bind(this);
this.addGroup = this.addGroup.bind(this);
this.removeGroup = this.removeGroup.bind(this);
this.whitelist= this.whitelist.bind(this);
// Set supported options
this.supportedOptions = {
'permit_join': this.permitJoin,
'last_seen': this.lastSeen,
'elapsed': this.elapsed,
'reset': this.reset,
'log_level': this.logLevel,
'devices': this.devices,
'groups': this.groups,
'devices/get': this.devices,
'rename': this.rename,
'remove': this.remove,
'ban': this.ban,
'device_options': this.deviceOptions,
'add_group': this.addGroup,
'remove_group': this.removeGroup,
'whitelist': this.whitelist,
};
}
whitelist(topic, message) {
message = message.toString();
const IDByFriendlyName = settings.getIeeeAddrByFriendlyName(message);
const deviceID = IDByFriendlyName ? IDByFriendlyName : message;
const whitelisted = settings.whitelistDevice(deviceID);
whitelisted ? logger.info(`Whitelisted '${deviceID}'`) : logger.error(`Failed to whitelist '${deviceID}'`);
this.mqtt.log('device_whitelisted', deviceID);
}
deviceOptions(topic, message) {
let json = null;
try {
@@ -88,6 +109,19 @@ class BridgeConfig {
logger.info(`Set last_seen to ${message}`);
}
elapsed(topic, message) {
const allowed = ['true', 'false'];
message = message.toString();
if (!allowed.includes(message)) {
logger.error(`${message} is not an allowed value, possible: ${allowed}`);
return;
}
settings.set(['advanced', 'elapsed'], message === 'true');
logger.info(`Set elapsed to ${message}`);
}
logLevel(topic, message) {
const level = message.toString().toLowerCase();
if (allowedLogLevels.includes(level)) {
@@ -102,19 +136,39 @@ class BridgeConfig {
}
devices(topic, message) {
const devices = this.zigbee.getAllClients().map((device) => {
const mappedDevice = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
const friendlyDevice = settings.getDevice(device.ieeeAddr);
return {
const devices = this.zigbee.getDevices().map((device) => {
const payload = {
ieeeAddr: device.ieeeAddr,
type: device.type,
model: mappedDevice ? mappedDevice.model : device.modelId,
friendly_name: friendlyDevice ? friendlyDevice.friendly_name : device.ieeeAddr,
};
if (device.type !== 'Coordinator') {
const mappedDevice = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
const friendlyDevice = settings.getDevice(device.ieeeAddr);
payload.model = mappedDevice ? mappedDevice.model : device.modelId;
payload.friendly_name = friendlyDevice ? friendlyDevice.friendly_name : device.ieeeAddr;
payload.nwkAddr = device.nwkAddr;
payload.manufId = device.manufId;
payload.manufName = device.manufName;
payload.powerSource = device.powerSource;
payload.modelId = device.modelId;
payload.hwVersion = device.hwVersion;
payload.swBuildId = device.swBuildId;
payload.dateCode = device.dateCode;
}
return payload;
});
this.mqtt.log('devices', devices);
if (topic.split('/').pop() == 'get') {
this.mqtt.publish(`bridge/config/devices`, JSON.stringify(devices), {});
} else {
this.mqtt.log('devices', devices);
}
}
groups(topic, message) {
this.mqtt.log('groups', settings.getGroups());
}
rename(topic, message) {
@@ -144,6 +198,18 @@ class BridgeConfig {
}
}
addGroup(topic, message) {
const name = message.toString();
const added = settings.addGroup(name);
added ? logger.info(`Added group '${name}'`) : logger.error(`Failed to add group '${name}'`);
}
removeGroup(topic, message) {
const name = message.toString();
const removed = settings.removeGroup(name);
removed ? logger.info(`Removed group '${name}'`) : logger.error(`Failed to remove group '${name}'`);
}
remove(topic, message) {
this.removeOrBan(false, message);
}
@@ -193,7 +259,7 @@ class BridgeConfig {
return false;
}
const option = topic.split('/').slice(-1)[0];
const option = topic.match(configRegex)[1];
if (!this.supportedOptions.hasOwnProperty(option)) {
return false;
@@ -205,13 +271,18 @@ class BridgeConfig {
}
publish() {
const topic = `bridge/config`;
const payload = {
log_level: logger.transports.console.level,
permit_join: this.zigbee.getPermitJoin(),
};
utils.getZigbee2mqttVersion((info) => {
const topic = `bridge/config`;
const payload = {
version: info.version,
commit: info.commitHash,
coordinator: this.zigbee.getFirmwareVersion(),
log_level: logger.transports.console.level,
permit_join: this.zigbee.getPermitJoin(),
};
this.mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 0}, null);
this.mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 0}, null);
});
}
}
+50 -24
View File
@@ -30,15 +30,21 @@ class DeviceAvailability {
}
isPingable(device) {
logger.trace(`Checking if ${device.ieeeAddr} is pingable`);
if (this.blacklist.includes(device.ieeeAddr)) {
logger.trace(`${device.ieeeAddr} is not pingable because of blacklist`);
return false;
}
if (pingableDevices.find((d) => d.zigbeeModel.includes(device.modelId))) {
logger.trace(`${device.ieeeAddr} is pingable because in pingable devices`);
return true;
}
return device.type === 'Router' && (device.powerSource && device.powerSource !== 'Battery');
const result = utils.isRouter(device) && !utils.isBatteryPowered(device);
logger.trace(`${device.ieeeAddr} is pingable (${result}) not router or battery powered`);
return result;
}
getAllPingableDevices() {
@@ -53,12 +59,13 @@ class DeviceAvailability {
.forEach((device) => this.publishAvailability(device.ieeeAddr, true));
// Start timers for all devices
this.getAllPingableDevices().forEach((device) => this.setTimer(device.ieeeAddr));
this.getAllPingableDevices().forEach((device) => this.setTimer(device));
}
handleInterval(ieeeAddr) {
handleInterval(device) {
// Check if a job is already pending.
// This avoids overflowing of the queue in case the queue is not able to catch-up with the jobs being added.
const ieeeAddr = device.ieeeAddr;
if (this.pending.includes(ieeeAddr)) {
logger.debug(`Skipping ping for ${ieeeAddr} becuase job is already in queue`);
return;
@@ -68,6 +75,7 @@ class DeviceAvailability {
// When a device is already unavailable, log the ping failed on 'debug' instead of 'error'.
const errorLogLevel = this.state.hasOwnProperty(ieeeAddr) && !this.state[ieeeAddr] ? 'debug' : 'error';
const mechanism = utils.isXiaomiDevice(device) ? 'basic' : 'default';
this.zigbee.ping(ieeeAddr, errorLogLevel, (error) => {
this.publishAvailability(ieeeAddr, !error);
@@ -78,17 +86,17 @@ class DeviceAvailability {
this.pending.splice(index, 1);
}
this.setTimer(ieeeAddr);
});
this.setTimer(device);
}, mechanism);
}
setTimer(ieeeAddr) {
if (this.timers[ieeeAddr]) {
clearTimeout(this.timers[ieeeAddr]);
setTimer(device) {
if (this.timers[device.ieeeAddr]) {
clearTimeout(this.timers[device.ieeeAddr]);
}
this.timers[ieeeAddr] = setTimeout(() => {
this.handleInterval(ieeeAddr);
this.timers[device.ieeeAddr] = setTimeout(() => {
this.handleInterval(device);
}, utils.secondsToMilliseconds(this.availability_timeout));
}
@@ -112,12 +120,14 @@ class DeviceAvailability {
});
converters.forEach((converter) => {
const converted = converter.convert(null, null, null, 'get');
if (converted) {
this.zigbee.publish(
ieeeAddr, 'device', converted.cid, converted.cmd, converted.cmdType,
converted.zclData, converted.cfg, null, () => {}
);
const convertedResults = converter.convert(null, null, null, 'get');
if (convertedResults && convertedResults.length) {
convertedResults.forEach((converted) => {
this.zigbee.publish(
ieeeAddr, 'device', converted.cid, converted.cmd, converted.cmdType,
converted.zclData, converted.cfg, null, () => {}
);
});
}
});
}
@@ -137,18 +147,34 @@ class DeviceAvailability {
}
onZigbeeMessage(message, device, mappedDevice) {
// When a zigbee message from a device is received we know the device is still alive.
// => reset the timer.
if (device && this.isPingable(this.zigbee.getDevice(device.ieeeAddr))) {
// When a message is received and the device is marked as offline, mark it online.
if (this.state.hasOwnProperty(device.ieeeAddr) && !this.state[device.ieeeAddr]) {
this.publishAvailability(device.ieeeAddr, true);
} else if (!this.state.hasOwnProperty(device.ieeeAddr)) {
// When a zigbee message from a device is received we know the device is still alive.
// => reset the timer.
this.setTimer(device);
const online = this.state.hasOwnProperty(device.ieeeAddr) && this.state[device.ieeeAddr];
const offline = this.state.hasOwnProperty(device.ieeeAddr) && !this.state[device.ieeeAddr];
if (!online && !offline) {
// A new device has been connected
this.publishAvailability(device.ieeeAddr, true);
} else if (offline) {
// When a message is received and the device is marked as offline, mark it online.
this.publishAvailability(device.ieeeAddr, true);
} else if (online) {
/**
* In case the device is powered off AND on within the availability timeout,
* zigbee2qmtt does not detect the device as offline (device is still marked online).
* When a device is turned on again the state could be out of sync.
* https://github.com/Koenkk/zigbee2mqtt/issues/1383#issuecomment-489412168
* endDeviceAnnce is typically send when a device comes online.
*
* This isn't needed for TRADFRI devices as they already send the state themself.
*/
if (message.type === 'endDeviceAnnce' && !utils.isIkeaTradfriDevice(device)) {
this.onReconnect(device.ieeeAddr);
}
}
this.setTimer(device.ieeeAddr);
}
}
}
+45 -44
View File
@@ -1,7 +1,8 @@
const settings = require('../util/settings');
const logger = require('../util/logger');
const Queue = require('queue');
const utils = require('../util/utils');
const postfixes = utils.getPostfixes();
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/(bind|unbind)/.+$`);
const allowedClusters = [
@@ -17,16 +18,25 @@ class DeviceBind {
this.mqtt = mqtt;
this.state = state;
this.publishEntityState = publishEntityState;
// Setup queue
this.queue = new Queue();
this.queue.concurrency = 1;
this.queue.autostart = true;
}
onMQTTConnected() {
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/bind/+`);
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/bind/+/+`);
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/unbind/+`);
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/unbind/+/+`);
}
getIDAndPostfix(topic) {
let postfix = null;
if (postfixes.find((p) => topic.endsWith(`/${p}`))) {
postfix = topic.substr(topic.lastIndexOf('/') + 1, topic.length);
// Remove postfix from topic
topic = topic.replace(`/${postfix}`, '');
}
return {ID: topic, postfix};
}
parseTopic(topic) {
@@ -42,8 +52,7 @@ class DeviceBind {
// Remove type from topic
topic = topic.replace(`${type}/`, '');
return {ID: topic, type};
return {type, ...this.getIDAndPostfix(topic)};
}
onMQTTMessage(topic, message) {
@@ -55,28 +64,21 @@ class DeviceBind {
// Find source; can only be a device.
const sourceEntity = settings.resolveEntity(topic.ID);
const source = this.zigbee.getEndpoint(sourceEntity.ID);
const source = utils.getEndpointByEntityID(this.zigbee, sourceEntity.ID, topic.postfix);
if (!source) {
logger.error(`Failed to find device '${sourceEntity.ID}'`);
return false;
}
// Find target; can be a device or group.
const targetEntity = settings.resolveEntity(message.toString());
const targetEntityIDPostfix= this.getIDAndPostfix(message.toString());
const targetEntity = settings.resolveEntity(targetEntityIDPostfix.ID);
let target = null;
if (targetEntity.type === 'device') {
target = this.zigbee.getEndpoint(targetEntity.ID);
if (!target) {
logger.error(`Failed to find target device '${targetEntity.ID}'`);
return false;
}
target = utils.getEndpointByEntityID(this.zigbee, targetEntity.ID, targetEntityIDPostfix.postfix);
} else if (targetEntity.type === 'group') {
target = targetEntity.ID;
}
if (!source || !target) {
return false;
}
// Find which clusters are supported by both the source and target.
// Groups are assumed to support all clusters (as we don't know which devices are in)
let supported = [];
@@ -94,29 +96,28 @@ class DeviceBind {
// Bind
clusters.forEach((cluster) => {
this.queue.push((queueCallback) => {
logger.debug(`${topic.type}ing cluster '${cluster}' from ${sourceEntity.ID}' to '${targetEntity.ID}'`);
logger.debug(
`${topic.type}ing cluster '${cluster}' from '${sourceEntity.ID}' ` +
`(${source.epId}) to '${targetEntity.ID}'`
);
source[topic.type](cluster, target, (error) => {
if (error) {
logger.error(
`Failed to ${topic.type} cluster '${cluster}' from ${sourceEntity.ID}' to ` +
`'${targetEntity.ID}' (${error})`
);
} else {
logger.info(
`Successfully ${topic.type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from ` +
`${sourceEntity.ID}' to '${targetEntity.ID}'`
);
this.zigbee[topic.type](source, cluster, target, (error) => {
if (error) {
logger.error(
`Failed to ${topic.type} cluster '${cluster}' from '${sourceEntity.ID}' to ` +
`'${targetEntity.ID}' (${error})`
);
} else {
logger.info(
`Successfully ${topic.type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from ` +
`'${sourceEntity.ID}' to '${targetEntity.ID}'`
);
this.mqtt.log(
`device_${topic.type}`,
{from: sourceEntity.ID, to: targetEntity.ID, cluster}
);
}
queueCallback();
});
this.mqtt.log(
`device_${topic.type}`,
{from: sourceEntity.ID, to: targetEntity.ID, cluster}
);
}
});
});
+1 -5
View File
@@ -57,11 +57,7 @@ class DeviceGroupMembership {
}
logger.info(`${msgGroupList} and ${msgCapacity}`);
// never cache
this.publishEntityState(ieeeAddr, {
group_list: grouplist,
group_capacity: capacity,
}, false);
this.publishEntityState(ieeeAddr, {group_list: grouplist, group_capacity: capacity});
}
};
+102 -125
View File
@@ -2,48 +2,19 @@
const settings = require('../util/settings');
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
const logger = require('../util/logger');
const utils = require('../util/utils');
const postfixes = utils.getPostfixes();
const topicRegex = new RegExp(`^(.+?)(?:/(${postfixes.join('|')}))?/(get|set)(?:/(.+))?`);
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/.+/(set|get)$`);
const postfixes = [
'left', 'right', 'center', 'bottom_left', 'bottom_right',
'top_left', 'top_right', 'white', 'rgb', 'system', 'top', 'bottom',
];
const maxDepth = 20;
const groupConverters = [
{
from: (converted) => {
if (converted.cid === 'genOnOff') {
return {state: converted.cmd.toUpperCase()};
} else if (converted.cid === 'genLevelCtrl') {
return {state: 'ON', brightness: Number(converted.zclData.level)};
}
},
to: zigbeeShepherdConverters.toZigbeeConverters.light_onoff_brightness,
},
{
from: (converted) => {
return {color_temp: Number(converted.zclData.colortemp)};
},
to: zigbeeShepherdConverters.toZigbeeConverters.light_colortemp,
},
{
from: (converted) => {
if (converted.zclData.hasOwnProperty('colorx') && converted.zclData.hasOwnProperty('colory')) {
return {
color: {
x: converted.zclData.colorx / 65535,
y: converted.zclData.colory / 65535,
},
};
}
},
to: zigbeeShepherdConverters.toZigbeeConverters.light_color,
},
{
from: () => null,
to: zigbeeShepherdConverters.toZigbeeConverters.ignore_transition,
},
zigbeeShepherdConverters.toZigbeeConverters.light_onoff_brightness,
zigbeeShepherdConverters.toZigbeeConverters.light_colortemp,
zigbeeShepherdConverters.toZigbeeConverters.light_color,
zigbeeShepherdConverters.toZigbeeConverters.light_alert,
zigbeeShepherdConverters.toZigbeeConverters.ignore_transition,
];
class DevicePublish {
@@ -56,39 +27,29 @@ class DevicePublish {
onMQTTConnected() {
// Subscribe to topics.
const baseTopic = settings.get().mqtt.base_topic;
for (let step = 1; step < maxDepth; step++) {
const topic = `${settings.get().mqtt.base_topic}/${'+/'.repeat(step)}`;
const topic = `${baseTopic}/${'+/'.repeat(step)}`;
this.mqtt.subscribe(`${topic}set`);
this.mqtt.subscribe(`${topic}set/+`);
this.mqtt.subscribe(`${topic}get`);
this.mqtt.subscribe(`${topic}get/+`);
}
}
parseTopic(topic) {
if (!topic.match(topicRegex)) {
const match = topic.match(topicRegex);
if (!match) {
return null;
}
// Remove base from topic
topic = topic.replace(`${settings.get().mqtt.base_topic}/`, '');
// Parse type from topic
const type = topic.substr(topic.lastIndexOf('/') + 1, topic.length);
// Remove type from topic
topic = topic.replace(`/${type}`, '');
// Check if we have to deal with a postfix.
let postfix = '';
if (postfixes.find((p) => topic.endsWith(`/${p}`))) {
postfix = topic.substr(topic.lastIndexOf('/') + 1, topic.length);
// Remove postfix from topic
topic = topic.replace(`/${postfix}`, '');
const ID = match[1].replace(`${settings.get().mqtt.base_topic}/`, '');
// If we didn't repalce base_topic we received something we don't care about
if (ID === match[1] || ID.match(/bridge\/config/)) {
return null;
}
const ID = topic;
return {type, ID, postfix};
return {ID: ID, postfix: match[2] || '', type: match[3], attribute: match[4]};
}
handlePublishError(entity, message, error) {
@@ -100,31 +61,20 @@ class DevicePublish {
this.mqtt.log('zigbee_publish_error', error.toString(), meta);
}
handlePublished(entity, topic, converter, converted) {
if (entity.type === 'device' && topic.type === 'set') {
// Devices do not report when they go off, this ensures state (on/off) is always in sync.
// Brightness onoff converters also control the state. (do a moveToLevelWithOnOff)
const msg = {};
const _key = topic.postfix ? `state_${topic.postfix}` : 'state';
handlePublished(entity, topic, converter, converted, key, value) {
if (topic.type === 'set' && converted.hasOwnProperty('newState') && converted.newState) {
const msg = converted.newState;
if (converted.cid === 'genOnOff') {
msg[_key] = converted.cmd.toUpperCase();
} else if (converted.cid === 'genLevelCtrl' && converted.cmd === 'moveToLevelWithOnOff') {
msg[_key] = 'ON';
msg['brightness'] = Number(converted.zclData.level);
} else if (converted.cid === 'genLevelCtrl' && converted.cmd === 'moveToLevel') {
msg['brightness'] = Number(converted.zclData.level);
if (topic.postfix) {
msg[`state_${topic.postfix}`] = msg.state;
delete msg.state;
}
if (Object.keys(msg).length > 0) {
this.publishEntityState(entity.ID, msg, true);
}
} else if (entity.type === 'group' && topic.type === 'set') {
// As a group doesn't confirm it's state, we mock the state here.
const payload = groupConverters.find((g) => g.to === converter).from(converted);
if (payload) {
this.publishEntityState(entity.ID, payload, true);
if (settings.get().advanced.last_seen !== 'disable') {
msg.last_seen = utils.formatDate(Date.now(), settings.get().advanced.last_seen);
}
this.publishEntityState(entity.ID, msg);
}
}
@@ -142,11 +92,12 @@ class DevicePublish {
let endpoint = null;
let converters = null;
let device = null;
let options = {};
if (entity.type === 'device') {
device = this.zigbee.getDevice(entity.ID);
if (!device) {
logger.error(`Failed to find device with ieeAddr: '${entity.ID}'`);
logger.error(`Failed to find device with ieeeAddr: '${entity.ID}'`);
this.mqtt.log('entity_not_found', entity.ID);
return;
}
@@ -169,17 +120,39 @@ class DevicePublish {
}
converters = model.toZigbee;
options = model.options || {};
} else if (entity.type === 'group') {
converters = groupConverters.map((g) => g.to);
converters = groupConverters;
}
// Convert the MQTT message to a Zigbee message.
let json = null;
try {
json = JSON.parse(message);
} catch (e) {
// Cannot be parsed to JSON, assume state message.
json = {state: message.toString()};
let json = {};
if (topic.hasOwnProperty('attribute') && topic.attribute) {
json[topic.attribute] = message.toString();
} else {
try {
json = JSON.parse(message);
} catch (e) {
// Cannot be parsed to JSON, assume state message.
json = {state: message.toString()};
}
}
/**
* Home Assistant always publishes 'state', even when e.g. only setting
* the color temperature. This would lead to 2 zigbee publishes, where the first one
* (state) is probably unecessary.
*/
if (settings.get().homeassistant) {
const deviceState = this.state.get(entity.ID);
const hasColorTemp = json.hasOwnProperty('color_temp');
const hasColor = json.hasOwnProperty('color');
const hasBrightness = json.hasOwnProperty('brightness');
const isOn = deviceState && deviceState.state === 'ON' ? true : false;
if (isOn && (hasColorTemp || hasColor) && !hasBrightness) {
delete json.state;
logger.debug('Skipping state because of Home Assistant');
}
}
// Ensure that state and brightness are executed before other commands.
@@ -202,49 +175,53 @@ class DevicePublish {
}
// Converter didn't return a result, skip
const converted = converter.convert(key, json[key], json, topic.type, topic.postfix);
if (!converted) {
const convertedResults = converter.convert(key, json[key], json, topic.type, topic.postfix, options);
if (!convertedResults || !convertedResults.length) {
return;
}
this.zigbee.publish(
entity.ID,
entity.type,
converted.cid,
converted.cmd,
converted.cmdType,
converted.zclData,
converted.cfg,
endpoint,
(error, rsp) => {
if (!error) {
this.handlePublished(entity, topic, converter, converted);
} else {
this.handlePublishError(entity, message, error);
convertedResults.forEach((converted) => {
this.zigbee.publish(
entity.ID,
entity.type,
converted.cid,
converted.cmd,
converted.cmdType,
converted.zclData,
converted.cfg,
endpoint,
(error, rsp) => {
if (!error) {
this.handlePublished(entity, topic, converter, converted, key, json[key]);
} else {
this.handlePublishError(entity, message, error);
}
}
);
// It's possible for devices to get out of sync when writing an attribute that's not reportable.
// So here we re-read the value after a specified timeout, this timeout could for example be the
// transition time of a color change or for forcing a state read for devices that don't
// automatically report a new state when set.
// When reporting is requested for a device (report: true in device-specific settings) we won't
// ever issue a read here, as we assume the device will properly report changes.
// Only do this when the retrieve_state option is enabled for this device.
const deviceSettings = settings.getDevice(entity.ID);
if (topic.type === 'set' && entity.type === 'device' &&
converted.hasOwnProperty('readAfterWriteTime') && deviceSettings && deviceSettings.retrieve_state) {
const getConvertedResults = converter.convert(key, json[key], json, 'get', '', options);
getConvertedResults.forEach((getConverted) => {
setTimeout(() => {
this.zigbee.publish(
entity.ID, entity.type, getConverted.cid, getConverted.cmd, getConverted.cmdType,
getConverted.zclData, getConverted.cfg, endpoint, () => {}
);
}, converted.readAfterWriteTime);
});
}
);
// It's possible for devices to get out of sync when writing an attribute that's not reportable.
// So here we re-read the value after a specified timeout, this timeout could for example be the
// transition time of a color change or for forcing a state read for devices that don't
// automatically report a new state when set.
// When reporting is requested for a device (report: true in device-specific settings) we won't
// ever issue a read here, as we assume the device will properly report changes.
const deviceSettings = settings.getDevice(entity.ID);
if (topic.type === 'set' && entity.type === 'device'
&& converted.hasOwnProperty('readAfterWriteTime')
&& !(deviceSettings && deviceSettings.report)) {
const getConverted = converter.convert(key, json[key], json, 'get');
setTimeout(() => {
this.zigbee.publish(
entity.ID, entity.type, getConverted.cid, getConverted.cmd, getConverted.cmdType,
getConverted.zclData, getConverted.cfg, endpoint, () => {}
);
}, converted.readAfterWriteTime);
}
usedConverters.push(converter);
usedConverters.push(converter);
});
});
return true;
+70 -28
View File
@@ -1,12 +1,7 @@
const settings = require('../util/settings');
const logger = require('../util/logger');
const utils = require('../util/utils');
const dontCacheProperties = [
'action', 'button', 'button_left', 'button_right', 'click', 'forgotten', 'keyerror',
'step_size', 'transition_time', 'action_color_temperature', 'action_color',
'action_group',
];
const debounce = require('debounce');
/**
* This extensions handles messages received from devices.
@@ -19,12 +14,28 @@ class DeviceReceive {
this.publishEntityState = publishEntityState;
this.coordinator = null;
this.elapsed = {};
this.debouncers = {};
}
onZigbeeStarted() {
this.coordinator = this.zigbee.getCoordinator().device.ieeeAddr;
}
publishDebounce(ieeeAddr, payload, time) {
if (!this.debouncers[ieeeAddr]) {
this.debouncers[ieeeAddr] = {
payload: {},
publish: debounce(() => {
this.publishEntityState(ieeeAddr, this.debouncers[ieeeAddr].payload);
this.debouncers[ieeeAddr].payload = {};
}, time * 1000),
};
}
this.debouncers[ieeeAddr].payload = {...this.debouncers[ieeeAddr].payload, ...payload};
this.debouncers[ieeeAddr].publish();
}
onZigbeeMessage(message, device, mappedDevice) {
if (message.type == 'devInterview') {
if (!settings.getDevice(message.data)) {
@@ -51,7 +62,8 @@ class DeviceReceive {
}
// Check if this is a new device.
if (!settings.getDevice(device.ieeeAddr)) {
const settingsDevice = settings.getDevice(device.ieeeAddr);
if (!settingsDevice) {
logger.info(`New device '${device.modelId}' with address ${device.ieeeAddr} connected!`);
settings.addDevice(device.ieeeAddr);
this.mqtt.log('device_connected', device.ieeeAddr, {modelID: device.modelId});
@@ -68,6 +80,25 @@ class DeviceReceive {
return;
}
/**
* Don't handle re-transmitted Xiaomi messages.
* https://github.com/Koenkk/zigbee2mqtt/issues/1238
*
* Some Xiaomi router devices re-transmit messages from Xiaomi end devices.
* The source address of these message is set to the one of the Xiaomi router.
* Therefore it looks like if the message came from the Xiaomi router, while in
* fact it came from the end device.
* Handling these message would result in false state updates.
* The group ID attribute of these message defines the source address of the end device.
* As the same message is also received directly from the end device, it makes no sense
* to handle these messages.
*/
const hasGroupID = message.hasOwnProperty('groupid') && message.groupid != 0;
if (utils.isXiaomiDevice(device) && utils.isRouter(device) && hasGroupID) {
logger.debug('Skipping re-transmitted Xiaomi message');
return;
}
// Find a conveter for this message.
const cid = message.data.cid;
const cmdId = message.data.cmdId;
@@ -75,8 +106,15 @@ class DeviceReceive {
if (cid) {
// readRsp messages have the same structure as attReport messages.
// search for attReport converters on readRsp.
const search = message.type === 'readRsp' ? 'attReport' : message.type;
return c.cid === cid && c.type === search;
if (c.cid === cid) {
if (c.type instanceof Array) {
return c.type.includes(message.type);
} else {
return c.type === message.type;
}
}
return false;
} else if (cmdId) {
return c.cmd === cmdId;
}
@@ -113,14 +151,6 @@ class DeviceReceive {
// - If NO payload is returned do nothing. This is for non-standard behaviour
// for e.g. click switches where we need to count number of clicks and detect long presses.
const publish = (payload) => {
// Don't cache messages with following properties:
let cache = true;
dontCacheProperties.forEach((property) => {
if (payload.hasOwnProperty(property)) {
cache = false;
}
});
// Add device linkquality.
if (message.hasOwnProperty('linkquality')) {
payload.linkquality = message.linkquality;
@@ -128,16 +158,8 @@ class DeviceReceive {
// Add last seen timestamp
const now = Date.now();
switch (settings.get().advanced.last_seen) {
case 'ISO_8601':
payload.last_seen = new Date(now).toISOString();
break;
case 'ISO_8601_local':
payload.last_seen = utils.toLocalISOString(new Date(now));
break;
case 'epoch':
payload.last_seen = now;
break;
if (settings.get().advanced.last_seen !== 'disable') {
payload.last_seen = utils.formatDate(now, settings.get().advanced.last_seen);
}
if (settings.get().advanced.elapsed) {
@@ -148,7 +170,27 @@ class DeviceReceive {
this.elapsed[device.ieeeAddr] = now;
}
this.publishEntityState(device.ieeeAddr, payload, cache);
// Check if we have to debounce
if (settingsDevice && settingsDevice.hasOwnProperty('debounce')) {
this.publishDebounce(device.ieeeAddr, payload, settingsDevice.debounce);
} else {
this.publishEntityState(device.ieeeAddr, payload);
if (settings.get().homeassistant) {
/**
* Publish an empty value for click and action payload, in this way Home Assistant
* can use Home Assistant entities in automations.
* https://github.com/Koenkk/zigbee2mqtt/issues/959#issuecomment-480341347
*/
Object.keys(payload).forEach((key) => {
if (['action', 'click'].includes(key)) {
const counterPayload = {};
counterPayload[key] = '';
this.publishEntityState(device.ieeeAddr, counterPayload);
}
});
}
}
};
let payload = {};
+28 -11
View File
@@ -1,5 +1,7 @@
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
const logger = require('../util/logger');
const CC2530Router = zigbeeShepherdConverters.devices.find((d) => d.model === 'CC2530.ROUTER');
const utils = require('../util/utils');
const candidates = {
'genOnOff': {
@@ -21,7 +23,7 @@ const reportInterval = {
max: 3600,
};
const reportableChange = 0;
const reportableChange = 1;
class DeviceReport {
constructor(zigbee, mqtt, state, publishEntityState) {
@@ -34,6 +36,11 @@ class DeviceReport {
setupReporting(mappedDevice, device) {
let epId = null;
if (mappedDevice === CC2530Router) {
logger.debug('Not setting up reporting for CC2530 router');
return;
}
// Check if this device uses a different epId.
if (mappedDevice.hasOwnProperty('ep')) {
const eps = mappedDevice.ep(device);
@@ -48,12 +55,15 @@ class DeviceReport {
}
logger.debug(`Setting up reporting for ${device.ieeeAddr}`);
Object.values(endpoint.clusters).filter((c) => c).forEach((c) => {
const cluster = c.attrs.cid;
if (candidates[cluster]) {
const candidate = candidates[cluster];
const attributeNames = candidate.attrs.filter((a) => c.attrs.hasOwnProperty(a));
let attributeNames = candidate.attrs.filter((a) => c.attrs.hasOwnProperty(a));
// Sometimes a cluster has no attributes, in this case setup reporting for all attributes.
attributeNames = attributeNames.length ? attributeNames : candidate.attrs;
const attributes = attributeNames.map((attribute) => {
return {
attr: attribute,
@@ -73,14 +83,20 @@ class DeviceReport {
});
}
onZigbeeStarted() {
this.zigbee.getAllClients().forEach((device) => {
const mappedDevice = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
shouldSetupReporting(device) {
return utils.isRouter(device) && !utils.isBatteryPowered(device);
}
if (mappedDevice) {
this.setupReporting(mappedDevice, device);
}
});
onZigbeeStarted() {
this.zigbee.getAllClients()
.filter((d) => this.shouldSetupReporting(d))
.forEach((device) => {
const mappedDevice = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
if (mappedDevice) {
this.setupReporting(mappedDevice, device);
}
});
}
onZigbeeMessage(message, device, mappedDevice) {
@@ -89,7 +105,8 @@ class DeviceReport {
// Ikea TRADFRI tend to forget their reporting after powered off.
// Re-setup reporting.
// https://github.com/Koenkk/zigbee2mqtt/issues/966
if (device && mappedDevice && ['endDeviceAnnce', 'devIncoming'].includes(message.type)) {
if (device && mappedDevice && ['endDeviceAnnce', 'devIncoming'].includes(message.type) &&
this.shouldSetupReporting(device)) {
this.setupReporting(mappedDevice, device);
}
}
+182 -51
View File
@@ -1,7 +1,12 @@
const settings = require('../util/settings');
const logger = require('../util/logger');
const data = require('../util/data');
const utils = require('../util/utils');
const fs = require('fs');
const diff = require('deep-diff');
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/.+/(remove|add|remove_all)$`);
const topicRegexRemoveAll = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/(remove|add|remove_all)$`);
class Groups {
constructor(zigbee, mqtt, state, publishEntityState) {
@@ -9,16 +14,112 @@ class Groups {
this.mqtt = mqtt;
this.state = state;
this.publishEntityState = publishEntityState;
this.onStateChange = this.onStateChange.bind(this);
this.groupsCacheFile = data.joinPathStorage('groups_cache.json');
this.groupsCache = this.readGroupsCache();
}
readGroupsCache() {
return fs.existsSync(this.groupsCacheFile) ? JSON.parse(fs.readFileSync(this.groupsCacheFile, 'utf8')) : {};
}
writeGroupsCache() {
fs.writeFileSync(this.groupsCacheFile, JSON.stringify(this.groupsCache), 'utf8');
}
onMQTTConnected() {
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/group/+/remove`);
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/group/+/add`);
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/group/+/remove_all`);
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/group/remove_all`);
}
apply(from, to) {
const sortGroups = (obj) => Object.keys(obj).forEach((key) => obj[key] = obj[key].sort());
sortGroups(from);
sortGroups(to);
const differences = diff(from, to);
if (differences) {
differences.forEach((diff) => {
const groupID = diff.path[0];
if (diff.kind === 'N') {
diff.rhs.forEach((ID) => this.updateDeviceGroup(ID, 'add', groupID));
} else if (diff.kind === 'A') {
if (diff.item.lhs) {
this.updateDeviceGroup(diff.item.lhs, 'remove', groupID);
} else {
this.updateDeviceGroup(diff.item.rhs, 'add', groupID);
}
} else if (diff.kind === 'D') {
diff.lhs.forEach((ID) => this.updateDeviceGroup(ID, 'remove', groupID));
} else if (diff.kind === 'E') {
this.updateDeviceGroup(diff.rhs, 'add', groupID);
this.updateDeviceGroup(diff.lhs, 'remove', groupID);
}
});
}
}
parseID(ID) {
let entityID = ID;
let endpointID = null;
const postfix = utils.getPostfixes().find((p) => entityID.endsWith(`/${p}`));
if (postfix) {
// Found a postfix, retrieve the endpoint which correspodns to the postfix
entityID = entityID.substring(0, entityID.length - (postfix.length + 1));
const endpoint = utils.getEndpointByEntityID(this.zigbee, entityID, postfix);
if (!endpoint) {
return;
}
endpointID = endpoint.epId;
}
return {endpointID, entityID};
}
getGroupsOfDevice(entityID) {
return Object.keys(settings.getGroups()).filter((groupID) => {
return settings.getGroup(groupID).devices.includes(entityID);
});
}
onStateChange(ieeeAddr, from, to) {
const properties = ['state', 'brightness', 'color_temp', 'color'];
const payload = {};
properties.forEach((prop) => {
if (to.hasOwnProperty(prop) && (!from || from[prop] != to[prop])) {
payload[prop] = to[prop];
}
});
if (Object.keys(payload)) {
const groups = this.getGroupsOfDevice(ieeeAddr);
groups.forEach((groupID) => {
this.publishEntityState(groupID, payload);
});
}
}
onZigbeeStarted() {
this.state.registerOnStateChangeListener(this.onStateChange);
const settingsGroups = {};
Object.keys(settings.getGroups()).forEach((groupID) => {
settingsGroups[groupID] = settings.getGroup(groupID).devices;
});
this.apply(this.groupsCache, settingsGroups);
}
parseTopic(topic) {
if (!topic.match(topicRegex)) {
if (!topic.match(topicRegex) && !topic.match(topicRegexRemoveAll)) {
return null;
}
@@ -31,7 +132,77 @@ class Groups {
// Remove type from topic
topic = topic.replace(`/${type}`, '');
return {friendly_name: topic, type};
return {friendly_name: type === 'remove_all' ? null : topic, type};
}
updateDeviceGroup(ID, cmd, groupID) {
let payload = null;
const orignalCmd = cmd;
if (cmd === 'add') {
payload = {groupid: groupID, groupname: ''};
cmd = 'add';
} else if (cmd === 'remove') {
payload = {groupid: groupID};
cmd = 'remove';
} else if (cmd === 'remove_all') {
payload = {};
cmd = 'removeAll';
}
const {entityID, endpointID} = this.parseID(ID);
const ieeeAddr = settings.resolveEntity(entityID).ID;
const cb = (error, rsp) => {
if (error) {
logger.error(`Failed to ${cmd} ${ieeeAddr} from ${groupID}`);
} else {
logger.info(`Successfully ${cmd} ${ieeeAddr} to ${groupID}`);
// Log to MQTT
this.mqtt.log({
device: settings.getDevice(ieeeAddr).friendly_name,
group: groupID,
action: orignalCmd,
});
// Update group cache
if (cmd === 'add') {
if (!this.groupsCache[groupID]) {
this.groupsCache[groupID] = [];
}
if (!this.groupsCache[groupID].includes(ieeeAddr)) {
this.groupsCache[groupID].push(ieeeAddr);
}
} else if (cmd === 'remove') {
if (this.groupsCache[groupID]) {
this.groupsCache[groupID] = this.groupsCache[groupID].filter((device) => device != ieeeAddr);
}
} else if (cmd === 'removeAll') {
Object.keys(this.groupsCache).forEach((groupID_) => {
this.groupsCache[groupID_] = this.groupsCache[groupID_].filter((device) => device != ieeeAddr);
});
}
this.writeGroupsCache();
// Update settings
if (cmd === 'add') {
settings.addDeviceToGroup(groupID, ieeeAddr);
} else if (cmd === 'remove') {
settings.removeDeviceFromGroup(groupID, ieeeAddr);
} else if (cmd === 'removeAll') {
Object.keys(settings.get().groups).forEach((groupID_) => {
settings.removeDeviceFromGroup(groupID_, ieeeAddr);
});
}
}
};
this.zigbee.publish(
ieeeAddr, 'device', 'genGroups', cmd, 'functional',
payload, null, endpointID, cb,
);
}
onMQTTMessage(topic, message) {
@@ -42,59 +213,19 @@ class Groups {
}
// Find ID of this group.
const groupID = settings.getGroupIDByFriendlyName(topic.friendly_name);
if (!groupID) {
logger.error(`Group with friendly_name '${topic.friendly_name}' doesn't exist`);
return;
}
let groupID = null;
if (topic.type !== 'remove_all') {
groupID = settings.getGroupIDByFriendlyName(topic.friendly_name);
if (!groupID) {
logger.error(`Group with friendly_name '${topic.friendly_name}' doesn't exist`);
return;
}
if (groupID === 99) {
logger.error('Group 99 is reserved, please use a different groupID');
return;
}
// Map message to ieeeAddr and check if device exist.
message = message.toString();
const ieeeAddr = settings.getIeeeAddrByFriendlyName(message) || message;
if (!this.zigbee.getDevice(ieeeAddr)) {
logger.error(`Failed to find device '${message}'`);
return;
groupID = groupID.toString();
}
// Send command to the device.
let payload = null;
let cmd = null;
if (topic.type === 'add') {
payload = {groupid: groupID, groupname: ''};
cmd = 'add';
} else if (topic.type === 'remove') {
payload = {groupid: groupID};
cmd = 'remove';
} else if (topic.type === 'remove_all') {
payload = {};
cmd = 'removeAll';
}
const callback = (error, rsp) => {
if (error) {
logger.error(`Failed to ${topic.type} ${ieeeAddr} from ${topic.friendly_name}`);
} else {
logger.info(`Successfully ${topic.type} ${ieeeAddr} to ${topic.friendly_name}`);
// Log to MQTT
const log = {device: message};
if (['remove', 'add'].includes(topic.type)) {
log.group = topic.friendly_name;
}
this.mqtt.log(log);
}
};
this.zigbee.publish(
ieeeAddr, 'device', 'genGroups', cmd, 'functional',
payload, null, null, callback,
);
this.updateDeviceGroup(message.toString(), topic.type, groupID);
return true;
}
+287 -21
View File
@@ -65,6 +65,16 @@ const configurations = {
device_class: 'gas',
},
},
'binary_sensor_carbon_monoxide': {
type: 'binary_sensor',
object_id: 'carbon_monoxide',
discovery_payload: {
payload_on: true,
payload_off: false,
value_template: '{{ value_json.carbon_monoxide }}',
device_class: 'safety',
},
},
'binary_sensor_router': {
type: 'binary_sensor',
object_id: 'router',
@@ -85,6 +95,26 @@ const configurations = {
device_class: 'battery',
},
},
'binary_sensor_lock': {
type: 'binary_sensor',
object_id: 'lock',
discovery_payload: {
payload_on: 'UNLOCK',
payload_off: 'LOCK',
value_template: '{{ value_json.state}}',
device_class: 'lock',
},
},
'binary_sensor_lock_reverse': {
type: 'binary_sensor',
object_id: 'lock_reverse',
discovery_payload: {
payload_on: 'UNLOCK',
payload_off: 'LOCK',
value_template: '{{ value_json.reverse}}',
device_class: 'lock',
},
},
// Sensor
'sensor_illuminance': {
@@ -129,25 +159,41 @@ const configurations = {
discovery_payload: {
icon: 'mdi:toggle-switch',
value_template: '{{ value_json.click }}',
expire_after: 1,
},
},
'sensor_power': {
type: 'sensor',
object_id: 'power',
discovery_payload: {
unit_of_measurement: 'Watt',
icon: 'mdi:flash',
unit_of_measurement: 'W',
icon: 'mdi:factory',
value_template: '{{ value_json.power }}',
},
},
'sensor_current': {
type: 'sensor',
object_id: 'current',
discovery_payload: {
unit_of_measurement: 'A',
icon: 'mdi:power-plug',
value_template: '{{ value_json.current }}',
},
},
'sensor_voltage': {
type: 'sensor',
object_id: 'voltage',
discovery_payload: {
unit_of_measurement: 'V',
icon: 'mdi:flash',
value_template: '{{ value_json.voltage }}',
},
},
'sensor_action': {
type: 'sensor',
object_id: 'action',
discovery_payload: {
icon: 'mdi:gesture-double-tap',
value_template: '{{ value_json.action }}',
expire_after: 1,
},
},
'sensor_brightness': {
@@ -286,6 +332,42 @@ const configurations = {
value_template: '{{ value_json.state }}',
},
},
// Thermostat/HVAC
'thermostat': {
type: 'climate',
object_id: 'climate',
discovery_payload: {
state_topic: false,
min_temp: 7,
max_temp: 30,
modes: ['off', 'auto', 'heat'],
mode_state_topic: true,
mode_state_template: '{{ value_json.system_mode }}',
mode_command_topic: true,
current_temperature_topic: true,
current_temperature_template: '{{ value_json.local_temperature }}',
temperature_state_topic: true,
temperature_state_template: '{{ value_json.occupied_heating_setpoint }}',
temperature_command_topic: true,
},
},
// Fan
'fan': {
type: 'fan',
object_id: 'fan',
discovery_payload: {
state_topic: true,
state_value_template: '{{ value_json.fan_state }}',
command_topic: true,
command_topic_postfix: 'fan_state',
speed_state_topic: true,
speed_command_topic: true,
speed_value_template: '{{ value_json.fan_mode }}',
speeds: ['off', 'low', 'medium', 'high', 'on', 'auto', 'smart'],
},
},
};
const switchWithPostfix = (postfix) => {
@@ -307,10 +389,11 @@ const mapping = {
'WXKG01LM': [configurations.sensor_click, configurations.sensor_battery],
'WXKG11LM': [configurations.sensor_click, configurations.sensor_battery],
'WXKG12LM': [configurations.sensor_click, configurations.sensor_battery],
'WXKG03LM': [configurations.sensor_click, configurations.sensor_battery],
// BREAKING_IMPROVEMENT: only use sensor_click for WXKG03LM (action hold -> click hold)
'WXKG03LM': [configurations.sensor_click, configurations.sensor_battery, configurations.sensor_action],
'WXKG02LM': [configurations.sensor_click, configurations.sensor_battery],
'QBKG04LM': [configurations.switch],
'QBKG03LM': [switchWithPostfix('left'), switchWithPostfix('right')],
'QBKG04LM': [configurations.switch, configurations.sensor_click, configurations.sensor_action],
'QBKG03LM': [switchWithPostfix('left'), switchWithPostfix('right'), configurations.sensor_click],
'WSDCGQ01LM': [configurations.sensor_temperature, configurations.sensor_humidity, configurations.sensor_battery],
'WSDCGQ11LM': [
configurations.sensor_temperature, configurations.sensor_humidity, configurations.sensor_pressure,
@@ -335,9 +418,11 @@ const mapping = {
'LED1536G5': [configurations.light_brightness_colortemp],
'7299760PH': [configurations.light_brightness_colorxy],
'7146060PH': [configurations.light_brightness_colortemp_colorxy],
'046677476816': [configurations.light_brightness],
'F7C033': [configurations.light_brightness],
'JTYJ-GD-01LM/BW': [configurations.binary_sensor_smoke, configurations.sensor_battery],
'PLUG EDP RE:DY': [configurations.switch, configurations.sensor_power],
'SWITCH EDP RE:DY': [configurations.switch],
'CC2530.ROUTER': [configurations.binary_sensor_router],
'AA70155': [configurations.light_brightness_colortemp],
'4058075816718': [configurations.light_brightness_colortemp_colorxy],
@@ -370,6 +455,7 @@ const mapping = {
'RS 125': [configurations.light_brightness],
'RS 225': [configurations.light_brightness],
'RB 145': [configurations.light_brightness],
'RB 245': [configurations.light_brightness],
'PL 110': [configurations.light_brightness],
'ST 110': [configurations.light_brightness],
'UC 110': [configurations.light_brightness],
@@ -379,8 +465,11 @@ const mapping = {
'SL 110 M': [configurations.light_brightness],
'SL 110 W': [configurations.light_brightness],
'AA68199': [configurations.light_brightness_colortemp],
'QBKG11LM': [configurations.switch, configurations.sensor_power],
'QBKG12LM': [switchWithPostfix('left'), switchWithPostfix('right'), configurations.sensor_power],
'QBKG11LM': [configurations.switch, configurations.sensor_power, configurations.sensor_click],
'QBKG12LM': [
switchWithPostfix('left'), switchWithPostfix('right'), configurations.sensor_power,
configurations.sensor_click,
],
'K2RGBW01': [configurations.light_brightness_colortemp_colorxy],
'9290011370': [configurations.light_brightness],
'DNCKATSW001': [configurations.switch],
@@ -424,6 +513,7 @@ const mapping = {
configurations.binary_sensor_gas,
configurations.sensor_gas_density,
],
'50043': [configurations.switch],
'50045': [configurations.light_brightness],
'AV2010/22': [configurations.binary_sensor_occupancy, configurations.sensor_battery],
'3210-L': [configurations.switch],
@@ -436,7 +526,7 @@ const mapping = {
'3261030P7': [configurations.light_brightness_colortemp],
'3216431P5': [configurations.light_brightness_colortemp],
'DJT11LM': [configurations.sensor_action, configurations.sensor_battery],
'E1603': [configurations.switch],
'E1603/E1702': [configurations.switch],
'7199960PH': [configurations.light_brightness_colorxy],
'74696': [configurations.light_brightness],
'AB35996': [configurations.light_brightness_colortemp_colorxy],
@@ -467,8 +557,9 @@ const mapping = {
'SP 120': [configurations.switch, configurations.sensor_power],
'RB 248 T': [configurations.light_brightness_colortemp],
'HS3SA': [configurations.binary_sensor_smoke, configurations.binary_sensor_battery_low],
'HS1DS': [configurations.binary_sensor_contact],
'HS1WL': [configurations.binary_sensor_water_leak],
'HS1DS/HS3DS': [configurations.binary_sensor_contact],
'HS1WL/HS3WL': [configurations.binary_sensor_water_leak],
'HS1-WL-E': [configurations.binary_sensor_water_leak],
'421786': [configurations.light_brightness],
'ICZB-IW11D': [configurations.light_brightness],
'HGZB-01A': [configurations.light_brightness],
@@ -488,7 +579,7 @@ const mapping = {
'GL-B-008Z': [configurations.light_brightness_colortemp_colorxy],
'AV2010/25': [configurations.switch, configurations.sensor_power],
'E12-N14': [configurations.light_brightness],
'1TST-EU': [],
'1TST-EU': [configurations.thermostat, configurations.sensor_battery],
'RB 178 T': [configurations.light_brightness_colortemp],
'45856GE': [configurations.switch],
'GL-D-003Z': [configurations.light_brightness_colortemp_colorxy],
@@ -500,9 +591,9 @@ const mapping = {
'AV2010/32': [],
'HGZB-07A': [configurations.light_brightness_colortemp_colorxy],
'E1524': [configurations.sensor_action],
'GL-C-006': [configurations.light_brightness_colortemp],
'GL-C-006/GL-C-009': [configurations.light_brightness_colortemp],
'100.424.11': [configurations.light_brightness_colortemp],
'AC0251100NJ': [configurations.sensor_click, configurations.sensor_brightness, configurations.sensor_battery],
'AC0251100NJ': [configurations.sensor_action, configurations.sensor_battery],
'71831': [configurations.light_brightness_colortemp],
'404000/404005/404012': [configurations.light_brightness_colortemp_colorxy],
'404006/404008/404004': [configurations.light_brightness_colortemp],
@@ -536,12 +627,17 @@ const mapping = {
configurations.sensor_illuminance, configurations.sensor_battery,
],
'HGZB-042': [switchWithPostfix('top'), switchWithPostfix('bottom')],
'HGZB-42': [switchWithPostfix('top'), switchWithPostfix('bottom')],
'GL-FL-004TZ': [configurations.light_brightness_colortemp_colorxy],
'IM6001-OTP05': [configurations.switch],
'SV01': [
configurations.cover_position, configurations.sensor_temperature, configurations.sensor_pressure,
configurations.sensor_battery,
],
'SV02': [
configurations.cover_position, configurations.sensor_temperature, configurations.sensor_pressure,
configurations.sensor_battery,
],
'316GLEDRF': [configurations.light_brightness],
'LVS-ZB500D': [configurations.light_brightness],
'ST218': [],
@@ -562,6 +658,14 @@ const mapping = {
configurations.binary_sensor_occupancy, configurations.sensor_temperature,
configurations.sensor_battery, configurations.binary_sensor_battery_low,
],
'NCZ-3041-HA': [
configurations.binary_sensor_occupancy, configurations.sensor_temperature,
configurations.sensor_battery, configurations.binary_sensor_battery_low,
],
'NCZ-3045-HA': [
configurations.binary_sensor_occupancy, configurations.sensor_temperature,
configurations.sensor_battery, configurations.binary_sensor_battery_low,
],
'STS-IRM-250': [
configurations.binary_sensor_occupancy, configurations.sensor_temperature,
configurations.sensor_battery, configurations.binary_sensor_battery_low,
@@ -574,6 +678,128 @@ const mapping = {
configurations.sensor_temperature, configurations.binary_sensor_contact,
configurations.sensor_battery, configurations.binary_sensor_battery_low,
],
'IM6001-BTP01': [configurations.sensor_click, configurations.sensor_temperature],
'AV2010/34': [configurations.sensor_click],
'PP-WHT-US': [
configurations.switch, configurations.sensor_power,
configurations.sensor_current, configurations.sensor_voltage,
],
'CR701-YZ': [
configurations.binary_sensor_battery_low, configurations.binary_sensor_carbon_monoxide,
configurations.binary_sensor_gas,
],
'HGZB-1S': [configurations.switch, configurations.sensor_click],
'HGZB-045': [configurations.switch, configurations.sensor_click],
'HGZB-43': [switchWithPostfix('top'), switchWithPostfix('bottom'), switchWithPostfix('center')],
'HGZB-01A/02A': [configurations.switch],
'MCT-350 SMA': [configurations.binary_sensor_contact],
'3310-S': [configurations.sensor_temperature, configurations.sensor_battery],
'3315-S': [
configurations.sensor_temperature, configurations.binary_sensor_water_leak,
configurations.sensor_battery,
],
'F-MLT-US-2': [
configurations.sensor_temperature, configurations.binary_sensor_contact,
configurations.sensor_battery, configurations.binary_sensor_battery_low,
],
'SWO-KEF1PA': [configurations.sensor_action],
'HGZB-02S': [configurations.sensor_click, configurations.switch],
'HGZB-41': [configurations.switch],
'ZG9101SAC-HP': [configurations.light_brightness],
'RS 122': [configurations.light_brightness],
'GL-B-001Z': [configurations.light_brightness_colortemp_colorxy],
'IM6001-MTP01': [
configurations.sensor_temperature, configurations.sensor_battery,
configurations.binary_sensor_occupancy,
],
'U86K31ND6': [switchWithPostfix('left'), switchWithPostfix('right'), switchWithPostfix('center')],
'HLD812-Z-SC': [configurations.light_brightness],
'HLC610-Z': [configurations.light_brightness],
'BY 285 C': [configurations.light_brightness_colortemp_colorxy],
'HS1RC-M': [configurations.sensor_action, configurations.sensor_battery],
'SWO-WDS1PA': [configurations.binary_sensor_contact],
'LLKZMK11LM': [switchWithPostfix('l1'), switchWithPostfix('l2')],
'LVS-SM10ZW': [configurations.binary_sensor_contact, configurations.binary_sensor_battery_low],
'HS2SK': [configurations.switch, configurations.sensor_power],
'45853GE': [configurations.switch, configurations.sensor_power],
'50064': [configurations.light_brightness_colortemp],
'9290011998B': [configurations.light_brightness_colortemp],
'4096730U7': [configurations.light_brightness_colortemp],
'RB 278 T': [configurations.light_brightness],
'3315-G': [
configurations.sensor_temperature, configurations.sensor_battery, configurations.binary_sensor_water_leak,
],
'N2G-SP': [configurations.sensor_power, configurations.switch],
'AC0363900NJ': [configurations.light_brightness_colortemp_colorxy],
'LXZB-02A': [configurations.light_brightness],
'GL-S-004Z': [configurations.light_brightness_colortemp],
'SCM-5ZBS': [configurations.cover_position],
'YRD226HA2619': [configurations.sensor_battery, configurations.lock],
'YMF40': [configurations.lock],
'V3-BTZB': [configurations.lock],
'3RSS008Z': [configurations.switch, configurations.sensor_battery],
'99432': [configurations.fan, configurations.light_brightness],
'511.10': [configurations.light_brightness],
'IM6001-MPP01': [
configurations.sensor_temperature, configurations.binary_sensor_contact, configurations.sensor_battery,
],
'HLC821-Z-SC': [configurations.light_brightness],
'RS 228 T': [configurations.light_brightness_colortemp],
'67200BL': [configurations.switch],
'2430-100': [configurations.sensor_action],
'100.425.90': [configurations.switch],
'74580': [configurations.light_brightness],
'HS1CA-E': [
configurations.binary_sensor_carbon_monoxide, configurations.binary_sensor_battery_low,
configurations.sensor_battery,
],
'MCT-340 E': [configurations.binary_sensor_contact],
'D1542': [configurations.light_brightness_colortemp],
'ZGRC-KEY-013': [configurations.sensor_click],
'ZigUP': [configurations.switch],
'YRD256HA20BP': [configurations.sensor_battery, configurations.lock],
'SZ-ESW01-AU': [configurations.sensor_power, configurations.switch],
'PSM-29ZBSR': [configurations.switch],
'ZM350STW1TCF': [configurations.light_brightness_colortemp],
'M350STW1': [configurations.light_brightness],
'A806S-Q1R': [configurations.light_brightness],
'XY12S-15': [configurations.light_brightness_colortemp_colorxy],
'B07KG5KF5R': [configurations.light_brightness_colortemp],
'SCM-S1': [configurations.cover_position],
'HEIMAN-M1': [configurations.binary_sensor_contact],
'3216131P5': [configurations.light_brightness_colortemp],
'ST8AU-CON': [configurations.light_brightness],
'HS3MS': [configurations.binary_sensor_occupancy],
'DIYRUZ_R4_5': [
switchWithPostfix('bottom_left'), switchWithPostfix('bottom_right'), switchWithPostfix('center'),
switchWithPostfix('top_left'), switchWithPostfix('top_right'),
],
'NCZ-3011-HA': [
configurations.binary_sensor_occupancy, configurations.sensor_humidity, configurations.sensor_temperature,
],
'MEAZON_BIZY_PLUG': [configurations.sensor_power, configurations.switch, configurations.sensor_temperature],
'MEAZON_DINRAIL': [configurations.sensor_power, configurations.switch, configurations.sensor_temperature],
'HS1CA-M': [configurations.binary_sensor_carbon_monoxide, configurations.binary_sensor_battery_low],
'7099860PH': [configurations.light_brightness_colorxy],
'HV-GSCXZB269': [configurations.light_brightness_colortemp],
'3216231P5': [configurations.light_brightness_colortemp],
'AC03647': [configurations.light_brightness_colortemp_colorxy],
'12031': [configurations.cover_position],
'421792': [configurations.light_brightness_colortemp_colorxy],
'HGZB-06A': [configurations.light_brightness_colortemp_colorxy],
'LED1733G7': [configurations.light_brightness_colortemp],
'9290011370B': [configurations.light_brightness],
'RB 250 C': [configurations.light_brightness_colortemp_colorxy],
'8718696170625': [configurations.light_brightness],
'GL-G-001Z': [configurations.light_brightness_colortemp_colorxy],
'HV-GSCXZB279_HV-GSCXZB229': [configurations.light_brightness_colortemp],
'HS2WD-E': [configurations.sensor_battery],
'ZNMS12LM': [
configurations.sensor_action, configurations.binary_sensor_lock, configurations.binary_sensor_lock_reverse,
],
'12050': [configurations.switch, configurations.sensor_power],
'ROB_200-004-0': [configurations.light_brightness],
'RH3040': [configurations.sensor_battery, configurations.binary_sensor_occupancy],
};
Object.keys(mapping).forEach((key) => {
@@ -601,10 +827,13 @@ class HomeAssistant {
if (settings.get().experimental.output === 'attribute') {
throw new Error('Home Assitant integration is not possible with attribute output!');
}
this.discoveryTopic = settings.get().advanced.homeassistant_discovery_topic;
this.statusTopic = settings.get().advanced.homeassistant_status_topic;
}
onMQTTConnected() {
this.mqtt.subscribe('hass/status');
this.mqtt.subscribe(this.statusTopic);
// MQTT discovery of all paired devices on startup.
this.zigbee.getAllClients().forEach((device) => {
@@ -646,7 +875,7 @@ class HomeAssistant {
// Set json_attributes_topic for types which support this
// https://github.com/Koenkk/zigbee2mqtt/issues/840
if (['binary_sensor', 'sensor', 'lock'].includes(config.type)) {
if (['binary_sensor', 'sensor', 'lock', 'climate', 'fan'].includes(config.type)) {
payload.json_attributes_topic = payload.state_topic;
}
@@ -658,7 +887,7 @@ class HomeAssistant {
// Attributes for device registry
payload.device = {
identifiers: `zigbee2mqtt_${entityID}`,
identifiers: [`zigbee2mqtt_${entityID}`],
name: entity.friendlyName,
sw_version: `Zigbee2mqtt ${this.zigbee2mqttVersion}`,
model: `${mappedModel.description} (${mappedModel.model})`,
@@ -693,18 +922,55 @@ class HomeAssistant {
}
payload.command_topic += 'set';
if (payload.command_topic_postfix) {
payload.command_topic += `/${payload.command_topic_postfix}`;
delete payload.command_topic_postfix;
}
}
if (payload.set_position_topic && payload.command_topic) {
payload.set_position_topic = payload.command_topic;
}
if (payload.mode_state_topic) {
payload.mode_state_topic = stateTopic;
}
if (payload.mode_command_topic) {
payload.mode_command_topic = `${stateTopic}/set/system_mode`;
}
if (payload.current_temperature_topic) {
payload.current_temperature_topic = stateTopic;
}
if (payload.temperature_state_topic) {
payload.temperature_state_topic = stateTopic;
}
if (payload.speed_state_topic) {
payload.speed_state_topic = stateTopic;
}
if (payload.temperature_command_topic) {
payload.temperature_command_topic = `${stateTopic}/set/occupied_heating_setpoint`;
}
if (payload.speed_command_topic) {
payload.speed_command_topic = `${stateTopic}/set/fan_mode`;
}
// Override configuration with user settings.
if (device.hasOwnProperty('homeassistant')) {
const add = (obj) => {
Object.keys(obj).forEach((key) => {
if (['number', 'string'].includes(typeof obj[key])) {
payload[key] = obj[key];
} else if (key === 'device' && typeof obj[key] === 'object') {
Object.keys(obj['device']).forEach((key) => {
payload['device'][key] = obj['device'][key];
});
}
});
};
@@ -716,14 +982,14 @@ class HomeAssistant {
}
}
this.mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 0}, null, 'homeassistant');
this.mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 0}, null, this.discoveryTopic);
});
this.discovered[entityID] = true;
}
onMQTTMessage(topic, message) {
if (!topic === 'hass/status') {
if (!topic === this.statusTopic) {
return false;
}
@@ -732,7 +998,7 @@ class HomeAssistant {
// Publish all device states.
this.zigbee.getAllClients().forEach((device) => {
if (this.state.exists(device.ieeeAddr)) {
this.publishEntityState(device.ieeeAddr, this.state.get(device.ieeeAddr), false);
this.publishEntityState(device.ieeeAddr, this.state.get(device.ieeeAddr));
}
});
+2 -2
View File
@@ -101,7 +101,7 @@ class Livolo {
}
_sendToggle(zdev, ieeeAddr, ep, retry) {
this.zigbee.queue.push((queueCallback) => {
this.zigbee.queue.push(ieeeAddr, (queueCallback) => {
const cfg = {};
logger.debug(`LIVOLO ${ieeeAddr}. Sending the 'toggle' command. Retry: ${retry}`);
ep.functional('genOnOff', 'toggle', [cfg], foundationCfg,
@@ -118,7 +118,7 @@ class Livolo {
}
_sendPoll(zdev, ieeeAddr, ep, retry) {
this.zigbee.queue.push((queueCallback) => {
this.zigbee.queue.push(ieeeAddr, (queueCallback) => {
ep.foundation('genOnOff', 'read', [{
attrId: 0, // onOff
}], this._handleCommandRespWithData.bind({
+64 -19
View File
@@ -1,4 +1,5 @@
const settings = require('../util/settings');
const utils = require('../util/utils');
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
class NetworkMap {
@@ -6,9 +7,15 @@ class NetworkMap {
this.zigbee = zigbee;
this.mqtt = mqtt;
this.state = state;
this.lastSeenMap = {};
// Subscribe to topic.
this.topic = `${settings.get().mqtt.base_topic}/bridge/networkmap`;
this.topicRoutes = `${settings.get().mqtt.base_topic}/bridge/networkmap/routes`;
// Bind
this.raw = this.raw.bind(this);
this.graphviz = this.graphviz.bind(this);
// Set supported formats
this.supportedFormats = {
@@ -17,15 +24,23 @@ class NetworkMap {
};
}
onZigbeeMessage(message, device, mappedDevice) {
if (device) {
this.lastSeenMap[device.ieeeAddr] = Date.now();
}
}
onMQTTConnected() {
this.mqtt.subscribe(this.topic);
this.mqtt.subscribe(this.topicRoutes);
}
onMQTTMessage(topic, message) {
message = message.toString();
if (topic === this.topic && this.supportedFormats.hasOwnProperty(message)) {
this.zigbee.networkScan((result)=> {
const includeRoutes = (topic === this.topicRoutes) ? 1: 0;
if ((topic === this.topic || topic === this.topicRoutes)
&& this.supportedFormats.hasOwnProperty(message)) {
this.zigbee.networkScan(includeRoutes, (result)=> {
const converted = this.supportedFormats[message](this.zigbee, result);
this.mqtt.publish(`bridge/networkmap/${message}`, converted, {});
});
@@ -41,19 +56,26 @@ class NetworkMap {
}
graphviz(zigbee, topology) {
const colors = settings.get().map_options.graphviz.colors;
let text = 'digraph G {\nnode[shape=record];\n';
let devStyle = '';
zigbee.getDevices().forEach((device) => {
topology.nodes.forEach((device) => {
const labels = [];
const friendlyDevice = settings.getDevice(device.ieeeAddr);
const friendlyName = friendlyDevice ? friendlyDevice.friendly_name : device.ieeeAddr;
// Add friendly name
labels.push(friendlyName);
labels.push(`${device.friendlyName}`);
// Add the device type
labels.push(device.type);
// Add the device short network address and scan note (if any)
let scanNote = '';
if (device.scanfailed.includes('lqi')) {
scanNote += ' no lqi';
}
if (device.scanfailed.includes('rtg')) {
scanNote += ' no routes';
}
labels.push(`0x${device.nwkAddr.toString(16)} ${scanNote}`);
// Add the device model
const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
@@ -65,30 +87,53 @@ class NetworkMap {
labels.push(zigbeeModel ? zigbeeModel : 'No model information available');
}
// Add the device status (online/offline)
labels.push(device.status);
// Add the device status (online/offline) and last_seen timestamp
let lastSeen = 'unknown';
if (device.type == 'Coordinator') {
lastSeen = utils.formatDate(Date.now(), settings.get().advanced.last_seen,
utils.toLocalISOString(new Date(Date.now())));
} else {
if (this.lastSeenMap[device.ieeeAddr]) {
const lastSeenAgo =
`${new Date(Date.now() - this.lastSeenMap[device.ieeeAddr]).toISOString().substr(11, 8)}s ago`;
lastSeen = utils.formatDate(this.lastSeenMap[device.ieeeAddr],
settings.get().advanced.last_seen, lastSeenAgo);
}
}
labels.push(`${device.status} (${lastSeen})`);
// Shape the record according to device type
if (device.type == 'Coordinator') {
devStyle = 'style="bold"';
devStyle = `style="bold, filled", fillcolor="${colors.fill.coordinator}", ` +
`fontcolor="${colors.font.coordinator}"`;
} else if (device.type == 'Router') {
devStyle = 'style="rounded"';
devStyle = `style="rounded, filled", fillcolor="${colors.fill.router}", ` +
`fontcolor="${colors.font.router}"`;
} else {
devStyle = 'style="rounded, dashed"';
devStyle = `style="rounded, dashed, filled", fillcolor="${colors.fill.enddevice}", `
+ `fontcolor="${colors.font.enddevice}"`;
}
// Add the device with its labels to the graph as a node.
text += ` "${device.ieeeAddr}" [`+devStyle+`, label="{${labels.join('|')}}"];\n`;
/**
* Add an edge between the device and its parent to the graph
* Add an edge between the device and its child to the graph
* NOTE: There are situations where a device is NOT in the topology, this can be e.g.
* due to not responded to the lqi scan. In that case we do not add an edge for this device.
*/
topology.filter((e) => e.ieeeAddr === device.ieeeAddr).forEach((e) => {
const lineStyle = (e.lqi==0) ? `style="dashed", ` : ``;
text += ` "${device.ieeeAddr}" -> "${e.parent}" [`+lineStyle+`label="${e.lqi}"]\n`;
});
topology.links.filter((e) => (e.sourceIeeeAddr === device.ieeeAddr) || (e.SourceNwkAddr === device.nwkAddr))
.forEach((e) => {
const lineStyle = (device.type=='EndDevice') ? 'penwidth=1, '
: (!e.routes.length) ? 'penwidth=0.5, ' : 'penwidth=2, ';
const lineWeight = (!e.routes.length) ? `weight=0, color="${colors.line.inactive}", `
: `weight=1, color="${colors.line.active}", `;
const textRoutes = e.routes.map((r) => `0x${r.toString(16)}`);
const lineLabels = (!e.routes.length) ? `label="${e.lqi}"`
: `label="${e.lqi}\\n[${textRoutes.join(']\\n[')}]"`;
text += ` "${device.ieeeAddr}" -> "${e.targetIeeeAddr}"`;
text += ` [${lineStyle}${lineWeight}${lineLabels}]\n`;
});
});
text += '}';
+75
View File
@@ -0,0 +1,75 @@
const zclId = require('zigbee-herdsman/dist/zcl-id');
const OneJanuary2000 = new Date('January 01, 2000 00:00:00').getTime();
/**
* This extensions is reponsible for respondig to device requests.
*/
class ExtensionResponder {
constructor(zigbee, mqtt, state, publishEntityState) {
this.zigbee = zigbee;
this.mqtt = mqtt;
this.state = state;
this.publishEntityState = publishEntityState;
this.onZclFoundation = this.onZclFoundation.bind(this);
this.configured = [];
}
setupOnZclFoundation(device) {
if (device && device.epList && !this.configured.includes(device.ieeeAddr)) {
this.configured.push(device.ieeeAddr);
device.epList.forEach((epID) => {
const ep = this.zigbee.getEndpoint(device.ieeeAddr, epID);
ep.onZclFoundation = this.onZclFoundation;
});
}
}
onZigbeeStarted() {
this.zigbee.getAllClients().forEach((device) => this.setupOnZclFoundation(device));
}
onZigbeeMessage(message, device, mappedDevice) {
this.setupOnZclFoundation(device);
}
readResponse(message, endpoint) {
const clusterID = message.clusterid;
const cluster = zclId.cluster(clusterID).key;
const attributes = message.zclMsg.payload.map((p) => zclId.attr(message.clusterid, p.attrId));
const response = [];
attributes.forEach((attribute) => {
if (cluster === 'genTime' && attribute.key === 'time') {
const time = Math.round(((new Date()).getTime() - OneJanuary2000) / 1000);
response.push(this.createReadResponseRec(clusterID, attribute.value, time));
}
});
this.zigbee.publish(
endpoint.device.ieeeAddr, 'device', cluster, 'readRsp', 'foundation', response,
{direction: 1, seqNum: message.zclMsg.seqNum, disDefaultRsp: 1}, endpoint.epId,
);
}
createReadResponseRec(cId, attrId, value) {
return {
attrId: attrId,
status: 0,
attrData: value,
dataType: zclId.attrType(cId, attrId).value,
};
}
onZclFoundation(message, endpoint) {
const cmd = message.zclMsg.cmdId;
if (cmd === 'read') {
this.readResponse(message, endpoint);
}
}
}
module.exports = ExtensionResponder;
+13 -6
View File
@@ -1,5 +1,7 @@
const utils = require('../util/utils');
const interval = utils.secondsToMilliseconds(60);
const logger = require('../util/logger');
const settings = require('../util/settings');
/**
* This extensions handles Xiaomi devices.
@@ -28,7 +30,14 @@ class Xiaomi {
}
});
this.startTimer();
/**
* Device availability already pings Xiaomi devices.
*/
if (!settings.get().advanced.availability_timeout) {
this.startTimer();
} else {
logger.debug('Disabled pinging of Xiaomi devices in Xiaomi extension because availability is enabled');
}
}
startTimer() {
@@ -48,15 +57,13 @@ class Xiaomi {
}
ping(ieeeAddr) {
this.zigbee.ping(ieeeAddr);
this.zigbee.ping(ieeeAddr, 'error', null, 'basic');
}
handleInterval() {
this.zigbee.getAllClients()
.filter((d) => utils.isXiaomiDevice(d)) // Filter Xiaomi devices
.filter((d) => d.type === 'Router') // Filter routers
.filter((d) => d.powerSource && d.powerSource !== 'Battery') // Remove battery powered devices
.forEach((d) => this.ping(d.ieeeAddr)); // Ping devices.
.filter((d) => utils.isXiaomiDevice(d) && utils.isRouter(d) && !utils.isBatteryPowered(d))
.forEach((d) => this.ping(d.ieeeAddr));
}
}
+27
View File
@@ -1,6 +1,7 @@
const mqtt = require('mqtt');
const logger = require('./util/logger');
const settings = require('./util/settings');
const fs = require('fs');
class MQTT {
constructor() {
@@ -21,6 +22,32 @@ class MQTT {
},
};
if (mqttSettings.ca) {
logger.debug(`MQTT SSL/TLS: Path to CA certificate = ${mqttSettings.ca}`);
const ca = fs.readFileSync(mqttSettings.ca);
if (ca) {
options.ca = ca;
} else {
logger.error(`Error loading CA certificate for MQTT SSL/TLS configuration.`);
}
}
if (mqttSettings.key && mqttSettings.cert) {
logger.debug(`MQTT SSL/TLS: Path to client key = ${mqttSettings.key}`);
logger.debug(`MQTT SSL/TLS: Path to client certificate = ${mqttSettings.cert}`);
const key = fs.readFileSync(mqttSettings.key);
const cert = fs.readFileSync(mqttSettings.cert);
if (key && cert) {
options.key = key;
options.cert = cert;
} else {
logger.error(`Error loading key and/or certificate for MQTT SSL/TLS client authentication.`);
}
}
if (mqttSettings.user && mqttSettings.password) {
options.username = mqttSettings.user;
options.password = mqttSettings.password;
+24 -1
View File
@@ -2,14 +2,23 @@ const logger = require('./util/logger');
const data = require('./util/data');
const fs = require('fs');
const settings = require('./util/settings');
const objectAssignDeep = require('object-assign-deep');
const saveInterval = 1000 * 60 * 5; // 5 minutes
const dontCacheProperties = [
'action', 'button', 'button_left', 'button_right', 'click', 'forgotten', 'keyerror',
'step_size', 'transition_time', 'action_color_temperature', 'action_color',
'action_group', 'group_list', 'group_capacity', 'no_occupancy_since',
'step_mode', 'transition_time', 'duration', 'elapsed',
];
class State {
constructor() {
this.state = {};
this.file = data.joinPath('state.json');
this.timer = null;
this.stateChangeListeners = [];
this.handleSettingsChanged = this.handleSettingsChanged.bind(this);
}
@@ -31,6 +40,10 @@ class State {
this.checkLastSeen();
}
registerOnStateChangeListener(listener) {
this.stateChangeListeners.push(listener);
}
checkLastSeen() {
if (settings.get().advanced.last_seen === 'disable') {
Object.values(this.state).forEach((s) => {
@@ -83,7 +96,17 @@ class State {
}
set(ieeeAddr, state) {
this.state[ieeeAddr] = state;
const toState = objectAssignDeep.noMutate(state);
dontCacheProperties.forEach((property) => {
if (toState.hasOwnProperty(property)) {
delete toState[property];
}
});
const fromState = this.state[ieeeAddr];
this.stateChangeListeners.forEach((listener) => listener(ieeeAddr, fromState, toState));
this.state[ieeeAddr] = toState;
}
remove(ieeeAddr) {
+11
View File
@@ -1,4 +1,5 @@
const path = require('path');
const fs = require('fs');
let dataPath = null;
@@ -13,8 +14,18 @@ function load() {
load();
function joinPathStorage(file) {
const storagePath = path.join(dataPath, '.storage');
if (!fs.existsSync(storagePath)) {
fs.mkdirSync(storagePath);
}
return path.join(storagePath, file);
}
module.exports = {
joinPath: (file) => path.join(dataPath, file),
joinPathStorage: (file) => joinPathStorage(file),
getPath: () => dataPath,
// For test only.
+16
View File
@@ -0,0 +1,16 @@
const yaml = require('js-yaml');
const fs = require('fs');
function readYaml(file) {
return yaml.safeLoad(fs.readFileSync(file, 'utf8'));
}
function readYamlIfExists(file) {
return fs.existsSync(file) ? readYaml(file) : null;
}
function writeYaml(file, content) {
fs.writeFileSync(file, yaml.safeDump(content));
}
module.exports = {readYaml, readYamlIfExists, writeYaml};
+38 -13
View File
@@ -16,8 +16,27 @@ const directory = settings.get().advanced.log_directory.replace('%TIMESTAMP%', t
// Make sure that log directoy exsists
fx.mkdirSync(directory);
// Custom level
const levels = {
levels: {
error: 0,
warn: 1,
info: 2,
debug: 3,
trace: 4,
},
colors: {
error: 'red',
warn: 'yellow',
info: 'green',
debug: 'blue',
trace: 'magenta',
},
};
// Create logger
const logger = new winston.Logger({
levels: levels.levels,
transports: [
new winston.transports.File({
filename: path.join(directory, 'log.txt'),
@@ -37,25 +56,31 @@ const logger = new winston.Logger({
],
});
logger.info(`Logging to directory: '${directory}'`);
// Add colors
winston.addColors(levels.colors);
logger.transports.console.level = level;
// Cleanup any old log directory.
if (settings.get().advanced.log_directory.includes('%TIMESTAMP%')) {
const rootDirectory = path.join(directory, '..');
function cleanup() {
if (settings.get().advanced.log_directory.includes('%TIMESTAMP%')) {
const rootDirectory = path.join(directory, '..');
let directories = fs.readdirSync(rootDirectory).map((d) => {
d = path.join(rootDirectory, d);
return {path: d, birth: fs.statSync(d).birthtimeMs};
});
let directories = fs.readdirSync(rootDirectory).map((d) => {
d = path.join(rootDirectory, d);
return {path: d, birth: fs.statSync(d).birthtimeMs};
});
directories.sort((a, b) => b.birth - a.birth);
directories = directories.slice(10, directories.length);
directories.forEach((dir) => {
logger.debug(`Removing old log directory '${dir.path}'`);
rimraf.sync(dir.path);
});
directories.sort((a, b) => b.birth - a.birth);
directories = directories.slice(10, directories.length);
directories.forEach((dir) => {
logger.debug(`Removing old log directory '${dir.path}'`);
rimraf.sync(dir.path);
});
}
}
logger.cleanup = cleanup;
logger.directory = directory;
module.exports = logger;
+162 -22
View File
@@ -1,19 +1,43 @@
const yaml = require('js-yaml');
const fs = require('fs');
const data = require('./data');
const file = data.joinPath('configuration.yaml');
const objectAssignDeep = require(`object-assign-deep`);
const path = require('path');
const fs = require('./fs');
const onChangeHandlers = [];
const defaults = {
whitelist: [],
ban: [],
permit_join: false,
mqtt: {
include_device_information: false,
},
groups: {},
device_options: {},
map_options: {
graphviz: {
colors: {
fill: {
enddevice: '#fff8ce',
coordinator: '#e04e5d',
router: '#4ea3e0',
},
font: {
coordinator: '#ffffff',
router: '#ffffff',
enddevice: '#000000',
},
line: {
active: '#009900',
inactive: '#994444',
},
},
},
},
queue: {
delay: 250,
simultaneously: 5,
},
experimental: {
livolo: false,
// json or attribute
@@ -67,12 +91,32 @@ const defaults = {
* Enables reporting feature
*/
report: false,
/**
* Home Assistant discovery topic
*/
homeassistant_discovery_topic: 'homeassistant',
/**
* Home Assistant status topic
*/
homeassistant_status_topic: 'hass/status',
},
};
let _settings;
const getSettings = () => _settings||(_settings = read());
function getSettings() {
if (!_settings) {
_settings = read();
}
if (_settings.hasOwnProperty('advanced') && !_settings.advanced) {
delete _settings.advanced;
}
return _settings;
}
function writeRead() {
write();
@@ -84,41 +128,33 @@ function write() {
const settings = getSettings();
const toWrite = objectAssignDeep.noMutate(settings);
// Read settings to check if we have to split devices/groups into seperate file.
const actual = readYaml(file);
// Read settings to check if we have to split devices/groups into separate file.
const actual = fs.readYaml(file);
if (typeof actual.devices === 'string') {
writeYaml(data.joinPath(actual.devices), settings.devices);
fs.writeYaml(data.joinPath(actual.devices), settings.devices);
toWrite.devices = actual.devices;
}
if (typeof actual.groups === 'string') {
writeYaml(data.joinPath(actual.groups), settings.groups);
fs.writeYaml(data.joinPath(actual.groups), settings.groups);
toWrite.groups = actual.groups;
}
writeYaml(file, toWrite);
}
function readYaml(file) {
return yaml.safeLoad(fs.readFileSync(file, 'utf8'));
}
function writeYaml(file, content) {
fs.writeFileSync(file, yaml.safeDump(content));
fs.writeYaml(file, toWrite);
}
function read() {
const s = readYaml(file);
const s = fs.readYaml(file);
// Read devices/groups configuration from separate file.
if (typeof s.devices === 'string') {
const file = data.joinPath(s.devices);
s.devices = fs.existsSync(file) ? readYaml(file) : null;
s.devices = fs.readYamlIfExists(file);
}
if (typeof s.groups === 'string') {
const file = data.joinPath(s.groups);
s.groups = fs.existsSync(file) ? readYaml(file) : null;
s.groups = fs.readYamlIfExists(file);
}
return s;
@@ -147,9 +183,17 @@ const getDevices = () => getSettings().devices || [];
const getDevice = (ieeeAddr) => getDevices()[ieeeAddr];
const getGroups = () => getSettings().groups || [];
const getGroups = () => getSettings().groups || {};
const getGroup = (ID) => getGroups()[ID];
const getGroup = (ID) => {
const group = getGroups()[ID];
if (group && !group.hasOwnProperty('devices')) {
group.devices = [];
}
return group;
};
function addDevice(ieeeAddr) {
@@ -162,6 +206,28 @@ function addDevice(ieeeAddr) {
writeRead();
}
function whitelistDevice(ieeeAddr) {
const settings = getSettings();
if (!settings.whitelist) {
settings.whitelist = [];
}
settings.whitelist.push(ieeeAddr);
writeRead();
}
function banDevice(ieeeAddr) {
const settings = getSettings();
if (!settings.ban) {
settings.ban = [];
}
settings.ban.push(ieeeAddr);
writeRead();
}
function removeDevice(ieeeAddr) {
const settings = getSettings();
if (!settings.devices || !settings.devices[ieeeAddr]) return;
@@ -170,6 +236,73 @@ function removeDevice(ieeeAddr) {
writeRead();
}
function addGroup(groupName) {
const settings = getSettings();
if (!settings.groups) {
settings.groups = {};
}
let ID = '1';
while (settings.groups.hasOwnProperty(ID)) {
ID = (Number.parseInt(ID) + 1).toString();
}
if (Object.values(settings.groups).find((g) => g.friendly_name === groupName)) {
return false; // Duplicate group names are not allowed
}
settings.groups[ID] = {friendly_name: groupName};
writeRead();
return true;
}
function addDeviceToGroup(ID, ieeeAddr) {
const settings = getSettings();
const group = settings.groups[ID];
if (!group.devices) {
group.devices = [];
}
if (!group.devices.includes(ieeeAddr)) {
group.devices.push(ieeeAddr);
writeRead();
return true;
} else {
return false;
}
}
function removeDeviceFromGroup(ID, ieeeAddr) {
const settings = getSettings();
const group = settings.groups[ID];
if (group.devices && group.devices.includes(ieeeAddr)) {
group.devices = group.devices.filter((d) => d != ieeeAddr);
writeRead();
return true;
} else {
return false;
}
}
function removeGroup(name) {
const settings = getSettings();
if (!settings.groups) return;
const ID = Object.keys(settings.groups).find((key) => {
return settings.groups[key].friendly_name === name;
});
if (ID) {
delete settings.groups[ID];
writeRead();
return true;
} else {
return false;
}
}
function getIeeeAddrByFriendlyName(friendlyName) {
const entry = Object.entries(getDevices()).find(([ieeeAddr, device]) =>
device.friendly_name === friendlyName
@@ -250,9 +383,16 @@ module.exports = {
getDevice,
getGroup,
getGroups,
getDevices,
whitelistDevice: (ieeeAddr) => whitelistDevice(ieeeAddr),
banDevice: (ieeeAddr) => banDevice(ieeeAddr),
addDevice: (ieeeAddr) => addDevice(ieeeAddr),
removeDevice: (ieeeAddr) => removeDevice(ieeeAddr),
addGroup: (name) => addGroup(name),
removeGroup: (name) => removeGroup(name),
addDeviceToGroup: (ID, ieeeAddr) => addDeviceToGroup(ID, ieeeAddr),
removeDeviceFromGroup: (ID, ieeeAddr) => removeDeviceFromGroup(ID, ieeeAddr),
getIeeeAddrByFriendlyName: (friendlyName) => getIeeeAddrByFriendlyName(friendlyName),
getGroupIDByFriendlyName: (friendlyName) => getGroupIDByFriendlyName(friendlyName),
+119 -1
View File
@@ -1,3 +1,6 @@
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
const logger = require('../util/logger');
// Xiaomi uses 4151 and 4447 (lumi.plug) as manufacturer ID.
const xiaomiManufacturerID = [4151, 4447];
const ikeaTradfriManufacturerID = [4476];
@@ -24,11 +27,126 @@ function toLocalISOString(dDate) {
':' + pad(tzOffset % 60);
}
const postfixes = [
'left', 'right', 'center', 'bottom_left', 'bottom_right', 'l1', 'l2',
'top_left', 'top_right', 'white', 'rgb', 'system', 'top', 'bottom',
];
function flatten(arr) {
return arr.reduce((flat, toFlatten) => {
return flat.concat(Array.isArray(toFlatten) ? flatten(toFlatten) : toFlatten);
}, []);
}
const forceEndDevice = flatten(
['QBKG03LM', 'QBKG04LM']
.map((model) => zigbeeShepherdConverters.devices.find((d) => d.model === model))
.map((mappedModel) => mappedModel.zigbeeModel));
function getEndpointByEntityID(zigbee, entityID, epName) {
const device = zigbee.getDevice(entityID);
if (!device) {
logger.error(`Failed to find device with entity ID '${entityID}'`);
return;
}
const mappedDevice = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
if (!mappedDevice) {
logger.error(`Device with model ID ${device.modelId} is not supported`);
return;
}
let epID = null;
if (epName) {
if (!mappedDevice.ep) {
logger.error(`Device ${mappedDevice.model} doesn't define eps`);
return;
}
epID = mappedDevice.ep(device)[epName];
if (!epID) {
logger.error(`Device ${mappedDevice.model} doesn't have ep named '${epName}'`);
return;
}
} else if (mappedDevice.hasOwnProperty('ep')) {
const eps = mappedDevice.ep(device);
epID = eps[''] || null;
}
const endpoint = zigbee.getEndpoint(entityID, epID);
if (!endpoint) {
logger.error(`Failed to retrieve for entity ID ${entityID} and endpoint ID ${epID}`);
}
return endpoint;
}
function getZigbee2mqttVersion(callback) {
const git = require('git-last-commit');
const packageJSON = require('../../package.json');
const version = packageJSON.version;
git.getLastCommit((err, commit) => {
let commitHash = null;
if (err) {
try {
commitHash = require('../.hash.json').hash;
} catch (error) {
commitHash = 'unknown';
}
} else {
commitHash = commit.shortHash;
}
callback({commitHash, version});
});
}
function formatDate(date, type, _default) {
let result;
switch (type) {
case 'ISO_8601':
result = new Date(date).toISOString();
break;
case 'ISO_8601_local':
result = toLocalISOString(new Date(date));
break;
case 'epoch':
result = date;
break;
default:
result = _default;
break;
}
return result;
}
module.exports = {
millisecondsToSeconds: (milliseconds) => milliseconds / 1000,
secondsToMilliseconds: (seconds) => seconds * 1000,
isXiaomiDevice: (device) => xiaomiManufacturerID.includes(device.manufId),
isXiaomiDevice: (device) => {
return device.modelId !== 'lumi.router' && xiaomiManufacturerID.includes(device.manufId) &&
(!device.manufName || !device.manufName.startsWith('Trust'));
},
isIkeaTradfriDevice: (device) => ikeaTradfriManufacturerID.includes(device.manufId),
isRouter: (device) => device.type === 'Router' && !forceEndDevice.includes(device.modelId),
isBatteryPowered: (device) => device.powerSource && device.powerSource === 'Battery',
isNumeric: (string) => /^\d+$/.test(string),
toLocalISOString: (dDate) => toLocalISOString(dDate),
getPostfixes: () => postfixes,
formatDate: (date, type, _default=null) => formatDate(date, type, _default),
correctDeviceType: (device) => {
if (device) {
if (forceEndDevice.includes(device.modelId)) {
return 'EndDevice';
}
}
return device.type;
},
getEndpointByEntityID,
getZigbee2mqttVersion,
};
+16 -3
View File
@@ -1,6 +1,9 @@
const maxSimultaneouslyRunning = 5;
const delay = 250;
const settings = require('./settings');
const maxSimultaneouslyRunning = settings.get().queue.simultaneously;
const delay = settings.get().queue.delay;
const error17Retries = 3;
const logger = require('./logger');
class ZigbeeQueue {
constructor() {
@@ -8,6 +11,7 @@ class ZigbeeQueue {
this.active = [];
this.timer = null;
this.running = false;
this.ID = 0;
}
start() {
@@ -16,7 +20,13 @@ class ZigbeeQueue {
}
push(entityID, func) {
this.queue.push({entityID, func, attempts: 0});
this.ID++;
this.queue.push({entityID, func, attempts: 0, ID: this.ID});
this.log(`Added new job with ID ${this.ID} for ${entityID}`);
}
log(message) {
logger.trace(`zigbeeQueue: ${message}`);
}
stopTimer() {
@@ -40,6 +50,8 @@ class ZigbeeQueue {
}
handleJobComplete(job, error) {
this.log(`Completed job with ID ${job.ID} for ${job.entityID}${error ? ` with error ${error}` : ''}`);
if (error && error.message === 'rsp error: 17' && job.attempts < error17Retries) {
// Error 17 means that the buffer of the ZNP was full,
// retry this for a maximum of 3 times.
@@ -60,6 +72,7 @@ class ZigbeeQueue {
if (next) {
this.active.push(next);
this.log(`Executing job with ID ${next.ID} for ${next.entityID}`);
next.func((error) => this.handleJobComplete(next, error));
}
}
+6 -40
View File
@@ -3,49 +3,13 @@
* Based on: https://github.com/zigbeer/zapp-cie
*/
const Ziee = require('ziee');
const Zive = require('zive');
const Ziee = require('zigbee-herdsman/dist/ziee');
const Zive = require('zigbee-herdsman/dist/zive');
const cieClusters = new Ziee();
// Direction-Value 1: input, 2: output, 3: input/output
cieClusters.init('genBasic', 'dir', {value: 1}); // Server Side(Input)
cieClusters.init('ssIasAce', 'dir', {value: 1}); // Server Side(Input)
// IasZone Cluster-Init Direction
cieClusters.init('ssIasZone', 'dir', {value: 2}); // Client Side(Output)
cieClusters.init('ssIasWd', 'dir', {value: 2}); // Client Side(Output)
cieClusters.init('genIdentify', 'dir', {value: 3}); // Server and Client Side(Input/Output)
// Init Attributes Access Control
cieClusters.init('genBasic', 'acls', {
zclVersion: 'R',
hwVersion: 'R',
manufacturerName: 'R',
modelId: 'R',
dateCode: 'R',
powerSource: 'R',
locationDesc: 'RW',
physicalEnv: 'RW',
deviceEnabled: 'RW',
});
cieClusters.init('genIdentify', 'acls', {
identifyTime: 'RW',
});
// Init Attributes Value
cieClusters.init('genBasic', 'attrs', {
zclVersion: 1,
hwVersion: 1,
manufacturerName: 'sivann inc.',
modelId: 'hiver0001',
dateCode: '20170407',
powerSource: 1,
locationDesc: ' ',
physicalEnv: 0,
deviceEnabled: 1,
});
cieClusters.init('genIdentify', 'attrs', {
identifyTime: 0,
});
// Init Command Response Handler
cieClusters.init('ssIasZone', 'cmdRsps', {
@@ -53,5 +17,7 @@ cieClusters.init('ssIasZone', 'cmdRsps', {
statusChangeNotification: (zapp, argObj, cb) => {},
});
// Create Zive with profId: 0x0104 (Decimal: 260 / HA), devId: 0x0400 (Decimal: 1024 / iasControlIndicatingEquipment)
// Check the Values by https://github.com/zigbeer/zcl-id/wiki#5-table-of-identifiers
const cieApp = new Zive({profId: 0x0104, devId: 0x0400, discCmds: []}, cieClusters);
module.exports = cieApp;
+338 -45
View File
@@ -1,4 +1,4 @@
const ZShepherd = require('zigbee-shepherd');
const ZShepherd = require('zigbee-herdsman');
const logger = require('./util/logger');
const settings = require('./util/settings');
const data = require('./util/data');
@@ -6,7 +6,7 @@ const utils = require('./util/utils');
const ZigbeeQueue = require('./util/zigbeeQueue');
const cieApp = require('./zapp/cie');
const objectAssignDeep = require('object-assign-deep');
const zclId = require('zcl-id');
const zclId = require('zigbee-herdsman/dist/zcl-id');
const advancedSettings = settings.get().advanced;
@@ -22,6 +22,7 @@ const shepherdSettings = {
precfgkey: settings.get().advanced.network_key,
},
dbPath: data.joinPath('database.db'),
coordBackupPath: data.joinPath('coordinator_backup.json'),
sp: {
baudRate: advancedSettings.baudrate,
rtscts: advancedSettings.rtscts,
@@ -36,7 +37,6 @@ const defaultCfg = {
// Don't print network key.
const shepherdSettingsLog = objectAssignDeep.noMutate(shepherdSettings);
shepherdSettingsLog.net.precfgkey = 'HIDDEN';
logger.debug(`Using zigbee-shepherd with settings: '${JSON.stringify(shepherdSettingsLog)}'`);
class Zigbee {
constructor() {
@@ -44,37 +44,40 @@ class Zigbee {
this.onMessage = this.onMessage.bind(this);
this.onError = this.onError.bind(this);
this.messageHandler = null;
this.permitJoinTimer = null;
this.queue = new ZigbeeQueue();
}
start(messageHandler, callback) {
logger.info(`Starting zigbee-shepherd`);
logger.debug(`Using zigbee-shepherd with settings: '${JSON.stringify(shepherdSettingsLog)}'`);
this.messageHandler = messageHandler;
this.shepherd = new ZShepherd(settings.get().serial.port, shepherdSettings);
this.shepherd.start((error) => {
if (error) {
logger.info('Error while starting zigbee-shepherd, attempting to fix... (takes 60 seconds)');
logger.info(`Error while starting zigbee-shepherd, attempting to fix... (takes 60 seconds) (${error})`);
this.shepherd.controller._znp.close((() => null));
setTimeout(() => {
logger.info(`Starting zigbee-shepherd`);
this.shepherd.start((error) => {
if (error) {
logger.error('Error while starting zigbee-shepherd!');
logger.error(`Error while starting zigbee-shepherd! (${error})`);
logger.error(
'Press the reset button on the stick (the one closest to the USB) and start again'
);
callback(error);
} else {
this.logStartupInfo();
this._handleStarted();
callback(null);
}
});
}, utils.secondsToMilliseconds(60));
} else {
this.logStartupInfo();
this._handleStarted();
callback(null);
}
});
@@ -83,24 +86,82 @@ class Zigbee {
this.shepherd.on('ready', this.onReady);
this.shepherd.on('ind', this.onMessage);
this.shepherd.on('error', this.onError);
this._acceptDevIncoming = this._acceptDevIncoming.bind(this);
this.shepherd.acceptDevIncoming = this._acceptDevIncoming;
}
_handleStarted() {
this.logStartupInfo();
this.getAllClients().forEach((device) => {
// If set whitelist devices, all other device will be ban or reject to join the network
if (settings.get().whitelist.size>0) {
if (!settings.get().whitelist.includes(device.ieeeAddr)) {
logger.warn(`Blacklist device is connected (${device.ieeeAddr}), removing...`);
this.removeDevice(device.ieeeAddr, true, () => {});
}
} else {
if (settings.get().ban.includes(device.ieeeAddr)) {
logger.warn(`Banned device is connected (${device.ieeeAddr}), removing...`);
this.removeDevice(device.ieeeAddr, false, () => {});
}
}
});
this.shepherd.backupCoordinator(() => {});
}
_acceptDevIncoming(devInfo, callback) {
logger.debug(
`Accept device incoming with ieeeAddr '${devInfo.ieeeAddr}' permit join is '${this.getPermitJoin()}'`
);
// If set whitelist devices, all other device will be ban or reject to join the network
if (settings.get().whitelist.size>0) {
if (settings.get().whitelist.includes(devInfo.ieeeAddr)) {
logger.info(`whitelist device tried to connect (${devInfo.ieeeAddr})`);
callback(null, true);
} else {
logger.debug(`Not allowing device '${devInfo.ieeeAddr}' to join`);
callback(null, false);
}
} else {
if (settings.get().ban.includes(devInfo.ieeeAddr)) {
logger.info(`Banned device tried to connect (${devInfo.ieeeAddr})`);
callback(null, false);
} else {
logger.debug(`Allowing device '${devInfo.ieeeAddr}' to join`);
callback(null, true);
}
}
}
logStartupInfo() {
logger.info('zigbee-shepherd started');
logger.info(`Coordinator firmware version: '${this.shepherd.info().firmware.revision}'`);
logger.info(`Coordinator firmware version: '${this.getFirmwareVersion()}'`);
logger.debug(`zigbee-shepherd info: ${JSON.stringify(this.shepherd.info())}`);
}
getFirmwareVersion() {
return this.shepherd.info().firmware.revision;
}
softReset(callback) {
this.shepherd.reset('soft', callback);
}
stop(callback) {
if (this.permitJoinTimer) {
clearInterval(this.permitJoinTimer);
}
this.queue.stop();
this.shepherd.stop((error) => {
logger.info('zigbee-shepherd stopped');
callback(error);
// Backup coordinator
this.shepherd.backupCoordinator(() => {
this.shepherd.stop((error) => {
logger.info('zigbee-shepherd stopped');
callback(error);
});
});
}
@@ -139,6 +200,24 @@ class Zigbee {
logger.info('Zigbee: disabling joining new devices.');
}
// In zigbee 3.0 a network automatically closes after 254 seconds.
// As a workaround, we enable joining again.
if (this.permitJoinTimer) {
clearInterval(this.permitJoinTimer);
}
if (permit) {
this.permitJoinTimer = setInterval(() => {
this.shepherd.permitJoin(255, (error) => {
if (error) {
logger.error('Failed to reenable joining');
} else {
logger.info('Successfully reenabled joining');
}
});
}, utils.secondsToMilliseconds(160));
}
this.shepherd.permitJoin(permit ? 255 : 0, (error) => {
if (error) {
logger.info(error);
@@ -159,11 +238,18 @@ class Zigbee {
}
removeDevice(deviceID, ban, callback) {
this.shepherd.remove(deviceID, {reJoin: !ban}, (error) => {
if (ban) {
settings.banDevice(deviceID);
}
const friendlyName = this.getDeviceFriendlyName(deviceID);
this.shepherd.remove(deviceID, {reJoin: true}, (error) => {
if (error) {
logger.warn(`Failed to remove '${deviceID}', trying force remove...`);
logger.warn(`Failed to remove '${friendlyName}', trying force remove...`);
this.forceRemove(deviceID, callback);
} else {
logger.info(`Removed ${friendlyName}`);
callback(null);
}
});
@@ -173,7 +259,11 @@ class Zigbee {
const device = this.shepherd._findDevByAddr(deviceID);
if (device) {
return this.shepherd._unregisterDev(device, (error) => callback(error));
const friendlyName = this.getDeviceFriendlyName(deviceID);
return this.shepherd._unregisterDev(device, (error) => {
logger.info(`Force removed ${friendlyName}`);
callback(error);
});
} else {
logger.warn(`Could not find ${deviceID} for force removal`);
callback(true);
@@ -194,21 +284,180 @@ class Zigbee {
return this.getDevices().find((d) => d.ieeeAddr === ieeeAddr);
}
getDeviceFriendlyName(ieeeAddr) {
const device = settings.getDevice(ieeeAddr);
return device ? device.friendly_name || ieeeAddr : ieeeAddr;
}
getCoordinator() {
const device = this.getDevices().find((d) => d.type === 'Coordinator');
return this.shepherd.find(device.ieeeAddr, 1);
}
getScanable() {
return this.getDevices().filter((d) => d.type != 'EndDevice');
}
getGroup(ID) {
return this.shepherd.getGroup(ID);
}
networkScan(callback) {
logger.info('Starting network scan...');
this.shepherd.lqiScan().then((result) => {
logger.info('Network scan completed');
callback(result);
getGroupFriendlyName(ID) {
let friendlyName = null;
friendlyName = settings.getGroup(ID).friendly_name;
return (friendlyName ? friendlyName : ID);
}
networkScan(includeRoutes, callback) {
logger.info(`Starting network scan includeRoutes='${includeRoutes}'...`);
const lqiScanList = new Set();
const rtgScanList = new Set();
const linkMap = [];
const routeMap = [];
// Gather the lqi and the route info into separate lists and only collate them when done.
const collateMap = () => {
linkMap.sort();
logger.debug(`Link map: %j`, linkMap);
logger.debug(`Route map: %j`, routeMap);
// Merge the routes into the linkMap by matching on 'source|target' link short addresses
linkMap.forEach((link) => {
routeMap.filter((e) => e.key === link.key).forEach((e) => {
link.routes.push(e.destAddr);
});
link.routes.sort(function(a, b) {
return a-b;
});
delete link.key;
});
const networkMap = {nodes: [], links: linkMap};
this.getDevices().forEach((device) => {
const friendlyDevice = settings.getDevice(device.ieeeAddr);
const friendlyName = friendlyDevice ? friendlyDevice.friendly_name : device.ieeeAddr;
const deviceType = utils.correctDeviceType(device);
const scanfailed = [];
if (lqiScanList.has(device.ieeeAddr)) {
scanfailed.push('lqi');
}
if (rtgScanList.has(device.ieeeAddr)) {
scanfailed.push('rtg');
}
networkMap.nodes.push({ieeeAddr: device.ieeeAddr, friendlyName: friendlyName, type: deviceType,
nwkAddr: device.nwkAddr, manufName: device.manufName, modelId: device.modelId,
status: device.status, scanfailed: scanfailed});
});
// Clear remaining devices so they don't process when/if they eventually complete
lqiScanList.clear();
rtgScanList.clear();
logger.debug(`Merged map: %j`, networkMap);
callback(networkMap);
};
const processLqiResponse = (error, rsp, targetIeeeAddr, targetNwkAddr) => {
if (error) {
logger.warn(`Failed network lqi scan for device: '${targetIeeeAddr}' with error: '${error}'`);
} else {
if (lqiScanList.has(targetIeeeAddr)) {
// Haven't processed this one yet
if (rsp && rsp.status === 0 && rsp.neighborlqilist) {
logger.debug(`lqi scan: '${targetIeeeAddr}' with '${rsp.neighborlqilistcount}' neighbors`);
rsp.neighborlqilist.forEach(function(neighbor) {
// only include active relationships
if (neighbor.relationship <= 3) {
// lqi is measured at receiver so link is from neighbor (source) to scanned router
const key = neighbor.nwkAddr + '|' + targetNwkAddr;
linkMap.push({
key: key, sourceIeeeAddr: neighbor.extAddr, targetIeeeAddr: targetIeeeAddr,
sourceNwkAddr: neighbor.nwkAddr, lqi: neighbor.lqi, depth: neighbor.depth,
relationship: neighbor.relationship, routes: []});
}
});
// Remove from scan list and if both lists are done return the completed network map
lqiScanList.delete(targetIeeeAddr);
if (lqiScanList.size === 0 && rtgScanList.size === 0) {
logger.info('Network scan completed');
collateMap();
} else {
logger.debug(`Outstanding network lqi scans for devices: '${[...lqiScanList].join(' ')}'`);
logger.debug(`Outstanding network rtg scans for devices: '${[...rtgScanList].join(' ')}'`);
}
} else {
logger.warn(`Empty network lqi scan result for: '${targetIeeeAddr}'`);
}
} else {
// This target has already had timeout so don't add to result network map
logger.warn(`Ignoring late network lqi scan result for: '${targetIeeeAddr}'`);
}
}
};
const processRtgResponse = (error, rsp, sourceIeeeAddr, sourceNwkAddr) => {
if (error) {
logger.warn(`Failed network rtg scan for device: '${sourceIeeeAddr}' with error: '${error}'`);
} else {
if (rtgScanList.has(sourceIeeeAddr)) {
// Haven't processed this one yet
if (rsp && rsp.status === 0 && rsp.routingtablelist) {
logger.debug(`rtg scan: '${sourceIeeeAddr}' with '${rsp.routingtablelistcount}' entries`);
rsp.routingtablelist.forEach(function(route) {
if (route.routeStatus === 0) {
const key = sourceNwkAddr + '|' + route.nextHopNwkAddr;
routeMap.push({
key: key, destAddr: route.destNwkAddr});
}
});
// Remove from scan list and if both lists are done return the completed network map
rtgScanList.delete(sourceIeeeAddr);
if (lqiScanList.size === 0 && rtgScanList.size === 0) {
logger.info('Network scan completed');
collateMap();
} else {
logger.debug(`Outstanding network lqi scans for devices: '${[...lqiScanList].join(' ')}'`);
logger.debug(`Outstanding network rtg scans for devices: '${[...rtgScanList].join(' ')}'`);
}
} else {
logger.warn(`Empty network rtg scan result for: '${sourceIeeeAddr}'`);
}
} else {
// This source has already had timeout so don't add to result network map
logger.warn(`Ignoring late network rtg scan result for: '${sourceIeeeAddr}'`);
}
}
};
// Queue up an lqi scan and an rtg scan for coordinator and each router
this.getScanable().forEach((dev) => {
logger.debug(`Queing network scans for device: '${dev.ieeeAddr}'`);
lqiScanList.add(dev.ieeeAddr);
this.queue.push(dev.ieeeAddr, (queueCallback) => {
this.shepherd.controller.request('ZDO', 'mgmtLqiReq', {dstaddr: dev.nwkAddr, startindex: 0},
(error, rsp, ieeeAddr, nwkAddr) => {
processLqiResponse(error, rsp, dev.ieeeAddr, dev.nwkAddr);
queueCallback(error);
});
});
if (includeRoutes) {
rtgScanList.add(dev.ieeeAddr);
this.queue.push(dev.ieeeAddr, (queueCallback) => {
this.shepherd.controller.request('ZDO', 'mgmtRtgReq', {dstaddr: dev.nwkAddr, startindex: 0},
(error, rsp, ieeeAddr, nwkAddr) => {
processRtgResponse(error, rsp, dev.ieeeAddr, dev.nwkAddr);
queueCallback(error);
});
});
}
});
// Wait for all device scans before forcing map with whatever results are already in
setTimeout(() => {
if (lqiScanList.size === 0 && rtgScanList.size === 0) {
logger.info('Network scan timeout no outstanding requests');
} else {
logger.warn(`Network scan timeout, skipping outstanding lqi scans for '${[...lqiScanList].join(' ')}'`);
logger.warn(`Network scan timeout, skipping outstanding rtg scans for '${[...rtgScanList].join(' ')}'`);
collateMap();
}
}, lqiScanList.size * 1000);
}
getEndpoint(ieeeAddr, ep) {
@@ -227,10 +476,13 @@ class Zigbee {
publish(entityID, entityType, cid, cmd, cmdType, zclData, cfg=defaultCfg, ep, callback) {
let entity = null;
let friendlyName = null;
if (entityType === 'device') {
entity = this.getEndpoint(entityID, ep);
friendlyName = this.getDeviceFriendlyName(entityID);
} else if (entityType === 'group') {
entity = this.getGroup(entityID);
friendlyName = this.getGroupFriendlyName(entityID);
}
if (!entity) {
@@ -242,14 +494,15 @@ class Zigbee {
this.queue.push(entityID, (queueCallback) => {
logger.info(
`Zigbee publish to ${entityType} '${entityID}', ${cid} - ${cmd} - ` +
`${JSON.stringify(zclData)} - ${JSON.stringify(cfg)} - ${ep}`
`Zigbee publish to ${entityType} '${friendlyName}', ${cid} - ${cmd} - ` +
`${JSON.stringify(zclData)} - ${JSON.stringify(cfg)} - ${entity.epId}`
);
const callback_ = (error, rsp) => {
if (error) {
logger.error(
`Zigbee publish to ${entityType} '${entityID}', ${cid} - ${cmd} - ${JSON.stringify(zclData)} ` +
`Zigbee publish to ${entityType} '${friendlyName}', ${cid} ` +
`- ${cmd} - ${JSON.stringify(zclData)} ` +
`- ${JSON.stringify(cfg)} - ${ep} ` +
`failed with error ${error}`);
}
@@ -271,41 +524,80 @@ class Zigbee {
});
}
ping(ieeeAddr, errorLogLevel='error', cb) {
const device = this.shepherd._findDevByAddr(ieeeAddr);
ping(ieeeAddr, errorLogLevel='error', cb, mechanism='default') {
const friendlyName = this.getDeviceFriendlyName(ieeeAddr);
const callback = (error) => {
if (error) {
logger[errorLogLevel](`Failed to ping '${friendlyName}'`);
} else {
logger.debug(`Successfully pinged '${friendlyName}'`);
}
if (device) {
this.queue.push(ieeeAddr, (queueCallback) => {
logger.debug(`Ping ${ieeeAddr}`);
this.shepherd.controller.checkOnline(device, (error) => {
if (error) {
logger[errorLogLevel](`Failed to ping ${ieeeAddr}`);
} else {
logger.debug(`Successfully pinged ${ieeeAddr}`);
}
if (cb) {
cb(error);
}
};
if (cb) {
cb(error);
}
queueCallback(error);
if (mechanism === 'default') {
const device = this.shepherd._findDevByAddr(ieeeAddr);
if (device) {
logger.debug(`Ping ${ieeeAddr} (default)`);
this.queue.push(ieeeAddr, (queueCallback) => {
this.shepherd.controller.checkOnline(device, (error) => {
callback(error);
queueCallback(error);
});
});
});
}
} else if (mechanism === 'basic') {
const endpoint = this.getEndpoint(ieeeAddr, null);
if (endpoint) {
logger.debug(`Ping ${ieeeAddr} (basic)`);
this.queue.push(ieeeAddr, (queueCallback) => {
endpoint.foundation('genBasic', 'read', [{attrId: 0}], (error) => {
callback(error);
queueCallback(error);
});
});
}
}
}
bind(ep, cluster, target=this.getCoordinator()) {
const log = `for ${ep.device.ieeeAddr} - ${cluster}`;
bind(ep, cluster, target, callback) {
const friendlyName = this.getDeviceFriendlyName(ep.device.ieeeAddr);
const log = ` '${friendlyName}' - ${cluster}`;
target = !target ? this.getCoordinator() : target;
this.queue.push(ep.device.ieeeAddr, (queueCallback) => {
logger.debug(`Setup binding ${log}`);
logger.debug(`Binding ${log}`);
ep.bind(cluster, target, (error) => {
if (error) {
logger.error(`Failed to setup binding ${log} - (${error})`);
logger.error(`Failed to bind ${log} - (${error})`);
} else {
logger.debug(`Successfully setup binding ${log}`);
logger.debug(`Successfully bound ${log}`);
}
callback(error);
queueCallback(error);
});
});
}
unbind(ep, cluster, target, callback) {
const friendlyName = this.getDeviceFriendlyName(ep.device.ieeeAddr);
const log = ` '${friendlyName}' - ${cluster}`;
target = !target ? this.getCoordinator() : target;
this.queue.push(ep.device.ieeeAddr, (queueCallback) => {
logger.debug(`Unbinding ${log}`);
ep.unbind(cluster, target, (error) => {
if (error) {
logger.error(`Failed to unbind ${log} - (${error})`);
} else {
logger.debug(`Successfully unbound ${log}`);
}
callback(error);
queueCallback(error);
});
});
@@ -321,6 +613,7 @@ class Zigbee {
* change the minimum amount of change before sending a report
*/
report(ep, cluster, attributes) {
const friendlyName = this.getDeviceFriendlyName(ep.device.ieeeAddr);
const cfgArr = attributes.map((attribute) => {
const attrId = zclId.attr(cluster, attribute.attr).value;
const dataType = zclId.attrType(cluster, attribute.attr).value;
@@ -334,7 +627,7 @@ class Zigbee {
};
});
const log=`for ${ep.device.ieeeAddr} - ${cluster} - ${attributes.length}`;
const log=`for '${friendlyName}' - ${cluster} - ${attributes.length}`;
const configReport = () => {
this.queue.push(ep.device.ieeeAddr, (queueCallback) => {
+9511 -3126
View File
File diff suppressed because it is too large Load Diff
+10 -16
View File
@@ -1,6 +1,6 @@
{
"name": "zigbee2mqtt",
"version": "1.2.1",
"version": "1.5.1",
"description": "Zigbee to MQTT bridge using zigbee-shepherd",
"main": "index.js",
"repository": {
@@ -20,11 +20,10 @@
"cc2531"
],
"scripts": {
"coverage": "nyc --all report --reporter=text mocha --recursive --timeout=3000",
"docgen": "node docgen/docgen.js",
"coverage": "jest --collect-coverage",
"eslint": "node_modules/.bin/eslint .",
"start": "node index.js",
"test": "mocha --recursive"
"test": "jest"
},
"author": "Koen Kanters",
"license": "GPL-3.0",
@@ -33,6 +32,7 @@
},
"homepage": "https://koenkk.github.io/zigbee2mqtt",
"dependencies": {
"debounce": "*",
"git-last-commit": "*",
"js-yaml": "*",
"mkdir-recursive": "*",
@@ -43,21 +43,15 @@
"rimraf": "*",
"semver": "*",
"winston": "2.4.2",
"zcl-id": "git+https://github.com/Koenkk/zcl-id.git#77ba3058c6c4b09a9958761ae5e3046765c12872",
"ziee": "*",
"zigbee-shepherd": "git+https://github.com/Koenkk/zigbee-shepherd.git#f1f3701c20a125c939504e46c82ac4dda692ff41",
"zigbee-shepherd-converters": "7.2.7",
"zive": "*"
"zigbee-herdsman": "0.1.17",
"zigbee-shepherd-converters": "10.1.4",
"deep-diff": "*"
},
"devDependencies": {
"chai": "*",
"chai-spies": "*",
"@types/jest": "^24.0.11",
"eslint": "*",
"eslint-config-google": "*",
"eslint-plugin-mocha": "*",
"mocha": "*",
"nyc": "*",
"sinon": "*",
"tmp": "*"
"eslint-plugin-jest": "^22.3.0",
"jest": "^24.3.1"
}
}
+10
View File
@@ -0,0 +1,10 @@
const fs = require('fs');
const filename = process.argv[2];
const text = fs.readFileSync(filename, 'utf8');
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
for (const line of text.split('\n')) {
const model = zigbeeShepherdConverters.devices.find((d) => d.model === line);
console.log(`- \`${line}\` ${model.vendor} ${model.description}`);
}
+174
View File
@@ -0,0 +1,174 @@
const BridgeConfig = require('../lib/extension/bridgeConfig');
const settings = require('../lib/util/settings');
const fs = require('../lib/util/fs');
const objectAssignDeep = require('object-assign-deep');
const data = require('../lib/util/data');
const configurationFile = data.joinPath('configuration.yaml');
const utils = require('./utils');
const mqtt = {
subscribe: (topic) => {},
log: (type, message) => {},
};
describe('BridgeConfig', () => {
let bridgeConfig;
const write = (file, json) => fs.writeYaml(file, json);
const read = (file) => fs.readYaml(file);
const files = new Map();
beforeAll(() => {
utils.stubLogger(jest);
jest.spyOn(fs, 'readYaml').mockImplementation((file) => {
if (files.has(file)) return objectAssignDeep.noMutate(files.get(file));
throw new Error(`Fake file not found: ${file}`);
});
jest.spyOn(fs, 'readYamlIfExists').mockImplementation((file) => {
if (files.has(file)) return objectAssignDeep.noMutate(files.get(file));
return null;
});
jest.spyOn(fs, 'writeYaml').mockImplementation((file, content) => {
files.set(file, objectAssignDeep.noMutate(content));
});
});
beforeEach(() => {
settings._clear();
files.clear();
bridgeConfig = new BridgeConfig(null, mqtt, null, null);
});
afterAll(() => {
fs.readYaml.mockRestore();
fs.readYamlIfExists.mockRestore();
fs.writeYaml.mockRestore();
});
it('Setting elapsed false', async () => {
write(configurationFile, {advanced: {elapsed: true}});
bridgeConfig.onMQTTMessage('zigbee2mqtt/bridge/config/elapsed', 'false');
const expected = {
advanced: {
elapsed: false,
},
};
expect(read(configurationFile)).toStrictEqual(expected);
});
it('Add groups when there are none', async () => {
write(configurationFile, {});
bridgeConfig.onMQTTMessage('zigbee2mqtt/bridge/config/add_group', 'test123');
const expected = {
groups: {
'1': {
friendly_name: 'test123',
},
},
};
expect(read(configurationFile)).toStrictEqual(expected);
});
it('Add groups when there are', async () => {
write(configurationFile, {
groups: {
'1': {
friendly_name: 'test123',
},
},
});
bridgeConfig.onMQTTMessage('zigbee2mqtt/bridge/config/add_group', 'test1234');
const expected = {
groups: {
'1': {
friendly_name: 'test123',
},
'2': {
friendly_name: 'test1234',
},
},
};
expect(read(configurationFile)).toStrictEqual(expected);
});
it('Remove group', async () => {
write(configurationFile, {
groups: {
'1': {
friendly_name: 'test123',
},
'2': {
friendly_name: 'test1234',
},
},
});
bridgeConfig.onMQTTMessage('zigbee2mqtt/bridge/config/remove_group', 'test123');
const expected = {
groups: {
'2': {
friendly_name: 'test1234',
},
},
};
expect(read(configurationFile)).toStrictEqual(expected);
});
it('Get groups', async () => {
jest.spyOn(mqtt, 'log').mockImplementation((type, message) => {
expect(type).toBe('groups');
expect(Object.keys(message)).toHaveLength(2);
expect(message['1'].friendly_name).toBe('test1');
expect(message['4711'].friendly_name).toBe('test42');
});
write(configurationFile, {
groups: {
'1': {
friendly_name: 'test1',
},
'4711': {
friendly_name: 'test42',
},
},
});
bridgeConfig.onMQTTMessage('zigbee2mqtt/bridge/config/groups', 'whatever');
});
it('Rename device with é', async () => {
jest.spyOn(mqtt, 'log').mockImplementation((type, message) => {
expect(type).toBe('device_renamed');
});
write(configurationFile, {
devices: {
'0x12345678': {
friendly_name: 'test123',
retain: false,
},
},
});
const payload = {
old: 'test123',
new: 'tést123',
};
bridgeConfig.onMQTTMessage('zigbee2mqtt/bridge/config/rename', JSON.stringify(payload));
const expected = {
devices: {
'0x12345678': {
friendly_name: 'tést123',
retain: false,
},
},
};
expect(read(configurationFile)).toStrictEqual(expected);
});
});
+132 -54
View File
@@ -1,5 +1,3 @@
const assert = require('chai').assert;
const sinon = require('sinon');
const Controller = require('../lib/controller');
const settings = require('../lib/util/settings');
const mqtt = require('../lib/mqtt');
@@ -10,24 +8,25 @@ describe('Controller', () => {
let mqttPublish;
beforeEach(() => {
utils.stubLogger(sinon);
sinon.stub(settings, 'getDevice').callsFake((ieeeAddr) => {
return {friendly_name: 'test'};
});
mqttPublish = sinon.stub(mqtt.prototype, 'publish').callsFake(() => {});
utils.stubLogger(jest);
jest.spyOn(settings, 'getDevice').mockReturnValue({friendly_name: 'test'});
mqttPublish = jest.spyOn(mqtt.prototype, 'publish').mockReturnValue(undefined);
controller = new Controller();
controller.zigbee = {
getDevice: () => {
return {
modelId: 'TRADFRI bulb E27 CWS opal 600lm',
manufName: 'IKEA',
hwVersion: '1.1',
swBuildId: '2.0',
dateCode: '2018-01-01',
};
},
};
});
afterEach(() => {
sinon.restore();
jest.restoreAllMocks();
});
describe('Handling zigbee messages', () => {
@@ -35,71 +34,150 @@ describe('Controller', () => {
const device = {ieeeAddr: '0x12345678', modelId: 'TRADFRI bulb E27 CWS opal 600lm'};
const message = utils.zigbeeMessage(device, 'genOnOff', 'devChange', {onOff: 1}, 1);
controller.onZigbeeMessage(message);
assert.isTrue(mqttPublish.calledOnce);
assert.strictEqual(
mqttPublish.getCall(0).args[1],
JSON.stringify({state: 'ON'})
);
expect(mqttPublish).toHaveBeenCalledTimes(1);
expect(mqttPublish.mock.calls[0][1]).toBe(JSON.stringify({state: 'ON'}));
});
it('Should handle a zigbee message when include_device_information is set', () => {
sinon.stub(settings, 'get').callsFake(() => {
return {
mqtt: {
include_device_information: true,
},
advanced: {
cache_state: false,
},
experimental: {
output: 'json',
},
};
jest.spyOn(settings, 'get').mockReturnValue({
mqtt: {
include_device_information: true,
},
advanced: {
cache_state: false,
last_seen: 'disable',
},
experimental: {
output: 'json',
},
});
const payload = {
'state': 'ON',
'device': {
'ieeeAddr': '0x12345678',
'friendlyName': 'test',
'manufName': 'IKEA',
'modelId': 'TRADFRI bulb E27 CWS opal 600lm',
'hwVersion': '1.1',
'swBuildId': '2.0',
'dateCode': '2018-01-01',
},
};
const device = {ieeeAddr: '0x12345678', modelId: 'TRADFRI bulb E27 CWS opal 600lm'};
const message = utils.zigbeeMessage(device, 'genOnOff', 'devChange', {onOff: 1}, 1);
controller.onZigbeeMessage(message);
assert.isTrue(mqttPublish.calledOnce);
assert.strictEqual(
mqttPublish.getCall(0).args[1],
`{"state":"ON","device":{"ieeeAddr":"0x12345678","friendlyName":"test",` +
`"manufName":"IKEA","modelId":"TRADFRI bulb E27 CWS opal 600lm"}}`
);
expect(mqttPublish).toHaveBeenCalledTimes(1);
expect(JSON.parse(mqttPublish.mock.calls[0][1])).toStrictEqual(payload);
});
it('Should output to json by default', () => {
const payload = {temperature: 1, humidity: 2};
controller.publishEntityState('0x12345678', payload);
assert.isTrue(mqttPublish.calledOnce);
assert.deepEqual(
JSON.parse(mqttPublish.getCall(0).args[1]),
payload
);
expect(mqttPublish).toHaveBeenCalledTimes(1);
expect(JSON.parse(mqttPublish.mock.calls[0][1])).toStrictEqual(payload);
});
it('Should output to attribute', () => {
sinon.stub(settings, 'get').callsFake(() => {
return {
mqtt: {
include_device_information: false,
},
advanced: {
cache_state: false,
},
experimental: {
output: 'attribute',
},
};
jest.spyOn(settings, 'get').mockReturnValue({
mqtt: {
include_device_information: false,
},
advanced: {
cache_state: false,
},
experimental: {
output: 'attribute',
},
});
const payload = {temperature: 1, humidity: 2};
const payload = {
temperature: 1,
humidity: 2,
state: 'ON',
allowedStates: ['ON', 'OFF'],
color: {r: 100, g: 0, b: 102, a: 0},
nested: {
state: 'OFF',
color: {r: 1, g: 0, b: 2},
},
};
controller.publishEntityState('0x12345678', payload);
assert.isTrue(mqttPublish.calledTwice);
assert.deepEqual(mqttPublish.getCall(0).args[0], 'test/temperature');
assert.deepEqual(mqttPublish.getCall(0).args[1], '1');
assert.deepEqual(mqttPublish.getCall(1).args[0], 'test/humidity');
assert.deepEqual(mqttPublish.getCall(1).args[1], '2');
expect(mqttPublish.mock.calls.map((x) => [x[0], x[1]])).toEqual([
['test/temperature', '1'],
['test/humidity', '2'],
['test/state', 'ON'],
['test/allowedStates', 'ON,OFF'],
['test/color', '100,0,102'],
['test/nested-state', 'OFF'],
['test/nested-color', '1,0,2'],
]);
});
it('Should cache state', () => {
jest.spyOn(settings, 'get').mockReturnValue({
mqtt: {
include_device_information: false,
},
advanced: {
cache_state: true,
},
experimental: {
output: 'json',
},
});
controller.publishEntityState('0x12345678', {temperature: 1});
controller.publishEntityState('0x12345678', {humidity: 2});
controller.publishEntityState('0x12345678', {humidity: 5, temperature: 4});
controller.publishEntityState('0x12345678', {temperature: 9});
controller.publishEntityState('0x12345678', {temperature: 3});
expect(mqttPublish).toHaveBeenCalledTimes(5);
expect(JSON.parse(mqttPublish.mock.calls[0][1])).toStrictEqual({temperature: 1});
expect(JSON.parse(mqttPublish.mock.calls[1][1])).toStrictEqual({temperature: 1, humidity: 2});
expect(JSON.parse(mqttPublish.mock.calls[2][1])).toStrictEqual({temperature: 4, humidity: 5});
expect(JSON.parse(mqttPublish.mock.calls[3][1])).toStrictEqual({temperature: 9, humidity: 5});
expect(JSON.parse(mqttPublish.mock.calls[4][1])).toStrictEqual({temperature: 3, humidity: 5});
});
it('Shouldnt cache state', () => {
jest.spyOn(settings, 'get').mockReturnValue({
mqtt: {
include_device_information: false,
},
advanced: {
cache_state: false,
},
experimental: {
output: 'json',
},
});
controller.publishEntityState('0x12345678', {temperature: 1});
controller.publishEntityState('0x12345678', {humidity: 2});
controller.publishEntityState('0x12345678', {temperature: 3});
expect(mqttPublish).toHaveBeenCalledTimes(3);
expect(JSON.parse(mqttPublish.mock.calls[0][1])).toStrictEqual({temperature: 1});
expect(JSON.parse(mqttPublish.mock.calls[1][1])).toStrictEqual({humidity: 2});
expect(JSON.parse(mqttPublish.mock.calls[2][1])).toStrictEqual({temperature: 3});
});
it('Should not send empty messages', () => {
jest.spyOn(settings, 'get').mockReturnValue({
mqtt: {
include_device_information: false,
},
advanced: {
cache_state: true,
},
experimental: {
output: 'json',
},
});
controller.publishEntityState('0x12345678', {});
expect(mqttPublish).toHaveBeenCalledTimes(0);
});
});
});
+7 -3
View File
@@ -1,13 +1,17 @@
const assert = require('chai').assert;
const data = require('../lib/util/data.js');
const path = require('path');
const utils = require('./utils');
describe('Data', () => {
describe('Get path', () => {
beforeEach(() => {
utils.stubLogger(jest);
});
it('Should return correct path', () => {
const expected = path.normalize(path.join(__dirname, '..', 'data'));
const actual = data.getPath();
assert.strictEqual(actual, expected);
expect(actual).toBe(expected);
});
it('Should return correct path when ZIGBEE2MQTT_DATA set', () => {
@@ -15,7 +19,7 @@ describe('Data', () => {
process.env.ZIGBEE2MQTT_DATA = expected;
data._reload();
const actual = data.getPath();
assert.strictEqual(actual, expected);
expect(actual).toBe(expected);
delete process.env.ZIGBEE2MQTT_DATA;
data._reload();
});
+5 -7
View File
@@ -1,5 +1,3 @@
const assert = require('chai').assert;
const sinon = require('sinon');
const Availability = require('../lib/extension/deviceAvailability');
const utils = require('./utils');
@@ -7,12 +5,12 @@ describe('Availability', () => {
let availability;
beforeEach(() => {
utils.stubLogger(sinon);
utils.stubLogger(jest);
availability = new Availability(null, null, null, () => {});
});
afterEach(() => {
sinon.restore();
jest.restoreAllMocks();
});
describe('Determine pingable devices', () => {
@@ -22,7 +20,7 @@ describe('Availability', () => {
type: 'Router',
};
assert.isTrue(availability.isPingable(device));
expect(availability.isPingable(device)).toBe(true);
});
it('Battery device should not be a pingable device', () => {
@@ -31,7 +29,7 @@ describe('Availability', () => {
type: 'EndDevice',
};
assert.isFalse(availability.isPingable(device));
expect(availability.isPingable(device)).toBe(false);
});
it('E11-G13 should be a pingable device', () => {
@@ -42,7 +40,7 @@ describe('Availability', () => {
manufId: 4448,
};
assert.isTrue(availability.isPingable(device));
expect(availability.isPingable(device)).toBe(true);
});
});
});
+196
View File
@@ -0,0 +1,196 @@
const DeviceBind = require('../lib/extension/deviceBind');
const utils = require('./utils');
const settings = require('../lib/util/settings');
const mqtt = {
subscribe: () => {},
log: () => {},
};
const devices = {
bulb: {
getSimpleDesc: () => {
return {inClusterList: [6, 8]};
},
},
remote: {
getSimpleDesc: () => {
return {outClusterList: [5, 6, 8]};
},
},
switch_ep2: {
getSimpleDesc: () => {
return {outClusterList: [5, 6]};
},
},
switch_ep3: {
getSimpleDesc: () => {
return {inClusterList: [5, 6]};
},
},
occupancy_sensor: {
getSimpleDesc: () => {
return {outClusterList: [5, 6]};
},
},
};
const zigbee = {
bind: jest.fn((ep, cluster, target, callback) => {
callback(false, null);
}),
unbind: jest.fn((ep, cluster, target, callback) => {
callback(false, null);
}),
getEndpoint: (ID, ep) => {
if (ID === 'bulb' || ID === '0x002') {
return devices.bulb;
} else if (ID === 'remote' || ID === '0x001') {
return devices.remote;
} else if (ep == 2 && ID === 'switch_ep2') {
return devices.switch_ep2;
} else if (ep == 3 && ID === 'switch_ep3') {
return devices.switch_ep3;
} else if (ep == 2 && ID == 'occupancy_sensor') {
return devices.occupancy_sensor;
}
throw new Error(`No mock for ${ID} and ep ${ep}`);
},
getDevice: (ID) => {
const lookup = {
'occupancy_sensor': 'SML002',
'switch_ep2': 'lumi.sensor_86sw2.es1',
'switch_ep3': 'DNCKAT_S003',
'bulb': 'TRADFRI bulb E27 WS opal 980lm',
'remote': 'TRADFRI remote control',
'0x002': 'TRADFRI bulb E27 WS opal 980lm',
'0x001': 'TRADFRI remote control',
};
if (lookup.hasOwnProperty(ID)) {
return {modelId: lookup[ID]};
}
throw new Error(`No mock for ${ID}`);
},
};
describe('DeviceBind', () => {
let deviceBind;
beforeEach(() => {
utils.stubLogger(jest);
deviceBind = new DeviceBind(zigbee, mqtt, null, null);
});
afterEach(() => {
zigbee.bind.mockClear();
zigbee.unbind.mockClear();
jest.restoreAllMocks();
});
describe('Bind devices', () => {
it('Bind', async () => {
deviceBind.onMQTTMessage('zigbee2mqtt/bridge/bind/remote', 'bulb');
expect(zigbee.bind).toHaveBeenCalledTimes(2);
expect(zigbee.bind).toHaveBeenNthCalledWith(1,
devices.remote,
6,
devices.bulb,
expect.any(Function)
);
expect(zigbee.bind).toHaveBeenNthCalledWith(2,
devices.remote,
8,
devices.bulb,
expect.any(Function)
);
});
it('Bind by friendly name', async () => {
jest.spyOn(settings, 'getIeeeAddrByFriendlyName').mockImplementation((friendlyName) => {
const lookup = {
remote: '0x001',
bulb: '0x002',
};
return lookup[friendlyName];
});
deviceBind.onMQTTMessage('zigbee2mqtt/bridge/bind/remote', 'bulb');
expect(zigbee.bind).toHaveBeenCalledTimes(2);
expect(zigbee.bind).toHaveBeenNthCalledWith(1,
devices.remote,
6,
devices.bulb,
expect.any(Function)
);
expect(zigbee.bind).toHaveBeenNthCalledWith(2,
devices.remote,
8,
devices.bulb,
expect.any(Function)
);
});
it('Bind non default ep', async () => {
deviceBind.onMQTTMessage('zigbee2mqtt/bridge/bind/switch_ep2/right', 'bulb');
expect(zigbee.bind).toHaveBeenCalledTimes(1);
expect(zigbee.bind).toHaveBeenNthCalledWith(1,
devices.switch_ep2,
6,
devices.bulb,
expect.any(Function)
);
});
it('Bind non default ep to target with non default ep', async () => {
deviceBind.onMQTTMessage('zigbee2mqtt/bridge/bind/switch_ep2/right', 'switch_ep3/right');
expect(zigbee.bind).toHaveBeenCalledTimes(2);
expect(zigbee.bind).toHaveBeenNthCalledWith(1,
devices.switch_ep2,
5,
devices.switch_ep3,
expect.any(Function)
);
expect(zigbee.bind).toHaveBeenNthCalledWith(2,
devices.switch_ep2,
6,
devices.switch_ep3,
expect.any(Function)
);
});
it('Bind default ep when mapped', async () => {
deviceBind.onMQTTMessage('zigbee2mqtt/bridge/bind/occupancy_sensor', 'bulb');
expect(zigbee.bind).toHaveBeenCalledTimes(1);
expect(zigbee.bind).toHaveBeenNthCalledWith(1,
devices.occupancy_sensor,
6,
devices.bulb,
expect.any(Function)
);
});
});
describe('Unbind devices', () => {
it('Unbind', async () => {
deviceBind.onMQTTMessage('zigbee2mqtt/bridge/unbind/remote', 'bulb');
expect(zigbee.unbind).toHaveBeenCalledTimes(2);
expect(zigbee.unbind).toHaveBeenNthCalledWith(1,
devices.remote,
6,
devices.bulb,
expect.any(Function)
);
expect(zigbee.unbind).toHaveBeenNthCalledWith(2,
devices.remote,
8,
devices.bulb,
expect.any(Function)
);
});
});
});
+1091 -481
View File
File diff suppressed because it is too large Load Diff
+125 -90
View File
@@ -1,15 +1,16 @@
const assert = require('chai').assert;
const sinon = require('sinon');
const DeviceReceive = require('../lib/extension/deviceReceive');
const settings = require('../lib/util/settings');
const devices = require('zigbee-shepherd-converters').devices;
const utils = require('./utils');
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Devices
const WXKG11LM = devices.find((d) => d.model === 'WXKG11LM');
const WXKG02LM = devices.find((d) => d.model === 'WXKG02LM');
const WSDCGQ11LM = devices.find((d) => d.model === 'WSDCGQ11LM');
const RTCGQ11LM = devices.find((d) => d.model === 'RTCGQ11LM');
const ZNCZ02LM = devices.find((d) => d.model === 'ZNCZ02LM');
const mqtt = {
log: () => {},
@@ -20,14 +21,14 @@ describe('DeviceReceive', () => {
let publishEntityState;
beforeEach(() => {
utils.stubLogger(sinon);
sinon.stub(settings, 'addDevice').callsFake(() => {});
publishEntityState = sinon.spy();
utils.stubLogger(jest);
jest.spyOn(settings, 'addDevice').mockReturnValue(undefined);
publishEntityState = jest.fn();
deviceReceive = new DeviceReceive(null, mqtt, null, publishEntityState);
});
afterEach(() => {
sinon.restore();
jest.restoreAllMocks();
});
describe('Handling zigbee messages', () => {
@@ -35,24 +36,34 @@ describe('DeviceReceive', () => {
const device = {ieeeAddr: '0x12345678'};
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1}, 1);
deviceReceive.onZigbeeMessage(message, device, WXKG11LM);
assert.isTrue(publishEntityState.calledOnce);
assert.deepEqual(publishEntityState.getCall(0).args[1], {click: 'single'});
expect(publishEntityState).toHaveBeenCalledTimes(1);
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({click: 'single'});
});
it('Should handle a zigbee message and counter it when Home Assistant integration is enabled', () => {
jest.spyOn(settings, 'get').mockReturnValue({homeassistant: true, advanced: {last_seen: 'disable'}});
const device = {ieeeAddr: '0x12345678'};
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1}, 1);
deviceReceive.onZigbeeMessage(message, device, WXKG11LM);
expect(publishEntityState).toHaveBeenCalledTimes(2);
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({click: 'single'});
expect(publishEntityState.mock.calls[1][1]).toStrictEqual({click: ''});
});
it('Should handle a zigbee message which uses ep (left)', () => {
const device = {ieeeAddr: '0x12345678'};
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1}, 1);
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
assert.isTrue(publishEntityState.calledOnce);
assert.deepEqual(publishEntityState.getCall(0).args[1], {click: 'left'});
expect(publishEntityState).toHaveBeenCalledTimes(1);
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({click: 'left'});
});
it('Should handle a zigbee message which uses ep (right)', () => {
const device = {ieeeAddr: '0x12345678'};
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1}, 2);
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
assert.isTrue(publishEntityState.calledOnce);
assert.deepEqual(publishEntityState.getCall(0).args[1], {click: 'right'});
expect(publishEntityState).toHaveBeenCalledTimes(1);
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({click: 'right'});
});
it('Should handle a zigbee message with default precision', () => {
@@ -61,118 +72,133 @@ describe('DeviceReceive', () => {
device, 'msTemperatureMeasurement', 'attReport', {measuredValue: -85}, 1
);
deviceReceive.onZigbeeMessage(message, device, WSDCGQ11LM);
assert.isTrue(publishEntityState.calledOnce);
assert.deepEqual(publishEntityState.getCall(0).args[1], {temperature: -0.85});
expect(publishEntityState).toHaveBeenCalledTimes(1);
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({temperature: -0.85});
});
it('Should debounce messages', async () => {
const device = {ieeeAddr: '0x12345678'};
jest.spyOn(settings, 'getDevice').mockReturnValue({debounce: 0.1});
const message1 = utils.zigbeeMessage(
device, 'msTemperatureMeasurement', 'attReport', {measuredValue: 8}, 1
);
const message2 = utils.zigbeeMessage(
device, 'msRelativeHumidity', 'attReport', {measuredValue: 1}, 1
);
const message3 = utils.zigbeeMessage(
device, 'msPressureMeasurement', 'attReport', {measuredValue: 2}, 1
);
deviceReceive.onZigbeeMessage(message1, device, WSDCGQ11LM);
deviceReceive.onZigbeeMessage(message2, device, WSDCGQ11LM);
deviceReceive.onZigbeeMessage(message3, device, WSDCGQ11LM);
await wait(200);
expect(publishEntityState).toHaveBeenCalledTimes(1);
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
deviceReceive.onZigbeeMessage(message1, device, WSDCGQ11LM);
await wait(200);
expect(publishEntityState).toHaveBeenCalledTimes(2);
expect(publishEntityState.mock.calls[1][1]).toStrictEqual({temperature: 0.08});
});
it('Should handle a zigbee message with 1 precision', () => {
const device = {ieeeAddr: '0x12345678'};
sinon.stub(settings, 'getDevice').callsFake(() => {
return {temperature_precision: 1};
});
jest.spyOn(settings, 'getDevice').mockReturnValue({temperature_precision: 1});
const message = utils.zigbeeMessage(
device, 'msTemperatureMeasurement', 'attReport', {measuredValue: -85}, 1
);
deviceReceive.onZigbeeMessage(message, device, WSDCGQ11LM);
assert.isTrue(publishEntityState.calledOnce);
assert.deepEqual(publishEntityState.getCall(0).args[1], {temperature: -0.8});
expect(publishEntityState).toHaveBeenCalledTimes(1);
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({temperature: -0.8});
});
it('Should handle a zigbee message with 0 precision', () => {
const device = {ieeeAddr: '0x12345678'};
sinon.stub(settings, 'getDevice').callsFake(() => {
return {temperature_precision: 0};
});
jest.spyOn(settings, 'getDevice').mockReturnValue({temperature_precision: 0});
const message = utils.zigbeeMessage(
device, 'msTemperatureMeasurement', 'attReport', {measuredValue: -85}, 1
);
deviceReceive.onZigbeeMessage(message, device, WSDCGQ11LM);
assert.isTrue(publishEntityState.calledOnce);
assert.deepEqual(publishEntityState.getCall(0).args[1], {temperature: -1});
expect(publishEntityState).toHaveBeenCalledTimes(1);
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({temperature: -1});
});
it('Should handle a zigbee message with 1 precision when set via device_options', () => {
const device = {ieeeAddr: '0x12345678'};
sinon.stub(settings, 'get').callsFake(() => {
return {
device_options: {
temperature_precision: 1,
},
advanced: {
last_seen: 'disable',
},
};
});
sinon.stub(settings, 'getDevice').callsFake(() => {
return {};
jest.spyOn(settings, 'get').mockReturnValue({
device_options: {
temperature_precision: 1,
},
advanced: {
last_seen: 'disable',
},
});
jest.spyOn(settings, 'getDevice').mockReturnValue({});
const message = utils.zigbeeMessage(
device, 'msTemperatureMeasurement', 'attReport', {measuredValue: -85}, 1
);
deviceReceive.onZigbeeMessage(message, device, WSDCGQ11LM);
assert.isTrue(publishEntityState.calledOnce);
assert.deepEqual(publishEntityState.getCall(0).args[1], {temperature: -0.8});
});
expect(publishEntityState).toHaveBeenCalledTimes(1);
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({temperature: -0.8});
}
);
it('Should handle a zigbee message with 2 precision when overrides device_options', () => {
const device = {ieeeAddr: '0x12345678'};
sinon.stub(settings, 'get').callsFake(() => {
return {
device_options: {
temperature_precision: 1,
},
advanced: {
last_seen: 'disable',
},
};
jest.spyOn(settings, 'get').mockReturnValue({
device_options: {
temperature_precision: 1,
},
advanced: {
last_seen: 'disable',
},
});
sinon.stub(settings, 'getDevice').callsFake(() => {
return {
temperature_precision: 2,
};
jest.spyOn(settings, 'getDevice').mockReturnValue({
temperature_precision: 2,
});
const message = utils.zigbeeMessage(
device, 'msTemperatureMeasurement', 'attReport', {measuredValue: -85}, 1
);
deviceReceive.onZigbeeMessage(message, device, WSDCGQ11LM);
assert.isTrue(publishEntityState.calledOnce);
assert.deepEqual(publishEntityState.getCall(0).args[1], {temperature: -0.85});
});
expect(publishEntityState).toHaveBeenCalledTimes(1);
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({temperature: -0.85});
}
);
it('Should handle a zigbee message with voltage 3010', () => {
const device = {ieeeAddr: '0x12345678'};
const message = utils.zigbeeMessage(device, 'genBasic', 'attReport', {'65281': {'1': 3010}}, 1);
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
assert.isTrue(publishEntityState.calledOnce);
expect(publishEntityState).toHaveBeenCalledTimes(1);
const expected = {battery: 100, voltage: 3010};
assert.deepEqual(publishEntityState.getCall(0).args[1], expected);
expect(publishEntityState.mock.calls[0][1]).toStrictEqual(expected);
});
it('Should handle a zigbee message with voltage 2850', () => {
const device = {ieeeAddr: '0x12345678'};
const message = utils.zigbeeMessage(device, 'genBasic', 'attReport', {'65281': {'1': 2850}}, 1);
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
assert.isTrue(publishEntityState.calledOnce);
expect(publishEntityState).toHaveBeenCalledTimes(1);
const expected = {battery: 35, voltage: 2850};
assert.deepEqual(publishEntityState.getCall(0).args[1], expected);
expect(publishEntityState.mock.calls[0][1]).toStrictEqual(expected);
});
it('Should handle a zigbee message with voltage 2650', () => {
const device = {ieeeAddr: '0x12345678'};
const message = utils.zigbeeMessage(device, 'genBasic', 'attReport', {'65281': {'1': 2650}}, 1);
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
assert.isTrue(publishEntityState.calledOnce);
expect(publishEntityState).toHaveBeenCalledTimes(1);
const expected = {battery: 14, voltage: 2650};
assert.deepEqual(publishEntityState.getCall(0).args[1], expected);
expect(publishEntityState.mock.calls[0][1]).toStrictEqual(expected);
});
it('Should handle a zigbee message with voltage 2000', () => {
const device = {ieeeAddr: '0x12345678'};
const message = utils.zigbeeMessage(device, 'genBasic', 'attReport', {'65281': {'1': 2000}}, 1);
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
assert.isTrue(publishEntityState.calledOnce);
expect(publishEntityState).toHaveBeenCalledTimes(1);
const expected = {battery: 0, voltage: 2000};
assert.deepEqual(publishEntityState.getCall(0).args[1], expected);
expect(publishEntityState.mock.calls[0][1]).toStrictEqual(expected);
});
it('Should publish 1 message when converted twice', () => {
@@ -182,9 +208,9 @@ describe('DeviceReceive', () => {
};
const message = utils.zigbeeMessage(device, 'genBasic', 'attReport', payload, 1);
deviceReceive.onZigbeeMessage(message, device, RTCGQ11LM);
assert.isTrue(publishEntityState.calledOnce);
expect(publishEntityState).toHaveBeenCalledTimes(1);
const expected = {'battery': 100, 'illuminance': 381, 'voltage': 3045};
assert.deepEqual(publishEntityState.getCall(0).args[1], expected);
expect(publishEntityState.mock.calls[0][1]).toStrictEqual(expected);
});
it('Should publish no message when converted without result', () => {
@@ -192,52 +218,61 @@ describe('DeviceReceive', () => {
const payload = {'9999': {'1': 3045}};
const message = utils.zigbeeMessage(device, 'genBasic', 'attReport', payload, 1);
deviceReceive.onZigbeeMessage(message, device, RTCGQ11LM);
assert.isTrue(publishEntityState.notCalled);
expect(publishEntityState).toHaveBeenCalledTimes(0);
});
it('Should publish last_seen epoch', () => {
const device = {ieeeAddr: '0x12345678'};
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1}, 1);
sinon.stub(settings, 'get').callsFake(() => {
return {
advanced: {
last_seen: 'epoch',
},
};
jest.spyOn(settings, 'get').mockReturnValue({
advanced: {
last_seen: 'epoch',
},
});
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
assert.isTrue(publishEntityState.calledOnce);
assert.equal(typeof publishEntityState.getCall(0).args[1].last_seen, 'number');
expect(publishEntityState).toHaveBeenCalledTimes(1);
expect(typeof publishEntityState.mock.calls[0][1].last_seen).toBe('number');
});
it('Should publish last_seen ISO_8601', () => {
const device = {ieeeAddr: '0x12345678'};
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1}, 1);
sinon.stub(settings, 'get').callsFake(() => {
return {
advanced: {
last_seen: 'ISO_8601',
},
};
jest.spyOn(settings, 'get').mockReturnValue({
advanced: {
last_seen: 'ISO_8601',
},
});
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
assert.isTrue(publishEntityState.calledOnce);
assert.equal(typeof publishEntityState.getCall(0).args[1].last_seen, 'string');
expect(publishEntityState).toHaveBeenCalledTimes(1);
expect(typeof publishEntityState.mock.calls[0][1].last_seen).toBe('string');
});
it('Should publish last_seen ISO_8601_local', () => {
const device = {ieeeAddr: '0x12345678'};
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1}, 1);
sinon.stub(settings, 'get').callsFake(() => {
return {
advanced: {
last_seen: 'ISO_8601_local',
},
};
jest.spyOn(settings, 'get').mockReturnValue({
advanced: {
last_seen: 'ISO_8601_local',
},
});
deviceReceive.onZigbeeMessage(message, device, WXKG02LM);
assert.isTrue(publishEntityState.calledOnce);
assert.equal(typeof publishEntityState.getCall(0).args[1].last_seen, 'string');
expect(publishEntityState).toHaveBeenCalledTimes(1);
expect(typeof publishEntityState.mock.calls[0][1].last_seen).toBe('string');
});
it('Should not handle messages forwarded Xiaomi messages', () => {
const device = {ieeeAddr: '0x12345678', manufId: 4151, type: 'Router'};
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1}, 1, 599);
deviceReceive.onZigbeeMessage(message, device, ZNCZ02LM);
expect(publishEntityState).toHaveBeenCalledTimes(0);
});
it('Should handle messages from Xiaomi router devices', () => {
const device = {ieeeAddr: '0x12345678', manufId: 4151, type: 'Router'};
const message = utils.zigbeeMessage(device, 'genOnOff', 'attReport', {onOff: 1});
deviceReceive.onZigbeeMessage(message, device, ZNCZ02LM);
expect(publishEntityState).toHaveBeenCalledTimes(1);
expect(publishEntityState.mock.calls[0][1]).toStrictEqual({state: 'ON'});
});
});
});
+287
View File
@@ -0,0 +1,287 @@
const Groups = require('../lib/extension/groups');
const settings = require('../lib/util/settings');
const utils = require('./utils');
let groupExtension = null;
let zigbee = null;
describe('Groups', () => {
beforeEach(() => {
utils.stubLogger(jest);
zigbee = {
publish: jest.fn(),
};
groupExtension = new Groups(zigbee, null, null, null);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('Apply group updates add', async () => {
zigbee.publish.mockClear();
const from = {};
const to = {'1': ['1', '2']};
groupExtension.apply(from, to);
expect(zigbee.publish).toHaveBeenCalledTimes(2);
expect(zigbee.publish).toHaveBeenCalledWith(
'1', 'device', 'genGroups', 'add', 'functional',
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
);
expect(zigbee.publish).toHaveBeenCalledWith(
'2', 'device', 'genGroups', 'add', 'functional',
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
);
});
it('Apply group updates remove', async () => {
zigbee.publish.mockClear();
const from = {'1': ['1', '2', '3']};
const to = {'1': ['1']};
groupExtension.apply(from, to);
expect(zigbee.publish).toHaveBeenCalledTimes(2);
expect(zigbee.publish).toHaveBeenCalledWith(
'3', 'device', 'genGroups', 'remove', 'functional',
{groupid: '1'}, null, null, expect.any(Function)
);
expect(zigbee.publish).toHaveBeenCalledWith(
'2', 'device', 'genGroups', 'remove', 'functional',
{groupid: '1'}, null, null, expect.any(Function)
);
});
it('Apply group updates add 1', async () => {
zigbee.publish.mockClear();
const from = {'1': ['1']};
const to = {'1': ['1', '2', '3']};
groupExtension.apply(from, to);
expect(zigbee.publish).toHaveBeenCalledTimes(2);
expect(zigbee.publish).toHaveBeenCalledWith(
'3', 'device', 'genGroups', 'add', 'functional',
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
);
expect(zigbee.publish).toHaveBeenCalledWith(
'2', 'device', 'genGroups', 'add', 'functional',
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
);
});
it('Apply group updates add and remove group', async () => {
zigbee.publish.mockClear();
const from = {'1': ['1', '2']};
const to = {'2': ['1', '2']};
groupExtension.apply(from, to);
expect(zigbee.publish).toHaveBeenCalledTimes(4);
expect(zigbee.publish).toHaveBeenCalledWith(
'1', 'device', 'genGroups', 'remove', 'functional',
{groupid: '1'}, null, null, expect.any(Function)
);
expect(zigbee.publish).toHaveBeenCalledWith(
'2', 'device', 'genGroups', 'remove', 'functional',
{groupid: '1'}, null, null, expect.any(Function)
);
expect(zigbee.publish).toHaveBeenCalledWith(
'1', 'device', 'genGroups', 'add', 'functional',
{groupid: '2', groupname: ''}, null, null, expect.any(Function)
);
expect(zigbee.publish).toHaveBeenCalledWith(
'2', 'device', 'genGroups', 'add', 'functional',
{groupid: '2', groupname: ''}, null, null, expect.any(Function)
);
});
it('Apply group updates change 1', async () => {
zigbee.publish.mockClear();
const from = {'1': ['1', '4', '2']};
const to = {'1': ['1', '2', '3']};
groupExtension.apply(from, to);
expect(zigbee.publish).toHaveBeenCalledTimes(2);
expect(zigbee.publish).toHaveBeenCalledWith(
'3', 'device', 'genGroups', 'add', 'functional',
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
);
expect(zigbee.publish).toHaveBeenCalledWith(
'4', 'device', 'genGroups', 'remove', 'functional',
{groupid: '1'}, null, null, expect.any(Function)
);
});
it('Apply group updates change 2', async () => {
zigbee.publish.mockClear();
const from = {'1': ['1', '2', '3']};
const to = {'1': ['3', '1', '2', '4']};
groupExtension.apply(from, to);
expect(zigbee.publish).toHaveBeenCalledTimes(1);
expect(zigbee.publish).toHaveBeenCalledWith(
'4', 'device', 'genGroups', 'add', 'functional',
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
);
});
it('Apply group updates change 3', async () => {
zigbee.publish.mockClear();
const from = {'1': ['1', '2']};
const to = {'1': ['3', '1', '4', '2']};
groupExtension.apply(from, to);
expect(zigbee.publish).toHaveBeenCalledTimes(2);
expect(zigbee.publish).toHaveBeenCalledWith(
'3', 'device', 'genGroups', 'add', 'functional',
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
);
expect(zigbee.publish).toHaveBeenCalledWith(
'4', 'device', 'genGroups', 'add', 'functional',
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
);
});
it('Apply group updates change 4', async () => {
zigbee.publish.mockClear();
const from = {'1': ['1']};
const to = {'2': ['3', '1']};
groupExtension.apply(from, to);
expect(zigbee.publish).toHaveBeenCalledTimes(3);
expect(zigbee.publish).toHaveBeenCalledWith(
'1', 'device', 'genGroups', 'remove', 'functional',
{groupid: '1'}, null, null, expect.any(Function)
);
expect(zigbee.publish).toHaveBeenCalledWith(
'1', 'device', 'genGroups', 'add', 'functional',
{groupid: '2', groupname: ''}, null, null, expect.any(Function)
);
expect(zigbee.publish).toHaveBeenCalledWith(
'3', 'device', 'genGroups', 'add', 'functional',
{groupid: '2', groupname: ''}, null, null, expect.any(Function)
);
});
it('Apply group updates change 5', async () => {
zigbee.publish.mockClear();
const from = {'1': ['1']};
const to = {'1': ['3'], '2': ['3', '1']};
groupExtension.apply(from, to);
expect(zigbee.publish).toHaveBeenCalledTimes(4);
expect(zigbee.publish).toHaveBeenCalledWith(
'1', 'device', 'genGroups', 'remove', 'functional',
{groupid: '1'}, null, null, expect.any(Function)
);
expect(zigbee.publish).toHaveBeenCalledWith(
'3', 'device', 'genGroups', 'add', 'functional',
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
);
expect(zigbee.publish).toHaveBeenCalledWith(
'1', 'device', 'genGroups', 'add', 'functional',
{groupid: '2', groupname: ''}, null, null, expect.any(Function)
);
expect(zigbee.publish).toHaveBeenCalledWith(
'3', 'device', 'genGroups', 'add', 'functional',
{groupid: '2', groupname: ''}, null, null, expect.any(Function)
);
});
it('Apply group updates add with postfix', async () => {
zigbee.publish.mockClear();
zigbee.getDevice = () => ({modelId: 'lumi.ctrl_neutral2'});
zigbee.getEndpoint = (entityID, ep) => ({epId: ep});
const from = {};
const to = {'1': ['0x12345689/right']};
groupExtension.apply(from, to);
expect(zigbee.publish).toHaveBeenCalledTimes(1);
expect(zigbee.publish).toHaveBeenCalledWith(
'0x12345689', 'device', 'genGroups', 'add', 'functional',
{groupid: '1', groupname: ''}, null, 3, expect.any(Function)
);
});
it('Apply group updates add and remove with postfix', async () => {
zigbee.publish.mockClear();
zigbee.getDevice = () => ({modelId: 'lumi.ctrl_neutral2'});
zigbee.getEndpoint = (entityID, ep) => ({epId: ep});
const from = {'1': ['0x12345689/right']};
const to = {'1': ['0x12345689'], '2': ['0x12345689/left']};
groupExtension.apply(from, to);
expect(zigbee.publish).toHaveBeenCalledTimes(3);
expect(zigbee.publish).toHaveBeenCalledWith(
'0x12345689', 'device', 'genGroups', 'add', 'functional',
{groupid: '2', groupname: ''}, null, 2, expect.any(Function)
);
expect(zigbee.publish).toHaveBeenCalledWith(
'0x12345689', 'device', 'genGroups', 'remove', 'functional',
{groupid: '1'}, null, 3, expect.any(Function)
);
expect(zigbee.publish).toHaveBeenCalledWith(
'0x12345689', 'device', 'genGroups', 'add', 'functional',
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
);
});
it('Add to group via MQTT', async () => {
zigbee.publish.mockClear();
zigbee.getDevice = () => ({modelId: 'lumi.ctrl_neutral2'});
zigbee.getEndpoint = (entityID, ep) => ({epId: ep});
jest.spyOn(settings, 'getGroupIDByFriendlyName').mockReturnValue(1);
jest.spyOn(settings, 'getIeeeAddrByFriendlyName').mockReturnValue('0x12345689');
groupExtension.onMQTTMessage('zigbee2mqtt/bridge/group/my_group/add', 'my_switch');
expect(zigbee.publish).toHaveBeenCalledTimes(1);
expect(zigbee.publish).toHaveBeenCalledWith(
'0x12345689', 'device', 'genGroups', 'add', 'functional',
{groupid: '1', groupname: ''}, null, null, expect.any(Function)
);
});
it('Add to group via MQTT with postfix', async () => {
zigbee.publish.mockClear();
zigbee.getDevice = () => ({modelId: 'lumi.ctrl_neutral2'});
zigbee.getEndpoint = (entityID, ep) => ({epId: ep});
jest.spyOn(settings, 'getGroupIDByFriendlyName').mockReturnValue(1);
jest.spyOn(settings, 'getIeeeAddrByFriendlyName').mockReturnValue('0x12345689');
groupExtension.onMQTTMessage('zigbee2mqtt/bridge/group/my_group/add', 'my_switch/right');
expect(zigbee.publish).toHaveBeenCalledTimes(1);
expect(zigbee.publish).toHaveBeenCalledWith(
'0x12345689', 'device', 'genGroups', 'add', 'functional',
{groupid: '1', groupname: ''}, null, 3, expect.any(Function)
);
});
it('Remove from group via MQTT with postfix', async () => {
zigbee.publish.mockClear();
zigbee.getDevice = () => ({modelId: 'lumi.ctrl_neutral2'});
zigbee.getEndpoint = (entityID, ep) => ({epId: ep});
jest.spyOn(settings, 'getGroupIDByFriendlyName').mockReturnValue(1);
jest.spyOn(settings, 'getIeeeAddrByFriendlyName').mockReturnValue('0x12345689');
groupExtension.onMQTTMessage('zigbee2mqtt/bridge/group/my_group/remove', 'my_switch/left');
expect(zigbee.publish).toHaveBeenCalledTimes(1);
expect(zigbee.publish).toHaveBeenCalledWith(
'0x12345689', 'device', 'genGroups', 'remove', 'functional',
{groupid: '1'}, null, 2, expect.any(Function)
);
});
it('Remove all group via MQTT', async () => {
zigbee.publish.mockClear();
zigbee.getDevice = () => ({modelId: 'lumi.ctrl_neutral2'});
zigbee.getEndpoint = (entityID, ep) => ({epId: ep});
jest.spyOn(settings, 'getIeeeAddrByFriendlyName').mockReturnValue('0x12345689');
groupExtension.onMQTTMessage('zigbee2mqtt/bridge/group/remove_all', 'my_switch');
expect(zigbee.publish).toHaveBeenCalledTimes(1);
expect(zigbee.publish).toHaveBeenCalledWith(
'0x12345689', 'device', 'genGroups', 'removeAll', 'functional',
{}, null, null, expect.any(Function)
);
});
it('Remove all group via MQTT deprecated', async () => {
zigbee.publish.mockClear();
zigbee.getDevice = () => ({modelId: 'lumi.ctrl_neutral2'});
zigbee.getEndpoint = (entityID, ep) => ({epId: ep});
jest.spyOn(settings, 'getGroupIDByFriendlyName').mockReturnValue(1);
jest.spyOn(settings, 'getIeeeAddrByFriendlyName').mockReturnValue('0x12345689');
groupExtension.onMQTTMessage('zigbee2mqtt/bridge/group/my_group/remove_all', 'my_switch');
expect(zigbee.publish).toHaveBeenCalledTimes(1);
expect(zigbee.publish).toHaveBeenCalledWith(
'0x12345689', 'device', 'genGroups', 'removeAll', 'functional',
{}, null, null, expect.any(Function)
);
});
});
+289 -113
View File
@@ -1,19 +1,21 @@
const devices = require('zigbee-shepherd-converters').devices;
const HomeassistantExtension = require('../lib/extension/homeassistant');
const assert = require('chai').assert;
const sinon = require('sinon');
const settings = require('../lib/util/settings');
const utils = require('./utils');
const WSDCGQ11LM = devices.find((d) => d.model === 'WSDCGQ11LM');
const SV01 = devices.find((d) => d.model === 'SV01');
const FAN99432 = devices.find((d) => d.model === '99432');
describe('HomeAssistant extension', () => {
let homeassistant = null;
let mqtt = null;
beforeEach(() => {
utils.stubLogger(jest);
mqtt = {
publish: sinon.spy(),
publish: jest.fn(),
};
homeassistant = new HomeassistantExtension(null, mqtt, null, null);
@@ -21,7 +23,7 @@ describe('HomeAssistant extension', () => {
});
afterEach(() => {
sinon.restore();
jest.restoreAllMocks();
});
it('Should have mapping for all devices supported by zigbee-shepherd-converters', () => {
@@ -33,17 +35,16 @@ describe('HomeAssistant extension', () => {
}
});
assert.strictEqual(missing.length, 0, `Missing HomeAssistant mapping for: ${missing.join(', ')}`);
});
expect(missing).toHaveLength(0);
}
);
it('Should discover devices', () => {
let payload = null;
sinon.stub(settings, 'getDevice').callsFake(() => {
return {friendly_name: 'my_device'};
});
jest.spyOn(settings, 'getDevice').mockReturnValue({friendly_name: 'my_device'});
homeassistant.discover('0x12345678', WSDCGQ11LM, false);
assert.equal(mqtt.publish.callCount, 5);
expect(mqtt.publish).toHaveBeenCalledTimes(5);
// 1
payload = {
@@ -55,7 +56,7 @@ describe('HomeAssistant extension', () => {
'name': 'my_device_temperature',
'unique_id': '0x12345678_temperature_zigbee2mqtt',
'device': {
'identifiers': 'zigbee2mqtt_0x12345678',
'identifiers': ['zigbee2mqtt_0x12345678'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
@@ -64,10 +65,10 @@ describe('HomeAssistant extension', () => {
'availability_topic': 'zigbee2mqtt/bridge/state',
};
assert.deepEqual(JSON.parse(mqtt.publish.getCall(0).args[1]), payload);
assert.deepEqual(mqtt.publish.getCall(0).args[2], {retain: true, qos: 0});
assert.equal(mqtt.publish.getCall(0).args[3], null);
assert.equal(mqtt.publish.getCall(0).args[4], 'homeassistant');
expect(JSON.parse(mqtt.publish.mock.calls[0][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[0][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[0][3]).toBeNull();
expect(mqtt.publish.mock.calls[0][4]).toBe('homeassistant');
// 2
payload = {
@@ -79,7 +80,7 @@ describe('HomeAssistant extension', () => {
'name': 'my_device_humidity',
'unique_id': '0x12345678_humidity_zigbee2mqtt',
'device': {
'identifiers': 'zigbee2mqtt_0x12345678',
'identifiers': ['zigbee2mqtt_0x12345678'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
@@ -88,10 +89,10 @@ describe('HomeAssistant extension', () => {
'availability_topic': 'zigbee2mqtt/bridge/state',
};
assert.deepEqual(JSON.parse(mqtt.publish.getCall(1).args[1]), payload);
assert.deepEqual(mqtt.publish.getCall(1).args[2], {retain: true, qos: 0});
assert.equal(mqtt.publish.getCall(1).args[3], null);
assert.equal(mqtt.publish.getCall(1).args[4], 'homeassistant');
expect(JSON.parse(mqtt.publish.mock.calls[1][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[1][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[1][3]).toBeNull();
expect(mqtt.publish.mock.calls[1][4]).toBe('homeassistant');
// 3
payload = {
@@ -103,7 +104,7 @@ describe('HomeAssistant extension', () => {
'name': 'my_device_pressure',
'unique_id': '0x12345678_pressure_zigbee2mqtt',
'device': {
'identifiers': 'zigbee2mqtt_0x12345678',
'identifiers': ['zigbee2mqtt_0x12345678'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
@@ -112,10 +113,10 @@ describe('HomeAssistant extension', () => {
'availability_topic': 'zigbee2mqtt/bridge/state',
};
assert.deepEqual(JSON.parse(mqtt.publish.getCall(2).args[1]), payload);
assert.deepEqual(mqtt.publish.getCall(2).args[2], {retain: true, qos: 0});
assert.equal(mqtt.publish.getCall(2).args[3], null);
assert.equal(mqtt.publish.getCall(2).args[4], 'homeassistant');
expect(JSON.parse(mqtt.publish.mock.calls[2][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[2][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[2][3]).toBeNull();
expect(mqtt.publish.mock.calls[2][4]).toBe('homeassistant');
// 4
payload = {
@@ -127,7 +128,7 @@ describe('HomeAssistant extension', () => {
'name': 'my_device_battery',
'unique_id': '0x12345678_battery_zigbee2mqtt',
'device': {
'identifiers': 'zigbee2mqtt_0x12345678',
'identifiers': ['zigbee2mqtt_0x12345678'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
@@ -136,10 +137,10 @@ describe('HomeAssistant extension', () => {
'availability_topic': 'zigbee2mqtt/bridge/state',
};
assert.deepEqual(JSON.parse(mqtt.publish.getCall(3).args[1]), payload);
assert.deepEqual(mqtt.publish.getCall(3).args[2], {retain: true, qos: 0});
assert.equal(mqtt.publish.getCall(3).args[3], null);
assert.equal(mqtt.publish.getCall(3).args[4], 'homeassistant');
expect(JSON.parse(mqtt.publish.mock.calls[3][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[3][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[3][3]).toBeNull();
expect(mqtt.publish.mock.calls[3][4]).toBe('homeassistant');
// 5
payload = {
@@ -150,7 +151,7 @@ describe('HomeAssistant extension', () => {
'name': 'my_device_linkquality',
'unique_id': '0x12345678_linkquality_zigbee2mqtt',
'device': {
'identifiers': 'zigbee2mqtt_0x12345678',
'identifiers': ['zigbee2mqtt_0x12345678'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
@@ -159,25 +160,23 @@ describe('HomeAssistant extension', () => {
'availability_topic': 'zigbee2mqtt/bridge/state',
};
assert.deepEqual(JSON.parse(mqtt.publish.getCall(4).args[1]), payload);
assert.deepEqual(mqtt.publish.getCall(4).args[2], {retain: true, qos: 0});
assert.equal(mqtt.publish.getCall(4).args[3], null);
assert.equal(mqtt.publish.getCall(4).args[4], 'homeassistant');
expect(JSON.parse(mqtt.publish.mock.calls[4][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[4][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[4][3]).toBeNull();
expect(mqtt.publish.mock.calls[4][4]).toBe('homeassistant');
});
it('Should discover devices with precision', () => {
let payload = null;
sinon.stub(settings, 'getDevice').callsFake(() => {
return {
friendly_name: 'my_device',
humidity_precision: 0,
temperature_precision: 1,
pressure_precision: 2,
};
jest.spyOn(settings, 'getDevice').mockReturnValue({
friendly_name: 'my_device',
humidity_precision: 0,
temperature_precision: 1,
pressure_precision: 2,
});
homeassistant.discover('0x12345678', WSDCGQ11LM, false);
assert.equal(mqtt.publish.callCount, 5);
expect(mqtt.publish).toHaveBeenCalledTimes(5);
// 1
payload = {
@@ -189,7 +188,7 @@ describe('HomeAssistant extension', () => {
'name': 'my_device_temperature',
'unique_id': '0x12345678_temperature_zigbee2mqtt',
'device': {
'identifiers': 'zigbee2mqtt_0x12345678',
'identifiers': ['zigbee2mqtt_0x12345678'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
@@ -198,10 +197,10 @@ describe('HomeAssistant extension', () => {
'availability_topic': 'zigbee2mqtt/bridge/state',
};
assert.deepEqual(JSON.parse(mqtt.publish.getCall(0).args[1]), payload);
assert.deepEqual(mqtt.publish.getCall(0).args[2], {retain: true, qos: 0});
assert.equal(mqtt.publish.getCall(0).args[3], null);
assert.equal(mqtt.publish.getCall(0).args[4], 'homeassistant');
expect(JSON.parse(mqtt.publish.mock.calls[0][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[0][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[0][3]).toBeNull();
expect(mqtt.publish.mock.calls[0][4]).toBe('homeassistant');
// 2
payload = {
@@ -213,7 +212,7 @@ describe('HomeAssistant extension', () => {
'name': 'my_device_humidity',
'unique_id': '0x12345678_humidity_zigbee2mqtt',
'device': {
'identifiers': 'zigbee2mqtt_0x12345678',
'identifiers': ['zigbee2mqtt_0x12345678'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
@@ -222,10 +221,10 @@ describe('HomeAssistant extension', () => {
'availability_topic': 'zigbee2mqtt/bridge/state',
};
assert.deepEqual(JSON.parse(mqtt.publish.getCall(1).args[1]), payload);
assert.deepEqual(mqtt.publish.getCall(1).args[2], {retain: true, qos: 0});
assert.equal(mqtt.publish.getCall(1).args[3], null);
assert.equal(mqtt.publish.getCall(1).args[4], 'homeassistant');
expect(JSON.parse(mqtt.publish.mock.calls[1][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[1][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[1][3]).toBeNull();
expect(mqtt.publish.mock.calls[1][4]).toBe('homeassistant');
// 3
payload = {
@@ -237,7 +236,7 @@ describe('HomeAssistant extension', () => {
'name': 'my_device_pressure',
'unique_id': '0x12345678_pressure_zigbee2mqtt',
'device': {
'identifiers': 'zigbee2mqtt_0x12345678',
'identifiers': ['zigbee2mqtt_0x12345678'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
@@ -246,10 +245,10 @@ describe('HomeAssistant extension', () => {
'availability_topic': 'zigbee2mqtt/bridge/state',
};
assert.deepEqual(JSON.parse(mqtt.publish.getCall(2).args[1]), payload);
assert.deepEqual(mqtt.publish.getCall(2).args[2], {retain: true, qos: 0});
assert.equal(mqtt.publish.getCall(2).args[3], null);
assert.equal(mqtt.publish.getCall(2).args[4], 'homeassistant');
expect(JSON.parse(mqtt.publish.mock.calls[2][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[2][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[2][3]).toBeNull();
expect(mqtt.publish.mock.calls[2][4]).toBe('homeassistant');
// 4
payload = {
@@ -261,7 +260,7 @@ describe('HomeAssistant extension', () => {
'name': 'my_device_battery',
'unique_id': '0x12345678_battery_zigbee2mqtt',
'device': {
'identifiers': 'zigbee2mqtt_0x12345678',
'identifiers': ['zigbee2mqtt_0x12345678'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
@@ -270,10 +269,10 @@ describe('HomeAssistant extension', () => {
'availability_topic': 'zigbee2mqtt/bridge/state',
};
assert.deepEqual(JSON.parse(mqtt.publish.getCall(3).args[1]), payload);
assert.deepEqual(mqtt.publish.getCall(3).args[2], {retain: true, qos: 0});
assert.equal(mqtt.publish.getCall(3).args[3], null);
assert.equal(mqtt.publish.getCall(3).args[4], 'homeassistant');
expect(JSON.parse(mqtt.publish.mock.calls[3][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[3][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[3][3]).toBeNull();
expect(mqtt.publish.mock.calls[3][4]).toBe('homeassistant');
// 5
payload = {
@@ -284,7 +283,7 @@ describe('HomeAssistant extension', () => {
'name': 'my_device_linkquality',
'unique_id': '0x12345678_linkquality_zigbee2mqtt',
'device': {
'identifiers': 'zigbee2mqtt_0x12345678',
'identifiers': ['zigbee2mqtt_0x12345678'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
@@ -293,29 +292,27 @@ describe('HomeAssistant extension', () => {
'availability_topic': 'zigbee2mqtt/bridge/state',
};
assert.deepEqual(JSON.parse(mqtt.publish.getCall(4).args[1]), payload);
assert.deepEqual(mqtt.publish.getCall(4).args[2], {retain: true, qos: 0});
assert.equal(mqtt.publish.getCall(4).args[3], null);
assert.equal(mqtt.publish.getCall(4).args[4], 'homeassistant');
expect(JSON.parse(mqtt.publish.mock.calls[4][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[4][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[4][3]).toBeNull();
expect(mqtt.publish.mock.calls[4][4]).toBe('homeassistant');
});
it('Should discover devices with overriden user configuration', () => {
let payload = null;
sinon.stub(settings, 'getDevice').callsFake(() => {
return {
friendly_name: 'my_device',
homeassistant: {
expire_after: 30,
icon: 'mdi:test',
temperature: {
expire_after: 90,
},
jest.spyOn(settings, 'getDevice').mockReturnValue({
friendly_name: 'my_device',
homeassistant: {
expire_after: 30,
icon: 'mdi:test',
temperature: {
expire_after: 90,
},
};
},
});
homeassistant.discover('0x12345678', WSDCGQ11LM, false);
assert.equal(mqtt.publish.callCount, 5);
expect(mqtt.publish).toHaveBeenCalledTimes(5);
// 1
payload = {
@@ -329,7 +326,7 @@ describe('HomeAssistant extension', () => {
'expire_after': 90,
'icon': 'mdi:test',
'device': {
'identifiers': 'zigbee2mqtt_0x12345678',
'identifiers': ['zigbee2mqtt_0x12345678'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
@@ -338,10 +335,10 @@ describe('HomeAssistant extension', () => {
'availability_topic': 'zigbee2mqtt/bridge/state',
};
assert.deepEqual(JSON.parse(mqtt.publish.getCall(0).args[1]), payload);
assert.deepEqual(mqtt.publish.getCall(0).args[2], {retain: true, qos: 0});
assert.equal(mqtt.publish.getCall(0).args[3], null);
assert.equal(mqtt.publish.getCall(0).args[4], 'homeassistant');
expect(JSON.parse(mqtt.publish.mock.calls[0][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[0][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[0][3]).toBeNull();
expect(mqtt.publish.mock.calls[0][4]).toBe('homeassistant');
// 2
payload = {
@@ -355,7 +352,7 @@ describe('HomeAssistant extension', () => {
'expire_after': 30,
'icon': 'mdi:test',
'device': {
'identifiers': 'zigbee2mqtt_0x12345678',
'identifiers': ['zigbee2mqtt_0x12345678'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
@@ -364,10 +361,10 @@ describe('HomeAssistant extension', () => {
'availability_topic': 'zigbee2mqtt/bridge/state',
};
assert.deepEqual(JSON.parse(mqtt.publish.getCall(1).args[1]), payload);
assert.deepEqual(mqtt.publish.getCall(1).args[2], {retain: true, qos: 0});
assert.equal(mqtt.publish.getCall(1).args[3], null);
assert.equal(mqtt.publish.getCall(1).args[4], 'homeassistant');
expect(JSON.parse(mqtt.publish.mock.calls[1][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[1][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[1][3]).toBeNull();
expect(mqtt.publish.mock.calls[1][4]).toBe('homeassistant');
// 3
payload = {
@@ -381,7 +378,7 @@ describe('HomeAssistant extension', () => {
'expire_after': 30,
'icon': 'mdi:test',
'device': {
'identifiers': 'zigbee2mqtt_0x12345678',
'identifiers': ['zigbee2mqtt_0x12345678'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
@@ -390,10 +387,10 @@ describe('HomeAssistant extension', () => {
'availability_topic': 'zigbee2mqtt/bridge/state',
};
assert.deepEqual(JSON.parse(mqtt.publish.getCall(2).args[1]), payload);
assert.deepEqual(mqtt.publish.getCall(2).args[2], {retain: true, qos: 0});
assert.equal(mqtt.publish.getCall(2).args[3], null);
assert.equal(mqtt.publish.getCall(2).args[4], 'homeassistant');
expect(JSON.parse(mqtt.publish.mock.calls[2][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[2][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[2][3]).toBeNull();
expect(mqtt.publish.mock.calls[2][4]).toBe('homeassistant');
// 4
payload = {
@@ -407,7 +404,7 @@ describe('HomeAssistant extension', () => {
'expire_after': 30,
'icon': 'mdi:test',
'device': {
'identifiers': 'zigbee2mqtt_0x12345678',
'identifiers': ['zigbee2mqtt_0x12345678'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
@@ -416,10 +413,10 @@ describe('HomeAssistant extension', () => {
'availability_topic': 'zigbee2mqtt/bridge/state',
};
assert.deepEqual(JSON.parse(mqtt.publish.getCall(3).args[1]), payload);
assert.deepEqual(mqtt.publish.getCall(3).args[2], {retain: true, qos: 0});
assert.equal(mqtt.publish.getCall(3).args[3], null);
assert.equal(mqtt.publish.getCall(3).args[4], 'homeassistant');
expect(JSON.parse(mqtt.publish.mock.calls[3][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[3][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[3][3]).toBeNull();
expect(mqtt.publish.mock.calls[3][4]).toBe('homeassistant');
// 5
payload = {
@@ -432,7 +429,7 @@ describe('HomeAssistant extension', () => {
'expire_after': 30,
'icon': 'mdi:test',
'device': {
'identifiers': 'zigbee2mqtt_0x12345678',
'identifiers': ['zigbee2mqtt_0x12345678'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
@@ -441,20 +438,53 @@ describe('HomeAssistant extension', () => {
'availability_topic': 'zigbee2mqtt/bridge/state',
};
assert.deepEqual(JSON.parse(mqtt.publish.getCall(4).args[1]), payload);
assert.deepEqual(mqtt.publish.getCall(4).args[2], {retain: true, qos: 0});
assert.equal(mqtt.publish.getCall(4).args[3], null);
assert.equal(mqtt.publish.getCall(4).args[4], 'homeassistant');
expect(JSON.parse(mqtt.publish.mock.calls[4][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[4][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[4][3]).toBeNull();
expect(mqtt.publish.mock.calls[4][4]).toBe('homeassistant');
});
it('Should discover devices with fan', () => {
let payload = null;
jest.spyOn(settings, 'getDevice').mockReturnValue({friendly_name: 'my_device'});
homeassistant.discover('0x12345678', FAN99432, false);
expect(mqtt.publish).toHaveBeenCalledTimes(3);
// 1
payload = {
name: 'my_device_fan',
state_topic: 'zigbee2mqtt/my_device',
state_value_template: '{{ value_json.fan_state }}',
command_topic: 'zigbee2mqtt/my_device/set/fan_state',
speed_state_topic: 'zigbee2mqtt/my_device',
speed_value_template: '{{ value_json.fan_mode }}',
speed_command_topic: 'zigbee2mqtt/my_device/set/fan_mode',
unique_id: '0x12345678_fan_zigbee2mqtt',
speeds: ['off', 'low', 'medium', 'high', 'on', 'auto', 'smart'],
device: {
'identifiers': ['zigbee2mqtt_0x12345678'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'manufacturer': 'Hampton Bay',
'model': 'Universal wink enabled white ceiling fan premier remote control (99432)',
},
availability_topic: 'zigbee2mqtt/bridge/state',
json_attributes_topic: 'zigbee2mqtt/my_device',
};
expect(JSON.parse(mqtt.publish.mock.calls[0][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[0][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[0][3]).toBeNull();
expect(mqtt.publish.mock.calls[0][4]).toBe('homeassistant');
});
it('Should discover devices with cover_position', () => {
let payload = null;
sinon.stub(settings, 'getDevice').callsFake(() => {
return {friendly_name: 'my_device'};
});
jest.spyOn(settings, 'getDevice').mockReturnValue({friendly_name: 'my_device'});
homeassistant.discover('0x12345678', SV01, false);
assert.equal(mqtt.publish.callCount, 5);
expect(mqtt.publish).toHaveBeenCalledTimes(5);
// 1
payload = {
@@ -466,7 +496,7 @@ describe('HomeAssistant extension', () => {
value_template: '{{ value_json.position }}',
unique_id: '0x12345678_cover_zigbee2mqtt',
device: {
'identifiers': 'zigbee2mqtt_0x12345678',
'identifiers': ['zigbee2mqtt_0x12345678'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Smart vent (SV01)',
@@ -475,9 +505,155 @@ describe('HomeAssistant extension', () => {
availability_topic: 'zigbee2mqtt/bridge/state',
};
assert.deepEqual(JSON.parse(mqtt.publish.getCall(0).args[1]), payload);
assert.deepEqual(mqtt.publish.getCall(0).args[2], {retain: true, qos: 0});
assert.equal(mqtt.publish.getCall(0).args[3], null);
assert.equal(mqtt.publish.getCall(0).args[4], 'homeassistant');
expect(JSON.parse(mqtt.publish.mock.calls[0][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[0][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[0][3]).toBeNull();
expect(mqtt.publish.mock.calls[0][4]).toBe('homeassistant');
});
it('Should discover devices with overriden user configuration in device', () => {
let payload = null;
jest.spyOn(settings, 'getDevice').mockReturnValue({
friendly_name: 'my_device',
homeassistant: {
device: {
identifiers: ['test123'],
},
temperature: {
expire_after: 90,
},
},
});
homeassistant.discover('0x12345678', WSDCGQ11LM, false);
expect(mqtt.publish).toHaveBeenCalledTimes(5);
// 1
payload = {
'unit_of_measurement': '°C',
'device_class': 'temperature',
'value_template': '{{ value_json.temperature }}',
'json_attributes_topic': 'zigbee2mqtt/my_device',
'state_topic': 'zigbee2mqtt/my_device',
'name': 'my_device_temperature',
'unique_id': '0x12345678_temperature_zigbee2mqtt',
'expire_after': 90,
'device': {
'identifiers': ['test123'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
'manufacturer': 'Xiaomi',
},
'availability_topic': 'zigbee2mqtt/bridge/state',
};
expect(JSON.parse(mqtt.publish.mock.calls[0][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[0][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[0][3]).toBeNull();
expect(mqtt.publish.mock.calls[0][4]).toBe('homeassistant');
});
it('Should discover devices with overriden user configuration in device in temperature', () => {
let payload = null;
jest.spyOn(settings, 'getDevice').mockReturnValue({
friendly_name: 'my_device',
homeassistant: {
temperature: {
expire_after: 90,
device: {
identifiers: ['test'],
},
},
},
});
homeassistant.discover('0x12345678', WSDCGQ11LM, false);
expect(mqtt.publish).toHaveBeenCalledTimes(5);
// 1
payload = {
'unit_of_measurement': '°C',
'device_class': 'temperature',
'value_template': '{{ value_json.temperature }}',
'json_attributes_topic': 'zigbee2mqtt/my_device',
'state_topic': 'zigbee2mqtt/my_device',
'name': 'my_device_temperature',
'unique_id': '0x12345678_temperature_zigbee2mqtt',
'expire_after': 90,
'device': {
'identifiers': ['test'],
'name': 'my_device',
'sw_version': 'Zigbee2mqtt test',
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
'manufacturer': 'Xiaomi',
},
'availability_topic': 'zigbee2mqtt/bridge/state',
};
expect(JSON.parse(mqtt.publish.mock.calls[0][1])).toStrictEqual(payload);
expect(mqtt.publish.mock.calls[0][2]).toStrictEqual({retain: true, qos: 0});
expect(mqtt.publish.mock.calls[0][3]).toBeNull();
expect(mqtt.publish.mock.calls[0][4]).toBe('homeassistant');
});
it('Should discover devices with a custom discovery topic', () => {
jest.spyOn(settings, 'get').mockReturnValue({
mqtt: {
base_topic: 'zigbee2mqtt',
},
experimental: {
output: 'json',
},
advanced: {
homeassistant_discovery_topic: 'my_custom_discovery_topic',
},
});
homeassistant = new HomeassistantExtension(null, mqtt, null, null);
jest.spyOn(settings, 'getDevice').mockReturnValue({
friendly_name: 'my_device',
homeassistant: {
temperature: {
expire_after: 90,
device: {
identifiers: ['test'],
},
},
},
});
homeassistant.discover('0x12345678', WSDCGQ11LM, false);
expect(mqtt.publish).toHaveBeenCalledTimes(5);
expect(mqtt.publish.mock.calls[0][4]).toBe('my_custom_discovery_topic');
});
it('Should subscribe to custom status topic', () => {
jest.spyOn(settings, 'get').mockReturnValue({
experimental: {
output: 'json',
},
advanced: {
homeassistant_status_topic: 'my_custom_status_topic',
},
});
const zigbee = {
getAllClients: jest.fn().mockReturnValue([]),
};
mqtt = {
subscribe: jest.fn(),
};
homeassistant = new HomeassistantExtension(zigbee, mqtt, null, null);
homeassistant.onMQTTConnected();
expect(mqtt.subscribe).toHaveBeenCalledTimes(1);
expect(mqtt.subscribe.mock.calls[0][0]).toBe('my_custom_status_topic');
});
});
+131 -95
View File
@@ -1,60 +1,65 @@
const assert = require('chai').assert;
const sinon = require('sinon');
const objectAssignDeep = require('object-assign-deep');
const data = require('../lib/util/data');
const tmp = require('tmp');
const path = require('path');
const fs = require('fs');
const yaml = require('js-yaml');
const rimraf = require('rimraf');
const settings = require('../lib/util/settings.js');
const fs = require('../lib/util/fs');
const utils = require('./utils');
const configurationFile = data.joinPath('configuration.yaml');
const devicesFile = data.joinPath('devices.yaml');
const groupsFile = data.joinPath('groups.yaml');
describe('Settings', () => {
let dir = null;
const write = (file, json) => fs.writeYaml(file, json);
const write = (file, json) => {
fs.writeFileSync(file, yaml.safeDump(json));
};
const read = (file) => fs.readYaml(file);
const read = (file) => {
return yaml.safeLoad(fs.readFileSync(file, 'utf8'));
};
const files = new Map();
const setup = (configuration) => {
const configurationFile = path.join(dir.name, 'configuration.yaml');
write(configurationFile, configuration);
delete require.cache[require.resolve('../lib/util/settings.js')];
const settings = require('../lib/util/settings.js');
return {configurationFile, settings};
};
beforeAll(() => {
utils.stubLogger(jest);
beforeEach(() => {
dir = tmp.dirSync();
sinon.stub(data, 'joinPath').callsFake((file) => {
return path.join(dir.name, file);
jest.spyOn(fs, 'readYaml').mockImplementation((file) => {
if (files.has(file)) return objectAssignDeep.noMutate(files.get(file));
throw new Error(`Fake file not found: ${file}`);
});
jest.spyOn(fs, 'readYamlIfExists').mockImplementation((file) => {
if (files.has(file)) return objectAssignDeep.noMutate(files.get(file));
return null;
});
jest.spyOn(fs, 'writeYaml').mockImplementation((file, content) => {
files.set(file, objectAssignDeep.noMutate(content));
});
});
afterEach(() => {
rimraf.sync(dir.name);
sinon.restore();
beforeEach(() => {
settings._clear();
files.clear();
});
afterAll(() => {
fs.readYaml.mockRestore();
fs.readYamlIfExists.mockRestore();
fs.writeYaml.mockRestore();
});
describe('Settings', () => {
it('Should return default settings', () => {
const {settings} = setup({});
write(configurationFile, {});
const s = settings.get();
assert.deepEqual(s, settings._getDefaults());
expect(s).toStrictEqual(settings._getDefaults());
});
it('Should return settings', () => {
const {settings} = setup({permit_join: true});
write(configurationFile, {permit_join: true});
const s = settings.get();
const expected = settings._getDefaults();
expected.permit_join = true;
assert.deepEqual(s, expected);
expect(s).toStrictEqual(expected);
});
it('Should add devices', () => {
const {settings, configurationFile} = setup({});
write(configurationFile, {});
settings.addDevice('0x12345678');
const actual = read(configurationFile);
@@ -67,7 +72,7 @@ describe('Settings', () => {
},
};
assert.deepEqual(actual, expected);
expect(actual).toStrictEqual(expected);
});
it('Should read devices', () => {
@@ -80,7 +85,7 @@ describe('Settings', () => {
},
};
const {settings} = setup(content);
write(configurationFile, content);
const device = settings.getDevice('0x12345678');
const expected = {
@@ -88,10 +93,10 @@ describe('Settings', () => {
retain: false,
};
assert.deepEqual(device, expected);
expect(device).toStrictEqual(expected);
});
it('Should read devices form a seperate file', () => {
it('Should read devices form a separate file', () => {
const contentConfiguration = {
devices: 'devices.yaml',
};
@@ -103,37 +108,36 @@ describe('Settings', () => {
},
};
write(path.join(dir.name, 'devices.yaml'), contentDevices);
const {settings} = setup(contentConfiguration);
const device = settings.getDevice('0x12345678');
const expected = {
friendly_name: '0x12345678',
retain: false,
};
assert.deepEqual(device, expected);
});
it('Should add devices to a seperate file', () => {
const contentConfiguration = {
devices: 'devices.yaml',
};
const contentDevices = {
'0x12345678': {
friendly_name: '0x12345678',
retain: false,
},
};
const devicesFile = path.join(dir.name, 'devices.yaml');
write(devicesFile, contentDevices);
const {settings, configurationFile} = setup(contentConfiguration);
write(configurationFile, contentConfiguration);
const device = settings.getDevice('0x12345678');
const expected = {
friendly_name: '0x12345678',
retain: false,
};
expect(device).toStrictEqual(expected);
});
it('Should add devices to a separate file', () => {
const contentConfiguration = {
devices: 'devices.yaml',
};
const contentDevices = {
'0x12345678': {
friendly_name: '0x12345678',
retain: false,
},
};
write(devicesFile, contentDevices);
write(configurationFile, contentConfiguration);
settings.addDevice('0x1234');
assert.deepEqual(read(configurationFile), {devices: 'devices.yaml'});
expect(read(configurationFile)).toStrictEqual({devices: 'devices.yaml'});
const expected = {
'0x12345678': {
@@ -146,20 +150,19 @@ describe('Settings', () => {
},
};
assert.deepEqual(read(devicesFile), expected);
expect(read(devicesFile)).toStrictEqual(expected);
});
it('Should add devices to a seperate file if devices.yaml doesnt exist', () => {
it('Should add devices to a separate file if devices.yaml doesnt exist', () => {
const contentConfiguration = {
devices: 'devices.yaml',
};
const devicesFile = path.join(dir.name, 'devices.yaml');
const {settings, configurationFile} = setup(contentConfiguration);
write(configurationFile, contentConfiguration);
settings.addDevice('0x1234');
assert.deepEqual(read(configurationFile), {devices: 'devices.yaml'});
expect(read(configurationFile)).toStrictEqual({devices: 'devices.yaml'});
const expected = {
'0x1234': {
@@ -168,25 +171,26 @@ describe('Settings', () => {
},
};
assert.deepEqual(read(devicesFile), expected);
});
expect(read(devicesFile)).toStrictEqual(expected);
}
);
it('Should add and remove devices to a seperate file if devices.yaml doesnt exist', () => {
it('Should add and remove devices to a separate file if devices.yaml doesnt exist', () => {
const contentConfiguration = {
devices: 'devices.yaml',
};
const devicesFile = path.join(dir.name, 'devices.yaml');
const {settings, configurationFile} = setup(contentConfiguration);
write(configurationFile, contentConfiguration);
settings.addDevice('0x1234');
assert.deepEqual(read(configurationFile), {devices: 'devices.yaml'});
expect(read(configurationFile)).toStrictEqual({devices: 'devices.yaml'});
settings.removeDevice('0x1234');
assert.deepEqual(read(configurationFile), {devices: 'devices.yaml'});
expect(read(configurationFile)).toStrictEqual({devices: 'devices.yaml'});
assert.deepEqual(read(devicesFile), {});
});
expect(read(devicesFile)).toStrictEqual({});
}
);
it('Should read groups', () => {
const content = {
@@ -197,17 +201,18 @@ describe('Settings', () => {
},
};
const {settings} = setup(content);
write(configurationFile, content);
const group = settings.getGroup('1');
const expected = {
friendly_name: '123',
devices: [],
};
assert.deepEqual(group, expected);
expect(group).toStrictEqual(expected);
});
it('Should read groups form a seperate file', () => {
it('Should read groups from a separate file', () => {
const contentConfiguration = {
groups: 'groups.yaml',
};
@@ -218,18 +223,19 @@ describe('Settings', () => {
},
};
write(path.join(dir.name, 'groups.yaml'), contentGroups);
const {settings} = setup(contentConfiguration);
write(groupsFile, contentGroups);
write(configurationFile, contentConfiguration);
const group = settings.getGroup('1');
const expected = {
friendly_name: '123',
devices: [],
};
assert.deepEqual(group, expected);
expect(group).toStrictEqual(expected);
});
it('Combine everything! groups and devices from separte file :)', () => {
it('Combine everything! groups and devices from separate file :)', () => {
const contentConfiguration = {
devices: 'devices.yaml',
groups: 'groups.yaml',
@@ -238,24 +244,23 @@ describe('Settings', () => {
const contentGroups = {
'1': {
friendly_name: '123',
devices: [],
},
};
write(path.join(dir.name, 'groups.yaml'), contentGroups);
const {settings, configurationFile} = setup(contentConfiguration);
const devicesFile = path.join(dir.name, 'devices.yaml');
write(groupsFile, contentGroups);
write(configurationFile, contentConfiguration);
const expectedConfiguration = {
devices: 'devices.yaml',
groups: 'groups.yaml',
};
assert.deepEqual(read(configurationFile), expectedConfiguration);
expect(read(configurationFile)).toStrictEqual(expectedConfiguration);
settings.addDevice('0x1234');
assert.deepEqual(read(configurationFile), expectedConfiguration);
expect(read(configurationFile)).toStrictEqual(expectedConfiguration);
const expectedDevice = {
'0x1234': {
@@ -264,23 +269,54 @@ describe('Settings', () => {
},
};
assert.deepEqual(read(devicesFile), expectedDevice);
expect(read(devicesFile)).toStrictEqual(expectedDevice);
const group = settings.getGroup('1');
const expectedGroup = {
friendly_name: '123',
devices: [],
};
assert.deepEqual(group, expectedGroup);
expect(group).toStrictEqual(expectedGroup);
assert.deepEqual(read(configurationFile), expectedConfiguration);
expect(read(configurationFile)).toStrictEqual(expectedConfiguration);
const expectedDevice2 = {
friendly_name: '0x1234',
retain: false,
};
assert.deepEqual(settings.getDevice('0x1234'), expectedDevice2);
expect(settings.getDevice('0x1234')).toStrictEqual(expectedDevice2);
});
it('Should add groups', () => {
write(configurationFile, {});
const added = settings.addGroup('test123');
const expected = {
'1': {
friendly_name: 'test123',
},
};
expect(added).toStrictEqual(true);
expect(settings.get().groups).toStrictEqual(expected);
});
it('Should not add duplicate groups', () => {
write(configurationFile, {});
const added = settings.addGroup('test123');
const added2 = settings.addGroup('test123');
const expected = {
'1': {
friendly_name: 'test123',
},
};
expect(added).toStrictEqual(true);
expect(added2).toStrictEqual(false);
expect(settings.get().groups).toStrictEqual(expected);
});
});
});
+8 -7
View File
@@ -1,13 +1,14 @@
const logger = require('../lib/util/logger');
module.exports = {
stubLogger: (sandbox) => {
sandbox.stub(logger, 'info').callsFake(() => {});
sandbox.stub(logger, 'warn').callsFake(() => {});
sandbox.stub(logger, 'debug').callsFake(() => {});
sandbox.stub(logger, 'error').callsFake(() => {});
stubLogger: (jest) => {
jest.spyOn(logger, 'info').mockReturnValue(undefined);
jest.spyOn(logger, 'warn').mockReturnValue(undefined);
jest.spyOn(logger, 'debug').mockReturnValue(undefined);
jest.spyOn(logger, 'error').mockReturnValue(undefined);
jest.spyOn(logger, 'trace').mockReturnValue(undefined);
},
zigbeeMessage: (device, cid, type, data, epId) => {
return {data: {cid, data}, type, endpoints: [{device, epId}]};
zigbeeMessage: (device, cid, type, data, epId, groupid=0) => {
return {data: {cid, data}, type, groupid, endpoints: [{device, epId}]};
},
};
+78
View File
@@ -0,0 +1,78 @@
const utils = require('../lib/util/utils.js');
const testUtils = require('./utils');
describe('Utils', () => {
beforeAll(() => {
testUtils.stubLogger(jest);
});
describe('Is xiaomi device', () => {
it('Identify xiaomi device', () => {
const device = {type: 'Router', manufId: 4151, manufName: 'Xiaomi'};
expect(true).toBe(utils.isXiaomiDevice(device));
});
it('Identify xiaomi device without manufName', () => {
const device = {type: 'Router', manufId: 4447};
expect(true).toBe(utils.isXiaomiDevice(device));
});
it('Identify xiaomi device with different manufName', () => {
const device = {type: 'Router', manufId: 4151, manufName: 'Trust International B.V.\u0000'};
expect(false).toBe(utils.isXiaomiDevice(device));
});
it('Identify QBKG03LM as enddevice', () => {
const device = {type: 'Router', manufId: 4447, modelId: 'lumi.ctrl_neutral1'};
expect(false).toBe(utils.isRouter(device));
expect('EndDevice').toBe(utils.correctDeviceType(device));
});
it('Identify QBKG04LM as enddevice', () => {
const device = {type: 'Router', manufId: 4447, modelId: 'lumi.ctrl_neutral2'};
expect(false).toBe(utils.isRouter(device));
expect('EndDevice').toBe(utils.correctDeviceType(device));
});
});
describe('Get endpoint by id', () => {
it('Pick default ep', () => {
const zigbee = {
getDevice: (entityID) => {
return {modelId: 'TRADFRI on/off switch'};
},
getEndpoint: (entityID, epId) => {
return {epId: epId == null ? 1 : 0};
},
};
const endpoint = utils.getEndpointByEntityID(zigbee, '0x12345678', null);
expect(endpoint.epId).toBe(1);
});
it('Pick default ep from mapping when default defined', () => {
const zigbee = {
getDevice: (entityID) => {
return {modelId: 'SML002'};
},
getEndpoint: (entityID, epId) => {
return {epId};
},
};
const endpoint = utils.getEndpointByEntityID(zigbee, '0x12345678', null);
expect(endpoint.epId).toBe(2);
});
it('Pick default ep from mapping when not defined', () => {
const zigbee = {
getDevice: (entityID) => {
return {modelId: 'lumi.sensor_86sw2.es1'};
},
getEndpoint: (entityID, epId) => {
return {epId: epId == null ? 1 : 0};
},
};
const endpoint = utils.getEndpointByEntityID(zigbee, '0x12345678', null);
expect(endpoint.epId).toBe(1);
});
});
});