@switchbot/homebridge-switchbot 5.0.0-beta.5 → 5.0.0-beta.51
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 +15 -0
- package/README.md +45 -3
- package/config.schema.json +866 -13754
- package/dist/devices-hap/airpurifier.d.ts.map +1 -1
- package/dist/devices-hap/airpurifier.js +12 -6
- package/dist/devices-hap/airpurifier.js.map +1 -1
- package/dist/devices-hap/blindtilt.js +3 -3
- package/dist/devices-hap/bot.d.ts.map +1 -1
- package/dist/devices-hap/bot.js +16 -5
- package/dist/devices-hap/bot.js.map +1 -1
- package/dist/devices-hap/ceilinglight.d.ts.map +1 -1
- package/dist/devices-hap/ceilinglight.js +13 -7
- package/dist/devices-hap/ceilinglight.js.map +1 -1
- package/dist/devices-hap/colorbulb.d.ts.map +1 -1
- package/dist/devices-hap/colorbulb.js +49 -9
- package/dist/devices-hap/colorbulb.js.map +1 -1
- package/dist/devices-hap/contact.js +3 -3
- package/dist/devices-hap/curtain.js +2 -2
- package/dist/devices-hap/curtain.js.map +1 -1
- 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 +141 -69
- package/dist/devices-hap/device.js.map +1 -1
- package/dist/devices-hap/fan.d.ts.map +1 -1
- package/dist/devices-hap/fan.js +12 -6
- package/dist/devices-hap/fan.js.map +1 -1
- package/dist/devices-hap/hub.d.ts.map +1 -1
- package/dist/devices-hap/hub.js +6 -5
- package/dist/devices-hap/hub.js.map +1 -1
- package/dist/devices-hap/humidifier.d.ts +5 -0
- package/dist/devices-hap/humidifier.d.ts.map +1 -1
- package/dist/devices-hap/humidifier.js +92 -4
- package/dist/devices-hap/humidifier.js.map +1 -1
- package/dist/devices-hap/iosensor.d.ts.map +1 -1
- package/dist/devices-hap/iosensor.js +36 -21
- package/dist/devices-hap/iosensor.js.map +1 -1
- package/dist/devices-hap/lightstrip.d.ts.map +1 -1
- package/dist/devices-hap/lightstrip.js +38 -8
- package/dist/devices-hap/lightstrip.js.map +1 -1
- package/dist/devices-hap/lock.d.ts.map +1 -1
- package/dist/devices-hap/lock.js +14 -6
- package/dist/devices-hap/lock.js.map +1 -1
- package/dist/devices-hap/meter.d.ts.map +1 -1
- package/dist/devices-hap/meter.js +6 -5
- package/dist/devices-hap/meter.js.map +1 -1
- package/dist/devices-hap/meterplus.d.ts.map +1 -1
- package/dist/devices-hap/meterplus.js +6 -5
- package/dist/devices-hap/meterplus.js.map +1 -1
- package/dist/devices-hap/meterpro.d.ts.map +1 -1
- package/dist/devices-hap/meterpro.js +7 -6
- package/dist/devices-hap/meterpro.js.map +1 -1
- package/dist/devices-hap/motion.js +3 -3
- package/dist/devices-hap/plug.d.ts.map +1 -1
- package/dist/devices-hap/plug.js +11 -6
- package/dist/devices-hap/plug.js.map +1 -1
- package/dist/devices-hap/relayswitch.js +3 -3
- package/dist/devices-hap/robotvacuumcleaner.d.ts.map +1 -1
- package/dist/devices-hap/robotvacuumcleaner.js +13 -6
- package/dist/devices-hap/robotvacuumcleaner.js.map +1 -1
- package/dist/devices-hap/waterdetector.js +3 -3
- 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/devices-matter/RoboticVacuumAccessory.d.ts +11 -2
- package/dist/devices-matter/RoboticVacuumAccessory.d.ts.map +1 -1
- package/dist/devices-matter/RoboticVacuumAccessory.js +26 -17
- package/dist/devices-matter/RoboticVacuumAccessory.js.map +1 -1
- package/dist/homebridge-ui/public/index.html +64 -2
- package/dist/homebridge-ui/server.js +77 -9
- package/dist/homebridge-ui/server.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -7
- package/dist/index.js.map +1 -1
- package/dist/irdevice/irdevice.d.ts +11 -10
- package/dist/irdevice/irdevice.d.ts.map +1 -1
- package/dist/irdevice/irdevice.js +76 -35
- package/dist/irdevice/irdevice.js.map +1 -1
- package/dist/platform-hap.d.ts +26 -15
- package/dist/platform-hap.d.ts.map +1 -1
- package/dist/platform-hap.js +333 -153
- package/dist/platform-hap.js.map +1 -1
- package/dist/platform-matter.d.ts +98 -6
- package/dist/platform-matter.d.ts.map +1 -1
- package/dist/platform-matter.js +1885 -253
- package/dist/platform-matter.js.map +1 -1
- package/dist/settings.d.ts +58 -7
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js.map +1 -1
- package/dist/test/apiRequestTracker.test.d.ts +2 -0
- package/dist/test/apiRequestTracker.test.d.ts.map +1 -0
- package/dist/test/apiRequestTracker.test.js +392 -0
- package/dist/test/apiRequestTracker.test.js.map +1 -0
- package/dist/test/hap/device-webhook-context.test.d.ts +2 -0
- package/dist/test/hap/device-webhook-context.test.d.ts.map +1 -0
- package/dist/test/hap/device-webhook-context.test.js +128 -0
- package/dist/test/hap/device-webhook-context.test.js.map +1 -0
- package/dist/test/hap/platform-hap.logging.test.d.ts +2 -0
- package/dist/test/hap/platform-hap.logging.test.d.ts.map +1 -0
- package/dist/test/hap/platform-hap.logging.test.js +33 -0
- package/dist/test/hap/platform-hap.logging.test.js.map +1 -0
- package/dist/test/hap/platform-hap.test.d.ts +2 -0
- package/dist/test/hap/platform-hap.test.d.ts.map +1 -0
- package/dist/test/hap/platform-hap.test.js +62 -0
- package/dist/test/hap/platform-hap.test.js.map +1 -0
- package/dist/test/helpers/platform-fixtures.d.ts +9 -0
- package/dist/test/helpers/platform-fixtures.d.ts.map +1 -0
- package/dist/test/helpers/platform-fixtures.js +30 -0
- package/dist/test/helpers/platform-fixtures.js.map +1 -0
- package/dist/test/homebridge-ui/server.test.d.ts +2 -0
- package/dist/test/homebridge-ui/server.test.d.ts.map +1 -0
- package/dist/test/homebridge-ui/server.test.js +445 -0
- package/dist/test/homebridge-ui/server.test.js.map +1 -0
- package/dist/{index.test.d.ts.map → test/index.test.d.ts.map} +1 -1
- package/dist/test/index.test.js +19 -0
- package/dist/test/index.test.js.map +1 -0
- package/dist/test/matter/devices-matter/baseMatterAccessory.test.d.ts +2 -0
- package/dist/test/matter/devices-matter/baseMatterAccessory.test.d.ts.map +1 -0
- package/dist/test/matter/devices-matter/baseMatterAccessory.test.js +71 -0
- package/dist/test/matter/devices-matter/baseMatterAccessory.test.js.map +1 -0
- package/dist/test/matter/platform-matter.additional.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.additional.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.additional.test.js +35 -0
- package/dist/test/matter/platform-matter.additional.test.js.map +1 -0
- package/dist/test/matter/platform-matter.bleparse.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.bleparse.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.bleparse.test.js +43 -0
- package/dist/test/matter/platform-matter.bleparse.test.js.map +1 -0
- package/dist/test/matter/platform-matter.cleanup.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.cleanup.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.cleanup.test.js +70 -0
- package/dist/test/matter/platform-matter.cleanup.test.js.map +1 -0
- package/dist/test/matter/platform-matter.keepstale.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.keepstale.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.keepstale.test.js +27 -0
- package/dist/test/matter/platform-matter.keepstale.test.js.map +1 -0
- package/dist/test/matter/platform-matter.logging.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.logging.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.logging.test.js +29 -0
- package/dist/test/matter/platform-matter.logging.test.js.map +1 -0
- package/dist/test/matter/platform-matter.mapping.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.mapping.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.mapping.test.js +43 -0
- package/dist/test/matter/platform-matter.mapping.test.js.map +1 -0
- package/dist/test/matter/platform-matter.openapi-mapping.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.openapi-mapping.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.openapi-mapping.test.js +84 -0
- package/dist/test/matter/platform-matter.openapi-mapping.test.js.map +1 -0
- package/dist/test/matter/platform-matter.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.test.js +117 -0
- package/dist/test/matter/platform-matter.test.js.map +1 -0
- package/dist/test/matter/platform-matter.unregister.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.unregister.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.unregister.test.js +30 -0
- package/dist/test/matter/platform-matter.unregister.test.js.map +1 -0
- package/dist/test/matter/platform-matter.webhook.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.webhook.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.webhook.test.js +46 -0
- package/dist/test/matter/platform-matter.webhook.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 +204 -3
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +713 -33
- package/dist/utils.js.map +1 -1
- package/docs/assets/highlight.css +14 -0
- package/docs/assets/main.js +2 -2
- package/docs/index.html +31 -2
- package/docs/variables/default.html +1 -1
- package/package.json +15 -15
- package/src/devices-hap/airpurifier.ts +11 -6
- package/src/devices-hap/blindtilt.ts +3 -3
- package/src/devices-hap/bot.ts +15 -5
- package/src/devices-hap/ceilinglight.ts +12 -7
- package/src/devices-hap/colorbulb.ts +46 -10
- package/src/devices-hap/contact.ts +3 -3
- package/src/devices-hap/curtain.ts +2 -2
- package/src/devices-hap/device.ts +149 -70
- package/src/devices-hap/fan.ts +11 -6
- package/src/devices-hap/hub.ts +6 -5
- package/src/devices-hap/humidifier.ts +97 -4
- package/src/devices-hap/iosensor.ts +36 -21
- package/src/devices-hap/lightstrip.ts +35 -8
- package/src/devices-hap/lock.ts +13 -6
- package/src/devices-hap/meter.ts +6 -5
- package/src/devices-hap/meterplus.ts +6 -5
- package/src/devices-hap/meterpro.ts +7 -6
- package/src/devices-hap/motion.ts +3 -3
- package/src/devices-hap/plug.ts +10 -6
- package/src/devices-hap/relayswitch.ts +3 -3
- package/src/devices-hap/robotvacuumcleaner.ts +12 -6
- package/src/devices-hap/waterdetector.ts +3 -3
- 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/devices-matter/RoboticVacuumAccessory.ts +27 -17
- package/src/homebridge-ui/public/index.html +64 -2
- package/src/homebridge-ui/server.ts +80 -9
- package/src/index.ts +4 -7
- package/src/irdevice/irdevice.ts +74 -35
- package/src/platform-hap.ts +365 -169
- package/src/platform-matter.ts +1938 -256
- package/src/settings.ts +62 -3
- package/src/test/apiRequestTracker.test.ts +417 -0
- package/src/test/hap/device-webhook-context.test.ts +136 -0
- package/src/test/hap/platform-hap.logging.test.ts +36 -0
- package/src/test/hap/platform-hap.test.ts +70 -0
- package/src/test/helpers/platform-fixtures.ts +33 -0
- package/src/test/homebridge-ui/server.test.ts +486 -0
- package/src/test/index.test.ts +24 -0
- package/src/test/matter/devices-matter/baseMatterAccessory.test.ts +88 -0
- package/src/test/matter/platform-matter.additional.test.ts +44 -0
- package/src/test/matter/platform-matter.bleparse.test.ts +47 -0
- package/src/test/matter/platform-matter.cleanup.test.ts +86 -0
- package/src/test/matter/platform-matter.keepstale.test.ts +37 -0
- package/src/test/matter/platform-matter.logging.test.ts +33 -0
- package/src/test/matter/platform-matter.mapping.test.ts +57 -0
- package/src/test/matter/platform-matter.openapi-mapping.test.ts +109 -0
- package/src/test/matter/platform-matter.test.ts +144 -0
- package/src/test/matter/platform-matter.unregister.test.ts +39 -0
- package/src/test/matter/platform-matter.webhook.test.ts +54 -0
- package/src/test/utils.test.ts +96 -0
- package/src/{verifyconfig.test.ts → test/verifyconfig.test.ts} +12 -11
- package/src/utils.ts +777 -36
- 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,61 @@ 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
|
+
if (this.switchBotAPI) {
|
|
184
|
+
try {
|
|
185
|
+
this.apiTracker = new ApiRequestTracker(this.api, this.log, 'SwitchBot Matter', {
|
|
186
|
+
dailyLimit: this.config.options?.dailyApiLimit ?? 10000,
|
|
187
|
+
reserveForCommands: this.config.options?.dailyApiReserveForCommands ?? 1000,
|
|
188
|
+
pausePollingAtReserve: this.config.options?.webhookOnlyOnReserve ?? false,
|
|
189
|
+
resetAtLocalMidnight: this.config.options?.dailyApiResetAtLocalMidnight ?? false,
|
|
190
|
+
})
|
|
191
|
+
this.apiTracker.startHourlyLogging()
|
|
192
|
+
} catch (e: any) {
|
|
193
|
+
this.errorLog('Failed to initialize API request tracking:', e?.message ?? e)
|
|
194
|
+
}
|
|
113
195
|
}
|
|
114
196
|
|
|
115
197
|
try {
|
|
116
198
|
this.switchBotBLE = new SwitchBotBLE()
|
|
117
199
|
if (!this.config.options?.disableLogsforBLE && this.switchBotBLE?.on) {
|
|
118
|
-
this.switchBotBLE.on('log', (l: any) => this.
|
|
200
|
+
this.switchBotBLE.on('log', (l: any) => this.debugLog('[SwitchBot BLE]', l.message))
|
|
119
201
|
}
|
|
120
202
|
} catch (e: any) {
|
|
121
|
-
this.
|
|
203
|
+
this.errorLog('Failed to initialize SwitchBot BLE client:', e?.message ?? e)
|
|
122
204
|
}
|
|
123
205
|
|
|
124
206
|
// If BLE scanning is enabled, start scanning and route advertisements to registered handlers
|
|
@@ -128,7 +210,7 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
128
210
|
try {
|
|
129
211
|
await ble.startScan()
|
|
130
212
|
} catch (e: any) {
|
|
131
|
-
this.
|
|
213
|
+
this.errorLog(`Failed to start BLE scanning: ${e?.message ?? e}`)
|
|
132
214
|
}
|
|
133
215
|
|
|
134
216
|
// route advertisements to our handlers
|
|
@@ -140,29 +222,317 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
140
222
|
await handler(ad.serviceData)
|
|
141
223
|
}
|
|
142
224
|
} catch (e: any) {
|
|
143
|
-
this.
|
|
225
|
+
this.errorLog(`Failed to handle BLE advertisement: ${e?.message ?? e}`)
|
|
144
226
|
}
|
|
145
227
|
}
|
|
146
228
|
})()
|
|
147
229
|
}
|
|
148
230
|
|
|
149
|
-
//
|
|
150
|
-
|
|
231
|
+
// Ensure we clean up any per-device timers and BLE handlers when Homebridge shuts down
|
|
232
|
+
this.api.on('shutdown', async () => {
|
|
233
|
+
try {
|
|
234
|
+
this.infoLog('Homebridge shutting down: clearing refresh timers and BLE handlers')
|
|
235
|
+
|
|
236
|
+
// Stop API tracking hourly logging
|
|
237
|
+
if (this.apiTracker) {
|
|
238
|
+
this.apiTracker.stopHourlyLogging()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Clear all refresh timers
|
|
242
|
+
for (const [nid, t] of Array.from(this.refreshTimers.entries())) {
|
|
243
|
+
try {
|
|
244
|
+
clearInterval(t)
|
|
245
|
+
} catch (e: any) {
|
|
246
|
+
this.debugLog(`Failed to clear timer for ${nid}: ${e?.message ?? e}`)
|
|
247
|
+
}
|
|
248
|
+
this.refreshTimers.delete(nid)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Clear platform-level refresh timer
|
|
252
|
+
try {
|
|
253
|
+
if (this.platformRefreshTimer) {
|
|
254
|
+
clearInterval(this.platformRefreshTimer)
|
|
255
|
+
this.platformRefreshTimer = undefined
|
|
256
|
+
}
|
|
257
|
+
} catch (e: any) {
|
|
258
|
+
this.debugLog(`Failed to clear platform refresh timer: ${e?.message ?? e}`)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Clear accessory instances registry
|
|
262
|
+
try {
|
|
263
|
+
this.accessoryInstances.clear()
|
|
264
|
+
} catch (e: any) {
|
|
265
|
+
this.debugLog(`Failed to clear accessoryInstances: ${e?.message ?? e}`)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Remove BLE handlers
|
|
269
|
+
try {
|
|
270
|
+
for (const k of Object.keys(this.bleEventHandler)) {
|
|
271
|
+
delete this.bleEventHandler[k]
|
|
272
|
+
}
|
|
273
|
+
} catch (e: any) {
|
|
274
|
+
this.debugLog(`Failed to clear bleEventHandler: ${e?.message ?? e}`)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Stop BLE scanning if available
|
|
278
|
+
try {
|
|
279
|
+
if (this.switchBotBLE && typeof (this.switchBotBLE as any).stopScan === 'function') {
|
|
280
|
+
await (this.switchBotBLE as any).stopScan()
|
|
281
|
+
this.infoLog('Stopped BLE scanning')
|
|
282
|
+
}
|
|
283
|
+
} catch (e: any) {
|
|
284
|
+
this.debugLog(`Failed to stop BLE scanning: ${e?.message ?? e}`)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Persist backoff cooldowns to disk
|
|
288
|
+
try {
|
|
289
|
+
if (this.backoffFilePath) {
|
|
290
|
+
const obj: Record<string, number> = {}
|
|
291
|
+
for (const [k, v] of this.backoffCooldowns.entries()) {
|
|
292
|
+
if (Number.isFinite(v)) {
|
|
293
|
+
obj[k] = v
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
writeFileSync(this.backoffFilePath, JSON.stringify(obj, null, 2))
|
|
297
|
+
this.debugLog(`Saved ${Object.keys(obj).length} backoff cooldown entries`)
|
|
298
|
+
}
|
|
299
|
+
} catch (e: any) {
|
|
300
|
+
this.debugLog(`Failed to save backoff state: ${e?.message ?? e}`)
|
|
301
|
+
}
|
|
302
|
+
} catch (e: any) {
|
|
303
|
+
this.debugLog('Shutdown cleanup failed: %s', e?.message ?? e)
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// perform device discovery from SwitchBot OpenAPI (if configured) and
|
|
308
|
+
// register Matter accessories after discovery completes. Previously we
|
|
309
|
+
// called discoverDevices() without awaiting it which caused registration
|
|
310
|
+
// to run before discovery finished and only example accessories were
|
|
311
|
+
// created. Use an async IIFE to sequentially await discovery then register.
|
|
312
|
+
;(async () => {
|
|
313
|
+
try {
|
|
314
|
+
await this.discoverDevices()
|
|
315
|
+
} catch (e: any) {
|
|
316
|
+
this.debugLog('Device discovery failed during startup: %s', e?.message ?? e)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
await this.registerMatterAccessories()
|
|
321
|
+
} catch (e: any) {
|
|
322
|
+
this.errorLog('Failed to register Matter accessories: %s', e?.message ?? e)
|
|
323
|
+
}
|
|
324
|
+
})()
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
this.setupMqtt()
|
|
329
|
+
} catch (e: any) {
|
|
330
|
+
this.errorLog(`Setup MQTT, Error Message: ${e?.message ?? e}, Submit Bugs Here: ` + 'https://tinyurl.com/SwitchBotBug')
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
this.setupwebhook()
|
|
334
|
+
} catch (e: any) {
|
|
335
|
+
this.errorLog(`Setup Webhook, Error Message: ${e?.message ?? e}, Submit Bugs Here: ` + 'https://tinyurl.com/SwitchBotBug')
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Initialize backoff persistence file path and load any saved cooldowns
|
|
339
|
+
try {
|
|
340
|
+
const storagePath = this.api.user.storagePath()
|
|
341
|
+
this.backoffFilePath = join(storagePath, `${PLUGIN_NAME.replace('@', '').replace('/', '-')}-matter-backoff.json`)
|
|
342
|
+
if (existsSync(this.backoffFilePath)) {
|
|
343
|
+
try {
|
|
344
|
+
const raw = readFileSync(this.backoffFilePath, 'utf8')
|
|
345
|
+
const parsed = JSON.parse(raw)
|
|
346
|
+
if (parsed && typeof parsed === 'object') {
|
|
347
|
+
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
|
348
|
+
const ts = Number(v)
|
|
349
|
+
if (Number.isFinite(ts)) {
|
|
350
|
+
this.backoffCooldowns.set(String(k), ts)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
this.debugLog(`Loaded ${this.backoffCooldowns.size} backoff cooldown entries`)
|
|
354
|
+
}
|
|
355
|
+
} catch (e: any) {
|
|
356
|
+
this.debugLog(`Failed to load backoff state: ${e?.message ?? e}`)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
} catch (e: any) {
|
|
360
|
+
this.debugLog(`Failed to initialize backoff persistence: ${e?.message ?? e}`)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Normalize a deviceId for matching (uppercase alphanumerics only)
|
|
366
|
+
* @deprecated Use shared normalizeDeviceId from utils.js instead
|
|
367
|
+
*/
|
|
368
|
+
private normalizeDeviceId(deviceId: string) {
|
|
369
|
+
return normalizeDeviceId(deviceId)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Determine the platform-level batch interval in seconds */
|
|
373
|
+
private getPlatformBatchInterval(): number {
|
|
374
|
+
const opt = this.config.options
|
|
375
|
+
const val = opt?.matterBatchRefreshRate ?? opt?.refreshRate ?? 300
|
|
376
|
+
const n = Number(val)
|
|
377
|
+
return Number.isFinite(n) && n > 0 ? n : 300
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Clear per-device resources: refresh timers, accessory instance registry, BLE handlers
|
|
382
|
+
*/
|
|
383
|
+
private clearDeviceResources(deviceId?: string) {
|
|
384
|
+
if (!deviceId) {
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
const nid = this.normalizeDeviceId(deviceId)
|
|
389
|
+
const existing = this.refreshTimers.get(nid)
|
|
390
|
+
if (existing) {
|
|
391
|
+
try {
|
|
392
|
+
clearInterval(existing)
|
|
393
|
+
} catch (e: any) {
|
|
394
|
+
this.debugLog(`Failed to clear refresh timer for ${deviceId}: ${e?.message ?? e}`)
|
|
395
|
+
}
|
|
396
|
+
this.refreshTimers.delete(nid)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
this.accessoryInstances.delete(nid)
|
|
401
|
+
} catch (e: any) {
|
|
402
|
+
this.debugLog(`Failed to delete accessory instance for ${deviceId}: ${e?.message ?? e}`)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
const mac = formatDeviceIdAsMac(deviceId).toLowerCase()
|
|
407
|
+
if (this.bleEventHandler[mac]) {
|
|
408
|
+
delete this.bleEventHandler[mac]
|
|
409
|
+
}
|
|
410
|
+
} catch (e: any) {
|
|
411
|
+
// formatting failed (not a MAC-like id) — ignore
|
|
412
|
+
this.debugLog(`clearDeviceResources: failed to remove BLE handler for ${deviceId}: ${e?.message ?? e}`)
|
|
413
|
+
}
|
|
414
|
+
} catch (e: any) {
|
|
415
|
+
this.debugLog(`clearDeviceResources top-level error for ${deviceId}: ${e?.message ?? e}`)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Merge discovered devices with per-device overrides from `config.options.devices`.
|
|
421
|
+
* Also applies device-type templates when applyToAllDevicesOfType is set.
|
|
422
|
+
*/
|
|
423
|
+
private async mergeDiscoveredDevices(discovered: device[]): Promise<any[]> {
|
|
424
|
+
// If there's no per-device config, return discovered as-is
|
|
425
|
+
if (!this.config.options?.devices) {
|
|
426
|
+
return discovered
|
|
427
|
+
}
|
|
151
428
|
|
|
152
|
-
|
|
429
|
+
// Assign missing deviceType from configDeviceType if needed
|
|
430
|
+
const devicesWithTypeAssigned = discovered.map((deviceObj) => {
|
|
431
|
+
if (!deviceObj.deviceType) {
|
|
432
|
+
deviceObj.deviceType = (deviceObj as any).configDeviceType !== undefined ? (deviceObj as any).configDeviceType : 'Unknown'
|
|
433
|
+
this.debugLog(`API missing deviceType for ${deviceObj.deviceId}, using configDeviceType: ${(deviceObj as any).configDeviceType}`)
|
|
434
|
+
}
|
|
435
|
+
return deviceObj
|
|
153
436
|
})
|
|
437
|
+
|
|
438
|
+
// Apply device-type templates from config entries with applyToAllDevicesOfType=true
|
|
439
|
+
const devicesWithTemplates = applyDeviceTypeTemplates(
|
|
440
|
+
devicesWithTypeAssigned,
|
|
441
|
+
this.config.options.devices,
|
|
442
|
+
'deviceType',
|
|
443
|
+
msg => this.debugLog(msg),
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
// Merge per-device overrides by matching deviceId
|
|
447
|
+
const allowConfigOnly = Boolean(this.config.options?.allowConfigOnlyDevices)
|
|
448
|
+
const merged = mergeByDeviceId(this.config.options?.devices ?? [], devicesWithTemplates ?? [], allowConfigOnly)
|
|
449
|
+
|
|
450
|
+
// Apply global webhook setting if not explicitly set on individual devices
|
|
451
|
+
if (this.config.options?.webhook === true) {
|
|
452
|
+
merged.forEach((device: any) => {
|
|
453
|
+
if (device.webhook === undefined) {
|
|
454
|
+
device.webhook = true
|
|
455
|
+
this.debugLog(`Applied global webhook setting to Matter device: ${device.deviceId}`)
|
|
456
|
+
}
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return merged
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Merge discovered IR devices with per-device overrides from `config.options.irdevices`.
|
|
465
|
+
* Also applies device-type templates when applyToAllDevicesOfType is set.
|
|
466
|
+
*/
|
|
467
|
+
private async mergeDiscoveredIRDevices(discovered: irdevice[]): Promise<irdevice[]> {
|
|
468
|
+
// If there's no per-device config, return discovered as-is
|
|
469
|
+
if (!this.config.options?.irdevices) {
|
|
470
|
+
return discovered
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Assign missing remoteType from configRemoteType if needed
|
|
474
|
+
const devicesWithTypeAssigned = discovered.map((deviceObj) => {
|
|
475
|
+
if (!deviceObj.remoteType && (deviceObj as any).configRemoteType) {
|
|
476
|
+
deviceObj.remoteType = (deviceObj as any).configRemoteType
|
|
477
|
+
this.debugLog(`API missing remoteType for ${deviceObj.deviceId}, using configRemoteType: ${(deviceObj as any).configRemoteType}`)
|
|
478
|
+
} else if (!deviceObj.remoteType) {
|
|
479
|
+
this.warnLog(`No remoteType for IR device ${deviceObj.deviceId}, skipping`)
|
|
480
|
+
return null
|
|
481
|
+
}
|
|
482
|
+
return deviceObj
|
|
483
|
+
}).filter(device => device !== null) as irdevice[]
|
|
484
|
+
|
|
485
|
+
// Apply remote-type templates from config entries with applyToAllDevicesOfType=true
|
|
486
|
+
const devicesWithTemplates = applyDeviceTypeTemplates(
|
|
487
|
+
devicesWithTypeAssigned,
|
|
488
|
+
this.config.options.irdevices,
|
|
489
|
+
'remoteType',
|
|
490
|
+
msg => this.debugLog(msg),
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
// Merge per-device overrides by matching deviceId
|
|
494
|
+
const allowConfigOnly = Boolean(this.config.options?.allowConfigOnlyDevices)
|
|
495
|
+
type IRMerged = irdevice & irDevicesConfig
|
|
496
|
+
const merged = mergeByDeviceId(this.config.options?.irdevices ?? [], devicesWithTemplates ?? [], allowConfigOnly) as IRMerged[]
|
|
497
|
+
|
|
498
|
+
// Apply global webhook setting if not explicitly set on individual IR devices
|
|
499
|
+
if (this.config.options?.webhook === true) {
|
|
500
|
+
merged.forEach((device: any) => {
|
|
501
|
+
if (device.webhook === undefined) {
|
|
502
|
+
device.webhook = true
|
|
503
|
+
this.debugLog(`Applied global webhook setting to Matter IR device: ${device.deviceId}`)
|
|
504
|
+
}
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return merged as unknown as irdevice[]
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Select effective connection type for a device: prefer explicit device.connectionType,
|
|
513
|
+
* otherwise prefer BLE when platform BLE is enabled and device provides a BLE model/id.
|
|
514
|
+
*/
|
|
515
|
+
private chooseConnectionType(deviceObj: any): 'BLE' | 'OpenAPI' {
|
|
516
|
+
if (deviceObj?.connectionType) {
|
|
517
|
+
return deviceObj.connectionType === 'BLE' ? 'BLE' : 'OpenAPI'
|
|
518
|
+
}
|
|
519
|
+
// If platform BLE is enabled and we have a bleModel or deviceId that formats to a MAC, prefer BLE
|
|
520
|
+
if (this.config.options?.BLE && (deviceObj?.bleModel || formatDeviceIdAsMac(deviceObj?.deviceId))) {
|
|
521
|
+
return 'BLE'
|
|
522
|
+
}
|
|
523
|
+
return 'OpenAPI'
|
|
154
524
|
}
|
|
155
525
|
|
|
156
526
|
/**
|
|
157
527
|
* Map a SwitchBot device object to a MatterAccessory using the device-specific
|
|
158
528
|
* Matter accessory classes in `src/devices-matter`.
|
|
159
529
|
*/
|
|
160
|
-
private async createAccessoryFromDevice(dev: device): Promise<MatterAccessory<Record<string, unknown>> | undefined> {
|
|
530
|
+
private async createAccessoryFromDevice(dev: device & devicesConfig): Promise<MatterAccessory<Record<string, unknown>> | undefined> {
|
|
161
531
|
// Basic metadata
|
|
162
532
|
const displayName = dev.deviceName ?? dev.deviceId ?? 'SwitchBot Device'
|
|
163
533
|
const serial = dev.deviceId ?? 'unknown'
|
|
164
534
|
const manufacturer = 'SwitchBot'
|
|
165
|
-
const model = dev.deviceType ?? 'SwitchBot'
|
|
535
|
+
const model = dev.model ?? dev.deviceType ?? 'SwitchBot'
|
|
166
536
|
const firmware = (dev as any).firmware ?? (dev as any).version ?? '0.0.0'
|
|
167
537
|
|
|
168
538
|
// Helper to build a default opts object consumed by the matter device classes
|
|
@@ -175,90 +545,90 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
175
545
|
firmwareRevision: String(firmware),
|
|
176
546
|
hardwareRevision: '1.0.0',
|
|
177
547
|
deviceId: dev.deviceId,
|
|
178
|
-
|
|
548
|
+
// Inject handy platform-side helpers into the accessory `context` so Matter
|
|
549
|
+
// accessory classes can perform OpenAPI/BLE actions without reaching into
|
|
550
|
+
// the platform implementation directly.
|
|
551
|
+
context: {
|
|
552
|
+
deviceId: dev.deviceId,
|
|
553
|
+
// Expose the display name so Matter accessory classes can read it from context
|
|
554
|
+
name: displayName,
|
|
555
|
+
// Provide device-level logging override (if present) and platform logging flag
|
|
556
|
+
// so accessories can decide how verbose they should be.
|
|
557
|
+
deviceLogging: (dev as any)?.logging,
|
|
558
|
+
platformLogging: this.platformLogging,
|
|
559
|
+
// Expose effective webhook setting for parity with HAP base device logic
|
|
560
|
+
// Prefer explicit per-device setting; otherwise fall back to global option
|
|
561
|
+
webhook: (dev as any)?.webhook !== undefined ? (dev as any).webhook : (this.config.options?.webhook === true ? true : undefined),
|
|
562
|
+
},
|
|
179
563
|
}
|
|
180
564
|
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
565
|
+
// Log effective webhook for diagnostics
|
|
566
|
+
try {
|
|
567
|
+
const effectiveWebhook = (dev as any)?.webhook !== undefined ? (dev as any).webhook : (this.config.options?.webhook === true ? true : undefined)
|
|
568
|
+
this.debugLog(`Effective webhook for Matter device ${displayName} (${dev.deviceId}): ${String(effectiveWebhook)}`)
|
|
569
|
+
} catch (e: any) {
|
|
570
|
+
this.debugLog(`Failed logging effective webhook for Matter device ${displayName} (${dev.deviceId}): ${e?.message ?? e}`)
|
|
186
571
|
}
|
|
187
572
|
|
|
188
|
-
//
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
while (attempt < maxRetries) {
|
|
199
|
-
try {
|
|
200
|
-
const list = await this.switchBotBLE.discover({ model: (dev as any).bleModel, id })
|
|
201
|
-
if (!Array.isArray(list) || list.length === 0) {
|
|
202
|
-
throw new Error('BLE device not found')
|
|
203
|
-
}
|
|
204
|
-
const deviceInst: any = list[0]
|
|
205
|
-
if (typeof deviceInst[methodName] !== 'function') {
|
|
206
|
-
throw new TypeError(`BLE method ${methodName} not available on device`)
|
|
207
|
-
}
|
|
208
|
-
return await deviceInst[methodName](...args)
|
|
209
|
-
} catch (e: any) {
|
|
210
|
-
attempt++
|
|
211
|
-
if (attempt >= maxRetries) {
|
|
212
|
-
throw e
|
|
213
|
-
}
|
|
214
|
-
this.log.debug(`BLE ${methodName} attempt ${attempt} failed for ${dev.deviceId}: ${e?.message ?? e}, retrying in ${retryDelay}ms`)
|
|
215
|
-
await sleep(retryDelay)
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
throw new Error('BLE operation failed')
|
|
573
|
+
// Build platform-side helpers using shared factories so they can be reused/tested
|
|
574
|
+
const sendOpenAPI = makeOpenAPISender(this.retryCommand.bind(this), dev, { maxRetries: this.config.options?.maxRetries ?? 1, delayBetweenRetries: this.config.options?.delayBetweenRetries ?? 1000 })
|
|
575
|
+
const sendBLE = makeBLESender(this.switchBotBLE, dev, { bleRetries: (this.config.options as any)?.bleRetries ?? 2, bleRetryDelay: (this.config.options as any)?.bleRetryDelay ?? 500 })
|
|
576
|
+
|
|
577
|
+
// Log that we're initializing this device so it's visible in startup logs
|
|
578
|
+
try {
|
|
579
|
+
this.infoLog(`Initializing Matter device: ${displayName} (type=${dev.deviceType ?? 'Unknown'}) id=${dev.deviceId}`)
|
|
580
|
+
} catch (e: any) {
|
|
581
|
+
// best-effort logging — swallow errors to avoid breaking initialization
|
|
582
|
+
this.debugLog('Failed to log initializing device:', e?.message ?? e)
|
|
219
583
|
}
|
|
220
584
|
|
|
221
|
-
const makeOnOffHandlers = (uuid: string) => ({
|
|
585
|
+
const makeOnOffHandlers = (uuid: string, connectionType: 'BLE' | 'OpenAPI') => ({
|
|
222
586
|
onOff: {
|
|
223
587
|
on: async () => {
|
|
224
588
|
try {
|
|
225
|
-
if (
|
|
589
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
226
590
|
await sendBLE('turnOn')
|
|
227
591
|
} else {
|
|
228
592
|
await sendOpenAPI('turnOn')
|
|
229
593
|
}
|
|
230
594
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.OnOff, { onOff: true })
|
|
231
595
|
} catch (e: any) {
|
|
232
|
-
this.
|
|
596
|
+
this.errorLog(`Failed to turn on device ${dev.deviceId}: ${e?.message ?? e}`)
|
|
233
597
|
}
|
|
234
598
|
},
|
|
235
599
|
off: async () => {
|
|
236
600
|
try {
|
|
237
|
-
if (
|
|
601
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
238
602
|
await sendBLE('turnOff')
|
|
239
603
|
} else {
|
|
240
604
|
await sendOpenAPI('turnOff')
|
|
241
605
|
}
|
|
242
606
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.OnOff, { onOff: false })
|
|
243
607
|
} catch (e: any) {
|
|
244
|
-
this.
|
|
608
|
+
this.errorLog(`Failed to turn off device ${dev.deviceId}: ${e?.message ?? e}`)
|
|
245
609
|
}
|
|
246
610
|
},
|
|
247
611
|
},
|
|
248
612
|
})
|
|
249
613
|
|
|
250
|
-
// Mapping from SwitchBot deviceType -> constructor
|
|
614
|
+
// Mapping from SwitchBot deviceType -> constructor (expanded for parity with HAP)
|
|
251
615
|
const mapping: { [key: string]: any } = {
|
|
252
616
|
// Plugs / Outlets
|
|
253
617
|
'Plug': OnOffOutletAccessory,
|
|
254
618
|
'Plug Mini (US)': OnOffOutletAccessory,
|
|
255
619
|
'Plug Mini (JP)': OnOffOutletAccessory,
|
|
620
|
+
'Plug Mini': OnOffOutletAccessory,
|
|
621
|
+
'WoPlug': OnOffOutletAccessory,
|
|
256
622
|
|
|
257
623
|
// Lighting
|
|
258
624
|
'Color Bulb': ColorLightAccessory,
|
|
625
|
+
'Color Bulb Mini': ColorLightAccessory,
|
|
259
626
|
'Ceiling Light': ColorTemperatureLightAccessory,
|
|
260
627
|
'Ceiling Light Pro': ColorTemperatureLightAccessory,
|
|
261
628
|
'Strip Light': ExtendedColorLightAccessory,
|
|
629
|
+
'Light Strip': ExtendedColorLightAccessory,
|
|
630
|
+
'Light Strip Plus': ExtendedColorLightAccessory,
|
|
631
|
+
'Strip Light Pro': ExtendedColorLightAccessory,
|
|
262
632
|
'Dimmable Light': DimmableLightAccessory,
|
|
263
633
|
|
|
264
634
|
// Robot Vacuums
|
|
@@ -269,10 +639,13 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
269
639
|
'Robot Vacuum Cleaner S1': RoboticVacuumAccessory,
|
|
270
640
|
'Robot Vacuum Cleaner S1 Plus': RoboticVacuumAccessory,
|
|
271
641
|
'Robot Vacuum Cleaner S10': RoboticVacuumAccessory,
|
|
642
|
+
'Robot Vacuum Cleaner S1 Pro': RoboticVacuumAccessory,
|
|
643
|
+
'Robot Vacuum Cleaner S1 Mini': RoboticVacuumAccessory,
|
|
272
644
|
|
|
273
645
|
// Locks
|
|
274
646
|
'Smart Lock': DoorLockAccessory,
|
|
275
647
|
'Smart Lock Pro': DoorLockAccessory,
|
|
648
|
+
'Smart Lock Ultra': DoorLockAccessory,
|
|
276
649
|
|
|
277
650
|
// Sensors
|
|
278
651
|
'Motion Sensor': OccupancySensorAccessory,
|
|
@@ -283,6 +656,10 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
283
656
|
'MeterPro': TemperatureSensorAccessory,
|
|
284
657
|
'WoIOSensor': TemperatureSensorAccessory,
|
|
285
658
|
'Air Purifier PM2.5': HumiditySensorAccessory,
|
|
659
|
+
'Air Purifier Table PM2.5': HumiditySensorAccessory,
|
|
660
|
+
'Air Purifier': HumiditySensorAccessory,
|
|
661
|
+
'Air Purifier VOC': HumiditySensorAccessory,
|
|
662
|
+
'Air Purifier Table VOC': HumiditySensorAccessory,
|
|
286
663
|
|
|
287
664
|
// Fans
|
|
288
665
|
'Battery Circulator Fan': FanAccessory,
|
|
@@ -290,28 +667,34 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
290
667
|
// Curtains / Blinds
|
|
291
668
|
'Blind Tilt': VenetianBlindAccessory,
|
|
292
669
|
'Curtain': WindowBlindAccessory,
|
|
670
|
+
'Curtain2': WindowBlindAccessory,
|
|
293
671
|
'Curtain3': WindowBlindAccessory,
|
|
672
|
+
'Curtain 2': WindowBlindAccessory,
|
|
294
673
|
'WoRollerShade': WindowBlindAccessory,
|
|
295
674
|
'Roller Shade': WindowBlindAccessory,
|
|
675
|
+
'Venetian Blind': VenetianBlindAccessory,
|
|
296
676
|
|
|
297
677
|
// Switches / Relays
|
|
298
678
|
'Relay Switch 1': OnOffSwitchAccessory,
|
|
299
679
|
'Relay Switch 1PM': OnOffSwitchAccessory,
|
|
680
|
+
'Relay Switch 2': OnOffSwitchAccessory,
|
|
681
|
+
'Relay Switch 3': OnOffSwitchAccessory,
|
|
300
682
|
|
|
301
|
-
// Misc
|
|
683
|
+
// Misc / hubs / other
|
|
302
684
|
'Hub 2': undefined,
|
|
303
685
|
'Hub 3': undefined,
|
|
304
|
-
'
|
|
305
|
-
'
|
|
306
|
-
'
|
|
307
|
-
'
|
|
308
|
-
'
|
|
309
|
-
'
|
|
686
|
+
'Hub Mini': undefined,
|
|
687
|
+
'Bot': OnOffSwitchAccessory,
|
|
688
|
+
'Smart Bot': OnOffSwitchAccessory,
|
|
689
|
+
'Humidifier': HumiditySensorAccessory,
|
|
690
|
+
'Humidifier2': HumiditySensorAccessory,
|
|
691
|
+
'Thermostat': ThermostatAccessory,
|
|
692
|
+
'Water Heater': ThermostatAccessory,
|
|
310
693
|
}
|
|
311
694
|
|
|
312
695
|
const Ctor = mapping[dev.deviceType ?? '']
|
|
313
696
|
if (!Ctor) {
|
|
314
|
-
this.
|
|
697
|
+
this.debugLog(`No Matter mapping for deviceType='${dev.deviceType}', deviceId=${dev.deviceId}`)
|
|
315
698
|
return undefined
|
|
316
699
|
}
|
|
317
700
|
|
|
@@ -319,8 +702,11 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
319
702
|
const uuid = baseOpts.uuid
|
|
320
703
|
const handlers: Record<string, any> = {}
|
|
321
704
|
|
|
705
|
+
// Choose connection type for this device (BLE vs OpenAPI)
|
|
706
|
+
const connectionType = this.chooseConnectionType(dev)
|
|
707
|
+
|
|
322
708
|
// On/Off common
|
|
323
|
-
handlers.onOff = makeOnOffHandlers(uuid).onOff
|
|
709
|
+
handlers.onOff = makeOnOffHandlers(uuid, connectionType).onOff
|
|
324
710
|
|
|
325
711
|
// If this is a light, add brightness and color handlers
|
|
326
712
|
if (['Color Bulb', 'Ceiling Light', 'Ceiling Light Pro', 'Strip Light', 'Dimmable Light'].includes(dev.deviceType ?? '')) {
|
|
@@ -330,14 +716,14 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
330
716
|
try {
|
|
331
717
|
const level = request.level as number
|
|
332
718
|
const percent = Math.round((level / 254) * 100)
|
|
333
|
-
if (
|
|
719
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
334
720
|
await sendBLE('setBrightness', percent)
|
|
335
721
|
} else {
|
|
336
722
|
await sendOpenAPI('setBrightness', `${percent}`)
|
|
337
723
|
}
|
|
338
724
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.LevelControl, { currentLevel: level })
|
|
339
725
|
} catch (e: any) {
|
|
340
|
-
this.
|
|
726
|
+
this.errorLog(`Failed to set brightness for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
341
727
|
}
|
|
342
728
|
},
|
|
343
729
|
}
|
|
@@ -349,14 +735,14 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
349
735
|
const hue = request.hue as number
|
|
350
736
|
const saturation = request.saturation as number
|
|
351
737
|
const [r, g, b] = hs2rgb(Math.round((hue / 254) * 360), Math.round((saturation / 254) * 100))
|
|
352
|
-
if (
|
|
738
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
353
739
|
await sendBLE('setRGB', Number(request.level ?? 100), r, g, b)
|
|
354
740
|
} else {
|
|
355
741
|
await sendOpenAPI('setColor', `${r}:${g}:${b}`)
|
|
356
742
|
}
|
|
357
743
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentHue: hue, currentSaturation: saturation })
|
|
358
744
|
} catch (e: any) {
|
|
359
|
-
this.
|
|
745
|
+
this.errorLog(`Failed to set hue/sat for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
360
746
|
}
|
|
361
747
|
},
|
|
362
748
|
moveToColorLogic: async (request: any) => {
|
|
@@ -368,14 +754,14 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
368
754
|
const hueApprox = Math.round((colorX / 65535) * 360)
|
|
369
755
|
const satApprox = Math.round((colorY / 65535) * 100)
|
|
370
756
|
const [r, g, b] = hs2rgb(hueApprox, satApprox)
|
|
371
|
-
if (
|
|
757
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
372
758
|
await sendBLE('setRGB', Number(request.level ?? 100), r, g, b)
|
|
373
759
|
} else {
|
|
374
760
|
await sendOpenAPI('setColor', `${r}:${g}:${b}`)
|
|
375
761
|
}
|
|
376
762
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentX: colorX, currentY: colorY })
|
|
377
763
|
} catch (e: any) {
|
|
378
|
-
this.
|
|
764
|
+
this.errorLog(`Failed to set XY color for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
379
765
|
}
|
|
380
766
|
},
|
|
381
767
|
}
|
|
@@ -385,23 +771,58 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
385
771
|
moveToColorTemperature: async (request: any) => {
|
|
386
772
|
try {
|
|
387
773
|
const kelvin = Math.round(1000000 / Number(request.colorTemperature))
|
|
388
|
-
if (
|
|
774
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
389
775
|
await sendBLE('setColorTemperature', kelvin)
|
|
390
776
|
} else {
|
|
391
777
|
await sendOpenAPI('setColorTemperature', `${kelvin}`)
|
|
392
778
|
}
|
|
393
779
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentX: request.colorX ?? 0, currentY: request.colorY ?? 0 })
|
|
394
780
|
} catch (e: any) {
|
|
395
|
-
this.
|
|
781
|
+
this.errorLog(`Failed to set color temperature for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
396
782
|
}
|
|
397
783
|
},
|
|
398
784
|
}
|
|
399
785
|
}
|
|
400
786
|
|
|
787
|
+
// Expose platform helpers to the accessory via context so accessory
|
|
788
|
+
// classes can call OpenAPI/BLE actions (sendOpenAPI/sendBLE) and know
|
|
789
|
+
// the effective connection type.
|
|
790
|
+
try {
|
|
791
|
+
/* Inject platform helpers (OpenAPI/BLE senders + logging helpers + connection type)
|
|
792
|
+
into the accessory context so Matter accessory classes can use them without
|
|
793
|
+
reaching into the platform implementation directly. */
|
|
794
|
+
;(baseOpts as any).context = Object.assign({}, (baseOpts as any).context, {
|
|
795
|
+
sendOpenAPI,
|
|
796
|
+
sendBLE,
|
|
797
|
+
connectionType,
|
|
798
|
+
// Expose platform logging helpers so accessories can use consistent logging
|
|
799
|
+
infoLog: this.infoLog,
|
|
800
|
+
debugLog: this.debugLog,
|
|
801
|
+
warnLog: this.warnLog,
|
|
802
|
+
errorLog: this.errorLog,
|
|
803
|
+
successLog: this.successLog,
|
|
804
|
+
})
|
|
805
|
+
} catch (e: any) {
|
|
806
|
+
this.debugLog('Failed to attach platform helpers to baseOpts.context: %s', e?.message ?? e)
|
|
807
|
+
}
|
|
808
|
+
|
|
401
809
|
const opts = Object.assign({}, baseOpts, { handlers })
|
|
402
810
|
|
|
403
811
|
// Instantiate the device class and return its serialized accessory
|
|
404
812
|
const instance = new Ctor(this.api, this.log, opts)
|
|
813
|
+
// Save instance in registry so platform can call device-specific update methods if needed
|
|
814
|
+
try {
|
|
815
|
+
if (dev?.deviceId) {
|
|
816
|
+
this.accessoryInstances.set(this.normalizeDeviceId(dev.deviceId), instance)
|
|
817
|
+
}
|
|
818
|
+
} catch (e: any) {
|
|
819
|
+
this.debugLog('Failed to register accessory instance: %s', e?.message ?? e)
|
|
820
|
+
}
|
|
821
|
+
try {
|
|
822
|
+
this.infoLog(`Initialized Matter accessory: ${displayName} (type=${dev.deviceType ?? 'Unknown'}) id=${dev.deviceId}`)
|
|
823
|
+
} catch (e: any) {
|
|
824
|
+
this.debugLog('Failed to log initialized accessory:', e?.message ?? e)
|
|
825
|
+
}
|
|
405
826
|
|
|
406
827
|
// Register BLE->Matter push handler for this device's MAC (if BLE scanning is active)
|
|
407
828
|
try {
|
|
@@ -410,54 +831,191 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
410
831
|
this.bleEventHandler[mac] = async (serviceData?: any) => {
|
|
411
832
|
const uuidLocal = baseOpts.uuid
|
|
412
833
|
|
|
413
|
-
//
|
|
834
|
+
// First try model-specific / normalized parsing of BLE advertisement
|
|
414
835
|
try {
|
|
415
|
-
|
|
836
|
+
const parsed = this.parseAdvertisementForDevice(dev, serviceData)
|
|
837
|
+
try {
|
|
838
|
+
const _p = JSON.stringify(parsed)
|
|
839
|
+
this.debugLog(`BLE advertisement parsed for ${dev.deviceId}: ${_p}`)
|
|
840
|
+
} catch (e) {
|
|
841
|
+
this.debugLog(`BLE advertisement parsed for ${dev.deviceId}: [unstringifiable parse result]`)
|
|
842
|
+
}
|
|
843
|
+
if (parsed) {
|
|
416
844
|
// Power
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const on = (String(power).toLowerCase() === 'on' || Number(power) === 1)
|
|
420
|
-
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.OnOff, { onOff: on })
|
|
845
|
+
if (parsed.power !== undefined) {
|
|
846
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.OnOff, { onOff: Boolean(parsed.power) })
|
|
421
847
|
}
|
|
422
848
|
|
|
423
849
|
// Brightness
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const level = Math.
|
|
850
|
+
if (parsed.brightness !== undefined) {
|
|
851
|
+
const rawLevel = Math.round((Number(parsed.brightness) / 100) * 254)
|
|
852
|
+
const level = Math.max(0, Math.min(254, rawLevel))
|
|
853
|
+
this.debugLog(`[BLE Brightness Debug] Device ${dev.deviceId}: rawBrightness=${parsed.brightness}, calculated=${rawLevel}, clamped=${level}`)
|
|
427
854
|
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.LevelControl, { currentLevel: level })
|
|
428
855
|
}
|
|
429
856
|
|
|
430
|
-
// Color
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
let r = 0
|
|
434
|
-
let g = 0
|
|
435
|
-
let b = 0
|
|
436
|
-
const c = String(color)
|
|
437
|
-
if (c.includes(':')) {
|
|
438
|
-
const parts = c.split(':').map(Number)
|
|
439
|
-
;[r, g, b] = parts
|
|
440
|
-
} else if (c.startsWith('#')) {
|
|
441
|
-
const hex = c.replace('#', '')
|
|
442
|
-
r = Number.parseInt(hex.substring(0, 2), 16)
|
|
443
|
-
g = Number.parseInt(hex.substring(2, 4), 16)
|
|
444
|
-
b = Number.parseInt(hex.substring(4, 6), 16)
|
|
445
|
-
} else if (/^[0-9a-f]{6}$/i.test(c)) {
|
|
446
|
-
r = Number.parseInt(c.substring(0, 2), 16)
|
|
447
|
-
g = Number.parseInt(c.substring(2, 4), 16)
|
|
448
|
-
b = Number.parseInt(c.substring(4, 6), 16)
|
|
449
|
-
}
|
|
857
|
+
// Color
|
|
858
|
+
if (parsed.color !== undefined) {
|
|
859
|
+
const { r, g, b } = parsed.color
|
|
450
860
|
const [h, s] = rgb2hs(r, g, b)
|
|
451
|
-
|
|
861
|
+
const rawHue = Math.round((h / 360) * 254)
|
|
862
|
+
const rawSat = Math.round((s / 100) * 254)
|
|
863
|
+
const hue = Math.max(0, Math.min(254, rawHue))
|
|
864
|
+
const sat = Math.max(0, Math.min(254, rawSat))
|
|
865
|
+
this.debugLog(`[BLE Color Debug] Device ${dev.deviceId}: RGB=[${r},${g},${b}], HS=[${h},${s}], Matter=[${rawHue},${rawSat}], clamped=[${hue},${sat}]`)
|
|
866
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.ColorControl, { currentHue: hue, currentSaturation: sat })
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Battery -> powerSource cluster (common mapping)
|
|
870
|
+
if (parsed.battery !== undefined) {
|
|
871
|
+
// Skip battery updates for device types that don't support PowerSource cluster
|
|
872
|
+
const deviceType = String(dev?.deviceType ?? '')
|
|
873
|
+
const unsupportedTypes = ['Curtain', 'Curtain2', 'Curtain3', 'Curtain 2', 'Blind Tilt']
|
|
874
|
+
|
|
875
|
+
if (unsupportedTypes.includes(deviceType)) {
|
|
876
|
+
this.debugLog(`Device ${dev.deviceId} type ${deviceType} does not support PowerSource cluster, skipping BLE battery update`)
|
|
877
|
+
} else {
|
|
878
|
+
try {
|
|
879
|
+
const percentage = Number(parsed.battery)
|
|
880
|
+
const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)))
|
|
881
|
+
let batChargeLevel = 0
|
|
882
|
+
if (percentage < 20) {
|
|
883
|
+
batChargeLevel = 2
|
|
884
|
+
} else if (percentage < 40) {
|
|
885
|
+
batChargeLevel = 1
|
|
886
|
+
}
|
|
887
|
+
try {
|
|
888
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'powerSource', { batPercentRemaining, batChargeLevel })
|
|
889
|
+
} catch (updateError: any) {
|
|
890
|
+
// Silently skip if powerSource cluster doesn't exist on this device
|
|
891
|
+
const msg = String(updateError?.message ?? updateError)
|
|
892
|
+
if (!msg.includes('does not exist') && !msg.includes('not found')) {
|
|
893
|
+
throw updateError
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
} catch (e: any) {
|
|
897
|
+
this.debugLog(`Failed to update battery state for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Temperature -> temperatureMeasurement
|
|
903
|
+
if (parsed.temperature !== undefined) {
|
|
904
|
+
try {
|
|
905
|
+
const c = Number(parsed.temperature)
|
|
906
|
+
const measured = Math.round(c * 100)
|
|
907
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'temperatureMeasurement', { measuredValue: measured })
|
|
908
|
+
} catch (e: any) {
|
|
909
|
+
this.debugLog(`Failed to update temperature for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Humidity -> relativeHumidityMeasurement
|
|
914
|
+
if (parsed.humidity !== undefined) {
|
|
915
|
+
try {
|
|
916
|
+
const percent = Number(parsed.humidity)
|
|
917
|
+
const measured = Math.round(percent * 100)
|
|
918
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'relativeHumidityMeasurement', { measuredValue: measured })
|
|
919
|
+
} catch (e: any) {
|
|
920
|
+
this.debugLog(`Failed to update humidity for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Contact / Leak -> BooleanState
|
|
925
|
+
if (parsed.contact !== undefined || parsed.leak !== undefined) {
|
|
926
|
+
try {
|
|
927
|
+
// Some devices report contact as true=open; ContactSensor expects inverted value
|
|
928
|
+
const isContactOpen = parsed.contact === undefined ? undefined : Boolean(parsed.contact)
|
|
929
|
+
const leakDetected = parsed.leak === undefined ? undefined : Boolean(parsed.leak)
|
|
930
|
+
|
|
931
|
+
if (isContactOpen !== undefined) {
|
|
932
|
+
// If this is a contact sensor device type, invert; otherwise set conservatively
|
|
933
|
+
if ((dev.deviceType || '').includes('Contact')) {
|
|
934
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: !isContactOpen })
|
|
935
|
+
} else {
|
|
936
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: isContactOpen })
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (leakDetected !== undefined) {
|
|
941
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: leakDetected })
|
|
942
|
+
}
|
|
943
|
+
} catch (e: any) {
|
|
944
|
+
this.debugLog(`Failed to update contact/leak for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Motion -> occupancy
|
|
949
|
+
if (parsed.motion !== undefined) {
|
|
950
|
+
try {
|
|
951
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'occupancySensing', { occupancy: { occupied: Boolean(parsed.motion) } })
|
|
952
|
+
} catch (e: any) {
|
|
953
|
+
this.debugLog(`Failed to update occupancy for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Lock state -> doorLock
|
|
958
|
+
if (parsed.lock !== undefined) {
|
|
959
|
+
try {
|
|
960
|
+
const s = String(parsed.lock).toLowerCase()
|
|
961
|
+
let lockState = 0
|
|
962
|
+
if (s === 'locked' || s === '1' || s === 'true') {
|
|
963
|
+
lockState = 1
|
|
964
|
+
} else if (s === 'unlocked' || s === '0' || s === 'false') {
|
|
965
|
+
lockState = 2
|
|
966
|
+
}
|
|
967
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.DoorLock, { lockState })
|
|
968
|
+
} catch (e: any) {
|
|
969
|
+
this.debugLog(`Failed to update lock for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Position / Cover -> WindowCovering (convert open percent to closed*100)
|
|
974
|
+
if (parsed.position !== undefined) {
|
|
975
|
+
try {
|
|
976
|
+
const openPercent = Number(parsed.position)
|
|
977
|
+
const closedPercent = 100 - Math.max(0, Math.min(100, openPercent))
|
|
978
|
+
const value = Math.round(closedPercent * 100)
|
|
979
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.WindowCovering, { currentPositionLiftPercent100ths: value, targetPositionLiftPercent100ths: value })
|
|
980
|
+
} catch (e: any) {
|
|
981
|
+
this.debugLog(`Failed to update cover position for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Fan speed -> FanControl
|
|
986
|
+
if (parsed.fanSpeed !== undefined) {
|
|
987
|
+
try {
|
|
988
|
+
const percent = Number(parsed.fanSpeed)
|
|
989
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.FanControl, { percentSetting: percent, percentCurrent: percent })
|
|
990
|
+
} catch (e: any) {
|
|
991
|
+
this.debugLog(`Failed to update fan speed for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Robot vacuum fields (battery already handled) - update run/operational/clean modes when present
|
|
996
|
+
if (parsed.rvcRunMode !== undefined) {
|
|
997
|
+
try {
|
|
998
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'rvcRunMode', { currentMode: Number(parsed.rvcRunMode) })
|
|
999
|
+
} catch (e: any) {
|
|
1000
|
+
this.debugLog(`Failed to update rvcRunMode for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
if (parsed.rvcOperationalState !== undefined) {
|
|
1005
|
+
try {
|
|
1006
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'rvcOperationalState', { operationalState: Number(parsed.rvcOperationalState) })
|
|
1007
|
+
} catch (e: any) {
|
|
1008
|
+
this.debugLog(`Failed to update rvcOperationalState for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1009
|
+
}
|
|
452
1010
|
}
|
|
453
1011
|
|
|
454
|
-
// If
|
|
1012
|
+
// If we parsed something from serviceData prefer it and return early
|
|
455
1013
|
if (serviceData) {
|
|
456
1014
|
return
|
|
457
1015
|
}
|
|
458
1016
|
}
|
|
459
1017
|
} catch (e: any) {
|
|
460
|
-
this.
|
|
1018
|
+
this.debugLog(`BLE advertisement parsing failed for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
461
1019
|
}
|
|
462
1020
|
|
|
463
1021
|
// Fallback to OpenAPI getDeviceStatus when serviceData is not present or parsing failed
|
|
@@ -465,50 +1023,122 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
465
1023
|
return
|
|
466
1024
|
}
|
|
467
1025
|
try {
|
|
468
|
-
|
|
469
|
-
if (!(statusCode === 100 || statusCode === 200)) {
|
|
1026
|
+
if (!this.apiTracker?.trySpend('poll')) {
|
|
470
1027
|
return
|
|
471
1028
|
}
|
|
1029
|
+
const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret)
|
|
472
1030
|
const respAny: any = response
|
|
473
1031
|
const body = respAny?.body ?? respAny
|
|
1032
|
+
try {
|
|
1033
|
+
const s = JSON.stringify(body)
|
|
1034
|
+
this.debugLog(`OpenAPI getDeviceStatus for ${dev.deviceId} returned statusCode=${statusCode} body=${s}`)
|
|
1035
|
+
} catch (e) {
|
|
1036
|
+
this.debugLog(`OpenAPI getDeviceStatus for ${dev.deviceId} returned statusCode=${statusCode} (body not stringifiable)`)
|
|
1037
|
+
}
|
|
1038
|
+
if (!isSuccessfulStatusCode(statusCode)) {
|
|
1039
|
+
return
|
|
1040
|
+
}
|
|
474
1041
|
const status = body?.status ?? body
|
|
475
1042
|
|
|
476
|
-
//
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
1043
|
+
// Use centralized mapper which prefers accessory instance update helpers
|
|
1044
|
+
await this.applyStatusWithRegistrationRetry(uuidLocal, dev, status)
|
|
1045
|
+
} catch (e: any) {
|
|
1046
|
+
this.debugLog(`BLE push handler failed for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
} catch (e: any) {
|
|
1050
|
+
this.debugLog(`Failed to register BLE handler for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1051
|
+
}
|
|
481
1052
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
1053
|
+
// Schedule periodic OpenAPI refreshes for this device (if OpenAPI is configured)
|
|
1054
|
+
try {
|
|
1055
|
+
const nid = this.normalizeDeviceId(dev.deviceId)
|
|
1056
|
+
// Clear any existing timer for this device
|
|
1057
|
+
const existing = this.refreshTimers.get(nid)
|
|
1058
|
+
if (existing) {
|
|
1059
|
+
clearInterval(existing)
|
|
1060
|
+
this.refreshTimers.delete(nid)
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const platformInterval = this.getPlatformBatchInterval()
|
|
1064
|
+
const hasDeviceInterval = typeof dev.refreshRate === 'number' && Number(dev.refreshRate) > 0
|
|
1065
|
+
if (this.switchBotAPI && (hasDeviceInterval || platformInterval > 0)) {
|
|
1066
|
+
// One-shot to populate initial state AFTER registration has likely completed
|
|
1067
|
+
// Defer slightly to avoid race where updateAccessoryState runs before registration
|
|
1068
|
+
;(async () => {
|
|
1069
|
+
await sleep(250)
|
|
1070
|
+
try {
|
|
1071
|
+
this.infoLog(`Performing initial OpenAPI refresh for ${dev.deviceId}`)
|
|
1072
|
+
if (!this.apiTracker?.trySpend('poll')) {
|
|
1073
|
+
this.warnLog(`Skipping initial OpenAPI refresh for ${dev.deviceId} due to daily budget`)
|
|
1074
|
+
return
|
|
1075
|
+
}
|
|
1076
|
+
const { response, statusCode } = await this.switchBotAPI!.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret)
|
|
1077
|
+
const respAny: any = response
|
|
1078
|
+
const body = respAny?.body ?? respAny
|
|
1079
|
+
try {
|
|
1080
|
+
const s = JSON.stringify(body)
|
|
1081
|
+
this.debugLog(`Initial OpenAPI refresh for ${dev.deviceId} returned statusCode=${statusCode} body=${s}`)
|
|
1082
|
+
} catch (e) {
|
|
1083
|
+
this.debugLog(`Initial OpenAPI refresh for ${dev.deviceId} returned statusCode=${statusCode} (body not stringifiable)`)
|
|
1084
|
+
}
|
|
1085
|
+
if (isSuccessfulStatusCode(statusCode)) {
|
|
1086
|
+
const status = body?.status ?? body
|
|
1087
|
+
await this.applyStatusWithRegistrationRetry(uuid, dev, status)
|
|
1088
|
+
this.infoLog(`Initial OpenAPI refresh succeeded for ${dev.deviceId}`)
|
|
1089
|
+
} else {
|
|
1090
|
+
this.warnLog(`Initial OpenAPI refresh returned unexpected statusCode=${statusCode} for ${dev.deviceId}`)
|
|
1091
|
+
}
|
|
1092
|
+
} catch (e: any) {
|
|
1093
|
+
this.errorLog(`Initial OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
486
1094
|
}
|
|
1095
|
+
})()
|
|
487
1096
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
1097
|
+
if (hasDeviceInterval) {
|
|
1098
|
+
// Create a per-device timer and exclude it from batch
|
|
1099
|
+
const interval = Number(dev.refreshRate)
|
|
1100
|
+
this.perDeviceRefreshSet.add(nid)
|
|
1101
|
+
const timer = setInterval(async () => {
|
|
1102
|
+
try {
|
|
1103
|
+
// Skip if device is under cooldown
|
|
1104
|
+
const now = Date.now()
|
|
1105
|
+
const nextAllowed = this.backoffCooldowns.get(nid) ?? 0
|
|
1106
|
+
if (now < nextAllowed) {
|
|
1107
|
+
return
|
|
1108
|
+
}
|
|
1109
|
+
await this.refreshSingleDeviceWithRetry(dev)
|
|
1110
|
+
} catch (e: any) {
|
|
1111
|
+
this.debugLog(`Per-device refresh failed for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
502
1112
|
}
|
|
503
|
-
|
|
504
|
-
|
|
1113
|
+
}, interval * 1000)
|
|
1114
|
+
this.refreshTimers.set(nid, timer)
|
|
1115
|
+
this.infoLog(`Started per-device refresh timer for ${dev.deviceId} at ${interval}s`)
|
|
1116
|
+
} else {
|
|
1117
|
+
// Start platform-level batched refresh timer (only once)
|
|
1118
|
+
this.startPlatformRefreshTimer(platformInterval)
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
} catch (e: any) {
|
|
1122
|
+
this.debugLog(`Failed to schedule refresh for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// Register webhook handler for this device
|
|
1126
|
+
try {
|
|
1127
|
+
if (dev.webhook && dev.deviceId) {
|
|
1128
|
+
this.debugLog(`Registering webhook handler for Matter device: ${dev.deviceId}`)
|
|
1129
|
+
this.webhookEventHandler[dev.deviceId] = async (context: any) => {
|
|
1130
|
+
try {
|
|
1131
|
+
this.debugLog(`Received webhook for Matter device ${dev.deviceId}: ${JSON.stringify(context)}`)
|
|
1132
|
+
// Apply webhook status update to the accessory
|
|
1133
|
+
await this.applyStatusWithRegistrationRetry(uuid, dev, context)
|
|
1134
|
+
} catch (e: any) {
|
|
1135
|
+
this.errorLog(`Failed to handle webhook for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
505
1136
|
}
|
|
506
|
-
} catch (e: any) {
|
|
507
|
-
this.log.debug(`BLE push handler failed for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
508
1137
|
}
|
|
1138
|
+
this.debugSuccessLog(`Webhook handler registered for ${dev.deviceId}`)
|
|
509
1139
|
}
|
|
510
1140
|
} catch (e: any) {
|
|
511
|
-
this.
|
|
1141
|
+
this.debugLog(`Failed to register webhook handler for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
512
1142
|
}
|
|
513
1143
|
|
|
514
1144
|
return instance.toAccessory()
|
|
@@ -519,57 +1149,727 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
519
1149
|
*/
|
|
520
1150
|
private async discoverDevices(): Promise<void> {
|
|
521
1151
|
if (!this.switchBotAPI) {
|
|
522
|
-
this.
|
|
1152
|
+
this.debugLog('SwitchBot OpenAPI not configured; skipping discovery')
|
|
523
1153
|
return
|
|
524
1154
|
}
|
|
525
1155
|
|
|
526
1156
|
try {
|
|
1157
|
+
if (!this.apiTracker?.trySpend('discovery')) {
|
|
1158
|
+
this.warnLog('OpenAPI daily budget reached; skipping Matter discovery')
|
|
1159
|
+
return
|
|
1160
|
+
}
|
|
527
1161
|
const { response, statusCode } = await this.switchBotAPI.getDevices()
|
|
528
|
-
this.
|
|
529
|
-
if (statusCode
|
|
1162
|
+
this.debugLog(`SwitchBot getDevices response status: ${statusCode}`)
|
|
1163
|
+
if (isSuccessfulStatusCode(statusCode)) {
|
|
530
1164
|
const deviceList = Array.isArray(response?.body?.deviceList) ? response.body.deviceList : []
|
|
531
1165
|
this.discoveredDevices = deviceList
|
|
532
|
-
this.
|
|
1166
|
+
this.infoLog(`Discovered ${deviceList.length} SwitchBot device(s) from OpenAPI`)
|
|
533
1167
|
for (const d of deviceList) {
|
|
534
|
-
this.
|
|
1168
|
+
this.debugLog(` - ${d.deviceName} (${d.deviceType}) id=${d.deviceId}`)
|
|
535
1169
|
}
|
|
1170
|
+
|
|
1171
|
+
const irDeviceList = Array.isArray(response?.body?.infraredRemoteList) ? response.body.infraredRemoteList : []
|
|
1172
|
+
this.discoveredIRDevices = irDeviceList
|
|
1173
|
+
this.infoLog(`Discovered ${irDeviceList.length} SwitchBot IR device(s) from OpenAPI`)
|
|
1174
|
+
for (const d of irDeviceList) {
|
|
1175
|
+
this.debugLog(` - ${d.deviceName} (${d.remoteType}) id=${d.deviceId}`)
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// Diagnostic: warn users if their device count + refresh rate may exceed daily limits
|
|
1179
|
+
this.validateApiUsageConfig(deviceList.length, irDeviceList.length)
|
|
536
1180
|
} else {
|
|
537
|
-
this.
|
|
1181
|
+
this.warnLog(`SwitchBot getDevices returned status ${statusCode}`)
|
|
1182
|
+
// If rate limit exceeded (429), log specific message
|
|
1183
|
+
if (statusCode === 429) {
|
|
1184
|
+
this.warnLog('OpenAPI rate limit (429) exceeded during discovery.')
|
|
1185
|
+
this.warnLog('Webhook functionality will still work for manually configured devices.')
|
|
1186
|
+
this.warnLog('Device state updates will be limited until rate limit resets.')
|
|
1187
|
+
}
|
|
538
1188
|
}
|
|
539
1189
|
} catch (e: any) {
|
|
540
|
-
this.
|
|
1190
|
+
this.errorLog('Failed to discover SwitchBot devices:', e?.message ?? e)
|
|
541
1191
|
}
|
|
542
1192
|
}
|
|
543
1193
|
|
|
544
1194
|
/**
|
|
545
|
-
*
|
|
1195
|
+
* Setup MQTT connection (if configured) and route incoming webhook messages
|
|
1196
|
+
* to registered webhook handlers. Mirrors behaviour in platform-hap.
|
|
546
1197
|
*/
|
|
547
|
-
async
|
|
548
|
-
|
|
549
|
-
while (retryCount < maxRetries) {
|
|
1198
|
+
async setupMqtt(): Promise<void> {
|
|
1199
|
+
if (this.config.options?.mqttURL) {
|
|
550
1200
|
try {
|
|
551
|
-
|
|
552
|
-
|
|
1201
|
+
const { connectAsync } = asyncmqtt
|
|
1202
|
+
this.mqttClient = await connectAsync(this.config.options?.mqttURL, this.config.options.mqttOptions || {})
|
|
1203
|
+
this.debugLog('MQTT connection has been established successfully.')
|
|
1204
|
+
this.mqttClient.on('error', async (e: Error) => {
|
|
1205
|
+
this.errorLog(`Failed to publish MQTT messages. ${e.message ?? e}`)
|
|
1206
|
+
})
|
|
1207
|
+
if (!this.config.options?.webhookURL) {
|
|
1208
|
+
// receive webhook events via MQTT
|
|
1209
|
+
this.infoLog(`Webhook is configured to be received through ${this.config.options.mqttURL}/homebridge-switchbot/webhook.`)
|
|
1210
|
+
this.mqttClient.subscribe('homebridge-switchbot/webhook/+')
|
|
1211
|
+
this.mqttClient.on('message', async (topic: string, message: any) => {
|
|
1212
|
+
try {
|
|
1213
|
+
this.debugLog(`Received Webhook via MQTT: ${topic}=${message}`)
|
|
1214
|
+
const context = JSON.parse(message.toString())
|
|
1215
|
+
this.webhookEventHandler[context.deviceMac]?.(context)
|
|
1216
|
+
} catch (e: any) {
|
|
1217
|
+
this.errorLog(`Failed to handle webhook event. Error: ${e.message ?? e}`)
|
|
1218
|
+
}
|
|
1219
|
+
})
|
|
553
1220
|
}
|
|
554
|
-
const { response, statusCode } = await this.switchBotAPI.controlDevice(
|
|
555
|
-
deviceObj.deviceId,
|
|
556
|
-
bodyChange.command,
|
|
557
|
-
bodyChange.parameter,
|
|
558
|
-
bodyChange.commandType as import('node-switchbot').commandType | undefined,
|
|
559
|
-
this.config.credentials?.token,
|
|
560
|
-
this.config.credentials?.secret,
|
|
561
|
-
)
|
|
562
|
-
return { response, statusCode }
|
|
563
1221
|
} catch (e: any) {
|
|
564
|
-
this.
|
|
1222
|
+
this.mqttClient = null
|
|
1223
|
+
this.errorLog(`Failed to establish MQTT connection. ${e.message ?? e}`)
|
|
565
1224
|
}
|
|
566
|
-
|
|
567
|
-
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Setup OpenAPI webhook (if webhookURL configured) and forward incoming
|
|
1230
|
+
* webhook events to MQTT (if configured) and local handlers.
|
|
1231
|
+
*/
|
|
1232
|
+
async setupwebhook() {
|
|
1233
|
+
if (this.config.options?.webhookURL) {
|
|
1234
|
+
const url = this.config.options?.webhookURL
|
|
1235
|
+
try {
|
|
1236
|
+
this.switchBotAPI?.setupWebhook(url)
|
|
1237
|
+
this.infoLog(`Webhook configured for URL: ${url}`)
|
|
1238
|
+
// Listen for webhook events
|
|
1239
|
+
this.switchBotAPI?.on('webhookEvent', (body: any) => {
|
|
1240
|
+
try {
|
|
1241
|
+
this.infoLog(`Received webhook event for device: ${body.context.deviceMac}`)
|
|
1242
|
+
if (this.config.options?.mqttURL) {
|
|
1243
|
+
const mac = body.context.deviceMac?.toLowerCase().match(/[\s\S]{1,2}/g)?.join(':')
|
|
1244
|
+
const options = this.config.options?.mqttPubOptions || {}
|
|
1245
|
+
this.mqttClient?.publish(`homebridge-switchbot/webhook/${mac}`, `${JSON.stringify(body.context)}`, options)
|
|
1246
|
+
}
|
|
1247
|
+
this.webhookEventHandler[body.context.deviceMac]?.(body.context)
|
|
1248
|
+
} catch (e: any) {
|
|
1249
|
+
this.errorLog(`Failed to handle webhook event. Error: ${e.message ?? e}`)
|
|
1250
|
+
}
|
|
1251
|
+
})
|
|
1252
|
+
} catch (e: any) {
|
|
1253
|
+
this.errorLog(`Failed to setup webhook. Error: ${e.message ?? e}`)
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
this.api.on('shutdown', async () => {
|
|
1257
|
+
try {
|
|
1258
|
+
this.switchBotAPI?.deleteWebhook(url)
|
|
1259
|
+
} catch (e: any) {
|
|
1260
|
+
this.errorLog(`Failed to delete webhook. Error: ${e.message ?? e}`)
|
|
1261
|
+
}
|
|
1262
|
+
})
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Retry wrapper for control commands using SwitchBot OpenAPI
|
|
1268
|
+
*/
|
|
1269
|
+
async retryCommand(deviceObj: device, bodyChange: bodyChange, maxRetries = 1, delayBetweenRetries = 1000): Promise<{ response: any, statusCode: number }> {
|
|
1270
|
+
// Check API budget BEFORE attempting any retries
|
|
1271
|
+
if (!this.apiTracker?.trySpend('command')) {
|
|
1272
|
+
return { response: {}, statusCode: 429 }
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
let retryCount = 0
|
|
1276
|
+
while (retryCount < maxRetries) {
|
|
1277
|
+
try {
|
|
1278
|
+
if (!this.switchBotAPI) {
|
|
1279
|
+
throw new Error('SwitchBot OpenAPI not initialized')
|
|
1280
|
+
}
|
|
1281
|
+
const { response, statusCode } = await this.switchBotAPI.controlDevice(
|
|
1282
|
+
deviceObj.deviceId,
|
|
1283
|
+
bodyChange.command,
|
|
1284
|
+
bodyChange.parameter,
|
|
1285
|
+
bodyChange.commandType as import('node-switchbot').commandType | undefined,
|
|
1286
|
+
this.config.credentials?.token,
|
|
1287
|
+
this.config.credentials?.secret,
|
|
1288
|
+
)
|
|
1289
|
+
return { response, statusCode }
|
|
1290
|
+
} catch (e: any) {
|
|
1291
|
+
this.debugLog(`retryCommand error: ${e?.message ?? e}`)
|
|
1292
|
+
}
|
|
1293
|
+
retryCount++
|
|
1294
|
+
|
|
568
1295
|
await sleep(delayBetweenRetries)
|
|
569
1296
|
}
|
|
570
1297
|
return { response: {}, statusCode: 500 }
|
|
571
1298
|
}
|
|
572
1299
|
|
|
1300
|
+
/**
|
|
1301
|
+
* Parse BLE advertisement/serviceData into normalized fields for a given device.
|
|
1302
|
+
* Returns null when serviceData is falsy or parsing fails.
|
|
1303
|
+
*/
|
|
1304
|
+
private parseAdvertisementForDevice(dev: device, serviceData?: any) {
|
|
1305
|
+
if (!serviceData) {
|
|
1306
|
+
return null
|
|
1307
|
+
}
|
|
1308
|
+
try {
|
|
1309
|
+
const sd = serviceData
|
|
1310
|
+
const result: any = {}
|
|
1311
|
+
|
|
1312
|
+
// Power/on state - supports multiple field names used by different models
|
|
1313
|
+
const power = sd.power ?? sd.on ?? sd.p
|
|
1314
|
+
if (power !== undefined) {
|
|
1315
|
+
result.power = (String(power).toLowerCase() === 'on' || Number(power) === 1)
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// Brightness (0-100)
|
|
1319
|
+
const brightness = sd.brightness ?? sd.b
|
|
1320
|
+
if (brightness !== undefined) {
|
|
1321
|
+
result.brightness = Number(brightness)
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Color - could be 'r:g:b', '#rrggbb' or 'rrggbb'
|
|
1325
|
+
const color = sd.color ?? sd.rgb ?? sd.c
|
|
1326
|
+
if (color !== undefined) {
|
|
1327
|
+
let r = 0
|
|
1328
|
+
let g = 0
|
|
1329
|
+
let b = 0
|
|
1330
|
+
const c = String(color)
|
|
1331
|
+
if (c.includes(':')) {
|
|
1332
|
+
const parts = c.split(':').map(Number)
|
|
1333
|
+
;[r, g, b] = parts
|
|
1334
|
+
} else if (c.includes(',')) {
|
|
1335
|
+
const parts = c.split(',').map(s => Number(s.trim()))
|
|
1336
|
+
;[r, g, b] = parts
|
|
1337
|
+
} else if (c.includes(' ')) {
|
|
1338
|
+
const parts = c.split(' ').map(s => Number(s.trim()))
|
|
1339
|
+
;[r, g, b] = parts
|
|
1340
|
+
} else if (c.startsWith('#')) {
|
|
1341
|
+
const hex = c.replace('#', '')
|
|
1342
|
+
r = Number.parseInt(hex.substring(0, 2), 16)
|
|
1343
|
+
g = Number.parseInt(hex.substring(2, 4), 16)
|
|
1344
|
+
b = Number.parseInt(hex.substring(4, 6), 16)
|
|
1345
|
+
} else if (/^[0-9a-f]{6}$/i.test(c)) {
|
|
1346
|
+
r = Number.parseInt(c.substring(0, 2), 16)
|
|
1347
|
+
g = Number.parseInt(c.substring(2, 4), 16)
|
|
1348
|
+
b = Number.parseInt(c.substring(4, 6), 16)
|
|
1349
|
+
}
|
|
1350
|
+
result.color = { r, g, b }
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Battery (some devices use battery or batt)
|
|
1354
|
+
const battery = sd.battery ?? sd.batt
|
|
1355
|
+
if (battery !== undefined) {
|
|
1356
|
+
result.battery = Number(battery)
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// VOC / TVOC (some air quality devices report total volatile organic compounds)
|
|
1360
|
+
const voc = sd.voc ?? sd.tvoc
|
|
1361
|
+
if (voc !== undefined) {
|
|
1362
|
+
result.voc = Number(voc)
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// PM10 (some devices report PM10 alongside PM2.5)
|
|
1366
|
+
const pm10 = sd.pm10
|
|
1367
|
+
if (pm10 !== undefined) {
|
|
1368
|
+
result.pm10 = Number(pm10)
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// PM2.5 (some BLE adverts use pm25 / pm_2_5)
|
|
1372
|
+
const pm25 = sd.pm2_5 ?? sd.pm25 ?? sd.pm_2_5
|
|
1373
|
+
if (pm25 !== undefined) {
|
|
1374
|
+
result.pm25 = Number(pm25)
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// CO2 (carbon dioxide ppm)
|
|
1378
|
+
const co2 = sd.co2 ?? sd.co2ppm ?? sd.carbonDioxide
|
|
1379
|
+
if (co2 !== undefined) {
|
|
1380
|
+
result.co2 = Number(co2)
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Temperature (C) and Humidity (%) — support common shorthand keys
|
|
1384
|
+
const temperature = sd.temperature ?? sd.temp ?? sd.t
|
|
1385
|
+
if (temperature !== undefined) {
|
|
1386
|
+
result.temperature = Number(temperature)
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const humidity = sd.humidity ?? sd.h ?? sd.humid
|
|
1390
|
+
if (humidity !== undefined) {
|
|
1391
|
+
result.humidity = Number(humidity)
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// Motion, Contact, Leak
|
|
1395
|
+
const motion = sd.motion ?? sd.m
|
|
1396
|
+
if (motion !== undefined) {
|
|
1397
|
+
result.motion = Boolean(motion)
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
const contact = sd.contact ?? sd.open
|
|
1401
|
+
if (contact !== undefined) {
|
|
1402
|
+
result.contact = contact
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
const leak = sd.leak ?? sd.water
|
|
1406
|
+
if (leak !== undefined) {
|
|
1407
|
+
result.leak = Boolean(leak)
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Position / Cover / Curtain synonyms
|
|
1411
|
+
const position = sd.position ?? sd.percent ?? sd.slidePosition ?? sd.curtainPosition
|
|
1412
|
+
if (position !== undefined) {
|
|
1413
|
+
result.position = Number(position)
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// Fan speed/speed
|
|
1417
|
+
const fanSpeed = sd.fanSpeed ?? sd.speed
|
|
1418
|
+
if (fanSpeed !== undefined) {
|
|
1419
|
+
result.fanSpeed = Number(fanSpeed)
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// Lock state
|
|
1423
|
+
const lock = sd.lock
|
|
1424
|
+
if (lock !== undefined) {
|
|
1425
|
+
result.lock = lock
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// Robot vacuum fields
|
|
1429
|
+
const rvcRunMode = sd.rvcRunMode
|
|
1430
|
+
if (rvcRunMode !== undefined) {
|
|
1431
|
+
result.rvcRunMode = rvcRunMode
|
|
1432
|
+
}
|
|
1433
|
+
const rvcOperationalState = sd.rvcOperationalState
|
|
1434
|
+
if (rvcOperationalState !== undefined) {
|
|
1435
|
+
result.rvcOperationalState = rvcOperationalState
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
return result
|
|
1439
|
+
} catch (e: any) {
|
|
1440
|
+
this.debugLog(`parseAdvertisementForDevice failed for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1441
|
+
return null
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* Central helper to apply a SwitchBot status object to a Matter accessory.
|
|
1447
|
+
* Tries to call accessory instance update helpers when available, otherwise
|
|
1448
|
+
* falls back to calling api.matter.updateAccessoryState directly.
|
|
1449
|
+
*/
|
|
1450
|
+
private async applyStatusToAccessory(uuidLocal: string, dev: device & devicesConfig, status: any) {
|
|
1451
|
+
if (!status) {
|
|
1452
|
+
return
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
const instance = dev?.deviceId ? this.accessoryInstances.get(this.normalizeDeviceId(dev.deviceId)) : undefined
|
|
1456
|
+
|
|
1457
|
+
// Helper to safely call instance methods or fallback to api.matter.updateAccessoryState
|
|
1458
|
+
const safeUpdate = async (cluster: string, attributes: Record<string, unknown>, methodName?: string) => {
|
|
1459
|
+
try {
|
|
1460
|
+
// Special-case: powerSource cluster is optional on many devices (e.g., Curtains/Blinds).
|
|
1461
|
+
// To avoid noisy Matter server errors ("Behavior ID powerSource does not exist"),
|
|
1462
|
+
// always use the direct updateAccessoryState path wrapped in a guard for this cluster,
|
|
1463
|
+
// even when an accessory instance is present.
|
|
1464
|
+
const powerClusterName = (this.api.matter?.clusterNames && (this.api.matter.clusterNames as any).PowerSource)
|
|
1465
|
+
? (this.api.matter.clusterNames as any).PowerSource
|
|
1466
|
+
: 'powerSource'
|
|
1467
|
+
const isPowerSourceCluster = cluster === powerClusterName || cluster === 'powerSource'
|
|
1468
|
+
|
|
1469
|
+
// If the accessory instance declares supported clusters, skip updates for clusters
|
|
1470
|
+
// not present to avoid triggering Matter server errors and logs.
|
|
1471
|
+
let clusterSupported = true
|
|
1472
|
+
if (instance && (instance as any).clusters) {
|
|
1473
|
+
const declared = (instance as any).clusters
|
|
1474
|
+
if (Array.isArray(declared)) {
|
|
1475
|
+
clusterSupported = declared.includes(cluster)
|
|
1476
|
+
} else if (typeof declared === 'object') {
|
|
1477
|
+
clusterSupported = Object.prototype.hasOwnProperty.call(declared, cluster)
|
|
1478
|
+
}
|
|
1479
|
+
if (!clusterSupported) {
|
|
1480
|
+
this.debugLog(`Cluster ${cluster} not declared on accessory for ${dev.deviceId}, skipping update`)
|
|
1481
|
+
return
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
if (instance && methodName && typeof (instance as any)[methodName] === 'function') {
|
|
1486
|
+
// prefer device-specific update helpers when available
|
|
1487
|
+
await (instance as any)[methodName](...(Object.values(attributes)))
|
|
1488
|
+
} else if (!isPowerSourceCluster && instance && typeof (instance as any).updateState === 'function') {
|
|
1489
|
+
// some accessories expose updateState that accepts cluster and attributes
|
|
1490
|
+
await (instance as any).updateState(cluster, attributes)
|
|
1491
|
+
} else {
|
|
1492
|
+
try {
|
|
1493
|
+
await this.api.matter.updateAccessoryState(uuidLocal, cluster, attributes)
|
|
1494
|
+
} catch (updateError: any) {
|
|
1495
|
+
// Silently ignore "does not exist" errors for clusters that aren't
|
|
1496
|
+
// supported by this device type (e.g., powerSource on WindowBlind).
|
|
1497
|
+
const msg = String(updateError?.message ?? updateError)
|
|
1498
|
+
if (msg.includes('does not exist') || msg.includes('not found')) {
|
|
1499
|
+
this.debugLog(`Cluster ${cluster} not available on ${dev.deviceId}, skipping update`)
|
|
1500
|
+
return
|
|
1501
|
+
}
|
|
1502
|
+
throw updateError
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
} catch (e: any) {
|
|
1506
|
+
this.debugLog(`safeUpdate failed for ${dev.deviceId} cluster=${cluster}: ${e?.message ?? e}`)
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
try {
|
|
1511
|
+
// On/Off
|
|
1512
|
+
if (status?.power !== undefined) {
|
|
1513
|
+
const on = (String(status.power).toLowerCase() === 'on' || Number(status.power) === 1 || Boolean(status.power) === true)
|
|
1514
|
+
await safeUpdate(this.api.matter.clusterNames.OnOff, { onOff: on }, 'updateOnOffState')
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// Robot vacuum: some OpenAPI responses use 'runState' or similar textual
|
|
1518
|
+
// fields to indicate cleaning/mapping/idle. Map common textual values to
|
|
1519
|
+
// the numeric run mode values expected by the RoboticVacuumAccessory and
|
|
1520
|
+
// prefer calling accessory helpers when present.
|
|
1521
|
+
if (status?.runState !== undefined || status?.run_state !== undefined || status?.run !== undefined) {
|
|
1522
|
+
try {
|
|
1523
|
+
const raw = status?.runState ?? status?.run_state ?? status?.run
|
|
1524
|
+
let mode: number | undefined
|
|
1525
|
+
if (typeof raw === 'number') {
|
|
1526
|
+
mode = Number(raw)
|
|
1527
|
+
} else if (typeof raw === 'string') {
|
|
1528
|
+
const s = raw.toLowerCase()
|
|
1529
|
+
if (s.includes('clean')) {
|
|
1530
|
+
mode = 1 // Cleaning
|
|
1531
|
+
} else if (s.includes('map')) {
|
|
1532
|
+
mode = 2 // Mapping
|
|
1533
|
+
} else if (s.includes('idle') || s.includes('stop') || s.includes('dock') || s.includes('charge') || s.includes('docked')) {
|
|
1534
|
+
mode = 0 // Idle
|
|
1535
|
+
} else {
|
|
1536
|
+
const n = Number(raw)
|
|
1537
|
+
if (!Number.isNaN(n)) {
|
|
1538
|
+
mode = n
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
if (mode !== undefined) {
|
|
1544
|
+
await safeUpdate('rvcRunMode', { currentMode: Number(mode) }, 'updateRunMode')
|
|
1545
|
+
}
|
|
1546
|
+
} catch (e: any) {
|
|
1547
|
+
this.debugLog(`Failed to apply runState for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// Robot vacuum: some firmwares expose 'taskType' or 'task' with similar semantics
|
|
1552
|
+
// to runState (e.g., 'cleaning', 'mapping', 'idle'). Map to rvcRunMode accordingly.
|
|
1553
|
+
if (status?.taskType !== undefined || status?.task_type !== undefined || status?.task !== undefined) {
|
|
1554
|
+
try {
|
|
1555
|
+
const raw = status?.taskType ?? status?.task_type ?? status?.task
|
|
1556
|
+
let mode: number | undefined
|
|
1557
|
+
if (typeof raw === 'number') {
|
|
1558
|
+
mode = Number(raw)
|
|
1559
|
+
} else if (typeof raw === 'string') {
|
|
1560
|
+
const s = raw.toLowerCase()
|
|
1561
|
+
if (s.includes('clean')) {
|
|
1562
|
+
mode = 1 // Cleaning
|
|
1563
|
+
} else if (s.includes('map')) {
|
|
1564
|
+
mode = 2 // Mapping
|
|
1565
|
+
} else if (s.includes('idle') || s.includes('stop') || s.includes('dock') || s.includes('charge') || s.includes('docked')) {
|
|
1566
|
+
mode = 0 // Idle
|
|
1567
|
+
} else {
|
|
1568
|
+
const n = Number(raw)
|
|
1569
|
+
if (!Number.isNaN(n)) {
|
|
1570
|
+
mode = n
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
if (mode !== undefined) {
|
|
1576
|
+
await safeUpdate('rvcRunMode', { currentMode: Number(mode) }, 'updateRunMode')
|
|
1577
|
+
}
|
|
1578
|
+
} catch (e: any) {
|
|
1579
|
+
this.debugLog(`Failed to apply taskType for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
// Brightness
|
|
1584
|
+
if (status?.brightness !== undefined) {
|
|
1585
|
+
const rawBrightness = Number(status.brightness)
|
|
1586
|
+
const clampedBrightness = Math.max(0, Math.min(100, rawBrightness))
|
|
1587
|
+
|
|
1588
|
+
// If instance has updateBrightness method, it expects percentage (0-100)
|
|
1589
|
+
// Otherwise, updateAccessoryState expects Matter-scaled value (0-254)
|
|
1590
|
+
if (instance && typeof instance.updateBrightness === 'function') {
|
|
1591
|
+
this.debugLog(`[Brightness Debug] Device ${dev.deviceId}: calling updateBrightness with percent=${clampedBrightness}`)
|
|
1592
|
+
await safeUpdate(this.api.matter.clusterNames.LevelControl, { currentLevel: clampedBrightness }, 'updateBrightness')
|
|
1593
|
+
} else {
|
|
1594
|
+
const level = Math.round((clampedBrightness / 100) * 254)
|
|
1595
|
+
const clampedLevel = Math.max(0, Math.min(254, level))
|
|
1596
|
+
this.debugLog(`[Brightness Debug] Device ${dev.deviceId}: calling updateAccessoryState with rawBrightness=${rawBrightness}, level=${clampedLevel}`)
|
|
1597
|
+
await safeUpdate(this.api.matter.clusterNames.LevelControl, { currentLevel: clampedLevel })
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// Color
|
|
1602
|
+
if (status?.color !== undefined) {
|
|
1603
|
+
const color = String(status.color)
|
|
1604
|
+
let r = 0
|
|
1605
|
+
let g = 0
|
|
1606
|
+
let b = 0
|
|
1607
|
+
if (color.includes(':')) {
|
|
1608
|
+
const parts = color.split(':').map(Number)
|
|
1609
|
+
;[r, g, b] = parts
|
|
1610
|
+
} else if (color.includes(',')) {
|
|
1611
|
+
const parts = color.split(',').map(s => Number(s.trim()))
|
|
1612
|
+
;[r, g, b] = parts
|
|
1613
|
+
} else if (color.includes(' ')) {
|
|
1614
|
+
const parts = color.split(' ').map(s => Number(s.trim()))
|
|
1615
|
+
;[r, g, b] = parts
|
|
1616
|
+
} else if (color.startsWith('#')) {
|
|
1617
|
+
const hex = color.replace('#', '')
|
|
1618
|
+
r = Number.parseInt(hex.substring(0, 2), 16)
|
|
1619
|
+
g = Number.parseInt(hex.substring(2, 4), 16)
|
|
1620
|
+
b = Number.parseInt(hex.substring(4, 6), 16)
|
|
1621
|
+
}
|
|
1622
|
+
const [h, s] = rgb2hs(r, g, b)
|
|
1623
|
+
const clampedH = Math.max(0, Math.min(360, h))
|
|
1624
|
+
const clampedS = Math.max(0, Math.min(100, s))
|
|
1625
|
+
|
|
1626
|
+
// If instance has updateHueSaturation method, it expects raw values (h: 0-360, s: 0-100)
|
|
1627
|
+
// Otherwise, updateAccessoryState expects Matter-scaled values (0-254)
|
|
1628
|
+
if (instance && typeof instance.updateHueSaturation === 'function') {
|
|
1629
|
+
this.debugLog(`[Color Debug] Device ${dev.deviceId}: calling updateHueSaturation with color="${color}", RGB=[${r},${g},${b}], HS=[${clampedH},${clampedS}]`)
|
|
1630
|
+
await safeUpdate(this.api.matter.clusterNames.ColorControl, { currentHue: clampedH, currentSaturation: clampedS }, 'updateHueSaturation')
|
|
1631
|
+
} else {
|
|
1632
|
+
const hue = Math.round((clampedH / 360) * 254)
|
|
1633
|
+
const sat = Math.round((clampedS / 100) * 254)
|
|
1634
|
+
const clampedHue = Math.max(0, Math.min(254, hue))
|
|
1635
|
+
const clampedSat = Math.max(0, Math.min(254, sat))
|
|
1636
|
+
this.debugLog(`[Color Debug] Device ${dev.deviceId}: calling updateAccessoryState with color="${color}", RGB=[${r},${g},${b}], HS=[${h},${s}], Matter=[${clampedHue},${clampedSat}]`)
|
|
1637
|
+
await safeUpdate(this.api.matter.clusterNames.ColorControl, { currentHue: clampedHue, currentSaturation: clampedSat })
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// Battery/powerSource (support many possible field names)
|
|
1642
|
+
// Note: Some device types like WindowBlind and RoboticVacuumCleaner don't support PowerSource cluster
|
|
1643
|
+
if (status?.battery !== undefined || status?.batt !== undefined || status?.batteryLevel !== undefined || status?.batteryPercentage !== undefined || status?.battery_level !== undefined
|
|
1644
|
+
|| status?.baseBattery !== undefined || status?.base_battery !== undefined || status?.stationBattery !== undefined || status?.waterBaseBattery !== undefined || status?.dockBattery !== undefined) {
|
|
1645
|
+
// Skip battery updates for device types that don't support PowerSource cluster
|
|
1646
|
+
const deviceType = String(status?.deviceType ?? dev?.deviceType ?? '')
|
|
1647
|
+
const unsupportedTypes = [
|
|
1648
|
+
'Curtain',
|
|
1649
|
+
'Curtain2',
|
|
1650
|
+
'Curtain3',
|
|
1651
|
+
'Curtain 2',
|
|
1652
|
+
'Blind Tilt',
|
|
1653
|
+
// Robot Vacuums - PowerSource cluster not in Matter spec for RoboticVacuumCleaner
|
|
1654
|
+
'K10+',
|
|
1655
|
+
'K10+ Pro',
|
|
1656
|
+
'WoSweeper',
|
|
1657
|
+
'WoSweeperMini',
|
|
1658
|
+
'Robot Vacuum Cleaner S1',
|
|
1659
|
+
'Robot Vacuum Cleaner S1 Plus',
|
|
1660
|
+
'Robot Vacuum Cleaner S10',
|
|
1661
|
+
'Robot Vacuum Cleaner S1 Pro',
|
|
1662
|
+
'Robot Vacuum Cleaner S1 Mini',
|
|
1663
|
+
]
|
|
1664
|
+
|
|
1665
|
+
if (unsupportedTypes.includes(deviceType)) {
|
|
1666
|
+
this.debugLog(`Device ${dev.deviceId} type ${deviceType} does not support PowerSource cluster, skipping battery update`)
|
|
1667
|
+
} else {
|
|
1668
|
+
try {
|
|
1669
|
+
const percentage = Number(
|
|
1670
|
+
status?.battery ?? status?.batt ?? status?.batteryPercentage ?? status?.batteryLevel ?? status?.battery_level
|
|
1671
|
+
?? status?.baseBattery ?? status?.base_battery ?? status?.stationBattery ?? status?.waterBaseBattery ?? status?.dockBattery,
|
|
1672
|
+
)
|
|
1673
|
+
const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)))
|
|
1674
|
+
let batChargeLevel = 0
|
|
1675
|
+
if (percentage < 20) {
|
|
1676
|
+
batChargeLevel = 2
|
|
1677
|
+
} else if (percentage < 40) {
|
|
1678
|
+
batChargeLevel = 1
|
|
1679
|
+
}
|
|
1680
|
+
const powerCluster = (this.api.matter?.clusterNames && (this.api.matter.clusterNames as any).PowerSource) ? (this.api.matter.clusterNames as any).PowerSource : 'powerSource'
|
|
1681
|
+
await safeUpdate(powerCluster, { batPercentRemaining, batChargeLevel }, 'updateBatteryPercentage')
|
|
1682
|
+
} catch (e: any) {
|
|
1683
|
+
this.debugLog(`Failed to apply battery status for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// Temperature + thermostat
|
|
1689
|
+
if (status?.temperature !== undefined || status?.temp !== undefined) {
|
|
1690
|
+
try {
|
|
1691
|
+
const c = Number(status?.temperature ?? status?.temp)
|
|
1692
|
+
const measured = Math.round(c * 100)
|
|
1693
|
+
await safeUpdate('temperatureMeasurement', { measuredValue: measured }, 'updateTemperature')
|
|
1694
|
+
// Thermostat-specific mapping
|
|
1695
|
+
if (status?.targetTemp !== undefined || status?.targetTemperature !== undefined || status?.heatingSetpoint !== undefined) {
|
|
1696
|
+
const target = Number(status?.targetTemp ?? status?.targetTemperature ?? status?.heatingSetpoint)
|
|
1697
|
+
const val = Math.round(target * 100)
|
|
1698
|
+
await safeUpdate('thermostat', { occupiedHeatingSetpoint: val }, 'updateHeatingSetpoint')
|
|
1699
|
+
}
|
|
1700
|
+
} catch (e: any) {
|
|
1701
|
+
this.debugLog(`Failed to apply temperature for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
// Humidity (support different keys)
|
|
1706
|
+
if (status?.humidity !== undefined || status?.h !== undefined || status?.humid !== undefined) {
|
|
1707
|
+
try {
|
|
1708
|
+
const percent = Number(status?.humidity ?? status?.h ?? status?.humid)
|
|
1709
|
+
const measured = Math.round(percent * 100)
|
|
1710
|
+
await safeUpdate('relativeHumidityMeasurement', { measuredValue: measured }, 'updateHumidity')
|
|
1711
|
+
} catch (e: any) {
|
|
1712
|
+
this.debugLog(`Failed to apply humidity for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// Contact / Leak -> BooleanState
|
|
1717
|
+
if (status?.contact !== undefined || status?.open !== undefined || status?.leak !== undefined || status?.water !== undefined) {
|
|
1718
|
+
try {
|
|
1719
|
+
const isContactOpen = status?.contact ?? status?.open
|
|
1720
|
+
if (isContactOpen !== undefined) {
|
|
1721
|
+
if ((dev.deviceType || '').includes('Contact')) {
|
|
1722
|
+
await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: !(String(isContactOpen).toLowerCase() === 'true' || Number(isContactOpen) === 1) }, 'updateContactState')
|
|
1723
|
+
} else {
|
|
1724
|
+
await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: (String(isContactOpen).toLowerCase() === 'true' || Number(isContactOpen) === 1) }, 'updateContactState')
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
const leakDetected = status?.leak ?? status?.water
|
|
1728
|
+
if (leakDetected !== undefined) {
|
|
1729
|
+
await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: Boolean(leakDetected) }, 'updateLeakState')
|
|
1730
|
+
}
|
|
1731
|
+
} catch (e: any) {
|
|
1732
|
+
this.debugLog(`Failed to apply contact/leak for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// Motion -> occupancy
|
|
1737
|
+
if (status?.motion !== undefined || status?.m !== undefined) {
|
|
1738
|
+
try {
|
|
1739
|
+
const detected = Boolean(status?.motion ?? status?.m)
|
|
1740
|
+
await safeUpdate('occupancySensing', { occupancy: { occupied: detected } }, 'updateOccupancy')
|
|
1741
|
+
} catch (e: any) {
|
|
1742
|
+
this.debugLog(`Failed to apply motion for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// Lock state
|
|
1747
|
+
if (status?.lock !== undefined) {
|
|
1748
|
+
try {
|
|
1749
|
+
const s = String(status.lock).toLowerCase()
|
|
1750
|
+
let lockState = 0
|
|
1751
|
+
if (s === 'locked' || s === '1' || s === 'true') {
|
|
1752
|
+
lockState = 1
|
|
1753
|
+
} else if (s === 'unlocked' || s === '0' || s === 'false') {
|
|
1754
|
+
lockState = 2
|
|
1755
|
+
}
|
|
1756
|
+
await safeUpdate(this.api.matter.clusterNames.DoorLock, { lockState }, 'updateLockState')
|
|
1757
|
+
} catch (e: any) {
|
|
1758
|
+
this.debugLog(`Failed to apply lock for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// Cover position
|
|
1763
|
+
if (status?.position !== undefined || status?.percent !== undefined) {
|
|
1764
|
+
try {
|
|
1765
|
+
const openPercent = Number(status?.position ?? status?.percent)
|
|
1766
|
+
const closedPercent = 100 - Math.max(0, Math.min(100, openPercent))
|
|
1767
|
+
const value = Math.round(closedPercent * 100)
|
|
1768
|
+
await safeUpdate(this.api.matter.clusterNames.WindowCovering, { currentPositionLiftPercent100ths: value, targetPositionLiftPercent100ths: value }, 'updateLiftPosition')
|
|
1769
|
+
} catch (e: any) {
|
|
1770
|
+
this.debugLog(`Failed to apply cover position for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// Fan
|
|
1775
|
+
if (status?.fanSpeed !== undefined || status?.speed !== undefined) {
|
|
1776
|
+
try {
|
|
1777
|
+
const percent = Number(status?.fanSpeed ?? status?.speed)
|
|
1778
|
+
await safeUpdate(this.api.matter.clusterNames.FanControl, { percentSetting: percent, percentCurrent: percent }, 'updateFanSpeed')
|
|
1779
|
+
} catch (e: any) {
|
|
1780
|
+
this.debugLog(`Failed to apply fan speed for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// Robot vacuum: run/operational/clean modes
|
|
1785
|
+
if (status?.rvcRunMode !== undefined) {
|
|
1786
|
+
try {
|
|
1787
|
+
await safeUpdate('rvcRunMode', { currentMode: Number(status.rvcRunMode) }, 'updateRunMode')
|
|
1788
|
+
} catch (e: any) {
|
|
1789
|
+
this.debugLog(`Failed to apply rvcRunMode for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
// CO2 (carbon dioxide) - support common synonyms
|
|
1793
|
+
if (status?.co2 !== undefined || status?.co2ppm !== undefined || status?.carbonDioxide !== undefined) {
|
|
1794
|
+
try {
|
|
1795
|
+
const val = Number(status?.co2 ?? status?.co2ppm ?? status?.carbonDioxide)
|
|
1796
|
+
await safeUpdate('carbonDioxide', { carbonDioxideLevel: val }, 'updateCO2')
|
|
1797
|
+
} catch (e: any) {
|
|
1798
|
+
this.debugLog(`Failed to apply CO2 for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// PM2.5 / particulate matter
|
|
1803
|
+
if (status?.pm2_5 !== undefined || status?.pm25 !== undefined || status?.pm_2_5 !== undefined) {
|
|
1804
|
+
try {
|
|
1805
|
+
const pm = Number(status?.pm2_5 ?? status?.pm25 ?? status?.pm_2_5)
|
|
1806
|
+
await safeUpdate('pm2_5', { pm25: pm }, 'updatePM25')
|
|
1807
|
+
} catch (e: any) {
|
|
1808
|
+
this.debugLog(`Failed to apply PM2.5 for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
// PM10 (some devices report pm10)
|
|
1812
|
+
if (status?.pm10 !== undefined) {
|
|
1813
|
+
try {
|
|
1814
|
+
const pm10 = Number(status?.pm10)
|
|
1815
|
+
await safeUpdate('pm10', { pm10 }, 'updatePM10')
|
|
1816
|
+
} catch (e: any) {
|
|
1817
|
+
this.debugLog(`Failed to apply PM10 for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// VOC / TVOC - volatile organic compounds
|
|
1822
|
+
if (status?.voc !== undefined || status?.tvoc !== undefined) {
|
|
1823
|
+
try {
|
|
1824
|
+
const val = Number(status?.voc ?? status?.tvoc)
|
|
1825
|
+
await safeUpdate('voc', { voc: val }, 'updateVOC')
|
|
1826
|
+
} catch (e: any) {
|
|
1827
|
+
this.debugLog(`Failed to apply VOC for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
if (status?.rvcOperationalState !== undefined) {
|
|
1831
|
+
try {
|
|
1832
|
+
await safeUpdate('rvcOperationalState', { operationalState: Number(status.rvcOperationalState) }, 'updateOperationalState')
|
|
1833
|
+
} catch (e: any) {
|
|
1834
|
+
this.debugLog(`Failed to apply rvcOperationalState for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
} catch (e: any) {
|
|
1838
|
+
this.debugLog(`applyStatusToAccessory top-level failure for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
/**
|
|
1843
|
+
* Apply status update with a small retry if the accessory is not yet registered.
|
|
1844
|
+
* Useful for first-time updates that can race with registration.
|
|
1845
|
+
*/
|
|
1846
|
+
private async applyStatusWithRegistrationRetry(
|
|
1847
|
+
uuidLocal: string,
|
|
1848
|
+
dev: device & devicesConfig,
|
|
1849
|
+
status: any,
|
|
1850
|
+
retries = 1,
|
|
1851
|
+
delayMs = 300,
|
|
1852
|
+
): Promise<void> {
|
|
1853
|
+
let attempt = 0
|
|
1854
|
+
// Normalize retries bounds
|
|
1855
|
+
const maxAttempts = Math.max(1, retries + 1)
|
|
1856
|
+
while (attempt < maxAttempts) {
|
|
1857
|
+
try {
|
|
1858
|
+
await this.applyStatusToAccessory(uuidLocal, dev, status)
|
|
1859
|
+
return
|
|
1860
|
+
} catch (e: any) {
|
|
1861
|
+
const msg = String(e?.message ?? e)
|
|
1862
|
+
const notReady = msg.includes('not found') || msg.includes('not registered')
|
|
1863
|
+
if (!notReady || attempt >= maxAttempts - 1) {
|
|
1864
|
+
throw e
|
|
1865
|
+
}
|
|
1866
|
+
this.debugLog(`Accessory ${uuidLocal} not registered yet for ${dev.deviceId}, retrying in ${delayMs}ms`)
|
|
1867
|
+
await sleep(delayMs)
|
|
1868
|
+
attempt++
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
|
|
573
1873
|
/**
|
|
574
1874
|
* Required for DynamicPlatformPlugin
|
|
575
1875
|
* Called when homebridge restores cached accessories from disk at startup
|
|
@@ -587,7 +1887,7 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
587
1887
|
* any custom data you stored when the accessory was originally registered.
|
|
588
1888
|
*/
|
|
589
1889
|
configureMatterAccessory(accessory: SerializedMatterAccessory) {
|
|
590
|
-
this.
|
|
1890
|
+
this.debugLog('Loading cached Matter accessory:', accessory.displayName)
|
|
591
1891
|
this.matterAccessories.set(accessory.uuid, accessory)
|
|
592
1892
|
}
|
|
593
1893
|
|
|
@@ -595,51 +1895,224 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
595
1895
|
* Register all Matter accessories
|
|
596
1896
|
*/
|
|
597
1897
|
private async registerMatterAccessories() {
|
|
598
|
-
this.log.info('═'.repeat(80))
|
|
599
|
-
this.log.info('Homebridge Matter Plugin')
|
|
600
|
-
this.log.info('═'.repeat(80))
|
|
601
|
-
|
|
602
1898
|
// Remove accessories that are disabled in config
|
|
603
1899
|
await this.removeDisabledAccessories()
|
|
604
1900
|
|
|
605
1901
|
// If we discovered real SwitchBot devices via OpenAPI, map and register them
|
|
606
1902
|
if (this.discoveredDevices && this.discoveredDevices.length > 0) {
|
|
607
|
-
this.
|
|
608
|
-
|
|
609
|
-
|
|
1903
|
+
this.infoLog(`Registering ${this.discoveredDevices.length} discovered SwitchBot device(s) as Matter accessories`)
|
|
1904
|
+
|
|
1905
|
+
// Merge device config (deviceConfig per deviceType and per-device overrides) to match HAP behavior
|
|
1906
|
+
const devicesToProcess = await this.mergeDiscoveredDevices(this.discoveredDevices)
|
|
1907
|
+
|
|
1908
|
+
// By default, automatically remove previously-registered Matter
|
|
1909
|
+
// accessories whose deviceId is not present in the merged discovered
|
|
1910
|
+
// list. If the user explicitly sets `options.keepStaleAccessories` to
|
|
1911
|
+
// true, then we will keep previously-registered accessories (legacy
|
|
1912
|
+
// behavior).
|
|
1913
|
+
if ((this.config as any).options?.keepStaleAccessories) {
|
|
1914
|
+
this.debugLog('Keeping previously-registered stale accessories because options.keepStaleAccessories=true')
|
|
1915
|
+
} else {
|
|
1916
|
+
try {
|
|
1917
|
+
const desiredIds = new Set((devicesToProcess || []).map((d: any) => this.normalizeDeviceId(d.deviceId)))
|
|
1918
|
+
const toUnregister: Array<MatterAccessory<Record<string, unknown>>> = []
|
|
1919
|
+
for (const [uuid, acc] of Array.from(this.matterAccessories.entries())) {
|
|
1920
|
+
try {
|
|
1921
|
+
const deviceId = (acc as any)?.context?.deviceId
|
|
1922
|
+
if (!deviceId) {
|
|
1923
|
+
continue
|
|
1924
|
+
}
|
|
1925
|
+
const nid = this.normalizeDeviceId(deviceId)
|
|
1926
|
+
if (!desiredIds.has(nid)) {
|
|
1927
|
+
// Accessory exists but is no longer desired -> schedule for removal
|
|
1928
|
+
this.infoLog(`Removing previously-registered accessory for deviceId=${deviceId} (no longer discovered or configured)`)
|
|
1929
|
+
try {
|
|
1930
|
+
this.clearDeviceResources(deviceId)
|
|
1931
|
+
} catch (e: any) {
|
|
1932
|
+
this.debugLog(`Failed to clear resources for ${deviceId} before unregister: ${e?.message ?? e}`)
|
|
1933
|
+
}
|
|
1934
|
+
toUnregister.push(acc as unknown as MatterAccessory<Record<string, unknown>>)
|
|
1935
|
+
this.matterAccessories.delete(uuid)
|
|
1936
|
+
}
|
|
1937
|
+
} catch (e: any) {
|
|
1938
|
+
this.debugLog(`Error while checking existing accessory ${uuid}: ${e?.message ?? e}`)
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
if (toUnregister.length > 0) {
|
|
1943
|
+
try {
|
|
1944
|
+
await this.api.matter.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, toUnregister)
|
|
1945
|
+
} catch (e: any) {
|
|
1946
|
+
this.debugLog(`Failed to unregister accessories: ${e?.message ?? e}`)
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
} catch (e: any) {
|
|
1950
|
+
this.debugLog(`Failed to remove stale accessories: ${e?.message ?? e}`)
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
// We'll separate discovered devices into two buckets:
|
|
1955
|
+
// - platformAccessories: accessories that will be hosted under the plugin's Matter bridge
|
|
1956
|
+
// - roboticAccessories: robot vacuum devices which require standalone commissioning behaviour
|
|
1957
|
+
const platformAccessories: Array<MatterAccessory<Record<string, unknown>>> = []
|
|
1958
|
+
const roboticAccessories: Array<MatterAccessory<Record<string, unknown>>> = []
|
|
1959
|
+
|
|
1960
|
+
// Known robot vacuum deviceType names (matches mapping in createAccessoryFromDevice)
|
|
1961
|
+
const robotTypes = new Set([
|
|
1962
|
+
'K10+',
|
|
1963
|
+
'K10+ Pro',
|
|
1964
|
+
'WoSweeper',
|
|
1965
|
+
'WoSweeperMini',
|
|
1966
|
+
'Robot Vacuum Cleaner S1',
|
|
1967
|
+
'Robot Vacuum Cleaner S1 Plus',
|
|
1968
|
+
'Robot Vacuum Cleaner S10',
|
|
1969
|
+
'Robot Vacuum Cleaner S1 Pro',
|
|
1970
|
+
'Robot Vacuum Cleaner S1 Mini',
|
|
1971
|
+
])
|
|
1972
|
+
|
|
1973
|
+
for (const dev of devicesToProcess) {
|
|
610
1974
|
try {
|
|
611
1975
|
const acc = await this.createAccessoryFromDevice(dev)
|
|
612
|
-
if (acc) {
|
|
613
|
-
|
|
1976
|
+
if (!acc) {
|
|
1977
|
+
continue
|
|
1978
|
+
}
|
|
1979
|
+
if (robotTypes.has(dev.deviceType ?? '')) {
|
|
1980
|
+
roboticAccessories.push(acc)
|
|
1981
|
+
} else {
|
|
1982
|
+
platformAccessories.push(acc)
|
|
614
1983
|
}
|
|
615
1984
|
} catch (e: any) {
|
|
616
|
-
this.
|
|
1985
|
+
this.errorLog(`Failed to create Matter accessory for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
617
1986
|
}
|
|
618
1987
|
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
1988
|
+
|
|
1989
|
+
// Register platform-hosted accessories (most devices)
|
|
1990
|
+
if (platformAccessories.length > 0) {
|
|
1991
|
+
this.infoLog(`✓ Registered ${platformAccessories.length} discovered platform-hosted device(s)`)
|
|
1992
|
+
for (const acc of platformAccessories) {
|
|
1993
|
+
this.infoLog(` - ${acc.displayName}`)
|
|
623
1994
|
}
|
|
624
|
-
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME,
|
|
625
|
-
|
|
1995
|
+
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, platformAccessories)
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// Register robotic accessories (robot vacuums) separately so they can be
|
|
1999
|
+
// commissioned in the way Apple Home expects (these devices often require
|
|
2000
|
+
// standalone commissioning flow). We still call registerPlatformAccessories
|
|
2001
|
+
// because the accessory implementations manage their commissioning behavior.
|
|
2002
|
+
if (roboticAccessories.length > 0) {
|
|
2003
|
+
this.infoLog(`✓ Registered ${roboticAccessories.length} discovered robot vacuum device(s)`)
|
|
2004
|
+
for (const acc of roboticAccessories) {
|
|
2005
|
+
this.infoLog(` - ${acc.displayName} (standalone for Apple Home compatibility)`)
|
|
2006
|
+
}
|
|
2007
|
+
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, roboticAccessories)
|
|
626
2008
|
}
|
|
627
|
-
|
|
2009
|
+
|
|
2010
|
+
// Debug/info: how many discovered vs example accessories were registered.
|
|
2011
|
+
// Example accessories are disabled — we intentionally do NOT register them.
|
|
2012
|
+
const discoveredRegistered = platformAccessories.length + roboticAccessories.length
|
|
2013
|
+
const exampleRegistered = 0
|
|
2014
|
+
this.debugLog(`Discovered accessories registered: ${discoveredRegistered}; Example accessories registered: ${exampleRegistered}`)
|
|
2015
|
+
|
|
2016
|
+
// Dump registry state to help runtime debugging: which accessory instances
|
|
2017
|
+
// were created and which refresh timers are scheduled. This helps confirm
|
|
2018
|
+
// whether safeUpdate will prefer accessory helpers and whether periodic
|
|
2019
|
+
// refreshes exist for each device.
|
|
2020
|
+
try {
|
|
2021
|
+
const instanceKeys = Array.from(this.accessoryInstances.keys())
|
|
2022
|
+
this.debugLog(`Accessory instances registered (${instanceKeys.length}): ${JSON.stringify(instanceKeys)}`)
|
|
2023
|
+
const timerKeys = Array.from(this.refreshTimers.keys())
|
|
2024
|
+
this.debugLog(`Refresh timers scheduled (${timerKeys.length}): ${JSON.stringify(timerKeys)}`)
|
|
2025
|
+
} catch (e: any) {
|
|
2026
|
+
this.debugLog(`Failed to dump platform registries: ${e?.message ?? e}`)
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
return
|
|
628
2030
|
}
|
|
629
2031
|
|
|
630
|
-
//
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
2032
|
+
// If no discovered devices are available, check for cached Matter accessories
|
|
2033
|
+
const hasCachedAccessories = this.matterAccessories.size > 0
|
|
2034
|
+
if (hasCachedAccessories) {
|
|
2035
|
+
this.infoLog(`No devices discovered via OpenAPI, but found ${this.matterAccessories.size} cached Matter accessories.`)
|
|
2036
|
+
this.infoLog('Cached accessories will continue to function with webhook updates.')
|
|
2037
|
+
this.infoLog('Restoring webhook handlers for cached Matter accessories...')
|
|
2038
|
+
await this.restoreCachedMatterAccessoryWebhooks()
|
|
2039
|
+
this.infoLog('Device discovery will resume when API becomes available.')
|
|
2040
|
+
} else {
|
|
2041
|
+
this.infoLog('No discovered SwitchBot devices found.')
|
|
2042
|
+
}
|
|
639
2043
|
|
|
640
|
-
this.
|
|
641
|
-
this.
|
|
642
|
-
this.
|
|
2044
|
+
this.debugLog('═'.repeat(80))
|
|
2045
|
+
this.debugLog('Finished registering Matter accessories')
|
|
2046
|
+
this.debugLog('═'.repeat(80))
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
/**
|
|
2050
|
+
* Restore webhook handlers for cached Matter accessories
|
|
2051
|
+
* This ensures webhook functionality continues to work even when device discovery fails
|
|
2052
|
+
*/
|
|
2053
|
+
private async restoreCachedMatterAccessoryWebhooks() {
|
|
2054
|
+
this.debugLog('Restoring webhook handlers for cached Matter accessories...')
|
|
2055
|
+
|
|
2056
|
+
let restoredCount = 0
|
|
2057
|
+
let fallbackCount = 0
|
|
2058
|
+
for (const [uuid, accessory] of this.matterAccessories.entries()) {
|
|
2059
|
+
try {
|
|
2060
|
+
const context = (accessory as any)?.context
|
|
2061
|
+
const deviceId = context?.deviceId
|
|
2062
|
+
let webhook = context?.webhook as boolean | undefined
|
|
2063
|
+
// If cached accessory predates global webhook context, fall back to global option
|
|
2064
|
+
if (webhook === undefined && this.config.options?.webhook === true) {
|
|
2065
|
+
webhook = true
|
|
2066
|
+
try {
|
|
2067
|
+
;(accessory as any).context.webhook = true
|
|
2068
|
+
this.debugLog(`Applying global webhook fallback for cached Matter device ${deviceId}`)
|
|
2069
|
+
fallbackCount++
|
|
2070
|
+
} catch (e: any) {
|
|
2071
|
+
this.debugLog(`Failed to persist global webhook fallback for ${deviceId}: ${e?.message ?? e}`)
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
if (!deviceId) {
|
|
2076
|
+
this.debugLog(`Skipping cached accessory ${accessory.displayName} - no deviceId in context`)
|
|
2077
|
+
continue
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// Only register webhook if the device had webhook enabled
|
|
2081
|
+
if (webhook) {
|
|
2082
|
+
this.debugLog(`Restoring webhook handler for Matter device: ${deviceId}`)
|
|
2083
|
+
|
|
2084
|
+
// Create a minimal device object from cached context for webhook handling
|
|
2085
|
+
const dev: any = {
|
|
2086
|
+
deviceId,
|
|
2087
|
+
deviceName: context?.name || accessory.displayName,
|
|
2088
|
+
deviceType: context?.deviceType,
|
|
2089
|
+
webhook: true,
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
this.webhookEventHandler[deviceId] = async (webhookContext: any) => {
|
|
2093
|
+
try {
|
|
2094
|
+
this.debugLog(`Received webhook for cached Matter device ${deviceId}: ${JSON.stringify(webhookContext)}`)
|
|
2095
|
+
// Apply webhook status update to the accessory
|
|
2096
|
+
await this.applyStatusWithRegistrationRetry(uuid, dev, webhookContext)
|
|
2097
|
+
} catch (e: any) {
|
|
2098
|
+
this.errorLog(`Failed to handle webhook for cached device ${deviceId}: ${e?.message ?? e}`)
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
restoredCount++
|
|
2103
|
+
this.debugSuccessLog(`Webhook handler restored for ${deviceId}`)
|
|
2104
|
+
} else {
|
|
2105
|
+
this.debugLog(`Device ${deviceId} does not have webhook enabled, skipping`)
|
|
2106
|
+
}
|
|
2107
|
+
} catch (e: any) {
|
|
2108
|
+
this.errorLog(`Failed to restore webhook handler for cached accessory ${accessory.displayName}: ${e?.message ?? e}`)
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
if (fallbackCount > 0) {
|
|
2113
|
+
this.infoLog(`Applied global webhook fallback for ${fallbackCount} cached Matter accessories`)
|
|
2114
|
+
}
|
|
2115
|
+
this.infoLog(`Restored webhook handlers for ${restoredCount} cached Matter accessories`)
|
|
643
2116
|
}
|
|
644
2117
|
|
|
645
2118
|
/**
|
|
@@ -674,7 +2147,16 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
674
2147
|
if (enabled === false) {
|
|
675
2148
|
const existingAccessory = this.matterAccessories.get(uuid)
|
|
676
2149
|
if (existingAccessory) {
|
|
677
|
-
this.
|
|
2150
|
+
this.infoLog(`Removing accessory '${name}' (disabled in config)`)
|
|
2151
|
+
// Attempt to clear any per-device resources (timers, BLE handlers, instances)
|
|
2152
|
+
try {
|
|
2153
|
+
const deviceId = (existingAccessory as any)?.context?.deviceId
|
|
2154
|
+
if (deviceId) {
|
|
2155
|
+
this.clearDeviceResources(deviceId)
|
|
2156
|
+
}
|
|
2157
|
+
} catch (e: any) {
|
|
2158
|
+
this.debugLog(`Failed to clear resources for disabled accessory ${name}: ${e?.message ?? e}`)
|
|
2159
|
+
}
|
|
678
2160
|
await this.api.matter.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory as unknown as MatterAccessory])
|
|
679
2161
|
this.matterAccessories.delete(uuid)
|
|
680
2162
|
}
|
|
@@ -686,9 +2168,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
686
2168
|
* Section 4: Lighting Devices (Matter Spec § 4)
|
|
687
2169
|
*/
|
|
688
2170
|
private async registerSection4Lighting() {
|
|
689
|
-
this.
|
|
690
|
-
this.
|
|
691
|
-
this.
|
|
2171
|
+
this.debugLog('═'.repeat(80))
|
|
2172
|
+
this.infoLog('Section 4: Lighting Devices (Matter Spec § 4)')
|
|
2173
|
+
this.debugLog('═'.repeat(80))
|
|
692
2174
|
|
|
693
2175
|
const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
|
|
694
2176
|
|
|
@@ -723,9 +2205,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
723
2205
|
}
|
|
724
2206
|
|
|
725
2207
|
if (accessories.length > 0) {
|
|
726
|
-
this.
|
|
2208
|
+
this.infoLog(`✓ Registered ${accessories.length} lighting device(s)`)
|
|
727
2209
|
for (const acc of accessories) {
|
|
728
|
-
this.
|
|
2210
|
+
this.infoLog(` - ${acc.displayName}`)
|
|
729
2211
|
}
|
|
730
2212
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
|
|
731
2213
|
}
|
|
@@ -735,9 +2217,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
735
2217
|
* Section 5: Smart Plugs/Actuators (Matter Spec § 5)
|
|
736
2218
|
*/
|
|
737
2219
|
private async registerSection5SmartPlugs() {
|
|
738
|
-
this.
|
|
739
|
-
this.
|
|
740
|
-
this.
|
|
2220
|
+
this.debugLog('═'.repeat(80))
|
|
2221
|
+
this.infoLog('Section 5: Smart Plugs/Actuators (Matter Spec § 5)')
|
|
2222
|
+
this.debugLog('═'.repeat(80))
|
|
741
2223
|
|
|
742
2224
|
const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
|
|
743
2225
|
|
|
@@ -748,9 +2230,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
748
2230
|
}
|
|
749
2231
|
|
|
750
2232
|
if (accessories.length > 0) {
|
|
751
|
-
this.
|
|
2233
|
+
this.infoLog(`✓ Registered ${accessories.length} smart plug/actuator device(s)`)
|
|
752
2234
|
for (const acc of accessories) {
|
|
753
|
-
this.
|
|
2235
|
+
this.infoLog(` - ${acc.displayName}`)
|
|
754
2236
|
}
|
|
755
2237
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
|
|
756
2238
|
}
|
|
@@ -760,9 +2242,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
760
2242
|
* Section 6: Switches & Controllers (Matter Spec § 6)
|
|
761
2243
|
*/
|
|
762
2244
|
private async registerSection6Switches() {
|
|
763
|
-
this.
|
|
764
|
-
this.
|
|
765
|
-
this.
|
|
2245
|
+
this.debugLog('═'.repeat(80))
|
|
2246
|
+
this.infoLog('Section 6: Switches & Controllers (Matter Spec § 6)')
|
|
2247
|
+
this.debugLog('═'.repeat(80))
|
|
766
2248
|
|
|
767
2249
|
const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
|
|
768
2250
|
|
|
@@ -773,9 +2255,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
773
2255
|
}
|
|
774
2256
|
|
|
775
2257
|
if (accessories.length > 0) {
|
|
776
|
-
this.
|
|
2258
|
+
this.infoLog(`✓ Registered ${accessories.length} switch/controller device(s)`)
|
|
777
2259
|
for (const acc of accessories) {
|
|
778
|
-
this.
|
|
2260
|
+
this.infoLog(` - ${acc.displayName}`)
|
|
779
2261
|
}
|
|
780
2262
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
|
|
781
2263
|
}
|
|
@@ -785,9 +2267,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
785
2267
|
* Section 7: Sensors (Matter Spec § 7)
|
|
786
2268
|
*/
|
|
787
2269
|
private async registerSection7Sensors() {
|
|
788
|
-
this.
|
|
789
|
-
this.
|
|
790
|
-
this.
|
|
2270
|
+
this.debugLog('═'.repeat(80))
|
|
2271
|
+
this.infoLog('Section 7: Sensors (Matter Spec § 7)')
|
|
2272
|
+
this.debugLog('═'.repeat(80))
|
|
791
2273
|
|
|
792
2274
|
const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
|
|
793
2275
|
|
|
@@ -834,9 +2316,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
834
2316
|
}
|
|
835
2317
|
|
|
836
2318
|
if (accessories.length > 0) {
|
|
837
|
-
this.
|
|
2319
|
+
this.infoLog(`✓ Registered ${accessories.length} sensor device(s)`)
|
|
838
2320
|
for (const acc of accessories) {
|
|
839
|
-
this.
|
|
2321
|
+
this.infoLog(` - ${acc.displayName}`)
|
|
840
2322
|
}
|
|
841
2323
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
|
|
842
2324
|
}
|
|
@@ -846,9 +2328,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
846
2328
|
* Section 8: Closure Devices (Matter Spec § 8)
|
|
847
2329
|
*/
|
|
848
2330
|
private async registerSection8Closure() {
|
|
849
|
-
this.
|
|
850
|
-
this.
|
|
851
|
-
this.
|
|
2331
|
+
this.debugLog('═'.repeat(80))
|
|
2332
|
+
this.infoLog('Section 8: Closure Devices (Matter Spec § 8)')
|
|
2333
|
+
this.debugLog('═'.repeat(80))
|
|
852
2334
|
|
|
853
2335
|
const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
|
|
854
2336
|
|
|
@@ -871,9 +2353,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
871
2353
|
}
|
|
872
2354
|
|
|
873
2355
|
if (accessories.length > 0) {
|
|
874
|
-
this.
|
|
2356
|
+
this.infoLog(`✓ Registered ${accessories.length} closure device(s)`)
|
|
875
2357
|
for (const acc of accessories) {
|
|
876
|
-
this.
|
|
2358
|
+
this.infoLog(` - ${acc.displayName}`)
|
|
877
2359
|
}
|
|
878
2360
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
|
|
879
2361
|
}
|
|
@@ -883,9 +2365,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
883
2365
|
* Section 9: HVAC (Matter Spec § 9)
|
|
884
2366
|
*/
|
|
885
2367
|
private async registerSection9HVAC() {
|
|
886
|
-
this.
|
|
887
|
-
this.
|
|
888
|
-
this.
|
|
2368
|
+
this.debugLog('═'.repeat(80))
|
|
2369
|
+
this.infoLog('Section 9: HVAC (Matter Spec § 9)')
|
|
2370
|
+
this.debugLog('═'.repeat(80))
|
|
889
2371
|
|
|
890
2372
|
const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
|
|
891
2373
|
|
|
@@ -902,9 +2384,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
902
2384
|
}
|
|
903
2385
|
|
|
904
2386
|
if (accessories.length > 0) {
|
|
905
|
-
this.
|
|
2387
|
+
this.infoLog(`✓ Registered ${accessories.length} HVAC device(s)`)
|
|
906
2388
|
for (const acc of accessories) {
|
|
907
|
-
this.
|
|
2389
|
+
this.infoLog(` - ${acc.displayName}`)
|
|
908
2390
|
}
|
|
909
2391
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
|
|
910
2392
|
}
|
|
@@ -917,9 +2399,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
917
2399
|
* Use those codes to pair the vacuum as a separate bridge in your Home app.
|
|
918
2400
|
*/
|
|
919
2401
|
private async registerSection12Robotic() {
|
|
920
|
-
this.
|
|
921
|
-
this.
|
|
922
|
-
this.
|
|
2402
|
+
this.debugLog('═'.repeat(80))
|
|
2403
|
+
this.infoLog('Section 12: Robotic Devices (Matter Spec § 12)')
|
|
2404
|
+
this.debugLog('═'.repeat(80))
|
|
923
2405
|
|
|
924
2406
|
const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
|
|
925
2407
|
|
|
@@ -930,9 +2412,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
930
2412
|
}
|
|
931
2413
|
|
|
932
2414
|
if (accessories.length > 0) {
|
|
933
|
-
this.
|
|
2415
|
+
this.infoLog(`✓ Registered ${accessories.length} robot vacuum device(s)`)
|
|
934
2416
|
for (const acc of accessories) {
|
|
935
|
-
this.
|
|
2417
|
+
this.infoLog(` - ${acc.displayName} (standalone for Apple Home compatibility)`)
|
|
936
2418
|
}
|
|
937
2419
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
|
|
938
2420
|
}
|
|
@@ -946,9 +2428,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
946
2428
|
* like managing multiple logical components within a single device.
|
|
947
2429
|
*/
|
|
948
2430
|
private async registerCustomDevices() {
|
|
949
|
-
this.
|
|
950
|
-
this.
|
|
951
|
-
this.
|
|
2431
|
+
this.debugLog('═'.repeat(80))
|
|
2432
|
+
this.infoLog('Custom Devices')
|
|
2433
|
+
this.debugLog('═'.repeat(80))
|
|
952
2434
|
|
|
953
2435
|
const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
|
|
954
2436
|
|
|
@@ -959,11 +2441,211 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
959
2441
|
}
|
|
960
2442
|
|
|
961
2443
|
if (accessories.length > 0) {
|
|
962
|
-
this.
|
|
2444
|
+
this.infoLog(`✓ Registered ${accessories.length} custom device(s)`)
|
|
963
2445
|
for (const acc of accessories) {
|
|
964
|
-
this.
|
|
2446
|
+
this.infoLog(` - ${acc.displayName}`)
|
|
965
2447
|
}
|
|
966
2448
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
|
|
967
2449
|
}
|
|
968
2450
|
}
|
|
2451
|
+
|
|
2452
|
+
/**
|
|
2453
|
+
* Start platform-level refresh timer to batch all device status updates
|
|
2454
|
+
*/
|
|
2455
|
+
private startPlatformRefreshTimer(refreshRateSec: number): void {
|
|
2456
|
+
// Only create timer once
|
|
2457
|
+
if (this.platformRefreshTimer) {
|
|
2458
|
+
return
|
|
2459
|
+
}
|
|
2460
|
+
// Respect user toggle
|
|
2461
|
+
if (this.config.options?.matterBatchEnabled === false) {
|
|
2462
|
+
this.infoLog('Matter batch refresh is disabled by configuration')
|
|
2463
|
+
return
|
|
2464
|
+
}
|
|
2465
|
+
const jitterSec = Number(this.config.options?.matterBatchJitter ?? 0)
|
|
2466
|
+
const intervalMs = Number(refreshRateSec) * 1000
|
|
2467
|
+
const jitterMs = Number.isFinite(jitterSec) && jitterSec > 0 ? Math.floor(Math.random() * jitterSec * 1000) : 0
|
|
2468
|
+
// Start after optional jitter, then schedule recurring interval
|
|
2469
|
+
setTimeout(async () => {
|
|
2470
|
+
try {
|
|
2471
|
+
await this.batchRefreshAllDevices()
|
|
2472
|
+
} catch (e: any) {
|
|
2473
|
+
this.debugLog(`Initial batch refresh failed: ${e?.message ?? e}`)
|
|
2474
|
+
}
|
|
2475
|
+
this.platformRefreshTimer = setInterval(async () => {
|
|
2476
|
+
await this.batchRefreshAllDevices()
|
|
2477
|
+
}, intervalMs)
|
|
2478
|
+
}, jitterMs)
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
/**
|
|
2482
|
+
* Batch refresh all devices - still makes individual API calls but batches them together
|
|
2483
|
+
* Note: SwitchBot API doesn't support true batch status calls, but we can parallelize them
|
|
2484
|
+
*/
|
|
2485
|
+
private async batchRefreshAllDevices(): Promise<void> {
|
|
2486
|
+
if (!this.switchBotAPI) {
|
|
2487
|
+
return
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
this.debugLog('Performing batched periodic OpenAPI refresh for all devices')
|
|
2491
|
+
|
|
2492
|
+
// Build list from registered accessory instances (uuid) and discovered devices
|
|
2493
|
+
const devicesToRefresh: Array<{ uuid: string, dev: device }> = []
|
|
2494
|
+
try {
|
|
2495
|
+
for (const [nid, instance] of this.accessoryInstances.entries()) {
|
|
2496
|
+
const uuid = (instance as any)?.uuid as string | undefined
|
|
2497
|
+
if (!uuid) {
|
|
2498
|
+
continue
|
|
2499
|
+
}
|
|
2500
|
+
const dev = this.discoveredDevices.find(d => this.normalizeDeviceId(d.deviceId) === nid)
|
|
2501
|
+
// Skip devices with per-device timers and those in cooldown
|
|
2502
|
+
const now = Date.now()
|
|
2503
|
+
const nextAllowed = this.backoffCooldowns.get(nid) ?? 0
|
|
2504
|
+
if (dev && !this.perDeviceRefreshSet.has(nid) && now >= nextAllowed) {
|
|
2505
|
+
devicesToRefresh.push({ uuid, dev })
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
} catch (e: any) {
|
|
2509
|
+
this.errorLog(`Failed to enumerate devices for batch refresh: ${e?.message ?? e}`)
|
|
2510
|
+
return
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
if (devicesToRefresh.length === 0) {
|
|
2514
|
+
this.debugLog('No devices to refresh')
|
|
2515
|
+
return
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
this.debugLog(`Refreshing ${devicesToRefresh.length} devices in parallel batch`)
|
|
2519
|
+
|
|
2520
|
+
// Randomize order to reduce synchronized spikes
|
|
2521
|
+
for (let i = devicesToRefresh.length - 1; i > 0; i--) {
|
|
2522
|
+
const j = Math.floor(Math.random() * (i + 1))
|
|
2523
|
+
;[devicesToRefresh[i], devicesToRefresh[j]] = [devicesToRefresh[j], devicesToRefresh[i]]
|
|
2524
|
+
}
|
|
2525
|
+
const concurrency = Number(this.config.options?.matterBatchConcurrency ?? 5)
|
|
2526
|
+
await this.runWithConcurrency(devicesToRefresh, async ({ uuid, dev }) => {
|
|
2527
|
+
try {
|
|
2528
|
+
const status = await this.refreshSingleDeviceWithRetry(dev)
|
|
2529
|
+
if (status) {
|
|
2530
|
+
await this.applyStatusToAccessory(uuid, dev as any, status)
|
|
2531
|
+
}
|
|
2532
|
+
} catch (e: any) {
|
|
2533
|
+
this.errorLog(`Periodic OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
2534
|
+
}
|
|
2535
|
+
}, Number.isFinite(concurrency) && concurrency > 0 ? concurrency : 5)
|
|
2536
|
+
this.debugLog(`Batch refresh completed for ${devicesToRefresh.length} devices`)
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
/** Refresh a single device with retry and backoff; returns status object if successful */
|
|
2540
|
+
private async refreshSingleDeviceWithRetry(dev: device, retries = 3, baseDelayMs = 500): Promise<any | null> {
|
|
2541
|
+
const deviceId = dev.deviceId
|
|
2542
|
+
|
|
2543
|
+
// Check API budget BEFORE attempting any retries - don't waste cycles on blocked requests
|
|
2544
|
+
if (!this.apiTracker?.trySpend('poll')) {
|
|
2545
|
+
// Don't log on every blocked request - the ApiRequestTracker handles periodic warnings
|
|
2546
|
+
return null
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
let attempt = 0
|
|
2550
|
+
while (attempt <= retries) {
|
|
2551
|
+
try {
|
|
2552
|
+
const { response, statusCode } = await this.switchBotAPI!.getDeviceStatus(deviceId, this.config.credentials?.token, this.config.credentials?.secret)
|
|
2553
|
+
const respAny: any = response
|
|
2554
|
+
const body = respAny?.body ?? respAny
|
|
2555
|
+
if (isSuccessfulStatusCode(statusCode)) {
|
|
2556
|
+
const status = body?.status ?? body
|
|
2557
|
+
this.deviceStatusCache.set(this.normalizeDeviceId(deviceId), { status, timestamp: Date.now() })
|
|
2558
|
+
this.debugLog(`OpenAPI refresh succeeded for ${deviceId} (attempt ${attempt + 1})`)
|
|
2559
|
+
return status
|
|
2560
|
+
}
|
|
2561
|
+
this.debugLog(`OpenAPI refresh unexpected statusCode=${statusCode} for ${deviceId} (attempt ${attempt + 1})`)
|
|
2562
|
+
} catch (e: any) {
|
|
2563
|
+
this.debugLog(`OpenAPI refresh error for ${deviceId} (attempt ${attempt + 1}): ${e?.message ?? e}`)
|
|
2564
|
+
}
|
|
2565
|
+
// backoff before next retry if any left
|
|
2566
|
+
attempt++
|
|
2567
|
+
if (attempt <= retries) {
|
|
2568
|
+
const delay = baseDelayMs * (2 ** (attempt - 1))
|
|
2569
|
+
await sleep(delay)
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
// Set a cooldown after exhausting retries to avoid hammering problematic devices
|
|
2573
|
+
try {
|
|
2574
|
+
const nid = this.normalizeDeviceId(deviceId)
|
|
2575
|
+
const cooldownMs = Math.max(60_000, baseDelayMs * (2 ** retries) * 10)
|
|
2576
|
+
this.backoffCooldowns.set(nid, Date.now() + cooldownMs)
|
|
2577
|
+
this.debugLog(`Applied cooldown for ${deviceId}: ${Math.round(cooldownMs / 1000)}s`)
|
|
2578
|
+
} catch {}
|
|
2579
|
+
return null
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
/**
|
|
2583
|
+
* Validate that the user's configuration won't exceed API limits
|
|
2584
|
+
* Warn if device count × polling frequency will hit daily limits
|
|
2585
|
+
*/
|
|
2586
|
+
private validateApiUsageConfig(deviceCount: number, irDeviceCount: number): void {
|
|
2587
|
+
try {
|
|
2588
|
+
const totalDevices = deviceCount + irDeviceCount
|
|
2589
|
+
if (totalDevices === 0) {
|
|
2590
|
+
return
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// For Matter platform, use matterBatchRefreshRate or fallback to refreshRate
|
|
2594
|
+
const refreshRate = this.getPlatformBatchInterval() // seconds
|
|
2595
|
+
const dailyLimit = this.config.options?.dailyApiLimit ?? 10000
|
|
2596
|
+
const reserveForCommands = this.config.options?.dailyApiReserveForCommands ?? 1000
|
|
2597
|
+
|
|
2598
|
+
// Calculate polls per day (86400 seconds in a day)
|
|
2599
|
+
const pollsPerDevicePerDay = Math.floor(86400 / refreshRate)
|
|
2600
|
+
const totalPollsPerDay = pollsPerDevicePerDay * totalDevices
|
|
2601
|
+
|
|
2602
|
+
// Add discovery calls (typically 1-2 per day)
|
|
2603
|
+
const estimatedDiscoveryCalls = 2
|
|
2604
|
+
const totalEstimatedCalls = totalPollsPerDay + estimatedDiscoveryCalls
|
|
2605
|
+
|
|
2606
|
+
const usableLimit = dailyLimit - reserveForCommands
|
|
2607
|
+
const percentOfLimit = Math.round((totalEstimatedCalls / usableLimit) * 100)
|
|
2608
|
+
|
|
2609
|
+
this.debugLog(`[API Usage Diagnostic] ${totalDevices} devices × ${pollsPerDevicePerDay} polls/day = ${totalPollsPerDay} estimated daily polls`)
|
|
2610
|
+
this.debugLog(`[API Usage Diagnostic] With ${reserveForCommands} reserved for commands, usable limit is ${usableLimit}`)
|
|
2611
|
+
|
|
2612
|
+
if (totalEstimatedCalls > dailyLimit) {
|
|
2613
|
+
this.errorLog(`⚠️ API LIMIT WARNING: Your configuration will exceed the daily API limit!`)
|
|
2614
|
+
this.errorLog(` Devices: ${totalDevices} | Refresh rate: ${refreshRate}s | Estimated daily polls: ${totalEstimatedCalls}`)
|
|
2615
|
+
this.errorLog(` Daily limit: ${dailyLimit} | You will use ${percentOfLimit}% of available budget`)
|
|
2616
|
+
this.errorLog(` SOLUTION: Increase matterBatchRefreshRate to ${Math.ceil((totalDevices * 86400) / usableLimit)} seconds or higher`)
|
|
2617
|
+
this.errorLog(` OR: Enable webhooks and set 'webhookOnlyOnReserve: true' to reduce polling`)
|
|
2618
|
+
} else if (totalEstimatedCalls > usableLimit) {
|
|
2619
|
+
this.warnLog(`⚠️ API USAGE WARNING: Configuration may exceed usable daily API budget`)
|
|
2620
|
+
this.warnLog(` Devices: ${totalDevices} | Refresh rate: ${refreshRate}s | Estimated daily polls: ${totalEstimatedCalls}`)
|
|
2621
|
+
this.warnLog(` Usable limit (after reserve): ${usableLimit} | You will use ${percentOfLimit}% of budget`)
|
|
2622
|
+
this.warnLog(` Polling may pause when approaching limit. Consider increasing matterBatchRefreshRate to ${Math.ceil((totalDevices * 86400) / usableLimit)}s`)
|
|
2623
|
+
} else if (percentOfLimit > 75) {
|
|
2624
|
+
this.infoLog(`[API Usage] Using ${percentOfLimit}% of daily budget (${totalEstimatedCalls}/${usableLimit} calls). Monitor usage if adding more devices.`)
|
|
2625
|
+
} else {
|
|
2626
|
+
this.debugLog(`[API Usage] Configuration looks good: ${percentOfLimit}% of daily budget (${totalEstimatedCalls}/${usableLimit} calls)`)
|
|
2627
|
+
}
|
|
2628
|
+
} catch (e: any) {
|
|
2629
|
+
this.debugLog(`Failed to validate API usage config: ${e?.message ?? e}`)
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
/** Simple concurrency limiter for an array of items */
|
|
2634
|
+
private async runWithConcurrency<T>(items: T[], worker: (item: T) => Promise<void>, concurrency: number): Promise<void> {
|
|
2635
|
+
const queue = items.slice()
|
|
2636
|
+
const workers: Promise<void>[] = []
|
|
2637
|
+
const runNext = async (): Promise<void> => {
|
|
2638
|
+
const item = queue.shift()
|
|
2639
|
+
if (!item) {
|
|
2640
|
+
return
|
|
2641
|
+
}
|
|
2642
|
+
await worker(item)
|
|
2643
|
+
return runNext()
|
|
2644
|
+
}
|
|
2645
|
+
const pool = Math.min(concurrency, Math.max(1, items.length))
|
|
2646
|
+
for (let i = 0; i < pool; i++) {
|
|
2647
|
+
workers.push(runNext())
|
|
2648
|
+
}
|
|
2649
|
+
await Promise.all(workers)
|
|
2650
|
+
}
|
|
969
2651
|
}
|