mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 15:18:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac3b924065 | ||
|
|
dd8a55cf08 | ||
|
|
3ae2d2b946 | ||
|
|
31b8352f5a | ||
|
|
503fef807c | ||
|
|
4aab81d9e3 | ||
|
|
8d705ade39 | ||
|
|
d3e97953c3 | ||
|
|
1b859c4dc2 | ||
|
|
225919bb23 | ||
|
|
74b8340afb | ||
|
|
08c0f2b614 | ||
|
|
61b54c50a5 | ||
|
|
6400f52a28 | ||
|
|
97f9f83181 | ||
|
|
90c3fc0320 | ||
|
|
94a7cd8ec9 | ||
|
|
1df43bdbc4 | ||
|
|
3daf97283c | ||
|
|
aca0c39291 | ||
|
|
9624384c3f | ||
|
|
3b4ee3bc2e | ||
|
|
7a19c8876a | ||
|
|
51df6b7e8a | ||
|
|
7cabe3a523 | ||
|
|
7b2522f712 | ||
|
|
5a132ad34b | ||
|
|
362b910e48 | ||
|
|
241b300f7b | ||
|
|
0e9931af6c | ||
|
|
f4f297e8a2 | ||
|
|
5934efa41e | ||
|
|
38a1aec823 | ||
|
|
3c398df658 | ||
|
|
91d415404c | ||
|
|
ef01346776 | ||
|
|
801e97143c | ||
|
|
304b2efa48 | ||
|
|
a36ed2032d | ||
|
|
17d01e937e | ||
|
|
110f80ec84 | ||
|
|
b28da0265e | ||
|
|
1c529c4873 | ||
|
|
3981c1c6e4 | ||
|
|
c48f73e36b | ||
|
|
94e03d4189 | ||
|
|
e30ff9e722 | ||
|
|
1f63a32170 | ||
|
|
e9b5a588cc | ||
|
|
4357064203 | ||
|
|
86a65ebec0 | ||
|
|
259ba9d658 | ||
|
|
9fe76c29ef | ||
|
|
dd49a76e5c | ||
|
|
6976f8e70a | ||
|
|
82dc8437a1 | ||
|
|
fb12d15d9d | ||
|
|
852950fc09 | ||
|
|
b663e37fd9 | ||
|
|
70fbe714e3 | ||
|
|
ee8161b807 | ||
|
|
8ad6aee43a | ||
|
|
4497214588 | ||
|
|
08e55e469f | ||
|
|
c29bfe1455 | ||
|
|
82ed260d47 | ||
|
|
caff94559e | ||
|
|
c2b4d63e12 | ||
|
|
825f096434 | ||
|
|
4b14222bda | ||
|
|
927c4dbc05 | ||
|
|
c4e9ea208a | ||
|
|
af09624cff | ||
|
|
e4a50b662d | ||
|
|
22b64d09f2 | ||
|
|
dc028d5046 | ||
|
|
0352a738a7 | ||
|
|
6adfdacb3c | ||
|
|
18913a1577 | ||
|
|
1e04c229dd | ||
|
|
31d21546b9 | ||
|
|
7f9104367b | ||
|
|
92d0002bbb | ||
|
|
0074c32136 | ||
|
|
faa6aa41a9 | ||
|
|
3ed9bbde1a | ||
|
|
8f39ae9d63 | ||
|
|
a09da4fe10 | ||
|
|
9cc82701c1 | ||
|
|
190dda5104 | ||
|
|
3d74a6e031 | ||
|
|
6b7546547d | ||
|
|
3649fb1bc9 | ||
|
|
eb2c4cbdee | ||
|
|
f93a3e1c07 | ||
|
|
ee9584121a | ||
|
|
ba1eca82eb | ||
|
|
68d2d0a73d | ||
|
|
d8d78af4ec | ||
|
|
80e682688d | ||
|
|
c6617fb7d9 | ||
|
|
26d8d27e85 | ||
|
|
87232988e7 | ||
|
|
a3d26ceeb2 | ||
|
|
1f6e64b4c0 | ||
|
|
919b8a9b4c | ||
|
|
31088255ab | ||
|
|
39fe50600e | ||
|
|
a54e256b40 | ||
|
|
4472c01076 | ||
|
|
801be951a4 | ||
|
|
cb16ba2f71 | ||
|
|
0d587f304e | ||
|
|
21dc1a8c8e | ||
|
|
32b76edb7c | ||
|
|
0eaf0ad5d6 | ||
|
|
1ebd774d6f | ||
|
|
950b3c7ace | ||
|
|
f8106feb18 | ||
|
|
69e9dbc5b2 | ||
|
|
75718b360e | ||
|
|
beb2713cdd | ||
|
|
2cdbe49385 | ||
|
|
d0e66ed1da | ||
|
|
39d6a8e218 | ||
|
|
2f187ae282 | ||
|
|
3a5bdffd4e | ||
|
|
a1b43d12f6 | ||
|
|
310f9892d6 | ||
|
|
c2edaed866 | ||
|
|
2d4c447ee1 | ||
|
|
b13d87d461 | ||
|
|
da9425290e | ||
|
|
811692fed8 | ||
|
|
78ff9b1937 | ||
|
|
c6011c0911 | ||
|
|
9f16f99424 | ||
|
|
08ad3a877d | ||
|
|
24780e6eac | ||
|
|
2554cf8e8b | ||
|
|
4aa5877797 | ||
|
|
61942f8411 | ||
|
|
00ebd441a0 | ||
|
|
4843d85fe9 | ||
|
|
32db1fd07a | ||
|
|
cee35c66e6 | ||
|
|
76d7861671 | ||
|
|
35fd1a68fc | ||
|
|
8828ee60ae | ||
|
|
55c3edfa95 | ||
|
|
2f52b851a9 | ||
|
|
91bc56a09f | ||
|
|
c737f569f9 | ||
|
|
de4f9f44fd | ||
|
|
21714855bf | ||
|
|
50986591c1 | ||
|
|
e10999f0f2 | ||
|
|
7015741a08 | ||
|
|
64fa02abab | ||
|
|
7e6dc67861 | ||
|
|
3c5727b320 | ||
|
|
9ef4d2b810 | ||
|
|
e96dac2596 | ||
|
|
9a15e8c118 | ||
|
|
25408769e6 | ||
|
|
2328ecb2ee | ||
|
|
b2a453bec3 | ||
|
|
20d9bac317 | ||
|
|
40596bab6a | ||
|
|
69d04d0648 | ||
|
|
363f4e1c3b | ||
|
|
008fda4d90 | ||
|
|
c64158b75d | ||
|
|
1ea694cb1a | ||
|
|
e537784d34 | ||
|
|
0277591085 | ||
|
|
7b2480be52 | ||
|
|
70c30736a1 | ||
|
|
24cf72f166 | ||
|
|
e5081b2856 | ||
|
|
4048cb81a4 | ||
|
|
2af2a8ea05 | ||
|
|
1b066d9f59 | ||
|
|
5c9b31b2fc | ||
|
|
61d85af3fd | ||
|
|
789ec0cc90 | ||
|
|
b97f5d1afa | ||
|
|
4cb0c0bbb0 | ||
|
|
6d24a49062 | ||
|
|
bee722c9c4 | ||
|
|
36bba0154d | ||
|
|
a2a90bcdbd | ||
|
|
1043053812 | ||
|
|
e6044da604 | ||
|
|
a8cf925fcb | ||
|
|
ad835e090b | ||
|
|
3a96473e2b | ||
|
|
661f79ce93 | ||
|
|
7461fec28b | ||
|
|
8b4b9bea19 | ||
|
|
fdcf2e8c3d | ||
|
|
b39e195763 | ||
|
|
f2cdbeab38 | ||
|
|
f37d52b870 | ||
|
|
969585c84e | ||
|
|
d4d85633d2 | ||
|
|
f54f3bd40f | ||
|
|
03ca8ec24d | ||
|
|
a65fbd6e2c | ||
|
|
ac28ca2841 | ||
|
|
79d11e295b | ||
|
|
9f31cd86a3 | ||
|
|
c722b611dd | ||
|
|
3c4fa40a32 | ||
|
|
d81a972f1b | ||
|
|
0f9333f8b9 | ||
|
|
6e60063337 | ||
|
|
438626691e | ||
|
|
a978b26001 | ||
|
|
7cec70aba8 | ||
|
|
df1825d65e | ||
|
|
532b3b2484 | ||
|
|
84a5277d45 | ||
|
|
acbab01b7e | ||
|
|
6a4073b305 | ||
|
|
18aee4803b | ||
|
|
3b1fae1452 | ||
|
|
cf2f10394a | ||
|
|
9eed7e271e | ||
|
|
07765093be |
+4
-4
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
github: [koenkk]
|
||||
custom: https://www.paypal.me/koenkk
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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}
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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 += '}';
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
Generated
+9511
-3126
File diff suppressed because it is too large
Load Diff
+10
-16
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
const fs = require('fs');
|
||||
const filename = process.argv[2];
|
||||
const text = fs.readFileSync(filename, 'utf8');
|
||||
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
|
||||
|
||||
|
||||
for (const line of text.split('\n')) {
|
||||
const model = zigbeeShepherdConverters.devices.find((d) => d.model === line);
|
||||
console.log(`- \`${line}\` ${model.vendor} ${model.description}`);
|
||||
}
|
||||
@@ -0,0 +1,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
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+125
-90
@@ -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'});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}]};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user