@switchbot/homebridge-switchbot 5.0.0-beta.4 → 5.0.0-beta.41
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/CHANGELOG.md +13 -0
- package/README.md +23 -3
- package/config.schema.json +722 -13684
- package/dist/devices-hap/device.d.ts +18 -8
- package/dist/devices-hap/device.d.ts.map +1 -1
- package/dist/devices-hap/device.js +121 -68
- package/dist/devices-hap/device.js.map +1 -1
- package/dist/devices-matter/BaseMatterAccessory.d.ts +27 -0
- package/dist/devices-matter/BaseMatterAccessory.d.ts.map +1 -1
- package/dist/devices-matter/BaseMatterAccessory.js +169 -5
- package/dist/devices-matter/BaseMatterAccessory.js.map +1 -1
- package/dist/devices-matter/ColorLightAccessory.d.ts.map +1 -1
- package/dist/devices-matter/ColorLightAccessory.js +12 -12
- package/dist/devices-matter/ColorLightAccessory.js.map +1 -1
- package/dist/devices-matter/ColorTemperatureLightAccessory.d.ts.map +1 -1
- package/dist/devices-matter/ColorTemperatureLightAccessory.js +5 -7
- package/dist/devices-matter/ColorTemperatureLightAccessory.js.map +1 -1
- package/dist/devices-matter/DimmableLightAccessory.js +9 -9
- package/dist/devices-matter/DimmableLightAccessory.js.map +1 -1
- package/dist/devices-matter/ExtendedColorLightAccessory.d.ts.map +1 -1
- package/dist/devices-matter/ExtendedColorLightAccessory.js +14 -15
- package/dist/devices-matter/ExtendedColorLightAccessory.js.map +1 -1
- package/dist/devices-matter/OnOffLightAccessory.d.ts.map +1 -1
- package/dist/devices-matter/OnOffLightAccessory.js +8 -16
- package/dist/devices-matter/OnOffLightAccessory.js.map +1 -1
- package/dist/devices-matter/OnOffOutletAccessory.d.ts +2 -0
- package/dist/devices-matter/OnOffOutletAccessory.d.ts.map +1 -1
- package/dist/devices-matter/OnOffOutletAccessory.js +10 -7
- package/dist/devices-matter/OnOffOutletAccessory.js.map +1 -1
- package/dist/devices-matter/OnOffSwitchAccessory.js +2 -2
- package/dist/devices-matter/OnOffSwitchAccessory.js.map +1 -1
- package/dist/homebridge-ui/public/index.html +48 -1
- package/dist/homebridge-ui/server.js +66 -9
- package/dist/homebridge-ui/server.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -7
- package/dist/index.js.map +1 -1
- package/dist/irdevice/irdevice.d.ts +11 -10
- package/dist/irdevice/irdevice.d.ts.map +1 -1
- package/dist/irdevice/irdevice.js +76 -35
- package/dist/irdevice/irdevice.js.map +1 -1
- package/dist/platform-hap.d.ts +21 -15
- package/dist/platform-hap.d.ts.map +1 -1
- package/dist/platform-hap.js +246 -147
- package/dist/platform-hap.js.map +1 -1
- package/dist/platform-matter.d.ts +88 -6
- package/dist/platform-matter.d.ts.map +1 -1
- package/dist/platform-matter.js +1726 -243
- package/dist/platform-matter.js.map +1 -1
- package/dist/settings.d.ts +41 -6
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js.map +1 -1
- package/dist/test/apiRequestTracker.test.d.ts +2 -0
- package/dist/test/apiRequestTracker.test.d.ts.map +1 -0
- package/dist/test/apiRequestTracker.test.js +392 -0
- package/dist/test/apiRequestTracker.test.js.map +1 -0
- package/dist/test/hap/platform-hap.logging.test.d.ts +2 -0
- package/dist/test/hap/platform-hap.logging.test.d.ts.map +1 -0
- package/dist/test/hap/platform-hap.logging.test.js +33 -0
- package/dist/test/hap/platform-hap.logging.test.js.map +1 -0
- package/dist/test/hap/platform-hap.test.d.ts +2 -0
- package/dist/test/hap/platform-hap.test.d.ts.map +1 -0
- package/dist/test/hap/platform-hap.test.js +62 -0
- package/dist/test/hap/platform-hap.test.js.map +1 -0
- package/dist/test/helpers/platform-fixtures.d.ts +9 -0
- package/dist/test/helpers/platform-fixtures.d.ts.map +1 -0
- package/dist/test/helpers/platform-fixtures.js +30 -0
- package/dist/test/helpers/platform-fixtures.js.map +1 -0
- package/dist/test/homebridge-ui/server.test.d.ts +2 -0
- package/dist/test/homebridge-ui/server.test.d.ts.map +1 -0
- package/dist/test/homebridge-ui/server.test.js +445 -0
- package/dist/test/homebridge-ui/server.test.js.map +1 -0
- package/dist/{index.test.d.ts.map → test/index.test.d.ts.map} +1 -1
- package/dist/test/index.test.js +19 -0
- package/dist/test/index.test.js.map +1 -0
- package/dist/test/matter/devices-matter/baseMatterAccessory.test.d.ts +2 -0
- package/dist/test/matter/devices-matter/baseMatterAccessory.test.d.ts.map +1 -0
- package/dist/test/matter/devices-matter/baseMatterAccessory.test.js +71 -0
- package/dist/test/matter/devices-matter/baseMatterAccessory.test.js.map +1 -0
- package/dist/test/matter/platform-matter.additional.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.additional.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.additional.test.js +35 -0
- package/dist/test/matter/platform-matter.additional.test.js.map +1 -0
- package/dist/test/matter/platform-matter.bleparse.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.bleparse.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.bleparse.test.js +43 -0
- package/dist/test/matter/platform-matter.bleparse.test.js.map +1 -0
- package/dist/test/matter/platform-matter.cleanup.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.cleanup.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.cleanup.test.js +70 -0
- package/dist/test/matter/platform-matter.cleanup.test.js.map +1 -0
- package/dist/test/matter/platform-matter.keepstale.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.keepstale.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.keepstale.test.js +27 -0
- package/dist/test/matter/platform-matter.keepstale.test.js.map +1 -0
- package/dist/test/matter/platform-matter.logging.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.logging.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.logging.test.js +29 -0
- package/dist/test/matter/platform-matter.logging.test.js.map +1 -0
- package/dist/test/matter/platform-matter.mapping.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.mapping.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.mapping.test.js +43 -0
- package/dist/test/matter/platform-matter.mapping.test.js.map +1 -0
- package/dist/test/matter/platform-matter.openapi-mapping.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.openapi-mapping.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.openapi-mapping.test.js +84 -0
- package/dist/test/matter/platform-matter.openapi-mapping.test.js.map +1 -0
- package/dist/test/matter/platform-matter.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.test.js +117 -0
- package/dist/test/matter/platform-matter.test.js.map +1 -0
- package/dist/test/matter/platform-matter.unregister.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.unregister.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.unregister.test.js +30 -0
- package/dist/test/matter/platform-matter.unregister.test.js.map +1 -0
- package/dist/test/utils.test.d.ts +2 -0
- package/dist/test/utils.test.d.ts.map +1 -0
- package/dist/test/utils.test.js +95 -0
- package/dist/test/utils.test.js.map +1 -0
- package/dist/test/verifyconfig.test.d.ts.map +1 -0
- package/dist/{verifyconfig.test.js → test/verifyconfig.test.js} +2 -2
- package/dist/test/verifyconfig.test.js.map +1 -0
- package/dist/utils.d.ts +196 -3
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +656 -30
- package/dist/utils.js.map +1 -1
- package/docs/assets/main.js +2 -2
- package/docs/index.html +20 -2
- package/docs/variables/default.html +1 -1
- package/package.json +14 -14
- package/src/devices-hap/device.ts +129 -69
- package/src/devices-matter/BaseMatterAccessory.ts +176 -5
- package/src/devices-matter/ColorLightAccessory.ts +12 -12
- package/src/devices-matter/ColorTemperatureLightAccessory.ts +5 -7
- package/src/devices-matter/DimmableLightAccessory.ts +9 -9
- package/src/devices-matter/ExtendedColorLightAccessory.ts +14 -15
- package/src/devices-matter/OnOffLightAccessory.ts +8 -16
- package/src/devices-matter/OnOffOutletAccessory.ts +12 -7
- package/src/devices-matter/OnOffSwitchAccessory.ts +2 -2
- package/src/homebridge-ui/public/index.html +48 -1
- package/src/homebridge-ui/server.ts +69 -9
- package/src/index.ts +4 -7
- package/src/irdevice/irdevice.ts +74 -35
- package/src/platform-hap.ts +270 -160
- package/src/platform-matter.ts +1768 -240
- package/src/settings.ts +45 -2
- package/src/test/apiRequestTracker.test.ts +417 -0
- package/src/test/hap/platform-hap.logging.test.ts +36 -0
- package/src/test/hap/platform-hap.test.ts +70 -0
- package/src/test/helpers/platform-fixtures.ts +33 -0
- package/src/test/homebridge-ui/server.test.ts +486 -0
- package/src/test/index.test.ts +24 -0
- package/src/test/matter/devices-matter/baseMatterAccessory.test.ts +88 -0
- package/src/test/matter/platform-matter.additional.test.ts +44 -0
- package/src/test/matter/platform-matter.bleparse.test.ts +47 -0
- package/src/test/matter/platform-matter.cleanup.test.ts +86 -0
- package/src/test/matter/platform-matter.keepstale.test.ts +37 -0
- package/src/test/matter/platform-matter.logging.test.ts +33 -0
- package/src/test/matter/platform-matter.mapping.test.ts +57 -0
- package/src/test/matter/platform-matter.openapi-mapping.test.ts +109 -0
- package/src/test/matter/platform-matter.test.ts +144 -0
- package/src/test/matter/platform-matter.unregister.test.ts +39 -0
- package/src/test/utils.test.ts +96 -0
- package/src/{verifyconfig.test.ts → test/verifyconfig.test.ts} +12 -11
- package/src/utils.ts +714 -32
- package/dist/index.test.js +0 -14
- package/dist/index.test.js.map +0 -1
- package/dist/verifyconfig.test.d.ts.map +0 -1
- package/dist/verifyconfig.test.js.map +0 -1
- package/src/index.test.ts +0 -19
- /package/dist/{index.test.d.ts → test/index.test.d.ts} +0 -0
- /package/dist/{verifyconfig.test.d.ts → test/verifyconfig.test.d.ts} +0 -0
package/dist/platform-matter.js
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import asyncmqtt from 'async-mqtt';
|
|
1
4
|
import { SwitchBotBLE, SwitchBotOpenAPI } from 'node-switchbot';
|
|
2
5
|
import { ColorLightAccessory, ColorTemperatureLightAccessory, ContactSensorAccessory, DimmableLightAccessory, DoorLockAccessory, ExtendedColorLightAccessory, FanAccessory, HumiditySensorAccessory, LeakSensorAccessory, LightSensorAccessory, OccupancySensorAccessory, OnOffLightAccessory, OnOffOutletAccessory, OnOffSwitchAccessory, PowerStripAccessory, RoboticVacuumAccessory, SmokeCOAlarmAccessory, TemperatureSensorAccessory, ThermostatAccessory, VenetianBlindAccessory, WindowBlindAccessory, } from './devices-matter/index.js';
|
|
3
6
|
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
|
|
4
|
-
import {
|
|
5
|
-
/**
|
|
6
|
-
* MatterPlatform
|
|
7
|
-
* Demonstrates all available Matter device types in Homebridge
|
|
8
|
-
*
|
|
9
|
-
* Organized by official Matter Specification v1.4.1 categories
|
|
10
|
-
*/
|
|
7
|
+
import { ApiRequestTracker, applyDeviceTypeTemplates, createPlatformLogger, formatDeviceIdAsMac, hs2rgb, isSuccessfulStatusCode, makeBLESender, makeOpenAPISender, mergeByDeviceId, normalizeDeviceId, rgb2hs, sleep } from './utils.js';
|
|
11
8
|
export class SwitchBotMatterPlatform {
|
|
12
9
|
log;
|
|
13
10
|
config;
|
|
@@ -22,32 +19,80 @@ export class SwitchBotMatterPlatform {
|
|
|
22
19
|
switchBotBLE;
|
|
23
20
|
// discovered devices cache
|
|
24
21
|
discoveredDevices = [];
|
|
22
|
+
// discovered IR devices cache
|
|
23
|
+
discoveredIRDevices = [];
|
|
24
|
+
// Registry of created accessory instances keyed by normalized deviceId
|
|
25
|
+
accessoryInstances = new Map();
|
|
26
|
+
// Refresh timers keyed by normalized deviceId
|
|
27
|
+
refreshTimers = new Map();
|
|
28
|
+
// Platform-level refresh timer for batch status updates
|
|
29
|
+
platformRefreshTimer;
|
|
30
|
+
// Device status cache from last refresh
|
|
31
|
+
deviceStatusCache = new Map();
|
|
32
|
+
// Backoff cooldowns persisted across restarts: normalized deviceId -> nextAllowedAt(ms)
|
|
33
|
+
backoffCooldowns = new Map();
|
|
34
|
+
backoffFilePath;
|
|
35
|
+
// Devices that have explicit per-device refresh timers (normalized deviceId)
|
|
36
|
+
perDeviceRefreshSet = new Set();
|
|
25
37
|
// BLE event handlers keyed by device MAC (formatted)
|
|
26
38
|
bleEventHandler = {};
|
|
39
|
+
// MQTT and Webhook properties (mirror HAP behavior so Matter platform can
|
|
40
|
+
// receive OpenAPI webhook events and/or MQTT-proxied webhook messages)
|
|
41
|
+
mqttClient = null;
|
|
42
|
+
webhookEventListener = null;
|
|
43
|
+
webhookEventHandler = {};
|
|
44
|
+
// Platform logging toggle (can be controlled via UI or config)
|
|
45
|
+
// Use same shape as HAP platform: string values like 'debug', 'debugMode', 'standard', or 'none'
|
|
46
|
+
platformLogging;
|
|
47
|
+
// API request tracking (persistent across restarts)
|
|
48
|
+
apiTracker;
|
|
49
|
+
// Platform-provided logging helpers (attached in constructor)
|
|
50
|
+
infoLog;
|
|
51
|
+
successLog;
|
|
52
|
+
debugSuccessLog;
|
|
53
|
+
warnLog;
|
|
54
|
+
debugWarnLog;
|
|
55
|
+
errorLog;
|
|
56
|
+
debugErrorLog;
|
|
57
|
+
debugLog;
|
|
58
|
+
loggingIsDebug;
|
|
59
|
+
enablingPlatformLogging;
|
|
27
60
|
constructor(log, config, api) {
|
|
28
61
|
this.log = log;
|
|
29
62
|
this.config = config;
|
|
30
63
|
this.api = api;
|
|
31
|
-
|
|
32
|
-
//
|
|
64
|
+
// Determine platform logging preference (match HAP behaviour as closely as
|
|
65
|
+
// possible using config values. We default to 'standard' when unspecified.)
|
|
66
|
+
this.platformLogging = (this.config.options?.logging === 'debug' || this.config.options?.logging === 'standard' || this.config.options?.logging === 'none')
|
|
67
|
+
? this.config.options.logging
|
|
68
|
+
: 'standard';
|
|
69
|
+
// Unconditional diagnostic using the raw Homebridge `log` so it always
|
|
70
|
+
// appears regardless of the platform logging helpers' gating logic.
|
|
33
71
|
try {
|
|
34
|
-
|
|
35
|
-
const cleaned = cleanDeviceConfig(this.config.options.deviceConfig);
|
|
36
|
-
if (cleaned) {
|
|
37
|
-
;
|
|
38
|
-
this.config.options.deviceConfig = cleaned;
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
delete this.config.options.deviceConfig;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
72
|
+
this.log.debug?.(`[SwitchBot Matter] effective platformLogging=${String(this.platformLogging)}`);
|
|
44
73
|
}
|
|
45
74
|
catch (e) {
|
|
46
|
-
|
|
75
|
+
// swallow any logging errors — diagnostics are best-effort
|
|
47
76
|
}
|
|
77
|
+
// Attach platform-wide logging helpers from utils so Matter and device
|
|
78
|
+
// classes can use consistent logging methods (infoLog/debugLog/etc.)
|
|
79
|
+
const _pl = createPlatformLogger(async () => this.platformLogging, this.log);
|
|
80
|
+
this.infoLog = _pl.infoLog;
|
|
81
|
+
this.successLog = _pl.successLog;
|
|
82
|
+
this.debugSuccessLog = _pl.debugSuccessLog;
|
|
83
|
+
this.warnLog = _pl.warnLog;
|
|
84
|
+
this.debugWarnLog = _pl.debugWarnLog;
|
|
85
|
+
this.errorLog = _pl.errorLog;
|
|
86
|
+
this.debugErrorLog = _pl.debugErrorLog;
|
|
87
|
+
this.debugLog = _pl.debugLog;
|
|
88
|
+
this.loggingIsDebug = _pl.loggingIsDebug;
|
|
89
|
+
this.enablingPlatformLogging = _pl.enablingPlatformLogging;
|
|
90
|
+
this.debugLog('Finished initializing platform:', this.config.name);
|
|
91
|
+
// Note: deviceConfig and irdeviceConfig have been removed from the platform.
|
|
92
|
+
// All device-specific configuration should be done via options.devices arrays.
|
|
48
93
|
// Does the user have a version of Homebridge that is compatible with matter?
|
|
49
94
|
if (!this.api.isMatterAvailable?.()) {
|
|
50
|
-
this.
|
|
95
|
+
this.warnLog('Matter is not available in this version of Homebridge. Please update Homebridge to use this plugin.');
|
|
51
96
|
}
|
|
52
97
|
// Check if the user has matter enabled, this means:
|
|
53
98
|
// - If the plugin is running on the main bridge, then the user must have enabled matter in the Homebridge settings page in the UI
|
|
@@ -55,36 +100,59 @@ export class SwitchBotMatterPlatform {
|
|
|
55
100
|
// In reality, only the below check is needed, but they are both included here for completeness
|
|
56
101
|
// Remember to use a '?.' optional chaining operator in case the user is running an older version of Homebridge that does not have these APIs
|
|
57
102
|
if (!this.api.isMatterEnabled?.()) {
|
|
58
|
-
this.
|
|
103
|
+
this.warnLog('Matter is not enabled in Homebridge. Please enable Matter in the Homebridge settings to use this plugin.');
|
|
59
104
|
return;
|
|
60
105
|
}
|
|
61
106
|
// Register Matter accessories when Homebridge has finished launching
|
|
62
107
|
this.api.on('didFinishLaunching', () => {
|
|
63
|
-
this.
|
|
108
|
+
this.debugLog('Executed didFinishLaunching callback');
|
|
109
|
+
// Log presence of credentials (do not log actual values)
|
|
110
|
+
try {
|
|
111
|
+
this.debugLog(`SwitchBot credentials present? token=${Boolean(this.config.credentials?.token)}, secret=${Boolean(this.config.credentials?.secret)}`);
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
this.debugLog('Failed to log credentials presence: %s', e?.message ?? e);
|
|
115
|
+
}
|
|
116
|
+
// Do not fall back to environment variables here — credentials must
|
|
117
|
+
// come from plugin config (Homebridge UI). If credentials appear
|
|
118
|
+
// missing we'll log that fact and continue; the UI should store token
|
|
119
|
+
// and secret in `config.credentials`.
|
|
64
120
|
// Initialize SwitchBot API clients
|
|
65
121
|
try {
|
|
66
122
|
if (this.config.credentials?.token && this.config.credentials?.secret) {
|
|
67
123
|
this.switchBotAPI = new SwitchBotOpenAPI(this.config.credentials.token, this.config.credentials.secret, this.config.options?.hostname);
|
|
68
124
|
// forward basic logs
|
|
69
125
|
if (!this.config.options?.disableLogsforOpenAPI && this.switchBotAPI?.on) {
|
|
70
|
-
this.switchBotAPI.on('log', (l) => this.
|
|
126
|
+
this.switchBotAPI.on('log', (l) => this.debugLog('[SwitchBot OpenAPI]', l.message));
|
|
71
127
|
}
|
|
72
128
|
}
|
|
73
129
|
else {
|
|
74
|
-
this.
|
|
130
|
+
this.debugLog('SwitchBot OpenAPI credentials not provided; cloud devices will be skipped');
|
|
75
131
|
}
|
|
76
132
|
}
|
|
77
133
|
catch (e) {
|
|
78
|
-
this.
|
|
134
|
+
this.errorLog('Failed to initialize SwitchBot OpenAPI:', e?.message ?? e);
|
|
135
|
+
}
|
|
136
|
+
// Initialize API request tracking
|
|
137
|
+
try {
|
|
138
|
+
this.apiTracker = new ApiRequestTracker(this.api, this.log, 'SwitchBot Matter', {
|
|
139
|
+
dailyLimit: this.config.options?.dailyApiLimit ?? 10000,
|
|
140
|
+
reserveForCommands: this.config.options?.dailyApiReserveForCommands ?? 1000,
|
|
141
|
+
pausePollingAtReserve: this.config.options?.webhookOnlyOnReserve ?? false,
|
|
142
|
+
});
|
|
143
|
+
this.apiTracker.startHourlyLogging();
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
this.errorLog('Failed to initialize API request tracking:', e?.message ?? e);
|
|
79
147
|
}
|
|
80
148
|
try {
|
|
81
149
|
this.switchBotBLE = new SwitchBotBLE();
|
|
82
150
|
if (!this.config.options?.disableLogsforBLE && this.switchBotBLE?.on) {
|
|
83
|
-
this.switchBotBLE.on('log', (l) => this.
|
|
151
|
+
this.switchBotBLE.on('log', (l) => this.debugLog('[SwitchBot BLE]', l.message));
|
|
84
152
|
}
|
|
85
153
|
}
|
|
86
154
|
catch (e) {
|
|
87
|
-
this.
|
|
155
|
+
this.errorLog('Failed to initialize SwitchBot BLE client:', e?.message ?? e);
|
|
88
156
|
}
|
|
89
157
|
// If BLE scanning is enabled, start scanning and route advertisements to registered handlers
|
|
90
158
|
if (this.config.options?.BLE && this.switchBotBLE) {
|
|
@@ -94,7 +162,7 @@ export class SwitchBotMatterPlatform {
|
|
|
94
162
|
await ble.startScan();
|
|
95
163
|
}
|
|
96
164
|
catch (e) {
|
|
97
|
-
this.
|
|
165
|
+
this.errorLog(`Failed to start BLE scanning: ${e?.message ?? e}`);
|
|
98
166
|
}
|
|
99
167
|
// route advertisements to our handlers
|
|
100
168
|
ble.onadvertisement = async (ad) => {
|
|
@@ -106,15 +174,259 @@ export class SwitchBotMatterPlatform {
|
|
|
106
174
|
}
|
|
107
175
|
}
|
|
108
176
|
catch (e) {
|
|
109
|
-
this.
|
|
177
|
+
this.errorLog(`Failed to handle BLE advertisement: ${e?.message ?? e}`);
|
|
110
178
|
}
|
|
111
179
|
};
|
|
112
180
|
})();
|
|
113
181
|
}
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
182
|
+
// Ensure we clean up any per-device timers and BLE handlers when Homebridge shuts down
|
|
183
|
+
this.api.on('shutdown', async () => {
|
|
184
|
+
try {
|
|
185
|
+
this.infoLog('Homebridge shutting down: clearing refresh timers and BLE handlers');
|
|
186
|
+
// Stop API tracking hourly logging
|
|
187
|
+
if (this.apiTracker) {
|
|
188
|
+
this.apiTracker.stopHourlyLogging();
|
|
189
|
+
}
|
|
190
|
+
// Clear all refresh timers
|
|
191
|
+
for (const [nid, t] of Array.from(this.refreshTimers.entries())) {
|
|
192
|
+
try {
|
|
193
|
+
clearInterval(t);
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
this.debugLog(`Failed to clear timer for ${nid}: ${e?.message ?? e}`);
|
|
197
|
+
}
|
|
198
|
+
this.refreshTimers.delete(nid);
|
|
199
|
+
}
|
|
200
|
+
// Clear platform-level refresh timer
|
|
201
|
+
try {
|
|
202
|
+
if (this.platformRefreshTimer) {
|
|
203
|
+
clearInterval(this.platformRefreshTimer);
|
|
204
|
+
this.platformRefreshTimer = undefined;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
this.debugLog(`Failed to clear platform refresh timer: ${e?.message ?? e}`);
|
|
209
|
+
}
|
|
210
|
+
// Clear accessory instances registry
|
|
211
|
+
try {
|
|
212
|
+
this.accessoryInstances.clear();
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
this.debugLog(`Failed to clear accessoryInstances: ${e?.message ?? e}`);
|
|
216
|
+
}
|
|
217
|
+
// Remove BLE handlers
|
|
218
|
+
try {
|
|
219
|
+
for (const k of Object.keys(this.bleEventHandler)) {
|
|
220
|
+
delete this.bleEventHandler[k];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
this.debugLog(`Failed to clear bleEventHandler: ${e?.message ?? e}`);
|
|
225
|
+
}
|
|
226
|
+
// Stop BLE scanning if available
|
|
227
|
+
try {
|
|
228
|
+
if (this.switchBotBLE && typeof this.switchBotBLE.stopScan === 'function') {
|
|
229
|
+
await this.switchBotBLE.stopScan();
|
|
230
|
+
this.infoLog('Stopped BLE scanning');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (e) {
|
|
234
|
+
this.debugLog(`Failed to stop BLE scanning: ${e?.message ?? e}`);
|
|
235
|
+
}
|
|
236
|
+
// Persist backoff cooldowns to disk
|
|
237
|
+
try {
|
|
238
|
+
if (this.backoffFilePath) {
|
|
239
|
+
const obj = {};
|
|
240
|
+
for (const [k, v] of this.backoffCooldowns.entries()) {
|
|
241
|
+
if (Number.isFinite(v)) {
|
|
242
|
+
obj[k] = v;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
writeFileSync(this.backoffFilePath, JSON.stringify(obj, null, 2));
|
|
246
|
+
this.debugLog(`Saved ${Object.keys(obj).length} backoff cooldown entries`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
this.debugLog(`Failed to save backoff state: ${e?.message ?? e}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch (e) {
|
|
254
|
+
this.debugLog('Shutdown cleanup failed: %s', e?.message ?? e);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
(async () => {
|
|
258
|
+
try {
|
|
259
|
+
await this.discoverDevices();
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
this.debugLog('Device discovery failed during startup: %s', e?.message ?? e);
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
await this.registerMatterAccessories();
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
this.errorLog('Failed to register Matter accessories: %s', e?.message ?? e);
|
|
269
|
+
}
|
|
270
|
+
})();
|
|
271
|
+
});
|
|
272
|
+
try {
|
|
273
|
+
this.setupMqtt();
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
this.errorLog(`Setup MQTT, Error Message: ${e?.message ?? e}, Submit Bugs Here: ` + 'https://tinyurl.com/SwitchBotBug');
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
this.setupwebhook();
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
this.errorLog(`Setup Webhook, Error Message: ${e?.message ?? e}, Submit Bugs Here: ` + 'https://tinyurl.com/SwitchBotBug');
|
|
283
|
+
}
|
|
284
|
+
// Initialize backoff persistence file path and load any saved cooldowns
|
|
285
|
+
try {
|
|
286
|
+
const storagePath = this.api.user.storagePath();
|
|
287
|
+
this.backoffFilePath = join(storagePath, `${PLUGIN_NAME.replace('@', '').replace('/', '-')}-matter-backoff.json`);
|
|
288
|
+
if (existsSync(this.backoffFilePath)) {
|
|
289
|
+
try {
|
|
290
|
+
const raw = readFileSync(this.backoffFilePath, 'utf8');
|
|
291
|
+
const parsed = JSON.parse(raw);
|
|
292
|
+
if (parsed && typeof parsed === 'object') {
|
|
293
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
294
|
+
const ts = Number(v);
|
|
295
|
+
if (Number.isFinite(ts)) {
|
|
296
|
+
this.backoffCooldowns.set(String(k), ts);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
this.debugLog(`Loaded ${this.backoffCooldowns.size} backoff cooldown entries`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
this.debugLog(`Failed to load backoff state: ${e?.message ?? e}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (e) {
|
|
308
|
+
this.debugLog(`Failed to initialize backoff persistence: ${e?.message ?? e}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Normalize a deviceId for matching (uppercase alphanumerics only)
|
|
313
|
+
* @deprecated Use shared normalizeDeviceId from utils.js instead
|
|
314
|
+
*/
|
|
315
|
+
normalizeDeviceId(deviceId) {
|
|
316
|
+
return normalizeDeviceId(deviceId);
|
|
317
|
+
}
|
|
318
|
+
/** Determine the platform-level batch interval in seconds */
|
|
319
|
+
getPlatformBatchInterval() {
|
|
320
|
+
const opt = this.config.options;
|
|
321
|
+
const val = opt?.matterBatchRefreshRate ?? opt?.refreshRate ?? 300;
|
|
322
|
+
const n = Number(val);
|
|
323
|
+
return Number.isFinite(n) && n > 0 ? n : 300;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Clear per-device resources: refresh timers, accessory instance registry, BLE handlers
|
|
327
|
+
*/
|
|
328
|
+
clearDeviceResources(deviceId) {
|
|
329
|
+
if (!deviceId) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const nid = this.normalizeDeviceId(deviceId);
|
|
334
|
+
const existing = this.refreshTimers.get(nid);
|
|
335
|
+
if (existing) {
|
|
336
|
+
try {
|
|
337
|
+
clearInterval(existing);
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
this.debugLog(`Failed to clear refresh timer for ${deviceId}: ${e?.message ?? e}`);
|
|
341
|
+
}
|
|
342
|
+
this.refreshTimers.delete(nid);
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
this.accessoryInstances.delete(nid);
|
|
346
|
+
}
|
|
347
|
+
catch (e) {
|
|
348
|
+
this.debugLog(`Failed to delete accessory instance for ${deviceId}: ${e?.message ?? e}`);
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
const mac = formatDeviceIdAsMac(deviceId).toLowerCase();
|
|
352
|
+
if (this.bleEventHandler[mac]) {
|
|
353
|
+
delete this.bleEventHandler[mac];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
catch (e) {
|
|
357
|
+
// formatting failed (not a MAC-like id) — ignore
|
|
358
|
+
this.debugLog(`clearDeviceResources: failed to remove BLE handler for ${deviceId}: ${e?.message ?? e}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
catch (e) {
|
|
362
|
+
this.debugLog(`clearDeviceResources top-level error for ${deviceId}: ${e?.message ?? e}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Merge discovered devices with per-device overrides from `config.options.devices`.
|
|
367
|
+
* Also applies device-type templates when applyToAllDevicesOfType is set.
|
|
368
|
+
*/
|
|
369
|
+
async mergeDiscoveredDevices(discovered) {
|
|
370
|
+
// If there's no per-device config, return discovered as-is
|
|
371
|
+
if (!this.config.options?.devices) {
|
|
372
|
+
return discovered;
|
|
373
|
+
}
|
|
374
|
+
// Assign missing deviceType from configDeviceType if needed
|
|
375
|
+
const devicesWithTypeAssigned = discovered.map((deviceObj) => {
|
|
376
|
+
if (!deviceObj.deviceType) {
|
|
377
|
+
deviceObj.deviceType = deviceObj.configDeviceType !== undefined ? deviceObj.configDeviceType : 'Unknown';
|
|
378
|
+
this.debugLog(`API missing deviceType for ${deviceObj.deviceId}, using configDeviceType: ${deviceObj.configDeviceType}`);
|
|
379
|
+
}
|
|
380
|
+
return deviceObj;
|
|
117
381
|
});
|
|
382
|
+
// Apply device-type templates from config entries with applyToAllDevicesOfType=true
|
|
383
|
+
const devicesWithTemplates = applyDeviceTypeTemplates(devicesWithTypeAssigned, this.config.options.devices, 'deviceType', msg => this.debugLog(msg));
|
|
384
|
+
// Merge per-device overrides by matching deviceId
|
|
385
|
+
const allowConfigOnly = Boolean(this.config.options?.allowConfigOnlyDevices);
|
|
386
|
+
const merged = mergeByDeviceId(this.config.options?.devices ?? [], devicesWithTemplates ?? [], allowConfigOnly);
|
|
387
|
+
return merged;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Merge discovered IR devices with per-device overrides from `config.options.irdevices`.
|
|
391
|
+
* Also applies device-type templates when applyToAllDevicesOfType is set.
|
|
392
|
+
*/
|
|
393
|
+
async mergeDiscoveredIRDevices(discovered) {
|
|
394
|
+
// If there's no per-device config, return discovered as-is
|
|
395
|
+
if (!this.config.options?.irdevices) {
|
|
396
|
+
return discovered;
|
|
397
|
+
}
|
|
398
|
+
// Assign missing remoteType from configRemoteType if needed
|
|
399
|
+
const devicesWithTypeAssigned = discovered.map((deviceObj) => {
|
|
400
|
+
if (!deviceObj.remoteType && deviceObj.configRemoteType) {
|
|
401
|
+
deviceObj.remoteType = deviceObj.configRemoteType;
|
|
402
|
+
this.debugLog(`API missing remoteType for ${deviceObj.deviceId}, using configRemoteType: ${deviceObj.configRemoteType}`);
|
|
403
|
+
}
|
|
404
|
+
else if (!deviceObj.remoteType) {
|
|
405
|
+
this.warnLog(`No remoteType for IR device ${deviceObj.deviceId}, skipping`);
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
return deviceObj;
|
|
409
|
+
}).filter(device => device !== null);
|
|
410
|
+
// Apply remote-type templates from config entries with applyToAllDevicesOfType=true
|
|
411
|
+
const devicesWithTemplates = applyDeviceTypeTemplates(devicesWithTypeAssigned, this.config.options.irdevices, 'remoteType', msg => this.debugLog(msg));
|
|
412
|
+
// Merge per-device overrides by matching deviceId
|
|
413
|
+
const allowConfigOnly = Boolean(this.config.options?.allowConfigOnlyDevices);
|
|
414
|
+
const merged = mergeByDeviceId(this.config.options?.irdevices ?? [], devicesWithTemplates ?? [], allowConfigOnly);
|
|
415
|
+
return merged;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Select effective connection type for a device: prefer explicit device.connectionType,
|
|
419
|
+
* otherwise prefer BLE when platform BLE is enabled and device provides a BLE model/id.
|
|
420
|
+
*/
|
|
421
|
+
chooseConnectionType(deviceObj) {
|
|
422
|
+
if (deviceObj?.connectionType) {
|
|
423
|
+
return deviceObj.connectionType === 'BLE' ? 'BLE' : 'OpenAPI';
|
|
424
|
+
}
|
|
425
|
+
// If platform BLE is enabled and we have a bleModel or deviceId that formats to a MAC, prefer BLE
|
|
426
|
+
if (this.config.options?.BLE && (deviceObj?.bleModel || formatDeviceIdAsMac(deviceObj?.deviceId))) {
|
|
427
|
+
return 'BLE';
|
|
428
|
+
}
|
|
429
|
+
return 'OpenAPI';
|
|
118
430
|
}
|
|
119
431
|
/**
|
|
120
432
|
* Map a SwitchBot device object to a MatterAccessory using the device-specific
|
|
@@ -125,7 +437,7 @@ export class SwitchBotMatterPlatform {
|
|
|
125
437
|
const displayName = dev.deviceName ?? dev.deviceId ?? 'SwitchBot Device';
|
|
126
438
|
const serial = dev.deviceId ?? 'unknown';
|
|
127
439
|
const manufacturer = 'SwitchBot';
|
|
128
|
-
const model = dev.deviceType ?? 'SwitchBot';
|
|
440
|
+
const model = dev.model ?? dev.deviceType ?? 'SwitchBot';
|
|
129
441
|
const firmware = dev.firmware ?? dev.version ?? '0.0.0';
|
|
130
442
|
// Helper to build a default opts object consumed by the matter device classes
|
|
131
443
|
const baseOpts = {
|
|
@@ -137,35 +449,35 @@ export class SwitchBotMatterPlatform {
|
|
|
137
449
|
firmwareRevision: String(firmware),
|
|
138
450
|
hardwareRevision: '1.0.0',
|
|
139
451
|
deviceId: dev.deviceId,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
const id = formatDeviceIdAsMac(dev.deviceId);
|
|
154
|
-
const list = await this.switchBotBLE.discover({ model: dev.bleModel, id });
|
|
155
|
-
if (!Array.isArray(list) || list.length === 0) {
|
|
156
|
-
throw new Error('BLE device not found');
|
|
157
|
-
}
|
|
158
|
-
const deviceInst = list[0];
|
|
159
|
-
if (typeof deviceInst[methodName] !== 'function') {
|
|
160
|
-
throw new TypeError(`BLE method ${methodName} not available on device`);
|
|
161
|
-
}
|
|
162
|
-
return await deviceInst[methodName](...args);
|
|
452
|
+
// Inject handy platform-side helpers into the accessory `context` so Matter
|
|
453
|
+
// accessory classes can perform OpenAPI/BLE actions without reaching into
|
|
454
|
+
// the platform implementation directly.
|
|
455
|
+
context: {
|
|
456
|
+
deviceId: dev.deviceId,
|
|
457
|
+
// Expose the display name so Matter accessory classes can read it from context
|
|
458
|
+
name: displayName,
|
|
459
|
+
// Provide device-level logging override (if present) and platform logging flag
|
|
460
|
+
// so accessories can decide how verbose they should be.
|
|
461
|
+
deviceLogging: dev?.logging,
|
|
462
|
+
platformLogging: this.platformLogging,
|
|
463
|
+
},
|
|
163
464
|
};
|
|
164
|
-
|
|
465
|
+
// Build platform-side helpers using shared factories so they can be reused/tested
|
|
466
|
+
const sendOpenAPI = makeOpenAPISender(this.retryCommand.bind(this), dev, { maxRetries: this.config.options?.maxRetries ?? 1, delayBetweenRetries: this.config.options?.delayBetweenRetries ?? 1000 });
|
|
467
|
+
const sendBLE = makeBLESender(this.switchBotBLE, dev, { bleRetries: this.config.options?.bleRetries ?? 2, bleRetryDelay: this.config.options?.bleRetryDelay ?? 500 });
|
|
468
|
+
// Log that we're initializing this device so it's visible in startup logs
|
|
469
|
+
try {
|
|
470
|
+
this.infoLog(`Initializing Matter device: ${displayName} (type=${dev.deviceType ?? 'Unknown'}) id=${dev.deviceId}`);
|
|
471
|
+
}
|
|
472
|
+
catch (e) {
|
|
473
|
+
// best-effort logging — swallow errors to avoid breaking initialization
|
|
474
|
+
this.debugLog('Failed to log initializing device:', e?.message ?? e);
|
|
475
|
+
}
|
|
476
|
+
const makeOnOffHandlers = (uuid, connectionType) => ({
|
|
165
477
|
onOff: {
|
|
166
478
|
on: async () => {
|
|
167
479
|
try {
|
|
168
|
-
if (
|
|
480
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
169
481
|
await sendBLE('turnOn');
|
|
170
482
|
}
|
|
171
483
|
else {
|
|
@@ -174,12 +486,12 @@ export class SwitchBotMatterPlatform {
|
|
|
174
486
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.OnOff, { onOff: true });
|
|
175
487
|
}
|
|
176
488
|
catch (e) {
|
|
177
|
-
this.
|
|
489
|
+
this.errorLog(`Failed to turn on device ${dev.deviceId}: ${e?.message ?? e}`);
|
|
178
490
|
}
|
|
179
491
|
},
|
|
180
492
|
off: async () => {
|
|
181
493
|
try {
|
|
182
|
-
if (
|
|
494
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
183
495
|
await sendBLE('turnOff');
|
|
184
496
|
}
|
|
185
497
|
else {
|
|
@@ -188,21 +500,30 @@ export class SwitchBotMatterPlatform {
|
|
|
188
500
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.OnOff, { onOff: false });
|
|
189
501
|
}
|
|
190
502
|
catch (e) {
|
|
191
|
-
this.
|
|
503
|
+
this.errorLog(`Failed to turn off device ${dev.deviceId}: ${e?.message ?? e}`);
|
|
192
504
|
}
|
|
193
505
|
},
|
|
194
506
|
},
|
|
195
507
|
});
|
|
196
|
-
// Mapping from SwitchBot deviceType -> constructor
|
|
508
|
+
// Mapping from SwitchBot deviceType -> constructor (expanded for parity with HAP)
|
|
197
509
|
const mapping = {
|
|
510
|
+
// Plugs / Outlets
|
|
198
511
|
'Plug': OnOffOutletAccessory,
|
|
199
512
|
'Plug Mini (US)': OnOffOutletAccessory,
|
|
200
513
|
'Plug Mini (JP)': OnOffOutletAccessory,
|
|
514
|
+
'Plug Mini': OnOffOutletAccessory,
|
|
515
|
+
'WoPlug': OnOffOutletAccessory,
|
|
516
|
+
// Lighting
|
|
201
517
|
'Color Bulb': ColorLightAccessory,
|
|
518
|
+
'Color Bulb Mini': ColorLightAccessory,
|
|
202
519
|
'Ceiling Light': ColorTemperatureLightAccessory,
|
|
203
520
|
'Ceiling Light Pro': ColorTemperatureLightAccessory,
|
|
204
521
|
'Strip Light': ExtendedColorLightAccessory,
|
|
522
|
+
'Light Strip': ExtendedColorLightAccessory,
|
|
523
|
+
'Light Strip Plus': ExtendedColorLightAccessory,
|
|
524
|
+
'Strip Light Pro': ExtendedColorLightAccessory,
|
|
205
525
|
'Dimmable Light': DimmableLightAccessory,
|
|
526
|
+
// Robot Vacuums
|
|
206
527
|
'K10+': RoboticVacuumAccessory,
|
|
207
528
|
'K10+ Pro': RoboticVacuumAccessory,
|
|
208
529
|
'WoSweeper': RoboticVacuumAccessory,
|
|
@@ -210,8 +531,12 @@ export class SwitchBotMatterPlatform {
|
|
|
210
531
|
'Robot Vacuum Cleaner S1': RoboticVacuumAccessory,
|
|
211
532
|
'Robot Vacuum Cleaner S1 Plus': RoboticVacuumAccessory,
|
|
212
533
|
'Robot Vacuum Cleaner S10': RoboticVacuumAccessory,
|
|
534
|
+
'Robot Vacuum Cleaner S1 Pro': RoboticVacuumAccessory,
|
|
535
|
+
'Robot Vacuum Cleaner S1 Mini': RoboticVacuumAccessory,
|
|
536
|
+
// Locks
|
|
213
537
|
'Smart Lock': DoorLockAccessory,
|
|
214
538
|
'Smart Lock Pro': DoorLockAccessory,
|
|
539
|
+
// Sensors
|
|
215
540
|
'Motion Sensor': OccupancySensorAccessory,
|
|
216
541
|
'Contact Sensor': ContactSensorAccessory,
|
|
217
542
|
'Water Detector': LeakSensorAccessory,
|
|
@@ -220,25 +545,49 @@ export class SwitchBotMatterPlatform {
|
|
|
220
545
|
'MeterPro': TemperatureSensorAccessory,
|
|
221
546
|
'WoIOSensor': TemperatureSensorAccessory,
|
|
222
547
|
'Air Purifier PM2.5': HumiditySensorAccessory,
|
|
548
|
+
'Air Purifier Table PM2.5': HumiditySensorAccessory,
|
|
549
|
+
'Air Purifier': HumiditySensorAccessory,
|
|
550
|
+
'Air Purifier VOC': HumiditySensorAccessory,
|
|
551
|
+
'Air Purifier Table VOC': HumiditySensorAccessory,
|
|
552
|
+
// Fans
|
|
223
553
|
'Battery Circulator Fan': FanAccessory,
|
|
554
|
+
// Curtains / Blinds
|
|
224
555
|
'Blind Tilt': VenetianBlindAccessory,
|
|
225
556
|
'Curtain': WindowBlindAccessory,
|
|
557
|
+
'Curtain2': WindowBlindAccessory,
|
|
226
558
|
'Curtain3': WindowBlindAccessory,
|
|
559
|
+
'Curtain 2': WindowBlindAccessory,
|
|
227
560
|
'WoRollerShade': WindowBlindAccessory,
|
|
228
561
|
'Roller Shade': WindowBlindAccessory,
|
|
562
|
+
'Venetian Blind': VenetianBlindAccessory,
|
|
563
|
+
// Switches / Relays
|
|
229
564
|
'Relay Switch 1': OnOffSwitchAccessory,
|
|
230
565
|
'Relay Switch 1PM': OnOffSwitchAccessory,
|
|
566
|
+
'Relay Switch 2': OnOffSwitchAccessory,
|
|
567
|
+
'Relay Switch 3': OnOffSwitchAccessory,
|
|
568
|
+
// Misc / hubs / other
|
|
569
|
+
'Hub 2': undefined,
|
|
570
|
+
'Hub 3': undefined,
|
|
571
|
+
'Hub Mini': undefined,
|
|
572
|
+
'Bot': OnOffSwitchAccessory,
|
|
573
|
+
'Smart Bot': OnOffSwitchAccessory,
|
|
574
|
+
'Humidifier': HumiditySensorAccessory,
|
|
575
|
+
'Humidifier2': HumiditySensorAccessory,
|
|
576
|
+
'Thermostat': ThermostatAccessory,
|
|
577
|
+
'Water Heater': ThermostatAccessory,
|
|
231
578
|
};
|
|
232
579
|
const Ctor = mapping[dev.deviceType ?? ''];
|
|
233
580
|
if (!Ctor) {
|
|
234
|
-
this.
|
|
581
|
+
this.debugLog(`No Matter mapping for deviceType='${dev.deviceType}', deviceId=${dev.deviceId}`);
|
|
235
582
|
return undefined;
|
|
236
583
|
}
|
|
237
584
|
// Build opts and handlers tailored for basic capabilities
|
|
238
585
|
const uuid = baseOpts.uuid;
|
|
239
586
|
const handlers = {};
|
|
587
|
+
// Choose connection type for this device (BLE vs OpenAPI)
|
|
588
|
+
const connectionType = this.chooseConnectionType(dev);
|
|
240
589
|
// On/Off common
|
|
241
|
-
handlers.onOff = makeOnOffHandlers(uuid).onOff;
|
|
590
|
+
handlers.onOff = makeOnOffHandlers(uuid, connectionType).onOff;
|
|
242
591
|
// If this is a light, add brightness and color handlers
|
|
243
592
|
if (['Color Bulb', 'Ceiling Light', 'Ceiling Light Pro', 'Strip Light', 'Dimmable Light'].includes(dev.deviceType ?? '')) {
|
|
244
593
|
// levelControl
|
|
@@ -247,7 +596,7 @@ export class SwitchBotMatterPlatform {
|
|
|
247
596
|
try {
|
|
248
597
|
const level = request.level;
|
|
249
598
|
const percent = Math.round((level / 254) * 100);
|
|
250
|
-
if (
|
|
599
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
251
600
|
await sendBLE('setBrightness', percent);
|
|
252
601
|
}
|
|
253
602
|
else {
|
|
@@ -256,7 +605,7 @@ export class SwitchBotMatterPlatform {
|
|
|
256
605
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.LevelControl, { currentLevel: level });
|
|
257
606
|
}
|
|
258
607
|
catch (e) {
|
|
259
|
-
this.
|
|
608
|
+
this.errorLog(`Failed to set brightness for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
260
609
|
}
|
|
261
610
|
},
|
|
262
611
|
};
|
|
@@ -267,7 +616,7 @@ export class SwitchBotMatterPlatform {
|
|
|
267
616
|
const hue = request.hue;
|
|
268
617
|
const saturation = request.saturation;
|
|
269
618
|
const [r, g, b] = hs2rgb(Math.round((hue / 254) * 360), Math.round((saturation / 254) * 100));
|
|
270
|
-
if (
|
|
619
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
271
620
|
await sendBLE('setRGB', Number(request.level ?? 100), r, g, b);
|
|
272
621
|
}
|
|
273
622
|
else {
|
|
@@ -276,7 +625,7 @@ export class SwitchBotMatterPlatform {
|
|
|
276
625
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentHue: hue, currentSaturation: saturation });
|
|
277
626
|
}
|
|
278
627
|
catch (e) {
|
|
279
|
-
this.
|
|
628
|
+
this.errorLog(`Failed to set hue/sat for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
280
629
|
}
|
|
281
630
|
},
|
|
282
631
|
moveToColorLogic: async (request) => {
|
|
@@ -288,7 +637,7 @@ export class SwitchBotMatterPlatform {
|
|
|
288
637
|
const hueApprox = Math.round((colorX / 65535) * 360);
|
|
289
638
|
const satApprox = Math.round((colorY / 65535) * 100);
|
|
290
639
|
const [r, g, b] = hs2rgb(hueApprox, satApprox);
|
|
291
|
-
if (
|
|
640
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
292
641
|
await sendBLE('setRGB', Number(request.level ?? 100), r, g, b);
|
|
293
642
|
}
|
|
294
643
|
else {
|
|
@@ -297,7 +646,7 @@ export class SwitchBotMatterPlatform {
|
|
|
297
646
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentX: colorX, currentY: colorY });
|
|
298
647
|
}
|
|
299
648
|
catch (e) {
|
|
300
|
-
this.
|
|
649
|
+
this.errorLog(`Failed to set XY color for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
301
650
|
}
|
|
302
651
|
},
|
|
303
652
|
};
|
|
@@ -306,7 +655,7 @@ export class SwitchBotMatterPlatform {
|
|
|
306
655
|
moveToColorTemperature: async (request) => {
|
|
307
656
|
try {
|
|
308
657
|
const kelvin = Math.round(1000000 / Number(request.colorTemperature));
|
|
309
|
-
if (
|
|
658
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
310
659
|
await sendBLE('setColorTemperature', kelvin);
|
|
311
660
|
}
|
|
312
661
|
else {
|
|
@@ -315,118 +664,371 @@ export class SwitchBotMatterPlatform {
|
|
|
315
664
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentX: request.colorX ?? 0, currentY: request.colorY ?? 0 });
|
|
316
665
|
}
|
|
317
666
|
catch (e) {
|
|
318
|
-
this.
|
|
667
|
+
this.errorLog(`Failed to set color temperature for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
319
668
|
}
|
|
320
669
|
},
|
|
321
670
|
};
|
|
322
671
|
}
|
|
672
|
+
// Expose platform helpers to the accessory via context so accessory
|
|
673
|
+
// classes can call OpenAPI/BLE actions (sendOpenAPI/sendBLE) and know
|
|
674
|
+
// the effective connection type.
|
|
675
|
+
try {
|
|
676
|
+
/* Inject platform helpers (OpenAPI/BLE senders + logging helpers + connection type)
|
|
677
|
+
into the accessory context so Matter accessory classes can use them without
|
|
678
|
+
reaching into the platform implementation directly. */
|
|
679
|
+
;
|
|
680
|
+
baseOpts.context = Object.assign({}, baseOpts.context, {
|
|
681
|
+
sendOpenAPI,
|
|
682
|
+
sendBLE,
|
|
683
|
+
connectionType,
|
|
684
|
+
// Expose platform logging helpers so accessories can use consistent logging
|
|
685
|
+
infoLog: this.infoLog,
|
|
686
|
+
debugLog: this.debugLog,
|
|
687
|
+
warnLog: this.warnLog,
|
|
688
|
+
errorLog: this.errorLog,
|
|
689
|
+
successLog: this.successLog,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
catch (e) {
|
|
693
|
+
this.debugLog('Failed to attach platform helpers to baseOpts.context: %s', e?.message ?? e);
|
|
694
|
+
}
|
|
323
695
|
const opts = Object.assign({}, baseOpts, { handlers });
|
|
324
696
|
// Instantiate the device class and return its serialized accessory
|
|
325
697
|
const instance = new Ctor(this.api, this.log, opts);
|
|
698
|
+
// Save instance in registry so platform can call device-specific update methods if needed
|
|
699
|
+
try {
|
|
700
|
+
if (dev?.deviceId) {
|
|
701
|
+
this.accessoryInstances.set(this.normalizeDeviceId(dev.deviceId), instance);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
catch (e) {
|
|
705
|
+
this.debugLog('Failed to register accessory instance: %s', e?.message ?? e);
|
|
706
|
+
}
|
|
707
|
+
try {
|
|
708
|
+
this.infoLog(`Initialized Matter accessory: ${displayName} (type=${dev.deviceType ?? 'Unknown'}) id=${dev.deviceId}`);
|
|
709
|
+
}
|
|
710
|
+
catch (e) {
|
|
711
|
+
this.debugLog('Failed to log initialized accessory:', e?.message ?? e);
|
|
712
|
+
}
|
|
326
713
|
// Register BLE->Matter push handler for this device's MAC (if BLE scanning is active)
|
|
327
714
|
try {
|
|
328
715
|
const mac = formatDeviceIdAsMac(dev.deviceId).toLowerCase();
|
|
329
716
|
// Handler receives advertisement/serviceData when BLE scan events arrive
|
|
330
717
|
this.bleEventHandler[mac] = async (serviceData) => {
|
|
331
718
|
const uuidLocal = baseOpts.uuid;
|
|
332
|
-
//
|
|
719
|
+
// First try model-specific / normalized parsing of BLE advertisement
|
|
333
720
|
try {
|
|
334
|
-
|
|
721
|
+
const parsed = this.parseAdvertisementForDevice(dev, serviceData);
|
|
722
|
+
try {
|
|
723
|
+
const _p = JSON.stringify(parsed);
|
|
724
|
+
this.debugLog(`BLE advertisement parsed for ${dev.deviceId}: ${_p}`);
|
|
725
|
+
}
|
|
726
|
+
catch (e) {
|
|
727
|
+
this.debugLog(`BLE advertisement parsed for ${dev.deviceId}: [unstringifiable parse result]`);
|
|
728
|
+
}
|
|
729
|
+
if (parsed) {
|
|
335
730
|
// Power
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const on = (String(power).toLowerCase() === 'on' || Number(power) === 1);
|
|
339
|
-
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.OnOff, { onOff: on });
|
|
731
|
+
if (parsed.power !== undefined) {
|
|
732
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.OnOff, { onOff: Boolean(parsed.power) });
|
|
340
733
|
}
|
|
341
734
|
// Brightness
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const level = Math.
|
|
735
|
+
if (parsed.brightness !== undefined) {
|
|
736
|
+
const rawLevel = Math.round((Number(parsed.brightness) / 100) * 254);
|
|
737
|
+
const level = Math.max(0, Math.min(254, rawLevel));
|
|
738
|
+
this.debugLog(`[BLE Brightness Debug] Device ${dev.deviceId}: rawBrightness=${parsed.brightness}, calculated=${rawLevel}, clamped=${level}`);
|
|
345
739
|
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.LevelControl, { currentLevel: level });
|
|
346
740
|
}
|
|
347
|
-
// Color
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
741
|
+
// Color
|
|
742
|
+
if (parsed.color !== undefined) {
|
|
743
|
+
const { r, g, b } = parsed.color;
|
|
744
|
+
const [h, s] = rgb2hs(r, g, b);
|
|
745
|
+
const rawHue = Math.round((h / 360) * 254);
|
|
746
|
+
const rawSat = Math.round((s / 100) * 254);
|
|
747
|
+
const hue = Math.max(0, Math.min(254, rawHue));
|
|
748
|
+
const sat = Math.max(0, Math.min(254, rawSat));
|
|
749
|
+
this.debugLog(`[BLE Color Debug] Device ${dev.deviceId}: RGB=[${r},${g},${b}], HS=[${h},${s}], Matter=[${rawHue},${rawSat}], clamped=[${hue},${sat}]`);
|
|
750
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.ColorControl, { currentHue: hue, currentSaturation: sat });
|
|
751
|
+
}
|
|
752
|
+
// Battery -> powerSource cluster (common mapping)
|
|
753
|
+
if (parsed.battery !== undefined) {
|
|
754
|
+
// Skip battery updates for device types that don't support PowerSource cluster
|
|
755
|
+
const deviceType = String(dev?.deviceType ?? '');
|
|
756
|
+
const unsupportedTypes = ['Curtain', 'Curtain2', 'Curtain3', 'Curtain 2', 'Blind Tilt'];
|
|
757
|
+
if (unsupportedTypes.includes(deviceType)) {
|
|
758
|
+
this.debugLog(`Device ${dev.deviceId} type ${deviceType} does not support PowerSource cluster, skipping BLE battery update`);
|
|
357
759
|
}
|
|
358
|
-
else
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
760
|
+
else {
|
|
761
|
+
try {
|
|
762
|
+
const percentage = Number(parsed.battery);
|
|
763
|
+
const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)));
|
|
764
|
+
let batChargeLevel = 0;
|
|
765
|
+
if (percentage < 20) {
|
|
766
|
+
batChargeLevel = 2;
|
|
767
|
+
}
|
|
768
|
+
else if (percentage < 40) {
|
|
769
|
+
batChargeLevel = 1;
|
|
770
|
+
}
|
|
771
|
+
try {
|
|
772
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'powerSource', { batPercentRemaining, batChargeLevel });
|
|
773
|
+
}
|
|
774
|
+
catch (updateError) {
|
|
775
|
+
// Silently skip if powerSource cluster doesn't exist on this device
|
|
776
|
+
const msg = String(updateError?.message ?? updateError);
|
|
777
|
+
if (!msg.includes('does not exist') && !msg.includes('not found')) {
|
|
778
|
+
throw updateError;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
catch (e) {
|
|
783
|
+
this.debugLog(`Failed to update battery state for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
784
|
+
}
|
|
363
785
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
786
|
+
}
|
|
787
|
+
// Temperature -> temperatureMeasurement
|
|
788
|
+
if (parsed.temperature !== undefined) {
|
|
789
|
+
try {
|
|
790
|
+
const c = Number(parsed.temperature);
|
|
791
|
+
const measured = Math.round(c * 100);
|
|
792
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'temperatureMeasurement', { measuredValue: measured });
|
|
793
|
+
}
|
|
794
|
+
catch (e) {
|
|
795
|
+
this.debugLog(`Failed to update temperature for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
// Humidity -> relativeHumidityMeasurement
|
|
799
|
+
if (parsed.humidity !== undefined) {
|
|
800
|
+
try {
|
|
801
|
+
const percent = Number(parsed.humidity);
|
|
802
|
+
const measured = Math.round(percent * 100);
|
|
803
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'relativeHumidityMeasurement', { measuredValue: measured });
|
|
804
|
+
}
|
|
805
|
+
catch (e) {
|
|
806
|
+
this.debugLog(`Failed to update humidity for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
// Contact / Leak -> BooleanState
|
|
810
|
+
if (parsed.contact !== undefined || parsed.leak !== undefined) {
|
|
811
|
+
try {
|
|
812
|
+
// Some devices report contact as true=open; ContactSensor expects inverted value
|
|
813
|
+
const isContactOpen = parsed.contact === undefined ? undefined : Boolean(parsed.contact);
|
|
814
|
+
const leakDetected = parsed.leak === undefined ? undefined : Boolean(parsed.leak);
|
|
815
|
+
if (isContactOpen !== undefined) {
|
|
816
|
+
// If this is a contact sensor device type, invert; otherwise set conservatively
|
|
817
|
+
if ((dev.deviceType || '').includes('Contact')) {
|
|
818
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: !isContactOpen });
|
|
819
|
+
}
|
|
820
|
+
else {
|
|
821
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: isContactOpen });
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (leakDetected !== undefined) {
|
|
825
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: leakDetected });
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
catch (e) {
|
|
829
|
+
this.debugLog(`Failed to update contact/leak for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
// Motion -> occupancy
|
|
833
|
+
if (parsed.motion !== undefined) {
|
|
834
|
+
try {
|
|
835
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'occupancySensing', { occupancy: { occupied: Boolean(parsed.motion) } });
|
|
836
|
+
}
|
|
837
|
+
catch (e) {
|
|
838
|
+
this.debugLog(`Failed to update occupancy for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
368
839
|
}
|
|
369
|
-
const [h, s] = rgb2hs(r, g, b);
|
|
370
|
-
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.ColorControl, { currentHue: Math.round((h / 360) * 254), currentSaturation: Math.round((s / 100) * 254) });
|
|
371
840
|
}
|
|
372
|
-
//
|
|
841
|
+
// Lock state -> doorLock
|
|
842
|
+
if (parsed.lock !== undefined) {
|
|
843
|
+
try {
|
|
844
|
+
const s = String(parsed.lock).toLowerCase();
|
|
845
|
+
let lockState = 0;
|
|
846
|
+
if (s === 'locked' || s === '1' || s === 'true') {
|
|
847
|
+
lockState = 1;
|
|
848
|
+
}
|
|
849
|
+
else if (s === 'unlocked' || s === '0' || s === 'false') {
|
|
850
|
+
lockState = 2;
|
|
851
|
+
}
|
|
852
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.DoorLock, { lockState });
|
|
853
|
+
}
|
|
854
|
+
catch (e) {
|
|
855
|
+
this.debugLog(`Failed to update lock for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
// Position / Cover -> WindowCovering (convert open percent to closed*100)
|
|
859
|
+
if (parsed.position !== undefined) {
|
|
860
|
+
try {
|
|
861
|
+
const openPercent = Number(parsed.position);
|
|
862
|
+
const closedPercent = 100 - Math.max(0, Math.min(100, openPercent));
|
|
863
|
+
const value = Math.round(closedPercent * 100);
|
|
864
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.WindowCovering, { currentPositionLiftPercent100ths: value, targetPositionLiftPercent100ths: value });
|
|
865
|
+
}
|
|
866
|
+
catch (e) {
|
|
867
|
+
this.debugLog(`Failed to update cover position for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
// Fan speed -> FanControl
|
|
871
|
+
if (parsed.fanSpeed !== undefined) {
|
|
872
|
+
try {
|
|
873
|
+
const percent = Number(parsed.fanSpeed);
|
|
874
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.FanControl, { percentSetting: percent, percentCurrent: percent });
|
|
875
|
+
}
|
|
876
|
+
catch (e) {
|
|
877
|
+
this.debugLog(`Failed to update fan speed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
// Robot vacuum fields (battery already handled) - update run/operational/clean modes when present
|
|
881
|
+
if (parsed.rvcRunMode !== undefined) {
|
|
882
|
+
try {
|
|
883
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'rvcRunMode', { currentMode: Number(parsed.rvcRunMode) });
|
|
884
|
+
}
|
|
885
|
+
catch (e) {
|
|
886
|
+
this.debugLog(`Failed to update rvcRunMode for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
if (parsed.rvcOperationalState !== undefined) {
|
|
890
|
+
try {
|
|
891
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'rvcOperationalState', { operationalState: Number(parsed.rvcOperationalState) });
|
|
892
|
+
}
|
|
893
|
+
catch (e) {
|
|
894
|
+
this.debugLog(`Failed to update rvcOperationalState for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
// If we parsed something from serviceData prefer it and return early
|
|
373
898
|
if (serviceData) {
|
|
374
899
|
return;
|
|
375
900
|
}
|
|
376
901
|
}
|
|
377
902
|
}
|
|
378
903
|
catch (e) {
|
|
379
|
-
this.
|
|
904
|
+
this.debugLog(`BLE advertisement parsing failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
380
905
|
}
|
|
381
906
|
// Fallback to OpenAPI getDeviceStatus when serviceData is not present or parsing failed
|
|
382
907
|
if (!this.switchBotAPI) {
|
|
383
908
|
return;
|
|
384
909
|
}
|
|
385
910
|
try {
|
|
386
|
-
|
|
387
|
-
if (!(statusCode === 100 || statusCode === 200)) {
|
|
911
|
+
if (!this.apiTracker?.trySpend('poll')) {
|
|
388
912
|
return;
|
|
389
913
|
}
|
|
914
|
+
const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret);
|
|
390
915
|
const respAny = response;
|
|
391
916
|
const body = respAny?.body ?? respAny;
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if (
|
|
400
|
-
|
|
401
|
-
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.LevelControl, { currentLevel: level });
|
|
402
|
-
}
|
|
403
|
-
// Color
|
|
404
|
-
if (status?.color !== undefined) {
|
|
405
|
-
const color = String(status.color);
|
|
406
|
-
let r = 0;
|
|
407
|
-
let g = 0;
|
|
408
|
-
let b = 0;
|
|
409
|
-
if (color.includes(':')) {
|
|
410
|
-
const parts = color.split(':').map(Number);
|
|
411
|
-
[r, g, b] = parts;
|
|
412
|
-
}
|
|
413
|
-
else if (color.startsWith('#')) {
|
|
414
|
-
const hex = color.replace('#', '');
|
|
415
|
-
r = Number.parseInt(hex.substring(0, 2), 16);
|
|
416
|
-
g = Number.parseInt(hex.substring(2, 4), 16);
|
|
417
|
-
b = Number.parseInt(hex.substring(4, 6), 16);
|
|
418
|
-
}
|
|
419
|
-
const [h, s] = rgb2hs(r, g, b);
|
|
420
|
-
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.ColorControl, { currentHue: Math.round((h / 360) * 254), currentSaturation: Math.round((s / 100) * 254) });
|
|
917
|
+
try {
|
|
918
|
+
const s = JSON.stringify(body);
|
|
919
|
+
this.debugLog(`OpenAPI getDeviceStatus for ${dev.deviceId} returned statusCode=${statusCode} body=${s}`);
|
|
920
|
+
}
|
|
921
|
+
catch (e) {
|
|
922
|
+
this.debugLog(`OpenAPI getDeviceStatus for ${dev.deviceId} returned statusCode=${statusCode} (body not stringifiable)`);
|
|
923
|
+
}
|
|
924
|
+
if (!isSuccessfulStatusCode(statusCode)) {
|
|
925
|
+
return;
|
|
421
926
|
}
|
|
927
|
+
const status = body?.status ?? body;
|
|
928
|
+
// Use centralized mapper which prefers accessory instance update helpers
|
|
929
|
+
await this.applyStatusToAccessory(uuidLocal, dev, status);
|
|
422
930
|
}
|
|
423
931
|
catch (e) {
|
|
424
|
-
this.
|
|
932
|
+
this.debugLog(`BLE push handler failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
425
933
|
}
|
|
426
934
|
};
|
|
427
935
|
}
|
|
428
936
|
catch (e) {
|
|
429
|
-
this.
|
|
937
|
+
this.debugLog(`Failed to register BLE handler for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
938
|
+
}
|
|
939
|
+
// Schedule periodic OpenAPI refreshes for this device (if OpenAPI is configured)
|
|
940
|
+
try {
|
|
941
|
+
const nid = this.normalizeDeviceId(dev.deviceId);
|
|
942
|
+
// Clear any existing timer for this device
|
|
943
|
+
const existing = this.refreshTimers.get(nid);
|
|
944
|
+
if (existing) {
|
|
945
|
+
clearInterval(existing);
|
|
946
|
+
this.refreshTimers.delete(nid);
|
|
947
|
+
}
|
|
948
|
+
const platformInterval = this.getPlatformBatchInterval();
|
|
949
|
+
const hasDeviceInterval = typeof dev.refreshRate === 'number' && Number(dev.refreshRate) > 0;
|
|
950
|
+
if (this.switchBotAPI && (hasDeviceInterval || platformInterval > 0)) {
|
|
951
|
+
// Immediate one-shot to populate initial state
|
|
952
|
+
;
|
|
953
|
+
(async () => {
|
|
954
|
+
try {
|
|
955
|
+
this.infoLog(`Performing initial OpenAPI refresh for ${dev.deviceId}`);
|
|
956
|
+
if (!this.apiTracker?.trySpend('poll')) {
|
|
957
|
+
this.warnLog(`Skipping initial OpenAPI refresh for ${dev.deviceId} due to daily budget`);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret);
|
|
961
|
+
const respAny = response;
|
|
962
|
+
const body = respAny?.body ?? respAny;
|
|
963
|
+
try {
|
|
964
|
+
const s = JSON.stringify(body);
|
|
965
|
+
this.debugLog(`Initial OpenAPI refresh for ${dev.deviceId} returned statusCode=${statusCode} body=${s}`);
|
|
966
|
+
}
|
|
967
|
+
catch (e) {
|
|
968
|
+
this.debugLog(`Initial OpenAPI refresh for ${dev.deviceId} returned statusCode=${statusCode} (body not stringifiable)`);
|
|
969
|
+
}
|
|
970
|
+
if (isSuccessfulStatusCode(statusCode)) {
|
|
971
|
+
const status = body?.status ?? body;
|
|
972
|
+
await this.applyStatusToAccessory(uuid, dev, status);
|
|
973
|
+
this.infoLog(`Initial OpenAPI refresh succeeded for ${dev.deviceId}`);
|
|
974
|
+
}
|
|
975
|
+
else {
|
|
976
|
+
this.warnLog(`Initial OpenAPI refresh returned unexpected statusCode=${statusCode} for ${dev.deviceId}`);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
catch (e) {
|
|
980
|
+
this.errorLog(`Initial OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
981
|
+
}
|
|
982
|
+
})();
|
|
983
|
+
if (hasDeviceInterval) {
|
|
984
|
+
// Create a per-device timer and exclude it from batch
|
|
985
|
+
const interval = Number(dev.refreshRate);
|
|
986
|
+
this.perDeviceRefreshSet.add(nid);
|
|
987
|
+
const timer = setInterval(async () => {
|
|
988
|
+
try {
|
|
989
|
+
// Skip if device is under cooldown
|
|
990
|
+
const now = Date.now();
|
|
991
|
+
const nextAllowed = this.backoffCooldowns.get(nid) ?? 0;
|
|
992
|
+
if (now < nextAllowed) {
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
await this.refreshSingleDeviceWithRetry(dev);
|
|
996
|
+
}
|
|
997
|
+
catch (e) {
|
|
998
|
+
this.debugLog(`Per-device refresh failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
999
|
+
}
|
|
1000
|
+
}, interval * 1000);
|
|
1001
|
+
this.refreshTimers.set(nid, timer);
|
|
1002
|
+
this.infoLog(`Started per-device refresh timer for ${dev.deviceId} at ${interval}s`);
|
|
1003
|
+
}
|
|
1004
|
+
else {
|
|
1005
|
+
// Start platform-level batched refresh timer (only once)
|
|
1006
|
+
this.startPlatformRefreshTimer(platformInterval);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
catch (e) {
|
|
1011
|
+
this.debugLog(`Failed to schedule refresh for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1012
|
+
}
|
|
1013
|
+
// Register webhook handler for this device
|
|
1014
|
+
try {
|
|
1015
|
+
if (dev.webhook && dev.deviceId) {
|
|
1016
|
+
this.debugLog(`Registering webhook handler for Matter device: ${dev.deviceId}`);
|
|
1017
|
+
this.webhookEventHandler[dev.deviceId] = async (context) => {
|
|
1018
|
+
try {
|
|
1019
|
+
this.debugLog(`Received webhook for Matter device ${dev.deviceId}: ${JSON.stringify(context)}`);
|
|
1020
|
+
// Apply webhook status update to the accessory
|
|
1021
|
+
await this.applyStatusToAccessory(uuid, dev, context);
|
|
1022
|
+
}
|
|
1023
|
+
catch (e) {
|
|
1024
|
+
this.errorLog(`Failed to handle webhook for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
this.debugSuccessLog(`Webhook handler registered for ${dev.deviceId}`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
catch (e) {
|
|
1031
|
+
this.debugLog(`Failed to register webhook handler for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
430
1032
|
}
|
|
431
1033
|
return instance.toAccessory();
|
|
432
1034
|
}
|
|
@@ -435,114 +1037,844 @@ export class SwitchBotMatterPlatform {
|
|
|
435
1037
|
*/
|
|
436
1038
|
async discoverDevices() {
|
|
437
1039
|
if (!this.switchBotAPI) {
|
|
438
|
-
this.
|
|
1040
|
+
this.debugLog('SwitchBot OpenAPI not configured; skipping discovery');
|
|
439
1041
|
return;
|
|
440
1042
|
}
|
|
441
1043
|
try {
|
|
1044
|
+
if (!this.apiTracker?.trySpend('discovery')) {
|
|
1045
|
+
this.warnLog('OpenAPI daily budget reached; skipping Matter discovery');
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
442
1048
|
const { response, statusCode } = await this.switchBotAPI.getDevices();
|
|
443
|
-
this.
|
|
444
|
-
if (statusCode
|
|
1049
|
+
this.debugLog(`SwitchBot getDevices response status: ${statusCode}`);
|
|
1050
|
+
if (isSuccessfulStatusCode(statusCode)) {
|
|
445
1051
|
const deviceList = Array.isArray(response?.body?.deviceList) ? response.body.deviceList : [];
|
|
446
1052
|
this.discoveredDevices = deviceList;
|
|
447
|
-
this.
|
|
1053
|
+
this.infoLog(`Discovered ${deviceList.length} SwitchBot device(s) from OpenAPI`);
|
|
448
1054
|
for (const d of deviceList) {
|
|
449
|
-
this.
|
|
1055
|
+
this.debugLog(` - ${d.deviceName} (${d.deviceType}) id=${d.deviceId}`);
|
|
1056
|
+
}
|
|
1057
|
+
const irDeviceList = Array.isArray(response?.body?.infraredRemoteList) ? response.body.infraredRemoteList : [];
|
|
1058
|
+
this.discoveredIRDevices = irDeviceList;
|
|
1059
|
+
this.infoLog(`Discovered ${irDeviceList.length} SwitchBot IR device(s) from OpenAPI`);
|
|
1060
|
+
for (const d of irDeviceList) {
|
|
1061
|
+
this.debugLog(` - ${d.deviceName} (${d.remoteType}) id=${d.deviceId}`);
|
|
450
1062
|
}
|
|
451
1063
|
}
|
|
452
1064
|
else {
|
|
453
|
-
this.
|
|
1065
|
+
this.warnLog(`SwitchBot getDevices returned status ${statusCode}`);
|
|
1066
|
+
// If rate limit exceeded (429), log specific message
|
|
1067
|
+
if (statusCode === 429) {
|
|
1068
|
+
this.warnLog('OpenAPI rate limit (429) exceeded during discovery.');
|
|
1069
|
+
this.warnLog('Webhook functionality will still work for manually configured devices.');
|
|
1070
|
+
this.warnLog('Device state updates will be limited until rate limit resets.');
|
|
1071
|
+
}
|
|
454
1072
|
}
|
|
455
1073
|
}
|
|
456
1074
|
catch (e) {
|
|
457
|
-
this.
|
|
1075
|
+
this.errorLog('Failed to discover SwitchBot devices:', e?.message ?? e);
|
|
458
1076
|
}
|
|
459
1077
|
}
|
|
460
1078
|
/**
|
|
461
|
-
*
|
|
1079
|
+
* Setup MQTT connection (if configured) and route incoming webhook messages
|
|
1080
|
+
* to registered webhook handlers. Mirrors behaviour in platform-hap.
|
|
462
1081
|
*/
|
|
463
|
-
async
|
|
464
|
-
|
|
465
|
-
while (retryCount < maxRetries) {
|
|
1082
|
+
async setupMqtt() {
|
|
1083
|
+
if (this.config.options?.mqttURL) {
|
|
466
1084
|
try {
|
|
467
|
-
|
|
468
|
-
|
|
1085
|
+
const { connectAsync } = asyncmqtt;
|
|
1086
|
+
this.mqttClient = await connectAsync(this.config.options?.mqttURL, this.config.options.mqttOptions || {});
|
|
1087
|
+
this.debugLog('MQTT connection has been established successfully.');
|
|
1088
|
+
this.mqttClient.on('error', async (e) => {
|
|
1089
|
+
this.errorLog(`Failed to publish MQTT messages. ${e.message ?? e}`);
|
|
1090
|
+
});
|
|
1091
|
+
if (!this.config.options?.webhookURL) {
|
|
1092
|
+
// receive webhook events via MQTT
|
|
1093
|
+
this.infoLog(`Webhook is configured to be received through ${this.config.options.mqttURL}/homebridge-switchbot/webhook.`);
|
|
1094
|
+
this.mqttClient.subscribe('homebridge-switchbot/webhook/+');
|
|
1095
|
+
this.mqttClient.on('message', async (topic, message) => {
|
|
1096
|
+
try {
|
|
1097
|
+
this.debugLog(`Received Webhook via MQTT: ${topic}=${message}`);
|
|
1098
|
+
const context = JSON.parse(message.toString());
|
|
1099
|
+
this.webhookEventHandler[context.deviceMac]?.(context);
|
|
1100
|
+
}
|
|
1101
|
+
catch (e) {
|
|
1102
|
+
this.errorLog(`Failed to handle webhook event. Error: ${e.message ?? e}`);
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
469
1105
|
}
|
|
470
|
-
const { response, statusCode } = await this.switchBotAPI.controlDevice(deviceObj.deviceId, bodyChange.command, bodyChange.parameter, bodyChange.commandType, this.config.credentials?.token, this.config.credentials?.secret);
|
|
471
|
-
return { response, statusCode };
|
|
472
1106
|
}
|
|
473
1107
|
catch (e) {
|
|
474
|
-
this.
|
|
1108
|
+
this.mqttClient = null;
|
|
1109
|
+
this.errorLog(`Failed to establish MQTT connection. ${e.message ?? e}`);
|
|
475
1110
|
}
|
|
476
|
-
retryCount++;
|
|
477
|
-
await sleep(delayBetweenRetries);
|
|
478
1111
|
}
|
|
479
|
-
return { response: {}, statusCode: 500 };
|
|
480
|
-
}
|
|
481
|
-
/**
|
|
482
|
-
* Required for DynamicPlatformPlugin
|
|
483
|
-
* Called when homebridge restores cached accessories from disk at startup
|
|
484
|
-
*/
|
|
485
|
-
configureAccessory( /* accessory: PlatformAccessory */) {
|
|
486
|
-
// Note this is not used for Matter accessories - use configureMatterAccessory instead
|
|
487
|
-
// This plugin does not have any hap accessories, so here we can comment this out
|
|
488
|
-
// this.accessories.set(accessory.UUID, accessory)
|
|
489
1112
|
}
|
|
490
1113
|
/**
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
* This is where you can access the `accessory.context` object to retrieve
|
|
494
|
-
* any custom data you stored when the accessory was originally registered.
|
|
1114
|
+
* Setup OpenAPI webhook (if webhookURL configured) and forward incoming
|
|
1115
|
+
* webhook events to MQTT (if configured) and local handlers.
|
|
495
1116
|
*/
|
|
496
|
-
|
|
497
|
-
this.
|
|
498
|
-
|
|
1117
|
+
async setupwebhook() {
|
|
1118
|
+
if (this.config.options?.webhookURL) {
|
|
1119
|
+
const url = this.config.options?.webhookURL;
|
|
1120
|
+
try {
|
|
1121
|
+
this.switchBotAPI?.setupWebhook(url);
|
|
1122
|
+
this.infoLog(`Webhook configured for URL: ${url}`);
|
|
1123
|
+
// Listen for webhook events
|
|
1124
|
+
this.switchBotAPI?.on('webhookEvent', (body) => {
|
|
1125
|
+
try {
|
|
1126
|
+
this.infoLog(`Received webhook event for device: ${body.context.deviceMac}`);
|
|
1127
|
+
if (this.config.options?.mqttURL) {
|
|
1128
|
+
const mac = body.context.deviceMac?.toLowerCase().match(/[\s\S]{1,2}/g)?.join(':');
|
|
1129
|
+
const options = this.config.options?.mqttPubOptions || {};
|
|
1130
|
+
this.mqttClient?.publish(`homebridge-switchbot/webhook/${mac}`, `${JSON.stringify(body.context)}`, options);
|
|
1131
|
+
}
|
|
1132
|
+
this.webhookEventHandler[body.context.deviceMac]?.(body.context);
|
|
1133
|
+
}
|
|
1134
|
+
catch (e) {
|
|
1135
|
+
this.errorLog(`Failed to handle webhook event. Error: ${e.message ?? e}`);
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
catch (e) {
|
|
1140
|
+
this.errorLog(`Failed to setup webhook. Error: ${e.message ?? e}`);
|
|
1141
|
+
}
|
|
1142
|
+
this.api.on('shutdown', async () => {
|
|
1143
|
+
try {
|
|
1144
|
+
this.switchBotAPI?.deleteWebhook(url);
|
|
1145
|
+
}
|
|
1146
|
+
catch (e) {
|
|
1147
|
+
this.errorLog(`Failed to delete webhook. Error: ${e.message ?? e}`);
|
|
1148
|
+
}
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Retry wrapper for control commands using SwitchBot OpenAPI
|
|
1154
|
+
*/
|
|
1155
|
+
async retryCommand(deviceObj, bodyChange, maxRetries = 1, delayBetweenRetries = 1000) {
|
|
1156
|
+
let retryCount = 0;
|
|
1157
|
+
while (retryCount < maxRetries) {
|
|
1158
|
+
try {
|
|
1159
|
+
if (!this.switchBotAPI) {
|
|
1160
|
+
throw new Error('SwitchBot OpenAPI not initialized');
|
|
1161
|
+
}
|
|
1162
|
+
if (!this.apiTracker?.trySpend('command')) {
|
|
1163
|
+
return { response: {}, statusCode: 429 };
|
|
1164
|
+
}
|
|
1165
|
+
const { response, statusCode } = await this.switchBotAPI.controlDevice(deviceObj.deviceId, bodyChange.command, bodyChange.parameter, bodyChange.commandType, this.config.credentials?.token, this.config.credentials?.secret);
|
|
1166
|
+
return { response, statusCode };
|
|
1167
|
+
}
|
|
1168
|
+
catch (e) {
|
|
1169
|
+
this.debugLog(`retryCommand error: ${e?.message ?? e}`);
|
|
1170
|
+
}
|
|
1171
|
+
retryCount++;
|
|
1172
|
+
await sleep(delayBetweenRetries);
|
|
1173
|
+
}
|
|
1174
|
+
return { response: {}, statusCode: 500 };
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Parse BLE advertisement/serviceData into normalized fields for a given device.
|
|
1178
|
+
* Returns null when serviceData is falsy or parsing fails.
|
|
1179
|
+
*/
|
|
1180
|
+
parseAdvertisementForDevice(dev, serviceData) {
|
|
1181
|
+
if (!serviceData) {
|
|
1182
|
+
return null;
|
|
1183
|
+
}
|
|
1184
|
+
try {
|
|
1185
|
+
const sd = serviceData;
|
|
1186
|
+
const result = {};
|
|
1187
|
+
// Power/on state - supports multiple field names used by different models
|
|
1188
|
+
const power = sd.power ?? sd.on ?? sd.p;
|
|
1189
|
+
if (power !== undefined) {
|
|
1190
|
+
result.power = (String(power).toLowerCase() === 'on' || Number(power) === 1);
|
|
1191
|
+
}
|
|
1192
|
+
// Brightness (0-100)
|
|
1193
|
+
const brightness = sd.brightness ?? sd.b;
|
|
1194
|
+
if (brightness !== undefined) {
|
|
1195
|
+
result.brightness = Number(brightness);
|
|
1196
|
+
}
|
|
1197
|
+
// Color - could be 'r:g:b', '#rrggbb' or 'rrggbb'
|
|
1198
|
+
const color = sd.color ?? sd.rgb ?? sd.c;
|
|
1199
|
+
if (color !== undefined) {
|
|
1200
|
+
let r = 0;
|
|
1201
|
+
let g = 0;
|
|
1202
|
+
let b = 0;
|
|
1203
|
+
const c = String(color);
|
|
1204
|
+
if (c.includes(':')) {
|
|
1205
|
+
const parts = c.split(':').map(Number);
|
|
1206
|
+
[r, g, b] = parts;
|
|
1207
|
+
}
|
|
1208
|
+
else if (c.includes(',')) {
|
|
1209
|
+
const parts = c.split(',').map(s => Number(s.trim()));
|
|
1210
|
+
[r, g, b] = parts;
|
|
1211
|
+
}
|
|
1212
|
+
else if (c.includes(' ')) {
|
|
1213
|
+
const parts = c.split(' ').map(s => Number(s.trim()));
|
|
1214
|
+
[r, g, b] = parts;
|
|
1215
|
+
}
|
|
1216
|
+
else if (c.startsWith('#')) {
|
|
1217
|
+
const hex = c.replace('#', '');
|
|
1218
|
+
r = Number.parseInt(hex.substring(0, 2), 16);
|
|
1219
|
+
g = Number.parseInt(hex.substring(2, 4), 16);
|
|
1220
|
+
b = Number.parseInt(hex.substring(4, 6), 16);
|
|
1221
|
+
}
|
|
1222
|
+
else if (/^[0-9a-f]{6}$/i.test(c)) {
|
|
1223
|
+
r = Number.parseInt(c.substring(0, 2), 16);
|
|
1224
|
+
g = Number.parseInt(c.substring(2, 4), 16);
|
|
1225
|
+
b = Number.parseInt(c.substring(4, 6), 16);
|
|
1226
|
+
}
|
|
1227
|
+
result.color = { r, g, b };
|
|
1228
|
+
}
|
|
1229
|
+
// Battery (some devices use battery or batt)
|
|
1230
|
+
const battery = sd.battery ?? sd.batt;
|
|
1231
|
+
if (battery !== undefined) {
|
|
1232
|
+
result.battery = Number(battery);
|
|
1233
|
+
}
|
|
1234
|
+
// VOC / TVOC (some air quality devices report total volatile organic compounds)
|
|
1235
|
+
const voc = sd.voc ?? sd.tvoc;
|
|
1236
|
+
if (voc !== undefined) {
|
|
1237
|
+
result.voc = Number(voc);
|
|
1238
|
+
}
|
|
1239
|
+
// PM10 (some devices report PM10 alongside PM2.5)
|
|
1240
|
+
const pm10 = sd.pm10;
|
|
1241
|
+
if (pm10 !== undefined) {
|
|
1242
|
+
result.pm10 = Number(pm10);
|
|
1243
|
+
}
|
|
1244
|
+
// PM2.5 (some BLE adverts use pm25 / pm_2_5)
|
|
1245
|
+
const pm25 = sd.pm2_5 ?? sd.pm25 ?? sd.pm_2_5;
|
|
1246
|
+
if (pm25 !== undefined) {
|
|
1247
|
+
result.pm25 = Number(pm25);
|
|
1248
|
+
}
|
|
1249
|
+
// CO2 (carbon dioxide ppm)
|
|
1250
|
+
const co2 = sd.co2 ?? sd.co2ppm ?? sd.carbonDioxide;
|
|
1251
|
+
if (co2 !== undefined) {
|
|
1252
|
+
result.co2 = Number(co2);
|
|
1253
|
+
}
|
|
1254
|
+
// Temperature (C) and Humidity (%) — support common shorthand keys
|
|
1255
|
+
const temperature = sd.temperature ?? sd.temp ?? sd.t;
|
|
1256
|
+
if (temperature !== undefined) {
|
|
1257
|
+
result.temperature = Number(temperature);
|
|
1258
|
+
}
|
|
1259
|
+
const humidity = sd.humidity ?? sd.h ?? sd.humid;
|
|
1260
|
+
if (humidity !== undefined) {
|
|
1261
|
+
result.humidity = Number(humidity);
|
|
1262
|
+
}
|
|
1263
|
+
// Motion, Contact, Leak
|
|
1264
|
+
const motion = sd.motion ?? sd.m;
|
|
1265
|
+
if (motion !== undefined) {
|
|
1266
|
+
result.motion = Boolean(motion);
|
|
1267
|
+
}
|
|
1268
|
+
const contact = sd.contact ?? sd.open;
|
|
1269
|
+
if (contact !== undefined) {
|
|
1270
|
+
result.contact = contact;
|
|
1271
|
+
}
|
|
1272
|
+
const leak = sd.leak ?? sd.water;
|
|
1273
|
+
if (leak !== undefined) {
|
|
1274
|
+
result.leak = Boolean(leak);
|
|
1275
|
+
}
|
|
1276
|
+
// Position / Cover / Curtain synonyms
|
|
1277
|
+
const position = sd.position ?? sd.percent ?? sd.slidePosition ?? sd.curtainPosition;
|
|
1278
|
+
if (position !== undefined) {
|
|
1279
|
+
result.position = Number(position);
|
|
1280
|
+
}
|
|
1281
|
+
// Fan speed/speed
|
|
1282
|
+
const fanSpeed = sd.fanSpeed ?? sd.speed;
|
|
1283
|
+
if (fanSpeed !== undefined) {
|
|
1284
|
+
result.fanSpeed = Number(fanSpeed);
|
|
1285
|
+
}
|
|
1286
|
+
// Lock state
|
|
1287
|
+
const lock = sd.lock;
|
|
1288
|
+
if (lock !== undefined) {
|
|
1289
|
+
result.lock = lock;
|
|
1290
|
+
}
|
|
1291
|
+
// Robot vacuum fields
|
|
1292
|
+
const rvcRunMode = sd.rvcRunMode;
|
|
1293
|
+
if (rvcRunMode !== undefined) {
|
|
1294
|
+
result.rvcRunMode = rvcRunMode;
|
|
1295
|
+
}
|
|
1296
|
+
const rvcOperationalState = sd.rvcOperationalState;
|
|
1297
|
+
if (rvcOperationalState !== undefined) {
|
|
1298
|
+
result.rvcOperationalState = rvcOperationalState;
|
|
1299
|
+
}
|
|
1300
|
+
return result;
|
|
1301
|
+
}
|
|
1302
|
+
catch (e) {
|
|
1303
|
+
this.debugLog(`parseAdvertisementForDevice failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1304
|
+
return null;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Central helper to apply a SwitchBot status object to a Matter accessory.
|
|
1309
|
+
* Tries to call accessory instance update helpers when available, otherwise
|
|
1310
|
+
* falls back to calling api.matter.updateAccessoryState directly.
|
|
1311
|
+
*/
|
|
1312
|
+
async applyStatusToAccessory(uuidLocal, dev, status) {
|
|
1313
|
+
if (!status) {
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
const instance = dev?.deviceId ? this.accessoryInstances.get(this.normalizeDeviceId(dev.deviceId)) : undefined;
|
|
1317
|
+
// Helper to safely call instance methods or fallback to api.matter.updateAccessoryState
|
|
1318
|
+
const safeUpdate = async (cluster, attributes, methodName) => {
|
|
1319
|
+
try {
|
|
1320
|
+
// Special-case: powerSource cluster is optional on many devices (e.g., Curtains/Blinds).
|
|
1321
|
+
// To avoid noisy Matter server errors ("Behavior ID powerSource does not exist"),
|
|
1322
|
+
// always use the direct updateAccessoryState path wrapped in a guard for this cluster,
|
|
1323
|
+
// even when an accessory instance is present.
|
|
1324
|
+
const powerClusterName = (this.api.matter?.clusterNames && this.api.matter.clusterNames.PowerSource)
|
|
1325
|
+
? this.api.matter.clusterNames.PowerSource
|
|
1326
|
+
: 'powerSource';
|
|
1327
|
+
const isPowerSourceCluster = cluster === powerClusterName || cluster === 'powerSource';
|
|
1328
|
+
// If the accessory instance declares supported clusters, skip updates for clusters
|
|
1329
|
+
// not present to avoid triggering Matter server errors and logs.
|
|
1330
|
+
let clusterSupported = true;
|
|
1331
|
+
if (instance && instance.clusters) {
|
|
1332
|
+
const declared = instance.clusters;
|
|
1333
|
+
if (Array.isArray(declared)) {
|
|
1334
|
+
clusterSupported = declared.includes(cluster);
|
|
1335
|
+
}
|
|
1336
|
+
else if (typeof declared === 'object') {
|
|
1337
|
+
clusterSupported = Object.prototype.hasOwnProperty.call(declared, cluster);
|
|
1338
|
+
}
|
|
1339
|
+
if (!clusterSupported) {
|
|
1340
|
+
this.debugLog(`Cluster ${cluster} not declared on accessory for ${dev.deviceId}, skipping update`);
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
if (instance && methodName && typeof instance[methodName] === 'function') {
|
|
1345
|
+
// prefer device-specific update helpers when available
|
|
1346
|
+
await instance[methodName](...(Object.values(attributes)));
|
|
1347
|
+
}
|
|
1348
|
+
else if (!isPowerSourceCluster && instance && typeof instance.updateState === 'function') {
|
|
1349
|
+
// some accessories expose updateState that accepts cluster and attributes
|
|
1350
|
+
await instance.updateState(cluster, attributes);
|
|
1351
|
+
}
|
|
1352
|
+
else {
|
|
1353
|
+
try {
|
|
1354
|
+
await this.api.matter.updateAccessoryState(uuidLocal, cluster, attributes);
|
|
1355
|
+
}
|
|
1356
|
+
catch (updateError) {
|
|
1357
|
+
// Silently ignore "does not exist" errors for clusters that aren't
|
|
1358
|
+
// supported by this device type (e.g., powerSource on WindowBlind).
|
|
1359
|
+
const msg = String(updateError?.message ?? updateError);
|
|
1360
|
+
if (msg.includes('does not exist') || msg.includes('not found')) {
|
|
1361
|
+
this.debugLog(`Cluster ${cluster} not available on ${dev.deviceId}, skipping update`);
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
throw updateError;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
catch (e) {
|
|
1369
|
+
this.debugLog(`safeUpdate failed for ${dev.deviceId} cluster=${cluster}: ${e?.message ?? e}`);
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
try {
|
|
1373
|
+
// On/Off
|
|
1374
|
+
if (status?.power !== undefined) {
|
|
1375
|
+
const on = (String(status.power).toLowerCase() === 'on' || Number(status.power) === 1 || Boolean(status.power) === true);
|
|
1376
|
+
await safeUpdate(this.api.matter.clusterNames.OnOff, { onOff: on }, 'updateOnOffState');
|
|
1377
|
+
}
|
|
1378
|
+
// Robot vacuum: some OpenAPI responses use 'runState' or similar textual
|
|
1379
|
+
// fields to indicate cleaning/mapping/idle. Map common textual values to
|
|
1380
|
+
// the numeric run mode values expected by the RoboticVacuumAccessory and
|
|
1381
|
+
// prefer calling accessory helpers when present.
|
|
1382
|
+
if (status?.runState !== undefined || status?.run_state !== undefined || status?.run !== undefined) {
|
|
1383
|
+
try {
|
|
1384
|
+
const raw = status?.runState ?? status?.run_state ?? status?.run;
|
|
1385
|
+
let mode;
|
|
1386
|
+
if (typeof raw === 'number') {
|
|
1387
|
+
mode = Number(raw);
|
|
1388
|
+
}
|
|
1389
|
+
else if (typeof raw === 'string') {
|
|
1390
|
+
const s = raw.toLowerCase();
|
|
1391
|
+
if (s.includes('clean')) {
|
|
1392
|
+
mode = 1; // Cleaning
|
|
1393
|
+
}
|
|
1394
|
+
else if (s.includes('map')) {
|
|
1395
|
+
mode = 2; // Mapping
|
|
1396
|
+
}
|
|
1397
|
+
else if (s.includes('idle') || s.includes('stop') || s.includes('dock') || s.includes('charge') || s.includes('docked')) {
|
|
1398
|
+
mode = 0; // Idle
|
|
1399
|
+
}
|
|
1400
|
+
else {
|
|
1401
|
+
const n = Number(raw);
|
|
1402
|
+
if (!Number.isNaN(n)) {
|
|
1403
|
+
mode = n;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
if (mode !== undefined) {
|
|
1408
|
+
await safeUpdate('rvcRunMode', { currentMode: Number(mode) }, 'updateRunMode');
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
catch (e) {
|
|
1412
|
+
this.debugLog(`Failed to apply runState for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
// Brightness
|
|
1416
|
+
if (status?.brightness !== undefined) {
|
|
1417
|
+
const rawBrightness = Number(status.brightness);
|
|
1418
|
+
const clampedBrightness = Math.max(0, Math.min(100, rawBrightness));
|
|
1419
|
+
// If instance has updateBrightness method, it expects percentage (0-100)
|
|
1420
|
+
// Otherwise, updateAccessoryState expects Matter-scaled value (0-254)
|
|
1421
|
+
if (instance && typeof instance.updateBrightness === 'function') {
|
|
1422
|
+
this.debugLog(`[Brightness Debug] Device ${dev.deviceId}: calling updateBrightness with percent=${clampedBrightness}`);
|
|
1423
|
+
await safeUpdate(this.api.matter.clusterNames.LevelControl, { currentLevel: clampedBrightness }, 'updateBrightness');
|
|
1424
|
+
}
|
|
1425
|
+
else {
|
|
1426
|
+
const level = Math.round((clampedBrightness / 100) * 254);
|
|
1427
|
+
const clampedLevel = Math.max(0, Math.min(254, level));
|
|
1428
|
+
this.debugLog(`[Brightness Debug] Device ${dev.deviceId}: calling updateAccessoryState with rawBrightness=${rawBrightness}, level=${clampedLevel}`);
|
|
1429
|
+
await safeUpdate(this.api.matter.clusterNames.LevelControl, { currentLevel: clampedLevel });
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
// Color
|
|
1433
|
+
if (status?.color !== undefined) {
|
|
1434
|
+
const color = String(status.color);
|
|
1435
|
+
let r = 0;
|
|
1436
|
+
let g = 0;
|
|
1437
|
+
let b = 0;
|
|
1438
|
+
if (color.includes(':')) {
|
|
1439
|
+
const parts = color.split(':').map(Number);
|
|
1440
|
+
[r, g, b] = parts;
|
|
1441
|
+
}
|
|
1442
|
+
else if (color.includes(',')) {
|
|
1443
|
+
const parts = color.split(',').map(s => Number(s.trim()));
|
|
1444
|
+
[r, g, b] = parts;
|
|
1445
|
+
}
|
|
1446
|
+
else if (color.includes(' ')) {
|
|
1447
|
+
const parts = color.split(' ').map(s => Number(s.trim()));
|
|
1448
|
+
[r, g, b] = parts;
|
|
1449
|
+
}
|
|
1450
|
+
else if (color.startsWith('#')) {
|
|
1451
|
+
const hex = color.replace('#', '');
|
|
1452
|
+
r = Number.parseInt(hex.substring(0, 2), 16);
|
|
1453
|
+
g = Number.parseInt(hex.substring(2, 4), 16);
|
|
1454
|
+
b = Number.parseInt(hex.substring(4, 6), 16);
|
|
1455
|
+
}
|
|
1456
|
+
const [h, s] = rgb2hs(r, g, b);
|
|
1457
|
+
const clampedH = Math.max(0, Math.min(360, h));
|
|
1458
|
+
const clampedS = Math.max(0, Math.min(100, s));
|
|
1459
|
+
// If instance has updateHueSaturation method, it expects raw values (h: 0-360, s: 0-100)
|
|
1460
|
+
// Otherwise, updateAccessoryState expects Matter-scaled values (0-254)
|
|
1461
|
+
if (instance && typeof instance.updateHueSaturation === 'function') {
|
|
1462
|
+
this.debugLog(`[Color Debug] Device ${dev.deviceId}: calling updateHueSaturation with color="${color}", RGB=[${r},${g},${b}], HS=[${clampedH},${clampedS}]`);
|
|
1463
|
+
await safeUpdate(this.api.matter.clusterNames.ColorControl, { currentHue: clampedH, currentSaturation: clampedS }, 'updateHueSaturation');
|
|
1464
|
+
}
|
|
1465
|
+
else {
|
|
1466
|
+
const hue = Math.round((clampedH / 360) * 254);
|
|
1467
|
+
const sat = Math.round((clampedS / 100) * 254);
|
|
1468
|
+
const clampedHue = Math.max(0, Math.min(254, hue));
|
|
1469
|
+
const clampedSat = Math.max(0, Math.min(254, sat));
|
|
1470
|
+
this.debugLog(`[Color Debug] Device ${dev.deviceId}: calling updateAccessoryState with color="${color}", RGB=[${r},${g},${b}], HS=[${h},${s}], Matter=[${clampedHue},${clampedSat}]`);
|
|
1471
|
+
await safeUpdate(this.api.matter.clusterNames.ColorControl, { currentHue: clampedHue, currentSaturation: clampedSat });
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
// Battery/powerSource (support many possible field names)
|
|
1475
|
+
// Note: Some device types like WindowBlind don't support PowerSource cluster
|
|
1476
|
+
if (status?.battery !== undefined || status?.batt !== undefined || status?.batteryLevel !== undefined || status?.batteryPercentage !== undefined || status?.battery_level !== undefined) {
|
|
1477
|
+
// Skip battery updates for device types that don't support PowerSource cluster
|
|
1478
|
+
const deviceType = String(status?.deviceType ?? dev?.deviceType ?? '');
|
|
1479
|
+
const unsupportedTypes = ['Curtain', 'Curtain2', 'Curtain3', 'Curtain 2', 'Blind Tilt'];
|
|
1480
|
+
if (unsupportedTypes.includes(deviceType)) {
|
|
1481
|
+
this.debugLog(`Device ${dev.deviceId} type ${deviceType} does not support PowerSource cluster, skipping battery update`);
|
|
1482
|
+
}
|
|
1483
|
+
else {
|
|
1484
|
+
try {
|
|
1485
|
+
const percentage = Number(status?.battery ?? status?.batt ?? status?.batteryPercentage ?? status?.batteryLevel ?? status?.battery_level);
|
|
1486
|
+
const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)));
|
|
1487
|
+
let batChargeLevel = 0;
|
|
1488
|
+
if (percentage < 20) {
|
|
1489
|
+
batChargeLevel = 2;
|
|
1490
|
+
}
|
|
1491
|
+
else if (percentage < 40) {
|
|
1492
|
+
batChargeLevel = 1;
|
|
1493
|
+
}
|
|
1494
|
+
const powerCluster = (this.api.matter?.clusterNames && this.api.matter.clusterNames.PowerSource) ? this.api.matter.clusterNames.PowerSource : 'powerSource';
|
|
1495
|
+
await safeUpdate(powerCluster, { batPercentRemaining, batChargeLevel }, 'updateBatteryPercentage');
|
|
1496
|
+
}
|
|
1497
|
+
catch (e) {
|
|
1498
|
+
this.debugLog(`Failed to apply battery status for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
// Temperature + thermostat
|
|
1503
|
+
if (status?.temperature !== undefined || status?.temp !== undefined) {
|
|
1504
|
+
try {
|
|
1505
|
+
const c = Number(status?.temperature ?? status?.temp);
|
|
1506
|
+
const measured = Math.round(c * 100);
|
|
1507
|
+
await safeUpdate('temperatureMeasurement', { measuredValue: measured }, 'updateTemperature');
|
|
1508
|
+
// Thermostat-specific mapping
|
|
1509
|
+
if (status?.targetTemp !== undefined || status?.targetTemperature !== undefined || status?.heatingSetpoint !== undefined) {
|
|
1510
|
+
const target = Number(status?.targetTemp ?? status?.targetTemperature ?? status?.heatingSetpoint);
|
|
1511
|
+
const val = Math.round(target * 100);
|
|
1512
|
+
await safeUpdate('thermostat', { occupiedHeatingSetpoint: val }, 'updateHeatingSetpoint');
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
catch (e) {
|
|
1516
|
+
this.debugLog(`Failed to apply temperature for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
// Humidity (support different keys)
|
|
1520
|
+
if (status?.humidity !== undefined || status?.h !== undefined || status?.humid !== undefined) {
|
|
1521
|
+
try {
|
|
1522
|
+
const percent = Number(status?.humidity ?? status?.h ?? status?.humid);
|
|
1523
|
+
const measured = Math.round(percent * 100);
|
|
1524
|
+
await safeUpdate('relativeHumidityMeasurement', { measuredValue: measured }, 'updateHumidity');
|
|
1525
|
+
}
|
|
1526
|
+
catch (e) {
|
|
1527
|
+
this.debugLog(`Failed to apply humidity for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
// Contact / Leak -> BooleanState
|
|
1531
|
+
if (status?.contact !== undefined || status?.open !== undefined || status?.leak !== undefined || status?.water !== undefined) {
|
|
1532
|
+
try {
|
|
1533
|
+
const isContactOpen = status?.contact ?? status?.open;
|
|
1534
|
+
if (isContactOpen !== undefined) {
|
|
1535
|
+
if ((dev.deviceType || '').includes('Contact')) {
|
|
1536
|
+
await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: !(String(isContactOpen).toLowerCase() === 'true' || Number(isContactOpen) === 1) }, 'updateContactState');
|
|
1537
|
+
}
|
|
1538
|
+
else {
|
|
1539
|
+
await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: (String(isContactOpen).toLowerCase() === 'true' || Number(isContactOpen) === 1) }, 'updateContactState');
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
const leakDetected = status?.leak ?? status?.water;
|
|
1543
|
+
if (leakDetected !== undefined) {
|
|
1544
|
+
await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: Boolean(leakDetected) }, 'updateLeakState');
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
catch (e) {
|
|
1548
|
+
this.debugLog(`Failed to apply contact/leak for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
// Motion -> occupancy
|
|
1552
|
+
if (status?.motion !== undefined || status?.m !== undefined) {
|
|
1553
|
+
try {
|
|
1554
|
+
const detected = Boolean(status?.motion ?? status?.m);
|
|
1555
|
+
await safeUpdate('occupancySensing', { occupancy: { occupied: detected } }, 'updateOccupancy');
|
|
1556
|
+
}
|
|
1557
|
+
catch (e) {
|
|
1558
|
+
this.debugLog(`Failed to apply motion for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
// Lock state
|
|
1562
|
+
if (status?.lock !== undefined) {
|
|
1563
|
+
try {
|
|
1564
|
+
const s = String(status.lock).toLowerCase();
|
|
1565
|
+
let lockState = 0;
|
|
1566
|
+
if (s === 'locked' || s === '1' || s === 'true') {
|
|
1567
|
+
lockState = 1;
|
|
1568
|
+
}
|
|
1569
|
+
else if (s === 'unlocked' || s === '0' || s === 'false') {
|
|
1570
|
+
lockState = 2;
|
|
1571
|
+
}
|
|
1572
|
+
await safeUpdate(this.api.matter.clusterNames.DoorLock, { lockState }, 'updateLockState');
|
|
1573
|
+
}
|
|
1574
|
+
catch (e) {
|
|
1575
|
+
this.debugLog(`Failed to apply lock for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
// Cover position
|
|
1579
|
+
if (status?.position !== undefined || status?.percent !== undefined) {
|
|
1580
|
+
try {
|
|
1581
|
+
const openPercent = Number(status?.position ?? status?.percent);
|
|
1582
|
+
const closedPercent = 100 - Math.max(0, Math.min(100, openPercent));
|
|
1583
|
+
const value = Math.round(closedPercent * 100);
|
|
1584
|
+
await safeUpdate(this.api.matter.clusterNames.WindowCovering, { currentPositionLiftPercent100ths: value, targetPositionLiftPercent100ths: value }, 'updateLiftPosition');
|
|
1585
|
+
}
|
|
1586
|
+
catch (e) {
|
|
1587
|
+
this.debugLog(`Failed to apply cover position for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
// Fan
|
|
1591
|
+
if (status?.fanSpeed !== undefined || status?.speed !== undefined) {
|
|
1592
|
+
try {
|
|
1593
|
+
const percent = Number(status?.fanSpeed ?? status?.speed);
|
|
1594
|
+
await safeUpdate(this.api.matter.clusterNames.FanControl, { percentSetting: percent, percentCurrent: percent }, 'updateFanSpeed');
|
|
1595
|
+
}
|
|
1596
|
+
catch (e) {
|
|
1597
|
+
this.debugLog(`Failed to apply fan speed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
// Robot vacuum: run/operational/clean modes
|
|
1601
|
+
if (status?.rvcRunMode !== undefined) {
|
|
1602
|
+
try {
|
|
1603
|
+
await safeUpdate('rvcRunMode', { currentMode: Number(status.rvcRunMode) }, 'updateRunMode');
|
|
1604
|
+
}
|
|
1605
|
+
catch (e) {
|
|
1606
|
+
this.debugLog(`Failed to apply rvcRunMode for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
// CO2 (carbon dioxide) - support common synonyms
|
|
1610
|
+
if (status?.co2 !== undefined || status?.co2ppm !== undefined || status?.carbonDioxide !== undefined) {
|
|
1611
|
+
try {
|
|
1612
|
+
const val = Number(status?.co2 ?? status?.co2ppm ?? status?.carbonDioxide);
|
|
1613
|
+
await safeUpdate('carbonDioxide', { carbonDioxideLevel: val }, 'updateCO2');
|
|
1614
|
+
}
|
|
1615
|
+
catch (e) {
|
|
1616
|
+
this.debugLog(`Failed to apply CO2 for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
// PM2.5 / particulate matter
|
|
1620
|
+
if (status?.pm2_5 !== undefined || status?.pm25 !== undefined || status?.pm_2_5 !== undefined) {
|
|
1621
|
+
try {
|
|
1622
|
+
const pm = Number(status?.pm2_5 ?? status?.pm25 ?? status?.pm_2_5);
|
|
1623
|
+
await safeUpdate('pm2_5', { pm25: pm }, 'updatePM25');
|
|
1624
|
+
}
|
|
1625
|
+
catch (e) {
|
|
1626
|
+
this.debugLog(`Failed to apply PM2.5 for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
// PM10 (some devices report pm10)
|
|
1630
|
+
if (status?.pm10 !== undefined) {
|
|
1631
|
+
try {
|
|
1632
|
+
const pm10 = Number(status?.pm10);
|
|
1633
|
+
await safeUpdate('pm10', { pm10 }, 'updatePM10');
|
|
1634
|
+
}
|
|
1635
|
+
catch (e) {
|
|
1636
|
+
this.debugLog(`Failed to apply PM10 for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
// VOC / TVOC - volatile organic compounds
|
|
1640
|
+
if (status?.voc !== undefined || status?.tvoc !== undefined) {
|
|
1641
|
+
try {
|
|
1642
|
+
const val = Number(status?.voc ?? status?.tvoc);
|
|
1643
|
+
await safeUpdate('voc', { voc: val }, 'updateVOC');
|
|
1644
|
+
}
|
|
1645
|
+
catch (e) {
|
|
1646
|
+
this.debugLog(`Failed to apply VOC for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
if (status?.rvcOperationalState !== undefined) {
|
|
1650
|
+
try {
|
|
1651
|
+
await safeUpdate('rvcOperationalState', { operationalState: Number(status.rvcOperationalState) }, 'updateOperationalState');
|
|
1652
|
+
}
|
|
1653
|
+
catch (e) {
|
|
1654
|
+
this.debugLog(`Failed to apply rvcOperationalState for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
catch (e) {
|
|
1659
|
+
this.debugLog(`applyStatusToAccessory top-level failure for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
/**
|
|
1663
|
+
* Required for DynamicPlatformPlugin
|
|
1664
|
+
* Called when homebridge restores cached accessories from disk at startup
|
|
1665
|
+
*/
|
|
1666
|
+
configureAccessory( /* accessory: PlatformAccessory */) {
|
|
1667
|
+
// Note this is not used for Matter accessories - use configureMatterAccessory instead
|
|
1668
|
+
// This plugin does not have any hap accessories, so here we can comment this out
|
|
1669
|
+
// this.accessories.set(accessory.UUID, accessory)
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Called when homebridge restores cached Matter accessories from disk at startup.
|
|
1673
|
+
*
|
|
1674
|
+
* This is where you can access the `accessory.context` object to retrieve
|
|
1675
|
+
* any custom data you stored when the accessory was originally registered.
|
|
1676
|
+
*/
|
|
1677
|
+
configureMatterAccessory(accessory) {
|
|
1678
|
+
this.debugLog('Loading cached Matter accessory:', accessory.displayName);
|
|
1679
|
+
this.matterAccessories.set(accessory.uuid, accessory);
|
|
499
1680
|
}
|
|
500
1681
|
/**
|
|
501
1682
|
* Register all Matter accessories
|
|
502
1683
|
*/
|
|
503
1684
|
async registerMatterAccessories() {
|
|
504
|
-
this.log.info('═'.repeat(80));
|
|
505
|
-
this.log.info('Homebridge Matter Plugin');
|
|
506
|
-
this.log.info('═'.repeat(80));
|
|
507
1685
|
// Remove accessories that are disabled in config
|
|
508
1686
|
await this.removeDisabledAccessories();
|
|
509
1687
|
// If we discovered real SwitchBot devices via OpenAPI, map and register them
|
|
510
1688
|
if (this.discoveredDevices && this.discoveredDevices.length > 0) {
|
|
511
|
-
this.
|
|
512
|
-
|
|
513
|
-
|
|
1689
|
+
this.infoLog(`Registering ${this.discoveredDevices.length} discovered SwitchBot device(s) as Matter accessories`);
|
|
1690
|
+
// Merge device config (deviceConfig per deviceType and per-device overrides) to match HAP behavior
|
|
1691
|
+
const devicesToProcess = await this.mergeDiscoveredDevices(this.discoveredDevices);
|
|
1692
|
+
// By default, automatically remove previously-registered Matter
|
|
1693
|
+
// accessories whose deviceId is not present in the merged discovered
|
|
1694
|
+
// list. If the user explicitly sets `options.keepStaleAccessories` to
|
|
1695
|
+
// true, then we will keep previously-registered accessories (legacy
|
|
1696
|
+
// behavior).
|
|
1697
|
+
if (this.config.options?.keepStaleAccessories) {
|
|
1698
|
+
this.debugLog('Keeping previously-registered stale accessories because options.keepStaleAccessories=true');
|
|
1699
|
+
}
|
|
1700
|
+
else {
|
|
1701
|
+
try {
|
|
1702
|
+
const desiredIds = new Set((devicesToProcess || []).map((d) => this.normalizeDeviceId(d.deviceId)));
|
|
1703
|
+
const toUnregister = [];
|
|
1704
|
+
for (const [uuid, acc] of Array.from(this.matterAccessories.entries())) {
|
|
1705
|
+
try {
|
|
1706
|
+
const deviceId = acc?.context?.deviceId;
|
|
1707
|
+
if (!deviceId) {
|
|
1708
|
+
continue;
|
|
1709
|
+
}
|
|
1710
|
+
const nid = this.normalizeDeviceId(deviceId);
|
|
1711
|
+
if (!desiredIds.has(nid)) {
|
|
1712
|
+
// Accessory exists but is no longer desired -> schedule for removal
|
|
1713
|
+
this.infoLog(`Removing previously-registered accessory for deviceId=${deviceId} (no longer discovered or configured)`);
|
|
1714
|
+
try {
|
|
1715
|
+
this.clearDeviceResources(deviceId);
|
|
1716
|
+
}
|
|
1717
|
+
catch (e) {
|
|
1718
|
+
this.debugLog(`Failed to clear resources for ${deviceId} before unregister: ${e?.message ?? e}`);
|
|
1719
|
+
}
|
|
1720
|
+
toUnregister.push(acc);
|
|
1721
|
+
this.matterAccessories.delete(uuid);
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
catch (e) {
|
|
1725
|
+
this.debugLog(`Error while checking existing accessory ${uuid}: ${e?.message ?? e}`);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
if (toUnregister.length > 0) {
|
|
1729
|
+
try {
|
|
1730
|
+
await this.api.matter.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, toUnregister);
|
|
1731
|
+
}
|
|
1732
|
+
catch (e) {
|
|
1733
|
+
this.debugLog(`Failed to unregister accessories: ${e?.message ?? e}`);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
catch (e) {
|
|
1738
|
+
this.debugLog(`Failed to remove stale accessories: ${e?.message ?? e}`);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
// We'll separate discovered devices into two buckets:
|
|
1742
|
+
// - platformAccessories: accessories that will be hosted under the plugin's Matter bridge
|
|
1743
|
+
// - roboticAccessories: robot vacuum devices which require standalone commissioning behaviour
|
|
1744
|
+
const platformAccessories = [];
|
|
1745
|
+
const roboticAccessories = [];
|
|
1746
|
+
// Known robot vacuum deviceType names (matches mapping in createAccessoryFromDevice)
|
|
1747
|
+
const robotTypes = new Set([
|
|
1748
|
+
'K10+',
|
|
1749
|
+
'K10+ Pro',
|
|
1750
|
+
'WoSweeper',
|
|
1751
|
+
'WoSweeperMini',
|
|
1752
|
+
'Robot Vacuum Cleaner S1',
|
|
1753
|
+
'Robot Vacuum Cleaner S1 Plus',
|
|
1754
|
+
'Robot Vacuum Cleaner S10',
|
|
1755
|
+
'Robot Vacuum Cleaner S1 Pro',
|
|
1756
|
+
'Robot Vacuum Cleaner S1 Mini',
|
|
1757
|
+
]);
|
|
1758
|
+
for (const dev of devicesToProcess) {
|
|
514
1759
|
try {
|
|
515
1760
|
const acc = await this.createAccessoryFromDevice(dev);
|
|
516
|
-
if (acc) {
|
|
517
|
-
|
|
1761
|
+
if (!acc) {
|
|
1762
|
+
continue;
|
|
1763
|
+
}
|
|
1764
|
+
if (robotTypes.has(dev.deviceType ?? '')) {
|
|
1765
|
+
roboticAccessories.push(acc);
|
|
1766
|
+
}
|
|
1767
|
+
else {
|
|
1768
|
+
platformAccessories.push(acc);
|
|
518
1769
|
}
|
|
519
1770
|
}
|
|
520
1771
|
catch (e) {
|
|
521
|
-
this.
|
|
1772
|
+
this.errorLog(`Failed to create Matter accessory for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
522
1773
|
}
|
|
523
1774
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
1775
|
+
// Register platform-hosted accessories (most devices)
|
|
1776
|
+
if (platformAccessories.length > 0) {
|
|
1777
|
+
this.infoLog(`✓ Registered ${platformAccessories.length} discovered platform-hosted device(s)`);
|
|
1778
|
+
for (const acc of platformAccessories) {
|
|
1779
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
528
1780
|
}
|
|
529
|
-
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME,
|
|
530
|
-
|
|
1781
|
+
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, platformAccessories);
|
|
1782
|
+
}
|
|
1783
|
+
// Register robotic accessories (robot vacuums) separately so they can be
|
|
1784
|
+
// commissioned in the way Apple Home expects (these devices often require
|
|
1785
|
+
// standalone commissioning flow). We still call registerPlatformAccessories
|
|
1786
|
+
// because the accessory implementations manage their commissioning behavior.
|
|
1787
|
+
if (roboticAccessories.length > 0) {
|
|
1788
|
+
this.infoLog(`✓ Registered ${roboticAccessories.length} discovered robot vacuum device(s)`);
|
|
1789
|
+
for (const acc of roboticAccessories) {
|
|
1790
|
+
this.infoLog(` - ${acc.displayName} (standalone for Apple Home compatibility)`);
|
|
1791
|
+
}
|
|
1792
|
+
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, roboticAccessories);
|
|
531
1793
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
1794
|
+
// Debug/info: how many discovered vs example accessories were registered.
|
|
1795
|
+
// Example accessories are disabled — we intentionally do NOT register them.
|
|
1796
|
+
const discoveredRegistered = platformAccessories.length + roboticAccessories.length;
|
|
1797
|
+
const exampleRegistered = 0;
|
|
1798
|
+
this.debugLog(`Discovered accessories registered: ${discoveredRegistered}; Example accessories registered: ${exampleRegistered}`);
|
|
1799
|
+
// Dump registry state to help runtime debugging: which accessory instances
|
|
1800
|
+
// were created and which refresh timers are scheduled. This helps confirm
|
|
1801
|
+
// whether safeUpdate will prefer accessory helpers and whether periodic
|
|
1802
|
+
// refreshes exist for each device.
|
|
1803
|
+
try {
|
|
1804
|
+
const instanceKeys = Array.from(this.accessoryInstances.keys());
|
|
1805
|
+
this.debugLog(`Accessory instances registered (${instanceKeys.length}): ${JSON.stringify(instanceKeys)}`);
|
|
1806
|
+
const timerKeys = Array.from(this.refreshTimers.keys());
|
|
1807
|
+
this.debugLog(`Refresh timers scheduled (${timerKeys.length}): ${JSON.stringify(timerKeys)}`);
|
|
1808
|
+
}
|
|
1809
|
+
catch (e) {
|
|
1810
|
+
this.debugLog(`Failed to dump platform registries: ${e?.message ?? e}`);
|
|
1811
|
+
}
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
// If no discovered devices are available, check for cached Matter accessories
|
|
1815
|
+
const hasCachedAccessories = this.matterAccessories.size > 0;
|
|
1816
|
+
if (hasCachedAccessories) {
|
|
1817
|
+
this.infoLog(`No devices discovered via OpenAPI, but found ${this.matterAccessories.size} cached Matter accessories.`);
|
|
1818
|
+
this.infoLog('Cached accessories will continue to function with webhook updates.');
|
|
1819
|
+
this.infoLog('Restoring webhook handlers for cached Matter accessories...');
|
|
1820
|
+
await this.restoreCachedMatterAccessoryWebhooks();
|
|
1821
|
+
this.infoLog('Device discovery will resume when API becomes available.');
|
|
1822
|
+
}
|
|
1823
|
+
else {
|
|
1824
|
+
this.infoLog('No discovered SwitchBot devices found.');
|
|
1825
|
+
}
|
|
1826
|
+
this.debugLog('═'.repeat(80));
|
|
1827
|
+
this.debugLog('Finished registering Matter accessories');
|
|
1828
|
+
this.debugLog('═'.repeat(80));
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* Restore webhook handlers for cached Matter accessories
|
|
1832
|
+
* This ensures webhook functionality continues to work even when device discovery fails
|
|
1833
|
+
*/
|
|
1834
|
+
async restoreCachedMatterAccessoryWebhooks() {
|
|
1835
|
+
this.debugLog('Restoring webhook handlers for cached Matter accessories...');
|
|
1836
|
+
let restoredCount = 0;
|
|
1837
|
+
for (const [uuid, accessory] of this.matterAccessories.entries()) {
|
|
1838
|
+
try {
|
|
1839
|
+
const context = accessory?.context;
|
|
1840
|
+
const deviceId = context?.deviceId;
|
|
1841
|
+
const webhook = context?.webhook;
|
|
1842
|
+
if (!deviceId) {
|
|
1843
|
+
this.debugLog(`Skipping cached accessory ${accessory.displayName} - no deviceId in context`);
|
|
1844
|
+
continue;
|
|
1845
|
+
}
|
|
1846
|
+
// Only register webhook if the device had webhook enabled
|
|
1847
|
+
if (webhook) {
|
|
1848
|
+
this.debugLog(`Restoring webhook handler for Matter device: ${deviceId}`);
|
|
1849
|
+
// Create a minimal device object from cached context for webhook handling
|
|
1850
|
+
const dev = {
|
|
1851
|
+
deviceId,
|
|
1852
|
+
deviceName: context?.name || accessory.displayName,
|
|
1853
|
+
deviceType: context?.deviceType,
|
|
1854
|
+
webhook: true,
|
|
1855
|
+
};
|
|
1856
|
+
this.webhookEventHandler[deviceId] = async (webhookContext) => {
|
|
1857
|
+
try {
|
|
1858
|
+
this.debugLog(`Received webhook for cached Matter device ${deviceId}: ${JSON.stringify(webhookContext)}`);
|
|
1859
|
+
// Apply webhook status update to the accessory
|
|
1860
|
+
await this.applyStatusToAccessory(uuid, dev, webhookContext);
|
|
1861
|
+
}
|
|
1862
|
+
catch (e) {
|
|
1863
|
+
this.errorLog(`Failed to handle webhook for cached device ${deviceId}: ${e?.message ?? e}`);
|
|
1864
|
+
}
|
|
1865
|
+
};
|
|
1866
|
+
restoredCount++;
|
|
1867
|
+
this.debugSuccessLog(`Webhook handler restored for ${deviceId}`);
|
|
1868
|
+
}
|
|
1869
|
+
else {
|
|
1870
|
+
this.debugLog(`Device ${deviceId} does not have webhook enabled, skipping`);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
catch (e) {
|
|
1874
|
+
this.errorLog(`Failed to restore webhook handler for cached accessory ${accessory.displayName}: ${e?.message ?? e}`);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
this.infoLog(`Restored webhook handlers for ${restoredCount} cached Matter accessories`);
|
|
546
1878
|
}
|
|
547
1879
|
/**
|
|
548
1880
|
* Remove accessories that are disabled in config
|
|
@@ -575,7 +1907,17 @@ export class SwitchBotMatterPlatform {
|
|
|
575
1907
|
if (enabled === false) {
|
|
576
1908
|
const existingAccessory = this.matterAccessories.get(uuid);
|
|
577
1909
|
if (existingAccessory) {
|
|
578
|
-
this.
|
|
1910
|
+
this.infoLog(`Removing accessory '${name}' (disabled in config)`);
|
|
1911
|
+
// Attempt to clear any per-device resources (timers, BLE handlers, instances)
|
|
1912
|
+
try {
|
|
1913
|
+
const deviceId = existingAccessory?.context?.deviceId;
|
|
1914
|
+
if (deviceId) {
|
|
1915
|
+
this.clearDeviceResources(deviceId);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
catch (e) {
|
|
1919
|
+
this.debugLog(`Failed to clear resources for disabled accessory ${name}: ${e?.message ?? e}`);
|
|
1920
|
+
}
|
|
579
1921
|
await this.api.matter.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
|
|
580
1922
|
this.matterAccessories.delete(uuid);
|
|
581
1923
|
}
|
|
@@ -586,9 +1928,9 @@ export class SwitchBotMatterPlatform {
|
|
|
586
1928
|
* Section 4: Lighting Devices (Matter Spec § 4)
|
|
587
1929
|
*/
|
|
588
1930
|
async registerSection4Lighting() {
|
|
589
|
-
this.
|
|
590
|
-
this.
|
|
591
|
-
this.
|
|
1931
|
+
this.debugLog('═'.repeat(80));
|
|
1932
|
+
this.infoLog('Section 4: Lighting Devices (Matter Spec § 4)');
|
|
1933
|
+
this.debugLog('═'.repeat(80));
|
|
592
1934
|
const accessories = [];
|
|
593
1935
|
// On/Off Light
|
|
594
1936
|
if (this.config.enableOnOffLight !== false) {
|
|
@@ -616,9 +1958,9 @@ export class SwitchBotMatterPlatform {
|
|
|
616
1958
|
accessories.push(device.toAccessory());
|
|
617
1959
|
}
|
|
618
1960
|
if (accessories.length > 0) {
|
|
619
|
-
this.
|
|
1961
|
+
this.infoLog(`✓ Registered ${accessories.length} lighting device(s)`);
|
|
620
1962
|
for (const acc of accessories) {
|
|
621
|
-
this.
|
|
1963
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
622
1964
|
}
|
|
623
1965
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
624
1966
|
}
|
|
@@ -627,9 +1969,9 @@ export class SwitchBotMatterPlatform {
|
|
|
627
1969
|
* Section 5: Smart Plugs/Actuators (Matter Spec § 5)
|
|
628
1970
|
*/
|
|
629
1971
|
async registerSection5SmartPlugs() {
|
|
630
|
-
this.
|
|
631
|
-
this.
|
|
632
|
-
this.
|
|
1972
|
+
this.debugLog('═'.repeat(80));
|
|
1973
|
+
this.infoLog('Section 5: Smart Plugs/Actuators (Matter Spec § 5)');
|
|
1974
|
+
this.debugLog('═'.repeat(80));
|
|
633
1975
|
const accessories = [];
|
|
634
1976
|
// On/Off Outlet
|
|
635
1977
|
if (this.config.enableOnOffOutlet !== false) {
|
|
@@ -637,9 +1979,9 @@ export class SwitchBotMatterPlatform {
|
|
|
637
1979
|
accessories.push(device.toAccessory());
|
|
638
1980
|
}
|
|
639
1981
|
if (accessories.length > 0) {
|
|
640
|
-
this.
|
|
1982
|
+
this.infoLog(`✓ Registered ${accessories.length} smart plug/actuator device(s)`);
|
|
641
1983
|
for (const acc of accessories) {
|
|
642
|
-
this.
|
|
1984
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
643
1985
|
}
|
|
644
1986
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
645
1987
|
}
|
|
@@ -648,9 +1990,9 @@ export class SwitchBotMatterPlatform {
|
|
|
648
1990
|
* Section 6: Switches & Controllers (Matter Spec § 6)
|
|
649
1991
|
*/
|
|
650
1992
|
async registerSection6Switches() {
|
|
651
|
-
this.
|
|
652
|
-
this.
|
|
653
|
-
this.
|
|
1993
|
+
this.debugLog('═'.repeat(80));
|
|
1994
|
+
this.infoLog('Section 6: Switches & Controllers (Matter Spec § 6)');
|
|
1995
|
+
this.debugLog('═'.repeat(80));
|
|
654
1996
|
const accessories = [];
|
|
655
1997
|
// On/Off Switch
|
|
656
1998
|
if (this.config.enableOnOffSwitch !== false) {
|
|
@@ -658,9 +2000,9 @@ export class SwitchBotMatterPlatform {
|
|
|
658
2000
|
accessories.push(device.toAccessory());
|
|
659
2001
|
}
|
|
660
2002
|
if (accessories.length > 0) {
|
|
661
|
-
this.
|
|
2003
|
+
this.infoLog(`✓ Registered ${accessories.length} switch/controller device(s)`);
|
|
662
2004
|
for (const acc of accessories) {
|
|
663
|
-
this.
|
|
2005
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
664
2006
|
}
|
|
665
2007
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
666
2008
|
}
|
|
@@ -669,9 +2011,9 @@ export class SwitchBotMatterPlatform {
|
|
|
669
2011
|
* Section 7: Sensors (Matter Spec § 7)
|
|
670
2012
|
*/
|
|
671
2013
|
async registerSection7Sensors() {
|
|
672
|
-
this.
|
|
673
|
-
this.
|
|
674
|
-
this.
|
|
2014
|
+
this.debugLog('═'.repeat(80));
|
|
2015
|
+
this.infoLog('Section 7: Sensors (Matter Spec § 7)');
|
|
2016
|
+
this.debugLog('═'.repeat(80));
|
|
675
2017
|
const accessories = [];
|
|
676
2018
|
// Contact Sensor
|
|
677
2019
|
if (this.config.enableContactSensor !== false) {
|
|
@@ -709,9 +2051,9 @@ export class SwitchBotMatterPlatform {
|
|
|
709
2051
|
accessories.push(device.toAccessory());
|
|
710
2052
|
}
|
|
711
2053
|
if (accessories.length > 0) {
|
|
712
|
-
this.
|
|
2054
|
+
this.infoLog(`✓ Registered ${accessories.length} sensor device(s)`);
|
|
713
2055
|
for (const acc of accessories) {
|
|
714
|
-
this.
|
|
2056
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
715
2057
|
}
|
|
716
2058
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
717
2059
|
}
|
|
@@ -720,9 +2062,9 @@ export class SwitchBotMatterPlatform {
|
|
|
720
2062
|
* Section 8: Closure Devices (Matter Spec § 8)
|
|
721
2063
|
*/
|
|
722
2064
|
async registerSection8Closure() {
|
|
723
|
-
this.
|
|
724
|
-
this.
|
|
725
|
-
this.
|
|
2065
|
+
this.debugLog('═'.repeat(80));
|
|
2066
|
+
this.infoLog('Section 8: Closure Devices (Matter Spec § 8)');
|
|
2067
|
+
this.debugLog('═'.repeat(80));
|
|
726
2068
|
const accessories = [];
|
|
727
2069
|
// Door Lock
|
|
728
2070
|
if (this.config.enableDoorLock !== false) {
|
|
@@ -740,9 +2082,9 @@ export class SwitchBotMatterPlatform {
|
|
|
740
2082
|
accessories.push(device.toAccessory());
|
|
741
2083
|
}
|
|
742
2084
|
if (accessories.length > 0) {
|
|
743
|
-
this.
|
|
2085
|
+
this.infoLog(`✓ Registered ${accessories.length} closure device(s)`);
|
|
744
2086
|
for (const acc of accessories) {
|
|
745
|
-
this.
|
|
2087
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
746
2088
|
}
|
|
747
2089
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
748
2090
|
}
|
|
@@ -751,9 +2093,9 @@ export class SwitchBotMatterPlatform {
|
|
|
751
2093
|
* Section 9: HVAC (Matter Spec § 9)
|
|
752
2094
|
*/
|
|
753
2095
|
async registerSection9HVAC() {
|
|
754
|
-
this.
|
|
755
|
-
this.
|
|
756
|
-
this.
|
|
2096
|
+
this.debugLog('═'.repeat(80));
|
|
2097
|
+
this.infoLog('Section 9: HVAC (Matter Spec § 9)');
|
|
2098
|
+
this.debugLog('═'.repeat(80));
|
|
757
2099
|
const accessories = [];
|
|
758
2100
|
// Thermostat
|
|
759
2101
|
if (this.config.enableThermostat !== false) {
|
|
@@ -766,9 +2108,9 @@ export class SwitchBotMatterPlatform {
|
|
|
766
2108
|
accessories.push(device.toAccessory());
|
|
767
2109
|
}
|
|
768
2110
|
if (accessories.length > 0) {
|
|
769
|
-
this.
|
|
2111
|
+
this.infoLog(`✓ Registered ${accessories.length} HVAC device(s)`);
|
|
770
2112
|
for (const acc of accessories) {
|
|
771
|
-
this.
|
|
2113
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
772
2114
|
}
|
|
773
2115
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
774
2116
|
}
|
|
@@ -780,9 +2122,9 @@ export class SwitchBotMatterPlatform {
|
|
|
780
2122
|
* Use those codes to pair the vacuum as a separate bridge in your Home app.
|
|
781
2123
|
*/
|
|
782
2124
|
async registerSection12Robotic() {
|
|
783
|
-
this.
|
|
784
|
-
this.
|
|
785
|
-
this.
|
|
2125
|
+
this.debugLog('═'.repeat(80));
|
|
2126
|
+
this.infoLog('Section 12: Robotic Devices (Matter Spec § 12)');
|
|
2127
|
+
this.debugLog('═'.repeat(80));
|
|
786
2128
|
const accessories = [];
|
|
787
2129
|
// Robot Vacuum
|
|
788
2130
|
if (this.config.enableRobotVacuum !== false) {
|
|
@@ -790,9 +2132,9 @@ export class SwitchBotMatterPlatform {
|
|
|
790
2132
|
accessories.push(device.toAccessory());
|
|
791
2133
|
}
|
|
792
2134
|
if (accessories.length > 0) {
|
|
793
|
-
this.
|
|
2135
|
+
this.infoLog(`✓ Registered ${accessories.length} robot vacuum device(s)`);
|
|
794
2136
|
for (const acc of accessories) {
|
|
795
|
-
this.
|
|
2137
|
+
this.infoLog(` - ${acc.displayName} (standalone for Apple Home compatibility)`);
|
|
796
2138
|
}
|
|
797
2139
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
798
2140
|
}
|
|
@@ -805,9 +2147,9 @@ export class SwitchBotMatterPlatform {
|
|
|
805
2147
|
* like managing multiple logical components within a single device.
|
|
806
2148
|
*/
|
|
807
2149
|
async registerCustomDevices() {
|
|
808
|
-
this.
|
|
809
|
-
this.
|
|
810
|
-
this.
|
|
2150
|
+
this.debugLog('═'.repeat(80));
|
|
2151
|
+
this.infoLog('Custom Devices');
|
|
2152
|
+
this.debugLog('═'.repeat(80));
|
|
811
2153
|
const accessories = [];
|
|
812
2154
|
// Power Strip (4 Outlets)
|
|
813
2155
|
if (this.config.enablePowerStrip !== false) {
|
|
@@ -815,12 +2157,153 @@ export class SwitchBotMatterPlatform {
|
|
|
815
2157
|
accessories.push(device.toAccessory());
|
|
816
2158
|
}
|
|
817
2159
|
if (accessories.length > 0) {
|
|
818
|
-
this.
|
|
2160
|
+
this.infoLog(`✓ Registered ${accessories.length} custom device(s)`);
|
|
819
2161
|
for (const acc of accessories) {
|
|
820
|
-
this.
|
|
2162
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
821
2163
|
}
|
|
822
2164
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
823
2165
|
}
|
|
824
2166
|
}
|
|
2167
|
+
/**
|
|
2168
|
+
* Start platform-level refresh timer to batch all device status updates
|
|
2169
|
+
*/
|
|
2170
|
+
startPlatformRefreshTimer(refreshRateSec) {
|
|
2171
|
+
// Only create timer once
|
|
2172
|
+
if (this.platformRefreshTimer) {
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
// Respect user toggle
|
|
2176
|
+
if (this.config.options?.matterBatchEnabled === false) {
|
|
2177
|
+
this.infoLog('Matter batch refresh is disabled by configuration');
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
const jitterSec = Number(this.config.options?.matterBatchJitter ?? 0);
|
|
2181
|
+
const intervalMs = Number(refreshRateSec) * 1000;
|
|
2182
|
+
const jitterMs = Number.isFinite(jitterSec) && jitterSec > 0 ? Math.floor(Math.random() * jitterSec * 1000) : 0;
|
|
2183
|
+
// Start after optional jitter, then schedule recurring interval
|
|
2184
|
+
setTimeout(async () => {
|
|
2185
|
+
try {
|
|
2186
|
+
await this.batchRefreshAllDevices();
|
|
2187
|
+
}
|
|
2188
|
+
catch (e) {
|
|
2189
|
+
this.debugLog(`Initial batch refresh failed: ${e?.message ?? e}`);
|
|
2190
|
+
}
|
|
2191
|
+
this.platformRefreshTimer = setInterval(async () => {
|
|
2192
|
+
await this.batchRefreshAllDevices();
|
|
2193
|
+
}, intervalMs);
|
|
2194
|
+
}, jitterMs);
|
|
2195
|
+
}
|
|
2196
|
+
/**
|
|
2197
|
+
* Batch refresh all devices - still makes individual API calls but batches them together
|
|
2198
|
+
* Note: SwitchBot API doesn't support true batch status calls, but we can parallelize them
|
|
2199
|
+
*/
|
|
2200
|
+
async batchRefreshAllDevices() {
|
|
2201
|
+
if (!this.switchBotAPI) {
|
|
2202
|
+
return;
|
|
2203
|
+
}
|
|
2204
|
+
this.debugLog('Performing batched periodic OpenAPI refresh for all devices');
|
|
2205
|
+
// Build list from registered accessory instances (uuid) and discovered devices
|
|
2206
|
+
const devicesToRefresh = [];
|
|
2207
|
+
try {
|
|
2208
|
+
for (const [nid, instance] of this.accessoryInstances.entries()) {
|
|
2209
|
+
const uuid = instance?.uuid;
|
|
2210
|
+
if (!uuid) {
|
|
2211
|
+
continue;
|
|
2212
|
+
}
|
|
2213
|
+
const dev = this.discoveredDevices.find(d => this.normalizeDeviceId(d.deviceId) === nid);
|
|
2214
|
+
// Skip devices with per-device timers and those in cooldown
|
|
2215
|
+
const now = Date.now();
|
|
2216
|
+
const nextAllowed = this.backoffCooldowns.get(nid) ?? 0;
|
|
2217
|
+
if (dev && !this.perDeviceRefreshSet.has(nid) && now >= nextAllowed) {
|
|
2218
|
+
devicesToRefresh.push({ uuid, dev });
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
catch (e) {
|
|
2223
|
+
this.errorLog(`Failed to enumerate devices for batch refresh: ${e?.message ?? e}`);
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
if (devicesToRefresh.length === 0) {
|
|
2227
|
+
this.debugLog('No devices to refresh');
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
this.debugLog(`Refreshing ${devicesToRefresh.length} devices in parallel batch`);
|
|
2231
|
+
// Randomize order to reduce synchronized spikes
|
|
2232
|
+
for (let i = devicesToRefresh.length - 1; i > 0; i--) {
|
|
2233
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
2234
|
+
[devicesToRefresh[i], devicesToRefresh[j]] = [devicesToRefresh[j], devicesToRefresh[i]];
|
|
2235
|
+
}
|
|
2236
|
+
const concurrency = Number(this.config.options?.matterBatchConcurrency ?? 5);
|
|
2237
|
+
await this.runWithConcurrency(devicesToRefresh, async ({ uuid, dev }) => {
|
|
2238
|
+
try {
|
|
2239
|
+
const status = await this.refreshSingleDeviceWithRetry(dev);
|
|
2240
|
+
if (status) {
|
|
2241
|
+
await this.applyStatusToAccessory(uuid, dev, status);
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
catch (e) {
|
|
2245
|
+
this.errorLog(`Periodic OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
2246
|
+
}
|
|
2247
|
+
}, Number.isFinite(concurrency) && concurrency > 0 ? concurrency : 5);
|
|
2248
|
+
this.debugLog(`Batch refresh completed for ${devicesToRefresh.length} devices`);
|
|
2249
|
+
}
|
|
2250
|
+
/** Refresh a single device with retry and backoff; returns status object if successful */
|
|
2251
|
+
async refreshSingleDeviceWithRetry(dev, retries = 3, baseDelayMs = 500) {
|
|
2252
|
+
const deviceId = dev.deviceId;
|
|
2253
|
+
let attempt = 0;
|
|
2254
|
+
while (attempt <= retries) {
|
|
2255
|
+
try {
|
|
2256
|
+
if (!this.apiTracker?.trySpend('poll')) {
|
|
2257
|
+
return null;
|
|
2258
|
+
}
|
|
2259
|
+
const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(deviceId, this.config.credentials?.token, this.config.credentials?.secret);
|
|
2260
|
+
const respAny = response;
|
|
2261
|
+
const body = respAny?.body ?? respAny;
|
|
2262
|
+
if (isSuccessfulStatusCode(statusCode)) {
|
|
2263
|
+
const status = body?.status ?? body;
|
|
2264
|
+
this.deviceStatusCache.set(this.normalizeDeviceId(deviceId), { status, timestamp: Date.now() });
|
|
2265
|
+
this.debugLog(`OpenAPI refresh succeeded for ${deviceId} (attempt ${attempt + 1})`);
|
|
2266
|
+
return status;
|
|
2267
|
+
}
|
|
2268
|
+
this.debugLog(`OpenAPI refresh unexpected statusCode=${statusCode} for ${deviceId} (attempt ${attempt + 1})`);
|
|
2269
|
+
}
|
|
2270
|
+
catch (e) {
|
|
2271
|
+
this.debugLog(`OpenAPI refresh error for ${deviceId} (attempt ${attempt + 1}): ${e?.message ?? e}`);
|
|
2272
|
+
}
|
|
2273
|
+
// backoff before next retry if any left
|
|
2274
|
+
attempt++;
|
|
2275
|
+
if (attempt <= retries) {
|
|
2276
|
+
const delay = baseDelayMs * (2 ** (attempt - 1));
|
|
2277
|
+
await sleep(delay);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
// Set a cooldown after exhausting retries to avoid hammering problematic devices
|
|
2281
|
+
try {
|
|
2282
|
+
const nid = this.normalizeDeviceId(deviceId);
|
|
2283
|
+
const cooldownMs = Math.max(60_000, baseDelayMs * (2 ** retries) * 10);
|
|
2284
|
+
this.backoffCooldowns.set(nid, Date.now() + cooldownMs);
|
|
2285
|
+
this.debugLog(`Applied cooldown for ${deviceId}: ${Math.round(cooldownMs / 1000)}s`);
|
|
2286
|
+
}
|
|
2287
|
+
catch { }
|
|
2288
|
+
return null;
|
|
2289
|
+
}
|
|
2290
|
+
/** Simple concurrency limiter for an array of items */
|
|
2291
|
+
async runWithConcurrency(items, worker, concurrency) {
|
|
2292
|
+
const queue = items.slice();
|
|
2293
|
+
const workers = [];
|
|
2294
|
+
const runNext = async () => {
|
|
2295
|
+
const item = queue.shift();
|
|
2296
|
+
if (!item) {
|
|
2297
|
+
return;
|
|
2298
|
+
}
|
|
2299
|
+
await worker(item);
|
|
2300
|
+
return runNext();
|
|
2301
|
+
};
|
|
2302
|
+
const pool = Math.min(concurrency, Math.max(1, items.length));
|
|
2303
|
+
for (let i = 0; i < pool; i++) {
|
|
2304
|
+
workers.push(runNext());
|
|
2305
|
+
}
|
|
2306
|
+
await Promise.all(workers);
|
|
2307
|
+
}
|
|
825
2308
|
}
|
|
826
2309
|
//# sourceMappingURL=platform-matter.js.map
|