@switchbot/homebridge-switchbot 5.0.0-beta.17 → 5.0.0-beta.19

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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. This project uses [Semantic Versioning](https://semver.org/)
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ### What's Changed
8
+ - Matter platform: register only devices discovered via the SwitchBot OpenAPI by default. Per-device config overrides (by deviceId) are correctly merged into discovered devices.
9
+ - Add `options.allowConfigOnlyDevices` (boolean) to opt-in to registering devices that exist only in config (not discovered).
10
+ - Add unit tests for per-device merging and config-only behavior.
11
+
5
12
  ## [4.3.1](https://github.com/OpenWonderLabs/homebridge-switchbot/releases/tag/v4.3.1) (2025-03-04)
6
13
 
7
14
  # *No New Releases During Lent*
@@ -13077,6 +13077,11 @@
13077
13077
  }
13078
13078
  ]
13079
13079
  },
13080
+ "allowConfigOnlyDevices": {
13081
+ "title": "Allow Config-only Devices",
13082
+ "type": "boolean",
13083
+ "description": "If true, devices declared in options.devices that are not discovered by the SwitchBot OpenAPI will still be included (config-only devices). Default: false."
13084
+ },
13080
13085
  "allowInvalidCharacters": {
13081
13086
  "title": "Allow Invalid Characters",
13082
13087
  "type": "boolean",
@@ -14822,8 +14827,18 @@
14822
14827
  "key": "options.pushRate",
14823
14828
  "description": "<em class='primary-text'>Specifies the interval, in seconds, between pushes to the SwitchBot API.</em>"
14824
14829
  },
14825
- "options.logging",
14826
- "options.allowInvalidCharacters"
14830
+ {
14831
+ "key": "options.logging",
14832
+ "description": "<em class='primary-text'>Specifies the logging level for the plugin. Default: info.</em>"
14833
+ },
14834
+ {
14835
+ "key": "options.allowConfigOnlyDevices",
14836
+ "description": "<em class='primary-text'>If true, devices declared in options.devices that are not discovered by the SwitchBot OpenAPI will still be included (config-only devices). Default: false.</em>"
14837
+ },
14838
+ {
14839
+ "key": "options.allowInvalidCharacters",
14840
+ "description": "<em class='primary-text'>Allows device names with characters that are normally invalid in HomeKit. Use with caution, as this may lead to unexpected behavior.</em>"
14841
+ }
14827
14842
  ]
14828
14843
  }
14829
14844
  ]
@@ -1,12 +1,6 @@
1
1
  import type { API, DynamicPlatformPlugin, Logging, SerializedMatterAccessory } from 'homebridge';
2
2
  import type { bodyChange, device } from 'node-switchbot';
3
3
  import type { SwitchBotPlatformConfig } from './settings.js';
4
- /**
5
- * MatterPlatform
6
- * Demonstrates all available Matter device types in Homebridge
7
- *
8
- * Organized by official Matter Specification v1.4.1 categories
9
- */
10
4
  export declare class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
11
5
  readonly log: Logging;
12
6
  readonly config: SwitchBotPlatformConfig;
@@ -15,6 +9,8 @@ export declare class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
15
9
  private switchBotAPI?;
16
10
  private switchBotBLE?;
17
11
  private discoveredDevices;
12
+ private accessoryInstances;
13
+ private refreshTimers;
18
14
  private bleEventHandler;
19
15
  private platformLogging?;
20
16
  infoLog: (...args: any[]) => void;
@@ -68,6 +64,12 @@ export declare class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
68
64
  * Returns null when serviceData is falsy or parsing fails.
69
65
  */
70
66
  private parseAdvertisementForDevice;
67
+ /**
68
+ * Central helper to apply a SwitchBot status object to a Matter accessory.
69
+ * Tries to call accessory instance update helpers when available, otherwise
70
+ * falls back to calling api.matter.updateAccessoryState directly.
71
+ */
72
+ private applyStatusToAccessory;
71
73
  /**
72
74
  * Required for DynamicPlatformPlugin
73
75
  * Called when homebridge restores cached accessories from disk at startup
@@ -1 +1 @@
1
- {"version":3,"file":"platform-matter.d.ts","sourceRoot":"","sources":["../src/platform-matter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,GAAG,EACH,qBAAqB,EACrB,OAAO,EAEP,yBAAyB,EAC1B,MAAM,YAAY,CAAA;AACnB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAA;AAExD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAA;AA8B5D;;;;;GAKG;AACH,qBAAa,uBAAwB,YAAW,qBAAqB;aA8BjD,GAAG,EAAE,OAAO;aACZ,MAAM,EAAE,uBAAuB;aAC/B,GAAG,EAAE,GAAG;IA1B1B,SAAgB,iBAAiB,EAAE,GAAG,CAAC,MAAM,EAAE,yBAAyB,CAAC,CAAY;IAErF,OAAO,CAAC,YAAY,CAAC,CAAkB;IACvC,OAAO,CAAC,YAAY,CAAC,CAAc;IAEnC,OAAO,CAAC,iBAAiB,CAAe;IAExC,OAAO,CAAC,eAAe,CAA8C;IAErE,OAAO,CAAC,eAAe,CAAC,CAAS;IAGjC,OAAO,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAClC,UAAU,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IACrC,eAAe,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAC1C,OAAO,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAClC,YAAY,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IACvC,QAAQ,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IACnC,aAAa,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IACxC,QAAQ,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IACnC,cAAc,EAAG,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;IACvC,uBAAuB,EAAG,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;gBAG9B,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,uBAAuB,EAC/B,GAAG,EAAE,GAAG;IAwH1B;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAIzB;;;OAGG;IACH,OAAO,CAAC,eAAe;IAOvB;;;OAGG;YACW,sBAAsB;IAsCpC;;;OAGG;IACH,OAAO,CAAC,oBAAoB;IAW5B;;;OAGG;YACW,yBAAyB;IAiXvC;;OAEG;YACW,eAAe;IAwB7B;;OAEG;IACG,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,SAAI,EAAE,mBAAmB,SAAO,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,GAAG,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;IA0BzJ;;;OAGG;IACH,OAAO,CAAC,2BAA2B;IAwDnC;;;OAGG;IACH,kBAAkB;IAMlB;;;;;OAKG;IACH,wBAAwB,CAAC,SAAS,EAAE,yBAAyB;IAK7D;;OAEG;YACW,yBAAyB;IAwFvC;;OAEG;YACW,yBAAyB;IAqCvC;;OAEG;YACW,wBAAwB;IA8CtC;;OAEG;YACW,0BAA0B;IAsBxC;;OAEG;YACW,wBAAwB;IAsBtC;;OAEG;YACW,uBAAuB;IA0DrC;;OAEG;YACW,uBAAuB;IAkCrC;;OAEG;YACW,oBAAoB;IA4BlC;;;;;OAKG;YACW,wBAAwB;IAsBtC;;;;;;OAMG;YACW,qBAAqB;CAqBpC"}
1
+ {"version":3,"file":"platform-matter.d.ts","sourceRoot":"","sources":["../src/platform-matter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,GAAG,EACH,qBAAqB,EACrB,OAAO,EAEP,yBAAyB,EAC1B,MAAM,YAAY,CAAA;AACnB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAA;AAExD,OAAO,KAAK,EAAiB,uBAAuB,EAAE,MAAM,eAAe,CAAA;AA8B3E,qBAAa,uBAAwB,YAAW,qBAAqB;aAkCjD,GAAG,EAAE,OAAO;aACZ,MAAM,EAAE,uBAAuB;aAC/B,GAAG,EAAE,GAAG;IA9B1B,SAAgB,iBAAiB,EAAE,GAAG,CAAC,MAAM,EAAE,yBAAyB,CAAC,CAAY;IAErF,OAAO,CAAC,YAAY,CAAC,CAAkB;IACvC,OAAO,CAAC,YAAY,CAAC,CAAc;IAEnC,OAAO,CAAC,iBAAiB,CAAe;IAExC,OAAO,CAAC,kBAAkB,CAA8B;IAExD,OAAO,CAAC,aAAa,CAAyC;IAE9D,OAAO,CAAC,eAAe,CAA8C;IAErE,OAAO,CAAC,eAAe,CAAC,CAAS;IAGjC,OAAO,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAClC,UAAU,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IACrC,eAAe,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAC1C,OAAO,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAClC,YAAY,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IACvC,QAAQ,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IACnC,aAAa,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IACxC,QAAQ,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IACnC,cAAc,EAAG,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;IACvC,uBAAuB,EAAG,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;gBAG9B,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,uBAAuB,EAC/B,GAAG,EAAE,GAAG;IAwH1B;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAIzB;;;OAGG;IACH,OAAO,CAAC,eAAe;IAgBvB;;;OAGG;YACW,sBAAsB;IAuCpC;;;OAGG;IACH,OAAO,CAAC,oBAAoB;IAW5B;;;OAGG;YACW,yBAAyB;IA2gBvC;;OAEG;YACW,eAAe;IAwB7B;;OAEG;IACG,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,SAAI,EAAE,mBAAmB,SAAO,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,GAAG,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;IA0BzJ;;;OAGG;IACH,OAAO,CAAC,2BAA2B;IAwDnC;;;;OAIG;YACW,sBAAsB;IA6LpC;;;OAGG;IACH,kBAAkB;IAMlB;;;;;OAKG;IACH,wBAAwB,CAAC,SAAS,EAAE,yBAAyB;IAK7D;;OAEG;YACW,yBAAyB;IAwFvC;;OAEG;YACW,yBAAyB;IAqCvC;;OAEG;YACW,wBAAwB;IA8CtC;;OAEG;YACW,0BAA0B;IAsBxC;;OAEG;YACW,wBAAwB;IAsBtC;;OAEG;YACW,uBAAuB;IA0DrC;;OAEG;YACW,uBAAuB;IAkCrC;;OAEG;YACW,oBAAoB;IA4BlC;;;;;OAKG;YACW,wBAAwB;IAsBtC;;;;;;OAMG;YACW,qBAAqB;CAqBpC"}
@@ -2,12 +2,6 @@ import { SwitchBotBLE, SwitchBotOpenAPI } from 'node-switchbot';
2
2
  import { ColorLightAccessory, ColorTemperatureLightAccessory, ContactSensorAccessory, DimmableLightAccessory, DoorLockAccessory, ExtendedColorLightAccessory, FanAccessory, HumiditySensorAccessory, LeakSensorAccessory, LightSensorAccessory, OccupancySensorAccessory, OnOffLightAccessory, OnOffOutletAccessory, OnOffSwitchAccessory, PowerStripAccessory, RoboticVacuumAccessory, SmokeCOAlarmAccessory, TemperatureSensorAccessory, ThermostatAccessory, VenetianBlindAccessory, WindowBlindAccessory, } from './devices-matter/index.js';
3
3
  import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
4
4
  import { cleanDeviceConfig, createPlatformLogger, formatDeviceIdAsMac, hs2rgb, makeBLESender, makeOpenAPISender, rgb2hs, sleep } from './utils.js';
5
- /**
6
- * MatterPlatform
7
- * Demonstrates all available Matter device types in Homebridge
8
- *
9
- * Organized by official Matter Specification v1.4.1 categories
10
- */
11
5
  export class SwitchBotMatterPlatform {
12
6
  log;
13
7
  config;
@@ -22,6 +16,10 @@ export class SwitchBotMatterPlatform {
22
16
  switchBotBLE;
23
17
  // discovered devices cache
24
18
  discoveredDevices = [];
19
+ // Registry of created accessory instances keyed by normalized deviceId
20
+ accessoryInstances = new Map();
21
+ // Refresh timers keyed by normalized deviceId
22
+ refreshTimers = new Map();
25
23
  // BLE event handlers keyed by device MAC (formatted)
26
24
  bleEventHandler = {};
27
25
  // Platform logging toggle (can be controlled via UI or config)
@@ -170,10 +168,20 @@ export class SwitchBotMatterPlatform {
170
168
  * find matching item in a2 (discovered devices) and merge them with user overrides last.
171
169
  */
172
170
  mergeByDeviceId(a1, a2) {
173
- return a1.map((itm) => {
174
- const matchingItem = a2.find(item => this.normalizeDeviceId(item.deviceId) === this.normalizeDeviceId(itm.deviceId));
175
- return Object.assign({}, matchingItem, itm);
176
- });
171
+ const allowConfigOnly = Boolean(this.config.options?.allowConfigOnlyDevices);
172
+ const result = [];
173
+ for (const itm of (a1 || [])) {
174
+ const matchingItem = (a2 || []).find(item => this.normalizeDeviceId(item.deviceId) === this.normalizeDeviceId(itm.deviceId));
175
+ if (matchingItem) {
176
+ result.push(Object.assign({}, matchingItem, itm));
177
+ }
178
+ else if (allowConfigOnly) {
179
+ // include config-only device as-is when explicitly allowed
180
+ result.push(Object.assign({}, itm));
181
+ }
182
+ // otherwise skip config-only entries
183
+ }
184
+ return result;
177
185
  }
178
186
  /**
179
187
  * Merge discovered devices with deviceConfig (per deviceType) and per-device overrides
@@ -198,7 +206,8 @@ export class SwitchBotMatterPlatform {
198
206
  // For any entries in merged (which are based on config.options.devices), ensure final per-device merges include deviceId-specific config
199
207
  const final = [];
200
208
  for (const device of merged) {
201
- const deviceIdConfig = this.config.options?.devices?.[device.deviceId] || {};
209
+ // Find per-device config entry by deviceId (config.options.devices is an array)
210
+ const deviceIdConfig = (this.config.options?.devices || []).find((d) => this.normalizeDeviceId(d.deviceId) === this.normalizeDeviceId(device.deviceId)) || {};
202
211
  const deviceWithConfig = Object.assign({}, device, deviceIdConfig);
203
212
  final.push(deviceWithConfig);
204
213
  }
@@ -234,7 +243,7 @@ export class SwitchBotMatterPlatform {
234
243
  const displayName = dev.deviceName ?? dev.deviceId ?? 'SwitchBot Device';
235
244
  const serial = dev.deviceId ?? 'unknown';
236
245
  const manufacturer = 'SwitchBot';
237
- const model = dev.deviceType ?? 'SwitchBot';
246
+ const model = dev.model ?? dev.deviceType ?? 'SwitchBot';
238
247
  const firmware = dev.firmware ?? dev.version ?? '0.0.0';
239
248
  // Helper to build a default opts object consumed by the matter device classes
240
249
  const baseOpts = {
@@ -492,6 +501,15 @@ export class SwitchBotMatterPlatform {
492
501
  const opts = Object.assign({}, baseOpts, { handlers });
493
502
  // Instantiate the device class and return its serialized accessory
494
503
  const instance = new Ctor(this.api, this.log, opts);
504
+ // Save instance in registry so platform can call device-specific update methods if needed
505
+ try {
506
+ if (dev?.deviceId) {
507
+ this.accessoryInstances.set(this.normalizeDeviceId(dev.deviceId), instance);
508
+ }
509
+ }
510
+ catch (e) {
511
+ this.debugLog('Failed to register accessory instance: %s', e?.message ?? e);
512
+ }
495
513
  try {
496
514
  this.infoLog(`Initialized Matter accessory: ${displayName} (type=${dev.deviceType ?? 'Unknown'}) id=${dev.deviceId}`);
497
515
  }
@@ -523,6 +541,134 @@ export class SwitchBotMatterPlatform {
523
541
  const [h, s] = rgb2hs(r, g, b);
524
542
  await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.ColorControl, { currentHue: Math.round((h / 360) * 254), currentSaturation: Math.round((s / 100) * 254) });
525
543
  }
544
+ // Battery -> powerSource cluster (common mapping)
545
+ if (parsed.battery !== undefined) {
546
+ try {
547
+ const percentage = Number(parsed.battery);
548
+ const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)));
549
+ let batChargeLevel = 0;
550
+ if (percentage < 20) {
551
+ batChargeLevel = 2;
552
+ }
553
+ else if (percentage < 40) {
554
+ batChargeLevel = 1;
555
+ }
556
+ await this.api.matter.updateAccessoryState(uuidLocal, 'powerSource', { batPercentRemaining, batChargeLevel });
557
+ }
558
+ catch (e) {
559
+ this.debugLog(`Failed to update battery state for ${dev.deviceId}: ${e?.message ?? e}`);
560
+ }
561
+ }
562
+ // Temperature -> temperatureMeasurement
563
+ if (parsed.temperature !== undefined) {
564
+ try {
565
+ const c = Number(parsed.temperature);
566
+ const measured = Math.round(c * 100);
567
+ await this.api.matter.updateAccessoryState(uuidLocal, 'temperatureMeasurement', { measuredValue: measured });
568
+ }
569
+ catch (e) {
570
+ this.debugLog(`Failed to update temperature for ${dev.deviceId}: ${e?.message ?? e}`);
571
+ }
572
+ }
573
+ // Humidity -> relativeHumidityMeasurement
574
+ if (parsed.humidity !== undefined) {
575
+ try {
576
+ const percent = Number(parsed.humidity);
577
+ const measured = Math.round(percent * 100);
578
+ await this.api.matter.updateAccessoryState(uuidLocal, 'relativeHumidityMeasurement', { measuredValue: measured });
579
+ }
580
+ catch (e) {
581
+ this.debugLog(`Failed to update humidity for ${dev.deviceId}: ${e?.message ?? e}`);
582
+ }
583
+ }
584
+ // Contact / Leak -> BooleanState
585
+ if (parsed.contact !== undefined || parsed.leak !== undefined) {
586
+ try {
587
+ // Some devices report contact as true=open; ContactSensor expects inverted value
588
+ const isContactOpen = parsed.contact === undefined ? undefined : Boolean(parsed.contact);
589
+ const leakDetected = parsed.leak === undefined ? undefined : Boolean(parsed.leak);
590
+ if (isContactOpen !== undefined) {
591
+ // If this is a contact sensor device type, invert; otherwise set conservatively
592
+ if ((dev.deviceType || '').includes('Contact')) {
593
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: !isContactOpen });
594
+ }
595
+ else {
596
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: isContactOpen });
597
+ }
598
+ }
599
+ if (leakDetected !== undefined) {
600
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: leakDetected });
601
+ }
602
+ }
603
+ catch (e) {
604
+ this.debugLog(`Failed to update contact/leak for ${dev.deviceId}: ${e?.message ?? e}`);
605
+ }
606
+ }
607
+ // Motion -> occupancy
608
+ if (parsed.motion !== undefined) {
609
+ try {
610
+ await this.api.matter.updateAccessoryState(uuidLocal, 'occupancySensing', { occupancy: { occupied: Boolean(parsed.motion) } });
611
+ }
612
+ catch (e) {
613
+ this.debugLog(`Failed to update occupancy for ${dev.deviceId}: ${e?.message ?? e}`);
614
+ }
615
+ }
616
+ // Lock state -> doorLock
617
+ if (parsed.lock !== undefined) {
618
+ try {
619
+ const s = String(parsed.lock).toLowerCase();
620
+ let lockState = 0;
621
+ if (s === 'locked' || s === '1' || s === 'true') {
622
+ lockState = 1;
623
+ }
624
+ else if (s === 'unlocked' || s === '0' || s === 'false') {
625
+ lockState = 2;
626
+ }
627
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.DoorLock, { lockState });
628
+ }
629
+ catch (e) {
630
+ this.debugLog(`Failed to update lock for ${dev.deviceId}: ${e?.message ?? e}`);
631
+ }
632
+ }
633
+ // Position / Cover -> WindowCovering (convert open percent to closed*100)
634
+ if (parsed.position !== undefined) {
635
+ try {
636
+ const openPercent = Number(parsed.position);
637
+ const closedPercent = 100 - Math.max(0, Math.min(100, openPercent));
638
+ const value = Math.round(closedPercent * 100);
639
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.WindowCovering, { currentPositionLiftPercent100ths: value, targetPositionLiftPercent100ths: value });
640
+ }
641
+ catch (e) {
642
+ this.debugLog(`Failed to update cover position for ${dev.deviceId}: ${e?.message ?? e}`);
643
+ }
644
+ }
645
+ // Fan speed -> FanControl
646
+ if (parsed.fanSpeed !== undefined) {
647
+ try {
648
+ const percent = Number(parsed.fanSpeed);
649
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.FanControl, { percentSetting: percent, percentCurrent: percent });
650
+ }
651
+ catch (e) {
652
+ this.debugLog(`Failed to update fan speed for ${dev.deviceId}: ${e?.message ?? e}`);
653
+ }
654
+ }
655
+ // Robot vacuum fields (battery already handled) - update run/operational/clean modes when present
656
+ if (parsed.rvcRunMode !== undefined) {
657
+ try {
658
+ await this.api.matter.updateAccessoryState(uuidLocal, 'rvcRunMode', { currentMode: Number(parsed.rvcRunMode) });
659
+ }
660
+ catch (e) {
661
+ this.debugLog(`Failed to update rvcRunMode for ${dev.deviceId}: ${e?.message ?? e}`);
662
+ }
663
+ }
664
+ if (parsed.rvcOperationalState !== undefined) {
665
+ try {
666
+ await this.api.matter.updateAccessoryState(uuidLocal, 'rvcOperationalState', { operationalState: Number(parsed.rvcOperationalState) });
667
+ }
668
+ catch (e) {
669
+ this.debugLog(`Failed to update rvcOperationalState for ${dev.deviceId}: ${e?.message ?? e}`);
670
+ }
671
+ }
526
672
  // If we parsed something from serviceData prefer it and return early
527
673
  if (serviceData) {
528
674
  return;
@@ -544,35 +690,8 @@ export class SwitchBotMatterPlatform {
544
690
  const respAny = response;
545
691
  const body = respAny?.body ?? respAny;
546
692
  const status = body?.status ?? body;
547
- // On/Off
548
- if (status?.power !== undefined) {
549
- const on = (String(status.power).toLowerCase() === 'on' || Number(status.power) === 1);
550
- await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.OnOff, { onOff: on });
551
- }
552
- // Brightness
553
- if (status?.brightness !== undefined) {
554
- const level = Math.round((Number(status.brightness) / 100) * 254);
555
- await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.LevelControl, { currentLevel: level });
556
- }
557
- // Color
558
- if (status?.color !== undefined) {
559
- const color = String(status.color);
560
- let r = 0;
561
- let g = 0;
562
- let b = 0;
563
- if (color.includes(':')) {
564
- const parts = color.split(':').map(Number);
565
- [r, g, b] = parts;
566
- }
567
- else if (color.startsWith('#')) {
568
- const hex = color.replace('#', '');
569
- r = Number.parseInt(hex.substring(0, 2), 16);
570
- g = Number.parseInt(hex.substring(2, 4), 16);
571
- b = Number.parseInt(hex.substring(4, 6), 16);
572
- }
573
- const [h, s] = rgb2hs(r, g, b);
574
- await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.ColorControl, { currentHue: Math.round((h / 360) * 254), currentSaturation: Math.round((s / 100) * 254) });
575
- }
693
+ // Use centralized mapper which prefers accessory instance update helpers
694
+ await this.applyStatusToAccessory(uuidLocal, dev, status);
576
695
  }
577
696
  catch (e) {
578
697
  this.debugLog(`BLE push handler failed for ${dev.deviceId}: ${e?.message ?? e}`);
@@ -582,6 +701,53 @@ export class SwitchBotMatterPlatform {
582
701
  catch (e) {
583
702
  this.debugLog(`Failed to register BLE handler for ${dev.deviceId}: ${e?.message ?? e}`);
584
703
  }
704
+ // Schedule periodic OpenAPI refreshes for this device (if OpenAPI is configured)
705
+ try {
706
+ const nid = this.normalizeDeviceId(dev.deviceId);
707
+ // Clear any existing timer for this device
708
+ const existing = this.refreshTimers.get(nid);
709
+ if (existing) {
710
+ clearInterval(existing);
711
+ this.refreshTimers.delete(nid);
712
+ }
713
+ const refreshRateSec = dev.refreshRate ?? this.config.options?.refreshRate ?? 300;
714
+ if (this.switchBotAPI && refreshRateSec && Number(refreshRateSec) > 0) {
715
+ // Immediate one-shot to populate initial state
716
+ ;
717
+ (async () => {
718
+ try {
719
+ const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret);
720
+ if (statusCode === 100 || statusCode === 200) {
721
+ const respAny = response;
722
+ const body = respAny?.body ?? respAny;
723
+ const status = body?.status ?? body;
724
+ await this.applyStatusToAccessory(uuid, dev, status);
725
+ }
726
+ }
727
+ catch (e) {
728
+ this.debugLog(`Initial OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`);
729
+ }
730
+ })();
731
+ const timer = setInterval(async () => {
732
+ try {
733
+ const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret);
734
+ if (statusCode === 100 || statusCode === 200) {
735
+ const respAny = response;
736
+ const body = respAny?.body ?? respAny;
737
+ const status = body?.status ?? body;
738
+ await this.applyStatusToAccessory(uuid, dev, status);
739
+ }
740
+ }
741
+ catch (e) {
742
+ this.debugLog(`Periodic OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`);
743
+ }
744
+ }, Number(refreshRateSec) * 1000);
745
+ this.refreshTimers.set(nid, timer);
746
+ }
747
+ }
748
+ catch (e) {
749
+ this.debugLog(`Failed to schedule refresh for ${dev.deviceId}: ${e?.message ?? e}`);
750
+ }
585
751
  return instance.toAccessory();
586
752
  }
587
753
  /**
@@ -689,6 +855,203 @@ export class SwitchBotMatterPlatform {
689
855
  return null;
690
856
  }
691
857
  }
858
+ /**
859
+ * Central helper to apply a SwitchBot status object to a Matter accessory.
860
+ * Tries to call accessory instance update helpers when available, otherwise
861
+ * falls back to calling api.matter.updateAccessoryState directly.
862
+ */
863
+ async applyStatusToAccessory(uuidLocal, dev, status) {
864
+ if (!status) {
865
+ return;
866
+ }
867
+ const instance = dev?.deviceId ? this.accessoryInstances.get(this.normalizeDeviceId(dev.deviceId)) : undefined;
868
+ // Helper to safely call instance methods or fallback to api.matter.updateAccessoryState
869
+ const safeUpdate = async (cluster, attributes, methodName) => {
870
+ try {
871
+ if (instance && methodName && typeof instance[methodName] === 'function') {
872
+ // prefer device-specific update helpers when available
873
+ await instance[methodName](...(Object.values(attributes)));
874
+ }
875
+ else if (instance && typeof instance.updateState === 'function') {
876
+ // some accessories expose updateState that accepts cluster and attributes
877
+ await instance.updateState(cluster, attributes);
878
+ }
879
+ else {
880
+ await this.api.matter.updateAccessoryState(uuidLocal, cluster, attributes);
881
+ }
882
+ }
883
+ catch (e) {
884
+ this.debugLog(`safeUpdate failed for ${dev.deviceId} cluster=${cluster}: ${e?.message ?? e}`);
885
+ }
886
+ };
887
+ try {
888
+ // On/Off
889
+ if (status?.power !== undefined) {
890
+ const on = (String(status.power).toLowerCase() === 'on' || Number(status.power) === 1 || Boolean(status.power) === true);
891
+ await safeUpdate(this.api.matter.clusterNames.OnOff, { onOff: on }, 'updateOnOffState');
892
+ }
893
+ // Brightness
894
+ if (status?.brightness !== undefined) {
895
+ const level = Math.round((Number(status.brightness) / 100) * 254);
896
+ await safeUpdate(this.api.matter.clusterNames.LevelControl, { currentLevel: level }, 'updateBrightness');
897
+ }
898
+ // Color
899
+ if (status?.color !== undefined) {
900
+ const color = String(status.color);
901
+ let r = 0;
902
+ let g = 0;
903
+ let b = 0;
904
+ if (color.includes(':')) {
905
+ const parts = color.split(':').map(Number);
906
+ [r, g, b] = parts;
907
+ }
908
+ else if (color.startsWith('#')) {
909
+ const hex = color.replace('#', '');
910
+ r = Number.parseInt(hex.substring(0, 2), 16);
911
+ g = Number.parseInt(hex.substring(2, 4), 16);
912
+ b = Number.parseInt(hex.substring(4, 6), 16);
913
+ }
914
+ const [h, s] = rgb2hs(r, g, b);
915
+ await safeUpdate(this.api.matter.clusterNames.ColorControl, { currentHue: Math.round((h / 360) * 254), currentSaturation: Math.round((s / 100) * 254) }, 'updateHueSaturation');
916
+ }
917
+ // Battery/powerSource
918
+ if (status?.battery !== undefined || status?.batt !== undefined) {
919
+ try {
920
+ const percentage = Number(status?.battery ?? status?.batt);
921
+ const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)));
922
+ let batChargeLevel = 0;
923
+ if (percentage < 20) {
924
+ batChargeLevel = 2;
925
+ }
926
+ else if (percentage < 40) {
927
+ batChargeLevel = 1;
928
+ }
929
+ await safeUpdate('powerSource', { batPercentRemaining, batChargeLevel }, 'updateBatteryPercentage');
930
+ }
931
+ catch (e) {
932
+ this.debugLog(`Failed to apply battery status for ${dev.deviceId}: ${e?.message ?? e}`);
933
+ }
934
+ }
935
+ // Temperature + thermostat
936
+ if (status?.temperature !== undefined || status?.temp !== undefined) {
937
+ try {
938
+ const c = Number(status?.temperature ?? status?.temp);
939
+ const measured = Math.round(c * 100);
940
+ await safeUpdate('temperatureMeasurement', { measuredValue: measured }, 'updateTemperature');
941
+ // Thermostat-specific mapping
942
+ if (status?.targetTemp !== undefined || status?.targetTemperature !== undefined || status?.heatingSetpoint !== undefined) {
943
+ const target = Number(status?.targetTemp ?? status?.targetTemperature ?? status?.heatingSetpoint);
944
+ const val = Math.round(target * 100);
945
+ await safeUpdate('thermostat', { occupiedHeatingSetpoint: val }, 'updateHeatingSetpoint');
946
+ }
947
+ }
948
+ catch (e) {
949
+ this.debugLog(`Failed to apply temperature for ${dev.deviceId}: ${e?.message ?? e}`);
950
+ }
951
+ }
952
+ // Humidity
953
+ if (status?.humidity !== undefined || status?.h !== undefined) {
954
+ try {
955
+ const percent = Number(status?.humidity ?? status?.h);
956
+ const measured = Math.round(percent * 100);
957
+ await safeUpdate('relativeHumidityMeasurement', { measuredValue: measured }, 'updateHumidity');
958
+ }
959
+ catch (e) {
960
+ this.debugLog(`Failed to apply humidity for ${dev.deviceId}: ${e?.message ?? e}`);
961
+ }
962
+ }
963
+ // Contact / Leak -> BooleanState
964
+ if (status?.contact !== undefined || status?.open !== undefined || status?.leak !== undefined || status?.water !== undefined) {
965
+ try {
966
+ const isContactOpen = status?.contact ?? status?.open;
967
+ if (isContactOpen !== undefined) {
968
+ if ((dev.deviceType || '').includes('Contact')) {
969
+ await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: !(String(isContactOpen).toLowerCase() === 'true' || Number(isContactOpen) === 1) }, 'updateContactState');
970
+ }
971
+ else {
972
+ await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: (String(isContactOpen).toLowerCase() === 'true' || Number(isContactOpen) === 1) }, 'updateContactState');
973
+ }
974
+ }
975
+ const leakDetected = status?.leak ?? status?.water;
976
+ if (leakDetected !== undefined) {
977
+ await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: Boolean(leakDetected) }, 'updateLeakState');
978
+ }
979
+ }
980
+ catch (e) {
981
+ this.debugLog(`Failed to apply contact/leak for ${dev.deviceId}: ${e?.message ?? e}`);
982
+ }
983
+ }
984
+ // Motion -> occupancy
985
+ if (status?.motion !== undefined || status?.m !== undefined) {
986
+ try {
987
+ const detected = Boolean(status?.motion ?? status?.m);
988
+ await safeUpdate('occupancySensing', { occupancy: { occupied: detected } }, 'updateOccupancy');
989
+ }
990
+ catch (e) {
991
+ this.debugLog(`Failed to apply motion for ${dev.deviceId}: ${e?.message ?? e}`);
992
+ }
993
+ }
994
+ // Lock state
995
+ if (status?.lock !== undefined) {
996
+ try {
997
+ const s = String(status.lock).toLowerCase();
998
+ let lockState = 0;
999
+ if (s === 'locked' || s === '1' || s === 'true') {
1000
+ lockState = 1;
1001
+ }
1002
+ else if (s === 'unlocked' || s === '0' || s === 'false') {
1003
+ lockState = 2;
1004
+ }
1005
+ await safeUpdate(this.api.matter.clusterNames.DoorLock, { lockState }, 'updateLockState');
1006
+ }
1007
+ catch (e) {
1008
+ this.debugLog(`Failed to apply lock for ${dev.deviceId}: ${e?.message ?? e}`);
1009
+ }
1010
+ }
1011
+ // Cover position
1012
+ if (status?.position !== undefined || status?.percent !== undefined) {
1013
+ try {
1014
+ const openPercent = Number(status?.position ?? status?.percent);
1015
+ const closedPercent = 100 - Math.max(0, Math.min(100, openPercent));
1016
+ const value = Math.round(closedPercent * 100);
1017
+ await safeUpdate(this.api.matter.clusterNames.WindowCovering, { currentPositionLiftPercent100ths: value, targetPositionLiftPercent100ths: value }, 'updateLiftPosition');
1018
+ }
1019
+ catch (e) {
1020
+ this.debugLog(`Failed to apply cover position for ${dev.deviceId}: ${e?.message ?? e}`);
1021
+ }
1022
+ }
1023
+ // Fan
1024
+ if (status?.fanSpeed !== undefined || status?.speed !== undefined) {
1025
+ try {
1026
+ const percent = Number(status?.fanSpeed ?? status?.speed);
1027
+ await safeUpdate(this.api.matter.clusterNames.FanControl, { percentSetting: percent, percentCurrent: percent }, 'updateFanSpeed');
1028
+ }
1029
+ catch (e) {
1030
+ this.debugLog(`Failed to apply fan speed for ${dev.deviceId}: ${e?.message ?? e}`);
1031
+ }
1032
+ }
1033
+ // Robot vacuum: run/operational/clean modes
1034
+ if (status?.rvcRunMode !== undefined) {
1035
+ try {
1036
+ await safeUpdate('rvcRunMode', { currentMode: Number(status.rvcRunMode) }, 'updateRunMode');
1037
+ }
1038
+ catch (e) {
1039
+ this.debugLog(`Failed to apply rvcRunMode for ${dev.deviceId}: ${e?.message ?? e}`);
1040
+ }
1041
+ }
1042
+ if (status?.rvcOperationalState !== undefined) {
1043
+ try {
1044
+ await safeUpdate('rvcOperationalState', { operationalState: Number(status.rvcOperationalState) }, 'updateOperationalState');
1045
+ }
1046
+ catch (e) {
1047
+ this.debugLog(`Failed to apply rvcOperationalState for ${dev.deviceId}: ${e?.message ?? e}`);
1048
+ }
1049
+ }
1050
+ }
1051
+ catch (e) {
1052
+ this.debugLog(`applyStatusToAccessory top-level failure for ${dev.deviceId}: ${e?.message ?? e}`);
1053
+ }
1054
+ }
692
1055
  /**
693
1056
  * Required for DynamicPlatformPlugin
694
1057
  * Called when homebridge restores cached accessories from disk at startup