@switchbot/homebridge-switchbot 5.0.0-beta.133 → 5.0.0-beta.135
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/config.schema.json +0 -11
- package/dist/deviceFactory.d.ts +3 -1
- package/dist/deviceFactory.d.ts.map +1 -1
- package/dist/deviceFactory.js +3 -18
- package/dist/deviceFactory.js.map +1 -1
- package/dist/devices/genericDevice.d.ts +1 -1
- package/dist/devices/genericDevice.d.ts.map +1 -1
- package/dist/devices/genericDevice.js +67 -19
- package/dist/devices/genericDevice.js.map +1 -1
- package/dist/homebridge-ui/public/index.html +107 -124
- package/dist/homebridge-ui/public/js/api.d.ts +1 -4
- package/dist/homebridge-ui/public/js/api.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/api.js +1 -7
- package/dist/homebridge-ui/public/js/api.js.map +1 -1
- package/dist/homebridge-ui/public/js/api.ts +1 -8
- package/dist/homebridge-ui/public/js/app.js +201 -90
- package/dist/homebridge-ui/public/js/app.js.map +2 -2
- package/dist/homebridge-ui/public/js/app.ts +5 -6
- package/dist/homebridge-ui/public/js/credentials.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/credentials.js +1 -1
- package/dist/homebridge-ui/public/js/credentials.js.map +1 -1
- package/dist/homebridge-ui/public/js/credentials.ts +7 -3
- package/dist/homebridge-ui/public/js/devices-delete.js +1 -1
- package/dist/homebridge-ui/public/js/devices-delete.js.map +1 -1
- package/dist/homebridge-ui/public/js/devices-delete.ts +1 -1
- package/dist/homebridge-ui/public/js/devices.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/devices.js +0 -1
- package/dist/homebridge-ui/public/js/devices.js.map +1 -1
- package/dist/homebridge-ui/public/js/devices.ts +0 -2
- package/dist/homebridge-ui/public/js/discovery.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/discovery.js +6 -3
- package/dist/homebridge-ui/public/js/discovery.js.map +1 -1
- package/dist/homebridge-ui/public/js/discovery.ts +9 -3
- package/dist/homebridge-ui/public/js/logger.d.ts +1 -0
- package/dist/homebridge-ui/public/js/logger.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/logger.js +8 -3
- package/dist/homebridge-ui/public/js/logger.js.map +1 -1
- package/dist/homebridge-ui/public/js/logger.ts +12 -6
- package/dist/homebridge-ui/public/js/modals.d.ts +4 -0
- package/dist/homebridge-ui/public/js/modals.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/modals.js +152 -59
- package/dist/homebridge-ui/public/js/modals.js.map +1 -1
- package/dist/homebridge-ui/public/js/modals.ts +187 -60
- package/dist/homebridge-ui/public/js/render.d.ts +3 -0
- package/dist/homebridge-ui/public/js/render.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/render.js +63 -24
- package/dist/homebridge-ui/public/js/render.js.map +1 -1
- package/dist/homebridge-ui/public/js/render.ts +102 -51
- package/dist/homebridge-ui/utils/logger.js +1 -1
- package/dist/homebridge-ui/utils/logger.js.map +1 -1
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +38 -30
- package/dist/platform.js.map +1 -1
- package/dist/switchbotClient.d.ts +5 -5
- package/dist/switchbotClient.d.ts.map +1 -1
- package/dist/switchbotClient.js +21 -12
- package/dist/switchbotClient.js.map +1 -1
- package/docs/variables/default.html +1 -1
- package/package.json +1 -1
- package/scripts/build-ui.js +4 -3
- package/scripts/generate-matter-maps.js +34 -17
- package/scripts/sync-device-types.mjs +4 -1
- package/src/deviceFactory.ts +3 -18
- package/src/devices/genericDevice.ts +69 -21
- package/src/homebridge-ui/public/index.html +107 -124
- package/src/homebridge-ui/public/js/api.ts +1 -8
- package/src/homebridge-ui/public/js/app.ts +5 -6
- package/src/homebridge-ui/public/js/credentials.ts +7 -3
- package/src/homebridge-ui/public/js/devices-delete.ts +1 -1
- package/src/homebridge-ui/public/js/devices.ts +0 -2
- package/src/homebridge-ui/public/js/discovery.ts +9 -3
- package/src/homebridge-ui/public/js/logger.ts +12 -6
- package/src/homebridge-ui/public/js/modals.ts +187 -60
- package/src/homebridge-ui/public/js/render.ts +102 -51
- package/src/homebridge-ui/utils/logger.ts +1 -1
- package/src/platform.ts +38 -29
- package/src/switchbotClient.ts +42 -27
- package/test/accessory-restore.spec.ts +9 -8
- package/test/device-mapping.spec.ts +4 -3
- package/test/deviceFactory.spec.ts +4 -3
- package/test/e2e/run-e2e.spec.ts +6 -10
- package/test/fan-swing.spec.ts +7 -6
- package/test/helpers/matter-harness.ts +18 -7
- package/test/lock-users.spec.ts +5 -5
- package/test/matter-childbridge.spec.ts +10 -7
- package/test/matter-descriptors.spec.ts +8 -8
- package/test/matter-device-state.spec.ts +52 -36
- package/test/matter-integration.spec.ts +53 -6
- package/test/platform.integration.spec.ts +7 -5
- package/test/switchbot-client-debounce.spec.ts +36 -40
- package/test/switchbot-client-openapi.spec.ts +22 -15
- package/test/switchbotClient.spec.ts +4 -3
- package/test/utils.spec.ts +2 -1
package/src/platform.ts
CHANGED
|
@@ -97,12 +97,46 @@ export const DEVICE_MATTER_CLUSTERS: Record<string, any> = {
|
|
|
97
97
|
}, // Switch in HAP, RobotVacuumCleaner in Matter
|
|
98
98
|
curtain: {
|
|
99
99
|
windowCovering: {
|
|
100
|
-
|
|
100
|
+
currentPositionLiftPercent100ths: 0,
|
|
101
|
+
targetPositionLiftPercent100ths: 0,
|
|
102
|
+
operationalStatus: {
|
|
103
|
+
global: 0,
|
|
104
|
+
lift: 0,
|
|
105
|
+
tilt: 0,
|
|
106
|
+
},
|
|
107
|
+
endProductType: 0,
|
|
108
|
+
configStatus: {
|
|
109
|
+
operational: true,
|
|
110
|
+
onlineReserved: true,
|
|
111
|
+
liftMovementReversed: false,
|
|
112
|
+
liftPositionAware: true,
|
|
113
|
+
tiltPositionAware: false,
|
|
114
|
+
liftEncoderControlled: true,
|
|
115
|
+
tiltEncoderControlled: false,
|
|
116
|
+
},
|
|
101
117
|
},
|
|
102
118
|
}, // WindowCovering → WindowCovering (includes curtain3, rollershade variants via normalization)
|
|
103
119
|
blindtilt: {
|
|
104
120
|
windowCovering: {
|
|
105
|
-
|
|
121
|
+
currentPositionLiftPercent100ths: 0,
|
|
122
|
+
targetPositionLiftPercent100ths: 0,
|
|
123
|
+
currentPositionTiltPercent100ths: 0,
|
|
124
|
+
targetPositionTiltPercent100ths: 0,
|
|
125
|
+
operationalStatus: {
|
|
126
|
+
global: 0,
|
|
127
|
+
lift: 0,
|
|
128
|
+
tilt: 0,
|
|
129
|
+
},
|
|
130
|
+
endProductType: 8,
|
|
131
|
+
configStatus: {
|
|
132
|
+
operational: true,
|
|
133
|
+
onlineReserved: true,
|
|
134
|
+
liftMovementReversed: false,
|
|
135
|
+
liftPositionAware: true,
|
|
136
|
+
tiltPositionAware: true,
|
|
137
|
+
liftEncoderControlled: true,
|
|
138
|
+
tiltEncoderControlled: true,
|
|
139
|
+
},
|
|
106
140
|
},
|
|
107
141
|
}, // WindowCovering with tilt → WindowCovering
|
|
108
142
|
fan: {
|
|
@@ -1030,7 +1064,7 @@ export class SwitchBotHAPPlatform {
|
|
|
1030
1064
|
|
|
1031
1065
|
constructor(log: Logger, config: PlatformConfig, api?: API) {
|
|
1032
1066
|
this.log = log
|
|
1033
|
-
this.config = (config as any)
|
|
1067
|
+
this.config = { ...(config as any), logger: log }
|
|
1034
1068
|
this.api = api
|
|
1035
1069
|
this.accessories = new Map()
|
|
1036
1070
|
this.log.info('SwitchBot HAP platform initialized')
|
|
@@ -1044,15 +1078,6 @@ export class SwitchBotHAPPlatform {
|
|
|
1044
1078
|
this.log.debug('Failed to create shared SwitchBot client', e)
|
|
1045
1079
|
}
|
|
1046
1080
|
|
|
1047
|
-
// Create/shared SwitchBot client and attach to config so child devices reuse it.
|
|
1048
|
-
try {
|
|
1049
|
-
const client = new SwitchBotClient(this.config)
|
|
1050
|
-
void client.init()
|
|
1051
|
-
;(this.config as any)._client = client
|
|
1052
|
-
} catch (e) {
|
|
1053
|
-
this.log.debug('Failed to create shared SwitchBot client', e)
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
1081
|
// Wait for Homebridge to finish launching to create/register accessories
|
|
1057
1082
|
if (this.api && typeof (this.api as any).on === 'function') {
|
|
1058
1083
|
(this.api as any).on('didFinishLaunching', async () => {
|
|
@@ -1133,23 +1158,7 @@ export class SwitchBotHAPPlatform {
|
|
|
1133
1158
|
}
|
|
1134
1159
|
|
|
1135
1160
|
const type: string = normalizeTypeForMatter(d.type)
|
|
1136
|
-
const windowCoveringTypes = ['curtain', 'curtain3', 'rollershade', 'roller shade', 'blindtilt', 'blind tilt']
|
|
1137
|
-
// Patch: If a config override for matterType is present, update DEVICE_MATTER_CLUSTERS for this device
|
|
1138
|
-
if (windowCoveringTypes.includes(type.toLowerCase()) && typeof raw.matterType === 'number') {
|
|
1139
|
-
if (DEVICE_MATTER_CLUSTERS[type] && DEVICE_MATTER_CLUSTERS[type].windowCovering) {
|
|
1140
|
-
DEVICE_MATTER_CLUSTERS[type].windowCovering.type = raw.matterType
|
|
1141
|
-
this.log.info(`[Matter/Debug] Overriding DEVICE_MATTER_CLUSTERS.${type}.windowCovering.type to`, raw.matterType)
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
1161
|
const deviceOpts: any = { id: d.id, type, name: d.name, encryptionKey: d.encryptionKey, keyId: d.keyId }
|
|
1145
|
-
if (windowCoveringTypes.includes(type.toLowerCase())) {
|
|
1146
|
-
if (typeof raw.matterType === 'number') {
|
|
1147
|
-
deviceOpts.matterType = raw.matterType
|
|
1148
|
-
this.log.info(`[Matter/Debug] Passing matterType override for device ${d.name ?? d.id}: matterType=${raw.matterType}`)
|
|
1149
|
-
} else {
|
|
1150
|
-
this.log.info(`[Matter/Debug] No matterType override for device ${d.name ?? d.id}`)
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
1162
|
this.log.debug(`[Matter/Debug] Device options for ${d.name ?? d.id}:`, JSON.stringify(deviceOpts, null, 2))
|
|
1154
1163
|
|
|
1155
1164
|
const matterSupported = !!DEVICE_MATTER_SUPPORTED[(type || '').toLowerCase()]
|
|
@@ -1353,7 +1362,7 @@ export class SwitchBotMatterPlatform {
|
|
|
1353
1362
|
|
|
1354
1363
|
constructor(log: Logger, config: PlatformConfig, api?: API) {
|
|
1355
1364
|
this.log = log
|
|
1356
|
-
this.config = (config as any)
|
|
1365
|
+
this.config = { ...(config as any), logger: log }
|
|
1357
1366
|
this.api = api
|
|
1358
1367
|
this.accessories = new Map()
|
|
1359
1368
|
this.log.info('SwitchBot Matter platform initialized')
|
package/src/switchbotClient.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { SwitchBotPluginConfig } from './settings.js'
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
import { CharacteristicMissingError, SwitchbotAuthenticationError, SwitchbotOperationError } from './errors.js'
|
|
3
4
|
|
|
4
5
|
export interface ISwitchBotClient {
|
|
5
|
-
init()
|
|
6
|
-
getDevice(id: string)
|
|
7
|
-
getDevices()
|
|
8
|
-
setDeviceState(id: string, body: any)
|
|
9
|
-
destroy()
|
|
6
|
+
init: () => Promise<void>
|
|
7
|
+
getDevice: (id: string) => Promise<any>
|
|
8
|
+
getDevices: () => Promise<any[]>
|
|
9
|
+
setDeviceState: (id: string, body: any) => Promise<any>
|
|
10
|
+
destroy: () => Promise<void>
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -21,15 +22,24 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
21
22
|
private requestTimeout = 5000
|
|
22
23
|
private maxRetries = 2
|
|
23
24
|
private writeDebounceMs = 100
|
|
24
|
-
private logger:
|
|
25
|
-
private pendingWrites: Map<string, { timer: any
|
|
25
|
+
private logger: import('homebridge').Logger
|
|
26
|
+
private pendingWrites: Map<string, { timer: any, body: any, resolvers: Array<{ resolve: (v: any) => void, reject: (e: any) => void }> }> = new Map()
|
|
26
27
|
|
|
27
28
|
constructor(cfg: SwitchBotPluginConfig) {
|
|
28
29
|
this.cfg = cfg
|
|
29
|
-
this.logger = (cfg as any)?.logger
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
this.logger = (cfg as any)?.logger as import('homebridge').Logger
|
|
31
|
+
if (!this.logger) {
|
|
32
|
+
throw new Error('SwitchBotClient requires a logger (Homebridge logger) in config')
|
|
33
|
+
}
|
|
34
|
+
if (typeof (cfg as any)?.requestTimeout === 'number') {
|
|
35
|
+
this.requestTimeout = (cfg as any).requestTimeout
|
|
36
|
+
}
|
|
37
|
+
if (typeof (cfg as any)?.maxRetries === 'number') {
|
|
38
|
+
this.maxRetries = (cfg as any).maxRetries
|
|
39
|
+
}
|
|
40
|
+
if (typeof (cfg as any)?.writeDebounceMs === 'number') {
|
|
41
|
+
this.writeDebounceMs = (cfg as any).writeDebounceMs
|
|
42
|
+
}
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
async init(): Promise<void> {
|
|
@@ -40,12 +50,12 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
40
50
|
token: this.cfg.openApiToken,
|
|
41
51
|
secret: this.cfg.openApiSecret,
|
|
42
52
|
// Enable all built-in resilience features from node-switchbot v4
|
|
43
|
-
enableFallback: true,
|
|
44
|
-
enableRetry: true,
|
|
45
|
-
enableCircuitBreaker: true,
|
|
46
|
-
enableMetrics: true,
|
|
47
|
-
enableBLE: true,
|
|
48
|
-
scanDuration: 5000,
|
|
53
|
+
enableFallback: true, // Auto-fallback from BLE to API
|
|
54
|
+
enableRetry: true, // Retry with exponential backoff
|
|
55
|
+
enableCircuitBreaker: true, // Circuit breaker per connection type
|
|
56
|
+
enableMetrics: true, // Connection tracking and statistics
|
|
57
|
+
enableBLE: true, // Enable BLE scanning by default
|
|
58
|
+
scanDuration: 5000, // BLE scan duration in milliseconds
|
|
49
59
|
...(typeof (this.cfg as any)?.nodeClientConfig === 'object' && (this.cfg as any).nodeClientConfig),
|
|
50
60
|
})
|
|
51
61
|
this.logger?.info?.('SwitchBot client initialized with native resilience features')
|
|
@@ -65,7 +75,7 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
65
75
|
const controller = new AbortController()
|
|
66
76
|
const timer = setTimeout(() => controller.abort(), to)
|
|
67
77
|
try {
|
|
68
|
-
const fetchOpts =
|
|
78
|
+
const fetchOpts = { ...opts }
|
|
69
79
|
try {
|
|
70
80
|
Object.defineProperty(fetchOpts, 'signal', { value: controller.signal, enumerable: false, configurable: true })
|
|
71
81
|
} catch (_e) {
|
|
@@ -73,7 +83,9 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
73
83
|
}
|
|
74
84
|
const resp = await fetch(url, fetchOpts)
|
|
75
85
|
clearTimeout(timer)
|
|
76
|
-
if (resp.ok)
|
|
86
|
+
if (resp.ok) {
|
|
87
|
+
return resp
|
|
88
|
+
}
|
|
77
89
|
// server error / rate limit: retry
|
|
78
90
|
if (resp.status >= 500 || resp.status === 429) {
|
|
79
91
|
throw new Error(`retriable response: ${resp.status}`)
|
|
@@ -81,10 +93,12 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
81
93
|
return resp
|
|
82
94
|
} catch (err: any) {
|
|
83
95
|
clearTimeout(timer)
|
|
84
|
-
if (attempt > max)
|
|
96
|
+
if (attempt > max) {
|
|
97
|
+
throw err
|
|
98
|
+
}
|
|
85
99
|
// exponential backoff
|
|
86
|
-
const backoff = Math.min(100 *
|
|
87
|
-
await new Promise(
|
|
100
|
+
const backoff = Math.min(100 * 2 ** (attempt - 1), 2000)
|
|
101
|
+
await new Promise(r => setTimeout(r, backoff))
|
|
88
102
|
}
|
|
89
103
|
}
|
|
90
104
|
}
|
|
@@ -151,10 +165,12 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
151
165
|
return
|
|
152
166
|
}
|
|
153
167
|
|
|
154
|
-
const resolvers: Array<{ resolve: (v:any)=>void
|
|
168
|
+
const resolvers: Array<{ resolve: (v: any) => void, reject: (e: any) => void }> = [{ resolve, reject }]
|
|
155
169
|
const timer = setTimeout(async () => {
|
|
156
170
|
const entry = this.pendingWrites.get(id)
|
|
157
|
-
if (!entry)
|
|
171
|
+
if (!entry) {
|
|
172
|
+
return
|
|
173
|
+
}
|
|
158
174
|
this.pendingWrites.delete(id)
|
|
159
175
|
try {
|
|
160
176
|
const out = await this._doSetDeviceState(id, entry.body)
|
|
@@ -224,7 +240,7 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
224
240
|
const opts = {
|
|
225
241
|
method: 'POST',
|
|
226
242
|
headers: {
|
|
227
|
-
Authorization: this.cfg.openApiToken,
|
|
243
|
+
'Authorization': this.cfg.openApiToken,
|
|
228
244
|
'Content-Type': 'application/json',
|
|
229
245
|
},
|
|
230
246
|
body: JSON.stringify(body),
|
|
@@ -248,4 +264,3 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
248
264
|
this.client = null
|
|
249
265
|
}
|
|
250
266
|
}
|
|
251
|
-
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { describe,
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
2
3
|
import { SwitchBotHAPPlatform, SwitchBotMatterPlatform } from '../src/platform'
|
|
3
4
|
|
|
4
5
|
const log = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }
|
|
@@ -8,7 +9,7 @@ describe('accessory restore', () => {
|
|
|
8
9
|
const registered: any[] = []
|
|
9
10
|
const hap = { uuid: { generate: (s: string) => `uuid-${s}` }, Service: {}, Characteristic: {} }
|
|
10
11
|
|
|
11
|
-
function PlatformAccessory(name: string, uuid: string) {
|
|
12
|
+
function PlatformAccessory(this: any, name: string, uuid: string) {
|
|
12
13
|
this.displayName = name
|
|
13
14
|
this.UUID = uuid
|
|
14
15
|
this.context = {}
|
|
@@ -20,10 +21,10 @@ describe('accessory restore', () => {
|
|
|
20
21
|
hap,
|
|
21
22
|
platformAccessory: PlatformAccessory as any,
|
|
22
23
|
registerPlatformAccessories: (_p: string, _n: string, accs: any[]) => registered.push(...accs),
|
|
23
|
-
on: (
|
|
24
|
+
on: () => {},
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
const platform = new SwitchBotHAPPlatform(log as any, { devices: [{ id: 'dev1', type: 'light', name: 'NewName' }] }, api)
|
|
27
|
+
const platform = new SwitchBotHAPPlatform(log as any, { platform: 'SwitchBot', devices: [{ id: 'dev1', type: 'light', name: 'NewName' }] }, api)
|
|
27
28
|
|
|
28
29
|
// Simulate restored accessory with different uuid but correct deviceId in context
|
|
29
30
|
const restored = new (api.platformAccessory)('OldName', 'old-uuid')
|
|
@@ -36,7 +37,7 @@ describe('accessory restore', () => {
|
|
|
36
37
|
// No new registration for this device because restored accessory was reused
|
|
37
38
|
expect(registered.length).toBe(0)
|
|
38
39
|
// The restored accessory should remain in the platform's accessory map
|
|
39
|
-
const keys =
|
|
40
|
+
const keys = [...(platform as any).accessories.keys()]
|
|
40
41
|
expect(keys).toContain('old-uuid')
|
|
41
42
|
const acc = (platform as any).accessories.get('old-uuid')
|
|
42
43
|
expect(acc.context.deviceId).toBe('dev1')
|
|
@@ -49,10 +50,10 @@ describe('accessory restore', () => {
|
|
|
49
50
|
matter,
|
|
50
51
|
isMatterAvailable: () => true,
|
|
51
52
|
isMatterEnabled: () => true,
|
|
52
|
-
on: (
|
|
53
|
+
on: () => {},
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
const platform = new SwitchBotMatterPlatform(log as any, { devices: [{ id: 'm1', type: 'light', name: 'M1' }] }, api)
|
|
56
|
+
const platform = new SwitchBotMatterPlatform(log as any, { platform: 'SwitchBot', devices: [{ id: 'm1', type: 'light', name: 'M1' }] }, api)
|
|
56
57
|
|
|
57
58
|
// Simulate restored Matter accessory with a different uuid but matching deviceId
|
|
58
59
|
const restored = { uuid: 'restored-uuid', displayName: 'OldM', context: { deviceId: 'm1', type: 'light' } }
|
|
@@ -65,7 +66,7 @@ describe('accessory restore', () => {
|
|
|
65
66
|
await (platform as any).registerMatterAccessories()
|
|
66
67
|
|
|
67
68
|
// The restored accessory should still be present and associated with deviceId
|
|
68
|
-
const keys =
|
|
69
|
+
const keys = [...(platform as any).accessories.keys()]
|
|
69
70
|
expect(keys).toContain(restored.uuid)
|
|
70
71
|
const acc = (platform as any).accessories.get(restored.uuid)
|
|
71
72
|
expect(acc.context.deviceId).toBe('m1')
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { describe,
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
2
3
|
import { createDevice } from '../src/deviceFactory'
|
|
3
4
|
|
|
4
5
|
const DEVICE_TYPES = [
|
|
@@ -18,7 +19,7 @@ const DEVICE_TYPES = [
|
|
|
18
19
|
describe('device mapping', () => {
|
|
19
20
|
it('maps known device types to device descriptors (HAP)', async () => {
|
|
20
21
|
for (const t of DEVICE_TYPES) {
|
|
21
|
-
const created = await createDevice({ id: `dev-${t}`, type: t, name: `Name-${t}` }, {}, false)
|
|
22
|
+
const created = await createDevice({ id: `dev-${t}`, type: t, name: `Name-${t}` }, { log: console, logger: console }, false)
|
|
22
23
|
expect(created).toHaveProperty('instance')
|
|
23
24
|
expect(created).toHaveProperty('createAccessory')
|
|
24
25
|
const acc = created.createAccessory?.(undefined)
|
|
@@ -31,7 +32,7 @@ describe('device mapping', () => {
|
|
|
31
32
|
})
|
|
32
33
|
|
|
33
34
|
it('returns matter protocol when requested', async () => {
|
|
34
|
-
const created = await createDevice({ id: 'm1', type: 'light', name: 'M1' }, { preferMatter: true, enableMatter: true }, true)
|
|
35
|
+
const created = await createDevice({ id: 'm1', type: 'light', name: 'M1' }, { preferMatter: true, enableMatter: true, log: console, logger: console }, true)
|
|
35
36
|
expect(created.protocol).toBe('matter')
|
|
36
37
|
})
|
|
37
38
|
})
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { describe,
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
2
3
|
import { createDevice } from '../src/deviceFactory'
|
|
3
4
|
|
|
4
5
|
describe('deviceFactory', () => {
|
|
5
6
|
it('creates HAP device descriptor for light', async () => {
|
|
6
|
-
const created = await createDevice({ id: 'dev1', type: 'light', name: 'L1' }, {}, false)
|
|
7
|
+
const created = await createDevice({ id: 'dev1', type: 'light', name: 'L1' }, { log: console, logger: console }, false)
|
|
7
8
|
expect(created).toHaveProperty('instance')
|
|
8
9
|
expect(created).toHaveProperty('createAccessory')
|
|
9
10
|
expect(created.protocol).toBe('hap')
|
|
@@ -12,7 +13,7 @@ describe('deviceFactory', () => {
|
|
|
12
13
|
})
|
|
13
14
|
|
|
14
15
|
it('creates Matter descriptor when useMatter=true', async () => {
|
|
15
|
-
const created = await createDevice({ id: 'dev2', type: 'light', name: 'L2' }, { preferMatter: true, enableMatter: true }, true)
|
|
16
|
+
const created = await createDevice({ id: 'dev2', type: 'light', name: 'L2' }, { preferMatter: true, enableMatter: true, log: console, logger: console }, true)
|
|
16
17
|
expect(created.protocol).toBe('matter')
|
|
17
18
|
})
|
|
18
19
|
})
|
package/test/e2e/run-e2e.spec.ts
CHANGED
|
@@ -22,29 +22,25 @@ describe('e2e scripts (manual-run harness)', () => {
|
|
|
22
22
|
{ envVar: 'LOCK_ACCESSORY_ID', script: 'lock-e2e.sh' },
|
|
23
23
|
]
|
|
24
24
|
|
|
25
|
+
const logger = console
|
|
25
26
|
for (const s of scriptsToRun) {
|
|
26
27
|
if (!process.env[s.envVar]) {
|
|
27
|
-
|
|
28
|
-
// eslint-disable-next-line no-console
|
|
29
|
-
console.log(`Skipping ${s.script} because ${s.envVar} not set`)
|
|
28
|
+
logger.info(`Skipping ${s.script} because ${s.envVar} not set`)
|
|
30
29
|
continue
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
const scriptPath = path.join(scriptsDir, s.script)
|
|
34
|
-
// Forward environment so scripts can read HB_URL / HB_TOKEN / <ACCESSORY_ID>
|
|
35
33
|
const env = { ...process.env }
|
|
36
34
|
|
|
37
35
|
try {
|
|
38
|
-
|
|
39
|
-
console.log(`Running ${s.script}...`)
|
|
36
|
+
logger.info(`Running ${s.script}...`)
|
|
40
37
|
const { stdout, stderr } = await execFileP('bash', [scriptPath], { env })
|
|
41
|
-
|
|
42
|
-
console.log(stdout)
|
|
38
|
+
logger.info(stdout)
|
|
43
39
|
if (stderr) {
|
|
44
|
-
|
|
40
|
+
logger.error(stderr)
|
|
45
41
|
}
|
|
46
42
|
} catch (e: any) {
|
|
47
|
-
|
|
43
|
+
logger.error(`Script ${s.script} failed: ${e.message}`)
|
|
48
44
|
throw new Error(`Script ${s.script} failed: ${e.message}`)
|
|
49
45
|
}
|
|
50
46
|
}
|
package/test/fan-swing.spec.ts
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
import { describe,
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
2
3
|
import { FanDevice } from '../src/devices/genericDevice'
|
|
3
4
|
|
|
4
|
-
describe('
|
|
5
|
+
describe('fan swing commands', () => {
|
|
5
6
|
it('sends setSwing on boolean swing', async () => {
|
|
6
7
|
const mockClient = { setDeviceState: vi.fn(async (id: string, body: any) => ({ id, body })) }
|
|
7
|
-
const d = new FanDevice({ id: 'fan1', type: 'fan', name: 'F' }, { _client: mockClient } as any)
|
|
8
|
+
const d = new FanDevice({ id: 'fan1', type: 'fan', name: 'F' }, { _client: mockClient, log: console } as any)
|
|
8
9
|
await d.init()
|
|
9
|
-
|
|
10
|
+
await d.setState({ swing: true })
|
|
10
11
|
expect(mockClient.setDeviceState).toHaveBeenCalled()
|
|
11
12
|
expect((mockClient.setDeviceState as any).mock.calls[0][1]).toEqual({ command: 'setSwing', parameter: 'on', commandType: 'command' })
|
|
12
13
|
})
|
|
13
14
|
|
|
14
15
|
it('sends setSwing with angle', async () => {
|
|
15
16
|
const mockClient = { setDeviceState: vi.fn(async (id: string, body: any) => ({ id, body })) }
|
|
16
|
-
const d = new FanDevice({ id: 'fan2', type: 'fan', name: 'F2' }, { _client: mockClient } as any)
|
|
17
|
+
const d = new FanDevice({ id: 'fan2', type: 'fan', name: 'F2' }, { _client: mockClient, log: console } as any)
|
|
17
18
|
await d.init()
|
|
18
19
|
await d.setState({ swingAngle: 45 })
|
|
19
20
|
expect((mockClient.setDeviceState as any).mock.calls[0][1]).toEqual({ command: 'setSwing', parameter: '45', commandType: 'command' })
|
|
@@ -21,7 +22,7 @@ describe('Fan swing commands', () => {
|
|
|
21
22
|
|
|
22
23
|
it('sends setSwing with mode', async () => {
|
|
23
24
|
const mockClient = { setDeviceState: vi.fn(async (id: string, body: any) => ({ id, body })) }
|
|
24
|
-
const d = new FanDevice({ id: 'fan3', type: 'fan', name: 'F3' }, { _client: mockClient } as any)
|
|
25
|
+
const d = new FanDevice({ id: 'fan3', type: 'fan', name: 'F3' }, { _client: mockClient, log: console } as any)
|
|
25
26
|
await d.init()
|
|
26
27
|
await d.setState({ swingMode: 'vertical' })
|
|
27
28
|
expect((mockClient.setDeviceState as any).mock.calls[0][1]).toEqual({ command: 'setSwing', parameter: 'vertical', commandType: 'command' })
|
|
@@ -30,16 +30,27 @@ export function makeFakeApiWithMatter(matterApi?: any) {
|
|
|
30
30
|
|
|
31
31
|
export function makeFakeApiWithoutMatter() {
|
|
32
32
|
// Provide a minimal platformAccessory constructor and registerPlatformAccessories
|
|
33
|
-
|
|
33
|
+
interface IPlatformAccessory {
|
|
34
|
+
displayName: string
|
|
35
|
+
UUID: string
|
|
36
|
+
services: any[]
|
|
37
|
+
context: Record<string, any>
|
|
38
|
+
getService: (type: any) => any
|
|
39
|
+
addService: (type: any) => any
|
|
40
|
+
}
|
|
41
|
+
function PlatformAccessory(this: IPlatformAccessory, name: string, uuid: string) {
|
|
34
42
|
this.displayName = name
|
|
35
43
|
this.UUID = uuid
|
|
36
44
|
this.services = []
|
|
37
|
-
this.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
this.context = {}
|
|
46
|
+
}
|
|
47
|
+
PlatformAccessory.prototype.getService = function (this: IPlatformAccessory, type: any) {
|
|
48
|
+
return this.services.find((s: any) => s.type === type)
|
|
49
|
+
}
|
|
50
|
+
PlatformAccessory.prototype.addService = function (this: IPlatformAccessory, type: any) {
|
|
51
|
+
const s: any = { type, characteristics: {} }
|
|
52
|
+
this.services.push(s)
|
|
53
|
+
return s
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
return {
|
package/test/lock-users.spec.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { LockDevice } from '../src/devices/genericDevice'
|
|
|
4
4
|
describe('Lock user management', () => {
|
|
5
5
|
it('sets lock pin via pin field', async () => {
|
|
6
6
|
const mockClient = { setDeviceState: vi.fn(async (id: string, body: any) => ({ id, body })) }
|
|
7
|
-
const d = new LockDevice({ id: 'lock1', type: 'lock', name: 'L1' }, { _client: mockClient } as any)
|
|
7
|
+
const d = new LockDevice({ id: 'lock1', type: 'lock', name: 'L1' }, { _client: mockClient, log: console } as any)
|
|
8
8
|
await d.init()
|
|
9
9
|
await d.setState({ pin: '1234' })
|
|
10
10
|
expect((mockClient.setDeviceState as any).mock.calls[0][1]).toEqual({ command: 'setLockPin', parameter: '1234', commandType: 'command' })
|
|
@@ -12,7 +12,7 @@ describe('Lock user management', () => {
|
|
|
12
12
|
|
|
13
13
|
it('adds a user code with addUser action', async () => {
|
|
14
14
|
const mockClient = { setDeviceState: vi.fn(async (id: string, body: any) => ({ id, body })) }
|
|
15
|
-
const d = new LockDevice({ id: 'lock2', type: 'lock', name: 'L2' }, { _client: mockClient } as any)
|
|
15
|
+
const d = new LockDevice({ id: 'lock2', type: 'lock', name: 'L2' }, { _client: mockClient, log: console } as any)
|
|
16
16
|
await d.init()
|
|
17
17
|
await d.setState({ action: 'addUser', user: 'user1', pin: '9999' })
|
|
18
18
|
expect((mockClient.setDeviceState as any).mock.calls[0][1]).toEqual({ command: 'addUserCode', parameter: 'user1:9999', commandType: 'command' })
|
|
@@ -20,7 +20,7 @@ describe('Lock user management', () => {
|
|
|
20
20
|
|
|
21
21
|
it('removes a user code with removeUser action', async () => {
|
|
22
22
|
const mockClient = { setDeviceState: vi.fn(async (id: string, body: any) => ({ id, body })) }
|
|
23
|
-
const d = new LockDevice({ id: 'lock3', type: 'lock', name: 'L3' }, { _client: mockClient } as any)
|
|
23
|
+
const d = new LockDevice({ id: 'lock3', type: 'lock', name: 'L3' }, { _client: mockClient, log: console } as any)
|
|
24
24
|
await d.init()
|
|
25
25
|
await d.setState({ action: 'removeUser', user: 'user1' })
|
|
26
26
|
expect((mockClient.setDeviceState as any).mock.calls[0][1]).toEqual({ command: 'removeUserCode', parameter: 'user1', commandType: 'command' })
|
|
@@ -28,7 +28,7 @@ describe('Lock user management', () => {
|
|
|
28
28
|
|
|
29
29
|
it('lists users with listUsers action', async () => {
|
|
30
30
|
const mockClient = { setDeviceState: vi.fn(async (id: string, body: any) => ({ id, body })) }
|
|
31
|
-
const d = new LockDevice({ id: 'lock4', type: 'lock', name: 'L4' }, { _client: mockClient } as any)
|
|
31
|
+
const d = new LockDevice({ id: 'lock4', type: 'lock', name: 'L4' }, { _client: mockClient, log: console } as any)
|
|
32
32
|
await d.init()
|
|
33
33
|
await d.setState({ action: 'listUsers' })
|
|
34
34
|
expect((mockClient.setDeviceState as any).mock.calls[0][1]).toEqual({ command: 'listUsers', parameter: 'default', commandType: 'command' })
|
|
@@ -36,7 +36,7 @@ describe('Lock user management', () => {
|
|
|
36
36
|
|
|
37
37
|
it('unlocks with pin using unlockWithPin action', async () => {
|
|
38
38
|
const mockClient = { setDeviceState: vi.fn(async (id: string, body: any) => ({ id, body })) }
|
|
39
|
-
const d = new LockDevice({ id: 'lock5', type: 'lock', name: 'L5' }, { _client: mockClient } as any)
|
|
39
|
+
const d = new LockDevice({ id: 'lock5', type: 'lock', name: 'L5' }, { _client: mockClient, log: console } as any)
|
|
40
40
|
await d.init()
|
|
41
41
|
await d.setState({ action: 'unlockWithPin', pin: '5555' })
|
|
42
42
|
expect((mockClient.setDeviceState as any).mock.calls[0][1]).toEqual({ command: 'unlockWithPin', parameter: '5555', commandType: 'command' })
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe,
|
|
2
|
-
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
3
|
import { createPlatformProxy } from '../src/utils'
|
|
4
4
|
|
|
5
5
|
// This is a lightweight integration-style unit test that verifies the
|
|
@@ -14,23 +14,26 @@ describe('platform integration (Matter)', () => {
|
|
|
14
14
|
constructor(public log: any, public cfg: any, public api: any) {}
|
|
15
15
|
async registerMatterAccessories(accessories: any[]) {
|
|
16
16
|
this.registered.push(...accessories)
|
|
17
|
-
return
|
|
18
17
|
}
|
|
18
|
+
|
|
19
19
|
// simulate configureAccessory hook for restored matter accessories
|
|
20
|
-
configureMatterAccessory(
|
|
20
|
+
configureMatterAccessory() {
|
|
21
21
|
// no-op
|
|
22
22
|
}
|
|
23
|
+
|
|
23
24
|
// lightweight loadDevices implementation that uses an injected discover hook if present
|
|
24
25
|
async loadDevices() {
|
|
25
26
|
const devices = (this.cfg?.devices) || (this as any)._discoverDevices ? await (this as any)._discoverDevices() : []
|
|
26
27
|
// Simulate preparing descriptors and immediately registering them
|
|
27
28
|
const serialized = devices.map((d: any) => ({ id: d.id ?? d.deviceId ?? d, protocol: 'matter', name: d.name }))
|
|
28
|
-
if (serialized.length > 0)
|
|
29
|
+
if (serialized.length > 0) {
|
|
30
|
+
await this.registerMatterAccessories(serialized)
|
|
31
|
+
}
|
|
29
32
|
}
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
// Create proxy that would choose the fake matter platform when preferred
|
|
33
|
-
const Proxy = createPlatformProxy(
|
|
36
|
+
const Proxy = createPlatformProxy(() => {}, FakeMatterPlatform)
|
|
34
37
|
const matterApi = {
|
|
35
38
|
isMatterAvailable: () => true,
|
|
36
39
|
isMatterEnabled: () => true,
|
|
@@ -53,7 +56,7 @@ describe('platform integration (Matter)', () => {
|
|
|
53
56
|
expect(registered).toHaveProperty('protocol', 'matter')
|
|
54
57
|
expect(registered).toHaveProperty('id', device.id)
|
|
55
58
|
} else {
|
|
56
|
-
throw new
|
|
59
|
+
throw new TypeError('platform.loadDevices not implemented')
|
|
57
60
|
}
|
|
58
61
|
})
|
|
59
62
|
})
|
|
@@ -13,7 +13,7 @@ describe('matter descriptors and translations (per-device)', () => {
|
|
|
13
13
|
calls.push({ id, body })
|
|
14
14
|
return { ok: true }
|
|
15
15
|
} }
|
|
16
|
-
const d = new FanDevice({ id: 'f1', type: 'fan' } as any, { _client: fakeClient } as any)
|
|
16
|
+
const d = new FanDevice({ id: 'f1', type: 'fan' } as any, { _client: fakeClient, log: console } as any)
|
|
17
17
|
|
|
18
18
|
// Oscillate
|
|
19
19
|
await d.setState({ oscillate: true })
|
|
@@ -36,7 +36,7 @@ describe('matter descriptors and translations (per-device)', () => {
|
|
|
36
36
|
calls.push(body)
|
|
37
37
|
return { ok: true }
|
|
38
38
|
} }
|
|
39
|
-
const d = new LightDevice({ id: 'l1', type: 'light' } as any, { _client: fakeClient } as any)
|
|
39
|
+
const d = new LightDevice({ id: 'l1', type: 'light' } as any, { _client: fakeClient, log: console } as any)
|
|
40
40
|
|
|
41
41
|
// color temperature (mired)
|
|
42
42
|
await d.setState({ colorTemperature: 300 })
|
|
@@ -59,18 +59,18 @@ describe('matter descriptors and translations (per-device)', () => {
|
|
|
59
59
|
calls.push(body)
|
|
60
60
|
return { ok: true }
|
|
61
61
|
} }
|
|
62
|
-
const d = new CurtainDevice({ id: 'c1', type: 'curtain' } as any, { _client: fakeClient } as any)
|
|
62
|
+
const d = new CurtainDevice({ id: 'c1', type: 'curtain' } as any, { _client: fakeClient, log: console } as any)
|
|
63
63
|
|
|
64
64
|
// Setting position via HAP-style setState
|
|
65
65
|
await d.setState({ position: 25 })
|
|
66
66
|
expect(calls.pop()?.command).toBe('setPosition')
|
|
67
67
|
|
|
68
|
-
// Matter descriptor write: call
|
|
68
|
+
// Matter descriptor write: call targetPositionLiftPercent100ths write handler
|
|
69
69
|
const matter = await d.createMatterAccessory({})
|
|
70
70
|
const cluster = matter.clusters.find((c: any) => c.type === 'WindowCovering')
|
|
71
|
-
//
|
|
72
|
-
expect(cluster.attributes.
|
|
73
|
-
await cluster.attributes.
|
|
71
|
+
// targetPositionLiftPercent100ths attribute exists and has a write function
|
|
72
|
+
expect(cluster.attributes.targetPositionLiftPercent100ths.write).toBeTypeOf('function')
|
|
73
|
+
await cluster.attributes.targetPositionLiftPercent100ths.write(8000)
|
|
74
74
|
expect(calls.pop()?.command).toBe('setPosition')
|
|
75
75
|
})
|
|
76
76
|
|
|
@@ -80,7 +80,7 @@ describe('matter descriptors and translations (per-device)', () => {
|
|
|
80
80
|
calls.push(body)
|
|
81
81
|
return { ok: true }
|
|
82
82
|
} }
|
|
83
|
-
const d = new LockDevice({ id: 'lock1', type: 'lock' } as any, { _client: fakeClient } as any)
|
|
83
|
+
const d = new LockDevice({ id: 'lock1', type: 'lock' } as any, { _client: fakeClient, log: console } as any)
|
|
84
84
|
|
|
85
85
|
// addUser via setState
|
|
86
86
|
await d.setState({ action: 'addUser', user: 'u1', pin: '1234' })
|