@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.
Files changed (143) hide show
  1. package/.github/copilot-instructions.md +0 -63
  2. package/.github/scripts/compute-version-override.cjs +51 -0
  3. package/.github/scripts/normalize-changelog-header.cjs +1 -1
  4. package/.github/scripts/npm-version-script-esm-auto.js +1 -1
  5. package/.github/scripts/resolve-version-bump-esm.js +1 -1
  6. package/CHANGELOG.md +32 -9
  7. package/MIGRATION.md +0 -5
  8. package/README.md +0 -25
  9. package/config.schema.json +0 -11
  10. package/dist/{Platform.HAP.d.ts → SwitchBotHAPPlatform.d.ts} +16 -2
  11. package/dist/SwitchBotHAPPlatform.d.ts.map +1 -0
  12. package/dist/{Platform.HAP.js → SwitchBotHAPPlatform.js} +111 -40
  13. package/dist/SwitchBotHAPPlatform.js.map +1 -0
  14. package/dist/{Platform.Matter.d.ts → SwitchBotMatterPlatform.d.ts} +2 -4
  15. package/dist/SwitchBotMatterPlatform.d.ts.map +1 -0
  16. package/dist/{Platform.Matter.js → SwitchBotMatterPlatform.js} +6 -62
  17. package/dist/SwitchBotMatterPlatform.js.map +1 -0
  18. package/dist/device-types.d.ts.map +1 -1
  19. package/dist/device-types.js +10 -0
  20. package/dist/device-types.js.map +1 -1
  21. package/dist/deviceFactory.d.ts.map +1 -1
  22. package/dist/deviceFactory.js +10 -16
  23. package/dist/deviceFactory.js.map +1 -1
  24. package/dist/devices/genericDevice.d.ts +63 -5
  25. package/dist/devices/genericDevice.d.ts.map +1 -1
  26. package/dist/devices/genericDevice.js +251 -11
  27. package/dist/devices/genericDevice.js.map +1 -1
  28. package/dist/homebridge-ui/device-types.js +10 -0
  29. package/dist/homebridge-ui/device-types.js.map +1 -1
  30. package/dist/homebridge-ui/endpoints/config.d.ts.map +1 -1
  31. package/dist/homebridge-ui/endpoints/config.js +2 -9
  32. package/dist/homebridge-ui/endpoints/config.js.map +1 -1
  33. package/dist/homebridge-ui/homebridge-ui/endpoints/config.js +2 -9
  34. package/dist/homebridge-ui/homebridge-ui/endpoints/config.js.map +1 -1
  35. package/dist/homebridge-ui/homebridge-ui/utils/config-parser.js +0 -8
  36. package/dist/homebridge-ui/homebridge-ui/utils/config-parser.js.map +1 -1
  37. package/dist/homebridge-ui/public/css/styles.css +1 -61
  38. package/dist/homebridge-ui/public/index.html +2 -61
  39. package/dist/homebridge-ui/public/js/advanced-settings.d.ts.map +1 -1
  40. package/dist/homebridge-ui/public/js/advanced-settings.js +3 -4
  41. package/dist/homebridge-ui/public/js/advanced-settings.js.map +1 -1
  42. package/dist/homebridge-ui/public/js/advanced-settings.ts +3 -4
  43. package/dist/homebridge-ui/public/js/api.d.ts +0 -16
  44. package/dist/homebridge-ui/public/js/api.d.ts.map +1 -1
  45. package/dist/homebridge-ui/public/js/api.js +0 -48
  46. package/dist/homebridge-ui/public/js/api.js.map +1 -1
  47. package/dist/homebridge-ui/public/js/api.ts +1 -61
  48. package/dist/homebridge-ui/public/js/app.js +135 -272
  49. package/dist/homebridge-ui/public/js/app.js.map +4 -4
  50. package/dist/homebridge-ui/public/js/app.ts +4 -47
  51. package/dist/homebridge-ui/public/js/credentials.d.ts.map +1 -1
  52. package/dist/homebridge-ui/public/js/credentials.js +5 -11
  53. package/dist/homebridge-ui/public/js/credentials.js.map +1 -1
  54. package/dist/homebridge-ui/public/js/credentials.ts +5 -11
  55. package/dist/homebridge-ui/public/js/devices.d.ts.map +1 -1
  56. package/dist/homebridge-ui/public/js/devices.js +0 -6
  57. package/dist/homebridge-ui/public/js/devices.js.map +1 -1
  58. package/dist/homebridge-ui/public/js/devices.ts +0 -6
  59. package/dist/homebridge-ui/public/js/modals.d.ts +0 -1
  60. package/dist/homebridge-ui/public/js/modals.d.ts.map +1 -1
  61. package/dist/homebridge-ui/public/js/modals.js +0 -53
  62. package/dist/homebridge-ui/public/js/modals.js.map +1 -1
  63. package/dist/homebridge-ui/public/js/modals.ts +0 -58
  64. package/dist/homebridge-ui/public/js/render.d.ts.map +1 -1
  65. package/dist/homebridge-ui/public/js/render.js +1 -2
  66. package/dist/homebridge-ui/public/js/render.js.map +1 -1
  67. package/dist/homebridge-ui/public/js/render.ts +1 -2
  68. package/dist/homebridge-ui/settings.js.map +1 -1
  69. package/dist/homebridge-ui/utils/config-parser.d.ts +0 -8
  70. package/dist/homebridge-ui/utils/config-parser.d.ts.map +1 -1
  71. package/dist/homebridge-ui/utils/config-parser.js +0 -8
  72. package/dist/homebridge-ui/utils/config-parser.js.map +1 -1
  73. package/dist/index.js +2 -2
  74. package/dist/index.js.map +1 -1
  75. package/dist/settings.d.ts +0 -11
  76. package/dist/settings.d.ts.map +1 -1
  77. package/dist/settings.js.map +1 -1
  78. package/dist/utils.d.ts +0 -5
  79. package/dist/utils.d.ts.map +1 -1
  80. package/dist/utils.js +0 -27
  81. package/dist/utils.js.map +1 -1
  82. package/docs/assets/highlight.css +16 -16
  83. package/docs/assets/main.js +1 -1
  84. package/docs/assets/style.css +18 -3
  85. package/docs/hierarchy.html +26 -1
  86. package/docs/index.html +70 -23
  87. package/docs/modules.html +33 -1
  88. package/docs/variables/default.html +46 -3
  89. package/package.json +22 -15
  90. package/scripts/build-ui.js +0 -30
  91. package/src/{Platform.HAP.ts → SwitchBotHAPPlatform.ts} +112 -39
  92. package/src/{Platform.Matter.ts → SwitchBotMatterPlatform.ts} +8 -63
  93. package/src/device-types.ts +11 -0
  94. package/src/deviceFactory.ts +10 -15
  95. package/src/devices/genericDevice.ts +271 -12
  96. package/src/homebridge-ui/endpoints/config.ts +2 -10
  97. package/src/homebridge-ui/public/css/styles.css +1 -61
  98. package/src/homebridge-ui/public/index.html +0 -59
  99. package/src/homebridge-ui/public/js/advanced-settings.ts +3 -4
  100. package/src/homebridge-ui/public/js/api.ts +1 -61
  101. package/src/homebridge-ui/public/js/app.ts +4 -47
  102. package/src/homebridge-ui/public/js/credentials.ts +5 -11
  103. package/src/homebridge-ui/public/js/devices.ts +0 -6
  104. package/src/homebridge-ui/public/js/modals.ts +0 -58
  105. package/src/homebridge-ui/public/js/render.ts +1 -2
  106. package/src/homebridge-ui/utils/config-parser.ts +0 -9
  107. package/src/index.ts +2 -2
  108. package/src/settings.ts +0 -12
  109. package/src/utils.ts +0 -31
  110. package/test/device/deviceFactory.spec.ts +9 -0
  111. package/test/device/getstate-cache.spec.ts +62 -0
  112. package/test/device/getstate-returns-status.spec.ts +89 -0
  113. package/test/device/model-identities.spec.ts +45 -0
  114. package/test/device/water-detector-battery.spec.ts +81 -0
  115. package/test/device/water-detector-leak.spec.ts +43 -0
  116. package/test/hap-push-updates.spec.ts +150 -0
  117. package/test/platform/accessory-restore.spec.ts +2 -2
  118. package/test/platform/matter-childbridge.spec.ts +1 -1
  119. package/test/platform/matter-integration.spec.ts +1 -51
  120. package/test/platform/platform-edge.spec.ts +2 -2
  121. package/test/platform/platform.integration.spec.ts +1 -1
  122. package/test/utils/utils.spec.ts +0 -15
  123. package/.github/npm-version-script-esm.js +0 -97
  124. package/dist/Platform.HAP.d.ts.map +0 -1
  125. package/dist/Platform.HAP.js.map +0 -1
  126. package/dist/Platform.Matter.d.ts.map +0 -1
  127. package/dist/Platform.Matter.js.map +0 -1
  128. package/dist/homebridge-ui/homebridge-ui/utils/v4-detection.js +0 -23
  129. package/dist/homebridge-ui/homebridge-ui/utils/v4-detection.js.map +0 -1
  130. package/dist/homebridge-ui/public/js/legacy-view.d.ts +0 -3
  131. package/dist/homebridge-ui/public/js/legacy-view.d.ts.map +0 -1
  132. package/dist/homebridge-ui/public/js/legacy-view.js +0 -45
  133. package/dist/homebridge-ui/public/js/legacy-view.js.map +0 -1
  134. package/dist/homebridge-ui/public/js/legacy-view.ts +0 -107
  135. package/dist/homebridge-ui/utils/v4-detection.d.ts +0 -12
  136. package/dist/homebridge-ui/utils/v4-detection.d.ts.map +0 -1
  137. package/dist/homebridge-ui/utils/v4-detection.js +0 -23
  138. package/dist/homebridge-ui/utils/v4-detection.js.map +0 -1
  139. package/src/device-types.js +0 -246
  140. package/src/homebridge-ui/public/js/legacy-view.js +0 -45
  141. package/src/homebridge-ui/public/js/legacy-view.ts +0 -107
  142. package/src/homebridge-ui/utils/v4-detection.ts +0 -23
  143. package/test/homebridge-ui/config-parser.spec.ts +0 -61
@@ -1,56 +1,13 @@
1
- import { detectV4Config } from './api.js'
2
1
  import { loadCredentialStatus, saveCredentials } from './credentials.js'
3
2
  import { initRemoveAllButton, loadConfiguredDevices } from './devices.js'
4
- import { discoverDevices, initializeDiscoverySettings } from './discovery.js'
3
+ import { discoverDevices, initializeDiscoverySettings } from './discovery.js';
5
4
 
6
- // Ensure SwitchBot config block exists before any UI actions
7
- async function ensureSwitchBotConfigBlock(): Promise<void> {
8
- try {
9
- if (typeof homebridge.getPluginConfig !== 'function' || typeof homebridge.updatePluginConfig !== 'function') {
10
- return
11
- }
12
- let configArr = await homebridge.getPluginConfig()
13
- if (!Array.isArray(configArr)) {
14
- configArr = []
15
- }
16
- const idx = configArr.findIndex(c => (c.platform || c.name || '').toLowerCase().includes('switchbot'))
17
- if (idx === -1) {
18
- configArr.push({ platform: 'SwitchBot' })
19
- await homebridge.updatePluginConfig(configArr)
20
- if (typeof homebridge.savePluginConfig === 'function') {
21
- await homebridge.savePluginConfig()
22
- }
23
- }
24
- } catch (_e) {
25
- // Ignore errors
26
- }
27
- }
28
-
29
- ;
30
-
31
- (window as any).loadCredentialStatus = loadCredentialStatus
32
- ;(window as any).saveCredentials = saveCredentials
33
- ;(window as any).discoverDevices = discoverDevices
34
-
35
- /**
36
- * Show or hide the v4 config incompatibility warning banner.
37
- */
38
- async function checkV4Config(): Promise<void> {
39
- const banner = document.getElementById('v4ConfigWarning')
40
- if (!banner) {
41
- return
42
- }
43
- const isV4 = await detectV4Config()
44
- if (isV4) {
45
- banner.style.display = 'block'
46
- }
47
- }
5
+ (window as any).loadCredentialStatus = loadCredentialStatus;
6
+ (window as any).saveCredentials = saveCredentials;
7
+ (window as any).discoverDevices = discoverDevices
48
8
 
49
9
  // Initialize on page load
50
-
51
10
  async function init(): Promise<void> {
52
- await ensureSwitchBotConfigBlock()
53
- await checkV4Config()
54
11
  await loadCredentialStatus()
55
12
  await initializeDiscoverySettings()
56
13
  await loadConfiguredDevices()
@@ -63,20 +63,14 @@ export async function saveCredentials(): Promise<void> {
63
63
  if (typeof homebridge.getPluginConfig !== 'function' || typeof homebridge.updatePluginConfig !== 'function') {
64
64
  throw new TypeError('Homebridge UI API not available')
65
65
  }
66
- let configArr = await homebridge.getPluginConfig()
67
- if (!Array.isArray(configArr)) {
68
- configArr = []
69
- }
70
- let idx = configArr.findIndex(c => (c.platform || c.name || '').toLowerCase() === 'switchbot')
71
- if (idx === -1) {
72
- // Add new SwitchBot platform config
73
- configArr.push({ platform: 'SwitchBot' })
74
- idx = configArr.length - 1
66
+ const configArr = await homebridge.getPluginConfig()
67
+ if (!Array.isArray(configArr) || configArr.length === 0) {
68
+ throw new Error('No plugin config found')
75
69
  }
76
- const config = configArr[idx]
70
+ const config = configArr[0]
77
71
  config.openApiToken = token
78
72
  config.openApiSecret = secret
79
- await homebridge.updatePluginConfig(configArr)
73
+ await homebridge.updatePluginConfig([config])
80
74
  if (typeof homebridge.savePluginConfig === 'function') {
81
75
  await homebridge.savePluginConfig()
82
76
  }
@@ -26,14 +26,8 @@ export async function addDeviceToConfig(device: any, options: { refresh?: boolea
26
26
  address: importValues.address,
27
27
  model: device.model,
28
28
  rssi: device.rssi,
29
- connectionPreference: importValues.connectionPreference,
30
- externalPublishProtocol: importValues.externalPublishProtocol,
31
- room: importValues.room,
32
29
  encryptionKey: importValues.encryptionKey,
33
30
  keyId: importValues.keyId,
34
- refreshRate: importValues.refreshRate,
35
- blePollingEnabled: importValues.blePollingEnabled,
36
- blePollIntervalMs: importValues.blePollIntervalMs,
37
31
  })
38
32
  uiLog.info('Add device response:', resp)
39
33
 
@@ -6,7 +6,6 @@ export type ImportDiscoveredDeviceResult = {
6
6
  configDeviceType: string
7
7
  address?: string
8
8
  connectionPreference?: string
9
- externalPublishProtocol?: string
10
9
  room?: string
11
10
  encryptionKey?: string
12
11
  keyId?: string
@@ -155,30 +154,6 @@ export async function importDiscoveredDevice(device: any): Promise<ImportDiscove
155
154
  connectionPrefSelect.appendChild(opt)
156
155
  })
157
156
 
158
- // --- External Publish Protocol ---
159
- const externalPublishLabel = document.createElement('label')
160
- externalPublishLabel.textContent = 'Publish as External Device'
161
- externalPublishLabel.style.display = 'block'
162
- externalPublishLabel.style.marginBottom = '6px'
163
- externalPublishLabel.style.fontWeight = '500'
164
- externalPublishLabel.style.fontSize = '12px'
165
- externalPublishLabel.style.color = '#6b7280'
166
-
167
- const externalPublishSelect = document.createElement('select')
168
- externalPublishSelect.style.width = '100%'
169
- externalPublishSelect.style.marginBottom = '12px'
170
- externalPublishSelect.style.padding = '8px 10px'
171
- externalPublishSelect.style.borderRadius = '6px'
172
- externalPublishSelect.style.fontSize = '14px'
173
- externalPublishSelect.style.boxSizing = 'border-box'
174
- ;['none', 'hap', 'matter'].forEach((val) => {
175
- const opt = document.createElement('option')
176
- opt.value = val
177
- opt.text = val === 'none' ? 'None' : val.toUpperCase()
178
- opt.selected = (device.externalPublishProtocol || 'none') === val
179
- externalPublishSelect.appendChild(opt)
180
- })
181
-
182
157
  // --- Room ---
183
158
  const roomLabel = document.createElement('label')
184
159
  roomLabel.textContent = 'Room'
@@ -333,7 +308,6 @@ export async function importDiscoveredDevice(device: any): Promise<ImportDiscove
333
308
  configDeviceType: typeSelect.value || device.type,
334
309
  address: macInput.value || undefined,
335
310
  connectionPreference: connectionPrefSelect.value || undefined,
336
- externalPublishProtocol: externalPublishSelect.value || 'none',
337
311
  room: roomInput.value || undefined,
338
312
  encryptionKey: encryptionKeyInput.value || undefined,
339
313
  keyId: keyIdInput.value || undefined,
@@ -357,8 +331,6 @@ export async function importDiscoveredDevice(device: any): Promise<ImportDiscove
357
331
  contentDiv.appendChild(typeSelect)
358
332
  contentDiv.appendChild(connectionPrefLabel)
359
333
  contentDiv.appendChild(connectionPrefSelect)
360
- contentDiv.appendChild(externalPublishLabel)
361
- contentDiv.appendChild(externalPublishSelect)
362
334
  contentDiv.appendChild(roomLabel)
363
335
  contentDiv.appendChild(roomInput)
364
336
  contentDiv.appendChild(macLabel)
@@ -601,30 +573,6 @@ export async function editDevice(device: any): Promise<void> {
601
573
  connectionPrefSelect.appendChild(opt)
602
574
  })
603
575
 
604
- // --- External Publish Protocol ---
605
- const externalPublishLabel = document.createElement('label')
606
- externalPublishLabel.textContent = 'Publish as External Device'
607
- externalPublishLabel.style.display = 'block'
608
- externalPublishLabel.style.marginBottom = '6px'
609
- externalPublishLabel.style.fontWeight = '500'
610
- externalPublishLabel.style.fontSize = '12px'
611
- externalPublishLabel.style.color = '#6b7280'
612
-
613
- const externalPublishSelect = document.createElement('select')
614
- externalPublishSelect.style.width = '100%'
615
- externalPublishSelect.style.marginBottom = '12px'
616
- externalPublishSelect.style.padding = '8px 10px'
617
- externalPublishSelect.style.borderRadius = '6px'
618
- externalPublishSelect.style.fontSize = '14px'
619
- externalPublishSelect.style.boxSizing = 'border-box'
620
- ;['none', 'hap', 'matter'].forEach((val) => {
621
- const opt = document.createElement('option')
622
- opt.value = val
623
- opt.text = val === 'none' ? 'None' : val.toUpperCase()
624
- opt.selected = (device.externalPublishProtocol || 'none') === val
625
- externalPublishSelect.appendChild(opt)
626
- })
627
-
628
576
  // --- Room ---
629
577
  const roomLabel = document.createElement('label')
630
578
  roomLabel.textContent = 'Room'
@@ -726,7 +674,6 @@ export async function editDevice(device: any): Promise<void> {
726
674
  configDeviceName: nameInput.value || undefined,
727
675
  configDeviceType: typeSelect.value,
728
676
  connectionPreference: connectionPrefSelect.value,
729
- externalPublishProtocol: externalPublishSelect.value,
730
677
  room: roomInput.value || undefined,
731
678
  encryptionKey: encryptionKeyInput.value || undefined,
732
679
  keyId: keyIdInput.value || undefined,
@@ -743,9 +690,6 @@ export async function editDevice(device: any): Promise<void> {
743
690
  if (params.room !== undefined) {
744
691
  options.room = params.room
745
692
  }
746
- if (params.externalPublishProtocol !== undefined) {
747
- options.externalPublishProtocol = params.externalPublishProtocol
748
- }
749
693
  if (params.encryptionKey !== undefined) {
750
694
  options.encryptionKey = params.encryptionKey
751
695
  }
@@ -795,8 +739,6 @@ export async function editDevice(device: any): Promise<void> {
795
739
  contentDiv.appendChild(typeSelect)
796
740
  contentDiv.appendChild(connectionPrefLabel)
797
741
  contentDiv.appendChild(connectionPrefSelect)
798
- contentDiv.appendChild(externalPublishLabel)
799
- contentDiv.appendChild(externalPublishSelect)
800
742
  contentDiv.appendChild(roomLabel)
801
743
  contentDiv.appendChild(roomInput)
802
744
  contentDiv.appendChild(encryptionKeyLabel)
@@ -1014,9 +1014,8 @@ export function renderDeviceList(list: any[]): void {
1014
1014
  const id = `ID: ${deviceIdentifier}`
1015
1015
  const typeText = d.configDeviceType || d.type ? `Type: ${d.configDeviceType || d.type}` : ''
1016
1016
  const connText = d.connectionPreference ? `Conn: ${d.connectionPreference}` : ''
1017
- const extText = d.externalPublishProtocol && d.externalPublishProtocol !== 'none' ? `external: ${d.externalPublishProtocol}` : ''
1018
1017
  const roomText = d.room ? `Room: ${d.room}` : ''
1019
- meta.textContent = [id, typeText, connText, extText, roomText].filter(Boolean).join(' | ')
1018
+ meta.textContent = [id, typeText, connText, roomText].filter(Boolean).join(' | ')
1020
1019
 
1021
1020
  info.appendChild(nameContainer)
1022
1021
  info.appendChild(meta)
@@ -57,15 +57,6 @@ export function getAllDevices(platform: any): any[] {
57
57
  })
58
58
  }
59
59
 
60
- /**
61
- * Detect whether a platform config block is in the legacy v4 format.
62
- *
63
- * v4 configs stored credentials under a `credentials` sub-object and devices
64
- * under `options.devices`. v5+ flattened these to `openApiToken`,
65
- * `openApiSecret`, and a root-level `devices` array.
66
- */
67
- export { isV4Config } from './v4-detection.js'
68
-
69
60
  /**
70
61
  * Get credential from platform config
71
62
  */
package/src/index.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  */
5
5
  import type { API } from 'homebridge'
6
6
 
7
- import { SwitchBotHAPPlatform } from './Platform.HAP.js'
8
- import { SwitchBotMatterPlatform } from './Platform.Matter.js'
9
7
  import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'
8
+ import { SwitchBotHAPPlatform } from './SwitchBotHAPPlatform.js'
9
+ import { SwitchBotMatterPlatform } from './SwitchBotMatterPlatform.js'
10
10
  import { createPlatformProxy } from './utils.js'
11
11
 
12
12
  /**
package/src/settings.ts CHANGED
@@ -1,24 +1,12 @@
1
1
  export const PLUGIN_NAME = '@switchbot/homebridge-switchbot'
2
2
  export const PLATFORM_NAME = 'SwitchBot'
3
3
 
4
- export interface SwitchBotDeviceConfig {
5
- deviceId?: string
6
- id?: string
7
- configDeviceName?: string
8
- name?: string
9
- configDeviceType?: string
10
- type?: string
11
- externalPublishProtocol?: 'none' | 'hap' | 'matter'
12
- [key: string]: any
13
- }
14
-
15
4
  export interface SwitchBotPluginConfig {
16
5
  openApiToken?: string
17
6
  openApiSecret?: string
18
7
  preferMatter?: boolean
19
8
  enableMatter?: boolean
20
9
  enableBLE?: boolean // Enable or disable BLE support
21
- devices?: SwitchBotDeviceConfig[]
22
10
  // other plugin-specific configuration
23
11
  [key: string]: any
24
12
  }
package/src/utils.ts CHANGED
@@ -1137,37 +1137,6 @@ export function normalizeConfig(raw?: PlatformConfig): SwitchBotPluginConfig {
1137
1137
  return { ...(raw as any) } as SwitchBotPluginConfig
1138
1138
  }
1139
1139
 
1140
- /**
1141
- * Resolve the per-device external publish protocol from raw config.
1142
- * Accepts canonical values (`none`, `hap`, `matter`) and common aliases.
1143
- */
1144
- export function resolveExternalPublishProtocol(rawDevice: any): 'none' | 'hap' | 'matter' {
1145
- const raw = String(
1146
- rawDevice?.externalPublishProtocol
1147
- ?? rawDevice?.externalDeviceProtocol
1148
- ?? rawDevice?.publishExternal
1149
- ?? rawDevice?.external
1150
- ?? 'none',
1151
- ).trim().toLowerCase()
1152
-
1153
- if (raw === 'true') {
1154
- return 'hap'
1155
- }
1156
- if (raw === 'false') {
1157
- return 'none'
1158
- }
1159
- if (raw === 'homekit') {
1160
- return 'hap'
1161
- }
1162
- if (raw === 'none' || raw === 'off' || raw === 'disabled') {
1163
- return 'none'
1164
- }
1165
- if (raw === 'hap' || raw === 'matter') {
1166
- return raw
1167
- }
1168
- return 'none'
1169
- }
1170
-
1171
1140
  // Create a Proxy constructor that instantiates the right platform implementation at runtime.
1172
1141
  /**
1173
1142
  * Creates a proxy class that instantiates the correct platform implementation (HAP or Matter) at runtime.
@@ -30,4 +30,13 @@ describe('createDevice', () => {
30
30
  const result = await createDevice({ id: 'xyz', type: 'UnknownType' }, dummyConfig as any, false)
31
31
  expect(result.instance.constructor.name).toBe('GenericDevice')
32
32
  })
33
+
34
+ it('should create a WaterDetectorDevice for normalized "waterdetector" type', async () => {
35
+ // normalizeTypeForMatter() returns 'waterdetector' (no space) for water detector variants.
36
+ // This regression test ensures the no-space key is mapped so devices aren't silently
37
+ // downgraded to GenericDevice (which would expose a bogus Switch service).
38
+ const result = await createDevice({ id: 'wd1', type: 'waterdetector' }, dummyConfig as any, false)
39
+ expect(result.instance).toBeDefined()
40
+ expect(result.instance.constructor.name).toBe('WaterDetectorDevice')
41
+ })
33
42
  })
@@ -0,0 +1,62 @@
1
+ /* Copyright(C) 2021-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved.
2
+ *
3
+ * getstate-cache.spec.ts: one fetch serves a burst of reads
4
+ */
5
+
6
+ import { describe, expect, it, vi } from 'vitest'
7
+
8
+ import { GenericDevice } from '../../src/devices/genericDevice.js'
9
+
10
+ const log: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), success: vi.fn() }
11
+
12
+ function device() {
13
+ const getDevice = vi.fn(async () => ({ getStatus: async () => ({ temperature: 22.7, humidity: 59 }) }))
14
+ const d = new GenericDevice({ id: 'B0E9FED044E3', type: 'meter', name: 'M', log }, { log, _client: { getDevice } } as any)
15
+ return { d, getDevice }
16
+ }
17
+
18
+ describe('genericDevice.getState caching', () => {
19
+ // HomeKit reads every characteristic of an accessory at once. Seven
20
+ // characteristics must not mean seven API requests.
21
+ it('fetches once for a burst of concurrent reads', async () => {
22
+ const { d, getDevice } = device()
23
+
24
+ const states = await Promise.all([d.getState(), d.getState(), d.getState(), d.getState()])
25
+
26
+ expect(getDevice).toHaveBeenCalledTimes(1)
27
+ expect(states.every(s => s.temperature === 22.7)).toBe(true)
28
+ })
29
+
30
+ it('reuses the value for sequential reads inside the window', async () => {
31
+ const { d, getDevice } = device()
32
+
33
+ await d.getState()
34
+ await d.getState()
35
+
36
+ expect(getDevice).toHaveBeenCalledTimes(1)
37
+ })
38
+
39
+ it('fetches again once the window has passed', async () => {
40
+ const { d, getDevice } = device()
41
+ await d.getState()
42
+
43
+ vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 20_000)
44
+ await d.getState()
45
+ vi.restoreAllMocks()
46
+
47
+ expect(getDevice).toHaveBeenCalledTimes(2)
48
+ })
49
+
50
+ it('marks an unreadable device and does not cache it', async () => {
51
+ const getDevice = vi.fn()
52
+ .mockRejectedValueOnce(new Error('offline'))
53
+ .mockResolvedValueOnce({ getStatus: async () => ({ temperature: 21 }) })
54
+ const d = new GenericDevice({ id: 'x', type: 'meter', name: 'M', log }, { log, _client: { getDevice } } as any)
55
+
56
+ const first = await d.getState()
57
+ const second = await d.getState()
58
+
59
+ expect(first).toEqual({ id: 'x', type: 'meter', unreadable: true })
60
+ expect(second.temperature).toBe(21)
61
+ })
62
+ })
@@ -0,0 +1,89 @@
1
+ /* Copyright(C) 2021-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved.
2
+ *
3
+ * getstate-returns-status.spec.ts: getState() must yield readings, not the device
4
+ */
5
+
6
+ import { describe, expect, it, vi } from 'vitest'
7
+
8
+ import { createDevice } from '../../src/deviceFactory.js'
9
+ import { GenericDevice, MeterDevice } from '../../src/devices/genericDevice.js'
10
+
11
+ const log: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), success: vi.fn() }
12
+
13
+ /** A node-switchbot style device: identity on the instance, readings via getStatus(). */
14
+ function switchBotDevice(status: any) {
15
+ return {
16
+ id: 'B0E9FED044E3',
17
+ name: 'Meter Pro CO2 Monitor',
18
+ deviceType: 'MeterPro(CO2)',
19
+ mac: 'b0:e9:fe:d0:44:e3',
20
+ getStatus: async () => status,
21
+ }
22
+ }
23
+
24
+ const STATUS = { deviceId: 'B0E9FED044E3', temperature: 23.7, humidity: 59, battery: 100, connectionType: 'api' }
25
+
26
+ function deviceWith(client: any) {
27
+ return new GenericDevice({ id: 'B0E9FED044E3', type: 'meter', name: 'Meter', log }, { log, _client: client } as any)
28
+ }
29
+
30
+ describe('genericDevice.getState', () => {
31
+ it('returns the status rather than the device instance', async () => {
32
+ const state = await deviceWith({ getDevice: async () => switchBotDevice(STATUS) }).getState()
33
+
34
+ expect(state).toEqual(STATUS)
35
+ expect(state.temperature).toBe(23.7)
36
+ expect(state.humidity).toBe(59)
37
+ })
38
+
39
+ it('passes through a plain status object unchanged', async () => {
40
+ const state = await deviceWith({ getDevice: async () => ({ temperature: 20, humidity: 40 }) }).getState()
41
+
42
+ expect(state).toEqual({ temperature: 20, humidity: 40 })
43
+ })
44
+
45
+ it('unwraps an API-shaped body', async () => {
46
+ const state = await deviceWith({ getDevice: async () => ({ body: { temperature: 19 } }) }).getState()
47
+
48
+ expect(state).toEqual({ temperature: 19 })
49
+ })
50
+
51
+ it.each([
52
+ ['throws', async () => { throw new Error('unreachable') }],
53
+ ['returns null', async () => null],
54
+ ])('reports minimal info when getStatus() %s', async (_label, getStatus) => {
55
+ const client = { getDevice: async () => ({ ...switchBotDevice(STATUS), getStatus }) }
56
+
57
+ const state = await deviceWith(client).getState()
58
+
59
+ // Not the device instance: that would look like a reading of every field.
60
+ expect(state).toEqual({ id: 'B0E9FED044E3', type: 'meter', unreadable: true })
61
+ expect(state.temperature).toBeUndefined()
62
+ })
63
+ })
64
+
65
+ describe('createDevice', () => {
66
+ it('does not replace the device getState with the raw client device', async () => {
67
+ const client = { init: async () => {}, getDevice: async () => switchBotDevice(STATUS) }
68
+ const created: any = await createDevice(
69
+ { id: 'B0E9FED044E3', type: 'meter', name: 'Meter', log } as any,
70
+ { log, _client: client } as any,
71
+ false,
72
+ )
73
+
74
+ const state = await created.instance.getState()
75
+
76
+ expect(state.temperature).toBe(23.7)
77
+ expect(state.getStatus).toBeUndefined()
78
+ })
79
+
80
+ it('lets a meter report a real temperature end to end', async () => {
81
+ const client = { init: async () => {}, getDevice: async () => switchBotDevice(STATUS) }
82
+ const device = new MeterDevice({ id: 'B0E9FED044E3', type: 'meter', name: 'Meter', log } as any, { log, _client: client } as any)
83
+
84
+ const services = device.createHAPAccessory(null).services
85
+ const temperature = services.find((s: any) => s.type === 'TemperatureSensor')
86
+
87
+ await expect(temperature.characteristics.CurrentTemperature.get()).resolves.toBe(23.7)
88
+ })
89
+ })
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { normalizeDeviceType } from '../../src/device-types'
4
+ import { createDevice } from '../../src/deviceFactory'
5
+
6
+ // node-switchbot 5 reports product-specific identities (pySwitchbot 3.0.0 parity).
7
+ // Every identity must reach the same accessory class as its canonical display name.
8
+ describe('product-specific model identities', () => {
9
+ const mockLogger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }
10
+ const dummyConfig = { logger: mockLogger, log: mockLogger }
11
+
12
+ it.each([
13
+ ['WoPlugUS', 'Plug Mini (US)'],
14
+ ['WoPlugJP', 'Plug Mini (JP)'],
15
+ ['WoCurtain3', 'Curtain3'],
16
+ ['WoMeterPlus', 'Meter Plus'],
17
+ ['WoTHPc', 'Meter Pro (CO2)'],
18
+ ['WoCeilingPro', 'Ceiling Light Pro'],
19
+ ['WoLinkMini', 'Hub Mini'],
20
+ ['WoIOSensorTH', 'WoIOSensor'],
21
+ ['Outdoor Meter', 'WoIOSensor'],
22
+ ['Indoor/Outdoor Thermo-Hygrometer', 'WoIOSensor'],
23
+ ])('normalizes %s to %s', (input, expected) => {
24
+ expect(normalizeDeviceType(input)).toBe(expected)
25
+ })
26
+
27
+ it.each([
28
+ ['WoPlugUS', 'PlugMiniDevice'],
29
+ ['Plug Mini (US)', 'PlugMiniDevice'],
30
+ ['WoPlugJP', 'PlugMiniDevice'],
31
+ ['Plug Mini (JP)', 'PlugMiniDevice'],
32
+ ['WoCurtain3', 'Curtain3Device'],
33
+ ['Curtain3', 'Curtain3Device'],
34
+ ['WoMeterPlus', 'MeterDevice'],
35
+ ['Meter Plus', 'MeterDevice'],
36
+ ['WoTHPc', 'MeterDevice'],
37
+ ['Meter Pro (CO2)', 'MeterDevice'],
38
+ ['Meter Pro', 'MeterDevice'],
39
+ ['WoCeilingPro', 'LightDevice'],
40
+ ['Ceiling Light Pro', 'LightDevice'],
41
+ ])('creates the right accessory for %s', async (type, className) => {
42
+ const result = await createDevice({ id: `id-${type}`, type } as any, dummyConfig as any, false)
43
+ expect(result.instance.constructor.name).toBe(className)
44
+ })
45
+ })
@@ -0,0 +1,81 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { WaterDetectorDevice } from '../../src/devices/genericDevice'
4
+
5
+ const openApiMocks = vi.hoisted(() => {
6
+ const getStatus = vi.fn()
7
+ return {
8
+ getStatus,
9
+ // Use a function implementation so the mock is constructible with `new OpenAPIClient()`
10
+ // eslint-disable-next-line prefer-arrow-callback
11
+ OpenAPIClient: vi.fn().mockImplementation(function () { return { getStatus } }),
12
+
13
+ }
14
+ })
15
+
16
+ vi.mock('node-switchbot', () => ({
17
+ OpenAPIClient: openApiMocks.OpenAPIClient,
18
+ }))
19
+
20
+ const mockLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
21
+
22
+ const mockApi = {
23
+ hap: {
24
+ HapStatusError: class HapStatusError extends Error {
25
+ constructor(readonly status: number) {
26
+ super(`HAP ${status}`)
27
+ }
28
+ },
29
+ HAPStatus: {
30
+ SERVICE_COMMUNICATION_FAILURE: -70402,
31
+ },
32
+ },
33
+ }
34
+
35
+ function makeDevice(config: Record<string, unknown> = {}) {
36
+ return new WaterDetectorDevice(
37
+ { id: 'wd1', type: 'waterdetector', name: 'Fridge', log: mockLogger, blePollingEnabled: false },
38
+ { log: mockLogger, openApiToken: 'token', openApiSecret: 'secret', ...config } as any,
39
+ )
40
+ }
41
+
42
+ function getBatteryService(device: WaterDetectorDevice) {
43
+ const accessory = device.createHAPAccessory(mockApi)
44
+ return accessory.services.find((service: any) => service.type === 'Battery')
45
+ }
46
+
47
+ describe('water detector device battery service', () => {
48
+ beforeEach(() => {
49
+ vi.clearAllMocks()
50
+ openApiMocks.getStatus.mockResolvedValue({ battery: 87 })
51
+ })
52
+
53
+ it('adds a Battery service to the HAP accessory descriptor', () => {
54
+ const batteryService = getBatteryService(makeDevice())
55
+
56
+ expect(batteryService).toBeDefined()
57
+ expect(batteryService.characteristics.BatteryLevel).toBeDefined()
58
+ expect(batteryService.characteristics.StatusLowBattery).toBeDefined()
59
+ expect(batteryService.characteristics.ChargingState).toBeDefined()
60
+ })
61
+
62
+ it('warms the battery cache from direct OpenAPI status', async () => {
63
+ const device = makeDevice()
64
+
65
+ await device.init()
66
+
67
+ const batteryService = getBatteryService(device)
68
+ expect(openApiMocks.OpenAPIClient).toHaveBeenCalledWith('token', 'secret')
69
+ expect(openApiMocks.getStatus).toHaveBeenCalledWith('wd1')
70
+ expect(batteryService.characteristics.BatteryLevel.get()).toBe(87)
71
+ expect(batteryService.characteristics.StatusLowBattery.get()).toBe(0)
72
+ expect(batteryService.characteristics.ChargingState.get()).toBe(2)
73
+ })
74
+
75
+ it('does not fabricate a battery level before one is cached', () => {
76
+ const device = makeDevice({ openApiSecret: undefined })
77
+ const batteryService = getBatteryService(device)
78
+
79
+ expect(() => batteryService.characteristics.BatteryLevel.get()).toThrow('HAP -70402')
80
+ })
81
+ })
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { WaterDetectorDevice } from '../../src/devices/genericDevice'
4
+
5
+ const mockLogger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }
6
+
7
+ const mockApi = {
8
+ hap: {
9
+ HapStatusError: class HapStatusError extends Error {
10
+ constructor(public status: number) {
11
+ super(`HAP status ${status}`)
12
+ }
13
+ },
14
+ HAPStatus: {
15
+ SERVICE_COMMUNICATION_FAILURE: -70402,
16
+ },
17
+ },
18
+ }
19
+
20
+ describe('water detector device leak state', () => {
21
+ it('warms leak state and serves LeakDetected synchronously from cache', async () => {
22
+ let reads = 0
23
+ const device = new WaterDetectorDevice({ id: 'wd1', type: 'waterdetector' }, { log: mockLogger })
24
+ device.getState = async () => {
25
+ reads += 1
26
+ return { waterLeakDetected: true }
27
+ }
28
+
29
+ await device.init()
30
+ const leakDetected = device.createHAPAccessory(mockApi).services[0].characteristics.LeakDetected.get()
31
+
32
+ expect(leakDetected).toBe(1)
33
+ expect(leakDetected).not.toBeInstanceOf(Promise)
34
+ expect(reads).toBe(1)
35
+ })
36
+
37
+ it('throws a HAP communication failure instead of blocking when no leak state is cached', () => {
38
+ const device = new WaterDetectorDevice({ id: 'wd1', type: 'waterdetector' }, { log: mockLogger })
39
+ device.getState = async () => ({ waterLeakDetected: false })
40
+
41
+ expect(() => device.createHAPAccessory(mockApi).services[0].characteristics.LeakDetected.get()).toThrow('HAP status -70402')
42
+ })
43
+ })