@switchbot/homebridge-switchbot 5.1.0-beta.8 → 5.1.0
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/.github/copilot-instructions.md +0 -63
- package/.github/scripts/normalize-changelog-header.cjs +1 -1
- package/.github/scripts/npm-version-script-esm-auto.js +1 -1
- package/.github/scripts/resolve-version-bump-esm.js +1 -1
- package/CHANGELOG.md +10 -9
- package/MIGRATION.md +0 -5
- package/README.md +0 -25
- package/config.schema.json +0 -11
- package/dist/{Platform.HAP.d.ts → SwitchBotHAPPlatform.d.ts} +16 -2
- package/dist/SwitchBotHAPPlatform.d.ts.map +1 -0
- package/dist/{Platform.HAP.js → SwitchBotHAPPlatform.js} +111 -40
- package/dist/SwitchBotHAPPlatform.js.map +1 -0
- package/dist/{Platform.Matter.d.ts → SwitchBotMatterPlatform.d.ts} +2 -4
- package/dist/SwitchBotMatterPlatform.d.ts.map +1 -0
- package/dist/{Platform.Matter.js → SwitchBotMatterPlatform.js} +6 -62
- package/dist/SwitchBotMatterPlatform.js.map +1 -0
- package/dist/deviceFactory.d.ts.map +1 -1
- package/dist/deviceFactory.js +5 -15
- package/dist/deviceFactory.js.map +1 -1
- package/dist/devices/genericDevice.d.ts +63 -5
- package/dist/devices/genericDevice.d.ts.map +1 -1
- package/dist/devices/genericDevice.js +251 -11
- package/dist/devices/genericDevice.js.map +1 -1
- package/dist/homebridge-ui/endpoints/config.d.ts.map +1 -1
- package/dist/homebridge-ui/endpoints/config.js +2 -9
- package/dist/homebridge-ui/endpoints/config.js.map +1 -1
- package/dist/homebridge-ui/homebridge-ui/endpoints/config.js +2 -9
- package/dist/homebridge-ui/homebridge-ui/endpoints/config.js.map +1 -1
- package/dist/homebridge-ui/homebridge-ui/utils/config-parser.js +0 -8
- package/dist/homebridge-ui/homebridge-ui/utils/config-parser.js.map +1 -1
- package/dist/homebridge-ui/public/css/styles.css +1 -61
- package/dist/homebridge-ui/public/index.html +2 -61
- package/dist/homebridge-ui/public/js/advanced-settings.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/advanced-settings.js +3 -4
- package/dist/homebridge-ui/public/js/advanced-settings.js.map +1 -1
- package/dist/homebridge-ui/public/js/advanced-settings.ts +3 -4
- package/dist/homebridge-ui/public/js/api.d.ts +0 -16
- package/dist/homebridge-ui/public/js/api.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/api.js +0 -48
- package/dist/homebridge-ui/public/js/api.js.map +1 -1
- package/dist/homebridge-ui/public/js/api.ts +1 -61
- package/dist/homebridge-ui/public/js/app.js +115 -267
- package/dist/homebridge-ui/public/js/app.js.map +4 -4
- package/dist/homebridge-ui/public/js/app.ts +4 -47
- package/dist/homebridge-ui/public/js/credentials.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/credentials.js +5 -11
- package/dist/homebridge-ui/public/js/credentials.js.map +1 -1
- package/dist/homebridge-ui/public/js/credentials.ts +5 -11
- package/dist/homebridge-ui/public/js/devices.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/devices.js +0 -6
- package/dist/homebridge-ui/public/js/devices.js.map +1 -1
- package/dist/homebridge-ui/public/js/devices.ts +0 -6
- package/dist/homebridge-ui/public/js/modals.d.ts +0 -1
- package/dist/homebridge-ui/public/js/modals.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/modals.js +0 -53
- package/dist/homebridge-ui/public/js/modals.js.map +1 -1
- package/dist/homebridge-ui/public/js/modals.ts +0 -58
- package/dist/homebridge-ui/public/js/render.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/render.js +1 -2
- package/dist/homebridge-ui/public/js/render.js.map +1 -1
- package/dist/homebridge-ui/public/js/render.ts +1 -2
- package/dist/homebridge-ui/settings.js.map +1 -1
- package/dist/homebridge-ui/utils/config-parser.d.ts +0 -8
- package/dist/homebridge-ui/utils/config-parser.d.ts.map +1 -1
- package/dist/homebridge-ui/utils/config-parser.js +0 -8
- package/dist/homebridge-ui/utils/config-parser.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/settings.d.ts +0 -11
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js.map +1 -1
- package/dist/utils.d.ts +0 -5
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +0 -27
- package/dist/utils.js.map +1 -1
- package/docs/assets/highlight.css +15 -15
- package/docs/index.html +5 -22
- package/docs/variables/default.html +1 -1
- package/package.json +15 -9
- package/scripts/build-ui.js +0 -30
- package/src/{Platform.HAP.ts → SwitchBotHAPPlatform.ts} +112 -39
- package/src/{Platform.Matter.ts → SwitchBotMatterPlatform.ts} +8 -63
- package/src/deviceFactory.ts +5 -14
- package/src/devices/genericDevice.ts +271 -12
- package/src/homebridge-ui/endpoints/config.ts +2 -10
- package/src/homebridge-ui/public/css/styles.css +1 -61
- package/src/homebridge-ui/public/index.html +0 -59
- package/src/homebridge-ui/public/js/advanced-settings.ts +3 -4
- package/src/homebridge-ui/public/js/api.ts +1 -61
- package/src/homebridge-ui/public/js/app.ts +4 -47
- package/src/homebridge-ui/public/js/credentials.ts +5 -11
- package/src/homebridge-ui/public/js/devices.ts +0 -6
- package/src/homebridge-ui/public/js/modals.ts +0 -58
- package/src/homebridge-ui/public/js/render.ts +1 -2
- package/src/homebridge-ui/utils/config-parser.ts +0 -9
- package/src/index.ts +2 -2
- package/src/settings.ts +0 -12
- package/src/utils.ts +0 -31
- package/test/device/deviceFactory.spec.ts +9 -0
- package/test/device/getstate-cache.spec.ts +62 -0
- package/test/device/getstate-returns-status.spec.ts +89 -0
- package/test/device/water-detector-battery.spec.ts +81 -0
- package/test/device/water-detector-leak.spec.ts +43 -0
- package/test/hap-push-updates.spec.ts +150 -0
- package/test/platform/accessory-restore.spec.ts +2 -2
- package/test/platform/matter-childbridge.spec.ts +1 -1
- package/test/platform/matter-integration.spec.ts +1 -51
- package/test/platform/platform-edge.spec.ts +2 -2
- package/test/platform/platform.integration.spec.ts +1 -1
- package/test/utils/utils.spec.ts +0 -15
- package/.github/npm-version-script-esm.js +0 -97
- package/dist/Platform.HAP.d.ts.map +0 -1
- package/dist/Platform.HAP.js.map +0 -1
- package/dist/Platform.Matter.d.ts.map +0 -1
- package/dist/Platform.Matter.js.map +0 -1
- package/dist/homebridge-ui/homebridge-ui/utils/v4-detection.js +0 -23
- package/dist/homebridge-ui/homebridge-ui/utils/v4-detection.js.map +0 -1
- package/dist/homebridge-ui/public/js/legacy-view.d.ts +0 -3
- package/dist/homebridge-ui/public/js/legacy-view.d.ts.map +0 -1
- package/dist/homebridge-ui/public/js/legacy-view.js +0 -43
- package/dist/homebridge-ui/public/js/legacy-view.js.map +0 -1
- package/dist/homebridge-ui/public/js/legacy-view.ts +0 -99
- package/dist/homebridge-ui/utils/v4-detection.d.ts +0 -12
- package/dist/homebridge-ui/utils/v4-detection.d.ts.map +0 -1
- package/dist/homebridge-ui/utils/v4-detection.js +0 -23
- package/dist/homebridge-ui/utils/v4-detection.js.map +0 -1
- package/src/homebridge-ui/public/js/legacy-view.js +0 -43
- package/src/homebridge-ui/public/js/legacy-view.ts +0 -99
- package/src/homebridge-ui/utils/v4-detection.ts +0 -23
- package/test/homebridge-ui/config-parser.spec.ts +0 -61
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import type { API, Logger, PlatformConfig } from 'homebridge'
|
|
2
|
-
|
|
3
1
|
import type { SwitchBotPluginConfig } from './settings.js'
|
|
2
|
+
import type { API, Logger, PlatformConfig } from 'homebridge'
|
|
4
3
|
|
|
5
4
|
import { createDevice } from './deviceFactory.js'
|
|
6
5
|
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'
|
|
7
6
|
import { SwitchBotClient } from './switchbotClient.js'
|
|
8
|
-
import { createMatterHandlers, DEVICE_MATTER_CLUSTERS, DEVICE_MATTER_SUPPORTED, normalizeTypeForMatter,
|
|
7
|
+
import { createMatterHandlers, DEVICE_MATTER_CLUSTERS, DEVICE_MATTER_SUPPORTED, normalizeTypeForMatter, resolveMatterDeviceType } from './utils.js'
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
10
|
* Homebridge platform class for SwitchBot Matter integration.
|
|
@@ -114,7 +113,7 @@ export class SwitchBotMatterPlatform {
|
|
|
114
113
|
}
|
|
115
114
|
|
|
116
115
|
const devices = (this.config as any)?.devices ?? []
|
|
117
|
-
const createdDevices: { created: any, d: any, type: string, useMatter: boolean, matterAvailable: boolean
|
|
116
|
+
const createdDevices: { created: any, d: any, type: string, useMatter: boolean, matterAvailable: boolean }[] = []
|
|
118
117
|
for (const raw of devices) {
|
|
119
118
|
// Normalize config keys from UI schema to internal shape (for cross-platform consistency)
|
|
120
119
|
const d: any = {
|
|
@@ -126,7 +125,6 @@ export class SwitchBotMatterPlatform {
|
|
|
126
125
|
_raw: raw,
|
|
127
126
|
}
|
|
128
127
|
const type: string = normalizeTypeForMatter(d.type)
|
|
129
|
-
const externalPublishProtocol = resolveExternalPublishProtocol(raw)
|
|
130
128
|
const deviceOpts: any = { id: d.id, type, name: d.name, encryptionKey: d.encryptionKey, keyId: d.keyId, log: this.log }
|
|
131
129
|
this.log.debug(`[Matter/Debug] Device options for ${d.name ?? d.id}:`, JSON.stringify(deviceOpts, null, 2))
|
|
132
130
|
const matterSupported = !!DEVICE_MATTER_SUPPORTED[(type || '').toLowerCase()]
|
|
@@ -136,7 +134,7 @@ export class SwitchBotMatterPlatform {
|
|
|
136
134
|
try {
|
|
137
135
|
const created = await createDevice(deviceOpts, this.config, useMatter)
|
|
138
136
|
this.devices.push(created)
|
|
139
|
-
createdDevices.push({ created, d, type, useMatter, matterAvailable
|
|
137
|
+
createdDevices.push({ created, d, type, useMatter, matterAvailable })
|
|
140
138
|
if (useMatter) {
|
|
141
139
|
this.log.info(`Prepared Matter accessory for ${d.id} (${type})${matterAvailable ? ' (auto-detected)' : ' (manually enabled)'}`)
|
|
142
140
|
} else {
|
|
@@ -181,15 +179,14 @@ export class SwitchBotMatterPlatform {
|
|
|
181
179
|
* If the Homebridge Matter API is not available, registration is skipped and a log message is emitted.
|
|
182
180
|
* Accessories that are not enabled for Matter are ignored.
|
|
183
181
|
*/
|
|
184
|
-
async registerMatterAccessories(createdDevices: { created: any, d: any, type: string, useMatter: boolean, matterAvailable: boolean
|
|
182
|
+
async registerMatterAccessories(createdDevices: { created: any, d: any, type: string, useMatter: boolean, matterAvailable: boolean }[]): Promise<void> {
|
|
185
183
|
if (!this.api || !(this.api as any).matter || typeof (this.api as any).matter.registerPlatformAccessories !== 'function') {
|
|
186
184
|
this.log.info('Homebridge Matter API not available; skipping Matter accessory registration')
|
|
187
185
|
return
|
|
188
186
|
}
|
|
189
187
|
const matterApi = (this.api as any).matter
|
|
190
188
|
const accessoriesToRegister: any[] = []
|
|
191
|
-
const
|
|
192
|
-
for (const { created, d, type, useMatter, matterAvailable, externalPublishProtocol } of createdDevices) {
|
|
189
|
+
for (const { created, d, type, useMatter, matterAvailable } of createdDevices) {
|
|
193
190
|
// Only register accessories where Matter is enabled and supported
|
|
194
191
|
if (!useMatter) {
|
|
195
192
|
// Log reason for skipping registration
|
|
@@ -240,11 +237,7 @@ export class SwitchBotMatterPlatform {
|
|
|
240
237
|
handlers: createdDesc.handlers || createMatterHandlers(this.log, d.id, type, (this.config as any)?._client) || undefined,
|
|
241
238
|
context: { deviceId: d.id, type, created: true },
|
|
242
239
|
}
|
|
243
|
-
|
|
244
|
-
externalAccessoriesToPublish.push(accessory)
|
|
245
|
-
} else {
|
|
246
|
-
accessoriesToRegister.push(accessory)
|
|
247
|
-
}
|
|
240
|
+
accessoriesToRegister.push(accessory)
|
|
248
241
|
this.accessories.set(uuid, accessory)
|
|
249
242
|
} else {
|
|
250
243
|
// Ensure context and update properties for restored accessory
|
|
@@ -267,11 +260,7 @@ export class SwitchBotMatterPlatform {
|
|
|
267
260
|
accessory.handlers = createdDesc.handlers || createMatterHandlers(this.log, d.id, type, (this.config as any)?._client) || undefined
|
|
268
261
|
accessory.displayName = createdDesc.name || d.name || type
|
|
269
262
|
accessory.UUID = accessory.UUID || accessory.uuid || uuid
|
|
270
|
-
|
|
271
|
-
externalAccessoriesToPublish.push(accessory)
|
|
272
|
-
} else {
|
|
273
|
-
accessoriesToRegister.push(accessory)
|
|
274
|
-
}
|
|
263
|
+
accessoriesToRegister.push(accessory)
|
|
275
264
|
this.accessories.set(accessory.UUID || uuid, accessory)
|
|
276
265
|
}
|
|
277
266
|
this.log.info(`Created/updated Matter accessory ${d.id} (${type})`)
|
|
@@ -289,49 +278,6 @@ export class SwitchBotMatterPlatform {
|
|
|
289
278
|
} else {
|
|
290
279
|
this.log.info('No Matter accessories to register')
|
|
291
280
|
}
|
|
292
|
-
if (externalAccessoriesToPublish.length > 0) {
|
|
293
|
-
try {
|
|
294
|
-
if (typeof matterApi.publishExternalAccessories === 'function') {
|
|
295
|
-
await matterApi.publishExternalAccessories(PLUGIN_NAME, externalAccessoriesToPublish)
|
|
296
|
-
this.log.info(`Published ${externalAccessoriesToPublish.length} Matter external accessory(ies) with Homebridge`)
|
|
297
|
-
} else if (typeof matterApi.registerExternalAccessories === 'function') {
|
|
298
|
-
await matterApi.registerExternalAccessories(PLUGIN_NAME, PLATFORM_NAME, externalAccessoriesToPublish)
|
|
299
|
-
this.log.info(`Registered ${externalAccessoriesToPublish.length} Matter external accessory(ies) with Homebridge`)
|
|
300
|
-
} else if (await this.publishMatterExternalAccessories(externalAccessoriesToPublish)) {
|
|
301
|
-
this.log.info(`Published ${externalAccessoriesToPublish.length} Matter external accessory(ies) with Homebridge`)
|
|
302
|
-
} else {
|
|
303
|
-
this.log.warn('Matter external accessory publishing API not available; falling back to regular platform registration')
|
|
304
|
-
await matterApi.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, externalAccessoriesToPublish)
|
|
305
|
-
this.log.info(`Registered ${externalAccessoriesToPublish.length} Matter accessory(ies) as fallback (external publishing unavailable)`)
|
|
306
|
-
}
|
|
307
|
-
} catch (e) {
|
|
308
|
-
this.log.warn('Failed to publish/register Matter external accessories', e)
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
private async publishMatterExternalAccessories(accessories: any[]): Promise<boolean> {
|
|
314
|
-
const api = this.api as any
|
|
315
|
-
if (!api || typeof api.emit !== 'function') {
|
|
316
|
-
return false
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const pendingRegistrations = api._pendingExternalRegistrations
|
|
320
|
-
if (!(pendingRegistrations instanceof Map) && pendingRegistrations !== undefined) {
|
|
321
|
-
return false
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const registrationId = `${PLUGIN_NAME}-${Date.now()}-${Math.random()}`
|
|
325
|
-
const registrationPromise = new Promise<void>((resolve) => {
|
|
326
|
-
if (!(api._pendingExternalRegistrations instanceof Map)) {
|
|
327
|
-
api._pendingExternalRegistrations = new Map()
|
|
328
|
-
}
|
|
329
|
-
api._pendingExternalRegistrations.set(registrationId, resolve)
|
|
330
|
-
})
|
|
331
|
-
|
|
332
|
-
api.emit('publishExternalMatterAccessories', accessories, registrationId)
|
|
333
|
-
await registrationPromise
|
|
334
|
-
return true
|
|
335
281
|
}
|
|
336
282
|
|
|
337
283
|
/**
|
|
@@ -366,7 +312,6 @@ export class SwitchBotMatterPlatform {
|
|
|
366
312
|
id: d.deviceId ?? d.id,
|
|
367
313
|
type: d.configDeviceType ?? d.type,
|
|
368
314
|
name: d.configDeviceName ?? d.name,
|
|
369
|
-
externalPublishProtocol: resolveExternalPublishProtocol(d),
|
|
370
315
|
})))
|
|
371
316
|
}
|
|
372
317
|
|
package/src/deviceFactory.ts
CHANGED
|
@@ -109,6 +109,7 @@ const DEVICE_CLASS_MAP: Record<string, any> = {
|
|
|
109
109
|
'meter': MeterDevice,
|
|
110
110
|
'meter plus (jp)': MeterDevice,
|
|
111
111
|
'water detector': WaterDetectorDevice,
|
|
112
|
+
'waterdetector': WaterDetectorDevice,
|
|
112
113
|
'smart fan': SmartFanDevice,
|
|
113
114
|
'strip light': StripLightDevice,
|
|
114
115
|
'wosweeper': WoSweeperDevice,
|
|
@@ -176,20 +177,10 @@ export async function createDevice(opts: DeviceOptions, cfg: SwitchBotPluginConf
|
|
|
176
177
|
const device = new DeviceCtor(deviceOpts, mergedCfg)
|
|
177
178
|
await device.init()
|
|
178
179
|
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
device
|
|
182
|
-
|
|
183
|
-
// Prefer client-backed getDevice when available
|
|
184
|
-
const dev = await client.getDevice(opts.id)
|
|
185
|
-
if (dev) {
|
|
186
|
-
return dev
|
|
187
|
-
}
|
|
188
|
-
} catch (e) {
|
|
189
|
-
// ignore and fallback to device implementation
|
|
190
|
-
}
|
|
191
|
-
return originalGetState()
|
|
192
|
-
}
|
|
180
|
+
// No getState() override here: the device's own implementation already
|
|
181
|
+
// prefers the client-backed lookup, and it converts the result into a status.
|
|
182
|
+
// An override that returned the client device directly shadowed it, and that
|
|
183
|
+
// object carries no readings.
|
|
193
184
|
|
|
194
185
|
// Provide accessory factory based on platform selection
|
|
195
186
|
return {
|
|
@@ -186,7 +186,44 @@ export class GenericDevice extends DeviceBase {
|
|
|
186
186
|
})
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/**
|
|
190
|
+
* How long a fetched state is reused. HomeKit reads every characteristic of
|
|
191
|
+
* an accessory together, so without this a single read costs one API request
|
|
192
|
+
* per characteristic.
|
|
193
|
+
*/
|
|
194
|
+
private static readonly STATE_TTL_MS = 15_000
|
|
195
|
+
|
|
196
|
+
/** Marks a state that carries no readings, so a caller can skip it. */
|
|
197
|
+
static readonly UNREADABLE = Symbol('unreadable')
|
|
198
|
+
|
|
199
|
+
private stateCache?: { at: number, value: any }
|
|
200
|
+
private stateInFlight?: Promise<any>
|
|
201
|
+
|
|
189
202
|
async getState(): Promise<any> {
|
|
203
|
+
if (this.stateCache && (Date.now() - this.stateCache.at) < GenericDevice.STATE_TTL_MS) {
|
|
204
|
+
return this.stateCache.value
|
|
205
|
+
}
|
|
206
|
+
// Collapse the burst of reads HomeKit makes into a single fetch.
|
|
207
|
+
if (this.stateInFlight) {
|
|
208
|
+
return this.stateInFlight
|
|
209
|
+
}
|
|
210
|
+
this.stateInFlight = this.fetchState()
|
|
211
|
+
.then((value) => {
|
|
212
|
+
if (value === GenericDevice.UNREADABLE) {
|
|
213
|
+
// Do not cache a failure. Caching it would hold every characteristic
|
|
214
|
+
// at its fallback for the whole window after one transient error.
|
|
215
|
+
return { id: this.opts.id, type: this.opts.type, unreadable: true }
|
|
216
|
+
}
|
|
217
|
+
this.stateCache = { at: Date.now(), value }
|
|
218
|
+
return value
|
|
219
|
+
})
|
|
220
|
+
.finally(() => {
|
|
221
|
+
this.stateInFlight = undefined
|
|
222
|
+
})
|
|
223
|
+
return this.stateInFlight
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
protected async fetchState(): Promise<any> {
|
|
190
227
|
// Default: return minimal info; implementations should override
|
|
191
228
|
if (this.client && typeof this.client.getDevice === 'function') {
|
|
192
229
|
try {
|
|
@@ -201,15 +238,27 @@ export class GenericDevice extends DeviceBase {
|
|
|
201
238
|
// Normalize common response shapes
|
|
202
239
|
try {
|
|
203
240
|
const device = raw?.body ?? raw
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
241
|
+
// node-switchbot returns a device instance, not a status. The instance
|
|
242
|
+
// exposes id/name/deviceType/mac but carries no readings, so a caller
|
|
243
|
+
// reading state.temperature would get undefined. Ask it for status.
|
|
244
|
+
if (device && typeof device.getStatus === 'function') {
|
|
245
|
+
const status = await device.getStatus()
|
|
246
|
+
if (status && typeof status === 'object') {
|
|
247
|
+
return status
|
|
248
|
+
}
|
|
249
|
+
// Fall through to the minimal info below rather than returning the
|
|
250
|
+
// instance, which would look like a state with every field missing.
|
|
251
|
+
} else {
|
|
252
|
+
return device
|
|
253
|
+
}
|
|
254
|
+
} catch (e: any) {
|
|
255
|
+
this.log?.debug?.(`getState: could not read status for ${this.opts.id}:`, e instanceof Error ? e.message : e)
|
|
207
256
|
}
|
|
208
257
|
} catch (e) {
|
|
209
258
|
// ignore and fallback
|
|
210
259
|
}
|
|
211
260
|
}
|
|
212
|
-
return
|
|
261
|
+
return GenericDevice.UNREADABLE
|
|
213
262
|
}
|
|
214
263
|
|
|
215
264
|
async setState(change: any): Promise<any> {
|
|
@@ -483,6 +532,38 @@ export class GenericDevice extends DeviceBase {
|
|
|
483
532
|
export class BotDevice extends GenericDevice {}
|
|
484
533
|
|
|
485
534
|
export class CurtainDevice extends GenericDevice {
|
|
535
|
+
private lastKnownPosition = 0
|
|
536
|
+
private lastTargetPosition = 0
|
|
537
|
+
private positionState = 2
|
|
538
|
+
private preferLocalPositionUntil = 0
|
|
539
|
+
|
|
540
|
+
private clampHomeKitPosition(position: number): number {
|
|
541
|
+
return Math.max(0, Math.min(100, Math.round(Number(position))))
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
private toHomeKitPosition(switchBotPosition: number): number {
|
|
545
|
+
return 100 - this.clampHomeKitPosition(switchBotPosition)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private toSwitchBotPosition(homeKitPosition: number): number {
|
|
549
|
+
return 100 - this.clampHomeKitPosition(homeKitPosition)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private async getPositionForHomeKit(): Promise<number> {
|
|
553
|
+
if (Date.now() < this.preferLocalPositionUntil) {
|
|
554
|
+
return this.lastKnownPosition
|
|
555
|
+
}
|
|
556
|
+
const state = await Promise.race([
|
|
557
|
+
this.getState(),
|
|
558
|
+
new Promise(resolve => setTimeout(resolve, 1000)),
|
|
559
|
+
])
|
|
560
|
+
if (typeof state?.position === 'number') {
|
|
561
|
+
this.lastKnownPosition = this.toHomeKitPosition(Number(state.position))
|
|
562
|
+
this.lastTargetPosition = this.lastKnownPosition
|
|
563
|
+
}
|
|
564
|
+
return this.lastKnownPosition
|
|
565
|
+
}
|
|
566
|
+
|
|
486
567
|
createHAPAccessory(api: any) {
|
|
487
568
|
return {
|
|
488
569
|
services: [
|
|
@@ -491,18 +572,27 @@ export class CurtainDevice extends GenericDevice {
|
|
|
491
572
|
characteristics: {
|
|
492
573
|
CurrentPosition: {
|
|
493
574
|
get: async () => {
|
|
494
|
-
|
|
495
|
-
return typeof s.position === 'number' ? s.position : 0
|
|
575
|
+
return this.getPositionForHomeKit()
|
|
496
576
|
},
|
|
497
577
|
},
|
|
578
|
+
PositionState: {
|
|
579
|
+
get: async () => this.positionState,
|
|
580
|
+
},
|
|
498
581
|
TargetPosition: {
|
|
499
582
|
get: async () => {
|
|
500
|
-
|
|
501
|
-
return
|
|
583
|
+
await this.getPositionForHomeKit()
|
|
584
|
+
return this.lastTargetPosition
|
|
502
585
|
},
|
|
503
586
|
set: async (v: any) => {
|
|
504
|
-
|
|
587
|
+
const position = this.clampHomeKitPosition(Number(v))
|
|
588
|
+
this.lastTargetPosition = position
|
|
589
|
+
this.positionState = position > this.lastKnownPosition ? 1 : position < this.lastKnownPosition ? 0 : 2
|
|
590
|
+
await this.setState({ position: this.toSwitchBotPosition(position) })
|
|
591
|
+
this.lastKnownPosition = position
|
|
592
|
+
this.preferLocalPositionUntil = Date.now() + 30000
|
|
593
|
+
this.positionState = 2
|
|
505
594
|
},
|
|
595
|
+
refreshAfterSet: ['CurrentPosition', 'TargetPosition', 'PositionState'],
|
|
506
596
|
},
|
|
507
597
|
},
|
|
508
598
|
},
|
|
@@ -1306,6 +1396,161 @@ export class MeterDevice extends GenericDevice {
|
|
|
1306
1396
|
}
|
|
1307
1397
|
|
|
1308
1398
|
export class WaterDetectorDevice extends GenericDevice {
|
|
1399
|
+
private _leakRefreshing = false
|
|
1400
|
+
private _leakRefreshTs = 0
|
|
1401
|
+
private lastLeakDetected?: boolean
|
|
1402
|
+
private readonly LEAK_REFRESH_TTL_MS = 30_000
|
|
1403
|
+
|
|
1404
|
+
private lastBatteryLevel?: number
|
|
1405
|
+
private _batteryRefreshing = false
|
|
1406
|
+
private _batteryRefreshTs = 0
|
|
1407
|
+
private readonly BATTERY_REFRESH_TTL_MS = 30_000
|
|
1408
|
+
|
|
1409
|
+
async init(): Promise<void> {
|
|
1410
|
+
await super.init()
|
|
1411
|
+
await this._refreshLeakState(!this.client)
|
|
1412
|
+
await this._refreshBattery()
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
private normalizeLeakDetected(state: any): boolean | undefined {
|
|
1416
|
+
if (typeof state?.leak === 'boolean') {
|
|
1417
|
+
return state.leak
|
|
1418
|
+
}
|
|
1419
|
+
if (typeof state?.waterLeakDetected === 'boolean') {
|
|
1420
|
+
return state.waterLeakDetected
|
|
1421
|
+
}
|
|
1422
|
+
if (typeof state?.body?.waterLeakDetected === 'boolean') {
|
|
1423
|
+
return state.body.waterLeakDetected
|
|
1424
|
+
}
|
|
1425
|
+
const status = state?.status ?? state?.body?.status
|
|
1426
|
+
if (typeof status === 'number') {
|
|
1427
|
+
return status === 1
|
|
1428
|
+
}
|
|
1429
|
+
if (typeof status === 'string') {
|
|
1430
|
+
return ['1', 'leak', 'leaked', 'detected', 'water_leak_detected'].includes(status.toLowerCase())
|
|
1431
|
+
}
|
|
1432
|
+
return undefined
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
private normalizeBatteryLevel(state: any): number | undefined {
|
|
1436
|
+
const raw = state?.battery ?? state?.body?.battery
|
|
1437
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
|
1438
|
+
return undefined
|
|
1439
|
+
}
|
|
1440
|
+
return Math.max(0, Math.min(100, raw))
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
private async getOpenAPIStatus(): Promise<any> {
|
|
1444
|
+
const token = this.cfg?.openApiToken
|
|
1445
|
+
const secret = this.cfg?.openApiSecret
|
|
1446
|
+
if (!token || !secret) {
|
|
1447
|
+
return undefined
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
try {
|
|
1451
|
+
const { OpenAPIClient } = await import('node-switchbot')
|
|
1452
|
+
const apiClient = new OpenAPIClient(token, secret)
|
|
1453
|
+
return await apiClient.getStatus(this.opts.id)
|
|
1454
|
+
} catch (e) {
|
|
1455
|
+
this.log?.debug?.(`[WaterDetector] direct OpenAPI refresh failed: ${(e as Error)?.message}`)
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
return undefined
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
private async readLeakDetected(fallbackToDeviceState = true): Promise<boolean | undefined> {
|
|
1462
|
+
const status = await this.getOpenAPIStatus()
|
|
1463
|
+
if (status) {
|
|
1464
|
+
const leak = this.normalizeLeakDetected(status)
|
|
1465
|
+
if (typeof leak === 'boolean') {
|
|
1466
|
+
return leak
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
if (!fallbackToDeviceState) {
|
|
1470
|
+
return undefined
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
try {
|
|
1474
|
+
return this.normalizeLeakDetected(await this.getState())
|
|
1475
|
+
} catch (e) {
|
|
1476
|
+
this.log?.debug?.(`[WaterDetector] leak refresh failed: ${(e as Error)?.message}`)
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
return undefined
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
private async readBatteryLevel(): Promise<number | undefined> {
|
|
1483
|
+
const status = await this.getOpenAPIStatus()
|
|
1484
|
+
if (status) {
|
|
1485
|
+
const battery = this.normalizeBatteryLevel(status)
|
|
1486
|
+
if (typeof battery === 'number') {
|
|
1487
|
+
return battery
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
return undefined
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
private async _refreshLeakState(fallbackToDeviceState = true): Promise<void> {
|
|
1495
|
+
if (this._leakRefreshing) {
|
|
1496
|
+
return
|
|
1497
|
+
}
|
|
1498
|
+
this._leakRefreshing = true
|
|
1499
|
+
try {
|
|
1500
|
+
const leakDetected = await this.readLeakDetected(fallbackToDeviceState)
|
|
1501
|
+
if (typeof leakDetected === 'boolean') {
|
|
1502
|
+
this.lastLeakDetected = leakDetected
|
|
1503
|
+
this._leakRefreshTs = Date.now()
|
|
1504
|
+
}
|
|
1505
|
+
} catch (e) {
|
|
1506
|
+
this.log?.debug?.(`[WaterDetector] leak refresh failed: ${(e as Error)?.message}`)
|
|
1507
|
+
} finally {
|
|
1508
|
+
this._leakRefreshing = false
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
private async _refreshBattery(): Promise<void> {
|
|
1513
|
+
if (this._batteryRefreshing) {
|
|
1514
|
+
return
|
|
1515
|
+
}
|
|
1516
|
+
this._batteryRefreshing = true
|
|
1517
|
+
try {
|
|
1518
|
+
const battery = await this.readBatteryLevel()
|
|
1519
|
+
if (typeof battery === 'number') {
|
|
1520
|
+
this.lastBatteryLevel = battery
|
|
1521
|
+
this._batteryRefreshTs = Date.now()
|
|
1522
|
+
}
|
|
1523
|
+
} catch (e) {
|
|
1524
|
+
this.log?.debug?.(`[WaterDetector] battery refresh failed: ${(e as Error)?.message}`)
|
|
1525
|
+
} finally {
|
|
1526
|
+
this._batteryRefreshing = false
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
private getLeakDetectedFast(api: any): number {
|
|
1531
|
+
if (Date.now() - this._leakRefreshTs >= this.LEAK_REFRESH_TTL_MS) {
|
|
1532
|
+
this._refreshLeakState().catch(() => undefined)
|
|
1533
|
+
}
|
|
1534
|
+
if (typeof this.lastLeakDetected === 'boolean') {
|
|
1535
|
+
return this.lastLeakDetected ? 1 : 0
|
|
1536
|
+
}
|
|
1537
|
+
throw new api.hap.HapStatusError(api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE)
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// Returns the cached battery level immediately and triggers a background
|
|
1541
|
+
// refresh if the cache is stale. Throws HapStatusError on true cold start
|
|
1542
|
+
// so HomeKit shows "Not Available" rather than fabricating a misleading
|
|
1543
|
+
// value or timing out (which surfaces as 0% in the Home app).
|
|
1544
|
+
private getBatteryFast(api: any): number {
|
|
1545
|
+
if (Date.now() - this._batteryRefreshTs >= this.BATTERY_REFRESH_TTL_MS) {
|
|
1546
|
+
this._refreshBattery().catch(() => undefined)
|
|
1547
|
+
}
|
|
1548
|
+
if (typeof this.lastBatteryLevel === 'number') {
|
|
1549
|
+
return this.lastBatteryLevel
|
|
1550
|
+
}
|
|
1551
|
+
throw new api.hap.HapStatusError(api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE)
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1309
1554
|
createHAPAccessory(api: any) {
|
|
1310
1555
|
return {
|
|
1311
1556
|
services: [
|
|
@@ -1313,11 +1558,25 @@ export class WaterDetectorDevice extends GenericDevice {
|
|
|
1313
1558
|
type: 'LeakSensor',
|
|
1314
1559
|
characteristics: {
|
|
1315
1560
|
LeakDetected: {
|
|
1316
|
-
get:
|
|
1317
|
-
|
|
1318
|
-
|
|
1561
|
+
get: () => this.getLeakDetectedFast(api),
|
|
1562
|
+
},
|
|
1563
|
+
},
|
|
1564
|
+
},
|
|
1565
|
+
{
|
|
1566
|
+
type: 'Battery',
|
|
1567
|
+
characteristics: {
|
|
1568
|
+
BatteryLevel: {
|
|
1569
|
+
get: () => this.getBatteryFast(api),
|
|
1570
|
+
},
|
|
1571
|
+
StatusLowBattery: {
|
|
1572
|
+
get: () => {
|
|
1573
|
+
const b = this.getBatteryFast(api)
|
|
1574
|
+
return b < 20 ? 1 : 0
|
|
1319
1575
|
},
|
|
1320
1576
|
},
|
|
1577
|
+
ChargingState: {
|
|
1578
|
+
get: () => 2,
|
|
1579
|
+
},
|
|
1321
1580
|
},
|
|
1322
1581
|
},
|
|
1323
1582
|
],
|
|
@@ -4,7 +4,7 @@ import { RequestError } from '@homebridge/plugin-ui-utils'
|
|
|
4
4
|
import fs from 'node:fs/promises'
|
|
5
5
|
|
|
6
6
|
import { isValidDeviceType } from '../../device-types.js'
|
|
7
|
-
import { getAllDevices,
|
|
7
|
+
import { getAllDevices, SWITCHBOT_PLATFORM_REGEX } from '../utils/config-parser.js'
|
|
8
8
|
import { validateAndMigrateDeviceType } from '../utils/device-migration.js'
|
|
9
9
|
import { uiLog } from '../utils/logger.js'
|
|
10
10
|
|
|
@@ -31,7 +31,6 @@ export function registerConfigEndpoints(server: HomebridgePluginUiServer) {
|
|
|
31
31
|
typeValidationWarning?: string
|
|
32
32
|
}> = []
|
|
33
33
|
const invalidTypeDevices: Array<{ name: string, type: string, suggestion?: string }> = []
|
|
34
|
-
let v4ConfigDetected = false
|
|
35
34
|
|
|
36
35
|
const platforms = Array.isArray(cfg.platforms) ? cfg.platforms : []
|
|
37
36
|
for (const p of platforms) {
|
|
@@ -42,12 +41,6 @@ export function registerConfigEndpoints(server: HomebridgePluginUiServer) {
|
|
|
42
41
|
continue
|
|
43
42
|
}
|
|
44
43
|
|
|
45
|
-
// Detect legacy v4 config format
|
|
46
|
-
if (isV4Config(p)) {
|
|
47
|
-
v4ConfigDetected = true
|
|
48
|
-
uiLog.warn('Detected legacy v4 config format (credentials/options.devices). This config is not compatible with v5+. Please migrate to the v5 config format.')
|
|
49
|
-
}
|
|
50
|
-
|
|
51
44
|
const devices = getAllDevices(p)
|
|
52
45
|
for (const d of devices) {
|
|
53
46
|
const id = d.deviceId ?? d.id
|
|
@@ -101,12 +94,11 @@ export function registerConfigEndpoints(server: HomebridgePluginUiServer) {
|
|
|
101
94
|
)
|
|
102
95
|
}
|
|
103
96
|
|
|
104
|
-
uiLog.info(`GET /devices - Found ${found.length} devices in ${cfgPath}${invalidTypeDevices.length > 0 ? ` (${invalidTypeDevices.length} with validation warnings)` : ''}
|
|
97
|
+
uiLog.info(`GET /devices - Found ${found.length} devices in ${cfgPath}${invalidTypeDevices.length > 0 ? ` (${invalidTypeDevices.length} with validation warnings)` : ''}`)
|
|
105
98
|
return {
|
|
106
99
|
success: true,
|
|
107
100
|
data: found,
|
|
108
101
|
...(invalidTypeDevices.length > 0 && { validationWarnings: invalidTypeDevices }),
|
|
109
|
-
...(v4ConfigDetected && { v4ConfigDetected: true }),
|
|
110
102
|
}
|
|
111
103
|
} catch (e) {
|
|
112
104
|
const msg = e instanceof Error ? e.message : String(e)
|
|
@@ -480,64 +480,4 @@ ul.device-grid {
|
|
|
480
480
|
|
|
481
481
|
.discovery-scanning-pulse {
|
|
482
482
|
animation: pulse 1.5s ease-in-out infinite;
|
|
483
|
-
}
|
|
484
|
-
/* v4 config incompatibility warning banner */
|
|
485
|
-
.v4-config-warning {
|
|
486
|
-
background: #7c3015;
|
|
487
|
-
color: #fff;
|
|
488
|
-
border: 1px solid #a04020;
|
|
489
|
-
border-radius: 8px;
|
|
490
|
-
padding: 14px 18px;
|
|
491
|
-
margin-bottom: 18px;
|
|
492
|
-
font-size: 13px;
|
|
493
|
-
line-height: 1.5;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
.v4-config-warning strong {
|
|
497
|
-
display: block;
|
|
498
|
-
font-size: 15px;
|
|
499
|
-
margin-bottom: 8px;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
.v4-config-warning p {
|
|
503
|
-
margin: 6px 0;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
.v4-config-warning ul {
|
|
507
|
-
margin: 6px 0 6px 18px;
|
|
508
|
-
padding: 0;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
.v4-config-warning li {
|
|
512
|
-
margin-bottom: 3px;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
.v4-config-warning code {
|
|
516
|
-
background: rgba(255, 255, 255, 0.15);
|
|
517
|
-
border-radius: 3px;
|
|
518
|
-
padding: 1px 4px;
|
|
519
|
-
font-size: 12px;
|
|
520
|
-
color: #ffd;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
.v4-config-warning a {
|
|
524
|
-
color: #ffd;
|
|
525
|
-
text-decoration: underline;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
@media (prefers-color-scheme: light) {
|
|
529
|
-
.v4-config-warning {
|
|
530
|
-
background: #fff3cd;
|
|
531
|
-
color: #7c3015;
|
|
532
|
-
border-color: #f0ad4e;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
.v4-config-warning code {
|
|
536
|
-
background: rgba(124, 48, 21, 0.1);
|
|
537
|
-
color: #7c3015;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
.v4-config-warning a {
|
|
541
|
-
color: #7c3015;
|
|
542
|
-
}
|
|
543
|
-
}
|
|
483
|
+
}
|