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