@switchbot/homebridge-switchbot 5.1.0-beta.9 → 5.1.1
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/compute-version-override.cjs +51 -0
- 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 +32 -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/device-types.d.ts.map +1 -1
- package/dist/device-types.js +10 -0
- package/dist/device-types.js.map +1 -1
- package/dist/deviceFactory.d.ts.map +1 -1
- package/dist/deviceFactory.js +10 -16
- 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/device-types.js +10 -0
- package/dist/homebridge-ui/device-types.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 +135 -272
- 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 +16 -16
- package/docs/assets/main.js +1 -1
- package/docs/assets/style.css +18 -3
- package/docs/hierarchy.html +26 -1
- package/docs/index.html +70 -23
- package/docs/modules.html +33 -1
- package/docs/variables/default.html +46 -3
- package/package.json +22 -15
- 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/device-types.ts +11 -0
- package/src/deviceFactory.ts +10 -15
- 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/model-identities.spec.ts +45 -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 -45
- package/dist/homebridge-ui/public/js/legacy-view.js.map +0 -1
- package/dist/homebridge-ui/public/js/legacy-view.ts +0 -107
- 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/device-types.js +0 -246
- package/src/homebridge-ui/public/js/legacy-view.js +0 -45
- package/src/homebridge-ui/public/js/legacy-view.ts +0 -107
- package/src/homebridge-ui/utils/v4-detection.ts +0 -23
- package/test/homebridge-ui/config-parser.spec.ts +0 -61
package/src/deviceFactory.ts
CHANGED
|
@@ -64,6 +64,9 @@ const DEVICE_CLASS_MAP: Record<string, any> = {
|
|
|
64
64
|
'meterplus': MeterDevice,
|
|
65
65
|
'meterpro': MeterDevice,
|
|
66
66
|
'meterpro(co2)': MeterDevice,
|
|
67
|
+
'meter plus': MeterDevice,
|
|
68
|
+
'meter pro': MeterDevice,
|
|
69
|
+
'meter pro (co2)': MeterDevice,
|
|
67
70
|
'walletfinder': WalletFinderDevice,
|
|
68
71
|
'plug': PlugDevice,
|
|
69
72
|
'plug mini (eu)': PlugMiniDevice,
|
|
@@ -109,6 +112,7 @@ const DEVICE_CLASS_MAP: Record<string, any> = {
|
|
|
109
112
|
'meter': MeterDevice,
|
|
110
113
|
'meter plus (jp)': MeterDevice,
|
|
111
114
|
'water detector': WaterDetectorDevice,
|
|
115
|
+
'waterdetector': WaterDetectorDevice,
|
|
112
116
|
'smart fan': SmartFanDevice,
|
|
113
117
|
'strip light': StripLightDevice,
|
|
114
118
|
'wosweeper': WoSweeperDevice,
|
|
@@ -137,7 +141,8 @@ const DEVICE_CLASS_MAP: Record<string, any> = {
|
|
|
137
141
|
|
|
138
142
|
function classForType(type: string) {
|
|
139
143
|
const rawKey = (type || '').toLowerCase()
|
|
140
|
-
|
|
144
|
+
// Normalised types are canonical display names; class map keys are lowercase
|
|
145
|
+
const key = (DEVICE_TYPE_NORMALIZATION_MAP[rawKey] ?? rawKey).toLowerCase()
|
|
141
146
|
return DEVICE_CLASS_MAP[key] ?? GenericDevice
|
|
142
147
|
}
|
|
143
148
|
|
|
@@ -176,20 +181,10 @@ export async function createDevice(opts: DeviceOptions, cfg: SwitchBotPluginConf
|
|
|
176
181
|
const device = new DeviceCtor(deviceOpts, mergedCfg)
|
|
177
182
|
await device.init()
|
|
178
183
|
|
|
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
|
-
}
|
|
184
|
+
// No getState() override here: the device's own implementation already
|
|
185
|
+
// prefers the client-backed lookup, and it converts the result into a status.
|
|
186
|
+
// An override that returned the client device directly shadowed it, and that
|
|
187
|
+
// object carries no readings.
|
|
193
188
|
|
|
194
189
|
// Provide accessory factory based on platform selection
|
|
195
190
|
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
|
+
}
|
|
@@ -9,57 +9,8 @@
|
|
|
9
9
|
</head>
|
|
10
10
|
|
|
11
11
|
<body>
|
|
12
|
-
|
|
13
12
|
<h1>🤖 SwitchBot Configuration</h1>
|
|
14
13
|
|
|
15
|
-
<!-- ...existing code... -->
|
|
16
|
-
|
|
17
|
-
<!-- Centered legacy view button at the bottom -->
|
|
18
|
-
<div class="centered-toggle-btn">
|
|
19
|
-
<button id="legacyViewBtn">Legacy View</button>
|
|
20
|
-
<button id="dynamicViewBtn" style="display: none;">Dynamic View</button>
|
|
21
|
-
</div>
|
|
22
|
-
<style>
|
|
23
|
-
.centered-toggle-btn {
|
|
24
|
-
display: flex;
|
|
25
|
-
flex-direction: row;
|
|
26
|
-
justify-content: center;
|
|
27
|
-
align-items: center;
|
|
28
|
-
margin-top: 32px;
|
|
29
|
-
margin-bottom: 12px;
|
|
30
|
-
gap: 12px;
|
|
31
|
-
}
|
|
32
|
-
#legacyViewBtn, #dynamicViewBtn {
|
|
33
|
-
font-size: 12px;
|
|
34
|
-
padding: 4px 10px;
|
|
35
|
-
}
|
|
36
|
-
</style>
|
|
37
|
-
|
|
38
|
-
<div id="dynamicUiContainer">
|
|
39
|
-
|
|
40
|
-
<!-- v4 config incompatibility warning – shown dynamically by app.js -->
|
|
41
|
-
<div id="v4ConfigWarning" class="v4-config-warning" style="display: none">
|
|
42
|
-
<strong>⚠️ Incompatible v4 Configuration Detected</strong>
|
|
43
|
-
<p>
|
|
44
|
-
Your config.json appears to be in the <strong>v4 format</strong> (using <code>credentials</code> and
|
|
45
|
-
<code>options.devices</code> fields). This format is <strong>not compatible with v5 or newer</strong> of the
|
|
46
|
-
SwitchBot plugin. Devices will not be recognised until the config is updated.
|
|
47
|
-
</p>
|
|
48
|
-
<p>
|
|
49
|
-
Please update your config to the v5 format:
|
|
50
|
-
</p>
|
|
51
|
-
<ul>
|
|
52
|
-
<li>Move <code>credentials.token</code> → <code>openApiToken</code> (top level)</li>
|
|
53
|
-
<li>Move <code>credentials.secret</code> → <code>openApiSecret</code> (top level)</li>
|
|
54
|
-
<li>Move <code>options.devices</code> → <code>devices</code> (top level)</li>
|
|
55
|
-
<li>Rename per-device <code>connectionType</code> → <code>connectionPreference</code></li>
|
|
56
|
-
<li>Remove the <code>credentials</code> and <code>options</code> wrapper objects</li>
|
|
57
|
-
</ul>
|
|
58
|
-
<p>
|
|
59
|
-
See the <a href="https://github.com/OpenWonderLabs/homebridge-switchbot/blob/latest/MIGRATION.md" target="_blank" rel="noopener">Migration Guide</a> for full details.
|
|
60
|
-
</p>
|
|
61
|
-
</div>
|
|
62
|
-
|
|
63
14
|
<div class="card compact">
|
|
64
15
|
<h2>API Credentials</h2>
|
|
65
16
|
<div class="credentials-row">
|
|
@@ -253,18 +204,8 @@
|
|
|
253
204
|
</form>
|
|
254
205
|
</div>
|
|
255
206
|
|
|
256
|
-
</div> <!-- end dynamicUiContainer -->
|
|
257
|
-
|
|
258
|
-
<div id="legacyViewContainer" style="display: none;">
|
|
259
|
-
<div class="card compact">
|
|
260
|
-
<h2>Legacy Config View</h2>
|
|
261
|
-
<div id="legacyConfigForm"></div>
|
|
262
|
-
</div>
|
|
263
|
-
</div>
|
|
264
|
-
|
|
265
207
|
<script type="module" src="js/app.js"></script>
|
|
266
208
|
<script type="module" src="js/advanced-settings.js"></script>
|
|
267
|
-
<script type="module" src="js/legacy-view.js"></script>
|
|
268
209
|
</body>
|
|
269
210
|
|
|
270
211
|
</html>
|
|
@@ -46,11 +46,10 @@ async function saveAdvancedSettings(): Promise<void> {
|
|
|
46
46
|
if (!Array.isArray(pluginConfigBlocks) || !pluginConfigBlocks.length) {
|
|
47
47
|
throw new Error('No plugin config blocks returned from Homebridge')
|
|
48
48
|
}
|
|
49
|
-
// Find
|
|
50
|
-
|
|
49
|
+
// Find the SwitchBot config block
|
|
50
|
+
const idx = pluginConfigBlocks.findIndex(c => (c.platform || c.name || '').toLowerCase().includes('switchbot'))
|
|
51
51
|
if (idx === -1) {
|
|
52
|
-
|
|
53
|
-
idx = pluginConfigBlocks.length - 1
|
|
52
|
+
throw new Error('SwitchBot config not found')
|
|
54
53
|
}
|
|
55
54
|
const config = pluginConfigBlocks[idx]
|
|
56
55
|
// Update config values from UI
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// Fetch the list of configured devices from the Homebridge UI API
|
|
2
2
|
import { isValidDeviceType, normalizeDeviceType } from '../../../device-types.js'
|
|
3
|
-
import { isV4Config } from '../../utils/v4-detection.js'
|
|
4
3
|
import './types.js'
|
|
5
4
|
import { uiLog } from './logger.js'
|
|
6
5
|
import { toastError } from './toast.js'
|
|
@@ -23,35 +22,6 @@ export async function fetchDevices(): Promise<any[]> {
|
|
|
23
22
|
}
|
|
24
23
|
}
|
|
25
24
|
|
|
26
|
-
/**
|
|
27
|
-
* Detect whether the current plugin config is in the legacy v4 format.
|
|
28
|
-
*
|
|
29
|
-
* v4 configs stored credentials under a `credentials` sub-object and devices
|
|
30
|
-
* under `options.devices`. v5+ flattened these to `openApiToken`,
|
|
31
|
-
* `openApiSecret`, and a root-level `devices` array.
|
|
32
|
-
*
|
|
33
|
-
* Returns true when a v4 config block is found, false otherwise.
|
|
34
|
-
*/
|
|
35
|
-
export async function detectV4Config(): Promise<boolean> {
|
|
36
|
-
try {
|
|
37
|
-
if (typeof homebridge.getPluginConfig !== 'function') {
|
|
38
|
-
return false
|
|
39
|
-
}
|
|
40
|
-
const configArr = await homebridge.getPluginConfig()
|
|
41
|
-
if (!Array.isArray(configArr)) {
|
|
42
|
-
return false
|
|
43
|
-
}
|
|
44
|
-
const config = configArr.find(isSwitchBotPlatformConfig)
|
|
45
|
-
if (!config || typeof config !== 'object') {
|
|
46
|
-
return false
|
|
47
|
-
}
|
|
48
|
-
return isV4Config(config)
|
|
49
|
-
} catch (e) {
|
|
50
|
-
uiLog.warn('Error detecting v4 config:', e)
|
|
51
|
-
return false
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
25
|
/**
|
|
56
26
|
* Validate and auto-correct device types in the config array before saving.
|
|
57
27
|
* Returns an array of errors for devices that cannot be fixed.
|
|
@@ -219,19 +189,7 @@ export async function addDevice(
|
|
|
219
189
|
deviceId: string,
|
|
220
190
|
name: string,
|
|
221
191
|
type: string,
|
|
222
|
-
options?: {
|
|
223
|
-
address?: string
|
|
224
|
-
model?: string
|
|
225
|
-
rssi?: number
|
|
226
|
-
connectionPreference?: string
|
|
227
|
-
externalPublishProtocol?: string
|
|
228
|
-
room?: string
|
|
229
|
-
encryptionKey?: string
|
|
230
|
-
keyId?: string
|
|
231
|
-
refreshRate?: number
|
|
232
|
-
blePollingEnabled?: boolean
|
|
233
|
-
blePollIntervalMs?: number
|
|
234
|
-
},
|
|
192
|
+
options?: { address?: string, model?: string, rssi?: number, encryptionKey?: string, keyId?: string },
|
|
235
193
|
): Promise<any> {
|
|
236
194
|
if (typeof homebridge.getPluginConfig !== 'function' || typeof homebridge.updatePluginConfig !== 'function') {
|
|
237
195
|
throw new TypeError('Homebridge UI API not available')
|
|
@@ -266,24 +224,6 @@ export async function addDevice(
|
|
|
266
224
|
if (options?.keyId) {
|
|
267
225
|
newDevice.keyId = options.keyId
|
|
268
226
|
}
|
|
269
|
-
if (options?.connectionPreference) {
|
|
270
|
-
newDevice.connectionPreference = options.connectionPreference
|
|
271
|
-
}
|
|
272
|
-
if (options?.externalPublishProtocol) {
|
|
273
|
-
newDevice.externalPublishProtocol = options.externalPublishProtocol
|
|
274
|
-
}
|
|
275
|
-
if (options?.room) {
|
|
276
|
-
newDevice.room = options.room
|
|
277
|
-
}
|
|
278
|
-
if (typeof options?.refreshRate === 'number' && Number.isFinite(options.refreshRate)) {
|
|
279
|
-
newDevice.refreshRate = options.refreshRate
|
|
280
|
-
}
|
|
281
|
-
if (typeof options?.blePollingEnabled === 'boolean') {
|
|
282
|
-
newDevice.blePollingEnabled = options.blePollingEnabled
|
|
283
|
-
}
|
|
284
|
-
if (typeof options?.blePollIntervalMs === 'number' && Number.isFinite(options.blePollIntervalMs)) {
|
|
285
|
-
newDevice.blePollIntervalMs = options.blePollIntervalMs
|
|
286
|
-
}
|
|
287
227
|
config.devices.push(newDevice)
|
|
288
228
|
await homebridge.updatePluginConfig(configArr)
|
|
289
229
|
if (typeof homebridge.savePluginConfig === 'function') {
|