@switchbot/homebridge-switchbot 5.0.0 → 5.1.0-beta.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 (85) hide show
  1. package/.github/npm-version-script-esm.js +97 -0
  2. package/CHANGELOG.md +21 -8
  3. package/MIGRATION.md +5 -0
  4. package/README.md +25 -0
  5. package/config.schema.json +11 -0
  6. package/dist/SwitchBotHAPPlatform.d.ts.map +1 -1
  7. package/dist/SwitchBotHAPPlatform.js +33 -4
  8. package/dist/SwitchBotHAPPlatform.js.map +1 -1
  9. package/dist/SwitchBotMatterPlatform.d.ts +2 -0
  10. package/dist/SwitchBotMatterPlatform.d.ts.map +1 -1
  11. package/dist/SwitchBotMatterPlatform.js +61 -5
  12. package/dist/SwitchBotMatterPlatform.js.map +1 -1
  13. package/dist/homebridge-ui/endpoints/config.d.ts.map +1 -1
  14. package/dist/homebridge-ui/endpoints/config.js +9 -2
  15. package/dist/homebridge-ui/endpoints/config.js.map +1 -1
  16. package/dist/homebridge-ui/homebridge-ui/endpoints/config.js +9 -2
  17. package/dist/homebridge-ui/homebridge-ui/endpoints/config.js.map +1 -1
  18. package/dist/homebridge-ui/homebridge-ui/utils/config-parser.js +8 -0
  19. package/dist/homebridge-ui/homebridge-ui/utils/config-parser.js.map +1 -1
  20. package/dist/homebridge-ui/homebridge-ui/utils/v4-detection.js +23 -0
  21. package/dist/homebridge-ui/homebridge-ui/utils/v4-detection.js.map +1 -0
  22. package/dist/homebridge-ui/public/css/styles.css +61 -1
  23. package/dist/homebridge-ui/public/index.html +25 -2
  24. package/dist/homebridge-ui/public/js/api.d.ts +16 -0
  25. package/dist/homebridge-ui/public/js/api.d.ts.map +1 -1
  26. package/dist/homebridge-ui/public/js/api.js +48 -0
  27. package/dist/homebridge-ui/public/js/api.js.map +1 -1
  28. package/dist/homebridge-ui/public/js/api.ts +61 -1
  29. package/dist/homebridge-ui/public/js/app.js +237 -111
  30. package/dist/homebridge-ui/public/js/app.js.map +4 -4
  31. package/dist/homebridge-ui/public/js/app.ts +22 -4
  32. package/dist/homebridge-ui/public/js/devices.d.ts.map +1 -1
  33. package/dist/homebridge-ui/public/js/devices.js +6 -0
  34. package/dist/homebridge-ui/public/js/devices.js.map +1 -1
  35. package/dist/homebridge-ui/public/js/devices.ts +6 -0
  36. package/dist/homebridge-ui/public/js/modals.d.ts +1 -0
  37. package/dist/homebridge-ui/public/js/modals.d.ts.map +1 -1
  38. package/dist/homebridge-ui/public/js/modals.js +53 -0
  39. package/dist/homebridge-ui/public/js/modals.js.map +1 -1
  40. package/dist/homebridge-ui/public/js/modals.ts +58 -0
  41. package/dist/homebridge-ui/public/js/render.d.ts.map +1 -1
  42. package/dist/homebridge-ui/public/js/render.js +2 -1
  43. package/dist/homebridge-ui/public/js/render.js.map +1 -1
  44. package/dist/homebridge-ui/public/js/render.ts +2 -1
  45. package/dist/homebridge-ui/settings.js.map +1 -1
  46. package/dist/homebridge-ui/utils/config-parser.d.ts +8 -0
  47. package/dist/homebridge-ui/utils/config-parser.d.ts.map +1 -1
  48. package/dist/homebridge-ui/utils/config-parser.js +8 -0
  49. package/dist/homebridge-ui/utils/config-parser.js.map +1 -1
  50. package/dist/homebridge-ui/utils/v4-detection.d.ts +12 -0
  51. package/dist/homebridge-ui/utils/v4-detection.d.ts.map +1 -0
  52. package/dist/homebridge-ui/utils/v4-detection.js +23 -0
  53. package/dist/homebridge-ui/utils/v4-detection.js.map +1 -0
  54. package/dist/settings.d.ts +11 -0
  55. package/dist/settings.d.ts.map +1 -1
  56. package/dist/settings.js.map +1 -1
  57. package/dist/utils.d.ts +5 -0
  58. package/dist/utils.d.ts.map +1 -1
  59. package/dist/utils.js +27 -0
  60. package/dist/utils.js.map +1 -1
  61. package/docs/assets/highlight.css +15 -15
  62. package/docs/assets/icons.js +1 -1
  63. package/docs/assets/icons.svg +1 -1
  64. package/docs/assets/main.js +3 -3
  65. package/docs/index.html +22 -5
  66. package/docs/variables/default.html +1 -1
  67. package/package.json +6 -6
  68. package/scripts/build-ui.js +30 -0
  69. package/src/SwitchBotHAPPlatform.ts +31 -6
  70. package/src/SwitchBotMatterPlatform.ts +61 -7
  71. package/src/homebridge-ui/endpoints/config.ts +10 -2
  72. package/src/homebridge-ui/public/css/styles.css +61 -1
  73. package/src/homebridge-ui/public/index.html +23 -0
  74. package/src/homebridge-ui/public/js/api.ts +61 -1
  75. package/src/homebridge-ui/public/js/app.ts +22 -4
  76. package/src/homebridge-ui/public/js/devices.ts +6 -0
  77. package/src/homebridge-ui/public/js/modals.ts +58 -0
  78. package/src/homebridge-ui/public/js/render.ts +2 -1
  79. package/src/homebridge-ui/utils/config-parser.ts +9 -0
  80. package/src/homebridge-ui/utils/v4-detection.ts +23 -0
  81. package/src/settings.ts +12 -0
  82. package/src/utils.ts +31 -0
  83. package/test/homebridge-ui/config-parser.spec.ts +61 -0
  84. package/test/platform/matter-integration.spec.ts +50 -0
  85. package/test/utils/utils.spec.ts +15 -0
@@ -11,6 +11,29 @@
11
11
  <body>
12
12
  <h1>🤖 SwitchBot Configuration</h1>
13
13
 
14
+ <!-- v4 config incompatibility warning – shown dynamically by app.js -->
15
+ <div id="v4ConfigWarning" class="v4-config-warning" style="display: none">
16
+ <strong>⚠️ Incompatible v4 Configuration Detected</strong>
17
+ <p>
18
+ Your config.json appears to be in the <strong>v4 format</strong> (using <code>credentials</code> and
19
+ <code>options.devices</code> fields). This format is <strong>not compatible with v5 or newer</strong> of the
20
+ SwitchBot plugin. Devices will not be recognised until the config is updated.
21
+ </p>
22
+ <p>
23
+ Please update your config to the v5 format:
24
+ </p>
25
+ <ul>
26
+ <li>Move <code>credentials.token</code> → <code>openApiToken</code> (top level)</li>
27
+ <li>Move <code>credentials.secret</code> → <code>openApiSecret</code> (top level)</li>
28
+ <li>Move <code>options.devices</code> → <code>devices</code> (top level)</li>
29
+ <li>Rename per-device <code>connectionType</code> → <code>connectionPreference</code></li>
30
+ <li>Remove the <code>credentials</code> and <code>options</code> wrapper objects</li>
31
+ </ul>
32
+ <p>
33
+ See the <a href="https://github.com/OpenWonderLabs/homebridge-switchbot/blob/latest/MIGRATION.md" target="_blank" rel="noopener">Migration Guide</a> for full details.
34
+ </p>
35
+ </div>
36
+
14
37
  <div class="card compact">
15
38
  <h2>API Credentials</h2>
16
39
  <div class="credentials-row">
@@ -1,5 +1,6 @@
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'
3
4
  import './types.js'
4
5
  import { uiLog } from './logger.js'
5
6
  import { toastError } from './toast.js'
@@ -22,6 +23,35 @@ export async function fetchDevices(): Promise<any[]> {
22
23
  }
23
24
  }
24
25
 
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
+
25
55
  /**
26
56
  * Validate and auto-correct device types in the config array before saving.
27
57
  * Returns an array of errors for devices that cannot be fixed.
@@ -189,7 +219,19 @@ export async function addDevice(
189
219
  deviceId: string,
190
220
  name: string,
191
221
  type: string,
192
- options?: { address?: string, model?: string, rssi?: number, encryptionKey?: string, keyId?: 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
+ },
193
235
  ): Promise<any> {
194
236
  if (typeof homebridge.getPluginConfig !== 'function' || typeof homebridge.updatePluginConfig !== 'function') {
195
237
  throw new TypeError('Homebridge UI API not available')
@@ -224,6 +266,24 @@ export async function addDevice(
224
266
  if (options?.keyId) {
225
267
  newDevice.keyId = options.keyId
226
268
  }
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
+ }
227
287
  config.devices.push(newDevice)
228
288
  await homebridge.updatePluginConfig(configArr)
229
289
  if (typeof homebridge.savePluginConfig === 'function') {
@@ -1,13 +1,31 @@
1
+ import { detectV4Config } from './api.js'
1
2
  import { loadCredentialStatus, saveCredentials } from './credentials.js'
2
3
  import { initRemoveAllButton, loadConfiguredDevices } from './devices.js'
3
- import { discoverDevices, initializeDiscoverySettings } from './discovery.js';
4
+ import { discoverDevices, initializeDiscoverySettings } from './discovery.js'
4
5
 
5
- (window as any).loadCredentialStatus = loadCredentialStatus;
6
- (window as any).saveCredentials = saveCredentials;
7
- (window as any).discoverDevices = discoverDevices
6
+ ;
7
+
8
+ (window as any).loadCredentialStatus = loadCredentialStatus
9
+ ;(window as any).saveCredentials = saveCredentials
10
+ ;(window as any).discoverDevices = discoverDevices
11
+
12
+ /**
13
+ * Show or hide the v4 config incompatibility warning banner.
14
+ */
15
+ async function checkV4Config(): Promise<void> {
16
+ const banner = document.getElementById('v4ConfigWarning')
17
+ if (!banner) {
18
+ return
19
+ }
20
+ const isV4 = await detectV4Config()
21
+ if (isV4) {
22
+ banner.style.display = 'block'
23
+ }
24
+ }
8
25
 
9
26
  // Initialize on page load
10
27
  async function init(): Promise<void> {
28
+ await checkV4Config()
11
29
  await loadCredentialStatus()
12
30
  await initializeDiscoverySettings()
13
31
  await loadConfiguredDevices()
@@ -26,8 +26,14 @@ 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,
29
32
  encryptionKey: importValues.encryptionKey,
30
33
  keyId: importValues.keyId,
34
+ refreshRate: importValues.refreshRate,
35
+ blePollingEnabled: importValues.blePollingEnabled,
36
+ blePollIntervalMs: importValues.blePollIntervalMs,
31
37
  })
32
38
  uiLog.info('Add device response:', resp)
33
39
 
@@ -6,6 +6,7 @@ export type ImportDiscoveredDeviceResult = {
6
6
  configDeviceType: string
7
7
  address?: string
8
8
  connectionPreference?: string
9
+ externalPublishProtocol?: string
9
10
  room?: string
10
11
  encryptionKey?: string
11
12
  keyId?: string
@@ -154,6 +155,30 @@ export async function importDiscoveredDevice(device: any): Promise<ImportDiscove
154
155
  connectionPrefSelect.appendChild(opt)
155
156
  })
156
157
 
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
+
157
182
  // --- Room ---
158
183
  const roomLabel = document.createElement('label')
159
184
  roomLabel.textContent = 'Room'
@@ -308,6 +333,7 @@ export async function importDiscoveredDevice(device: any): Promise<ImportDiscove
308
333
  configDeviceType: typeSelect.value || device.type,
309
334
  address: macInput.value || undefined,
310
335
  connectionPreference: connectionPrefSelect.value || undefined,
336
+ externalPublishProtocol: externalPublishSelect.value || 'none',
311
337
  room: roomInput.value || undefined,
312
338
  encryptionKey: encryptionKeyInput.value || undefined,
313
339
  keyId: keyIdInput.value || undefined,
@@ -331,6 +357,8 @@ export async function importDiscoveredDevice(device: any): Promise<ImportDiscove
331
357
  contentDiv.appendChild(typeSelect)
332
358
  contentDiv.appendChild(connectionPrefLabel)
333
359
  contentDiv.appendChild(connectionPrefSelect)
360
+ contentDiv.appendChild(externalPublishLabel)
361
+ contentDiv.appendChild(externalPublishSelect)
334
362
  contentDiv.appendChild(roomLabel)
335
363
  contentDiv.appendChild(roomInput)
336
364
  contentDiv.appendChild(macLabel)
@@ -573,6 +601,30 @@ export async function editDevice(device: any): Promise<void> {
573
601
  connectionPrefSelect.appendChild(opt)
574
602
  })
575
603
 
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
+
576
628
  // --- Room ---
577
629
  const roomLabel = document.createElement('label')
578
630
  roomLabel.textContent = 'Room'
@@ -674,6 +726,7 @@ export async function editDevice(device: any): Promise<void> {
674
726
  configDeviceName: nameInput.value || undefined,
675
727
  configDeviceType: typeSelect.value,
676
728
  connectionPreference: connectionPrefSelect.value,
729
+ externalPublishProtocol: externalPublishSelect.value,
677
730
  room: roomInput.value || undefined,
678
731
  encryptionKey: encryptionKeyInput.value || undefined,
679
732
  keyId: keyIdInput.value || undefined,
@@ -690,6 +743,9 @@ export async function editDevice(device: any): Promise<void> {
690
743
  if (params.room !== undefined) {
691
744
  options.room = params.room
692
745
  }
746
+ if (params.externalPublishProtocol !== undefined) {
747
+ options.externalPublishProtocol = params.externalPublishProtocol
748
+ }
693
749
  if (params.encryptionKey !== undefined) {
694
750
  options.encryptionKey = params.encryptionKey
695
751
  }
@@ -739,6 +795,8 @@ export async function editDevice(device: any): Promise<void> {
739
795
  contentDiv.appendChild(typeSelect)
740
796
  contentDiv.appendChild(connectionPrefLabel)
741
797
  contentDiv.appendChild(connectionPrefSelect)
798
+ contentDiv.appendChild(externalPublishLabel)
799
+ contentDiv.appendChild(externalPublishSelect)
742
800
  contentDiv.appendChild(roomLabel)
743
801
  contentDiv.appendChild(roomInput)
744
802
  contentDiv.appendChild(encryptionKeyLabel)
@@ -1011,8 +1011,9 @@ export function renderDeviceList(list: any[]): void {
1011
1011
 
1012
1012
  const typeText = d.type ? `type: ${d.type}` : ''
1013
1013
  const connText = d.connectionPreference ? `conn: ${d.connectionPreference}` : ''
1014
+ const extText = d.externalPublishProtocol && d.externalPublishProtocol !== 'none' ? `external: ${d.externalPublishProtocol}` : ''
1014
1015
  const roomText = d.room ? `room: ${d.room}` : ''
1015
- meta.textContent = [typeText, connText, roomText].filter(Boolean).join(' | ')
1016
+ meta.textContent = [typeText, connText, extText, roomText].filter(Boolean).join(' | ')
1016
1017
 
1017
1018
  info.appendChild(nameContainer)
1018
1019
  info.appendChild(meta)
@@ -57,6 +57,15 @@ 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
+
60
69
  /**
61
70
  * Get credential from platform config
62
71
  */
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Detect whether a platform config block is in the legacy v4 format.
3
+ *
4
+ * v4 configs stored credentials under a `credentials` sub-object and devices
5
+ * under `options.devices`. v5+ flattened these to `openApiToken`,
6
+ * `openApiSecret`, and a root-level `devices` array.
7
+ *
8
+ * This helper has no Node.js-specific dependencies so it can be imported from
9
+ * both the Homebridge server-side code and the browser-side plugin UI bundle.
10
+ */
11
+ export function isV4Config(platform: any): boolean {
12
+ if (!platform || typeof platform !== 'object') {
13
+ return false
14
+ }
15
+ // Presence of a `credentials` sub-object is the clearest v4 signal
16
+ const hasCredentials = platform.credentials !== undefined && typeof platform.credentials === 'object'
17
+ // Presence of `options.devices` is the other v4 signal
18
+ const hasOptionsDevices
19
+ = platform.options !== undefined
20
+ && typeof platform.options === 'object'
21
+ && Array.isArray(platform.options.devices)
22
+ return hasCredentials || hasOptionsDevices
23
+ }
package/src/settings.ts CHANGED
@@ -1,12 +1,24 @@
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
+
4
15
  export interface SwitchBotPluginConfig {
5
16
  openApiToken?: string
6
17
  openApiSecret?: string
7
18
  preferMatter?: boolean
8
19
  enableMatter?: boolean
9
20
  enableBLE?: boolean // Enable or disable BLE support
21
+ devices?: SwitchBotDeviceConfig[]
10
22
  // other plugin-specific configuration
11
23
  [key: string]: any
12
24
  }
package/src/utils.ts CHANGED
@@ -1137,6 +1137,37 @@ 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
+
1140
1171
  // Create a Proxy constructor that instantiates the right platform implementation at runtime.
1141
1172
  /**
1142
1173
  * Creates a proxy class that instantiates the correct platform implementation (HAP or Matter) at runtime.
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { isV4Config } from '../../src/homebridge-ui/utils/v4-detection.js'
4
+
5
+ describe('isV4Config', () => {
6
+ it('returns false for null/undefined input', () => {
7
+ expect(isV4Config(null)).toBe(false)
8
+ expect(isV4Config(undefined)).toBe(false)
9
+ })
10
+
11
+ it('returns false for an empty v5 config block', () => {
12
+ expect(isV4Config({ platform: 'SwitchBot', devices: [] })).toBe(false)
13
+ })
14
+
15
+ it('returns false for a v5 config with credentials omitted', () => {
16
+ expect(isV4Config({ platform: 'SwitchBot', openApiToken: 'tok', openApiSecret: 'sec', devices: [] })).toBe(false)
17
+ })
18
+
19
+ it('returns true when a credentials sub-object is present (v4 token location)', () => {
20
+ const v4 = {
21
+ platform: 'SwitchBot',
22
+ credentials: { token: 'tok', secret: 'sec', notice: 'Keep your token a secret!' },
23
+ options: { devices: [] },
24
+ }
25
+ expect(isV4Config(v4)).toBe(true)
26
+ })
27
+
28
+ it('returns true when options.devices array is present without credentials (partial v4)', () => {
29
+ const partialV4 = {
30
+ platform: 'SwitchBot',
31
+ options: {
32
+ devices: [{ deviceId: 'aabbcc', configDeviceName: 'Curtain', configDeviceType: 'Curtain', connectionType: 'BLE' }],
33
+ },
34
+ }
35
+ expect(isV4Config(partialV4)).toBe(true)
36
+ })
37
+
38
+ it('returns true for the exact config from the bug report', () => {
39
+ const bugReportConfig = {
40
+ name: 'SwitchBot',
41
+ credentials: { notice: 'Keep your token a secret!' },
42
+ options: {
43
+ devices: [
44
+ {
45
+ deviceId: 'xxxxxxxx',
46
+ configDeviceName: 'Curtain',
47
+ configDeviceType: 'Curtain',
48
+ connectionType: 'BLE',
49
+ hide_lightsensor: false,
50
+ set_min: 96,
51
+ set_max: 12,
52
+ maxRetry: 0,
53
+ logging: 'standard',
54
+ },
55
+ ],
56
+ },
57
+ platform: 'SwitchBot',
58
+ }
59
+ expect(isV4Config(bugReportConfig)).toBe(true)
60
+ })
61
+ })
@@ -30,4 +30,54 @@ describe('matter integration platform', () => {
30
30
  const platform = new TestMatterPlatform(logger, config, api)
31
31
  expect(platform.getDevices()).toEqual([])
32
32
  })
33
+
34
+ it('should publish per-device external Matter accessories via the Homebridge external event', async () => {
35
+ const pendingExternalRegistrations = new Map()
36
+ const apiWithMatter: any = {
37
+ matter: {
38
+ uuid: { generate: (value: string) => `m-${value}` },
39
+ registerPlatformAccessories: vi.fn(async () => {}),
40
+ },
41
+ hap: { uuid: { generate: (value: string) => `h-${value}` } },
42
+ isMatterAvailable: () => true,
43
+ isMatterEnabled: () => true,
44
+ on: vi.fn(),
45
+ emit: null as any,
46
+ _pendingExternalRegistrations: pendingExternalRegistrations,
47
+ }
48
+ const emit = vi.fn((event: string, accessories: any[], registrationId: string) => {
49
+ if (event === 'publishExternalMatterAccessories') {
50
+ const resolve = pendingExternalRegistrations.get(registrationId)
51
+ resolve?.()
52
+ }
53
+ return true
54
+ })
55
+ apiWithMatter.emit = emit
56
+ const platform = new TestMatterPlatform(logger, config, apiWithMatter)
57
+ const created = {
58
+ createAccessory: vi.fn(async () => ({
59
+ name: 'Curtain3',
60
+ manufacturer: 'SwitchBot',
61
+ model: 'Curtain3',
62
+ serialNumber: 'serial-1',
63
+ deviceType: { deviceType: 514 },
64
+ clusters: { windowCovering: { currentPositionLiftPercent100ths: 0 } },
65
+ handlers: {},
66
+ })),
67
+ }
68
+
69
+ await platform.registerMatterAccessories([
70
+ {
71
+ created,
72
+ d: { id: 'device-1', name: 'Curtain3' },
73
+ type: 'curtain',
74
+ useMatter: true,
75
+ matterAvailable: true,
76
+ externalPublishProtocol: 'matter',
77
+ },
78
+ ])
79
+
80
+ expect(emit).toHaveBeenCalledWith('publishExternalMatterAccessories', expect.any(Array), expect.any(String))
81
+ expect(apiWithMatter.matter.registerPlatformAccessories).not.toHaveBeenCalled()
82
+ })
33
83
  })
@@ -5,6 +5,7 @@ import {
5
5
  MATTER_ATTRIBUTE_IDS,
6
6
  MATTER_CLUSTER_IDS,
7
7
  normalizeConfig,
8
+ resolveExternalPublishProtocol,
8
9
  } from '../../src/utils'
9
10
 
10
11
  describe('utils', () => {
@@ -50,4 +51,18 @@ describe('utils', () => {
50
51
  expect(HAP).toHaveBeenCalled()
51
52
  expect(Matter).not.toHaveBeenCalled()
52
53
  })
54
+
55
+ it('resolveExternalPublishProtocol normalizes known values and aliases', () => {
56
+ expect(resolveExternalPublishProtocol({ externalPublishProtocol: 'hap' })).toBe('hap')
57
+ expect(resolveExternalPublishProtocol({ externalPublishProtocol: 'matter' })).toBe('matter')
58
+ expect(resolveExternalPublishProtocol({ externalPublishProtocol: 'none' })).toBe('none')
59
+ expect(resolveExternalPublishProtocol({ externalPublishProtocol: 'homekit' })).toBe('hap')
60
+ expect(resolveExternalPublishProtocol({ publishExternal: true })).toBe('hap')
61
+ expect(resolveExternalPublishProtocol({ publishExternal: false })).toBe('none')
62
+ })
63
+
64
+ it('resolveExternalPublishProtocol defaults to none for unknown values', () => {
65
+ expect(resolveExternalPublishProtocol({ externalPublishProtocol: 'invalid' })).toBe('none')
66
+ expect(resolveExternalPublishProtocol({})).toBe('none')
67
+ })
53
68
  })