@switchbot/homebridge-switchbot 5.0.0-beta.20 → 5.0.0-beta.22
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 +6 -0
- package/config.schema.json +9 -0
- package/dist/platform-matter.cleanup.test.d.ts +2 -0
- package/dist/platform-matter.cleanup.test.d.ts.map +1 -0
- package/dist/platform-matter.cleanup.test.js +85 -0
- package/dist/platform-matter.cleanup.test.js.map +1 -0
- package/dist/platform-matter.d.ts.map +1 -1
- package/dist/platform-matter.js +179 -7
- package/dist/platform-matter.js.map +1 -1
- package/dist/platform-matter.mapping.test.d.ts +2 -0
- package/dist/platform-matter.mapping.test.d.ts.map +1 -0
- package/dist/platform-matter.mapping.test.js +50 -0
- package/dist/platform-matter.mapping.test.js.map +1 -0
- package/dist/platform-matter.unregister.test.d.ts +2 -0
- package/dist/platform-matter.unregister.test.d.ts.map +1 -0
- package/dist/platform-matter.unregister.test.js +37 -0
- package/dist/platform-matter.unregister.test.js.map +1 -0
- package/dist/settings.d.ts +6 -0
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js.map +1 -1
- package/docs/variables/default.html +1 -1
- package/package.json +1 -1
- package/src/platform-matter.cleanup.test.ts +101 -0
- package/src/platform-matter.mapping.test.ts +60 -0
- package/src/platform-matter.ts +183 -7
- package/src/platform-matter.unregister.test.ts +48 -0
- package/src/settings.ts +6 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { SwitchBotMatterPlatform } from './platform-matter.js'
|
|
4
|
+
import { formatDeviceIdAsMac } from './utils.js'
|
|
5
|
+
|
|
6
|
+
describe('platform-matter lifecycle cleanup', () => {
|
|
7
|
+
it('clearDeviceResources removes timers, instances and BLE handler entries', async () => {
|
|
8
|
+
// Setup stubbed API and logs
|
|
9
|
+
const handlers: Record<string, (...args: any[]) => any> = {}
|
|
10
|
+
const api: any = {
|
|
11
|
+
matter: {
|
|
12
|
+
uuid: { generate: (s: string) => `uuid-${s}` },
|
|
13
|
+
registerPlatformAccessories: vi.fn(),
|
|
14
|
+
unregisterPlatformAccessories: vi.fn(),
|
|
15
|
+
clusterNames: { OnOff: 'OnOff' },
|
|
16
|
+
},
|
|
17
|
+
isMatterAvailable: () => true,
|
|
18
|
+
isMatterEnabled: () => true,
|
|
19
|
+
on: (ev: string, fn: (...args: any[]) => any) => { handlers[ev] = fn },
|
|
20
|
+
_handlers: handlers,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const log = { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }
|
|
24
|
+
|
|
25
|
+
const platform = new SwitchBotMatterPlatform(log as any, {} as any, api)
|
|
26
|
+
|
|
27
|
+
// Insert a fake timer, accessory instance, and BLE handler
|
|
28
|
+
const deviceId = 'AA:BB:CC:11:22:33'
|
|
29
|
+
const nid = (platform as any).normalizeDeviceId(deviceId)
|
|
30
|
+
|
|
31
|
+
const timer = setInterval(() => {}, 100000)
|
|
32
|
+
;(platform as any).refreshTimers.set(nid, timer)
|
|
33
|
+
;(platform as any).accessoryInstances.set(nid, { dummy: true })
|
|
34
|
+
;(platform as any).bleEventHandler[deviceId.toLowerCase()] = () => {}
|
|
35
|
+
|
|
36
|
+
// Ensure they exist prior
|
|
37
|
+
expect((platform as any).refreshTimers.get(nid)).toBeDefined()
|
|
38
|
+
expect((platform as any).accessoryInstances.get(nid)).toBeDefined()
|
|
39
|
+
expect((platform as any).bleEventHandler[deviceId.toLowerCase()]).toBeDefined()
|
|
40
|
+
|
|
41
|
+
// Call the private helper
|
|
42
|
+
;(platform as any).clearDeviceResources(deviceId)
|
|
43
|
+
|
|
44
|
+
// Now they should be removed
|
|
45
|
+
expect((platform as any).refreshTimers.get(nid)).toBeUndefined()
|
|
46
|
+
expect((platform as any).accessoryInstances.get(nid)).toBeUndefined()
|
|
47
|
+
expect((platform as any).bleEventHandler[deviceId.toLowerCase()]).toBeUndefined()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('shutdown handler clears all timers and handlers when invoked', async () => {
|
|
51
|
+
// Setup stubbed API and logs
|
|
52
|
+
const handlers: Record<string, (...args: any[]) => any> = {}
|
|
53
|
+
const api: any = {
|
|
54
|
+
matter: {
|
|
55
|
+
uuid: { generate: (s: string) => `uuid-${s}` },
|
|
56
|
+
registerPlatformAccessories: vi.fn(),
|
|
57
|
+
unregisterPlatformAccessories: vi.fn(),
|
|
58
|
+
clusterNames: { OnOff: 'OnOff' },
|
|
59
|
+
},
|
|
60
|
+
isMatterAvailable: () => true,
|
|
61
|
+
isMatterEnabled: () => true,
|
|
62
|
+
on: (ev: string, fn: (...args: any[]) => any) => { handlers[ev] = fn },
|
|
63
|
+
_handlers: handlers,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const log = { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }
|
|
67
|
+
|
|
68
|
+
const platform = new SwitchBotMatterPlatform(log as any, {} as any, api)
|
|
69
|
+
|
|
70
|
+
// Add two timers to the platform (using normalized ids)
|
|
71
|
+
const ids = ['devA', 'devB']
|
|
72
|
+
for (const id of ids) {
|
|
73
|
+
const nid = (platform as any).normalizeDeviceId(id)
|
|
74
|
+
const t = setInterval(() => {}, 100000)
|
|
75
|
+
;(platform as any).refreshTimers.set(nid, t)
|
|
76
|
+
;(platform as any).accessoryInstances.set(nid, { dummy: true })
|
|
77
|
+
try {
|
|
78
|
+
const mac = formatDeviceIdAsMac(id).toLowerCase()
|
|
79
|
+
;(platform as any).bleEventHandler[mac] = () => {}
|
|
80
|
+
} catch {
|
|
81
|
+
// ignore formatting errors in this test
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Invoke didFinishLaunching to ensure the platform registered its shutdown handler
|
|
86
|
+
await Promise.resolve(api._handlers.didFinishLaunching?.())
|
|
87
|
+
|
|
88
|
+
// Shutdown handler should now be registered on api._handlers.shutdown
|
|
89
|
+
expect(typeof api._handlers.shutdown).toBe('function')
|
|
90
|
+
|
|
91
|
+
// Call the shutdown handler
|
|
92
|
+
await Promise.resolve(api._handlers.shutdown())
|
|
93
|
+
|
|
94
|
+
// All refresh timers should be cleared
|
|
95
|
+
for (const id of ids) {
|
|
96
|
+
const nid = (platform as any).normalizeDeviceId(id)
|
|
97
|
+
expect((platform as any).refreshTimers.get(nid)).toBeUndefined()
|
|
98
|
+
expect((platform as any).accessoryInstances.get(nid)).toBeUndefined()
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { SwitchBotMatterPlatform } from './platform-matter.js'
|
|
4
|
+
|
|
5
|
+
describe('platform-matter mapping helper', () => {
|
|
6
|
+
it('prefers accessory instance update methods for battery and falls back to api.matter.updateAccessoryState', async () => {
|
|
7
|
+
const updateAccessoryState = vi.fn()
|
|
8
|
+
const api: any = {
|
|
9
|
+
matter: {
|
|
10
|
+
uuid: { generate: (s: string) => `uuid-${s}` },
|
|
11
|
+
updateAccessoryState,
|
|
12
|
+
clusterNames: { OnOff: 'OnOff', LevelControl: 'LevelControl', ColorControl: 'ColorControl', PowerSource: 'powerSource' },
|
|
13
|
+
},
|
|
14
|
+
isMatterAvailable: () => true,
|
|
15
|
+
isMatterEnabled: () => true,
|
|
16
|
+
on: () => {},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const log: any = { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }
|
|
20
|
+
const platform = new SwitchBotMatterPlatform(log as any, {} as any, api)
|
|
21
|
+
|
|
22
|
+
// Create a fake accessory instance with updateBatteryPercentage
|
|
23
|
+
const deviceId = 'DEV123'
|
|
24
|
+
const nid = (platform as any).normalizeDeviceId(deviceId)
|
|
25
|
+
const fakeInstance = { updateBatteryPercentage: vi.fn() }
|
|
26
|
+
;(platform as any).accessoryInstances.set(nid, fakeInstance)
|
|
27
|
+
|
|
28
|
+
// Call the private helper with different battery field names
|
|
29
|
+
await (platform as any).applyStatusToAccessory('uuid-test', { deviceId } as any, { batteryPercentage: 55 })
|
|
30
|
+
expect(fakeInstance.updateBatteryPercentage).toHaveBeenCalled()
|
|
31
|
+
|
|
32
|
+
// Remove instance to force fallback
|
|
33
|
+
;(platform as any).accessoryInstances.delete(nid)
|
|
34
|
+
await (platform as any).applyStatusToAccessory('uuid-test', { deviceId } as any, { battery: 30 })
|
|
35
|
+
expect(updateAccessoryState).toHaveBeenCalled()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('handles temperature and humidity synonyms', async () => {
|
|
39
|
+
const updateAccessoryState = vi.fn()
|
|
40
|
+
const api: any = {
|
|
41
|
+
matter: {
|
|
42
|
+
uuid: { generate: (s: string) => `uuid-${s}` },
|
|
43
|
+
updateAccessoryState,
|
|
44
|
+
clusterNames: {},
|
|
45
|
+
},
|
|
46
|
+
isMatterAvailable: () => true,
|
|
47
|
+
isMatterEnabled: () => true,
|
|
48
|
+
on: () => {},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const log: any = { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }
|
|
52
|
+
const platform = new SwitchBotMatterPlatform(log as any, {} as any, api)
|
|
53
|
+
|
|
54
|
+
const deviceId = 'DEV-TEMP'
|
|
55
|
+
await (platform as any).applyStatusToAccessory('uuid-temp', { deviceId } as any, { temp: 21.5, humid: 48 })
|
|
56
|
+
|
|
57
|
+
// Expect updateAccessoryState to have been called for temperature and humidity
|
|
58
|
+
expect(updateAccessoryState).toHaveBeenCalled()
|
|
59
|
+
})
|
|
60
|
+
})
|
package/src/platform-matter.ts
CHANGED
|
@@ -978,6 +978,12 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
978
978
|
if (c.includes(':')) {
|
|
979
979
|
const parts = c.split(':').map(Number)
|
|
980
980
|
;[r, g, b] = parts
|
|
981
|
+
} else if (c.includes(',')) {
|
|
982
|
+
const parts = c.split(',').map(s => Number(s.trim()))
|
|
983
|
+
;[r, g, b] = parts
|
|
984
|
+
} else if (c.includes(' ')) {
|
|
985
|
+
const parts = c.split(' ').map(s => Number(s.trim()))
|
|
986
|
+
;[r, g, b] = parts
|
|
981
987
|
} else if (c.startsWith('#')) {
|
|
982
988
|
const hex = c.replace('#', '')
|
|
983
989
|
r = Number.parseInt(hex.substring(0, 2), 16)
|
|
@@ -997,6 +1003,85 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
997
1003
|
result.battery = Number(battery)
|
|
998
1004
|
}
|
|
999
1005
|
|
|
1006
|
+
// VOC / TVOC (some air quality devices report total volatile organic compounds)
|
|
1007
|
+
const voc = sd.voc ?? sd.tvoc
|
|
1008
|
+
if (voc !== undefined) {
|
|
1009
|
+
result.voc = Number(voc)
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// PM10 (some devices report PM10 alongside PM2.5)
|
|
1013
|
+
const pm10 = sd.pm10
|
|
1014
|
+
if (pm10 !== undefined) {
|
|
1015
|
+
result.pm10 = Number(pm10)
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// PM2.5 (some BLE adverts use pm25 / pm_2_5)
|
|
1019
|
+
const pm25 = sd.pm2_5 ?? sd.pm25 ?? sd.pm_2_5
|
|
1020
|
+
if (pm25 !== undefined) {
|
|
1021
|
+
result.pm25 = Number(pm25)
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// CO2 (carbon dioxide ppm)
|
|
1025
|
+
const co2 = sd.co2 ?? sd.co2ppm ?? sd.carbonDioxide
|
|
1026
|
+
if (co2 !== undefined) {
|
|
1027
|
+
result.co2 = Number(co2)
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Temperature (C) and Humidity (%) — support common shorthand keys
|
|
1031
|
+
const temperature = sd.temperature ?? sd.temp ?? sd.t
|
|
1032
|
+
if (temperature !== undefined) {
|
|
1033
|
+
result.temperature = Number(temperature)
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const humidity = sd.humidity ?? sd.h ?? sd.humid
|
|
1037
|
+
if (humidity !== undefined) {
|
|
1038
|
+
result.humidity = Number(humidity)
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Motion, Contact, Leak
|
|
1042
|
+
const motion = sd.motion ?? sd.m
|
|
1043
|
+
if (motion !== undefined) {
|
|
1044
|
+
result.motion = Boolean(motion)
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const contact = sd.contact ?? sd.open
|
|
1048
|
+
if (contact !== undefined) {
|
|
1049
|
+
result.contact = contact
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const leak = sd.leak ?? sd.water
|
|
1053
|
+
if (leak !== undefined) {
|
|
1054
|
+
result.leak = Boolean(leak)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Position / Cover / Curtain synonyms
|
|
1058
|
+
const position = sd.position ?? sd.percent ?? sd.slidePosition ?? sd.curtainPosition
|
|
1059
|
+
if (position !== undefined) {
|
|
1060
|
+
result.position = Number(position)
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// Fan speed/speed
|
|
1064
|
+
const fanSpeed = sd.fanSpeed ?? sd.speed
|
|
1065
|
+
if (fanSpeed !== undefined) {
|
|
1066
|
+
result.fanSpeed = Number(fanSpeed)
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Lock state
|
|
1070
|
+
const lock = sd.lock
|
|
1071
|
+
if (lock !== undefined) {
|
|
1072
|
+
result.lock = lock
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Robot vacuum fields
|
|
1076
|
+
const rvcRunMode = sd.rvcRunMode
|
|
1077
|
+
if (rvcRunMode !== undefined) {
|
|
1078
|
+
result.rvcRunMode = rvcRunMode
|
|
1079
|
+
}
|
|
1080
|
+
const rvcOperationalState = sd.rvcOperationalState
|
|
1081
|
+
if (rvcOperationalState !== undefined) {
|
|
1082
|
+
result.rvcOperationalState = rvcOperationalState
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1000
1085
|
return result
|
|
1001
1086
|
} catch (e: any) {
|
|
1002
1087
|
this.debugLog(`parseAdvertisementForDevice failed for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
@@ -1055,6 +1140,12 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
1055
1140
|
if (color.includes(':')) {
|
|
1056
1141
|
const parts = color.split(':').map(Number)
|
|
1057
1142
|
;[r, g, b] = parts
|
|
1143
|
+
} else if (color.includes(',')) {
|
|
1144
|
+
const parts = color.split(',').map(s => Number(s.trim()))
|
|
1145
|
+
;[r, g, b] = parts
|
|
1146
|
+
} else if (color.includes(' ')) {
|
|
1147
|
+
const parts = color.split(' ').map(s => Number(s.trim()))
|
|
1148
|
+
;[r, g, b] = parts
|
|
1058
1149
|
} else if (color.startsWith('#')) {
|
|
1059
1150
|
const hex = color.replace('#', '')
|
|
1060
1151
|
r = Number.parseInt(hex.substring(0, 2), 16)
|
|
@@ -1065,10 +1156,10 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
1065
1156
|
await safeUpdate(this.api.matter.clusterNames.ColorControl, { currentHue: Math.round((h / 360) * 254), currentSaturation: Math.round((s / 100) * 254) }, 'updateHueSaturation')
|
|
1066
1157
|
}
|
|
1067
1158
|
|
|
1068
|
-
// Battery/powerSource
|
|
1069
|
-
if (status?.battery !== undefined || status?.batt !== undefined) {
|
|
1159
|
+
// Battery/powerSource (support many possible field names)
|
|
1160
|
+
if (status?.battery !== undefined || status?.batt !== undefined || status?.batteryLevel !== undefined || status?.batteryPercentage !== undefined || status?.battery_level !== undefined) {
|
|
1070
1161
|
try {
|
|
1071
|
-
const percentage = Number(status?.battery ?? status?.batt)
|
|
1162
|
+
const percentage = Number(status?.battery ?? status?.batt ?? status?.batteryPercentage ?? status?.batteryLevel ?? status?.battery_level)
|
|
1072
1163
|
const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)))
|
|
1073
1164
|
let batChargeLevel = 0
|
|
1074
1165
|
if (percentage < 20) {
|
|
@@ -1076,7 +1167,8 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
1076
1167
|
} else if (percentage < 40) {
|
|
1077
1168
|
batChargeLevel = 1
|
|
1078
1169
|
}
|
|
1079
|
-
|
|
1170
|
+
const powerCluster = (this.api.matter?.clusterNames && (this.api.matter.clusterNames as any).PowerSource) ? (this.api.matter.clusterNames as any).PowerSource : 'powerSource'
|
|
1171
|
+
await safeUpdate(powerCluster, { batPercentRemaining, batChargeLevel }, 'updateBatteryPercentage')
|
|
1080
1172
|
} catch (e: any) {
|
|
1081
1173
|
this.debugLog(`Failed to apply battery status for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1082
1174
|
}
|
|
@@ -1099,10 +1191,10 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
1099
1191
|
}
|
|
1100
1192
|
}
|
|
1101
1193
|
|
|
1102
|
-
// Humidity
|
|
1103
|
-
if (status?.humidity !== undefined || status?.h !== undefined) {
|
|
1194
|
+
// Humidity (support different keys)
|
|
1195
|
+
if (status?.humidity !== undefined || status?.h !== undefined || status?.humid !== undefined) {
|
|
1104
1196
|
try {
|
|
1105
|
-
const percent = Number(status?.humidity ?? status?.h)
|
|
1197
|
+
const percent = Number(status?.humidity ?? status?.h ?? status?.humid)
|
|
1106
1198
|
const measured = Math.round(percent * 100)
|
|
1107
1199
|
await safeUpdate('relativeHumidityMeasurement', { measuredValue: measured }, 'updateHumidity')
|
|
1108
1200
|
} catch (e: any) {
|
|
@@ -1186,6 +1278,44 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
1186
1278
|
this.debugLog(`Failed to apply rvcRunMode for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1187
1279
|
}
|
|
1188
1280
|
}
|
|
1281
|
+
// CO2 (carbon dioxide) - support common synonyms
|
|
1282
|
+
if (status?.co2 !== undefined || status?.co2ppm !== undefined || status?.carbonDioxide !== undefined) {
|
|
1283
|
+
try {
|
|
1284
|
+
const val = Number(status?.co2 ?? status?.co2ppm ?? status?.carbonDioxide)
|
|
1285
|
+
await safeUpdate('carbonDioxide', { carbonDioxideLevel: val }, 'updateCO2')
|
|
1286
|
+
} catch (e: any) {
|
|
1287
|
+
this.debugLog(`Failed to apply CO2 for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// PM2.5 / particulate matter
|
|
1292
|
+
if (status?.pm2_5 !== undefined || status?.pm25 !== undefined || status?.pm_2_5 !== undefined) {
|
|
1293
|
+
try {
|
|
1294
|
+
const pm = Number(status?.pm2_5 ?? status?.pm25 ?? status?.pm_2_5)
|
|
1295
|
+
await safeUpdate('pm2_5', { pm25: pm }, 'updatePM25')
|
|
1296
|
+
} catch (e: any) {
|
|
1297
|
+
this.debugLog(`Failed to apply PM2.5 for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
// PM10 (some devices report pm10)
|
|
1301
|
+
if (status?.pm10 !== undefined) {
|
|
1302
|
+
try {
|
|
1303
|
+
const pm10 = Number(status?.pm10)
|
|
1304
|
+
await safeUpdate('pm10', { pm10 }, 'updatePM10')
|
|
1305
|
+
} catch (e: any) {
|
|
1306
|
+
this.debugLog(`Failed to apply PM10 for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// VOC / TVOC - volatile organic compounds
|
|
1311
|
+
if (status?.voc !== undefined || status?.tvoc !== undefined) {
|
|
1312
|
+
try {
|
|
1313
|
+
const val = Number(status?.voc ?? status?.tvoc)
|
|
1314
|
+
await safeUpdate('voc', { voc: val }, 'updateVOC')
|
|
1315
|
+
} catch (e: any) {
|
|
1316
|
+
this.debugLog(`Failed to apply VOC for ${dev.deviceId}: ${e?.message ?? e}`)
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1189
1319
|
if (status?.rvcOperationalState !== undefined) {
|
|
1190
1320
|
try {
|
|
1191
1321
|
await safeUpdate('rvcOperationalState', { operationalState: Number(status.rvcOperationalState) }, 'updateOperationalState')
|
|
@@ -1237,6 +1367,52 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
1237
1367
|
// Merge device config (deviceConfig per deviceType and per-device overrides) to match HAP behavior
|
|
1238
1368
|
const devicesToProcess = await this.mergeDiscoveredDevices(this.discoveredDevices)
|
|
1239
1369
|
|
|
1370
|
+
// By default, automatically remove previously-registered Matter
|
|
1371
|
+
// accessories whose deviceId is not present in the merged discovered
|
|
1372
|
+
// list. If the user explicitly sets `options.keepStaleAccessories` to
|
|
1373
|
+
// true, then we will keep previously-registered accessories (legacy
|
|
1374
|
+
// behavior).
|
|
1375
|
+
if ((this.config as any).options?.keepStaleAccessories) {
|
|
1376
|
+
this.debugLog('Keeping previously-registered stale accessories because options.keepStaleAccessories=true')
|
|
1377
|
+
} else {
|
|
1378
|
+
try {
|
|
1379
|
+
const desiredIds = new Set((devicesToProcess || []).map((d: any) => this.normalizeDeviceId(d.deviceId)))
|
|
1380
|
+
const toUnregister: Array<MatterAccessory<Record<string, unknown>>> = []
|
|
1381
|
+
for (const [uuid, acc] of Array.from(this.matterAccessories.entries())) {
|
|
1382
|
+
try {
|
|
1383
|
+
const deviceId = (acc as any)?.context?.deviceId
|
|
1384
|
+
if (!deviceId) {
|
|
1385
|
+
continue
|
|
1386
|
+
}
|
|
1387
|
+
const nid = this.normalizeDeviceId(deviceId)
|
|
1388
|
+
if (!desiredIds.has(nid)) {
|
|
1389
|
+
// Accessory exists but is no longer desired -> schedule for removal
|
|
1390
|
+
this.infoLog(`Removing previously-registered accessory for deviceId=${deviceId} (no longer discovered or configured)`)
|
|
1391
|
+
try {
|
|
1392
|
+
this.clearDeviceResources(deviceId)
|
|
1393
|
+
} catch (e: any) {
|
|
1394
|
+
this.debugLog(`Failed to clear resources for ${deviceId} before unregister: ${e?.message ?? e}`)
|
|
1395
|
+
}
|
|
1396
|
+
toUnregister.push(acc as unknown as MatterAccessory<Record<string, unknown>>)
|
|
1397
|
+
this.matterAccessories.delete(uuid)
|
|
1398
|
+
}
|
|
1399
|
+
} catch (e: any) {
|
|
1400
|
+
this.debugLog(`Error while checking existing accessory ${uuid}: ${e?.message ?? e}`)
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
if (toUnregister.length > 0) {
|
|
1405
|
+
try {
|
|
1406
|
+
await this.api.matter.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, toUnregister)
|
|
1407
|
+
} catch (e: any) {
|
|
1408
|
+
this.debugLog(`Failed to unregister accessories: ${e?.message ?? e}`)
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
} catch (e: any) {
|
|
1412
|
+
this.debugLog(`Failed to remove stale accessories: ${e?.message ?? e}`)
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1240
1416
|
// We'll separate discovered devices into two buckets:
|
|
1241
1417
|
// - platformAccessories: accessories that will be hosted under the plugin's Matter bridge
|
|
1242
1418
|
// - roboticAccessories: robot vacuum devices which require standalone commissioning behaviour
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { SwitchBotMatterPlatform } from './platform-matter.js'
|
|
4
|
+
import { formatDeviceIdAsMac } from './utils.js'
|
|
5
|
+
|
|
6
|
+
describe('removeDisabledAccessories and unregister edge cases', () => {
|
|
7
|
+
it('clears resources and unregisters accessory even with invalid timer and non-MAC deviceId', async () => {
|
|
8
|
+
// Prepare API stub
|
|
9
|
+
const unregister = vi.fn()
|
|
10
|
+
const api: any = {
|
|
11
|
+
matter: {
|
|
12
|
+
uuid: { generate: (s: string) => `uuid-${s}` },
|
|
13
|
+
unregisterPlatformAccessories: unregister,
|
|
14
|
+
},
|
|
15
|
+
isMatterAvailable: () => true,
|
|
16
|
+
isMatterEnabled: () => true,
|
|
17
|
+
on: () => {},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const log: any = { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn(), success: vi.fn() }
|
|
21
|
+
const platform = new SwitchBotMatterPlatform(log as any, { enableOnOffLight: false } as any, api)
|
|
22
|
+
|
|
23
|
+
// Build a fake serialized accessory matching the generated UUID for OnOff Light
|
|
24
|
+
const uuid = api.matter.uuid.generate('matter-onoff-light')
|
|
25
|
+
const accessory: any = { uuid, displayName: 'OnOff Light', context: { deviceId: 'NOT-A-MAC' } }
|
|
26
|
+
|
|
27
|
+
// Put invalid timer object into refreshTimers to ensure code handles it
|
|
28
|
+
const nid = (platform as any).normalizeDeviceId(accessory.context.deviceId)
|
|
29
|
+
;(platform as any).refreshTimers.set(nid, null as any)
|
|
30
|
+
;(platform as any).accessoryInstances.set(nid, { dummy: true })
|
|
31
|
+
|
|
32
|
+
// Insert accessory into matterAccessories so removeDisabledAccessories will see it
|
|
33
|
+
;(platform as any).matterAccessories.set(uuid, accessory)
|
|
34
|
+
|
|
35
|
+
// Spy on clearDeviceResources
|
|
36
|
+
const spyClear = vi.spyOn(platform as any, 'clearDeviceResources')
|
|
37
|
+
|
|
38
|
+
// Call removeDisabledAccessories directly
|
|
39
|
+
await (platform as any).removeDisabledAccessories()
|
|
40
|
+
|
|
41
|
+
// Expect unregister was called and matterAccessories cleared
|
|
42
|
+
expect(unregister).toHaveBeenCalled()
|
|
43
|
+
expect((platform as any).matterAccessories.get(uuid)).toBeUndefined()
|
|
44
|
+
|
|
45
|
+
// clearDeviceResources should have been called with the accessory.deviceId
|
|
46
|
+
expect(spyClear).toHaveBeenCalledWith(accessory.context.deviceId)
|
|
47
|
+
})
|
|
48
|
+
})
|
package/src/settings.ts
CHANGED
|
@@ -40,6 +40,12 @@ export interface options {
|
|
|
40
40
|
// discovered via the SwitchBot OpenAPI will still be included (config-only
|
|
41
41
|
// devices). Default: false.
|
|
42
42
|
allowConfigOnlyDevices?: boolean
|
|
43
|
+
/**
|
|
44
|
+
* When true, previously-registered accessories for devices that are no
|
|
45
|
+
* longer discovered or configured will be kept on the bridge. Default: false.
|
|
46
|
+
* When false (default), stale accessories are removed automatically.
|
|
47
|
+
*/
|
|
48
|
+
keepStaleAccessories?: boolean
|
|
43
49
|
mqttURL?: string
|
|
44
50
|
mqttOptions?: IClientOptions
|
|
45
51
|
mqttPubOptions?: IClientOptions
|