mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 15:18:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a1fccf6b3 | ||
|
|
9e4b65b188 | ||
|
|
39c0b522d9 | ||
|
|
109c62bb4d | ||
|
|
a2e3f60de3 | ||
|
|
bbf05b5c95 | ||
|
|
a5413b9254 | ||
|
|
d6890b5fe4 | ||
|
|
b1c1637547 | ||
|
|
ed8ca293a3 | ||
|
|
abd4b23024 | ||
|
|
7a198ce4a3 | ||
|
|
e0c3ea28c0 | ||
|
|
d137d1975b | ||
|
|
e7bc8b9a9a | ||
|
|
72ffe4d118 | ||
|
|
e81d197979 | ||
|
|
1dc36f4cc1 | ||
|
|
252994968d | ||
|
|
7fed9e7334 | ||
|
|
bef5bff637 | ||
|
|
498a5b3f32 | ||
|
|
b08ce534c8 | ||
|
|
56812e7844 | ||
|
|
d19c60be07 | ||
|
|
387378e935 | ||
|
|
16c3f912b1 | ||
|
|
3f0a2fed9d | ||
|
|
c8e3c9b5a2 | ||
|
|
b665106d44 | ||
|
|
88314f1c6a | ||
|
|
8c12551ac8 | ||
|
|
011d02d0e6 | ||
|
|
8ac3989ada | ||
|
|
caa22c559e | ||
|
|
7469e29877 | ||
|
|
c647e87641 | ||
|
|
de825014c0 | ||
|
|
ca7bb0cd4d | ||
|
|
158341a9ff | ||
|
|
eaad39a199 | ||
|
|
ca255d3aa3 | ||
|
|
97d15b8d15 | ||
|
|
549ccf036e | ||
|
|
8ae4909836 | ||
|
|
bfb2427a6c | ||
|
|
3571937060 | ||
|
|
f7c0b46bef | ||
|
|
ad5cda4d40 | ||
|
|
d18455b596 | ||
|
|
0ae7bd0a2d | ||
|
|
8fac172ced | ||
|
|
cff4ea726e | ||
|
|
749381bf2f | ||
|
|
6b380526ae | ||
|
|
e8dd29e183 | ||
|
|
b58e123594 | ||
|
|
899383d79e | ||
|
|
de7b120ed2 | ||
|
|
4608de5d57 | ||
|
|
a362e6c99d | ||
|
|
ed8a863c30 | ||
|
|
7086bc3e9d | ||
|
|
2144e9b622 | ||
|
|
ff5411a328 | ||
|
|
47297983c2 | ||
|
|
a774b43d63 | ||
|
|
7f8632437d | ||
|
|
6977235200 | ||
|
|
4e520bb042 | ||
|
|
45f096ec0a | ||
|
|
0ea74e18b2 | ||
|
|
ab5d368a4e | ||
|
|
ebd20e30dc | ||
|
|
c2d4335e45 | ||
|
|
38cbfa4e91 | ||
|
|
f7fd0ce825 | ||
|
|
ea550a8fcf | ||
|
|
9907e59fa7 | ||
|
|
3b0a906a97 | ||
|
|
c88f643407 | ||
|
|
89d2b0088d | ||
|
|
d03c5e2372 | ||
|
|
90d20f936e | ||
|
|
6f9371e4b0 | ||
|
|
4082763f59 | ||
|
|
42497f3176 | ||
|
|
7ea29eadf4 | ||
|
|
db8d0ecf20 | ||
|
|
bd216d7a24 | ||
|
|
23b87755cf | ||
|
|
7415e8416d | ||
|
|
8e2c79f85d | ||
|
|
ab3a9da630 | ||
|
|
7ccf781b65 | ||
|
|
30badb62e7 | ||
|
|
a0512a489d | ||
|
|
edb145a8c3 | ||
|
|
45a4978ca8 | ||
|
|
fb96551beb | ||
|
|
2fb59f8efa | ||
|
|
c28731957a | ||
|
|
0282011255 | ||
|
|
045ee573a0 | ||
|
|
839e280159 | ||
|
|
44922120a6 | ||
|
|
c7b9c06c89 | ||
|
|
258326a88c | ||
|
|
4ec828d68e | ||
|
|
d9735f5981 | ||
|
|
fa8011983d | ||
|
|
7f98771e99 | ||
|
|
7d6d71dcbe | ||
|
|
8d9f14e30b | ||
|
|
06d30d1036 | ||
|
|
f517b85a75 | ||
|
|
738b241a31 | ||
|
|
2605d48557 | ||
|
|
918d8e2f88 | ||
|
|
0dfadb3a10 | ||
|
|
27a7bdb14a | ||
|
|
5829663b95 | ||
|
|
a8eec889f5 | ||
|
|
6888394600 | ||
|
|
57b4d678d5 | ||
|
|
59dd6e6bff | ||
|
|
89dea86b3a | ||
|
|
74ee5581fe | ||
|
|
aaad76e323 | ||
|
|
7557c10095 | ||
|
|
58957a30e4 | ||
|
|
32ddcc5e61 | ||
|
|
6369fe1528 | ||
|
|
187dde436c | ||
|
|
80b35ba728 | ||
|
|
41b1d53723 | ||
|
|
9f12ef7370 | ||
|
|
7f4a9c2a9b | ||
|
|
48de2fa647 | ||
|
|
27b36b316c | ||
|
|
ca3c6ce32f | ||
|
|
0d3998f369 | ||
|
|
b929dcbeb8 | ||
|
|
b558bbdc85 | ||
|
|
bb5be4df25 | ||
|
|
eca5492a8e | ||
|
|
ac44a50d3d | ||
|
|
32b127d280 | ||
|
|
8c0827afe3 | ||
|
|
7a97c67f74 | ||
|
|
bb1cbf3de7 | ||
|
|
75d2fe393d | ||
|
|
fb081316e2 | ||
|
|
feff729eb6 | ||
|
|
69e3deb7ac | ||
|
|
c1eb56aa30 | ||
|
|
cadcdc3525 | ||
|
|
d140d2b4b2 | ||
|
|
22bba95a70 | ||
|
|
291383fe2f | ||
|
|
7e5814aaad | ||
|
|
22a607da7b | ||
|
|
d07333c70d | ||
|
|
3c87803c79 | ||
|
|
02a508270f | ||
|
|
4a51e0c0c9 | ||
|
|
edf14b80d2 | ||
|
|
3708b314d1 | ||
|
|
7da50f9248 | ||
|
|
e59f347d68 | ||
|
|
b112184b65 | ||
|
|
1a514b824f | ||
|
|
a2653037e9 | ||
|
|
cf019195af | ||
|
|
b8c245b9f3 | ||
|
|
31327f7e1b | ||
|
|
e050a763f7 | ||
|
|
4125ae0888 | ||
|
|
c299d9b632 | ||
|
|
eb5750d968 | ||
|
|
d8432b502e | ||
|
|
15685cacf8 | ||
|
|
d92bdf1c57 | ||
|
|
4f7264e565 | ||
|
|
05b19117cf | ||
|
|
991b937451 | ||
|
|
9d60f9b64f | ||
|
|
9e3afbf48a | ||
|
|
17b2315d89 | ||
|
|
7122137639 | ||
|
|
a9e4745075 | ||
|
|
fd7351ff61 | ||
|
|
22430631d2 | ||
|
|
7b8c5944a0 | ||
|
|
09198d5ad5 | ||
|
|
352ebceadd | ||
|
|
3774a78134 | ||
|
|
5b6b4c1736 | ||
|
|
8bb296f3f7 | ||
|
|
6741e43e32 | ||
|
|
d78bf2a3d2 | ||
|
|
13e757429d | ||
|
|
f21516a2f2 | ||
|
|
ccc91b499a | ||
|
|
1234c5a3e0 | ||
|
|
9129f95c39 | ||
|
|
b18373b669 | ||
|
|
5e2e8f3eb3 | ||
|
|
d7ae59886e | ||
|
|
8f75dd8652 | ||
|
|
d07f1dab31 | ||
|
|
9e8dcb2784 | ||
|
|
59f6455f13 | ||
|
|
69d51c95db | ||
|
|
dffc58aa6b | ||
|
|
c7bf3324dc | ||
|
|
cb47efe3fe | ||
|
|
94cfad445b | ||
|
|
eebc92f931 | ||
|
|
f9451ffe22 | ||
|
|
288c0b795c | ||
|
|
c134d4e975 | ||
|
|
fb390544af | ||
|
|
c93b9300da | ||
|
|
486333d342 | ||
|
|
773de25657 | ||
|
|
726462caed | ||
|
|
fe24391de8 | ||
|
|
05deca6261 | ||
|
|
755e8d3866 | ||
|
|
ffc48131de | ||
|
|
0d5d5a696a | ||
|
|
a8545d08b6 | ||
|
|
e5f20f342d | ||
|
|
c18a698e6b | ||
|
|
f74047d090 | ||
|
|
17dc22fdb0 | ||
|
|
5a2f262fe5 | ||
|
|
61e133b894 | ||
|
|
fc2e8304f5 | ||
|
|
a3f22833cd | ||
|
|
a41c00faea | ||
|
|
632f9f579c | ||
|
|
6dcae7b83e | ||
|
|
5c8fa5cb48 | ||
|
|
500642d42c | ||
|
|
bee7474b8f | ||
|
|
911c3d285e | ||
|
|
86743179a2 | ||
|
|
108694276e | ||
|
|
3d27a63b5a | ||
|
|
950498898c | ||
|
|
fab62e10fe | ||
|
|
08fa79d6d8 | ||
|
|
1b2398f13f | ||
|
|
0685ade693 | ||
|
|
10615e29f3 | ||
|
|
a380df4f21 | ||
|
|
86d92a9591 | ||
|
|
33ee9f7bd7 | ||
|
|
056e07a411 | ||
|
|
62d763614a | ||
|
|
7db4e1b8d0 | ||
|
|
db61a623d5 | ||
|
|
c813eca843 | ||
|
|
ceb63071c8 |
+1
-1
@@ -29,7 +29,7 @@ module.exports = {
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
'@typescript-eslint/ban-ts-ignore': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'error',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2-beta
|
||||
with:
|
||||
node-version: 12
|
||||
node-version: 14
|
||||
registry-url: https://registry.npmjs.org/
|
||||
- name: Get NodeJS Version
|
||||
id: nodeversion
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
- uses: actions/setup-node@v2-beta
|
||||
with:
|
||||
node-version: 12
|
||||
node-version: 14
|
||||
- run: rm -rf npm-shrinkwrap.json
|
||||
- run: npx npm-check-updates -u
|
||||
- run: npm install && npm shrinkwrap --dev
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
- uses: actions/setup-node@v2-beta
|
||||
with:
|
||||
node-version: 12
|
||||
node-version: 14
|
||||
- run: npm install zigbee2mqtt-frontend@${{ github.event.client_payload.version }} --save-exact
|
||||
- uses: peter-evans/create-pull-request@v3
|
||||
with:
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
- uses: actions/setup-node@v2-beta
|
||||
with:
|
||||
node-version: 12
|
||||
node-version: 14
|
||||
- run: npm install zigbee-herdsman@${{ github.event.client_payload.version }} --save-exact
|
||||
- uses: peter-evans/create-pull-request@v3
|
||||
with:
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
- uses: actions/setup-node@v2-beta
|
||||
with:
|
||||
node-version: 12
|
||||
node-version: 14
|
||||
- run: npm install zigbee-herdsman-converters@${{ github.event.client_payload.version }} --save-exact
|
||||
- uses: peter-evans/create-pull-request@v3
|
||||
with:
|
||||
|
||||
+4
-1
@@ -75,5 +75,8 @@ data/extension
|
||||
# MacOS indexing file
|
||||
.DS_Store
|
||||
|
||||
# IDE specific folders
|
||||
.idea
|
||||
|
||||
# Ignore config
|
||||
data/*.yaml
|
||||
data/*.yaml
|
||||
|
||||
@@ -3,4 +3,9 @@ module.exports = {
|
||||
['@babel/preset-env', {targets: {node: 'current'}}],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
assumptions: {setPublicClassFields: true},
|
||||
plugins: [
|
||||
['@babel/plugin-proposal-decorators', {'legacy': true}],
|
||||
['@babel/plugin-proposal-class-properties'],
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
require('core-js/features/object/from-entries');
|
||||
require('core-js/features/array/flat');
|
||||
const semver = require('semver');
|
||||
const engines = require('./package.json').engines;
|
||||
const indexJsRestart = 'indexjs.restart';
|
||||
@@ -5,11 +7,12 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {exec} = require('child_process');
|
||||
const rimraf = require('rimraf');
|
||||
require('source-map-support').install();
|
||||
|
||||
let controller;
|
||||
let stopping = false;
|
||||
|
||||
const hashFile = path.join('dist', '.hash');
|
||||
const hashFile = path.join(__dirname, 'dist', '.hash');
|
||||
|
||||
async function restart() {
|
||||
await stop(indexJsRestart);
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
const MQTT = require('./mqtt');
|
||||
const Zigbee = require('./zigbee');
|
||||
const EventBus = require('./eventBus');
|
||||
const State = require('./state');
|
||||
const logger = require('./util/logger');
|
||||
const settings = require('./util/settings');
|
||||
const utils = require('./util/utils');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const assert = require('assert');
|
||||
|
||||
// Extensions
|
||||
const ExtensionFrontend = require('./extension/frontend');
|
||||
const ExtensionPublish = require('./extension/publish');
|
||||
const ExtensionReceive = require('./extension/receive');
|
||||
const ExtensionNetworkMap = require('./extension/networkMap');
|
||||
const ExtensionSoftReset = require('./extension/legacy/softReset');
|
||||
const ExtensionHomeAssistant = require('./extension/homeassistant');
|
||||
const ExtensionConfigure = require('./extension/configure');
|
||||
const ExtensionDeviceGroupMembership = require('./extension/legacy/deviceGroupMembership');
|
||||
const ExtensionBridgeLegacy = require('./extension/legacy/bridgeLegacy');
|
||||
const ExtensionBridge = require('./extension/bridge');
|
||||
const ExtensionGroups = require('./extension/groups');
|
||||
const ExtensionAvailability = require('./extension/availability');
|
||||
const ExtensionBind = require('./extension/bind');
|
||||
const ExtensionReport = require('./extension/legacy/report');
|
||||
const ExtensionOnEvent = require('./extension/onEvent');
|
||||
const ExtensionOTAUpdate = require('./extension/otaUpdate');
|
||||
const ExtensionExternalConverters = require('./extension/externalConverters');
|
||||
const ExtensionExternalExtension = require('./extension/externalExtension');
|
||||
|
||||
const AllExtensions = [
|
||||
ExtensionPublish, ExtensionReceive, ExtensionNetworkMap, ExtensionSoftReset, ExtensionHomeAssistant,
|
||||
ExtensionConfigure, ExtensionDeviceGroupMembership, ExtensionBridgeLegacy, ExtensionBridge, ExtensionGroups,
|
||||
ExtensionAvailability, ExtensionBind, ExtensionReport, ExtensionOnEvent, ExtensionOTAUpdate,
|
||||
ExtensionExternalConverters, ExtensionFrontend, ExtensionExternalExtension,
|
||||
];
|
||||
|
||||
class Controller {
|
||||
constructor(restartCallback, exitCallback) {
|
||||
this.zigbee = new Zigbee();
|
||||
this.mqtt = new MQTT();
|
||||
this.eventBus = new EventBus();
|
||||
this.state = new State(this.eventBus);
|
||||
this.restartCallback = restartCallback;
|
||||
this.exitCallback = exitCallback;
|
||||
|
||||
this.publishEntityState = this.publishEntityState.bind(this);
|
||||
this.enableDisableExtension = this.enableDisableExtension.bind(this);
|
||||
this.onZigbeeAdapterDisconnected = this.onZigbeeAdapterDisconnected.bind(this);
|
||||
this.addExtension = this.addExtension.bind(this);
|
||||
|
||||
// Initialize extensions.
|
||||
const args = [this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus,
|
||||
this.enableDisableExtension, this.restartCallback, this.addExtension];
|
||||
this.extensions = [
|
||||
new ExtensionBridge(...args),
|
||||
new ExtensionPublish(...args),
|
||||
new ExtensionReceive(...args),
|
||||
new ExtensionDeviceGroupMembership(...args),
|
||||
new ExtensionConfigure(...args),
|
||||
new ExtensionNetworkMap(...args),
|
||||
new ExtensionGroups(...args),
|
||||
new ExtensionBind(...args),
|
||||
new ExtensionOnEvent(...args),
|
||||
new ExtensionOTAUpdate(...args),
|
||||
new ExtensionReport(...args),
|
||||
];
|
||||
|
||||
if (settings.get().frontend) {
|
||||
this.extensions.push(new ExtensionFrontend(...args));
|
||||
}
|
||||
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
this.extensions.push(new ExtensionBridgeLegacy(...args));
|
||||
}
|
||||
|
||||
if (settings.get().external_converters.length) {
|
||||
this.extensions.push(new ExtensionExternalConverters(...args));
|
||||
}
|
||||
|
||||
if (settings.get().homeassistant) {
|
||||
this.extensions.push(new ExtensionHomeAssistant(...args));
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (settings.get().advanced.soft_reset_timeout !== 0) {
|
||||
this.extensions.push(new ExtensionSoftReset(...args));
|
||||
}
|
||||
|
||||
if (settings.get().advanced.availability_timeout) {
|
||||
this.extensions.push(new ExtensionAvailability(...args));
|
||||
}
|
||||
this.extensions.push(new ExtensionExternalExtension(...args));
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.state.start();
|
||||
logger.logOutput();
|
||||
|
||||
const info = await utils.getZigbee2mqttVersion();
|
||||
logger.info(`Starting Zigbee2MQTT version ${info.version} (commit #${info.commitHash})`);
|
||||
|
||||
// Start zigbee
|
||||
let startResult;
|
||||
try {
|
||||
startResult = await this.zigbee.start();
|
||||
this.callExtensionMethod('onZigbeeStarted', []);
|
||||
this.zigbee.on('event', this.onZigbeeEvent.bind(this));
|
||||
this.zigbee.on('adapterDisconnected', this.onZigbeeAdapterDisconnected);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start zigbee');
|
||||
// eslint-disable-next-line
|
||||
logger.error('Check https://www.zigbee2mqtt.io/information/FAQ.html#help-zigbee2mqtt-fails-to-start for possible solutions');
|
||||
logger.error('Exiting...');
|
||||
logger.error(error.stack);
|
||||
this.exitCallback(1);
|
||||
}
|
||||
|
||||
// Disable some legacy options on new network creation
|
||||
if (startResult === 'reset') {
|
||||
settings.set(['advanced', 'homeassistant_legacy_entity_attributes'], false);
|
||||
settings.set(['advanced', 'legacy_api'], false);
|
||||
settings.set(['device_options', 'legacy'], false);
|
||||
this.enableDisableExtension(false, 'BridgeLegacy');
|
||||
}
|
||||
|
||||
// Log zigbee clients on startup
|
||||
const devices = this.zigbee.getClients();
|
||||
logger.info(`Currently ${devices.length} devices are joined:`);
|
||||
for (const device of devices) {
|
||||
const entity = this.zigbee.resolveEntity(device);
|
||||
const model = entity.definition ?
|
||||
`${entity.definition.model} - ${entity.definition.vendor} ${entity.definition.description}` :
|
||||
'Not supported';
|
||||
logger.info(`${entity.name} (${entity.device.ieeeAddr}): ${model} (${entity.device.type})`);
|
||||
}
|
||||
|
||||
// Enable zigbee join.
|
||||
if (settings.get().permit_join) {
|
||||
logger.warn('`permit_join` set to `true` in configuration.yaml.');
|
||||
logger.warn('Allowing new devices to join.');
|
||||
logger.warn('Set `permit_join` to `false` once you joined all devices.');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.zigbee.permitJoin(settings.get().permit_join);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to set permit join to ${settings.get().permit_join}`);
|
||||
}
|
||||
|
||||
// MQTT
|
||||
this.mqtt.on('message', this.onMQTTMessage.bind(this));
|
||||
await this.mqtt.connect();
|
||||
|
||||
// Send all cached states.
|
||||
if (settings.get().advanced.cache_state_send_on_startup && settings.get().advanced.cache_state) {
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
if (this.state.exists(device.ieeeAddr)) {
|
||||
this.publishEntityState(device.ieeeAddr, this.state.get(device.ieeeAddr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add devices which are in database but not in configuration to configuration
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
if (!settings.getDevice(device.ieeeAddr)) {
|
||||
settings.addDevice(device.ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
// Call extensions
|
||||
await this.callExtensionMethod('onMQTTConnected', []);
|
||||
}
|
||||
|
||||
async enableDisableExtension(enable, name) {
|
||||
if (!enable) {
|
||||
const extension = this.extensions.find((e) => e.constructor.name === name);
|
||||
if (extension) {
|
||||
await this.callExtensionMethod('stop', [], [extension]);
|
||||
this.extensions.splice(this.extensions.indexOf(extension), 1);
|
||||
}
|
||||
} else {
|
||||
const Extension = AllExtensions.find((e) => e.name === name);
|
||||
assert(Extension, `Extension '${name}' does not exist`);
|
||||
const extension = new Extension(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus,
|
||||
this.enableDisableExtension, this.restartCallback, this.addExtension);
|
||||
|
||||
this.extensions.push(extension);
|
||||
await this.callExtensionMethod('onZigbeeStarted', [], [extension]);
|
||||
await this.callExtensionMethod('onMQTTConnected', [], [extension]);
|
||||
}
|
||||
}
|
||||
|
||||
addExtension(extension) {
|
||||
this.extensions.push(extension);
|
||||
}
|
||||
|
||||
async stop(reason=null) {
|
||||
// Call extensions
|
||||
await this.callExtensionMethod('stop', []);
|
||||
|
||||
// Wrap-up
|
||||
this.state.stop();
|
||||
await this.mqtt.disconnect();
|
||||
|
||||
try {
|
||||
await this.zigbee.stop();
|
||||
logger.info('Stopped Zigbee2MQTT');
|
||||
this.exitCallback(0, reason);
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop Zigbee2MQTT');
|
||||
this.exitCallback(1, reason);
|
||||
}
|
||||
}
|
||||
|
||||
async onZigbeeAdapterDisconnected() {
|
||||
logger.error('Adapter disconnected, stopping');
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
async onZigbeeEvent(type, data) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(data.device || data.ieeeAddr);
|
||||
if (data.device && data.device.type === 'Coordinator') {
|
||||
logger.debug('Ignoring message from coordinator');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.device && !settings.getDevice(data.device.ieeeAddr)) {
|
||||
// Only deviceLeave doesn't have a device (not interesting to add to settings)
|
||||
resolvedEntity.settings = {...settings.get().device_options, ...settings.addDevice(data.device.ieeeAddr)};
|
||||
}
|
||||
|
||||
const name = resolvedEntity && resolvedEntity.settings ? resolvedEntity.settings.friendlyName : null;
|
||||
|
||||
if (type === 'message') {
|
||||
logger.debug(
|
||||
`Received Zigbee message from '${name}', type '${data.type}', cluster '${data.cluster}'` +
|
||||
`, data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}` +
|
||||
(data.hasOwnProperty('groupID') ? ` with groupID ${data.groupID}` : ``),
|
||||
);
|
||||
} else if (type === 'deviceJoined') {
|
||||
logger.info(`Device '${name}' joined`);
|
||||
} else if (type === 'deviceInterview') {
|
||||
if (data.status === 'successful') {
|
||||
logger.info(`Successfully interviewed '${name}', device has successfully been paired`);
|
||||
|
||||
if (resolvedEntity.definition) {
|
||||
const {vendor, description, model} = resolvedEntity.definition;
|
||||
logger.info(`Device '${name}' is supported, identified as: ${vendor} ${description} (${model})`);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Device '${name}' with Zigbee model '${data.device.modelID}' and manufacturer name ` +
|
||||
`'${data.device.manufacturerName}' is NOT supported, ` +
|
||||
`please follow https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html`,
|
||||
);
|
||||
}
|
||||
} else if (data.status === 'failed') {
|
||||
logger.error(`Failed to interview '${name}', device has not successfully been paired`);
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (data.status === 'started') {
|
||||
logger.info(`Starting interview of '${name}'`);
|
||||
}
|
||||
}
|
||||
} else if (type === 'deviceAnnounce') {
|
||||
logger.debug(`Device '${name}' announced itself`);
|
||||
} else if (type === 'deviceNetworkAddressChanged') {
|
||||
logger.debug(`Device '${name}' changed network address`);
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (type === 'deviceLeave') {
|
||||
const settingsDevice = settings.getDevice(data.ieeeAddr);
|
||||
logger.warn(`Device '${settingsDevice ? settingsDevice.friendlyName : data.ieeeAddr}' ` +
|
||||
`left the network`);
|
||||
this.state.remove(data.ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
// Call extensions
|
||||
const result = await this.callExtensionMethod('onZigbeeEvent', [type, data, resolvedEntity]);
|
||||
|
||||
// In case this message is not handled by the receive extension and last_seen is enabled, publish
|
||||
// a message to update the last_seen.
|
||||
if (name && !result[ExtensionReceive.prototype.constructor.name] &&
|
||||
settings.get().advanced.last_seen !== 'disable' && type !== 'deviceLeave') {
|
||||
this.publishEntityState(name, {});
|
||||
}
|
||||
}
|
||||
|
||||
onMQTTMessage(payload) {
|
||||
const {topic, message} = payload;
|
||||
logger.debug(`Received MQTT message on '${topic}' with data '${message}'`);
|
||||
|
||||
// Call extensions
|
||||
this.callExtensionMethod('onMQTTMessage', [topic, message]);
|
||||
}
|
||||
|
||||
async publishEntityState(IDorName, payload, stateChangeReason=null) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(IDorName);
|
||||
if (!resolvedEntity || !resolvedEntity.settings) {
|
||||
logger.error(`'${IDorName}' does not exist, skipping publish`);
|
||||
return;
|
||||
}
|
||||
|
||||
let messagePayload = {...payload};
|
||||
|
||||
// Update state cache with new state.
|
||||
const newState = this.state.set(resolvedEntity.settings.ID, payload, stateChangeReason);
|
||||
|
||||
if (settings.get().advanced.cache_state) {
|
||||
// Add cached state to payload
|
||||
messagePayload = newState;
|
||||
}
|
||||
|
||||
const options = {
|
||||
retain: utils.getObjectProperty(resolvedEntity.settings, 'retain', false),
|
||||
qos: utils.getObjectProperty(resolvedEntity.settings, 'qos', 0),
|
||||
};
|
||||
|
||||
const retention = utils.getObjectProperty(resolvedEntity.settings, 'retention', false);
|
||||
if (retention !== false) {
|
||||
options.properties = {messageExpiryInterval: retention};
|
||||
}
|
||||
|
||||
const isDevice = resolvedEntity.type === 'device';
|
||||
if (isDevice && settings.get().mqtt.include_device_information) {
|
||||
const attributes = [
|
||||
'ieeeAddr', 'networkAddress', 'type', 'manufacturerID', 'manufacturerName', 'powerSource',
|
||||
'applicationVersion', 'stackVersion', 'zclVersion', 'hardwareVersion', 'dateCode', 'softwareBuildID',
|
||||
];
|
||||
|
||||
messagePayload.device = {
|
||||
friendlyName: resolvedEntity.name,
|
||||
model: resolvedEntity.definition ? resolvedEntity.definition.model : 'unknown',
|
||||
};
|
||||
|
||||
attributes.forEach((a) => messagePayload.device[a] = resolvedEntity.device[a]);
|
||||
}
|
||||
|
||||
// Add lastseen
|
||||
const lastSeen = settings.get().advanced.last_seen;
|
||||
if (isDevice && lastSeen !== 'disable' && resolvedEntity.device.lastSeen) {
|
||||
messagePayload.last_seen = utils.formatDate(resolvedEntity.device.lastSeen, lastSeen);
|
||||
}
|
||||
|
||||
// Add device linkquality.
|
||||
if (resolvedEntity.type === 'device' && resolvedEntity.device.linkquality !== undefined) {
|
||||
messagePayload.linkquality = resolvedEntity.device.linkquality;
|
||||
}
|
||||
|
||||
// filter mqtt message attributes
|
||||
if (resolvedEntity.settings.filtered_attributes) {
|
||||
resolvedEntity.settings.filtered_attributes.forEach((a) => delete messagePayload[a]);
|
||||
}
|
||||
|
||||
for (const extension of this.extensions) {
|
||||
if (extension.adjustMessagePayloadBeforePublish) {
|
||||
extension.adjustMessagePayloadBeforePublish(resolvedEntity, messagePayload);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.entries(messagePayload).length) {
|
||||
const output = settings.get().experimental.output;
|
||||
if (output === 'attribute_and_json' || output === 'json') {
|
||||
await this.mqtt.publish(resolvedEntity.name, stringify(messagePayload), options);
|
||||
}
|
||||
|
||||
if (output === 'attribute_and_json' || output === 'attribute') {
|
||||
await this.iteratePayloadAttributeOutput(`${resolvedEntity.name}/`, messagePayload, options);
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.emit('publishEntityState', {messagePayload, entity: resolvedEntity, stateChangeReason, payload});
|
||||
}
|
||||
|
||||
async iteratePayloadAttributeOutput(topicRoot, payload, options) {
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
let subPayload = value;
|
||||
let message = null;
|
||||
|
||||
// Special cases
|
||||
if (key === 'color' && utils.objectHasProperties(subPayload, ['r', 'g', 'b'])) {
|
||||
subPayload = [subPayload.r, subPayload.g, subPayload.b];
|
||||
}
|
||||
|
||||
// Check Array first, since it is also an Object
|
||||
if (subPayload === null || subPayload === undefined) {
|
||||
message = '';
|
||||
} else if (Array.isArray(subPayload)) {
|
||||
message = subPayload.map((x) => `${x}`).join(',');
|
||||
} else if (typeof subPayload === 'object') {
|
||||
this.iteratePayloadAttributeOutput(`${topicRoot}${key}-`, subPayload, options);
|
||||
} else {
|
||||
message = typeof subPayload === 'string' ? subPayload : stringify(subPayload);
|
||||
}
|
||||
|
||||
if (message !== null) {
|
||||
await this.mqtt.publish(`${topicRoot}${key}`, message, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async callExtensionMethod(method, parameters, extensions=null) {
|
||||
const result = {};
|
||||
for (const extension of extensions || this.extensions) {
|
||||
if (extension[method]) {
|
||||
try {
|
||||
result[extension.constructor.name] = await extension[method](...parameters);
|
||||
} catch (error) {
|
||||
/* istanbul ignore next */
|
||||
logger.error(`Failed to call '${extension.constructor.name}' '${method}' (${error.stack})`);
|
||||
/* istanbul ignore next */
|
||||
if (process.env.JEST_WORKER_ID !== undefined) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Controller;
|
||||
@@ -0,0 +1,321 @@
|
||||
import MQTT from './mqtt';
|
||||
import Zigbee from './zigbee';
|
||||
import EventBus from './eventBus';
|
||||
import State from './state';
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import utils from './util/utils';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import assert from 'assert';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
// Extensions
|
||||
import ExtensionFrontend from './extension/frontend';
|
||||
import ExtensionPublish from './extension/publish';
|
||||
import ExtensionReceive from './extension/receive';
|
||||
import ExtensionNetworkMap from './extension/networkMap';
|
||||
import ExtensionSoftReset from './extension/legacy/softReset';
|
||||
import ExtensionHomeAssistant from './extension/homeassistant';
|
||||
import ExtensionConfigure from './extension/configure';
|
||||
import ExtensionDeviceGroupMembership from './extension/legacy/deviceGroupMembership';
|
||||
import ExtensionBridgeLegacy from './extension/legacy/bridgeLegacy';
|
||||
import ExtensionBridge from './extension/bridge';
|
||||
import ExtensionGroups from './extension/groups';
|
||||
import ExtensionAvailability from './extension/availability';
|
||||
import ExtensionBind from './extension/bind';
|
||||
import ExtensionReport from './extension/legacy/report';
|
||||
import ExtensionOnEvent from './extension/onEvent';
|
||||
import ExtensionOTAUpdate from './extension/otaUpdate';
|
||||
import ExtensionExternalConverters from './extension/externalConverters';
|
||||
import ExtensionExternalExtension from './extension/externalExtension';
|
||||
|
||||
const AllExtensions = [
|
||||
ExtensionPublish, ExtensionReceive, ExtensionNetworkMap, ExtensionSoftReset, ExtensionHomeAssistant,
|
||||
ExtensionConfigure, ExtensionDeviceGroupMembership, ExtensionBridgeLegacy, ExtensionBridge, ExtensionGroups,
|
||||
ExtensionBind, ExtensionReport, ExtensionOnEvent, ExtensionOTAUpdate,
|
||||
ExtensionExternalConverters, ExtensionFrontend, ExtensionExternalExtension, ExtensionAvailability,
|
||||
];
|
||||
|
||||
type ExtensionArgs = [Zigbee, MQTT, State, PublishEntityState, EventBus,
|
||||
(enable: boolean, name: string) => Promise<void>, () => void, (extension: Extension) => void];
|
||||
|
||||
class Controller {
|
||||
private eventBus: EventBus;
|
||||
private zigbee: Zigbee;
|
||||
private state: State;
|
||||
private mqtt: MQTT;
|
||||
private restartCallback: () => void;
|
||||
private exitCallback: (code: number) => void;
|
||||
private extensions: Extension[];
|
||||
private extensionArgs: ExtensionArgs;
|
||||
|
||||
constructor(restartCallback: () => void, exitCallback: (code: number) => void) {
|
||||
this.eventBus = new EventBus( /* istanbul ignore next */ (error) => {
|
||||
logger.error(`Error: ${error.message}`);
|
||||
logger.debug(error.stack);
|
||||
});
|
||||
this.zigbee = new Zigbee(this.eventBus);
|
||||
this.mqtt = new MQTT(this.eventBus);
|
||||
this.state = new State(this.eventBus);
|
||||
this.restartCallback = restartCallback;
|
||||
this.exitCallback = exitCallback;
|
||||
|
||||
// Initialize extensions.
|
||||
this.extensionArgs = [this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus,
|
||||
this.enableDisableExtension, this.restartCallback, this.addExtension];
|
||||
|
||||
this.extensions = [
|
||||
new ExtensionBridge(...this.extensionArgs),
|
||||
new ExtensionPublish(...this.extensionArgs),
|
||||
new ExtensionReceive(...this.extensionArgs),
|
||||
new ExtensionDeviceGroupMembership(...this.extensionArgs),
|
||||
new ExtensionConfigure(...this.extensionArgs),
|
||||
new ExtensionNetworkMap(...this.extensionArgs),
|
||||
new ExtensionGroups(...this.extensionArgs),
|
||||
new ExtensionBind(...this.extensionArgs),
|
||||
new ExtensionOnEvent(...this.extensionArgs),
|
||||
new ExtensionOTAUpdate(...this.extensionArgs),
|
||||
new ExtensionReport(...this.extensionArgs),
|
||||
new ExtensionExternalExtension(...this.extensionArgs),
|
||||
new ExtensionAvailability(...this.extensionArgs),
|
||||
settings.get().frontend && new ExtensionFrontend(...this.extensionArgs),
|
||||
settings.get().advanced.legacy_api && new ExtensionBridgeLegacy(...this.extensionArgs),
|
||||
settings.get().external_converters.length && new ExtensionExternalConverters(...this.extensionArgs),
|
||||
settings.get().homeassistant && new ExtensionHomeAssistant(...this.extensionArgs),
|
||||
/* istanbul ignore next */
|
||||
settings.get().advanced.soft_reset_timeout !== 0 && new ExtensionSoftReset(...this.extensionArgs),
|
||||
].filter((n) => n);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.state.start();
|
||||
logger.logOutput();
|
||||
|
||||
const info = await utils.getZigbee2MQTTVersion();
|
||||
logger.info(`Starting Zigbee2MQTT version ${info.version} (commit #${info.commitHash})`);
|
||||
|
||||
// Start zigbee
|
||||
let startResult;
|
||||
try {
|
||||
startResult = await this.zigbee.start();
|
||||
this.eventBus.onAdapterDisconnected(this, this.onZigbeeAdapterDisconnected);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start zigbee');
|
||||
logger.error('Check https://www.zigbee2mqtt.io/information/FAQ.html#help-zigbee2mqtt-fails-to-start for possible solutions'); /* eslint-disable-line max-len */
|
||||
logger.error('Exiting...');
|
||||
logger.error(error.stack);
|
||||
this.exitCallback(1);
|
||||
}
|
||||
|
||||
// Disable some legacy options on new network creation
|
||||
if (startResult === 'reset') {
|
||||
settings.set(['advanced', 'homeassistant_legacy_entity_attributes'], false);
|
||||
settings.set(['advanced', 'legacy_api'], false);
|
||||
settings.set(['device_options', 'legacy'], false);
|
||||
this.enableDisableExtension(false, 'BridgeLegacy');
|
||||
}
|
||||
|
||||
// Log zigbee clients on startup
|
||||
const devices = this.zigbee.devices(false);
|
||||
logger.info(`Currently ${devices.length} devices are joined:`);
|
||||
for (const device of devices) {
|
||||
const model = device.definition ?
|
||||
`${device.definition.model} - ${device.definition.vendor} ${device.definition.description}` :
|
||||
'Not supported';
|
||||
logger.info(`${device.name} (${device.ieeeAddr}): ${model} (${device.zh.type})`);
|
||||
}
|
||||
|
||||
// Enable zigbee join
|
||||
try {
|
||||
if (settings.get().permit_join) {
|
||||
logger.warn('`permit_join` set to `true` in configuration.yaml.');
|
||||
logger.warn('Allowing new devices to join.');
|
||||
logger.warn('Set `permit_join` to `false` once you joined all devices.');
|
||||
}
|
||||
|
||||
await this.zigbee.permitJoin(settings.get().permit_join);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to set permit join to ${settings.get().permit_join}`);
|
||||
}
|
||||
|
||||
// MQTT
|
||||
try {
|
||||
await this.mqtt.connect();
|
||||
} catch (error) {
|
||||
logger.error(`MQTT failed to connect: ${error.message}`);
|
||||
logger.error('Exiting...');
|
||||
await this.zigbee.stop();
|
||||
this.exitCallback(1);
|
||||
}
|
||||
|
||||
// Call extensions
|
||||
await this.callExtensions('start', [...this.extensions]);
|
||||
|
||||
// Send all cached states.
|
||||
if (settings.get().advanced.cache_state_send_on_startup && settings.get().advanced.cache_state) {
|
||||
for (const device of devices) {
|
||||
if (this.state.exists(device)) {
|
||||
this.publishEntityState(device, this.state.get(device));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.get().advanced.last_seen && settings.get().advanced.last_seen !== 'disable') {
|
||||
this.eventBus.onLastSeenChanged(this, (data) =>
|
||||
this.publishEntityState(data.device, {}, 'lastSeenChanged'));
|
||||
}
|
||||
}
|
||||
|
||||
@bind async enableDisableExtension(enable: boolean, name: string): Promise<void> {
|
||||
if (!enable) {
|
||||
const extension = this.extensions.find((e) => e.constructor.name === name);
|
||||
if (extension) {
|
||||
await this.callExtensions('stop', [extension]);
|
||||
this.extensions.splice(this.extensions.indexOf(extension), 1);
|
||||
}
|
||||
} else {
|
||||
const Extension = AllExtensions.find((e) => e.name === name);
|
||||
assert(Extension, `Extension '${name}' does not exist`);
|
||||
const extension = new Extension(...this.extensionArgs);
|
||||
this.extensions.push(extension);
|
||||
await this.callExtensions('start', [extension]);
|
||||
}
|
||||
}
|
||||
|
||||
@bind async addExtension(extension: Extension): Promise<void> {
|
||||
this.extensions.push(extension);
|
||||
await this.callExtensions('start', [extension]);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
// Call extensions
|
||||
await this.callExtensions('stop', this.extensions);
|
||||
this.eventBus.removeListeners(this);
|
||||
|
||||
// Wrap-up
|
||||
this.state.stop();
|
||||
await this.mqtt.disconnect();
|
||||
|
||||
try {
|
||||
await this.zigbee.stop();
|
||||
logger.info('Stopped Zigbee2MQTT');
|
||||
this.exitCallback(0);
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop Zigbee2MQTT');
|
||||
this.exitCallback(1);
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onZigbeeAdapterDisconnected(): Promise<void> {
|
||||
logger.error('Adapter disconnected, stopping');
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
@bind async publishEntityState(entity: Group | Device, payload: KeyValue,
|
||||
stateChangeReason?: StateChangeReason): Promise<void> {
|
||||
let message = {...payload};
|
||||
|
||||
// Update state cache with new state.
|
||||
const newState = this.state.set(entity, payload, stateChangeReason);
|
||||
|
||||
if (settings.get().advanced.cache_state) {
|
||||
// Add cached state to payload
|
||||
message = newState;
|
||||
}
|
||||
|
||||
const options: MQTTOptions = {
|
||||
retain: utils.getObjectProperty(entity.settings, 'retain', false) as boolean,
|
||||
qos: utils.getObjectProperty(entity.settings, 'qos', 0) as 0 | 1 | 2,
|
||||
};
|
||||
|
||||
const retention = utils.getObjectProperty(entity.settings, 'retention', false);
|
||||
if (retention !== false) {
|
||||
options.properties = {messageExpiryInterval: retention as number};
|
||||
}
|
||||
|
||||
if (entity.isDevice() && settings.get().mqtt.include_device_information) {
|
||||
message.device = {
|
||||
friendlyName: entity.name, model: entity.definition ? entity.definition.model : 'unknown',
|
||||
ieeeAddr: entity.ieeeAddr, networkAddress: entity.zh.networkAddress, type: entity.zh.type,
|
||||
manufacturerID: entity.zh.manufacturerID, manufacturerName: entity.zh.manufacturerName,
|
||||
powerSource: entity.zh.powerSource, applicationVersion: entity.zh.applicationVersion,
|
||||
stackVersion: entity.zh.stackVersion, zclVersion: entity.zh.zclVersion,
|
||||
hardwareVersion: entity.zh.hardwareVersion, dateCode: entity.zh.dateCode,
|
||||
softwareBuildID: entity.zh.softwareBuildID,
|
||||
};
|
||||
}
|
||||
|
||||
// Add lastseen
|
||||
const lastSeen = settings.get().advanced.last_seen;
|
||||
if (entity.isDevice() && lastSeen !== 'disable' && entity.zh.lastSeen) {
|
||||
message.last_seen = utils.formatDate(entity.zh.lastSeen, lastSeen);
|
||||
}
|
||||
|
||||
// Add device linkquality.
|
||||
if (entity.isDevice() && entity.zh.linkquality !== undefined) {
|
||||
message.linkquality = entity.zh.linkquality;
|
||||
}
|
||||
|
||||
// filter mqtt message attributes
|
||||
if (entity.settings.filtered_attributes) {
|
||||
entity.settings.filtered_attributes.forEach((a) => delete message[a]);
|
||||
}
|
||||
|
||||
for (const extension of this.extensions) {
|
||||
extension.adjustMessageBeforePublish?.(entity, message);
|
||||
}
|
||||
|
||||
if (Object.entries(message).length) {
|
||||
const output = settings.get().experimental.output;
|
||||
if (output === 'attribute_and_json' || output === 'json') {
|
||||
await this.mqtt.publish(entity.name, stringify(message), options);
|
||||
}
|
||||
|
||||
if (output === 'attribute_and_json' || output === 'attribute') {
|
||||
await this.iteratePayloadAttributeOutput(`${entity.name}/`, message, options);
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.emitPublishEntityState({entity, message, stateChangeReason});
|
||||
}
|
||||
|
||||
async iteratePayloadAttributeOutput(topicRoot: string, payload: KeyValue, options: MQTTOptions): Promise<void> {
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
let subPayload = value;
|
||||
let message = null;
|
||||
|
||||
// Special cases
|
||||
if (key === 'color' && utils.objectHasProperties(subPayload, ['r', 'g', 'b'])) {
|
||||
subPayload = [subPayload.r, subPayload.g, subPayload.b];
|
||||
}
|
||||
|
||||
// Check Array first, since it is also an Object
|
||||
if (subPayload === null || subPayload === undefined) {
|
||||
message = '';
|
||||
} else if (Array.isArray(subPayload)) {
|
||||
message = subPayload.map((x) => `${x}`).join(',');
|
||||
} else if (typeof subPayload === 'object') {
|
||||
await this.iteratePayloadAttributeOutput(`${topicRoot}${key}-`, subPayload, options);
|
||||
} else {
|
||||
message = typeof subPayload === 'string' ? subPayload : stringify(subPayload);
|
||||
}
|
||||
|
||||
if (message !== null) {
|
||||
await this.mqtt.publish(`${topicRoot}${key}`, message, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async callExtensions(method: 'start' | 'stop', extensions: Extension[]): Promise<void> {
|
||||
for (const extension of extensions) {
|
||||
try {
|
||||
await extension[method]?.();
|
||||
} catch (error) {
|
||||
/* istanbul ignore next */
|
||||
logger.error(`Failed to call '${extension.constructor.name}' '${method}' (${error.stack})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Controller;
|
||||
@@ -1,41 +0,0 @@
|
||||
const events = require('events');
|
||||
const assert = require('assert');
|
||||
|
||||
const allowedEvents = [
|
||||
'deviceRemoved', // Device has been removed
|
||||
'deviceRenamed', // Device has been renamed
|
||||
'groupRenamed', // Group has been renamed
|
||||
'publishEntityState', // Entity state will be published
|
||||
'stateChange', // Entity changes its state
|
||||
'groupMembersChanged', // Members of a group has been changed
|
||||
'reportingDisabled', // Reporting is disabled for a device
|
||||
'devicesChanged', // Devices changed
|
||||
];
|
||||
|
||||
class EventBus extends events.EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.callbackByExtension = {};
|
||||
}
|
||||
|
||||
emit(event, data) {
|
||||
assert(allowedEvents.includes(event), `Event '${event}' not supported`);
|
||||
super.emit(event, data);
|
||||
}
|
||||
|
||||
on(event, callback, extension) {
|
||||
assert(allowedEvents.includes(event), `Event '${event}' not supported`);
|
||||
assert(extension, `Extension cannot be null`);
|
||||
if (!this.callbackByExtension[extension]) this.callbackByExtension[extension] = [];
|
||||
this.callbackByExtension[extension].push({event, callback});
|
||||
super.on(event, callback);
|
||||
}
|
||||
|
||||
removeListenersExtension(extension) {
|
||||
for (const entry of this.callbackByExtension[extension] || []) {
|
||||
super.removeListener(entry.event, entry.callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EventBus;
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import events from 'events';
|
||||
events.captureRejections = true;
|
||||
|
||||
// eslint-disable-next-line
|
||||
type ListenerKey = object;
|
||||
|
||||
export default class EventBus {
|
||||
private callbacksByExtension: { [s: string]: { event: string, callback: (...args: unknown[]) => void }[] } = {};
|
||||
private emitter = new events.EventEmitter();
|
||||
|
||||
constructor(onError: (error: Error) => void) {
|
||||
this.emitter.setMaxListeners(100);
|
||||
this.emitter.on('error', onError);
|
||||
}
|
||||
|
||||
public emitAdapterDisconnected(): void {
|
||||
this.emitter.emit('adapterDisconnected');
|
||||
}
|
||||
public onAdapterDisconnected(key: ListenerKey, callback: () => void): void {
|
||||
this.on('adapterDisconnected', callback, key);
|
||||
}
|
||||
|
||||
public emitPermitJoinChanged(data: eventdata.PermitJoinChanged): void {
|
||||
this.emitter.emit('permitJoinChanged', data);
|
||||
}
|
||||
public onPermitJoinChanged(key: ListenerKey, callback: (data: eventdata.PermitJoinChanged) => void): void {
|
||||
this.on('permitJoinChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceRenamed(data: eventdata.DeviceRenamed): void {
|
||||
this.emitter.emit('deviceRenamed', data);
|
||||
}
|
||||
public onDeviceRenamed(key: ListenerKey, callback: (data: eventdata.DeviceRenamed) => void): void {
|
||||
this.on('deviceRenamed', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceRemoved(data: eventdata.DeviceRemoved): void {
|
||||
this.emitter.emit('deviceRemoved', data);
|
||||
}
|
||||
public onDeviceRemoved(key: ListenerKey, callback: (data: eventdata.DeviceRemoved) => void): void {
|
||||
this.on('deviceRemoved', callback, key);
|
||||
}
|
||||
|
||||
public emitLastSeenChanged(data: eventdata.LastSeenChanged): void {
|
||||
this.emitter.emit('lastSeenChanged', data);
|
||||
}
|
||||
public onLastSeenChanged(key: ListenerKey, callback: (data: eventdata.LastSeenChanged) => void): void {
|
||||
this.on('lastSeenChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceNetworkAddressChanged(data: eventdata.DeviceNetworkAddressChanged): void {
|
||||
this.emitter.emit('deviceNetworkAddressChanged', data);
|
||||
}
|
||||
public onDeviceNetworkAddressChanged(
|
||||
key: ListenerKey, callback: (data: eventdata.DeviceNetworkAddressChanged) => void): void {
|
||||
this.on('deviceNetworkAddressChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceAnnounce(data: eventdata.DeviceAnnounce): void {
|
||||
this.emitter.emit('deviceAnnounce', data);
|
||||
}
|
||||
public onDeviceAnnounce(key: ListenerKey, callback: (data: eventdata.DeviceAnnounce) => void): void {
|
||||
this.on('deviceAnnounce', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceInterview(data: eventdata.DeviceInterview): void {
|
||||
this.emitter.emit('deviceInterview', data);
|
||||
}
|
||||
public onDeviceInterview(key: ListenerKey, callback: (data: eventdata.DeviceInterview) => void): void {
|
||||
this.on('deviceInterview', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceJoined(data: eventdata.DeviceJoined): void {
|
||||
this.emitter.emit('deviceJoined', data);
|
||||
}
|
||||
public onDeviceJoined(key: ListenerKey, callback: (data: eventdata.DeviceJoined) => void): void {
|
||||
this.on('deviceJoined', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceLeave(data: eventdata.DeviceLeave): void {
|
||||
this.emitter.emit('deviceLeave', data);
|
||||
}
|
||||
public onDeviceLeave(key: ListenerKey, callback: (data: eventdata.DeviceLeave) => void): void {
|
||||
this.on('deviceLeave', callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceMessage(data: eventdata.DeviceMessage): void {
|
||||
this.emitter.emit('deviceMessage', data);
|
||||
}
|
||||
public onDeviceMessage(key: ListenerKey, callback: (data: eventdata.DeviceMessage) => void): void {
|
||||
this.on('deviceMessage', callback, key);
|
||||
}
|
||||
|
||||
public emitMQTTMessage(data: eventdata.MQTTMessage): void {
|
||||
this.emitter.emit('mqttMessage', data);
|
||||
}
|
||||
public onMQTTMessage(key: ListenerKey, callback: (data: eventdata.MQTTMessage) => void): void {
|
||||
this.on('mqttMessage', callback, key);
|
||||
}
|
||||
|
||||
public emitMQTTMessagePublished(data: eventdata.MQTTMessagePublished): void {
|
||||
this.emitter.emit('mqttMessagePublished', data);
|
||||
}
|
||||
public onMQTTMessagePublished(key: ListenerKey, callback: (data: eventdata.MQTTMessagePublished) => void): void {
|
||||
this.on('mqttMessagePublished', callback, key);
|
||||
}
|
||||
|
||||
public emitPublishEntityState(data: eventdata.PublishEntityState): void {
|
||||
this.emitter.emit('publishEntityState', data);
|
||||
}
|
||||
public onPublishEntityState(key: ListenerKey, callback: (data: eventdata.PublishEntityState) => void): void {
|
||||
this.on('publishEntityState', callback, key);
|
||||
}
|
||||
|
||||
public emitGroupMembersChanged(data: eventdata.GroupMembersChanged): void {
|
||||
this.emitter.emit('groupMembersChanged', data);
|
||||
}
|
||||
public onGroupMembersChanged(key: ListenerKey, callback: (data: eventdata.GroupMembersChanged) => void): void {
|
||||
this.on('groupMembersChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitDevicesChanged(): void {
|
||||
this.emitter.emit('devicesChanged');
|
||||
}
|
||||
public onDevicesChanged(key: ListenerKey, callback: () => void): void {
|
||||
this.on('devicesChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitScenesChanged(): void {
|
||||
this.emitter.emit('scenesChanged');
|
||||
}
|
||||
public onScenesChanged(key: ListenerKey, callback: () => void): void {
|
||||
this.on('scenesChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitReportingDisabled(data: eventdata.ReportingDisabled): void {
|
||||
this.emitter.emit('reportingDisabled', data);
|
||||
}
|
||||
public onReportingDisabled(key: ListenerKey, callback: (data: eventdata.ReportingDisabled) => void): void {
|
||||
this.on('reportingDisabled', callback, key);
|
||||
}
|
||||
|
||||
public emitStateChange(data: eventdata.StateChange): void {
|
||||
this.emitter.emit('stateChange', data);
|
||||
}
|
||||
public onStateChange(key: ListenerKey, callback: (data: eventdata.StateChange) => void): void {
|
||||
this.on('stateChange', callback, key);
|
||||
}
|
||||
|
||||
private on(event: string, callback: (...args: unknown[]) => void, key: ListenerKey): void {
|
||||
if (!this.callbacksByExtension[key.constructor.name]) this.callbacksByExtension[key.constructor.name] = [];
|
||||
this.callbacksByExtension[key.constructor.name].push({event, callback});
|
||||
this.emitter.on(event, callback);
|
||||
}
|
||||
|
||||
public removeListeners(key: ListenerKey): void {
|
||||
this.callbacksByExtension[key.constructor.name]?.forEach(
|
||||
(e) => this.emitter.removeListener(e.event, e.callback));
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
const logger = require('../util/logger');
|
||||
const settings = require('../util/settings');
|
||||
const utils = require('../util/utils');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const Extension = require('./extension');
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/(.*)/availability`);
|
||||
|
||||
// Pingable end devices, some end devices should be pinged
|
||||
// e.g. E11-G13 https://github.com/Koenkk/zigbee2mqtt/issues/775#issuecomment-453683846
|
||||
const pingableEndDevices = [
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'E11-G13'),
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'E11-N1EA'),
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === '53170161'),
|
||||
];
|
||||
|
||||
const Hours25 = 1000 * 60 * 60 * 25;
|
||||
const AvailabilityLagRatio = 0.1;
|
||||
|
||||
|
||||
function timeoutLag(timeout, ratio) {
|
||||
const lag = timeout * ratio;
|
||||
return Math.floor(Math.random() * Math.floor(lag));
|
||||
}
|
||||
|
||||
/**
|
||||
* This extensions pings devices to check if they are online.
|
||||
*/
|
||||
class Availability extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
|
||||
this.availability_timeout = settings.get().advanced.availability_timeout;
|
||||
this.timers = {};
|
||||
this.state = {};
|
||||
|
||||
this.eventBus.on('deviceRemoved', (data) => this.onDeviceRemoved(data.resolvedEntity), this.constructor.name);
|
||||
this.eventBus.on('deviceRenamed', (data) => this.onDeviceRenamed(data), this.constructor.name);
|
||||
|
||||
this.blocklist = settings.get().advanced.availability_blocklist
|
||||
.concat(settings.get().advanced.availability_blacklist)
|
||||
.map((e) => settings.getEntity(e).ID);
|
||||
|
||||
this.passlist = settings.get().advanced.availability_passlist
|
||||
.concat(settings.get().advanced.availability_whitelist)
|
||||
.map((e) => settings.getEntity(e).ID);
|
||||
}
|
||||
|
||||
onDeviceRenamed(data) {
|
||||
this.mqtt.publish(`${data.from}/availability`, null, {retain: true, qos: 0});
|
||||
}
|
||||
|
||||
onDeviceRemoved(resolvedEntity) {
|
||||
this.mqtt.publish(`${resolvedEntity.name}/availability`, null, {retain: true, qos: 0});
|
||||
delete this.state[resolvedEntity.device.ieeeAddr];
|
||||
clearTimeout(this.timers[resolvedEntity.device.ieeeAddr]);
|
||||
}
|
||||
|
||||
inPasslistOrNotInBlocklist(device) {
|
||||
const ieeeAddr = device.ieeeAddr;
|
||||
const deviceSettings = settings.getDevice(ieeeAddr);
|
||||
const name = deviceSettings.friendlyName;
|
||||
|
||||
// Passlist is not empty and device is in it, enable availability
|
||||
if (this.passlist.length > 0) {
|
||||
return this.passlist.includes(ieeeAddr) || (name && this.passlist.includes(name));
|
||||
}
|
||||
|
||||
// Device is on blocklist, disable availability
|
||||
if (this.blocklist.includes(ieeeAddr) || (name && this.blocklist.includes(name))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
isPingable(device) {
|
||||
if (pingableEndDevices.find((d) => d.hasOwnProperty('zigbeeModel') && d.zigbeeModel.includes(device.modelID))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Device is a mains powered router
|
||||
return device.type === 'Router' && device.powerSource !== 'Battery';
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
// Mark all devices as online on start
|
||||
const ieeeAddr = device.ieeeAddr;
|
||||
this.publishAvailability(device, this.state.hasOwnProperty(ieeeAddr) ? this.state[ieeeAddr] : true, true);
|
||||
|
||||
if (this.inPasslistOrNotInBlocklist(device)) {
|
||||
if (this.isPingable(device)) {
|
||||
this.setTimerPingable(device);
|
||||
} else {
|
||||
this.timers[ieeeAddr] = setInterval(() => {
|
||||
this.handleIntervalNotPingable(device);
|
||||
}, utils.secondsToMilliseconds(300));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
// Clear topics for non-existing devices
|
||||
const match = topic.match(topicRegex);
|
||||
if (match && (!this.zigbee.resolveEntity(match[1]) || this.zigbee.resolveEntity(match[1]).name !== match[1])) {
|
||||
this.mqtt.publish(`${match[1]}/availability`, null, {retain: true, qos: 0});
|
||||
}
|
||||
}
|
||||
|
||||
async handleIntervalPingable(device) {
|
||||
// When a device is already unavailable, log the ping failed on 'debug' instead of 'error'.
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device.ieeeAddr);
|
||||
if (!resolvedEntity) {
|
||||
logger.debug(`Stop pinging '${device.ieeeAddr}', device is not known anymore`);
|
||||
return;
|
||||
}
|
||||
|
||||
const level = this.state.hasOwnProperty(device.ieeeAddr) && !this.state[device.ieeeAddr] ? 'debug' : 'error';
|
||||
try {
|
||||
await device.ping();
|
||||
this.publishAvailability(device, true);
|
||||
logger.debug(`Successfully pinged '${resolvedEntity.name}'`);
|
||||
} catch (error) {
|
||||
this.publishAvailability(device, false);
|
||||
logger[level](`Failed to ping '${resolvedEntity.name}'`);
|
||||
} finally {
|
||||
this.setTimerPingable(device);
|
||||
}
|
||||
}
|
||||
|
||||
async handleIntervalNotPingable(device) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device.ieeeAddr);
|
||||
if (!resolvedEntity || !device.lastSeen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ago = Date.now() - resolvedEntity.device.lastSeen;
|
||||
logger.debug(`Non-pingable device '${resolvedEntity.name}' was last seen '${ago / 1000}' seconds ago.`);
|
||||
|
||||
if (ago > Hours25) {
|
||||
this.publishAvailability(device, false);
|
||||
}
|
||||
}
|
||||
|
||||
setTimerPingable(device) {
|
||||
const timeout = this.availability_timeout + timeoutLag(this.availability_timeout, AvailabilityLagRatio);
|
||||
clearTimeout(this.timers[device.ieeeAddr]);
|
||||
this.timers[device.ieeeAddr] = setTimeout(async () => {
|
||||
await this.handleIntervalPingable(device);
|
||||
}, utils.secondsToMilliseconds(timeout));
|
||||
}
|
||||
|
||||
async stop() {
|
||||
super.stop();
|
||||
for (const timer of Object.values(this.timers)) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
this.zigbee.getClients().forEach((device) => this.publishAvailability(device, false));
|
||||
}
|
||||
|
||||
async onReconnect(device) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
if (resolvedEntity && resolvedEntity.definition) {
|
||||
try {
|
||||
for (const key of ['state', 'brightness', 'color', 'color_temp']) {
|
||||
const converter = resolvedEntity.definition.toZigbee.find((tz) => tz.key.includes(key));
|
||||
if (converter) {
|
||||
await converter.convertGet(device.endpoints[0], key, {});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read state of '${resolvedEntity.name}' after reconnect`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publishAvailability(device, available, force=false) {
|
||||
const ieeeAddr = device.ieeeAddr;
|
||||
if (this.state.hasOwnProperty(ieeeAddr) && !this.state[ieeeAddr] && available) {
|
||||
this.onReconnect(device);
|
||||
}
|
||||
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
const topic = `${resolvedEntity.name}/availability`;
|
||||
const payload = available ? 'online' : 'offline';
|
||||
if (this.state[ieeeAddr] !== available || force) {
|
||||
this.state[ieeeAddr] = available;
|
||||
this.mqtt.publish(topic, payload, {retain: true, qos: 0});
|
||||
}
|
||||
}
|
||||
|
||||
onZigbeeEvent(type, data, resolvedEntity) {
|
||||
const device = data.device;
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.inPasslistOrNotInBlocklist(device)) {
|
||||
this.publishAvailability(data.device, true);
|
||||
|
||||
if (this.isPingable(device)) {
|
||||
// When a zigbee message from a device is received we know the device is still alive.
|
||||
// => reset the timer.
|
||||
this.setTimerPingable(device);
|
||||
|
||||
const online = this.state.hasOwnProperty(device.ieeeAddr) && this.state[device.ieeeAddr];
|
||||
if (online && type === 'deviceAnnounce' && !utils.isIkeaTradfriDevice(device)) {
|
||||
/**
|
||||
* In case the device is powered off AND on within the availability timeout,
|
||||
* zigbee2qmtt does not detect the device as offline (device is still marked online).
|
||||
* When a device is turned on again the state could be out of sync.
|
||||
* https://github.com/Koenkk/zigbee2mqtt/issues/1383#issuecomment-489412168
|
||||
* deviceAnnounce is typically send when a device comes online.
|
||||
*
|
||||
* This isn't needed for TRADFRI devices as they already send the state themself.
|
||||
*/
|
||||
this.onReconnect(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Availability;
|
||||
@@ -0,0 +1,188 @@
|
||||
import Extension from './extension';
|
||||
import logger from '../util/logger';
|
||||
import utils from '../util/utils';
|
||||
import * as settings from '../util/settings';
|
||||
import debounce from 'debounce';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
export default class Availability extends Extension {
|
||||
private timers: {[s: string]: NodeJS.Timeout} = {};
|
||||
private availabilityCache: {[s: string]: boolean} = {};
|
||||
private retrieveStateDebouncers: {[s: string]: () => void} = {};
|
||||
private pingQueue: Device[] = [];
|
||||
private pingQueueExecuting = false;
|
||||
|
||||
private getTimeout(device: Device): number {
|
||||
if (typeof device.settings.availability === 'object' && device.settings.availability?.timeout != null) {
|
||||
return utils.minutes(device.settings.availability.timeout);
|
||||
}
|
||||
|
||||
const key = this.isActiveDevice(device) ? 'active' : 'passive';
|
||||
const availabilitySettings = settings.get().availability;
|
||||
if (typeof availabilitySettings === 'object' && availabilitySettings[key]?.timeout != null) {
|
||||
return utils.minutes(availabilitySettings[key]?.timeout);
|
||||
}
|
||||
|
||||
return key === 'active' ? utils.minutes(10) : utils.hours(25);
|
||||
}
|
||||
|
||||
private isActiveDevice(device: Device): boolean {
|
||||
return (device.zh.type === 'Router' && device.zh.powerSource !== 'Battery') ||
|
||||
device.zh.powerSource === 'Mains (single phase)';
|
||||
}
|
||||
|
||||
private isAvailable(device: Device): boolean {
|
||||
const ago = Date.now() - device.zh.lastSeen;
|
||||
return ago < this.getTimeout(device);
|
||||
}
|
||||
|
||||
private resetTimer(device: Device): void {
|
||||
clearTimeout(this.timers[device.ieeeAddr]);
|
||||
|
||||
// If the timer triggers, the device is not avaiable anymore otherwise resetTimer already have been called
|
||||
if (this.isActiveDevice(device)) {
|
||||
// If device did not check in, ping it, if that fails it will be marked as offline
|
||||
this.timers[device.ieeeAddr] = setTimeout(
|
||||
() => this.addToPingQueue(device), this.getTimeout(device) + utils.seconds(1));
|
||||
} else {
|
||||
this.timers[device.ieeeAddr] = setTimeout(
|
||||
() => this.publishAvailability(device, true), this.getTimeout(device) + utils.seconds(1));
|
||||
}
|
||||
}
|
||||
|
||||
private addToPingQueue(device: Device): void {
|
||||
this.pingQueue.push(device);
|
||||
this.pingQueueExecuteNext();
|
||||
}
|
||||
|
||||
private removeFromPingQueue(device: Device): void {
|
||||
const index = this.pingQueue.findIndex((d) => d.ieeeAddr === device.ieeeAddr);
|
||||
index != -1 && this.pingQueue.splice(index, 1);
|
||||
}
|
||||
|
||||
private async pingQueueExecuteNext(): Promise<void> {
|
||||
if (this.pingQueue.length === 0 || this.pingQueueExecuting) return;
|
||||
this.pingQueueExecuting = true;
|
||||
|
||||
const device = this.pingQueue[0];
|
||||
let pingedSuccessfully = false;
|
||||
const available = this.availabilityCache[device.ieeeAddr] || this.isAvailable(device);
|
||||
const attempts = available ? 2 : 1;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
// Enable recovery if device is marked as available and first ping fails.
|
||||
const disableRecovery = !(i == 1 && available);
|
||||
await device.zh.ping(disableRecovery);
|
||||
pingedSuccessfully = true;
|
||||
logger.debug(`Succesfully pinged '${device.name}' (attempt ${i + 1}/${attempts})`);
|
||||
break;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ping '${device.name}' (attempt ${i + 1}/${attempts}, ${error.message})`);
|
||||
// Try again in 3 seconds.
|
||||
const lastAttempt = i - 1 === attempts;
|
||||
!lastAttempt && await utils.sleep(3);
|
||||
}
|
||||
}
|
||||
|
||||
this.publishAvailability(device, !pingedSuccessfully);
|
||||
this.resetTimer(device);
|
||||
this.removeFromPingQueue(device);
|
||||
|
||||
// Sleep 2 seconds before executing next ping
|
||||
await utils.sleep(2);
|
||||
this.pingQueueExecuting = false;
|
||||
this.pingQueueExecuteNext();
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
logger.warn('Using experimental new availability feature');
|
||||
|
||||
this.eventBus.onDeviceRenamed(this, (data: eventdata.DeviceRenamed) =>
|
||||
this.publishAvailability(data.device, false, true));
|
||||
this.eventBus.onDeviceLeave(this, (data: eventdata.DeviceLeave) => clearTimeout(this.timers[data.ieeeAddr]));
|
||||
this.eventBus.onDeviceAnnounce(this, (data: eventdata.DeviceAnnounce) => this.retrieveState(data.device));
|
||||
this.eventBus.onLastSeenChanged(this, this.onLastSeenChanged);
|
||||
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
if (utils.isAvailabilityEnabledForDevice(device, settings.get())) {
|
||||
// Publish initial availablility
|
||||
this.publishAvailability(device, true);
|
||||
|
||||
this.resetTimer(device);
|
||||
|
||||
// If an active device is initially unavailable, ping it.
|
||||
if (this.isActiveDevice(device) && !this.isAvailable(device)) {
|
||||
this.addToPingQueue(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private publishAvailability(device: Device, logLastSeen: boolean, forcePublish=false): void {
|
||||
if (logLastSeen) {
|
||||
const ago = Date.now() - device.zh.lastSeen;
|
||||
if (this.isActiveDevice(device)) {
|
||||
logger.debug(`Active device '${device.name}' was last seen ` +
|
||||
`'${(ago / utils.minutes(1)).toFixed(2)}' minutes ago.`);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Passive device '${device.name}' was last seen '${(ago / utils.hours(1)).toFixed(2)}' hours ago.`);
|
||||
}
|
||||
}
|
||||
|
||||
const available = this.isAvailable(device);
|
||||
if (!forcePublish && this.availabilityCache[device.ieeeAddr] == available) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.ieeeAddr in this.availabilityCache && available &&
|
||||
this.availabilityCache[device.ieeeAddr] === false) {
|
||||
logger.debug(`Device '${device.name}' reconnected`);
|
||||
this.retrieveState(device);
|
||||
}
|
||||
|
||||
const topic = `${device.name}/availability`;
|
||||
const payload = available ? 'online' : 'offline';
|
||||
this.availabilityCache[device.ieeeAddr] = available;
|
||||
this.mqtt.publish(topic, payload, {retain: true, qos: 0});
|
||||
}
|
||||
|
||||
@bind private onLastSeenChanged(data: eventdata.LastSeenChanged): void {
|
||||
if (utils.isAvailabilityEnabledForDevice(data.device, settings.get())) {
|
||||
// Remove from ping queue, not necessary anymore since we know the device is online.
|
||||
this.removeFromPingQueue(data.device);
|
||||
this.resetTimer(data.device);
|
||||
this.publishAvailability(data.device, false);
|
||||
}
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
super.stop();
|
||||
Object.values(this.timers).forEach((t) => clearTimeout(t));
|
||||
}
|
||||
|
||||
private retrieveState(device: Device): void {
|
||||
/**
|
||||
* Retrieve state of a device in a debounced manner, this function is called on a 'deviceAnnounce' which a
|
||||
* device can send multiple times after each other.
|
||||
*/
|
||||
if (device.definition && !device.zh.interviewing && !this.retrieveStateDebouncers[device.ieeeAddr]) {
|
||||
this.retrieveStateDebouncers[device.ieeeAddr] = debounce(async () => {
|
||||
try {
|
||||
logger.debug(`Retrieving state of '${device.name}' after reconnect`);
|
||||
// Color and color temperature converters do both, only needs to be called once.
|
||||
const keySet = [['state'], ['brightness'], ['color', 'color_temp']];
|
||||
for (const keys of keySet) {
|
||||
const converter = device.definition.toZigbee.find((c) => c.key.find((k) => keys.includes(k)));
|
||||
await converter?.convertGet?.(device.endpoint(), keys[0],
|
||||
{message: this.state.get(device) || {}, mapped: device.definition});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read state of '${device.name}' after reconnect (${error.message})`);
|
||||
}
|
||||
}, utils.seconds(2));
|
||||
}
|
||||
|
||||
this.retrieveStateDebouncers[device.ieeeAddr]?.();
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,43 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const utils = require('../util/utils');
|
||||
import * as settings from '../util/settings';
|
||||
import logger from '../util/logger';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import debounce from 'debounce';
|
||||
import * as zigbeeHersdman from 'zigbee-herdsman/dist';
|
||||
import bind from 'bind-decorator';
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
|
||||
const legacyApi = settings.get().advanced.legacy_api;
|
||||
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/(bind|unbind)/.+$`);
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
|
||||
const Extension = require('./extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const debounce = require('debounce');
|
||||
const zigbeeHersdman = require('zigbee-herdsman');
|
||||
|
||||
const clusterCandidates = ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl', 'closuresWindowCovering',
|
||||
'hvacThermostat', 'msTemperatureMeasurement'];
|
||||
|
||||
// See zigbee-herdsman-converters
|
||||
const defaultBindGroup = {type: 'group_number', ID: 901};
|
||||
const defaultBindGroup = {type: 'group_number', ID: 901, name: 'default_bind_group'};
|
||||
|
||||
const defaultReportConfiguration = {
|
||||
minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1,
|
||||
};
|
||||
|
||||
const getColorCapabilities = async (endpoint) => {
|
||||
const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean, colorXY: boolean}> => {
|
||||
if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') == null) {
|
||||
await endpoint.read('lightingColorCtrl', ['colorCapabilities']);
|
||||
}
|
||||
|
||||
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities');
|
||||
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number;
|
||||
return {
|
||||
colorTemperature: (value & 1<<4) > 0,
|
||||
colorXY: (value & 1<<3) > 0,
|
||||
};
|
||||
};
|
||||
|
||||
const reportClusters = {
|
||||
const reportClusters: {[s: string]:
|
||||
{attribute: string, minimumReportInterval: number, maximumReportInterval: number, reportableChange: number
|
||||
condition?: (endpoint: zh.Endpoint) => Promise<boolean>}[]} =
|
||||
{
|
||||
'genOnOff': [
|
||||
{attribute: 'onOff', ...defaultReportConfiguration, minimumReportInterval: 0, reportableChange: 0},
|
||||
],
|
||||
@@ -40,15 +47,15 @@ const reportClusters = {
|
||||
'lightingColorCtrl': [
|
||||
{
|
||||
attribute: 'colorTemperature', ...defaultReportConfiguration,
|
||||
condition: async (endpoint) => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
},
|
||||
{
|
||||
attribute: 'currentX', ...defaultReportConfiguration,
|
||||
condition: async (endpoint) => (await getColorCapabilities(endpoint)).colorXY,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
{
|
||||
attribute: 'currentY', ...defaultReportConfiguration,
|
||||
condition: async (endpoint) => (await getColorCapabilities(endpoint)).colorXY,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
],
|
||||
'closuresWindowCovering': [
|
||||
@@ -57,7 +64,13 @@ const reportClusters = {
|
||||
],
|
||||
};
|
||||
|
||||
const pollOnMessage = [
|
||||
type PollOnMessage = {
|
||||
cluster: {[s: string]: {type: string, data: KeyValue}[]}
|
||||
read: {cluster: string, attributes: string[], attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise<string[]>}
|
||||
manufacturerIDs: number[]
|
||||
}[];
|
||||
|
||||
const pollOnMessage: PollOnMessage = [
|
||||
{
|
||||
// On messages that have the cluster and type of below
|
||||
cluster: {
|
||||
@@ -74,6 +87,9 @@ const pollOnMessage = [
|
||||
{type: 'commandMove', data: {}},
|
||||
{type: 'commandMoveToLevelWithOnOff', data: {}},
|
||||
],
|
||||
genScenes: [
|
||||
{type: 'commandRecall', data: {}},
|
||||
],
|
||||
},
|
||||
// Read the following attributes
|
||||
read: {cluster: 'genLevelCtrl', attributes: ['currentLevel']},
|
||||
@@ -99,6 +115,9 @@ const pollOnMessage = [
|
||||
{type: 'commandOffWithEffect', data: {}},
|
||||
{type: 'commandToggle', data: {}},
|
||||
],
|
||||
genScenes: [
|
||||
{type: 'commandRecall', data: {}},
|
||||
],
|
||||
manuSpecificPhilips: [
|
||||
{type: 'commandHueNotification', data: {button: 1}},
|
||||
{type: 'commandHueNotification', data: {button: 4}},
|
||||
@@ -112,31 +131,62 @@ const pollOnMessage = [
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.MUELLER_LICHT_INT,
|
||||
],
|
||||
},
|
||||
{
|
||||
cluster: {
|
||||
genScenes: [
|
||||
{type: 'commandRecall', data: {}},
|
||||
],
|
||||
},
|
||||
read: {
|
||||
cluster: 'lightingColorCtrl',
|
||||
attributes: [] as string[],
|
||||
// Since not all devices support the same attributes they need to be calculated dynamically
|
||||
// depending on the capabilities of the endpoint.
|
||||
attributesForEndpoint: async (endpoint): Promise<string[]> => {
|
||||
const supportedAttrs = await getColorCapabilities(endpoint);
|
||||
const readAttrs: string[] = [];
|
||||
supportedAttrs.colorXY && readAttrs.push('currentX', 'currentY');
|
||||
supportedAttrs.colorTemperature && readAttrs.push('colorTemperature');
|
||||
return readAttrs;
|
||||
},
|
||||
},
|
||||
manufacturerIDs: [
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.Philips,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.ATMEL,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
|
||||
zigbeeHersdman.Zcl.ManufacturerCode.MUELLER_LICHT_INT,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
class Bind extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.legacyApi = settings.get().advanced.legacy_api;
|
||||
this.eventBus.on(`groupMembersChanged`, (d) => this.groupMembersChanged(d), this.constructor.name);
|
||||
this.pollDebouncers = {};
|
||||
interface ParsedMQTTMessage {
|
||||
type: 'bind' | 'unbind', sourceKey: string, targetKey: string, clusters: string[], skipDisableReporting: boolean
|
||||
}
|
||||
|
||||
export default class Bind extends Extension {
|
||||
private pollDebouncers: {[s: string]: () => void} = {};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onDeviceMessage(this, this.poll);
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.eventBus.onGroupMembersChanged(this, this.onGroupMembersChanged);
|
||||
}
|
||||
|
||||
parseMQTTMessage(topic, message) {
|
||||
let type = null;
|
||||
private parseMQTTMessage(data: eventdata.MQTTMessage): ParsedMQTTMessage {
|
||||
let type: 'bind' | 'unbind' = null;
|
||||
let sourceKey = null;
|
||||
let targetKey = null;
|
||||
let clusters = null;
|
||||
let skipDisableReporting = false;
|
||||
|
||||
if (this.legacyApi && topic.match(legacyTopicRegex)) {
|
||||
topic = topic.replace(`${settings.get().mqtt.base_topic}/bridge/`, '');
|
||||
type = topic.split('/')[0];
|
||||
if (legacyApi && data.topic.match(legacyTopicRegex)) {
|
||||
const topic = data.topic.replace(`${settings.get().mqtt.base_topic}/bridge/`, '');
|
||||
type = topic.split('/')[0] as 'bind' | 'unbind';
|
||||
sourceKey = topic.replace(`${type}/`, '');
|
||||
targetKey = message;
|
||||
} else if (topic.match(topicRegex)) {
|
||||
type = topic.endsWith('unbind') ? 'unbind' : 'bind';
|
||||
message = JSON.parse(message);
|
||||
targetKey = data.message;
|
||||
} else if (data.topic.match(topicRegex)) {
|
||||
type = data.topic.endsWith('unbind') ? 'unbind' : 'bind';
|
||||
const message = JSON.parse(data.message);
|
||||
sourceKey = message.from;
|
||||
targetKey = message.to;
|
||||
clusters = message.clusters;
|
||||
@@ -146,31 +196,33 @@ class Bind extends Extension {
|
||||
return {type, sourceKey, targetKey, clusters, skipDisableReporting};
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
const {type, sourceKey, targetKey, clusters, skipDisableReporting} = this.parseMQTTMessage(topic, message);
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const {type, sourceKey, targetKey, clusters, skipDisableReporting} = this.parseMQTTMessage(data);
|
||||
if (!type) return null;
|
||||
message = utils.parseJSON(message, message);
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
|
||||
let error = null;
|
||||
const source = this.zigbee.resolveEntity(sourceKey);
|
||||
const target = targetKey === 'default_bind_group' ? defaultBindGroup : this.zigbee.resolveEntity(targetKey);
|
||||
const responseData = {from: sourceKey, to: targetKey};
|
||||
const parsedSource = utils.parseEntityID(sourceKey);
|
||||
const parsedTarget = utils.parseEntityID(targetKey);
|
||||
const source = this.zigbee.resolveEntity(parsedSource.ID);
|
||||
const target = targetKey === 'default_bind_group' ?
|
||||
defaultBindGroup : this.zigbee.resolveEntity(parsedTarget.ID);
|
||||
const responseData: KeyValue = {from: sourceKey, to: targetKey};
|
||||
|
||||
if (!source || source.type !== 'device') {
|
||||
if (!source || !(source instanceof Device)) {
|
||||
error = `Source device '${sourceKey}' does not exist`;
|
||||
} else if (!target) {
|
||||
error = `Target device or group '${targetKey}' does not exist`;
|
||||
} else {
|
||||
const sourceName = source.settings.friendlyName;
|
||||
const targetName = targetKey === 'default_bind_group' ? targetKey : target.settings.friendlyName;
|
||||
const successfulClusters = [];
|
||||
const successfulClusters: string[] = [];
|
||||
const failedClusters = [];
|
||||
const attemptedClusters = [];
|
||||
|
||||
let bindTarget = null;
|
||||
if (target.type === 'group') bindTarget = target.group;
|
||||
else if (target.type === 'group_number') bindTarget = target.ID;
|
||||
else bindTarget = target.endpoint;
|
||||
const bindSource: zh.Endpoint = source.endpoint(parsedSource.endpoint);
|
||||
let bindTarget: number | zh.Group | zh.Endpoint = null;
|
||||
if (target instanceof Device) bindTarget = target.endpoint(parsedTarget.endpoint);
|
||||
else if (target instanceof Group) bindTarget = target.zh;
|
||||
else bindTarget = Number(target.ID);
|
||||
|
||||
// Find which clusters are supported by both the source and target.
|
||||
// Groups are assumed to support all clusters.
|
||||
@@ -178,34 +230,34 @@ class Bind extends Extension {
|
||||
if (clusters && !clusters.includes(cluster)) continue;
|
||||
let matchingClusters = false;
|
||||
|
||||
const anyClusterValid = target.type === 'group' || target.type === 'group_number' ||
|
||||
target.device.type === 'Coordinator';
|
||||
const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' ||
|
||||
(target as Device).zh.type === 'Coordinator';
|
||||
|
||||
if (!anyClusterValid) {
|
||||
matchingClusters = ((target.endpoint.supportsInputCluster(cluster) &&
|
||||
source.endpoint.supportsOutputCluster(cluster)) ||
|
||||
(source.endpoint.supportsInputCluster(cluster) &&
|
||||
target.endpoint.supportsOutputCluster(cluster)) );
|
||||
if (!anyClusterValid && utils.isEndpoint(bindTarget)) {
|
||||
matchingClusters = ((bindTarget.supportsInputCluster(cluster) &&
|
||||
bindSource.supportsOutputCluster(cluster)) ||
|
||||
(bindSource.supportsInputCluster(cluster) &&
|
||||
bindTarget.supportsOutputCluster(cluster)) );
|
||||
}
|
||||
|
||||
const sourceValid = source.endpoint.supportsInputCluster(cluster) ||
|
||||
source.endpoint.supportsOutputCluster(cluster);
|
||||
const sourceValid = bindSource.supportsInputCluster(cluster) ||
|
||||
bindSource.supportsOutputCluster(cluster);
|
||||
|
||||
if ( sourceValid && (anyClusterValid || matchingClusters)) {
|
||||
logger.debug(`${type}ing cluster '${cluster}' from '${sourceName}' to '${targetName}'`);
|
||||
logger.debug(`${type}ing cluster '${cluster}' from '${source.name}' to '${target.name}'`);
|
||||
attemptedClusters.push(cluster);
|
||||
|
||||
try {
|
||||
if (type === 'bind') {
|
||||
await source.endpoint.bind(cluster, bindTarget);
|
||||
await bindSource.bind(cluster, bindTarget);
|
||||
} else {
|
||||
await source.endpoint.unbind(cluster, bindTarget);
|
||||
await bindSource.unbind(cluster, bindTarget);
|
||||
}
|
||||
|
||||
successfulClusters.push(cluster);
|
||||
logger.info(
|
||||
`Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from ` +
|
||||
`'${sourceName}' to '${targetName}'`,
|
||||
`'${source.name}' to '${target.name}'`,
|
||||
);
|
||||
|
||||
/* istanbul ignore else */
|
||||
@@ -213,14 +265,14 @@ class Bind extends Extension {
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_${type}`,
|
||||
message: {from: sourceName, to: targetName, cluster}}),
|
||||
message: {from: source.name, to: target.name, cluster}}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
failedClusters.push(cluster);
|
||||
logger.error(
|
||||
`Failed to ${type} cluster '${cluster}' from '${sourceName}' to ` +
|
||||
`'${targetName}' (${error})`,
|
||||
`Failed to ${type} cluster '${cluster}' from '${source.name}' to ` +
|
||||
`'${target.name}' (${error})`,
|
||||
);
|
||||
|
||||
/* istanbul ignore else */
|
||||
@@ -228,7 +280,7 @@ class Bind extends Extension {
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_${type}_failed`,
|
||||
message: {from: sourceName, to: targetName, cluster}}),
|
||||
message: {from: source.name, to: target.name, cluster}}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -236,14 +288,14 @@ class Bind extends Extension {
|
||||
}
|
||||
|
||||
if (attemptedClusters.length === 0) {
|
||||
logger.error(`Nothing to ${type} from '${sourceName}' to '${targetName}'`);
|
||||
logger.error(`Nothing to ${type} from '${source.name}' to '${target.name}'`);
|
||||
error = `Nothing to ${type}`;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_${type}_failed`, message: {from: sourceName, to: targetName}}),
|
||||
stringify({type: `device_${type}_failed`, message: {from: source.name, to: target.name}}),
|
||||
);
|
||||
}
|
||||
} else if (failedClusters.length === attemptedClusters.length) {
|
||||
@@ -255,15 +307,15 @@ class Bind extends Extension {
|
||||
|
||||
if (successfulClusters.length !== 0) {
|
||||
if (type === 'bind') {
|
||||
await this.setupReporting(source.endpoint.binds.filter((b) =>
|
||||
await this.setupReporting(bindSource.binds.filter((b) =>
|
||||
successfulClusters.includes(b.cluster.name) && b.target === bindTarget));
|
||||
} else if ((target.type !== 'group_number') && !skipDisableReporting) {
|
||||
} else if ((typeof bindTarget !== 'number') && !skipDisableReporting) {
|
||||
await this.disableUnnecessaryReportings(bindTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const triggeredViaLegacyApi = topic.match(legacyTopicRegex);
|
||||
const triggeredViaLegacyApi = data.topic.match(legacyTopicRegex);
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
await this.mqtt.publish(`bridge/response/device/${type}`, stringify(response));
|
||||
@@ -272,15 +324,15 @@ class Bind extends Extension {
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
} else {
|
||||
this.eventBus.emit(`devicesChanged`);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
}
|
||||
}
|
||||
|
||||
async groupMembersChanged(data) {
|
||||
@bind async onGroupMembersChanged(data: eventdata.GroupMembersChanged): Promise<void> {
|
||||
if (data.action === 'add') {
|
||||
const bindsToGroup = this.zigbee.getClients().map((c) => c.endpoints)
|
||||
const bindsToGroup = this.zigbee.devices(false).map((c) => c.zh.endpoints)
|
||||
.reduce((a, v) => a.concat(v)).map((e) => e.binds)
|
||||
.reduce((a, v) => a.concat(v)).filter((b) => b.target === data.group.group);
|
||||
.reduce((a, v) => a.concat(v)).filter((b) => b.target === data.group.zh);
|
||||
await this.setupReporting(bindsToGroup);
|
||||
} else { // action === remove/remove_all
|
||||
if (!data.skipDisableReporting) {
|
||||
@@ -289,8 +341,8 @@ class Bind extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
getSetupReportingEndpoints(bind, coordinatorEp) {
|
||||
const endpoints = bind.target.constructor.name === 'Group' ? bind.target.members : [bind.target];
|
||||
getSetupReportingEndpoints(bind: zh.Bind, coordinatorEp: zh.Endpoint): zh.Endpoint[] {
|
||||
const endpoints = utils.isEndpoint(bind.target) ? [bind.target] : bind.target.members;
|
||||
return endpoints.filter((e) => {
|
||||
const supportsInputCluster = e.supportsInputCluster(bind.cluster.name);
|
||||
const hasConfiguredReporting = !!e.configuredReportings.find((c) => c.cluster.name === bind.cluster.name);
|
||||
@@ -299,22 +351,23 @@ class Bind extends Extension {
|
||||
});
|
||||
}
|
||||
|
||||
async setupReporting(binds) {
|
||||
const coordinator = this.zigbee.getDevicesByType('Coordinator')[0];
|
||||
const coordinatorEndpoint = coordinator.getEndpoint(1);
|
||||
async setupReporting(binds: zh.Bind[]): Promise<void> {
|
||||
const coordinatorEndpoint = this.zigbee.firstCoordinatorEndpoint();
|
||||
for (const bind of binds.filter((b) => b.cluster.name in reportClusters)) {
|
||||
for (const endpoint of this.getSetupReportingEndpoints(bind, coordinatorEndpoint)) {
|
||||
const entity = `${this.zigbee.resolveEntity(endpoint.getDevice()).name}/${endpoint.ID}`;
|
||||
try {
|
||||
await endpoint.bind(bind.cluster.name, coordinatorEndpoint);
|
||||
const items = [];
|
||||
for (const c of reportClusters[bind.cluster.name]) {
|
||||
/* istanbul ignore else */
|
||||
if (!c.condition || await c.condition(endpoint)) {
|
||||
const i = {...c};
|
||||
delete i.condition;
|
||||
items.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const items = await reportClusters[bind.cluster.name]
|
||||
.filter(async (a) => !a.condition || await a.condition(endpoint))
|
||||
.map((a) => {
|
||||
const result = {...a};
|
||||
delete result.condition;
|
||||
return result;
|
||||
});
|
||||
await endpoint.configureReporting(bind.cluster.name, items);
|
||||
logger.info(`Succesfully setup reporting for '${entity}' cluster '${bind.cluster.name}'`);
|
||||
} catch (error) {
|
||||
@@ -323,36 +376,40 @@ class Bind extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.emit(`devicesChanged`);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
}
|
||||
|
||||
async disableUnnecessaryReportings(target) {
|
||||
const coordinator = this.zigbee.getDevicesByType('Coordinator')[0];
|
||||
const coordinatorEndpoint = coordinator.getEndpoint(1);
|
||||
const endpoints = target.constructor.name === 'Group' ? target.members : [target];
|
||||
async disableUnnecessaryReportings(target: zh.Group | zh.Endpoint): Promise<void> {
|
||||
const coordinator = this.zigbee.firstCoordinatorEndpoint();
|
||||
const endpoints = utils.isEndpoint(target) ? [target] : target.members;
|
||||
for (const endpoint of endpoints) {
|
||||
const entity = `${this.zigbee.resolveEntity(endpoint.getDevice()).name}/${endpoint.ID}`;
|
||||
const boundClusters = endpoint.binds.filter((b) => b.target === coordinatorEndpoint)
|
||||
const device = this.zigbee.resolveEntity(endpoint.getDevice()) as Device;
|
||||
const entity = `${device.name}/${endpoint.ID}`;
|
||||
const boundClusters = endpoint.binds.filter((b) => b.target === coordinator)
|
||||
.map((b) => b.cluster.name);
|
||||
const requiredClusters = this.zigbee.getClients().map((c) => c.endpoints).reduce((a, v) => a.concat(v))
|
||||
const requiredClusters = this.zigbee.devices(false).map((c) => c.zh.endpoints)
|
||||
.reduce((a, v) => a.concat(v))
|
||||
.map((e) => e.binds).reduce((a, v) => a.concat(v)).filter((bind) => {
|
||||
if (bind.target.constructor.name === 'Group') {
|
||||
return bind.target.members.includes(endpoint);
|
||||
} else {
|
||||
if (utils.isEndpoint(bind.target)) {
|
||||
return bind.target === endpoint;
|
||||
} else {
|
||||
return bind.target.members.includes(endpoint);
|
||||
}
|
||||
}).map((b) => b.cluster.name).filter((v, i, a) => a.indexOf(v) === i);
|
||||
|
||||
for (const cluster of boundClusters.filter((c) => !requiredClusters.includes(c) && c in reportClusters)) {
|
||||
try {
|
||||
await endpoint.unbind(cluster, coordinatorEndpoint);
|
||||
const items = await reportClusters[cluster]
|
||||
.filter(async (a) => !a.condition || await a.condition(endpoint))
|
||||
.map((a) => {
|
||||
const result = {...a, maximumReportInterval: 0xFFFF};
|
||||
delete result.condition;
|
||||
return result;
|
||||
});
|
||||
await endpoint.unbind(cluster, coordinator);
|
||||
const items = [];
|
||||
for (const item of reportClusters[cluster]) {
|
||||
/* istanbul ignore else */
|
||||
if (!item.condition || await item.condition(endpoint)) {
|
||||
const i = {...item};
|
||||
delete i.condition;
|
||||
items.push({...i, maximumReportInterval: 0xFFFF});
|
||||
}
|
||||
}
|
||||
|
||||
await endpoint.configureReporting(cluster, items);
|
||||
logger.info(`Succesfully disabled reporting for '${entity}' cluster '${cluster}'`);
|
||||
} catch (error) {
|
||||
@@ -360,17 +417,11 @@ class Bind extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.emit('reportingDisabled', {device: endpoint.getDevice()});
|
||||
this.eventBus.emitReportingDisabled({device});
|
||||
}
|
||||
}
|
||||
|
||||
async onZigbeeEvent(type, data, resolvedEntity) {
|
||||
if (type === 'message') {
|
||||
this.poll(data);
|
||||
}
|
||||
}
|
||||
|
||||
poll(message) {
|
||||
@bind async poll(data: eventdata.DeviceMessage): Promise<void> {
|
||||
/**
|
||||
* This method poll bound endpoints and group members for state changes.
|
||||
*
|
||||
@@ -380,38 +431,42 @@ class Bind extends Extension {
|
||||
* When we receive a message from a Hue dimmer we read the brightness from the bulb (if bound).
|
||||
*/
|
||||
const polls = pollOnMessage.filter((p) =>
|
||||
p.cluster[message.cluster] && p.cluster[message.cluster].find((c) => c.type === message.type &&
|
||||
utils.equalsPartial(message.data, c.data)),
|
||||
);
|
||||
p.cluster[data.cluster]?.find((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)));
|
||||
|
||||
if (polls.length) {
|
||||
let toPoll = [];
|
||||
|
||||
const toPoll: Set<zh.Endpoint> = new Set();
|
||||
// Add bound devices
|
||||
toPoll = toPoll.concat([].concat(...message.device.endpoints.map((e) =>
|
||||
e.binds.map((e) => e).filter((e) => e.target))));
|
||||
toPoll = toPoll.filter((e) => e.target.constructor.name === 'Endpoint');
|
||||
toPoll = toPoll.filter((e) => e.target.getDevice().type !== 'Coordinator');
|
||||
toPoll = toPoll.map((e) => e.target);
|
||||
|
||||
// If message is published to a group, add members of the group
|
||||
const group = message.groupID !== 0 ? this.zigbee.getGroupByID(message.groupID) : null;
|
||||
if (group) {
|
||||
toPoll = toPoll.concat(group.members);
|
||||
for (const endpoint of data.device.zh.endpoints) {
|
||||
for (const bind of endpoint.binds) {
|
||||
if (utils.isEndpoint(bind.target) && bind.target.getDevice().type !== 'Coordinator') {
|
||||
toPoll.add(bind.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toPoll = new Set(toPoll);
|
||||
// If message is published to a group, add members of the group
|
||||
const group = data.groupID && data.groupID !== 0 && this.zigbee.groupByID(data.groupID);
|
||||
if (group) {
|
||||
group.zh.members.forEach((m) => toPoll.add(m));
|
||||
}
|
||||
|
||||
for (const endpoint of toPoll) {
|
||||
for (const poll of polls) {
|
||||
if (!poll.manufacturerIDs.includes(endpoint.getDevice().manufacturerID)) {
|
||||
if (!poll.manufacturerIDs.includes(endpoint.getDevice().manufacturerID) ||
|
||||
!endpoint.supportsInputCluster(poll.read.cluster)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${endpoint.deviceIeeeAddress}_${endpoint.ID}_${pollOnMessage.indexOf(poll)}`;
|
||||
let readAttrs = poll.read.attributes;
|
||||
if (poll.read.attributesForEndpoint) {
|
||||
const attrsForEndpoint = await poll.read.attributesForEndpoint(endpoint);
|
||||
readAttrs = [...poll.read.attributes, ...attrsForEndpoint];
|
||||
}
|
||||
|
||||
const key = `${endpoint.getDevice().ieeeAddr}_${endpoint.ID}_${pollOnMessage.indexOf(poll)}`;
|
||||
if (!this.pollDebouncers[key]) {
|
||||
this.pollDebouncers[key] = debounce(async () => {
|
||||
await endpoint.read(poll.read.cluster, poll.read.attributes);
|
||||
await endpoint.read(poll.read.cluster, readAttrs);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
@@ -421,5 +476,3 @@ class Bind extends Extension {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Bind;
|
||||
@@ -1,80 +1,58 @@
|
||||
const logger = require('../util/logger');
|
||||
const utils = require('../util/utils');
|
||||
const Extension = require('./extension');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const settings = require('../util/settings');
|
||||
const Transport = require('winston-transport');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const objectAssignDeep = require(`object-assign-deep`);
|
||||
const {updatedDiff, addedDiff} = require('deep-object-diff');
|
||||
/* eslint-disable camelcase */
|
||||
import logger from '../util/logger';
|
||||
import utils from '../util/utils';
|
||||
import * as settings from '../util/settings';
|
||||
import Transport from 'winston-transport';
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import {detailedDiff} from 'deep-object-diff';
|
||||
import Extension from './extension';
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
|
||||
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
|
||||
|
||||
class Bridge extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.enableDisableExtension = enableDisableExtension;
|
||||
this.restartCallback = restartCallback;
|
||||
this.lastJoinedDeviceIeeeAddr = null;
|
||||
this.setupMQTTLogging();
|
||||
this.restartRequired = false;
|
||||
type Scene = {id: number, name: string};
|
||||
type DefinitionPayload = {
|
||||
model: string, vendor: string, description: string, exposes: zhc.DefinitionExpose[], supports_ota:
|
||||
boolean, icon: string, options: zhc.DefinitionExpose[],
|
||||
};
|
||||
|
||||
export default class Bridge extends Extension {
|
||||
private zigbee2mqttVersion: {commitHash: string, version: string};
|
||||
private coordinatorVersion: zh.CoordinatorVersion;
|
||||
private restartRequired = false;
|
||||
private lastJoinedDeviceIeeeAddr: string;
|
||||
private requestLookup: {[key: string]: (message: KeyValue | string) => Promise<MQTTResponse>};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.requestLookup = {
|
||||
'device/options': this.deviceOptions.bind(this),
|
||||
'device/configure_reporting': this.deviceConfigureReporting.bind(this),
|
||||
'device/remove': this.deviceRemove.bind(this),
|
||||
'device/rename': this.deviceRename.bind(this),
|
||||
'group/add': this.groupAdd.bind(this),
|
||||
'group/options': this.groupOptions.bind(this),
|
||||
'group/remove': this.groupRemove.bind(this),
|
||||
'group/rename': this.groupRename.bind(this),
|
||||
'permit_join': this.permitJoin.bind(this),
|
||||
'restart': this.restart.bind(this),
|
||||
'touchlink/factory_reset': this.touchlinkFactoryReset.bind(this),
|
||||
'touchlink/identify': this.touchlinkIdentify.bind(this),
|
||||
'touchlink/scan': this.touchlinkScan.bind(this),
|
||||
'health_check': this.healthCheck.bind(this),
|
||||
'options': this.bridgeOptions.bind(this),
|
||||
'device/options': this.deviceOptions,
|
||||
'device/configure_reporting': this.deviceConfigureReporting,
|
||||
'device/remove': this.deviceRemove,
|
||||
'device/rename': this.deviceRename,
|
||||
'group/add': this.groupAdd,
|
||||
'group/options': this.groupOptions,
|
||||
'group/remove': this.groupRemove,
|
||||
'group/rename': this.groupRename,
|
||||
'permit_join': this.permitJoin,
|
||||
'restart': this.restart,
|
||||
'touchlink/factory_reset': this.touchlinkFactoryReset,
|
||||
'touchlink/identify': this.touchlinkIdentify,
|
||||
'touchlink/scan': this.touchlinkScan,
|
||||
'health_check': this.healthCheck,
|
||||
'options': this.bridgeOptions,
|
||||
// Below are deprecated
|
||||
'config/last_seen': this.configLastSeen.bind(this),
|
||||
'config/homeassistant': this.configHomeAssistant.bind(this),
|
||||
'config/elapsed': this.configElapsed.bind(this),
|
||||
'config/log_level': this.configLogLevel.bind(this),
|
||||
'config/last_seen': this.configLastSeen,
|
||||
'config/homeassistant': this.configHomeAssistant,
|
||||
'config/elapsed': this.configElapsed,
|
||||
'config/log_level': this.configLogLevel,
|
||||
};
|
||||
}
|
||||
|
||||
async onMQTTConnected() {
|
||||
this.zigbee2mqttVersion = await utils.getZigbee2mqttVersion();
|
||||
this.coordinatorVersion = await this.zigbee.getCoordinatorVersion();
|
||||
|
||||
this.eventBus.on(`groupMembersChanged`, () => this.publishGroups(), this.constructor.name);
|
||||
this.eventBus.on(`devicesChanged`, () => {
|
||||
this.publishDevices();
|
||||
this.publishInfo();
|
||||
}, this.constructor.name);
|
||||
this.eventBus.on(`deviceRenamed`, () => {
|
||||
this.publishInfo();
|
||||
}, this.constructor.name);
|
||||
this.eventBus.on(`groupRenamed`, () => {
|
||||
this.publishInfo();
|
||||
}, this.constructor.name);
|
||||
this.zigbee.removeListener('permitJoinChanged', this.permitJoinChanged);
|
||||
this.permitJoinChanged = this.permitJoinChanged.bind(this);
|
||||
this.zigbee.on('permitJoinChanged', this.permitJoinChanged);
|
||||
await this.publishInfo();
|
||||
await this.publishDevices();
|
||||
await this.publishGroups();
|
||||
}
|
||||
|
||||
async stop() {
|
||||
super.stop();
|
||||
this.zigbee.removeListener('permitJoinChanged', this.permitJoinChanged);
|
||||
}
|
||||
|
||||
setupMQTTLogging() {
|
||||
const mqtt = this.mqtt;
|
||||
class EventTransport extends Transport {
|
||||
log(info, callback) {
|
||||
log(info: {message: string, level: string}, callback: () => void): void {
|
||||
const payload = stringify({message: info.message, level: info.level});
|
||||
mqtt.publish(`bridge/logging`, payload, {}, settings.get().mqtt.base_topic, true);
|
||||
callback();
|
||||
@@ -82,96 +60,107 @@ class Bridge extends Extension {
|
||||
}
|
||||
|
||||
logger.addTransport(new EventTransport());
|
||||
|
||||
this.zigbee2mqttVersion = await utils.getZigbee2MQTTVersion();
|
||||
this.coordinatorVersion = await this.zigbee.getCoordinatorVersion();
|
||||
|
||||
this.eventBus.onDeviceRenamed(this, () => this.publishInfo());
|
||||
this.eventBus.onGroupMembersChanged(this, () => this.publishGroups());
|
||||
this.eventBus.onDevicesChanged(this, () => this.publishDevices() && this.publishInfo());
|
||||
this.eventBus.onPermitJoinChanged(this, () => !this.zigbee.isStopping() && this.publishInfo());
|
||||
this.eventBus.onScenesChanged(this, () => {
|
||||
this.publishDevices();
|
||||
this.publishGroups();
|
||||
});
|
||||
|
||||
// Zigbee events
|
||||
const publishEvent = (type: string, data: KeyValue): Promise<void> =>
|
||||
this.mqtt.publish('bridge/event', stringify({type, data}), {retain: false, qos: 0});
|
||||
this.eventBus.onDeviceJoined(this, (data) => {
|
||||
this.lastJoinedDeviceIeeeAddr = data.device.ieeeAddr;
|
||||
this.publishDevices();
|
||||
publishEvent('device_joined', {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr});
|
||||
});
|
||||
this.eventBus.onDeviceLeave(this, (data) => {
|
||||
this.publishDevices();
|
||||
publishEvent('device_leave', {ieee_address: data.ieeeAddr, friendly_name: data.name});
|
||||
});
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, () => this.publishDevices());
|
||||
this.eventBus.onDeviceInterview(this, (data) => {
|
||||
this.publishDevices();
|
||||
const payload: KeyValue =
|
||||
{friendly_name: data.device.name, status: data.status, ieee_address: data.device.ieeeAddr};
|
||||
if (data.status === 'successful') {
|
||||
payload.supported = !!data.device.definition;
|
||||
payload.definition = this.getDefinitionPayload(data.device);
|
||||
}
|
||||
publishEvent('device_interview', payload);
|
||||
});
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => {
|
||||
this.publishDevices();
|
||||
publishEvent('device_announce', {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr});
|
||||
});
|
||||
|
||||
await this.publishInfo();
|
||||
await this.publishDevices();
|
||||
await this.publishGroups();
|
||||
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
permitJoinChanged(data) {
|
||||
if (!this.zigbee.isStopping()) {
|
||||
this.publishInfo();
|
||||
}
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
const match = topic.match(requestRegex);
|
||||
if (match && this.requestLookup[match[1].toLowerCase()]) {
|
||||
message = utils.parseJSON(message, message);
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(requestRegex);
|
||||
const key = match?.[1]?.toLowerCase();
|
||||
if (key in this.requestLookup) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
|
||||
try {
|
||||
const response = await this.requestLookup[match[1].toLowerCase()](message);
|
||||
const response = await this.requestLookup[key](message);
|
||||
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
|
||||
} catch (error) {
|
||||
logger.error(`Request '${topic}' failed with error: '${error.message}'`);
|
||||
logger.error(`Request '${data.topic}' failed with error: '${error.message}'`);
|
||||
logger.debug(error.stack);
|
||||
const response = utils.getResponse(message, {}, error.message);
|
||||
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onZigbeeEvent(type, data, resolvedEntity) {
|
||||
if (type === 'deviceJoined' && resolvedEntity) {
|
||||
this.lastJoinedDeviceIeeeAddr = resolvedEntity.device.ieeeAddr;
|
||||
}
|
||||
|
||||
if (['deviceJoined', 'deviceLeave', 'deviceInterview', 'deviceAnnounce'].includes(type)) {
|
||||
let payload;
|
||||
const ieeeAddress = data.device ? data.device.ieeeAddr : data.ieeeAddr;
|
||||
if (type === 'deviceJoined') {
|
||||
payload = {friendly_name: resolvedEntity.settings.friendlyName, ieee_address: ieeeAddress};
|
||||
} else if (type === 'deviceInterview') {
|
||||
payload = {
|
||||
friendly_name: resolvedEntity.settings.friendlyName, status: data.status, ieee_address: ieeeAddress,
|
||||
};
|
||||
if (data.status === 'successful') {
|
||||
const definition = resolvedEntity.definition;
|
||||
payload.supported = !!definition;
|
||||
payload.definition = this.getDefinitionPayload(definition, resolvedEntity);
|
||||
}
|
||||
} else if (type === 'deviceAnnounce') {
|
||||
payload = {
|
||||
friendly_name: resolvedEntity.settings.friendlyName, ieee_address: ieeeAddress,
|
||||
};
|
||||
} else {
|
||||
// deviceLeave
|
||||
const settingsDevice = settings.getDevice(ieeeAddress);
|
||||
payload = {ieee_address: ieeeAddress,
|
||||
friendly_name: settingsDevice ? settingsDevice.friendlyName : null};
|
||||
}
|
||||
|
||||
await this.mqtt.publish(
|
||||
'bridge/event',
|
||||
stringify({type: utils.toSnakeCase(type), data: payload}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
}
|
||||
|
||||
if ('deviceLeave' === type || ('deviceInterview' === type && data.status !== 'started') ||
|
||||
'deviceNetworkAddressChanged' === type) {
|
||||
await this.publishDevices();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests
|
||||
*/
|
||||
|
||||
async deviceOptions(message) {
|
||||
@bind async deviceOptions(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
return this.changeEntityOptions('device', message);
|
||||
}
|
||||
|
||||
async groupOptions(message) {
|
||||
@bind async groupOptions(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
return this.changeEntityOptions('group', message);
|
||||
}
|
||||
|
||||
async bridgeOptions(message) {
|
||||
@bind async bridgeOptions(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
if (typeof message !== 'object' || typeof message.options !== 'object') {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const diffUpdated = updatedDiff(settings.get(), message.options);
|
||||
const diffAdded = addedDiff(settings.get(), message.options);
|
||||
const newSettings = objectAssignDeep.noMutate(diffUpdated, diffAdded);
|
||||
const diff: KeyValue = detailedDiff(settings.get(), message.options);
|
||||
|
||||
// Remove any settings that are in the deleted.diff but not in the passed options
|
||||
const cleanupDeleted = (options: KeyValue, deleted: KeyValue): void => {
|
||||
for (const key of Object.keys(deleted)) {
|
||||
if (!(key in options)) {
|
||||
delete deleted[key];
|
||||
} else if (!Array.isArray(options[key])) {
|
||||
cleanupDeleted(options[key], deleted[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
cleanupDeleted(message.options, diff.deleted);
|
||||
|
||||
const newSettings = objectAssignDeep({}, diff.added, diff.updated, diff.deleted);
|
||||
|
||||
// deep-object-diff converts arrays to objects, set original array back here
|
||||
const convertBackArray = (before, after) => {
|
||||
const convertBackArray = (before: KeyValue, after: KeyValue): void => {
|
||||
for (const [key, afterValue] of Object.entries(after)) {
|
||||
const beforeValue = before[key];
|
||||
if (Array.isArray(beforeValue)) {
|
||||
@@ -192,7 +181,7 @@ class Bridge extends Extension {
|
||||
}
|
||||
|
||||
if (newSettings.hasOwnProperty('homeassistant')) {
|
||||
this.enableDisableExtension(newSettings.homeassistant, 'HomeAssistant');
|
||||
await this.enableDisableExtension(newSettings.homeassistant, 'HomeAssistant');
|
||||
}
|
||||
|
||||
if (newSettings.hasOwnProperty('advanced') && newSettings.advanced.hasOwnProperty('log_level')) {
|
||||
@@ -204,19 +193,19 @@ class Bridge extends Extension {
|
||||
return utils.getResponse(message, {restart_required: this.restartRequired}, null);
|
||||
}
|
||||
|
||||
async deviceRemove(message) {
|
||||
@bind async deviceRemove(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return this.removeEntity('device', message);
|
||||
}
|
||||
|
||||
async groupRemove(message) {
|
||||
@bind async groupRemove(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return this.removeEntity('group', message);
|
||||
}
|
||||
|
||||
async healthCheck(message) {
|
||||
@bind async healthCheck(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return utils.getResponse(message, {healthy: true}, null);
|
||||
}
|
||||
|
||||
async groupAdd(message) {
|
||||
@bind async groupAdd(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
if (typeof message === 'object' && !message.hasOwnProperty('friendly_name')) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
@@ -226,38 +215,40 @@ class Bridge extends Extension {
|
||||
const group = settings.addGroup(friendlyName, ID);
|
||||
this.zigbee.createGroup(group.ID);
|
||||
this.publishGroups();
|
||||
return utils.getResponse(message, {friendly_name: group.friendlyName, id: group.ID}, null);
|
||||
return utils.getResponse(message, {friendly_name: group.friendly_name, id: group.ID}, null);
|
||||
}
|
||||
|
||||
async deviceRename(message) {
|
||||
@bind async deviceRename(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return this.renameEntity('device', message);
|
||||
}
|
||||
|
||||
async groupRename(message) {
|
||||
@bind async groupRename(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return this.renameEntity('group', message);
|
||||
}
|
||||
|
||||
async restart(message) {
|
||||
@bind async restart(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
// Wait 500 ms before restarting so response can be send.
|
||||
setTimeout(this.restartCallback, 500);
|
||||
logger.info('Restarting Zigbee2MQTT');
|
||||
return utils.getResponse(message, {}, null);
|
||||
}
|
||||
|
||||
async permitJoin(message) {
|
||||
@bind async permitJoin(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
if (typeof message === 'object' && !message.hasOwnProperty('value')) {
|
||||
throw new Error('Invalid payload');
|
||||
}
|
||||
|
||||
let value;
|
||||
let time;
|
||||
let resolvedEntity;
|
||||
let value: boolean | string;
|
||||
let time: number;
|
||||
let device: Device = null;
|
||||
if (typeof message === 'object') {
|
||||
value = message.value;
|
||||
time = message.time;
|
||||
if (message.device) {
|
||||
resolvedEntity = this.zigbee.resolveEntity(message.device);
|
||||
if (!resolvedEntity || resolvedEntity.type !== 'device') {
|
||||
const resolved = this.zigbee.resolveEntity(message.device);
|
||||
if (resolved instanceof Device) {
|
||||
device = resolved;
|
||||
} else {
|
||||
throw new Error(`Device '${message.device}' does not exist`);
|
||||
}
|
||||
}
|
||||
@@ -269,18 +260,18 @@ class Bridge extends Extension {
|
||||
value = value.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
await this.zigbee.permitJoin(value, resolvedEntity, time);
|
||||
const response = {value};
|
||||
if (resolvedEntity) response.device = message.device;
|
||||
if (time) response.time = message.time;
|
||||
await this.zigbee.permitJoin(value, device, time);
|
||||
const response: {value: boolean, device?: string, time?: number} = {value};
|
||||
if (device && typeof message === 'object') response.device = message.device;
|
||||
if (time && typeof message === 'object') response.time = message.time;
|
||||
return utils.getResponse(message, response, null);
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
configLastSeen(message) {
|
||||
@bind async configLastSeen(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
const allowed = ['disable', 'ISO_8601', 'epoch', 'ISO_8601_local'];
|
||||
const value = this.getValue(message);
|
||||
if (!allowed.includes(value)) {
|
||||
if (typeof value !== 'string' || !allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
@@ -290,24 +281,24 @@ class Bridge extends Extension {
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
configHomeAssistant(message) {
|
||||
@bind async configHomeAssistant(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
const allowed = [true, false];
|
||||
const value = this.getValue(message);
|
||||
if (!allowed.includes(value)) {
|
||||
if (typeof value !== 'boolean' || !allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
this.enableDisableExtension(value, 'HomeAssistant');
|
||||
await this.enableDisableExtension(value, 'HomeAssistant');
|
||||
settings.set(['homeassistant'], value);
|
||||
this.publishInfo();
|
||||
return utils.getResponse(message, {value}, null);
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
configElapsed(message) {
|
||||
@bind async configElapsed(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
const allowed = [true, false];
|
||||
const value = this.getValue(message);
|
||||
if (!allowed.includes(value)) {
|
||||
if (typeof value !== 'boolean' || !allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
@@ -317,10 +308,10 @@ class Bridge extends Extension {
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
configLogLevel(message) {
|
||||
@bind async configLogLevel(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
const allowed = ['error', 'warn', 'info', 'debug'];
|
||||
const value = this.getValue(message);
|
||||
if (!allowed.includes(value)) {
|
||||
const value = this.getValue(message) as 'error' | 'warn' | 'info' | 'debug';
|
||||
if (typeof value !== 'string' || !allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
@@ -329,7 +320,7 @@ class Bridge extends Extension {
|
||||
return utils.getResponse(message, {value}, null);
|
||||
}
|
||||
|
||||
async touchlinkIdentify(message) {
|
||||
@bind async touchlinkIdentify(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
if (typeof message !== 'object' || !message.hasOwnProperty('ieee_address') ||
|
||||
!message.hasOwnProperty('channel')) {
|
||||
throw new Error('Invalid payload');
|
||||
@@ -340,9 +331,9 @@ class Bridge extends Extension {
|
||||
return utils.getResponse(message, {ieee_address: message.ieee_address, channel: message.channel}, null);
|
||||
}
|
||||
|
||||
async touchlinkFactoryReset(message) {
|
||||
@bind async touchlinkFactoryReset(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
let result = false;
|
||||
const payload = {};
|
||||
const payload: {ieee_address?: string, channel?: number} = {};
|
||||
if (typeof message === 'object' && message.hasOwnProperty('ieee_address') &&
|
||||
message.hasOwnProperty('channel')) {
|
||||
logger.info(`Start Touchlink factory reset of '${message.ieee_address}' on channel ${message.channel}`);
|
||||
@@ -363,7 +354,7 @@ class Bridge extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
async touchlinkScan(message) {
|
||||
@bind async touchlinkScan(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
logger.info('Start Touchlink scan');
|
||||
const result = await this.zigbee.touchlinkScan();
|
||||
const found = result.map((r) => {
|
||||
@@ -377,7 +368,7 @@ class Bridge extends Extension {
|
||||
* Utils
|
||||
*/
|
||||
|
||||
getValue(message) {
|
||||
getValue(message: KeyValue | string): string | boolean | number {
|
||||
if (typeof message === 'object') {
|
||||
if (!message.hasOwnProperty('value')) {
|
||||
throw new Error('No value given');
|
||||
@@ -389,20 +380,21 @@ class Bridge extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
async changeEntityOptions(entityType, message) {
|
||||
async changeEntityOptions(entityType: 'device' | 'group', message: KeyValue | string): Promise<MQTTResponse> {
|
||||
if (typeof message !== 'object' || !message.hasOwnProperty('id') || !message.hasOwnProperty('options')) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const ID = message.id;
|
||||
const entity = this.getEntity(entityType, ID);
|
||||
settings.changeEntityOptions(ID, message.options);
|
||||
const cleanup = (o) => {
|
||||
const cleanup = (o: KeyValue): KeyValue => {
|
||||
delete o.friendlyName; delete o.friendly_name; delete o.ID; delete o.type; delete o.devices;
|
||||
return o;
|
||||
};
|
||||
const oldOptions = cleanup(entity.settings);
|
||||
const newOptions = cleanup(settings.getEntity(ID));
|
||||
|
||||
const ID = message.id;
|
||||
const entity = this.getEntity(entityType, ID);
|
||||
const oldOptions = objectAssignDeep({}, cleanup(entity.settings));
|
||||
settings.changeEntityOptions(ID, message.options);
|
||||
const newOptions = cleanup(entity.settings);
|
||||
await this.publishInfo();
|
||||
|
||||
logger.info(`Changed config for ${entityType} ${ID}`);
|
||||
@@ -410,18 +402,17 @@ class Bridge extends Extension {
|
||||
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID}, null);
|
||||
}
|
||||
|
||||
async deviceConfigureReporting(message) {
|
||||
@bind async deviceConfigureReporting(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
if (typeof message !== 'object' || !message.hasOwnProperty('id') || !message.hasOwnProperty('cluster') ||
|
||||
!message.hasOwnProperty('maximum_report_interval') || !message.hasOwnProperty('minimum_report_interval') ||
|
||||
!message.hasOwnProperty('reportable_change') || !message.hasOwnProperty('attribute')) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const ID = message.id;
|
||||
const endpoint = this.getEntity('device', ID).endpoint;
|
||||
const parsedID = utils.parseEntityID(message.id);
|
||||
const endpoint = (this.getEntity('device', parsedID.ID) as Device).endpoint(parsedID.endpoint);
|
||||
|
||||
const coordinator = this.zigbee.getDevicesByType('Coordinator')[0];
|
||||
const coordinatorEndpoint = coordinator.getEndpoint(1);
|
||||
const coordinatorEndpoint = this.zigbee.firstCoordinatorEndpoint();
|
||||
await endpoint.bind(message.cluster, coordinatorEndpoint);
|
||||
|
||||
await endpoint.configureReporting(message.cluster, [{
|
||||
@@ -440,7 +431,7 @@ class Bridge extends Extension {
|
||||
}, null);
|
||||
}
|
||||
|
||||
renameEntity(entityType, message) {
|
||||
async renameEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> {
|
||||
const deviceAndHasLast = entityType === 'device' && typeof message === 'object' && message.last === true;
|
||||
if (typeof message !== 'object' || (!message.hasOwnProperty('from') && !deviceAndHasLast) ||
|
||||
!message.hasOwnProperty('to')) {
|
||||
@@ -456,23 +447,23 @@ class Bridge extends Extension {
|
||||
const homeAssisantRename = message.hasOwnProperty('homeassistant_rename') ?
|
||||
message.homeassistant_rename : false;
|
||||
const entity = this.getEntity(entityType, from);
|
||||
const oldFriendlyName = entity.settings.friendly_name;
|
||||
|
||||
settings.changeFriendlyName(from, to);
|
||||
|
||||
// Clear retained messages
|
||||
this.mqtt.publish(entity.name, '', {retain: true});
|
||||
this.mqtt.publish(oldFriendlyName, '', {retain: true});
|
||||
|
||||
const oldFriendlyName = entity.settings.friendlyName;
|
||||
if (entity.type === 'device') {
|
||||
if (entity instanceof Device) {
|
||||
this.publishDevices();
|
||||
this.eventBus.emit(`deviceRenamed`, {device: entity.device, homeAssisantRename, from: oldFriendlyName, to});
|
||||
this.eventBus.emitDeviceRenamed({device: entity, homeAssisantRename, from: oldFriendlyName, to});
|
||||
} else {
|
||||
this.publishGroups();
|
||||
this.eventBus.emit(`groupRenamed`, {group: entity.group, homeAssisantRename, from: oldFriendlyName, to});
|
||||
this.publishInfo();
|
||||
}
|
||||
|
||||
// Repulish entity state
|
||||
this.publishEntityState(to, {});
|
||||
this.publishEntityState(entity, {});
|
||||
|
||||
return utils.getResponse(
|
||||
message,
|
||||
@@ -481,9 +472,11 @@ class Bridge extends Extension {
|
||||
);
|
||||
}
|
||||
|
||||
async removeEntity(entityType, message) {
|
||||
async removeEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> {
|
||||
const ID = typeof message === 'object' ? message.id : message.trim();
|
||||
const entity = this.getEntity(entityType, ID);
|
||||
const friendlyName = entity.name;
|
||||
const entityID = entity.ID;
|
||||
|
||||
let block = false;
|
||||
let force = false;
|
||||
@@ -499,46 +492,49 @@ class Bridge extends Extension {
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`Removing ${entity.type} '${entity.settings.friendlyName}'${blockForceLog}`);
|
||||
if (entity.type === 'device') {
|
||||
logger.info(`Removing ${entityType} '${entity.name}'${blockForceLog}`);
|
||||
const ieeeAddr = entity.isDevice() && entity.ieeeAddr;
|
||||
const name = entity.name;
|
||||
|
||||
if (entity instanceof Device) {
|
||||
if (block) {
|
||||
settings.blockDevice(entity.settings.ID);
|
||||
settings.blockDevice(entity.ieeeAddr);
|
||||
}
|
||||
|
||||
if (force) {
|
||||
await entity.device.removeFromDatabase();
|
||||
await entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
await entity.device.removeFromNetwork();
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
} else {
|
||||
if (force) {
|
||||
await entity.group.removeFromDatabase();
|
||||
entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
await entity.group.removeFromNetwork();
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
}
|
||||
|
||||
// Fire event
|
||||
if (entity.type === 'device') {
|
||||
this.eventBus.emit('deviceRemoved', {resolvedEntity: entity});
|
||||
if (entity instanceof Device) {
|
||||
this.eventBus.emitDeviceRemoved({ieeeAddr, name});
|
||||
}
|
||||
|
||||
// Remove from configuration.yaml
|
||||
if (entity.type === 'device') {
|
||||
settings.removeDevice(entity.settings.ID);
|
||||
if (entity instanceof Device) {
|
||||
settings.removeDevice(entityID as string);
|
||||
} else {
|
||||
settings.removeGroup(entity.settings.ID);
|
||||
settings.removeGroup(entityID);
|
||||
}
|
||||
|
||||
// Remove from state
|
||||
this.state.remove(entity.settings.ID);
|
||||
this.state.remove(entityID);
|
||||
|
||||
// Clear any retained messages
|
||||
this.mqtt.publish(entity.name, '', {retain: true});
|
||||
this.mqtt.publish(friendlyName, '', {retain: true});
|
||||
|
||||
logger.info(`Successfully removed ${entity.type} '${entity.settings.friendlyName}'${blockForceLog}`);
|
||||
logger.info(`Successfully removed ${entityType} '${friendlyName}'${blockForceLog}`);
|
||||
|
||||
if (entity.type === 'device') {
|
||||
if (entity instanceof Device) {
|
||||
this.publishGroups();
|
||||
this.publishDevices();
|
||||
return utils.getResponse(message, {id: ID, block, force}, null);
|
||||
@@ -548,21 +544,21 @@ class Bridge extends Extension {
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to remove ${entity.type} '${entity.settings.friendlyName}'${blockForceLog} (${error})`,
|
||||
`Failed to remove ${entityType} '${friendlyName}'${blockForceLog} (${error})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getEntity(type, ID) {
|
||||
getEntity(type: 'group' | 'device', ID: string): Device | Group {
|
||||
const entity = this.zigbee.resolveEntity(ID);
|
||||
if (!entity || entity.type !== type) {
|
||||
if (!entity || entity.constructor.name.toLowerCase() !== type) {
|
||||
throw new Error(`${utils.capitalize(type)} '${ID}' does not exist`);
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
async publishInfo() {
|
||||
const config = objectAssignDeep.noMutate({}, settings.get());
|
||||
async publishInfo(): Promise<void> {
|
||||
const config = objectAssignDeep({}, settings.get());
|
||||
delete config.advanced.network_key;
|
||||
delete config.mqtt.password;
|
||||
config.frontend && delete config.frontend.auth_token;
|
||||
@@ -583,13 +579,38 @@ class Bridge extends Extension {
|
||||
'bridge/info', stringify(payload), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
}
|
||||
|
||||
async publishDevices() {
|
||||
const devices = this.zigbee.getDevices().map((device) => {
|
||||
const definition = zigbeeHerdsmanConverters.findByDevice(device);
|
||||
const resolved = this.zigbee.resolveEntity(device);
|
||||
const endpoints = {};
|
||||
for (const endpoint of device.endpoints) {
|
||||
const data = {
|
||||
private getScenes(entity: zh.Endpoint | zh.Group): Scene[] {
|
||||
const scenes: {[id: number]: Scene} = {};
|
||||
const endpoints = utils.isEndpoint(entity) ? [entity] : entity.members;
|
||||
const groupID = utils.isEndpoint(entity) ? 0 : entity.groupID;
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
for (const [key, data] of Object.entries(endpoint.meta?.scenes || {})) {
|
||||
const split = key.split('_');
|
||||
const sceneID = parseInt(split[0], 10);
|
||||
const sceneGroupID = parseInt(split[1], 10);
|
||||
if (sceneGroupID === groupID) {
|
||||
scenes[sceneID] = {id: sceneID, name: (data as KeyValue).name || `Scene ${sceneID}`};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(scenes);
|
||||
}
|
||||
|
||||
async publishDevices(): Promise<void> {
|
||||
interface Data {
|
||||
bindings: {cluster: string, target: {type: string, endpoint?: number, ieee_address?: string, id?: number}}[]
|
||||
configured_reportings: {cluster: string, attribute: string | number,
|
||||
minimum_report_interval: number, maximum_report_interval: number, reportable_change: number}[],
|
||||
clusters: {input: string[], output: string[]}, scenes: Scene[]
|
||||
}
|
||||
|
||||
const devices = this.zigbee.devices().map((device) => {
|
||||
const endpoints: {[s: number]: Data} = {};
|
||||
for (const endpoint of device.zh.endpoints) {
|
||||
const data: Data = {
|
||||
scenes: this.getScenes(endpoint),
|
||||
bindings: [],
|
||||
configured_reportings: [],
|
||||
clusters: {
|
||||
@@ -599,16 +620,9 @@ class Bridge extends Extension {
|
||||
};
|
||||
|
||||
for (const bind of endpoint.binds) {
|
||||
let target;
|
||||
|
||||
if (bind.target.constructor.name === 'Endpoint') {
|
||||
target = {
|
||||
type: 'endpoint', ieee_address: bind.target.getDevice().ieeeAddr, endpoint: bind.target.ID,
|
||||
};
|
||||
} else {
|
||||
target = {type: 'group', id: bind.target.groupID};
|
||||
}
|
||||
|
||||
const target = utils.isEndpoint(bind.target) ?
|
||||
{type: 'endpoint', ieee_address: bind.target.getDevice().ieeeAddr, endpoint: bind.target.ID} :
|
||||
{type: 'group', id: bind.target.groupID};
|
||||
data.bindings.push({cluster: bind.cluster.name, target});
|
||||
}
|
||||
|
||||
@@ -627,75 +641,57 @@ class Bridge extends Extension {
|
||||
|
||||
return {
|
||||
ieee_address: device.ieeeAddr,
|
||||
type: device.type,
|
||||
network_address: device.networkAddress,
|
||||
supported: !!definition,
|
||||
friendly_name: resolved.name,
|
||||
definition: this.getDefinitionPayload(definition, resolved),
|
||||
power_source: device.powerSource,
|
||||
software_build_id: device.softwareBuildID,
|
||||
date_code: device.dateCode,
|
||||
model_id: device.modelID,
|
||||
interviewing: device.interviewing,
|
||||
interview_completed: device.interviewCompleted,
|
||||
manufacturer: device.manufacturerName,
|
||||
type: device.zh.type,
|
||||
network_address: device.zh.networkAddress,
|
||||
supported: !!device.definition,
|
||||
friendly_name: device.name,
|
||||
definition: this.getDefinitionPayload(device),
|
||||
power_source: device.zh.powerSource,
|
||||
software_build_id: device.zh.softwareBuildID,
|
||||
date_code: device.zh.dateCode,
|
||||
model_id: device.zh.modelID,
|
||||
interviewing: device.zh.interviewing,
|
||||
interview_completed: device.zh.interviewCompleted,
|
||||
manufacturer: device.zh.manufacturerName,
|
||||
endpoints,
|
||||
};
|
||||
});
|
||||
|
||||
await this.mqtt.publish(
|
||||
'bridge/devices', stringify(devices), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
await this.mqtt.publish('bridge/devices', stringify(devices),
|
||||
{retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
}
|
||||
|
||||
async publishGroups() {
|
||||
const groups = this.zigbee.getGroups().map((group) => {
|
||||
const resolved = this.zigbee.resolveEntity(group);
|
||||
async publishGroups(): Promise<void> {
|
||||
const groups = this.zigbee.groups().map((g) => {
|
||||
return {
|
||||
id: group.groupID,
|
||||
friendly_name: group.groupID === 901 ? 'default_bind_group' : resolved.name,
|
||||
members: group.members.map((m) => {
|
||||
return {
|
||||
ieee_address: m.deviceIeeeAddress,
|
||||
endpoint: m.ID,
|
||||
};
|
||||
id: g.ID,
|
||||
friendly_name: g.ID === 901 ? 'default_bind_group' : g.name,
|
||||
scenes: this.getScenes(g.zh),
|
||||
members: g.zh.members.map((e) => {
|
||||
return {ieee_address: e.getDevice().ieeeAddr, endpoint: e.ID};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
await this.mqtt.publish(
|
||||
'bridge/groups', stringify(groups), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
}
|
||||
|
||||
getDefinitionPayload(definition, resolvedDevice) {
|
||||
if (definition) {
|
||||
let icon = null;
|
||||
if (resolvedDevice && resolvedDevice.hasOwnProperty('settings')) {
|
||||
icon = resolvedDevice.settings.icon;
|
||||
}
|
||||
if (!icon) {
|
||||
icon = definition.icon;
|
||||
}
|
||||
if (icon) {
|
||||
let zigbeeModel = '';
|
||||
/* istanbul ignore else */
|
||||
if (resolvedDevice && resolvedDevice.hasOwnProperty('device')) {
|
||||
zigbeeModel = resolvedDevice.device.modelID;
|
||||
}
|
||||
icon = icon.replace('${zigbeeModel}', utils.sanitizeImageParameter(zigbeeModel));
|
||||
icon = icon.replace('${model}', utils.sanitizeImageParameter(definition.model));
|
||||
}
|
||||
return {
|
||||
model: definition.model,
|
||||
vendor: definition.vendor,
|
||||
description: definition.description,
|
||||
exposes: definition.exposes,
|
||||
supports_ota: !!definition.ota,
|
||||
icon,
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
getDefinitionPayload(device: Device): DefinitionPayload {
|
||||
if (!device.definition) return null;
|
||||
let icon = device.settings.icon ? device.settings.icon : device.definition.icon;
|
||||
if (icon) {
|
||||
icon = icon.replace('${zigbeeModel}', utils.sanitizeImageParameter(device.zh.modelID));
|
||||
icon = icon.replace('${model}', utils.sanitizeImageParameter(device.definition.model));
|
||||
}
|
||||
|
||||
return {
|
||||
model: device.definition.model,
|
||||
vendor: device.definition.vendor,
|
||||
description: device.definition.description,
|
||||
exposes: device.definition.exposes,
|
||||
supports_ota: !!device.definition.ota,
|
||||
options: device.definition.options,
|
||||
icon,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Bridge;
|
||||
@@ -1,157 +0,0 @@
|
||||
const settings = require('../util/settings');
|
||||
const utils = require('../util/utils');
|
||||
const logger = require('../util/logger');
|
||||
const Extension = require('./extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const zhc = require('zigbee-herdsman-converters');
|
||||
|
||||
/**
|
||||
* This extension calls the zigbee-herdsman-converters definition configure() method
|
||||
*/
|
||||
class Configure extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
|
||||
this.configuring = new Set();
|
||||
this.onReportingDisabled = this.onReportingDisabled.bind(this);
|
||||
this.attempts = {};
|
||||
|
||||
this.topic = `${settings.get().mqtt.base_topic}/bridge/request/device/configure`;
|
||||
this.legacyTopic = `${settings.get().mqtt.base_topic}/bridge/configure`;
|
||||
this.eventBus.on(`reportingDisabled`, this.onReportingDisabled, this.constructor.name);
|
||||
}
|
||||
|
||||
onReportingDisabled(data) {
|
||||
// Disabling reporting unbinds some cluster which could be bound by configure, re-setup.
|
||||
const device = data.device;
|
||||
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
if (resolvedEntity.device.meta && resolvedEntity.device.meta.hasOwnProperty('configured')) {
|
||||
delete device.meta.configured;
|
||||
device.save();
|
||||
}
|
||||
|
||||
if (this.shouldConfigure(resolvedEntity)) {
|
||||
this.configure(resolvedEntity, 'reporting_disabled');
|
||||
}
|
||||
}
|
||||
|
||||
shouldConfigure(resolvedEntity, event) {
|
||||
if (!resolvedEntity || !resolvedEntity.definition || !resolvedEntity.definition.configure) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (resolvedEntity.device.meta &&
|
||||
resolvedEntity.device.meta.hasOwnProperty('configured') &&
|
||||
resolvedEntity.device.meta.configured === zhc.getConfigureKey(resolvedEntity.definition)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (resolvedEntity.device.interviewing === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only configure end devices when a message is received, otherwise it will likely fails as they are sleeping.
|
||||
if (resolvedEntity.device.type === 'EndDevice' && event !== 'message_received') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
if (topic === this.legacyTopic) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(message);
|
||||
if (!resolvedEntity || resolvedEntity.type !== 'device') {
|
||||
logger.error(`Device '${message}' does not exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resolvedEntity.definition || !resolvedEntity.definition.configure) {
|
||||
logger.warn(`Skipping configure of '${resolvedEntity.name}', device does not require this.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.configure(resolvedEntity, true);
|
||||
} else if (topic === this.topic) {
|
||||
message = utils.parseJSON(message, message);
|
||||
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message;
|
||||
let error = null;
|
||||
|
||||
const resolvedEntity = this.zigbee.resolveEntity(ID);
|
||||
if (!resolvedEntity || resolvedEntity.type !== 'device') {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!resolvedEntity.definition || !resolvedEntity.definition.configure) {
|
||||
error = `Device '${resolvedEntity.name}' cannot be configured`;
|
||||
} else {
|
||||
try {
|
||||
await this.configure(resolvedEntity, true, true);
|
||||
} catch (e) {
|
||||
error = `Failed to configure (${e.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = utils.getResponse(message, {id: ID}, error);
|
||||
await this.mqtt.publish(`bridge/response/device/configure`, stringify(response));
|
||||
}
|
||||
}
|
||||
|
||||
async onZigbeeStarted() {
|
||||
this.coordinatorEndpoint = this.zigbee.getDevicesByType('Coordinator')[0].getEndpoint(1);
|
||||
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
if (this.shouldConfigure(resolvedEntity, 'started')) {
|
||||
await this.configure(resolvedEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onZigbeeEvent(type, data, resolvedEntity) {
|
||||
const device = data.device;
|
||||
|
||||
if (type === 'deviceJoined' && device.meta.hasOwnProperty('configured')) {
|
||||
delete device.meta.configured;
|
||||
device.save();
|
||||
}
|
||||
|
||||
if (this.shouldConfigure(resolvedEntity, 'message_received')) {
|
||||
this.configure(resolvedEntity);
|
||||
}
|
||||
}
|
||||
|
||||
async configure(resolvedEntity, force=false, thowError=false) {
|
||||
const device = resolvedEntity.device;
|
||||
if (this.configuring.has(device.ieeeAddr) || (this.attempts[device.ieeeAddr] >= 3 && !force)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.configuring.add(device.ieeeAddr);
|
||||
|
||||
if (!this.attempts.hasOwnProperty(device.ieeeAddr)) {
|
||||
this.attempts[device.ieeeAddr] = 0;
|
||||
}
|
||||
|
||||
logger.info(`Configuring '${resolvedEntity.name}'`);
|
||||
try {
|
||||
await resolvedEntity.definition.configure(device, this.coordinatorEndpoint, logger);
|
||||
logger.info(`Successfully configured '${resolvedEntity.name}'`);
|
||||
device.meta.configured = zhc.getConfigureKey(resolvedEntity.definition);
|
||||
device.save();
|
||||
this.eventBus.emit(`devicesChanged`);
|
||||
} catch (error) {
|
||||
this.attempts[device.ieeeAddr]++;
|
||||
const attempt = this.attempts[device.ieeeAddr];
|
||||
const msg = `Failed to configure '${resolvedEntity.name}', attempt ${attempt} (${error.stack})`;
|
||||
logger.error(msg);
|
||||
|
||||
if (thowError) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
this.configuring.delete(device.ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Configure;
|
||||
@@ -0,0 +1,133 @@
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import logger from '../util/logger';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import zhc from 'zigbee-herdsman-converters';
|
||||
import Extension from './extension';
|
||||
import bind from 'bind-decorator';
|
||||
import Device from '../model/device';
|
||||
|
||||
/**
|
||||
* This extension calls the zigbee-herdsman-converters definition configure() method
|
||||
*/
|
||||
export default class Configure extends Extension {
|
||||
private configuring = new Set();
|
||||
private attempts: {[s: string]: number} = {};
|
||||
private topic = `${settings.get().mqtt.base_topic}/bridge/request/device/configure`;
|
||||
private legacyTopic = `${settings.get().mqtt.base_topic}/bridge/configure`;
|
||||
|
||||
@bind private async onReportingDisabled(data: eventdata.ReportingDisabled): Promise<void> {
|
||||
// Disabling reporting unbinds some cluster which could be bound by configure, re-setup.
|
||||
if (data.device.zh.meta?.hasOwnProperty('configured')) {
|
||||
delete data.device.zh.meta.configured;
|
||||
data.device.zh.save();
|
||||
}
|
||||
|
||||
await this.configure(data.device, 'reporting_disabled');
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
if (data.topic === this.legacyTopic) {
|
||||
const device = this.zigbee.resolveEntity(data.message);
|
||||
if (!device || !(device instanceof Device)) {
|
||||
logger.error(`Device '${data.message}' does not exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!device.definition || !device.definition.configure) {
|
||||
logger.warn(`Skipping configure of '${device.name}', device does not require this.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.configure(device, 'mqtt_message', true);
|
||||
} else if (data.topic === this.topic) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message;
|
||||
let error = null;
|
||||
|
||||
const device = this.zigbee.resolveEntity(ID);
|
||||
if (!device || !(device instanceof Device)) {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!device.definition || !device.definition.configure) {
|
||||
error = `Device '${device.name}' cannot be configured`;
|
||||
} else {
|
||||
try {
|
||||
await this.configure(device, 'mqtt_message', true, true);
|
||||
} catch (e) {
|
||||
error = `Failed to configure (${e.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = utils.getResponse(message, {id: ID}, error);
|
||||
await this.mqtt.publish(`bridge/response/device/configure`, stringify(response));
|
||||
}
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
await this.configure(device, 'started');
|
||||
}
|
||||
|
||||
this.eventBus.onDeviceJoined(this, (data) => {
|
||||
if (data.device.zh.meta.hasOwnProperty('configured')) {
|
||||
delete data.device.zh.meta.configured;
|
||||
data.device.zh.save();
|
||||
}
|
||||
|
||||
this.configure(data.device, 'zigbee_event');
|
||||
});
|
||||
this.eventBus.onDeviceInterview(this, (data) => this.configure(data.device, 'zigbee_event'));
|
||||
this.eventBus.onLastSeenChanged(this, (data) => this.configure(data.device, 'zigbee_event'));
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.eventBus.onReportingDisabled(this, this.onReportingDisabled);
|
||||
}
|
||||
|
||||
private async configure(device: Device, event: 'started' | 'zigbee_event' | 'reporting_disabled' | 'mqtt_message',
|
||||
force=false, thowError=false): Promise<void> {
|
||||
if (!force) {
|
||||
if (!device.definition?.configure || device.zh.interviewing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.zh.meta?.hasOwnProperty('configured') &&
|
||||
device.zh.meta.configured === zhc.getConfigureKey(device.definition)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only configure end devices when it is active, otherwise it will likely fails as they are sleeping.
|
||||
if (device.zh.type === 'EndDevice' && event !== 'zigbee_event') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.configuring.has(device.ieeeAddr) || (this.attempts[device.ieeeAddr] >= 3 && !force)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.configuring.add(device.ieeeAddr);
|
||||
|
||||
if (!this.attempts.hasOwnProperty(device.ieeeAddr)) {
|
||||
this.attempts[device.ieeeAddr] = 0;
|
||||
}
|
||||
|
||||
logger.info(`Configuring '${device.name}'`);
|
||||
try {
|
||||
await device.definition.configure(device.zh, this.zigbee.firstCoordinatorEndpoint(), logger);
|
||||
logger.info(`Successfully configured '${device.name}'`);
|
||||
device.zh.meta.configured = zhc.getConfigureKey(device.definition);
|
||||
device.zh.save();
|
||||
this.eventBus.emitDevicesChanged();
|
||||
} catch (error) {
|
||||
this.attempts[device.ieeeAddr]++;
|
||||
const attempt = this.attempts[device.ieeeAddr];
|
||||
const msg = `Failed to configure '${device.name}', attempt ${attempt} (${error.stack})`;
|
||||
logger.error(msg);
|
||||
|
||||
if (thowError) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
this.configuring.delete(device.ieeeAddr);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
class Extension {
|
||||
/**
|
||||
* Besides intializing variables, the constructor should do nothing!
|
||||
*
|
||||
* @param {Zigbee} zigbee Zigbee controller
|
||||
* @param {MQTT} mqtt MQTT controller
|
||||
* @param {State} state State controller
|
||||
* @param {Function} publishEntityState Method to publish device state to MQTT.
|
||||
* @param {EventBus} eventBus The event bus
|
||||
*/
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.state = state;
|
||||
this.publishEntityState = publishEntityState;
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called by the controller once Zigbee has been started.
|
||||
*/
|
||||
// onZigbeeStarted() {}
|
||||
|
||||
/**
|
||||
* This method is called by the controller once connected to the MQTT server.
|
||||
*/
|
||||
// onMQTTConnected() {}
|
||||
|
||||
/**
|
||||
* Is called when a Zigbee message from a device is received.
|
||||
* @param {string} type Type of the message
|
||||
* @param {Object} data Data of the message
|
||||
* @param {Object?} resolvedEntity Resolved entity returned from this.zigbee.resolveEntity()
|
||||
* @param {Object?} settingsDevice Device settings
|
||||
*/
|
||||
// onZigbeeEvent(type, data, resolvedEntity) {}
|
||||
|
||||
/**
|
||||
* Is called when a MQTT message is received
|
||||
* @param {string} topic Topic on which the message was received
|
||||
* @param {Object} message The received message
|
||||
* @return {boolean} if the message was handled
|
||||
*/
|
||||
// onMQTTMessage(topic, message) {}
|
||||
|
||||
/**
|
||||
* Is called once the extension has to stop
|
||||
*/
|
||||
stop() {
|
||||
this.eventBus.removeListenersExtension(this.constructor.name);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Extension;
|
||||
@@ -0,0 +1,53 @@
|
||||
abstract class Extension {
|
||||
protected zigbee: Zigbee;
|
||||
protected mqtt: MQTT;
|
||||
protected state: State;
|
||||
protected publishEntityState: PublishEntityState;
|
||||
protected eventBus: EventBus;
|
||||
protected enableDisableExtension: (enable: boolean, name: string) => Promise<void>;
|
||||
protected restartCallback: () => void;
|
||||
protected addExtension: (extension: Extension) => void;
|
||||
|
||||
/**
|
||||
* Besides intializing variables, the constructor should do nothing!
|
||||
*
|
||||
* @param {Zigbee} zigbee Zigbee controller
|
||||
* @param {MQTT} mqtt MQTT controller
|
||||
* @param {State} state State controller
|
||||
* @param {Function} publishEntityState Method to publish device state to MQTT.
|
||||
* @param {EventBus} eventBus The event bus
|
||||
* @param {enableDisableExtension} enableDisableExtension Enable/disable extension method
|
||||
* @param {restartCallback} restartCallback Restart Zigbee2MQTT
|
||||
* @param {addExtension} addExtension Add an extension
|
||||
*/
|
||||
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => void, addExtension: (extension: Extension) => void) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
this.state = state;
|
||||
this.publishEntityState = publishEntityState;
|
||||
this.eventBus = eventBus;
|
||||
this.enableDisableExtension = enableDisableExtension;
|
||||
this.restartCallback = restartCallback;
|
||||
this.addExtension = addExtension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is called once the extension has to start
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
async start(): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Is called once the extension has to stop
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
this.eventBus.removeListeners(this);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public adjustMessageBeforePublish(entity: Group | Device, message: KeyValue): void {}
|
||||
}
|
||||
|
||||
export default Extension;
|
||||
@@ -1,18 +0,0 @@
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const settings = require('../util/settings');
|
||||
const Extension = require('./extension');
|
||||
const utils = require('../util/utils');
|
||||
|
||||
class ExternalConverters extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
|
||||
for (const definition of utils.getExternalConvertersDefinitions(settings)) {
|
||||
const toAdd = {...definition};
|
||||
delete toAdd['homeassistant'];
|
||||
zigbeeHerdsmanConverters.addDeviceDefinition(toAdd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ExternalConverters;
|
||||
@@ -0,0 +1,18 @@
|
||||
import zhc from 'zigbee-herdsman-converters';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
|
||||
export default class ExternalConverters extends Extension {
|
||||
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => void, addExtension: (extension: Extension) => void) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
|
||||
|
||||
for (const definition of utils.getExternalConvertersDefinitions(settings.get())) {
|
||||
const toAdd = {...definition};
|
||||
delete toAdd['homeassistant'];
|
||||
zhc.addDeviceDefinition(toAdd);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,30 @@
|
||||
const settings = require('../util/settings');
|
||||
const Extension = require('./extension');
|
||||
const utils = require('../util/utils');
|
||||
const fs = require('fs');
|
||||
const data = require('./../util/data');
|
||||
const path = require('path');
|
||||
const logger = require('./../util/logger');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import fs from 'fs';
|
||||
import data from './../util/data';
|
||||
import path from 'path';
|
||||
import logger from './../util/logger';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import bind from 'bind-decorator';
|
||||
import Extension from './extension';
|
||||
|
||||
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/extension/(save|remove)`);
|
||||
|
||||
class ExternalExtension extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus,
|
||||
enableDisableExtension, restartCallback, addExtension) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.args = [zigbee, mqtt, state, publishEntityState, eventBus];
|
||||
this.addExtension = addExtension;
|
||||
this.enableDisableExtension = enableDisableExtension;
|
||||
this.extensionsBaseDir = 'extension';
|
||||
this.loadExtension = this.loadExtension.bind(this);
|
||||
this.requestLookup = {
|
||||
'save': this.saveExtension.bind(this),
|
||||
'remove': this.removeExtension.bind(this),
|
||||
};
|
||||
export default class ExternalExtension extends Extension {
|
||||
private requestLookup: {[s: string]: (message: KeyValue) => MQTTResponse};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.requestLookup = {'save': this.saveExtension, 'remove': this.removeExtension};
|
||||
this.loadUserDefinedExtensions();
|
||||
}
|
||||
getExtensionsBasePath() {
|
||||
return data.joinPath(this.extensionsBaseDir);
|
||||
await this.publishExtensions();
|
||||
}
|
||||
|
||||
getListOfUserDefinedExtensions() {
|
||||
private getExtensionsBasePath(): string {
|
||||
return data.joinPath('extension');
|
||||
}
|
||||
|
||||
private getListOfUserDefinedExtensions(): {name: string, code: string}[] {
|
||||
const basePath = this.getExtensionsBasePath();
|
||||
if (fs.existsSync(basePath)) {
|
||||
return fs.readdirSync(basePath).filter((f) => f.endsWith('.js')).map((fileName) => {
|
||||
@@ -38,7 +35,8 @@ class ExternalExtension extends Extension {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
removeExtension(message) {
|
||||
|
||||
@bind private removeExtension(message: KeyValue): MQTTResponse {
|
||||
const {name} = message;
|
||||
const extensions = this.getListOfUserDefinedExtensions();
|
||||
const extensionToBeRemoved = extensions.find((e) => e.name === name);
|
||||
@@ -46,8 +44,8 @@ class ExternalExtension extends Extension {
|
||||
if (extensionToBeRemoved) {
|
||||
this.enableDisableExtension(false, extensionToBeRemoved.name);
|
||||
const basePath = this.getExtensionsBasePath();
|
||||
const extensonFilePath = path.join(basePath, path.basename(name));
|
||||
fs.unlinkSync(extensonFilePath);
|
||||
const extensionFilePath = path.join(basePath, path.basename(name));
|
||||
fs.unlinkSync(extensionFilePath);
|
||||
this.publishExtensions();
|
||||
logger.info(`Extension ${name} removed`);
|
||||
return utils.getResponse(message, {}, null);
|
||||
@@ -55,9 +53,10 @@ class ExternalExtension extends Extension {
|
||||
return utils.getResponse(message, {}, `Extension ${name} doesn't exists`);
|
||||
}
|
||||
}
|
||||
saveExtension(message) {
|
||||
|
||||
@bind private saveExtension(message: KeyValue): MQTTResponse {
|
||||
const {name, code} = message;
|
||||
const ModuleConstructor = utils.loadModuleFromText(code);
|
||||
const ModuleConstructor = utils.loadModuleFromText(code) as Extension;
|
||||
this.loadExtension(ModuleConstructor);
|
||||
const basePath = this.getExtensionsBasePath();
|
||||
/* istanbul ignore else */
|
||||
@@ -71,38 +70,36 @@ class ExternalExtension extends Extension {
|
||||
return utils.getResponse(message, {}, null);
|
||||
}
|
||||
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
const match = topic.match(requestRegex);
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(requestRegex);
|
||||
if (match && this.requestLookup[match[1].toLowerCase()]) {
|
||||
message = utils.parseJSON(message, message);
|
||||
const message = utils.parseJSON(data.message, data.message) as KeyValue;
|
||||
try {
|
||||
const response = await this.requestLookup[match[1].toLowerCase()](message);
|
||||
const response = this.requestLookup[match[1].toLowerCase()](message);
|
||||
await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response));
|
||||
} catch (error) {
|
||||
logger.error(`Request '${topic}' failed with error: '${error.message}'`);
|
||||
logger.error(`Request '${data.topic}' failed with error: '${error.message}'`);
|
||||
const response = utils.getResponse(message, {}, error.message);
|
||||
await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadExtension(ConstructorClass) {
|
||||
this.enableDisableExtension(false, ConstructorClass.name);
|
||||
this.addExtension(new ConstructorClass(...this.args, settings, logger));
|
||||
@bind private loadExtension(ConstructorClass: Extension): void {
|
||||
this.enableDisableExtension(false, ConstructorClass.constructor.name);
|
||||
// @ts-ignore
|
||||
this.addExtension(new ConstructorClass(
|
||||
this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus, settings, logger));
|
||||
}
|
||||
|
||||
loadUserDefinedExtensions() {
|
||||
private loadUserDefinedExtensions(): void {
|
||||
const extensions = this.getListOfUserDefinedExtensions();
|
||||
extensions
|
||||
.map(({code}) => utils.loadModuleFromText(code))
|
||||
.map(this.loadExtension);
|
||||
}
|
||||
async onMQTTConnected() {
|
||||
this.publishExtensions();
|
||||
}
|
||||
|
||||
async publishExtensions() {
|
||||
private async publishExtensions(): Promise<void> {
|
||||
const extensions = this.getListOfUserDefinedExtensions();
|
||||
await this.mqtt.publish('bridge/extensions', stringify(extensions), {
|
||||
retain: true,
|
||||
@@ -110,5 +107,3 @@ class ExternalExtension extends Extension {
|
||||
}, settings.get().mqtt.base_topic, true);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ExternalExtension;
|
||||
@@ -1,128 +0,0 @@
|
||||
const http = require('http');
|
||||
const serveStatic = require('serve-static');
|
||||
const finalhandler = require('finalhandler');
|
||||
const Extension = require('./extension');
|
||||
const logger = require('../util/logger');
|
||||
const frontend = require('zigbee2mqtt-frontend');
|
||||
const WebSocket = require('ws');
|
||||
const url = require('url');
|
||||
const settings = require('../util/settings');
|
||||
const utils = require('../util/utils');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
/**
|
||||
* This extension servers the frontend
|
||||
*/
|
||||
class Frontend extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.onRequest = this.onRequest.bind(this);
|
||||
this.onUpgrade = this.onUpgrade.bind(this);
|
||||
this.mqttBaseTopic = settings.get().mqtt.base_topic;
|
||||
this.onMQTTPublishMessage = this.onMQTTPublishMessage.bind(this);
|
||||
this.mqtt.on('publishMessage', this.onMQTTPublishMessage);
|
||||
this.onWebSocketConnection = this.onWebSocketConnection.bind(this);
|
||||
this.server = http.createServer(this.onRequest);
|
||||
this.server.on('upgrade', this.onUpgrade);
|
||||
this.host = settings.get().frontend.host || '0.0.0.0';
|
||||
this.port = settings.get().frontend.port || 8080;
|
||||
this.authToken = settings.get().frontend.auth_token || false;
|
||||
this.retainedMessages = new Map();
|
||||
/* istanbul ignore next */
|
||||
const options = {setHeaders: (res, path) => {
|
||||
if (path.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
}};
|
||||
this.fileServer = serveStatic(frontend.getPath(), options);
|
||||
this.wss = new WebSocket.Server({noServer: true});
|
||||
this.wss.on('connection', this.onWebSocketConnection);
|
||||
}
|
||||
|
||||
onZigbeeStarted() {
|
||||
this.server.listen(this.port, this.host);
|
||||
logger.info(`Started frontend on port ${this.host}:${this.port}`);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
for (const client of this.wss.clients) {
|
||||
client.send(stringify({topic: 'bridge/state', payload: 'offline'}));
|
||||
client.close();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.server.close(resolve);
|
||||
});
|
||||
}
|
||||
onRequest(request, response) {
|
||||
this.fileServer(request, response, finalhandler(request, response));
|
||||
}
|
||||
authenticate(request, cb) {
|
||||
const {query} = url.parse(request.url, true);
|
||||
cb(!this.authToken || this.authToken === query.token);
|
||||
}
|
||||
|
||||
onUpgrade(request, socket, head) {
|
||||
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
this.authenticate(request, (isAuthentificated) => {
|
||||
if (isAuthentificated) {
|
||||
this.wss.emit('connection', ws, request);
|
||||
} else {
|
||||
ws.close(4401, 'Unauthorized');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onWebSocketConnection(ws) {
|
||||
ws.on('message', (message) => {
|
||||
if (message) {
|
||||
const {topic, payload} = utils.parseJSON(message, message);
|
||||
this.mqtt.onMessage(`${this.mqttBaseTopic}/${topic}`, stringify(payload));
|
||||
}
|
||||
});
|
||||
|
||||
for (const [key, value] of this.retainedMessages) {
|
||||
ws.send(stringify({topic: key, payload: value}));
|
||||
}
|
||||
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
let payload = {};
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
if (this.state.exists(device.ieeeAddr)) {
|
||||
payload = {...payload, ...this.state.get(device.ieeeAddr)};
|
||||
}
|
||||
|
||||
const lastSeen = settings.get().advanced.last_seen;
|
||||
if (lastSeen !== 'disable') {
|
||||
payload.last_seen = utils.formatDate(resolvedEntity.device.lastSeen, lastSeen);
|
||||
}
|
||||
|
||||
if (resolvedEntity.device.linkquality !== undefined) {
|
||||
payload.linkquality = resolvedEntity.device.linkquality;
|
||||
}
|
||||
|
||||
ws.send(stringify({topic: resolvedEntity.name, payload}));
|
||||
}
|
||||
}
|
||||
|
||||
onMQTTPublishMessage(data) {
|
||||
let {topic, payload, options} = data;
|
||||
if (topic.startsWith(`${this.mqttBaseTopic}/`)) {
|
||||
// Send topic without base_topic
|
||||
topic = topic.substring(this.mqttBaseTopic.length + 1);
|
||||
payload = utils.parseJSON(payload, payload);
|
||||
if (options.retain) {
|
||||
this.retainedMessages.set(topic, payload);
|
||||
}
|
||||
|
||||
for (const client of this.wss.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(stringify({topic, payload}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Frontend;
|
||||
@@ -0,0 +1,139 @@
|
||||
import http from 'http';
|
||||
import serveStatic from 'serve-static';
|
||||
import finalhandler from 'finalhandler';
|
||||
import logger from '../util/logger';
|
||||
import frontend from 'zigbee2mqtt-frontend';
|
||||
import WebSocket from 'ws';
|
||||
import net from 'net';
|
||||
import url from 'url';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import Extension from './extension';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
/**
|
||||
* This extension servers the frontend
|
||||
*/
|
||||
export default class Frontend extends Extension {
|
||||
private mqttBaseTopic = settings.get().mqtt.base_topic;
|
||||
private host = settings.get().frontend.host || '0.0.0.0';
|
||||
private port = settings.get().frontend.port || 8080;
|
||||
private authToken = settings.get().frontend.auth_token || false;
|
||||
private retainedMessages = new Map();
|
||||
private server: http.Server;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private fileServer: serveStatic.RequestHandler<any>;
|
||||
private wss: WebSocket.Server = null;
|
||||
|
||||
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => void, addExtension: (extension: Extension) => void) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
|
||||
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.server = http.createServer(this.onRequest);
|
||||
this.server.on('upgrade', this.onUpgrade);
|
||||
|
||||
/* istanbul ignore next */ // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const options = {setHeaders: (res: any, path: any): void => {
|
||||
if (path.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
}};
|
||||
|
||||
this.fileServer = serveStatic(frontend.getPath(), options);
|
||||
this.wss = new WebSocket.Server({noServer: true});
|
||||
this.wss.on('connection', this.onWebSocketConnection);
|
||||
|
||||
this.server.listen(this.port, this.host);
|
||||
logger.info(`Started frontend on port ${this.host}:${this.port}`);
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
super.stop();
|
||||
for (const client of this.wss.clients) {
|
||||
client.send(stringify({topic: 'bridge/state', payload: 'offline'}));
|
||||
client.terminate();
|
||||
}
|
||||
this.wss.close();
|
||||
return new Promise((cb: () => void) => this.server.close(cb));
|
||||
}
|
||||
|
||||
@bind private onRequest(request: http.IncomingMessage, response: http.ServerResponse): void {
|
||||
// @ts-ignore
|
||||
this.fileServer(request, response, finalhandler(request, response));
|
||||
}
|
||||
|
||||
private authenticate(request: http.IncomingMessage, cb: (authenticate: boolean) => void): void {
|
||||
const {query} = url.parse(request.url, true);
|
||||
cb(!this.authToken || this.authToken === query.token);
|
||||
}
|
||||
|
||||
@bind private onUpgrade(request: http.IncomingMessage, socket: net.Socket, head: Buffer): void {
|
||||
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
this.authenticate(request, (isAuthentificated) => {
|
||||
if (isAuthentificated) {
|
||||
this.wss.emit('connection', ws, request);
|
||||
} else {
|
||||
ws.close(4401, 'Unauthorized');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@bind private onWebSocketConnection(ws: WebSocket): void {
|
||||
ws.on('message', (data: Buffer, isBinary: boolean) => {
|
||||
if (!isBinary && data) {
|
||||
const message = data.toString();
|
||||
const {topic, payload} = JSON.parse(message);
|
||||
this.mqtt.onMessage(`${this.mqttBaseTopic}/${topic}`, stringify(payload));
|
||||
}
|
||||
});
|
||||
|
||||
for (const [key, value] of this.retainedMessages) {
|
||||
ws.send(stringify({topic: key, payload: value}));
|
||||
}
|
||||
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
let payload: KeyValue = {};
|
||||
if (this.state.exists(device)) {
|
||||
payload = {...payload, ...this.state.get(device)};
|
||||
}
|
||||
|
||||
const lastSeen = settings.get().advanced.last_seen;
|
||||
/* istanbul ignore if */
|
||||
if (lastSeen !== 'disable') {
|
||||
payload.last_seen = utils.formatDate(device.zh.lastSeen, lastSeen);
|
||||
}
|
||||
|
||||
if (device.zh.linkquality !== undefined) {
|
||||
payload.linkquality = device.zh.linkquality;
|
||||
}
|
||||
|
||||
ws.send(stringify({topic: device.name, payload}));
|
||||
}
|
||||
}
|
||||
|
||||
@bind private onMQTTPublishMessage(data: eventdata.MQTTMessagePublished): void {
|
||||
if (data.topic.startsWith(`${this.mqttBaseTopic}/`)) {
|
||||
// Send topic without base_topic
|
||||
const topic = data.topic.substring(this.mqttBaseTopic.length + 1);
|
||||
const payload = utils.parseJSON(data.payload, data.payload);
|
||||
if (data.options.retain) {
|
||||
this.retainedMessages.set(topic, payload);
|
||||
}
|
||||
|
||||
if (this.wss) {
|
||||
for (const client of this.wss.clients) {
|
||||
/* istanbul ignore else */
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(stringify({topic, payload}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const Extension = require('./extension');
|
||||
const utils = require('../util/utils');
|
||||
const postfixes = utils.getEndpointNames();
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const equals = require('fast-deep-equal/es6');
|
||||
|
||||
const topicRegex =
|
||||
new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
|
||||
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/(.+)/(remove|add|remove_all)$`);
|
||||
const legacyTopicRegexRemoveAll = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/remove_all$`);
|
||||
|
||||
const stateProperties = {
|
||||
'state': (value, definition) => true,
|
||||
'brightness': (value, definition) =>
|
||||
definition.exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'brightness')),
|
||||
'color_temp': (value, definition) =>
|
||||
definition.exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'color_temp')),
|
||||
'color': (value, definition) =>
|
||||
definition.exposes.find((e) => e.type === 'light' &&
|
||||
e.features.find((f) => f.name === 'color_xy' || f.name === 'color_hs')),
|
||||
'color_mode': (value, definition) =>
|
||||
definition.exposes.find((e) => e.type === 'light' && (
|
||||
(e.features.find((f) => f.name === `color_${value}`)) ||
|
||||
(value === 'color_temp' && e.features.find((f) => f.name === 'color_temp')) )),
|
||||
};
|
||||
|
||||
class Groups extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.onStateChange = this.onStateChange.bind(this);
|
||||
this.legacyApi = settings.get().advanced.legacy_api;
|
||||
this.lastOptimisticState = {};
|
||||
}
|
||||
|
||||
async onZigbeeStarted() {
|
||||
this.eventBus.on('stateChange', this.onStateChange, this.constructor.name);
|
||||
await this.syncGroupsWithSettings();
|
||||
}
|
||||
|
||||
async syncGroupsWithSettings() {
|
||||
const settingsGroups = settings.getGroups();
|
||||
const zigbeeGroups = this.zigbee.getGroups();
|
||||
|
||||
const addRemoveFromGroup = async (action, deviceName, groupName, endpoint, group) => {
|
||||
try {
|
||||
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} '${deviceName}' to group '${groupName}'`);
|
||||
if (action === 'remove') {
|
||||
await endpoint.removeFromGroup(group);
|
||||
} else {
|
||||
await endpoint.addToGroup(group);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${action} '${deviceName}' from '${groupName}'`);
|
||||
logger.debug(error.stack);
|
||||
}
|
||||
};
|
||||
|
||||
for (const settingGroup of settingsGroups) {
|
||||
const groupID = settingGroup.ID;
|
||||
const zigbeeGroup = zigbeeGroups.find((g) => g.groupID === groupID) || this.zigbee.createGroup(groupID);
|
||||
const settingsEntity = settingGroup.devices.map((d) => {
|
||||
const entity = this.zigbee.resolveEntity(d);
|
||||
if (!entity) logger.error(`Cannot find '${d}' of group '${settingGroup.friendlyName}'`);
|
||||
return entity;
|
||||
}).filter((e) => e != null);
|
||||
|
||||
// In settings but not in zigbee
|
||||
for (const entity of settingsEntity) {
|
||||
if (!zigbeeGroup.hasMember(entity.endpoint)) {
|
||||
addRemoveFromGroup('add', entity.name, settingGroup.friendlyName, entity.endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
|
||||
// In zigbee but not in settings
|
||||
for (const endpoint of zigbeeGroup.members) {
|
||||
if (!settingsEntity.find((e) => e.endpoint === endpoint)) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendlyName;
|
||||
addRemoveFromGroup('remove', deviceName, settingGroup.friendlyName, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const zigbeeGroup of zigbeeGroups) {
|
||||
if (!settingsGroups.find((g) => g.ID === zigbeeGroup.groupID)) {
|
||||
for (const endpoint of zigbeeGroup.members) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendlyName;
|
||||
addRemoveFromGroup('remove', deviceName, zigbeeGroup.groupID, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onStateChange(data) {
|
||||
const reason = 'group_optimistic';
|
||||
if (data.reason === reason) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {};
|
||||
|
||||
let endpointName;
|
||||
for (let [prop, value] of Object.entries(data.update)) {
|
||||
const endpointNameMatch = utils.getEndpointNames().find((n) => prop.endsWith(`_${n}`));
|
||||
if (endpointNameMatch) {
|
||||
prop = prop.substring(0, prop.length - endpointNameMatch.length - 1);
|
||||
endpointName = endpointNameMatch;
|
||||
}
|
||||
|
||||
if (prop in stateProperties) {
|
||||
payload[prop] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(`${data.ID}${endpointName ? `/${endpointName}` : ''}`);
|
||||
const zigbeeGroups = this.zigbee.getGroups().filter((zigbeeGroup) => {
|
||||
const settingsGroup = settings.getGroup(zigbeeGroup.groupID);
|
||||
return settingsGroup && (!settingsGroup.hasOwnProperty('optimistic') || settingsGroup.optimistic);
|
||||
});
|
||||
|
||||
if (resolvedEntity.type === 'device') {
|
||||
for (const zigbeeGroup of zigbeeGroups) {
|
||||
if (zigbeeGroup.hasMember(resolvedEntity.endpoint) &&
|
||||
!equals(this.lastOptimisticState[zigbeeGroup.groupID], payload)) {
|
||||
if (!payload || payload.state !== 'OFF' || this.areAllMembersOff(zigbeeGroup)) {
|
||||
this.lastOptimisticState[zigbeeGroup.groupID] = payload;
|
||||
await this.publishEntityState(zigbeeGroup.groupID, payload, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Invalidate the last optimistic group state when group state is changed directly.
|
||||
delete this.lastOptimisticState[resolvedEntity.group.groupID];
|
||||
|
||||
const groupIDsToPublish = new Set();
|
||||
for (const member of resolvedEntity.group.members) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(member);
|
||||
const memberPayload = {};
|
||||
Object.keys(payload).forEach((key) => {
|
||||
if (stateProperties[key](payload[key], resolvedEntity.definition)) {
|
||||
memberPayload[key] = payload[key];
|
||||
}
|
||||
});
|
||||
|
||||
const endpointName = resolvedEntity.endpointName;
|
||||
if (endpointName) {
|
||||
Object.keys(memberPayload).forEach((key) => {
|
||||
memberPayload[`${key}_${endpointName}`] = memberPayload[key];
|
||||
delete memberPayload[key];
|
||||
});
|
||||
}
|
||||
|
||||
await this.publishEntityState(member.getDevice().ieeeAddr, memberPayload, reason);
|
||||
for (const zigbeeGroup of zigbeeGroups) {
|
||||
if (zigbeeGroup.hasMember(member)) {
|
||||
if (!payload || payload.state !== 'OFF' || this.areAllMembersOff(zigbeeGroup)) {
|
||||
groupIDsToPublish.add(zigbeeGroup.groupID);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
groupIDsToPublish.delete(resolvedEntity.group.groupID);
|
||||
for (const groupID of groupIDsToPublish) {
|
||||
await this.publishEntityState(groupID, payload, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
areAllMembersOff(zigbeeGroup) {
|
||||
for (const member of zigbeeGroup.members) {
|
||||
const device = member.getDevice();
|
||||
if (this.state.exists(device.ieeeAddr)) {
|
||||
const state = this.state.get(device.ieeeAddr);
|
||||
if (state && state.state === 'ON') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
parseMQTTMessage(topic, message) {
|
||||
let type = null;
|
||||
let resolvedEntityGroup = null;
|
||||
let resolvedEntityDevice = null;
|
||||
let hasEndpointName = null;
|
||||
let error = null;
|
||||
let groupKey = null;
|
||||
let deviceKey = null;
|
||||
let triggeredViaLegacyApi = false;
|
||||
let skipDisableReporting = false;
|
||||
|
||||
/* istanbul ignore else */
|
||||
const topicRegexMatch = topic.match(topicRegex);
|
||||
const legacyTopicRegexRemoveAllMatch = topic.match(legacyTopicRegexRemoveAll);
|
||||
const legacyTopicRegexMatch = topic.match(legacyTopicRegex);
|
||||
|
||||
if (this.legacyApi && (legacyTopicRegexMatch || legacyTopicRegexRemoveAllMatch)) {
|
||||
triggeredViaLegacyApi = true;
|
||||
if (legacyTopicRegexMatch) {
|
||||
resolvedEntityGroup = this.zigbee.resolveEntity(legacyTopicRegexMatch[1]);
|
||||
type = legacyTopicRegexMatch[2];
|
||||
|
||||
if (!resolvedEntityGroup || resolvedEntityGroup.type !== 'group') {
|
||||
logger.error(`Group '${legacyTopicRegexMatch[1]}' does not exist`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {
|
||||
friendly_name: message, group: legacyTopicRegexMatch[1], error: 'group doesn\'t exists',
|
||||
};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_${type}_failed`, message: payload}),
|
||||
);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
} else {
|
||||
type = 'remove_all';
|
||||
}
|
||||
|
||||
resolvedEntityDevice = this.zigbee.resolveEntity(message);
|
||||
if (!resolvedEntityDevice || !resolvedEntityDevice.type === 'device') {
|
||||
logger.error(`Device '${message}' does not exist`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {
|
||||
friendly_name: message, group: legacyTopicRegexMatch[1], error: 'entity doesn\'t exists',
|
||||
};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_${type}_failed`, message: payload}),
|
||||
);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
hasEndpointName = postfixes.find((p) => message.endsWith(`/${p}`));
|
||||
} else if (topicRegexMatch) {
|
||||
type = topicRegexMatch[1];
|
||||
message = JSON.parse(message);
|
||||
deviceKey = message.device;
|
||||
skipDisableReporting = 'skip_disable_reporting' in message ? message.skip_disable_reporting : false;
|
||||
|
||||
if (type !== 'remove_all') {
|
||||
groupKey = message.group;
|
||||
resolvedEntityGroup = this.zigbee.resolveEntity(message.group);
|
||||
if (!resolvedEntityGroup || resolvedEntityGroup.type !== 'group') {
|
||||
error = `Group '${message.group}' does not exist`;
|
||||
}
|
||||
}
|
||||
|
||||
resolvedEntityDevice = this.zigbee.resolveEntity(message.device);
|
||||
if (!error && (!resolvedEntityDevice || !resolvedEntityDevice.type === 'device')) {
|
||||
error = `Device '${message.device}' does not exist`;
|
||||
}
|
||||
|
||||
hasEndpointName = postfixes.find((p) => message.device.endsWith(`/${p}`));
|
||||
}
|
||||
|
||||
return {
|
||||
resolvedEntityGroup, resolvedEntityDevice, type, hasEndpointName, error, groupKey, deviceKey,
|
||||
triggeredViaLegacyApi, skipDisableReporting,
|
||||
};
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
let {
|
||||
resolvedEntityGroup, resolvedEntityDevice, type, hasEndpointName, error, triggeredViaLegacyApi,
|
||||
groupKey, deviceKey, skipDisableReporting,
|
||||
} = this.parseMQTTMessage(topic, message);
|
||||
if (!type) return;
|
||||
message = utils.parseJSON(message, message);
|
||||
|
||||
const responseData = {device: deviceKey};
|
||||
if (groupKey) {
|
||||
responseData.group = groupKey;
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
const keys = [
|
||||
`${resolvedEntityDevice.device.ieeeAddr}/${resolvedEntityDevice.endpoint.ID}`,
|
||||
`${resolvedEntityDevice.name}/${resolvedEntityDevice.endpoint.ID}`,
|
||||
];
|
||||
|
||||
const definition = resolvedEntityDevice.definition;
|
||||
const endpoints = definition && definition.endpoint ?
|
||||
definition.endpoint(resolvedEntityDevice.device) : null;
|
||||
let endpointName = null;
|
||||
if (endpoints) {
|
||||
const matches = Object.entries(endpoints).find((e) => e[1] === resolvedEntityDevice.endpoint.ID);
|
||||
endpointName = matches && matches[0];
|
||||
}
|
||||
|
||||
if (endpointName) {
|
||||
keys.push(`${resolvedEntityDevice.device.ieeeAddr}/${endpointName}`);
|
||||
keys.push(`${resolvedEntityDevice.name}/${endpointName}`);
|
||||
}
|
||||
|
||||
if (!hasEndpointName) {
|
||||
keys.push(resolvedEntityDevice.name);
|
||||
keys.push(resolvedEntityDevice.device.ieeeAddr);
|
||||
}
|
||||
|
||||
if (type === 'add') {
|
||||
logger.info(`Adding '${resolvedEntityDevice.name}' to '${resolvedEntityGroup.name}'`);
|
||||
await resolvedEntityDevice.endpoint.addToGroup(resolvedEntityGroup.group);
|
||||
settings.addDeviceToGroup(resolvedEntityGroup.settings.ID, keys);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_add`, message: payload}),
|
||||
);
|
||||
}
|
||||
} else if (type === 'remove') {
|
||||
logger.info(`Removing '${resolvedEntityDevice.name}' from '${resolvedEntityGroup.name}'`);
|
||||
await resolvedEntityDevice.endpoint.removeFromGroup(resolvedEntityGroup.group);
|
||||
settings.removeDeviceFromGroup(resolvedEntityGroup.settings.ID, keys);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_remove`, message: payload}),
|
||||
);
|
||||
}
|
||||
} else { // remove_all
|
||||
logger.info(`Removing '${resolvedEntityDevice.name}' from all groups`);
|
||||
await resolvedEntityDevice.endpoint.removeFromAllGroups();
|
||||
for (const settingsGroup of settings.getGroups()) {
|
||||
settings.removeDeviceFromGroup(settingsGroup.ID, keys);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {friendly_name: resolvedEntityDevice.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_remove_all`, message: payload}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Failed to ${type} from group (${e.message})`;
|
||||
logger.debug(e.stack);
|
||||
}
|
||||
}
|
||||
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
await this.mqtt.publish(`bridge/response/group/members/${type}`, stringify(response));
|
||||
}
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
} else {
|
||||
this.eventBus.emit('groupMembersChanged',
|
||||
{group: resolvedEntityGroup, action: type, endpoint: resolvedEntityDevice.endpoint,
|
||||
skipDisableReporting: skipDisableReporting});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Groups;
|
||||
@@ -0,0 +1,373 @@
|
||||
import * as settings from '../util/settings';
|
||||
import logger from '../util/logger';
|
||||
import utils from '../util/utils';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import bind from 'bind-decorator';
|
||||
import Extension from './extension';
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
|
||||
const topicRegex =
|
||||
new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
|
||||
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/(.+)/(remove|add|remove_all)$`);
|
||||
const legacyTopicRegexRemoveAll = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/remove_all$`);
|
||||
|
||||
const stateProperties: {[s: string]: (value: string, definition: zhc.Definition) => boolean} = {
|
||||
'state': () => true,
|
||||
'brightness': (value, definition) =>
|
||||
!!definition.exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'brightness')),
|
||||
'color_temp': (value, definition) =>
|
||||
!!definition.exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'color_temp')),
|
||||
'color': (value, definition) =>
|
||||
!!definition.exposes.find((e) => e.type === 'light' &&
|
||||
e.features.find((f) => f.name === 'color_xy' || f.name === 'color_hs')),
|
||||
'color_mode': (value, definition) =>
|
||||
!!definition.exposes.find((e) => e.type === 'light' && (
|
||||
(e.features.find((f) => f.name === `color_${value}`)) ||
|
||||
(value === 'color_temp' && e.features.find((f) => f.name === 'color_temp')) )),
|
||||
};
|
||||
|
||||
interface ParsedMQTTMessage {
|
||||
type: 'remove' | 'add' | 'remove_all', resolvedEntityGroup: Group, resolvedEntityDevice: Device,
|
||||
error: string, groupKey: string, deviceKey: string, triggeredViaLegacyApi: boolean,
|
||||
skipDisableReporting: boolean, resolvedEntityEndpoint: zh.Endpoint,
|
||||
}
|
||||
|
||||
export default class Groups extends Extension {
|
||||
private legacyApi = settings.get().advanced.legacy_api;
|
||||
private lastOptimisticState: {[s: string]: KeyValue} = {};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onStateChange(this, this.onStateChange);
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
await this.syncGroupsWithSettings();
|
||||
}
|
||||
|
||||
private async syncGroupsWithSettings(): Promise<void> {
|
||||
const settingsGroups = settings.getGroups();
|
||||
const zigbeeGroups = this.zigbee.groups();
|
||||
|
||||
const addRemoveFromGroup = async (action: 'add' | 'remove', deviceName: string,
|
||||
groupName: string | number, endpoint: zh.Endpoint, group: Group): Promise<void> => {
|
||||
try {
|
||||
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} '${deviceName}' to group '${groupName}'`);
|
||||
if (action === 'remove') {
|
||||
await endpoint.removeFromGroup(group.zh);
|
||||
} else {
|
||||
await endpoint.addToGroup(group.zh);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${action} '${deviceName}' from '${groupName}'`);
|
||||
logger.debug(error.stack);
|
||||
}
|
||||
};
|
||||
|
||||
for (const settingGroup of settingsGroups) {
|
||||
const groupID = settingGroup.ID;
|
||||
const zigbeeGroup = zigbeeGroups.find((g) => g.ID === groupID) || this.zigbee.createGroup(groupID);
|
||||
const settingsEndpoint = settingGroup.devices.map((d) => {
|
||||
const parsed = utils.parseEntityID(d);
|
||||
const entity = this.zigbee.resolveEntity(parsed.ID) as Device;
|
||||
if (!entity) logger.error(`Cannot find '${d}' of group '${settingGroup.friendly_name}'`);
|
||||
return {'endpoint': entity?.endpoint(parsed.endpoint), 'name': entity?.name};
|
||||
}).filter((e) => e.endpoint != null);
|
||||
|
||||
// In settings but not in zigbee
|
||||
for (const entity of settingsEndpoint) {
|
||||
if (!zigbeeGroup.zh.hasMember(entity.endpoint)) {
|
||||
addRemoveFromGroup('add', entity.name, settingGroup.friendly_name, entity.endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
|
||||
// In zigbee but not in settings
|
||||
for (const endpoint of zigbeeGroup.zh.members) {
|
||||
if (!settingsEndpoint.find((e) => e.endpoint === endpoint)) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name;
|
||||
addRemoveFromGroup('remove', deviceName, settingGroup.friendly_name, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const zigbeeGroup of zigbeeGroups) {
|
||||
if (!settingsGroups.find((g) => g.ID === zigbeeGroup.ID)) {
|
||||
for (const endpoint of zigbeeGroup.zh.members) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name;
|
||||
addRemoveFromGroup('remove', deviceName, zigbeeGroup.ID, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onStateChange(data: eventdata.StateChange): Promise<void> {
|
||||
const reason = 'groupOptimistic';
|
||||
if (data.reason === reason) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: KeyValue = {};
|
||||
|
||||
let endpointName: string = null;
|
||||
for (let [prop, value] of Object.entries(data.update)) {
|
||||
const endpointNameMatch = utils.endpointNames.find((n) => prop.endsWith(`_${n}`));
|
||||
if (endpointNameMatch) {
|
||||
prop = prop.substring(0, prop.length - endpointNameMatch.length - 1);
|
||||
endpointName = endpointNameMatch;
|
||||
}
|
||||
|
||||
if (prop in stateProperties) {
|
||||
payload[prop] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length) {
|
||||
const entity = data.entity;
|
||||
const groups = this.zigbee.groups().filter((g) => {
|
||||
return g.settings && (!g.settings.hasOwnProperty('optimistic') || g.settings.optimistic);
|
||||
});
|
||||
|
||||
if (entity instanceof Device) {
|
||||
for (const group of groups) {
|
||||
if (group.zh.hasMember(entity.endpoint(endpointName)) &&
|
||||
!equals(this.lastOptimisticState[group.ID], payload)) {
|
||||
if (!payload || payload.state !== 'OFF' || this.areAllMembersOff(group)) {
|
||||
this.lastOptimisticState[group.ID] = payload;
|
||||
await this.publishEntityState(group, payload, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Invalidate the last optimistic group state when group state is changed directly.
|
||||
delete this.lastOptimisticState[entity.ID];
|
||||
|
||||
const groupsToPublish: Set<Group> = new Set();
|
||||
for (const member of entity.zh.members) {
|
||||
const device = this.zigbee.resolveEntity(member.getDevice()) as Device;
|
||||
const memberPayload: KeyValue = {};
|
||||
Object.keys(payload).forEach((key) => {
|
||||
if (stateProperties[key](payload[key], device.definition)) {
|
||||
memberPayload[key] = payload[key];
|
||||
}
|
||||
});
|
||||
|
||||
const endpointName = device.endpointName(member);
|
||||
if (endpointName) {
|
||||
Object.keys(memberPayload).forEach((key) => {
|
||||
memberPayload[`${key}_${endpointName}`] = memberPayload[key];
|
||||
delete memberPayload[key];
|
||||
});
|
||||
}
|
||||
|
||||
await this.publishEntityState(device, memberPayload, reason);
|
||||
for (const zigbeeGroup of groups) {
|
||||
if (zigbeeGroup.zh.hasMember(member)) {
|
||||
if (!payload || payload.state !== 'OFF' || this.areAllMembersOff(zigbeeGroup)) {
|
||||
groupsToPublish.add(zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
groupsToPublish.delete(entity);
|
||||
for (const group of groupsToPublish) {
|
||||
await this.publishEntityState(group, payload, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private areAllMembersOff(group: Group): boolean {
|
||||
for (const member of group.zh.members) {
|
||||
const device = this.zigbee.resolveEntity(member.getDevice());
|
||||
if (this.state.exists(device)) {
|
||||
const state = this.state.get(device);
|
||||
if (state && state.state === 'ON') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private parseMQTTMessage(data: eventdata.MQTTMessage): ParsedMQTTMessage {
|
||||
let type: 'remove' | 'add' | 'remove_all' = null;
|
||||
let resolvedEntityGroup: Group = null;
|
||||
let resolvedEntityDevice: Device = null;
|
||||
let resolvedEntityEndpoint: zh.Endpoint = null;
|
||||
let error: string = null;
|
||||
let groupKey: string = null;
|
||||
let deviceKey: string = null;
|
||||
let triggeredViaLegacyApi = false;
|
||||
let skipDisableReporting = false;
|
||||
|
||||
/* istanbul ignore else */
|
||||
const topicRegexMatch = data.topic.match(topicRegex);
|
||||
const legacyTopicRegexRemoveAllMatch = data.topic.match(legacyTopicRegexRemoveAll);
|
||||
const legacyTopicRegexMatch = data.topic.match(legacyTopicRegex);
|
||||
|
||||
if (this.legacyApi && (legacyTopicRegexMatch || legacyTopicRegexRemoveAllMatch)) {
|
||||
triggeredViaLegacyApi = true;
|
||||
if (legacyTopicRegexMatch) {
|
||||
resolvedEntityGroup = this.zigbee.resolveEntity(legacyTopicRegexMatch[1]) as Group;
|
||||
type = legacyTopicRegexMatch[2] as 'remove' | 'remove_all' | 'add';
|
||||
|
||||
if (!resolvedEntityGroup || !(resolvedEntityGroup instanceof Group)) {
|
||||
logger.error(`Group '${legacyTopicRegexMatch[1]}' does not exist`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {friendly_name: data.message,
|
||||
group: legacyTopicRegexMatch[1], error: 'group doesn\'t exists'};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_${type}_failed`, message: payload}),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
type = 'remove_all';
|
||||
}
|
||||
|
||||
const parsedEntity = utils.parseEntityID(data.message);
|
||||
resolvedEntityDevice = this.zigbee.resolveEntity(parsedEntity.ID) as Device;
|
||||
if (!resolvedEntityDevice || !(resolvedEntityDevice instanceof Device)) {
|
||||
logger.error(`Device '${data.message}' does not exist`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {
|
||||
friendly_name: data.message, group: legacyTopicRegexMatch[1], error: 'entity doesn\'t exists',
|
||||
};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_${type}_failed`, message: payload}),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
resolvedEntityEndpoint = resolvedEntityDevice.endpoint(parsedEntity.endpoint);
|
||||
} else if (topicRegexMatch) {
|
||||
type = topicRegexMatch[1] as 'remove' | 'add' | 'remove_all';
|
||||
const message = JSON.parse(data.message);
|
||||
deviceKey = message.device;
|
||||
skipDisableReporting = 'skip_disable_reporting' in message ? message.skip_disable_reporting : false;
|
||||
|
||||
if (type !== 'remove_all') {
|
||||
groupKey = message.group;
|
||||
resolvedEntityGroup = this.zigbee.resolveEntity(message.group) as Group;
|
||||
if (!resolvedEntityGroup || !(resolvedEntityGroup instanceof Group)) {
|
||||
error = `Group '${message.group}' does not exist`;
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = utils.parseEntityID(message.device);
|
||||
resolvedEntityDevice = this.zigbee.resolveEntity(parsed.ID) as Device;
|
||||
if (!error && (!resolvedEntityDevice || !(resolvedEntityDevice instanceof Device))) {
|
||||
error = `Device '${message.device}' does not exist`;
|
||||
}
|
||||
if (!error) {
|
||||
resolvedEntityEndpoint = resolvedEntityDevice.endpoint(parsed.endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
resolvedEntityGroup, resolvedEntityDevice, type, error, groupKey, deviceKey,
|
||||
triggeredViaLegacyApi, skipDisableReporting, resolvedEntityEndpoint,
|
||||
};
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const parsed = this.parseMQTTMessage(data);
|
||||
if (!parsed || !parsed.type) return;
|
||||
let {
|
||||
resolvedEntityGroup, resolvedEntityDevice, type, error, triggeredViaLegacyApi,
|
||||
groupKey, deviceKey, skipDisableReporting, resolvedEntityEndpoint,
|
||||
} = parsed;
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
|
||||
const responseData: KeyValue = {device: deviceKey};
|
||||
if (groupKey) {
|
||||
responseData.group = groupKey;
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
const keys = [
|
||||
`${resolvedEntityDevice.ieeeAddr}/${resolvedEntityEndpoint.ID}`,
|
||||
`${resolvedEntityDevice.name}/${resolvedEntityEndpoint.ID}`,
|
||||
];
|
||||
|
||||
const endpointNameLocal = resolvedEntityDevice.endpointName(resolvedEntityEndpoint);
|
||||
if (endpointNameLocal) {
|
||||
keys.push(`${resolvedEntityDevice.ieeeAddr}/${endpointNameLocal}`);
|
||||
keys.push(`${resolvedEntityDevice.name}/${endpointNameLocal}`);
|
||||
}
|
||||
|
||||
if (!endpointNameLocal) {
|
||||
keys.push(resolvedEntityDevice.name);
|
||||
keys.push(resolvedEntityDevice.ieeeAddr);
|
||||
}
|
||||
|
||||
if (type === 'add') {
|
||||
logger.info(`Adding '${resolvedEntityDevice.name}' to '${resolvedEntityGroup.name}'`);
|
||||
await resolvedEntityEndpoint.addToGroup(resolvedEntityGroup.zh);
|
||||
settings.addDeviceToGroup(resolvedEntityGroup.ID.toString(), keys);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_add`, message: payload}),
|
||||
);
|
||||
}
|
||||
} else if (type === 'remove') {
|
||||
logger.info(`Removing '${resolvedEntityDevice.name}' from '${resolvedEntityGroup.name}'`);
|
||||
await resolvedEntityEndpoint.removeFromGroup(resolvedEntityGroup.zh);
|
||||
settings.removeDeviceFromGroup(resolvedEntityGroup.ID.toString(), keys);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_remove`, message: payload}),
|
||||
);
|
||||
}
|
||||
} else { // remove_all
|
||||
logger.info(`Removing '${resolvedEntityDevice.name}' from all groups`);
|
||||
await resolvedEntityEndpoint.removeFromAllGroups();
|
||||
for (const settingsGroup of settings.getGroups()) {
|
||||
settings.removeDeviceFromGroup(settingsGroup.ID.toString(), keys);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const payload = {friendly_name: resolvedEntityDevice.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_group_remove_all`, message: payload}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Failed to ${type} from group (${e.message})`;
|
||||
logger.debug(e.stack);
|
||||
}
|
||||
}
|
||||
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
await this.mqtt.publish(`bridge/response/group/members/${type}`, stringify(response));
|
||||
}
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
} else {
|
||||
this.eventBus.emitGroupMembersChanged({
|
||||
group: resolvedEntityGroup, action: type, endpoint: resolvedEntityEndpoint, skipDisableReporting});
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,41 +1,21 @@
|
||||
const settings = require('../../util/settings');
|
||||
const logger = require('../../util/logger');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const utils = require('../../util/utils');
|
||||
const assert = require('assert');
|
||||
const Extension = require('../extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
import * as settings from '../../util/settings';
|
||||
import logger from '../../util/logger';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
import utils from '../../util/utils';
|
||||
import assert from 'assert';
|
||||
import Extension from '../extension';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
const configRegex =
|
||||
new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/((?:\\w+/get)|(?:\\w+/factory_reset)|(?:\\w+))`);
|
||||
const allowedLogLevels = ['error', 'warn', 'info', 'debug'];
|
||||
|
||||
class BridgeLegacy extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
export default class BridgeLegacy extends Extension {
|
||||
private lastJoinedDeviceName: string = null;
|
||||
private supportedOptions: {[s: string]: (topic: string, message: string) => Promise<void> | void};
|
||||
|
||||
// Bind functions
|
||||
this.permitJoin = this.permitJoin.bind(this);
|
||||
this.lastSeen = this.lastSeen.bind(this);
|
||||
this.elapsed = this.elapsed.bind(this);
|
||||
this.reset = this.reset.bind(this);
|
||||
this.logLevel = this.logLevel.bind(this);
|
||||
this.devices = this.devices.bind(this);
|
||||
this.groups = this.groups.bind(this);
|
||||
this.rename = this.rename.bind(this);
|
||||
this.renameLast = this.renameLast.bind(this);
|
||||
this.remove = this.remove.bind(this);
|
||||
this.forceRemove = this.forceRemove.bind(this);
|
||||
this.ban = this.ban.bind(this);
|
||||
this.deviceOptions = this.deviceOptions.bind(this);
|
||||
this.addGroup = this.addGroup.bind(this);
|
||||
this.removeGroup = this.removeGroup.bind(this);
|
||||
this.whitelist = this.whitelist.bind(this);
|
||||
this.touchlinkFactoryReset = this.touchlinkFactoryReset.bind(this);
|
||||
|
||||
this.lastJoinedDeviceName = null;
|
||||
|
||||
// Set supported options
|
||||
override async start(): Promise<void> {
|
||||
this.supportedOptions = {
|
||||
'permit_join': this.permitJoin,
|
||||
'last_seen': this.lastSeen,
|
||||
@@ -57,24 +37,32 @@ class BridgeLegacy extends Extension {
|
||||
'whitelist': this.whitelist,
|
||||
'touchlink/factory_reset': this.touchlinkFactoryReset,
|
||||
};
|
||||
|
||||
this.eventBus.onDeviceJoined(this, (data) => this.onZigbeeEvent_('deviceJoined', data, data.device));
|
||||
this.eventBus.onDeviceInterview(this, (data) => this.onZigbeeEvent_('deviceInterview', data, data.device));
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => this.onZigbeeEvent_('deviceAnnounce', data, data.device));
|
||||
this.eventBus.onDeviceLeave(this, (data) => this.onZigbeeEvent_('deviceLeave', data, null));
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
|
||||
await this.publish();
|
||||
}
|
||||
|
||||
whitelist(topic, message) {
|
||||
@bind whitelist(topic: string, message: string): void {
|
||||
try {
|
||||
const entity = settings.getEntity(message);
|
||||
const entity = settings.getDevice(message);
|
||||
assert(entity, `Entity '${message}' does not exist`);
|
||||
settings.whitelistDevice(entity.ID);
|
||||
logger.info(`Whitelisted '${entity.friendlyName}'`);
|
||||
settings.whitelistDevice(entity.ID.toString());
|
||||
logger.info(`Whitelisted '${entity.friendly_name}'`);
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: 'device_whitelisted', message: {friendly_name: entity.friendlyName}}),
|
||||
stringify({type: 'device_whitelisted', message: {friendly_name: entity.friendly_name}}),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to whitelist '${message}' '${error}'`);
|
||||
}
|
||||
}
|
||||
|
||||
deviceOptions(topic, message) {
|
||||
@bind deviceOptions(topic: string, message: string): void {
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(message);
|
||||
@@ -88,18 +76,18 @@ class BridgeLegacy extends Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
const entity = settings.getEntity(json.friendly_name);
|
||||
const entity = settings.getDevice(json.friendly_name);
|
||||
assert(entity, `Entity '${json.friendly_name}' does not exist`);
|
||||
settings.changeEntityOptions(entity.ID, json.options);
|
||||
settings.changeEntityOptions(entity.ID.toString(), json.options);
|
||||
logger.info(`Changed device specific options of '${json.friendly_name}' (${stringify(json.options)})`);
|
||||
}
|
||||
|
||||
async permitJoin(topic, message) {
|
||||
@bind async permitJoin(topic: string, message: string): Promise<void> {
|
||||
await this.zigbee.permitJoin(message.toLowerCase() === 'true');
|
||||
this.publish();
|
||||
}
|
||||
|
||||
async reset(topic, message) {
|
||||
@bind async reset(): Promise<void> {
|
||||
try {
|
||||
await this.zigbee.reset('soft');
|
||||
logger.info('Soft resetted ZNP');
|
||||
@@ -108,7 +96,7 @@ class BridgeLegacy extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
lastSeen(topic, message) {
|
||||
@bind lastSeen(topic: string, message: string): void {
|
||||
const allowed = ['disable', 'ISO_8601', 'epoch', 'ISO_8601_local'];
|
||||
if (!allowed.includes(message)) {
|
||||
logger.error(`${message} is not an allowed value, possible: ${allowed}`);
|
||||
@@ -119,7 +107,7 @@ class BridgeLegacy extends Extension {
|
||||
logger.info(`Set last_seen to ${message}`);
|
||||
}
|
||||
|
||||
elapsed(topic, message) {
|
||||
@bind elapsed(topic: string, message: string): void {
|
||||
const allowed = ['true', 'false'];
|
||||
if (!allowed.includes(message)) {
|
||||
logger.error(`${message} is not an allowed value, possible: ${allowed}`);
|
||||
@@ -130,8 +118,8 @@ class BridgeLegacy extends Extension {
|
||||
logger.info(`Set elapsed to ${message}`);
|
||||
}
|
||||
|
||||
logLevel(topic, message) {
|
||||
const level = message.toLowerCase();
|
||||
@bind logLevel(topic: string, message: string): void {
|
||||
const level = message.toLowerCase() as 'error' | 'warn' | 'info' | 'debug';
|
||||
if (allowedLogLevels.includes(level)) {
|
||||
logger.info(`Switching log level to '${level}'`);
|
||||
logger.setLevel(level);
|
||||
@@ -142,30 +130,29 @@ class BridgeLegacy extends Extension {
|
||||
this.publish();
|
||||
}
|
||||
|
||||
async devices(topic, message) {
|
||||
@bind async devices(topic: string): Promise<void> {
|
||||
const coordinator = await this.zigbee.getCoordinatorVersion();
|
||||
const devices = this.zigbee.getDevices().map((device) => {
|
||||
const payload = {
|
||||
const devices = this.zigbee.devices().map((device) => {
|
||||
const payload: KeyValue = {
|
||||
ieeeAddr: device.ieeeAddr,
|
||||
type: device.type,
|
||||
networkAddress: device.networkAddress,
|
||||
type: device.zh.type,
|
||||
networkAddress: device.zh.networkAddress,
|
||||
};
|
||||
|
||||
if (device.type !== 'Coordinator') {
|
||||
const definition = zigbeeHerdsmanConverters.findByDevice(device);
|
||||
const friendlyDevice = settings.getDevice(device.ieeeAddr);
|
||||
payload.model = definition ? definition.model : device.modelID;
|
||||
if (device.zh.type !== 'Coordinator') {
|
||||
const definition = zigbeeHerdsmanConverters.findByDevice(device.zh);
|
||||
payload.model = definition ? definition.model : device.zh.modelID;
|
||||
payload.vendor = definition ? definition.vendor : '-';
|
||||
payload.description = definition ? definition.description : '-';
|
||||
payload.friendly_name = friendlyDevice ? friendlyDevice.friendly_name : device.ieeeAddr;
|
||||
payload.manufacturerID = device.manufacturerID;
|
||||
payload.manufacturerName = device.manufacturerName;
|
||||
payload.powerSource = device.powerSource;
|
||||
payload.modelID = device.modelID;
|
||||
payload.hardwareVersion = device.hardwareVersion;
|
||||
payload.softwareBuildID = device.softwareBuildID;
|
||||
payload.dateCode = device.dateCode;
|
||||
payload.lastSeen = device.lastSeen;
|
||||
payload.friendly_name = device.name;
|
||||
payload.manufacturerID = device.zh.manufacturerID;
|
||||
payload.manufacturerName = device.zh.manufacturerName;
|
||||
payload.powerSource = device.zh.powerSource;
|
||||
payload.modelID = device.zh.modelID;
|
||||
payload.hardwareVersion = device.zh.hardwareVersion;
|
||||
payload.softwareBuildID = device.zh.softwareBuildID;
|
||||
payload.dateCode = device.zh.dateCode;
|
||||
payload.lastSeen = device.zh.lastSeen;
|
||||
} else {
|
||||
payload.friendly_name = 'Coordinator';
|
||||
payload.softwareBuildID = coordinator.type;
|
||||
@@ -185,17 +172,15 @@ class BridgeLegacy extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
groups(topic, message) {
|
||||
@bind groups(): void {
|
||||
const payload = settings.getGroups().map((g) => {
|
||||
const group = {...g};
|
||||
delete group.friendlyName;
|
||||
return group;
|
||||
return {...g, ID: Number(g.ID)};
|
||||
});
|
||||
|
||||
this.mqtt.publish('bridge/log', stringify({type: 'groups', message: payload}));
|
||||
}
|
||||
|
||||
rename(topic, message) {
|
||||
@bind rename(topic: string, message: string): void {
|
||||
const invalid =
|
||||
`Invalid rename message format expected {"old": "friendly_name", "new": "new_name"} got ${message}`;
|
||||
|
||||
@@ -216,7 +201,7 @@ class BridgeLegacy extends Extension {
|
||||
this._renameInternal(json.old, json.new);
|
||||
}
|
||||
|
||||
renameLast(topic, message) {
|
||||
@bind renameLast(topic: string, message: string): void {
|
||||
if (!this.lastJoinedDeviceName) {
|
||||
logger.error(`Cannot rename last joined device, no device has joined during this session`);
|
||||
return;
|
||||
@@ -225,15 +210,15 @@ class BridgeLegacy extends Extension {
|
||||
this._renameInternal(this.lastJoinedDeviceName, message);
|
||||
}
|
||||
|
||||
_renameInternal(from, to) {
|
||||
_renameInternal(from: string, to: string): void {
|
||||
try {
|
||||
const isGroup = settings.getGroup(from) !== null;
|
||||
settings.changeFriendlyName(from, to);
|
||||
logger.info(`Successfully renamed - ${from} to ${to} `);
|
||||
const entity = this.zigbee.resolveEntity(to);
|
||||
const eventData = isGroup ? {group: entity.group} : {device: entity.device, from, to};
|
||||
eventData.homeAssisantRename = false;
|
||||
this.eventBus.emit(`${isGroup ? 'group' : 'device'}Renamed`, eventData);
|
||||
if (entity.isDevice()) {
|
||||
this.eventBus.emitDeviceRenamed({homeAssisantRename: false, from, to, device: entity});
|
||||
}
|
||||
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
@@ -244,7 +229,7 @@ class BridgeLegacy extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
addGroup(topic, message) {
|
||||
@bind addGroup(topic: string, message: string): void {
|
||||
let id = null;
|
||||
let name = null;
|
||||
try {
|
||||
@@ -273,15 +258,15 @@ class BridgeLegacy extends Extension {
|
||||
logger.info(`Added group '${name}'`);
|
||||
}
|
||||
|
||||
removeGroup(topic, message) {
|
||||
@bind removeGroup(topic: string, message: string): void {
|
||||
const name = message;
|
||||
const entity = this.zigbee.resolveEntity(message);
|
||||
assert(entity && entity.type === 'group', `Group '${message}' does not exist`);
|
||||
const entity = this.zigbee.resolveEntity(message) as Group;
|
||||
assert(entity && entity.isGroup(), `Group '${message}' does not exist`);
|
||||
|
||||
if (topic.includes('force')) {
|
||||
entity.group.removeFromDatabase();
|
||||
entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
entity.group.removeFromNetwork();
|
||||
entity.zh.removeFromNetwork();
|
||||
}
|
||||
settings.removeGroup(message);
|
||||
|
||||
@@ -289,21 +274,21 @@ class BridgeLegacy extends Extension {
|
||||
logger.info(`Removed group '${name}'`);
|
||||
}
|
||||
|
||||
async forceRemove(topic, message) {
|
||||
@bind async forceRemove(topic: string, message: string): Promise<void> {
|
||||
await this.removeForceRemoveOrBan('force_remove', message);
|
||||
}
|
||||
|
||||
async remove(topic, message) {
|
||||
@bind async remove(topic: string, message: string): Promise<void> {
|
||||
await this.removeForceRemoveOrBan('remove', message);
|
||||
}
|
||||
|
||||
async ban(topic, message) {
|
||||
@bind async ban(topic: string, message: string): Promise<void> {
|
||||
await this.removeForceRemoveOrBan('ban', message);
|
||||
}
|
||||
|
||||
async removeForceRemoveOrBan(action, message) {
|
||||
const entity = this.zigbee.resolveEntity(message.trim());
|
||||
const lookup = {
|
||||
@bind async removeForceRemoveOrBan(action: string, message: string): Promise<void> {
|
||||
const entity = this.zigbee.resolveEntity(message.trim()) as Device;
|
||||
const lookup: KeyValue = {
|
||||
ban: ['banned', 'Banning', 'ban'],
|
||||
force_remove: ['force_removed', 'Force removing', 'force remove'],
|
||||
remove: ['removed', 'Removing', 'remove'],
|
||||
@@ -316,31 +301,34 @@ class BridgeLegacy extends Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
const ieeeAddr = entity.ieeeAddr;
|
||||
const name = entity.name;
|
||||
|
||||
const cleanup = (): void => {
|
||||
// Fire event
|
||||
this.eventBus.emit('deviceRemoved', {resolvedEntity: entity});
|
||||
this.eventBus.emitDeviceRemoved({ieeeAddr, name});
|
||||
|
||||
// Remove from configuration.yaml
|
||||
settings.removeDevice(entity.settings.ID);
|
||||
settings.removeDevice(entity.ieeeAddr);
|
||||
|
||||
// Remove from state
|
||||
this.state.remove(entity.settings.ID);
|
||||
this.state.remove(ieeeAddr);
|
||||
|
||||
logger.info(`Successfully ${lookup[action][0]} ${entity.settings.friendlyName}`);
|
||||
logger.info(`Successfully ${lookup[action][0]} ${entity.name}`);
|
||||
this.mqtt.publish('bridge/log', stringify({type: `device_${lookup[action][0]}`, message}));
|
||||
};
|
||||
|
||||
try {
|
||||
logger.info(`${lookup[action][1]} '${entity.settings.friendlyName}'`);
|
||||
logger.info(`${lookup[action][1]} '${entity.name}'`);
|
||||
if (action === 'force_remove') {
|
||||
await entity.device.removeFromDatabase();
|
||||
await entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
await entity.device.removeFromNetwork();
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${lookup[action][2]} ${entity.settings.friendlyName} (${error})`);
|
||||
logger.error(`Failed to ${lookup[action][2]} ${entity.name} (${error})`);
|
||||
// eslint-disable-next-line
|
||||
logger.error(`See https://www.zigbee2mqtt.io/information/mqtt_topics_and_message_structure.html#zigbee2mqttbridgeconfigremove for more info`);
|
||||
|
||||
@@ -348,32 +336,29 @@ class BridgeLegacy extends Extension {
|
||||
}
|
||||
|
||||
if (action === 'ban') {
|
||||
settings.banDevice(entity.settings.ID);
|
||||
settings.banDevice(ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
async onMQTTConnected() {
|
||||
await this.publish();
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const {topic, message} = data;
|
||||
if (!topic.match(configRegex)) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
const option = topic.match(configRegex)[1];
|
||||
|
||||
if (!this.supportedOptions.hasOwnProperty(option)) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
await this.supportedOptions[option](topic, message);
|
||||
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
async publish() {
|
||||
const info = await utils.getZigbee2mqttVersion();
|
||||
async publish(): Promise<void> {
|
||||
const info = await utils.getZigbee2MQTTVersion();
|
||||
const coordinator = await this.zigbee.getCoordinatorVersion();
|
||||
const topic = `bridge/config`;
|
||||
const payload = {
|
||||
@@ -388,7 +373,7 @@ class BridgeLegacy extends Extension {
|
||||
await this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0});
|
||||
}
|
||||
|
||||
onZigbeeEvent(type, data, resolvedEntity) {
|
||||
onZigbeeEvent_(type: string, data: KeyValue, resolvedEntity: Device): void {
|
||||
if (type === 'deviceJoined' && resolvedEntity) {
|
||||
this.lastJoinedDeviceName = resolvedEntity.name;
|
||||
}
|
||||
@@ -436,7 +421,7 @@ class BridgeLegacy extends Extension {
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (type === 'deviceLeave') {
|
||||
const name = resolvedEntity ? resolvedEntity.name : data.ieeeAddr;
|
||||
const name = data.ieeeAddr;
|
||||
const meta = {friendly_name: name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
@@ -446,7 +431,7 @@ class BridgeLegacy extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
async touchlinkFactoryReset() {
|
||||
@bind async touchlinkFactoryReset(): Promise<void> {
|
||||
logger.info('Starting touchlink factory reset...');
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
@@ -469,5 +454,3 @@ class BridgeLegacy extends Extension {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BridgeLegacy;
|
||||
@@ -1,50 +0,0 @@
|
||||
/* istanbul ignore file */
|
||||
const settings = require('../../util/settings');
|
||||
const logger = require('../../util/logger');
|
||||
const Extension = require('../extension');
|
||||
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/device/(.+)/get_group_membership$`);
|
||||
|
||||
class DeviceGroupMembership extends Extension {
|
||||
async onMQTTMessage(topic, message) {
|
||||
const match = topic.match(topicRegex);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entity = this.zigbee.resolveEntity(match[1]);
|
||||
if (!entity || entity.type !== 'device') {
|
||||
logger.error(`Device '${match[1]}' does not exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await entity.endpoint.command(
|
||||
`genGroups`, 'getMembership', {groupcount: 0, grouplist: []}, {}, true,
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
logger.warn(`Couldn't get group membership of ${entity.device.ieeeAddr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let {grouplist, capacity} = response;
|
||||
|
||||
grouplist = grouplist.map((gid) => {
|
||||
const g = settings.getGroup(gid);
|
||||
return g ? g.friendlyName : gid;
|
||||
});
|
||||
|
||||
const msgGroupList = `${entity.device.ieeeAddr} is in groups [${grouplist}]`;
|
||||
let msgCapacity;
|
||||
if (capacity === 254) {
|
||||
msgCapacity = 'it can be a part of at least 1 more group';
|
||||
} else {
|
||||
msgCapacity = `its remaining group capacity is ${capacity === 255 ? 'unknown' : capacity}`;
|
||||
}
|
||||
logger.info(`${msgGroupList} and ${msgCapacity}`);
|
||||
|
||||
this.publishEntityState(entity.device.ieeeAddr, {group_list: grouplist, group_capacity: capacity});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DeviceGroupMembership;
|
||||
@@ -0,0 +1,56 @@
|
||||
/* istanbul ignore file */
|
||||
import * as settings from '../../util/settings';
|
||||
import logger from '../../util/logger';
|
||||
import utils from '../../util/utils';
|
||||
import Extension from '../extension';
|
||||
import bind from 'bind-decorator';
|
||||
import Device from '../../model/device';
|
||||
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/device/(.+)/get_group_membership$`);
|
||||
|
||||
export default class DeviceGroupMembership extends Extension {
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(topicRegex);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = utils.parseEntityID(match[1]);
|
||||
const device = this.zigbee.resolveEntity(parsed.ID) as Device;
|
||||
if (!device || !(device instanceof Device)) {
|
||||
logger.error(`Device '${match[1]}' does not exist`);
|
||||
return;
|
||||
}
|
||||
const endpoint = device.endpoint(parsed.endpoint);
|
||||
const response = await endpoint.command(
|
||||
`genGroups`, 'getMembership', {groupcount: 0, grouplist: []}, {},
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
logger.warn(`Couldn't get group membership of ${device.ieeeAddr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let {grouplist, capacity} = response;
|
||||
|
||||
grouplist = grouplist.map((gid: string) => {
|
||||
const g = settings.getGroup(gid);
|
||||
return g ? g.friendly_name : gid;
|
||||
});
|
||||
|
||||
const msgGroupList = `${device.ieeeAddr} is in groups [${grouplist}]`;
|
||||
let msgCapacity;
|
||||
if (capacity === 254) {
|
||||
msgCapacity = 'it can be a part of at least 1 more group';
|
||||
} else {
|
||||
msgCapacity = `its remaining group capacity is ${capacity === 255 ? 'unknown' : capacity}`;
|
||||
}
|
||||
logger.info(`${msgGroupList} and ${msgCapacity}`);
|
||||
|
||||
this.publishEntityState(device, {group_list: grouplist, group_capacity: capacity});
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,40 @@
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const logger = require('../../util/logger');
|
||||
const settings = require('../../util/settings');
|
||||
const ZNLDP12LM = zigbeeHerdsmanConverters.devices.find((d) => d.model === 'ZNLDP12LM');
|
||||
const utils = require('../../util/utils');
|
||||
const Extension = require('../extension');
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
import logger from '../../util/logger';
|
||||
import * as settings from '../../util/settings';
|
||||
import Extension from '../extension';
|
||||
|
||||
const defaultConfiguration = {
|
||||
minimumReportInterval: 3, maximumReportInterval: 300, reportableChange: 1,
|
||||
};
|
||||
|
||||
const ZNLDP12LM = zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'ZNLDP12LM');
|
||||
|
||||
const devicesNotSupportingReporting = [
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'CC2530.ROUTER'),
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'BASICZBR3'),
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'ZM-CSW032-D'),
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'TS0001'),
|
||||
zigbeeHerdsmanConverters.devices.find((d) => d.model === 'TS0115'),
|
||||
zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'CC2530.ROUTER'),
|
||||
zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'BASICZBR3'),
|
||||
zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'ZM-CSW032-D'),
|
||||
zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'TS0001'),
|
||||
zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'TS0115'),
|
||||
];
|
||||
|
||||
const reportKey = 1;
|
||||
|
||||
const getColorCapabilities = async (endpoint) => {
|
||||
const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean, colorXY: boolean}> => {
|
||||
if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') === undefined) {
|
||||
await endpoint.read('lightingColorCtrl', ['colorCapabilities']);
|
||||
}
|
||||
|
||||
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities');
|
||||
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number;
|
||||
return {
|
||||
colorTemperature: (value & 1<<4) > 0,
|
||||
colorXY: (value & 1<<3) > 0,
|
||||
};
|
||||
};
|
||||
|
||||
const clusters = {
|
||||
const clusters: {[s: string]:
|
||||
{attribute: string, minimumReportInterval: number, maximumReportInterval: number, reportableChange: number
|
||||
condition?: (endpoint: zh.Endpoint) => Promise<boolean>}[]} =
|
||||
{
|
||||
'genOnOff': [
|
||||
{attribute: 'onOff', ...defaultConfiguration, minimumReportInterval: 0, reportableChange: 0},
|
||||
],
|
||||
@@ -41,15 +44,15 @@ const clusters = {
|
||||
'lightingColorCtrl': [
|
||||
{
|
||||
attribute: 'colorTemperature', ...defaultConfiguration,
|
||||
condition: async (endpoint) => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
},
|
||||
{
|
||||
attribute: 'currentX', ...defaultConfiguration,
|
||||
condition: async (endpoint) => (await getColorCapabilities(endpoint)).colorXY,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
{
|
||||
attribute: 'currentY', ...defaultConfiguration,
|
||||
condition: async (endpoint) => (await getColorCapabilities(endpoint)).colorXY,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
],
|
||||
'closuresWindowCovering': [
|
||||
@@ -58,15 +61,12 @@ const clusters = {
|
||||
],
|
||||
};
|
||||
|
||||
class Report extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.queue = new Set();
|
||||
this.failed = new Set();
|
||||
this.enabled = settings.get().advanced.report;
|
||||
}
|
||||
export default class Report extends Extension {
|
||||
private queue: Set<string> = new Set();
|
||||
private failed: Set<string> = new Set();
|
||||
private enabled = settings.get().advanced.report;
|
||||
|
||||
shouldIgnoreClusterForDevice(cluster, definition) {
|
||||
shouldIgnoreClusterForDevice(cluster: string, definition: zhc.Definition): boolean {
|
||||
if (definition === ZNLDP12LM && cluster === 'closuresWindowCovering') {
|
||||
// Device announces it but doesn't support it
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/2611
|
||||
@@ -76,9 +76,7 @@ class Report extends Extension {
|
||||
return false;
|
||||
}
|
||||
|
||||
async setupReporting(resolvedEntity) {
|
||||
const {device, definition} = resolvedEntity;
|
||||
|
||||
async setupReporting(device: Device): Promise<void> {
|
||||
if (this.queue.has(device.ieeeAddr) || this.failed.has(device.ieeeAddr)) return;
|
||||
this.queue.add(device.ieeeAddr);
|
||||
|
||||
@@ -86,9 +84,10 @@ class Report extends Extension {
|
||||
const term2 = this.enabled ? 'setup' : 'disabled';
|
||||
|
||||
try {
|
||||
for (const ep of device.endpoints) {
|
||||
for (const ep of device.zh.endpoints) {
|
||||
for (const [cluster, configuration] of Object.entries(clusters)) {
|
||||
if (ep.supportsInputCluster(cluster) && !this.shouldIgnoreClusterForDevice(cluster, definition)) {
|
||||
if (ep.supportsInputCluster(cluster) &&
|
||||
!this.shouldIgnoreClusterForDevice(cluster, device.definition)) {
|
||||
logger.debug(`${term1} reporting for '${device.ieeeAddr}' - ${ep.ID} - ${cluster}`);
|
||||
|
||||
const items = [];
|
||||
@@ -102,8 +101,8 @@ class Report extends Extension {
|
||||
}
|
||||
|
||||
this.enabled ?
|
||||
await ep.bind(cluster, this.coordinatorEndpoint) :
|
||||
await ep.unbind(cluster, this.coordinatorEndpoint);
|
||||
await ep.bind(cluster, this.zigbee.firstCoordinatorEndpoint()) :
|
||||
await ep.unbind(cluster, this.zigbee.firstCoordinatorEndpoint());
|
||||
|
||||
await ep.configureReporting(cluster, items);
|
||||
logger.info(
|
||||
@@ -114,13 +113,13 @@ class Report extends Extension {
|
||||
}
|
||||
|
||||
if (this.enabled) {
|
||||
device.meta.reporting = reportKey;
|
||||
device.zh.meta.reporting = reportKey;
|
||||
} else {
|
||||
delete device.meta.reporting;
|
||||
this.eventBus.emit('reportingDisabled', {device});
|
||||
delete device.zh.meta.reporting;
|
||||
this.eventBus.emitReportingDisabled({device});
|
||||
}
|
||||
|
||||
this.eventBus.emit(`devicesChanged`);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to ${term1.toLowerCase()} reporting for '${device.ieeeAddr}' - ${error.stack}`,
|
||||
@@ -129,15 +128,13 @@ class Report extends Extension {
|
||||
this.failed.add(device.ieeeAddr);
|
||||
}
|
||||
|
||||
device.save();
|
||||
device.zh.save();
|
||||
this.queue.delete(device.ieeeAddr);
|
||||
}
|
||||
|
||||
shouldSetupReporting(resolvedEntity, messageType) {
|
||||
if (!resolvedEntity || !resolvedEntity.device || !resolvedEntity.definition ||
|
||||
messageType === 'deviceLeave') return false;
|
||||
shouldSetupReporting(device: Device, messageType: string): boolean {
|
||||
if (!device || !device.zh || !device.definition) return false;
|
||||
|
||||
const {device, definition} = resolvedEntity;
|
||||
// Handle messages of type endDeviceAnnce and devIncoming.
|
||||
// This message is typically send when a device comes online after being powered off
|
||||
// Ikea TRADFRI tend to forget their reporting after powered off.
|
||||
@@ -146,48 +143,52 @@ class Report extends Extension {
|
||||
// else reconfigure is done in zigbee-herdsman-converters ikea.js/bulbOnEvent
|
||||
// configuredReportings are saved since Zigbee2MQTT 1.17.0
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/966
|
||||
if (this.enabled && messageType === 'deviceAnnounce' && utils.isIkeaTradfriDevice(device) &&
|
||||
device.endpoints.filter((e) => e.configuredReportings.length === 0).length === device.endpoints.length) {
|
||||
if (this.enabled && messageType === 'deviceAnnounce' && device.isIkeaTradfri() &&
|
||||
device.zh.endpoints.filter((e) => e.configuredReportings.length === 0).length ===
|
||||
device.zh.endpoints.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// These do not support reproting.
|
||||
// https://github.com/Koenkk/zigbee-herdsman/issues/110
|
||||
const philipsIgnoreSw = ['5.127.1.26581', '5.130.1.30000'];
|
||||
if (device.manufacturerName === 'Philips' && philipsIgnoreSw.includes(device.softwareBuildID)) return false;
|
||||
if (device.zh.manufacturerName === 'Philips' &&
|
||||
philipsIgnoreSw.includes(device.zh.softwareBuildID)) return false;
|
||||
|
||||
if (resolvedEntity.device.interviewing === true) return false;
|
||||
if (device.type !== 'Router' || device.powerSource === 'Battery') return false;
|
||||
if (device.zh.interviewing === true) return false;
|
||||
if (device.zh.type !== 'Router' || device.zh.powerSource === 'Battery') return false;
|
||||
// Gledopto devices don't support reporting.
|
||||
if (devicesNotSupportingReporting.includes(definition) || definition.vendor === 'Gledopto') return false;
|
||||
if (devicesNotSupportingReporting.includes(device.definition) ||
|
||||
device.definition.vendor === 'Gledopto') return false;
|
||||
|
||||
if (this.enabled && device.meta.hasOwnProperty('reporting') && device.meta.reporting === reportKey) {
|
||||
if (this.enabled && device.zh.meta.hasOwnProperty('reporting') &&
|
||||
device.zh.meta.reporting === reportKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.enabled && !device.meta.hasOwnProperty('reporting')) {
|
||||
if (!this.enabled && !device.zh.meta.hasOwnProperty('reporting')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async onZigbeeStarted() {
|
||||
this.coordinatorEndpoint = this.zigbee.getDevicesByType('Coordinator')[0].getEndpoint(1);
|
||||
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
if (this.shouldSetupReporting(resolvedEntity, null)) {
|
||||
await this.setupReporting(resolvedEntity);
|
||||
override async start(): Promise<void> {
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
if (this.shouldSetupReporting(device, null)) {
|
||||
await this.setupReporting(device);
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => this.onZigbeeEvent_('deviceAnnounce', data.device));
|
||||
this.eventBus.onDeviceMessage(this, (data) => this.onZigbeeEvent_('dummy', data.device));
|
||||
this.eventBus.onDeviceJoined(this, (data) => this.onZigbeeEvent_('dummy', data.device));
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, (data) => this.onZigbeeEvent_('dummy', data.device));
|
||||
}
|
||||
|
||||
async onZigbeeEvent(type, data, resolvedEntity) {
|
||||
if (this.shouldSetupReporting(resolvedEntity, type)) {
|
||||
await this.setupReporting(resolvedEntity);
|
||||
async onZigbeeEvent_(type: string, device: Device): Promise<void> {
|
||||
if (this.shouldSetupReporting(device, type)) {
|
||||
await this.setupReporting(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Report;
|
||||
@@ -1,33 +1,35 @@
|
||||
/* istanbul ignore file */
|
||||
// DEPRECATED
|
||||
const settings = require('../../util/settings');
|
||||
const logger = require('../../util/logger');
|
||||
const utils = require('../../util/utils');
|
||||
const Extension = require('../extension');
|
||||
import * as settings from '../../util/settings';
|
||||
import logger from '../../util/logger';
|
||||
import utils from '../../util/utils';
|
||||
import Extension from '../extension';
|
||||
|
||||
/**
|
||||
* This extensions soft resets the ZNP after a certain timeout.
|
||||
*/
|
||||
class SoftReset extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.timer = null;
|
||||
this.timeout = utils.secondsToMilliseconds(settings.get().advanced.soft_reset_timeout);
|
||||
}
|
||||
export default class SoftReset extends Extension {
|
||||
private timer: NodeJS.Timer = null;
|
||||
private timeout = utils.seconds(settings.get().advanced.soft_reset_timeout);
|
||||
|
||||
onZigbeeStarted() {
|
||||
logger.debug(`Soft reset timeout set to ${utils.millisecondsToSeconds(this.timeout)} seconds`);
|
||||
override async start(): Promise<void> {
|
||||
logger.debug(`Soft reset timeout set to ${this.timeout / 1000} seconds`);
|
||||
this.resetTimer();
|
||||
this.eventBus.onDeviceMessage(this, () => this.resetTimer());
|
||||
this.eventBus.onDeviceAnnounce(this, () => this.resetTimer());
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, () => this.resetTimer());
|
||||
this.eventBus.onDeviceJoined(this, () => this.resetTimer());
|
||||
this.eventBus.onDeviceInterview(this, () => this.resetTimer());
|
||||
}
|
||||
|
||||
clearTimer() {
|
||||
private clearTimer(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
resetTimer() {
|
||||
private resetTimer(): void {
|
||||
if (this.timeout === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -36,7 +38,7 @@ class SoftReset extends Extension {
|
||||
this.timer = setTimeout(() => this.handleTimeout(), this.timeout);
|
||||
}
|
||||
|
||||
async handleTimeout() {
|
||||
private async handleTimeout(): Promise<void> {
|
||||
logger.warn('Soft reset timeout triggered');
|
||||
|
||||
try {
|
||||
@@ -57,10 +59,4 @@ class SoftReset extends Extension {
|
||||
|
||||
this.resetTimer();
|
||||
}
|
||||
|
||||
onZigbeeEvent() {
|
||||
this.resetTimer();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SoftReset;
|
||||
@@ -1,27 +1,36 @@
|
||||
const settings = require('../util/settings');
|
||||
const utils = require('../util/utils');
|
||||
const logger = require('../util/logger');
|
||||
const Extension = require('./extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import logger from '../util/logger';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import Extension from './extension';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
interface Link {
|
||||
source: {ieeeAddr: string, networkAddress: number}, target: {ieeeAddr: string, networkAddress: number},
|
||||
linkquality: number, depth: number, routes: zh.RoutingTableEntry[],
|
||||
sourceIeeeAddr: string, targetIeeeAddr: string, sourceNwkAddr: number, lqi: number, relationship: number,
|
||||
}
|
||||
|
||||
interface Topology {
|
||||
nodes: {
|
||||
ieeeAddr: string, friendlyName: string, type: string, networkAddress: number, manufacturerName: string,
|
||||
modelID: string, failed: string[], lastSeen: number,
|
||||
definition: {model: string, vendor: string, supports: string, description: string}}[],
|
||||
links: Link[],
|
||||
}
|
||||
|
||||
/**
|
||||
* This extension creates a network map
|
||||
*/
|
||||
class NetworkMap extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
export default class NetworkMap extends Extension {
|
||||
private legacyApi = settings.get().advanced.legacy_api;
|
||||
private legacyTopic = `${settings.get().mqtt.base_topic}/bridge/networkmap`;
|
||||
private legacyTopicRoutes = `${settings.get().mqtt.base_topic}/bridge/networkmap/routes`;
|
||||
private topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
|
||||
private supportedFormats: {[s: string]: (topology: Topology) => KeyValue | string};
|
||||
|
||||
this.legacyApi = settings.get().advanced.legacy_api;
|
||||
this.legacyTopic = `${settings.get().mqtt.base_topic}/bridge/networkmap`;
|
||||
this.legacyTopicRoutes = `${settings.get().mqtt.base_topic}/bridge/networkmap/routes`;
|
||||
this.topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
|
||||
|
||||
// Bind
|
||||
this.raw = this.raw.bind(this);
|
||||
this.graphviz = this.graphviz.bind(this);
|
||||
this.plantuml = this.plantuml.bind(this);
|
||||
|
||||
// Set supported formats
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.supportedFormats = {
|
||||
'raw': this.raw,
|
||||
'graphviz': this.graphviz,
|
||||
@@ -29,22 +38,22 @@ class NetworkMap extends Extension {
|
||||
};
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
/* istanbul ignore else */
|
||||
if (this.legacyApi) {
|
||||
if ((topic === this.legacyTopic || topic === this.legacyTopicRoutes) &&
|
||||
this.supportedFormats.hasOwnProperty(message)) {
|
||||
const includeRoutes = topic === this.legacyTopicRoutes;
|
||||
if ((data.topic === this.legacyTopic || data.topic === this.legacyTopicRoutes) &&
|
||||
this.supportedFormats.hasOwnProperty(data.message)) {
|
||||
const includeRoutes = data.topic === this.legacyTopicRoutes;
|
||||
const topology = await this.networkScan(includeRoutes);
|
||||
let converted = this.supportedFormats[message](topology);
|
||||
converted = message === 'raw' ? stringify(converted) : converted;
|
||||
this.mqtt.publish(`bridge/networkmap/${message}`, converted, {});
|
||||
let converted = this.supportedFormats[data.message](topology);
|
||||
converted = data.message === 'raw' ? stringify(converted) : converted;
|
||||
this.mqtt.publish(`bridge/networkmap/${data.message}`, converted as string, {});
|
||||
}
|
||||
}
|
||||
|
||||
if (topic === this.topic) {
|
||||
if (data.topic === this.topic) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
try {
|
||||
message = utils.parseJSON(message, message);
|
||||
const type = typeof message === 'object' ? message.type : message;
|
||||
if (!this.supportedFormats.hasOwnProperty(type)) {
|
||||
throw new Error(`Type '${type}' not supported, allowed are: ${Object.keys(this.supportedFormats)}`);
|
||||
@@ -66,11 +75,11 @@ class NetworkMap extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
raw(topology) {
|
||||
@bind raw(topology: Topology): KeyValue {
|
||||
return topology;
|
||||
}
|
||||
|
||||
graphviz(topology) {
|
||||
@bind graphviz(topology: Topology): string {
|
||||
const colors = settings.get().map_options.graphviz.colors;
|
||||
|
||||
let text = 'digraph G {\nnode[shape=record];\n';
|
||||
@@ -102,7 +111,7 @@ class NetworkMap extends Extension {
|
||||
let lastSeen = 'unknown';
|
||||
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen;
|
||||
if (date) {
|
||||
lastSeen = utils.formatDate(date, 'relative');
|
||||
lastSeen = utils.formatDate(date, 'relative') as string;
|
||||
}
|
||||
|
||||
labels.push(lastSeen);
|
||||
@@ -145,7 +154,7 @@ class NetworkMap extends Extension {
|
||||
return text.replace(/\0/g, '');
|
||||
}
|
||||
|
||||
plantuml(topology) {
|
||||
@bind plantuml(topology: Topology): string {
|
||||
const text = [];
|
||||
|
||||
text.push(`' paste into: https://www.planttext.com/`);
|
||||
@@ -167,7 +176,7 @@ class NetworkMap extends Extension {
|
||||
// Add the device model
|
||||
if (node.type !== 'Coordinator') {
|
||||
text.push(`---`);
|
||||
const definition = this.zigbee.resolveEntity(node.ieeeAddr).definition;
|
||||
const definition = (this.zigbee.resolveEntity(node.ieeeAddr) as Device).definition;
|
||||
if (definition) {
|
||||
text.push(`${definition.vendor} ${definition.description} (${definition.model})`);
|
||||
} else {
|
||||
@@ -180,7 +189,7 @@ class NetworkMap extends Extension {
|
||||
let lastSeen = 'unknown';
|
||||
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen;
|
||||
if (date) {
|
||||
lastSeen = utils.formatDate(date, 'relative');
|
||||
lastSeen = utils.formatDate(date, 'relative') as string;
|
||||
}
|
||||
text.push(`---`);
|
||||
text.push(lastSeen);
|
||||
@@ -203,19 +212,18 @@ class NetworkMap extends Extension {
|
||||
return text.join(`\n`);
|
||||
}
|
||||
|
||||
async networkScan(includeRoutes) {
|
||||
async networkScan(includeRoutes: boolean): Promise<Topology> {
|
||||
logger.info(`Starting network scan (includeRoutes '${includeRoutes}')`);
|
||||
const devices = this.zigbee.getDevices().filter((d) => d.type !== 'GreenPower');
|
||||
const lqis = new Map();
|
||||
const routingTables = new Map();
|
||||
const failed = new Map();
|
||||
const devices = this.zigbee.devices().filter((d) => d.zh.type !== 'GreenPower');
|
||||
const lqis: Map<Device, zh.LQI> = new Map();
|
||||
const routingTables: Map<Device, zh.RoutingTable> = new Map();
|
||||
const failed: Map<Device, string[]> = new Map();
|
||||
|
||||
for (const device of devices.filter((d) => d.type != 'EndDevice')) {
|
||||
for (const device of devices.filter((d) => d.zh.type != 'EndDevice')) {
|
||||
failed.set(device, []);
|
||||
await utils.sleep(1); // sleep 1 second between each scan to reduce stress on network.
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
|
||||
const doRequest = async (request, firstAttempt = true) => {
|
||||
const doRequest = async <T>(request: () => Promise<T>, firstAttempt = true): Promise<T> => {
|
||||
try {
|
||||
return await request();
|
||||
} catch (error) {
|
||||
@@ -224,53 +232,51 @@ class NetworkMap extends Extension {
|
||||
} else {
|
||||
// Network is possibly congested, sleep 5 seconds to let the network settle.
|
||||
await utils.sleep(5);
|
||||
return await doRequest(request, false);
|
||||
return doRequest(request, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await doRequest(async () => await device.lqi());
|
||||
const result = await doRequest<zh.LQI>(async () => device.zh.lqi());
|
||||
lqis.set(device, result);
|
||||
logger.debug(`LQI succeeded for '${resolvedEntity.name}'`);
|
||||
logger.debug(`LQI succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
failed.get(device).push('lqi');
|
||||
logger.error(`Failed to execute LQI for '${resolvedEntity.name}'`);
|
||||
logger.error(`Failed to execute LQI for '${device.name}'`);
|
||||
}
|
||||
|
||||
if (includeRoutes) {
|
||||
try {
|
||||
const result = await doRequest(async () => await device.routingTable());
|
||||
const result = await doRequest(async () => device.zh.routingTable());
|
||||
routingTables.set(device, result);
|
||||
logger.debug(`Routing table succeeded for '${resolvedEntity.name}'`);
|
||||
logger.debug(`Routing table succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
failed.get(device).push('routingTable');
|
||||
logger.error(`Failed to execute routing table for '${resolvedEntity.name}'`);
|
||||
logger.error(`Failed to execute routing table for '${device.name}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Network scan finished`);
|
||||
|
||||
const networkMap = {nodes: [], links: []};
|
||||
const topology: Topology = {nodes: [], links: []};
|
||||
// Add nodes
|
||||
for (const device of devices) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
const definition = resolvedEntity.definition ? {
|
||||
model: resolvedEntity.definition.model,
|
||||
vendor: resolvedEntity.definition.vendor,
|
||||
description: resolvedEntity.definition.description,
|
||||
supports: Array.from(new Set((resolvedEntity.definition.exposes).map((e) => {
|
||||
return e.hasOwnProperty('name') ?
|
||||
e.name :
|
||||
const definition = device.definition ? {
|
||||
model: device.definition.model,
|
||||
vendor: device.definition.vendor,
|
||||
description: device.definition.description,
|
||||
supports: Array.from(new Set((device.definition.exposes).map((e) => {
|
||||
return e.hasOwnProperty('name') ? e.name :
|
||||
`${e.type} (${e.features.map((f) => f.name).join(', ')})`;
|
||||
}))).join(', '),
|
||||
} : null;
|
||||
|
||||
networkMap.nodes.push({
|
||||
ieeeAddr: device.ieeeAddr, friendlyName: resolvedEntity.name, type: device.type,
|
||||
networkAddress: device.networkAddress, manufacturerName: device.manufacturerName,
|
||||
modelID: device.modelID, failed: failed.get(device), lastSeen: device.lastSeen,
|
||||
topology.nodes.push({
|
||||
ieeeAddr: device.ieeeAddr, friendlyName: device.name, type: device.zh.type,
|
||||
networkAddress: device.zh.networkAddress, manufacturerName: device.zh.manufacturerName,
|
||||
modelID: device.zh.modelID, failed: failed.get(device), lastSeen: device.zh.lastSeen,
|
||||
definition,
|
||||
});
|
||||
}
|
||||
@@ -285,14 +291,14 @@ class NetworkMap extends Extension {
|
||||
|
||||
// Some Xiaomi devices return 0x00 as the neighbor ieeeAddr (obviously not correct).
|
||||
// Determine the correct ieeeAddr based on the networkAddress.
|
||||
const neighborDevice = this.zigbee.getDeviceByNetworkAddress(neighbor.networkAddress);
|
||||
const neighborDevice = this.zigbee.deviceByNetworkAddress(neighbor.networkAddress);
|
||||
if (neighbor.ieeeAddr === '0x0000000000000000' && neighborDevice) {
|
||||
neighbor.ieeeAddr = neighborDevice.ieeeAddr;
|
||||
}
|
||||
|
||||
const link = {
|
||||
const link: Link = {
|
||||
source: {ieeeAddr: neighbor.ieeeAddr, networkAddress: neighbor.networkAddress},
|
||||
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.networkAddress},
|
||||
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.zh.networkAddress},
|
||||
linkquality: neighbor.linkquality, depth: neighbor.depth, routes: [],
|
||||
// DEPRECATED:
|
||||
sourceIeeeAddr: neighbor.ieeeAddr, targetIeeeAddr: device.ieeeAddr,
|
||||
@@ -306,12 +312,10 @@ class NetworkMap extends Extension {
|
||||
.filter((t) => t.status === 'ACTIVE' && t.nextHop === neighbor.networkAddress);
|
||||
}
|
||||
|
||||
networkMap.links.push(link);
|
||||
topology.links.push(link);
|
||||
}
|
||||
});
|
||||
|
||||
return networkMap;
|
||||
return topology;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NetworkMap;
|
||||
@@ -1,38 +0,0 @@
|
||||
const Extension = require('./extension');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
|
||||
/**
|
||||
* This extension calls the zigbee-herdsman-converters onEvent.
|
||||
*/
|
||||
class OnEvent extends Extension {
|
||||
async onZigbeeStarted() {
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
this.callOnEvent(resolvedEntity, 'start', {});
|
||||
}
|
||||
}
|
||||
|
||||
onZigbeeEvent(type, data, resolvedEntity) {
|
||||
if (resolvedEntity && resolvedEntity.type === 'device') {
|
||||
this.callOnEvent(resolvedEntity, type, data);
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
super.stop();
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
const resolvedEntity = this.zigbee.resolveEntity(device);
|
||||
this.callOnEvent(resolvedEntity, 'stop', {});
|
||||
}
|
||||
}
|
||||
|
||||
callOnEvent(resolvedEntity, type, data) {
|
||||
zigbeeHerdsmanConverters.onEvent(type, data, resolvedEntity.device, resolvedEntity.settings);
|
||||
|
||||
if (resolvedEntity.definition && resolvedEntity.definition.onEvent) {
|
||||
resolvedEntity.definition.onEvent(type, data, resolvedEntity.device, resolvedEntity.settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OnEvent;
|
||||
@@ -0,0 +1,42 @@
|
||||
import zhc from 'zigbee-herdsman-converters';
|
||||
import Extension from './extension';
|
||||
|
||||
/**
|
||||
* This extension calls the zigbee-herdsman-converters onEvent.
|
||||
*/
|
||||
export default class OnEvent extends Extension {
|
||||
override async start(): Promise<void> {
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
await this.callOnEvent(device, 'start', {});
|
||||
}
|
||||
|
||||
this.eventBus.onDeviceMessage(this, (data) => this.callOnEvent(data.device, 'message', this.convertData(data)));
|
||||
this.eventBus.onDeviceJoined(this,
|
||||
(data) => this.callOnEvent(data.device, 'deviceJoined', this.convertData(data)));
|
||||
this.eventBus.onDeviceInterview(this,
|
||||
(data) => this.callOnEvent(data.device, 'deviceInterview', this.convertData(data)));
|
||||
this.eventBus.onDeviceAnnounce(this,
|
||||
(data) => this.callOnEvent(data.device, 'deviceAnnounce', this.convertData(data)));
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this,
|
||||
(data) => this.callOnEvent(data.device, 'deviceNetworkAddressChanged', this.convertData(data)));
|
||||
}
|
||||
|
||||
private convertData(data: KeyValue): KeyValue {
|
||||
return {...data, device: data.device.zh};
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
super.stop();
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
await this.callOnEvent(device, 'stop', {});
|
||||
}
|
||||
}
|
||||
|
||||
private async callOnEvent(device: Device, type: string, data: KeyValue): Promise<void> {
|
||||
zhc.onEvent(type, data, device.zh);
|
||||
|
||||
if (device.definition?.onEvent) {
|
||||
await device.definition.onEvent(type, data, device.zh, device.settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,51 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const utils = require('../util/utils');
|
||||
import * as settings from '../util/settings';
|
||||
import logger from '../util/logger';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import utils from '../util/utils';
|
||||
import tradfriOTA from 'zigbee-herdsman-converters/lib/ota/tradfri';
|
||||
import Extension from './extension';
|
||||
import bind from 'bind-decorator';
|
||||
import Device from '../model/device';
|
||||
|
||||
type UpdateState = 'updating' | 'idle' | 'available';
|
||||
interface UpdatePayload {
|
||||
// eslint-disable-next-line camelcase
|
||||
update: {progress?: number, remaining?: number, state: UpdateState}, update_available?: boolean}
|
||||
|
||||
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/ota_update/.+$`);
|
||||
const tradfriOTA = require('zigbee-herdsman-converters/lib/ota/tradfri');
|
||||
const topicRegex =
|
||||
new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check)`, 'i');
|
||||
|
||||
const Extension = require('./extension');
|
||||
|
||||
class OTAUpdate extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.inProgress = new Set();
|
||||
this.lastChecked = {};
|
||||
this.legacyApi = settings.get().advanced.legacy_api;
|
||||
export default class OTAUpdate extends Extension {
|
||||
private inProgress = new Set();
|
||||
private lastChecked: {[s: string]: number} = {};
|
||||
private legacyApi = settings.get().advanced.legacy_api;
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.eventBus.onDeviceMessage(this, this.onZigbeeEvent);
|
||||
if (settings.get().advanced.ikea_ota_use_test_url) {
|
||||
tradfriOTA.useTestURL();
|
||||
}
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
for (const device of this.zigbee.getClients()) {
|
||||
for (const device of this.zigbee.devices(false)) {
|
||||
// In case Zigbee2MQTT is restared during an update, progress and remaining values are still in state.
|
||||
// remove them.
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
}
|
||||
}
|
||||
|
||||
removeProgressAndRemainingFromState(device) {
|
||||
this.state.removeKey(device.ieeeAddr, ['update', 'progress']);
|
||||
this.state.removeKey(device.ieeeAddr, ['update', 'remaining']);
|
||||
private removeProgressAndRemainingFromState(device: Device): void {
|
||||
delete this.state.get(device)?.update?.progress;
|
||||
delete this.state.get(device)?.update?.remaining;
|
||||
}
|
||||
|
||||
async onZigbeeEvent(type, data, resolvedEntity) {
|
||||
@bind private async onZigbeeEvent(data: eventdata.DeviceMessage): Promise<void> {
|
||||
if (settings.get().ota.disable_automatic_update_check) return;
|
||||
if (data.type !== 'commandQueryNextImageRequest' || !resolvedEntity || !resolvedEntity.definition) return;
|
||||
if (data.type !== 'commandQueryNextImageRequest' || !data.device.definition) return;
|
||||
logger.debug(`Device '${data.device.name}' requested OTA`);
|
||||
|
||||
const supportsOTA = resolvedEntity.definition.hasOwnProperty('ota');
|
||||
const supportsOTA = data.device.definition.hasOwnProperty('ota');
|
||||
if (supportsOTA) {
|
||||
// When a device does a next image request, it will usually do it a few times after each other
|
||||
// with only 10 - 60 seconds inbetween. It doesn't make sense to check for a new update
|
||||
@@ -49,17 +56,18 @@ class OTAUpdate extends Extension {
|
||||
if (!check || this.inProgress.has(data.device.ieeeAddr)) return;
|
||||
|
||||
this.lastChecked[data.device.ieeeAddr] = Date.now();
|
||||
const available = await resolvedEntity.definition.ota.isUpdateAvailable(data.device, logger, data.data);
|
||||
const available = await data.device.definition.ota.isUpdateAvailable(
|
||||
data.device.zh, logger, data.data);
|
||||
const payload = this.getEntityPublishPayload(available ? 'available' : 'idle');
|
||||
this.publishEntityState(data.device.ieeeAddr, payload);
|
||||
this.publishEntityState(data.device, payload);
|
||||
|
||||
if (available) {
|
||||
const message = `Update available for '${resolvedEntity.settings.friendly_name}'`;
|
||||
const message = `Update available for '${data.device.name}'`;
|
||||
logger.info(message);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: 'available', device: resolvedEntity.settings.friendly_name};
|
||||
const meta = {status: 'available', device: data.device.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message, meta}),
|
||||
@@ -71,22 +79,23 @@ class OTAUpdate extends Extension {
|
||||
// Respond to the OTA request:
|
||||
// - In case we don't support OTA: respond with NO_IMAGE_AVAILABLE (0x98) (so the client stops requesting OTAs)
|
||||
// - In case we do support OTA: respond with ABORT (0x95) as we don't want to update now.
|
||||
const endpoint = data.device.endpoints.find((e) => e.supportsOutputCluster('genOta'));
|
||||
const endpoint = data.device.zh.endpoints.find((e) => e.supportsOutputCluster('genOta'));
|
||||
if (endpoint) {
|
||||
// Some devices send OTA requests without defining OTA cluster as input cluster.
|
||||
await endpoint.commandResponse('genOta', 'queryNextImageResponse', {status: supportsOTA ? 0x95 : 0x98});
|
||||
}
|
||||
}
|
||||
|
||||
async readSoftwareBuildIDAndDateCode(device, update) {
|
||||
private async readSoftwareBuildIDAndDateCode(device: Device, update: boolean):
|
||||
Promise<{softwareBuildID: string, dateCode: string}> {
|
||||
try {
|
||||
const endpoint = device.endpoints.find((e) => e.supportsInputCluster('genBasic'));
|
||||
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster('genBasic'));
|
||||
const result = await endpoint.read('genBasic', ['dateCode', 'swBuildId']);
|
||||
|
||||
if (update) {
|
||||
device.softwareBuildID = result.swBuildId;
|
||||
device.dateCode = result.dateCode;
|
||||
device.save();
|
||||
device.zh.softwareBuildID = result.swBuildId;
|
||||
device.zh.dateCode = result.dateCode;
|
||||
device.zh.save();
|
||||
}
|
||||
|
||||
return {softwareBuildID: result.swBuildId, dateCode: result.dateCode};
|
||||
@@ -95,8 +104,8 @@ class OTAUpdate extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
getEntityPublishPayload(state, progress=null, remaining=null) {
|
||||
const payload = {update: {state}};
|
||||
private getEntityPublishPayload(state: UpdateState, progress: number=null, remaining: number=null): UpdatePayload {
|
||||
const payload: UpdatePayload = {update: {state}};
|
||||
if (progress !== null) payload.update.progress = progress;
|
||||
if (remaining !== null) payload.update.remaining = Math.round(remaining);
|
||||
|
||||
@@ -108,43 +117,44 @@ class OTAUpdate extends Extension {
|
||||
return payload;
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
if ((!this.legacyApi || !topic.match(legacyTopicRegex)) && !topic.match(topicRegex)) {
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
if ((!this.legacyApi || !data.topic.match(legacyTopicRegex)) && !data.topic.match(topicRegex)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
message = utils.parseJSON(message, message);
|
||||
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message;
|
||||
const resolvedEntity = this.zigbee.resolveEntity(ID);
|
||||
const type = topic.substring(topic.lastIndexOf('/') + 1);
|
||||
const responseData = {id: ID};
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
const ID = (typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message) as string;
|
||||
const device = this.zigbee.resolveEntity(ID);
|
||||
const type = data.topic.substring(data.topic.lastIndexOf('/') + 1);
|
||||
const responseData: {id: string, updateAvailable?: boolean, from?: string, to?: string}= {id: ID};
|
||||
let error = null;
|
||||
let errorStack = null;
|
||||
|
||||
if (!resolvedEntity || resolvedEntity.type !== 'device') {
|
||||
if (!(device instanceof Device)) {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!resolvedEntity.definition || !resolvedEntity.definition.ota) {
|
||||
error = `Device '${resolvedEntity.name}' does not support OTA updates`;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `not_supported`, device: resolvedEntity.name};
|
||||
const meta = {status: `not_supported`, device: device.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: error, meta}),
|
||||
);
|
||||
}
|
||||
} else if (this.inProgress.has(resolvedEntity.device.ieeeAddr)) {
|
||||
error = `Update or check for update already in progress for '${resolvedEntity.name}'`;
|
||||
} else if (this.inProgress.has(device.ieeeAddr)) {
|
||||
error = `Update or check for update already in progress for '${device.name}'`;
|
||||
} else {
|
||||
this.inProgress.add(resolvedEntity.device.ieeeAddr);
|
||||
this.inProgress.add(device.ieeeAddr);
|
||||
|
||||
if (type === 'check') {
|
||||
const msg = `Checking if update available for '${resolvedEntity.name}'`;
|
||||
const msg = `Checking if update available for '${device.name}'`;
|
||||
logger.info(msg);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `checking_if_available`, device: resolvedEntity.name};
|
||||
const meta = {status: `checking_if_available`, device: device.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: msg, meta}),
|
||||
@@ -152,15 +162,13 @@ class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
try {
|
||||
const available = await resolvedEntity.definition.ota.isUpdateAvailable(
|
||||
resolvedEntity.device, logger,
|
||||
);
|
||||
const msg = `${available ? 'Update' : 'No update'} available for '${resolvedEntity.name}'`;
|
||||
const available = await device.definition.ota.isUpdateAvailable(device.zh, logger);
|
||||
const msg = `${available ? 'Update' : 'No update'} available for '${device.name}'`;
|
||||
logger.info(msg);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: available ? 'available' : 'not_available', device: resolvedEntity.name};
|
||||
const meta = {status: available ? 'available' : 'not_available', device: device.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: msg, meta}),
|
||||
@@ -168,15 +176,16 @@ class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
const payload = this.getEntityPublishPayload(available ? 'available' : 'idle');
|
||||
this.publishEntityState(resolvedEntity.device.ieeeAddr, payload);
|
||||
this.lastChecked[resolvedEntity.device.ieeeAddr] = Date.now();
|
||||
this.publishEntityState(device, payload);
|
||||
this.lastChecked[device.ieeeAddr] = Date.now();
|
||||
responseData.updateAvailable = available;
|
||||
} catch (e) {
|
||||
error = `Failed to check if update available for '${resolvedEntity.name}' (${e.message})`;
|
||||
error = `Failed to check if update available for '${device.name}' (${e.message})`;
|
||||
errorStack = e.stack;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `check_failed`, device: resolvedEntity.name};
|
||||
const meta = {status: `check_failed`, device: device.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: error, meta}),
|
||||
@@ -184,12 +193,12 @@ class OTAUpdate extends Extension {
|
||||
}
|
||||
}
|
||||
} else { // type === 'update'
|
||||
const msg = `Updating '${resolvedEntity.name}' to latest firmware`;
|
||||
const msg = `Updating '${device.name}' to latest firmware`;
|
||||
logger.info(msg);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_in_progress`, device: resolvedEntity.name};
|
||||
const meta = {status: `update_in_progress`, device: device.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `ota_update`, message: msg, meta}),
|
||||
@@ -197,8 +206,8 @@ class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
try {
|
||||
const onProgress = (progress, remaining) => {
|
||||
let msg = `Update of '${resolvedEntity.name}' at ${progress.toFixed(2)}%`;
|
||||
const onProgress = (progress: number, remaining: number): void => {
|
||||
let msg = `Update of '${device.name}' at ${progress.toFixed(2)}%`;
|
||||
if (remaining) {
|
||||
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
|
||||
}
|
||||
@@ -206,54 +215,55 @@ class OTAUpdate extends Extension {
|
||||
logger.info(msg);
|
||||
|
||||
const payload = this.getEntityPublishPayload('updating', progress, remaining);
|
||||
this.publishEntityState(resolvedEntity.device.ieeeAddr, payload);
|
||||
this.publishEntityState(device, payload);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_progress`, device: resolvedEntity.name, progress};
|
||||
const meta = {status: `update_progress`, device: device.name, progress};
|
||||
this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
|
||||
}
|
||||
};
|
||||
|
||||
const from_ = await this.readSoftwareBuildIDAndDateCode(resolvedEntity.device, false);
|
||||
await resolvedEntity.definition.ota.updateToLatest(resolvedEntity.device, logger, onProgress);
|
||||
const to = await this.readSoftwareBuildIDAndDateCode(resolvedEntity.device, true);
|
||||
const from_ = await this.readSoftwareBuildIDAndDateCode(device, false);
|
||||
await device.definition.ota.updateToLatest(device.zh, logger, onProgress);
|
||||
const to = await this.readSoftwareBuildIDAndDateCode(device, true);
|
||||
const [fromS, toS] = [stringify(from_), stringify(to)];
|
||||
const msg = `Finished update of '${resolvedEntity.name}'` +
|
||||
const msg = `Finished update of '${device.name}'` +
|
||||
(to ? `, from '${fromS}' to '${toS}'` : ``);
|
||||
logger.info(msg);
|
||||
this.removeProgressAndRemainingFromState(resolvedEntity.device);
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
const payload = this.getEntityPublishPayload('idle');
|
||||
this.publishEntityState(resolvedEntity.device.ieeeAddr, payload);
|
||||
this.publishEntityState(device, payload);
|
||||
responseData.from = from_ ? utils.toSnakeCase(from_) : null;
|
||||
responseData.to = to ? utils.toSnakeCase(to) : null;
|
||||
this.eventBus.emit(`devicesChanged`);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_succeeded`, device: resolvedEntity.name, from: from_, to};
|
||||
const meta = {status: `update_succeeded`, device: device.name, from: from_, to};
|
||||
this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message, meta}));
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug(`Update of '${resolvedEntity.name}' failed (${e})`);
|
||||
error = `Update of '${resolvedEntity.name}' failed (${e.message})`;
|
||||
logger.debug(`Update of '${device.name}' failed (${e})`);
|
||||
error = `Update of '${device.name}' failed (${e.message})`;
|
||||
errorStack = e.stack;
|
||||
|
||||
this.removeProgressAndRemainingFromState(resolvedEntity.device);
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
const payload = this.getEntityPublishPayload('available');
|
||||
this.publishEntityState(resolvedEntity.device.ieeeAddr, payload);
|
||||
this.publishEntityState(device, payload);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_failed`, device: resolvedEntity.name};
|
||||
const meta = {status: `update_failed`, device: device.name};
|
||||
this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.inProgress.delete(resolvedEntity.device.ieeeAddr);
|
||||
this.inProgress.delete(device.ieeeAddr);
|
||||
}
|
||||
|
||||
const triggeredViaLegacyApi = topic.match(legacyTopicRegex);
|
||||
const triggeredViaLegacyApi = data.topic.match(legacyTopicRegex);
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
await this.mqtt.publish(`bridge/response/device/ota_update/${type}`, stringify(response));
|
||||
@@ -261,8 +271,7 @@ class OTAUpdate extends Extension {
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
errorStack && logger.debug(errorStack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OTAUpdate;
|
||||
@@ -1,304 +0,0 @@
|
||||
|
||||
const settings = require('../util/settings');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const logger = require('../util/logger');
|
||||
const utils = require('../util/utils');
|
||||
const assert = require('assert');
|
||||
const Extension = require('./extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
const topicRegex = new RegExp(`^(.+?)(?:/(${utils.getEndpointNames().join('|')}))?/(get|set)(?:/(.+))?`);
|
||||
const stateValues = ['on', 'off', 'toggle', 'open', 'close', 'stop', 'lock', 'unlock'];
|
||||
|
||||
const defaultGroupConverters = [
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_onoff_brightness,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_color_colortemp,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.effect,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.ignore_transition,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.cover_position_tilt,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.thermostat_occupied_heating_setpoint,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.tint_scene,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_brightness_move,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_brightness_step,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_colortemp_step,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_colortemp_move,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_hue_saturation_move,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_hue_saturation_step,
|
||||
];
|
||||
|
||||
class EntityPublish extends Extension {
|
||||
parseTopic(topic) {
|
||||
const match = topic.match(topicRegex);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ID = match[1].replace(`${settings.get().mqtt.base_topic}/`, '');
|
||||
// If we didn't replace base_topic we received something we don't care about
|
||||
if (ID === match[1] || ID.match(/bridge/)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {ID: ID, endpointName: match[2] || '', type: match[3], attribute: match[4]};
|
||||
}
|
||||
|
||||
async onMQTTMessage(topic, message) {
|
||||
topic = this.parseTopic(topic);
|
||||
if (!topic) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entityKey = `${topic.ID}` + (topic.endpointName ? `/${topic.endpointName}` : '');
|
||||
const resolvedEntity = this.zigbee.resolveEntity(entityKey);
|
||||
|
||||
if (!resolvedEntity) {
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const message = {friendly_name: entityKey};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `entity_not_found`, message}),
|
||||
);
|
||||
}
|
||||
|
||||
logger.error(`Entity '${entityKey}' is unknown`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get entity details
|
||||
let converters = null;
|
||||
let target = null;
|
||||
let options = {};
|
||||
let device = null;
|
||||
let definition = null;
|
||||
let membersState = null;
|
||||
|
||||
assert(resolvedEntity.type === 'device' || resolvedEntity.type === 'group');
|
||||
if (resolvedEntity.type === 'device') {
|
||||
if (!resolvedEntity.definition) {
|
||||
logger.warn(`Device with modelID '${resolvedEntity.device.modelID}' is not supported.`);
|
||||
logger.warn(`Please see: https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html`);
|
||||
return;
|
||||
}
|
||||
|
||||
device = resolvedEntity.device;
|
||||
definition = resolvedEntity.definition;
|
||||
target = resolvedEntity.endpoint;
|
||||
converters = resolvedEntity.definition.toZigbee;
|
||||
options = resolvedEntity.settings;
|
||||
} else {
|
||||
target = resolvedEntity.group;
|
||||
options = resolvedEntity.settings;
|
||||
definition = resolvedEntity.group.members
|
||||
.map((e) => zigbeeHerdsmanConverters.findByDevice(e.getDevice())).filter((d) => d);
|
||||
converters = new Set();
|
||||
definition.forEach((d) => d.toZigbee.forEach(converters.add, converters));
|
||||
converters = converters.size ? [...converters] : defaultGroupConverters;
|
||||
membersState = {};
|
||||
for (const member of resolvedEntity.group.members) {
|
||||
const ieeeAddr = member.getDevice().ieeeAddr;
|
||||
membersState[ieeeAddr] = this.state.get(ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the MQTT message to a Zigbee message.
|
||||
let json = {};
|
||||
if (topic.hasOwnProperty('attribute') && topic.attribute) {
|
||||
try {
|
||||
json[topic.attribute] = JSON.parse(message);
|
||||
} catch (e) {
|
||||
json[topic.attribute] = message;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
json = JSON.parse(message);
|
||||
} catch (e) {
|
||||
if (stateValues.includes(message.toLowerCase())) {
|
||||
json = {state: message};
|
||||
} else {
|
||||
logger.error(`Invalid JSON '${message}', skipping...`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!json) {
|
||||
logger.error(`Invalid JSON '${message}', skipping...`);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Home Assistant always publishes 'state', even when e.g. only setting
|
||||
* the color temperature. This would lead to 2 zigbee publishes, where the first one
|
||||
* (state) is probably unecessary.
|
||||
*/
|
||||
const entityState = this.state.get(resolvedEntity.settings.ID) || {};
|
||||
if (settings.get().homeassistant) {
|
||||
const hasColorTemp = json.hasOwnProperty('color_temp');
|
||||
const hasColor = json.hasOwnProperty('color');
|
||||
const hasBrightness = json.hasOwnProperty('brightness');
|
||||
const isOn = entityState && entityState.state === 'ON' ? true : false;
|
||||
if (isOn && (hasColorTemp || hasColor) && !hasBrightness) {
|
||||
delete json.state;
|
||||
logger.debug('Skipping state because of Home Assistant');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Order state & brightness based on current bulb state
|
||||
*
|
||||
* Not all bulbs support setting the color/color_temp while it is off
|
||||
* this results in inconsistant behavior between different vendors.
|
||||
*
|
||||
* bulb on => move state & brightness to the back
|
||||
* bulb off => move state & brightness to the front
|
||||
*/
|
||||
const entries = Object.entries(json);
|
||||
const sorter = typeof json.state === 'string' && json.state.toLowerCase() === 'off' ? 1 : -1;
|
||||
entries.sort((a, b) => (['state', 'brightness', 'brightness_percent'].includes(a[0]) ? sorter : sorter * -1));
|
||||
|
||||
// For each attribute call the corresponding converter
|
||||
const usedConverters = {};
|
||||
const toPublish = {};
|
||||
const addToToPublish = (ID, payload) => {
|
||||
if (!(ID in toPublish)) toPublish[ID] = {};
|
||||
toPublish[ID] = {...toPublish[ID], ...payload};
|
||||
};
|
||||
|
||||
for (let [key, value] of entries) {
|
||||
let endpointName = topic.endpointName;
|
||||
let actualTarget = target;
|
||||
let endpointOrGroupID = actualTarget.constructor.name == 'Group' ? actualTarget.groupID : actualTarget.ID;
|
||||
|
||||
// When the key has a endpointName included (e.g. state_right), this will override the target.
|
||||
if (resolvedEntity.type === 'device' && key.includes('_')) {
|
||||
const endpointNameMatch = utils.getEndpointNames().find((n) => key.endsWith(`_${n}`));
|
||||
if (endpointNameMatch) {
|
||||
endpointName = endpointNameMatch;
|
||||
const regex = new RegExp(`(_${endpointNameMatch})$`, 'gm');
|
||||
key = key.replace(regex, ``);
|
||||
|
||||
const device = target.getDevice();
|
||||
actualTarget = device.getEndpoint(definition.endpoint(device)[endpointName]);
|
||||
endpointOrGroupID = endpointName;
|
||||
if (!actualTarget) {
|
||||
logger.error(`Device '${resolvedEntity.name}' has no endpoint '${endpointName}'`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!usedConverters.hasOwnProperty(endpointOrGroupID)) usedConverters[endpointOrGroupID] = [];
|
||||
const converter = converters.find((c) => c.key.includes(key));
|
||||
|
||||
if (topic.type === 'set' && usedConverters[endpointOrGroupID].includes(converter)) {
|
||||
// Use a converter for set only once
|
||||
// (e.g. light_onoff_brightness converters can convert state and brightness)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!converter) {
|
||||
logger.error(`No converter available for '${key}' (${json[key]})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Converter didn't return a result, skip
|
||||
const meta = {
|
||||
endpoint_name: endpointName,
|
||||
options,
|
||||
message: {...json},
|
||||
logger,
|
||||
device,
|
||||
state: entityState,
|
||||
membersState,
|
||||
mapped: definition,
|
||||
};
|
||||
|
||||
// Strip endpoint name from meta.message properties.
|
||||
if (meta.endpoint_name) {
|
||||
for (const [key, value] of Object.entries(meta.message)) {
|
||||
if (key.endsWith(meta.endpoint_name)) {
|
||||
delete meta.message[key];
|
||||
const keyWithoutEndpoint = key.substring(0, key.length - meta.endpoint_name.length - 1);
|
||||
meta.message[keyWithoutEndpoint] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (topic.type === 'set' && converter.convertSet) {
|
||||
logger.debug(`Publishing '${topic.type}' '${key}' to '${resolvedEntity.name}'`);
|
||||
const result = await converter.convertSet(actualTarget, key, value, meta);
|
||||
const optimistic = !options.hasOwnProperty('optimistic') || options.optimistic;
|
||||
if (result && result.state && optimistic) {
|
||||
const msg = result.state;
|
||||
|
||||
if (endpointName) {
|
||||
for (const key of Object.keys(msg)) {
|
||||
msg[`${key}_${endpointName}`] = msg[key];
|
||||
delete msg[key];
|
||||
}
|
||||
}
|
||||
|
||||
// filter out attribute listed in filtered_optimistic
|
||||
if (options.hasOwnProperty('filtered_optimistic')) {
|
||||
options.filtered_optimistic.forEach((a) => delete msg[a]);
|
||||
}
|
||||
|
||||
addToToPublish(resolvedEntity.settings.ID, msg);
|
||||
}
|
||||
|
||||
if (result && result.membersState && optimistic) {
|
||||
for (const [ieeeAddr, state] of Object.entries(result.membersState)) {
|
||||
addToToPublish(ieeeAddr, state);
|
||||
}
|
||||
}
|
||||
|
||||
// It's possible for devices to get out of sync when writing an attribute that's not reportable.
|
||||
// So here we re-read the value after a specified timeout, this timeout could for example be the
|
||||
// transition time of a color change or for forcing a state read for devices that don't
|
||||
// automatically report a new state when set.
|
||||
// When reporting is requested for a device (report: true in device-specific settings) we won't
|
||||
// ever issue a read here, as we assume the device will properly report changes.
|
||||
// Only do this when the retrieve_state option is enabled for this device.
|
||||
// retrieve_state == decprecated
|
||||
if (
|
||||
resolvedEntity.type === 'device' && result && result.hasOwnProperty('readAfterWriteTime') &&
|
||||
resolvedEntity.settings.retrieve_state
|
||||
) {
|
||||
setTimeout(() => converter.convertGet(actualTarget, key, meta), result.readAfterWriteTime);
|
||||
}
|
||||
} else if (topic.type === 'get' && converter.convertGet) {
|
||||
logger.debug(`Publishing get '${topic.type}' '${key}' to '${resolvedEntity.name}'`);
|
||||
await converter.convertGet(actualTarget, key, meta);
|
||||
} else {
|
||||
logger.error(`No converter available for '${topic.type}' '${key}' (${json[key]})`);
|
||||
continue;
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
`Publish '${topic.type}' '${key}' to '${resolvedEntity.name}' failed: '${error}'`;
|
||||
logger.error(message);
|
||||
logger.debug(error.stack);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {friendly_name: resolvedEntity.name};
|
||||
this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `zigbee_publish_error`, message, meta}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
usedConverters[endpointOrGroupID].push(converter);
|
||||
}
|
||||
|
||||
for (const [ID, payload] of Object.entries(toPublish)) {
|
||||
this.publishEntityState(ID, payload);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EntityPublish;
|
||||
@@ -0,0 +1,301 @@
|
||||
|
||||
import * as settings from '../util/settings';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
import logger from '../util/logger';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import Group from '../model/group';
|
||||
import Device from '../model/device';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
const topicRegex = new RegExp(`^(.+?)(?:/(${utils.endpointNames.join('|')}|\\d+))?/(get|set)(?:/(.+))?`);
|
||||
const propertyEndpointRegex = new RegExp(`^(.*)_(${utils.endpointNames.join('|')}|\\d+)$`);
|
||||
const stateValues = ['on', 'off', 'toggle', 'open', 'close', 'stop', 'lock', 'unlock'];
|
||||
const sceneConverterKeys = ['scene_store', 'scene_add', 'scene_remove', 'scene_remove_all'];
|
||||
|
||||
// Legacy: don't provide default converters anymore, this is required by older z2m installs not saving group members
|
||||
const defaultGroupConverters = [
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_onoff_brightness,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_color_colortemp,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.effect,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.ignore_transition,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.cover_position_tilt,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.thermostat_occupied_heating_setpoint,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.tint_scene,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_brightness_move,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_brightness_step,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_colortemp_step,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_colortemp_move,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_hue_saturation_move,
|
||||
zigbeeHerdsmanConverters.toZigbeeConverters.light_hue_saturation_step,
|
||||
];
|
||||
|
||||
interface ParsedTopic {ID: string, endpoint: string, attribute: string, type: 'get' | 'set'}
|
||||
|
||||
export default class Publish extends Extension {
|
||||
async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
parseTopic(topic: string): ParsedTopic | null {
|
||||
const match = topic.match(topicRegex);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ID = match[1].replace(`${settings.get().mqtt.base_topic}/`, '');
|
||||
// If we didn't replace base_topic we received something we don't care about
|
||||
if (ID === match[1] || ID.match(/bridge/)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {ID: ID, endpoint: match[2], type: match[3] as 'get' | 'set', attribute: match[4]};
|
||||
}
|
||||
|
||||
parseMessage(parsedTopic: ParsedTopic, data: eventdata.MQTTMessage): KeyValue | null {
|
||||
if (parsedTopic.attribute) {
|
||||
try {
|
||||
return {[parsedTopic.attribute]: JSON.parse(data.message)};
|
||||
} catch (e) {
|
||||
return {[parsedTopic.attribute]: data.message};
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return JSON.parse(data.message);
|
||||
} catch (e) {
|
||||
if (stateValues.includes(data.message.toLowerCase())) {
|
||||
return {state: data.message};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
legacyLog(payload: KeyValue): void {
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
this.mqtt.publish('bridge/log', stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
legacyRetrieveState(re: Device | Group, converter: zhc.ToZigbeeConverter, result: zhc.ToZigbeeConverterResult,
|
||||
target: zh.Endpoint | zh.Group, key: string, meta: zhc.ToZigbeeConverterGetMeta): void {
|
||||
// It's possible for devices to get out of sync when writing an attribute that's not reportable.
|
||||
// So here we re-read the value after a specified timeout, this timeout could for example be the
|
||||
// transition time of a color change or for forcing a state read for devices that don't
|
||||
// automatically report a new state when set.
|
||||
// When reporting is requested for a device (report: true in device-specific settings) we won't
|
||||
// ever issue a read here, as we assume the device will properly report changes.
|
||||
// Only do this when the retrieve_state option is enabled for this device.
|
||||
// retrieve_state == decprecated
|
||||
if (re instanceof Device && result && result.hasOwnProperty('readAfterWriteTime') &&
|
||||
re.settings.retrieve_state
|
||||
) {
|
||||
setTimeout(() => converter.convertGet(target, key, meta), result.readAfterWriteTime);
|
||||
}
|
||||
}
|
||||
|
||||
updateMessageHomeAssistant(message: KeyValue, entityState: KeyValue): void {
|
||||
/**
|
||||
* Home Assistant always publishes 'state', even when e.g. only setting
|
||||
* the color temperature. This would lead to 2 zigbee publishes, where the first one
|
||||
* (state) is probably unecessary.
|
||||
*/
|
||||
if (settings.get().homeassistant) {
|
||||
const hasColorTemp = message.hasOwnProperty('color_temp');
|
||||
const hasColor = message.hasOwnProperty('color');
|
||||
const hasBrightness = message.hasOwnProperty('brightness');
|
||||
const isOn = entityState.state === 'ON' ? true : false;
|
||||
if (isOn && (hasColorTemp || hasColor) && !hasBrightness) {
|
||||
delete message.state;
|
||||
logger.debug('Skipping state because of Home Assistant');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const parsedTopic = this.parseTopic(data.topic);
|
||||
if (!parsedTopic) return;
|
||||
|
||||
const re = this.zigbee.resolveEntity(parsedTopic.ID);
|
||||
if (re == null) {
|
||||
this.legacyLog({type: `entity_not_found`, message: {friendly_name: parsedTopic.ID}});
|
||||
logger.error(`Entity '${parsedTopic.ID}' is unknown`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get entity details
|
||||
const definition = re instanceof Device ? re.definition : re.membersDefinitions();
|
||||
if (definition == null) {
|
||||
logger.error(`Cannot publish to unsupported device '${re.name}'`);
|
||||
return;
|
||||
}
|
||||
const target = re instanceof Group ? re.zh : re.endpoint(parsedTopic.endpoint);
|
||||
if (target == null) {
|
||||
logger.error(`Device '${re.name}' has no endpoint '${parsedTopic.endpoint}'`);
|
||||
return;
|
||||
}
|
||||
const device = re instanceof Device ? re.zh : null;
|
||||
const entitySettings = re.settings;
|
||||
const entityState = this.state.get(re) || {};
|
||||
const membersState = re instanceof Group ?
|
||||
Object.fromEntries(re.zh.members.map((e) => [e.getDevice().ieeeAddr,
|
||||
this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr))])) : null;
|
||||
let converters: zhc.ToZigbeeConverter[];
|
||||
{
|
||||
if (Array.isArray(definition)) {
|
||||
const c = new Set(definition.map((d) => d.toZigbee).flat());
|
||||
if (c.size == 0) converters = defaultGroupConverters;
|
||||
else converters = Array.from(c);
|
||||
} else {
|
||||
converters = definition.toZigbee;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the MQTT message to a Zigbee message.
|
||||
const message = this.parseMessage(parsedTopic, data);
|
||||
if (message == null) {
|
||||
logger.error(`Invalid message '${message}', skipping...`);
|
||||
return;
|
||||
}
|
||||
this.updateMessageHomeAssistant(message, entityState);
|
||||
|
||||
/**
|
||||
* Order state & brightness based on current bulb state
|
||||
*
|
||||
* Not all bulbs support setting the color/color_temp while it is off
|
||||
* this results in inconsistant behavior between different vendors.
|
||||
*
|
||||
* bulb on => move state & brightness to the back
|
||||
* bulb off => move state & brightness to the front
|
||||
*/
|
||||
const entries = Object.entries(message);
|
||||
const sorter = typeof message.state === 'string' && message.state.toLowerCase() === 'off' ? 1 : -1;
|
||||
entries.sort((a) => (['state', 'brightness', 'brightness_percent'].includes(a[0]) ? sorter : sorter * -1));
|
||||
|
||||
// For each attribute call the corresponding converter
|
||||
const usedConverters: {[s: number]: zhc.ToZigbeeConverter[]} = {};
|
||||
const toPublish: {[s: number | string]: KeyValue} = {};
|
||||
const toPublishEntity: {[s: number | string]: Device | Group} = {};
|
||||
const addToToPublish = (entity: Device | Group, payload: KeyValue): void => {
|
||||
const ID = entity.ID;
|
||||
if (!(ID in toPublish)) {
|
||||
toPublish[ID] = {};
|
||||
toPublishEntity[ID] = entity;
|
||||
}
|
||||
toPublish[ID] = {...toPublish[ID], ...payload};
|
||||
};
|
||||
|
||||
for (let [key, value] of entries) {
|
||||
let endpointName = parsedTopic.endpoint;
|
||||
let localTarget = target;
|
||||
let endpointOrGroupID = utils.isEndpoint(target) ? target.ID : target.groupID;
|
||||
|
||||
// When the key has a endpointName included (e.g. state_right), this will override the target.
|
||||
const propertyEndpointMatch = key.match(propertyEndpointRegex);
|
||||
if (re instanceof Device && propertyEndpointMatch) {
|
||||
endpointName = propertyEndpointMatch[2];
|
||||
key = propertyEndpointMatch[1];
|
||||
localTarget = re.endpoint(endpointName);
|
||||
if (localTarget == null) {
|
||||
logger.error(`Device '${re.name}' has no endpoint '${endpointName}'`);
|
||||
continue;
|
||||
}
|
||||
endpointOrGroupID = localTarget.ID;
|
||||
}
|
||||
|
||||
if (!usedConverters.hasOwnProperty(endpointOrGroupID)) usedConverters[endpointOrGroupID] = [];
|
||||
const converter = converters.find((c) => c.key.includes(key));
|
||||
|
||||
if (parsedTopic.type === 'set' && usedConverters[endpointOrGroupID].includes(converter)) {
|
||||
// Use a converter for set only once
|
||||
// (e.g. light_onoff_brightness converters can convert state and brightness)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!converter) {
|
||||
logger.error(`No converter available for '${key}' (${message[key]})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the endpoint_name name is a nubmer, try to map it to a friendlyName
|
||||
if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isEndpoint(localTarget) &&
|
||||
re.endpointName(localTarget)) {
|
||||
endpointName = re.endpointName(localTarget);
|
||||
}
|
||||
|
||||
// Converter didn't return a result, skip
|
||||
const meta = {endpoint_name: endpointName, options: entitySettings, message: {...message}, logger, device,
|
||||
state: entityState, membersState, mapped: definition};
|
||||
|
||||
// Strip endpoint name from meta.message properties.
|
||||
if (endpointName) {
|
||||
for (const [key, value] of Object.entries(meta.message)) {
|
||||
if (key.endsWith(endpointName)) {
|
||||
delete meta.message[key];
|
||||
const keyWithoutEndpoint = key.substring(0, key.length - endpointName.length - 1);
|
||||
meta.message[keyWithoutEndpoint] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (parsedTopic.type === 'set' && converter.convertSet) {
|
||||
logger.debug(`Publishing '${parsedTopic.type}' '${key}' to '${re.name}'`);
|
||||
const result = await converter.convertSet(localTarget, key, value, meta);
|
||||
const optimistic = !entitySettings.hasOwnProperty('optimistic') || entitySettings.optimistic;
|
||||
if (result && result.state && optimistic) {
|
||||
const msg = result.state;
|
||||
|
||||
if (endpointName) {
|
||||
for (const key of Object.keys(msg)) {
|
||||
msg[`${key}_${endpointName}`] = msg[key];
|
||||
delete msg[key];
|
||||
}
|
||||
}
|
||||
|
||||
// filter out attribute listed in filtered_optimistic
|
||||
entitySettings.filtered_optimistic?.forEach((a) => delete msg[a]);
|
||||
addToToPublish(re, msg);
|
||||
}
|
||||
|
||||
if (result && result.membersState && optimistic) {
|
||||
for (const [ieeeAddr, state] of Object.entries(result.membersState)) {
|
||||
addToToPublish(this.zigbee.resolveEntity(ieeeAddr), state);
|
||||
}
|
||||
}
|
||||
|
||||
this.legacyRetrieveState(re, converter, result, localTarget, key, meta);
|
||||
} else if (parsedTopic.type === 'get' && converter.convertGet) {
|
||||
logger.debug(`Publishing get '${parsedTopic.type}' '${key}' to '${re.name}'`);
|
||||
await converter.convertGet(localTarget, key, meta);
|
||||
} else {
|
||||
logger.error(`No converter available for '${parsedTopic.type}' '${key}' (${message[key]})`);
|
||||
continue;
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
`Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`;
|
||||
logger.error(message);
|
||||
logger.debug(error.stack);
|
||||
this.legacyLog({type: `zigbee_publish_error`, message, meta: {friendly_name: re.name}});
|
||||
}
|
||||
|
||||
usedConverters[endpointOrGroupID].push(converter);
|
||||
}
|
||||
|
||||
for (const [ID, payload] of Object.entries(toPublish)) {
|
||||
if (Object.keys(payload).length != 0) {
|
||||
this.publishEntityState(toPublishEntity[ID], payload);
|
||||
}
|
||||
}
|
||||
|
||||
const scenesChanged = Object.values(usedConverters)
|
||||
.some((cl) => cl.some((c) => c.key.some((k) => sceneConverterKeys.includes(k))));
|
||||
if (scenesChanged) {
|
||||
this.eventBus.emitScenesChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
const settings = require('../util/settings');
|
||||
const logger = require('../util/logger');
|
||||
const utils = require('../util/utils');
|
||||
const debounce = require('debounce');
|
||||
const Extension = require('./extension');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
class Receive extends Extension {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
||||
this.elapsed = {};
|
||||
this.debouncers = {};
|
||||
this.eventBus.on('publishEntityState', (data) => this.onPublishEntityState(data), this.constructor.name);
|
||||
}
|
||||
|
||||
async onZigbeeStarted() {
|
||||
this.coordinator = this.zigbee.getDevicesByType('Coordinator')[0];
|
||||
}
|
||||
|
||||
async onPublishEntityState(data) {
|
||||
/**
|
||||
* Prevent that outdated properties are being published.
|
||||
* In case that e.g. the state is currently held back by a debounce and a new state is published
|
||||
* remove it from the to be send debounced message.
|
||||
*/
|
||||
if (data.entity.type === 'device' && this.debouncers[data.entity.device.ieeeAddr] &&
|
||||
data.stateChangeReason !== 'publishDebounce') {
|
||||
for (const key of Object.keys(data.payload)) {
|
||||
delete this.debouncers[data.entity.device.ieeeAddr].payload[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publishDebounce(ieeeAddr, payload, time, debounceIgnore) {
|
||||
if (!this.debouncers[ieeeAddr]) {
|
||||
this.debouncers[ieeeAddr] = {
|
||||
payload: {},
|
||||
publish: debounce(() => {
|
||||
this.publishEntityState(ieeeAddr, this.debouncers[ieeeAddr].payload, 'publishDebounce');
|
||||
this.debouncers[ieeeAddr].payload = {};
|
||||
}, time * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isPayloadConflicted(payload, this.debouncers[ieeeAddr].payload, debounceIgnore)) {
|
||||
// publish previous payload immediately
|
||||
this.debouncers[ieeeAddr].publish.flush();
|
||||
}
|
||||
|
||||
// extend debounced payload with current
|
||||
this.debouncers[ieeeAddr].payload = {...this.debouncers[ieeeAddr].payload, ...payload};
|
||||
this.debouncers[ieeeAddr].publish();
|
||||
}
|
||||
|
||||
// if debounce_ignore are specified (Array of strings)
|
||||
// then all newPayload values with key present in debounce_ignore
|
||||
// should equal or be undefined in oldPayload
|
||||
// otherwise payload is conflicted
|
||||
isPayloadConflicted(newPayload, oldPayload, debounceIgnore) {
|
||||
let result = false;
|
||||
Object.keys(oldPayload)
|
||||
.filter((key) => (debounceIgnore || []).includes(key))
|
||||
.forEach((key) => {
|
||||
if (typeof newPayload[key] !== 'undefined' && newPayload[key] !== oldPayload[key]) {
|
||||
result = true;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
shouldProcess(type, data, resolvedEntity) {
|
||||
if (type !== 'message' || !resolvedEntity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!resolvedEntity.definition) {
|
||||
if (data.device.interviewing) {
|
||||
logger.debug(`Skipping message, definition is undefined and still interviewing`);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Received message from unsupported device with Zigbee model '${data.device.modelID}' ` +
|
||||
`and manufacturer name '${data.device.manufacturerName}'`);
|
||||
logger.warn(`Please see: https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html.`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onZigbeeEvent(type, data, resolvedEntity) {
|
||||
/**
|
||||
* Handling of re-transmitted Xiaomi messages.
|
||||
* https://github.com/Koenkk/zigbee2mqtt/issues/1238
|
||||
* https://github.com/Koenkk/zigbee2mqtt/issues/3592
|
||||
*
|
||||
* Some Xiaomi router devices re-transmit messages from Xiaomi end devices.
|
||||
* The network address of these message is set to the one of the Xiaomi router.
|
||||
* Therefore it looks like if the message came from the Xiaomi router, while in
|
||||
* fact it came from the end device.
|
||||
* Handling these message would result in false state updates.
|
||||
* The group ID attribute of these message defines the network address of the end device.
|
||||
*/
|
||||
if (type === 'message' && utils.isXiaomiDevice(data.device) && data.device.type === 'Router' && data.groupID) {
|
||||
logger.debug('Handling re-transmitted Xiaomi message');
|
||||
data.device = this.zigbee.getDeviceByNetworkAddress(data.groupID);
|
||||
resolvedEntity = this.zigbee.resolveEntity(data.device);
|
||||
}
|
||||
|
||||
if (!this.shouldProcess(type, data, resolvedEntity)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const converters = resolvedEntity.definition.fromZigbee.filter((c) => {
|
||||
const type = Array.isArray(c.type) ? c.type.includes(data.type) : c.type === data.type;
|
||||
return c.cluster === data.cluster && type;
|
||||
});
|
||||
|
||||
// Check if there is an available converter, genOta messages are not interesting.
|
||||
if (!converters.length && !['genOta', 'genTime', 'genBasic'].includes(data.cluster)) {
|
||||
logger.debug(
|
||||
`No converter available for '${resolvedEntity.definition.model}' with cluster '${data.cluster}' ` +
|
||||
`and type '${data.type}' and data '${stringify(data.data)}'`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert this Zigbee message to a MQTT message.
|
||||
// Get payload for the message.
|
||||
// - If a payload is returned publish it to the MQTT broker
|
||||
// - If NO payload is returned do nothing. This is for non-standard behaviour
|
||||
// for e.g. click switches where we need to count number of clicks and detect long presses.
|
||||
const publish = (payload) => {
|
||||
if (settings.get().advanced.elapsed) {
|
||||
const now = Date.now();
|
||||
if (this.elapsed[data.device.ieeeAddr]) {
|
||||
payload.elapsed = now - this.elapsed[data.device.ieeeAddr];
|
||||
}
|
||||
|
||||
this.elapsed[data.device.ieeeAddr] = now;
|
||||
}
|
||||
|
||||
// Check if we have to debounce
|
||||
if (resolvedEntity.settings.debounce) {
|
||||
this.publishDebounce(
|
||||
data.device.ieeeAddr, payload, resolvedEntity.settings.debounce,
|
||||
resolvedEntity.settings.debounce_ignore,
|
||||
);
|
||||
} else {
|
||||
this.publishEntityState(data.device.ieeeAddr, payload);
|
||||
}
|
||||
};
|
||||
|
||||
const meta = {device: data.device, logger, state: this.state.get(data.device.ieeeAddr)};
|
||||
let payload = {};
|
||||
converters.forEach((converter) => {
|
||||
const converted = converter.convert(
|
||||
resolvedEntity.definition, data, publish, resolvedEntity.settings, meta,
|
||||
);
|
||||
if (converted) {
|
||||
payload = {...payload, ...converted};
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(payload).length) {
|
||||
publish(payload);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Receive;
|
||||
Executable
+167
@@ -0,0 +1,167 @@
|
||||
import * as settings from '../util/settings';
|
||||
import logger from '../util/logger';
|
||||
import debounce from 'debounce';
|
||||
import Extension from './extension';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
type DebounceFunction = (() => void) & { clear(): void; } & { flush(): void; };
|
||||
|
||||
export default class Receive extends Extension {
|
||||
private elapsed: {[s: string]: number} = {};
|
||||
private debouncers: {[s: string]: {payload: KeyValue, publish: DebounceFunction }} = {};
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.eventBus.onPublishEntityState(this, this.onPublishEntityState);
|
||||
this.eventBus.onDeviceMessage(this, this.onDeviceMessage);
|
||||
}
|
||||
|
||||
@bind async onPublishEntityState(data: eventdata.PublishEntityState): Promise<void> {
|
||||
/**
|
||||
* Prevent that outdated properties are being published.
|
||||
* In case that e.g. the state is currently held back by a debounce and a new state is published
|
||||
* remove it from the to be send debounced message.
|
||||
*/
|
||||
if (data.entity.isDevice() && this.debouncers[data.entity.ieeeAddr] &&
|
||||
data.stateChangeReason !== 'publishDebounce' && data.stateChangeReason !== 'lastSeenChanged') {
|
||||
for (const key of Object.keys(data.message)) {
|
||||
delete this.debouncers[data.entity.ieeeAddr].payload[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publishDebounce(device: Device, payload: KeyValue, time: number, debounceIgnore: string[]): void {
|
||||
if (!this.debouncers[device.ieeeAddr]) {
|
||||
this.debouncers[device.ieeeAddr] = {
|
||||
payload: {},
|
||||
publish: debounce(() => {
|
||||
this.publishEntityState(device, this.debouncers[device.ieeeAddr].payload, 'publishDebounce');
|
||||
this.debouncers[device.ieeeAddr].payload = {};
|
||||
}, time * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isPayloadConflicted(payload, this.debouncers[device.ieeeAddr].payload, debounceIgnore)) {
|
||||
// publish previous payload immediately
|
||||
this.debouncers[device.ieeeAddr].publish.flush();
|
||||
}
|
||||
|
||||
// extend debounced payload with current
|
||||
this.debouncers[device.ieeeAddr].payload = {...this.debouncers[device.ieeeAddr].payload, ...payload};
|
||||
this.debouncers[device.ieeeAddr].publish();
|
||||
}
|
||||
|
||||
// if debounce_ignore are specified (Array of strings)
|
||||
// then all newPayload values with key present in debounce_ignore
|
||||
// should equal or be undefined in oldPayload
|
||||
// otherwise payload is conflicted
|
||||
isPayloadConflicted(newPayload: KeyValue, oldPayload: KeyValue, debounceIgnore: string[] | null): boolean {
|
||||
let result = false;
|
||||
Object.keys(oldPayload)
|
||||
.filter((key) => (debounceIgnore || []).includes(key))
|
||||
.forEach((key) => {
|
||||
if (typeof newPayload[key] !== 'undefined' && newPayload[key] !== oldPayload[key]) {
|
||||
result = true;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
shouldProcess(data: eventdata.DeviceMessage): boolean {
|
||||
if (!data.device.definition) {
|
||||
if (data.device.zh.interviewing) {
|
||||
logger.debug(`Skipping message, definition is undefined and still interviewing`);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Received message from unsupported device with Zigbee model '${data.device.zh.modelID}' ` +
|
||||
`and manufacturer name '${data.device.zh.manufacturerName}'`);
|
||||
logger.warn(`Please see: https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html.`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@bind async onDeviceMessage(data: eventdata.DeviceMessage): Promise<void> {
|
||||
/* istanbul ignore next */
|
||||
if (!data.device) return;
|
||||
|
||||
/**
|
||||
* Handling of re-transmitted Xiaomi messages.
|
||||
* https://github.com/Koenkk/zigbee2mqtt/issues/1238
|
||||
* https://github.com/Koenkk/zigbee2mqtt/issues/3592
|
||||
*
|
||||
* Some Xiaomi router devices re-transmit messages from Xiaomi end devices.
|
||||
* The network address of these message is set to the one of the Xiaomi router.
|
||||
* Therefore it looks like if the message came from the Xiaomi router, while in
|
||||
* fact it came from the end device.
|
||||
* Handling these message would result in false state updates.
|
||||
* The group ID attribute of these message defines the network address of the end device.
|
||||
*/
|
||||
if (data.device.isXiaomi() && data.device.zh.type === 'Router' && data.groupID) {
|
||||
logger.debug('Handling re-transmitted Xiaomi message');
|
||||
data = {...data, device: this.zigbee.deviceByNetworkAddress(data.groupID)};
|
||||
}
|
||||
|
||||
if (!this.shouldProcess(data)) return;
|
||||
|
||||
const converters = data.device.definition.fromZigbee.filter((c) => {
|
||||
const type = Array.isArray(c.type) ? c.type.includes(data.type) : c.type === data.type;
|
||||
return c.cluster === data.cluster && type;
|
||||
});
|
||||
|
||||
// Check if there is an available converter, genOta messages are not interesting.
|
||||
const ignoreClusters: (string | number)[] = ['genOta', 'genTime', 'genBasic'];
|
||||
if (converters.length == 0 && !ignoreClusters.includes(data.cluster)) {
|
||||
logger.debug(`No converter available for '${data.device.definition.model}' with ` +
|
||||
`cluster '${data.cluster}' and type '${data.type}' and data '${stringify(data.data)}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert this Zigbee message to a MQTT message.
|
||||
// Get payload for the message.
|
||||
// - If a payload is returned publish it to the MQTT broker
|
||||
// - If NO payload is returned do nothing. This is for non-standard behaviour
|
||||
// for e.g. click switches where we need to count number of clicks and detect long presses.
|
||||
const publish = (payload: KeyValue): void => {
|
||||
if (settings.get().advanced.elapsed) {
|
||||
const now = Date.now();
|
||||
if (this.elapsed[data.device.ieeeAddr]) {
|
||||
payload.elapsed = now - this.elapsed[data.device.ieeeAddr];
|
||||
}
|
||||
|
||||
this.elapsed[data.device.ieeeAddr] = now;
|
||||
}
|
||||
|
||||
// Check if we have to debounce
|
||||
if (data.device.settings.debounce) {
|
||||
this.publishDebounce(data.device, payload, data.device.settings.debounce,
|
||||
data.device.settings.debounce_ignore);
|
||||
} else {
|
||||
this.publishEntityState(data.device, payload);
|
||||
}
|
||||
};
|
||||
|
||||
const meta = {device: data.device.zh, logger, state: this.state.get(data.device)};
|
||||
let payload: KeyValue = {};
|
||||
for (const converter of converters) {
|
||||
try {
|
||||
const converted = await converter.convert(
|
||||
data.device.definition, data, publish, data.device.settings, meta);
|
||||
if (converted) {
|
||||
payload = {...payload, ...converted};
|
||||
}
|
||||
} catch (error) {
|
||||
// istanbul ignore next
|
||||
logger.error(`Exception while calling fromZigbee converter: ${error.message}}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length) {
|
||||
publish(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/* eslint-disable brace-style */
|
||||
import * as settings from '../util/settings';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
|
||||
export default class Device {
|
||||
public zh: zh.Device;
|
||||
private _definition: zhc.Definition;
|
||||
|
||||
get ieeeAddr(): string {return this.zh.ieeeAddr;}
|
||||
get ID(): string {return this.zh.ieeeAddr;}
|
||||
get settings(): DeviceSettings {return {...settings.get().device_options, ...settings.getDevice(this.ieeeAddr)};}
|
||||
get name(): string {
|
||||
return this.zh.type === 'Coordinator' ? 'Coordinator' : this.settings?.friendly_name || this.ieeeAddr;
|
||||
}
|
||||
get definition(): zhc.Definition {
|
||||
if (!this._definition && !this.zh.interviewing) {
|
||||
this._definition = zigbeeHerdsmanConverters.findByDevice(this.zh);
|
||||
}
|
||||
return this._definition;
|
||||
}
|
||||
|
||||
constructor(device: zh.Device) {
|
||||
this.zh = device;
|
||||
}
|
||||
|
||||
ensureInSettings(): void {
|
||||
if (this.zh.type !== 'Coordinator' && !settings.getDevice(this.zh.ieeeAddr)) {
|
||||
settings.addDevice(this.zh.ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
endpoint(key?: string | number): zh.Endpoint {
|
||||
let endpoint: zh.Endpoint;
|
||||
if (key == null || key == '') key = 'default';
|
||||
|
||||
if (!isNaN(Number(key))) {
|
||||
endpoint = this.zh.getEndpoint(Number(key));
|
||||
} else if (this.definition?.endpoint) {
|
||||
const ID = this.definition?.endpoint?.(this.zh)[key];
|
||||
if (ID) endpoint = this.zh.getEndpoint(ID);
|
||||
else if (key === 'default') endpoint = this.zh.endpoints[0];
|
||||
else return null;
|
||||
} else {
|
||||
/* istanbul ignore next */
|
||||
if (key !== 'default') return null;
|
||||
endpoint = this.zh.endpoints[0];
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
endpointName(endpoint: zh.Endpoint): string {
|
||||
let name = null;
|
||||
if (this.definition?.endpoint) {
|
||||
name = Object.entries(this.definition?.endpoint(this.zh)).find((e) => e[1] == endpoint.ID)[0];
|
||||
}
|
||||
/* istanbul ignore next */
|
||||
return name === 'default' ? null : name;
|
||||
}
|
||||
|
||||
isXiaomi(): boolean {
|
||||
const xiaomiManufacturerID = [4151, 4447];
|
||||
/* istanbul ignore next */
|
||||
return this.zh.modelID !== 'lumi.router' && xiaomiManufacturerID.includes(this.zh.manufacturerID) &&
|
||||
(!this.zh.manufacturerName || !this.zh.manufacturerName.startsWith('Trust'));
|
||||
}
|
||||
|
||||
isIkeaTradfri(): boolean {return this.zh.manufacturerID === 4476;}
|
||||
|
||||
isDevice(): this is Device {return true;}
|
||||
/* istanbul ignore next */
|
||||
isGroup(): this is Group {return false;}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/* eslint-disable brace-style */
|
||||
import * as settings from '../util/settings';
|
||||
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
|
||||
|
||||
export default class Group {
|
||||
public zh: zh.Group;
|
||||
|
||||
get ID(): number {return this.zh.groupID;}
|
||||
get settings(): GroupSettings {return settings.getGroup(this.ID);}
|
||||
get name(): string {return this.settings?.friendly_name || this.ID.toString();}
|
||||
|
||||
constructor(group: zh.Group) {
|
||||
this.zh = group;
|
||||
}
|
||||
|
||||
membersDefinitions(): zhc.Definition[] {
|
||||
return this.zh.members.map((m) =>
|
||||
zigbeeHerdsmanConverters.findByDevice(m.getDevice())).filter((d) => d) as zhc.Definition[];
|
||||
}
|
||||
|
||||
isDevice(): this is Device {return false;}
|
||||
isGroup(): this is Group {return true;}
|
||||
}
|
||||
+49
-41
@@ -1,25 +1,30 @@
|
||||
const mqtt = require('mqtt');
|
||||
const logger = require('./util/logger');
|
||||
const settings = require('./util/settings');
|
||||
const fs = require('fs');
|
||||
const events = require('events');
|
||||
import mqtt from 'mqtt';
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import utils from './util/utils';
|
||||
import fs from 'fs';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
class MQTT extends events.EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.publishedTopics = new Set();
|
||||
export default class MQTT {
|
||||
private publishedTopics: Set<string> = new Set();
|
||||
private connectionTimer: NodeJS.Timeout;
|
||||
private client: mqtt.MqttClient;
|
||||
private eventBus: EventBus;
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
async connect(): Promise<void> {
|
||||
const mqttSettings = settings.get().mqtt;
|
||||
logger.info(`Connecting to MQTT server at ${mqttSettings.server}`);
|
||||
|
||||
const options = {
|
||||
const options: mqtt.IClientOptions = {
|
||||
will: {
|
||||
topic: `${settings.get().mqtt.base_topic}/bridge/state`,
|
||||
payload: 'offline',
|
||||
retain: settings.get().mqtt.force_disable_retain ? false : true,
|
||||
qos: 1,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -62,61 +67,68 @@ class MQTT extends events.EventEmitter {
|
||||
options.rejectUnauthorized = false;
|
||||
}
|
||||
|
||||
// Set timer at interval to check if connected to MQTT server.
|
||||
const interval = 10 * 1000; // seconds * 1000.
|
||||
this.connectionTimer = setInterval(() => {
|
||||
if (this.client.reconnecting) {
|
||||
logger.error('Not connected to MQTT server!');
|
||||
}
|
||||
}, interval);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client = mqtt.connect(mqttSettings.server, options);
|
||||
|
||||
const self = this;
|
||||
this.client.on('connect', () => {
|
||||
logger.info('Connected to MQTT server');
|
||||
self.subscribe(`${settings.get().mqtt.base_topic}/#`);
|
||||
self.publish('bridge/state', 'online', {retain: true, qos: 0});
|
||||
const onConnect = this.onConnect;
|
||||
this.client.on('connect', async () => {
|
||||
await onConnect();
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => reject(err));
|
||||
this.client.on('message', this.onMessage);
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
@bind private async onConnect(): Promise<void> {
|
||||
// Set timer at interval to check if connected to MQTT server.
|
||||
clearTimeout(this.connectionTimer);
|
||||
this.connectionTimer = setInterval(() => {
|
||||
if (this.client.reconnecting) {
|
||||
logger.error('Not connected to MQTT server!');
|
||||
}
|
||||
}, utils.seconds(10));
|
||||
|
||||
logger.info('Connected to MQTT server');
|
||||
this.subscribe(`${settings.get().mqtt.base_topic}/#`);
|
||||
await this.publish('bridge/state', 'online', {retain: true, qos: 0});
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
clearTimeout(this.connectionTimer);
|
||||
this.connectionTimer = null;
|
||||
await this.publish('bridge/state', 'offline', {retain: true, qos: 0});
|
||||
logger.info('Disconnecting from MQTT server');
|
||||
this.client.end();
|
||||
}
|
||||
|
||||
subscribe(topic) {
|
||||
subscribe(topic: string): void {
|
||||
this.client.subscribe(topic);
|
||||
}
|
||||
|
||||
onMessage(topic, message) {
|
||||
@bind public onMessage(topic: string, message: string): void {
|
||||
// Since we subscribe to zigbee2mqtt/# we also receive the message we send ourselves, skip these.
|
||||
if (!this.publishedTopics.has(topic)) {
|
||||
this.emit('message', {topic, message: message + ''});
|
||||
logger.debug(`Received MQTT message on '${topic}' with data '${message}'`);
|
||||
this.eventBus.emitMQTTMessage({topic, message: message + ''});
|
||||
}
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
isConnected(): boolean {
|
||||
return this.client && !this.client.reconnecting;
|
||||
}
|
||||
|
||||
async publish(topic, payload, options, base=settings.get().mqtt.base_topic, skipLog=false, skipReceive=true) {
|
||||
async publish(topic: string, payload: string, options: MQTTOptions={},
|
||||
base=settings.get().mqtt.base_topic, skipLog=false, skipReceive=true,
|
||||
): Promise<void> {
|
||||
const defaultOptions: {qos: mqtt.QoS, retain: boolean} = {qos: 0, retain: false};
|
||||
topic = `${base}/${topic}`;
|
||||
options = {qos: 0, retain: false, ...options};
|
||||
|
||||
if (skipReceive) {
|
||||
this.publishedTopics.add(topic);
|
||||
}
|
||||
|
||||
this.emit('publishMessage', {topic, payload, options});
|
||||
this.eventBus.emitMQTTMessagePublished({topic, payload, options: {...defaultOptions, ...options}});
|
||||
|
||||
if (!this.isConnected()) {
|
||||
if (!skipLog) {
|
||||
@@ -130,17 +142,13 @@ class MQTT extends events.EventEmitter {
|
||||
logger.info(`MQTT publish: topic '${topic}', payload '${payload}'`);
|
||||
}
|
||||
|
||||
const actualOptions = {...options};
|
||||
const actualOptions: mqtt.IClientPublishOptions = {...defaultOptions, ...options};
|
||||
if (settings.get().mqtt.force_disable_retain) {
|
||||
actualOptions.retain = false;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.client.publish(topic, payload, actualOptions, () => {
|
||||
resolve();
|
||||
});
|
||||
this.client.publish(topic, payload, actualOptions, () => resolve());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MQTT;
|
||||
+31
-55
@@ -1,8 +1,8 @@
|
||||
const logger = require('./util/logger');
|
||||
const data = require('./util/data');
|
||||
const settings = require('./util/settings');
|
||||
const fs = require('fs');
|
||||
const objectAssignDeep = require('object-assign-deep');
|
||||
import logger from './util/logger';
|
||||
import data from './util/data';
|
||||
import * as settings from './util/settings';
|
||||
import fs from 'fs';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
|
||||
const saveInterval = 1000 * 60 * 5; // 5 minutes
|
||||
|
||||
@@ -13,34 +13,30 @@ const dontCacheProperties = [
|
||||
];
|
||||
|
||||
class State {
|
||||
constructor(eventBus) {
|
||||
this.state = {};
|
||||
this.file = data.joinPath('state.json');
|
||||
this.timer = null;
|
||||
private state: {[s: string | number]: KeyValue} = {};
|
||||
private file = data.joinPath('state.json');
|
||||
private timer: NodeJS.Timer = null;
|
||||
private eventBus: EventBus;
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
start() {
|
||||
this._load();
|
||||
start(): void {
|
||||
this.load();
|
||||
|
||||
// Save the state on every interval
|
||||
this.clearTimer();
|
||||
this.timer = setInterval(() => this.save(), saveInterval);
|
||||
this.eventBus.onDeviceLeave(this, (data) => delete this.state[data.ieeeAddr]);
|
||||
}
|
||||
|
||||
clearTimer() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.clearTimer();
|
||||
stop(): void {
|
||||
this.eventBus.removeListeners(this);
|
||||
clearTimeout(this.timer);
|
||||
this.save();
|
||||
}
|
||||
|
||||
_load() {
|
||||
private load(): void {
|
||||
if (fs.existsSync(this.file)) {
|
||||
try {
|
||||
this.state = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
||||
@@ -53,7 +49,7 @@ class State {
|
||||
}
|
||||
}
|
||||
|
||||
save() {
|
||||
private save(): void {
|
||||
if (settings.get().advanced.cache_state_persistent) {
|
||||
logger.debug(`Saving state to file ${this.file}`);
|
||||
const json = JSON.stringify(this.state, null, 4);
|
||||
@@ -67,17 +63,17 @@ class State {
|
||||
}
|
||||
}
|
||||
|
||||
exists(ID) {
|
||||
return this.state.hasOwnProperty(ID);
|
||||
exists(entity: Device | Group): boolean {
|
||||
return this.state.hasOwnProperty(entity.ID);
|
||||
}
|
||||
|
||||
get(ID) {
|
||||
return this.state[ID];
|
||||
get(entity: Group | Device): KeyValue {
|
||||
return this.state[entity.ID];
|
||||
}
|
||||
|
||||
set(ID, update, reason=null) {
|
||||
const fromState = this.state[ID] || {};
|
||||
const toState = objectAssignDeep.noMutate(fromState, update);
|
||||
set(entity: Group | Device, update: KeyValue, reason: string=null): KeyValue {
|
||||
const fromState = this.state[entity.ID] || {};
|
||||
const toState = objectAssignDeep({}, fromState, update);
|
||||
const result = {...toState};
|
||||
|
||||
for (const property of Object.keys(toState)) {
|
||||
@@ -86,34 +82,14 @@ class State {
|
||||
}
|
||||
}
|
||||
|
||||
this.state[ID] = toState;
|
||||
this.eventBus.emit('stateChange', {ID, from: fromState, to: toState, reason, update});
|
||||
this.state[entity.ID] = toState;
|
||||
this.eventBus.emitStateChange({entity, from: fromState, to: toState, reason, update});
|
||||
return result;
|
||||
}
|
||||
|
||||
removeKey(ID, path) {
|
||||
if (this.exists(ID)) {
|
||||
let state = this.state[ID];
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const key = path[i];
|
||||
if (i === path.length - 1) {
|
||||
delete state[key];
|
||||
} else {
|
||||
if (state[key]) {
|
||||
state = state[key];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(ID) {
|
||||
if (this.exists(ID)) {
|
||||
delete this.state[ID];
|
||||
}
|
||||
remove(ID: string | number): void {
|
||||
delete this.state[ID];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = State;
|
||||
export default State;
|
||||
@@ -0,0 +1,3 @@
|
||||
declare module 'json-stable-stringify-without-jsonify' {
|
||||
export default function (obj: unknown): string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
declare module 'mkdir-recursive' {
|
||||
export function mkdirSync(path: string): void;
|
||||
}
|
||||
Vendored
+298
@@ -0,0 +1,298 @@
|
||||
/* eslint-disable camelcase */
|
||||
import type {
|
||||
Device as ZHDevice,
|
||||
Group as ZHGroup,
|
||||
Endpoint as ZHEndpoint,
|
||||
} from 'zigbee-herdsman/dist/controller/model';
|
||||
|
||||
import type {
|
||||
NetworkParameters as ZHNetworkParameters,
|
||||
CoordinatorVersion as ZHCoordinatorVersion,
|
||||
LQI as ZHLQI,
|
||||
RoutingTable as ZHRoutingTable,
|
||||
RoutingTableEntry as ZHRoutingTableEntry,
|
||||
} from 'zigbee-herdsman/dist/adapter/tstype';
|
||||
|
||||
import type {
|
||||
Cluster as ZHCluster,
|
||||
} from 'zigbee-herdsman/dist/zcl/tstype';
|
||||
|
||||
import type * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
|
||||
|
||||
import type TypeEventBus from 'lib/eventBus';
|
||||
import type TypeMQTT from 'lib/mqtt';
|
||||
import type TypeState from 'lib/state';
|
||||
import type TypeZigbee from 'lib/zigbee';
|
||||
import type TypeDevice from 'lib/model/device';
|
||||
import type TypeGroup from 'lib/model/group';
|
||||
import type TypeExtension from 'lib/extension/extension';
|
||||
|
||||
import type mqtt from 'mqtt';
|
||||
|
||||
declare global {
|
||||
// Define some class types as global
|
||||
type EventBus = TypeEventBus;
|
||||
type MQTT = TypeMQTT;
|
||||
type Zigbee = TypeZigbee;
|
||||
type Group = TypeGroup;
|
||||
type Device = TypeDevice;
|
||||
type State = TypeState;
|
||||
type Extension = TypeExtension;
|
||||
|
||||
// Types
|
||||
interface MQTTResponse {data: KeyValue, status: 'error' | 'ok', error?: string, transaction?: string}
|
||||
interface MQTTOptions {qos?: mqtt.QoS, retain?: boolean, properties?: {messageExpiryInterval: number}}
|
||||
type StateChangeReason = 'publishDebounce' | 'groupOptimistic' | 'lastSeenChanged';
|
||||
type PublishEntityState = (entity: Device | Group, payload: KeyValue,
|
||||
stateChangeReason?: StateChangeReason) => Promise<void>;
|
||||
type RecursivePartial<T> = {[P in keyof T]?: RecursivePartial<T[P]>;};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
interface KeyValue {[s: string]: any}
|
||||
|
||||
// zigbee-herdsman
|
||||
namespace zh {
|
||||
type Endpoint = ZHEndpoint;
|
||||
type Device = ZHDevice;
|
||||
type Group = ZHGroup;
|
||||
type LQI = ZHLQI;
|
||||
type RoutingTable = ZHRoutingTable;
|
||||
type RoutingTableEntry = ZHRoutingTableEntry;
|
||||
type CoordinatorVersion = ZHCoordinatorVersion;
|
||||
type NetworkParameters = ZHNetworkParameters;
|
||||
type Cluster = ZHCluster;
|
||||
interface Bind {
|
||||
cluster: zh.Cluster;
|
||||
target: zh.Endpoint | zh.Group;
|
||||
}
|
||||
}
|
||||
|
||||
// zigbee-herdsman-converters
|
||||
namespace zhc {
|
||||
interface Logger {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
debug: (message: string) => void;
|
||||
}
|
||||
|
||||
interface ToZigbeeConverterGetMeta {message?: KeyValue, mapped?: Definition | Definition[]}
|
||||
|
||||
interface ToZigbeeConverterResult {state: KeyValue,
|
||||
membersState: {[s: string]: KeyValue}, readAfterWriteTime?: number}
|
||||
|
||||
interface ToZigbeeConverter {
|
||||
key: string[],
|
||||
convertGet?: (entity: zh.Endpoint | zh.Group, key: string, meta: ToZigbeeConverterGetMeta) => Promise<void>
|
||||
convertSet?: (entity: zh.Endpoint | zh.Group, key: string, value: KeyValue | string | number,
|
||||
meta: {state: KeyValue}) => Promise<ToZigbeeConverterResult>
|
||||
}
|
||||
|
||||
interface FromZigbeeConverter {
|
||||
cluster: string,
|
||||
type: string[] | string,
|
||||
convert: (model: Definition, msg: KeyValue, publish: (payload: KeyValue) => void, options: KeyValue,
|
||||
meta: {state: KeyValue, logger: Logger, device: zh.Device}) => Promise<KeyValue>,
|
||||
}
|
||||
|
||||
interface DefinitionExposeFeature {name: string, endpoint?: string,
|
||||
property: string, value_max?: number, value_min?: number,
|
||||
value_off?: string, value_on?: string, value_step?: number, values: string[], access: number}
|
||||
|
||||
interface DefinitionExpose {
|
||||
type: string, name?: string, features?: DefinitionExposeFeature[],
|
||||
endpoint?: string, values?: string[], value_off?: string, value_on?: string,
|
||||
access: number, property: string, unit?: string,
|
||||
value_min?: number, value_max?: number}
|
||||
|
||||
interface Definition {
|
||||
model: string,
|
||||
zigbeeModel: string[],
|
||||
endpoint?: (device: zh.Device) => {[s: string]: number}
|
||||
toZigbee: ToZigbeeConverter[]
|
||||
fromZigbee: FromZigbeeConverter[]
|
||||
icon?: string
|
||||
description: string
|
||||
options: zhc.DefinitionExpose[],
|
||||
vendor: string
|
||||
exposes: DefinitionExpose[]
|
||||
configure?: (device: zh.Device, coordinatorEndpoint: zh.Endpoint, logger: Logger) => Promise<void>;
|
||||
onEvent?: (type: string, data: KeyValue, device: zh.Device, settings: KeyValue) => Promise<void>;
|
||||
ota?: {
|
||||
isUpdateAvailable: (device: zh.Device, logger: Logger, data?: KeyValue) => Promise<boolean>;
|
||||
updateToLatest: (device: zh.Device, logger: Logger,
|
||||
onProgress: (progress: number, remaining: number) => void) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ExternalDefinition = Definition & {homeassistant: any};
|
||||
}
|
||||
|
||||
namespace eventdata {
|
||||
type DeviceRenamed = { device: Device, homeAssisantRename: boolean, from: string, to: string };
|
||||
type DeviceRemoved = { ieeeAddr: string, name: string };
|
||||
type MQTTMessage = { topic: string, message: string };
|
||||
type MQTTMessagePublished = { topic: string, payload: string, options: {retain: boolean, qos: number} };
|
||||
type StateChange = {
|
||||
entity: Device | Group, from: KeyValue, to: KeyValue, reason: string | null, update: KeyValue };
|
||||
type PermitJoinChanged = ZHEvents.PermitJoinChangedPayload;
|
||||
type LastSeenChanged = { device: Device };
|
||||
type DeviceNetworkAddressChanged = { device: Device };
|
||||
type DeviceAnnounce = { device: Device };
|
||||
type DeviceInterview = { device: Device, status: 'started' | 'successful' | 'failed' };
|
||||
type DeviceJoined = { device: Device };
|
||||
type ReportingDisabled = { device: Device };
|
||||
type DeviceLeave = { ieeeAddr: string, name: string };
|
||||
type GroupMembersChanged = {group: Group, action: 'remove' | 'add' | 'remove_all',
|
||||
endpoint: zh.Endpoint, skipDisableReporting: boolean };
|
||||
type PublishEntityState = {entity: Group | Device, message: KeyValue, stateChangeReason: StateChangeReason };
|
||||
type DeviceMessage = {
|
||||
type: ZHEvents.MessagePayloadType;
|
||||
device: Device;
|
||||
endpoint: zh.Endpoint;
|
||||
linkquality: number;
|
||||
groupID: number;
|
||||
cluster: string | number;
|
||||
data: KeyValue | Array<string | number>;
|
||||
meta: {zclTransactionSequenceNumber?: number;};
|
||||
};
|
||||
}
|
||||
|
||||
// Settings
|
||||
// eslint-disable camelcase
|
||||
interface Settings {
|
||||
homeassistant?: boolean,
|
||||
devices?: {[s: string]: DeviceSettings},
|
||||
groups?: {[s: string]: GroupSettings},
|
||||
passlist: string[],
|
||||
blocklist: string[],
|
||||
whitelist: string[],
|
||||
ban: string[],
|
||||
availability?: boolean | {
|
||||
active?: {timeout?: number},
|
||||
passive?: {timeout?: number}
|
||||
},
|
||||
permit_join: boolean,
|
||||
frontend?: {
|
||||
auth_token?: string,
|
||||
host?: string,
|
||||
port?: number,
|
||||
url?: string,
|
||||
},
|
||||
mqtt: {
|
||||
base_topic: string,
|
||||
include_device_information: boolean,
|
||||
force_disable_retain: boolean
|
||||
version?: number,
|
||||
user?: string,
|
||||
password?: string,
|
||||
server: string,
|
||||
ca?: string,
|
||||
keepalive?: number,
|
||||
key?: string,
|
||||
cert?: string,
|
||||
client_id?: string,
|
||||
reject_unauthorized?: boolean,
|
||||
},
|
||||
serial: {
|
||||
disable_led?: boolean,
|
||||
port?: string,
|
||||
adapter?: 'deconz' | 'zstack' | 'ezsp' | 'zigate'
|
||||
},
|
||||
device_options: KeyValue,
|
||||
map_options: {
|
||||
graphviz: {
|
||||
colors: {
|
||||
fill: {
|
||||
enddevice: string,
|
||||
coordinator: string,
|
||||
router: string,
|
||||
},
|
||||
font: {
|
||||
coordinator: string,
|
||||
router: string,
|
||||
enddevice: string,
|
||||
},
|
||||
line: {
|
||||
active: string,
|
||||
inactive: string,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
output: 'json' | 'attribute' | 'attribute_and_json',
|
||||
transmit_power?: number,
|
||||
},
|
||||
advanced: {
|
||||
legacy_api: boolean,
|
||||
log_rotation: boolean,
|
||||
log_symlink_current: boolean,
|
||||
log_output: ('console' | 'file' | 'syslog')[],
|
||||
log_directory: string,
|
||||
log_file: string,
|
||||
log_level: 'debug' | 'info' | 'error' | 'warn',
|
||||
log_syslog: KeyValue,
|
||||
soft_reset_timeout: number,
|
||||
pan_id: number | 'GENERATE',
|
||||
ext_pan_id: number[],
|
||||
channel: number,
|
||||
adapter_concurrent: number | null,
|
||||
adapter_delay: number | null,
|
||||
cache_state: boolean,
|
||||
cache_state_persistent: boolean,
|
||||
cache_state_send_on_startup: boolean,
|
||||
last_seen: 'disable' | 'ISO_8601' | 'ISO_8601_local' | 'epoch',
|
||||
elapsed: boolean,
|
||||
network_key: number[] | 'GENERATE',
|
||||
report: boolean,
|
||||
homeassistant_discovery_topic: string,
|
||||
homeassistant_status_topic: string,
|
||||
homeassistant_legacy_entity_attributes: boolean,
|
||||
homeassistant_legacy_triggers: boolean,
|
||||
timestamp_format: string,
|
||||
baudrate?: number,
|
||||
rtscts?: boolean,
|
||||
ikea_ota_use_test_url?: boolean,
|
||||
// below are deprecated
|
||||
availability_timeout?: number,
|
||||
availability_blocklist?: string[],
|
||||
availability_passlist?: string[],
|
||||
availability_blacklist?: string[],
|
||||
availability_whitelist?: string[],
|
||||
},
|
||||
ota: {
|
||||
update_check_interval: number,
|
||||
disable_automatic_update_check: boolean
|
||||
},
|
||||
external_converters: string[],
|
||||
}
|
||||
|
||||
interface DeviceSettings {
|
||||
ID?: string,
|
||||
retention?: number,
|
||||
availability?: boolean | {timeout: number},
|
||||
optimistic?: boolean,
|
||||
retrieve_state?: boolean,
|
||||
debounce?: number,
|
||||
debounce_ignore?: string[],
|
||||
filtered_optimistic?: string[],
|
||||
icon?: string,
|
||||
homeassistant?: KeyValue,
|
||||
legacy?: boolean,
|
||||
filtered_attributes?: string[],
|
||||
friendly_name: string,
|
||||
}
|
||||
|
||||
interface GroupSettings {
|
||||
devices?: string[],
|
||||
ID?: number,
|
||||
optimistic?: boolean,
|
||||
filtered_optimistic?: string[],
|
||||
retrieve_state?: boolean,
|
||||
homeassistant?: KeyValue,
|
||||
filtered_attributes?: string[],
|
||||
friendly_name: string,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
/* eslint-disable */
|
||||
|
||||
interface KeyValue {
|
||||
[s: string]: any,
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
devices?: {[s: string]: {friendly_name: string, retention?: number}},
|
||||
groups?: {[s: string]: {friendly_name: string, devices?: string[]}},
|
||||
passlist: string[],
|
||||
blocklist: string[],
|
||||
whitelist: string[],
|
||||
ban: string[],
|
||||
permit_join: boolean,
|
||||
frontend?: {
|
||||
auth_token?: string,
|
||||
},
|
||||
mqtt: {
|
||||
include_device_information: boolean,
|
||||
force_disable_retain: boolean
|
||||
version?: number,
|
||||
user?: string,
|
||||
password?: string,
|
||||
},
|
||||
serial: {
|
||||
disable_led: boolean,
|
||||
},
|
||||
device_options: {[s: string]: unknown},
|
||||
map_options: {
|
||||
graphviz: {
|
||||
colors: {
|
||||
fill: {
|
||||
enddevice: string,
|
||||
coordinator: string,
|
||||
router: string,
|
||||
},
|
||||
font: {
|
||||
coordinator: string,
|
||||
router: string,
|
||||
enddevice: string,
|
||||
},
|
||||
line: {
|
||||
active: string,
|
||||
inactive: string,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
output: 'json' | 'attribute' | 'attribute_and_json',
|
||||
},
|
||||
advanced: {
|
||||
legacy_api: boolean,
|
||||
log_rotation: boolean,
|
||||
log_symlink_current: boolean,
|
||||
log_output: ('console' | 'file')[],
|
||||
log_directory: string,
|
||||
log_file: string,
|
||||
log_level: 'debug' | 'info' | 'error' | 'warn',
|
||||
log_syslog: {},
|
||||
soft_reset_timeout: number,
|
||||
pan_id: number,
|
||||
ext_pan_id: number[],
|
||||
channel: number,
|
||||
adapter_concurrent: number | null,
|
||||
adapter_delay: number | null,
|
||||
availability_timeout: number,
|
||||
availability_blocklist: string[],
|
||||
availability_passlist: string[],
|
||||
availability_blacklist: string[],
|
||||
availability_whitelist: string[],
|
||||
cache_state: boolean,
|
||||
cache_state_persistent: boolean,
|
||||
cache_state_send_on_startup: boolean,
|
||||
last_seen: 'disable' | 'ISO_8601' | 'ISO_8601_local' | 'epoch',
|
||||
elapsed: boolean,
|
||||
network_key: number[],
|
||||
report: boolean,
|
||||
homeassistant_discovery_topic: string,
|
||||
homeassistant_status_topic: string,
|
||||
homeassistant_legacy_entity_attributes: boolean,
|
||||
homeassistant_legacy_triggers: boolean,
|
||||
timestamp_format: string,
|
||||
},
|
||||
ota: {
|
||||
update_check_interval: number,
|
||||
disable_automatic_update_check: boolean
|
||||
},
|
||||
external_converters: string[],
|
||||
}
|
||||
|
||||
interface DeviceSettings {
|
||||
friendlyName: string,
|
||||
ID: string,
|
||||
retention?: number,
|
||||
}
|
||||
|
||||
interface GroupSettings {
|
||||
friendlyName: string,
|
||||
devices: string[],
|
||||
ID: number,
|
||||
}
|
||||
|
||||
type EntitySettings = {
|
||||
type: 'device' | 'group'
|
||||
ID: number | string,
|
||||
friendlyName: string,
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
declare module 'zigbee-herdsman-converters' {
|
||||
export function findByDevice(device: zh.Device): zhc.Definition;
|
||||
export function getConfigureKey(definition: zhc.Definition): string | number;
|
||||
export const toZigbeeConverters: {[s: string]: zhc.ToZigbeeConverter};
|
||||
export const definitions: zhc.Definition[];
|
||||
export function addDeviceDefinition(definition: zhc.Definition): Promise<void>;
|
||||
export function onEvent(type: string, data: KeyValue, device: zh.Device): Promise<void>;
|
||||
}
|
||||
|
||||
declare module 'zigbee-herdsman-converters/lib/ota/tradfri' {
|
||||
export function useTestURL(): void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
declare module 'zigbee2mqtt-frontend' {
|
||||
export function getPath(): string;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
const path = require('path');
|
||||
|
||||
let dataPath = null;
|
||||
|
||||
function load() {
|
||||
if (process.env.ZIGBEE2MQTT_DATA) {
|
||||
dataPath = process.env.ZIGBEE2MQTT_DATA;
|
||||
} else {
|
||||
dataPath = path.join(__dirname, '..', '..', 'data');
|
||||
dataPath = path.normalize(dataPath);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
module.exports = {
|
||||
joinPath: (file) => path.join(dataPath, file),
|
||||
getPath: () => dataPath,
|
||||
|
||||
// For test only.
|
||||
_reload: () => load(),
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import path from 'path';
|
||||
|
||||
let dataPath: string = null;
|
||||
|
||||
function load(): void {
|
||||
if (process.env.ZIGBEE2MQTT_DATA) {
|
||||
dataPath = process.env.ZIGBEE2MQTT_DATA;
|
||||
} else {
|
||||
dataPath = path.join(__dirname, '..', '..', 'data');
|
||||
dataPath = path.normalize(dataPath);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
function joinPath(file: string): string {
|
||||
return path.join(dataPath, file);
|
||||
}
|
||||
|
||||
function getPath(): string {
|
||||
return dataPath;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
function __testingOnly_reload(): void {
|
||||
load();
|
||||
}
|
||||
|
||||
export default {joinPath, getPath, __testingOnly_reload};
|
||||
@@ -1,12 +1,13 @@
|
||||
const winston = require('winston');
|
||||
const moment = require('moment');
|
||||
const settings = require('./settings');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fx = require('mkdir-recursive');
|
||||
const rimraf = require('rimraf');
|
||||
import winston from 'winston';
|
||||
import moment from 'moment';
|
||||
import * as settings from './settings';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import fx from 'mkdir-recursive';
|
||||
import rimraf from 'rimraf';
|
||||
import assert from 'assert';
|
||||
|
||||
const colorizer = winston.format.colorize();
|
||||
const assert = require('assert');
|
||||
|
||||
// What transports to enable
|
||||
const output = settings.get().advanced.log_output;
|
||||
@@ -30,36 +31,38 @@ if (output.includes('file')) {
|
||||
}
|
||||
}
|
||||
|
||||
const z2mToWinstonLevel = (level) => level === 'warn' ? 'warning' : level;
|
||||
const winstonToZ2mLevel = (level) => level === 'warning' ? 'warn' : level;
|
||||
type Z2MLogLevel = 'warn' | 'debug' | 'info' | 'error';
|
||||
type WinstonLogLevel = 'warning' | 'debug' | 'info' | 'error';
|
||||
|
||||
const z2mToWinstonLevel = (level: Z2MLogLevel): WinstonLogLevel => level === 'warn' ? 'warning' : level;
|
||||
const winstonToZ2mLevel = (level: WinstonLogLevel): Z2MLogLevel => level === 'warning' ? 'warn' : level;
|
||||
|
||||
// Determine the log level.
|
||||
let level = settings.get().advanced.log_level;
|
||||
const z2mLevel = settings.get().advanced.log_level;
|
||||
const validLevels = ['info', 'error', 'warn', 'debug'];
|
||||
assert(validLevels.includes(level), `'${level}' is not a valid log_level, use one of '${validLevels.join(', ')}'`);
|
||||
level = z2mToWinstonLevel(level);
|
||||
assert(validLevels.includes(z2mLevel), `'${z2mLevel}' is not valid log_level, use one of '${validLevels.join(', ')}'`);
|
||||
const level = z2mToWinstonLevel(z2mLevel);
|
||||
|
||||
const levelWithCompensatedLength = {
|
||||
const levelWithCompensatedLength: {[s: string]: string} = {
|
||||
'info': 'info ',
|
||||
'error': 'error',
|
||||
'warn': 'warn ',
|
||||
'debug': 'debug',
|
||||
};
|
||||
|
||||
/* istanbul ignore next */
|
||||
const timestampFormat = () => moment().format(settings.get().advanced.timestamp_format);
|
||||
const timestampFormat = (): string => moment().format(settings.get().advanced.timestamp_format);
|
||||
|
||||
// Setup default console logger
|
||||
const transportsToUse = [
|
||||
const transportsToUse: winston.transport[] = [
|
||||
new winston.transports.Console({
|
||||
level,
|
||||
silent: !output.includes('console'),
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({format: timestampFormat}),
|
||||
winston.format.printf(/* istanbul ignore next */(info) => {
|
||||
let {timestamp, level, message} = info;
|
||||
level = winstonToZ2mLevel(level);
|
||||
const prefix = colorizer.colorize(level, `Zigbee2MQTT:${levelWithCompensatedLength[level]}`);
|
||||
const {timestamp, level, message} = info;
|
||||
const l = winstonToZ2mLevel(level as WinstonLogLevel);
|
||||
const prefix = colorizer.colorize(l, `Zigbee2MQTT:${levelWithCompensatedLength[l]}`);
|
||||
return `${prefix} ${timestamp.split('.')[0]}: ${message}`;
|
||||
}),
|
||||
),
|
||||
@@ -68,16 +71,16 @@ const transportsToUse = [
|
||||
|
||||
// Add file logger when enabled
|
||||
// NOTE: the initiation of the logger, even when not added as transport tries to create the logging directory
|
||||
const transportFileOptions = {
|
||||
const transportFileOptions: KeyValue = {
|
||||
filename: path.join(directory, logFilename),
|
||||
json: false,
|
||||
level,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({format: timestampFormat}),
|
||||
winston.format.printf(/* istanbul ignore next */(info) => {
|
||||
let {timestamp, level, message} = info;
|
||||
level = winstonToZ2mLevel(level);
|
||||
return `${levelWithCompensatedLength[level]} ${timestamp.split('.')[0]}: ${message}`;
|
||||
const {timestamp, level, message} = info;
|
||||
const l = winstonToZ2mLevel(level as WinstonLogLevel);
|
||||
return `${levelWithCompensatedLength[l]} ${timestamp.split('.')[0]}: ${message}`;
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -94,8 +97,9 @@ if (output.includes('file')) {
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (output.includes('syslog')) {
|
||||
// eslint-disable-next-line
|
||||
require('winston-syslog').Syslog;
|
||||
const options = {
|
||||
const options: KeyValue = {
|
||||
app_name: 'Zigbee2MQTT',
|
||||
format: winston.format.printf(/* istanbul ignore next */(info) => {
|
||||
return `${info.message}`;
|
||||
@@ -103,6 +107,7 @@ if (output.includes('syslog')) {
|
||||
...settings.get().advanced.log_syslog,
|
||||
};
|
||||
if (options.hasOwnProperty('type')) options.type = options.type.toString();
|
||||
// @ts-ignore
|
||||
transportsToUse.push(new winston.transports.Syslog(options));
|
||||
}
|
||||
|
||||
@@ -110,7 +115,7 @@ if (output.includes('syslog')) {
|
||||
const logger = winston.createLogger({transports: transportsToUse, levels: winston.config.syslog.levels});
|
||||
|
||||
// Cleanup any old log directory.
|
||||
function cleanup() {
|
||||
function cleanup(): void {
|
||||
if (settings.get().advanced.log_directory.includes('%TIMESTAMP%')) {
|
||||
const rootDirectory = path.join(directory, '..');
|
||||
|
||||
@@ -119,7 +124,7 @@ function cleanup() {
|
||||
return {path: d, birth: fs.statSync(d).mtime};
|
||||
});
|
||||
|
||||
directories.sort((a, b) => b.birth - a.birth);
|
||||
directories.sort((a: KeyValue, b: KeyValue) => b.birth - a.birth);
|
||||
directories = directories.slice(10, directories.length);
|
||||
directories.forEach((dir) => {
|
||||
logger.debug(`Removing old log directory '${dir.path}'`);
|
||||
@@ -129,33 +134,53 @@ function cleanup() {
|
||||
}
|
||||
|
||||
// Print to user what logging is enabled
|
||||
function logOutput() {
|
||||
function logOutput(): void {
|
||||
if (output.includes('file')) {
|
||||
if (output.includes('console')) {
|
||||
logger.info(`Logging to console and directory: '${directory}' filename: ${logFilename}`);
|
||||
} else {
|
||||
logger.info(`Logging to directory: '${directory}' filename: ${logFilename}`);
|
||||
}
|
||||
logger.cleanup();
|
||||
cleanup();
|
||||
} else if (output.includes('console')) {
|
||||
logger.info(`Logging to console only'`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.addTransport = (transport) => {
|
||||
function addTransport(transport: winston.transport): void {
|
||||
transport.level = transportsToUse[0].level;
|
||||
logger.add(transport);
|
||||
};
|
||||
logger.cleanup = cleanup;
|
||||
logger.logOutput = logOutput;
|
||||
logger.getLevel = () => winstonToZ2mLevel(transportsToUse[0].level);
|
||||
logger.setLevel = (level) => {
|
||||
level = z2mToWinstonLevel(level);
|
||||
logger.transports.forEach((transport) => transport.level = level);
|
||||
};
|
||||
}
|
||||
|
||||
// winston.config.syslog.levels doesnt have warn, but is required for syslog.
|
||||
/* istanbul ignore next */
|
||||
logger.warn = (message) => logger.warning(message);
|
||||
function getLevel(): Z2MLogLevel {
|
||||
return winstonToZ2mLevel(transportsToUse[0].level as WinstonLogLevel);
|
||||
}
|
||||
|
||||
module.exports = logger;
|
||||
function setLevel(level: Z2MLogLevel): void {
|
||||
logger.transports.forEach((transport) => transport.level = z2mToWinstonLevel(level as Z2MLogLevel));
|
||||
}
|
||||
|
||||
function warn(message: string): void {
|
||||
// winston.config.syslog.levels doesnt have warn, but is required for syslog.
|
||||
logger.warning(message);
|
||||
}
|
||||
|
||||
function warning(message: string): void {
|
||||
logger.warning(message);
|
||||
}
|
||||
|
||||
function info(message: string): void {
|
||||
logger.info(message);
|
||||
}
|
||||
|
||||
function debug(message: string): void {
|
||||
logger.debug(message);
|
||||
}
|
||||
|
||||
function error(message: string): void {
|
||||
logger.error(message);
|
||||
}
|
||||
|
||||
export default {
|
||||
logOutput, warn, warning, error, info, debug, setLevel, getLevel, cleanup, addTransport, winston: logger,
|
||||
};
|
||||
@@ -26,6 +26,53 @@
|
||||
},
|
||||
"examples": ["DIYRuZ_FreePad.js"]
|
||||
},
|
||||
"availability": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"title": "Availability (boolean)"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Availability (object)",
|
||||
"properties": {
|
||||
"active": {
|
||||
"type": "object",
|
||||
"title": "Active",
|
||||
"requiresRestart": true,
|
||||
"description": "Options for active devices (routers/mains powered)",
|
||||
"properties": {
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"requiresRestart": true,
|
||||
"default": 10,
|
||||
"description": "Time after which an active device will be marked as offline in minutes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"passive": {
|
||||
"type": "object",
|
||||
"title": "Active",
|
||||
"requiresRestart": true,
|
||||
"description": "Options for passive devices (routers/mains powered)",
|
||||
"properties": {
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"requiresRestart": true,
|
||||
"default": 1500,
|
||||
"description": "Time after which an passive device will be marked as offline in minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"title": "Availability",
|
||||
"requiresRestart": true,
|
||||
"description": "Checks wether devices are online/offline"
|
||||
},
|
||||
"mqtt": {
|
||||
"type": "object",
|
||||
"title": "MQTT",
|
||||
@@ -365,50 +412,6 @@
|
||||
"description": "Add an elapsed attribute to MQTT messages, contains milliseconds since the previous msg",
|
||||
"default": false
|
||||
},
|
||||
"availability_timeout": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"requiresRestart": true,
|
||||
"title": "Availability Timeout",
|
||||
"description": "Availability timeout in seconds when enabled, devices will be checked if they are still online. Only AC powered routers are checked for availability"
|
||||
},
|
||||
"availability_blocklist": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"requiresRestart": true,
|
||||
"title": "Availability Blocklist",
|
||||
"description": "Prevent devices from being checked for availability"
|
||||
},
|
||||
"availability_passlist": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"requiresRestart": true,
|
||||
"title": "Availability passlist",
|
||||
"description": "Only enable availability check for certain devices"
|
||||
},
|
||||
"availability_blacklist": {
|
||||
"type": "array",
|
||||
"readOnly": true,
|
||||
"requiresRestart": true,
|
||||
"title": "Availability blacklist (deprecated, use availability_blocklist)",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"availability_whitelist": {
|
||||
"type": "array",
|
||||
"readOnly": true,
|
||||
"requiresRestart": true,
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Availability whitelist (deprecated, use passlist)"
|
||||
},
|
||||
"report": {
|
||||
"type": "boolean",
|
||||
"title": "Reporting",
|
||||
|
||||
+17
-36
@@ -2,7 +2,7 @@ import data from './data';
|
||||
import utils from './utils';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import path from 'path';
|
||||
import * as yaml from './yaml';
|
||||
import yaml from './yaml';
|
||||
import Ajv from 'ajv';
|
||||
import schemaJson from './settings.schema.json';
|
||||
export const schema = schemaJson;
|
||||
@@ -13,7 +13,7 @@ const ajvSetting = new Ajv({allErrors: true}).addKeyword('requiresRestart').comp
|
||||
const ajvRestartRequired = new Ajv({allErrors: true})
|
||||
.addKeyword({keyword: 'requiresRestart', validate: (schema: unknown) => !schema}).compile(schema);
|
||||
|
||||
const defaults: Settings = {
|
||||
const defaults: RecursivePartial<Settings> = {
|
||||
passlist: [],
|
||||
blocklist: [],
|
||||
// Deprecated: use block/passlist
|
||||
@@ -73,7 +73,6 @@ const defaults: Settings = {
|
||||
adapter_delay: null,
|
||||
|
||||
// Availability timeout in seconds, disabled by default.
|
||||
availability_timeout: 0,
|
||||
availability_blocklist: [],
|
||||
availability_passlist: [],
|
||||
// Deprecated, use block/passlist
|
||||
@@ -168,8 +167,7 @@ let _settingsWithDefaults: Settings;
|
||||
|
||||
function write(): void {
|
||||
const settings = getInternalSettings();
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
const toWrite = objectAssignDeep.noMutate(settings);
|
||||
const toWrite: KeyValue = objectAssignDeep({}, settings);
|
||||
|
||||
// Read settings to check if we have to split devices/groups into separate file.
|
||||
const actual = yaml.read(file);
|
||||
@@ -194,8 +192,7 @@ function write(): void {
|
||||
const writeDevicesOrGroups = (type: 'devices' | 'groups'): void => {
|
||||
if (typeof actual[type] === 'string' || Array.isArray(actual[type])) {
|
||||
const fileToWrite = Array.isArray(actual[type]) ? actual[type][0] : actual[type];
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
const content = objectAssignDeep.noMutate(settings[type]);
|
||||
const content = objectAssignDeep({}, settings[type]);
|
||||
|
||||
// If an array, only write to first file and only devices which are not in the other files.
|
||||
if (Array.isArray(actual[type])) {
|
||||
@@ -216,8 +213,7 @@ function write(): void {
|
||||
yaml.writeIfChanged(file, toWrite);
|
||||
|
||||
_settings = read();
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
_settingsWithDefaults = objectAssignDeep.noMutate(defaults, getInternalSettings());
|
||||
_settingsWithDefaults = objectAssignDeep({}, defaults, getInternalSettings()) as Settings;
|
||||
}
|
||||
|
||||
export function validate(): string[] {
|
||||
@@ -271,7 +267,7 @@ export function validate(): string[] {
|
||||
|
||||
const checkAvailabilityList = (list: string[], type: string): void => {
|
||||
list.forEach((e) => {
|
||||
if (!getEntity(e)) {
|
||||
if (!getDevice(e)) {
|
||||
errors.push(`Non-existing entity '${e}' specified in '${type}'`);
|
||||
}
|
||||
});
|
||||
@@ -391,8 +387,7 @@ function getInternalSettings(): Partial<Settings> {
|
||||
|
||||
export function get(): Settings {
|
||||
if (!_settingsWithDefaults) {
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
_settingsWithDefaults = objectAssignDeep.noMutate(defaults, getInternalSettings());
|
||||
_settingsWithDefaults = objectAssignDeep({}, defaults, getInternalSettings()) as Settings;
|
||||
}
|
||||
|
||||
if (!_settingsWithDefaults.devices) {
|
||||
@@ -406,7 +401,7 @@ export function get(): Settings {
|
||||
return _settingsWithDefaults;
|
||||
}
|
||||
|
||||
export function set(path: string[], value: string | number): void {
|
||||
export function set(path: string[], value: string | number | boolean | KeyValue): void {
|
||||
/* eslint-disable-next-line */
|
||||
let settings: any = getInternalSettings();
|
||||
|
||||
@@ -445,16 +440,16 @@ export function apply(newSettings: Record<string, unknown>): boolean {
|
||||
return restartRequired;
|
||||
}
|
||||
|
||||
export function getGroup(IDorName: string): GroupSettings {
|
||||
export function getGroup(IDorName: string | number): GroupSettings {
|
||||
const settings = get();
|
||||
const byID = settings.groups[IDorName];
|
||||
if (byID) {
|
||||
return {devices: [], ...byID, ID: Number(IDorName), friendlyName: byID.friendly_name};
|
||||
return {devices: [], ...byID, ID: Number(IDorName)};
|
||||
}
|
||||
|
||||
for (const [ID, group] of Object.entries(settings.groups)) {
|
||||
if (group.friendly_name === IDorName) {
|
||||
return {devices: [], ...group, ID: Number(ID), friendlyName: group.friendly_name};
|
||||
return {devices: [], ...group, ID: Number(ID)};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,7 +459,7 @@ export function getGroup(IDorName: string): GroupSettings {
|
||||
export function getGroups(): GroupSettings[] {
|
||||
const settings = get();
|
||||
return Object.entries(settings.groups).map(([ID, group]) => {
|
||||
return {devices: [], ...group, ID: Number(ID), friendlyName: group.friendly_name};
|
||||
return {devices: [], ...group, ID: Number(ID)};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -481,12 +476,12 @@ export function getDevice(IDorName: string): DeviceSettings {
|
||||
const settings = get();
|
||||
const byID = settings.devices[IDorName];
|
||||
if (byID) {
|
||||
return {...byID, ID: IDorName, friendlyName: byID.friendly_name};
|
||||
return {...byID, ID: IDorName};
|
||||
}
|
||||
|
||||
for (const [ID, device] of Object.entries(settings.devices)) {
|
||||
if (device.friendly_name === IDorName) {
|
||||
return {...device, ID, friendlyName: device.friendly_name};
|
||||
return {...device, ID};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,20 +497,6 @@ function getDeviceThrowIfNotExists(IDorName: string): DeviceSettings {
|
||||
return device;
|
||||
}
|
||||
|
||||
export function getEntity(IDorName: string): EntitySettings {
|
||||
const device = getDevice(IDorName);
|
||||
if (device) {
|
||||
return {...device, type: 'device'};
|
||||
}
|
||||
|
||||
const group = getGroup(IDorName);
|
||||
if (group) {
|
||||
return {...group, type: 'group'};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function addDevice(ID: string): DeviceSettings {
|
||||
if (getDevice(ID)) {
|
||||
throw new Error(`Device '${ID}' already exists`);
|
||||
@@ -574,7 +555,7 @@ export function removeDevice(IDorName: string): void {
|
||||
// Remove device from groups
|
||||
if (settings.groups) {
|
||||
const regex =
|
||||
new RegExp(`^(${device.friendlyName}|${device.ID})(/(\\d|${utils.getEndpointNames().join('|')}))?$`);
|
||||
new RegExp(`^(${device.friendly_name}|${device.ID})(/(\\d|${utils.endpointNames.join('|')}))?$`);
|
||||
for (const group of Object.values(settings.groups).filter((g) => g.devices)) {
|
||||
group.devices = group.devices.filter((device) => !device.match(regex));
|
||||
}
|
||||
@@ -649,8 +630,8 @@ export function removeDeviceFromGroup(IDorName: string, keys: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function removeGroup(IDorName: string): void {
|
||||
const groupID = getGroupThrowIfNotExists(IDorName).ID;
|
||||
export function removeGroup(IDorName: string | number): void {
|
||||
const groupID = getGroupThrowIfNotExists(IDorName.toString()).ID;
|
||||
const settings = getInternalSettings();
|
||||
delete settings.groups[groupID];
|
||||
write();
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
const equals = require('fast-deep-equal/es6');
|
||||
const humanizeDuration = require('humanize-duration');
|
||||
const data = require('./data');
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Xiaomi uses 4151 and 4447 (lumi.plug) as manufacturer ID.
|
||||
const xiaomiManufacturerID = [4151, 4447];
|
||||
const ikeaTradfriManufacturerID = [4476];
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import humanizeDuration from 'humanize-duration';
|
||||
import data from './data';
|
||||
import vm from 'vm';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// construct a local ISO8601 string (instead of UTC-based)
|
||||
// Example:
|
||||
// - ISO8601 (UTC) = 2019-03-01T15:32:45.941+0000
|
||||
// - ISO8601 (local) = 2019-03-01T16:32:45.941+0100 (for timezone GMT+1)
|
||||
function toLocalISOString(dDate) {
|
||||
const tzOffset = -dDate.getTimezoneOffset();
|
||||
function toLocalISOString(date: Date): string {
|
||||
const tzOffset = -date.getTimezoneOffset();
|
||||
const plusOrMinus = tzOffset >= 0 ? '+' : '-';
|
||||
const pad = function(num) {
|
||||
const pad = (num: number): string => {
|
||||
const norm = Math.floor(Math.abs(num));
|
||||
return (norm < 10 ? '0' : '') + norm;
|
||||
};
|
||||
|
||||
return dDate.getFullYear() +
|
||||
'-' + pad(dDate.getMonth() + 1) +
|
||||
'-' + pad(dDate.getDate()) +
|
||||
'T' + pad(dDate.getHours()) +
|
||||
':' + pad(dDate.getMinutes()) +
|
||||
':' + pad(dDate.getSeconds()) +
|
||||
return date.getFullYear() +
|
||||
'-' + pad(date.getMonth() + 1) +
|
||||
'-' + pad(date.getDate()) +
|
||||
'T' + pad(date.getHours()) +
|
||||
':' + pad(date.getMinutes()) +
|
||||
':' + pad(date.getSeconds()) +
|
||||
plusOrMinus + pad(tzOffset / 60) +
|
||||
':' + pad(tzOffset % 60);
|
||||
}
|
||||
@@ -34,7 +30,7 @@ function toLocalISOString(dDate) {
|
||||
const endpointNames = [
|
||||
'left', 'right', 'center', 'bottom_left', 'bottom_right', 'default',
|
||||
'top_left', 'top_right', 'white', 'rgb', 'cct', 'system', 'top', 'bottom', 'center_left', 'center_right',
|
||||
'ep1', 'ep2', 'row_1', 'row_2', 'row_3', 'row_4', 'relay',
|
||||
'ep1', 'ep2', 'row_1', 'row_2', 'row_3', 'row_4', 'relay', 'usb',
|
||||
'l1', 'l2', 'l3', 'l4', 'l5', 'l6', 'l7', 'l8',
|
||||
'l9', 'l10', 'l11', 'l12', 'l13', 'l14', 'l15', 'l16',
|
||||
'button_1', 'button_2', 'button_3', 'button_4', 'button_5',
|
||||
@@ -45,17 +41,22 @@ const endpointNames = [
|
||||
'heat', 'cool', 'water', 'meter', 'wifi',
|
||||
];
|
||||
|
||||
function capitalize(s) {
|
||||
function capitalize(s: string): string {
|
||||
return s[0].toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
async function getZigbee2mqttVersion() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const git = require('git-last-commit');
|
||||
const packageJSON = require('../..' + '/package.json');
|
||||
async function getZigbee2MQTTVersion(includeCommitHash=true): Promise<{commitHash: string, version: string}> {
|
||||
const git = await import('git-last-commit');
|
||||
const packageJSON = await import('../..' + '/package.json');
|
||||
|
||||
if (!includeCommitHash) {
|
||||
return {version: packageJSON.version, commitHash: null};
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const version = packageJSON.version;
|
||||
|
||||
git.getLastCommit((err, commit) => {
|
||||
git.getLastCommit((err: Error, commit: {shortHash: string}) => {
|
||||
let commitHash = null;
|
||||
|
||||
if (err) {
|
||||
@@ -74,39 +75,22 @@ async function getZigbee2mqttVersion() {
|
||||
});
|
||||
}
|
||||
|
||||
async function getDependencyVersion(depend) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const packageJSON = require(path.join(__dirname, '..', '..', 'node_modules', depend, 'package.json'));
|
||||
const version = packageJSON.version;
|
||||
resolve({version});
|
||||
});
|
||||
async function getDependencyVersion(depend: string): Promise<{version: string}> {
|
||||
const packageJSON = await import(path.join(__dirname, '..', '..', 'node_modules', depend, 'package.json'));
|
||||
const version = packageJSON.version;
|
||||
return {version};
|
||||
}
|
||||
|
||||
function formatDate(date, type) {
|
||||
let result;
|
||||
|
||||
switch (type) {
|
||||
case 'ISO_8601':
|
||||
result = new Date(date).toISOString();
|
||||
break;
|
||||
case 'ISO_8601_local':
|
||||
result = toLocalISOString(new Date(date));
|
||||
break;
|
||||
case 'epoch':
|
||||
result = date;
|
||||
break;
|
||||
case 'relative':
|
||||
// https://github.com/EvanHahn/HumanizeDuration.js#options
|
||||
result = humanizeDuration(Date.now() - date, {language: 'en', largest: 2, round: true}) + ' ago';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported type '${type}'`);
|
||||
function formatDate(time: number, type: 'ISO_8601' | 'ISO_8601_local' | 'epoch' | 'relative'): string | number {
|
||||
if (type === 'ISO_8601') return new Date(time).toISOString();
|
||||
else if (type === 'ISO_8601_local') return toLocalISOString(new Date(time));
|
||||
else if (type === 'epoch') return time;
|
||||
else { // relative
|
||||
return humanizeDuration(Date.now() - time, {language: 'en', largest: 2, round: true}) + ' ago';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function objectHasProperties(object, properties) {
|
||||
function objectHasProperties(object: {[s: string]: unknown}, properties: string[]): boolean {
|
||||
for (const property of properties) {
|
||||
if (!object.hasOwnProperty(property)) {
|
||||
return false;
|
||||
@@ -116,7 +100,7 @@ function objectHasProperties(object, properties) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function equalsPartial(object, expected) {
|
||||
function equalsPartial(object: KeyValue, expected: KeyValue): boolean {
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
if (!equals(object[key], value)) {
|
||||
return false;
|
||||
@@ -126,12 +110,12 @@ function equalsPartial(object, expected) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function getObjectProperty(object, key, defaultValue) {
|
||||
function getObjectProperty(object: KeyValue, key: string, defaultValue: unknown): unknown {
|
||||
return object && object.hasOwnProperty(key) ? object[key] : defaultValue;
|
||||
}
|
||||
|
||||
function getResponse(request, data, error) {
|
||||
const response = {data, status: error ? 'error' : 'ok'};
|
||||
function getResponse(request: KeyValue | string, data: KeyValue, error: string): MQTTResponse {
|
||||
const response: MQTTResponse = {data, status: error ? 'error' : 'ok'};
|
||||
if (error) response.error = error;
|
||||
if (typeof request === 'object' && request.hasOwnProperty('transaction')) {
|
||||
response.transaction = request.transaction;
|
||||
@@ -139,15 +123,15 @@ function getResponse(request, data, error) {
|
||||
return response;
|
||||
}
|
||||
|
||||
function parseJSON(value, failedReturnValue) {
|
||||
function parseJSON(value: string, fallback: string): KeyValue | string {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return failedReturnValue;
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function loadModuleFromText(moduleCode) {
|
||||
function loadModuleFromText(moduleCode: string): unknown {
|
||||
const moduleFakePath = path.join(__dirname, 'externally-loaded.js');
|
||||
const sandbox = {
|
||||
require: require,
|
||||
@@ -161,16 +145,17 @@ function loadModuleFromText(moduleCode) {
|
||||
clearImmediate,
|
||||
};
|
||||
vm.runInNewContext(moduleCode, sandbox, moduleFakePath);
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
return sandbox.module.exports;
|
||||
}
|
||||
|
||||
function loadModuleFromFile(modulePath) {
|
||||
function loadModuleFromFile(modulePath: string): unknown {
|
||||
const moduleCode = fs.readFileSync(modulePath, {encoding: 'utf8'});
|
||||
return loadModuleFromText(moduleCode);
|
||||
}
|
||||
|
||||
function* getExternalConvertersDefinitions(settings) {
|
||||
const externalConverters = settings.get().external_converters;
|
||||
function* getExternalConvertersDefinitions(settings: Settings): Generator<zhc.ExternalDefinition> {
|
||||
const externalConverters = settings.external_converters;
|
||||
|
||||
for (const moduleName of externalConverters) {
|
||||
let converter;
|
||||
@@ -191,7 +176,7 @@ function* getExternalConvertersDefinitions(settings) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeNullPropertiesFromObject(obj) {
|
||||
function removeNullPropertiesFromObject(obj: KeyValue): void {
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = obj[key];
|
||||
if (value == null) {
|
||||
@@ -202,22 +187,13 @@ function removeNullPropertiesFromObject(obj) {
|
||||
}
|
||||
}
|
||||
|
||||
function getKey(object, value, fallback, convertTo) {
|
||||
for (const key in object) {
|
||||
if (object[key]===value) {
|
||||
return convertTo ? convertTo(key) : key;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function toNetworkAddressHex(value) {
|
||||
function toNetworkAddressHex(value: number): string {
|
||||
const hex = value.toString(16);
|
||||
return `0x${'0'.repeat(4 - hex.length)}${hex}`;
|
||||
}
|
||||
|
||||
function toSnakeCase(value) {
|
||||
// eslint-disable-next-line
|
||||
function toSnakeCase(value: string | KeyValue): any {
|
||||
if (typeof value === 'object') {
|
||||
value = {...value};
|
||||
for (const key of Object.keys(value)) {
|
||||
@@ -233,7 +209,7 @@ function toSnakeCase(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateFriendlyName(name, throwFirstError=false) {
|
||||
function validateFriendlyName(name: string, throwFirstError=false): string[] {
|
||||
const errors = [];
|
||||
for (const endpointName of endpointNames) {
|
||||
if (name.toLowerCase().endsWith('/' + endpointName)) {
|
||||
@@ -256,43 +232,59 @@ function validateFriendlyName(name, throwFirstError=false) {
|
||||
|
||||
return errors;
|
||||
}
|
||||
function sleep(seconds) {
|
||||
|
||||
function sleep(seconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
||||
}
|
||||
|
||||
function sanitizeImageParameter(parameter) {
|
||||
function sanitizeImageParameter(parameter: string): string {
|
||||
const replaceByDash = [/\?/g, /&/g, /[^a-z\d\- _./:]/gi];
|
||||
let sanitized = parameter;
|
||||
replaceByDash.forEach((r) => sanitized = sanitized.replace(r, '-'));
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
millisecondsToSeconds: (milliseconds) => milliseconds / 1000,
|
||||
secondsToMilliseconds: (seconds) => seconds * 1000,
|
||||
getZigbee2mqttVersion,
|
||||
getDependencyVersion,
|
||||
objectHasProperties,
|
||||
toSnakeCase,
|
||||
sleep,
|
||||
getObjectProperty,
|
||||
getEndpointNames: () => endpointNames,
|
||||
isXiaomiDevice: (device) => {
|
||||
return device.modelID !== 'lumi.router' && xiaomiManufacturerID.includes(device.manufacturerID) &&
|
||||
(!device.manufacturerName || !device.manufacturerName.startsWith('Trust'));
|
||||
},
|
||||
isIkeaTradfriDevice: (device) => ikeaTradfriManufacturerID.includes(device.manufacturerID),
|
||||
formatDate: (date, type) => formatDate(date, type),
|
||||
equalsPartial,
|
||||
getResponse,
|
||||
capitalize,
|
||||
toNetworkAddressHex,
|
||||
parseJSON,
|
||||
getExternalConvertersDefinitions,
|
||||
validateFriendlyName,
|
||||
loadModuleFromFile,
|
||||
loadModuleFromText,
|
||||
getKey,
|
||||
sanitizeImageParameter,
|
||||
removeNullPropertiesFromObject,
|
||||
function isAvailabilityEnabledForDevice(device: Device, settings: Settings): boolean {
|
||||
if (device.settings.hasOwnProperty('availability')) {
|
||||
return !!device.settings.availability;
|
||||
}
|
||||
|
||||
// availability_timeout = deprecated
|
||||
const enabledGlobal = settings.advanced.availability_timeout || settings.availability;
|
||||
if (!enabledGlobal) return false;
|
||||
|
||||
const passlist = settings.advanced.availability_passlist.concat(settings.advanced.availability_whitelist);
|
||||
if (passlist.length > 0) {
|
||||
return passlist.includes(device.name) || passlist.includes(device.ieeeAddr);
|
||||
}
|
||||
|
||||
const blocklist = settings.advanced.availability_blacklist.concat(settings.advanced.availability_blocklist);
|
||||
return !blocklist.includes(device.name) && !blocklist.includes(device.ieeeAddr);
|
||||
}
|
||||
|
||||
const entityIDRegex = new RegExp(`^(.+?)(?:/(${endpointNames.join('|')}|\\d+))?$`);
|
||||
function parseEntityID(ID: string): {ID: string, endpoint: string} {
|
||||
const match = ID.match(entityIDRegex);
|
||||
return match && {ID: match[1], endpoint: match[2]};
|
||||
}
|
||||
|
||||
function isEndpoint(obj: unknown): obj is zh.Endpoint {
|
||||
return obj.constructor.name.toLowerCase() === 'endpoint';
|
||||
}
|
||||
|
||||
function isZHGroup(obj: unknown): obj is zh.Group {
|
||||
return obj.constructor.name.toLowerCase() === 'group';
|
||||
}
|
||||
|
||||
const hours = (hours: number): number => 1000 * 60 * 60 * hours;
|
||||
const minutes = (minutes: number): number => 1000 * 60 * minutes;
|
||||
const seconds = (seconds: number): number => 1000 * seconds;
|
||||
|
||||
|
||||
export default {
|
||||
endpointNames, capitalize, getZigbee2MQTTVersion, getDependencyVersion, formatDate, objectHasProperties,
|
||||
equalsPartial, getObjectProperty, getResponse, parseJSON, loadModuleFromText, loadModuleFromFile,
|
||||
getExternalConvertersDefinitions, removeNullPropertiesFromObject, toNetworkAddressHex, toSnakeCase,
|
||||
parseEntityID, isEndpoint, isZHGroup, hours, minutes, seconds, validateFriendlyName, sleep,
|
||||
sanitizeImageParameter, isAvailabilityEnabledForDevice,
|
||||
};
|
||||
+6
-6
@@ -1,11 +1,9 @@
|
||||
import yaml from 'js-yaml';
|
||||
import fs from 'fs';
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import 'source-map-support/register';
|
||||
|
||||
export function read(file: string): KeyValue {
|
||||
function read(file: string): KeyValue {
|
||||
try {
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
return yaml.load(fs.readFileSync(file, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.name === 'YAMLException') {
|
||||
@@ -16,21 +14,23 @@ export function read(file: string): KeyValue {
|
||||
}
|
||||
}
|
||||
|
||||
export function readIfExists(file: string, default_?: KeyValue): KeyValue {
|
||||
function readIfExists(file: string, default_?: KeyValue): KeyValue {
|
||||
return fs.existsSync(file) ? read(file) : default_;
|
||||
}
|
||||
|
||||
export function writeIfChanged(file: string, content: KeyValue): void {
|
||||
function writeIfChanged(file: string, content: KeyValue): void {
|
||||
const before = readIfExists(file);
|
||||
if (!equals(before, content)) {
|
||||
fs.writeFileSync(file, yaml.dump(content));
|
||||
}
|
||||
}
|
||||
|
||||
export function updateIfChanged(file: string, key: string, value: KeyValue): void {
|
||||
function updateIfChanged(file: string, key: string, value: KeyValue): void {
|
||||
const content = read(file);
|
||||
if (content[key] !== value) {
|
||||
content[key] = value;
|
||||
writeIfChanged(file, content);
|
||||
}
|
||||
}
|
||||
|
||||
export default {read, readIfExists, updateIfChanged, writeIfChanged};
|
||||
|
||||
-351
@@ -1,351 +0,0 @@
|
||||
const ZigbeeHerdsman = require('zigbee-herdsman');
|
||||
const logger = require('./util/logger');
|
||||
const settings = require('./util/settings');
|
||||
const data = require('./util/data');
|
||||
const assert = require('assert');
|
||||
const utils = require('./util/utils');
|
||||
const events = require('events');
|
||||
const objectAssignDeep = require('object-assign-deep');
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
const endpointNames = utils.getEndpointNames();
|
||||
const keyEndpointByNumber = new RegExp(`.*/([0-9]*)$`);
|
||||
|
||||
class Zigbee extends events.EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.acceptJoiningDeviceHandler = this.acceptJoiningDeviceHandler.bind(this);
|
||||
}
|
||||
|
||||
async start() {
|
||||
const infoHerdsman = await utils.getDependencyVersion('zigbee-herdsman');
|
||||
logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`);
|
||||
const herdsmanSettings = {
|
||||
network: {
|
||||
panID: settings.get().advanced.pan_id,
|
||||
extendedPanID: settings.get().advanced.ext_pan_id,
|
||||
channelList: [settings.get().advanced.channel],
|
||||
networkKey: settings.get().advanced.network_key,
|
||||
},
|
||||
databasePath: data.joinPath('database.db'),
|
||||
databaseBackupPath: data.joinPath('database.db.backup'),
|
||||
backupPath: data.joinPath('coordinator_backup.json'),
|
||||
serialPort: {
|
||||
baudRate: settings.get().advanced.baudrate,
|
||||
rtscts: settings.get().advanced.rtscts,
|
||||
path: settings.get().serial.port,
|
||||
adapter: settings.get().serial.adapter,
|
||||
},
|
||||
adapter: {
|
||||
concurrent: settings.get().advanced.adapter_concurrent,
|
||||
delay: settings.get().advanced.adapter_delay,
|
||||
disableLED: settings.get().serial.disable_led,
|
||||
},
|
||||
};
|
||||
|
||||
const herdsmanSettingsLog = objectAssignDeep.noMutate(herdsmanSettings);
|
||||
herdsmanSettingsLog.network.networkKey = 'HIDDEN';
|
||||
logger.debug(`Using zigbee-herdsman with settings: '${stringify(herdsmanSettingsLog)}'`);
|
||||
|
||||
if (herdsmanSettings.network.networkKey === 'GENERATE') {
|
||||
const newKey = Array.from({length: 16}, () => Math.floor(Math.random() * 255));
|
||||
settings.set(['advanced', 'network_key'], newKey);
|
||||
herdsmanSettings.network.networkKey = newKey;
|
||||
}
|
||||
|
||||
if (herdsmanSettings.network.panID === 'GENERATE') {
|
||||
const newPanID = Math.floor(Math.random() * (0xFFFF - 2)) + 1;
|
||||
settings.set(['advanced', 'pan_id'], newPanID);
|
||||
herdsmanSettings.network.panID = newPanID;
|
||||
}
|
||||
|
||||
let startResult;
|
||||
try {
|
||||
herdsmanSettings.acceptJoiningDeviceHandler = this.acceptJoiningDeviceHandler;
|
||||
this.herdsman = new ZigbeeHerdsman.Controller(herdsmanSettings, logger);
|
||||
startResult = await this.herdsman.start();
|
||||
} catch (error) {
|
||||
logger.error(`Error while starting zigbee-herdsman`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.herdsman.on('adapterDisconnected', () => this.emit('adapterDisconnected'));
|
||||
this.herdsman.on('deviceNetworkAddressChanged', (data) =>
|
||||
this.emit('event', 'deviceNetworkAddressChanged', data));
|
||||
this.herdsman.on('deviceAnnounce', (data) => this.emit('event', 'deviceAnnounce', data));
|
||||
this.herdsman.on('deviceInterview', (data) => this.emit('event', 'deviceInterview', data));
|
||||
this.herdsman.on('deviceJoined', (data) => this.emit('event', 'deviceJoined', data));
|
||||
this.herdsman.on('deviceLeave', (data) => this.emit('event', 'deviceLeave', data));
|
||||
this.herdsman.on('message', (data) => this.emit('event', 'message', data));
|
||||
this.herdsman.on('permitJoinChanged', (data) => this.emit('permitJoinChanged', data));
|
||||
|
||||
logger.info(`zigbee-herdsman started (${startResult})`);
|
||||
logger.info(`Coordinator firmware version: '${stringify(await this.getCoordinatorVersion())}'`);
|
||||
logger.debug(`Zigbee network parameters: ${stringify(await this.herdsman.getNetworkParameters())}`);
|
||||
|
||||
for (const device of this.getClients()) {
|
||||
// If a passlist is used, all other device will be removed from the network.
|
||||
const passlist = settings.get().passlist.concat(settings.get().whitelist);
|
||||
const blocklist = settings.get().blocklist.concat(settings.get().ban);
|
||||
if (passlist.length > 0) {
|
||||
if (!passlist.includes(device.ieeeAddr)) {
|
||||
logger.warn(`Device which is not on passlist connected (${device.ieeeAddr}), removing...`);
|
||||
device.removeFromNetwork();
|
||||
}
|
||||
} else if (blocklist.includes(device.ieeeAddr)) {
|
||||
logger.warn(`Device on blocklist is connected (${device.ieeeAddr}), removing...`);
|
||||
device.removeFromNetwork();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have to turn off the led
|
||||
if (settings.get().serial.disable_led) {
|
||||
await this.herdsman.setLED(false);
|
||||
}
|
||||
|
||||
// Check if we have to set a transmit power
|
||||
if (settings.get().experimental.hasOwnProperty('transmit_power')) {
|
||||
const transmitPower = settings.get().experimental.transmit_power;
|
||||
await this.herdsman.setTransmitPower(transmitPower);
|
||||
logger.info(`Set transmit power to '${transmitPower}'`);
|
||||
}
|
||||
|
||||
return startResult;
|
||||
}
|
||||
|
||||
async getCoordinatorVersion() {
|
||||
return this.herdsman.getCoordinatorVersion();
|
||||
}
|
||||
|
||||
isStopping() {
|
||||
return this.herdsman.isStopping();
|
||||
}
|
||||
|
||||
async getNetworkParameters() {
|
||||
return this.herdsman.getNetworkParameters();
|
||||
}
|
||||
|
||||
async reset(type) {
|
||||
await this.herdsman.reset(type);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
logger.info('Stopping zigbee-herdsman...');
|
||||
await this.herdsman.stop();
|
||||
logger.info('Stopped zigbee-herdsman');
|
||||
}
|
||||
|
||||
async permitJoin(permit, resolvedEntity, time=undefined) {
|
||||
permit ?
|
||||
logger.info(`Zigbee: allowing new devices to join${resolvedEntity ? ` via ${resolvedEntity.name}` : ''}.`) :
|
||||
logger.info('Zigbee: disabling joining new devices.');
|
||||
|
||||
if (resolvedEntity && permit) {
|
||||
await this.herdsman.permitJoin(permit, resolvedEntity.device, time);
|
||||
} else {
|
||||
await this.herdsman.permitJoin(permit, undefined, time);
|
||||
}
|
||||
}
|
||||
|
||||
getPermitJoin() {
|
||||
return this.herdsman.getPermitJoin();
|
||||
}
|
||||
|
||||
getPermitJoinTimeout() {
|
||||
return this.herdsman.getPermitJoinTimeout();
|
||||
}
|
||||
|
||||
getClients() {
|
||||
return this.herdsman.getDevices().filter((device) => device.type !== 'Coordinator');
|
||||
}
|
||||
|
||||
getDevices() {
|
||||
return this.herdsman.getDevices();
|
||||
}
|
||||
|
||||
getDeviceByIeeeAddr(ieeeAddr) {
|
||||
return this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
|
||||
}
|
||||
|
||||
getDeviceByNetworkAddress(networkAddress) {
|
||||
return this.herdsman.getDeviceByNetworkAddress(networkAddress);
|
||||
}
|
||||
|
||||
getDevicesByType(type) {
|
||||
return this.herdsman.getDevicesByType(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @return {object} {
|
||||
* type: device | coordinator
|
||||
* device|group: zigbee-herdsman entity
|
||||
* endpoint: selected endpoint (only if type === device)
|
||||
* settings: from configuration.yaml
|
||||
* name: name of the entity
|
||||
* definition: zigbee-herdsman-converters definition (only if type === device)
|
||||
* }
|
||||
*/
|
||||
resolveEntity(key) {
|
||||
assert(
|
||||
typeof key === 'string' || typeof key === 'number' ||
|
||||
key.constructor.name === 'Device' || key.constructor.name === 'Group' ||
|
||||
key.constructor.name === 'Endpoint',
|
||||
`Wrong type '${typeof key}'`,
|
||||
);
|
||||
|
||||
const getEndpointName = (endpointNames, endpoint) => {
|
||||
return endpoint ?
|
||||
utils.getKey(endpointNames, endpoint.ID, null, ((v) => v === 'default' ? null : v)) : null;
|
||||
};
|
||||
|
||||
const deviceOptions = settings.get().device_options;
|
||||
if (typeof key === 'string' || typeof key === 'number') {
|
||||
if (typeof key === 'number') {
|
||||
key = key.toString();
|
||||
}
|
||||
|
||||
if (typeof key === 'string' && key.toLowerCase() === 'coordinator') {
|
||||
const coordinator = this.getDevicesByType('Coordinator')[0];
|
||||
return {
|
||||
type: 'device',
|
||||
device: coordinator,
|
||||
endpoint: coordinator.getEndpoint(1),
|
||||
settings: {friendlyName: 'Coordinator'},
|
||||
name: 'Coordinator',
|
||||
endpointName: null,
|
||||
};
|
||||
}
|
||||
|
||||
let endpointKey = endpointNames.find((p) => key.endsWith(`/${p}`));
|
||||
const endpointByNumber = key.match(keyEndpointByNumber);
|
||||
if (!endpointKey && endpointByNumber) {
|
||||
endpointKey = Number(endpointByNumber[1]);
|
||||
}
|
||||
if (endpointKey) {
|
||||
key = key.replace(`/${endpointKey}`, '');
|
||||
}
|
||||
|
||||
const entity = settings.getEntity(key);
|
||||
if (!entity) {
|
||||
return null;
|
||||
} else if (entity.type === 'device') {
|
||||
const device = this.getDeviceByIeeeAddr(entity.ID);
|
||||
if (!device) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definition = zigbeeHerdsmanConverters.findByDevice(device);
|
||||
const endpointNames = definition && definition.endpoint ? definition.endpoint(device) : null;
|
||||
let endpoint;
|
||||
if (endpointKey) {
|
||||
if (endpointByNumber) {
|
||||
endpoint = device.getEndpoint(endpointKey);
|
||||
} else {
|
||||
assert(definition != null, `Endpoint name '${endpointKey}' is given but device is unsupported`);
|
||||
assert(endpointNames != null,
|
||||
`Endpoint name '${endpointKey}' is given but no endpoints defined`);
|
||||
const endpointID = endpointNames[endpointKey];
|
||||
assert(endpointID, `Endpoint name '${endpointKey}' is given but device has no such endpoint`);
|
||||
endpoint = device.getEndpoint(endpointID);
|
||||
}
|
||||
} else if (endpointNames && endpointNames['default']) {
|
||||
endpoint = device.getEndpoint(endpointNames['default']);
|
||||
} else {
|
||||
endpoint = device.endpoints[0];
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'device', device, endpoint, settings: {...deviceOptions, ...entity},
|
||||
name: entity.friendlyName, definition,
|
||||
endpointName: getEndpointName(endpointNames, endpoint),
|
||||
};
|
||||
} else {
|
||||
let group = this.getGroupByID(entity.ID);
|
||||
if (!group) group = this.createGroup(entity.ID);
|
||||
return {type: 'group', group, settings: {...deviceOptions, ...entity}, name: entity.friendlyName};
|
||||
}
|
||||
} else if (key.constructor.name === 'Device' || key.constructor.name === 'Endpoint') {
|
||||
const device = key.constructor.name === 'Endpoint' ? key.getDevice() : key;
|
||||
const setting = settings.getEntity(device.ieeeAddr);
|
||||
const definition = zigbeeHerdsmanConverters.findByDevice(device);
|
||||
const name = setting ? setting.friendlyName :
|
||||
(device.type === 'Coordinator' ? 'Coordinator' : device.ieeeAddr);
|
||||
const endpointNames = definition && definition.endpoint ? definition.endpoint(device) : null;
|
||||
|
||||
let endpoint;
|
||||
if (key.constructor.name === 'Endpoint') endpoint = key;
|
||||
else if (endpointNames && endpointNames['default']) endpoint = device.getEndpoint(endpointNames['default']);
|
||||
else endpoint = device.endpoints[0];
|
||||
|
||||
return {
|
||||
type: 'device', definition, name, device, endpoint, settings: {...deviceOptions, ...(setting || {})},
|
||||
endpointName: getEndpointName(endpointNames, endpoint),
|
||||
};
|
||||
} else { // Group
|
||||
const setting = settings.getEntity(key.groupID);
|
||||
return {
|
||||
type: 'group',
|
||||
group: key,
|
||||
settings: {...deviceOptions, ...(setting || {})},
|
||||
name: setting ? setting.friendlyName : key.groupID,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getGroupByID(ID) {
|
||||
return this.herdsman.getGroupByID(ID);
|
||||
}
|
||||
|
||||
getGroups() {
|
||||
return this.herdsman.getGroups();
|
||||
}
|
||||
|
||||
createGroup(groupID) {
|
||||
return this.herdsman.createGroup(groupID);
|
||||
}
|
||||
|
||||
acceptJoiningDeviceHandler(ieeeAddr) {
|
||||
// If passlist is set, all devices not on passlist will be rejected to join the network
|
||||
const passlist = settings.get().passlist.concat(settings.get().whitelist);
|
||||
const blocklist = settings.get().blocklist.concat(settings.get().ban);
|
||||
if (passlist.length > 0) {
|
||||
if (passlist.includes(ieeeAddr)) {
|
||||
logger.info(`Accepting joining device which is on passlist '${ieeeAddr}'`);
|
||||
return true;
|
||||
} else {
|
||||
logger.info(`Rejecting joining not in passlist device '${ieeeAddr}'`);
|
||||
return false;
|
||||
}
|
||||
} else if (blocklist.length > 0) {
|
||||
if (blocklist.includes(ieeeAddr)) {
|
||||
logger.info(`Rejecting joining device which is on blocklist '${ieeeAddr}'`);
|
||||
return false;
|
||||
} else {
|
||||
logger.info(`Accepting joining not in blocklist device '${ieeeAddr}'`);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async touchlinkFactoryResetFirst() {
|
||||
return this.herdsman.touchlinkFactoryResetFirst();
|
||||
}
|
||||
|
||||
async touchlinkFactoryReset(ieeeAddr, channel) {
|
||||
return this.herdsman.touchlinkFactoryReset(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async touchlinkIdentify(ieeeAddr, channel) {
|
||||
await this.herdsman.touchlinkIdentify(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async touchlinkScan() {
|
||||
return this.herdsman.touchlinkScan();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Zigbee;
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
import {Controller} from 'zigbee-herdsman';
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import data from './util/data';
|
||||
import utils from './util/utils';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import Device from './model/device';
|
||||
import Group from './model/group';
|
||||
import * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
export default class Zigbee {
|
||||
private herdsman: Controller;
|
||||
private eventBus: EventBus;
|
||||
private groupLookup: {[s: number]: Group} = {};
|
||||
private deviceLookup: {[s: string]: Device} = {};
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
async start(): Promise<'reset' | 'resumed' | 'restored'> {
|
||||
const infoHerdsman = await utils.getDependencyVersion('zigbee-herdsman');
|
||||
logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`);
|
||||
const herdsmanSettings = {
|
||||
network: {
|
||||
panID: settings.get().advanced.pan_id === 'GENERATE' ?
|
||||
this.generatePanID() : settings.get().advanced.pan_id as number,
|
||||
extendedPanID: settings.get().advanced.ext_pan_id,
|
||||
channelList: [settings.get().advanced.channel],
|
||||
networkKey: settings.get().advanced.network_key === 'GENERATE' ?
|
||||
this.generateNetworkKey() : settings.get().advanced.network_key as number[],
|
||||
},
|
||||
databasePath: data.joinPath('database.db'),
|
||||
databaseBackupPath: data.joinPath('database.db.backup'),
|
||||
backupPath: data.joinPath('coordinator_backup.json'),
|
||||
serialPort: {
|
||||
baudRate: settings.get().advanced.baudrate,
|
||||
rtscts: settings.get().advanced.rtscts,
|
||||
path: settings.get().serial.port,
|
||||
adapter: settings.get().serial.adapter,
|
||||
},
|
||||
adapter: {
|
||||
concurrent: settings.get().advanced.adapter_concurrent,
|
||||
delay: settings.get().advanced.adapter_delay,
|
||||
disableLED: settings.get().serial.disable_led,
|
||||
},
|
||||
acceptJoiningDeviceHandler: this.acceptJoiningDeviceHandler,
|
||||
};
|
||||
|
||||
const herdsmanSettingsLog = objectAssignDeep({}, herdsmanSettings, {network: {networkKey: 'HIDDEN'}});
|
||||
logger.debug(`Using zigbee-herdsman with settings: '${stringify(herdsmanSettingsLog)}'`);
|
||||
|
||||
let startResult;
|
||||
try {
|
||||
this.herdsman = new Controller(herdsmanSettings, logger);
|
||||
startResult = await this.herdsman.start();
|
||||
} catch (error) {
|
||||
logger.error(`Error while starting zigbee-herdsman`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.herdsman.on('adapterDisconnected', () => this.eventBus.emitAdapterDisconnected());
|
||||
this.herdsman.on('lastSeenChanged', (data: ZHEvents.LastSeenChangedPayload) => {
|
||||
this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr)});
|
||||
});
|
||||
this.herdsman.on('permitJoinChanged', (data: ZHEvents.PermitJoinChangedPayload) => {
|
||||
this.eventBus.emitPermitJoinChanged(data);
|
||||
});
|
||||
this.herdsman.on('deviceNetworkAddressChanged', (data: ZHEvents.DeviceNetworkAddressChangedPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
logger.debug(`Device '${device.name}' changed network address`);
|
||||
this.eventBus.emitDeviceNetworkAddressChanged({device});
|
||||
});
|
||||
this.herdsman.on('deviceAnnounce', (data: ZHEvents.DeviceAnnouncePayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
logger.debug(`Device '${device.name}' announced itself`);
|
||||
this.eventBus.emitDeviceAnnounce({device});
|
||||
});
|
||||
this.herdsman.on('deviceInterview', (data: ZHEvents.DeviceInterviewPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
const d = {device, status: data.status};
|
||||
this.logDeviceInterview(d);
|
||||
this.eventBus.emitDeviceInterview(d);
|
||||
});
|
||||
this.herdsman.on('deviceJoined', (data: ZHEvents.DeviceJoinedPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
logger.info(`Device '${device.name}' joined`);
|
||||
this.eventBus.emitDeviceJoined({device});
|
||||
});
|
||||
this.herdsman.on('deviceLeave', (data: ZHEvents.DeviceLeavePayload) => {
|
||||
const name = settings.getDevice(data.ieeeAddr)?.friendly_name || data.ieeeAddr;
|
||||
logger.warn(`Device '${name}' left the network`);
|
||||
this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name});
|
||||
});
|
||||
this.herdsman.on('message', (data: ZHEvents.MessagePayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
logger.debug(`Received Zigbee message from '${device.name}', type '${data.type}', ` +
|
||||
`cluster '${data.cluster}', data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}` +
|
||||
(data.hasOwnProperty('groupID') ? ` with groupID ${data.groupID}` : ``));
|
||||
this.eventBus.emitDeviceMessage({...data, device});
|
||||
});
|
||||
|
||||
logger.info(`zigbee-herdsman started (${startResult})`);
|
||||
logger.info(`Coordinator firmware version: '${stringify(await this.getCoordinatorVersion())}'`);
|
||||
logger.debug(`Zigbee network parameters: ${stringify(await this.herdsman.getNetworkParameters())}`);
|
||||
|
||||
for (const device of this.devices(false)) {
|
||||
// If a passlist is used, all other device will be removed from the network.
|
||||
const passlist = settings.get().passlist.concat(settings.get().whitelist);
|
||||
const blocklist = settings.get().blocklist.concat(settings.get().ban);
|
||||
const remove = async (device: Device): Promise<void> => {
|
||||
try {
|
||||
await device.zh.removeFromNetwork();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove '${device.ieeeAddr}' (${error.message})`);
|
||||
}
|
||||
};
|
||||
if (passlist.length > 0) {
|
||||
if (!passlist.includes(device.ieeeAddr)) {
|
||||
logger.warn(`Device which is not on passlist connected (${device.ieeeAddr}), removing...`);
|
||||
await remove(device);
|
||||
}
|
||||
} else if (blocklist.includes(device.ieeeAddr)) {
|
||||
logger.warn(`Device on blocklist is connected (${device.ieeeAddr}), removing...`);
|
||||
await remove(device);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have to turn off the led
|
||||
if (settings.get().serial.disable_led) {
|
||||
await this.herdsman.setLED(false);
|
||||
}
|
||||
|
||||
// Check if we have to set a transmit power
|
||||
if (settings.get().experimental.hasOwnProperty('transmit_power')) {
|
||||
const transmitPower = settings.get().experimental.transmit_power;
|
||||
await this.herdsman.setTransmitPower(transmitPower);
|
||||
logger.info(`Set transmit power to '${transmitPower}'`);
|
||||
}
|
||||
|
||||
return startResult;
|
||||
}
|
||||
|
||||
private logDeviceInterview(data: eventdata.DeviceInterview): void {
|
||||
const name = data.device.name;
|
||||
if (data.status === 'successful') {
|
||||
logger.info(`Successfully interviewed '${name}', device has successfully been paired`);
|
||||
|
||||
if (data.device.definition) {
|
||||
const {vendor, description, model} = data.device.definition;
|
||||
logger.info(`Device '${name}' is supported, identified as: ${vendor} ${description} (${model})`);
|
||||
} else {
|
||||
logger.warn(`Device '${name}' with Zigbee model '${data.device.zh.modelID}' and manufacturer name ` +
|
||||
`'${data.device.zh.manufacturerName}' is NOT supported, ` +
|
||||
`please follow https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html`);
|
||||
}
|
||||
} else if (data.status === 'failed') {
|
||||
logger.error(`Failed to interview '${name}', device has not successfully been paired`);
|
||||
} else { // data.status === 'started'
|
||||
logger.info(`Starting interview of '${name}'`);
|
||||
}
|
||||
}
|
||||
|
||||
private generateNetworkKey(): number[] {
|
||||
const key = Array.from({length: 16}, () => Math.floor(Math.random() * 255));
|
||||
settings.set(['advanced', 'network_key'], key);
|
||||
return key;
|
||||
}
|
||||
|
||||
private generatePanID(): number {
|
||||
const panID = Math.floor(Math.random() * (0xFFFF - 2)) + 1;
|
||||
settings.set(['advanced', 'pan_id'], panID);
|
||||
return panID;
|
||||
}
|
||||
|
||||
async getCoordinatorVersion(): Promise<zh.CoordinatorVersion> {
|
||||
return this.herdsman.getCoordinatorVersion();
|
||||
}
|
||||
|
||||
isStopping(): boolean {
|
||||
return this.herdsman.isStopping();
|
||||
}
|
||||
|
||||
async getNetworkParameters(): Promise<zh.NetworkParameters> {
|
||||
return this.herdsman.getNetworkParameters();
|
||||
}
|
||||
|
||||
async reset(type: 'soft' | 'hard'): Promise<void> {
|
||||
await this.herdsman.reset(type);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
logger.info('Stopping zigbee-herdsman...');
|
||||
await this.herdsman.stop();
|
||||
logger.info('Stopped zigbee-herdsman');
|
||||
}
|
||||
|
||||
getPermitJoin(): boolean {
|
||||
return this.herdsman.getPermitJoin();
|
||||
}
|
||||
|
||||
getPermitJoinTimeout(): number {
|
||||
return this.herdsman.getPermitJoinTimeout();
|
||||
}
|
||||
|
||||
async permitJoin(permit: boolean, device?: Device, time: number=undefined): Promise<void> {
|
||||
if (permit) {
|
||||
logger.info(`Zigbee: allowing new devices to join${device ? ` via ${device.name}` : ''}.`);
|
||||
} else {
|
||||
logger.info('Zigbee: disabling joining new devices.');
|
||||
}
|
||||
|
||||
if (device && permit) {
|
||||
await this.herdsman.permitJoin(permit, device.zh, time);
|
||||
} else {
|
||||
await this.herdsman.permitJoin(permit, undefined, time);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveDevice(ieeeAddr: string): Device {
|
||||
if (!this.deviceLookup[ieeeAddr]) {
|
||||
const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
|
||||
device && (this.deviceLookup[ieeeAddr] = new Device(device));
|
||||
}
|
||||
|
||||
const device = this.deviceLookup[ieeeAddr];
|
||||
if (device && !device.zh.isDeleted) {
|
||||
device.ensureInSettings();
|
||||
return device;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveGroup(groupID: number): Group {
|
||||
const group = this.herdsman.getGroupByID(Number(groupID));
|
||||
if (group && !this.groupLookup[groupID]) {
|
||||
this.groupLookup[groupID] = new Group(group);
|
||||
}
|
||||
|
||||
return this.groupLookup[groupID];
|
||||
}
|
||||
|
||||
resolveEntity(key: string | number | zh.Device): Device | Group {
|
||||
if (typeof key === 'object') {
|
||||
return this.resolveDevice(key.ieeeAddr);
|
||||
} else if (typeof key === 'string' && key.toLowerCase() === 'coordinator') {
|
||||
return this.resolveDevice(this.herdsman.getDevicesByType('Coordinator')[0].ieeeAddr);
|
||||
} else {
|
||||
const settingsDevice = settings.getDevice(key.toString());
|
||||
if (settingsDevice) return this.resolveDevice(settingsDevice.ID);
|
||||
|
||||
const groupSettings = settings.getGroup(key);
|
||||
if (groupSettings) {
|
||||
const group = this.resolveGroup(groupSettings.ID);
|
||||
// If group does not exist, create it (since it's already in configuration.yaml)
|
||||
return group ? group : this.createGroup(groupSettings.ID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
firstCoordinatorEndpoint(): zh.Endpoint {
|
||||
return this.herdsman.getDevicesByType('Coordinator')[0].endpoints[0];
|
||||
}
|
||||
|
||||
groups(): Group[] {
|
||||
return this.herdsman.getGroups().map((g) => this.resolveGroup(g.groupID));
|
||||
}
|
||||
|
||||
devices(includeCoordinator=true): Device[] {
|
||||
return this.herdsman.getDevices()
|
||||
.map((d) => this.resolveDevice(d.ieeeAddr))
|
||||
.filter((d) => includeCoordinator || d.zh.type !== 'Coordinator');
|
||||
}
|
||||
|
||||
@bind private async acceptJoiningDeviceHandler(ieeeAddr: string): Promise<boolean> {
|
||||
// If passlist is set, all devices not on passlist will be rejected to join the network
|
||||
const passlist = settings.get().passlist.concat(settings.get().whitelist);
|
||||
const blocklist = settings.get().blocklist.concat(settings.get().ban);
|
||||
if (passlist.length > 0) {
|
||||
if (passlist.includes(ieeeAddr)) {
|
||||
logger.info(`Accepting joining device which is on passlist '${ieeeAddr}'`);
|
||||
return true;
|
||||
} else {
|
||||
logger.info(`Rejecting joining not in passlist device '${ieeeAddr}'`);
|
||||
return false;
|
||||
}
|
||||
} else if (blocklist.length > 0) {
|
||||
if (blocklist.includes(ieeeAddr)) {
|
||||
logger.info(`Rejecting joining device which is on blocklist '${ieeeAddr}'`);
|
||||
return false;
|
||||
} else {
|
||||
logger.info(`Accepting joining not in blocklist device '${ieeeAddr}'`);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async touchlinkFactoryResetFirst(): Promise<boolean> {
|
||||
return this.herdsman.touchlinkFactoryResetFirst();
|
||||
}
|
||||
|
||||
async touchlinkFactoryReset(ieeeAddr: string, channel: number): Promise<boolean> {
|
||||
return this.herdsman.touchlinkFactoryReset(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async touchlinkIdentify(ieeeAddr: string, channel: number): Promise<void> {
|
||||
await this.herdsman.touchlinkIdentify(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async touchlinkScan(): Promise<{ieeeAddr: string, channel: number}[]> {
|
||||
return this.herdsman.touchlinkScan();
|
||||
}
|
||||
|
||||
createGroup(ID: number): Group {
|
||||
this.herdsman.createGroup(ID);
|
||||
return this.resolveGroup(ID);
|
||||
}
|
||||
|
||||
deviceByNetworkAddress(networkAddress: number): Device {
|
||||
const device = this.herdsman.getDeviceByNetworkAddress(networkAddress);
|
||||
return device && this.resolveDevice(device.ieeeAddr);
|
||||
}
|
||||
|
||||
groupByID(ID: number): Group {
|
||||
return this.resolveGroup(ID);
|
||||
}
|
||||
}
|
||||
Generated
+3937
-11367
File diff suppressed because it is too large
Load Diff
+34
-25
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "1.21.0",
|
||||
"version": "1.22.0",
|
||||
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
|
||||
"main": "index.js",
|
||||
"repository": {
|
||||
@@ -20,9 +20,9 @@
|
||||
"cc2531"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node_modules/.bin/tsc && node index.js writehash",
|
||||
"build-watch": "node_modules/.bin/tsc --watch",
|
||||
"eslint": "node_modules/.bin/eslint lib/ --max-warnings=0",
|
||||
"build": "tsc && node index.js writehash",
|
||||
"build-watch": "tsc --watch",
|
||||
"eslint": "eslint lib/ --max-warnings=0",
|
||||
"start": "node index.js",
|
||||
"test-with-coverage": "jest test --coverage",
|
||||
"test": "jest test",
|
||||
@@ -35,12 +35,14 @@
|
||||
},
|
||||
"homepage": "https://koenkk.github.io/zigbee2mqtt",
|
||||
"dependencies": {
|
||||
"ajv": "^8.6.1",
|
||||
"ajv": "^8.6.3",
|
||||
"bind-decorator": "^1.0.11",
|
||||
"core-js": "^3.18.2",
|
||||
"debounce": "^1.2.1",
|
||||
"deep-object-diff": "^1.1.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"finalhandler": "^1.1.2",
|
||||
"git-last-commit": "^1.0.0",
|
||||
"git-last-commit": "^1.0.1",
|
||||
"humanize-duration": "^3.27.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
@@ -51,31 +53,37 @@
|
||||
"rimraf": "^3.0.2",
|
||||
"semver": "^7.3.5",
|
||||
"serve-static": "^1.14.1",
|
||||
"source-map-support": "^0.5.19",
|
||||
"source-map-support": "^0.5.20",
|
||||
"winston": "^3.3.3",
|
||||
"winston-syslog": "^2.4.4",
|
||||
"ws": "^7.5.2",
|
||||
"zigbee-herdsman": "0.13.124",
|
||||
"zigbee-herdsman-converters": "14.0.210",
|
||||
"zigbee2mqtt-frontend": "0.5.18"
|
||||
"ws": "^8.2.3",
|
||||
"zigbee-herdsman": "0.13.164",
|
||||
"zigbee-herdsman-converters": "14.0.303",
|
||||
"zigbee2mqtt-frontend": "0.6.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.14.6",
|
||||
"@babel/preset-env": "^7.14.7",
|
||||
"@babel/preset-typescript": "^7.14.5",
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/js-yaml": "^4.0.2",
|
||||
"@babel/core": "^7.15.5",
|
||||
"@babel/plugin-proposal-decorators": "^7.15.4",
|
||||
"@babel/preset-env": "^7.15.6",
|
||||
"@babel/preset-typescript": "^7.15.0",
|
||||
"@types/debounce": "^1.2.1",
|
||||
"@types/finalhandler": "^1.1.1",
|
||||
"@types/humanize-duration": "^3.25.1",
|
||||
"@types/jest": "^27.0.2",
|
||||
"@types/js-yaml": "^4.0.3",
|
||||
"@types/object-assign-deep": "^0.4.0",
|
||||
"@types/ws": "^7.4.6",
|
||||
"@typescript-eslint/eslint-plugin": "^4.28.3",
|
||||
"@typescript-eslint/parser": "^4.28.3",
|
||||
"babel-jest": "^27.0.6",
|
||||
"eslint": "^7.30.0",
|
||||
"@types/rimraf": "^3.0.2",
|
||||
"@types/serve-static": "^1.13.10",
|
||||
"@types/ws": "^8.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.32.0",
|
||||
"@typescript-eslint/parser": "^4.32.0",
|
||||
"babel-jest": "^27.2.4",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-plugin-jest": "^24.3.6",
|
||||
"jest": "^27.0.6",
|
||||
"eslint-plugin-jest": "^24.5.0",
|
||||
"jest": "^27.2.4",
|
||||
"tmp": "^0.2.1",
|
||||
"typescript": "^4.3.5"
|
||||
"typescript": "^4.4.3"
|
||||
},
|
||||
"jest": {
|
||||
"coverageThreshold": {
|
||||
@@ -87,7 +95,8 @@
|
||||
}
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"lib/**/*.js"
|
||||
"lib/**/*.js",
|
||||
"lib/**/*.ts"
|
||||
]
|
||||
},
|
||||
"bin": {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
const HomeAssistant = require('../lib/extension/homeassistant');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const homeassistant = new HomeAssistant(null, null, null, null, {on: () => {}});
|
||||
const mapping = Object.entries(homeassistant._getMapping());
|
||||
mapping.sort((a, b) => {
|
||||
if (a[0] > b[0]) {
|
||||
return -1;
|
||||
}
|
||||
if (b[0] > a[0]) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
for (const map of mapping) {
|
||||
map[1].sort((a, b) => {
|
||||
return JSON.stringify(a).localeCompare(JSON.stringify(b));
|
||||
});
|
||||
|
||||
const toDelete = [];
|
||||
for (const entry of map[1]) {
|
||||
if (entry.type === 'light') {
|
||||
if (entry.discovery_payload.brightness === false) delete entry.discovery_payload.brightness;
|
||||
if (entry.discovery_payload.color_temp === false) delete entry.discovery_payload.color_temp;
|
||||
if (entry.discovery_payload.xy === false) delete entry.discovery_payload.xy;
|
||||
if (entry.discovery_payload.hs === false) delete entry.discovery_payload.hs;
|
||||
} else if (entry.object_id === 'linkquality') {
|
||||
toDelete.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
toDelete.forEach((d) => map[1].splice(map[1].indexOf(d), 1));
|
||||
|
||||
console.log(`${map[0]} - ${stringify(map[1])}`);
|
||||
}
|
||||
@@ -64,11 +64,11 @@ class ZStackNvMemEraser {
|
||||
if (needOsal) {
|
||||
await this.znp.request(Subsystem.SYS, 'osalNvDelete',
|
||||
{id: id, len: len},
|
||||
null, [ZnpCommandStatus.SUCCESS, ZnpCommandStatus.NV_ITEM_INITIALIZED]);
|
||||
null, null, [ZnpCommandStatus.SUCCESS, ZnpCommandStatus.NV_ITEM_INITIALIZED]);
|
||||
} else {
|
||||
await this.znp.request(Subsystem.SYS, 'nvDelete',
|
||||
{sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0},
|
||||
null, [ZnpCommandStatus.SUCCESS, ZnpCommandStatus.NV_ITEM_INITIALIZED]);
|
||||
null, null, [ZnpCommandStatus.SUCCESS, ZnpCommandStatus.NV_ITEM_INITIALIZED]);
|
||||
}
|
||||
deletedCount++;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ function handle_opts {
|
||||
|
||||
-v|--verbose)
|
||||
verbose=1; shift; continue ;;
|
||||
|
||||
|
||||
-m|--man)
|
||||
makemanual ;;
|
||||
|
||||
@@ -84,7 +84,6 @@ port=""
|
||||
function main {
|
||||
handle_opts "$@"
|
||||
|
||||
# TODO: Check IP-address valid?
|
||||
if [[ "$ip" = "" ]]; then
|
||||
echo "IP-address not specified"
|
||||
exit 5
|
||||
@@ -161,7 +160,7 @@ function usage {
|
||||
-v, --verbose Print more information
|
||||
-m, --man Make and display manual
|
||||
-h, --help Display this help message
|
||||
|
||||
|
||||
\rOriginal concept by JFLN\
|
||||
"
|
||||
exit 0
|
||||
|
||||
@@ -4,7 +4,7 @@ class Example {
|
||||
this.mqtt.publish('example/extension', 'call from constructor')
|
||||
}
|
||||
|
||||
onMQTTConnected() {
|
||||
start() {
|
||||
this.mqtt.publish('example/extension', 'test')
|
||||
}
|
||||
}
|
||||
|
||||
+228
-354
@@ -1,411 +1,285 @@
|
||||
const data = require('./stub/data');
|
||||
const logger = require('./stub/logger');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
zigbeeHerdsman.returnDevices.push('0x000b57fffec6a5b3');
|
||||
zigbeeHerdsman.returnDevices.push('0x00124b00120144ae');
|
||||
zigbeeHerdsman.returnDevices.push('0x0017880104e45553');
|
||||
zigbeeHerdsman.returnDevices.push('0x0017880104e45517');
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
|
||||
import data from './stub/data';
|
||||
import logger from './stub/logger';
|
||||
import MQTT from './stub/mqtt';
|
||||
import zigbeeHerdsman from './stub/zigbeeHerdsman';
|
||||
|
||||
import utils from '../lib/util/utils';
|
||||
import * as settings from '../lib/util/settings';
|
||||
import Controller from '../lib/controller';
|
||||
import flushPromises from './lib/flushPromises';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
const mocks = [MQTT.publish, logger.warn, logger.debug];
|
||||
const devices = zigbeeHerdsman.devices;
|
||||
zigbeeHerdsman.returnDevices.push(
|
||||
...[devices.bulb_color.ieeeAddr, devices.bulb_color_2.ieeeAddr, devices.coordinator.ieeeAddr, devices.remote.ieeeAddr])
|
||||
|
||||
describe('Availability', () => {
|
||||
let controller;
|
||||
let extension;
|
||||
|
||||
let resetExtension = async () => {
|
||||
await controller.enableDisableExtension(false, 'Availability');
|
||||
await controller.enableDisableExtension(true, 'Availability');
|
||||
extension = controller.extensions.find((e) => e.constructor.name === 'Availability');
|
||||
}
|
||||
|
||||
const advancedTime = async (value) => {
|
||||
jest.setSystemTime(Date.now() + value);
|
||||
jest.advanceTimersByTime(value);
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(utils, 'sleep').mockImplementation(async (seconds) => {});
|
||||
jest.useFakeTimers('modern');
|
||||
settings.reRead();
|
||||
settings.set(['availability'], true);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers('modern').setSystemTime(utils.minutes(1));
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
settings.set(['advanced', 'availability_timeout'], 10);
|
||||
settings.set(['availability'], true);
|
||||
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], false);
|
||||
Object.values(devices).forEach(d => d.lastSeen = utils.minutes(1));
|
||||
mocks.forEach((m) => m.mockClear());
|
||||
await resetExtension();
|
||||
Object.values(devices).forEach((d) => d.ping.mockClear());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
Object.values(devices).forEach(d => d.lastSeen = utils.minutes(1));
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
})
|
||||
|
||||
it('Should publish availabilty on startup', async () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color/availability',
|
||||
'online',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
it('Should publish availabilty on startup for device where it is enabled for', async () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
|
||||
'online', {retain: true, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability',
|
||||
'online', {retain: true, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bulb_color_2/availability',
|
||||
'online', {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should publish availabilty offline when ping fails', async () => {
|
||||
it('Should publish offline for active device when not seen for 10 minutes', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
logger.error.mockClear();
|
||||
logger.debug.mockClear();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
device.ping.mockImplementationOnce(() => {throw new Error('failed')});
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenNthCalledWith(1,
|
||||
'zigbee2mqtt/bulb_color/availability',
|
||||
'offline',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenCalledWith("Failed to ping 'bulb_color'");
|
||||
device.ping.mockImplementationOnce(() => {throw new Error('failed')});
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(logger.debug).toHaveBeenCalledTimes(3);
|
||||
expect(logger.debug).toHaveBeenCalledWith("Failed to ping 'bulb_color'");
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenNthCalledWith(1,
|
||||
'zigbee2mqtt/bulb_color/availability',
|
||||
'offline',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
|
||||
await advancedTime(utils.minutes(5));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await advancedTime(utils.minutes(7));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
|
||||
'offline', {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should publish availabilty online and query state on reconnect', async () => {
|
||||
const device = zigbeeHerdsman.devices.E11_G13;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
device.ping.mockImplementationOnce(() => {throw new Error('failed')});
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
it('Shouldnt do anything for a device when availability: false is set for device', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_enddevice/availability',
|
||||
'online',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color_2}); // Coverage satisfaction
|
||||
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
await advancedTime(utils.minutes(12));
|
||||
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should fail gracefully when quering state after reconnect fails', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
it('Should publish offline for passive device when not seen for 25 hours', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
await advancedTime(utils.hours(26));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability',
|
||||
'offline', {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should reset ping timer when device last seen changes for active device', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await advancedTime(utils.minutes(5));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color});
|
||||
|
||||
await advancedTime(utils.minutes(7));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await advancedTime(utils.minutes(10));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
|
||||
'offline', {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should ping again when first ping fails', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await advancedTime(utils.minutes(5));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color});
|
||||
|
||||
await advancedTime(utils.minutes(7));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
devices.bulb_color.ping.mockImplementationOnce(() => {throw new Error('failed')});
|
||||
devices.bulb_color.lastSeen = Date.now() + utils.minutes(10);
|
||||
await advancedTime(utils.minutes(10));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
|
||||
'offline', {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should reset ping timer when device last seen changes for passive device', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await advancedTime(utils.hours(24));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.remote});
|
||||
|
||||
await advancedTime(utils.hours(25));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
devices.remote.ping.mockImplementationOnce(() => {throw new Error('failed')});
|
||||
await advancedTime(utils.hours(3));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability',
|
||||
'offline', {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should immediately mark device as online when it lastSeen changes', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await advancedTime(utils.minutes(15));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
|
||||
'offline', {retain: true, qos: 0}, expect.any(Function));
|
||||
|
||||
devices.bulb_color.lastSeen = Date.now();
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
|
||||
'online', {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should allow to change availability timeout via device options', async () => {
|
||||
settings.set(['devices', '0x000b57fffec6a5b3', 'availability'], {timeout: 40});
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await advancedTime(utils.minutes(25));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await advancedTime(utils.minutes(17));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should allow to change availability timeout via avaiability options', async () => {
|
||||
settings.set(['availability'], {active: {timeout: 30}});
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await advancedTime(utils.minutes(25));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await advancedTime(utils.minutes(7));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should stop pinging device when it leaves', async () => {
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await advancedTime(utils.minutes(9));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await zigbeeHerdsman.events.deviceLeave({ieeeAddr: devices.bulb_color.ieeeAddr});
|
||||
await flushPromises();
|
||||
|
||||
await advancedTime(utils.minutes(3));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should allow to be disabled', async () => {
|
||||
settings.set(['availability'], false);
|
||||
await resetExtension();
|
||||
|
||||
await advancedTime(utils.minutes(12));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should retrieve device state when it reconnects', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
const endpoint = devices.bulb_color.getEndpoint(1);
|
||||
endpoint.read.mockClear();
|
||||
endpoint.read.mockImplementationOnce(() => {throw new Error('Device timedout')});
|
||||
device.ping.mockImplementationOnce(() => {throw new Error('failed')});
|
||||
logger.debug.mockClear();
|
||||
jest.advanceTimersByTime(11 * 2000);
|
||||
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
await advancedTime(utils.seconds(1));
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(0);
|
||||
await advancedTime(utils.seconds(2));
|
||||
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('lightingColorCtrl',
|
||||
['colorMode', 'currentX', 'currentY', 'enhancedCurrentHue', 'currentSaturation', 'colorTemperature']);
|
||||
|
||||
// Should stop when one request fails
|
||||
endpoint.read.mockClear();
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
endpoint.read.mockImplementationOnce(() => {throw new Error('')});
|
||||
await advancedTime(utils.seconds(3));
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
expect(logger.error).toHaveBeenCalledWith(`Failed to read state of 'bulb_color' after reconnect`);
|
||||
});
|
||||
|
||||
it('Shouldnt ping again when still pinging', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
device.ping.mockClear();
|
||||
device.ping.mockImplementationOnce(async () => {await wait(100000000)});
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(1);
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should mark device online when receiving message while offline', async () => {
|
||||
it('Should republish availability when device is renamed', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
device.ping.mockImplementationOnce(() => {throw new Error('failed')});
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({from: 'bulb_color', to: 'bulb_new_name'}));
|
||||
await flushPromises();
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
const data = {modelID: 'test'}
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenNthCalledWith(1,
|
||||
'zigbee2mqtt/bulb_color/availability',
|
||||
'online',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability',
|
||||
'online', {retain: true, qos: 0}, expect.any(Function));
|
||||
await advancedTime(utils.hours(12));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability',
|
||||
'offline', {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should retrieve the state when device is turned on/off within availability timeout', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
extension.state = {};
|
||||
const payload = {device: zigbeeHerdsman.devices.bulb_color};
|
||||
await zigbeeHerdsman.events.deviceJoined(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color/availability',
|
||||
'online',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Should retrieve the state when device is turned on/off within availability timeout on deviceAnnounce', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
endpoint.read.mockClear();
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
});
|
||||
|
||||
it('Should not retrieve the state when device is turned on/off within availability timeout on deviceJoined', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
const device = zigbeeHerdsman.devices.E11_G13;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
endpoint.read.mockClear();
|
||||
await zigbeeHerdsman.events.deviceJoined({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should not do anything when message has no device', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const payload = {ieeeAddr: device.ieeeAddr};
|
||||
await zigbeeHerdsman.events.deviceLeave(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish.mock.calls.find((c) => c[0].includes('availability'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Should not ping devices on blocklist by friendly name', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
settings.set(['advanced', 'availability_blocklist'], ['bulb_color'])
|
||||
it('Deprecated - should allow to block via advanced.availability_blocklist', async () => {
|
||||
settings.set(['advanced', 'availability_blocklist'], [devices.bulb_color.ieeeAddr]);
|
||||
await resetExtension();
|
||||
device.ping.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await advancedTime(utils.minutes(12));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should not ping devices on blacklist by friendly name', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
settings.set(['advanced', 'availability_blacklist'], ['bulb_color'])
|
||||
it('Deprecated - should allow to pass certain devices via availability_passlist', async () => {
|
||||
settings.set(['advanced', 'availability_passlist'], [devices.bulb_color_2.ieeeAddr]);
|
||||
settings.changeEntityOptions(devices.bulb_color_2.ieeeAddr, {availability: null});
|
||||
await resetExtension();
|
||||
device.ping.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await advancedTime(utils.minutes(12));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should not ping devices on blocklist by IEEE address', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
settings.set(['advanced', 'availability_blocklist'], [device.ieeeAddr]);
|
||||
it('Deprecated - should allow to enable via availability_timeout', async () => {
|
||||
settings.set(['availability'], false);
|
||||
settings.set(['advanced', 'availability_timeout'], 60);
|
||||
await resetExtension();
|
||||
device.ping.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should ping forced pingable devices', async () => {
|
||||
const device = zigbeeHerdsman.devices.E11_G13;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const data = {modelID: 'test'}
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
device.ping.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should ping devices on passlist by friendly name if availability_passlist is set', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
settings.set(['advanced', 'availability_passlist'], ['bulb_color']);
|
||||
await resetExtension();
|
||||
device.ping.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should ping devices on whitelist by friendly name if availability_whitelist is set', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
settings.set(['advanced', 'availability_whitelist'], ['bulb_color']);
|
||||
await resetExtension();
|
||||
device.ping.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should ping devices on passlist by IEEE address if availability_passlist is set', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
settings.set(['advanced', 'availability_passlist'], [device.ieeeAddr]);
|
||||
await resetExtension();
|
||||
device.ping.mockClear();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should not ping devices not in passlist if availability_passlist is set', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
extension.state[device.ieeeAddr] = false;
|
||||
settings.set(['advanced', 'availability_passlist'], ['0x000b57fffec6a5b3'])
|
||||
await resetExtension();
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(0);
|
||||
MQTT.publish.mockClear();
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device});
|
||||
});
|
||||
|
||||
it('Should not read when device has no modelID and reconnects', async () => {
|
||||
const device = zigbeeHerdsman.devices.nomodel;
|
||||
extension.state[device.ieeeAddr] = true;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should not read when device has is unsupported', async () => {
|
||||
const device = zigbeeHerdsman.devices.unsupported_router;
|
||||
extension.state[device.ieeeAddr] = true;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should stop pinging device when removed', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
device.ping.mockClear();
|
||||
const endpoint = device.getEndpoint(1);
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(1);
|
||||
settings.removeDevice(device.ieeeAddr);
|
||||
jest.advanceTimersByTime(11 * 1000);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should publish availability when end device joins', async () => {
|
||||
const device = zigbeeHerdsman.devices.WXKG02LM_rev1;
|
||||
const payload = {device};
|
||||
MQTT.publish.mockClear();
|
||||
await zigbeeHerdsman.events.deviceJoined(payload);
|
||||
await flushPromises();
|
||||
expect(device.ping).toHaveBeenCalledTimes(0);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/button_double_key/availability',
|
||||
'online',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Should mark non-pingable device as non-available when offline for longer than 24 hours', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const defaultLastSeen = device.lastSeen;
|
||||
device.lastSeen = Date.now();
|
||||
MQTT.publish.mockClear();
|
||||
jest.advanceTimersByTime(1000 * 60 * 60 * 1); // 1 hours
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
device.lastSeen = device.lastSeen - (1000 * 60 * 60 * 25);
|
||||
jest.advanceTimersByTime(1000 * 60 * 60 * 1); // 1 hours
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/remote/availability',
|
||||
'offline',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
|
||||
// Shouldn't do anything more when device is removed
|
||||
settings.removeDevice(device.ieeeAddr);
|
||||
jest.advanceTimersByTime(1000 * 60 * 60 * 1); // 1 hours
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
|
||||
device.lastSeen = defaultLastSeen;
|
||||
});
|
||||
|
||||
it('Should republish existing state on MQTT connected', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
MQTT.publish.mockClear();
|
||||
extension.state[device.ieeeAddr] = false;
|
||||
await extension.onMQTTConnected();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color/availability',
|
||||
'offline',
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Should clear retained availability topic when device is remove', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', 'bulb_color');
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color/availability',
|
||||
null,
|
||||
{retain: true, qos: 0}, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Should clear retained availability topic when device is renamed', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({"from": "bulb_color", "to": "bulb_color_new_name"}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color/availability',
|
||||
null,
|
||||
{retain: true, qos: 0}, expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('Should clear retained availability topic when device does not exist', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/not_existing_hahaha/availability', 'offline');
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/not_existing_hahaha/availability', null, {retain: true, qos: 0}, expect.any(Function));
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/0x000b57fffec6a5b3/availability', 'offline');
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/0x000b57fffec6a5b3/availability', null, {retain: true, qos: 0}, expect.any(Function));
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bulb_color/availability', 'offline');
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
await advancedTime(utils.minutes(12));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -585,6 +585,19 @@ describe('Bind', () => {
|
||||
expect(zigbeeHerdsman.devices.bulb_color.getEndpoint(1).read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]);
|
||||
});
|
||||
|
||||
it('Should poll bounded Hue bulb when receiving message from scene controller', async () => {
|
||||
const remote = zigbeeHerdsman.devices.bj_scene_switch;
|
||||
const data = {"action": "recall_2_row_1"};
|
||||
const payload = {data, cluster: 'genScenes', device: remote, endpoint: remote.getEndpoint(10), type: 'commandRecall', linkquality: 10, groupID: 0};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
// Calls to three clusters are expected in this case
|
||||
expect(debounce).toHaveBeenCalledTimes(3);
|
||||
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith("genOnOff", ["onOff"]);
|
||||
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]);
|
||||
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith("lightingColorCtrl", ["currentX", "currentY", "colorTemperature"]);
|
||||
});
|
||||
|
||||
it('Should poll grouped Hue bulb when receiving message from TRADFRI remote', async () => {
|
||||
zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read.mockClear();
|
||||
zigbeeHerdsman.devices.bulb_2.getEndpoint(1).read.mockClear();
|
||||
|
||||
+62
-33
File diff suppressed because one or more lines are too long
+14
-22
@@ -102,10 +102,10 @@ describe('Configure', () => {
|
||||
|
||||
it('Should reconfigure reporting on reportingDisabled event', async () => {
|
||||
expectBulbConfigured();
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
mockClear(device);
|
||||
const device = controller.zigbee.resolveEntity(zigbeeHerdsman.devices.bulb);
|
||||
mockClear(device.zh);
|
||||
expectBulbNotConfigured();
|
||||
controller.eventBus.emit('reportingDisabled', {device})
|
||||
controller.eventBus.emitReportingDisabled({device});
|
||||
await flushPromises();
|
||||
expectBulbConfigured();
|
||||
});
|
||||
@@ -113,10 +113,8 @@ describe('Configure', () => {
|
||||
it('Should not configure twice', async () => {
|
||||
expectBulbConfigured();
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.deviceInterview({device});
|
||||
await flushPromises();
|
||||
expectBulbNotConfigured();
|
||||
});
|
||||
@@ -124,10 +122,8 @@ describe('Configure', () => {
|
||||
it('Should configure on zigbee message when not configured yet', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
delete device.meta.configured;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expectBulbConfigured();
|
||||
});
|
||||
@@ -202,8 +198,7 @@ describe('Configure', () => {
|
||||
device.interviewing = true;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expectRemoteNotConfigured();
|
||||
device.interviewing = false;
|
||||
@@ -215,8 +210,7 @@ describe('Configure', () => {
|
||||
device.interviewCompleted = false;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expectRemoteConfigured();
|
||||
device.interviewCompleted = true;
|
||||
@@ -228,11 +222,10 @@ describe('Configure', () => {
|
||||
const endpoint = device.getEndpoint(1);
|
||||
endpoint.bind.mockImplementationOnce(async () => await wait(500));
|
||||
mockClear(device);
|
||||
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -244,20 +237,19 @@ describe('Configure', () => {
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
|
||||
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
+59
-40
@@ -25,6 +25,7 @@ describe('Controller', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
MQTT.restoreOnMock();
|
||||
zigbeeHerdsman.returnDevices.splice(0);
|
||||
mockExit = jest.fn();
|
||||
controller = new Controller(jest.fn(), mockExit);
|
||||
@@ -51,7 +52,7 @@ describe('Controller', () => {
|
||||
expect(logger.info).toHaveBeenCalledWith('remote (0x0017880104e45517): 324131092621 - Philips Hue dimmer switch (EndDevice)');
|
||||
expect(logger.info).toHaveBeenCalledWith('0x0017880104e45518 (0x0017880104e45518): Not supported (EndDevice)');
|
||||
expect(MQTT.connect).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.connect).toHaveBeenCalledWith("mqtt://localhost", {"will": {"payload": "offline", "retain": true, "topic": "zigbee2mqtt/bridge/state"}});
|
||||
expect(MQTT.connect).toHaveBeenCalledWith("mqtt://localhost", {"will": {"payload": "offline", "retain": true, "topic": "zigbee2mqtt/bridge/state", "qos": 1}});
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}),{ retain: true, qos: 0 }, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote', stringify({"brightness":255}), { retain: true, qos: 0 }, expect.any(Function));
|
||||
});
|
||||
@@ -87,7 +88,7 @@ describe('Controller', () => {
|
||||
await flushPromises();
|
||||
expect(MQTT.connect).toHaveBeenCalledTimes(1);
|
||||
const expected = {
|
||||
"will": {"payload": "offline", "retain": true, "topic": "zigbee2mqtt/bridge/state"},
|
||||
"will": {"payload": "offline", "retain": true, "topic": "zigbee2mqtt/bridge/state", "qos": 1},
|
||||
keepalive: 30,
|
||||
ca: Buffer.from([99, 97]),
|
||||
key: Buffer.from([107, 101, 121]),
|
||||
@@ -155,7 +156,8 @@ describe('Controller', () => {
|
||||
await flushPromises();
|
||||
logger.error.mockClear();
|
||||
controller.mqtt.client.reconnecting = true;
|
||||
await controller.publishEntityState('bulb', {state: 'ON', brightness: 50, color_temp: 370, color: {r: 100, g: 50, b: 10}, dummy: {1: 'yes', 2: 'no'}});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON', brightness: 50, color_temp: 370, color: {r: 100, g: 50, b: 10}, dummy: {1: 'yes', 2: 'no'}});
|
||||
await flushPromises();
|
||||
expect(logger.error).toHaveBeenCalledTimes(2);
|
||||
expect(logger.error).toHaveBeenCalledWith("Not connected to MQTT server!");
|
||||
@@ -172,6 +174,7 @@ describe('Controller', () => {
|
||||
|
||||
it('Should remove device not on passlist on startup', async () => {
|
||||
settings.set(['passlist'], [zigbeeHerdsman.devices.bulb_color.ieeeAddr]);
|
||||
zigbeeHerdsman.devices.bulb.removeFromNetwork.mockImplementationOnce(() => {throw new Error("dummy")});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
expect(zigbeeHerdsman.devices.bulb_color.removeFromNetwork).toHaveBeenCalledTimes(0);
|
||||
@@ -208,6 +211,17 @@ describe('Controller', () => {
|
||||
expect(mockExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Start controller fails due to MQTT', async () => {
|
||||
MQTT.on.mockImplementation((type, handler) => {
|
||||
if (type === 'error') handler({message: 'addr not found'});
|
||||
});
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
expect(logger.error).toHaveBeenCalledWith('MQTT failed to connect: addr not found');
|
||||
expect(mockExit).toHaveBeenCalledTimes(1);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('Start controller with permit join true', async () => {
|
||||
settings.set(['permit_join'], false);
|
||||
await controller.start();
|
||||
@@ -235,7 +249,7 @@ describe('Controller', () => {
|
||||
expect(MQTT.end).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.stop).toHaveBeenCalledTimes(1);
|
||||
expect(mockExit).toHaveBeenCalledTimes(1);
|
||||
expect(mockExit).toHaveBeenCalledWith(0, null);
|
||||
expect(mockExit).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('Start controller and stop', async () => {
|
||||
@@ -245,7 +259,7 @@ describe('Controller', () => {
|
||||
expect(MQTT.end).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.stop).toHaveBeenCalledTimes(1);
|
||||
expect(mockExit).toHaveBeenCalledTimes(1);
|
||||
expect(mockExit).toHaveBeenCalledWith(1, null);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('Start controller adapter disconnects', async () => {
|
||||
@@ -256,7 +270,7 @@ describe('Controller', () => {
|
||||
expect(MQTT.end).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.stop).toHaveBeenCalledTimes(1);
|
||||
expect(mockExit).toHaveBeenCalledTimes(1);
|
||||
expect(mockExit).toHaveBeenCalledWith(1, null);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('Handle mqtt message', async () => {
|
||||
@@ -301,17 +315,6 @@ describe('Controller', () => {
|
||||
expect(settings.getDevice(device.ieeeAddr)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('On zigbee event message from unkown device should create it', async () => {
|
||||
await controller.start();
|
||||
const device = zigbeeHerdsman.devices.notInSettings;
|
||||
settings.removeDevice(device.ieeeAddr);
|
||||
expect(settings.getDevice(device.ieeeAddr)).toBeNull();
|
||||
const payload = {device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10, groupID: 0, cluster: 'genBasic', data: {modelId: device.modelID}};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(settings.getDevice(device.ieeeAddr)).toStrictEqual({"ID": "0x0017880104e45519", "friendlyName": "0x0017880104e45519", "friendly_name": "0x0017880104e45519"});
|
||||
});
|
||||
|
||||
it('On zigbee deviceJoined', async () => {
|
||||
await controller.start();
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
@@ -452,7 +455,8 @@ describe('Controller', () => {
|
||||
await controller.start();
|
||||
settings.set(['experimental', 'output'], 'attribute');
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {dummy: {1: 'yes', 2: 'no'}, color: {r: 100, g: 50, b: 10}, state: 'ON', test: undefined, test1: null, color_temp: 370, brightness: 50});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {dummy: {1: 'yes', 2: 'no'}, color: {r: 100, g: 50, b: 10}, state: 'ON', test: undefined, test1: null, color_temp: 370, brightness: 50});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/brightness", "50", {"qos": 0, "retain": true}, expect.any(Function));
|
||||
@@ -468,7 +472,8 @@ describe('Controller', () => {
|
||||
await controller.start();
|
||||
settings.set(['experimental', 'output'], 'attribute_and_json');
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(5);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function));
|
||||
@@ -484,7 +489,8 @@ describe('Controller', () => {
|
||||
settings.set(['experimental', 'output'], 'attribute_and_json');
|
||||
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'filtered_attributes'], ['color_temp', 'linkquality']);
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(3);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function));
|
||||
@@ -497,7 +503,8 @@ describe('Controller', () => {
|
||||
settings.set(['experimental', 'output'], 'attribute_and_json');
|
||||
settings.set(['device_options', 'filtered_attributes'], ['color_temp', 'linkquality']);
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(3);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function));
|
||||
@@ -509,12 +516,14 @@ describe('Controller', () => {
|
||||
await controller.start();
|
||||
settings.set(['mqtt', 'include_device_information'], true);
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON'});
|
||||
let device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON'});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"device":{"friendlyName":"bulb","model":"LED1545G12","ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369,"type":"Router","manufacturerID":4476,"powerSource":"Mains (single phase)","dateCode":null, "softwareBuildID": null}}), {"qos": 0, "retain": true}, expect.any(Function));
|
||||
|
||||
// Unsupported device should have model "unknown"
|
||||
await controller.publishEntityState('unsupported2', {state: 'ON'});
|
||||
device = controller.zigbee.resolveEntity('unsupported2');
|
||||
await controller.publishEntityState(device, {state: 'ON'});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/unsupported2', stringify({"state":"ON","device":{"friendlyName":"unsupported2","model":"unknown","ieeeAddr":"0x0017880104e45529","networkAddress":6536,"type":"EndDevice","manufacturerID":0,"powerSource":"Battery","dateCode":null, "softwareBuildID": null}}), {"qos": 0, "retain": false}, expect.any(Function));
|
||||
});
|
||||
@@ -523,7 +532,8 @@ describe('Controller', () => {
|
||||
await controller.start();
|
||||
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'retain'], false);
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON'});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON'});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}), {"qos": 0, "retain": false}, expect.any(Function));
|
||||
});
|
||||
@@ -532,7 +542,8 @@ describe('Controller', () => {
|
||||
await controller.start();
|
||||
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'retain'], true);
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON'});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON'});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}), {"qos": 0, "retain": true}, expect.any(Function));
|
||||
});
|
||||
@@ -543,7 +554,8 @@ describe('Controller', () => {
|
||||
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'retain'], true);
|
||||
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'retention'], 37);
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON'});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON'});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}), {"qos": 0, "retain": true, "properties": {messageExpiryInterval: 37}}, expect.any(Function));
|
||||
});
|
||||
@@ -552,7 +564,8 @@ describe('Controller', () => {
|
||||
data.writeEmptyState();
|
||||
await controller.start();
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -562,8 +575,9 @@ describe('Controller', () => {
|
||||
data.removeState();
|
||||
await controller.start();
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON'});
|
||||
await controller.publishEntityState('bulb', {brightness: 200});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON'});
|
||||
await controller.publishEntityState(device, {brightness: 200});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(2);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({state: "ON"}), {"qos": 0, "retain": true}, expect.any(Function));
|
||||
@@ -586,22 +600,15 @@ describe('Controller', () => {
|
||||
data.writeEmptyState();
|
||||
await controller.start();
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb', {state: 'ON'});
|
||||
await controller.publishEntityState('bulb', {brightness: 200});
|
||||
const device = controller.zigbee.resolveEntity('bulb');
|
||||
await controller.publishEntityState(device, {state: 'ON'});
|
||||
await controller.publishEntityState(device, {brightness: 200});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(2);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"state":"ON"}), {"qos": 0, "retain": true}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"brightness":200}), {"qos": 0, "retain": true}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Publish should not do anything for unknown entity', async () => {
|
||||
await controller.start();
|
||||
MQTT.publish.mockClear();
|
||||
await controller.publishEntityState('bulb-unknown', {brightness: 200});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should start when state is corrupted', async () => {
|
||||
fs.writeFileSync(path.join(data.mockDir, 'state.json'), 'corrupted');
|
||||
await controller.start();
|
||||
@@ -615,7 +622,7 @@ describe('Controller', () => {
|
||||
await flushPromises();
|
||||
expect(MQTT.connect).toHaveBeenCalledTimes(1);
|
||||
const expected = {
|
||||
"will": { "payload": "offline", "retain": false, "topic": "zigbee2mqtt/bridge/state" },
|
||||
"will": { "payload": "offline", "retain": false, "topic": "zigbee2mqtt/bridge/state", "qos": 1 },
|
||||
}
|
||||
expect(MQTT.connect).toHaveBeenCalledWith("mqtt://localhost", expected);
|
||||
});
|
||||
@@ -638,4 +645,16 @@ describe('Controller', () => {
|
||||
expect(settings.get().advanced.homeassistant_legacy_entity_attributes).toBeFalsy();
|
||||
expect(settings.get().advanced.legacy_api).toBeFalsy();
|
||||
});
|
||||
|
||||
it('Should publish last seen changes', async () => {
|
||||
settings.set(['advanced', 'last_seen'], 'epoch');
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/remote', stringify({"brightness":255,"last_seen":1000}), { qos: 0, retain: true }, expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
const logger = require('./stub/logger');
|
||||
const data = require('../lib/util/data.js');
|
||||
const data = require('../lib/util/data').default;
|
||||
const path = require('path');
|
||||
const tmp = require('tmp');
|
||||
const fs = require('fs');
|
||||
@@ -15,12 +15,12 @@ describe('Data', () => {
|
||||
it('Should return correct path when ZIGBEE2MQTT_DATA set', () => {
|
||||
const expected = tmp.dirSync().name;
|
||||
process.env.ZIGBEE2MQTT_DATA = expected;
|
||||
data._reload();
|
||||
data.__testingOnly_reload();
|
||||
const actual = data.getPath();
|
||||
expect(actual).toBe(expected);
|
||||
expect(data.joinPath('test')).toStrictEqual(path.join(expected, 'test'));
|
||||
delete process.env.ZIGBEE2MQTT_DATA;
|
||||
data._reload();
|
||||
data.__testingOnly_reload();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ const logger = require('./stub/logger');
|
||||
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const path = require('path');
|
||||
const rimraf = require('rimraf');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
@@ -44,7 +45,7 @@ describe('User extensions', () => {
|
||||
});
|
||||
afterEach(() => {
|
||||
const extensionPath = path.join(data.mockDir, 'extension');
|
||||
fs.rmdirSync(extensionPath, {recursive: true});
|
||||
rimraf.sync(extensionPath);
|
||||
})
|
||||
|
||||
it('Load user extension', async () => {
|
||||
|
||||
+11
-9
@@ -31,6 +31,7 @@ const mockWS = {
|
||||
cb(mockWSocket)
|
||||
}),
|
||||
emit: jest.fn(),
|
||||
close: jest.fn(),
|
||||
},
|
||||
variables: {},
|
||||
events: {},
|
||||
@@ -100,15 +101,16 @@ describe('Frontend', () => {
|
||||
|
||||
const mockWSClient = {
|
||||
implementation: {
|
||||
close: jest.fn(),
|
||||
terminate: jest.fn(),
|
||||
send: jest.fn(),
|
||||
},
|
||||
events: {},
|
||||
};
|
||||
mockWS.implementation.clients.push(mockWSClient.implementation);
|
||||
await controller.stop();
|
||||
expect(mockWSClient.implementation.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockWSClient.implementation.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(mockHTTP.implementation.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockWS.implementation.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Websocket interaction', async () => {
|
||||
@@ -133,28 +135,28 @@ describe('Frontend', () => {
|
||||
// Message
|
||||
MQTT.publish.mockClear();
|
||||
mockWSClient.implementation.send.mockClear();
|
||||
mockWSClient.events.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}))
|
||||
mockWSClient.events.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}), false)
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color',
|
||||
stringify({state: 'ON', linkquality: null}),
|
||||
stringify({state: 'ON', linkquality: null, update_available: null}),
|
||||
{ retain: false, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
mockWSClient.events.message(undefined);
|
||||
mockWSClient.events.message("");
|
||||
mockWSClient.events.message(null);
|
||||
mockWSClient.events.message(undefined, false);
|
||||
mockWSClient.events.message("", false);
|
||||
mockWSClient.events.message(null, false);
|
||||
await flushPromises();
|
||||
|
||||
// Received message on socket
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledTimes(1);
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'bulb_color', payload: {state: 'ON', linkquality: null}}));
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'bulb_color', payload: {state: 'ON', linkquality: null, update_available: null}}));
|
||||
|
||||
// Shouldnt set when not ready
|
||||
mockWSClient.implementation.send.mockClear();
|
||||
mockWSClient.implementation.readyState = 'close';
|
||||
mockWSClient.events.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}))
|
||||
mockWSClient.events.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}), false)
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Send last seen on connect
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ describe('Groups', () => {
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
+151
-161
@@ -8,25 +8,38 @@ const MQTT = require('./stub/mqtt');
|
||||
const Controller = require('../lib/controller');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const HomeAssistant = require('../lib/extension/homeassistant');
|
||||
|
||||
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
|
||||
|
||||
describe('HomeAssistant extension', () => {
|
||||
let version;
|
||||
let controller;
|
||||
let extension;
|
||||
|
||||
let resetExtension = async () => {
|
||||
await controller.enableDisableExtension(false, 'HomeAssistant');
|
||||
MQTT.publish.mockClear();
|
||||
await controller.enableDisableExtension(true, 'HomeAssistant');
|
||||
extension = controller.extensions.find((e) => e.constructor.name === 'HomeAssistant');
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
version = await require('../lib/util/utils').getZigbee2mqttVersion();
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
data.writeEmptyState();
|
||||
controller.state.load();
|
||||
await resetExtension();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
version = await require('../lib/util/utils').default.getZigbee2MQTTVersion();
|
||||
version = `Zigbee2MQTT ${version.version}`;
|
||||
jest.useFakeTimers();
|
||||
settings.set(['homeassistant'], true);
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
data.writeEmptyState();
|
||||
MQTT.publish.mockClear();
|
||||
settings.set(['homeassistant'], true);
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
controller = new Controller(false);
|
||||
await controller.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -35,10 +48,8 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
it('Should not have duplicate type/object_ids in a mapping', () => {
|
||||
const duplicated = [];
|
||||
const ha = new HomeAssistant(null, null, null, null, {on: () => {}});
|
||||
|
||||
require('zigbee-herdsman-converters').devices.forEach((d) => {
|
||||
const mapping = ha._getMapping()[d.model];
|
||||
const mapping = extension._getMapping()[d.model];
|
||||
const cfg_type_object_ids = [];
|
||||
|
||||
mapping.forEach((c) => {
|
||||
@@ -54,9 +65,6 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should discover devices and groups', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
|
||||
let payload;
|
||||
await flushPromises();
|
||||
|
||||
@@ -132,6 +140,7 @@ describe('HomeAssistant extension', () => {
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability': [{topic: 'zigbee2mqtt/bridge/state'}],
|
||||
'enabled_by_default': true,
|
||||
};
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
@@ -150,6 +159,7 @@ describe('HomeAssistant extension', () => {
|
||||
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'name': 'weather_sensor_humidity',
|
||||
'unique_id': '0x0017880104e45522_humidity_zigbee2mqtt',
|
||||
'enabled_by_default': true,
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
@@ -176,6 +186,7 @@ describe('HomeAssistant extension', () => {
|
||||
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'name': 'weather_sensor_pressure',
|
||||
'unique_id': '0x0017880104e45522_pressure_zigbee2mqtt',
|
||||
'enabled_by_default': true,
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
@@ -202,6 +213,7 @@ describe('HomeAssistant extension', () => {
|
||||
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'name': 'weather_sensor_battery',
|
||||
'unique_id': '0x0017880104e45522_battery_zigbee2mqtt',
|
||||
'enabled_by_default': true,
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
@@ -247,11 +259,7 @@ describe('HomeAssistant extension', () => {
|
||||
);
|
||||
|
||||
payload = {
|
||||
"availability":[
|
||||
{
|
||||
"topic":"zigbee2mqtt/bridge/state"
|
||||
}
|
||||
],
|
||||
"availability":[{"topic":"zigbee2mqtt/bridge/state"}],
|
||||
"command_topic":"zigbee2mqtt/wall_switch_double/left/set",
|
||||
"device":{
|
||||
"identifiers":[
|
||||
@@ -279,11 +287,7 @@ describe('HomeAssistant extension', () => {
|
||||
);
|
||||
|
||||
payload = {
|
||||
"availability":[
|
||||
{
|
||||
"topic":"zigbee2mqtt/bridge/state"
|
||||
}
|
||||
],
|
||||
"availability":[{"topic":"zigbee2mqtt/bridge/state"}],
|
||||
"command_topic":"zigbee2mqtt/wall_switch_double/right/set",
|
||||
"device":{
|
||||
"identifiers":[
|
||||
@@ -311,11 +315,7 @@ describe('HomeAssistant extension', () => {
|
||||
);
|
||||
|
||||
payload = {
|
||||
"availability":[
|
||||
{
|
||||
"topic":"zigbee2mqtt/bridge/state"
|
||||
}
|
||||
],
|
||||
"availability":[{"topic":"zigbee2mqtt/bridge/state"}],
|
||||
"brightness":true,
|
||||
"brightness_scale":254,
|
||||
"color_mode": true,
|
||||
@@ -365,8 +365,7 @@ describe('HomeAssistant extension', () => {
|
||||
retain: false,
|
||||
})
|
||||
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await resetExtension();
|
||||
|
||||
let payload;
|
||||
await flushPromises();
|
||||
@@ -375,6 +374,7 @@ describe('HomeAssistant extension', () => {
|
||||
'unit_of_measurement': '°C',
|
||||
'device_class': 'temperature',
|
||||
'state_class': 'measurement',
|
||||
'enabled_by_default': true,
|
||||
'value_template': "{{ value_json.temperature }}",
|
||||
'state_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
|
||||
@@ -406,6 +406,7 @@ describe('HomeAssistant extension', () => {
|
||||
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'name': 'weather_sensor_humidity',
|
||||
'unique_id': '0x0017880104e45522_humidity_zigbee2mqtt',
|
||||
'enabled_by_default': true,
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
@@ -431,6 +432,7 @@ describe('HomeAssistant extension', () => {
|
||||
'state_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'name': 'weather_sensor_pressure',
|
||||
'enabled_by_default': true,
|
||||
'unique_id': '0x0017880104e45522_pressure_zigbee2mqtt',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
@@ -474,8 +476,7 @@ describe('HomeAssistant extension', () => {
|
||||
retain: false,
|
||||
})
|
||||
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await resetExtension();
|
||||
|
||||
let payload;
|
||||
await flushPromises();
|
||||
@@ -488,6 +489,7 @@ describe('HomeAssistant extension', () => {
|
||||
'state_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'name': 'weather_sensor_temperature',
|
||||
'enabled_by_default': true,
|
||||
'unique_id': '0x0017880104e45522_temperature_zigbee2mqtt',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
@@ -516,6 +518,7 @@ describe('HomeAssistant extension', () => {
|
||||
'state_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'name': 'weather_sensor_humidity',
|
||||
'enabled_by_default': true,
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
@@ -552,8 +555,7 @@ describe('HomeAssistant extension', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await resetExtension();
|
||||
|
||||
let payload;
|
||||
await flushPromises();
|
||||
@@ -595,9 +597,7 @@ describe('HomeAssistant extension', () => {
|
||||
retain: false,
|
||||
})
|
||||
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
|
||||
await resetExtension();
|
||||
await flushPromises();
|
||||
|
||||
const topics = MQTT.publish.mock.calls.map((c) => c[0]);
|
||||
@@ -613,10 +613,7 @@ describe('HomeAssistant extension', () => {
|
||||
retain: false,
|
||||
})
|
||||
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
|
||||
const topics = MQTT.publish.mock.calls.map((c) => c[0]);
|
||||
expect(topics).not.toContain('homeassistant/sensor/0x0017880104e45522/humidity/config')
|
||||
@@ -624,11 +621,7 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should discover devices with fan', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
|
||||
let payload;
|
||||
await flushPromises();
|
||||
|
||||
payload = {
|
||||
"state_topic":"zigbee2mqtt/fan",
|
||||
@@ -670,11 +663,7 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should discover thermostat devices', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
|
||||
let payload;
|
||||
await flushPromises();
|
||||
|
||||
payload = {
|
||||
'availability': [{topic: 'zigbee2mqtt/bridge/state'}],
|
||||
@@ -730,11 +719,7 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should discover devices with cover_position', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
|
||||
let payload;
|
||||
await flushPromises();
|
||||
|
||||
payload = {
|
||||
command_topic: 'zigbee2mqtt/smart vent/set',
|
||||
@@ -766,11 +751,9 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
it('Should discover devices with custom homeassistant_discovery_topic', async () => {
|
||||
settings.set(['advanced', 'homeassistant_discovery_topic'], 'my_custom_discovery_topic')
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await resetExtension();
|
||||
|
||||
let payload;
|
||||
await flushPromises();
|
||||
|
||||
payload = {
|
||||
'unit_of_measurement': '°C',
|
||||
@@ -779,6 +762,7 @@ describe('HomeAssistant extension', () => {
|
||||
'value_template': '{{ value_json.temperature }}',
|
||||
'state_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'enabled_by_default': true,
|
||||
'name': 'weather_sensor_temperature',
|
||||
'unique_id': '0x0017880104e45522_temperature_zigbee2mqtt',
|
||||
'device': {
|
||||
@@ -801,6 +785,7 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
it('Should throw error when starting with attributes output', async () => {
|
||||
settings.set(['experimental', 'output'], 'attribute')
|
||||
settings.set(['homeassistant'], true)
|
||||
expect(() => {
|
||||
const controller = new Controller(false);
|
||||
}).toThrowError('Home Assistant integration is not possible with attribute output!');
|
||||
@@ -809,22 +794,19 @@ describe('HomeAssistant extension', () => {
|
||||
it('Should warn when starting with cache_state false', async () => {
|
||||
settings.set(['advanced', 'cache_state'], false);
|
||||
logger.warn.mockClear();
|
||||
const controller = new Controller(false);
|
||||
await resetExtension();
|
||||
expect(logger.warn).toHaveBeenCalledWith("In order for Home Assistant integration to work properly set `cache_state: true");
|
||||
});
|
||||
|
||||
it('Should set missing values to null', async () => {
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/6987
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
|
||||
const data = {measuredValue: -85}
|
||||
const payload = {data, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
MQTT.publish.mockClear();
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(3);
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/weather_sensor',
|
||||
stringify({"battery":null,"humidity":null,"linkquality":null,"pressure":null,"temperature":-0.85,"voltage":null}),
|
||||
@@ -834,9 +816,6 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should copy hue/saturtion to h/s if present', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const data = {currentHue: 0, currentSaturation: 254}
|
||||
const payload = {data, cluster: 'lightingColorCtrl', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
@@ -846,16 +825,13 @@ describe('HomeAssistant extension', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color',
|
||||
stringify({"color":{"hue": 0, "saturation": 100, "h": 0, "s": 100}, "color_mode": "hs", "linkquality": null, "state": null}),
|
||||
stringify({"color":{"hue": 0, "saturation": 100, "h": 0, "s": 100}, "color_mode": "hs", "linkquality": null, "state": null, "update_available": null}),
|
||||
{ retain: false, qos: 0 },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should not copy hue/saturtion if properties are missing', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const data = {currentX: 29991, currentY: 26872};
|
||||
const payload = {data, cluster: 'lightingColorCtrl', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
@@ -865,16 +841,13 @@ describe('HomeAssistant extension', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color',
|
||||
stringify({"color": {"x": 0.4576,"y": 0.41}, "color_mode": "xy", "linkquality": null,"state": null}),
|
||||
stringify({"color": {"x": 0.4576,"y": 0.41}, "color_mode": "xy", "linkquality": null,"state": null, "update_available": null}),
|
||||
{ retain: false, qos: 0 },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should not copy hue/saturtion if color is missing', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const data = {onOff: 1}
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
@@ -884,16 +857,13 @@ describe('HomeAssistant extension', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color',
|
||||
stringify({"linkquality": null,"state": "ON"}),
|
||||
stringify({"linkquality": null,"state": "ON", "update_available": null}),
|
||||
{ retain: false, qos: 0 },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Shouldt discover when already discovered', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
|
||||
const data = {measuredValue: -85}
|
||||
const payload = {data, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
@@ -905,9 +875,6 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should discover when not discovered yet', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
controller.extensions.find((e) => e.constructor.name === 'HomeAssistant').discovered = {};
|
||||
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
|
||||
const data = {measuredValue: -85}
|
||||
@@ -918,6 +885,7 @@ describe('HomeAssistant extension', () => {
|
||||
const payloadHA = {
|
||||
'unit_of_measurement': '°C',
|
||||
'device_class': 'temperature',
|
||||
'enabled_by_default': true,
|
||||
'state_class': 'measurement',
|
||||
'value_template': '{{ value_json.temperature }}',
|
||||
'state_topic': 'zigbee2mqtt/weather_sensor',
|
||||
@@ -943,9 +911,6 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Shouldnt discover when device leaves', async () => {
|
||||
const controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
controller.extensions.find((e) => e.constructor.name === 'HomeAssistant').discovered = {};
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const payload = {ieeeAddr: device.ieeeAddr};
|
||||
@@ -956,8 +921,8 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
it('Should send all status when home assistant comes online (default topic)', async () => {
|
||||
data.writeDefaultState();
|
||||
const controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
extension.state.load();
|
||||
await resetExtension();
|
||||
expect(MQTT.subscribe).toHaveBeenCalledWith('homeassistant/status');
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
@@ -967,13 +932,13 @@ describe('HomeAssistant extension', () => {
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb',
|
||||
stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"power_on_behavior":null}),
|
||||
stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"power_on_behavior":null, "update_available": null}),
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/remote',
|
||||
stringify({"action":null,"battery":null,"brightness":255,"linkquality":null}),
|
||||
stringify({"action":null,"battery":null,"brightness":255,"linkquality":null, "update_available": null}),
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
@@ -981,9 +946,8 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
it('Should send all status when home assistant comes online', async () => {
|
||||
data.writeDefaultState();
|
||||
const controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
extension.state.load();
|
||||
await resetExtension();
|
||||
expect(MQTT.subscribe).toHaveBeenCalledWith('hass/status');
|
||||
MQTT.publish.mockClear();
|
||||
await MQTT.events.message('hass/status', 'online');
|
||||
@@ -992,13 +956,13 @@ describe('HomeAssistant extension', () => {
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb',
|
||||
stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"power_on_behavior":null}),
|
||||
stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"power_on_behavior":null, "update_available": null}),
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/remote',
|
||||
stringify({"action":null,"battery":null,"brightness":255,"linkquality":null}),
|
||||
stringify({"action":null,"battery":null,"brightness":255,"linkquality":null, "update_available": null}),
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function)
|
||||
);
|
||||
@@ -1006,8 +970,8 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
it('Shouldnt send all status when home assistant comes offline', async () => {
|
||||
data.writeDefaultState();
|
||||
const controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
extension.state.load();
|
||||
await resetExtension();
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
await MQTT.events.message('hass/status', 'offline');
|
||||
@@ -1019,9 +983,8 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
it('Shouldnt send all status when home assistant comes online with different topic', async () => {
|
||||
data.writeDefaultState();
|
||||
const controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
extension.state.load();
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
await MQTT.events.message('hass/status_different', 'offline');
|
||||
await flushPromises();
|
||||
@@ -1031,16 +994,15 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should discover devices with availability', async () => {
|
||||
settings.set(['advanced', 'availability_timeout'], 1)
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
settings.set(['availability'], true)
|
||||
await resetExtension();
|
||||
|
||||
let payload;
|
||||
await flushPromises();
|
||||
|
||||
payload = {
|
||||
'unit_of_measurement': '°C',
|
||||
'device_class': 'temperature',
|
||||
'enabled_by_default': true,
|
||||
'state_class': 'measurement',
|
||||
'value_template': '{{ value_json.temperature }}',
|
||||
'state_topic': 'zigbee2mqtt/weather_sensor',
|
||||
@@ -1054,6 +1016,7 @@ describe('HomeAssistant extension', () => {
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
},
|
||||
'availability_mode': 'all',
|
||||
'availability': [{topic: 'zigbee2mqtt/bridge/state'}, {topic: 'zigbee2mqtt/weather_sensor/availability'}],
|
||||
};
|
||||
|
||||
@@ -1066,9 +1029,6 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should clear discovery when device is removed', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/remove', 'weather_sensor');
|
||||
await flushPromises();
|
||||
@@ -1106,9 +1066,6 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should not clear discovery when unsupported device is removed', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/remove', 'unsupported2');
|
||||
await flushPromises();
|
||||
@@ -1116,9 +1073,6 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should refresh discovery when device is renamed', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await MQTT.events.message('homeassistant/device_automation/0x0017880104e45522/action_double/config', stringify({topic: 'zigbee2mqtt/weather_sensor/action'}));
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
@@ -1129,6 +1083,7 @@ describe('HomeAssistant extension', () => {
|
||||
'unit_of_measurement': '°C',
|
||||
'device_class': 'temperature',
|
||||
'state_class': 'measurement',
|
||||
'enabled_by_default': true,
|
||||
'value_template': '{{ value_json.temperature }}',
|
||||
'state_topic': 'zigbee2mqtt/weather_sensor_renamed',
|
||||
'json_attributes_topic': 'zigbee2mqtt/weather_sensor_renamed',
|
||||
@@ -1182,9 +1137,6 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Shouldnt refresh discovery when device is renamed and homeassistant_rename is false', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({"from": "weather_sensor", "to": "weather_sensor_renamed","homeassistant_rename":false}));
|
||||
await flushPromises();
|
||||
@@ -1200,6 +1152,7 @@ describe('HomeAssistant extension', () => {
|
||||
'unit_of_measurement': '°C',
|
||||
'device_class': 'temperature',
|
||||
'state_class': 'measurement',
|
||||
'enabled_by_default': true,
|
||||
'value_template': '{{ value_json.temperature }}',
|
||||
'state_topic': 'zigbee2mqtt/weather_sensor_renamed',
|
||||
'json_attributes_topic': 'zigbee2mqtt/weather_sensor_renamed',
|
||||
@@ -1224,9 +1177,6 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should discover update_available sensor when device supports it', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
const payload = {
|
||||
"payload_on":true,
|
||||
"payload_off":false,
|
||||
@@ -1257,10 +1207,6 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should discover trigger when click is published', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
const discovered = MQTT.publish.mock.calls.filter((c) => c[0].includes('0x0017880104e45520')).map((c) => c[0]);
|
||||
expect(discovered.length).toBe(5);
|
||||
expect(discovered).toContain('homeassistant/sensor/0x0017880104e45520/click/config');
|
||||
@@ -1344,7 +1290,7 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/button',
|
||||
stringify({action: "", battery: null, linkquality: null, voltage: null}),
|
||||
stringify({action: "", battery: null, linkquality: null, voltage: null, click: null}),
|
||||
{ retain: false, qos: 0 },
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -1413,9 +1359,7 @@ describe('HomeAssistant extension', () => {
|
||||
settings.set(['device_options'], {
|
||||
homeassistant: {device_automation: null},
|
||||
})
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
const device = zigbeeHerdsman.devices.WXKG11LM;
|
||||
@@ -1437,9 +1381,7 @@ describe('HomeAssistant extension', () => {
|
||||
friendly_name: 'weather_sensor',
|
||||
retain: false,
|
||||
})
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
|
||||
const discovered = MQTT.publish.mock.calls.filter((c) => c[0].includes('0x0017880104e45520')).map((c) => c[0]);
|
||||
expect(discovered.length).toBe(4);
|
||||
@@ -1450,9 +1392,7 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
it('Should disable Home Assistant legacy triggers', async () => {
|
||||
settings.set(['advanced', 'homeassistant_legacy_triggers'], false);
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
|
||||
const discovered = MQTT.publish.mock.calls.filter((c) => c[0].includes('0x0017880104e45520')).map((c) => c[0]);
|
||||
expect(discovered.length).toBe(3);
|
||||
@@ -1509,9 +1449,6 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should republish payload to postfix topic with lightWithPostfix config', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await MQTT.events.message('zigbee2mqtt/U202DST600ZB/l2/set', stringify({state: 'ON', brightness: 20}));
|
||||
@@ -1522,9 +1459,6 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Shouldnt crash in onPublishEntityState on group publish', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
logger.error.mockClear();
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
@@ -1534,9 +1468,6 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should counter an action payload with an empty payload', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
const device = zigbeeHerdsman.devices.WXKG11LM;
|
||||
settings.set(['devices', device.ieeeAddr, 'legacy'], false);
|
||||
@@ -1546,10 +1477,10 @@ describe('HomeAssistant extension', () => {
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(4);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({action: 'single', battery: null, linkquality: null, voltage: null});
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({action: 'single', click: null, battery: null, linkquality: null, voltage: null});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false});
|
||||
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/button');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({action: '', battery: null, linkquality: null, voltage: null});
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({action: '', click: null, battery: null, linkquality: null, voltage: null});
|
||||
expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({"qos": 0, "retain": false});
|
||||
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('homeassistant/device_automation/0x0017880104e45520/action_single/config');
|
||||
expect(MQTT.publish.mock.calls[3][0]).toStrictEqual('zigbee2mqtt/button/action');
|
||||
@@ -1558,10 +1489,7 @@ describe('HomeAssistant extension', () => {
|
||||
it('Load Home Assistant mapping from external converters', async () => {
|
||||
fs.copyFileSync(path.join(__dirname, 'assets', 'mock-external-converter-multiple.js'), path.join(data.mockDir, 'mock-external-converter-multiple.js'));
|
||||
settings.set(['external_converters'], ['mock-external-converter-multiple.js']);
|
||||
const controller = new Controller(jest.fn(), jest.fn());
|
||||
const ha = controller.extensions.find((e) => e.constructor.name === 'HomeAssistant');
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
|
||||
const homeassistantSwitch = {
|
||||
type: 'switch',
|
||||
@@ -1573,14 +1501,10 @@ describe('HomeAssistant extension', () => {
|
||||
command_topic: true,
|
||||
},
|
||||
};
|
||||
expect(ha._getMapping()['external_converters_device_1']).toEqual([homeassistantSwitch]);
|
||||
expect(extension._getMapping()['external_converters_device_1']).toEqual([homeassistantSwitch]);
|
||||
});
|
||||
|
||||
it('Should clear outdated configs', async () => {
|
||||
let controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
// Non-existing group -> clear
|
||||
MQTT.publish.mockClear();
|
||||
await MQTT.events.message('homeassistant/light/1221051039810110150109113116116_91231/light/config', stringify({availability: [{topic: 'zigbee2mqtt/bridge/state'}]}));
|
||||
@@ -1653,12 +1577,8 @@ describe('HomeAssistant extension', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Device was flagged to be excluded from homeassistant discovery
|
||||
await controller.stop();
|
||||
await flushPromises();
|
||||
settings.set(['devices', '0x000b57fffec6a5b2', 'homeassistant'], null);
|
||||
controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
await MQTT.events.message('homeassistant/sensor/0x000b57fffec6a5b2/update_available/config', stringify({availability: [{topic: 'zigbee2mqtt/bridge/state'}]}));
|
||||
@@ -1672,8 +1592,7 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
it('Should not have Home Assistant legacy entity attributes when disabled', async () => {
|
||||
settings.set(['advanced', 'homeassistant_legacy_entity_attributes'], false);
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await resetExtension();
|
||||
|
||||
let payload;
|
||||
await flushPromises();
|
||||
@@ -1686,6 +1605,7 @@ describe('HomeAssistant extension', () => {
|
||||
'state_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'name': 'weather_sensor_temperature',
|
||||
'unique_id': '0x0017880104e45522_temperature_zigbee2mqtt',
|
||||
'enabled_by_default': true,
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
@@ -1705,9 +1625,6 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should rediscover group when device is added to it', async () => {
|
||||
const controller = new Controller(false);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'ha_discovery_group', device: 'wall_switch_double/left'}));
|
||||
await flushPromises();
|
||||
@@ -1743,4 +1660,77 @@ describe('HomeAssistant extension', () => {
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should discover last_seen when enabled', async () => {
|
||||
settings.set(['advanced', 'last_seen'], ' epoch');
|
||||
await resetExtension();
|
||||
|
||||
const payload = {
|
||||
"availability":[
|
||||
{
|
||||
"topic":"zigbee2mqtt/bridge/state"
|
||||
}
|
||||
],
|
||||
"device":{
|
||||
"identifiers":[
|
||||
"zigbee2mqtt_0x000b57fffec6a5b2"
|
||||
],
|
||||
"manufacturer":"IKEA",
|
||||
"model":"TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white (LED1545G12)",
|
||||
"name":"bulb",
|
||||
"sw_version":version
|
||||
},
|
||||
"enabled_by_default":false,
|
||||
"icon":"mdi:clock",
|
||||
"json_attributes_topic":"zigbee2mqtt/bulb",
|
||||
"name":"bulb last seen",
|
||||
"state_topic":"zigbee2mqtt/bulb",
|
||||
"unique_id":"0x000b57fffec6a5b2_last_seen_zigbee2mqtt",
|
||||
"value_template":"{{ value_json.last_seen }}"
|
||||
};
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/sensor/0x000b57fffec6a5b2/last_seen/config',
|
||||
stringify(payload),
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should discover devices with configuration url', async () => {
|
||||
settings.set(['frontend', 'url'], 'http://zigbee.mqtt');
|
||||
|
||||
await resetExtension();
|
||||
|
||||
let payload;
|
||||
await flushPromises();
|
||||
|
||||
payload = {
|
||||
'unit_of_measurement': '°C',
|
||||
'device_class': 'temperature',
|
||||
'state_class': 'measurement',
|
||||
'enabled_by_default': true,
|
||||
'value_template': "{{ value_json.temperature }}",
|
||||
'state_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
|
||||
'name': 'weather_sensor_temperature',
|
||||
'unique_id': '0x0017880104e45522_temperature_zigbee2mqtt',
|
||||
'device': {
|
||||
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
|
||||
'name': 'weather_sensor',
|
||||
'sw_version': version,
|
||||
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
|
||||
'manufacturer': 'Xiaomi',
|
||||
'configuration_url': 'http://zigbee.mqtt/#/device/0x0017880104e45522/info'
|
||||
},
|
||||
'availability': [{topic: 'zigbee2mqtt/bridge/state'}],
|
||||
};
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/sensor/0x0017880104e45522/temperature/config',
|
||||
stringify(payload),
|
||||
{ retain: true, qos: 0 },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('Bridge legacy', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
version = await require('../../lib/util/utils').getZigbee2mqttVersion();
|
||||
version = await require('../../lib/util/utils').default.getZigbee2MQTTVersion();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
})
|
||||
@@ -89,37 +89,37 @@ describe('Bridge legacy', () => {
|
||||
it('Should allow changing device options', async () => {
|
||||
const bulb_color = zigbeeHerdsman.devices.bulb_color;
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual(
|
||||
{"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "retain": false}
|
||||
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": false}
|
||||
);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', options: {retain: true}}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual(
|
||||
{"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "retain": true}
|
||||
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": true}
|
||||
);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', optionswrong: {retain: true}}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual(
|
||||
{"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "retain": true}
|
||||
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": true}
|
||||
);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', "{friendly_name: 'bulb_color'malformed: {retain: true}}");
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual(
|
||||
{"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "retain": true}
|
||||
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": true}
|
||||
);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', options: {random_setting: true}}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual(
|
||||
{"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "random_setting": true, "retain": true}
|
||||
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "random_setting": true, "retain": true}
|
||||
);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', options: {options: {random_1: true}}}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual(
|
||||
{"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "random_setting": true, "retain": true, options: {random_1: true}}
|
||||
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "random_setting": true, "retain": true, options: {random_1: true}}
|
||||
);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', options: {options: {random_2: false}}}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual(
|
||||
{"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "random_setting": true, "retain": true, options: {random_1: true, random_2: false}}
|
||||
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "random_setting": true, "retain": true, options: {random_1: true, random_2: false}}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -197,9 +197,9 @@ describe('Bridge legacy', () => {
|
||||
});
|
||||
|
||||
it('Should allow rename devices', async () => {
|
||||
const bulb_color2 = {"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color2", "friendly_name": "bulb_color2", "retain": false};
|
||||
const bulb_color2 = {"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color2", "retain": false};
|
||||
MQTT.publish.mockClear();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "retain": false});
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": false});
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/rename', stringify({old: 'bulb_color', new: 'bulb_color2'}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual(null);
|
||||
@@ -226,10 +226,10 @@ describe('Bridge legacy', () => {
|
||||
|
||||
it('Should allow rename groups', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
expect(settings.getGroup(1)).toStrictEqual({"ID": 1, devices: [], friendlyName: "group_1", "friendly_name": "group_1", retain: false});
|
||||
expect(settings.getGroup(1)).toStrictEqual({"ID": 1, devices: [], "friendly_name": "group_1", retain: false});
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/rename', stringify({old: 'group_1', new: 'group_1_renamed'}));
|
||||
await flushPromises();
|
||||
expect(settings.getGroup(1)).toStrictEqual({"ID": 1, devices: [], friendlyName: "group_1_renamed", "friendly_name": "group_1_renamed", retain: false});
|
||||
expect(settings.getGroup(1)).toStrictEqual({"ID": 1, devices: [], "friendly_name": "group_1_renamed", retain: false});
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'group_renamed', message: {from: 'group_1', to: 'group_1_renamed'}}),
|
||||
@@ -243,10 +243,10 @@ describe('Bridge legacy', () => {
|
||||
const payload = {device};
|
||||
await zigbeeHerdsman.events.deviceJoined(payload);
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendlyName).toStrictEqual('bulb');
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendly_name).toStrictEqual('bulb');
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/rename_last', 'bulb_new_name');
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendlyName).toStrictEqual('bulb_new_name');
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendly_name).toStrictEqual('bulb_new_name');
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'device_renamed', message: {from: 'bulb', to: 'bulb_new_name'}}),
|
||||
@@ -259,10 +259,10 @@ describe('Bridge legacy', () => {
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendlyName).toStrictEqual('bulb');
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendly_name).toStrictEqual('bulb');
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/rename_last', 'bulb_new_name');
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendlyName).toStrictEqual('bulb');
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendly_name).toStrictEqual('bulb');
|
||||
});
|
||||
|
||||
it('Should allow to add groups', async () => {
|
||||
@@ -275,7 +275,7 @@ describe('Bridge legacy', () => {
|
||||
{qos: 0, retain: false},
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(settings.getGroup('new_group')).toStrictEqual({"ID": 3, "friendlyName": "new_group", "friendly_name": "new_group", devices: []});
|
||||
expect(settings.getGroup('new_group')).toStrictEqual({"ID": 3, "friendly_name": "new_group", devices: []});
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(3);
|
||||
});
|
||||
@@ -284,7 +284,7 @@ describe('Bridge legacy', () => {
|
||||
zigbeeHerdsman.createGroup.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"friendly_name": "new_group"}');
|
||||
await flushPromises();
|
||||
expect(settings.getGroup('new_group')).toStrictEqual({"ID": 3, "friendlyName": "new_group", "friendly_name": "new_group", devices: []});
|
||||
expect(settings.getGroup('new_group')).toStrictEqual({"ID": 3, "friendly_name": "new_group", devices: []});
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(3);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
@@ -299,7 +299,7 @@ describe('Bridge legacy', () => {
|
||||
zigbeeHerdsman.createGroup.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"friendly_name": "new_group", "id": 42}');
|
||||
await flushPromises();
|
||||
expect(settings.getGroup('new_group')).toStrictEqual({"ID": 42, "friendlyName": "new_group", "friendly_name": "new_group", devices: []});
|
||||
expect(settings.getGroup('new_group')).toStrictEqual({"ID": 42, "friendly_name": "new_group", devices: []});
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(42);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
@@ -314,7 +314,7 @@ describe('Bridge legacy', () => {
|
||||
zigbeeHerdsman.createGroup.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"id": 42}');
|
||||
await flushPromises();
|
||||
expect(settings.getGroup('group_42')).toStrictEqual({"ID": 42, "friendlyName": "group_42", "friendly_name": "group_42", devices: []});
|
||||
expect(settings.getGroup('group_42')).toStrictEqual({"ID": 42, "friendly_name": "group_42", devices: []});
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(42)
|
||||
});
|
||||
@@ -443,7 +443,7 @@ describe('Bridge legacy', () => {
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "retain": false})
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": false})
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -457,7 +457,7 @@ describe('Bridge legacy', () => {
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({"ID": "0x000b57fffec6a5b3", "friendlyName": "bulb_color", "friendly_name": "bulb_color", "retain": false})
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": false})
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('Report', () => {
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
extension.queue = new Set();
|
||||
extension.failed = new Set();
|
||||
await extension.onZigbeeStarted();
|
||||
await extension.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -97,7 +97,7 @@ describe('Report', () => {
|
||||
});
|
||||
|
||||
it('Should configure reporting on startup', async () => {
|
||||
await extension.onZigbeeStarted();
|
||||
await extension.start();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
expectOnOffBrightnessColorReport(endpoint, true);
|
||||
@@ -109,7 +109,7 @@ describe('Report', () => {
|
||||
mockClear(device);
|
||||
delete device.meta.report;
|
||||
extension.enabled = false;
|
||||
await extension.onZigbeeStarted();
|
||||
await extension.start();
|
||||
expect(device.meta.reporting).toBe(undefined);
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -120,7 +120,7 @@ describe('Report', () => {
|
||||
const endpoint = device.getEndpoint(1);
|
||||
extension.enabled = false;
|
||||
mockClear(device);
|
||||
await extension.onZigbeeStarted();
|
||||
await extension.start();
|
||||
expectOnOffBrightnessColorReportDisabled(endpoint, true);
|
||||
});
|
||||
|
||||
|
||||
+44
-21
@@ -24,14 +24,14 @@ describe('Logger', () => {
|
||||
});
|
||||
|
||||
it('Create log directory', () => {
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
const dirs = fs.readdirSync(dir.name);
|
||||
expect(dirs.length).toBe(1);
|
||||
});
|
||||
|
||||
it('Should cleanup', () => {
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
@@ -48,7 +48,7 @@ describe('Logger', () => {
|
||||
})
|
||||
|
||||
it('Should not cleanup when there is no timestamp set', () => {
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
for (let i = 30; i < 40; i++) {
|
||||
fs.mkdirSync(path.join(dir.name, `log_${i}`));
|
||||
@@ -61,7 +61,7 @@ describe('Logger', () => {
|
||||
})
|
||||
|
||||
it('Set and get log level', () => {
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
logger.setLevel('debug');
|
||||
expect(logger.getLevel()).toBe('debug');
|
||||
@@ -73,24 +73,24 @@ describe('Logger', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const logger = require('../lib/util/logger.js');
|
||||
expect(logger.transports.length).toBe(2);
|
||||
const logger = require('../lib/util/logger').default;
|
||||
expect(logger.winston.transports.length).toBe(2);
|
||||
logger.addTransport(new DummyTransport());
|
||||
expect(logger.transports.length).toBe(3);
|
||||
expect(logger.winston.transports.length).toBe(3);
|
||||
});
|
||||
|
||||
it('Set and get log level warn <-> warning', () => {
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
logger.setLevel('warn');
|
||||
expect(logger.transports[0].level).toBe('warning');
|
||||
expect(logger.winston.transports[0].level).toBe('warning');
|
||||
expect(logger.getLevel()).toBe('warn');
|
||||
});
|
||||
|
||||
it('Logger should be console and file by default', () => {
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
const pipes = logger._readableState.pipes;
|
||||
const pipes = logger.winston._readableState.pipes;
|
||||
expect(pipes.length).toBe(2);
|
||||
expect(pipes[0].constructor.name).toBe('Console');
|
||||
expect(pipes[0].silent).toBe(false);
|
||||
@@ -100,9 +100,9 @@ describe('Logger', () => {
|
||||
|
||||
it('Logger can be file only', () => {
|
||||
settings.set(['advanced', 'log_output'], ['file']);
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
const pipes = logger._readableState.pipes;
|
||||
const pipes = logger.winston._readableState.pipes;
|
||||
expect(pipes.length).toBe(2);
|
||||
expect(pipes[0].constructor.name).toBe('Console');
|
||||
expect(pipes[0].silent).toBe(true);
|
||||
@@ -112,27 +112,27 @@ describe('Logger', () => {
|
||||
|
||||
it('Logger can be console only', () => {
|
||||
settings.set(['advanced', 'log_output'], ['console']);
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
const pipes = logger._readableState.pipes;
|
||||
const pipes = logger.winston._readableState.pipes;
|
||||
expect(pipes.constructor.name).toBe('Console');
|
||||
expect(pipes.silent).toBe(false);
|
||||
});
|
||||
|
||||
it('Logger can be nothing', () => {
|
||||
settings.set(['advanced', 'log_output'], []);
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
const pipes = logger._readableState.pipes;
|
||||
const pipes = logger.winston._readableState.pipes;
|
||||
expect(pipes.constructor.name).toBe('Console');
|
||||
expect(pipes.silent).toBe(true);
|
||||
});
|
||||
|
||||
it('Should allow to disable log rotation', () => {
|
||||
settings.set(['advanced', 'log_rotation'], false);
|
||||
const logger = require('../lib/util/logger.js');
|
||||
const logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
const pipes = logger._readableState.pipes;
|
||||
const pipes = logger.winston._readableState.pipes;
|
||||
expect(pipes[1].constructor.name).toBe('File');
|
||||
expect(pipes[1].maxFiles).toBeNull();
|
||||
expect(pipes[1].tailable).toBeFalsy();
|
||||
@@ -141,11 +141,34 @@ describe('Logger', () => {
|
||||
|
||||
it('Should allow to symlink logs to current directory', () => {
|
||||
settings.set(['advanced', 'log_symlink_current'], true);
|
||||
let logger = require('../lib/util/logger.js');
|
||||
let logger = require('../lib/util/logger').default;
|
||||
logger.logOutput();
|
||||
expect(fs.readdirSync(dir.name).includes('current')).toBeTruthy()
|
||||
|
||||
jest.resetModules();
|
||||
logger = require('../lib/util/logger.js');
|
||||
logger = require('../lib/util/logger').default;
|
||||
});
|
||||
|
||||
it('Log', () => {
|
||||
const logger = require('../lib/util/logger').default;
|
||||
const warn = jest.spyOn(logger.winston, 'warning');
|
||||
logger.warn('warn');
|
||||
expect(warn).toHaveBeenCalledWith('warn');
|
||||
|
||||
const debug = jest.spyOn(logger.winston, 'debug');
|
||||
logger.debug('debug');
|
||||
expect(debug).toHaveBeenCalledWith('debug');
|
||||
|
||||
const warning = jest.spyOn(logger.winston, 'warning');
|
||||
logger.warning('warning');
|
||||
expect(warning).toHaveBeenCalledWith('warning');
|
||||
|
||||
const info = jest.spyOn(logger.winston, 'info');
|
||||
logger.info('info');
|
||||
expect(info).toHaveBeenCalledWith('info');
|
||||
|
||||
const error = jest.spyOn(logger.winston, 'error');
|
||||
logger.error('error');
|
||||
expect(error).toHaveBeenCalledWith('error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,10 +66,7 @@ describe('On event', () => {
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device});
|
||||
await flushPromises();
|
||||
expect(mockOnEvent).toHaveBeenCalledTimes(1);
|
||||
const call = mockOnEvent.mock.calls[0];
|
||||
expect(call[0]).toBe('deviceAnnounce')
|
||||
expect(call[1]).toStrictEqual({device})
|
||||
expect(call[2]).toBe(device);
|
||||
expect(mockOnEvent).toHaveBeenCalledWith('deviceAnnounce', {device}, device, settings.getDevice(device.ieeeAddr));
|
||||
});
|
||||
|
||||
it('Should call index onEvent with zigbee event', async () => {
|
||||
@@ -77,9 +74,6 @@ describe('On event', () => {
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device});
|
||||
await flushPromises();
|
||||
expect(zigbeeHerdsmanConverters.onEvent).toHaveBeenCalledTimes(1);
|
||||
const call = zigbeeHerdsmanConverters.onEvent.mock.calls[0];
|
||||
expect(call[0]).toBe('deviceAnnounce')
|
||||
expect(call[1]).toStrictEqual({device})
|
||||
expect(call[2]).toBe(device);
|
||||
expect(zigbeeHerdsmanConverters.onEvent).toHaveBeenCalledWith('deviceAnnounce', {device}, device);
|
||||
});
|
||||
});
|
||||
|
||||
+47
-23
@@ -186,6 +186,17 @@ describe('Publish', () => {
|
||||
expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({"qos": 0, "retain": false});
|
||||
});
|
||||
|
||||
it('Should publish messages to zigbee devices with endpoint ID', async () => {
|
||||
const device = zigbeeHerdsman.devices.QBKG03LM;
|
||||
const endpoint = device.getEndpoint(3);
|
||||
await MQTT.events.message('zigbee2mqtt/wall_switch_double/3/set', stringify({state: 'OFF'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genOnOff", "off", {}, {});
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/wall_switch_double', stringify({state_right: 'OFF'}),
|
||||
{"qos": 0, "retain": false}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should publish messages to zigbee devices to non default-ep with state_[EP]', async () => {
|
||||
const device = zigbeeHerdsman.devices.QBKG03LM;
|
||||
const endpoint = device.getEndpoint(3);
|
||||
@@ -404,13 +415,14 @@ describe('Publish', () => {
|
||||
});
|
||||
|
||||
it('Should create and publish to group which is in configuration.yaml but not in zigbee-herdsman', async () => {
|
||||
delete zigbeeHerdsman.groups.group_2;
|
||||
expect(Object.values(zigbeeHerdsman.groups).length).toBe(9);
|
||||
await MQTT.events.message('zigbee2mqtt/group_2/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
settings.addGroup('group_12312', 12312);
|
||||
expect(Object.values(zigbeeHerdsman.groups).length).toBe(10);
|
||||
expect(zigbeeHerdsman.groups.group_2.command).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.groups.group_2.command).toHaveBeenCalledWith("genOnOff", "on", {}, {});
|
||||
await MQTT.events.message('zigbee2mqtt/group_12312/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
expect(Object.values(zigbeeHerdsman.groups).length).toBe(11);
|
||||
expect(zigbeeHerdsman.groups.group_12312.command).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.groups.group_12312.command).toHaveBeenCalledWith("genOnOff", "on", {}, {});
|
||||
delete zigbeeHerdsman.groups.group_12312;
|
||||
});
|
||||
|
||||
it('Shouldnt publish new state when optimistic = false', async () => {
|
||||
@@ -495,7 +507,17 @@ describe('Publish', () => {
|
||||
expect(endpoint3.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
});
|
||||
|
||||
it('Should log error when device has no such endpoint', async () => {
|
||||
it('Should log error when device has no such endpoint (via topic)', async () => {
|
||||
const device = zigbeeHerdsman.devices.QBKG03LM;
|
||||
const endpoint2 = device.getEndpoint(2);
|
||||
logger.error.mockClear();
|
||||
await MQTT.events.message('zigbee2mqtt/0x0017880104e45542/center/get', stringify({state: ''}));
|
||||
await flushPromises();
|
||||
expect(logger.error).toHaveBeenCalledWith(`Device 'wall_switch_double' has no endpoint 'center'`);
|
||||
expect(endpoint2.read).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should log error when device has no such endpoint (via property)', async () => {
|
||||
const device = zigbeeHerdsman.devices.QBKG03LM;
|
||||
const endpoint2 = device.getEndpoint(2);
|
||||
const endpoint3 = device.getEndpoint(3);
|
||||
@@ -1083,10 +1105,10 @@ describe('Publish', () => {
|
||||
|
||||
it('Home Assistant: should not set state when color temperature is also set and device is already on', async () => {
|
||||
settings.set(['homeassistant'], true);
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
controller.state.remove(device.ieeeAddr);
|
||||
controller.state.set(device.ieeeAddr, {state: 'ON'})
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const device = controller.zigbee.resolveEntity(zigbeeHerdsman.devices.bulb_color.ieeeAddr);
|
||||
controller.state.remove(device);
|
||||
controller.state.set(device, {state: 'ON'})
|
||||
const endpoint = device.zh.getEndpoint(1);
|
||||
const payload = {state: 'ON', color_temp: 100};
|
||||
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify(payload));
|
||||
await flushPromises();
|
||||
@@ -1099,10 +1121,10 @@ describe('Publish', () => {
|
||||
|
||||
it('Home Assistant: should set state when color temperature is also set and device is off', async () => {
|
||||
settings.set(['homeassistant'], true);
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
controller.state.remove(device.ieeeAddr);
|
||||
controller.state.set(device.ieeeAddr, {state: 'OFF'})
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const device = controller.zigbee.resolveEntity(zigbeeHerdsman.devices.bulb_color.ieeeAddr);
|
||||
controller.state.remove(device);
|
||||
controller.state.set(device, {state: 'OFF'})
|
||||
const endpoint = device.zh.getEndpoint(1);
|
||||
const payload = {state: 'ON', color_temp: 100};
|
||||
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify(payload));
|
||||
await flushPromises();
|
||||
@@ -1120,10 +1142,10 @@ describe('Publish', () => {
|
||||
|
||||
it('Home Assistant: should not set state when color is also set', async () => {
|
||||
settings.set(['homeassistant'], true);
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
controller.state.remove(device.ieeeAddr);
|
||||
controller.state.set(device.ieeeAddr, {state: 'ON'})
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const device = controller.zigbee.resolveEntity(zigbeeHerdsman.devices.bulb_color.ieeeAddr);
|
||||
controller.state.remove(device);
|
||||
controller.state.set(device, {state: 'ON'})
|
||||
const endpoint = device.zh.getEndpoint(1);
|
||||
const payload = {state: 'ON', color: {x: 0.41, y: 0.25}};
|
||||
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify(payload));
|
||||
await flushPromises();
|
||||
@@ -1217,7 +1239,7 @@ describe('Publish', () => {
|
||||
await MQTT.events.message('zigbee2mqtt/siren/set', stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("ssIasWd", "startWarning", {"startwarninginfo": 22, "warningduration": 100}, {disableDefaultResponse: true});
|
||||
expect(endpoint.command).toHaveBeenCalledWith("ssIasWd", "startWarning", {"startwarninginfo": 22, "warningduration": 100, "strobedutycycle": 0, "strobelevel": 1}, {disableDefaultResponse: true});
|
||||
});
|
||||
|
||||
it('HS2WD-E emergency warning', async () => {
|
||||
@@ -1226,7 +1248,7 @@ describe('Publish', () => {
|
||||
await MQTT.events.message('zigbee2mqtt/siren/set', stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("ssIasWd", "startWarning", {"startwarninginfo": 51, "warningduration": 10}, {disableDefaultResponse: true});
|
||||
expect(endpoint.command).toHaveBeenCalledWith("ssIasWd", "startWarning", {"startwarninginfo": 51, "warningduration": 10, "strobedutycycle": 0, "strobelevel": 1}, {disableDefaultResponse: true});
|
||||
});
|
||||
|
||||
it('HS2WD-E emergency without level', async () => {
|
||||
@@ -1235,7 +1257,7 @@ describe('Publish', () => {
|
||||
await MQTT.events.message('zigbee2mqtt/siren/set', stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("ssIasWd", "startWarning", {"startwarninginfo": 49, "warningduration": 10}, {disableDefaultResponse: true});
|
||||
expect(endpoint.command).toHaveBeenCalledWith("ssIasWd", "startWarning", {"startwarninginfo": 49, "warningduration": 10, "strobedutycycle": 0, "strobelevel": 1}, {disableDefaultResponse: true});
|
||||
});
|
||||
|
||||
it('HS2WD-E wrong payload (should use defaults)', async () => {
|
||||
@@ -1244,7 +1266,7 @@ describe('Publish', () => {
|
||||
await MQTT.events.message('zigbee2mqtt/siren/set', stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("ssIasWd", "startWarning", {"startwarninginfo": 53, "warningduration": 10}, {disableDefaultResponse: true});
|
||||
expect(endpoint.command).toHaveBeenCalledWith("ssIasWd", "startWarning", {"startwarninginfo": 53, "warningduration": 10, "strobedutycycle": 0, "strobelevel": 1}, {disableDefaultResponse: true});
|
||||
});
|
||||
|
||||
it('Shouldnt do anythign when device is not supported', async () => {
|
||||
@@ -1390,6 +1412,8 @@ describe('Publish', () => {
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(1);
|
||||
expect(group.command).toHaveBeenCalledWith('genScenes', 'store', { groupid: 15071, sceneid: 1 }, {});
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
|
||||
|
||||
await MQTT.events.message('zigbee2mqtt/bulb_color_2/set', stringify({"state": "ON", "brightness": 250, "color_temp": 20}));
|
||||
await MQTT.events.message('zigbee2mqtt/bulb_2/set', stringify({"state": "ON", "brightness": 110}));
|
||||
|
||||
+1
-14
@@ -71,7 +71,7 @@ describe('Receive', () => {
|
||||
const payload = {data, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(3);
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: -0.85});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 1, "retain": false});
|
||||
@@ -330,19 +330,6 @@ describe('Receive', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should publish last_seen for unhandled messages', async () => {
|
||||
const device = zigbeeHerdsman.devices.WXKG02LM_rev1;
|
||||
settings.set(['advanced', 'last_seen'], 'epoch');
|
||||
const data = {onOff: 1};
|
||||
const payload = {data, cluster: 'genRssiLocation', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
|
||||
expect(typeof JSON.parse(MQTT.publish.mock.calls[0][1]).last_seen).toBe('number')
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false});
|
||||
});
|
||||
|
||||
it('Should publish last_seen epoch', async () => {
|
||||
const device = zigbeeHerdsman.devices.WXKG02LM_rev1;
|
||||
settings.set(['advanced', 'last_seen'], 'epoch');
|
||||
|
||||
+3
-11
@@ -1,7 +1,7 @@
|
||||
require('./stub/logger');
|
||||
require('./stub/data');
|
||||
const data = require('../lib/util/data.js');
|
||||
const utils = require('../lib/util/utils.js');
|
||||
const data = require('../lib/util/data');
|
||||
const utils = require('../lib/util/utils').default;
|
||||
const settings = require('../lib/util/settings.ts');
|
||||
const fs = require('fs');
|
||||
const configurationFile = data.joinPath('configuration.yaml');
|
||||
@@ -67,7 +67,6 @@ describe('Settings', () => {
|
||||
process.env['ZIGBEE2MQTT_CONFIG_SERIAL_DISABLE_LED'] = 'true';
|
||||
process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_SOFT_RESET_TIMEOUT'] = 1;
|
||||
process.env['ZIGBEE2MQTT_CONFIG_EXPERIMENTAL_OUTPUT'] = 'csvtest';
|
||||
process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_AVAILABILITY_BLOCKLIST'] = '["0x43597f0dac781b1e", "x223b0aef2ae8d1b0"]';
|
||||
process.env['ZIGBEE2MQTT_CONFIG_MAP_OPTIONS_GRAPHVIZ_COLORS_FILL'] = '{"enddevice": "#ff0000", "coordinator": "#00ff00", "router": "#0000ff"}';
|
||||
process.env['ZIGBEE2MQTT_CONFIG_MQTT_BASE_TOPIC'] = 'testtopic';
|
||||
|
||||
@@ -79,7 +78,6 @@ describe('Settings', () => {
|
||||
expected.serial.disable_led = true;
|
||||
expected.advanced.soft_reset_timeout = 1;
|
||||
expected.experimental.output = 'csvtest';
|
||||
expected.advanced.availability_blocklist = ['0x43597f0dac781b1e', 'x223b0aef2ae8d1b0'];
|
||||
expected.map_options.graphviz.colors.fill = {enddevice: '#ff0000', coordinator: '#00ff00', router: '#0000ff'};
|
||||
expected.mqtt.base_topic = 'testtopic';
|
||||
|
||||
@@ -117,7 +115,6 @@ describe('Settings', () => {
|
||||
const device = settings.getDevice('0x12345678');
|
||||
const expected = {
|
||||
ID: "0x12345678",
|
||||
friendlyName: "0x12345678",
|
||||
friendly_name: '0x12345678',
|
||||
retain: false,
|
||||
};
|
||||
@@ -190,7 +187,6 @@ describe('Settings', () => {
|
||||
const device = settings.getDevice('0x12345678');
|
||||
const expected = {
|
||||
ID: "0x12345678",
|
||||
friendlyName: "0x12345678",
|
||||
friendly_name: '0x12345678',
|
||||
retain: false,
|
||||
};
|
||||
@@ -349,7 +345,6 @@ describe('Settings', () => {
|
||||
const group = settings.getGroup('1');
|
||||
const expected = {
|
||||
ID: 1,
|
||||
friendlyName: '123',
|
||||
friendly_name: '123',
|
||||
devices: [],
|
||||
};
|
||||
@@ -374,7 +369,6 @@ describe('Settings', () => {
|
||||
const group = settings.getGroup('1');
|
||||
const expected = {
|
||||
ID: 1,
|
||||
friendlyName: '123',
|
||||
friendly_name: '123',
|
||||
devices: [],
|
||||
};
|
||||
@@ -419,7 +413,6 @@ describe('Settings', () => {
|
||||
const group = settings.getGroup('1');
|
||||
const expectedGroup = {
|
||||
ID: 1,
|
||||
friendlyName: '123',
|
||||
friendly_name: '123',
|
||||
devices: [],
|
||||
};
|
||||
@@ -430,7 +423,6 @@ describe('Settings', () => {
|
||||
|
||||
const expectedDevice2 = {
|
||||
ID: '0x1234',
|
||||
friendlyName: '0x1234',
|
||||
friendly_name: '0x1234',
|
||||
};
|
||||
|
||||
@@ -794,7 +786,7 @@ describe('Settings', () => {
|
||||
|
||||
settings.reRead();
|
||||
|
||||
const error = `Following friendly_name are not allowed: '${utils.getEndpointNames()}'`;
|
||||
const error = `Following friendly_name are not allowed: '${utils.endpointNames}'`;
|
||||
expect(settings.validate()).toEqual(expect.arrayContaining([error]));
|
||||
});
|
||||
|
||||
|
||||
+1
-2
@@ -1,11 +1,10 @@
|
||||
const tmp = require('tmp');
|
||||
const yaml = require('../../lib/util/yaml');
|
||||
const yaml = require('../../lib/util/yaml').default;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
|
||||
const mockDir = tmp.dirSync().name;
|
||||
const mockDirStorage = tmp.dirSync().name;
|
||||
const stateFile = path.join(mockDir, 'state.json');
|
||||
|
||||
function writeDefaultConfiguration() {
|
||||
|
||||
+14
-8
@@ -5,13 +5,7 @@ const mock = {
|
||||
end: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
reconnecting: false,
|
||||
on: (type, handler) => {
|
||||
if (type === 'connect') {
|
||||
handler();
|
||||
}
|
||||
|
||||
events[type] = handler
|
||||
},
|
||||
on: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConnect = jest.fn().mockReturnValue(mock);
|
||||
@@ -20,6 +14,18 @@ jest.mock('mqtt', () => {
|
||||
return {connect: mockConnect};
|
||||
});
|
||||
|
||||
const restoreOnMock = () => {
|
||||
mock.on.mockImplementation((type, handler) => {
|
||||
if (type === 'connect') {
|
||||
handler();
|
||||
}
|
||||
|
||||
events[type] = handler
|
||||
});
|
||||
}
|
||||
|
||||
restoreOnMock();
|
||||
|
||||
module.exports = {
|
||||
events, ...mock, connect: mockConnect, mock,
|
||||
events, ...mock, connect: mockConnect, mock, restoreOnMock
|
||||
};
|
||||
@@ -31,7 +31,7 @@ const clusters = {
|
||||
}
|
||||
|
||||
class Endpoint {
|
||||
constructor(ID, inputClusters, outputClusters, deviceIeeeAddress, binds=[], clusterValues={}, configuredReportings=[], profileID=null, deviceID=null) {
|
||||
constructor(ID, inputClusters, outputClusters, deviceIeeeAddress, binds=[], clusterValues={}, configuredReportings=[], profileID=null, deviceID=null, meta={}) {
|
||||
this.deviceIeeeAddress = deviceIeeeAddress;
|
||||
this.clusterValues = clusterValues;
|
||||
this.ID = ID;
|
||||
@@ -45,7 +45,7 @@ class Endpoint {
|
||||
this.unbind = jest.fn();
|
||||
this.save = jest.fn();
|
||||
this.configureReporting = jest.fn();
|
||||
this.meta = {};
|
||||
this.meta = meta;
|
||||
this.binds = binds;
|
||||
this.profileID = profileID;
|
||||
this.deviceID = deviceID;
|
||||
@@ -127,7 +127,7 @@ class Device {
|
||||
const returnDevices = [];
|
||||
|
||||
const bulb_color = new Device('Router', '0x000b57fffec6a5b3', 40399, 4107, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5b3', [], {lightingColorCtrl: {colorCapabilities: 254}})], true, "Mains (single phase)", "LLC020");
|
||||
const bulb_color_2 = new Device('Router', '0x000b57fffec6a5b4', 401292, 4107, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5b4')], true, "Mains (single phase)", "LLC020", false, 'Philips', '2019.09', '5.127.1.26581');
|
||||
const bulb_color_2 = new Device('Router', '0x000b57fffec6a5b4', 401292, 4107, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5b4', [], {lightingColorCtrl: {colorCapabilities: 254}}, [], null, null, {'scenes': {'1_0': {name: 'Chill scene', state: {state: 'ON'}}, '4_9': {state: {state: 'OFF'}}}})], true, "Mains (single phase)", "LLC020", false, 'Philips', '2019.09', '5.127.1.26581');
|
||||
const bulb_2 = new Device('Router', '0x000b57fffec6a5b7', 40369, 4476, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5b7', [], {lightingColorCtrl: {colorCapabilities: 17}})], true, "Mains (single phase)", "TRADFRI bulb E27 WS opal 980lm");
|
||||
const TS0601_thermostat = new Device('EndDevice', '0x0017882104a44559', 6544,4151, [new Endpoint(1, [], [], '0x0017882104a44559')], true, "Mains (single phase)", 'kud7u2l');
|
||||
const ZNCZ02LM = new Device('Router', '0x0017880104e45524', 6540,4151, [new Endpoint(1, [0], [], '0x0017880104e45524')], true, "Mains (single phase)", "lumi.plug");
|
||||
@@ -196,6 +196,8 @@ const devices = {
|
||||
'unknown': new Device('Router', '0x0017980134e45545', 6540,4151, [], true, "Mains (single phase)"),
|
||||
'temperature_sensor': new Device('EndDevice', '0x0017880104e45561', 6544,4151, [new Endpoint(1, [0,3,4,1026], [])], true, "Battery", "temperature.sensor"),
|
||||
'heating_actuator': new Device('Router', '0x0017880104e45562', 6545,4151, [new Endpoint(1, [0,3,4,513], [1026])], true, "Mains (single phase)", "heating.actuator"),
|
||||
'bj_scene_switch': new Device('EndDevice', '0xd85def11a1002caa', 50117, 4398, [new Endpoint(10, [0,4096], [3,4,5,6,8,25,768,4096], '0xd85def11a1002caa', [{target: bulb_color_2.endpoints[0], cluster: {ID: 8, name: 'genLevelCtrl'}}, {target: bulb_color_2.endpoints[0], cluster: {ID: 6, name: 'genOnOff'}}, {target: bulb_color_2.endpoints[0], cluster: {ID: 768, name: 'lightingColorCtrl'}},]), new Endpoint(11, [0,4096], [3,4,5,6,8,25,768,4096], '0xd85def11a1002caa')], true, 'Battery', 'RB01', false, 'Busch-Jaeger', '20161222', '1.2.0'),
|
||||
|
||||
}
|
||||
|
||||
const mock = {
|
||||
|
||||
+3
-35
@@ -1,29 +1,8 @@
|
||||
const utils = require('../lib/util/utils.js');
|
||||
const utils = require('../lib/util/utils').default;
|
||||
const version = require('../package.json').version;
|
||||
const versionHerdsman = require('../node_modules/zigbee-herdsman/package.json').version;
|
||||
|
||||
describe('Utils', () => {
|
||||
describe('Is xiaomi device', () => {
|
||||
it('Identify xiaomi device', () => {
|
||||
const device = {type: 'Router', manufacturerID: 4151, manufacturerName: 'Xiaomi'};
|
||||
expect(true).toBe(utils.isXiaomiDevice(device));
|
||||
});
|
||||
|
||||
it('Identify xiaomi device without manufacturerName', () => {
|
||||
const device = {type: 'Router', manufacturerID: 4447};
|
||||
expect(true).toBe(utils.isXiaomiDevice(device));
|
||||
});
|
||||
|
||||
it('Identify xiaomi device with different manufacturerName', () => {
|
||||
const device = {type: 'Router', manufacturerID: 4151, manufacturerName: 'Trust International B.V.\u0000'};
|
||||
expect(false).toBe(utils.isXiaomiDevice(device));
|
||||
});
|
||||
});
|
||||
|
||||
it('Convert milliseconds to seconds', () => {
|
||||
expect(utils.millisecondsToSeconds(2000)).toBe(2);
|
||||
})
|
||||
|
||||
it('Object has properties', () => {
|
||||
expect(utils.objectHasProperties({a: 1, b: 2, c: 3}, ['a', 'b'])).toBeTruthy();
|
||||
expect(utils.objectHasProperties({a: 1, b: 2, c: 3}, ['a', 'b', 'd'])).toBeFalsy();
|
||||
@@ -36,10 +15,10 @@ describe('Utils', () => {
|
||||
}));
|
||||
|
||||
mockReturnValue = [false, {shortHash: '123'}]
|
||||
expect(await utils.getZigbee2mqttVersion()).toStrictEqual({"commitHash": "123", "version": version});
|
||||
expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({"commitHash": "123", "version": version});
|
||||
|
||||
mockReturnValue = [true, null]
|
||||
expect(await utils.getZigbee2mqttVersion()).toStrictEqual({"commitHash": expect.any(String), "version": version});
|
||||
expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({"commitHash": expect.any(String), "version": version});
|
||||
})
|
||||
|
||||
it('Check dependency version', async () => {
|
||||
@@ -56,15 +35,4 @@ describe('Utils', () => {
|
||||
expect(utils.formatDate(date, 'ISO_8601_local').endsWith('+01:00')).toBeTruthy();
|
||||
Date.prototype.getTimezoneOffset = getTimezoneOffset;
|
||||
})
|
||||
|
||||
it('Throw exception when formating with invalid date', async () => {
|
||||
var date = new Date('August 19, 1975 23:15:30 UTC+00:00');
|
||||
expect(() => utils.formatDate(date, 'invalid', 1)).toThrowError("Unsupported type 'invalid'")
|
||||
})
|
||||
|
||||
it('Get key', async () => {
|
||||
expect(utils.getKey({'1': '1'}, '1', 2, null)).toBe('1');
|
||||
expect(utils.getKey({'1': '1'}, '2', 2, null)).toBe(2);
|
||||
expect(utils.getKey({'1': '1'}, '1', null, () => '3')).toBe('3');
|
||||
})
|
||||
});
|
||||
|
||||
+3
-7
@@ -15,14 +15,10 @@
|
||||
"rootDir": "lib",
|
||||
"inlineSourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"*": [
|
||||
"node_modules/*",
|
||||
"lib/types/*"
|
||||
]
|
||||
},
|
||||
"experimentalDecorators": true
|
||||
},
|
||||
"include": [
|
||||
"lib/**/*"
|
||||
"lib/**/*",
|
||||
"lib/util/settings.schema.json"
|
||||
],
|
||||
}
|
||||
@@ -7,8 +7,12 @@ if [ -d data-backup ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Stopping Zigbee2MQTT..."
|
||||
sudo systemctl stop zigbee2mqtt
|
||||
if which systemctl 2> /dev/null > /dev/null; then
|
||||
echo "Stopping Zigbee2MQTT..."
|
||||
sudo systemctl stop zigbee2mqtt
|
||||
else
|
||||
echo "Skipped stopping Zigbee2MQTT, no systemctl found"
|
||||
fi
|
||||
|
||||
echo "Creating backup of configuration..."
|
||||
cp -R data data-backup
|
||||
@@ -24,7 +28,11 @@ echo "Restore configuration..."
|
||||
cp -R data-backup/* data
|
||||
rm -rf data-backup
|
||||
|
||||
echo "Starting Zigbee2MQTT..."
|
||||
sudo systemctl start zigbee2mqtt
|
||||
if which systemctl 2> /dev/null > /dev/null; then
|
||||
echo "Starting Zigbee2MQTT..."
|
||||
sudo systemctl start zigbee2mqtt
|
||||
else
|
||||
echo "Skipped starting Zigbee2MQTT, no systemctl found"
|
||||
fi
|
||||
|
||||
echo "Done!"
|
||||
|
||||
Reference in New Issue
Block a user