mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 15:18:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85875aee27 | ||
|
|
7f0a9aa2a3 | ||
|
|
ff528567e6 | ||
|
|
3473aa3f40 | ||
|
|
8ee5b1f03b | ||
|
|
89192f9dec | ||
|
|
a9a6ebe007 | ||
|
|
c7d54ec360 | ||
|
|
c880d36f29 | ||
|
|
e1e856d7da | ||
|
|
b9d9e5e75c | ||
|
|
f8385b8899 | ||
|
|
a77f48f5c4 | ||
|
|
79bcf276f5 | ||
|
|
93dc9a3664 | ||
|
|
1d5184f2bd | ||
|
|
8ecf353dd6 | ||
|
|
8560c11e06 | ||
|
|
4d287eed0c | ||
|
|
8d1240eba1 | ||
|
|
0c43c98306 | ||
|
|
396eb38e4b | ||
|
|
5957085fcc | ||
|
|
02a2fd8fef | ||
|
|
9d566f4c01 | ||
|
|
42d88fe90d | ||
|
|
909d2b32b7 | ||
|
|
061262ff97 | ||
|
|
142754b71c | ||
|
|
c599e19bf2 | ||
|
|
28a9f39dbc | ||
|
|
4e360d0bf2 | ||
|
|
15e385bac6 | ||
|
|
c7dae39799 | ||
|
|
b585e31304 | ||
|
|
bf888de478 | ||
|
|
f1156db227 | ||
|
|
d475aa5be5 | ||
|
+2 |
b71461e03c | ||
|
|
6cce05a514 | ||
|
|
30d311e849 | ||
|
|
402ed6094e | ||
|
|
e381d54f7e | ||
|
|
4b039103c0 | ||
|
|
8d8d37e388 | ||
|
|
09d010ea06 | ||
|
|
ede1dc5951 | ||
|
|
b0b02c30b2 | ||
|
|
9ddbb2a21a | ||
|
|
49b752da35 | ||
|
|
788b0d3993 | ||
|
|
f68834049c | ||
|
|
4afbce62eb | ||
|
|
4f412cb4da | ||
|
|
5a4d6b56ad | ||
|
|
26a0fd4de0 | ||
|
|
8d980ae6ec | ||
|
|
99e06a8d3e | ||
|
|
476efaa6e1 | ||
|
|
10478e797e | ||
|
|
5de47a0d93 | ||
|
|
14bffd58b1 | ||
|
|
999df2913f | ||
|
|
da1f77ec6e | ||
|
|
036ee471dd | ||
|
|
dd1c449796 | ||
|
|
6d5e7e334a | ||
|
|
8e682956e5 | ||
|
|
971ddaa3cb | ||
|
|
ad51d12a2d | ||
|
|
d9e4244f80 | ||
|
|
2bb44f7a8b | ||
|
|
20af50201e | ||
|
|
2e36084852 | ||
|
|
9d266bda9a | ||
|
|
043dd0ff1c | ||
|
|
ee6afaf1c6 | ||
|
|
4815d5149d | ||
|
|
6f0b02e009 | ||
|
|
5e1b7d4830 | ||
|
|
535f75e90c | ||
|
|
d44cdbde4f | ||
|
|
9a41ec2a39 | ||
|
|
1d87d2f234 | ||
|
|
29c757145d | ||
|
|
e68403d8d3 | ||
|
|
a32c8eec36 | ||
|
|
d4522c53ca | ||
|
|
57245e5234 | ||
|
|
4313baca09 | ||
|
|
ed52c49e34 | ||
|
|
a2b81ab894 | ||
|
|
3e60f915ff | ||
|
|
1eaa2f3e75 | ||
|
|
3adf7d5d8e | ||
|
|
1147cf16dd | ||
|
|
97a9b15240 | ||
|
|
3a49c95786 | ||
|
|
0eba0c953d | ||
|
|
4d6269e7e8 | ||
|
|
3727f078ec | ||
|
|
93c9704fcd | ||
|
|
2721b0ea78 | ||
|
|
4c48e9cfbc | ||
|
|
7cb5c58dc0 | ||
|
|
c61f67f061 | ||
|
|
58d98c7263 | ||
|
|
b10fa93700 | ||
|
|
c28a024664 | ||
|
|
8549b37228 | ||
|
|
4c02e3da8f | ||
|
|
ab0894715a | ||
|
|
1688a3b532 | ||
|
|
97694d2241 | ||
|
|
d6088c56db | ||
|
|
b39b8d478e | ||
|
|
01304cfc66 | ||
|
|
1d15c2142f | ||
|
|
3818c50b8a | ||
|
|
7f5f6a5849 | ||
|
|
0025ef87c5 | ||
|
|
3460f157df | ||
|
|
ecb0af4057 | ||
|
|
5db6ae0a19 | ||
|
|
ad3c09036b | ||
|
|
0c89629526 | ||
|
|
4574ffc71a | ||
|
|
1707f3abf3 | ||
|
|
8916fb7b50 | ||
|
|
6e70ef24d0 | ||
|
|
8a3c4bd59f | ||
|
|
c18d1329c8 | ||
|
|
ed1e7b333f | ||
|
|
95c38810a8 | ||
|
|
b70701f8b8 | ||
|
|
6d30fa156c | ||
|
|
9b6035387b | ||
|
|
9ca85dfac8 | ||
|
|
334fa2a60d | ||
|
|
a7f1345a91 | ||
|
|
bae9bb8812 | ||
|
|
740edd8b58 | ||
|
|
267fee3414 | ||
|
|
fa99c6dcca | ||
|
|
91f4b76970 | ||
|
|
fd4282b98d | ||
|
|
403bd4a725 | ||
|
|
ce27ff3cbf | ||
|
|
5f474e03b8 | ||
|
|
110149fa20 | ||
|
|
445890a071 | ||
|
|
3dce8548ce | ||
|
|
4a020fd8de | ||
|
|
d411fc5c69 | ||
|
|
631c4f6a57 | ||
|
|
c02ed09211 | ||
|
|
49b4876768 | ||
|
|
802271ed37 | ||
|
|
42fc6e03a7 | ||
|
|
35d8a5da7f | ||
|
|
72d862f4a0 | ||
|
|
ad0696856a | ||
|
|
a61646d584 | ||
|
|
854df498bd | ||
|
|
e2232e9e91 | ||
|
|
6775c4f17b | ||
|
|
17520f7cfb | ||
|
|
1707bcab88 | ||
|
|
c577c8e5f0 | ||
|
|
7b8efbe2c4 | ||
|
|
459b695f60 | ||
|
|
f26ade4f93 | ||
|
|
3e2aefbcf8 | ||
|
|
ba77ce7c8d | ||
|
|
4c21b66079 | ||
|
|
be774a4d0d | ||
|
|
7b7b988960 | ||
|
|
d6bf5174be | ||
|
|
73225e2436 | ||
|
|
afedab9680 | ||
|
|
53b7eaabf0 | ||
|
|
16d0e56968 | ||
|
|
aea41b5c82 | ||
|
|
564e9ab9eb | ||
|
|
61c6ec6111 | ||
|
|
4794b3c81f | ||
|
|
517b286583 | ||
|
|
aee173d10a | ||
|
|
940fff244c | ||
|
|
8822aedb5e | ||
|
|
e788c520a9 | ||
|
|
39c5bc8c04 | ||
|
|
5c4e43fd10 | ||
|
|
5a6a9cf4d2 | ||
|
|
ad0e91e61e | ||
|
|
5797b56871 | ||
|
|
ea1c5a8f4e | ||
|
|
8099322cc2 | ||
|
|
619b939b14 | ||
|
|
eeed56ef2c | ||
|
|
e9b4a6221b |
@@ -22,13 +22,13 @@ jobs:
|
||||
actions: read # for Nerivec/action-ci-bench
|
||||
pull-requests: write # for Nerivec/action-ci-bench
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
with:
|
||||
# Required for `release: merge dev -> master and promote dev`
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
if: ((github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push') == false
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
|
||||
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
- name: 'Dependency review'
|
||||
uses: actions/dependency-review-action@v4
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Issue Bot
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
contains(github.event.issue.labels.*.name, 'new device support') ||
|
||||
contains(github.event.issue.labels.*.name, 'external converter') ||
|
||||
startsWith(github.event.issue.title, '[New device support]') ||
|
||||
startsWith(github.event.issue.title, '[External Converter]')
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: |
|
||||
scripts
|
||||
path: z2m
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: Koenkk/zigbee-herdsman-converters
|
||||
ref: master
|
||||
fetch-depth: 1
|
||||
path: zhc
|
||||
|
||||
- name: Comment on new device support/external converter issue
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const {newDeviceSupport} = await import("${{ github.workspace }}/z2m/scripts/issueBot.mjs")
|
||||
await newDeviceSupport(github, core, context, "${{ github.workspace }}/zhc")
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
merge-master-to-dev:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: devmasx/merge-branch@master
|
||||
with:
|
||||
type: now
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
name: New Device Support/External Converter Issue Bot
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Comment on new device support/external converter issue
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const newDeviceSupport = issue.title && issue.title.startsWith("[New device support]");
|
||||
const externalConverter = issue.title && issue.title.startsWith("[External Converter]");
|
||||
|
||||
// Only proceed for new device support or external converter issues
|
||||
if (!newDeviceSupport && !externalConverter) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide previous bot comments
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
});
|
||||
|
||||
for (const comment of comments.data) {
|
||||
if (comment.user.type === 'Bot' && comment.user.login === 'github-actions[bot]') {
|
||||
await github.graphql(`
|
||||
mutation {
|
||||
minimizeComment(input: {subjectId: "${comment.node_id}", classifier: OUTDATED}) {
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// Checkout the repo and grep for the manufacturer name
|
||||
const execSync = require('child_process').execSync;
|
||||
execSync(`git clone https://github.com/Koenkk/zigbee-herdsman-converters.git zhc`, {stdio: 'ignore'});
|
||||
|
||||
// Check if Tuya manufacturer name is already supported.
|
||||
const tuyaManufacturerNameRe = /['"](_T\w+_(\w+))['"]/g;
|
||||
const tuyaManufacturerNames = Array.from(issue.body.matchAll(tuyaManufacturerNameRe), m => [m[1], m[2]]);
|
||||
if (tuyaManufacturerNames.length > 0) {
|
||||
for (const [fullName, partialName] of tuyaManufacturerNames) {
|
||||
const fullMatch = (() => { try { return execSync(`grep -r --include="*.ts" "${fullName}" zhc`, {encoding: 'utf8'}); } catch { return undefined; } })();
|
||||
if (fullMatch) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: [
|
||||
`👋 Hi there! The Tuya device with manufacturer name \`${fullName}\` is already supported in the latest dev branch.`,
|
||||
"See this [guide](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html) on how to update, after updating you can remove your external converter.",
|
||||
"",
|
||||
"In case you created the external converter with the goal to extend or fix an issue with the out-of-the-box support, please submit a pull request.",
|
||||
"For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request).",
|
||||
"If you need help with the process, feel free to ask here and we'll be happy to assist."
|
||||
].join('\n')
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const partialMatch = (() => { try { return execSync(`grep -r --include="*.ts" "${partialName}" zhc`, {encoding: 'utf8'}); } catch { return undefined; } })();
|
||||
if (partialMatch) {
|
||||
const candidates = Array.from(partialMatch.matchAll(tuyaManufacturerNameRe), m => m[1]);
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: [
|
||||
`👋 Hi there! A similar Tuya device of which the manufacturer name also ends with \`_${partialName}\` is already supported.`,
|
||||
"This means the device can probably be easily be supported by re-using the existing converter.",
|
||||
"",
|
||||
"I found the following matches: " + candidates.map((c) => `\`${c}\``).join(', '),
|
||||
`Try to stop Z2M, change all occurrences of \`${fullName}\` in the \`data/database.db\` to one of the matches above and start Z2M.`,
|
||||
"Let us know if it works so we can support this device out-of-the-box!",
|
||||
].join('\n')
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check if zigbee model is already supported.
|
||||
const zigbeeModelRe = /zigbeeModel: \[['"](.+)['"]\]/g;
|
||||
const zigbeeModels = Array.from(issue.body.matchAll(zigbeeModelRe), m => m[1]);
|
||||
if (zigbeeModels.length > 0) {
|
||||
for (const zigbeeModel of zigbeeModels) {
|
||||
const fullMatch = (() => { try { return execSync(`grep -r --include="*.ts" '"${zigbeeModel}"' zhc`, {encoding: 'utf8'}); } catch { return undefined; } })();
|
||||
if (fullMatch) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: [
|
||||
`👋 Hi there! The device with zigbee model \`${zigbeeModel}\` is already supported in the latest dev branch.`,
|
||||
"See this [guide](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html) on how to update, after updating you can remove your external converter.",
|
||||
"",
|
||||
"In case you created the external converter with the goal to extend or fix an issue with the out-of-the-box support, please submit a pull request.",
|
||||
"For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request).",
|
||||
"If you need help with the process, feel free to ask here and we'll be happy to assist."
|
||||
].join('\n')
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a request to pull request comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: [
|
||||
"🙏 Thank you for creating this issue and sharing your external converter!",
|
||||
"",
|
||||
"In case all features work, please submit a pull request on this repository so the device can be supported out-of-the-box with the next release.",
|
||||
"For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request).",
|
||||
"",
|
||||
"If **NOT** all features work, please follow the [How To Support new devices](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html)",
|
||||
tuyaManufacturerNames.length > 0 ? "Since this is a Tuya also consider [How To Support new Tuya devices](https://www.zigbee2mqtt.io/advanced/support-new-devices/02_support_new_tuya_devices.html)" : "",
|
||||
"",
|
||||
"If you need help with the process, feel free to ask here and we'll be happy to assist."
|
||||
].join('\n')
|
||||
});
|
||||
@@ -31,18 +31,18 @@ jobs:
|
||||
token: ${{secrets.GH_TOKEN}}
|
||||
|
||||
# Checkout repos
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: koenkk/zigbee2mqtt
|
||||
path: ./z2m
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: koenkk/zigbee2mqtt
|
||||
path: ./z2m-master
|
||||
ref: master
|
||||
|
||||
- name: Restore cache commit-user-lookup.json
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: z2m/scripts/commit-user-lookup.json
|
||||
key: commit-user-lookup-dummy
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{secrets.GH_TOKEN}}
|
||||
- name: Save cache commit-user-lookup.json
|
||||
uses: actions/cache/save@v4
|
||||
uses: actions/cache/save@v5
|
||||
if: always()
|
||||
with:
|
||||
path: z2m/scripts/commit-user-lookup.json
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: dev
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
- run: |
|
||||
pnpm install ${{ github.event.client_payload.package }}@${{ github.event.client_payload.version }} --save-exact
|
||||
pnpm install --no-frozen-lockfile
|
||||
- uses: peter-evans/create-pull-request@v7
|
||||
- uses: peter-evans/create-pull-request@v8
|
||||
id: cpr
|
||||
with:
|
||||
commit-message: 'fix(ignore): update ${{ github.event.client_payload.package }} to ${{ github.event.client_payload.version }}'
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.6.3"
|
||||
".": "2.9.1"
|
||||
}
|
||||
|
||||
+216
@@ -1,5 +1,221 @@
|
||||
# Changelog
|
||||
|
||||
## [2.9.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.9.0...2.9.1) (2026-03-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fix onboarding ([#31228](https://github.com/Koenkk/zigbee2mqtt/issues/31228)) ([7f0a9aa](https://github.com/Koenkk/zigbee2mqtt/commit/7f0a9aa2a30ab79c182d8ea9e875339e9031fdbd))
|
||||
|
||||
## [2.9.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.8.0...2.9.0) (2026-03-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Improve onboarding ([#31152](https://github.com/Koenkk/zigbee2mqtt/issues/31152)) ([8ecf353](https://github.com/Koenkk/zigbee2mqtt/commit/8ecf353dd634cc56f8cbbfbe21646945c262a11e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fix Home Assistant options cannot be updated while running ([#31151](https://github.com/Koenkk/zigbee2mqtt/issues/31151)) ([a77f48f](https://github.com/Koenkk/zigbee2mqtt/commit/a77f48f5c464903712f7143bb6a85ea701a39c5e))
|
||||
* Home Assistant: add `measurement` `state_class` to `_count` entities ([#30978](https://github.com/Koenkk/zigbee2mqtt/issues/30978)) ([d475aa5](https://github.com/Koenkk/zigbee2mqtt/commit/d475aa5be5caa85a5dc3f0883dc40b2bcddc09b2))
|
||||
* Home Assistant: add `state_class: measurement` for `external_humidity` and `occupancy_level` https://github.com/Koenkk/zigbee2mqtt/issues/31022 ([02a2fd8](https://github.com/Koenkk/zigbee2mqtt/commit/02a2fd8fef786be9488c1adc97093b1ec6bff9e1))
|
||||
* Home Assistant: add weather station sensor discovery lookup entries ([#31136](https://github.com/Koenkk/zigbee2mqtt/issues/31136)) ([8d1240e](https://github.com/Koenkk/zigbee2mqtt/commit/8d1240eba161e8eb92ce6fdda757710a07c4456e))
|
||||
* Home Assistant: discover `temperature_probe` as `temperature` `device_class` https://github.com/Koenkk/zigbee2mqtt/issues/30862 ([8d8d37e](https://github.com/Koenkk/zigbee2mqtt/commit/8d8d37e3888cbfa4899831874ee9df6e306ddb52))
|
||||
* Home Assistant: expose siren entity for IAS warning devices ([#31000](https://github.com/Koenkk/zigbee2mqtt/issues/31000)) ([c7dae39](https://github.com/Koenkk/zigbee2mqtt/commit/c7dae397999102ec30c6681bc6a7577e97c8b707))
|
||||
* Home Assistant: set `state_class` `measurement` for `external_temperature` https://github.com/Koenkk/zigbee2mqtt/issues/31022 ([28a9f39](https://github.com/Koenkk/zigbee2mqtt/commit/28a9f39dbcd5a04e5eb5630855de2b82ec676dac))
|
||||
* **ignore:** bump @biomejs/biome from 2.4.1 to 2.4.4 in the minor-patch group ([#31159](https://github.com/Koenkk/zigbee2mqtt/issues/31159)) ([1d5184f](https://github.com/Koenkk/zigbee2mqtt/commit/1d5184f2bd46afa50faa470a97e807888ed81a78))
|
||||
* **ignore:** bump mqtt from 5.14.1 to 5.15.0 in the minor-patch group ([#30918](https://github.com/Koenkk/zigbee2mqtt/issues/30918)) ([4b03910](https://github.com/Koenkk/zigbee2mqtt/commit/4b039103c0a9bd32458011dbc2e7bea0e609d0c7))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#31211](https://github.com/Koenkk/zigbee2mqtt/issues/31211)) ([89192f9](https://github.com/Koenkk/zigbee2mqtt/commit/89192f9decdf41338ec925c88860b560906b3ad4))
|
||||
* **ignore:** bump the minor-patch group with 3 updates ([#31011](https://github.com/Koenkk/zigbee2mqtt/issues/31011)) ([15e385b](https://github.com/Koenkk/zigbee2mqtt/commit/15e385bac6dbfd87869df87f1be9a66cf4db76f1))
|
||||
* **ignore:** bump the minor-patch group with 4 updates ([#31081](https://github.com/Koenkk/zigbee2mqtt/issues/31081)) ([42d88fe](https://github.com/Koenkk/zigbee2mqtt/commit/42d88fe90d6272f2c8241ac7a6e6b2c5ce5a55af))
|
||||
* **ignore:** Changes for new ZHC `definition.version` ([#30402](https://github.com/Koenkk/zigbee2mqtt/issues/30402)) ([b71461e](https://github.com/Koenkk/zigbee2mqtt/commit/b71461e03cd8ceea5365f4c0b2e794e34341325e))
|
||||
* **ignore:** Fix enableDisableExtension ([#31176](https://github.com/Koenkk/zigbee2mqtt/issues/31176)) ([e1e856d](https://github.com/Koenkk/zigbee2mqtt/commit/e1e856d7da9c92952c3de94853794337fea1945d))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.3 ([#30960](https://github.com/Koenkk/zigbee2mqtt/issues/30960)) ([30d311e](https://github.com/Koenkk/zigbee2mqtt/commit/30d311e8494c00e6ade7759a0a2bfdc9ab79022d))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.4 ([#30996](https://github.com/Koenkk/zigbee2mqtt/issues/30996)) ([bf888de](https://github.com/Koenkk/zigbee2mqtt/commit/bf888de4789b01e824ca6b5f82506691b4f689a9))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.5 ([#31117](https://github.com/Koenkk/zigbee2mqtt/issues/31117)) ([5957085](https://github.com/Koenkk/zigbee2mqtt/commit/5957085fcc8d6acdedf426a3f16632f1de55a061))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.6 ([#31149](https://github.com/Koenkk/zigbee2mqtt/issues/31149)) ([8560c11](https://github.com/Koenkk/zigbee2mqtt/commit/8560c11e0667304a643eac907d3c62e35bcc2e23))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.7 ([#31174](https://github.com/Koenkk/zigbee2mqtt/issues/31174)) ([79bcf27](https://github.com/Koenkk/zigbee2mqtt/commit/79bcf276f57454a77ea76b6d8286544abd4d7cca))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.8 ([#31207](https://github.com/Koenkk/zigbee2mqtt/issues/31207)) ([c7d54ec](https://github.com/Koenkk/zigbee2mqtt/commit/c7d54ec360ee34ba4cb062dcd474154fb70467a5))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.9 ([#31215](https://github.com/Koenkk/zigbee2mqtt/issues/31215)) ([8ee5b1f](https://github.com/Koenkk/zigbee2mqtt/commit/8ee5b1f03bdc06062d58913e827f086b6bbcb026))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.121.0 ([#30898](https://github.com/Koenkk/zigbee2mqtt/issues/30898)) ([09d010e](https://github.com/Koenkk/zigbee2mqtt/commit/09d010ea067446d26b6c77411aac78d045dbc6ea))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.122.0 ([#30935](https://github.com/Koenkk/zigbee2mqtt/issues/30935)) ([402ed60](https://github.com/Koenkk/zigbee2mqtt/commit/402ed6094eedb40ecdb841b4b1f7d29d59afc89c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.0.0 ([6cce05a](https://github.com/Koenkk/zigbee2mqtt/commit/6cce05a5141089e24211d85d1d77d09f8d2b36f0))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.1.0 ([#30981](https://github.com/Koenkk/zigbee2mqtt/issues/30981)) ([f1156db](https://github.com/Koenkk/zigbee2mqtt/commit/f1156db227bf937354d6ab4e70d6da59307dedad))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.10.1 ([#31175](https://github.com/Koenkk/zigbee2mqtt/issues/31175)) ([b9d9e5e](https://github.com/Koenkk/zigbee2mqtt/commit/b9d9e5e75c3f175ec0fc69f6315c5ca75a8e8128))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.11.0 ([#31185](https://github.com/Koenkk/zigbee2mqtt/issues/31185)) ([c880d36](https://github.com/Koenkk/zigbee2mqtt/commit/c880d36f29a8f146170cae34bef15b86d01f5889))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.12.0 ([#31208](https://github.com/Koenkk/zigbee2mqtt/issues/31208)) ([a9a6ebe](https://github.com/Koenkk/zigbee2mqtt/commit/a9a6ebe00743b47bd00667ae64d0f0fb25f5e38d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.2.0 ([#30998](https://github.com/Koenkk/zigbee2mqtt/issues/30998)) ([b585e31](https://github.com/Koenkk/zigbee2mqtt/commit/b585e3130431c7c27b18b9779ed038b9e6dd30c6))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.2.1 ([#31015](https://github.com/Koenkk/zigbee2mqtt/issues/31015)) ([4e360d0](https://github.com/Koenkk/zigbee2mqtt/commit/4e360d0bf2fed651f7c801bcce8455dc8cf4d649))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.3.0 ([#31028](https://github.com/Koenkk/zigbee2mqtt/issues/31028)) ([c599e19](https://github.com/Koenkk/zigbee2mqtt/commit/c599e19bf2252b90554ec3a68abd64487acd8882))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.4.0 ([#31036](https://github.com/Koenkk/zigbee2mqtt/issues/31036)) ([142754b](https://github.com/Koenkk/zigbee2mqtt/commit/142754b71cf99ac97d5d25d6a124c90d17bf321d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.5.0 ([#31058](https://github.com/Koenkk/zigbee2mqtt/issues/31058)) ([061262f](https://github.com/Koenkk/zigbee2mqtt/commit/061262ff97c5f30571f77df5b06362023c282fa6))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.6.0 ([#31076](https://github.com/Koenkk/zigbee2mqtt/issues/31076)) ([909d2b3](https://github.com/Koenkk/zigbee2mqtt/commit/909d2b32b7ee7fe9144351b82d174e31fd0bad40))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.7.0 ([#31090](https://github.com/Koenkk/zigbee2mqtt/issues/31090)) ([9d566f4](https://github.com/Koenkk/zigbee2mqtt/commit/9d566f4c015e7a31adaeef3b04bcc3c2312d583f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.8.0 ([#31118](https://github.com/Koenkk/zigbee2mqtt/issues/31118)) ([396eb38](https://github.com/Koenkk/zigbee2mqtt/commit/396eb38e4b9a329a651dec46ec816be537a3c997))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.9.0 ([#31137](https://github.com/Koenkk/zigbee2mqtt/issues/31137)) ([4d287ee](https://github.com/Koenkk/zigbee2mqtt/commit/4d287eed0c2b746e1cfbd18ad61ed7b5129a98d9))
|
||||
* Improve configure attempts lazy+cleanup ([#31126](https://github.com/Koenkk/zigbee2mqtt/issues/31126)) ([0c43c98](https://github.com/Koenkk/zigbee2mqtt/commit/0c43c983063c1aa01b85318c85bf7101f0085430))
|
||||
* Improve startup signals behavior ([#31130](https://github.com/Koenkk/zigbee2mqtt/issues/31130)) ([f8385b8](https://github.com/Koenkk/zigbee2mqtt/commit/f8385b88993cfcf3a27c315999c86b961fd8fbfa))
|
||||
* update zigbee2mqtt-windfront to 2.9.0 ([#31162](https://github.com/Koenkk/zigbee2mqtt/issues/31162)) ([93dc9a3](https://github.com/Koenkk/zigbee2mqtt/commit/93dc9a3664f23a4a13a55e54c16714d721e596d7))
|
||||
|
||||
## [2.8.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.7.2...2.8.0) (2026-02-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Improve OTA ([#30566](https://github.com/Koenkk/zigbee2mqtt/issues/30566)) ([dd1c449](https://github.com/Koenkk/zigbee2mqtt/commit/dd1c44979667360d77f05ac30729011f9f2d6cd6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Home Assisant: set state class to `measurement` for `illuminane_raw` https://github.com/Koenkk/zigbee2mqtt/issues/30439 ([3e60f91](https://github.com/Koenkk/zigbee2mqtt/commit/3e60f915ffe1de640690695ccf6536ad235c336e))
|
||||
* Home Assistant: add `gas` device class ([#30653](https://github.com/Koenkk/zigbee2mqtt/issues/30653)) ([4815d51](https://github.com/Koenkk/zigbee2mqtt/commit/4815d5149d3fbc70fe374fc4aa99213ac5f8a8c7))
|
||||
* Home Assistant: Change `device_class` of `eco2` to `volatile_organic_compounds_parts` ([#30840](https://github.com/Koenkk/zigbee2mqtt/issues/30840)) ([4f412cb](https://github.com/Koenkk/zigbee2mqtt/commit/4f412cb4da6217fcca57b5ea1afc40d34d662e19))
|
||||
* Home Assistant: discover temperature sensor for thermostats ([#30804](https://github.com/Koenkk/zigbee2mqtt/issues/30804)) ([26a0fd4](https://github.com/Koenkk/zigbee2mqtt/commit/26a0fd4de0f39d10a404517f42d4c37aff1eff8e))
|
||||
* Home Assistant: expose group settings override ([#30627](https://github.com/Koenkk/zigbee2mqtt/issues/30627)) ([6f0b02e](https://github.com/Koenkk/zigbee2mqtt/commit/6f0b02e0093cc7fd61d30c00fb1cd61d0cfdf09f))
|
||||
* Home Assistant: mark `load_estimate` as `state_class` `measurement` https://github.com/Koenkk/zigbee-herdsman-converters/issues/11240 ([a32c8ee](https://github.com/Koenkk/zigbee2mqtt/commit/a32c8eec3604a3ad0d16e87edf027581db7f6920))
|
||||
* Home Assistant: treat `Area1-4Occupancy` as `occupancy` ([#30712](https://github.com/Koenkk/zigbee2mqtt/issues/30712)) ([2e36084](https://github.com/Koenkk/zigbee2mqtt/commit/2e36084852e84f26dd375820a87c0a8520d4c3e7))
|
||||
* Home Assistant: Use `temperature_delta` for calibration ([#30784](https://github.com/Koenkk/zigbee2mqtt/issues/30784)) ([8e68295](https://github.com/Koenkk/zigbee2mqtt/commit/8e682956e5977101ed4ca80e795f413a5bfb4f09))
|
||||
* **ignore:** bump @biomejs/biome from 2.3.10 to 2.3.11 in the minor-patch group ([#30511](https://github.com/Koenkk/zigbee2mqtt/issues/30511)) ([ed52c49](https://github.com/Koenkk/zigbee2mqtt/commit/ed52c49e34a200f9adf5deb2065c78fbe8df3189))
|
||||
* **ignore:** bump @biomejs/biome from 2.3.12 to 2.3.13 in the minor-patch group ([#30810](https://github.com/Koenkk/zigbee2mqtt/issues/30810)) ([14bffd5](https://github.com/Koenkk/zigbee2mqtt/commit/14bffd58b1ae90cf5c32b654ddbb7a43d12a36cc))
|
||||
* **ignore:** bump @types/node from 24.10.7 to 24.10.9 in the minor-patch group ([#30713](https://github.com/Koenkk/zigbee2mqtt/issues/30713)) ([043dd0f](https://github.com/Koenkk/zigbee2mqtt/commit/043dd0ff1cc94ffa8ea59f6de674e0363f9f6920))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#30617](https://github.com/Koenkk/zigbee2mqtt/issues/30617)) ([1d87d2f](https://github.com/Koenkk/zigbee2mqtt/commit/1d87d2f2349f9eed6351a032645eec075476f148))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#30790](https://github.com/Koenkk/zigbee2mqtt/issues/30790)) ([da1f77e](https://github.com/Koenkk/zigbee2mqtt/commit/da1f77ec6eb599a480f66cc82edb12acc6c22ca8))
|
||||
* **ignore:** bump zigbee2mqtt-windfront from 2.8.0 to 2.8.1 in the minor-patch group ([#30879](https://github.com/Koenkk/zigbee2mqtt/issues/30879)) ([9ddbb2a](https://github.com/Koenkk/zigbee2mqtt/commit/9ddbb2a21a0d302ed39ac4c658587d9f33c48960))
|
||||
* **ignore:** bump zigbee2mqtt-windfront in the minor-patch group ([8d980ae](https://github.com/Koenkk/zigbee2mqtt/commit/8d980ae6ecd759c6c6fb42fc7fbb1d701a3317fa))
|
||||
* **ignore:** Replace multiple area occupancy fields with camelCase instead of PascalCase ([#30745](https://github.com/Koenkk/zigbee2mqtt/issues/30745)) ([d9e4244](https://github.com/Koenkk/zigbee2mqtt/commit/d9e4244f801aaa24b8347ab684521391df1ce0e2))
|
||||
* **ignore:** update zigbee-herdsman to 8.0.2 ([#30480](https://github.com/Koenkk/zigbee2mqtt/issues/30480)) ([1147cf1](https://github.com/Koenkk/zigbee2mqtt/commit/1147cf16dd62dce5863a93983835c46e8e92e95d))
|
||||
* **ignore:** update zigbee-herdsman to 8.0.3 ([#30632](https://github.com/Koenkk/zigbee2mqtt/issues/30632)) ([d44cdbd](https://github.com/Koenkk/zigbee2mqtt/commit/d44cdbde4f5247f69af4c79fd45a62c03037eca7))
|
||||
* **ignore:** update zigbee-herdsman to 8.1.0 ([#30725](https://github.com/Koenkk/zigbee2mqtt/issues/30725)) ([9d266bd](https://github.com/Koenkk/zigbee2mqtt/commit/9d266bda9a34efbe726f8d984da48e5ba3c46a83))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.0 ([#30787](https://github.com/Koenkk/zigbee2mqtt/issues/30787)) ([971ddaa](https://github.com/Koenkk/zigbee2mqtt/commit/971ddaa3cbc2132150f7cde4cdbe0575349eabac))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.1 ([#30788](https://github.com/Koenkk/zigbee2mqtt/issues/30788)) ([6d5e7e3](https://github.com/Koenkk/zigbee2mqtt/commit/6d5e7e334a9a560b9f6293cf448ad84de8504b67))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.2 ([10478e7](https://github.com/Koenkk/zigbee2mqtt/commit/10478e797e1733f9bf66defce0e84b6cc1f23020))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.100.0 ([#30499](https://github.com/Koenkk/zigbee2mqtt/issues/30499)) ([a2b81ab](https://github.com/Koenkk/zigbee2mqtt/commit/a2b81ab89491f8babf40001404969817f19c5b5e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.101.0 ([#30520](https://github.com/Koenkk/zigbee2mqtt/issues/30520)) ([4313bac](https://github.com/Koenkk/zigbee2mqtt/commit/4313baca09b8476466b8134c551b59f92996b53f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.102.0 ([#30537](https://github.com/Koenkk/zigbee2mqtt/issues/30537)) ([57245e5](https://github.com/Koenkk/zigbee2mqtt/commit/57245e52343568678a4dace9bf569c05f508decc))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.103.0 ([#30552](https://github.com/Koenkk/zigbee2mqtt/issues/30552)) ([d4522c5](https://github.com/Koenkk/zigbee2mqtt/commit/d4522c53caca86ba3a087fa67839e2ebf5d9deca))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.104.0 ([#30564](https://github.com/Koenkk/zigbee2mqtt/issues/30564)) ([e68403d](https://github.com/Koenkk/zigbee2mqtt/commit/e68403d8d3ac68f2d09df066d1be8db87f2e7977))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.105.0 ([#30605](https://github.com/Koenkk/zigbee2mqtt/issues/30605)) ([29c7571](https://github.com/Koenkk/zigbee2mqtt/commit/29c757145d6ed16a8629b68379cd85fabe45a8f1))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.106.0 ([#30631](https://github.com/Koenkk/zigbee2mqtt/issues/30631)) ([9a41ec2](https://github.com/Koenkk/zigbee2mqtt/commit/9a41ec2a39ca2373dbdcb15b09aa0eb09116c31e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.107.0 ([#30662](https://github.com/Koenkk/zigbee2mqtt/issues/30662)) ([535f75e](https://github.com/Koenkk/zigbee2mqtt/commit/535f75e90ca936afcac0c79e4c2452dce7b9b08d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.108.0 ([#30672](https://github.com/Koenkk/zigbee2mqtt/issues/30672)) ([5e1b7d4](https://github.com/Koenkk/zigbee2mqtt/commit/5e1b7d4830b5597eb993f32e3a6d1edd84b48e4c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.109.0 ([#30709](https://github.com/Koenkk/zigbee2mqtt/issues/30709)) ([ee6afaf](https://github.com/Koenkk/zigbee2mqtt/commit/ee6afaf1c608b89efef452d7cb337345b035d76a))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.110.0 ([#30726](https://github.com/Koenkk/zigbee2mqtt/issues/30726)) ([20af502](https://github.com/Koenkk/zigbee2mqtt/commit/20af50201ef1e081223ee8b2f0b6304777c7b7f1))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.111.0 ([#30739](https://github.com/Koenkk/zigbee2mqtt/issues/30739)) ([2bb44f7](https://github.com/Koenkk/zigbee2mqtt/commit/2bb44f7a8b8bd9fc1a21b127b764a5d697e65c3e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.112.0 ([#30767](https://github.com/Koenkk/zigbee2mqtt/issues/30767)) ([ad51d12](https://github.com/Koenkk/zigbee2mqtt/commit/ad51d12a2d1325e0b0c1b56a633b229f0f3d048b))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.113.0 ([#30789](https://github.com/Koenkk/zigbee2mqtt/issues/30789)) ([036ee47](https://github.com/Koenkk/zigbee2mqtt/commit/036ee471ddfda094a3ae3f970dedb5600c9d8808))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.114.0 ([#30803](https://github.com/Koenkk/zigbee2mqtt/issues/30803)) ([999df29](https://github.com/Koenkk/zigbee2mqtt/commit/999df2913ff7e47fe9438ed9398a0922c0b40916))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.115.0 ([#30826](https://github.com/Koenkk/zigbee2mqtt/issues/30826)) ([99e06a8](https://github.com/Koenkk/zigbee2mqtt/commit/99e06a8d3ea66ed2a6acf6cddece2f2070cce6ae))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.116.0 ([#30839](https://github.com/Koenkk/zigbee2mqtt/issues/30839)) ([5a4d6b5](https://github.com/Koenkk/zigbee2mqtt/commit/5a4d6b56ad2ea62378b7dd161eba31916f08c65b))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.117.0 ([#30849](https://github.com/Koenkk/zigbee2mqtt/issues/30849)) ([4afbce6](https://github.com/Koenkk/zigbee2mqtt/commit/4afbce62ebc667fc7d94b8b23c0bb0125f065d79))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.118.0 ([#30865](https://github.com/Koenkk/zigbee2mqtt/issues/30865)) ([f688340](https://github.com/Koenkk/zigbee2mqtt/commit/f68834049cde265f731ab271b50fcfa6f85fa971))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.119.0 ([#30874](https://github.com/Koenkk/zigbee2mqtt/issues/30874)) ([788b0d3](https://github.com/Koenkk/zigbee2mqtt/commit/788b0d3993c877adc0361a4b2f9d2df79cea0b8d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.120.0 ([#30876](https://github.com/Koenkk/zigbee2mqtt/issues/30876)) ([49b752d](https://github.com/Koenkk/zigbee2mqtt/commit/49b752da3527920dcdfae6a617f4aa084f6a8901))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.99.0 ([#30481](https://github.com/Koenkk/zigbee2mqtt/issues/30481)) ([3adf7d5](https://github.com/Koenkk/zigbee2mqtt/commit/3adf7d5d8ec6da3fabbd9f293d5d0bcaef3c9144))
|
||||
* Log error before renaming failed to load converters ([#30436](https://github.com/Koenkk/zigbee2mqtt/issues/30436)) ([1eaa2f3](https://github.com/Koenkk/zigbee2mqtt/commit/1eaa2f3e759351bc746dec2ccc57b92cdcf1b5d7))
|
||||
* Network map: escape double-quotes and backslashes in device attributes ([#30746](https://github.com/Koenkk/zigbee2mqtt/issues/30746)) ([5de47a0](https://github.com/Koenkk/zigbee2mqtt/commit/5de47a0d93f5fdc187e4f8a7489580a107a2fd49))
|
||||
* OTA availability detection ([#30815](https://github.com/Koenkk/zigbee2mqtt/issues/30815)) ([476efaa](https://github.com/Koenkk/zigbee2mqtt/commit/476efaa6e18a1d2ea069ea9c128536356df547ac))
|
||||
|
||||
## [2.7.2](https://github.com/Koenkk/zigbee2mqtt/compare/2.7.1...2.7.2) (2026-01-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Biome floating promises detection ([#30137](https://github.com/Koenkk/zigbee2mqtt/issues/30137)) ([0025ef8](https://github.com/Koenkk/zigbee2mqtt/commit/0025ef87c502970b94e1b720516d42631e21323c))
|
||||
* Don't fail to start when removing old log directory fails ([#30216](https://github.com/Koenkk/zigbee2mqtt/issues/30216)) ([b39b8d4](https://github.com/Koenkk/zigbee2mqtt/commit/b39b8d478e99c5a5ee5a3a610547168b3724ca91))
|
||||
* **ignore:** bump @types/node from 24.10.2 to 24.10.4 in the minor-patch group ([#30390](https://github.com/Koenkk/zigbee2mqtt/issues/30390)) ([93c9704](https://github.com/Koenkk/zigbee2mqtt/commit/93c9704fcd5552e234dd03a7ae1cddc11403622b))
|
||||
* **ignore:** bump the minor-patch group across 1 directory with 4 updates ([#30117](https://github.com/Koenkk/zigbee2mqtt/issues/30117)) ([ad3c090](https://github.com/Koenkk/zigbee2mqtt/commit/ad3c09036b804eb108238036f22776d83ea371b2))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#30207](https://github.com/Koenkk/zigbee2mqtt/issues/30207)) ([1d15c21](https://github.com/Koenkk/zigbee2mqtt/commit/1d15c2142fefb9c7ce96c947e5ac7ddc6101439f))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#30309](https://github.com/Koenkk/zigbee2mqtt/issues/30309)) ([b10fa93](https://github.com/Koenkk/zigbee2mqtt/commit/b10fa93700febe84634040dbdeb4857258cd2882))
|
||||
* **ignore:** bump zigbee2mqtt-windfront from 2.6.1 to 2.6.2 in the minor-patch group ([#30412](https://github.com/Koenkk/zigbee2mqtt/issues/30412)) ([4d6269e](https://github.com/Koenkk/zigbee2mqtt/commit/4d6269e7e89af52df0b2576925abade2f6b60e09))
|
||||
* **ignore:** update zigbee-herdsman to 7.0.5 ([#30116](https://github.com/Koenkk/zigbee2mqtt/issues/30116)) ([4574ffc](https://github.com/Koenkk/zigbee2mqtt/commit/4574ffc71a799846c6a04121c3b5908f968ff0be))
|
||||
* **ignore:** update zigbee-herdsman to 7.0.6 ([#30142](https://github.com/Koenkk/zigbee2mqtt/issues/30142)) ([ecb0af4](https://github.com/Koenkk/zigbee2mqtt/commit/ecb0af40575ebaadf887685c50cd1a9968cc1c68))
|
||||
* **ignore:** update zigbee-herdsman to 8.0.0 and zigbee-herdsman-converters to 25.87.0 ([#30186](https://github.com/Koenkk/zigbee2mqtt/issues/30186)) ([7f5f6a5](https://github.com/Koenkk/zigbee2mqtt/commit/7f5f6a5849d940a6e5288865edf7999c41af12e2))
|
||||
* **ignore:** update zigbee-herdsman to 8.0.1 ([#30240](https://github.com/Koenkk/zigbee2mqtt/issues/30240)) ([1688a3b](https://github.com/Koenkk/zigbee2mqtt/commit/1688a3b5323a8774ad2c36775fdb99f48057f72a))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.84.0 ([#30081](https://github.com/Koenkk/zigbee2mqtt/issues/30081)) ([c18d132](https://github.com/Koenkk/zigbee2mqtt/commit/c18d1329c801ce48cf5277c00682ad2f9098f383))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.85.0 ([#30120](https://github.com/Koenkk/zigbee2mqtt/issues/30120)) ([5db6ae0](https://github.com/Koenkk/zigbee2mqtt/commit/5db6ae0a197a180abc5e497aebe6e59fcb9b6b3c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.86.0 ([#30143](https://github.com/Koenkk/zigbee2mqtt/issues/30143)) ([3460f15](https://github.com/Koenkk/zigbee2mqtt/commit/3460f157df84d10f8de87a37d6b0b0b243de6874))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.88.0 ([#30196](https://github.com/Koenkk/zigbee2mqtt/issues/30196)) ([3818c50](https://github.com/Koenkk/zigbee2mqtt/commit/3818c50b8ae516e56ad07e8fce3c451f35e1b678))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.89.0 ([#30215](https://github.com/Koenkk/zigbee2mqtt/issues/30215)) ([01304cf](https://github.com/Koenkk/zigbee2mqtt/commit/01304cfc6604b86acf181dc3f07688f56ecbe8a1))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.90.0 ([#30267](https://github.com/Koenkk/zigbee2mqtt/issues/30267)) ([ab08947](https://github.com/Koenkk/zigbee2mqtt/commit/ab0894715a002bd5c14d8af45a67e5e546889590))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.91.0 ([#30279](https://github.com/Koenkk/zigbee2mqtt/issues/30279)) ([4c02e3d](https://github.com/Koenkk/zigbee2mqtt/commit/4c02e3da8f66cafbdcde89303bb1aea6eaf73bcd))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.92.0 ([#30300](https://github.com/Koenkk/zigbee2mqtt/issues/30300)) ([c28a024](https://github.com/Koenkk/zigbee2mqtt/commit/c28a024664989fe8a044babe3cdf43403b0ee2b5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.93.0 ([#30331](https://github.com/Koenkk/zigbee2mqtt/issues/30331)) ([c61f67f](https://github.com/Koenkk/zigbee2mqtt/commit/c61f67f061d2766e99c5cb9c99c02b172832271c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.94.0 ([#30355](https://github.com/Koenkk/zigbee2mqtt/issues/30355)) ([7cb5c58](https://github.com/Koenkk/zigbee2mqtt/commit/7cb5c58dc028f86c59935b64a738444950ef8e12))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.95.0 ([#30371](https://github.com/Koenkk/zigbee2mqtt/issues/30371)) ([4c48e9c](https://github.com/Koenkk/zigbee2mqtt/commit/4c48e9cfbcedb9cd6aedee9f694db2993038eaee))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.96.0 ([#30383](https://github.com/Koenkk/zigbee2mqtt/issues/30383)) ([2721b0e](https://github.com/Koenkk/zigbee2mqtt/commit/2721b0ea78f4e86e6fe3b5f4831c5787a28585a8))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.97.0 ([#30396](https://github.com/Koenkk/zigbee2mqtt/issues/30396)) ([3727f07](https://github.com/Koenkk/zigbee2mqtt/commit/3727f078ecde73a5c444c42909f36b4a0f35d8c3))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.98.0 ([#30417](https://github.com/Koenkk/zigbee2mqtt/issues/30417)) ([0eba0c9](https://github.com/Koenkk/zigbee2mqtt/commit/0eba0c953d7543b2850a9b9672ffbc3d87840fb3))
|
||||
* log dir tz format not working on some systems ([#30324](https://github.com/Koenkk/zigbee2mqtt/issues/30324)) ([58d98c7](https://github.com/Koenkk/zigbee2mqtt/commit/58d98c7263dbe687eb677199eb760799ecff8e8f))
|
||||
* Proper timezone in logs dir ([#30297](https://github.com/Koenkk/zigbee2mqtt/issues/30297)) ([8549b37](https://github.com/Koenkk/zigbee2mqtt/commit/8549b372283da8b55804c4049c7341294ced1538))
|
||||
* Remove extra `>` in onboarding([#30065](https://github.com/Koenkk/zigbee2mqtt/issues/30065)) ([ed1e7b3](https://github.com/Koenkk/zigbee2mqtt/commit/ed1e7b333f481a4b3a37b021f2058331b86ebd84))
|
||||
|
||||
## [2.7.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.7.0...2.7.1) (2025-12-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Home Assistant: Accept multiple access levels for `current_humidity` source property ([#29936](https://github.com/Koenkk/zigbee2mqtt/issues/29936)) ([fa99c6d](https://github.com/Koenkk/zigbee2mqtt/commit/fa99c6dcca97d2c8c90136249293dea3d42e0998))
|
||||
* Home Assistant: Translate boolean to state topic for `current_humidity_topic` ([#30014](https://github.com/Koenkk/zigbee2mqtt/issues/30014)) ([9ca85df](https://github.com/Koenkk/zigbee2mqtt/commit/9ca85dfac8b0f0421ad0622dfdbe36c8766d22b4))
|
||||
* **ignore:** bump finalhandler from 2.1.0 to 2.1.1 in the minor-patch group ([#29920](https://github.com/Koenkk/zigbee2mqtt/issues/29920)) ([ce27ff3](https://github.com/Koenkk/zigbee2mqtt/commit/ce27ff3cbfac204579b38d034e71733e4e4bb18f))
|
||||
* **ignore:** update zigbee-herdsman to 7.0.2 ([#29956](https://github.com/Koenkk/zigbee2mqtt/issues/29956)) ([403bd4a](https://github.com/Koenkk/zigbee2mqtt/commit/403bd4a72541bf7342de246b033c5090b186df32))
|
||||
* **ignore:** update zigbee-herdsman to 7.0.3 ([#30008](https://github.com/Koenkk/zigbee2mqtt/issues/30008)) ([bae9bb8](https://github.com/Koenkk/zigbee2mqtt/commit/bae9bb8812c81e46d4068b4284e0f9957821f88a))
|
||||
* **ignore:** update zigbee-herdsman to 7.0.4 ([#30041](https://github.com/Koenkk/zigbee2mqtt/issues/30041)) ([334fa2a](https://github.com/Koenkk/zigbee2mqtt/commit/334fa2a60d04c66749978b8b8f4b9b5af6c7c7eb))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.81.0 ([#29959](https://github.com/Koenkk/zigbee2mqtt/issues/29959)) ([fd4282b](https://github.com/Koenkk/zigbee2mqtt/commit/fd4282b98d9630f9a98e82f627c12c5920d810b4))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.82.0 ([#29990](https://github.com/Koenkk/zigbee2mqtt/issues/29990)) ([740edd8](https://github.com/Koenkk/zigbee2mqtt/commit/740edd8b58b7d72b7a2a5247f7f6ac8ee99ff0fa))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.83.0 ([#30011](https://github.com/Koenkk/zigbee2mqtt/issues/30011)) ([a7f1345](https://github.com/Koenkk/zigbee2mqtt/commit/a7f1345a9164f6c83cb81688c33511b14646fab8))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.83.1 ([#30050](https://github.com/Koenkk/zigbee2mqtt/issues/30050)) ([9b60353](https://github.com/Koenkk/zigbee2mqtt/commit/9b6035387be6f285fab455f7845baa75f3f722be))
|
||||
|
||||
## [2.7.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.6.3...2.7.0) (2025-12-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add `action` bridge/request API ([#29730](https://github.com/Koenkk/zigbee2mqtt/issues/29730)) ([a61646d](https://github.com/Koenkk/zigbee2mqtt/commit/a61646d584c0a0de46abb3545cd964f65d84312d))
|
||||
* Add new bind/reporting/map features ([#29750](https://github.com/Koenkk/zigbee2mqtt/issues/29750)) ([f26ade4](https://github.com/Koenkk/zigbee2mqtt/commit/f26ade4f938350d10ed22a32a2e30365f7514a72))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add port 8080 `EXPOSE` to container ([#29754](https://github.com/Koenkk/zigbee2mqtt/issues/29754)) ([4c21b66](https://github.com/Koenkk/zigbee2mqtt/commit/4c21b660794ee3b85c9a9a6d01b0df5ec8bce639))
|
||||
* Home Assistant: expose `current_humidity` for `climate` devices exposing `humidity` ([#29842](https://github.com/Koenkk/zigbee2mqtt/issues/29842)) ([631c4f6](https://github.com/Koenkk/zigbee2mqtt/commit/631c4f6a5729888d65a1125e0cca8f9ba2ff0e59))
|
||||
* **ignore:** bump @biomejs/biome from 2.3.3 to 2.3.4 in the minor-patch group ([#29571](https://github.com/Koenkk/zigbee2mqtt/issues/29571)) ([517b286](https://github.com/Koenkk/zigbee2mqtt/commit/517b2865835a7c21b4c5ce68018fd30ae45d1a3f))
|
||||
* **ignore:** bump debounce from 2.2.0 to 3.0.0 ([#29444](https://github.com/Koenkk/zigbee2mqtt/issues/29444)) ([8099322](https://github.com/Koenkk/zigbee2mqtt/commit/8099322cc2e93e5c1faf355d41485f2c673e0cea))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#29443](https://github.com/Koenkk/zigbee2mqtt/issues/29443)) ([619b939](https://github.com/Koenkk/zigbee2mqtt/commit/619b939b148394bd6a083c17cc5cf254c8eb3aea))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#29859](https://github.com/Koenkk/zigbee2mqtt/issues/29859)) ([c02ed09](https://github.com/Koenkk/zigbee2mqtt/commit/c02ed092116c7454220c0611bff5d078e12a8ecc))
|
||||
* **ignore:** bump the minor-patch group with 3 updates ([#29804](https://github.com/Koenkk/zigbee2mqtt/issues/29804)) ([e2232e9](https://github.com/Koenkk/zigbee2mqtt/commit/e2232e9e9173ea9ec55152ac33bc0ee0141471bb))
|
||||
* **ignore:** bump zigbee2mqtt-windfront from 2.4.0 to 2.4.1 in the minor-patch group ([#29905](https://github.com/Koenkk/zigbee2mqtt/issues/29905)) ([4a020fd](https://github.com/Koenkk/zigbee2mqtt/commit/4a020fd8de2dab2973b859c34b934416a15e82a5))
|
||||
* **ignore:** bump zigbee2mqtt-windfront to 2.4.2 ([#29919](https://github.com/Koenkk/zigbee2mqtt/issues/29919)) ([3dce854](https://github.com/Koenkk/zigbee2mqtt/commit/3dce8548ceee111f49a5b3460fdb2cbb4f47da62))
|
||||
* **ignore:** prop name convention on new API endpoints ([#29820](https://github.com/Koenkk/zigbee2mqtt/issues/29820)) ([ad06968](https://github.com/Koenkk/zigbee2mqtt/commit/ad0696856a9a63fc74b9c2115f08d7fd97850545))
|
||||
* **ignore:** update zigbee-herdsman to 6.3.3 ([#29500](https://github.com/Koenkk/zigbee2mqtt/issues/29500)) ([39c5bc8](https://github.com/Koenkk/zigbee2mqtt/commit/39c5bc8c0446a7ccdffe6f82ae9e7f31f60f20ba))
|
||||
* **ignore:** update zigbee-herdsman to 6.4.0 ([#29522](https://github.com/Koenkk/zigbee2mqtt/issues/29522)) ([e788c52](https://github.com/Koenkk/zigbee2mqtt/commit/e788c520a9268498daf0f88d7bedb289546d226e))
|
||||
* **ignore:** update zigbee-herdsman to 6.4.1 ([#29595](https://github.com/Koenkk/zigbee2mqtt/issues/29595)) ([61c6ec6](https://github.com/Koenkk/zigbee2mqtt/commit/61c6ec611174529e4c041a56146ba8405cfe9ed8))
|
||||
* **ignore:** update zigbee-herdsman to 6.4.2 ([#29683](https://github.com/Koenkk/zigbee2mqtt/issues/29683)) ([53b7eaa](https://github.com/Koenkk/zigbee2mqtt/commit/53b7eaabf0647c8d1edb0de24b89776991af576c))
|
||||
* **ignore:** update zigbee-herdsman to 7.0.0 ([3e2aefb](https://github.com/Koenkk/zigbee2mqtt/commit/3e2aefbcf84f43d45c1d6ca33397c8bb76648822))
|
||||
* **ignore:** update zigbee-herdsman to 7.0.1 ([#29855](https://github.com/Koenkk/zigbee2mqtt/issues/29855)) ([802271e](https://github.com/Koenkk/zigbee2mqtt/commit/802271ed37d404944d315f83f3b623ba9e424bb9))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.60.0 ([#29426](https://github.com/Koenkk/zigbee2mqtt/issues/29426)) ([eeed56e](https://github.com/Koenkk/zigbee2mqtt/commit/eeed56ef2c01dfe67e7a0cd7cafd35ef1876f5b4))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.62.0 ([#29449](https://github.com/Koenkk/zigbee2mqtt/issues/29449)) ([ea1c5a8](https://github.com/Koenkk/zigbee2mqtt/commit/ea1c5a8f4e8537a2f8c59988bb712f62a0adffd7))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.63.0 ([#29482](https://github.com/Koenkk/zigbee2mqtt/issues/29482)) ([5a6a9cf](https://github.com/Koenkk/zigbee2mqtt/commit/5a6a9cf4d2de803a10cd38bc19f3ec256eff5929))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.64.0 ([#29499](https://github.com/Koenkk/zigbee2mqtt/issues/29499)) ([5c4e43f](https://github.com/Koenkk/zigbee2mqtt/commit/5c4e43fd109321ddcfad3a67b60423f0197c51a6))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.65.0 ([#29523](https://github.com/Koenkk/zigbee2mqtt/issues/29523)) ([8822aed](https://github.com/Koenkk/zigbee2mqtt/commit/8822aedb5e254f5be5f6cda0cfe18fc6f69c84d6))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.66.0 ([#29558](https://github.com/Koenkk/zigbee2mqtt/issues/29558)) ([940fff2](https://github.com/Koenkk/zigbee2mqtt/commit/940fff244c39da04d0de5d7e4076359dc6245aed))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.67.0 ([#29572](https://github.com/Koenkk/zigbee2mqtt/issues/29572)) ([4794b3c](https://github.com/Koenkk/zigbee2mqtt/commit/4794b3c81f9e38660f8549995b7f3ef8b4cd4318))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.68.0 ([#29606](https://github.com/Koenkk/zigbee2mqtt/issues/29606)) ([564e9ab](https://github.com/Koenkk/zigbee2mqtt/commit/564e9ab9eb49c3517913917af8b23d4758c6b6c7))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.69.0 ([#29659](https://github.com/Koenkk/zigbee2mqtt/issues/29659)) ([16d0e56](https://github.com/Koenkk/zigbee2mqtt/commit/16d0e5696859e79be4701154dd35f7780e47acae))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.70.0 ([#29684](https://github.com/Koenkk/zigbee2mqtt/issues/29684)) ([73225e2](https://github.com/Koenkk/zigbee2mqtt/commit/73225e2436bb56ff4ed47d787e8a1793fd10998d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.71.0 ([#29716](https://github.com/Koenkk/zigbee2mqtt/issues/29716)) ([d6bf517](https://github.com/Koenkk/zigbee2mqtt/commit/d6bf5174be905128432e90d54b3c43c2a68e75ac))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.72.0 ([#29735](https://github.com/Koenkk/zigbee2mqtt/issues/29735)) ([7b7b988](https://github.com/Koenkk/zigbee2mqtt/commit/7b7b9889607a6e48001ae3cda5950386c24f5fd6))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.73.0 ([#29752](https://github.com/Koenkk/zigbee2mqtt/issues/29752)) ([be774a4](https://github.com/Koenkk/zigbee2mqtt/commit/be774a4d0d70122ced0f6ca79f49c060a7d2d2cb))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.74.0 ([#29770](https://github.com/Koenkk/zigbee2mqtt/issues/29770)) ([ba77ce7](https://github.com/Koenkk/zigbee2mqtt/commit/ba77ce7c8df0b29845a090c9defb4ce8aaf0f924))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.75.0 ([#29811](https://github.com/Koenkk/zigbee2mqtt/issues/29811)) ([854df49](https://github.com/Koenkk/zigbee2mqtt/commit/854df498bd3c8999393b683052dfba6b906ba669))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.76.0 ([#29823](https://github.com/Koenkk/zigbee2mqtt/issues/29823)) ([72d862f](https://github.com/Koenkk/zigbee2mqtt/commit/72d862f4a080bfcd91bf99d4c13387e719dfdb8e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.77.0 ([#29838](https://github.com/Koenkk/zigbee2mqtt/issues/29838)) ([35d8a5d](https://github.com/Koenkk/zigbee2mqtt/commit/35d8a5da7f1d817727f8b029da1992901f5b4115))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.78.0 ([#29856](https://github.com/Koenkk/zigbee2mqtt/issues/29856)) ([49b4876](https://github.com/Koenkk/zigbee2mqtt/commit/49b4876768c5d15fcff1ad0c9c2d02dda96543d0))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.79.0 ([#29903](https://github.com/Koenkk/zigbee2mqtt/issues/29903)) ([d411fc5](https://github.com/Koenkk/zigbee2mqtt/commit/d411fc5c693300863a19c2798f0a7a05cfc7e55f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.80.0 ([#29924](https://github.com/Koenkk/zigbee2mqtt/issues/29924)) ([445890a](https://github.com/Koenkk/zigbee2mqtt/commit/445890a0710ba7f6b101b651bf379f10390f9e7e))
|
||||
* **ignore:** update zigbee2mqtt-windfront to 2.3.1 ([#29788](https://github.com/Koenkk/zigbee2mqtt/issues/29788)) ([17520f7](https://github.com/Koenkk/zigbee2mqtt/commit/17520f7cfb69b577ebd986b86da80931a15a39db))
|
||||
* Reporting payload detection fixes ([#29854](https://github.com/Koenkk/zigbee2mqtt/issues/29854)) ([42fc6e0](https://github.com/Koenkk/zigbee2mqtt/commit/42fc6e03a7a3092502e3c3d1defbcb3ed6438230))
|
||||
|
||||
## [2.6.3](https://github.com/Koenkk/zigbee2mqtt/compare/2.6.2...2.6.3) (2025-11-01)
|
||||
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ Zigbee2MQTT integrates well with (almost) every home automation solution because
|
||||
|
||||
### [Home Assistant](https://www.home-assistant.io/)
|
||||
|
||||
<img align="left" height="75px" width="75px" src="https://user-images.githubusercontent.com/7738048/40914297-49e6e560-6800-11e8-8904-36cce896e5a8.png">
|
||||
<a href="https://www.home-assistant.io/"><img align="left" src="https://avatars.githubusercontent.com/u/13844975?s=75" style="padding-right: 20px;" alt="Home Assistant"></a>
|
||||
|
||||
<br clear="right">
|
||||
|
||||
@@ -58,7 +58,7 @@ Zigbee2MQTT integrates well with (almost) every home automation solution because
|
||||
|
||||
### [Homey](https://homey.app/)
|
||||
|
||||
<img align="left" height="75px" width="75px" src="https://etc.athom.com/logo/white/256.png">
|
||||
<a href="https://homey.app/"><img align="left" src="https://avatars.githubusercontent.com/u/8502422?s=75" style="padding-right: 20px;" alt="Homey"></a>
|
||||
|
||||
<br clear="right">
|
||||
|
||||
@@ -68,7 +68,7 @@ Integration implemented in the [Homey App](https://homey.app/nl-nl/app/com.gruij
|
||||
|
||||
### [Domoticz](https://www.domoticz.com/)
|
||||
|
||||
<img align="left" height="75px" width="75px" src="https://user-images.githubusercontent.com/2734836/47615848-b8dd8700-dabd-11e8-9d77-175002dd8987.png">
|
||||
<a href="https://www.domoticz.com/"><img align="left" src="https://avatars.githubusercontent.com/u/6067010?s=75" style="padding-right: 20px;" alt="Domoticz"></a>
|
||||
|
||||
<br clear="right">
|
||||
|
||||
@@ -78,7 +78,7 @@ Integration implemented in Domoticz ([documentation](https://www.domoticz.com/wi
|
||||
|
||||
### [Gladys Assistant](https://gladysassistant.com/)
|
||||
|
||||
<img align="left" height="75px" width="75px" src="./images/gladys-assistant-logo.jpg">
|
||||
<a href="https://gladysassistant.com/"><img align="left" src="https://avatars.githubusercontent.com/u/7365162?s=75" style="padding-right: 20px;" alt="Gladys Assistant"></a>
|
||||
|
||||
<br clear="right">
|
||||
|
||||
@@ -86,13 +86,13 @@ Integration implemented natively in Gladys Assistant ([documentation](https://gl
|
||||
|
||||
<br clear="both">
|
||||
|
||||
### [IoBroker](https://www.iobroker.net/)
|
||||
### [ioBroker](https://www.iobroker.net/)
|
||||
|
||||
<img align="left" height="75px" width="75px" src="https://forum.iobroker.net/assets/uploads/system/site-logo.png">
|
||||
<a href="https://www.iobroker.net/"><img align="left" src="https://avatars.githubusercontent.com/u/7231148?s=75" style="padding-right: 20px;" alt="ioBroker"></a>
|
||||
|
||||
<br clear="right">
|
||||
|
||||
Integration implemented in IoBroker ([documentation](https://github.com/o0shojo0o/ioBroker.zigbee2mqtt)).
|
||||
Integration implemented in ioBroker ([documentation](https://github.com/o0shojo0o/ioBroker.zigbee2mqtt)).
|
||||
|
||||
<br clear="both">
|
||||
|
||||
@@ -106,7 +106,7 @@ Zigbee2MQTT is made up of three modules, each developed in its own Github projec
|
||||
|
||||
### Developing
|
||||
|
||||
Zigbee2MQTT uses TypeScript. Therefore after making changes to files in the `lib/` directory you need to recompile Zigbee2MQTT. This can be done by executing `pnpm run build`. For faster development instead of running `pnpm run build` you can run `pnpm run build-watch` in another terminal session, this will recompile as you change files.
|
||||
Zigbee2MQTT uses TypeScript. Therefore after making changes to files in the `lib/` directory you need to recompile Zigbee2MQTT. This can be done by executing `pnpm run build`. For faster development instead of running `pnpm run build` you can run `pnpm run build:watch` in another terminal session, this will recompile as you change files.
|
||||
|
||||
Before running any of the commands, you'll first need to run `pnpm install --include=dev`.
|
||||
Before submitting changes run `pnpm run check:w` then `pnpm run test:coverage`.
|
||||
|
||||
+7
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.1/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
@@ -12,7 +12,7 @@
|
||||
"bracketSpacing": false
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!package.json"]
|
||||
"includes": ["**", "!package.json", "!!**/dist", "!!**/coverage"]
|
||||
},
|
||||
"linter": {
|
||||
"includes": ["**"],
|
||||
@@ -72,6 +72,11 @@
|
||||
"suspicious": {
|
||||
"noConstEnum": "off",
|
||||
"useAwait": "error"
|
||||
},
|
||||
"nursery": {
|
||||
"useExhaustiveSwitchCases": "error",
|
||||
"noFloatingPromises": "error",
|
||||
"noMisusedPromises": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Indicates the configuration version (used by configuration migrations)
|
||||
version: 4
|
||||
version: 5
|
||||
|
||||
# Home Assistant integration (MQTT discovery)
|
||||
homeassistant:
|
||||
@@ -15,7 +15,7 @@ mqtt:
|
||||
# MQTT base topic for zigbee2mqtt MQTT messages
|
||||
base_topic: zigbee2mqtt
|
||||
# MQTT server URL
|
||||
server: 'mqtt://localhost'
|
||||
server: "mqtt://localhost"
|
||||
# MQTT server authentication, uncomment if required:
|
||||
# user: my_user
|
||||
# password: my_password
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
FROM alpine:3.22 AS base
|
||||
FROM alpine:3.23 AS base
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
@@ -52,5 +52,8 @@ RUN mkdir /app/data
|
||||
ARG COMMIT
|
||||
RUN echo "$COMMIT" > dist/.hash
|
||||
|
||||
# Expose the default frontend port (see lib/util/settings.ts defaults.frontend.port)
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD [ "/sbin/tini", "--", "node", "index.js"]
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
const semver = require("semver");
|
||||
const engines = require("./package.json").engines;
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const {exec} = require("node:child_process");
|
||||
require("source-map-support").install();
|
||||
|
||||
/** @type {import("./dist/controller").Controller | undefined} */
|
||||
let controller;
|
||||
let stopping = false;
|
||||
const watchdog = process.env.Z2M_WATCHDOG != null;
|
||||
let watchdogCount = 0;
|
||||
let unsolicitedStop = false;
|
||||
// csv in minutes, default: 1min, 5min, 15min, 30min, 60min
|
||||
let watchdogDelays = [2000, 60000, 300000, 900000, 1800000, 3600000];
|
||||
|
||||
if (watchdog && process.env.Z2M_WATCHDOG !== "default") {
|
||||
if (process.env.Z2M_WATCHDOG != null && process.env.Z2M_WATCHDOG !== "default") {
|
||||
if (/^\d+(.\d+)?(,\d+(.\d+)?)*$/.test(process.env.Z2M_WATCHDOG)) {
|
||||
watchdogDelays = process.env.Z2M_WATCHDOG.split(",").map((v) => Number.parseFloat(v) * 60000);
|
||||
} else {
|
||||
@@ -25,6 +22,7 @@ if (watchdog && process.env.Z2M_WATCHDOG !== "default") {
|
||||
|
||||
const hashFile = path.join(__dirname, "dist", ".hash");
|
||||
|
||||
/** @type {(code: number) => Promise<void>} */
|
||||
async function triggerWatchdog(code) {
|
||||
const delay = watchdogDelays[watchdogCount];
|
||||
watchdogCount += 1;
|
||||
@@ -41,14 +39,16 @@ async function triggerWatchdog(code) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {() => Promise<void>} */
|
||||
async function restart() {
|
||||
await stop(true);
|
||||
await start();
|
||||
}
|
||||
|
||||
/** @type {(code: number, restart?: boolean) => Promise<void>} */
|
||||
async function exit(code, restart = false) {
|
||||
if (!restart) {
|
||||
if (watchdog && unsolicitedStop) {
|
||||
if (process.env.Z2M_WATCHDOG != null && unsolicitedStop) {
|
||||
await triggerWatchdog(code);
|
||||
} else {
|
||||
process.exit(code);
|
||||
@@ -56,6 +56,7 @@ async function exit(code, restart = false) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {() => Promise<string>} */
|
||||
async function currentHash() {
|
||||
return await new Promise((resolve) => {
|
||||
exec("git rev-parse --short=8 HEAD", (error, stdout) => {
|
||||
@@ -70,20 +71,23 @@ async function currentHash() {
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {() => Promise<void>} */
|
||||
async function writeHash() {
|
||||
const hash = await currentHash();
|
||||
|
||||
fs.writeFileSync(hashFile, hash);
|
||||
}
|
||||
|
||||
/** @type {(reason: "initial build" | "hash changed") => Promise<void>} */
|
||||
async function build(reason) {
|
||||
process.stdout.write(`Building Zigbee2MQTT... (${reason})`);
|
||||
const {totalmem} = await import("node:os");
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const env = {...process.env};
|
||||
const mb600 = 629145600;
|
||||
|
||||
if (mb600 > os.totalmem() && !env.NODE_OPTIONS) {
|
||||
if (mb600 > totalmem() && !env.NODE_OPTIONS) {
|
||||
// Prevent OOM on tsc compile for system with low memory
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/12034
|
||||
env.NODE_OPTIONS = "--max_old_space_size=256";
|
||||
@@ -107,6 +111,7 @@ async function build(reason) {
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {() => Promise<void>} */
|
||||
async function checkDist() {
|
||||
if (!fs.existsSync(hashFile)) {
|
||||
await build("initial build");
|
||||
@@ -120,20 +125,22 @@ async function checkDist() {
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {() => Promise<void>} */
|
||||
async function start() {
|
||||
console.log(`Starting Zigbee2MQTT ${watchdog ? `with watchdog (${watchdogDelays})` : "without watchdog"}.`);
|
||||
console.log(`Starting Zigbee2MQTT ${process.env.Z2M_WATCHDOG != null ? `with watchdog (${watchdogDelays})` : "without watchdog"}.`);
|
||||
await checkDist();
|
||||
|
||||
// gc
|
||||
{
|
||||
const version = engines.node;
|
||||
const packageJSON = (await import("./package.json", {with: {type: "json"}})).default;
|
||||
const version = packageJSON.engines.node;
|
||||
const {satisfies} = await import("semver");
|
||||
|
||||
if (!semver.satisfies(process.version, version)) {
|
||||
if (!satisfies(process.version, version)) {
|
||||
console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`);
|
||||
}
|
||||
|
||||
const {onboard} = require("./dist/util/onboarding");
|
||||
|
||||
const {onboard} = await import("./dist/util/onboarding.js");
|
||||
const success = await onboard();
|
||||
|
||||
if (!success) {
|
||||
@@ -143,7 +150,7 @@ async function start() {
|
||||
}
|
||||
}
|
||||
|
||||
const {Controller} = require("./dist/controller");
|
||||
const {Controller} = await import("./dist/controller.js");
|
||||
controller = new Controller(restart, exit);
|
||||
|
||||
await controller.start();
|
||||
@@ -153,32 +160,34 @@ async function start() {
|
||||
watchdogCount = 0; // reset
|
||||
}
|
||||
|
||||
async function stop(restart) {
|
||||
/** @type {(restart: boolean, signal?: NodeJS.Signals) => Promise<void>} */
|
||||
async function stop(restart, signal = undefined) {
|
||||
// `handleQuit` or `restart` never unsolicited
|
||||
unsolicitedStop = false;
|
||||
|
||||
await controller.stop(restart);
|
||||
await controller?.stop(restart, undefined, signal);
|
||||
}
|
||||
|
||||
async function handleQuit() {
|
||||
/** @type {(signal: NodeJS.Signals) => Promise<void>} */
|
||||
async function handleQuit(signal) {
|
||||
if (!stopping) {
|
||||
if (controller) {
|
||||
stopping = true;
|
||||
|
||||
await stop(false);
|
||||
await stop(false, signal);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module || require.main.filename.endsWith(`${path.sep}cli.js`)) {
|
||||
if (require.main === module || require.main?.filename.endsWith(`${path.sep}cli.js`)) {
|
||||
if (process.argv.length === 3 && process.argv[2] === "writehash") {
|
||||
writeHash();
|
||||
void writeHash();
|
||||
} else {
|
||||
process.on("SIGINT", handleQuit);
|
||||
process.on("SIGTERM", handleQuit);
|
||||
start();
|
||||
void start();
|
||||
}
|
||||
} else {
|
||||
process.on("SIGINT", handleQuit);
|
||||
|
||||
+73
-24
@@ -28,6 +28,8 @@ import utils from "./util/utils";
|
||||
import Zigbee from "./zigbee";
|
||||
|
||||
export class Controller {
|
||||
/** Allows canceling in-progress startup sequence if necessary. Signal can be passed to long-running ops as necessary for finer control. */
|
||||
#startAbortController: AbortController | undefined;
|
||||
public readonly eventBus: EventBus;
|
||||
public readonly zigbee: Zigbee;
|
||||
public readonly state: State;
|
||||
@@ -79,6 +81,9 @@ export class Controller {
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.#startAbortController = new AbortController();
|
||||
const abortSignal = this.#startAbortController.signal;
|
||||
|
||||
if (settings.get().frontend.enabled) {
|
||||
const {Frontend} = await import("./extension/frontend.js");
|
||||
|
||||
@@ -93,29 +98,48 @@ export class Controller {
|
||||
|
||||
this.state.start();
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const info = await utils.getZigbee2MQTTVersion();
|
||||
logger.info(`Starting Zigbee2MQTT version ${info.version} (commit #${info.commitHash})`);
|
||||
|
||||
// Start zigbee
|
||||
try {
|
||||
await this.zigbee.start();
|
||||
this.eventBus.onAdapterDisconnected(this, this.onZigbeeAdapterDisconnected);
|
||||
} catch (error) {
|
||||
logger.error("Failed to start zigbee-herdsman");
|
||||
logger.error(
|
||||
"Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html for possible solutions",
|
||||
);
|
||||
logger.error("Exiting...");
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.error((error as Error).stack!);
|
||||
const started = await this.zigbee.start(abortSignal);
|
||||
|
||||
/* v8 ignore start */
|
||||
if ((error as Error).message.includes("USB adapter discovery error (No valid USB adapter found)")) {
|
||||
logger.error("If this happens after updating to Zigbee2MQTT 2.0.0, see https://github.com/Koenkk/zigbee2mqtt/discussions/24364");
|
||||
if (started) {
|
||||
this.eventBus.onAdapterDisconnected(this, async () => {
|
||||
logger.error("Adapter disconnected, stopping");
|
||||
await this.stop(false, 2);
|
||||
});
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
} catch (error) {
|
||||
// skip if aborted by `stop`
|
||||
if (error !== "SIGINT" && error !== "SIGTERM" && error !== "STOPABORT") {
|
||||
logger.error("Failed to start zigbee-herdsman");
|
||||
logger.error(
|
||||
"Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html for possible solutions",
|
||||
);
|
||||
logger.error("Exiting...");
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.error((error as Error).stack!);
|
||||
|
||||
return await this.exit(1);
|
||||
/* v8 ignore start */
|
||||
if ((error as Error).message.includes("USB adapter discovery error (No valid USB adapter found)")) {
|
||||
logger.error("If this happens after updating to Zigbee2MQTT 2.0.0, see https://github.com/Koenkk/zigbee2mqtt/discussions/24364");
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
return await this.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Log zigbee clients on startup
|
||||
@@ -143,9 +167,19 @@ export class Controller {
|
||||
return await this.exit(1);
|
||||
}
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// copy current Set of extensions to ignore possible external extensions added while looping
|
||||
for (const extension of new Set(this.extensions)) {
|
||||
await this.startExtension(extension);
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Send all cached states.
|
||||
@@ -153,21 +187,32 @@ export class Controller {
|
||||
for (const entity of this.zigbee.devicesAndGroupsIterator()) {
|
||||
if (this.state.exists(entity)) {
|
||||
await this.publishEntityState(entity, this.state.get(entity), "publishCached");
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.onLastSeenChanged(this, (data) => utils.publishLastSeen(data, settings.get(), false, this.publishEntityState));
|
||||
this.eventBus.onLastSeenChanged(this, (data) => {
|
||||
utils.publishLastSeen(data, settings.get(), false, this.publishEntityState).catch(() => {});
|
||||
});
|
||||
|
||||
logger.info("Zigbee2MQTT started!");
|
||||
|
||||
this.sdNotify = await initSdNotify();
|
||||
|
||||
settings.setOnboarding(false);
|
||||
|
||||
this.#startAbortController = undefined; // gc
|
||||
}
|
||||
|
||||
@bind async enableDisableExtension(enable: boolean, name: string): Promise<void> {
|
||||
if (enable) {
|
||||
let extension: Extension;
|
||||
|
||||
switch (name) {
|
||||
case "Frontend": {
|
||||
if (!settings.get().frontend.enabled) {
|
||||
@@ -178,7 +223,7 @@ export class Controller {
|
||||
/* v8 ignore start */
|
||||
const {Frontend} = await import("./extension/frontend.js");
|
||||
|
||||
await this.addExtension(new Frontend(...this.extensionArgs));
|
||||
extension = new Frontend(...this.extensionArgs);
|
||||
|
||||
break;
|
||||
/* v8 ignore stop */
|
||||
@@ -190,7 +235,7 @@ export class Controller {
|
||||
|
||||
const {HomeAssistant} = await import("./extension/homeassistant.js");
|
||||
|
||||
await this.addExtension(new HomeAssistant(...this.extensionArgs));
|
||||
extension = new HomeAssistant(...this.extensionArgs);
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -200,6 +245,12 @@ export class Controller {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingExtension = this.getExtension(name);
|
||||
if (existingExtension) {
|
||||
await this.removeExtension(existingExtension);
|
||||
}
|
||||
await this.extensions.add(extension);
|
||||
} else {
|
||||
switch (name) {
|
||||
case "Frontend": {
|
||||
@@ -281,7 +332,10 @@ export class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
async stop(restart = false, code = 0): Promise<void> {
|
||||
async stop(restart = false, code = 0, signal: NodeJS.Signals | undefined = undefined): Promise<void> {
|
||||
logger.info(`Stopping Zigbee2MQTT (restart=${restart}, code=${code}, signal=${signal})`);
|
||||
|
||||
this.#startAbortController?.abort(signal ?? "STOPABORT");
|
||||
this.sdNotify?.notifyStopping();
|
||||
|
||||
let localCode = 0;
|
||||
@@ -317,11 +371,6 @@ export class Controller {
|
||||
return await this.exitCallback(code, restart);
|
||||
}
|
||||
|
||||
@bind async onZigbeeAdapterDisconnected(): Promise<void> {
|
||||
logger.error("Adapter disconnected, stopping");
|
||||
await this.stop(false, 2);
|
||||
}
|
||||
|
||||
@bind async publishEntityState(entity: Group | Device, payload: KeyValue, stateChangeReason?: StateChangeReason): Promise<void> {
|
||||
let message: Zigbee2MQTTAPI["{friendlyName}"] = {...payload};
|
||||
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ export default class EventBus {
|
||||
public emitAdapterDisconnected(): void {
|
||||
this.emitter.emit("adapterDisconnected");
|
||||
}
|
||||
public onAdapterDisconnected(key: ListenerKey, callback: () => void): void {
|
||||
public onAdapterDisconnected(key: ListenerKey, callback: () => Promise<void>): void {
|
||||
this.on("adapterDisconnected", callback, key);
|
||||
}
|
||||
|
||||
|
||||
+33
-5
@@ -184,7 +184,7 @@ interface ParsedMQTTMessage {
|
||||
}
|
||||
|
||||
export default class Bind extends Extension {
|
||||
#topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
|
||||
#topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind|binds/clear)`);
|
||||
private pollDebouncers: {[s: string]: () => void} = {};
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
@@ -276,6 +276,36 @@ export default class Bind extends Extension {
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
if (data.topic.endsWith("binds/clear")) {
|
||||
const message = JSON.parse(data.message) as Zigbee2MQTTAPI["bridge/request/device/binds/clear"];
|
||||
|
||||
if (typeof message !== "object" || typeof message.target !== "string") {
|
||||
await this.publishResponse("binds/clear", message, {}, "Invalid payload");
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this.zigbee.resolveEntity(message.target);
|
||||
|
||||
if (!(target instanceof Device)) {
|
||||
await this.publishResponse("binds/clear", message, {}, "Invalid target");
|
||||
return;
|
||||
}
|
||||
|
||||
// this list is raw (not resolved) to allow clearing any specific target (not only currently known)
|
||||
const eui64List = message.ieee_list ?? ["0xffffffffffffffff"];
|
||||
|
||||
await target.zh.clearAllBindings(eui64List);
|
||||
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/device/binds/clear"] = {
|
||||
target: message.target,
|
||||
ieee_list: eui64List,
|
||||
};
|
||||
|
||||
await this.publishResponse("binds/clear", message, responseData);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
const [raw, parsed, error] = this.parseMQTTMessage(data);
|
||||
|
||||
if (!raw || !parsed) {
|
||||
@@ -389,7 +419,7 @@ export default class Bind extends Extension {
|
||||
}
|
||||
|
||||
private async publishResponse<T extends Zigbee2MQTTResponseEndpoints>(
|
||||
type: ParsedMQTTMessage["type"],
|
||||
type: ParsedMQTTMessage["type"] | "binds/clear",
|
||||
request: KeyValue,
|
||||
data: Zigbee2MQTTAPI[T],
|
||||
error?: string,
|
||||
@@ -488,9 +518,7 @@ export default class Bind extends Extension {
|
||||
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
for (const endpoint of device.zh.endpoints) {
|
||||
for (const bind of endpoint.binds) {
|
||||
allBinds.push(bind);
|
||||
}
|
||||
allBinds.push(...endpoint.binds);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+78
-23
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import JSZip from "jszip";
|
||||
@@ -31,7 +32,10 @@ export default class Bridge extends Extension {
|
||||
private logTransport!: winston.transport;
|
||||
private requestLookup: {[key: string]: (message: KeyValue | string) => Promise<Zigbee2MQTTResponse<Zigbee2MQTTResponseEndpoints>>} = {
|
||||
"device/options": this.deviceOptions,
|
||||
"device/configure_reporting": this.deviceConfigureReporting,
|
||||
/** @deprecated 3.0 */
|
||||
"device/configure_reporting": this.deviceReportingConfigure,
|
||||
"device/reporting/configure": this.deviceReportingConfigure,
|
||||
"device/reporting/read": this.deviceReportingRead,
|
||||
"device/remove": this.deviceRemove,
|
||||
"device/interview": this.deviceInterview,
|
||||
"device/generate_external_definition": this.deviceGenerateExternalDefinition,
|
||||
@@ -50,6 +54,7 @@ export default class Bridge extends Extension {
|
||||
health_check: this.healthCheck,
|
||||
coordinator_check: this.coordinatorCheck,
|
||||
options: this.bridgeOptions,
|
||||
action: this.action,
|
||||
};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
@@ -315,14 +320,18 @@ export default class Bridge extends Extension {
|
||||
@bind async backup(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/backup">> {
|
||||
await this.zigbee.backup();
|
||||
const dataPath = data.getPath();
|
||||
const files = utils
|
||||
.getAllFiles(dataPath)
|
||||
.map((f) => [f, f.substring(dataPath.length + 1)])
|
||||
.filter((f) => !f[1].startsWith("log"));
|
||||
const files = utils.getAllFiles(dataPath);
|
||||
const zip = new JSZip();
|
||||
const logDir = `log${path.sep}`;
|
||||
const otaDir = `ota${path.sep}`;
|
||||
|
||||
for (const f of files) {
|
||||
zip.file(f[1], fs.readFileSync(f[0]));
|
||||
const name = f.slice(dataPath.length + 1);
|
||||
|
||||
// XXX: `log` could technically be something else depending on `log_directory` setting
|
||||
if (!name.startsWith(logDir) && !name.startsWith(otaDir)) {
|
||||
zip.file(name, fs.readFileSync(f));
|
||||
}
|
||||
}
|
||||
|
||||
const base64Zip = await zip.generateAsync({type: "base64"});
|
||||
@@ -466,16 +475,16 @@ export default class Bridge extends Extension {
|
||||
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID, restart_required: this.restartRequired});
|
||||
}
|
||||
|
||||
@bind async deviceConfigureReporting(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/device/configure_reporting">> {
|
||||
@bind async deviceReportingConfigure(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/device/reporting/configure">> {
|
||||
if (
|
||||
typeof message !== "object" ||
|
||||
message.id === undefined ||
|
||||
message.endpoint === undefined ||
|
||||
message.cluster === undefined ||
|
||||
message.maximum_report_interval === undefined ||
|
||||
message.minimum_report_interval === undefined ||
|
||||
message.reportable_change === undefined ||
|
||||
message.attribute === undefined
|
||||
message.attribute === undefined ||
|
||||
typeof message.maximum_report_interval !== "number" ||
|
||||
typeof message.minimum_report_interval !== "number" ||
|
||||
(message.reportable_change !== undefined && typeof message.reportable_change !== "number")
|
||||
) {
|
||||
throw new Error("Invalid payload");
|
||||
}
|
||||
@@ -518,25 +527,54 @@ export default class Bridge extends Extension {
|
||||
});
|
||||
}
|
||||
|
||||
@bind async deviceReportingRead(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/device/reporting/read">> {
|
||||
if (
|
||||
typeof message !== "object" ||
|
||||
message.id === undefined ||
|
||||
message.endpoint === undefined ||
|
||||
message.cluster === undefined ||
|
||||
message.configs === undefined
|
||||
) {
|
||||
throw new Error("Invalid payload");
|
||||
}
|
||||
|
||||
const device = this.getEntity("device", message.id);
|
||||
const endpoint = device.endpoint(message.endpoint);
|
||||
|
||||
if (!endpoint) {
|
||||
throw new Error(`Device '${device.ID}' does not have endpoint '${message.endpoint}'`);
|
||||
}
|
||||
|
||||
const response = await endpoint.readReportingConfig(
|
||||
message.cluster,
|
||||
message.configs,
|
||||
message.manufacturer_code ? {manufacturerCode: message.manufacturer_code} : {},
|
||||
);
|
||||
|
||||
await this.publishDevices();
|
||||
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/device/reporting/read"] = {
|
||||
id: message.id,
|
||||
endpoint: message.endpoint,
|
||||
cluster: message.cluster,
|
||||
configs: response,
|
||||
};
|
||||
|
||||
if (message.manufacturer_code) {
|
||||
responseData.manufacturer_code = message.manufacturer_code;
|
||||
}
|
||||
|
||||
return utils.getResponse(message, responseData);
|
||||
}
|
||||
|
||||
@bind async deviceInterview(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/device/interview">> {
|
||||
if (typeof message !== "object" || message.id === undefined) {
|
||||
throw new Error("Invalid payload");
|
||||
}
|
||||
|
||||
const device = this.getEntity("device", message.id);
|
||||
logger.info(`Interviewing '${device.name}'`);
|
||||
|
||||
try {
|
||||
await device.zh.interview(true);
|
||||
logger.info(`Successfully interviewed '${device.name}'`);
|
||||
} catch (error) {
|
||||
throw new Error(`interview of '${device.name}' (${device.ieeeAddr}) failed: ${error}`, {cause: error});
|
||||
}
|
||||
|
||||
// A re-interview can for example result in a different modelId, therefore reconsider the definition.
|
||||
await device.resolveDefinition(true);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
this.eventBus.emitExposesChanged({device});
|
||||
await device.reInterview(this.eventBus);
|
||||
|
||||
return utils.getResponse(message, {id: message.id});
|
||||
}
|
||||
@@ -554,6 +592,22 @@ export default class Bridge extends Extension {
|
||||
return utils.getResponse(message, {id: message.id, source});
|
||||
}
|
||||
|
||||
@bind async action(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/action">> {
|
||||
if (typeof message !== "object" || !message.action) {
|
||||
throw new Error("Invalid payload");
|
||||
}
|
||||
|
||||
const action = zhc.ACTIONS[message.action];
|
||||
|
||||
if (action === undefined) {
|
||||
throw new Error("Invalid action");
|
||||
}
|
||||
|
||||
const response = await action(this.zigbee.zhController, message.params ?? {});
|
||||
|
||||
return utils.getResponse(message, response);
|
||||
}
|
||||
|
||||
async renameEntity<T extends "device" | "group">(
|
||||
entityType: T,
|
||||
message: string | KeyValue,
|
||||
@@ -812,6 +866,7 @@ export default class Bridge extends Extension {
|
||||
const data: Zigbee2MQTTAPI["bridge/definitions"] = {
|
||||
clusters: Zcl.Clusters,
|
||||
custom_clusters: {},
|
||||
actions: Object.keys(zhc.ACTIONS),
|
||||
};
|
||||
|
||||
for (const device of this.zigbee.devicesIterator((d) => !utils.objectIsEmpty(d.customClusters))) {
|
||||
|
||||
+31
-17
@@ -1,6 +1,5 @@
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
import Device from "../model/device";
|
||||
import type {Zigbee2MQTTAPI} from "../types/api";
|
||||
import logger from "../util/logger";
|
||||
@@ -12,8 +11,8 @@ import Extension from "./extension";
|
||||
* 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 configuring = new Set<string>();
|
||||
private attempts = new Map<string, number>();
|
||||
private topic = `${settings.get().mqtt.base_topic}/bridge/request/device/configure`;
|
||||
|
||||
@bind private async onReconfigure(data: eventdata.Reconfigure): Promise<void> {
|
||||
@@ -75,6 +74,7 @@ export default class Configure extends Extension {
|
||||
|
||||
await this.configure(data.device, "zigbee_event");
|
||||
});
|
||||
// TODO: this is triggering for any `data.status`, but should only for `successful`? (relies on `device.definition?` early-return?)
|
||||
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);
|
||||
@@ -93,42 +93,56 @@ export default class Configure extends Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
const definitionVersion = device.definition.version;
|
||||
|
||||
if (!force) {
|
||||
if (device.options.disabled || !device.interviewed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.zh.meta?.configured !== undefined) {
|
||||
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;
|
||||
}
|
||||
|
||||
const shouldReconfigure =
|
||||
// Should always reconfigure when not configured before
|
||||
device.zh.meta?.configured === undefined ||
|
||||
// Or should reconfigure when definition.version is not '0.0.0' and differs from last `meta.configured`.
|
||||
// In older Z2M versions the stored `meta.configured` was the hash of the configure function.
|
||||
// Since we don't want to reconfigure all devices, we don't re-configure when the definition has the default version of '0.0.0'.
|
||||
(definitionVersion !== "0.0.0" && device.zh.meta?.configured !== definitionVersion);
|
||||
if (!shouldReconfigure) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.configuring.has(device.ieeeAddr) || (this.attempts[device.ieeeAddr] >= 3 && !force)) {
|
||||
if (this.configuring.has(device.ieeeAddr)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attempts = this.attempts.get(device.ieeeAddr) ?? 0;
|
||||
|
||||
if (attempts >= 3 && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.configuring.add(device.ieeeAddr);
|
||||
|
||||
if (this.attempts[device.ieeeAddr] === undefined) {
|
||||
this.attempts[device.ieeeAddr] = 0;
|
||||
}
|
||||
|
||||
logger.info(`Configuring '${device.name}'`);
|
||||
|
||||
try {
|
||||
await device.definition.configure(device.zh, this.zigbee.firstCoordinatorEndpoint(), device.definition);
|
||||
logger.info(`Successfully configured '${device.name}'`);
|
||||
device.zh.meta.configured = zhc.getConfigureKey(device.definition);
|
||||
this.attempts.delete(device.ieeeAddr);
|
||||
logger.info(`Successfully configured '${device.name}' (definition v${definitionVersion})`);
|
||||
device.zh.meta.configured = definitionVersion;
|
||||
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 as Error).stack})`;
|
||||
const newAttempts = attempts + 1;
|
||||
this.attempts.set(device.ieeeAddr, newAttempts);
|
||||
|
||||
const msg = `Failed to configure '${device.name}', attempt ${newAttempts} (${(error as Error).stack})`;
|
||||
logger.error(msg);
|
||||
|
||||
if (throwError) {
|
||||
|
||||
@@ -202,14 +202,14 @@ export default abstract class ExternalJSExtension<M> extends Extension {
|
||||
const mod = await this.importFile(filePath);
|
||||
await this.loadJS(extension.name, mod.default);
|
||||
} catch (error) {
|
||||
// change ext so Z2M doesn't try to load it again and again
|
||||
fs.renameSync(filePath, `${filePath}.invalid`);
|
||||
|
||||
logger.error(
|
||||
`Invalid external ${this.mqttTopic} '${extension.name}' was ignored and renamed to prevent interference with Zigbee2MQTT. (${(error as Error).message})`,
|
||||
);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
|
||||
// change ext so Z2M doesn't try to load it again and again
|
||||
fs.renameSync(filePath, `${filePath}.invalid`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ const COVER_STOPPED_LOOKUP: ReadonlyArray<string> = ["stopped", "stop", "pause",
|
||||
const SWITCH_DIFFERENT: ReadonlyArray<string> = ["valve_detection", "window_detection", "auto_lock", "away_mode"];
|
||||
const BINARY_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
activity_led_indicator: {icon: "mdi:led-on"},
|
||||
area1Occupancy: {device_class: "occupancy"},
|
||||
area2Occupancy: {device_class: "occupancy"},
|
||||
area3Occupancy: {device_class: "occupancy"},
|
||||
area4Occupancy: {device_class: "occupancy"},
|
||||
auto_off: {icon: "mdi:flash-auto"},
|
||||
battery_low: {entity_category: "diagnostic", device_class: "battery"},
|
||||
button_lock: {entity_category: "config", icon: "mdi:lock"},
|
||||
@@ -82,6 +86,7 @@ const BINARY_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
occupancy: {device_class: "occupancy"},
|
||||
power_outage_memory: {entity_category: "config", icon: "mdi:memory"},
|
||||
presence: {device_class: "occupancy"},
|
||||
rain_status: {device_class: "moisture", icon: "mdi:weather-pouring"},
|
||||
setup: {device_class: "running"},
|
||||
smoke: {device_class: "smoke"},
|
||||
sos: {device_class: "safety"},
|
||||
@@ -114,6 +119,7 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
alarm_temperature_min: {device_class: "temperature", entity_category: "config", icon: "mdi:thermometer-low"},
|
||||
angle: {icon: "angle-acute"},
|
||||
angle_axis: {icon: "angle-acute"},
|
||||
apparent_temperature: {device_class: "temperature", icon: "mdi:thermometer-lines", state_class: "measurement"},
|
||||
aqi: {device_class: "aqi", state_class: "measurement"},
|
||||
auto_relock_time: {entity_category: "config", icon: "mdi:timer"},
|
||||
away_preset_days: {entity_category: "config", icon: "mdi:timer"},
|
||||
@@ -148,24 +154,30 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
entity_category: "diagnostic",
|
||||
state_class: "measurement",
|
||||
},
|
||||
dew_point: {device_class: "temperature", icon: "mdi:thermometer-water", state_class: "measurement"},
|
||||
distance: {device_class: "distance", state_class: "measurement"},
|
||||
duration: {entity_category: "config", icon: "mdi:timer"},
|
||||
eco2: {device_class: "carbon_dioxide", state_class: "measurement"},
|
||||
eco2: {device_class: "volatile_organic_compounds_parts", state_class: "measurement"},
|
||||
eco_temperature: {entity_category: "config", icon: "mdi:thermometer"},
|
||||
energy: {device_class: "energy", state_class: "total_increasing"},
|
||||
external_temperature_input: {device_class: "temperature", icon: "mdi:thermometer"},
|
||||
external_temperature: {device_class: "temperature", icon: "mdi:thermometer"},
|
||||
external_humidity: {device_class: "humidity", icon: "mdi:water-percent"},
|
||||
external_temperature: {device_class: "temperature", icon: "mdi:thermometer", state_class: "measurement"},
|
||||
external_humidity: {device_class: "humidity", icon: "mdi:water-percent", state_class: "measurement"},
|
||||
formaldehyd: {state_class: "measurement"},
|
||||
flow: {device_class: "volume_flow_rate", state_class: "measurement"},
|
||||
gas: {device_class: "gas", state_class: "total_increasing", icon: "mdi:meter-gas"},
|
||||
gas_density: {icon: "mdi:google-circles-communities", state_class: "measurement"},
|
||||
gust_speed: {device_class: "wind_speed", icon: "mdi:weather-windy-variant", state_class: "measurement"},
|
||||
hcho: {icon: "mdi:air-filter", state_class: "measurement"},
|
||||
heat_stress: {icon: "mdi:weather-sunny-alert", state_class: "measurement"},
|
||||
humidex: {device_class: "temperature", icon: "mdi:thermometer-alert", state_class: "measurement"},
|
||||
humidity: {device_class: "humidity", state_class: "measurement"},
|
||||
humidity_calibration: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||
humidity_max: {entity_category: "config", icon: "mdi:water-percent"},
|
||||
humidity_min: {entity_category: "config", icon: "mdi:water-percent"},
|
||||
illuminance_calibration: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||
illuminance: {device_class: "illuminance", state_class: "measurement"},
|
||||
illuminance_raw: {state_class: "measurement"},
|
||||
internalTemperature: {
|
||||
device_class: "temperature",
|
||||
entity_category: "diagnostic",
|
||||
@@ -177,6 +189,7 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
icon: "mdi:signal",
|
||||
state_class: "measurement",
|
||||
},
|
||||
load_estimate: {state_class: "measurement"},
|
||||
local_temperature: {device_class: "temperature", state_class: "measurement"},
|
||||
max_range: {entity_category: "config", icon: "mdi:signal-distance-variant"},
|
||||
max_temperature: {entity_category: "config", icon: "mdi:thermometer-high"},
|
||||
@@ -189,7 +202,7 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
noise: {device_class: "sound_pressure", state_class: "measurement"},
|
||||
noise_detect_level: {icon: "mdi:volume-equal"},
|
||||
noise_timeout: {icon: "mdi:timer"},
|
||||
occupancy_level: {icon: "mdi:motion-sensor"},
|
||||
occupancy_level: {icon: "mdi:motion-sensor", state_class: "measurement"},
|
||||
occupancy_sensitivity: {entity_category: "config", icon: "mdi:motion-sensor"},
|
||||
occupancy_timeout: {entity_category: "config", icon: "mdi:timer"},
|
||||
overload_protection: {icon: "mdi:flash"},
|
||||
@@ -201,10 +214,13 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
power_phase_b: {device_class: "power", state_class: "measurement"},
|
||||
power_phase_c: {device_class: "power", state_class: "measurement"},
|
||||
power_factor: {device_class: "power_factor", enabled_by_default: false, entity_category: "diagnostic", state_class: "measurement"},
|
||||
power_outage_count: {icon: "mdi:counter", enabled_by_default: false},
|
||||
power_outage_count: {icon: "mdi:counter", enabled_by_default: false, state_class: "measurement"},
|
||||
precipitation: {device_class: "precipitation", icon: "mdi:weather-rainy", state_class: "total_increasing"},
|
||||
precision: {entity_category: "config", icon: "mdi:decimal-comma-increase"},
|
||||
pressure: {device_class: "atmospheric_pressure", state_class: "measurement"},
|
||||
pressure_trend: {icon: "mdi:trending-up", state_class: "measurement"},
|
||||
presence_timeout: {entity_category: "config", icon: "mdi:timer"},
|
||||
rain_rate: {device_class: "precipitation_intensity", icon: "mdi:weather-pouring", state_class: "measurement"},
|
||||
reporting_time: {entity_category: "config", icon: "mdi:clock-time-one-outline"},
|
||||
requested_brightness_level: {
|
||||
enabled_by_default: false,
|
||||
@@ -219,12 +235,14 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
smoke_density: {icon: "mdi:google-circles-communities", state_class: "measurement"},
|
||||
soil_moisture: {device_class: "moisture", state_class: "measurement"},
|
||||
temperature: {device_class: "temperature", state_class: "measurement"},
|
||||
temperature_probe: {device_class: "temperature", state_class: "measurement"},
|
||||
temperature_calibration: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||
temperature_max: {entity_category: "config", icon: "mdi:thermometer-plus"},
|
||||
temperature_min: {entity_category: "config", icon: "mdi:thermometer-minus"},
|
||||
temperature_offset: {icon: "mdi:thermometer-lines"},
|
||||
transition: {entity_category: "config", icon: "mdi:transition"},
|
||||
trigger_count: {icon: "mdi:counter", enabled_by_default: false},
|
||||
trigger_count: {icon: "mdi:counter", enabled_by_default: false, state_class: "measurement"},
|
||||
uv_index: {icon: "mdi:white-balance-sunny", state_class: "measurement"},
|
||||
voc: {device_class: "volatile_organic_compounds", state_class: "measurement"},
|
||||
voc_index: {state_class: "measurement", icon: "mdi:molecule"},
|
||||
voc_parts: {device_class: "volatile_organic_compounds_parts", state_class: "measurement"},
|
||||
@@ -236,6 +254,9 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
device_class: "water",
|
||||
state_class: "total_increasing",
|
||||
},
|
||||
wind_chill: {device_class: "temperature", icon: "mdi:snowflake-thermometer", state_class: "measurement"},
|
||||
wind_direction: {icon: "mdi:compass-outline", state_class: "measurement"},
|
||||
wind_speed: {device_class: "wind_speed", icon: "mdi:weather-windy", state_class: "measurement"},
|
||||
x: {icon: "mdi:axis-x-arrow", state_class: "measurement"},
|
||||
x_axis: {icon: "mdi:axis-x-arrow", state_class: "measurement"},
|
||||
y: {icon: "mdi:axis-y-arrow", state_class: "measurement"},
|
||||
@@ -283,6 +304,7 @@ const ENUM_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
thermostat_unit: {entity_category: "config", icon: "mdi:thermometer"},
|
||||
update: {device_class: "update"},
|
||||
volume: {entity_category: "config", icon: "mdi: volume-high"},
|
||||
weather_condition: {icon: "mdi:weather-partly-cloudy"},
|
||||
week: {entity_category: "config", icon: "mdi:calendar-clock"},
|
||||
} as const;
|
||||
const LIST_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
@@ -420,6 +442,7 @@ export class HomeAssistant extends Extension {
|
||||
this.eventBus.onGroupMembersChanged(this, this.onGroupMembersChanged);
|
||||
this.eventBus.onDeviceAnnounce(this, this.onZigbeeEvent);
|
||||
this.eventBus.onDeviceJoined(this, this.onZigbeeEvent);
|
||||
// TODO: this is triggering for any `data.status`?
|
||||
this.eventBus.onDeviceInterview(this, this.onZigbeeEvent);
|
||||
this.eventBus.onDeviceMessage(this, this.onZigbeeEvent);
|
||||
this.eventBus.onScenesChanged(this, this.onScenesChanged);
|
||||
@@ -689,9 +712,8 @@ export class HomeAssistant extends Extension {
|
||||
command_topic: true,
|
||||
command_topic_prefix: endpoint,
|
||||
command_topic_postfix: tempCalibration.property,
|
||||
device_class: "temperature",
|
||||
device_class: "temperature_delta",
|
||||
entity_category: "config",
|
||||
icon: "mdi:math-compass",
|
||||
...(tempCalibration.unit && {unit_of_measurement: tempCalibration.unit}),
|
||||
},
|
||||
};
|
||||
@@ -752,6 +774,34 @@ export class HomeAssistant extends Extension {
|
||||
discoveryEntries.push(discoveryEntry);
|
||||
}
|
||||
|
||||
const localTemperature = (firstExpose as zhc.Climate).features.filter(isNumericExpose).find((f) => f.name === "local_temperature");
|
||||
const temperatureSensor = allExposes?.filter(isNumericExpose).find((e) => e.name === "temperature" && e.access & ACCESS_STATE);
|
||||
const localTemperatureSensor = allExposes
|
||||
?.filter(isNumericExpose)
|
||||
.find((e) => e.name === "local_temperature" && e.access & ACCESS_STATE);
|
||||
if (localTemperature && !temperatureSensor && !localTemperatureSensor) {
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
type: "sensor",
|
||||
object_id: endpoint ? `${localTemperature.name}_${endpoint}` : `${localTemperature.name}`,
|
||||
mockProperties: [{property: localTemperature.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpoint ? `${localTemperature.label} ${endpoint}` : localTemperature.label,
|
||||
value_template: `{{ value_json.${localTemperature.property} }}`,
|
||||
...(localTemperature.unit && {unit_of_measurement: localTemperature.unit}),
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
},
|
||||
};
|
||||
|
||||
discoveryEntries.push(discoveryEntry);
|
||||
}
|
||||
|
||||
const currentHumidity = allExposes?.filter(isNumericExpose).find((e) => e.name === "humidity" && e.access & ACCESS_STATE);
|
||||
if (currentHumidity) {
|
||||
discoveryEntry.discovery_payload.current_humidity_template = `{{ value_json.${currentHumidity.property} }}`;
|
||||
discoveryEntry.discovery_payload.current_humidity_topic = true;
|
||||
}
|
||||
|
||||
discoveryEntries.push(discoveryEntry);
|
||||
break;
|
||||
}
|
||||
@@ -1184,6 +1234,61 @@ export class HomeAssistant extends Extension {
|
||||
case "composite":
|
||||
case "list": {
|
||||
const firstExposeTyped = firstExpose as zhc.Text | zhc.Composite | zhc.List;
|
||||
|
||||
// Warning composite → HA siren entity
|
||||
if (firstExposeTyped.type === "composite" && firstExposeTyped.name === "warning" && firstExposeTyped.access & ACCESS_SET) {
|
||||
const warningExpose = firstExpose as zhc.Composite;
|
||||
const modeFeature = warningExpose.features.filter(isEnumExpose).find((f) => f.name === "mode");
|
||||
const levelFeature = warningExpose.features.filter(isEnumExpose).find((f) => f.name === "level");
|
||||
const durationFeature = warningExpose.features.filter(isNumericExpose).find((f) => f.name === "duration");
|
||||
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
type: "siren",
|
||||
object_id: endpoint ? /* v8 ignore next */ `siren_${endpoint}` : "siren",
|
||||
mockProperties: [{property: warningExpose.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpoint ? /* v8 ignore next */ utils.capitalize(endpoint) : null,
|
||||
command_topic: true,
|
||||
command_topic_prefix: endpoint,
|
||||
state_topic: false,
|
||||
optimistic: true,
|
||||
},
|
||||
};
|
||||
|
||||
if (modeFeature) {
|
||||
const tones = modeFeature.values.filter((v) => v !== "stop");
|
||||
if (tones.length) {
|
||||
discoveryEntry.discovery_payload.available_tones = tones;
|
||||
}
|
||||
}
|
||||
|
||||
if (levelFeature) {
|
||||
discoveryEntry.discovery_payload.support_volume_set = true;
|
||||
}
|
||||
|
||||
if (durationFeature) {
|
||||
discoveryEntry.discovery_payload.support_duration = true;
|
||||
}
|
||||
|
||||
const levelTemplate =
|
||||
"{% if volume_level is defined %}" +
|
||||
"{% if volume_level | float <= 0.25 %}low" +
|
||||
"{% elif volume_level | float <= 0.5 %}medium" +
|
||||
"{% elif volume_level | float <= 0.75 %}high" +
|
||||
"{% else %}very_high{% endif %}" +
|
||||
"{% else %}medium{% endif %}";
|
||||
|
||||
discoveryEntry.discovery_payload.command_template =
|
||||
`{"warning": {"mode": "{{ tone | default('emergency') }}", ` +
|
||||
`"level": "${levelTemplate}", ` +
|
||||
`"duration": {{ duration | default(10) }}}}`;
|
||||
|
||||
discoveryEntry.discovery_payload.command_off_template = '{"warning": {"mode": "stop"}}';
|
||||
|
||||
discoveryEntries.push(discoveryEntry);
|
||||
break;
|
||||
}
|
||||
|
||||
if (firstExposeTyped.type === "text" && firstExposeTyped.access & ACCESS_SET) {
|
||||
discoveryEntries.push({
|
||||
type: "text",
|
||||
@@ -1675,6 +1780,10 @@ export class HomeAssistant extends Extension {
|
||||
payload.action_topic = stateTopic;
|
||||
}
|
||||
|
||||
if (payload.current_humidity_topic) {
|
||||
payload.current_humidity_topic = stateTopic;
|
||||
}
|
||||
|
||||
// Override configuration with user settings.
|
||||
if (entity.options.homeassistant != null) {
|
||||
const add = (obj: KeyValue, ignoreName: boolean): void => {
|
||||
|
||||
+30
-22
@@ -1,7 +1,8 @@
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type {Eui64} from "zigbee-herdsman/dist/zspec/tstypes";
|
||||
import type {LQITableEntry, RoutingTableEntry} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTNetworkMap} from "../types/api";
|
||||
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
@@ -91,6 +92,8 @@ export default class NetworkMap extends Extension {
|
||||
}
|
||||
|
||||
labels.push(lastSeen);
|
||||
// Escape backslashes and double-quotes to avoid breaking Graphviz .dot files.
|
||||
const escapedLabels = labels.map((label) => label.replace(/\\/g, "\\\\").replace(/"/g, '\\"'));
|
||||
|
||||
// Shape the record according to device type
|
||||
if (node.type === "Coordinator") {
|
||||
@@ -102,7 +105,7 @@ export default class NetworkMap extends Extension {
|
||||
}
|
||||
|
||||
// Add the device with its labels to the graph as a node.
|
||||
text += ` "${node.ieeeAddr}" [${style}, label="{${labels.join("|")}}"];\n`;
|
||||
text += ` "${node.ieeeAddr}" [${style}, label="{${escapedLabels.join("|")}}"];\n`;
|
||||
|
||||
/**
|
||||
* Add an edge between the device and its child to the graph
|
||||
@@ -185,8 +188,8 @@ export default class NetworkMap extends Extension {
|
||||
|
||||
async networkScan(includeRoutes: boolean): Promise<Zigbee2MQTTNetworkMap> {
|
||||
logger.info(`Starting network scan (includeRoutes '${includeRoutes}')`);
|
||||
const lqis = new Map<Device, zh.LQI>();
|
||||
const routingTables = new Map<Device, zh.RoutingTable>();
|
||||
const lqis = new Map<Device, LQITableEntry[]>();
|
||||
const routingTables = new Map<Device, RoutingTableEntry[]>();
|
||||
const failed = new Map<Device, string[]>();
|
||||
const requestWithRetry = async <T>(request: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
@@ -210,7 +213,7 @@ export default class NetworkMap extends Extension {
|
||||
await utils.sleep(1); // sleep 1 second between each scan to reduce stress on network.
|
||||
|
||||
try {
|
||||
const result = await requestWithRetry<zh.LQI>(async () => await device.zh.lqi());
|
||||
const result = await requestWithRetry<LQITableEntry[]>(async () => await device.zh.lqi());
|
||||
lqis.set(device, result);
|
||||
logger.debug(`LQI succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
@@ -222,7 +225,7 @@ export default class NetworkMap extends Extension {
|
||||
|
||||
if (includeRoutes) {
|
||||
try {
|
||||
const result = await requestWithRetry<zh.RoutingTable>(async () => await device.zh.routingTable());
|
||||
const result = await requestWithRetry<RoutingTableEntry[]>(async () => await device.zh.routingTable());
|
||||
routingTables.set(device, result);
|
||||
logger.debug(`Routing table succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
@@ -274,42 +277,47 @@ export default class NetworkMap extends Extension {
|
||||
}
|
||||
|
||||
// Add links
|
||||
for (const [device, lqi] of lqis) {
|
||||
for (const neighbor of lqi.neighbors) {
|
||||
for (const [device, table] of lqis) {
|
||||
for (const neighbor of table) {
|
||||
if (neighbor.relationship > 3) {
|
||||
// Relationship is not active, skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
let neighborEui64 = neighbor.eui64;
|
||||
|
||||
// Some Xiaomi devices return 0x00 as the neighbor ieeeAddr (obviously not correct).
|
||||
// Determine the correct ieeeAddr based on the networkAddress.
|
||||
if (neighbor.ieeeAddr === "0x0000000000000000") {
|
||||
const neighborDevice = this.zigbee.deviceByNetworkAddress(neighbor.networkAddress);
|
||||
if (neighborEui64 === "0x0000000000000000") {
|
||||
const neighborDevice = this.zigbee.deviceByNetworkAddress(neighbor.nwkAddress);
|
||||
|
||||
if (neighborDevice) {
|
||||
neighbor.ieeeAddr = neighborDevice.ieeeAddr;
|
||||
neighborEui64 = neighborDevice.ieeeAddr as Eui64;
|
||||
}
|
||||
}
|
||||
|
||||
const link: Zigbee2MQTTNetworkMap["links"][number] = {
|
||||
source: {ieeeAddr: neighbor.ieeeAddr, networkAddress: neighbor.networkAddress},
|
||||
source: {ieeeAddr: neighborEui64, networkAddress: neighbor.nwkAddress},
|
||||
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.zh.networkAddress},
|
||||
linkquality: neighbor.linkquality,
|
||||
depth: neighbor.depth,
|
||||
routes: [],
|
||||
// DEPRECATED:
|
||||
sourceIeeeAddr: neighbor.ieeeAddr,
|
||||
targetIeeeAddr: device.ieeeAddr,
|
||||
sourceNwkAddr: neighbor.networkAddress,
|
||||
lqi: neighbor.linkquality,
|
||||
deviceType: neighbor.deviceType,
|
||||
rxOnWhenIdle: neighbor.rxOnWhenIdle,
|
||||
relationship: neighbor.relationship,
|
||||
permitJoining: neighbor.permitJoining,
|
||||
depth: neighbor.depth,
|
||||
lqi: neighbor.lqi,
|
||||
routes: [],
|
||||
// below are @deprecated
|
||||
sourceIeeeAddr: neighborEui64,
|
||||
targetIeeeAddr: device.ieeeAddr,
|
||||
sourceNwkAddr: neighbor.nwkAddress,
|
||||
linkquality: neighbor.lqi,
|
||||
};
|
||||
|
||||
const routingTable = routingTables.get(device);
|
||||
|
||||
if (routingTable) {
|
||||
for (const entry of routingTable.table) {
|
||||
if (entry.status === "ACTIVE" && entry.nextHop === neighbor.networkAddress) {
|
||||
for (const entry of routingTable) {
|
||||
if (entry.nextHopAddress === neighbor.nwkAddress) {
|
||||
link.routes.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ export default class OnEvent extends Extension {
|
||||
}
|
||||
});
|
||||
this.eventBus.onDeviceInterview(this, async (data) => {
|
||||
// TODO: this is triggering for any `data.status`, any use outside `successful`?
|
||||
// ZHC definition would skip from `device.definition?` but OnEvent from ZHC index wouldn't => triple triggering?
|
||||
await this.callOnEvent(data.device, {type: "deviceInterview", data: {...this.#getOnEventBaseData(data.device), status: data.status}});
|
||||
});
|
||||
this.eventBus.onDeviceAnnounce(this, async (data) => {
|
||||
@@ -59,8 +61,11 @@ export default class OnEvent extends Extension {
|
||||
override async stop(): Promise<void> {
|
||||
await super.stop();
|
||||
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await this.callOnEvent(device, {type: "stop", data: {ieeeAddr: device.ieeeAddr}});
|
||||
// ensure properly started, else this throws undesired errors (e.g. SIGINT during startup)
|
||||
if (this.zigbee.zhController !== undefined) {
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await this.callOnEvent(device, {type: "stop", data: {ieeeAddr: device.ieeeAddr}});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+282
-197
@@ -1,10 +1,10 @@
|
||||
import assert from "node:assert";
|
||||
import path from "node:path";
|
||||
import {existsSync, mkdirSync, rmSync, writeFileSync} from "node:fs";
|
||||
import {join} from "node:path";
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {Zcl} from "zigbee-herdsman";
|
||||
import type {Ota} from "zigbee-herdsman-converters";
|
||||
import {ota} from "zigbee-herdsman-converters";
|
||||
import {setOtaConfiguration, Zcl} from "zigbee-herdsman";
|
||||
import type {OtaDataSettings, OtaSource, OtaUpdateAvailableResult} from "zigbee-herdsman/dist/controller/tstype";
|
||||
import Device from "../model/device";
|
||||
import type {Zigbee2MQTTAPI} from "../types/api";
|
||||
import dataDir from "../util/data";
|
||||
@@ -22,54 +22,65 @@ export interface UpdatePayload {
|
||||
state: UpdateState;
|
||||
installed_version: number | null;
|
||||
latest_version: number | null;
|
||||
latest_source: string | null;
|
||||
latest_release_notes: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write to `dataDir` and return created path
|
||||
*/
|
||||
function writeFirmwareHexToDataDir(hex: string, fileName: string | undefined, deviceIeee: string): string {
|
||||
if (!fileName) {
|
||||
fileName = `${deviceIeee}_${Date.now()}`;
|
||||
}
|
||||
|
||||
const baseDir = dataDir.joinPath("ota");
|
||||
|
||||
if (!existsSync(baseDir)) {
|
||||
mkdirSync(baseDir, {recursive: true});
|
||||
}
|
||||
|
||||
const filePath = join(baseDir, fileName);
|
||||
|
||||
writeFileSync(filePath, Buffer.from(hex, "hex"));
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export default class OTAUpdate extends Extension {
|
||||
#topicRegex = new RegExp(
|
||||
`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check|schedule|unschedule)/?(downgrade)?`,
|
||||
"i",
|
||||
);
|
||||
private inProgress = new Set<string>();
|
||||
private lastChecked = new Map<string, number>();
|
||||
private scheduledUpgrades = new Set<string>();
|
||||
private scheduledDowngrades = new Set<string>();
|
||||
#inProgress = new Set<string>();
|
||||
#lastChecked = new Map<string, number>();
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.eventBus.onDeviceMessage(this, this.onZigbeeEvent);
|
||||
|
||||
const otaSettings = settings.get().ota;
|
||||
// Let OTA module know if the override index file is provided
|
||||
let overrideIndexLocation = otaSettings.zigbee_ota_override_index_location;
|
||||
|
||||
// If the file name is not a full path, then treat it as a relative to the data directory
|
||||
if (overrideIndexLocation && !ota.isValidUrl(overrideIndexLocation) && !path.isAbsolute(overrideIndexLocation)) {
|
||||
overrideIndexLocation = dataDir.joinPath(overrideIndexLocation);
|
||||
}
|
||||
|
||||
// In order to support local firmware files we need to let zigbeeOTA know where the data directory is
|
||||
ota.setConfiguration({
|
||||
dataDir: dataDir.getPath(),
|
||||
overrideIndexLocation,
|
||||
// TODO: implement me
|
||||
imageBlockResponseDelay: otaSettings.image_block_response_delay,
|
||||
defaultMaximumDataSize: otaSettings.default_maximum_data_size,
|
||||
});
|
||||
setOtaConfiguration(dataDir.getPath(), settings.get().ota.zigbee_ota_override_index_location);
|
||||
|
||||
// In case Zigbee2MQTT is restared during an update, progress and remaining values are still in state, remove them.
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
this.#removeProgressAndRemainingFromState(device);
|
||||
|
||||
// Reset update state, e.g. when Z2M restarted during update.
|
||||
if (this.state.get(device).update?.state === "updating") {
|
||||
this.state.get(device).update.state = "available";
|
||||
this.state.get(device).update.state = "idle";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeProgressAndRemainingFromState(device: Device): void {
|
||||
// mostly intended for testing
|
||||
clearState(): void {
|
||||
this.#inProgress.clear();
|
||||
this.#lastChecked.clear();
|
||||
}
|
||||
|
||||
#removeProgressAndRemainingFromState(device: Device): void {
|
||||
const deviceState = this.state.get(device);
|
||||
|
||||
if (deviceState.update) {
|
||||
@@ -79,7 +90,7 @@ export default class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
@bind private async onZigbeeEvent(data: eventdata.DeviceMessage): Promise<void> {
|
||||
if (data.type !== "commandQueryNextImageRequest" || !data.device.definition || this.inProgress.has(data.device.ieeeAddr)) {
|
||||
if (data.type !== "commandQueryNextImageRequest" || !data.device.definition || this.#inProgress.has(data.device.ieeeAddr)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,115 +102,84 @@ export default class OTAUpdate extends Extension {
|
||||
|
||||
logger.debug(`Device '${data.device.name}' requested OTA`);
|
||||
|
||||
if (data.device.definition.ota) {
|
||||
if (this.scheduledUpgrades.has(data.device.ieeeAddr) || this.scheduledDowngrades.has(data.device.ieeeAddr)) {
|
||||
this.inProgress.add(data.device.ieeeAddr);
|
||||
if (data.device.zh.scheduledOta) {
|
||||
// allow custom source to override check for definition `ota`
|
||||
if (data.device.zh.scheduledOta?.url !== undefined || data.device.definition.ota) {
|
||||
this.#inProgress.add(data.device.ieeeAddr);
|
||||
|
||||
logger.info(`Updating '${data.device.name}' to latest firmware`);
|
||||
|
||||
try {
|
||||
const fileVersion = await ota.update(
|
||||
data.device.zh,
|
||||
data.device.otaExtraMetas,
|
||||
this.scheduledDowngrades.has(data.device.ieeeAddr),
|
||||
async (progress, remaining) => {
|
||||
let msg = `Update of '${data.device.name}' at ${progress.toFixed(2)}%`;
|
||||
|
||||
if (remaining) {
|
||||
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
|
||||
}
|
||||
|
||||
logger.info(msg);
|
||||
|
||||
await this.publishEntityState(
|
||||
data.device,
|
||||
this.getEntityPublishPayload(data.device, "updating", progress, remaining ?? undefined),
|
||||
);
|
||||
},
|
||||
data.data as Ota.ImageInfo,
|
||||
data.meta.zclTransactionSequenceNumber,
|
||||
);
|
||||
|
||||
// remove right away on update success or no image in case any of the below calls fail
|
||||
this.scheduledUpgrades.delete(data.device.ieeeAddr);
|
||||
this.scheduledDowngrades.delete(data.device.ieeeAddr);
|
||||
|
||||
if (fileVersion === undefined) {
|
||||
logger.info(`No image currently available for '${data.device.name}'. Unscheduling.`);
|
||||
|
||||
// XXX: superfluous?
|
||||
this.removeProgressAndRemainingFromState(data.device);
|
||||
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, "idle"));
|
||||
this.inProgress.delete(data.device.ieeeAddr);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Finished update of '${data.device.name}'`);
|
||||
|
||||
this.removeProgressAndRemainingFromState(data.device);
|
||||
await this.publishEntityState(
|
||||
const otaSettings = settings.get().ota;
|
||||
const [, toVersion] = await this.#update(
|
||||
undefined, // uses internally registered schedule
|
||||
data.device,
|
||||
this.getEntityPublishPayload(data.device, {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}),
|
||||
data.data as Zcl.ClustersTypes.TClusterCommandPayload<"genOta", "queryNextImageRequest">,
|
||||
data.meta.zclTransactionSequenceNumber,
|
||||
{
|
||||
// fallbacks are only to satisfy typing, should always be defined from settings defaults
|
||||
requestTimeout: otaSettings.image_block_request_timeout ?? /* v8 ignore next */ 150000,
|
||||
responseDelay: otaSettings.image_block_response_delay ?? /* v8 ignore next */ 250,
|
||||
baseSize: otaSettings.default_maximum_data_size ?? /* v8 ignore next */ 50,
|
||||
},
|
||||
data.endpoint,
|
||||
);
|
||||
|
||||
const firmwareTo = await this.readSoftwareBuildIDAndDateCode(data.device);
|
||||
|
||||
logger.info(() => `Device '${data.device.name}' was updated to '${stringify(firmwareTo)}'`);
|
||||
|
||||
/**
|
||||
* Re-configure after reading software build ID and date code, some devices use a
|
||||
* custom attribute for this (e.g. Develco SMSZB-120)
|
||||
*/
|
||||
this.eventBus.emitReconfigure({device: data.device});
|
||||
this.eventBus.emitDevicesChanged();
|
||||
if (toVersion === undefined) {
|
||||
logger.info(`No OTA image currently available for '${data.device.name}'. Unscheduled.`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug(`Update of '${data.device.name}' failed (${e}). Retry scheduled for next request.`);
|
||||
logger.debug(`OTA update of '${data.device.name}' failed (${e}). Retry scheduled for next request.`);
|
||||
|
||||
this.removeProgressAndRemainingFromState(data.device);
|
||||
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, "scheduled"));
|
||||
this.#removeProgressAndRemainingFromState(data.device);
|
||||
await this.publishEntityState(data.device, this.#getEntityPublishPayload(data.device, "scheduled"));
|
||||
}
|
||||
|
||||
this.inProgress.delete(data.device.ieeeAddr);
|
||||
this.#inProgress.delete(data.device.ieeeAddr);
|
||||
|
||||
return; // we're done
|
||||
}
|
||||
}
|
||||
|
||||
if (!settings.get().ota.disable_automatic_update_check) {
|
||||
if (data.device.definition.ota) {
|
||||
if (!data.device.options.disable_automatic_update_check && !settings.get().ota.disable_automatic_update_check) {
|
||||
// When a device does a next image request, it will usually do it a few times after each other
|
||||
// with only 10 - 60 seconds inbetween. It doesn't make sense to check for a new update
|
||||
// each time, so this interval can be set by the user. The default is 1,440 minutes (one day).
|
||||
const updateCheckInterval = settings.get().ota.update_check_interval * 1000 * 60;
|
||||
const deviceLastChecked = this.lastChecked.get(data.device.ieeeAddr);
|
||||
const deviceLastChecked = this.#lastChecked.get(data.device.ieeeAddr);
|
||||
const check = deviceLastChecked !== undefined ? Date.now() - deviceLastChecked > updateCheckInterval : true;
|
||||
|
||||
if (!check) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inProgress.add(data.device.ieeeAddr);
|
||||
this.lastChecked.set(data.device.ieeeAddr, Date.now());
|
||||
let availableResult: Ota.UpdateAvailableResult | undefined;
|
||||
this.#inProgress.add(data.device.ieeeAddr);
|
||||
this.#lastChecked.set(data.device.ieeeAddr, Date.now());
|
||||
let availableResult: OtaUpdateAvailableResult | undefined;
|
||||
|
||||
try {
|
||||
// never use 'previous' when responding to device request
|
||||
availableResult = await ota.isUpdateAvailable(data.device.zh, data.device.otaExtraMetas, data.data as Ota.ImageInfo, false);
|
||||
// auto-check defaults to zigbee-OTA + potential local index, and never `downgrade`
|
||||
availableResult = await data.device.zh.checkOta(
|
||||
{downgrade: false},
|
||||
data.data as Zcl.ClustersTypes.TClusterCommandPayload<"genOta", "queryNextImageRequest">,
|
||||
data.device.otaExtraMetas,
|
||||
data.endpoint,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to check if update available for '${data.device.name}' (${error})`);
|
||||
logger.debug(`Failed to check if OTA update available for '${data.device.name}' (${error})`);
|
||||
}
|
||||
|
||||
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, availableResult ?? "idle"));
|
||||
await this.publishEntityState(data.device, this.#getEntityPublishPayload(data.device, availableResult ?? "idle"));
|
||||
|
||||
if (availableResult?.available) {
|
||||
const message = `Update available for '${data.device.name}'`;
|
||||
logger.info(message);
|
||||
logger.info(`OTA update available for '${data.device.name}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Respond to stop the client from requesting OTAs
|
||||
const endpoint = data.device.zh.endpoints.find((e) => e.supportsOutputCluster("genOta")) || data.endpoint;
|
||||
await endpoint.commandResponse(
|
||||
await data.endpoint.commandResponse(
|
||||
"genOta",
|
||||
"queryNextImageResponse",
|
||||
{status: Zcl.Status.NO_IMAGE_AVAILABLE},
|
||||
@@ -207,47 +187,53 @@ export default class OTAUpdate extends Extension {
|
||||
data.meta.zclTransactionSequenceNumber,
|
||||
);
|
||||
logger.debug(`Responded to OTA request of '${data.device.name}' with 'NO_IMAGE_AVAILABLE'`);
|
||||
this.inProgress.delete(data.device.ieeeAddr);
|
||||
this.#inProgress.delete(data.device.ieeeAddr);
|
||||
}
|
||||
|
||||
private async readSoftwareBuildIDAndDateCode(
|
||||
async #readSoftwareBuildIDAndDateCode(
|
||||
device: Device,
|
||||
sendPolicy?: "immediate",
|
||||
): Promise<{softwareBuildID: string; dateCode: string} | undefined> {
|
||||
try {
|
||||
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster("genBasic"));
|
||||
assert(endpoint);
|
||||
|
||||
const result = await endpoint.read("genBasic", ["dateCode", "swBuildId"], {sendPolicy});
|
||||
|
||||
return {softwareBuildID: result.swBuildId, dateCode: result.dateCode};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getEntityPublishPayload(
|
||||
device: Device,
|
||||
state: Ota.UpdateAvailableResult | UpdateState,
|
||||
progress?: number,
|
||||
remaining?: number,
|
||||
): UpdatePayload {
|
||||
const deviceUpdateState = this.state.get(device).update;
|
||||
const payload: UpdatePayload = {
|
||||
update: {
|
||||
state: typeof state === "string" ? state : state.available ? "available" : "idle",
|
||||
installed_version: typeof state === "string" ? deviceUpdateState?.installed_version : state.currentFileVersion,
|
||||
latest_version: typeof state === "string" ? deviceUpdateState?.latest_version : state.otaFileVersion,
|
||||
},
|
||||
};
|
||||
#getEntityPublishPayload(device: Device, state: OtaUpdateAvailableResult | UpdateState, progress?: number, remaining?: number): UpdatePayload {
|
||||
const deviceUpdateState = this.state.get(device).update as UpdatePayload["update"];
|
||||
const update: UpdatePayload["update"] =
|
||||
typeof state === "string"
|
||||
? {
|
||||
state,
|
||||
installed_version: deviceUpdateState?.installed_version,
|
||||
latest_version: deviceUpdateState?.latest_version,
|
||||
latest_source: deviceUpdateState?.latest_source,
|
||||
latest_release_notes: deviceUpdateState?.latest_release_notes,
|
||||
}
|
||||
: {
|
||||
state: state.available ? "available" : "idle",
|
||||
installed_version: state.current.fileVersion,
|
||||
latest_version: state.availableMeta?.fileVersion ?? state.current.fileVersion,
|
||||
latest_source: state.availableMeta?.url || null,
|
||||
latest_release_notes: state.availableMeta?.releaseNotes || null,
|
||||
};
|
||||
|
||||
if (progress !== undefined) {
|
||||
payload.update.progress = progress;
|
||||
update.progress = progress;
|
||||
}
|
||||
|
||||
if (remaining !== undefined) {
|
||||
payload.update.remaining = Math.round(remaining);
|
||||
update.remaining = Math.round(remaining);
|
||||
}
|
||||
|
||||
return payload;
|
||||
return {update};
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
@@ -265,7 +251,14 @@ export default class OTAUpdate extends Extension {
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule/downgrade"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/unschedule"];
|
||||
const ID = (typeof message === "object" && message.id !== undefined ? message.id : message) as string;
|
||||
// TODO: deprecated 3.0 should remove string payload, enforce object
|
||||
const messageObject = typeof message === "object";
|
||||
|
||||
if (messageObject) {
|
||||
assert(message.id, "Invalid payload");
|
||||
}
|
||||
|
||||
const ID = (messageObject ? message.id : message) as string;
|
||||
const device = this.zigbee.resolveEntity(ID);
|
||||
const type = topicMatch[1] as "check" | "update" | "schedule" | "unschedule";
|
||||
const downgrade = topicMatch[2] === "downgrade";
|
||||
@@ -274,34 +267,53 @@ export default class OTAUpdate extends Extension {
|
||||
|
||||
if (!(device instanceof Device)) {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
} else if (this.inProgress.has(device.ieeeAddr)) {
|
||||
} else if (this.#inProgress.has(device.ieeeAddr)) {
|
||||
// also guards against scheduling while check/update op in progress that could result in undesired OTA state
|
||||
error = `Update or check for update already in progress for '${device.name}'`;
|
||||
error = `OTA update or check for update already in progress for '${device.name}'`;
|
||||
} else {
|
||||
switch (type) {
|
||||
case "check": {
|
||||
this.inProgress.add(device.ieeeAddr);
|
||||
this.#inProgress.add(device.ieeeAddr);
|
||||
|
||||
logger.info(`Checking if update available for '${device.name}'`);
|
||||
const source: OtaSource = {downgrade};
|
||||
|
||||
if (messageObject) {
|
||||
const payload = message as
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/check"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/check/downgrade"];
|
||||
|
||||
if (payload.url) {
|
||||
source.url = payload.url;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
|
||||
logger.info(`Checking if OTA update available for '${device.name}'`);
|
||||
|
||||
try {
|
||||
const availableResult = await ota.isUpdateAvailable(device.zh, device.otaExtraMetas, undefined, downgrade);
|
||||
const availableResult = await device.zh.checkOta(source, undefined, device.otaExtraMetas);
|
||||
|
||||
logger.info(`${availableResult.available ? "Update" : "No update"} available for '${device.name}'`);
|
||||
logger.info(`${availableResult.available ? "" : "No "}OTA update available for '${device.name}'`);
|
||||
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, availableResult));
|
||||
this.lastChecked.set(device.ieeeAddr, Date.now());
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, availableResult));
|
||||
this.#lastChecked.set(device.ieeeAddr, Date.now());
|
||||
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/check">(message, {
|
||||
id: ID,
|
||||
update_available: availableResult.available,
|
||||
downgrade: source.downgrade,
|
||||
source: availableResult.availableMeta?.url,
|
||||
release_notes: availableResult.availableMeta?.releaseNotes,
|
||||
});
|
||||
|
||||
await this.mqtt.publish("bridge/response/device/ota_update/check", stringify(response));
|
||||
} catch (e) {
|
||||
error = `Failed to check if update available for '${device.name}' (${(e as Error).message})`;
|
||||
error = `Failed to check if OTA update available for '${device.name}' (${(e as Error).message})`;
|
||||
errorStack = (e as Error).stack;
|
||||
}
|
||||
|
||||
@@ -309,94 +321,113 @@ export default class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
case "update": {
|
||||
this.inProgress.add(device.ieeeAddr);
|
||||
this.#inProgress.add(device.ieeeAddr);
|
||||
|
||||
if (this.scheduledUpgrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' upgrade was cancelled by manual update`);
|
||||
} else if (this.scheduledDowngrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' downgrade was cancelled by manual update`);
|
||||
}
|
||||
const otaSettings = settings.get().ota;
|
||||
const source: OtaSource = {downgrade};
|
||||
const dataSettings: OtaDataSettings = {
|
||||
// fallbacks are only to satisfy typing, should always be defined from settings defaults
|
||||
requestTimeout: otaSettings.image_block_request_timeout ?? /* v8 ignore next */ 150000,
|
||||
responseDelay: otaSettings.image_block_response_delay ?? /* v8 ignore next */ 250,
|
||||
baseSize: otaSettings.default_maximum_data_size ?? /* v8 ignore next */ 50,
|
||||
};
|
||||
|
||||
logger.info(`Updating '${device.name}' to ${downgrade ? "previous" : "latest"} firmware`);
|
||||
if (messageObject) {
|
||||
const payload = message as
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/update"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/update/downgrade"];
|
||||
|
||||
try {
|
||||
const firmwareFrom = await this.readSoftwareBuildIDAndDateCode(device, "immediate");
|
||||
const fileVersion = await ota.update(device.zh, device.otaExtraMetas, downgrade, async (progress, remaining) => {
|
||||
let msg = `Update of '${device.name}' at ${progress.toFixed(2)}%`;
|
||||
if (payload.hex) {
|
||||
assert(payload.hex.data);
|
||||
|
||||
if (remaining) {
|
||||
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
|
||||
}
|
||||
|
||||
logger.info(msg);
|
||||
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, "updating", progress, remaining ?? undefined));
|
||||
});
|
||||
|
||||
if (fileVersion === undefined) {
|
||||
throw new Error("No image currently available");
|
||||
// write to `dataDir` and pass created path as source URL
|
||||
source.url = writeFirmwareHexToDataDir(payload.hex.data, payload.hex.file_name, device.ieeeAddr);
|
||||
} else if (payload.url) {
|
||||
source.url = payload.url;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
|
||||
logger.info(`Finished update of '${device.name}'`);
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(
|
||||
device,
|
||||
this.getEntityPublishPayload(device, {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}),
|
||||
);
|
||||
if (payload.image_block_request_timeout) {
|
||||
dataSettings.requestTimeout = payload.image_block_request_timeout;
|
||||
}
|
||||
|
||||
const firmwareTo = await this.readSoftwareBuildIDAndDateCode(device);
|
||||
if (payload.image_block_response_delay) {
|
||||
dataSettings.responseDelay = payload.image_block_response_delay;
|
||||
}
|
||||
|
||||
logger.info(() => `Device '${device.name}' was updated from '${stringify(firmwareFrom)}' to '${stringify(firmwareTo)}'`);
|
||||
if (payload.default_maximum_data_size) {
|
||||
dataSettings.baseSize = payload.default_maximum_data_size;
|
||||
}
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-configure after reading software build ID and date code, some devices use a
|
||||
* custom attribute for this (e.g. Develco SMSZB-120)
|
||||
*/
|
||||
this.eventBus.emitReconfigure({device});
|
||||
this.eventBus.emitDevicesChanged();
|
||||
logger.info(`OTA updating '${device.name}' to ${downgrade ? "previous" : "latest"} firmware`);
|
||||
|
||||
try {
|
||||
const firmwareFrom = await this.#readSoftwareBuildIDAndDateCode(device, "immediate");
|
||||
const [fromVersion, toVersion] = await this.#update(source, device, undefined, undefined, dataSettings);
|
||||
|
||||
if (toVersion === undefined) {
|
||||
error = `Update of '${device.name}' failed (No image currently available)`;
|
||||
break;
|
||||
}
|
||||
|
||||
const firmwareTo = await this.#readSoftwareBuildIDAndDateCode(device);
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/update">(message, {
|
||||
id: ID,
|
||||
from: firmwareFrom ? {software_build_id: firmwareFrom.softwareBuildID, date_code: firmwareFrom.dateCode} : undefined,
|
||||
to: firmwareTo ? {software_build_id: firmwareTo.softwareBuildID, date_code: firmwareTo.dateCode} : undefined,
|
||||
from: {
|
||||
file_version: fromVersion,
|
||||
software_build_id: firmwareFrom?.softwareBuildID,
|
||||
date_code: firmwareFrom?.dateCode,
|
||||
},
|
||||
to: {file_version: toVersion, software_build_id: firmwareTo?.softwareBuildID, date_code: firmwareTo?.dateCode},
|
||||
});
|
||||
|
||||
await this.mqtt.publish("bridge/response/device/ota_update/update", stringify(response));
|
||||
} catch (e) {
|
||||
logger.debug(`Update of '${device.name}' failed (${e})`);
|
||||
error = `Update of '${device.name}' failed (${(e as Error).message})`;
|
||||
logger.debug(`OTA update of '${device.name}' failed (${e})`);
|
||||
error = `OTA update of '${device.name}' failed (${(e as Error).message})`;
|
||||
errorStack = (e as Error).stack;
|
||||
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, "available"));
|
||||
this.#removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, "available"));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "schedule": {
|
||||
// ensure only one type scheduled by deleting from the other if necessary
|
||||
if (downgrade) {
|
||||
if (this.scheduledUpgrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' upgrade was cancelled in favor of new downgrade request`);
|
||||
}
|
||||
const source: OtaSource = {downgrade};
|
||||
|
||||
this.scheduledDowngrades.add(device.ieeeAddr);
|
||||
} else {
|
||||
if (this.scheduledDowngrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' downgrade was cancelled in favor of new upgrade request`);
|
||||
}
|
||||
if (messageObject) {
|
||||
const payload = message as
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule/downgrade"];
|
||||
|
||||
this.scheduledUpgrades.add(device.ieeeAddr);
|
||||
if (payload.hex) {
|
||||
assert(payload.hex.data);
|
||||
|
||||
// write to `dataDir` and pass created path as source URL
|
||||
source.url = writeFirmwareHexToDataDir(payload.hex.data, payload.hex.file_name, device.ieeeAddr);
|
||||
} else if (payload.url) {
|
||||
source.url = payload.url;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
|
||||
logger.info(`Scheduled '${device.name}' to ${downgrade ? "downgrade" : "upgrade"} firmware on next request from device`);
|
||||
device.zh.scheduleOta(source);
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, "scheduled"));
|
||||
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, "scheduled", undefined, undefined));
|
||||
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/schedule">(message, {
|
||||
id: ID,
|
||||
});
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/schedule">(message, {id: ID, url: source.url});
|
||||
|
||||
await this.mqtt.publish("bridge/response/device/ota_update/schedule", stringify(response));
|
||||
|
||||
@@ -404,13 +435,12 @@ export default class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
case "unschedule": {
|
||||
if (this.scheduledUpgrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' upgrade was cancelled`);
|
||||
} else if (this.scheduledDowngrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' downgrade was cancelled`);
|
||||
if (device.zh.scheduledOta?.url?.startsWith(dataDir.joinPath("ota"))) {
|
||||
rmSync(device.zh.scheduledOta.url, {force: true});
|
||||
}
|
||||
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, "idle", undefined, undefined));
|
||||
device.zh.unscheduleOta();
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, "idle"));
|
||||
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/unschedule">(message, {
|
||||
id: ID,
|
||||
@@ -422,7 +452,7 @@ export default class OTAUpdate extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
this.inProgress.delete(device.ieeeAddr);
|
||||
this.#inProgress.delete(device.ieeeAddr);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -436,4 +466,59 @@ export default class OTAUpdate extends Extension {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Do the bulk of the update work (hand over to zigbee-herdsman, then re-interview).
|
||||
*
|
||||
* `dataSettings` object may be mutated by zigbee-herdsman to fit request (e.g. known manuf quirk)
|
||||
*/
|
||||
async #update(
|
||||
source: OtaSource | undefined,
|
||||
device: Device,
|
||||
requestPayload: Zcl.ClustersTypes.TClusterCommandPayload<"genOta", "queryNextImageRequest"> | undefined,
|
||||
requestTsn: number | undefined,
|
||||
dataSettings: OtaDataSettings,
|
||||
endpoint?: zh.Endpoint,
|
||||
): Promise<[from: number, to: number | undefined]> {
|
||||
const [from, to] = await device.zh.updateOta(
|
||||
source,
|
||||
requestPayload,
|
||||
requestTsn,
|
||||
device.otaExtraMetas,
|
||||
async (progress, remaining) => {
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, "updating", progress, remaining));
|
||||
},
|
||||
dataSettings,
|
||||
endpoint,
|
||||
);
|
||||
|
||||
if (to === undefined) {
|
||||
this.#removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, {available: false, current: from}));
|
||||
|
||||
return [from.fileVersion, undefined];
|
||||
}
|
||||
|
||||
logger.info(`Finished update of '${device.name}'`);
|
||||
|
||||
this.#removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, {available: false, current: to}));
|
||||
|
||||
logger.info(() => `Device '${device.name}' was OTA updated from '${from.fileVersion}' to '${to.fileVersion}'`);
|
||||
|
||||
// OTA update can bring new features & co, force full re-interview and re-configure, same as a "device joined"
|
||||
if (device.zh.meta.configured !== undefined) {
|
||||
delete device.zh.meta.configured;
|
||||
|
||||
device.zh.save();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
device.reInterview(this.eventBus).catch((error) => {
|
||||
logger.error(`${error.message}. Re-try manually after some time.`);
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
return [from.fileVersion, to.fileVersion];
|
||||
}
|
||||
}
|
||||
|
||||
+20
-2
@@ -1,9 +1,10 @@
|
||||
import assert from "node:assert";
|
||||
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
|
||||
import type {OtaExtraMetas} from "zigbee-herdsman/dist/controller/tstype";
|
||||
import type {CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
import {access, Numeric} from "zigbee-herdsman-converters";
|
||||
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
|
||||
const LINKQUALITY = new Numeric("linkquality", access.STATE)
|
||||
@@ -38,7 +39,7 @@ export default class Device {
|
||||
get customClusters(): CustomClusters {
|
||||
return this.zh.customClusters;
|
||||
}
|
||||
get otaExtraMetas(): zhc.Ota.ExtraMetas {
|
||||
get otaExtraMetas(): OtaExtraMetas {
|
||||
return typeof this.definition?.ota === "object" ? this.definition.ota : {};
|
||||
}
|
||||
get interviewed(): boolean {
|
||||
@@ -69,6 +70,23 @@ export default class Device {
|
||||
}
|
||||
}
|
||||
|
||||
async reInterview(eventBus: EventBus): Promise<void> {
|
||||
// logic follows that of receiving `deviceInterview` event from ZH layer
|
||||
logger.info(`Interviewing '${this.name}'`);
|
||||
eventBus.emitDeviceInterview({status: "started", device: this});
|
||||
|
||||
try {
|
||||
await this.zh.interview(true);
|
||||
// A re-interview can for example result in a different modelId, therefore reconsider the definition.
|
||||
await this.resolveDefinition(true);
|
||||
logger.info(`Successfully interviewed '${this.name}'`);
|
||||
eventBus.emitDeviceInterview({status: "successful", device: this});
|
||||
} catch (error) {
|
||||
eventBus.emitDeviceInterview({status: "failed", device: this});
|
||||
throw new Error(`Interview of '${this.name}' (${this.ieeeAddr}) failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
ensureInSettings(): void {
|
||||
if (this.zh.type !== "Coordinator" && !settings.getDevice(this.zh.ieeeAddr)) {
|
||||
settings.addDevice(this.zh.ieeeAddr);
|
||||
|
||||
+6
-1
@@ -152,7 +152,11 @@ export default class Mqtt {
|
||||
|
||||
const stateData: Zigbee2MQTTAPI["bridge/state"] = {state: "offline"};
|
||||
|
||||
await this.publish("bridge/state", JSON.stringify(stateData), {clientOptions: {retain: true}});
|
||||
// prevent undesirable error when receiving SIGTERM/SIGINT during startup
|
||||
if (this.client) {
|
||||
await this.publish("bridge/state", JSON.stringify(stateData), {clientOptions: {retain: true}});
|
||||
}
|
||||
|
||||
this.eventBus.removeListeners(this);
|
||||
logger.info("Disconnecting from MQTT server");
|
||||
await this.client?.endAsync();
|
||||
@@ -194,6 +198,7 @@ export default class Mqtt {
|
||||
}
|
||||
|
||||
async publish(topic: string, payload: string, options: Partial<MqttPublishOptions> = {}): Promise<void> {
|
||||
// TODO: add `options.validateTopic: boolean` to bypass these checks when topic is "controlled"
|
||||
if (topic.includes("+") || topic.includes("#")) {
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/26939#issuecomment-2772309646
|
||||
logger.error(`Topic '${topic}' includes wildcard characters, skipping publish.`);
|
||||
|
||||
+8
-5
@@ -51,11 +51,14 @@ class State {
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Remove any invalid states (ie when the device has left the network) when the system is stopped
|
||||
for (const [key] of this.state) {
|
||||
if (typeof key === "string" && key.startsWith("0x") && !this.zigbee.resolveEntity(key)) {
|
||||
// string key = ieeeAddr
|
||||
this.state.delete(key);
|
||||
// ensure properly started, else this throws undesired errors (e.g. SIGINT during startup)
|
||||
if (this.zigbee.zhController !== undefined) {
|
||||
// Remove any invalid states (ie when the device has left the network) when the system is stopped
|
||||
for (const [key] of this.state) {
|
||||
if (typeof key === "string" && key.startsWith("0x") && !this.zigbee.resolveEntity(key)) {
|
||||
// string key = ieeeAddr
|
||||
this.state.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+136
-22
@@ -1,5 +1,8 @@
|
||||
import type * as zigbeeHerdsman from "zigbee-herdsman/dist";
|
||||
import type {ZclPayload} from "zigbee-herdsman/dist/adapter/events";
|
||||
import type {Eui64} from "zigbee-herdsman/dist/zspec/tstypes";
|
||||
import type {ClusterDefinition, ClusterName, CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
|
||||
import type {GenericZdoResponse, RoutingTableEntry} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
|
||||
import type * as zigbeeHerdsmanConverter from "zigbee-herdsman-converters";
|
||||
import type {Base} from "zigbee-herdsman-converters/lib/exposes";
|
||||
|
||||
@@ -29,6 +32,33 @@ import type {LogLevel, schemaJson} from "../util/settings";
|
||||
// biome-ignore lint/suspicious/noExplicitAny: API
|
||||
type KeyValue = Record<string, any>;
|
||||
|
||||
export interface OnboardInitData {
|
||||
page: "form";
|
||||
settings: Zigbee2MQTTSettings;
|
||||
settingsSchema: typeof schemaJson;
|
||||
devices: {
|
||||
name: string;
|
||||
path: Zigbee2MQTTSettings["serial"]["port"];
|
||||
adapter?: Zigbee2MQTTSettings["serial"]["adapter"];
|
||||
baudRate?: Zigbee2MQTTSettings["serial"]["baudrate"];
|
||||
rtscts?: Zigbee2MQTTSettings["serial"]["rtscts"];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface OnboardDoneData {
|
||||
page: "done";
|
||||
frontendUrl: string | null;
|
||||
}
|
||||
|
||||
export interface OnboardFailureData {
|
||||
page: "failure";
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export type OnboardData = OnboardInitData | OnboardDoneData | OnboardFailureData;
|
||||
|
||||
export type OnboardSubmitResponse = {success: true; frontendUrl: string | null} | {success: false; error: string};
|
||||
|
||||
export interface Zigbee2MQTTDeviceOptions {
|
||||
disabled?: boolean;
|
||||
retention?: number;
|
||||
@@ -52,6 +82,7 @@ export interface Zigbee2MQTTDeviceOptions {
|
||||
friendly_name: string;
|
||||
description?: string;
|
||||
qos?: 0 | 1 | 2;
|
||||
disable_automatic_update_check?: boolean;
|
||||
}
|
||||
|
||||
export interface Zigbee2MQTTGroupOptions {
|
||||
@@ -137,6 +168,7 @@ export interface Zigbee2MQTTSettings {
|
||||
update_check_interval: number;
|
||||
disable_automatic_update_check: boolean;
|
||||
zigbee_ota_override_index_location?: string;
|
||||
image_block_request_timeout?: number;
|
||||
image_block_response_delay?: number;
|
||||
default_maximum_data_size?: number;
|
||||
};
|
||||
@@ -278,18 +310,21 @@ export interface Zigbee2MQTTNetworkMap {
|
||||
links: {
|
||||
source: {ieeeAddr: string; networkAddress: number};
|
||||
target: {ieeeAddr: string; networkAddress: number};
|
||||
linkquality: number;
|
||||
depth: number;
|
||||
routes: {
|
||||
destinationAddress: number;
|
||||
status: string;
|
||||
nextHop: number;
|
||||
}[];
|
||||
sourceIeeeAddr: string;
|
||||
targetIeeeAddr: string;
|
||||
sourceNwkAddr: number;
|
||||
lqi: number;
|
||||
deviceType: number;
|
||||
rxOnWhenIdle: number;
|
||||
relationship: number;
|
||||
permitJoining: number;
|
||||
depth: number;
|
||||
lqi: number;
|
||||
routes: RoutingTableEntry[];
|
||||
/** @deprecated 3.0 */
|
||||
linkquality: number;
|
||||
/** @deprecated 3.0 */
|
||||
sourceIeeeAddr: string;
|
||||
/** @deprecated 3.0 */
|
||||
targetIeeeAddr: string;
|
||||
/** @deprecated 3.0 */
|
||||
sourceNwkAddr: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -310,6 +345,7 @@ export interface Zigbee2MQTTAPI {
|
||||
"bridge/definitions": {
|
||||
clusters: Readonly<Record<ClusterName, Readonly<ClusterDefinition>>>;
|
||||
custom_clusters: Record<string, CustomClusters>;
|
||||
actions: string[];
|
||||
};
|
||||
|
||||
"bridge/event":
|
||||
@@ -558,6 +594,16 @@ export interface Zigbee2MQTTAPI {
|
||||
failed: string[];
|
||||
};
|
||||
|
||||
"bridge/request/device/binds/clear": {
|
||||
target: string;
|
||||
ieee_list?: Eui64[];
|
||||
};
|
||||
|
||||
"bridge/response/device/binds/clear": {
|
||||
target: string;
|
||||
ieee_list?: Eui64[];
|
||||
};
|
||||
|
||||
"bridge/request/device/configure":
|
||||
| {
|
||||
id: string | number;
|
||||
@@ -582,51 +628,93 @@ export interface Zigbee2MQTTAPI {
|
||||
|
||||
"bridge/request/device/ota_update/check": {
|
||||
id: string;
|
||||
/** expected to point to an index json if provided */
|
||||
url?: string | null;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/check/downgrade": {
|
||||
id: string;
|
||||
/** expected to point to an index json if provided */
|
||||
url?: string | null;
|
||||
};
|
||||
|
||||
"bridge/response/device/ota_update/check": {
|
||||
id: string;
|
||||
update_available: boolean;
|
||||
downgrade?: boolean;
|
||||
source?: string;
|
||||
release_notes?: string;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/update": {
|
||||
id: string;
|
||||
url?: string | null;
|
||||
/**
|
||||
* Full firmware file in hex form (expected compatible with `Buffer.from(hex, "hex")`)
|
||||
* If file name supplied, will be used instead of <ieee_utc> to store firmware in data dir.
|
||||
*/
|
||||
hex?: {data: string; file_name?: string} | null;
|
||||
image_block_request_timeout?: number | null;
|
||||
image_block_response_delay?: number | null;
|
||||
/** may be overridden internally to match specific device needs */
|
||||
default_maximum_data_size?: number | null;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/update/downgrade": {
|
||||
id: string;
|
||||
url?: string | null;
|
||||
/**
|
||||
* Full firmware file in hex form (expected compatible with `Buffer.from(hex, "hex")`)
|
||||
* If file name supplied, will be used instead of <ieee_utc> to store firmware in data dir.
|
||||
*/
|
||||
hex?: {data: string; file_name?: string} | null;
|
||||
image_block_request_timeout?: number | null;
|
||||
image_block_response_delay?: number | null;
|
||||
/** may be overridden internally to match specific device needs */
|
||||
default_maximum_data_size?: number | null;
|
||||
};
|
||||
|
||||
"bridge/response/device/ota_update/update": {
|
||||
id: string;
|
||||
from:
|
||||
| {
|
||||
software_build_id: string;
|
||||
date_code: string;
|
||||
file_version: number;
|
||||
software_build_id?: string;
|
||||
date_code?: string;
|
||||
}
|
||||
| undefined;
|
||||
to:
|
||||
| {
|
||||
software_build_id: string;
|
||||
date_code: string;
|
||||
file_version: number;
|
||||
software_build_id?: string;
|
||||
date_code?: string;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/schedule": {
|
||||
id: string;
|
||||
url?: string | null;
|
||||
/**
|
||||
* Full firmware file in hex form (expected compatible with `Buffer.from(hex, "hex")`)
|
||||
* If file name supplied, will be used instead of <ieee_utc> to store firmware in data dir.
|
||||
*/
|
||||
hex?: {data: string; file_name?: string} | null;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/schedule/downgrade": {
|
||||
id: string;
|
||||
url?: string | null;
|
||||
/**
|
||||
* Full firmware file in hex form (expected compatible with `Buffer.from(hex, "hex")`)
|
||||
* If file name supplied, will be used instead of <ieee_utc> to store firmware in data dir.
|
||||
*/
|
||||
hex?: {data: string; file_name?: string} | null;
|
||||
};
|
||||
|
||||
"bridge/response/device/ota_update/schedule": {
|
||||
id: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/unschedule": {
|
||||
@@ -686,25 +774,41 @@ export interface Zigbee2MQTTAPI {
|
||||
homeassistant_rename: boolean;
|
||||
};
|
||||
|
||||
"bridge/request/device/configure_reporting": {
|
||||
"bridge/request/device/reporting/configure": {
|
||||
id: string;
|
||||
endpoint: string | number;
|
||||
cluster: string | number;
|
||||
attribute: string | number | {ID: number; type: number};
|
||||
minimum_report_interval: number;
|
||||
maximum_report_interval: number;
|
||||
reportable_change: number;
|
||||
reportable_change?: number;
|
||||
option: Record<string, unknown>;
|
||||
};
|
||||
|
||||
"bridge/response/device/configure_reporting": {
|
||||
"bridge/response/device/reporting/configure": {
|
||||
id: string;
|
||||
endpoint: string | number;
|
||||
cluster: string | number;
|
||||
attribute: string | number | {ID: number; type: number};
|
||||
minimum_report_interval: number;
|
||||
maximum_report_interval: number;
|
||||
reportable_change: number;
|
||||
reportable_change?: number;
|
||||
};
|
||||
|
||||
"bridge/request/device/reporting/read": {
|
||||
id: string;
|
||||
endpoint: string | number;
|
||||
cluster: string | number;
|
||||
configs: {direction?: number; attribute: string | number | {ID: number; type: number}}[];
|
||||
manufacturer_code?: number;
|
||||
};
|
||||
|
||||
"bridge/response/device/reporting/read": {
|
||||
id: string;
|
||||
endpoint: string | number;
|
||||
cluster: string | number;
|
||||
configs: zigbeeHerdsman.Zcl.ClustersTypes.TFoundation["readReportConfigRsp"];
|
||||
manufacturer_code?: number;
|
||||
};
|
||||
|
||||
"bridge/request/group/remove": {
|
||||
@@ -821,6 +925,10 @@ export interface Zigbee2MQTTAPI {
|
||||
channel: number;
|
||||
};
|
||||
|
||||
"bridge/request/action": {action: string; params?: Record<string, unknown>};
|
||||
|
||||
"bridge/response/action": GenericZdoResponse | ZclPayload | undefined;
|
||||
|
||||
/**
|
||||
* entity state response
|
||||
*/
|
||||
@@ -883,6 +991,7 @@ export type Zigbee2MQTTRequestEndpoints =
|
||||
| "bridge/request/options"
|
||||
| "bridge/request/device/bind"
|
||||
| "bridge/request/device/unbind"
|
||||
| "bridge/request/device/binds/clear"
|
||||
| "bridge/request/device/configure"
|
||||
| "bridge/request/device/remove"
|
||||
| "bridge/request/device/ota_update/check"
|
||||
@@ -896,7 +1005,8 @@ export type Zigbee2MQTTRequestEndpoints =
|
||||
| "bridge/request/device/generate_external_definition"
|
||||
| "bridge/request/device/options"
|
||||
| "bridge/request/device/rename"
|
||||
| "bridge/request/device/configure_reporting"
|
||||
| "bridge/request/device/reporting/configure"
|
||||
| "bridge/request/device/reporting/read"
|
||||
| "bridge/request/group/remove"
|
||||
| "bridge/request/group/add"
|
||||
| "bridge/request/group/rename"
|
||||
@@ -907,6 +1017,7 @@ export type Zigbee2MQTTRequestEndpoints =
|
||||
| "bridge/request/touchlink/factory_reset"
|
||||
| "bridge/request/touchlink/scan"
|
||||
| "bridge/request/touchlink/identify"
|
||||
| "bridge/request/action"
|
||||
| "{friendlyNameOrId}/set"
|
||||
| "{friendlyNameOrId}/set/{attribute}"
|
||||
| "{friendlyNameOrId}/{endpoint}/set"
|
||||
@@ -931,6 +1042,7 @@ export type Zigbee2MQTTResponseEndpoints =
|
||||
| "bridge/response/options"
|
||||
| "bridge/response/device/bind"
|
||||
| "bridge/response/device/unbind"
|
||||
| "bridge/response/device/binds/clear"
|
||||
| "bridge/response/device/configure"
|
||||
| "bridge/response/device/remove"
|
||||
| "bridge/response/device/ota_update/check"
|
||||
@@ -941,7 +1053,8 @@ export type Zigbee2MQTTResponseEndpoints =
|
||||
| "bridge/response/device/generate_external_definition"
|
||||
| "bridge/response/device/options"
|
||||
| "bridge/response/device/rename"
|
||||
| "bridge/response/device/configure_reporting"
|
||||
| "bridge/response/device/reporting/configure"
|
||||
| "bridge/response/device/reporting/read"
|
||||
| "bridge/response/group/remove"
|
||||
| "bridge/response/group/add"
|
||||
| "bridge/response/group/rename"
|
||||
@@ -951,7 +1064,8 @@ export type Zigbee2MQTTResponseEndpoints =
|
||||
| "bridge/response/group/members/remove_all"
|
||||
| "bridge/response/touchlink/factory_reset"
|
||||
| "bridge/response/touchlink/scan"
|
||||
| "bridge/response/touchlink/identify";
|
||||
| "bridge/response/touchlink/identify"
|
||||
| "bridge/response/action";
|
||||
|
||||
export type Zigbee2MQTTRequest<T extends Zigbee2MQTTRequestEndpoints> = {
|
||||
transaction?: string;
|
||||
|
||||
Vendored
-3
@@ -36,9 +36,6 @@ declare global {
|
||||
type Endpoint = ZHModels.Endpoint;
|
||||
type Device = ZHModels.Device;
|
||||
type Group = ZHModels.Group;
|
||||
// biome-ignore lint/style/useNamingConvention: API
|
||||
type LQI = ZHAdapterTypes.Lqi;
|
||||
type RoutingTable = ZHAdapterTypes.RoutingTable;
|
||||
type CoordinatorVersion = ZHAdapterTypes.CoordinatorVersion;
|
||||
type NetworkParameters = ZHAdapterTypes.NetworkParameters;
|
||||
interface Bind {
|
||||
|
||||
+24
-23
@@ -9,27 +9,25 @@ import * as settings from "./settings";
|
||||
const NAMESPACE_SEPARATOR = ":";
|
||||
|
||||
class Logger {
|
||||
// @ts-expect-error initalized in `init`
|
||||
private level: settings.LogLevel;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private output: string[];
|
||||
// @ts-expect-error initalized in `init`
|
||||
private directory: string;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private logger: winston.Logger;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private fileTransport: winston.transports.FileTransportInstance;
|
||||
private level!: settings.LogLevel;
|
||||
private output!: string[];
|
||||
private directory!: string;
|
||||
private logger!: winston.Logger;
|
||||
private fileTransport: winston.transports.FileTransportInstance | undefined;
|
||||
private debugNamespaceIgnoreRegex?: RegExp;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private namespacedLevels: Record<string, settings.LogLevel>;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private cachedNamespacedLevels: Record<string, settings.LogLevel>;
|
||||
private namespacedLevels!: Record<string, settings.LogLevel>;
|
||||
private cachedNamespacedLevels!: Record<string, settings.LogLevel>;
|
||||
|
||||
public init(): void {
|
||||
// What transports to enable
|
||||
this.output = settings.get().advanced.log_output;
|
||||
// Directory to log to
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace("T", ".").replace(/:/g, "-");
|
||||
const date = new Date();
|
||||
// offset UTC by current timezone, ISO keeps "Z" (UTC) which is then wrong but we strip it
|
||||
const timestamp = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 19)
|
||||
.replace("T", ".")
|
||||
.replaceAll(":", "-");
|
||||
this.directory = settings.get().advanced.log_directory.replace("%TIMESTAMP%", timestamp);
|
||||
const logFilename = settings.get().advanced.log_file.replace("%TIMESTAMP%", timestamp);
|
||||
this.level = settings.get().advanced.log_level;
|
||||
@@ -144,11 +142,7 @@ class Logger {
|
||||
}
|
||||
|
||||
public getDebugNamespaceIgnore(): string {
|
||||
return (
|
||||
this.debugNamespaceIgnoreRegex
|
||||
?.toString()
|
||||
.slice(1, -1) /* remove slashes */ ?? ""
|
||||
);
|
||||
return this.debugNamespaceIgnoreRegex?.toString().slice(1, -1) /* remove slashes */ ?? "";
|
||||
}
|
||||
|
||||
public setDebugNamespaceIgnore(value: string): void {
|
||||
@@ -240,7 +234,11 @@ class Logger {
|
||||
|
||||
for (const dir of directories) {
|
||||
this.debug(`Removing old log directory '${dir.path}'`);
|
||||
rimrafSync(dir.path);
|
||||
try {
|
||||
rimrafSync(dir.path);
|
||||
} catch (e) {
|
||||
this.error(`Failed to remove old log directory '${dir.path}': ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,7 +260,10 @@ class Logger {
|
||||
// @ts-expect-error workaround
|
||||
this.fileTransport.on("open", () => this.fileTransport._dest.on("finish", resolve));
|
||||
}
|
||||
this.fileTransport.end();
|
||||
|
||||
if (this.fileTransport) {
|
||||
this.fileTransport.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ function generateHtmlForm(currentSettings: RecursivePartial<Settings>, devices:
|
||||
id="serial_rtscts"
|
||||
name="serial_rtscts"
|
||||
${currentSettings.serial?.rtscts ? "checked" : ""}
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_RTSCTS ? "disabled" : ""}>
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_RTSCTS ? "disabled" : ""}
|
||||
style="margin-bottom: 1rem;">
|
||||
<small>Can be ignored for networked coordinators (TCP).</small>
|
||||
</fieldset>
|
||||
@@ -488,7 +488,7 @@ async function startOnboardingServer(): Promise<boolean> {
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => {
|
||||
server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => {
|
||||
console.log(`Onboarding page is available at ${serverUrl.href}`);
|
||||
});
|
||||
});
|
||||
@@ -515,7 +515,7 @@ async function startFailureServer(errors: string): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => {
|
||||
server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => {
|
||||
console.error(`Failure page is available at ${serverUrl.href}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -366,13 +366,20 @@
|
||||
"description": "Location of override OTA index file",
|
||||
"examples": ["index.json"]
|
||||
},
|
||||
"image_block_request_timeout": {
|
||||
"type": "number",
|
||||
"title": "Image block request timeout",
|
||||
"description": "Timeout (in milliseconds) during OTA updates. You can increase this value if your device is requesting blocks too slowly.",
|
||||
"default": 150000,
|
||||
"minimum": 10000,
|
||||
"maximum": 2147483647
|
||||
},
|
||||
"image_block_response_delay": {
|
||||
"type": "number",
|
||||
"title": "Image block response delay",
|
||||
"description": "Limits the rate of requests (in milliseconds) during OTA updates to reduce network congestion. You can increase this value if your network appears unstable during OTA.",
|
||||
"default": 250,
|
||||
"minimum": 50,
|
||||
"requiresRestart": true
|
||||
"minimum": 50
|
||||
},
|
||||
"default_maximum_data_size": {
|
||||
"type": "number",
|
||||
@@ -380,8 +387,7 @@
|
||||
"description": "The size of file chunks sent during an update (in bytes). Note: This value may get ignored for manufacturers that require specific values.",
|
||||
"default": 50,
|
||||
"minimum": 10,
|
||||
"maximum": 100,
|
||||
"requiresRestart": true
|
||||
"maximum": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -930,6 +936,12 @@
|
||||
"description": "Name of the device in Home Assistant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"disable_automatic_update_check": {
|
||||
"type": "boolean",
|
||||
"title": "Disable automatic update check",
|
||||
"description": "Zigbee devices may request a firmware update, and do so frequently, causing Zigbee2MQTT to reach out to third party servers. If you disable these device initiated checks, you can still initiate a firmware update check manually.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["friendly_name"]
|
||||
@@ -965,6 +977,17 @@
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"homeassistant": {
|
||||
"type": ["object", "null"],
|
||||
"title": "Home Assistant",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Home Assistant name",
|
||||
"description": "Name of the group in Home Assistant"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["friendly_name"]
|
||||
|
||||
@@ -10,7 +10,7 @@ import yaml from "./yaml";
|
||||
export {schemaJson};
|
||||
// When updating also update:
|
||||
// - https://github.com/Koenkk/zigbee2mqtt/blob/dev/data/configuration.example.yaml#L2
|
||||
export const CURRENT_VERSION = 4;
|
||||
export const CURRENT_VERSION = 5;
|
||||
/** NOTE: by order of priority, lower index is lower level (more important) */
|
||||
export const LOG_LEVELS: readonly string[] = ["error", "warning", "info", "debug"] as const;
|
||||
export type LogLevel = "error" | "warning" | "info" | "debug";
|
||||
@@ -82,6 +82,7 @@ export const defaults = {
|
||||
ota: {
|
||||
update_check_interval: 24 * 60,
|
||||
disable_automatic_update_check: false,
|
||||
image_block_request_timeout: 150000,
|
||||
image_block_response_delay: 250,
|
||||
default_maximum_data_size: 50,
|
||||
},
|
||||
@@ -607,7 +608,7 @@ export function addGroup(name: string, id?: string): GroupOptions {
|
||||
id = "1";
|
||||
|
||||
while (settings.groups[id]) {
|
||||
id = (Number.parseInt(id) + 1).toString();
|
||||
id = (Number.parseInt(id, 10) + 1).toString();
|
||||
}
|
||||
} else {
|
||||
// ensure provided ID is not in use
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {copyFileSync, writeFileSync} from "node:fs";
|
||||
|
||||
import {copyFileSync, existsSync, readFileSync, writeFileSync} from "node:fs";
|
||||
import data from "./data";
|
||||
import * as settings from "./settings";
|
||||
import utils from "./utils";
|
||||
@@ -29,7 +28,7 @@ interface SettingsCustomHandler extends Omit<SettingsMigration, "path"> {
|
||||
execute: (currentSettings: Partial<Settings>) => [validPath: boolean, previousValue: unknown, changed: boolean];
|
||||
}
|
||||
|
||||
const SUPPORTED_VERSIONS: Settings["version"][] = [undefined, 2, 3, settings.CURRENT_VERSION];
|
||||
const SUPPORTED_VERSIONS: Settings["version"][] = [undefined, 2, 3, 4, settings.CURRENT_VERSION];
|
||||
|
||||
function backupSettings(version: number): void {
|
||||
const filePath = data.joinPath("configuration.yaml");
|
||||
@@ -481,6 +480,58 @@ function migrateToFour(
|
||||
});
|
||||
}
|
||||
|
||||
function migrateToFive(
|
||||
_currentSettings: Partial<Settings>,
|
||||
transfers: SettingsTransfer[],
|
||||
changes: SettingsChange[],
|
||||
additions: SettingsAdd[],
|
||||
removals: SettingsRemove[],
|
||||
customHandlers: SettingsCustomHandler[],
|
||||
): void {
|
||||
transfers.push();
|
||||
changes.push({
|
||||
path: ["version"],
|
||||
note: "Migrated settings to version 5",
|
||||
newValue: 5,
|
||||
});
|
||||
additions.push();
|
||||
removals.push();
|
||||
|
||||
const resetOtaStates = (_currentSettings: Partial<Settings>): ReturnType<SettingsCustomHandler["execute"]> => {
|
||||
const filePath = data.joinPath("state.json");
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
const stateFile = readFileSync(filePath, "utf8");
|
||||
const stateObj = JSON.parse(stateFile) as KeyValue;
|
||||
|
||||
for (const key in stateObj) {
|
||||
const entry = stateObj[key];
|
||||
|
||||
if (entry.update) {
|
||||
delete entry.update;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(stateObj), "utf8");
|
||||
|
||||
// hack, this does not actually affect settings, we just want to ensure the migration note triggers
|
||||
return [true, undefined, true];
|
||||
} catch (error) {
|
||||
console.error(`Failed to write state to '${filePath}' (${error})`);
|
||||
}
|
||||
}
|
||||
|
||||
return [false, undefined, false];
|
||||
};
|
||||
|
||||
customHandlers.push({
|
||||
note: "Reset all cached OTA states due to internal changes.",
|
||||
noteIf: () => true,
|
||||
execute: (currentSettings) => resetOtaStates(currentSettings),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Order of execution:
|
||||
* - Transfer
|
||||
@@ -533,6 +584,10 @@ export function migrateIfNecessary(): void {
|
||||
migrationNotesFileName = "migration-3-to-4.log";
|
||||
|
||||
migrateToFour(currentSettings, transfers, changes, additions, removals, customHandlers);
|
||||
} else if (currentSettings.version === 4) {
|
||||
migrationNotesFileName = "migration-4-to-5.log";
|
||||
|
||||
migrateToFive(currentSettings, transfers, changes, additions, removals, customHandlers);
|
||||
}
|
||||
|
||||
for (const transfer of transfers) {
|
||||
|
||||
+92
-58
@@ -4,7 +4,6 @@ import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type {Events as ZHEvents} from "zigbee-herdsman";
|
||||
import {Controller} from "zigbee-herdsman";
|
||||
import type {StartResult} from "zigbee-herdsman/dist/adapter/tstype";
|
||||
|
||||
import Device from "./model/device";
|
||||
import Group from "./model/group";
|
||||
import data from "./util/data";
|
||||
@@ -15,7 +14,7 @@ import utils from "./util/utils";
|
||||
const entityIDRegex = /^(.+?)(?:\/([^/]+))?$/;
|
||||
|
||||
export default class Zigbee {
|
||||
private herdsman!: Controller;
|
||||
#herdsman!: Controller;
|
||||
private eventBus: EventBus;
|
||||
private groupLookup = new Map<number /* group ID */, Group>();
|
||||
private deviceLookup = new Map<string /* IEEE address */, Device>();
|
||||
@@ -25,7 +24,11 @@ export default class Zigbee {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
async start(): Promise<StartResult> {
|
||||
get zhController() {
|
||||
return this.#herdsman;
|
||||
}
|
||||
|
||||
async start(abortSignal: AbortSignal): Promise<boolean> {
|
||||
const infoHerdsman = await utils.getDependencyVersion("zigbee-herdsman");
|
||||
logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`);
|
||||
const panId = settings.get().advanced.pan_id;
|
||||
@@ -63,37 +66,41 @@ export default class Zigbee {
|
||||
|
||||
let startResult: StartResult;
|
||||
try {
|
||||
this.herdsman = new Controller(herdsmanSettings);
|
||||
startResult = await this.herdsman.start();
|
||||
this.#herdsman = new Controller(herdsmanSettings);
|
||||
startResult = await this.#herdsman.start(abortSignal);
|
||||
} catch (error) {
|
||||
logger.error("Error while starting zigbee-herdsman");
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.coordinatorIeeeAddr = this.herdsman.getDevicesByType("Coordinator")[0].ieeeAddr;
|
||||
await this.resolveDevicesDefinitions();
|
||||
this.coordinatorIeeeAddr = this.#herdsman.getDevicesByType("Coordinator")[0].ieeeAddr;
|
||||
await this.resolveDevicesDefinitions(false, abortSignal);
|
||||
|
||||
this.herdsman.on("adapterDisconnected", () => this.eventBus.emitAdapterDisconnected());
|
||||
this.herdsman.on("lastSeenChanged", (data: ZHEvents.LastSeenChangedPayload) => {
|
||||
if (abortSignal.aborted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.#herdsman.on("adapterDisconnected", () => this.eventBus.emitAdapterDisconnected());
|
||||
this.#herdsman.on("lastSeenChanged", (data: ZHEvents.LastSeenChangedPayload) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr)!, reason: data.reason});
|
||||
});
|
||||
this.herdsman.on("permitJoinChanged", (data: ZHEvents.PermitJoinChangedPayload) => {
|
||||
this.#herdsman.on("permitJoinChanged", (data: ZHEvents.PermitJoinChangedPayload) => {
|
||||
this.eventBus.emitPermitJoinChanged(data);
|
||||
});
|
||||
this.herdsman.on("deviceNetworkAddressChanged", (data: ZHEvents.DeviceNetworkAddressChangedPayload) => {
|
||||
this.#herdsman.on("deviceNetworkAddressChanged", (data: ZHEvents.DeviceNetworkAddressChangedPayload) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
const device = this.resolveDevice(data.device.ieeeAddr)!;
|
||||
logger.debug(`Device '${device.name}' changed network address`);
|
||||
this.eventBus.emitDeviceNetworkAddressChanged({device});
|
||||
});
|
||||
this.herdsman.on("deviceAnnounce", (data: ZHEvents.DeviceAnnouncePayload) => {
|
||||
this.#herdsman.on("deviceAnnounce", (data: ZHEvents.DeviceAnnouncePayload) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
const device = this.resolveDevice(data.device.ieeeAddr)!;
|
||||
logger.debug(`Device '${device.name}' announced itself`);
|
||||
this.eventBus.emitDeviceAnnounce({device});
|
||||
});
|
||||
this.herdsman.on("deviceInterview", async (data: ZHEvents.DeviceInterviewPayload) => {
|
||||
this.#herdsman.on("deviceInterview", async (data: ZHEvents.DeviceInterviewPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
/* v8 ignore next */ if (!device) return; // Prevent potential race
|
||||
await device.resolveDefinition();
|
||||
@@ -101,19 +108,19 @@ export default class Zigbee {
|
||||
this.logDeviceInterview(d);
|
||||
this.eventBus.emitDeviceInterview(d);
|
||||
});
|
||||
this.herdsman.on("deviceJoined", async (data: ZHEvents.DeviceJoinedPayload) => {
|
||||
this.#herdsman.on("deviceJoined", async (data: ZHEvents.DeviceJoinedPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
/* v8 ignore next */ if (!device) return; // Prevent potential race
|
||||
await device.resolveDefinition();
|
||||
logger.info(`Device '${device.name}' joined`);
|
||||
this.eventBus.emitDeviceJoined({device});
|
||||
});
|
||||
this.herdsman.on("deviceLeave", (data: ZHEvents.DeviceLeavePayload) => {
|
||||
this.#herdsman.on("deviceLeave", (data: ZHEvents.DeviceLeavePayload) => {
|
||||
const name = settings.getDevice(data.ieeeAddr)?.friendly_name || data.ieeeAddr;
|
||||
logger.warning(`Device '${name}' left the network`);
|
||||
this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name, device: this.deviceLookup.get(data.ieeeAddr)});
|
||||
});
|
||||
this.herdsman.on("message", async (data: ZHEvents.MessagePayload) => {
|
||||
this.#herdsman.on("message", async (data: ZHEvents.MessagePayload) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
const device = this.resolveDevice(data.device.ieeeAddr)!;
|
||||
await device.resolveDefinition();
|
||||
@@ -129,32 +136,55 @@ export default class Zigbee {
|
||||
|
||||
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())}`);
|
||||
logger.debug(`Zigbee network parameters: ${stringify(await this.#herdsman.getNetworkParameters())}`);
|
||||
|
||||
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
// If a passlist is used, all other device will be removed from the network.
|
||||
const passlist = settings.get().passlist;
|
||||
const blocklist = settings.get().blocklist;
|
||||
const remove = async (device: Device): Promise<void> => {
|
||||
try {
|
||||
await device.zh.removeFromNetwork();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove '${device.ieeeAddr}' (${(error as Error).message})`);
|
||||
}
|
||||
};
|
||||
if (abortSignal.aborted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (passlist.length > 0) {
|
||||
const remove = async (device: Device): Promise<void> => {
|
||||
try {
|
||||
await device.zh.removeFromNetwork();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove '${device.ieeeAddr}' (${(error as Error).message})`);
|
||||
}
|
||||
};
|
||||
// If a passlist is used, all other device will be removed from the network.
|
||||
const passlist = settings.get().passlist;
|
||||
|
||||
if (passlist.length > 0) {
|
||||
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
if (!passlist.includes(device.ieeeAddr)) {
|
||||
logger.warning(`Device not on passlist currently connected (${device.ieeeAddr}), removing...`);
|
||||
logger.warning(`Device not on passlist currently on the network (${device.ieeeAddr}), removing...`);
|
||||
await remove(device);
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const ieee of settings.get().blocklist) {
|
||||
const device = this.resolveDevice(ieee);
|
||||
|
||||
if (device) {
|
||||
if (device.zh.type === "Coordinator") {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.warning(`Device on blocklist currently on the network (${device.ieeeAddr}), removing...`);
|
||||
await remove(device);
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Ignoring blocklist device ${ieee}, not currently on the network`);
|
||||
}
|
||||
} else if (blocklist.includes(device.ieeeAddr)) {
|
||||
logger.warning(`Device on blocklist currently connected (${device.ieeeAddr}), removing...`);
|
||||
await remove(device);
|
||||
}
|
||||
}
|
||||
|
||||
return startResult;
|
||||
return true;
|
||||
}
|
||||
|
||||
private logDeviceInterview(data: eventdata.DeviceInterview): void {
|
||||
@@ -198,39 +228,39 @@ export default class Zigbee {
|
||||
}
|
||||
|
||||
async getCoordinatorVersion(): Promise<zh.CoordinatorVersion> {
|
||||
return await this.herdsman.getCoordinatorVersion();
|
||||
return await this.#herdsman.getCoordinatorVersion();
|
||||
}
|
||||
|
||||
isStopping(): boolean {
|
||||
return this.herdsman.isStopping();
|
||||
return this.#herdsman.isStopping();
|
||||
}
|
||||
|
||||
async backup(): Promise<void> {
|
||||
return await this.herdsman.backup();
|
||||
return await this.#herdsman.backup();
|
||||
}
|
||||
|
||||
async coordinatorCheck(): Promise<{missingRouters: Device[]}> {
|
||||
const check = await this.herdsman.coordinatorCheck();
|
||||
const check = await this.#herdsman.coordinatorCheck();
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
return {missingRouters: check.missingRouters.map((d) => this.resolveDevice(d.ieeeAddr)!)};
|
||||
}
|
||||
|
||||
async getNetworkParameters(): Promise<zh.NetworkParameters> {
|
||||
return await this.herdsman.getNetworkParameters();
|
||||
return await this.#herdsman.getNetworkParameters();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
logger.info("Stopping zigbee-herdsman...");
|
||||
await this.herdsman.stop();
|
||||
await this.#herdsman?.stop(); // could be undefined if this is called during startup (abort)
|
||||
logger.info("Stopped zigbee-herdsman");
|
||||
}
|
||||
|
||||
getPermitJoin(): boolean {
|
||||
return this.herdsman.getPermitJoin();
|
||||
return this.#herdsman.getPermitJoin();
|
||||
}
|
||||
|
||||
getPermitJoinEnd(): number | undefined {
|
||||
return this.herdsman.getPermitJoinEnd();
|
||||
return this.#herdsman.getPermitJoinEnd();
|
||||
}
|
||||
|
||||
async permitJoin(time: number, device?: Device): Promise<void> {
|
||||
@@ -240,18 +270,22 @@ export default class Zigbee {
|
||||
logger.info("Zigbee: disabling joining new devices.");
|
||||
}
|
||||
|
||||
await this.herdsman.permitJoin(time, device?.zh);
|
||||
await this.#herdsman.permitJoin(time, device?.zh);
|
||||
}
|
||||
|
||||
async resolveDevicesDefinitions(ignoreCache = false): Promise<void> {
|
||||
async resolveDevicesDefinitions(ignoreCache = false, abortSignal: AbortSignal | undefined = undefined): Promise<void> {
|
||||
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await device.resolveDefinition(ignoreCache);
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind private resolveDevice(ieeeAddr: string): Device | undefined {
|
||||
if (!this.deviceLookup.has(ieeeAddr)) {
|
||||
const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
|
||||
const device = this.#herdsman.getDeviceByIeeeAddr(ieeeAddr);
|
||||
if (device) {
|
||||
this.deviceLookup.set(ieeeAddr, new Device(device));
|
||||
}
|
||||
@@ -266,7 +300,7 @@ export default class Zigbee {
|
||||
|
||||
private resolveGroup(groupID: number): Group | undefined {
|
||||
if (!this.groupLookup.has(groupID)) {
|
||||
const group = this.herdsman.getGroupByID(groupID);
|
||||
const group = this.#herdsman.getGroupByID(groupID);
|
||||
|
||||
if (group) {
|
||||
this.groupLookup.set(groupID, new Group(group, this.resolveDevice));
|
||||
@@ -339,33 +373,33 @@ export default class Zigbee {
|
||||
}
|
||||
|
||||
firstCoordinatorEndpoint(): zh.Endpoint {
|
||||
return this.herdsman.getDevicesByType("Coordinator")[0].endpoints[0];
|
||||
return this.#herdsman.getDevicesByType("Coordinator")[0].endpoints[0];
|
||||
}
|
||||
|
||||
*devicesAndGroupsIterator(
|
||||
devicePredicate?: (value: zh.Device) => boolean,
|
||||
groupPredicate?: (value: zh.Group) => boolean,
|
||||
): Generator<Device | Group> {
|
||||
for (const device of this.herdsman.getDevicesIterator(devicePredicate)) {
|
||||
for (const device of this.#herdsman.getDevicesIterator(devicePredicate)) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveDevice(device.ieeeAddr)!;
|
||||
}
|
||||
|
||||
for (const group of this.herdsman.getGroupsIterator(groupPredicate)) {
|
||||
for (const group of this.#herdsman.getGroupsIterator(groupPredicate)) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveGroup(group.groupID)!;
|
||||
}
|
||||
}
|
||||
|
||||
*groupsIterator(predicate?: (value: zh.Group) => boolean): Generator<Group> {
|
||||
for (const group of this.herdsman.getGroupsIterator(predicate)) {
|
||||
for (const group of this.#herdsman.getGroupsIterator(predicate)) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveGroup(group.groupID)!;
|
||||
}
|
||||
}
|
||||
|
||||
*devicesIterator(predicate?: (value: zh.Device) => boolean): Generator<Device> {
|
||||
for (const device of this.herdsman.getDevicesIterator(predicate)) {
|
||||
for (const device of this.#herdsman.getDevicesIterator(predicate)) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveDevice(device.ieeeAddr)!;
|
||||
}
|
||||
@@ -399,33 +433,33 @@ export default class Zigbee {
|
||||
}
|
||||
|
||||
async touchlinkFactoryResetFirst(): Promise<boolean> {
|
||||
return await this.herdsman.touchlinkFactoryResetFirst();
|
||||
return await this.#herdsman.touchlink.factoryResetFirst();
|
||||
}
|
||||
|
||||
async touchlinkFactoryReset(ieeeAddr: string, channel: number): Promise<boolean> {
|
||||
return await this.herdsman.touchlinkFactoryReset(ieeeAddr, channel);
|
||||
return await this.#herdsman.touchlink.factoryReset(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async addInstallCode(installCode: string): Promise<void> {
|
||||
await this.herdsman.addInstallCode(installCode);
|
||||
await this.#herdsman.addInstallCode(installCode);
|
||||
}
|
||||
|
||||
async touchlinkIdentify(ieeeAddr: string, channel: number): Promise<void> {
|
||||
await this.herdsman.touchlinkIdentify(ieeeAddr, channel);
|
||||
await this.#herdsman.touchlink.identify(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async touchlinkScan(): Promise<{ieeeAddr: string; channel: number}[]> {
|
||||
return await this.herdsman.touchlinkScan();
|
||||
return await this.#herdsman.touchlink.scan();
|
||||
}
|
||||
|
||||
createGroup(id: number): Group {
|
||||
this.herdsman.createGroup(id);
|
||||
this.#herdsman.createGroup(id);
|
||||
// biome-ignore lint/style/noNonNullAssertion: just created
|
||||
return this.resolveGroup(id)!;
|
||||
}
|
||||
|
||||
deviceByNetworkAddress(networkAddress: number): Device | undefined {
|
||||
const device = this.herdsman.getDeviceByNetworkAddress(networkAddress);
|
||||
const device = this.#herdsman.getDeviceByNetworkAddress(networkAddress);
|
||||
return device && this.resolveDevice(device.ieeeAddr);
|
||||
}
|
||||
|
||||
|
||||
+17
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "2.6.3",
|
||||
"version": "2.9.1",
|
||||
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
|
||||
"main": "index.js",
|
||||
"types": "dist/types/api.d.ts",
|
||||
@@ -42,39 +42,39 @@
|
||||
},
|
||||
"homepage": "https://koenkk.github.io/zigbee2mqtt",
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"ajv": "^8.18.0",
|
||||
"bind-decorator": "^1.0.11",
|
||||
"debounce": "^2.2.0",
|
||||
"debounce": "^3.0.0",
|
||||
"express-static-gzip": "^3.0.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"finalhandler": "^2.1.0",
|
||||
"humanize-duration": "^3.33.1",
|
||||
"finalhandler": "^2.1.1",
|
||||
"humanize-duration": "^3.33.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"mqtt": "^5.14.1",
|
||||
"mqtt": "^5.15.0",
|
||||
"object-assign-deep": "^0.4.0",
|
||||
"rimraf": "^6.1.0",
|
||||
"semver": "^7.7.3",
|
||||
"rimraf": "^6.1.3",
|
||||
"semver": "^7.7.4",
|
||||
"source-map-support": "^0.5.21",
|
||||
"throttleit": "^2.1.0",
|
||||
"winston": "^3.18.3",
|
||||
"winston": "^3.19.0",
|
||||
"winston-syslog": "^2.7.1",
|
||||
"winston-transport": "^4.9.0",
|
||||
"ws": "^8.18.1",
|
||||
"zigbee-herdsman": "6.3.2",
|
||||
"zigbee-herdsman-converters": "25.59.0",
|
||||
"ws": "^8.19.0",
|
||||
"zigbee-herdsman": "9.0.9",
|
||||
"zigbee-herdsman-converters": "26.12.0",
|
||||
"zigbee2mqtt-frontend": "0.9.21",
|
||||
"zigbee2mqtt-windfront": "2.2.3"
|
||||
"zigbee2mqtt-windfront": "2.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.2",
|
||||
"@biomejs/biome": "^2.4.4",
|
||||
"@types/finalhandler": "^1.2.3",
|
||||
"@types/humanize-duration": "^3.27.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^24.9.2",
|
||||
"@types/node": "^24.11.0",
|
||||
"@types/object-assign-deep": "^0.4.3",
|
||||
"@types/readable-stream": "4.0.22",
|
||||
"@types/readable-stream": "4.0.23",
|
||||
"@types/serve-static": "^2.2.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
@@ -99,4 +99,4 @@
|
||||
"optionalDependencies": {
|
||||
"unix-dgram": "^2.0.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+275
-271
File diff suppressed because it is too large
Load Diff
@@ -59,7 +59,13 @@ if (fs.existsSync(commitUserFile)) {
|
||||
}
|
||||
|
||||
const definitions = definitionIndex.default.map((d) => zhc.prepareDefinition(d));
|
||||
const whiteLabels = definitions.filter((d) => d.whiteLabel).flatMap((d) => d.whiteLabel);
|
||||
const whiteLabels = definitions
|
||||
.filter((d) => d.whiteLabel)
|
||||
.flatMap((d) =>
|
||||
d.whiteLabel.map((wl) => {
|
||||
return {model: wl.model, vendor: wl.vendor ?? d.vendor, description: wl.description ?? d.description};
|
||||
}),
|
||||
);
|
||||
const capitalizeFirstChar = (str) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
for (const changelog of changelogs) {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import {execSync} from "node:child_process";
|
||||
|
||||
async function checkDuplicateIssue(github, context, name) {
|
||||
// Search for existing issues with the same `name`
|
||||
const searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue -is:pr "${name}" label:"new device support","external converter"`;
|
||||
|
||||
try {
|
||||
const searchResults = await github.rest.search.issuesAndPullRequests({q: searchQuery, per_page: 100});
|
||||
|
||||
// Filter out the current issue and return the first duplicate found
|
||||
const existingIssues = searchResults.data.items.filter((item) => item.number !== context.payload.issue.number).map((i) => `#${i.number}`);
|
||||
if (existingIssues.length > 0) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body: `👋 Hi there! This issue appears to be a duplicate of ${existingIssues.join(", ")}
|
||||
|
||||
This issue will be closed. Please follow the existing issue for updates.`,
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
state: "closed",
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error searching for duplicate issues with ${name}:`, error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function newDeviceSupport(github, _core, context, zhcDir) {
|
||||
const issue = context.payload.issue;
|
||||
// Hide previous bot comments
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
});
|
||||
|
||||
for (const comment of comments.data) {
|
||||
if (comment.user.type === "Bot" && comment.user.login === "github-actions[bot]") {
|
||||
await github.graphql(`mutation {
|
||||
minimizeComment(input: {subjectId: "${comment.node_id}", classifier: OUTDATED}) {
|
||||
clientMutationId
|
||||
}
|
||||
}`);
|
||||
}
|
||||
}
|
||||
|
||||
const titleAndBody = `${issue.title}\n\n${issue.body ?? ""}`;
|
||||
|
||||
// Check if Tuya manufacturer name is already supported.
|
||||
const tuyaManufacturerNameRe = /['"](_T\w+_(\w+))['"]/g;
|
||||
const tuyaManufacturerNames = Array.from(titleAndBody.matchAll(tuyaManufacturerNameRe), (m) => [m[1], m[2]]);
|
||||
console.log("Found tuyaManufacturerNames", tuyaManufacturerNames);
|
||||
if (tuyaManufacturerNames.length > 0) {
|
||||
for (const [fullName, partialName] of tuyaManufacturerNames) {
|
||||
if (await checkDuplicateIssue(github, context, fullName)) return;
|
||||
const fullMatch = (() => {
|
||||
try {
|
||||
return execSync(`grep -r --include="*.ts" "${fullName}" "${zhcDir}"`, {encoding: "utf8"});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
console.log(`Checking full match for '${fullName}', result: '${fullMatch}'`);
|
||||
if (fullMatch) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `👋 Hi there! The Tuya device with manufacturer name \`${fullName}\` is already supported in the latest dev branch.
|
||||
See this [guide](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html) on how to update, after updating you can remove your external converter.
|
||||
|
||||
In case you created the external converter with the goal to extend or fix an issue with the out-of-the-box support, please submit a pull request.
|
||||
For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request).
|
||||
If you need help with the process, feel free to ask here and we'll be happy to assist.`,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const partialMatch = (() => {
|
||||
try {
|
||||
return execSync(`grep -r --include="*.ts" "${partialName}" "${zhcDir}"`, {encoding: "utf8"});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
console.log(`Checking partial match for '${partialName}', result: '${partialMatch}'`);
|
||||
if (partialMatch) {
|
||||
const candidates = Array.from(partialMatch.matchAll(tuyaManufacturerNameRe), (m) => m[1]);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `👋 Hi there! A similar Tuya device of which the manufacturer name also ends with \`_${partialName}\` is already supported.
|
||||
This means the device can probably be easily be supported by re-using the existing converter.
|
||||
|
||||
I found the following matches: ${candidates.map((c) => `\`${c}\``).join(", ")}
|
||||
Try to stop Z2M, change all occurrences of \`${fullName}\` in the \`data/database.db\` to one of the matches above and start Z2M.
|
||||
|
||||
Let us know if it works so we can support this device out-of-the-box!`,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check if zigbee model is already supported.
|
||||
const zigbeeModelRe = /zigbeeModel: \[['"](.+)['"]\]/g;
|
||||
const zigbeeModels = Array.from(titleAndBody.matchAll(zigbeeModelRe), (m) => m[1]);
|
||||
|
||||
if (zigbeeModels.length > 0) {
|
||||
for (const zigbeeModel of zigbeeModels) {
|
||||
if (await checkDuplicateIssue(github, context, fullName)) return;
|
||||
const fullMatch = (() => {
|
||||
try {
|
||||
return execSync(`grep -r --include="*.ts" '"${zigbeeModel}"' "${zhcDir}"`, {encoding: "utf8"});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
if (fullMatch) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `👋 Hi there! The device with zigbee model \`${zigbeeModel}\` is already supported in the latest dev branch.
|
||||
See this [guide](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html) on how to update, after updating you can remove your external converter.
|
||||
|
||||
In case you created the external converter with the goal to extend or fix an issue with the out-of-the-box support, please submit a pull request.
|
||||
For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request).
|
||||
|
||||
If you need help with the process, feel free to ask here and we'll be happy to assist.`,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a request to pull request comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `🙏 Thank you for creating this issue and sharing your external converter!
|
||||
|
||||
In case all features work, please submit a pull request on this repository so the device can be supported out-of-the-box with the next release.
|
||||
For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request).
|
||||
|
||||
If **NOT** all features work, please follow the [How To Support new devices](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html).
|
||||
${
|
||||
tuyaManufacturerNames.length > 0
|
||||
? "Since this is a Tuya also consider [How To Support new Tuya devices](https://www.zigbee2mqtt.io/advanced/support-new-devices/02_support_new_tuya_devices.html)."
|
||||
: ""
|
||||
}
|
||||
|
||||
If you need help with the process, feel free to ask here and we'll be happy to assist.`,
|
||||
});
|
||||
}
|
||||
@@ -91,4 +91,4 @@ if (processArgs.length !== 1) {
|
||||
|
||||
const eraser = new ZStackNvMemEraser(processArgs[0]);
|
||||
|
||||
eraser.start();
|
||||
void eraser.start();
|
||||
|
||||
+44
-41
@@ -1,7 +1,7 @@
|
||||
import {existsSync, mkdirSync} from "node:fs";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {bench, describe} from "vitest";
|
||||
import {Zcl, Zdo, ZSpec} from "zigbee-herdsman";
|
||||
import {bench, describe, vi} from "vitest";
|
||||
import {type Controller, Zcl, Zdo, ZSpec} from "zigbee-herdsman";
|
||||
import type Adapter from "zigbee-herdsman/dist/adapter/adapter";
|
||||
import type {ZclPayload} from "zigbee-herdsman/dist/adapter/events";
|
||||
import {Device, InterviewState} from "zigbee-herdsman/dist/controller/model/device";
|
||||
@@ -14,6 +14,48 @@ import type {RequestToResponseMap} from "zigbee-herdsman/dist/zspec/zdo/definiti
|
||||
import data from "../lib/util/data";
|
||||
import {BENCH_OPTIONS} from "./benchOptions";
|
||||
|
||||
vi.doMock("zigbee-herdsman", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("zigbee-herdsman")>();
|
||||
class MockHerdsman {
|
||||
on: Controller["on"] = vi.fn();
|
||||
start: Controller["start"] = async () => "resumed" as const;
|
||||
stop: Controller["stop"] = async () => {};
|
||||
isStopping: Controller["isStopping"] = () => false;
|
||||
getCoordinatorVersion: Controller["getCoordinatorVersion"] = async () =>
|
||||
Promise.resolve({
|
||||
type: "Dummy",
|
||||
meta: {revision: "9.9.9"},
|
||||
});
|
||||
getNetworkParameters: Controller["getNetworkParameters"] = async () => Promise.resolve({...NETWORK_PARAMS});
|
||||
getPermitJoin: Controller["getPermitJoin"] = () => false;
|
||||
getPermitJoinEnd: Controller["getPermitJoinEnd"] = () => undefined;
|
||||
getDeviceByIeeeAddr: Controller["getDeviceByIeeeAddr"] = (ieeeAddr) => ZH_DEVICES.find((device) => device.ieeeAddr === ieeeAddr);
|
||||
getGroupByID: Controller["getGroupByID"] = (id) => ZH_GROUPS.find((group) => group.groupID === id);
|
||||
getDevicesByType: Controller["getDevicesByType"] = (type) => ZH_DEVICES.filter((device) => device.type === type);
|
||||
getDeviceByNetworkAddress: Controller["getDeviceByNetworkAddress"] = (networkAddress) =>
|
||||
ZH_DEVICES.find((device) => device.networkAddress === networkAddress);
|
||||
*getDevicesIterator(predicate: ((device: Device) => boolean) | undefined) {
|
||||
for (const device of ZH_DEVICES) {
|
||||
if (!predicate || predicate(device)) {
|
||||
yield device;
|
||||
}
|
||||
}
|
||||
}
|
||||
*getGroupsIterator(predicate: ((group: Group) => boolean) | undefined) {
|
||||
for (const group of ZH_GROUPS) {
|
||||
if (!predicate || predicate(group)) {
|
||||
yield group;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...actual,
|
||||
Controller: MockHerdsman,
|
||||
};
|
||||
});
|
||||
|
||||
process.env.ZIGBEE2MQTT_DATA = "data-bench";
|
||||
data._testReload();
|
||||
|
||||
@@ -365,45 +407,6 @@ const initController = async () => {
|
||||
async () => {},
|
||||
);
|
||||
|
||||
controller.zigbee.start = async () => {
|
||||
await controller.zigbee.resolveDevicesDefinitions();
|
||||
|
||||
return "resumed";
|
||||
};
|
||||
controller.zigbee.stop = async () => {
|
||||
await Promise.resolve();
|
||||
};
|
||||
// @ts-expect-error mocking private
|
||||
controller.zigbee.herdsman = {
|
||||
isStopping: () => false,
|
||||
getCoordinatorVersion: async () =>
|
||||
Promise.resolve({
|
||||
type: "Dummy",
|
||||
meta: {revision: "9.9.9"},
|
||||
}),
|
||||
getNetworkParameters: async () => Promise.resolve({...NETWORK_PARAMS}),
|
||||
getPermitJoin: () => false,
|
||||
getPermitJoinEnd: () => undefined,
|
||||
getDeviceByIeeeAddr: (ieeeAddr) => ZH_DEVICES.find((device) => device.ieeeAddr === ieeeAddr),
|
||||
getGroupByID: (id) => ZH_GROUPS.find((group) => group.groupID === id),
|
||||
getDevicesByType: (type) => ZH_DEVICES.filter((device) => device.type === type),
|
||||
getDeviceByNetworkAddress: (networkAddress) => ZH_DEVICES.find((device) => device.networkAddress === networkAddress),
|
||||
*getDevicesIterator(predicate) {
|
||||
for (const device of ZH_DEVICES) {
|
||||
if (!predicate || predicate(device)) {
|
||||
yield device;
|
||||
}
|
||||
}
|
||||
},
|
||||
*getGroupsIterator(predicate) {
|
||||
for (const group of ZH_GROUPS) {
|
||||
if (!predicate || predicate(group)) {
|
||||
yield group;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// all dummies, can trigger `controller.mqtt.onMessage(topic, message)` as needed
|
||||
// @ts-expect-error mocking private
|
||||
controller.mqtt.client = {
|
||||
|
||||
+265
-2
@@ -25,6 +25,7 @@ import {Controller as ZHController} from "zigbee-herdsman";
|
||||
import {Controller} from "../lib/controller";
|
||||
import type Device from "../lib/model/device";
|
||||
import * as settings from "../lib/util/settings";
|
||||
import Frontend from "../lib/extension/frontend";
|
||||
|
||||
const LOG_MQTT_NS = "z2m:mqtt";
|
||||
|
||||
@@ -54,6 +55,7 @@ const mocksClear = [
|
||||
describe("Controller", () => {
|
||||
let controller: Controller;
|
||||
let mockExit: Mock;
|
||||
let stopAfter = true;
|
||||
|
||||
const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => {
|
||||
return controller.zigbee.resolveEntity(zhDevice)! as Device;
|
||||
@@ -64,6 +66,7 @@ describe("Controller", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
stopAfter = true;
|
||||
returnDevices.splice(0);
|
||||
mockExit = vi.fn();
|
||||
data.writeDefaultConfiguration();
|
||||
@@ -79,7 +82,10 @@ describe("Controller", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await controller?.stop();
|
||||
if (stopAfter) {
|
||||
await controller?.stop();
|
||||
}
|
||||
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
@@ -323,11 +329,14 @@ describe("Controller", () => {
|
||||
});
|
||||
|
||||
it("Should remove device on blocklist on startup", async () => {
|
||||
settings.set(["blocklist"], [devices.bulb_color.ieeeAddr]);
|
||||
settings.set(["blocklist"], [devices.bulb_color.ieeeAddr, "0x9998889998889990", "0x00124b00120144ae"]);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
expect(devices.bulb_color.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb.removeFromNetwork).toHaveBeenCalledTimes(0);
|
||||
expect(devices.coordinator.removeFromNetwork).toHaveBeenCalledTimes(0);
|
||||
const debugCalls = mockLogger.debug.mock.calls.map((c) => (typeof c[0] === "string" ? c[0] : c[0]()));
|
||||
expect(debugCalls.find((v) => v === "Ignoring blocklist device 0x9998889998889990, not currently on the network")).toBeDefined();
|
||||
});
|
||||
|
||||
it("Start controller fails", async () => {
|
||||
@@ -409,6 +418,252 @@ describe("Controller", () => {
|
||||
delete process.env.NOTIFY_SOCKET;
|
||||
});
|
||||
|
||||
describe("aborts during startup", () => {
|
||||
let stateStartSpy: MockInstance<() => void>;
|
||||
let zigbeeStartSpy: MockInstance<() => Promise<boolean>>;
|
||||
let mqttConnectSpy: MockInstance<() => Promise<void>>;
|
||||
let extensionStartSpy: MockInstance<() => Promise<void>>;
|
||||
let publishEntityStateSpy: MockInstance<() => Promise<void>>;
|
||||
let extensionStopSpy: MockInstance<() => Promise<void>>;
|
||||
let mqttDisconnectSpy: MockInstance<() => Promise<void>>;
|
||||
let mqttPublishSpy: MockInstance<() => Promise<void>>;
|
||||
|
||||
beforeEach(() => {
|
||||
stateStartSpy = vi.spyOn(controller.state, "start");
|
||||
zigbeeStartSpy = vi.spyOn(controller.zigbee, "start");
|
||||
mqttConnectSpy = vi.spyOn(controller.mqtt, "connect");
|
||||
const [firstExt] = controller.extensions;
|
||||
extensionStartSpy = vi.spyOn(firstExt, "start");
|
||||
publishEntityStateSpy = vi.spyOn(controller, "publishEntityState");
|
||||
extensionStopSpy = vi.spyOn(firstExt, "stop");
|
||||
mqttDisconnectSpy = vi.spyOn(controller.mqtt, "disconnect");
|
||||
mqttPublishSpy = vi.spyOn(controller.mqtt, "publish");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopAfter = false;
|
||||
stateStartSpy.mockRestore();
|
||||
zigbeeStartSpy.mockRestore();
|
||||
mqttConnectSpy.mockRestore();
|
||||
extensionStartSpy.mockRestore();
|
||||
publishEntityStateSpy.mockRestore();
|
||||
extensionStopSpy.mockRestore();
|
||||
mqttDisconnectSpy.mockRestore();
|
||||
mqttPublishSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("during state start", async () => {
|
||||
stateStartSpy.mockImplementationOnce(async () => {
|
||||
await controller.stop(undefined, undefined, "SIGINT");
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("during zigbee resolve definitions", async () => {
|
||||
const resolveDevicesDefinitionsSpy = vi.spyOn(controller.zigbee, "resolveDevicesDefinitions");
|
||||
resolveDevicesDefinitionsSpy.mockImplementationOnce(async (ignoreCache, abortSignal) => {
|
||||
const device = controller.zigbee.resolveEntity(devices.bulb)! as Device;
|
||||
device.resolveDefinition = vi.fn(async () => {
|
||||
await controller.stop(undefined, undefined, "SIGINT");
|
||||
});
|
||||
|
||||
return await controller.zigbee.resolveDevicesDefinitions(ignoreCache, abortSignal);
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("during MQTT connect", async () => {
|
||||
mqttConnectSpy.mockImplementationOnce(async () => {
|
||||
await controller.stop(undefined, undefined, "SIGINT");
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("during extension start", async () => {
|
||||
extensionStartSpy.mockImplementationOnce(async () => {
|
||||
await controller.stop(undefined, undefined, "SIGINT");
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("during entity state publish", async () => {
|
||||
publishEntityStateSpy.mockImplementationOnce(async () => {
|
||||
await controller.stop(undefined, undefined, "SIGINT");
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(13);
|
||||
});
|
||||
|
||||
it("without signal during state start", async () => {
|
||||
stateStartSpy.mockImplementationOnce(async () => {
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("without signal during MQTT connect", async () => {
|
||||
mqttConnectSpy.mockImplementationOnce(async () => {
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("during ZH Controller start", async () => {
|
||||
mockZHController.start.mockImplementationOnce(async (abortSignal) => {
|
||||
void controller.stop(undefined, undefined, "SIGINT");
|
||||
abortSignal?.throwIfAborted();
|
||||
|
||||
return await Promise.resolve("resumed");
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("during ZH Controller getNetworkParameters", async () => {
|
||||
mockZHController.getNetworkParameters.mockImplementationOnce(async () => {
|
||||
await controller.stop(undefined, undefined, "SIGINT");
|
||||
|
||||
return await Promise.resolve({panID: 0x162a, extendedPanID: "0x64c5fd698daf0c00", channel: 15, nwkUpdateID: 0});
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("during ZH Controller passlist", async () => {
|
||||
settings.set(["passlist"], [devices.bulb_color.ieeeAddr]);
|
||||
devices.bulb.removeFromNetwork.mockImplementationOnce(async () => {
|
||||
await controller.stop(undefined, undefined, "SIGINT");
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("during ZH Controller blocklist", async () => {
|
||||
settings.set(["blocklist"], [devices.bulb_color.ieeeAddr]);
|
||||
devices.bulb_color.removeFromNetwork.mockImplementationOnce(async () => {
|
||||
await controller.stop(undefined, undefined, "SIGINT");
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("Start controller adapter disconnects", async () => {
|
||||
// Fail to stop extension exit code 1 should not override adapter disconnect exit code 2
|
||||
vi.spyOn(Array.from(controller.extensions)[0], "stop").mockRejectedValueOnce(new Error("failed"));
|
||||
@@ -1168,4 +1423,12 @@ describe("Controller", () => {
|
||||
await controller.enableDisableExtension(false, "Availability");
|
||||
}).rejects.toThrow("Built-in extension Availability cannot be disabled at runtime");
|
||||
});
|
||||
|
||||
it("throws error when adding extension twice", async () => {
|
||||
settings.set(["frontend", "enabled"], true);
|
||||
await controller.start();
|
||||
await expect(async () => {
|
||||
await controller.addExtension(new Frontend(...controller.extensionArgs));
|
||||
}).rejects.toThrow("Extension with name Frontend already present");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -358,7 +358,7 @@ describe("Extension: Bind", () => {
|
||||
{attribute: "currentX", minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
|
||||
{attribute: "currentY", minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
|
||||
]);
|
||||
expect(devices.bulb_color.meta.configured).toBe(332242049);
|
||||
expect(devices.bulb_color.meta.configured).toBe("0.0.0");
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/unbind",
|
||||
stringify({
|
||||
@@ -795,4 +795,84 @@ describe("Extension: Bind", () => {
|
||||
// Should only call Hue bulb, not e.g. tradfri
|
||||
expect(devices.bulb_2.getEndpoint(1)!.read).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("clears all bindings", async () => {
|
||||
const device = devices.remote;
|
||||
|
||||
device.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/binds/clear", stringify({transaction: "1234", target: "remote"}));
|
||||
await flushPromises();
|
||||
|
||||
expect(device.clearAllBindings).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/binds/clear",
|
||||
stringify({
|
||||
transaction: "1234",
|
||||
data: {
|
||||
target: "remote",
|
||||
ieee_list: ["0xffffffffffffffff"],
|
||||
},
|
||||
status: "ok",
|
||||
}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
|
||||
});
|
||||
|
||||
it("clears targeted bindings", async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.bulb_color;
|
||||
|
||||
device.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/device/binds/clear",
|
||||
stringify({transaction: "1234", target: "remote", ieee_list: [target.ieeeAddr]}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(device.clearAllBindings).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/binds/clear",
|
||||
stringify({
|
||||
transaction: "1234",
|
||||
data: {
|
||||
target: "remote",
|
||||
ieee_list: [target.ieeeAddr],
|
||||
},
|
||||
status: "ok",
|
||||
}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
|
||||
});
|
||||
|
||||
it("throw on invalid clears bindings payload", async () => {
|
||||
const device = devices.remote;
|
||||
|
||||
device.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/binds/clear", stringify({targetz: "remote"}));
|
||||
await flushPromises();
|
||||
|
||||
expect(device.clearAllBindings).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/binds/clear",
|
||||
stringify({data: {}, status: "error", error: "Invalid payload"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("throw on invalid clears bindings target", async () => {
|
||||
const device = devices.remote;
|
||||
|
||||
device.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/binds/clear", stringify({target: "remotez"}));
|
||||
await flushPromises();
|
||||
|
||||
expect(device.clearAllBindings).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/binds/clear",
|
||||
stringify({data: {}, status: "error", error: "Invalid target"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+378
-29
@@ -1,5 +1,6 @@
|
||||
// biome-ignore assist/source/organizeImports: import mocks first
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
|
||||
import {Zdo} from "zigbee-herdsman";
|
||||
import * as data from "../mocks/data";
|
||||
import {mockJSZipFile, mockJSZipGenerateAsync} from "../mocks/jszip";
|
||||
import {mockLogger} from "../mocks/logger";
|
||||
@@ -17,6 +18,7 @@ import {Controller} from "../../lib/controller";
|
||||
import Bridge from "../../lib/extension/bridge";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
import utils, {DEFAULT_BIND_GROUP_ID} from "../../lib/util/utils";
|
||||
import {Zcl} from "zigbee-herdsman";
|
||||
|
||||
returnDevices.push(devices.coordinator.ieeeAddr);
|
||||
returnDevices.push(devices.bulb.ieeeAddr);
|
||||
@@ -162,6 +164,7 @@ describe("Extension: Bridge", () => {
|
||||
"0x0017880104a44559": {friendly_name: "J1_cover"},
|
||||
"0x0017880104e43559": {friendly_name: "U202DST600ZB"},
|
||||
"0x0017880104e44559": {friendly_name: "3157100_thermostat"},
|
||||
"0x18fc2600000d7ae3": {friendly_name: "bosch_rm230z"},
|
||||
"0x0017880104e45517": {friendly_name: "remote", retain: true},
|
||||
"0x0017880104e45520": {friendly_name: "button", retain: false},
|
||||
"0x0017880104e45521": {
|
||||
@@ -319,6 +322,7 @@ describe("Extension: Bridge", () => {
|
||||
ota: {
|
||||
default_maximum_data_size: 50,
|
||||
disable_automatic_update_check: false,
|
||||
image_block_request_timeout: 150000,
|
||||
image_block_response_delay: 250,
|
||||
update_check_interval: 1440,
|
||||
},
|
||||
@@ -545,6 +549,7 @@ describe("Extension: Bridge", () => {
|
||||
property: "transition",
|
||||
type: "numeric",
|
||||
value_min: 0,
|
||||
value_step: 0.1,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
@@ -771,6 +776,7 @@ describe("Extension: Bridge", () => {
|
||||
property: "transition",
|
||||
type: "numeric",
|
||||
value_min: 0,
|
||||
value_step: 0.1,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
@@ -1883,6 +1889,7 @@ describe("Extension: Bridge", () => {
|
||||
property: "transition",
|
||||
type: "numeric",
|
||||
value_min: 0,
|
||||
value_step: 0.1,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
@@ -2149,6 +2156,7 @@ describe("Extension: Bridge", () => {
|
||||
property: "transition",
|
||||
type: "numeric",
|
||||
value_min: 0,
|
||||
value_step: 0.1,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
@@ -2583,6 +2591,7 @@ describe("Extension: Bridge", () => {
|
||||
property: "transition",
|
||||
type: "numeric",
|
||||
value_min: 0,
|
||||
value_step: 0.1,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
@@ -3255,7 +3264,7 @@ describe("Extension: Bridge", () => {
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/interview",
|
||||
stringify({data: {}, status: "error", error: "interview of 'bulb' (0x000b57fffec6a5b2) failed: Error: something went wrong"}),
|
||||
stringify({data: {}, status: "error", error: "Interview of 'bulb' (0x000b57fffec6a5b2) failed: Error: something went wrong"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
@@ -3532,11 +3541,11 @@ describe("Extension: Bridge", () => {
|
||||
|
||||
it("Should allow to touchlink factory reset (succeeds)", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockZHController.touchlinkFactoryResetFirst.mockClear();
|
||||
mockZHController.touchlinkFactoryResetFirst.mockReturnValueOnce(true);
|
||||
mockZHController.touchlink.factoryResetFirst.mockClear();
|
||||
mockZHController.touchlink.factoryResetFirst.mockReturnValueOnce(true);
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/touchlink/factory_reset", "");
|
||||
await flushPromises();
|
||||
expect(mockZHController.touchlinkFactoryResetFirst).toHaveBeenCalledTimes(1);
|
||||
expect(mockZHController.touchlink.factoryResetFirst).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/touchlink/factory_reset",
|
||||
stringify({data: {}, status: "ok"}),
|
||||
@@ -3546,12 +3555,12 @@ describe("Extension: Bridge", () => {
|
||||
|
||||
it("Should allow to touchlink factory reset specific device", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockZHController.touchlinkFactoryReset.mockClear();
|
||||
mockZHController.touchlinkFactoryReset.mockReturnValueOnce(true);
|
||||
mockZHController.touchlink.factoryReset.mockClear();
|
||||
mockZHController.touchlink.factoryReset.mockReturnValueOnce(true);
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/touchlink/factory_reset", stringify({ieee_address: "0x1239", channel: 12}));
|
||||
await flushPromises();
|
||||
expect(mockZHController.touchlinkFactoryReset).toHaveBeenCalledTimes(1);
|
||||
expect(mockZHController.touchlinkFactoryReset).toHaveBeenCalledWith("0x1239", 12);
|
||||
expect(mockZHController.touchlink.factoryReset).toHaveBeenCalledTimes(1);
|
||||
expect(mockZHController.touchlink.factoryReset).toHaveBeenCalledWith("0x1239", 12);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/touchlink/factory_reset",
|
||||
stringify({data: {ieee_address: "0x1239", channel: 12}, status: "ok"}),
|
||||
@@ -3602,11 +3611,11 @@ describe("Extension: Bridge", () => {
|
||||
|
||||
it("Should allow to touchlink identify specific device", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockZHController.touchlinkIdentify.mockClear();
|
||||
mockZHController.touchlink.identify.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/touchlink/identify", stringify({ieee_address: "0x1239", channel: 12}));
|
||||
await flushPromises();
|
||||
expect(mockZHController.touchlinkIdentify).toHaveBeenCalledTimes(1);
|
||||
expect(mockZHController.touchlinkIdentify).toHaveBeenCalledWith("0x1239", 12);
|
||||
expect(mockZHController.touchlink.identify).toHaveBeenCalledTimes(1);
|
||||
expect(mockZHController.touchlink.identify).toHaveBeenCalledWith("0x1239", 12);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/touchlink/identify",
|
||||
stringify({data: {ieee_address: "0x1239", channel: 12}, status: "ok"}),
|
||||
@@ -3616,10 +3625,10 @@ describe("Extension: Bridge", () => {
|
||||
|
||||
it("Touchlink identify fails when payload is invalid", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockZHController.touchlinkIdentify.mockClear();
|
||||
mockZHController.touchlink.identify.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/touchlink/identify", stringify({ieee_address: "0x1239"}));
|
||||
await flushPromises();
|
||||
expect(mockZHController.touchlinkIdentify).toHaveBeenCalledTimes(0);
|
||||
expect(mockZHController.touchlink.identify).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/touchlink/identify",
|
||||
stringify({data: {}, status: "error", error: "Invalid payload"}),
|
||||
@@ -3629,11 +3638,11 @@ describe("Extension: Bridge", () => {
|
||||
|
||||
it("Should allow to touchlink factory reset (fails)", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockZHController.touchlinkFactoryResetFirst.mockClear();
|
||||
mockZHController.touchlinkFactoryResetFirst.mockReturnValueOnce(false);
|
||||
mockZHController.touchlink.factoryResetFirst.mockClear();
|
||||
mockZHController.touchlink.factoryResetFirst.mockReturnValueOnce(false);
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/touchlink/factory_reset", "");
|
||||
await flushPromises();
|
||||
expect(mockZHController.touchlinkFactoryResetFirst).toHaveBeenCalledTimes(1);
|
||||
expect(mockZHController.touchlink.factoryResetFirst).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/touchlink/factory_reset",
|
||||
stringify({data: {}, status: "error", error: "Failed to factory reset device through Touchlink"}),
|
||||
@@ -3643,14 +3652,14 @@ describe("Extension: Bridge", () => {
|
||||
|
||||
it("Should allow to touchlink scan", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockZHController.touchlinkScan.mockClear();
|
||||
mockZHController.touchlinkScan.mockReturnValueOnce([
|
||||
mockZHController.touchlink.scan.mockClear();
|
||||
mockZHController.touchlink.scan.mockReturnValueOnce([
|
||||
{ieeeAddr: "0x123", channel: 12},
|
||||
{ieeeAddr: "0x124", channel: 24},
|
||||
]);
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/touchlink/scan", "");
|
||||
await flushPromises();
|
||||
expect(mockZHController.touchlinkScan).toHaveBeenCalledTimes(1);
|
||||
expect(mockZHController.touchlink.scan).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/touchlink/scan",
|
||||
stringify({
|
||||
@@ -3673,7 +3682,7 @@ describe("Extension: Bridge", () => {
|
||||
endpoint.configureReporting.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/device/configure_reporting",
|
||||
"zigbee2mqtt/bridge/request/device/reporting/configure",
|
||||
stringify({
|
||||
id: "0x000b57fffec6a5b2",
|
||||
endpoint: 1,
|
||||
@@ -3694,7 +3703,7 @@ describe("Extension: Bridge", () => {
|
||||
undefined,
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/configure_reporting",
|
||||
"zigbee2mqtt/bridge/response/device/reporting/configure",
|
||||
stringify({
|
||||
data: {
|
||||
id: "0x000b57fffec6a5b2",
|
||||
@@ -3719,7 +3728,7 @@ describe("Extension: Bridge", () => {
|
||||
endpoint.configureReporting.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/device/configure_reporting",
|
||||
"zigbee2mqtt/bridge/request/device/reporting/configure",
|
||||
stringify({
|
||||
id: "0x000b57fffec6a5b2",
|
||||
endpoint: "1",
|
||||
@@ -3740,7 +3749,7 @@ describe("Extension: Bridge", () => {
|
||||
undefined,
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/configure_reporting",
|
||||
"zigbee2mqtt/bridge/response/device/reporting/configure",
|
||||
stringify({
|
||||
data: {
|
||||
id: "0x000b57fffec6a5b2",
|
||||
@@ -3764,7 +3773,7 @@ describe("Extension: Bridge", () => {
|
||||
endpoint.configureReporting.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/device/configure_reporting",
|
||||
"zigbee2mqtt/bridge/request/device/reporting/configure",
|
||||
stringify({
|
||||
id: "bulb",
|
||||
// endpoint: '1',
|
||||
@@ -3778,7 +3787,7 @@ describe("Extension: Bridge", () => {
|
||||
await flushPromises();
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/configure_reporting",
|
||||
"zigbee2mqtt/bridge/response/device/reporting/configure",
|
||||
stringify({data: {}, status: "error", error: "Invalid payload"}),
|
||||
{},
|
||||
);
|
||||
@@ -3790,7 +3799,7 @@ describe("Extension: Bridge", () => {
|
||||
endpoint.configureReporting.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/device/configure_reporting",
|
||||
"zigbee2mqtt/bridge/request/device/reporting/configure",
|
||||
stringify({
|
||||
id: "non_existing_device",
|
||||
endpoint: "1",
|
||||
@@ -3804,7 +3813,7 @@ describe("Extension: Bridge", () => {
|
||||
await flushPromises();
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/configure_reporting",
|
||||
"zigbee2mqtt/bridge/response/device/reporting/configure",
|
||||
stringify({data: {}, status: "error", error: "Device 'non_existing_device' does not exist"}),
|
||||
{},
|
||||
);
|
||||
@@ -3816,7 +3825,7 @@ describe("Extension: Bridge", () => {
|
||||
endpoint.configureReporting.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/device/configure_reporting",
|
||||
"zigbee2mqtt/bridge/request/device/reporting/configure",
|
||||
stringify({
|
||||
id: "0x000b57fffec6a5b2",
|
||||
endpoint: "non_existing_endpoint",
|
||||
@@ -3830,7 +3839,243 @@ describe("Extension: Bridge", () => {
|
||||
await flushPromises();
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/configure_reporting",
|
||||
"zigbee2mqtt/bridge/response/device/reporting/configure",
|
||||
stringify({data: {}, status: "error", error: "Device '0x000b57fffec6a5b2' does not have endpoint 'non_existing_endpoint'"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to read reporting config with endpoint as number", async () => {
|
||||
const device = devices.bulb;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
endpoint.bind.mockClear();
|
||||
endpoint.readReportingConfig.mockClear();
|
||||
endpoint.readReportingConfig.mockResolvedValueOnce([
|
||||
{
|
||||
status: Zcl.Status.SUCCESS,
|
||||
direction: Zcl.Direction.CLIENT_TO_SERVER,
|
||||
attrId: Zcl.Clusters.genLevelCtrl.attributes.currentLevel.ID,
|
||||
dataType: Zcl.DataType.UINT8,
|
||||
minRepIntval: 10,
|
||||
maxRepIntval: 60,
|
||||
repChange: 2,
|
||||
},
|
||||
]);
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/device/reporting/read",
|
||||
stringify({
|
||||
id: "0x000b57fffec6a5b2",
|
||||
endpoint: 1,
|
||||
cluster: "genLevelCtrl",
|
||||
configs: [{attribute: "currentLevel"}],
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(endpoint.readReportingConfig).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.readReportingConfig).toHaveBeenCalledWith("genLevelCtrl", [{attribute: "currentLevel"}], {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/reporting/read",
|
||||
stringify({
|
||||
data: {
|
||||
id: "0x000b57fffec6a5b2",
|
||||
endpoint: 1,
|
||||
cluster: "genLevelCtrl",
|
||||
configs: [
|
||||
{
|
||||
status: Zcl.Status.SUCCESS,
|
||||
direction: Zcl.Direction.CLIENT_TO_SERVER,
|
||||
attrId: Zcl.Clusters.genLevelCtrl.attributes.currentLevel.ID,
|
||||
dataType: Zcl.DataType.UINT8,
|
||||
minRepIntval: 10,
|
||||
maxRepIntval: 60,
|
||||
repChange: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "ok",
|
||||
}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
|
||||
});
|
||||
|
||||
it("Should allow to read reporting config with endpoint as string", async () => {
|
||||
const device = devices.bulb;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
endpoint.bind.mockClear();
|
||||
endpoint.readReportingConfig.mockClear();
|
||||
endpoint.readReportingConfig.mockResolvedValueOnce([
|
||||
{
|
||||
status: Zcl.Status.SUCCESS,
|
||||
direction: Zcl.Direction.CLIENT_TO_SERVER,
|
||||
attrId: Zcl.Clusters.genLevelCtrl.attributes.currentLevel.ID,
|
||||
dataType: Zcl.DataType.UINT8,
|
||||
minRepIntval: 10,
|
||||
maxRepIntval: 60,
|
||||
repChange: 2,
|
||||
},
|
||||
]);
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/device/reporting/read",
|
||||
stringify({
|
||||
id: "0x000b57fffec6a5b2",
|
||||
endpoint: "1",
|
||||
cluster: "genLevelCtrl",
|
||||
configs: [{attribute: "currentLevel"}],
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(endpoint.readReportingConfig).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.readReportingConfig).toHaveBeenCalledWith("genLevelCtrl", [{attribute: "currentLevel"}], {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/reporting/read",
|
||||
stringify({
|
||||
data: {
|
||||
id: "0x000b57fffec6a5b2",
|
||||
endpoint: "1",
|
||||
cluster: "genLevelCtrl",
|
||||
configs: [
|
||||
{
|
||||
status: Zcl.Status.SUCCESS,
|
||||
direction: Zcl.Direction.CLIENT_TO_SERVER,
|
||||
attrId: Zcl.Clusters.genLevelCtrl.attributes.currentLevel.ID,
|
||||
dataType: Zcl.DataType.UINT8,
|
||||
minRepIntval: 10,
|
||||
maxRepIntval: 60,
|
||||
repChange: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "ok",
|
||||
}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
|
||||
});
|
||||
|
||||
it("Should allow to read reporting config with manufacturer code", async () => {
|
||||
const device = devices.bulb;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
endpoint.bind.mockClear();
|
||||
endpoint.readReportingConfig.mockClear();
|
||||
endpoint.readReportingConfig.mockResolvedValueOnce([
|
||||
{
|
||||
status: Zcl.Status.SUCCESS,
|
||||
direction: Zcl.Direction.CLIENT_TO_SERVER,
|
||||
attrId: Zcl.Clusters.genLevelCtrl.attributes.currentLevel.ID,
|
||||
dataType: Zcl.DataType.UINT8,
|
||||
minRepIntval: 10,
|
||||
maxRepIntval: 60,
|
||||
repChange: 2,
|
||||
},
|
||||
]);
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/device/reporting/read",
|
||||
stringify({
|
||||
id: "0x000b57fffec6a5b2",
|
||||
endpoint: 1,
|
||||
cluster: "genLevelCtrl",
|
||||
configs: [{attribute: "currentLevel"}],
|
||||
manufacturer_code: 0x1234,
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(endpoint.readReportingConfig).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.readReportingConfig).toHaveBeenCalledWith("genLevelCtrl", [{attribute: "currentLevel"}], {manufacturerCode: 0x1234});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/reporting/read",
|
||||
stringify({
|
||||
data: {
|
||||
id: "0x000b57fffec6a5b2",
|
||||
endpoint: 1,
|
||||
cluster: "genLevelCtrl",
|
||||
configs: [
|
||||
{
|
||||
status: Zcl.Status.SUCCESS,
|
||||
direction: Zcl.Direction.CLIENT_TO_SERVER,
|
||||
attrId: Zcl.Clusters.genLevelCtrl.attributes.currentLevel.ID,
|
||||
dataType: Zcl.DataType.UINT8,
|
||||
minRepIntval: 10,
|
||||
maxRepIntval: 60,
|
||||
repChange: 2,
|
||||
},
|
||||
],
|
||||
manufacturer_code: 0x1234,
|
||||
},
|
||||
status: "ok",
|
||||
}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
|
||||
});
|
||||
|
||||
it("Should throw error when read reporting config is called with malformed payload", async () => {
|
||||
const device = devices.bulb;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
endpoint.readReportingConfig.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/device/reporting/read",
|
||||
stringify({
|
||||
id: "bulb",
|
||||
// endpoint: '1',
|
||||
cluster: "genLevelCtrl",
|
||||
configs: [{attribute: "currentLevel"}],
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(endpoint.readReportingConfig).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/reporting/read",
|
||||
stringify({data: {}, status: "error", error: "Invalid payload"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should throw error when read reporting config is called for non-existing device", async () => {
|
||||
const device = devices.bulb;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
endpoint.readReportingConfig.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/device/reporting/read",
|
||||
stringify({
|
||||
id: "non_existing_device",
|
||||
endpoint: "1",
|
||||
cluster: "genLevelCtrl",
|
||||
configs: [{attribute: "currentLevel"}],
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(endpoint.readReportingConfig).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/reporting/read",
|
||||
stringify({data: {}, status: "error", error: "Device 'non_existing_device' does not exist"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should throw error when read reporting config is called for non-existing endpoint", async () => {
|
||||
const device = devices.bulb;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
endpoint.readReportingConfig.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/device/reporting/read",
|
||||
stringify({
|
||||
id: "0x000b57fffec6a5b2",
|
||||
endpoint: "non_existing_endpoint",
|
||||
cluster: "genLevelCtrl",
|
||||
configs: [{attribute: "currentLevel"}],
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(endpoint.readReportingConfig).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/reporting/read",
|
||||
stringify({data: {}, status: "error", error: "Device '0x000b57fffec6a5b2' does not have endpoint 'non_existing_endpoint'"}),
|
||||
{},
|
||||
);
|
||||
@@ -3841,6 +4086,8 @@ describe("Extension: Bridge", () => {
|
||||
fs.writeFileSync(path.join(data.mockDir, "ext_converters", "afile.js"), "test123");
|
||||
fs.mkdirSync(path.join(data.mockDir, "log"));
|
||||
fs.writeFileSync(path.join(data.mockDir, "log", "log.log"), "test123");
|
||||
fs.mkdirSync(path.join(data.mockDir, "ota"));
|
||||
fs.writeFileSync(path.join(data.mockDir, "ota", "my.ota"), "test123");
|
||||
fs.mkdirSync(path.join(data.mockDir, "ext_converters", "123"));
|
||||
fs.writeFileSync(path.join(data.mockDir, "ext_converters", "123", "myfile.js"), "test123");
|
||||
fs.symlinkSync(
|
||||
@@ -3885,6 +4132,15 @@ describe("Extension: Bridge", () => {
|
||||
stringify({data: {restart_required: true}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
|
||||
// Change an option
|
||||
await mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/options",
|
||||
stringify({options: {homeassistant: {enabled: true, experimental_event_entities: true}}}),
|
||||
);
|
||||
// @ts-expect-error - private property
|
||||
await vi.waitUntil(() => controller.getExtension("HomeAssistant")?.experimentalEventEntities === true);
|
||||
|
||||
// revert
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/options", stringify({options: {homeassistant: {enabled: false}}}));
|
||||
await vi.waitUntil(() => controller.getExtension("HomeAssistant") === undefined);
|
||||
@@ -4106,4 +4362,97 @@ describe("Extension: Bridge", () => {
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/response/device/configure", expect.any(String), {});
|
||||
});
|
||||
|
||||
it("triggers ZHC action by name with params", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/action",
|
||||
JSON.stringify({
|
||||
action: "raw",
|
||||
params: {
|
||||
profileId: Zdo.ZDO_PROFILE_ID,
|
||||
ieeeAddress: "0xf1f2f3f4f5f6f7f8",
|
||||
networkAddress: 0x1234,
|
||||
clusterKey: Zdo.ClusterId.NETWORK_ADDRESS_REQUEST,
|
||||
zdoParams: ["0xa1a2a3a4a5a6a7a8", false, 0],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/action",
|
||||
JSON.stringify({data: [0x00, {assocDevList: [], eui64: "", nwkAddress: 0x1234, startIndex: 0}], status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("triggers ZHC action by name without params", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/action",
|
||||
JSON.stringify({
|
||||
action: "raw",
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
// we're mocking the response, so it's always this as long as the action & payload are valid
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/action",
|
||||
JSON.stringify({data: [0x00, {assocDevList: [], eui64: "", nwkAddress: 0x1234, startIndex: 0}], status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on invalid action payload", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/action",
|
||||
JSON.stringify({
|
||||
params: {
|
||||
profileId: Zdo.ZDO_PROFILE_ID,
|
||||
ieeeAddress: "0xf1f2f3f4f5f6f7f8",
|
||||
networkAddress: 0x1234,
|
||||
clusterKey: Zdo.ClusterId.NETWORK_ADDRESS_REQUEST,
|
||||
zdoParams: ["0xa1a2a3a4a5a6a7a8", false, 0],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/action",
|
||||
stringify({data: {}, status: "error", error: "Invalid payload"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on invalid action", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/action",
|
||||
JSON.stringify({
|
||||
action: "DOES_NOT_EXIST",
|
||||
params: {
|
||||
profileId: Zdo.ZDO_PROFILE_ID,
|
||||
ieeeAddress: "0xf1f2f3f4f5f6f7f8",
|
||||
networkAddress: 0x1234,
|
||||
clusterKey: Zdo.ClusterId.NETWORK_ADDRESS_REQUEST,
|
||||
zdoParams: ["0xa1a2a3a4a5a6a7a8", false, 0],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/action",
|
||||
stringify({data: {}, status: "error", error: "Invalid action"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,13 +4,15 @@ import * as data from "../mocks/data";
|
||||
import {mockLogger} from "../mocks/logger";
|
||||
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
|
||||
import {flushPromises} from "../mocks/utils";
|
||||
import {type Device, devices, type Endpoint, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
|
||||
import {devices, type Endpoint, events as mockZHEvents, type Device as ZhDevice} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import Device from "../../lib/model/device";
|
||||
import Configure from "../../lib/extension/configure";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
import assert from "node:assert";
|
||||
|
||||
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
|
||||
|
||||
@@ -23,12 +25,9 @@ describe("Extension: Configure", () => {
|
||||
await controller.addExtension(new Configure(...controller.extensionArgs));
|
||||
};
|
||||
|
||||
const mockClear = (device: Device): void => {
|
||||
const mockClear = (device: ZhDevice): void => {
|
||||
for (const endpoint of device.endpoints) {
|
||||
endpoint.read.mockClear();
|
||||
endpoint.write.mockClear();
|
||||
endpoint.configureReporting.mockClear();
|
||||
endpoint.bind.mockClear();
|
||||
endpoint.mockClear();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,7 +41,7 @@ describe("Extension: Configure", () => {
|
||||
const endpoint2 = device.getEndpoint(2)!;
|
||||
expect(endpoint2.write).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint2.write).toHaveBeenCalledWith("genBasic", {49: {type: 25, value: 11}}, {disableDefaultResponse: true, manufacturerCode: 4107});
|
||||
expect(device.meta.configured).toBe(332242049);
|
||||
expect(device.meta.configured).toBe("0.0.0");
|
||||
};
|
||||
|
||||
const expectBulbConfigured = (): void => {
|
||||
@@ -147,6 +146,52 @@ describe("Extension: Configure", () => {
|
||||
expectBulbConfigured();
|
||||
});
|
||||
|
||||
it("Should re-configure when the version of the definition changes to a different value than the default (0.0.0)", async () => {
|
||||
// Device is initially configured (definition has uses default version of 0.0.0)
|
||||
const device = devices.bulb;
|
||||
expectBulbConfigured();
|
||||
|
||||
// Nothing happens when receiving a Zigbee message
|
||||
mockClear(device);
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expectBulbNotConfigured();
|
||||
|
||||
// Simulate that the definition version changes
|
||||
const resolvedEntity = controller.zigbee.resolveEntity(device);
|
||||
assert(resolvedEntity instanceof Device && resolvedEntity.definition);
|
||||
assert(resolvedEntity.definition.version === "0.0.0");
|
||||
|
||||
try {
|
||||
// Change the version to a different value
|
||||
resolvedEntity.definition.version = "0.0.1";
|
||||
|
||||
// Now it should re-configure upon receiving a Zigbee message
|
||||
mockClear(device);
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expectBulbConfigured();
|
||||
} finally {
|
||||
resolvedEntity.definition.version = "0.0.0";
|
||||
}
|
||||
});
|
||||
|
||||
it("Should NOT re-configure when version of the definition is 0.0.0", async () => {
|
||||
// This test the migration from the old configureKey (hash of the configure function) to the new definition version system.
|
||||
// See commment in `configure.ts` -> `shouldReconfigure` for more details.
|
||||
|
||||
// Device is initially configured (definition has uses default version of 0.0.0)
|
||||
const device = devices.bulb;
|
||||
expectBulbConfigured();
|
||||
device.meta.configured = 1321;
|
||||
|
||||
// Nothing happens when receiving a Zigbee message
|
||||
mockClear(device);
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expectBulbNotConfigured();
|
||||
});
|
||||
|
||||
it("Should allow to configure via MQTT", async () => {
|
||||
mockClear(devices.remote);
|
||||
expectRemoteNotConfigured();
|
||||
@@ -227,27 +272,68 @@ describe("Extension: Configure", () => {
|
||||
});
|
||||
|
||||
it("Should configure max 3 times when fails", async () => {
|
||||
// @ts-expect-error private
|
||||
(controller.getExtension("Configure")! as Configure).attempts = {};
|
||||
const device = devices.remote;
|
||||
delete device.meta.configured;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA1"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA2"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA3"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("successfully configures after couple fails", async () => {
|
||||
const device = devices.remote;
|
||||
delete device.meta.configured;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA1"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA2"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(2 /* fails */ + 2 /* definition */);
|
||||
});
|
||||
|
||||
it("successfully configures with force after fails", async () => {
|
||||
const device = devices.remote;
|
||||
delete device.meta.configured;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA1"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA2"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA3"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA4"));
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", stringify({id: device.ieeeAddr}));
|
||||
await flushPromises();
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", stringify({id: device.ieeeAddr}));
|
||||
await flushPromises();
|
||||
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(4 /* fails */ + 2 /* definition */);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -478,10 +478,6 @@ describe("Extension: Frontend", () => {
|
||||
|
||||
await controller.enableDisableExtension(true, "Frontend");
|
||||
|
||||
await expect(async () => {
|
||||
await controller.enableDisableExtension(true, "Frontend");
|
||||
}).rejects.toThrow("Extension with name Frontend already present");
|
||||
|
||||
settings.set(["frontend", "enabled"], false);
|
||||
await controller.enableDisableExtension(false, "Frontend");
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
|
||||
it("Should discover devices and groups", async () => {
|
||||
settings.set(["homeassistant", "experimental_event_entities"], true);
|
||||
settings.set(["groups", "9", "homeassistant"], {name: "HA Discovery Group", icon: "mdi:lightbulb-group"});
|
||||
await resetExtension();
|
||||
|
||||
let payload;
|
||||
@@ -140,12 +141,13 @@ describe("Extension: HomeAssistant", () => {
|
||||
command_topic: "zigbee2mqtt/ha_discovery_group/set",
|
||||
device: {
|
||||
identifiers: ["zigbee2mqtt_1221051039810110150109113116116_9"],
|
||||
name: "ha_discovery_group",
|
||||
name: "HA Discovery Group",
|
||||
sw_version: version,
|
||||
model: "Group",
|
||||
manufacturer: "Zigbee2MQTT",
|
||||
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
||||
},
|
||||
icon: "mdi:lightbulb-group",
|
||||
max_mireds: 454,
|
||||
min_mireds: 250,
|
||||
name: null,
|
||||
@@ -209,12 +211,13 @@ describe("Extension: HomeAssistant", () => {
|
||||
command_topic: "zigbee2mqtt/ha_discovery_group/set",
|
||||
device: {
|
||||
identifiers: ["zigbee2mqtt_1221051039810110150109113116116_9"],
|
||||
name: "ha_discovery_group",
|
||||
name: "HA Discovery Group",
|
||||
sw_version: version,
|
||||
model: "Group",
|
||||
manufacturer: "Zigbee2MQTT",
|
||||
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
||||
},
|
||||
icon: "mdi:lightbulb-group",
|
||||
name: null,
|
||||
payload_off: "OFF",
|
||||
payload_on: "ON",
|
||||
@@ -941,6 +944,45 @@ describe("Extension: HomeAssistant", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Should discover siren (HS2WD-E)", () => {
|
||||
const payload = {
|
||||
available_tones: ["emergency"],
|
||||
support_duration: true,
|
||||
optimistic: true,
|
||||
command_topic: "zigbee2mqtt/siren/set",
|
||||
command_template:
|
||||
'{"warning": {"mode": "{{ tone | default(\'emergency\') }}", ' +
|
||||
'"level": "' +
|
||||
"{% if volume_level is defined %}" +
|
||||
"{% if volume_level | float <= 0.25 %}low" +
|
||||
"{% elif volume_level | float <= 0.5 %}medium" +
|
||||
"{% elif volume_level | float <= 0.75 %}high" +
|
||||
"{% else %}very_high{% endif %}" +
|
||||
'{% else %}medium{% endif %}", ' +
|
||||
'"duration": {{ duration | default(10) }}}}',
|
||||
command_off_template: '{"warning": {"mode": "stop"}}',
|
||||
name: null,
|
||||
object_id: "siren",
|
||||
default_entity_id: "siren.siren",
|
||||
unique_id: "0x0017880104e45549_siren_zigbee2mqtt",
|
||||
origin: origin,
|
||||
device: {
|
||||
identifiers: ["zigbee2mqtt_0x0017880104e45549"],
|
||||
name: "siren",
|
||||
model: "Smart siren",
|
||||
model_id: "HS2WD-E",
|
||||
manufacturer: "Heiman",
|
||||
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
||||
},
|
||||
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
||||
};
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/siren/0x0017880104e45549/siren/config", stringify(payload), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("Should discover devices with speed-controlled fan", () => {
|
||||
const payload = {
|
||||
state_topic: "zigbee2mqtt/fanbee",
|
||||
@@ -1117,10 +1159,10 @@ describe("Extension: HomeAssistant", () => {
|
||||
},
|
||||
max_temp: "30",
|
||||
min_temp: "5",
|
||||
mode_command_template: `{% set values = { 'auto':'schedule', 'heat':'manual', 'off':'pause' } %}{"operating_mode": "{{ values[value] if value in values.keys() else 'pause' }}"}`,
|
||||
mode_command_template: `{% set values = { 'auto':'schedule','heat':'manual','off':'pause'} %}{"operating_mode": "{{ values[value] if value in values.keys() else 'pause' }}"}`,
|
||||
mode_command_topic: "zigbee2mqtt/bosch_radiator/set",
|
||||
mode_state_template:
|
||||
"{% set values = { 'schedule':'auto', 'manual':'heat', 'pause':'off' } %}{% set value = value_json.operating_mode %}{{ values[value] if value in values.keys() else 'off' }}",
|
||||
"{% set values = {'schedule':'auto','manual':'heat','pause':'off'} %}{% set value = value_json.operating_mode %}{{ values[value] if value in values.keys() else 'off' }}",
|
||||
mode_state_topic: "zigbee2mqtt/bosch_radiator",
|
||||
modes: ["off", "heat", "auto"],
|
||||
name: null,
|
||||
@@ -1197,6 +1239,82 @@ describe("Extension: HomeAssistant", () => {
|
||||
overrideSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("Should discover Bosch BTH-RM230Z with a current_humidity attribute", () => {
|
||||
const payload = {
|
||||
action_template:
|
||||
"{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json.running_state] }}",
|
||||
action_topic: "zigbee2mqtt/bosch_rm230z",
|
||||
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
||||
current_humidity_template: "{{ value_json.humidity }}",
|
||||
current_humidity_topic: "zigbee2mqtt/bosch_rm230z",
|
||||
current_temperature_template: "{{ value_json.local_temperature }}",
|
||||
current_temperature_topic: "zigbee2mqtt/bosch_rm230z",
|
||||
default_entity_id: "climate.bosch_rm230z",
|
||||
device: {
|
||||
identifiers: ["zigbee2mqtt_0x18fc2600000d7ae3"],
|
||||
manufacturer: "Bosch",
|
||||
model: "Room thermostat II 230V",
|
||||
model_id: "BTH-RM230Z",
|
||||
name: "bosch_rm230z",
|
||||
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
||||
},
|
||||
max_temp: "30",
|
||||
min_temp: "5",
|
||||
mode_command_topic: "zigbee2mqtt/bosch_rm230z/set",
|
||||
mode_state_template:
|
||||
"{% set values = {'schedule':'auto','manual':'heat','pause':'off'} %}{% set value = value_json.operating_mode %}{% if value == \"manual\" %}{{ value_json.system_mode }}{% else %}{{ values[value] if value in values.keys() else 'off' }}{% endif %}",
|
||||
mode_command_template:
|
||||
"{% set values = { 'auto':'schedule','heat':'manual','cool':'manual','off':'pause'} %}{% if value == \"heat\" or value == \"cool\" %}{\"operating_mode\": \"manual\", \"system_mode\": \"{{ value }}\"}{% else %}{\"operating_mode\": \"{{ values[value] if value in values.keys() else 'pause' }}\"}{% endif %}",
|
||||
mode_state_topic: "zigbee2mqtt/bosch_rm230z",
|
||||
modes: ["off", "heat", "cool", "auto"],
|
||||
name: null,
|
||||
object_id: "bosch_rm230z",
|
||||
origin,
|
||||
temp_step: 0.5,
|
||||
temperature_high_command_topic: "zigbee2mqtt/bosch_rm230z/set/occupied_cooling_setpoint",
|
||||
temperature_high_state_template: "{{ value_json.occupied_cooling_setpoint }}",
|
||||
temperature_high_state_topic: "zigbee2mqtt/bosch_rm230z",
|
||||
temperature_low_command_topic: "zigbee2mqtt/bosch_rm230z/set/occupied_heating_setpoint",
|
||||
temperature_low_state_template: "{{ value_json.occupied_heating_setpoint }}",
|
||||
temperature_low_state_topic: "zigbee2mqtt/bosch_rm230z",
|
||||
temperature_unit: "C",
|
||||
unique_id: "0x18fc2600000d7ae3_climate_zigbee2mqtt",
|
||||
};
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/climate/0x18fc2600000d7ae3/climate/config", stringify(payload), {
|
||||
qos: 1,
|
||||
retain: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("Should discover seperate temperature sensor for thermostat", () => {
|
||||
const payload = {
|
||||
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
||||
default_entity_id: "sensor.bosch_rm230z_local_temperature",
|
||||
device: {
|
||||
identifiers: ["zigbee2mqtt_0x18fc2600000d7ae3"],
|
||||
manufacturer: "Bosch",
|
||||
model: "Room thermostat II 230V",
|
||||
model_id: "BTH-RM230Z",
|
||||
name: "bosch_rm230z",
|
||||
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
||||
},
|
||||
device_class: "temperature",
|
||||
object_id: "bosch_rm230z_local_temperature",
|
||||
origin,
|
||||
state_class: "measurement",
|
||||
state_topic: "zigbee2mqtt/bosch_rm230z",
|
||||
unique_id: "0x18fc2600000d7ae3_local_temperature_zigbee2mqtt",
|
||||
unit_of_measurement: "°C",
|
||||
value_template: "{{ value_json.local_temperature }}",
|
||||
};
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x18fc2600000d7ae3/local_temperature/config", stringify(payload), {
|
||||
qos: 1,
|
||||
retain: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("Should discover devices with cover_position", () => {
|
||||
let payload;
|
||||
|
||||
@@ -2897,10 +3015,6 @@ describe("Extension: HomeAssistant", () => {
|
||||
await controller.enableDisableExtension(false, "HomeAssistant");
|
||||
}).rejects.toThrow("Tried to disable HomeAssistant extension enabled in settings");
|
||||
|
||||
await expect(async () => {
|
||||
await controller.enableDisableExtension(true, "HomeAssistant");
|
||||
}).rejects.toThrow("Extension with name HomeAssistant already present");
|
||||
|
||||
settings.set(["homeassistant", "enabled"], false);
|
||||
|
||||
await expect(async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// biome-ignore assist/source/organizeImports: import mocks first
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
|
||||
import type {Eui64} from "zigbee-herdsman/dist/zspec/tstypes";
|
||||
import * as data from "../mocks/data";
|
||||
import {mockLogger} from "../mocks/logger";
|
||||
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
|
||||
@@ -25,6 +26,22 @@ returnDevices.push(
|
||||
|
||||
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
|
||||
|
||||
const LQI_BASE_RSP = {
|
||||
extendedPanId: [],
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
reserved1: 0,
|
||||
permitJoining: 0,
|
||||
reserved2: 0,
|
||||
};
|
||||
|
||||
const ROUTING_BASE_RSP = {
|
||||
memoryConstrained: 0,
|
||||
manyToOne: 0,
|
||||
routeRecordRequired: 0,
|
||||
reserved1: 0,
|
||||
};
|
||||
|
||||
describe("Extension: NetworkMap", () => {
|
||||
let controller: Controller;
|
||||
|
||||
@@ -39,58 +56,62 @@ describe("Extension: NetworkMap", () => {
|
||||
* | -> CC2530_ROUTER -> WXKG02LM_rev1
|
||||
*
|
||||
*/
|
||||
devices.coordinator.lqi.mockResolvedValueOnce({
|
||||
neighbors: [
|
||||
{
|
||||
ieeeAddr: devices.bulb_color.ieeeAddr,
|
||||
networkAddress: devices.bulb_color.networkAddress,
|
||||
relationship: 2,
|
||||
depth: 1,
|
||||
linkquality: 120,
|
||||
},
|
||||
{ieeeAddr: devices.bulb.ieeeAddr, networkAddress: devices.bulb.networkAddress, relationship: 2, depth: 1, linkquality: 92},
|
||||
{
|
||||
ieeeAddr: devices.external_converter_device.ieeeAddr,
|
||||
networkAddress: devices.external_converter_device.networkAddress,
|
||||
relationship: 2,
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
},
|
||||
],
|
||||
});
|
||||
devices.coordinator.routingTable.mockResolvedValueOnce({
|
||||
table: [{destinationAddress: devices.CC2530_ROUTER.networkAddress, status: "ACTIVE", nextHop: devices.bulb.networkAddress}],
|
||||
});
|
||||
devices.bulb.lqi.mockResolvedValueOnce({
|
||||
neighbors: [
|
||||
{
|
||||
ieeeAddr: devices.bulb_color.ieeeAddr,
|
||||
networkAddress: devices.bulb_color.networkAddress,
|
||||
relationship: 1,
|
||||
depth: 2,
|
||||
linkquality: 110,
|
||||
},
|
||||
{
|
||||
ieeeAddr: devices.CC2530_ROUTER.ieeeAddr,
|
||||
networkAddress: devices.CC2530_ROUTER.networkAddress,
|
||||
relationship: 1,
|
||||
depth: 2,
|
||||
linkquality: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
devices.CC2530_ROUTER.lqi.mockResolvedValueOnce({
|
||||
neighbors: [
|
||||
{ieeeAddr: "0x0000000000000000", networkAddress: devices.WXKG02LM_rev1.networkAddress, relationship: 1, depth: 2, linkquality: 130},
|
||||
{
|
||||
ieeeAddr: devices.bulb_color.ieeeAddr,
|
||||
networkAddress: devices.bulb_color.networkAddress,
|
||||
relationship: 4,
|
||||
depth: 2,
|
||||
linkquality: 130,
|
||||
},
|
||||
],
|
||||
});
|
||||
devices.coordinator.lqi.mockResolvedValueOnce([
|
||||
{
|
||||
eui64: devices.bulb_color.ieeeAddr as Eui64,
|
||||
nwkAddress: devices.bulb_color.networkAddress,
|
||||
relationship: 2,
|
||||
depth: 1,
|
||||
lqi: 120,
|
||||
...LQI_BASE_RSP,
|
||||
},
|
||||
{eui64: devices.bulb.ieeeAddr as Eui64, nwkAddress: devices.bulb.networkAddress, relationship: 2, depth: 1, lqi: 92, ...LQI_BASE_RSP},
|
||||
{
|
||||
eui64: devices.external_converter_device.ieeeAddr as Eui64,
|
||||
nwkAddress: devices.external_converter_device.networkAddress,
|
||||
relationship: 2,
|
||||
depth: 1,
|
||||
lqi: 92,
|
||||
...LQI_BASE_RSP,
|
||||
},
|
||||
]);
|
||||
devices.coordinator.routingTable.mockResolvedValueOnce([
|
||||
{
|
||||
destinationAddress: devices.CC2530_ROUTER.networkAddress,
|
||||
status: "ACTIVE",
|
||||
nextHopAddress: devices.bulb.networkAddress,
|
||||
...ROUTING_BASE_RSP,
|
||||
},
|
||||
]);
|
||||
devices.bulb.lqi.mockResolvedValueOnce([
|
||||
{
|
||||
eui64: devices.bulb_color.ieeeAddr as Eui64,
|
||||
nwkAddress: devices.bulb_color.networkAddress,
|
||||
relationship: 1,
|
||||
depth: 2,
|
||||
lqi: 110,
|
||||
...LQI_BASE_RSP,
|
||||
},
|
||||
{
|
||||
eui64: devices.CC2530_ROUTER.ieeeAddr as Eui64,
|
||||
nwkAddress: devices.CC2530_ROUTER.networkAddress,
|
||||
relationship: 1,
|
||||
depth: 2,
|
||||
lqi: 100,
|
||||
...LQI_BASE_RSP,
|
||||
},
|
||||
]);
|
||||
devices.CC2530_ROUTER.lqi.mockResolvedValueOnce([
|
||||
{eui64: "0x0000000000000000", nwkAddress: devices.WXKG02LM_rev1.networkAddress, relationship: 1, depth: 2, lqi: 130, ...LQI_BASE_RSP},
|
||||
{
|
||||
eui64: devices.bulb_color.ieeeAddr as Eui64,
|
||||
nwkAddress: devices.bulb_color.networkAddress,
|
||||
relationship: 4,
|
||||
depth: 2,
|
||||
lqi: 130,
|
||||
...LQI_BASE_RSP,
|
||||
},
|
||||
]);
|
||||
devices.unsupported_router.lqi.mockRejectedValueOnce("failed").mockRejectedValueOnce("failed");
|
||||
devices.unsupported_router.routingTable.mockRejectedValueOnce("failed").mockRejectedValueOnce("failed");
|
||||
};
|
||||
@@ -143,6 +164,9 @@ describe("Extension: NetworkMap", () => {
|
||||
value: {
|
||||
links: [
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 120,
|
||||
lqi: 120,
|
||||
@@ -155,11 +179,14 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
lqi: 92,
|
||||
relationship: 2,
|
||||
routes: [{destinationAddress: 6540, nextHop: 40369, status: "ACTIVE"}],
|
||||
routes: [{destinationAddress: 6540, nextHopAddress: 40369, status: "ACTIVE", ...ROUTING_BASE_RSP}],
|
||||
source: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
|
||||
sourceIeeeAddr: "0x000b57fffec6a5b2",
|
||||
sourceNwkAddr: 40369,
|
||||
@@ -167,6 +194,9 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
lqi: 92,
|
||||
@@ -179,6 +209,9 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 2,
|
||||
linkquality: 110,
|
||||
lqi: 110,
|
||||
@@ -191,6 +224,9 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x000b57fffec6a5b2",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 2,
|
||||
linkquality: 100,
|
||||
lqi: 100,
|
||||
@@ -203,6 +239,9 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x000b57fffec6a5b2",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 2,
|
||||
linkquality: 130,
|
||||
lqi: 130,
|
||||
@@ -328,23 +367,34 @@ describe("Extension: NetworkMap", () => {
|
||||
|
||||
it("Output graphviz networkmap", async () => {
|
||||
mock();
|
||||
const device = devices.bulb_color;
|
||||
device.lastSeen = undefined;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const data = {modelID: "test"};
|
||||
const payload = {data, cluster: "genOnOff", device, endpoint, type: "readResponse", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/networkmap", stringify({type: "graphviz", routes: true}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/response/networkmap");
|
||||
const z2mDevice = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
|
||||
const originalDef = z2mDevice.definition;
|
||||
z2mDevice.definition = {
|
||||
...originalDef,
|
||||
description: `${originalDef.description} 6"`,
|
||||
};
|
||||
|
||||
try {
|
||||
const device = devices.bulb_color;
|
||||
device.lastSeen = undefined;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const data = {modelID: "test"};
|
||||
const payload = {data, cluster: "genOnOff", device, endpoint, type: "readResponse", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/networkmap", stringify({type: "graphviz", routes: true}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/response/networkmap");
|
||||
} finally {
|
||||
z2mDevice.definition = originalDef;
|
||||
}
|
||||
|
||||
const expected = `digraph G {
|
||||
node[shape=record];
|
||||
"0x00124b00120144ae" [style="bold, filled", fillcolor="#e04e5d", fontcolor="#ffffff", label="{Coordinator|0x00124b00120144ae (0x0000)|0 seconds ago}"];
|
||||
"0x000b57fffec6a5b2" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb|0x000b57fffec6a5b2 (0x9db1)|IKEA TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm (LED1545G12)|9 seconds ago}"];
|
||||
"0x000b57fffec6a5b2" -> "0x00124b00120144ae" [penwidth=2, weight=1, color="#009900", label="92 (routes: 0x198c)"]
|
||||
"0x000b57fffec6a5b3" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb_color|0x000b57fffec6a5b3 (0x9dcf)|Philips Hue Go (7146060PH)|unknown}"];
|
||||
"0x000b57fffec6a5b3" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb_color|0x000b57fffec6a5b3 (0x9dcf)|Philips Hue Go 6\\" (7146060PH)|unknown}"];
|
||||
"0x000b57fffec6a5b3" -> "0x00124b00120144ae" [penwidth=0.5, weight=0, color="#994444", label="120"]
|
||||
"0x000b57fffec6a5b3" -> "0x000b57fffec6a5b2" [penwidth=0.5, weight=0, color="#994444", label="110"]
|
||||
"0x0017880104e45521" [style="rounded, dashed, filled", fillcolor="#fff8ce", fontcolor="#000000", label="{button_double_key|0x0017880104e45521 (0x198a)|Aqara Wireless remote switch (double rocker), 2016 model (WXKG02LM_rev1)|9 seconds ago}"];
|
||||
@@ -492,6 +542,9 @@ describe("Extension: NetworkMap", () => {
|
||||
value: {
|
||||
links: [
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 120,
|
||||
lqi: 120,
|
||||
@@ -504,11 +557,14 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
lqi: 92,
|
||||
relationship: 2,
|
||||
routes: [{destinationAddress: 6540, nextHop: 40369, status: "ACTIVE"}],
|
||||
routes: [{destinationAddress: 6540, nextHopAddress: 40369, status: "ACTIVE", ...ROUTING_BASE_RSP}],
|
||||
source: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
|
||||
sourceIeeeAddr: "0x000b57fffec6a5b2",
|
||||
sourceNwkAddr: 40369,
|
||||
@@ -516,6 +572,9 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
lqi: 92,
|
||||
@@ -528,6 +587,9 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 2,
|
||||
linkquality: 130,
|
||||
lqi: 130,
|
||||
@@ -649,6 +711,9 @@ describe("Extension: NetworkMap", () => {
|
||||
value: {
|
||||
links: [
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 120,
|
||||
lqi: 120,
|
||||
@@ -661,11 +726,14 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
lqi: 92,
|
||||
relationship: 2,
|
||||
routes: [{destinationAddress: 6540, nextHop: 40369, status: "ACTIVE"}],
|
||||
routes: [{destinationAddress: 6540, nextHopAddress: 40369, status: "ACTIVE", ...ROUTING_BASE_RSP}],
|
||||
source: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
|
||||
sourceIeeeAddr: "0x000b57fffec6a5b2",
|
||||
sourceNwkAddr: 40369,
|
||||
@@ -673,6 +741,9 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
lqi: 92,
|
||||
@@ -685,6 +756,9 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 2,
|
||||
linkquality: 130,
|
||||
lqi: 130,
|
||||
|
||||
+833
-236
File diff suppressed because it is too large
Load Diff
+411
-71
@@ -73,7 +73,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness: "200"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({brightness: 200, state: "ON"});
|
||||
@@ -119,7 +124,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness_percent: "92"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 235, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 235, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON", brightness: 235});
|
||||
@@ -132,7 +142,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness: 230}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 230, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 230, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON", brightness: 230});
|
||||
@@ -145,7 +160,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color_temp: "222"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColorTemp", {colortemp: 222, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColorTemp",
|
||||
{colortemp: 222, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color_mode: "color_temp", color_temp: 222});
|
||||
@@ -158,7 +178,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color_temp_percent: 100}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColorTemp", {colortemp: 500, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColorTemp",
|
||||
{colortemp: 500, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color_mode: "color_temp", color_temp: 500});
|
||||
@@ -223,7 +248,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/0x0017882194e45543/set", stringify({state_l2: "ON", brightness_l2: 50}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/QS-Zigbee-D02-TRIAC-2C-LN", stringify({brightness_l2: 50, state_l2: "ON"}), {
|
||||
retain: false,
|
||||
qos: 0,
|
||||
@@ -284,7 +314,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {x: 100, y: 50}}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 6553500, colory: 3276750, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color_mode: "xy", color: {x: 100, y: 50}});
|
||||
@@ -298,7 +333,12 @@ describe("Extension: Publish", () => {
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genOnOff", "on", {}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 6553500, colory: 3276750, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -313,8 +353,18 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {x: 100, y: 50}, brightness: 20}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 6553500, colory: 3276750, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -329,8 +379,18 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {x: 100, y: 50}, brightness: 20, state: "ON"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 6553500, colory: 3276750, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -345,7 +405,13 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {x: 100, y: 50}, brightness: 20, state: "OFF"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(1, "lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 6553500, colory: 3276750, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(2, "genOnOff", "off", {}, {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
@@ -361,7 +427,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {r: 100, g: 200, b: 10}}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 17806, colory: 43155, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 17806, colory: 43155, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color: {x: 0.2717, y: 0.6585}, color_mode: "xy"});
|
||||
@@ -373,7 +444,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {rgb: "100,200,10"}}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 17806, colory: 43155, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 17806, colory: 43155, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color: {x: 0.2717, y: 0.6585}, color_mode: "xy"});
|
||||
@@ -385,7 +461,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON", brightness: "50"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON", brightness: 50});
|
||||
@@ -410,7 +491,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({brightness_percent: 50}));
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(1);
|
||||
expect(group.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 128, transtime: 0}, {});
|
||||
expect(group.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 128, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2); // 'zigbee2mqtt/bulb_color' + below
|
||||
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/group_1");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({state: "ON", brightness: 128});
|
||||
@@ -449,7 +535,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({state: "ON", brightness: 50}));
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(1);
|
||||
expect(group.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {});
|
||||
expect(group.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2); // 'zigbee2mqtt/bulb_color' + below
|
||||
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/group_1");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({state: "ON", brightness: 50});
|
||||
@@ -475,7 +566,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({color: {x: 0.37, y: 0.28}}));
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(1);
|
||||
expect(group.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 24248, colory: 18350, transtime: 0}, {});
|
||||
expect(group.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 24248, colory: 18350, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2); // 'zigbee2mqtt/bulb_color' + below
|
||||
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/group_1");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({color: {x: 0.37, y: 0.28}, color_mode: "xy"});
|
||||
@@ -488,7 +584,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({color_temp: 100}));
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(1);
|
||||
expect(group.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColorTemp", {colortemp: 100, transtime: 0}, {});
|
||||
expect(group.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColorTemp",
|
||||
{colortemp: 100, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2); // 'zigbee2mqtt/bulb_color' + below
|
||||
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/group_1");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({color_temp: 100, color_mode: "color_temp"});
|
||||
@@ -515,7 +616,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness: "200"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
@@ -526,7 +632,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness: "200"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON"});
|
||||
});
|
||||
@@ -681,7 +792,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set/color", "#64C80A");
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 17806, colory: 43155, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 17806, colory: 43155, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color: {x: 0.2717, y: 0.6585}, color_mode: "xy"});
|
||||
@@ -798,7 +914,12 @@ describe("Extension: Publish", () => {
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genOnOff", "on", {}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColor", {colorx: 45940, colory: 19595, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 45940, colory: 19595, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should also use on/off cluster when controlling group with switch", async () => {
|
||||
@@ -809,7 +930,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/switch_group/set", stringify({state: "ON", brightness: 100}));
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(2);
|
||||
expect(group.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 100, transtime: 0}, {});
|
||||
expect(group.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 100, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(group.command).toHaveBeenCalledWith("genOnOff", "on", {}, {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/switch_group", stringify({state: "ON", brightness: 100}), {
|
||||
retain: false,
|
||||
@@ -835,7 +961,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({brightness: 100}));
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(1);
|
||||
expect(group.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 100, transtime: 200}, {});
|
||||
expect(group.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 100, transtime: 200, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2); // zigbee2mqtt/bulb_color + below
|
||||
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/group_1");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({state: "ON", brightness: 100});
|
||||
@@ -850,7 +981,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 200}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 200, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should use transition from device_options on brightness command", async () => {
|
||||
@@ -861,7 +997,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 200}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 200, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should turn bulb on with correct brightness when device is turned off twice and brightness is reported", async () => {
|
||||
@@ -909,10 +1050,34 @@ describe("Extension: Publish", () => {
|
||||
});
|
||||
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(4);
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(1, "genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(2, "genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(3, "genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(4, "genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 0, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 0, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should turn bulb on with full brightness when transition is used and no brightness is known", async () => {
|
||||
@@ -929,8 +1094,18 @@ describe("Extension: Publish", () => {
|
||||
qos: 0,
|
||||
});
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 5}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 254, transtime: 5}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 0, transtime: 5, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 254, transtime: 5, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Transition parameter should not influence brightness on state ON", async () => {
|
||||
@@ -944,9 +1119,19 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON", transition: 1}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genOnOff", "on", {}, {}]);
|
||||
expect(endpoint.command.mock.calls[2]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 10}, {}]);
|
||||
expect(endpoint.command.mock.calls[2]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 10, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should use transition when color temp", async () => {
|
||||
@@ -957,7 +1142,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 200, transtime: 200}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"moveToColorTemp",
|
||||
{colortemp: 200, transtime: 200, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should use transition only once when setting brightness and color temperature for TRADFRI", async () => {
|
||||
@@ -967,8 +1157,18 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 200, transtime: 200}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"moveToColorTemp",
|
||||
{colortemp: 200, transtime: 200, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should use transition only once when setting brightness and color temperature for group which contains TRADFRI", async () => {
|
||||
@@ -976,8 +1176,18 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/group_with_tradfri/set", stringify({state: "ON", transition: 60, brightness: 20, color_temp: 400}));
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(2);
|
||||
expect(group.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {}]);
|
||||
expect(group.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 400, transtime: 600}, {}]);
|
||||
expect(group.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(group.command.mock.calls[1]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"moveToColorTemp",
|
||||
{colortemp: 400, transtime: 600, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Message transition should overrule options transition", async () => {
|
||||
@@ -988,7 +1198,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 100}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 200, transtime: 100, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should set state with brightness before color", async () => {
|
||||
@@ -998,8 +1213,18 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 100, transtime: 30}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColor", {colorx: 45940, colory: 19595, transtime: 30}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 100, transtime: 30, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 45940, colory: 19595, transtime: 30, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should turn device off when brightness 0 is send", async () => {
|
||||
@@ -1012,9 +1237,24 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[2]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 0, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[2]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0]).toEqual([
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -1043,9 +1283,24 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 30}, {}]);
|
||||
expect(endpoint.command.mock.calls[2]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 0, transtime: 30, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[2]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0]).toEqual([
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -1074,7 +1329,7 @@ describe("Extension: Publish", () => {
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"enhancedMoveToHueAndSaturation",
|
||||
{enhancehue: 45510, saturation: 127, transtime: 0},
|
||||
{enhancehue: 45510, saturation: 127, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
@@ -1129,7 +1384,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 254, transtime: 30}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 254, transtime: 30, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should turn device on with on/off with transition when transition 0 is provided", async () => {
|
||||
@@ -1139,7 +1399,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 254, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 254, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should turn device off with onOff on off with transition", async () => {
|
||||
@@ -1149,7 +1414,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 10}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 0, transtime: 10, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "OFF"});
|
||||
@@ -1168,7 +1438,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "OFF", transition: 3}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 30}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 0, transtime: 30, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0]).toEqual([
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -1197,7 +1472,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON", transition: 3}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 30}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 200, transtime: 30, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync.mock.calls[2]).toEqual([
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -1219,7 +1499,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "OFF", transition: 3}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 30}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 0, transtime: 30, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0]).toEqual([
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -1231,7 +1516,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync.mock.calls[1]).toEqual([
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -1264,7 +1554,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 100, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"moveToColorTemp",
|
||||
{colortemp: 100, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON", color_temp: 100, color_mode: "color_temp"});
|
||||
@@ -1281,7 +1576,12 @@ describe("Extension: Publish", () => {
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genOnOff", "on", {}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 100, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"moveToColorTemp",
|
||||
{colortemp: 100, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -1300,7 +1600,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["lightingColorCtrl", "moveToColor", {colorx: 26869, colory: 16384, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 26869, colory: 16384, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color: {x: 0.41, y: 0.25}, state: "ON", color_mode: "xy"});
|
||||
@@ -1363,7 +1668,7 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness_move: -40}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "move", {movemode: 1, rate: 40}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "move", {movemode: 1, rate: 40, optionsMask: 0, optionsOverride: 0}, {});
|
||||
});
|
||||
|
||||
it("Should publish brightness_move down to zigbee devices", async () => {
|
||||
@@ -1371,7 +1676,7 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness_move: 30}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "move", {movemode: 0, rate: 30}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "move", {movemode: 0, rate: 30, optionsMask: 0, optionsOverride: 0}, {});
|
||||
});
|
||||
|
||||
it("HS2WD-E burglar warning", async () => {
|
||||
@@ -1442,7 +1747,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/roller_shutter/set", stringify({state: "OPEN"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 255, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 255, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/roller_shutter");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({position: 100});
|
||||
@@ -1476,7 +1786,12 @@ describe("Extension: Publish", () => {
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genOnOff", "on", {}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/GL-S-007ZS", stringify({state: "ON", brightness: 20}), {
|
||||
qos: 0,
|
||||
retain: false,
|
||||
@@ -1509,8 +1824,18 @@ describe("Extension: Publish", () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 10}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 10, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should restore brightness when its turned off without transition and is turned on with", async () => {
|
||||
@@ -1527,7 +1852,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON", brightness: 123}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 123, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 123, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "OFF"}));
|
||||
await flushPromises();
|
||||
@@ -1537,7 +1867,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON", transition: 1.0}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(4);
|
||||
expect(endpoint.command.mock.calls[3]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 123, transtime: 10}, {}]);
|
||||
expect(endpoint.command.mock.calls[3]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 123, transtime: 10, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Shouldnt use moveToLevelWithOnOff on turn on when no transition has been used as some devices do not turn on in that case", async () => {
|
||||
@@ -1556,7 +1891,12 @@ describe("Extension: Publish", () => {
|
||||
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(4);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genOnOff", "on", {}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 150, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 150, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[2]).toEqual(["genOnOff", "off", {}, {}]);
|
||||
expect(endpoint.command.mock.calls[3]).toEqual(["genOnOff", "on", {}, {}]);
|
||||
|
||||
@@ -1601,7 +1941,7 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/group_tradfri_remote/set", stringify({scene_recall: 1}));
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(1);
|
||||
expect(group.command).toHaveBeenCalledWith("genScenes", "recall", {groupid: 15071, sceneid: 1}, {});
|
||||
expect(group.command).toHaveBeenCalledWith("genScenes", "recall", {groupid: 15071, sceneid: 1, transitionTime: 0xffff}, {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(8);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
|
||||
@@ -12,6 +12,14 @@ import Transport from "winston-transport";
|
||||
import logger from "../lib/util/logger";
|
||||
import * as settings from "../lib/util/settings";
|
||||
|
||||
vi.mock("rimraf", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("rimraf")>();
|
||||
return {
|
||||
...actual,
|
||||
rimrafSync: vi.fn(actual.rimrafSync),
|
||||
};
|
||||
});
|
||||
|
||||
describe("Logger", () => {
|
||||
let consoleWriteSpy: MockInstance;
|
||||
const dir = tmp.dirSync();
|
||||
@@ -43,6 +51,7 @@ describe("Logger", () => {
|
||||
it("Create log directory", () => {
|
||||
const dirs = fs.readdirSync(dir.name);
|
||||
expect(dirs.length).toBe(1);
|
||||
expect(dirs[0]).toMatch(/^[0-9]{4}-[0-9]{2}-[0-9]{2}\.[0-9]{2}-[0-9]{2}-[0-9]{2}$/);
|
||||
});
|
||||
|
||||
it("Should cleanup (default setting)", () => {
|
||||
@@ -59,6 +68,24 @@ describe("Logger", () => {
|
||||
expect(fs.readdirSync(dir.name).length).toBe(10);
|
||||
});
|
||||
|
||||
it("Should handle cleanup error", () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
fs.mkdirSync(path.join(dir.name, `log_${i}`));
|
||||
}
|
||||
|
||||
vi.mocked(rimrafSync).mockImplementationOnce(() => {
|
||||
throw new Error("EACCES: permission denied");
|
||||
});
|
||||
|
||||
const errorSpy = vi.spyOn(logger, "error");
|
||||
logger.init();
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/Failed to remove old log directory '.*': Error: EACCES: permission denied/));
|
||||
});
|
||||
|
||||
it("Should cleanup (15 folders setting)", () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
|
||||
+10
-4
@@ -1,9 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import tmp from "tmp";
|
||||
|
||||
import {vi} from "vitest";
|
||||
import yaml from "../../lib/util/yaml";
|
||||
|
||||
export const mockDir: string = tmp.dirSync().name;
|
||||
@@ -26,6 +25,9 @@ export const DEFAULT_CONFIGURATION = {
|
||||
"0x18fc2600000d7ae2": {
|
||||
friendly_name: "bosch_radiator",
|
||||
},
|
||||
"0x18fc2600000d7ae3": {
|
||||
friendly_name: "bosch_rm230z",
|
||||
},
|
||||
"0x000b57fffec6a5b2": {
|
||||
retain: true,
|
||||
friendly_name: "bulb",
|
||||
@@ -275,6 +277,10 @@ export function stateExists(): boolean {
|
||||
return fs.existsSync(stateFile);
|
||||
}
|
||||
|
||||
export function readState(): Record<string, unknown> {
|
||||
return JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
||||
}
|
||||
|
||||
const defaultState = {
|
||||
"0x000b57fffec6a5b2": {
|
||||
state: "ON",
|
||||
@@ -294,8 +300,8 @@ export function getDefaultState(): typeof defaultState {
|
||||
return defaultState;
|
||||
}
|
||||
|
||||
export function writeDefaultState(): void {
|
||||
fs.writeFileSync(path.join(mockDir, "state.json"), stringify(defaultState));
|
||||
export function writeDefaultState(data: Record<string, unknown> = defaultState): void {
|
||||
fs.writeFileSync(stateFile, stringify(data));
|
||||
}
|
||||
|
||||
export function writeEmptyDatabase(): void {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {vi} from "vitest";
|
||||
import type Transport from "winston-transport";
|
||||
|
||||
import type {LogLevel} from "../../lib/util/settings";
|
||||
|
||||
let level: LogLevel = "info";
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import type {IClientPublishOptions} from "mqtt";
|
||||
|
||||
import {vi} from "vitest";
|
||||
import type {EventHandler} from "./utils";
|
||||
|
||||
export const events: Record<string, EventHandler> = {};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import assert from "node:assert";
|
||||
import type {Mock} from "vitest";
|
||||
import {type Mock, vi} from "vitest";
|
||||
import type {AdapterTypes} from "zigbee-herdsman";
|
||||
|
||||
import {Zcl} from "zigbee-herdsman";
|
||||
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
|
||||
import type {OtaDataSettings, OtaExtraMetas, OtaSource, OtaUpdateAvailableResult} from "zigbee-herdsman/dist/controller/tstype";
|
||||
import type {TClusterCommandPayload} from "zigbee-herdsman/dist/zspec/zcl/definition/clusters-types";
|
||||
import type {BindingTableEntry, LQITableEntry, RoutingTableEntry} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
|
||||
import {DEFAULT_BIND_GROUP_ID} from "../../lib/util/utils";
|
||||
import type {EventHandler} from "./utils";
|
||||
|
||||
@@ -104,6 +106,7 @@ export class Endpoint {
|
||||
unbind: Mock;
|
||||
save: Mock;
|
||||
configureReporting: Mock;
|
||||
readReportingConfig: Mock;
|
||||
meta: Record<string, unknown>;
|
||||
binds: ZHBind[];
|
||||
profileID: number | undefined;
|
||||
@@ -138,6 +141,7 @@ export class Endpoint {
|
||||
this.unbind = vi.fn();
|
||||
this.save = vi.fn();
|
||||
this.configureReporting = vi.fn();
|
||||
this.readReportingConfig = vi.fn();
|
||||
this.meta = meta;
|
||||
this.binds = binds;
|
||||
this.profileID = profileID;
|
||||
@@ -218,6 +222,7 @@ export class Endpoint {
|
||||
this.unbind.mockClear();
|
||||
this.save.mockClear();
|
||||
this.configureReporting.mockClear();
|
||||
this.readReportingConfig.mockClear();
|
||||
this.addToGroup.mockClear();
|
||||
this.removeFromGroup.mockClear();
|
||||
this.getClusterAttributeValue.mockClear();
|
||||
@@ -247,8 +252,38 @@ export class Device {
|
||||
lastSeen: number | undefined;
|
||||
isDeleted: boolean;
|
||||
linkquality?: number;
|
||||
lqi: Mock;
|
||||
routingTable: Mock;
|
||||
lqi: Mock<() => Promise<LQITableEntry[]>>;
|
||||
routingTable: Mock<() => Promise<RoutingTableEntry[]>>;
|
||||
bindingTable: Mock<() => Promise<BindingTableEntry[]>>;
|
||||
clearAllBindings: Mock<() => Promise<void>>;
|
||||
checkOta =
|
||||
vi.fn<
|
||||
(
|
||||
source: OtaSource,
|
||||
current: TClusterCommandPayload<"genOta", "queryNextImageRequest"> | undefined,
|
||||
extraMetas: OtaExtraMetas,
|
||||
endpoint: Endpoint,
|
||||
) => Promise<OtaUpdateAvailableResult>
|
||||
>();
|
||||
updateOta =
|
||||
vi.fn<
|
||||
(
|
||||
source: Readonly<OtaSource> | undefined,
|
||||
requestPayload: TClusterCommandPayload<"genOta", "queryNextImageRequest"> | undefined,
|
||||
requestTsn: number | undefined,
|
||||
extraMetas: Readonly<OtaExtraMetas>,
|
||||
onProgress: (progress: number, remaining: number) => void,
|
||||
dataSettings: OtaDataSettings,
|
||||
endpoint: Endpoint,
|
||||
) => Promise<[from: OtaUpdateAvailableResult["current"], to: OtaUpdateAvailableResult["current"] | undefined]>
|
||||
>();
|
||||
scheduleOta = vi.fn((source) => {
|
||||
this.scheduledOta = source;
|
||||
});
|
||||
unscheduleOta = vi.fn(() => {
|
||||
this.scheduledOta = undefined;
|
||||
});
|
||||
scheduledOta: OtaSource | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
type: string,
|
||||
@@ -285,8 +320,10 @@ export class Device {
|
||||
this.manufacturerName = manufacturerName;
|
||||
this.lastSeen = 1000;
|
||||
this.isDeleted = false;
|
||||
this.lqi = vi.fn(() => ({neighbors: []}));
|
||||
this.routingTable = vi.fn(() => ({table: []}));
|
||||
this.lqi = vi.fn(() => Promise.resolve([] as LQITableEntry[]));
|
||||
this.routingTable = vi.fn(() => Promise.resolve([] as RoutingTableEntry[]));
|
||||
this.bindingTable = vi.fn(() => Promise.resolve([] as BindingTableEntry[]));
|
||||
this.clearAllBindings = vi.fn(() => {});
|
||||
}
|
||||
|
||||
getEndpoint(ID: number): Endpoint | undefined {
|
||||
@@ -302,7 +339,14 @@ export class Device {
|
||||
this.save.mockClear();
|
||||
this.lqi.mockClear();
|
||||
this.routingTable.mockClear();
|
||||
this.bindingTable.mockClear();
|
||||
this.clearAllBindings.mockClear();
|
||||
this.checkOta.mockClear();
|
||||
this.updateOta.mockClear();
|
||||
this.scheduleOta.mockClear();
|
||||
this.unscheduleOta.mockClear();
|
||||
this.meta = {};
|
||||
this.scheduledOta = undefined;
|
||||
|
||||
for (const ep of this.endpoints) {
|
||||
ep.mockClear();
|
||||
@@ -1128,18 +1172,33 @@ export const devices = {
|
||||
"DC Source",
|
||||
"FanBee1",
|
||||
),
|
||||
BTH_RM230Z: new Device(
|
||||
"Router",
|
||||
"0x18fc2600000d7ae3",
|
||||
35902,
|
||||
4617,
|
||||
[new Endpoint(1, [0, 3, 513, 516, 1029, 2821], [10, 25], "0x18fc2600000d7ae3")],
|
||||
InterviewState.Successful,
|
||||
"Mains (single phase)",
|
||||
"RBSH-RTH0-ZB-EU",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
CUSTOM_CLUSTER_BTHRA,
|
||||
),
|
||||
};
|
||||
|
||||
export const mockController = {
|
||||
on: (type: string, handler: EventHandler): void => {
|
||||
events[type] = handler;
|
||||
},
|
||||
start: vi.fn((): Promise<AdapterTypes.StartResult> => Promise.resolve("reset")),
|
||||
start: vi.fn(async (abortSignal?: AbortSignal): Promise<AdapterTypes.StartResult> => {
|
||||
abortSignal?.throwIfAborted();
|
||||
|
||||
return await Promise.resolve("reset");
|
||||
}),
|
||||
stop: vi.fn(),
|
||||
touchlinkIdentify: vi.fn(),
|
||||
touchlinkScan: vi.fn(),
|
||||
touchlinkFactoryReset: vi.fn(),
|
||||
touchlinkFactoryResetFirst: vi.fn(),
|
||||
touchlink: {identify: vi.fn(), scan: vi.fn(), factoryReset: vi.fn(), factoryResetFirst: vi.fn()},
|
||||
addInstallCode: vi.fn(),
|
||||
permitJoin: vi.fn(),
|
||||
getPermitJoin: vi.fn((): boolean => false),
|
||||
@@ -1195,6 +1254,9 @@ export const mockController = {
|
||||
groups[`group_${groupID}` as keyof typeof groups] = group;
|
||||
return group;
|
||||
}),
|
||||
sendRaw: vi.fn(async (/*rawPayload: RawPayload, customClusters: CustomClusters = {}*/) =>
|
||||
Promise.resolve([0x00, {nwkAddress: 0x1234, eui64: "", startIndex: 0, assocDevList: []}]),
|
||||
),
|
||||
};
|
||||
|
||||
vi.mock("zigbee-herdsman", async (importOriginal) => ({
|
||||
|
||||
@@ -293,7 +293,7 @@ describe("Onboarding", () => {
|
||||
}
|
||||
|
||||
const serverUrl = new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080");
|
||||
expect(mockHttpListen).toHaveBeenCalledWith(Number.parseInt(serverUrl.port), serverUrl.hostname, expect.any(Function));
|
||||
expect(mockHttpListen).toHaveBeenCalledWith(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, expect.any(Function));
|
||||
|
||||
return [resEnd.mock.calls[0][0], resEnd.mock.calls[1][0]];
|
||||
};
|
||||
@@ -351,7 +351,7 @@ describe("Onboarding", () => {
|
||||
expect(resEnd).toHaveBeenCalledTimes(2);
|
||||
|
||||
const serverUrl = new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080");
|
||||
expect(mockHttpListen).toHaveBeenCalledWith(Number.parseInt(serverUrl.port), serverUrl.hostname, expect.any(Function));
|
||||
expect(mockHttpListen).toHaveBeenCalledWith(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, expect.any(Function));
|
||||
|
||||
return resEnd.mock.calls[0][0];
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// biome-ignore assist/source/organizeImports: import mocks first
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from "vitest";
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
|
||||
import * as data from "./mocks/data";
|
||||
|
||||
import {existsSync, readFileSync, rmSync} from "node:fs";
|
||||
import {existsSync, readFileSync, rmSync, writeFileSync} from "node:fs";
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
import mockedData from "../lib/util/data";
|
||||
import * as settings from "../lib/util/settings";
|
||||
import * as settingsMigration from "../lib/util/settingsMigration";
|
||||
import path from "node:path";
|
||||
|
||||
describe("Settings Migration", () => {
|
||||
beforeAll(() => {});
|
||||
@@ -916,4 +917,79 @@ describe("Settings Migration", () => {
|
||||
expect(migrationNotesContent).toContain("[SPECIAL] Device icons are now saved as images.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Migrates v4 to v5", () => {
|
||||
const BASE_CONFIG = {
|
||||
version: 4,
|
||||
mqtt: {
|
||||
server: "mqtt://localhost",
|
||||
},
|
||||
};
|
||||
const DEFAULT_STATE: Record<string, unknown> = {
|
||||
"0x000b57fffec6a5b2": {
|
||||
state: "ON",
|
||||
brightness: 50,
|
||||
color_temp: 370,
|
||||
linkquality: 99,
|
||||
},
|
||||
"0x0017880104e45517": {
|
||||
brightness: 255,
|
||||
update: {state: "idle"},
|
||||
},
|
||||
1: {
|
||||
state: "ON",
|
||||
update: {state: "available", installed_version: 1, latest_version: 2},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
settings.testing.CURRENT_VERSION = 5; // stop update after this version
|
||||
data.writeDefaultConfiguration(BASE_CONFIG);
|
||||
data.writeDefaultState(DEFAULT_STATE);
|
||||
settings.reRead();
|
||||
});
|
||||
|
||||
it("Update", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 5;
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(beforeSettings);
|
||||
expect(data.readState()).toStrictEqual(DEFAULT_STATE);
|
||||
|
||||
settingsMigration.migrateIfNecessary();
|
||||
|
||||
const migratedSettings = settings.getPersistedSettings();
|
||||
expect(migratedSettings).toStrictEqual(afterSettings);
|
||||
|
||||
// @ts-expect-error workaround
|
||||
const migratedState = objectAssignDeep.noMutate({}, DEFAULT_STATE);
|
||||
delete (migratedState["0x0017880104e45517"] as Record<string, unknown>).update;
|
||||
delete (migratedState[1] as Record<string, unknown>).update;
|
||||
|
||||
expect(data.readState()).toStrictEqual(migratedState);
|
||||
});
|
||||
|
||||
it("handles errors gracefully", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error");
|
||||
writeFileSync(path.join(data.mockDir, "state.json"), "notjson", "utf8");
|
||||
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 5;
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(beforeSettings);
|
||||
|
||||
settingsMigration.migrateIfNecessary();
|
||||
|
||||
const migratedSettings = settings.getPersistedSettings();
|
||||
expect(migratedSettings).toStrictEqual(afterSettings);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to write state"));
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("is not valid JSON"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user