@switchbot/homebridge-switchbot 5.0.0-beta.98 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.changeset/config.json +14 -0
- package/.github/copilot-instructions.md +39 -0
- package/.github/workflows/ci.yml +4 -1
- package/.github/workflows/manual-e2e.yml +6 -3
- package/.github/workflows/release.yml +64 -15
- package/.github/workflows/stale.yml +2 -4
- package/.husky/pre-push +15 -0
- package/CHANGELOG.md +126 -134
- package/MIGRATION.md +16 -6
- package/README.md +84 -3
- package/TODO.md +263 -0
- package/config.schema.json +229 -36
- package/dist/SwitchBotHAPPlatform.d.ts +133 -0
- package/dist/SwitchBotHAPPlatform.d.ts.map +1 -0
- package/dist/SwitchBotHAPPlatform.js +555 -0
- package/dist/SwitchBotHAPPlatform.js.map +1 -0
- package/dist/SwitchBotMatterPlatform.d.ts +141 -0
- package/dist/SwitchBotMatterPlatform.d.ts.map +1 -0
- package/dist/SwitchBotMatterPlatform.js +536 -0
- package/dist/SwitchBotMatterPlatform.js.map +1 -0
- package/dist/device-types.d.ts +31 -0
- package/dist/device-types.d.ts.map +1 -0
- package/dist/device-types.js +246 -0
- package/dist/device-types.js.map +1 -0
- package/dist/deviceCommandMapper.d.ts +10 -0
- package/dist/deviceCommandMapper.d.ts.map +1 -0
- package/dist/deviceCommandMapper.js +319 -0
- package/dist/deviceCommandMapper.js.map +1 -0
- package/dist/deviceFactory.d.ts +3 -2
- package/dist/deviceFactory.d.ts.map +1 -1
- package/dist/deviceFactory.js +107 -29
- package/dist/deviceFactory.js.map +1 -1
- package/dist/devices/genericDevice.d.ts +59 -37
- package/dist/devices/genericDevice.d.ts.map +1 -1
- package/dist/devices/genericDevice.js +376 -78
- package/dist/devices/genericDevice.js.map +1 -1
- package/dist/errors.d.ts +38 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +32 -0
- package/dist/errors.js.map +1 -0
- package/dist/homebridge-ui/device-types.js +246 -0
- package/dist/homebridge-ui/device-types.js.map +1 -0
- package/dist/homebridge-ui/deviceCommandMapper.js +319 -0
- package/dist/homebridge-ui/deviceCommandMapper.js.map +1 -0
- package/dist/homebridge-ui/endpoints/config.d.ts +3 -0
- package/dist/homebridge-ui/endpoints/config.d.ts.map +1 -0
- package/dist/homebridge-ui/endpoints/config.js +90 -0
- package/dist/homebridge-ui/endpoints/config.js.map +1 -0
- package/dist/homebridge-ui/endpoints/devices.d.ts +6 -0
- package/dist/homebridge-ui/endpoints/devices.d.ts.map +1 -0
- package/dist/homebridge-ui/endpoints/devices.js +144 -0
- package/dist/homebridge-ui/endpoints/devices.js.map +1 -0
- package/dist/homebridge-ui/endpoints/discovery.d.ts +7 -0
- package/dist/homebridge-ui/endpoints/discovery.d.ts.map +1 -0
- package/dist/homebridge-ui/endpoints/discovery.js +219 -0
- package/dist/homebridge-ui/endpoints/discovery.js.map +1 -0
- package/dist/homebridge-ui/errors.js +32 -0
- package/dist/homebridge-ui/errors.js.map +1 -0
- package/dist/homebridge-ui/homebridge-ui/endpoints/config.js +90 -0
- package/dist/homebridge-ui/homebridge-ui/endpoints/config.js.map +1 -0
- package/dist/homebridge-ui/homebridge-ui/endpoints/devices.js +144 -0
- package/dist/homebridge-ui/homebridge-ui/endpoints/devices.js.map +1 -0
- package/dist/homebridge-ui/homebridge-ui/endpoints/discovery.js +219 -0
- package/dist/homebridge-ui/homebridge-ui/endpoints/discovery.js.map +1 -0
- package/dist/homebridge-ui/homebridge-ui/server.js +11 -0
- package/dist/homebridge-ui/homebridge-ui/server.js.map +1 -0
- package/dist/homebridge-ui/homebridge-ui/utils/config-parser.js +108 -0
- package/dist/homebridge-ui/homebridge-ui/utils/config-parser.js.map +1 -0
- package/dist/homebridge-ui/homebridge-ui/utils/device-migration.js +111 -0
- package/dist/homebridge-ui/homebridge-ui/utils/device-migration.js.map +1 -0
- package/dist/homebridge-ui/homebridge-ui/utils/logger.js +17 -0
- package/dist/homebridge-ui/homebridge-ui/utils/logger.js.map +1 -0
- package/dist/homebridge-ui/public/css/styles.css +483 -0
- package/dist/homebridge-ui/public/index.html +197 -621
- package/dist/homebridge-ui/public/js/advanced-settings.d.ts +3 -0
- package/dist/homebridge-ui/public/js/advanced-settings.d.ts.map +1 -0
- package/dist/homebridge-ui/public/js/advanced-settings.js +95 -0
- package/dist/homebridge-ui/public/js/advanced-settings.js.map +1 -0
- package/dist/homebridge-ui/public/js/advanced-settings.ts +94 -0
- package/dist/homebridge-ui/public/js/api.d.ts +66 -0
- package/dist/homebridge-ui/public/js/api.d.ts.map +1 -0
- package/dist/homebridge-ui/public/js/api.js +295 -0
- package/dist/homebridge-ui/public/js/api.js.map +1 -0
- package/dist/homebridge-ui/public/js/api.ts +355 -0
- package/dist/homebridge-ui/public/js/app.d.ts +2 -0
- package/dist/homebridge-ui/public/js/app.d.ts.map +1 -0
- package/dist/homebridge-ui/public/js/app.js +3722 -0
- package/dist/homebridge-ui/public/js/app.js.map +7 -0
- package/dist/homebridge-ui/public/js/app.ts +22 -0
- package/dist/homebridge-ui/public/js/constants.d.ts +2 -0
- package/dist/homebridge-ui/public/js/constants.d.ts.map +1 -0
- package/dist/homebridge-ui/public/js/constants.js +2 -0
- package/dist/homebridge-ui/public/js/constants.js.map +1 -0
- package/dist/homebridge-ui/public/js/constants.ts +1 -0
- package/dist/homebridge-ui/public/js/credentials.d.ts +3 -0
- package/dist/homebridge-ui/public/js/credentials.d.ts.map +1 -0
- package/dist/homebridge-ui/public/js/credentials.js +99 -0
- package/dist/homebridge-ui/public/js/credentials.js.map +1 -0
- package/dist/homebridge-ui/public/js/credentials.ts +105 -0
- package/dist/homebridge-ui/public/js/devices-delete.d.ts +3 -0
- package/dist/homebridge-ui/public/js/devices-delete.d.ts.map +1 -0
- package/dist/homebridge-ui/public/js/devices-delete.js +199 -0
- package/dist/homebridge-ui/public/js/devices-delete.js.map +1 -0
- package/dist/homebridge-ui/public/js/devices-delete.ts +227 -0
- package/dist/homebridge-ui/public/js/devices.d.ts +9 -0
- package/dist/homebridge-ui/public/js/devices.d.ts.map +1 -0
- package/dist/homebridge-ui/public/js/devices.js +98 -0
- package/dist/homebridge-ui/public/js/devices.js.map +1 -0
- package/dist/homebridge-ui/public/js/devices.ts +106 -0
- package/dist/homebridge-ui/public/js/discovery.d.ts +9 -0
- package/dist/homebridge-ui/public/js/discovery.d.ts.map +1 -0
- package/dist/homebridge-ui/public/js/discovery.js +1201 -0
- package/dist/homebridge-ui/public/js/discovery.js.map +1 -0
- package/dist/homebridge-ui/public/js/discovery.ts +1335 -0
- package/dist/homebridge-ui/public/js/logger.d.ts +7 -0
- package/dist/homebridge-ui/public/js/logger.d.ts.map +1 -0
- package/dist/homebridge-ui/public/js/logger.js +17 -0
- package/dist/homebridge-ui/public/js/logger.js.map +1 -0
- package/dist/homebridge-ui/public/js/logger.ts +17 -0
- package/dist/homebridge-ui/public/js/modal.d.ts +5 -0
- package/dist/homebridge-ui/public/js/modal.d.ts.map +1 -0
- package/dist/homebridge-ui/public/js/modal.js +35 -0
- package/dist/homebridge-ui/public/js/modal.js.map +1 -0
- package/dist/homebridge-ui/public/js/modal.ts +35 -0
- package/dist/homebridge-ui/public/js/modals.d.ts +15 -0
- package/dist/homebridge-ui/public/js/modals.d.ts.map +1 -0
- package/dist/homebridge-ui/public/js/modals.js +675 -0
- package/dist/homebridge-ui/public/js/modals.js.map +1 -0
- package/dist/homebridge-ui/public/js/modals.ts +765 -0
- package/dist/homebridge-ui/public/js/render.d.ts +71 -0
- package/dist/homebridge-ui/public/js/render.d.ts.map +1 -0
- package/dist/homebridge-ui/public/js/render.js +960 -0
- package/dist/homebridge-ui/public/js/render.js.map +1 -0
- package/dist/homebridge-ui/public/js/render.ts +1084 -0
- package/dist/homebridge-ui/public/js/toast.d.ts +6 -0
- package/dist/homebridge-ui/public/js/toast.d.ts.map +1 -0
- package/dist/homebridge-ui/public/js/toast.js +38 -0
- package/dist/homebridge-ui/public/js/toast.js.map +1 -0
- package/dist/homebridge-ui/public/js/toast.ts +44 -0
- package/dist/homebridge-ui/public/js/types.d.ts +23 -0
- package/dist/homebridge-ui/public/js/types.d.ts.map +1 -0
- package/dist/homebridge-ui/public/js/types.js +2 -0
- package/dist/homebridge-ui/public/js/types.js.map +1 -0
- package/dist/homebridge-ui/public/js/types.ts +26 -0
- package/dist/homebridge-ui/server.d.ts +1 -3
- package/dist/homebridge-ui/server.d.ts.map +1 -1
- package/dist/homebridge-ui/server.js +8 -450
- package/dist/homebridge-ui/server.js.map +1 -1
- package/dist/homebridge-ui/settings.js +8 -0
- package/dist/homebridge-ui/settings.js.map +1 -0
- package/dist/homebridge-ui/switchbotClient.js +247 -0
- package/dist/homebridge-ui/switchbotClient.js.map +1 -0
- package/dist/homebridge-ui/utils/config-parser.d.ts +39 -0
- package/dist/homebridge-ui/utils/config-parser.d.ts.map +1 -0
- package/dist/homebridge-ui/utils/config-parser.js +108 -0
- package/dist/homebridge-ui/utils/config-parser.js.map +1 -0
- package/dist/homebridge-ui/utils/device-migration.d.ts +35 -0
- package/dist/homebridge-ui/utils/device-migration.d.ts.map +1 -0
- package/dist/homebridge-ui/utils/device-migration.js +111 -0
- package/dist/homebridge-ui/utils/device-migration.js.map +1 -0
- package/dist/homebridge-ui/utils/logger.d.ts +7 -0
- package/dist/homebridge-ui/utils/logger.d.ts.map +1 -0
- package/dist/homebridge-ui/utils/logger.js +17 -0
- package/dist/homebridge-ui/utils/logger.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2
- package/dist/index.js.map +1 -1
- package/dist/settings.d.ts +1 -0
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +1 -0
- package/dist/settings.js.map +1 -1
- package/dist/switchbotClient.d.ts +12 -10
- package/dist/switchbotClient.d.ts.map +1 -1
- package/dist/switchbotClient.js +156 -103
- package/dist/switchbotClient.js.map +1 -1
- package/dist/utils.d.ts +76 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +1121 -4
- package/dist/utils.js.map +1 -1
- package/docs/assets/highlight.css +16 -2
- package/docs/assets/main.js +1 -1
- package/docs/index.html +82 -5
- package/docs/variables/default.html +3 -1
- package/eslint.config.js +9 -5
- package/nodemon.json +2 -2
- package/package.json +34 -21
- package/scripts/build-ui.js +37 -0
- package/scripts/free-dev-ports.mjs +105 -0
- package/scripts/generate-matter-maps.js +34 -17
- package/scripts/sync-device-types.mjs +31 -0
- package/src/SwitchBotHAPPlatform.ts +558 -0
- package/src/SwitchBotMatterPlatform.ts +538 -0
- package/src/device-types.js +246 -0
- package/src/device-types.js.map +1 -0
- package/src/device-types.ts +261 -0
- package/src/deviceCommandMapper.js +319 -0
- package/src/deviceCommandMapper.js.map +1 -0
- package/src/deviceCommandMapper.ts +333 -0
- package/src/deviceFactory.ts +125 -45
- package/src/devices/genericDevice.ts +411 -69
- package/src/errors.js +32 -0
- package/src/errors.js.map +1 -0
- package/src/errors.ts +35 -0
- package/src/homebridge-ui/endpoints/config.ts +110 -0
- package/src/homebridge-ui/endpoints/devices.ts +153 -0
- package/src/homebridge-ui/endpoints/discovery.ts +240 -0
- package/src/homebridge-ui/public/css/styles.css +483 -0
- package/src/homebridge-ui/public/index.html +197 -621
- package/src/homebridge-ui/public/js/advanced-settings.ts +94 -0
- package/src/homebridge-ui/public/js/api.ts +355 -0
- package/src/homebridge-ui/public/js/app.ts +22 -0
- package/src/homebridge-ui/public/js/constants.ts +1 -0
- package/src/homebridge-ui/public/js/credentials.ts +105 -0
- package/src/homebridge-ui/public/js/devices-delete.ts +227 -0
- package/src/homebridge-ui/public/js/devices.ts +106 -0
- package/src/homebridge-ui/public/js/discovery.ts +1335 -0
- package/src/homebridge-ui/public/js/logger.ts +17 -0
- package/src/homebridge-ui/public/js/modal.ts +35 -0
- package/src/homebridge-ui/public/js/modals.ts +765 -0
- package/src/homebridge-ui/public/js/render.ts +1084 -0
- package/src/homebridge-ui/public/js/toast.ts +44 -0
- package/src/homebridge-ui/public/js/types.ts +26 -0
- package/src/homebridge-ui/server.ts +9 -526
- package/src/homebridge-ui/utils/config-parser.ts +125 -0
- package/src/homebridge-ui/utils/device-migration.ts +144 -0
- package/src/homebridge-ui/utils/logger.ts +17 -0
- package/src/index.ts +12 -2
- package/src/settings.js +8 -0
- package/src/settings.js.map +1 -0
- package/src/settings.ts +2 -0
- package/src/switchbotClient.js +247 -0
- package/src/switchbotClient.js.map +1 -0
- package/src/switchbotClient.ts +177 -114
- package/src/utils.ts +1133 -5
- package/test/client/switchbot-client-debounce.spec.ts +35 -0
- package/test/client/switchbot-client-openapi.spec.ts +19 -0
- package/test/client/switchbotClient.spec.ts +64 -0
- package/test/device/device-mapping.spec.ts +23 -0
- package/test/device/deviceBase.spec.ts +26 -0
- package/test/device/deviceFactory-edge.spec.ts +15 -0
- package/test/device/deviceFactory.spec.ts +33 -0
- package/test/device/fan-swing.spec.ts +34 -0
- package/test/device/genericDevice-blepoll.spec.ts +47 -0
- package/test/device/irdevice.spec.ts +9 -0
- package/test/device/lock-users.spec.ts +35 -0
- package/test/device/matter-descriptors.spec.ts +22 -0
- package/test/device/matter-device-state.spec.ts +37 -0
- package/test/e2e/run-e2e.spec.ts +18 -19
- package/test/errors/errors.spec.ts +10 -0
- package/test/helpers/matter-harness.ts +20 -9
- package/test/homebridge-ui/server.spec.ts +9 -0
- package/test/platform/accessory-restore.spec.ts +37 -0
- package/test/platform/matter-childbridge.spec.ts +34 -0
- package/test/platform/matter-integration.spec.ts +33 -0
- package/test/platform/platform-edge.spec.ts +73 -0
- package/test/platform/platform.integration.spec.ts +34 -0
- package/test/utils/utils-extra.spec.ts +10 -0
- package/test/utils/utils.spec.ts +53 -0
- package/todo/TODO.md +80 -0
- package/tsconfig.ui.json +11 -0
- package/.github/npm-version-script-esm.js +0 -97
- package/.github/workflows/beta-release.yml +0 -52
- package/dist/platform.d.ts +0 -35
- package/dist/platform.d.ts.map +0 -1
- package/dist/platform.js +0 -850
- package/dist/platform.js.map +0 -1
- package/src/platform.ts +0 -867
- package/test/accessory-restore.spec.ts +0 -73
- package/test/device-mapping.spec.ts +0 -37
- package/test/deviceFactory.spec.ts +0 -18
- package/test/fan-swing.spec.ts +0 -29
- package/test/lock-users.spec.ts +0 -44
- package/test/matter-childbridge.spec.ts +0 -55
- package/test/matter-descriptors.spec.ts +0 -97
- package/test/matter-device-state.spec.ts +0 -101
- package/test/matter-integration.spec.ts +0 -70
- package/test/platform.integration.spec.ts +0 -55
- package/test/switchbot-client-debounce.spec.ts +0 -131
- package/test/switchbot-client-openapi.spec.ts +0 -56
- package/test/switchbotClient.spec.ts +0 -10
- package/test/utils.spec.ts +0 -20
|
@@ -1,14 +1,172 @@
|
|
|
1
|
+
// Utility: Validate BLE response length before parsing
|
|
2
|
+
/* eslint-disable style/max-statements-per-line, unused-imports/no-unused-vars */
|
|
3
|
+
import { Buffer } from 'node:buffer';
|
|
1
4
|
import { MATTER_ATTRIBUTE_IDS, MATTER_CLUSTER_IDS } from '../utils.js';
|
|
2
5
|
import { DeviceBase } from './deviceBase.js';
|
|
6
|
+
function validateBLEResponseLength(buf, expected, context = '', log) {
|
|
7
|
+
if (!buf || typeof buf.length !== 'number' || buf.length !== expected) {
|
|
8
|
+
log.warn(`[BLE] Invalid response length${context ? ` for ${context}` : ''}: expected ${expected}, got ${buf?.length}`);
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
// BLE notification handling: per-command notification futures and unsolicited notification logging
|
|
14
|
+
const BLE_NOTIFICATION_HANDLERS = new Map();
|
|
15
|
+
// Module-scope regex pattern to avoid recompilation
|
|
16
|
+
const HEX_COLOR_REGEX = /^#?[0-9A-F]{6}$/i;
|
|
3
17
|
export class GenericDevice extends DeviceBase {
|
|
18
|
+
log;
|
|
19
|
+
_blePollTimer = null;
|
|
20
|
+
_blePollIntervalMs;
|
|
21
|
+
_blePollingEnabled;
|
|
4
22
|
constructor(opts, cfg) {
|
|
5
23
|
super(opts, cfg);
|
|
24
|
+
// Require logger from opts or cfg
|
|
25
|
+
this.log = opts?.log || cfg?.log;
|
|
26
|
+
if (!this.log) {
|
|
27
|
+
throw new Error('Device requires a logger (Homebridge logger) in opts or cfg');
|
|
28
|
+
}
|
|
29
|
+
// If BLE encryptionKey/keyId provided, set on node-switchbot device instance if possible
|
|
30
|
+
if (opts.encryptionKey && this.client && typeof this.client.devices?.get === 'function') {
|
|
31
|
+
try {
|
|
32
|
+
const dev = this.client.devices.get(opts.id);
|
|
33
|
+
if (dev && typeof dev.setKey === 'function') {
|
|
34
|
+
dev.setKey({
|
|
35
|
+
encryptionKey: opts.encryptionKey,
|
|
36
|
+
keyId: opts.keyId || undefined,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
// ignore if device not found or setKey not available
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// BLE polling config: allow override via opts.blePollingEnabled/blePollIntervalMs or cfg.blePollingEnabled/blePollIntervalMs
|
|
45
|
+
this._blePollingEnabled = opts?.blePollingEnabled ?? cfg?.blePollingEnabled ?? true;
|
|
46
|
+
let pollMs = opts?.blePollIntervalMs ?? cfg?.blePollIntervalMs ?? 10 * 60 * 1000; // default: 10 min
|
|
47
|
+
if (typeof pollMs !== 'number' || Number.isNaN(pollMs) || pollMs < 60000) {
|
|
48
|
+
this.log.warn(`[BLE] Invalid blePollIntervalMs (${pollMs}), using minimum 60000ms`);
|
|
49
|
+
pollMs = 60000;
|
|
50
|
+
}
|
|
51
|
+
this._blePollIntervalMs = pollMs;
|
|
52
|
+
// Subscribe to BLE notifications if supported (node-switchbot v4+)
|
|
53
|
+
this._subscribeBLENotifications();
|
|
54
|
+
// Start BLE polling fallback if enabled
|
|
55
|
+
if (this._blePollingEnabled) {
|
|
56
|
+
this._startBlePolling();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Start periodic BLE polling as a fallback to notifications.
|
|
61
|
+
*/
|
|
62
|
+
_startBlePolling() {
|
|
63
|
+
if (this._blePollTimer) {
|
|
64
|
+
clearInterval(this._blePollTimer);
|
|
65
|
+
}
|
|
66
|
+
this._blePollTimer = setInterval(async () => {
|
|
67
|
+
try {
|
|
68
|
+
this.log.debug(`[BLE] Polling getState() for device ${this.opts.id}`);
|
|
69
|
+
await this.getState();
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
this.log.debug(`[BLE] Polling getState() failed for device ${this.opts.id}:`, e?.message);
|
|
73
|
+
}
|
|
74
|
+
}, this._blePollIntervalMs);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Clean up BLE polling timer on destroy.
|
|
78
|
+
*/
|
|
79
|
+
async destroy() {
|
|
80
|
+
if (this._blePollTimer) {
|
|
81
|
+
clearInterval(this._blePollTimer);
|
|
82
|
+
this._blePollTimer = null;
|
|
83
|
+
}
|
|
84
|
+
// Only call super.destroy if DeviceBase.prototype.destroy is a function and not this method itself
|
|
85
|
+
const baseProto = Object.getPrototypeOf(GenericDevice.prototype);
|
|
86
|
+
if (typeof baseProto.destroy === 'function' && baseProto.destroy !== GenericDevice.prototype.destroy) {
|
|
87
|
+
await super.destroy();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Subscribe to BLE notifications for this device (if supported by node-switchbot)
|
|
92
|
+
* Logs unsolicited notifications and enables per-command notification futures.
|
|
93
|
+
*/
|
|
94
|
+
async _subscribeBLENotifications() {
|
|
95
|
+
if (!this.client || typeof this.client.devices?.get !== 'function') {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const dev = this.client.devices.get(this.opts.id);
|
|
99
|
+
if (!dev || typeof dev.mac !== 'string' || !dev.mac) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Only subscribe once per device
|
|
103
|
+
if (BLE_NOTIFICATION_HANDLERS.has(dev.mac)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (typeof dev.subscribeNotifications === 'function') {
|
|
107
|
+
const handler = (payload) => {
|
|
108
|
+
// If a per-command notification future is waiting, let node-switchbot handle it
|
|
109
|
+
// Otherwise, log unsolicited notification
|
|
110
|
+
if (payload && payload.length > 0) {
|
|
111
|
+
// Unsolicited notification logging
|
|
112
|
+
// (node-switchbot will resolve per-command futures internally)
|
|
113
|
+
this.log.debug(`[BLE] Unsolicited notification from ${dev.mac}: ${payload.toString('hex')}`);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
try {
|
|
117
|
+
// Subscribe and remember handler for possible cleanup
|
|
118
|
+
await dev.subscribeNotifications(handler);
|
|
119
|
+
BLE_NOTIFICATION_HANDLERS.set(dev.mac, handler);
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
// ignore if subscription fails
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Await a BLE notification for this device (for advanced use in subclasses)
|
|
128
|
+
* Returns the notification payload or throws on timeout.
|
|
129
|
+
*/
|
|
130
|
+
async _awaitBLENotification(timeoutMs = 5000) {
|
|
131
|
+
if (!this.client || typeof this.client.devices?.get !== 'function') {
|
|
132
|
+
throw new Error('No BLE client/device');
|
|
133
|
+
}
|
|
134
|
+
const dev = this.client.devices.get(this.opts.id);
|
|
135
|
+
if (!dev || typeof dev.mac !== 'string' || !dev.mac) {
|
|
136
|
+
throw new Error('No BLE MAC for device');
|
|
137
|
+
}
|
|
138
|
+
if (typeof dev.bleConnection?.sendCommand !== 'function') {
|
|
139
|
+
throw new TypeError('BLE connection does not support sendCommand');
|
|
140
|
+
}
|
|
141
|
+
// This is a low-level utility; in most cases, node-switchbot handles notification futures for commands
|
|
142
|
+
// Here, we expose a direct await for advanced use
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
let timer;
|
|
145
|
+
const handler = (payload) => {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
dev.bleConnection?.unsubscribeNotifications(dev.mac, handler);
|
|
148
|
+
resolve(payload);
|
|
149
|
+
};
|
|
150
|
+
dev.bleConnection?.subscribeNotifications(dev.mac, handler).then(() => {
|
|
151
|
+
timer = setTimeout(() => {
|
|
152
|
+
dev.bleConnection?.unsubscribeNotifications(dev.mac, handler);
|
|
153
|
+
reject(new Error('BLE notification timeout'));
|
|
154
|
+
}, timeoutMs);
|
|
155
|
+
}).catch(reject);
|
|
156
|
+
});
|
|
6
157
|
}
|
|
7
158
|
async getState() {
|
|
8
159
|
// Default: return minimal info; implementations should override
|
|
9
160
|
if (this.client && typeof this.client.getDevice === 'function') {
|
|
10
161
|
try {
|
|
11
162
|
const raw = await this.client.getDevice(this.opts.id);
|
|
163
|
+
// If this is a BLE buffer/array, validate length (common BLE status: 12 bytes, but may vary by device)
|
|
164
|
+
if (raw && (raw instanceof Buffer || Array.isArray(raw) || raw instanceof Uint8Array)) {
|
|
165
|
+
// Default to 12, override per device if needed
|
|
166
|
+
if (!validateBLEResponseLength(raw, 12, this.opts.type, this.log)) {
|
|
167
|
+
return { id: this.opts.id, type: this.opts.type, error: 'invalid_ble_response_length', raw };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
12
170
|
// Normalize common response shapes
|
|
13
171
|
try {
|
|
14
172
|
const device = raw?.body ?? raw;
|
|
@@ -128,67 +286,150 @@ export class GenericDevice extends DeviceBase {
|
|
|
128
286
|
// platform can construct a Matter accessory representation when
|
|
129
287
|
// Homebridge Matter APIs are available. Device subclasses may override
|
|
130
288
|
// this to provide Matter-specific clusters/attributes if desired.
|
|
131
|
-
createMatterAccessory(api) {
|
|
132
|
-
|
|
289
|
+
async createMatterAccessory(api) {
|
|
290
|
+
// Dynamically detect features from getState()
|
|
291
|
+
const state = await this.getState();
|
|
133
292
|
const clusters = [];
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
293
|
+
// On/Off (Switch/Plug/Generic)
|
|
294
|
+
if ('on' in state || 'power' in state || 'state' in state) {
|
|
295
|
+
clusters.push({
|
|
296
|
+
type: 'OnOff',
|
|
297
|
+
clusterId: MATTER_CLUSTER_IDS.OnOff,
|
|
298
|
+
attributes: {
|
|
299
|
+
onOff: { read: async () => !!(await this.getState()).on || (await this.getState()).power === 'on' || (await this.getState()).state === 'on', write: async (v) => this.setState({ on: !!v }) },
|
|
300
|
+
[MATTER_ATTRIBUTE_IDS.OnOff.OnOff]: { read: async () => !!(await this.getState()).on || (await this.getState()).power === 'on' || (await this.getState()).state === 'on', write: async (v) => this.setState({ on: !!v }) },
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
// Brightness (Light)
|
|
305
|
+
if ('brightness' in state) {
|
|
306
|
+
clusters.push({
|
|
307
|
+
type: 'LevelControl',
|
|
308
|
+
clusterId: MATTER_CLUSTER_IDS.LevelControl,
|
|
309
|
+
attributes: {
|
|
310
|
+
currentLevel: { read: async () => (await this.getState()).brightness ?? 100, write: async (v) => this.setState({ brightness: Number(v) }) },
|
|
311
|
+
[MATTER_ATTRIBUTE_IDS.LevelControl.CurrentLevel]: { read: async () => (await this.getState()).brightness ?? 100, write: async (v) => this.setState({ brightness: Number(v) }) },
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
// Color (Light)
|
|
316
|
+
if ('hue' in state && 'saturation' in state) {
|
|
317
|
+
clusters.push({
|
|
318
|
+
type: 'ColorControl',
|
|
319
|
+
clusterId: MATTER_CLUSTER_IDS.ColorControl,
|
|
320
|
+
attributes: {
|
|
321
|
+
colorMode: { read: async () => 0 },
|
|
322
|
+
colorHue: { read: async () => (await this.getState()).hue ?? 0, write: async (v) => this.setState({ hue: Number(v) }) },
|
|
323
|
+
[MATTER_ATTRIBUTE_IDS.ColorControl.CurrentHue]: { read: async () => (await this.getState()).hue ?? 0, write: async (v) => this.setState({ hue: Number(v) }) },
|
|
324
|
+
colorSaturation: { read: async () => (await this.getState()).saturation ?? 0, write: async (v) => this.setState({ saturation: Number(v) }) },
|
|
325
|
+
[MATTER_ATTRIBUTE_IDS.ColorControl.CurrentSaturation]: { read: async () => (await this.getState()).saturation ?? 0, write: async (v) => this.setState({ saturation: Number(v) }) },
|
|
326
|
+
...(typeof state.colorTemperature === 'number' || typeof state.kelvin === 'number'
|
|
327
|
+
? {
|
|
328
|
+
colorTemperature: { read: async () => typeof (await this.getState()).colorTemperature === 'number' ? (await this.getState()).colorTemperature : Math.round(1000000 / ((await this.getState()).kelvin ?? 2500)), write: async (v) => this.setState({ colorTemperature: Number(v) }) },
|
|
329
|
+
[MATTER_ATTRIBUTE_IDS.ColorControl.ColorTemperatureMireds]: { read: async () => typeof (await this.getState()).colorTemperature === 'number' ? (await this.getState()).colorTemperature : Math.round(1000000 / ((await this.getState()).kelvin ?? 2500)), write: async (v) => this.setState({ colorTemperature: Number(v) }) },
|
|
330
|
+
}
|
|
331
|
+
: {}),
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
// Temperature sensor
|
|
336
|
+
if ('temperature' in state) {
|
|
337
|
+
clusters.push({
|
|
338
|
+
type: 'TemperatureMeasurement',
|
|
339
|
+
// No clusterId, not present in MATTER_CLUSTER_IDS
|
|
340
|
+
attributes: {
|
|
341
|
+
measuredValue: { read: async () => (await this.getState()).temperature ?? 0 },
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
// Humidity sensor
|
|
346
|
+
if ('humidity' in state) {
|
|
347
|
+
clusters.push({
|
|
348
|
+
type: 'RelativeHumidityMeasurement',
|
|
349
|
+
clusterId: MATTER_CLUSTER_IDS.RelativeHumidityMeasurement,
|
|
350
|
+
attributes: {
|
|
351
|
+
measuredValue: { read: async () => (await this.getState()).humidity ?? 0 },
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
// CO2 sensor
|
|
356
|
+
if ('CO2' in state) {
|
|
357
|
+
clusters.push({
|
|
358
|
+
type: 'AirQuality',
|
|
359
|
+
attributes: {
|
|
360
|
+
CO2: { read: async () => (await this.getState()).CO2 ?? 0 },
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
// Lock
|
|
365
|
+
if ('lockState' in state || 'locked' in state) {
|
|
366
|
+
clusters.push({
|
|
367
|
+
type: 'DoorLock',
|
|
368
|
+
clusterId: MATTER_CLUSTER_IDS.DoorLock,
|
|
369
|
+
attributes: {
|
|
370
|
+
lockState: { read: async () => (await this.getState()).lockState ?? (await this.getState()).locked ? 1 : 0, write: async (v) => this.setState({ locked: !!v }) },
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
// Motion sensor
|
|
375
|
+
if ('moveDetected' in state || 'motion' in state) {
|
|
376
|
+
clusters.push({
|
|
377
|
+
type: 'OccupancySensing',
|
|
378
|
+
// No clusterId, not present in MATTER_CLUSTER_IDS
|
|
379
|
+
attributes: {
|
|
380
|
+
occupancy: { read: async () => (await this.getState()).moveDetected === true || (await this.getState()).motion === true ? 1 : 0 },
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
// Contact sensor
|
|
385
|
+
if ('openState' in state || 'contact' in state || 'open' in state) {
|
|
386
|
+
clusters.push({
|
|
387
|
+
type: 'BooleanState',
|
|
388
|
+
// No clusterId, not present in MATTER_CLUSTER_IDS
|
|
389
|
+
attributes: {
|
|
390
|
+
stateValue: { read: async () => (await this.getState()).openState === 'open' || (await this.getState()).open === true ? 1 : 0 },
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
// Leak sensor
|
|
395
|
+
if ('leak' in state || 'status' in state) {
|
|
396
|
+
clusters.push({
|
|
397
|
+
type: 'LeakSensor',
|
|
398
|
+
attributes: {
|
|
399
|
+
leakDetected: { read: async () => (await this.getState()).leak === true || (await this.getState()).status === 1 ? 1 : 0 },
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
// Energy monitoring (Plug)
|
|
404
|
+
if ('voltage' in state || 'power' in state || 'electricCurrent' in state) {
|
|
405
|
+
clusters.push({
|
|
406
|
+
type: 'ElectricalMeasurement',
|
|
407
|
+
// No clusterId, not present in MATTER_CLUSTER_IDS
|
|
408
|
+
attributes: {
|
|
409
|
+
voltage: { read: async () => (await this.getState()).voltage ?? 0 },
|
|
410
|
+
power: { read: async () => (await this.getState()).power ?? 0 },
|
|
411
|
+
electricCurrent: { read: async () => (await this.getState()).electricCurrent ?? 0 },
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
// Fan
|
|
416
|
+
if ('speed' in state || 'fanSpeed' in state) {
|
|
417
|
+
clusters.push({
|
|
418
|
+
type: 'FanControl',
|
|
419
|
+
clusterId: MATTER_CLUSTER_IDS.FanControl,
|
|
420
|
+
attributes: {
|
|
421
|
+
speedCurrent: { read: async () => (await this.getState()).speed ?? (await this.getState()).fanSpeed ?? 0, write: async (v) => this.setState({ speed: Number(v) }) },
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
// Vacuum
|
|
426
|
+
if ('workingStatus' in state) {
|
|
427
|
+
clusters.push({
|
|
428
|
+
type: 'RobotVacuumCleaner',
|
|
429
|
+
attributes: {
|
|
430
|
+
workingStatus: { read: async () => (await this.getState()).workingStatus ?? 'StandBy' },
|
|
431
|
+
},
|
|
432
|
+
});
|
|
192
433
|
}
|
|
193
434
|
return {
|
|
194
435
|
id: this.opts.id,
|
|
@@ -228,25 +469,80 @@ export class CurtainDevice extends GenericDevice {
|
|
|
228
469
|
],
|
|
229
470
|
};
|
|
230
471
|
}
|
|
231
|
-
// Matter-specific descriptor for Curtain (
|
|
232
|
-
createMatterAccessory(api) {
|
|
472
|
+
// Matter-specific descriptor for Curtain (WindowCovering cluster) with new attributes
|
|
473
|
+
async createMatterAccessory(api) {
|
|
474
|
+
// Get current state for dynamic attributes
|
|
475
|
+
const state = await this.getState();
|
|
476
|
+
// Compose attributes for Matter WindowCovering cluster
|
|
477
|
+
const attributes = {
|
|
478
|
+
currentPositionLiftPercent100ths: {
|
|
479
|
+
read: async () => {
|
|
480
|
+
const s = await this.getState();
|
|
481
|
+
return typeof s.position === 'number' ? Math.round(s.position * 100) : 0;
|
|
482
|
+
},
|
|
483
|
+
write: undefined,
|
|
484
|
+
},
|
|
485
|
+
targetPositionLiftPercent100ths: {
|
|
486
|
+
read: async () => {
|
|
487
|
+
const s = await this.getState();
|
|
488
|
+
return typeof s.position === 'number' ? Math.round(s.position * 100) : 0;
|
|
489
|
+
},
|
|
490
|
+
write: async (v) => this.setState({ position: Math.round(Number(v) / 100) }),
|
|
491
|
+
},
|
|
492
|
+
operationalStatus: {
|
|
493
|
+
read: async () => state.operationalStatus ?? { global: 0, lift: 0, tilt: 0 },
|
|
494
|
+
write: undefined,
|
|
495
|
+
},
|
|
496
|
+
endProductType: {
|
|
497
|
+
read: async () => state.endProductType ?? 0,
|
|
498
|
+
write: undefined,
|
|
499
|
+
},
|
|
500
|
+
configStatus: {
|
|
501
|
+
read: async () => state.configStatus ?? {
|
|
502
|
+
operational: true,
|
|
503
|
+
onlineReserved: true,
|
|
504
|
+
liftMovementReversed: false,
|
|
505
|
+
liftPositionAware: true,
|
|
506
|
+
tiltPositionAware: false,
|
|
507
|
+
liftEncoderControlled: true,
|
|
508
|
+
tiltEncoderControlled: false,
|
|
509
|
+
},
|
|
510
|
+
write: undefined,
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
// If tilt is supported, add tilt attributes
|
|
514
|
+
if (typeof state.tilt === 'number') {
|
|
515
|
+
attributes.currentPositionTiltPercent100ths = {
|
|
516
|
+
read: async () => {
|
|
517
|
+
const s = await this.getState();
|
|
518
|
+
return typeof s.tilt === 'number' ? Math.round(s.tilt * 100) : 0;
|
|
519
|
+
},
|
|
520
|
+
write: undefined,
|
|
521
|
+
};
|
|
522
|
+
attributes.targetPositionTiltPercent100ths = {
|
|
523
|
+
read: async () => {
|
|
524
|
+
const s = await this.getState();
|
|
525
|
+
return typeof s.tilt === 'number' ? Math.round(s.tilt * 100) : 0;
|
|
526
|
+
},
|
|
527
|
+
write: async (v) => this.setState({ tilt: Math.round(Number(v) / 100) }),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
const windowCoveringCluster = {
|
|
531
|
+
type: 'WindowCovering',
|
|
532
|
+
clusterId: MATTER_CLUSTER_IDS.WindowCovering,
|
|
533
|
+
attributes,
|
|
534
|
+
};
|
|
535
|
+
// Provide both array and named property for clusters for compatibility with test expectations
|
|
536
|
+
const clustersArr = [windowCoveringCluster];
|
|
537
|
+
const clusters = [...clustersArr];
|
|
538
|
+
// Always set clusters.windowCovering to the WindowCovering cluster by clusterId
|
|
539
|
+
const foundWC = clustersArr.find((c) => c && c.clusterId === MATTER_CLUSTER_IDS.WindowCovering);
|
|
540
|
+
clusters.windowCovering = foundWC || null;
|
|
233
541
|
return {
|
|
234
542
|
id: this.opts.id,
|
|
235
543
|
name: this.opts.name ?? this.opts.type,
|
|
236
544
|
protocol: 'matter',
|
|
237
|
-
clusters
|
|
238
|
-
{
|
|
239
|
-
// Shade cluster
|
|
240
|
-
type: 'Shade',
|
|
241
|
-
clusterId: MATTER_CLUSTER_IDS.WindowCovering,
|
|
242
|
-
attributes: {
|
|
243
|
-
currentPosition: { read: async () => { const s = await this.getState(); return typeof s.position === 'number' ? s.position : 0; }, write: undefined },
|
|
244
|
-
[MATTER_ATTRIBUTE_IDS.WindowCovering.CurrentPosition]: { read: async () => { const s = await this.getState(); return typeof s.position === 'number' ? s.position : 0; }, write: undefined },
|
|
245
|
-
targetPosition: { read: async () => { const s = await this.getState(); return typeof s.position === 'number' ? s.position : 0; }, write: async (v) => this.setState({ position: Number(v) }) },
|
|
246
|
-
[MATTER_ATTRIBUTE_IDS.WindowCovering.TargetPosition]: { read: async () => { const s = await this.getState(); return typeof s.position === 'number' ? s.position : 0; }, write: async (v) => this.setState({ position: Number(v) }) },
|
|
247
|
-
},
|
|
248
|
-
},
|
|
249
|
-
],
|
|
545
|
+
clusters,
|
|
250
546
|
};
|
|
251
547
|
}
|
|
252
548
|
}
|
|
@@ -398,7 +694,7 @@ export class LightDevice extends GenericDevice {
|
|
|
398
694
|
}
|
|
399
695
|
// try HSV from color hex
|
|
400
696
|
const hex = s?.color || s?.colorHex || s?.colour;
|
|
401
|
-
if (typeof hex === 'string' &&
|
|
697
|
+
if (typeof hex === 'string' && HEX_COLOR_REGEX.test(hex)) {
|
|
402
698
|
const h = (() => {
|
|
403
699
|
const hsl = (h, s, l) => ({ h, s, l });
|
|
404
700
|
// convert hex -> rgb -> hsv
|
|
@@ -447,7 +743,7 @@ export class LightDevice extends GenericDevice {
|
|
|
447
743
|
}
|
|
448
744
|
// if color hex is available, derive saturation from rgb
|
|
449
745
|
const hex = s?.color || s?.colorHex || s?.colour;
|
|
450
|
-
if (typeof hex === 'string' &&
|
|
746
|
+
if (typeof hex === 'string' && HEX_COLOR_REGEX.test(hex)) {
|
|
451
747
|
const cleaned = hex.replace('#', '');
|
|
452
748
|
const r = Number.parseInt(cleaned.substr(0, 2), 16) / 255;
|
|
453
749
|
const g = Number.parseInt(cleaned.substr(2, 2), 16) / 255;
|
|
@@ -577,6 +873,8 @@ export class LightDevice extends GenericDevice {
|
|
|
577
873
|
type: 'ColorControl',
|
|
578
874
|
clusterId: MATTER_CLUSTER_IDS.ColorControl,
|
|
579
875
|
attributes: {
|
|
876
|
+
// Required colorMode attribute for Matter conformance (0 = currentHueAndSaturation)
|
|
877
|
+
colorMode: { read: async () => 0 },
|
|
580
878
|
colorHue: { read: async () => { const s = await this.getState(); return typeof s.hue === 'number' ? s.hue : 0; }, write: async (v) => this.setState({ hue: Number(v) }) },
|
|
581
879
|
[MATTER_ATTRIBUTE_IDS.ColorControl.CurrentHue]: { read: async () => { const s = await this.getState(); return typeof s.hue === 'number' ? s.hue : 0; }, write: async (v) => this.setState({ hue: Number(v) }) },
|
|
582
880
|
colorSaturation: { read: async () => { const s = await this.getState(); return typeof s.saturation === 'number' ? s.saturation : 0; }, write: async (v) => this.setState({ saturation: Number(v) }) },
|
|
@@ -911,7 +1209,7 @@ export class HumidifierDevice extends GenericDevice {
|
|
|
911
1209
|
}
|
|
912
1210
|
// Provide Matter descriptor for humidifier (humidity and on/off)
|
|
913
1211
|
export class HumidifierMatterDevice extends HumidifierDevice {
|
|
914
|
-
createMatterAccessory(api) {
|
|
1212
|
+
async createMatterAccessory(api) {
|
|
915
1213
|
return {
|
|
916
1214
|
id: this.opts.id,
|
|
917
1215
|
name: this.opts.name ?? this.opts.type,
|