@switchbot/homebridge-switchbot 5.0.0-beta.116 → 5.0.0-beta.118

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.
@@ -1,7 +1,32 @@
1
- // API wrapper functions for communicating with the Homebridge UI server
1
+ import { isValidDeviceType, normalizeDeviceType } from '../../../device-types.js'
2
2
  import './types.js'
3
3
  import { uiLog } from './logger.js'
4
4
 
5
+ /**
6
+ * Validate and auto-correct device types in the config array before saving.
7
+ * Returns an array of errors for devices that cannot be fixed.
8
+ */
9
+ export function validateAndFixDeviceTypes(devices: Array<{ deviceId: string, configDeviceName: string, configDeviceType: string }>) {
10
+ const errors: Array<{ deviceId: string, name: string, type: string }> = []
11
+ for (const d of devices) {
12
+ if (!isValidDeviceType(d.configDeviceType)) {
13
+ const fixed = normalizeDeviceType(d.configDeviceType)
14
+ if (fixed) {
15
+ d.configDeviceType = fixed
16
+ } else {
17
+ errors.push({
18
+ deviceId: d.deviceId,
19
+ name: d.configDeviceName,
20
+ type: d.configDeviceType,
21
+ })
22
+ }
23
+ }
24
+ }
25
+ return errors
26
+ }
27
+
28
+ // API wrapper functions for communicating with the Homebridge UI server
29
+
5
30
  function isSwitchBotPlatformConfig(block: any): boolean {
6
31
  const platformName = String(block?.platform || block?.name || '').toLowerCase()
7
32
  return (
@@ -39,6 +64,12 @@ export async function syncParentPluginConfigFromDisk(autoSave = false): Promise<
39
64
  return false
40
65
  }
41
66
 
67
+ // Validate and fix device types before saving
68
+ const errors = validateAndFixDeviceTypes(diskResp.data.devices || [])
69
+ if (errors.length > 0) {
70
+ homebridge.toast?.error?.(`Invalid device types found: ${errors.map(e => `${e.name} (${e.type})`).join(', ')}`)
71
+ return false
72
+ }
42
73
  pluginConfigBlocks[index] = diskResp.data
43
74
  await homebridge.updatePluginConfig(pluginConfigBlocks)
44
75
 
@@ -74,33 +105,34 @@ export async function fetchCredentialStatus(): Promise<any> {
74
105
  }
75
106
 
76
107
  export async function saveCredentials(token: string, secret: string): Promise<any> {
77
- try {
78
- uiLog.info('Saving credentials...')
79
- const resp = await homebridge.request('/credentials', { token, secret })
80
- uiLog.info('Save response:', resp)
81
-
82
- if (!resp || resp.success === false) {
83
- throw new Error(resp?.message || 'Save failed')
84
- }
85
-
86
- return resp.data || resp
87
- } catch (e) {
88
- throw e
108
+ uiLog.info('Saving credentials...')
109
+ const resp = await homebridge.request('/credentials', { token, secret })
110
+ uiLog.info('Save response:', resp)
111
+ if (!resp || resp.success === false) {
112
+ throw new Error(resp?.message || 'Save failed')
89
113
  }
114
+ return resp.data || resp
90
115
  }
91
116
 
92
117
  export async function fetchDevices(): Promise<any[]> {
93
118
  try {
94
119
  const resp = await homebridge.request('/devices', {})
95
- if (!resp || resp.success === false) { throw new Error(resp?.data?.message || 'request failed') }
120
+ if (!resp || resp.success === false) {
121
+ // Prefer backend error message if available
122
+ const backendMsg = resp?.data?.message || resp?.message
123
+ throw new Error(backendMsg || 'request failed')
124
+ }
96
125
  return resp.data || []
97
126
  } catch (e) {
98
- uiLog.error('Error fetching devices:', e)
127
+ // Show the real error to the user
128
+ const msg = e instanceof Error ? e.message : String(e)
129
+ uiLog.error('Error fetching devices:', msg)
130
+ // Optionally, show a toast or UI error here if needed
99
131
  return []
100
132
  }
101
133
  }
102
134
 
103
- export type DiscoverRequestOptions = {
135
+ export interface DiscoverRequestOptions {
104
136
  bleEnabled?: boolean
105
137
  bleScanDurationSeconds?: number
106
138
  bleTimeoutSeconds?: number
@@ -110,18 +142,12 @@ export async function discoverDevices(
110
142
  mode: 'all' | 'ble' | 'openapi' = 'all',
111
143
  options?: DiscoverRequestOptions,
112
144
  ): Promise<any[]> {
113
- try {
114
- const resp = await homebridge.request('/discover', { mode, ...options })
115
- uiLog.info('Discover response:', resp)
116
-
117
- if (!resp || resp.success === false) {
118
- throw new Error(resp?.data?.message || 'Discovery failed')
119
- }
120
-
121
- return resp.data || []
122
- } catch (e) {
123
- throw e
145
+ const resp = await homebridge.request('/discover', { mode, ...options })
146
+ uiLog.info('Discover response:', resp)
147
+ if (!resp || resp.success === false) {
148
+ throw new Error(resp?.data?.message || 'Discovery failed')
124
149
  }
150
+ return resp.data || []
125
151
  }
126
152
 
127
153
  export async function fetchBluetoothStatus(): Promise<{ available: boolean, message: string }> {
@@ -168,43 +194,40 @@ export async function addDevice(
168
194
  type: string,
169
195
  options?: { address?: string, model?: string, rssi?: number, encryptionKey?: string, keyId?: string },
170
196
  ): Promise<any> {
171
- try {
172
- const payload: any = { deviceId, name, type }
173
- if (options?.address) { payload.address = options.address }
174
- if (options?.model) { payload.model = options.model }
175
- if (options?.rssi !== undefined && options?.rssi !== null && options?.rssi !== 0) { payload.rssi = options.rssi }
176
- if (options?.encryptionKey) { payload.encryptionKey = options.encryptionKey }
177
- if (options?.keyId) { payload.keyId = options.keyId }
178
-
179
- uiLog.info('Adding device to config:', payload)
180
- const resp = await homebridge.request('/add-device', payload)
181
- uiLog.info('Add device response:', resp)
182
-
183
- if (!resp || resp.success === false) {
184
- throw new Error(resp?.data?.message || 'Failed to add device')
185
- }
186
-
187
- return resp.data || resp
188
- } catch (e) {
189
- throw e
197
+ const payload: any = { deviceId, name, type }
198
+ if (options?.address) {
199
+ payload.address = options.address
200
+ }
201
+ if (options?.model) {
202
+ payload.model = options.model
203
+ }
204
+ if (options?.rssi !== undefined && options?.rssi !== null && options?.rssi !== 0) {
205
+ payload.rssi = options.rssi
190
206
  }
207
+ if (options?.encryptionKey) {
208
+ payload.encryptionKey = options.encryptionKey
209
+ }
210
+ if (options?.keyId) {
211
+ payload.keyId = options.keyId
212
+ }
213
+ uiLog.info('Adding device to config:', payload)
214
+ const resp = await homebridge.request('/add-device', payload)
215
+ uiLog.info('Add device response:', resp)
216
+ if (!resp || resp.success === false) {
217
+ throw new Error(resp?.data?.message || 'Failed to add device')
218
+ }
219
+ return resp.data || resp
191
220
  }
192
221
 
193
222
  export async function addDevicesInBulk(
194
223
  devices: Array<{ deviceId: string, name: string, type: string, rssi?: number, address?: string, model?: string }>,
195
224
  ): Promise<any> {
196
- try {
197
- const resp = await homebridge.request('/add-devices', { devices })
198
- uiLog.info('Bulk add response:', resp)
199
-
200
- if (!resp || resp.success === false) {
201
- throw new Error(resp?.data?.message || 'Bulk add failed')
202
- }
203
-
204
- return resp.data || resp
205
- } catch (e) {
206
- throw e
225
+ const resp = await homebridge.request('/add-devices', { devices })
226
+ uiLog.info('Bulk add response:', resp)
227
+ if (!resp || resp.success === false) {
228
+ throw new Error(resp?.data?.message || 'Bulk add failed')
207
229
  }
230
+ return resp.data || resp
208
231
  }
209
232
 
210
233
  export async function updateDevice(
@@ -212,53 +235,38 @@ export async function updateDevice(
212
235
  configDeviceName?: string,
213
236
  configDeviceType?: string,
214
237
  ): Promise<any> {
215
- try {
216
- const params: any = { deviceId }
217
- if (configDeviceName) { params.configDeviceName = configDeviceName }
218
- if (configDeviceType) { params.configDeviceType = configDeviceType }
219
-
220
- uiLog.info('[Update Device] Sending update request with params:', params)
221
- const resp = await homebridge.request('/update-device', params)
222
- uiLog.info('[Update Device] Update response:', resp)
223
-
224
- if (!resp || resp.success === false) {
225
- throw new Error(resp?.data?.message || 'Failed to update device')
226
- }
227
-
228
- return resp.data || resp
229
- } catch (e) {
230
- throw e
238
+ const params: any = { deviceId }
239
+ if (configDeviceName) {
240
+ params.configDeviceName = configDeviceName
231
241
  }
242
+ if (configDeviceType) {
243
+ params.configDeviceType = configDeviceType
244
+ }
245
+ uiLog.info('[Update Device] Sending update request with params:', params)
246
+ const resp = await homebridge.request('/update-device', params)
247
+ uiLog.info('[Update Device] Update response:', resp)
248
+ if (!resp || resp.success === false) {
249
+ throw new Error(resp?.data?.message || 'Failed to update device')
250
+ }
251
+ return resp.data || resp
232
252
  }
233
253
 
234
254
  export async function deleteDevice(deviceId: string): Promise<any> {
235
- try {
236
- uiLog.info('Sending delete request for deviceId:', deviceId)
237
- const resp = await homebridge.request('/delete-device', { deviceId })
238
- uiLog.info('Delete response:', resp)
239
-
240
- if (!resp || resp.success === false) {
241
- throw new Error(resp?.data?.message || 'Failed to delete device')
242
- }
243
-
244
- return resp.data || resp
245
- } catch (e) {
246
- throw e
255
+ uiLog.info('Sending delete request for deviceId:', deviceId)
256
+ const resp = await homebridge.request('/delete-device', { deviceId })
257
+ uiLog.info('Delete response:', resp)
258
+ if (!resp || resp.success === false) {
259
+ throw new Error(resp?.data?.message || 'Failed to delete device')
247
260
  }
261
+ return resp.data || resp
248
262
  }
249
263
 
250
264
  export async function deleteAllDevices(): Promise<any> {
251
- try {
252
- uiLog.info('Sending delete all devices request')
253
- const resp = await homebridge.request('/delete-all-devices', {})
254
- uiLog.info('Delete all response:', resp)
255
-
256
- if (!resp || resp.success === false) {
257
- throw new Error(resp?.data?.message || 'Failed to delete all devices')
258
- }
259
-
260
- return resp.data || resp
261
- } catch (e) {
262
- throw e
265
+ uiLog.info('Sending delete all devices request')
266
+ const resp = await homebridge.request('/delete-all-devices', {})
267
+ uiLog.info('Delete all response:', resp)
268
+ if (!resp || resp.success === false) {
269
+ throw new Error(resp?.data?.message || 'Failed to delete all devices')
263
270
  }
271
+ return resp.data || resp
264
272
  }
@@ -81,13 +81,14 @@ export async function addDeviceToConfig(device: any, options: { refresh?: boolea
81
81
 
82
82
  return { added: !alreadyExists }
83
83
  } catch (e) {
84
- uiLog.error('Add device error:', e)
84
+ const msg = e instanceof Error ? e.message : String(e)
85
+ uiLog.error('Add device error:', msg)
85
86
  const status = document.getElementById('discoverStatus')
86
87
  if (status) {
87
- status.textContent = `✗ Error: ${e instanceof Error ? e.message : 'Failed to add device'}`
88
+ status.textContent = `✗ Error: ${msg}`
88
89
  status.classList.add('error')
89
90
  }
90
- toastError(e instanceof Error ? e.message : 'Failed to add device')
91
+ toastError(msg)
91
92
  return { added: false }
92
93
  } finally {
93
94
  hideBusyUi()
package/src/platform.ts CHANGED
@@ -97,9 +97,7 @@ export const DEVICE_MATTER_CLUSTERS: Record<string, any> = {
97
97
  }, // Switch in HAP, RobotVacuumCleaner in Matter
98
98
  curtain: {
99
99
  windowCovering: {
100
- // Use RollerShadeLiftOnly (9) for lift-only/non-tilt covering to satisfy Matter conformance rules.
101
- // Drapery (4) and Rollershade (0) can fail validation in some stacks when TL (tilt) is not present.
102
- type: 9, // RollerShadeLiftOnly
100
+ type: 0, // RollerShade (default for standard lift-only curtains)
103
101
  },
104
102
  }, // WindowCovering → WindowCovering (includes curtain3, rollershade variants via normalization)
105
103
  blindtilt: {
@@ -1052,22 +1050,60 @@ export class SwitchBotHAPPlatform {
1052
1050
 
1053
1051
  // Wait for Homebridge to finish launching to create/register accessories
1054
1052
  if (this.api && typeof (this.api as any).on === 'function') {
1055
- ;(this.api as any).on('didFinishLaunching', () => {
1056
- void this.loadDevices()
1053
+ (this.api as any).on('didFinishLaunching', async () => {
1054
+ await this.loadDevicesWithMatterInit()
1057
1055
  // Start periodic config reload to pick up UI changes
1058
1056
  this.configReloadInterval = setInterval(() => {
1059
- void this.checkAndReloadDevices()
1057
+ void this.loadDevicesWithMatterInit()
1060
1058
  }, 10000) // Check every 10 seconds
1061
1059
  })
1062
1060
  } else {
1063
- void this.loadDevices()
1061
+ void this.loadDevicesWithMatterInit()
1064
1062
  // Start periodic config reload to pick up UI changes
1065
1063
  this.configReloadInterval = setInterval(() => {
1066
- void this.checkAndReloadDevices()
1064
+ void this.loadDevicesWithMatterInit()
1067
1065
  }, 10000) // Check every 10 seconds
1068
1066
  }
1069
1067
  }
1070
1068
 
1069
+ /**
1070
+ * Ensures Matter API is loaded before loading devices.
1071
+ */
1072
+ private async loadDevicesWithMatterInit() {
1073
+ // Wait for Matter API to be loaded and available before loading devices
1074
+ const maxAttempts = 20 // Wait up to 10 seconds (20 x 500ms)
1075
+ let attempt = 0
1076
+ let matterLoaded = false
1077
+ if (
1078
+ this.api
1079
+ && typeof (this.api as any).isMatterAvailable === 'function'
1080
+ && typeof (this.api as any).isMatterEnabled === 'function'
1081
+ && typeof (this.api as any).loadMatterAPI === 'function'
1082
+ ) {
1083
+ if ((this.api as any).isMatterAvailable() && (this.api as any).isMatterEnabled()) {
1084
+ try {
1085
+ await (this.api as any).loadMatterAPI()
1086
+ this.log.info('Homebridge Matter API loaded successfully')
1087
+ } catch (e) {
1088
+ this.log.warn('Failed to load Homebridge Matter API', e)
1089
+ }
1090
+ // Wait for api.matter to be available
1091
+ while (attempt < maxAttempts) {
1092
+ if ((this.api as any).matter) {
1093
+ matterLoaded = true
1094
+ break
1095
+ }
1096
+ await new Promise(res => setTimeout(res, 500))
1097
+ attempt++
1098
+ }
1099
+ if (!matterLoaded) {
1100
+ this.log.warn('Matter API did not become available after loadMatterAPI()')
1101
+ }
1102
+ }
1103
+ }
1104
+ await this.loadDevices()
1105
+ }
1106
+
1071
1107
  private getConfigHash(): string {
1072
1108
  // Create a simple hash of current device config to detect changes
1073
1109
  const devices = (this.config as any)?.devices ?? []
@@ -1078,16 +1114,6 @@ export class SwitchBotHAPPlatform {
1078
1114
  })))
1079
1115
  }
1080
1116
 
1081
- private async checkAndReloadDevices() {
1082
- const currentHash = this.getConfigHash()
1083
- if (currentHash !== this.lastConfigHash) {
1084
- this.log.info('[SwitchBot] Detected config changes, reloading devices...')
1085
- // Clear existing devices
1086
- this.devices = []
1087
- await this.loadDevices()
1088
- }
1089
- }
1090
-
1091
1117
  async loadDevices() {
1092
1118
  const devices = (this.config as any)?.devices ?? []
1093
1119
  for (const raw of devices) {
@@ -1115,11 +1141,11 @@ export class SwitchBotHAPPlatform {
1115
1141
  // Prefer Matter: try registering to the Matter child bridge first.
1116
1142
  let matterRegistered = false
1117
1143
  if (useMatter) {
1118
- this.log.info(`Attempting Matter registration for ${d.id} (${type})`)
1119
- // If Homebridge Matter APIs are available, register the accessory
1120
- try {
1121
- const matterApi = (this.api as any)?.matter
1122
- if (this.api?.isMatterAvailable?.() && this.api?.isMatterEnabled?.() && matterApi && typeof matterApi.registerPlatformAccessories === 'function') {
1144
+ // Only attempt Matter registration if api.matter is truly available
1145
+ const matterApi = (this.api as any)?.matter
1146
+ if (this.api?.isMatterAvailable?.() && this.api?.isMatterEnabled?.() && matterApi && typeof matterApi.registerPlatformAccessories === 'function') {
1147
+ this.log.info(`Attempting Matter registration for ${d.id} (${type})`)
1148
+ try {
1123
1149
  const createdDesc = created.createAccessory?.(this.api) || { id: d.id, name: d.name || type }
1124
1150
  const uuid = matterApi.uuid.generate(`${d.id}`)
1125
1151
  const defaultClusters = DEVICE_MATTER_CLUSTERS[type.toLowerCase()] || { onOff: { onOff: false } }
@@ -1139,19 +1165,15 @@ export class SwitchBotHAPPlatform {
1139
1165
  handlers: createdDesc.handlers || createMatterHandlers(this.log, d.id, type, (this.config as any)?._client) || undefined,
1140
1166
  context: { deviceId: d.id, type, _created: true },
1141
1167
  }
1142
- try {
1143
- await matterApi.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
1144
- this.accessories.set(uuid, accessory)
1145
- matterRegistered = true
1146
- this.log.info(`Registered Matter accessory ${d.id} (${type}) with uuid=${uuid}`)
1147
- } catch (e) {
1148
- this.log.warn('Failed to register Matter accessory with Homebridge matter API', e)
1149
- }
1150
- } else {
1151
- this.log.info('Homebridge Matter API not available or not enabled; will fallback to HAP')
1168
+ await matterApi.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
1169
+ this.accessories.set(uuid, accessory)
1170
+ matterRegistered = true
1171
+ this.log.info(`Registered Matter accessory ${d.id} (${type}) with uuid=${uuid}`)
1172
+ } catch (e) {
1173
+ this.log.warn('Failed to register Matter accessory with Homebridge matter API', e)
1152
1174
  }
1153
- } catch (e) {
1154
- this.log.warn('Matter registration attempt failed', e)
1175
+ } else {
1176
+ this.log.info(`Matter API not available for ${d.id} (${type}); skipping Matter registration and falling back to HAP`)
1155
1177
  }
1156
1178
  }
1157
1179
 
@@ -43,8 +43,7 @@ describe('Matter integration harness', () => {
43
43
  const regs = matterApi.getRegistered()
44
44
  expect(regs.length).toBeGreaterThanOrEqual(1)
45
45
  const wcType = regs[0]?.clusters?.windowCovering?.type
46
- expect(wcType).toBe(9)
47
- expect(wcType).not.toBe(0)
46
+ expect(wcType).toBe(0) // 0 = RollerShade (default for standard lift-only curtains)
48
47
  })
49
48
 
50
49
  it('registers RGBIC Neon Wire Rope Light as lightstrip with conformant ColorControl defaults', async () => {