@switchbot/homebridge-switchbot 5.0.0-beta.4 → 5.0.0-beta.40

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 (162) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +23 -3
  3. package/config.schema.json +722 -13684
  4. package/dist/devices-hap/device.d.ts +18 -8
  5. package/dist/devices-hap/device.d.ts.map +1 -1
  6. package/dist/devices-hap/device.js +121 -68
  7. package/dist/devices-hap/device.js.map +1 -1
  8. package/dist/devices-matter/BaseMatterAccessory.d.ts +27 -0
  9. package/dist/devices-matter/BaseMatterAccessory.d.ts.map +1 -1
  10. package/dist/devices-matter/BaseMatterAccessory.js +169 -5
  11. package/dist/devices-matter/BaseMatterAccessory.js.map +1 -1
  12. package/dist/devices-matter/ColorLightAccessory.d.ts.map +1 -1
  13. package/dist/devices-matter/ColorLightAccessory.js +12 -12
  14. package/dist/devices-matter/ColorLightAccessory.js.map +1 -1
  15. package/dist/devices-matter/ColorTemperatureLightAccessory.d.ts.map +1 -1
  16. package/dist/devices-matter/ColorTemperatureLightAccessory.js +5 -7
  17. package/dist/devices-matter/ColorTemperatureLightAccessory.js.map +1 -1
  18. package/dist/devices-matter/DimmableLightAccessory.js +9 -9
  19. package/dist/devices-matter/DimmableLightAccessory.js.map +1 -1
  20. package/dist/devices-matter/ExtendedColorLightAccessory.d.ts.map +1 -1
  21. package/dist/devices-matter/ExtendedColorLightAccessory.js +14 -15
  22. package/dist/devices-matter/ExtendedColorLightAccessory.js.map +1 -1
  23. package/dist/devices-matter/OnOffLightAccessory.d.ts.map +1 -1
  24. package/dist/devices-matter/OnOffLightAccessory.js +8 -16
  25. package/dist/devices-matter/OnOffLightAccessory.js.map +1 -1
  26. package/dist/devices-matter/OnOffOutletAccessory.d.ts +2 -0
  27. package/dist/devices-matter/OnOffOutletAccessory.d.ts.map +1 -1
  28. package/dist/devices-matter/OnOffOutletAccessory.js +10 -7
  29. package/dist/devices-matter/OnOffOutletAccessory.js.map +1 -1
  30. package/dist/devices-matter/OnOffSwitchAccessory.js +2 -2
  31. package/dist/devices-matter/OnOffSwitchAccessory.js.map +1 -1
  32. package/dist/homebridge-ui/public/index.html +48 -1
  33. package/dist/homebridge-ui/server.js +53 -8
  34. package/dist/homebridge-ui/server.js.map +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +4 -7
  37. package/dist/index.js.map +1 -1
  38. package/dist/irdevice/irdevice.d.ts +11 -10
  39. package/dist/irdevice/irdevice.d.ts.map +1 -1
  40. package/dist/irdevice/irdevice.js +76 -35
  41. package/dist/irdevice/irdevice.js.map +1 -1
  42. package/dist/platform-hap.d.ts +21 -15
  43. package/dist/platform-hap.d.ts.map +1 -1
  44. package/dist/platform-hap.js +246 -147
  45. package/dist/platform-hap.js.map +1 -1
  46. package/dist/platform-matter.d.ts +88 -6
  47. package/dist/platform-matter.d.ts.map +1 -1
  48. package/dist/platform-matter.js +1726 -243
  49. package/dist/platform-matter.js.map +1 -1
  50. package/dist/settings.d.ts +41 -6
  51. package/dist/settings.d.ts.map +1 -1
  52. package/dist/settings.js.map +1 -1
  53. package/dist/test/hap/platform-hap.logging.test.d.ts +2 -0
  54. package/dist/test/hap/platform-hap.logging.test.d.ts.map +1 -0
  55. package/dist/test/hap/platform-hap.logging.test.js +33 -0
  56. package/dist/test/hap/platform-hap.logging.test.js.map +1 -0
  57. package/dist/test/hap/platform-hap.test.d.ts +2 -0
  58. package/dist/test/hap/platform-hap.test.d.ts.map +1 -0
  59. package/dist/test/hap/platform-hap.test.js +62 -0
  60. package/dist/test/hap/platform-hap.test.js.map +1 -0
  61. package/dist/test/helpers/platform-fixtures.d.ts +9 -0
  62. package/dist/test/helpers/platform-fixtures.d.ts.map +1 -0
  63. package/dist/test/helpers/platform-fixtures.js +30 -0
  64. package/dist/test/helpers/platform-fixtures.js.map +1 -0
  65. package/dist/{index.test.d.ts.map → test/index.test.d.ts.map} +1 -1
  66. package/dist/test/index.test.js +19 -0
  67. package/dist/test/index.test.js.map +1 -0
  68. package/dist/test/matter/devices-matter/baseMatterAccessory.test.d.ts +2 -0
  69. package/dist/test/matter/devices-matter/baseMatterAccessory.test.d.ts.map +1 -0
  70. package/dist/test/matter/devices-matter/baseMatterAccessory.test.js +71 -0
  71. package/dist/test/matter/devices-matter/baseMatterAccessory.test.js.map +1 -0
  72. package/dist/test/matter/platform-matter.additional.test.d.ts +2 -0
  73. package/dist/test/matter/platform-matter.additional.test.d.ts.map +1 -0
  74. package/dist/test/matter/platform-matter.additional.test.js +35 -0
  75. package/dist/test/matter/platform-matter.additional.test.js.map +1 -0
  76. package/dist/test/matter/platform-matter.bleparse.test.d.ts +2 -0
  77. package/dist/test/matter/platform-matter.bleparse.test.d.ts.map +1 -0
  78. package/dist/test/matter/platform-matter.bleparse.test.js +43 -0
  79. package/dist/test/matter/platform-matter.bleparse.test.js.map +1 -0
  80. package/dist/test/matter/platform-matter.cleanup.test.d.ts +2 -0
  81. package/dist/test/matter/platform-matter.cleanup.test.d.ts.map +1 -0
  82. package/dist/test/matter/platform-matter.cleanup.test.js +70 -0
  83. package/dist/test/matter/platform-matter.cleanup.test.js.map +1 -0
  84. package/dist/test/matter/platform-matter.keepstale.test.d.ts +2 -0
  85. package/dist/test/matter/platform-matter.keepstale.test.d.ts.map +1 -0
  86. package/dist/test/matter/platform-matter.keepstale.test.js +27 -0
  87. package/dist/test/matter/platform-matter.keepstale.test.js.map +1 -0
  88. package/dist/test/matter/platform-matter.logging.test.d.ts +2 -0
  89. package/dist/test/matter/platform-matter.logging.test.d.ts.map +1 -0
  90. package/dist/test/matter/platform-matter.logging.test.js +29 -0
  91. package/dist/test/matter/platform-matter.logging.test.js.map +1 -0
  92. package/dist/test/matter/platform-matter.mapping.test.d.ts +2 -0
  93. package/dist/test/matter/platform-matter.mapping.test.d.ts.map +1 -0
  94. package/dist/test/matter/platform-matter.mapping.test.js +43 -0
  95. package/dist/test/matter/platform-matter.mapping.test.js.map +1 -0
  96. package/dist/test/matter/platform-matter.openapi-mapping.test.d.ts +2 -0
  97. package/dist/test/matter/platform-matter.openapi-mapping.test.d.ts.map +1 -0
  98. package/dist/test/matter/platform-matter.openapi-mapping.test.js +84 -0
  99. package/dist/test/matter/platform-matter.openapi-mapping.test.js.map +1 -0
  100. package/dist/test/matter/platform-matter.test.d.ts +2 -0
  101. package/dist/test/matter/platform-matter.test.d.ts.map +1 -0
  102. package/dist/test/matter/platform-matter.test.js +117 -0
  103. package/dist/test/matter/platform-matter.test.js.map +1 -0
  104. package/dist/test/matter/platform-matter.unregister.test.d.ts +2 -0
  105. package/dist/test/matter/platform-matter.unregister.test.d.ts.map +1 -0
  106. package/dist/test/matter/platform-matter.unregister.test.js +30 -0
  107. package/dist/test/matter/platform-matter.unregister.test.js.map +1 -0
  108. package/dist/test/utils.test.d.ts +2 -0
  109. package/dist/test/utils.test.d.ts.map +1 -0
  110. package/dist/test/utils.test.js +95 -0
  111. package/dist/test/utils.test.js.map +1 -0
  112. package/dist/test/verifyconfig.test.d.ts.map +1 -0
  113. package/dist/{verifyconfig.test.js → test/verifyconfig.test.js} +2 -2
  114. package/dist/test/verifyconfig.test.js.map +1 -0
  115. package/dist/utils.d.ts +196 -3
  116. package/dist/utils.d.ts.map +1 -1
  117. package/dist/utils.js +656 -30
  118. package/dist/utils.js.map +1 -1
  119. package/docs/assets/main.js +2 -2
  120. package/docs/index.html +20 -2
  121. package/docs/variables/default.html +1 -1
  122. package/package.json +14 -14
  123. package/src/devices-hap/device.ts +129 -69
  124. package/src/devices-matter/BaseMatterAccessory.ts +176 -5
  125. package/src/devices-matter/ColorLightAccessory.ts +12 -12
  126. package/src/devices-matter/ColorTemperatureLightAccessory.ts +5 -7
  127. package/src/devices-matter/DimmableLightAccessory.ts +9 -9
  128. package/src/devices-matter/ExtendedColorLightAccessory.ts +14 -15
  129. package/src/devices-matter/OnOffLightAccessory.ts +8 -16
  130. package/src/devices-matter/OnOffOutletAccessory.ts +12 -7
  131. package/src/devices-matter/OnOffSwitchAccessory.ts +2 -2
  132. package/src/homebridge-ui/public/index.html +48 -1
  133. package/src/homebridge-ui/server.ts +55 -8
  134. package/src/index.ts +4 -7
  135. package/src/irdevice/irdevice.ts +74 -35
  136. package/src/platform-hap.ts +270 -160
  137. package/src/platform-matter.ts +1768 -240
  138. package/src/settings.ts +45 -2
  139. package/src/test/hap/platform-hap.logging.test.ts +36 -0
  140. package/src/test/hap/platform-hap.test.ts +70 -0
  141. package/src/test/helpers/platform-fixtures.ts +33 -0
  142. package/src/test/index.test.ts +24 -0
  143. package/src/test/matter/devices-matter/baseMatterAccessory.test.ts +88 -0
  144. package/src/test/matter/platform-matter.additional.test.ts +44 -0
  145. package/src/test/matter/platform-matter.bleparse.test.ts +47 -0
  146. package/src/test/matter/platform-matter.cleanup.test.ts +86 -0
  147. package/src/test/matter/platform-matter.keepstale.test.ts +37 -0
  148. package/src/test/matter/platform-matter.logging.test.ts +33 -0
  149. package/src/test/matter/platform-matter.mapping.test.ts +57 -0
  150. package/src/test/matter/platform-matter.openapi-mapping.test.ts +109 -0
  151. package/src/test/matter/platform-matter.test.ts +144 -0
  152. package/src/test/matter/platform-matter.unregister.test.ts +39 -0
  153. package/src/test/utils.test.ts +96 -0
  154. package/src/{verifyconfig.test.ts → test/verifyconfig.test.ts} +12 -11
  155. package/src/utils.ts +714 -32
  156. package/dist/index.test.js +0 -14
  157. package/dist/index.test.js.map +0 -1
  158. package/dist/verifyconfig.test.d.ts.map +0 -1
  159. package/dist/verifyconfig.test.js.map +0 -1
  160. package/src/index.test.ts +0 -19
  161. /package/dist/{index.test.d.ts → test/index.test.d.ts} +0 -0
  162. /package/dist/{verifyconfig.test.d.ts → test/verifyconfig.test.d.ts} +0 -0
package/src/utils.ts CHANGED
@@ -1,10 +1,14 @@
1
+ import type { API, Logging } from 'homebridge'
2
+ import type { blindTilt, curtain, curtain3, device } from 'node-switchbot'
3
+
4
+ import type { devicesConfig } from './settings.js'
5
+
1
6
  /* Copyright(C) 2017-2024, donavanbecker (https://github.com/donavanbecker). All rights reserved.
2
7
  *
3
8
  * util.ts: @switchbot/homebridge-switchbot platform class.
4
9
  */
5
- import type { blindTilt, curtain, curtain3, device } from 'node-switchbot'
6
-
7
- import type { devicesConfig } from './settings.js'
10
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
8
12
 
9
13
  export enum BlindTiltMappingMode {
10
14
  OnlyUp = 'only_up',
@@ -669,50 +673,728 @@ export function m2hs(m) {
669
673
  }
670
674
 
671
675
  /**
672
- * Remove deviceConfig entries that contain only default/empty values.
673
- * Returns undefined if nothing meaningful remains.
676
+ * Factory that returns a function to send OpenAPI commands using a retry wrapper.
677
+ *
678
+ * @param retryCommandFunc - bound function that calls platform.retryCommand(device, bodyChange, maxRetries, delay)
679
+ * @param deviceObj - the device object to operate on
680
+ * @param opts - optional overrides for maxRetries and delayBetweenRetries
681
+ * @param opts.maxRetries - override for maxRetries
682
+ * @param opts.delayBetweenRetries - override for delayBetweenRetries
683
+ */
684
+ export function makeOpenAPISender(retryCommandFunc: any, deviceObj: any, opts?: { maxRetries?: number, delayBetweenRetries?: number }) {
685
+ return async (command: string, parameter = 'default') => {
686
+ const bodyChange: any = { command, parameter, commandType: 'command' }
687
+ return retryCommandFunc(deviceObj, bodyChange, opts?.maxRetries, opts?.delayBetweenRetries)
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Factory that returns a function to perform BLE actions using a SwitchBotBLE client.
693
+ * Handles discovery retries and method invocation on the discovered device instance.
694
+ *
695
+ * @param switchBotBLE - instance of SwitchBotBLE (may be undefined)
696
+ * @param deviceObj - the device object (used to obtain bleModel/deviceId)
697
+ * @param opts - optional retry settings
698
+ * @param opts.bleRetries - number of BLE discovery retries
699
+ * @param opts.bleRetryDelay - delay between BLE retries in ms
700
+ */
701
+ export function makeBLESender(switchBotBLE: any, deviceObj: any, opts?: { bleRetries?: number, bleRetryDelay?: number }) {
702
+ return async (methodName: string, ...args: any[]) => {
703
+ if (!switchBotBLE) {
704
+ throw new Error('Platform BLE not available')
705
+ }
706
+ const id = formatDeviceIdAsMac(deviceObj.deviceId)
707
+ const maxRetries = opts?.bleRetries ?? 2
708
+ const retryDelay = opts?.bleRetryDelay ?? 500
709
+ let attempt = 0
710
+ while (attempt < maxRetries) {
711
+ try {
712
+ const list = await switchBotBLE.discover({ model: (deviceObj as any).bleModel, id })
713
+ if (!Array.isArray(list) || list.length === 0) {
714
+ throw new Error('BLE device not found')
715
+ }
716
+ const deviceInst: any = list[0]
717
+ if (typeof deviceInst[methodName] !== 'function') {
718
+ throw new TypeError(`BLE method ${methodName} not available on device`)
719
+ }
720
+ return await deviceInst[methodName](...args)
721
+ } catch (e: any) {
722
+ attempt++
723
+ if (attempt >= maxRetries) {
724
+ throw e
725
+ }
726
+ await sleep(retryDelay)
727
+ }
728
+ }
729
+ throw new Error('BLE operation failed')
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Decide effective connection type for a device given platform options.
735
+ * Mirrors the logic previously in platform-matter.
736
+ */
737
+ export function chooseConnectionType(platformOptions: any, deviceObj: any): 'BLE' | 'OpenAPI' {
738
+ if (deviceObj?.connectionType) {
739
+ return deviceObj.connectionType === 'BLE' ? 'BLE' : 'OpenAPI'
740
+ }
741
+ if (platformOptions?.BLE && (deviceObj?.bleModel || (typeof deviceObj?.deviceId === 'string' && deviceObj.deviceId.length > 0))) {
742
+ return 'BLE'
743
+ }
744
+ return 'OpenAPI'
745
+ }
746
+
747
+ /**
748
+ * Detect whether Matter is enabled/available on the provided Homebridge API object.
749
+ * This encapsulates the multi-fallback detection used across the project.
750
+ */
751
+ /**
752
+ * Detect whether Matter is enabled on the provided Homebridge API object.
753
+ * Returns an object with an `enabled` boolean and an optional `reason` string
754
+ * describing which check matched (useful for diagnostics).
755
+ */
756
+ export function detectMatter(apiObj: API): { enabled: boolean, reason?: string } {
757
+ try {
758
+ const maybe = (apiObj as any).isMatterEnabled
759
+ if (typeof maybe === 'function') {
760
+ return { enabled: Boolean(maybe.call(apiObj)), reason: 'api.isMatterEnabled() returned truthy' }
761
+ }
762
+ if (typeof maybe !== 'undefined') {
763
+ return { enabled: Boolean(maybe), reason: 'api.isMatterEnabled property present' }
764
+ }
765
+
766
+ const server = (apiObj as any).server ?? (apiObj as any).homebridgeServer ?? (apiObj as any).homebridge_server
767
+ const serverMaybe = server?.isMatterEnabled
768
+ if (typeof serverMaybe === 'function') {
769
+ return { enabled: Boolean(serverMaybe.call(server)), reason: 'server.isMatterEnabled() returned truthy' }
770
+ }
771
+ if (typeof server?.isMatterEnabled !== 'undefined') {
772
+ return { enabled: Boolean(server.isMatterEnabled), reason: 'server.isMatterEnabled property present' }
773
+ }
774
+ } catch (e: any) {
775
+ return { enabled: false, reason: `error during detection: ${String(e?.message ?? e)}` }
776
+ }
777
+ return { enabled: false, reason: 'no isMatterEnabled API or server fallback detected' }
778
+ }
779
+
780
+ /**
781
+ * Backwards-compatible boolean wrapper for detectMatter.
782
+ */
783
+ export function detectMatterEnabled(apiObj: API): boolean {
784
+ return detectMatter(apiObj).enabled
785
+ }
786
+
787
+ /**
788
+ * Create platform logging helpers used by both HAP and Matter platforms.
789
+ *
790
+ * getPlatformLogging may be either a synchronous string-returning function or an
791
+ * async function that resolves to the current platform logging setting. The
792
+ * returned helpers mirror the instance methods previously implemented on the
793
+ * HAP platform (infoLog, warnLog, errorLog, debugLog, etc.).
794
+ */
795
+ export function createPlatformLogger(getPlatformLogging: () => string | Promise<string | undefined>, log: Logging) {
796
+ const getPL = async () => {
797
+ try {
798
+ return await getPlatformLogging()
799
+ } catch {
800
+ return undefined
801
+ }
802
+ }
803
+
804
+ const loggingIsDebug = async () => {
805
+ const pl = await getPL()
806
+ return pl === 'debugMode' || pl === 'debug'
807
+ }
808
+
809
+ const enablingPlatformLogging = async () => {
810
+ const pl = await getPL()
811
+ return pl === 'debugMode' || pl === 'debug' || pl === 'standard'
812
+ }
813
+
814
+ const formatArgs = (args: any[]): string => {
815
+ return args
816
+ .map((a: any) => {
817
+ if (typeof a === 'string') {
818
+ return a
819
+ }
820
+ try {
821
+ return JSON.stringify(a)
822
+ } catch {
823
+ return String(a)
824
+ }
825
+ })
826
+ .join(' ')
827
+ }
828
+
829
+ return {
830
+ // Format arbitrary arguments into a single string to ensure values are not dropped
831
+ // when loggers only accept a single message parameter.
832
+ // Prefer readable JSON for objects, fall back to String() on errors.
833
+ // Example: infoLog('Loaded', accessory.displayName) => "Loaded My Light"
834
+ infoLog: async (...args: any[]) => {
835
+ if (await enablingPlatformLogging()) {
836
+ const msg = formatArgs(args)
837
+ log.info(msg)
838
+ }
839
+ },
840
+ successLog: async (...args: any[]) => {
841
+ if (await enablingPlatformLogging()) {
842
+ const msg = formatArgs(args)
843
+ // Some Logging implementations expose `success` — call if present
844
+ ;(log as any).success?.(msg) ?? log.info(msg)
845
+ }
846
+ },
847
+ debugSuccessLog: async (...args: any[]) => {
848
+ if (await enablingPlatformLogging()) {
849
+ if (await loggingIsDebug()) {
850
+ const msg = formatArgs(args)
851
+ ;(log as any).success?.(`[DEBUG] ${msg}`) ?? log.info(`[DEBUG] ${msg}`)
852
+ }
853
+ }
854
+ },
855
+ warnLog: async (...args: any[]) => {
856
+ if (await enablingPlatformLogging()) {
857
+ const msg = formatArgs(args)
858
+ log.warn(msg)
859
+ }
860
+ },
861
+ debugWarnLog: async (...args: any[]) => {
862
+ if (await enablingPlatformLogging()) {
863
+ if (await loggingIsDebug()) {
864
+ const msg = formatArgs(args)
865
+ log.warn(`[DEBUG] ${msg}`)
866
+ }
867
+ }
868
+ },
869
+ errorLog: async (...args: any[]) => {
870
+ if (await enablingPlatformLogging()) {
871
+ const msg = formatArgs(args)
872
+ log.error(msg)
873
+ }
874
+ },
875
+ debugErrorLog: async (...args: any[]) => {
876
+ if (await enablingPlatformLogging()) {
877
+ if (await loggingIsDebug()) {
878
+ const msg = formatArgs(args)
879
+ log.error(`[DEBUG] ${msg}`)
880
+ }
881
+ }
882
+ },
883
+ debugLog: async (...args: any[]) => {
884
+ if (await enablingPlatformLogging()) {
885
+ const pl = await getPL()
886
+ if (pl === 'debug') {
887
+ const msg = formatArgs(args)
888
+ log.info(`[DEBUG] ${msg}`)
889
+ } else if (pl === 'debugMode') {
890
+ const msg = formatArgs(args)
891
+ log.debug(msg)
892
+ }
893
+ }
894
+ },
895
+ loggingIsDebug,
896
+ enablingPlatformLogging,
897
+ }
898
+ }
899
+
900
+ /**
901
+ * Create a Platform proxy class that selects between two platform constructors
902
+ * (HAP vs Matter) at runtime using `detectMatter`. Returns a class suitable
903
+ * for passing to `api.registerPlatform`.
674
904
  */
675
- export function cleanDeviceConfig(deviceConfig?: Record<string, Record<string, any>>): Record<string, Record<string, any>> | undefined {
676
- if (!deviceConfig || typeof deviceConfig !== 'object') {
677
- return undefined
905
+ export function createPlatformProxy(HAPCtor: any, MatterCtor: any) {
906
+ return class PlatformProxy {
907
+ delegate: any
908
+
909
+ constructor(public readonly log: any, public readonly config: any, public readonly api: API) {
910
+ const matterInfo = detectMatter(this.api)
911
+ const isMatter = matterInfo.enabled
912
+ const reason = matterInfo.reason ? ` Reason: ${matterInfo.reason}` : ''
913
+ this.log.info?.(`Homebridge SwitchBot Plugin initializing in ${isMatter ? 'Matter' : 'HAP'} mode.`)
914
+ this.log.debug?.(`Homebridge SwitchBot Plugin initializing in ${isMatter ? 'Matter' : 'HAP'} mode.${reason}`)
915
+ const PlatformCtor = isMatter ? MatterCtor : HAPCtor
916
+ this.delegate = new PlatformCtor(this.log, this.config, this.api)
917
+ }
918
+
919
+ configureAccessory(accessory: any): void {
920
+ try {
921
+ if (this.delegate && typeof this.delegate.configureAccessory === 'function') {
922
+ return this.delegate.configureAccessory(accessory)
923
+ }
924
+ } catch (e) {
925
+ // swallow — preserve previous behaviour where delegate errors don't bubble here
926
+ }
927
+ }
928
+
929
+ configureMatterAccessory?(accessory: any): void {
930
+ try {
931
+ if (this.delegate && typeof this.delegate.configureMatterAccessory === 'function') {
932
+ return this.delegate.configureMatterAccessory(accessory)
933
+ }
934
+ } catch (e) {
935
+ // swallow — delegate may not implement this or may throw
936
+ }
937
+ }
938
+
939
+ get accessories(): any {
940
+ try {
941
+ return this.delegate?.accessories
942
+ } catch (e) {
943
+ return undefined
944
+ }
945
+ }
946
+
947
+ get matterAccessories(): any {
948
+ try {
949
+ return this.delegate?.matterAccessories
950
+ } catch (e) {
951
+ return undefined
952
+ }
953
+ }
954
+ }
955
+ }
956
+
957
+ /**
958
+ * API Request Tracker - Persistent tracking of SwitchBot API calls
959
+ * Tracks requests per day with automatic midnight rollover
960
+ */
961
+ export class ApiRequestTracker {
962
+ private count = 0
963
+ private date = ''
964
+ private statsFile = ''
965
+ private hourlyTimer?: NodeJS.Timeout
966
+ private midnightTimer?: NodeJS.Timeout
967
+ private log: Logging
968
+ // Daily limits
969
+ private dailyLimit: number
970
+ private reserveForCommands: number
971
+ private lastWarn: Record<string, number> = {}
972
+ private pausePollingAtReserve = false
973
+
974
+ constructor(api: API, log: Logging, pluginName = 'SwitchBot', limits?: { dailyLimit?: number, reserveForCommands?: number, pausePollingAtReserve?: boolean }) {
975
+ this.log = log
976
+ this.statsFile = join(api.user.storagePath(), `${pluginName.toLowerCase()}-api-stats.json`)
977
+ this.dailyLimit = Math.max(0, Number(limits?.dailyLimit ?? 10000))
978
+ this.reserveForCommands = Math.max(0, Number(limits?.reserveForCommands ?? 1000))
979
+ this.pausePollingAtReserve = Boolean(limits?.pausePollingAtReserve ?? false)
980
+ this.load()
981
+ }
982
+
983
+ /**
984
+ * Load API request statistics from persistent storage
985
+ */
986
+ private load(): void {
987
+ try {
988
+ const today = new Date().toISOString().split('T')[0]
989
+
990
+ if (existsSync(this.statsFile)) {
991
+ const data = JSON.parse(readFileSync(this.statsFile, 'utf8'))
992
+
993
+ // If it's a new day, reset the counter
994
+ if (data.date === today) {
995
+ this.count = data.count || 0
996
+ this.date = data.date
997
+ this.log.warn?.(`[API Stats] Loaded: ${this.count} requests today (${today})`)
998
+ } else {
999
+ this.log.error?.(`[API Stats] New day detected. Previous: ${data.count || 0} requests on ${data.date}`)
1000
+ this.count = 0
1001
+ this.date = today
1002
+ this.save()
1003
+ }
1004
+ } else {
1005
+ this.log.debug?.('[API Stats] No existing stats file, starting fresh')
1006
+ this.count = 0
1007
+ this.date = today
1008
+ this.save()
1009
+ }
1010
+ } catch (e: any) {
1011
+ this.log.error?.(`[API Stats] Failed to load stats: ${e?.message ?? e}`)
1012
+ this.count = 0
1013
+ this.date = new Date().toISOString().split('T')[0]
1014
+ }
1015
+ }
1016
+
1017
+ /**
1018
+ * Save API request statistics to persistent storage
1019
+ */
1020
+ private save(): void {
1021
+ try {
1022
+ const data = {
1023
+ date: this.date,
1024
+ count: this.count,
1025
+ lastUpdated: new Date().toISOString(),
1026
+ }
1027
+ writeFileSync(this.statsFile, JSON.stringify(data, null, 2), 'utf8')
1028
+ } catch (e: any) {
1029
+ this.log.debug?.(`[API Stats] Failed to save stats: ${e?.message ?? e}`)
1030
+ }
678
1031
  }
679
1032
 
680
- const cleaned: Record<string, Record<string, any>> = {}
1033
+ /**
1034
+ * Increment API request counter and save
1035
+ */
1036
+ public track(): void {
1037
+ const today = new Date().toISOString().split('T')[0]
681
1038
 
682
- for (const [deviceName, cfg] of Object.entries(deviceConfig)) {
683
- if (!cfg || typeof cfg !== 'object') {
684
- continue
1039
+ // Reset counter if it's a new day
1040
+ if (this.date !== today) {
1041
+ this.log.debug?.(`[API Stats] Day rollover: ${this.count} requests on ${this.date}`)
1042
+ this.count = 0
1043
+ this.date = today
685
1044
  }
686
1045
 
687
- const hasMeaningful = Object.values(cfg).some((v) => {
688
- if (v === null || v === undefined) {
689
- return false
1046
+ this.count++
1047
+ this.save()
1048
+ }
1049
+
1050
+ /**
1051
+ * Attempt to spend from the daily budget for a request of a given kind.
1052
+ * Kinds: 'command' (user actions), 'poll' (status refresh), 'discovery'.
1053
+ * Returns true if allowed (and increments the counter), false if blocked.
1054
+ */
1055
+ public trySpend(kind: 'command' | 'poll' | 'discovery', n = 1): boolean {
1056
+ const today = new Date().toISOString().split('T')[0]
1057
+ if (this.date !== today) {
1058
+ // Day rollover
1059
+ this.log.debug?.(`[API Stats] Day rollover: ${this.count} requests on ${this.date}`)
1060
+ this.count = 0
1061
+ this.date = today
1062
+ this.save()
1063
+ }
1064
+
1065
+ const softCap = Math.max(0, this.dailyLimit - this.reserveForCommands)
1066
+ const projected = this.count + n
1067
+ const now = Date.now()
1068
+ const overHardCap = projected > this.dailyLimit
1069
+ const overSoftCap = projected > softCap
1070
+ const shouldRateLimit = (kind === 'command')
1071
+ ? overHardCap
1072
+ : (this.pausePollingAtReserve ? overSoftCap : overHardCap)
1073
+
1074
+ if (shouldRateLimit) {
1075
+ const warnKey = kind === 'command' ? 'hardcap' : 'softcap'
1076
+ const last = this.lastWarn[warnKey] ?? 0
1077
+ if (now - last > 10 * 60 * 1000) { // warn at most every 10 minutes
1078
+ if (kind === 'command') {
1079
+ this.log.error?.(`[API Stats] Daily limit (${this.dailyLimit}) reached. Blocking command requests until reset.`)
1080
+ } else {
1081
+ const remainingForCommands = Math.max(0, this.dailyLimit - this.count)
1082
+ this.log.warn?.(`[API Stats] Near daily limit. Pausing ${kind} requests to reserve ~${this.reserveForCommands} calls for commands. Remaining today: ${remainingForCommands}`)
1083
+ }
1084
+ this.lastWarn[warnKey] = now
690
1085
  }
691
- if (typeof v === 'boolean') {
692
- return v === true
1086
+ return false
1087
+ }
1088
+
1089
+ this.count += n
1090
+ this.save()
1091
+ return true
1092
+ }
1093
+
1094
+ /**
1095
+ * Start hourly logging of API request count
1096
+ */
1097
+ public startHourlyLogging(): void {
1098
+ // Log immediately on startup
1099
+ this.log.info?.(`[API Stats] Today (${this.date}): ${this.count} API requests`)
1100
+
1101
+ // Then log every hour
1102
+ this.hourlyTimer = setInterval(() => {
1103
+ const today = new Date().toISOString().split('T')[0]
1104
+ if (this.date !== today) {
1105
+ // Day rollover
1106
+ this.log.info?.(`[API Stats] Day rollover - Previous day (${this.date}): ${this.count} API requests`)
1107
+ this.count = 0
1108
+ this.date = today
1109
+ this.save()
1110
+ this.log.info?.('[API Stats] Polling resumed after daily reset')
693
1111
  }
694
- if (typeof v === 'string') {
695
- return v.trim().length > 0
1112
+ this.log.info?.(`[API Stats] Today (${this.date}): ${this.count} API requests`)
1113
+ }, 60 * 60 * 1000) // Every hour
1114
+
1115
+ // Schedule an exact UTC midnight rollover log/reset
1116
+ this.scheduleMidnightRollover()
1117
+ }
1118
+
1119
+ /**
1120
+ * Stop hourly logging
1121
+ */
1122
+ public stopHourlyLogging(): void {
1123
+ if (this.hourlyTimer) {
1124
+ clearInterval(this.hourlyTimer)
1125
+ this.hourlyTimer = undefined
1126
+ }
1127
+ if (this.midnightTimer) {
1128
+ clearTimeout(this.midnightTimer)
1129
+ this.midnightTimer = undefined
1130
+ }
1131
+ }
1132
+
1133
+ /**
1134
+ * Get current count
1135
+ */
1136
+ public getCount(): number {
1137
+ return this.count
1138
+ }
1139
+
1140
+ /**
1141
+ * Get current date
1142
+ */
1143
+ public getDate(): string {
1144
+ return this.date
1145
+ }
1146
+
1147
+ /** Schedule a precise log/reset at the next UTC midnight */
1148
+ private scheduleMidnightRollover(): void {
1149
+ try {
1150
+ // Clear any previous timer
1151
+ if (this.midnightTimer) {
1152
+ clearTimeout(this.midnightTimer)
1153
+ this.midnightTimer = undefined
696
1154
  }
697
- if (typeof v === 'number') {
698
- return Number.isFinite(v)
1155
+ const now = new Date()
1156
+ const nextUtcMidnightMs = Date.UTC(
1157
+ now.getUTCFullYear(),
1158
+ now.getUTCMonth(),
1159
+ now.getUTCDate() + 1,
1160
+ 0,
1161
+ 0,
1162
+ 0,
1163
+ 0,
1164
+ )
1165
+ const delay = Math.max(1000, nextUtcMidnightMs - now.getTime())
1166
+ this.midnightTimer = setTimeout(() => {
1167
+ try {
1168
+ const today = new Date().toISOString().split('T')[0]
1169
+ if (this.date !== today) {
1170
+ this.log.info?.(`[API Stats] Day rollover - Previous day (${this.date}): ${this.count} API requests`)
1171
+ this.count = 0
1172
+ this.date = today
1173
+ this.save()
1174
+ }
1175
+ // Emit the precise resume line and a fresh today counter line
1176
+ this.log.info?.('[API Stats] Polling resumed after daily reset')
1177
+ this.log.info?.(`[API Stats] Today (${this.date}): ${this.count} API requests`)
1178
+ } catch {}
1179
+ // Reschedule for the next midnight
1180
+ this.scheduleMidnightRollover()
1181
+ }, delay)
1182
+ } catch {}
1183
+ }
1184
+ }
1185
+
1186
+ /**
1187
+ * Normalize a deviceId for matching (uppercase alphanumerics only)
1188
+ */
1189
+ export function normalizeDeviceId(deviceId: string): string {
1190
+ return (deviceId ?? '').toUpperCase().replace(/[^A-Z0-9]+/g, '')
1191
+ }
1192
+
1193
+ /**
1194
+ * Merge two arrays by deviceId. For each item in a1 (user-provided devices list),
1195
+ * find matching item in a2 (discovered devices) and merge them with user overrides last.
1196
+ */
1197
+ export function mergeByDeviceId(a1: { deviceId: string }[], a2: any[], allowConfigOnly = false): any[] {
1198
+ const result: any[] = []
1199
+ for (const itm of (a1 || [])) {
1200
+ const matchingItem = (a2 || []).find(item => normalizeDeviceId(item.deviceId) === normalizeDeviceId(itm.deviceId))
1201
+ if (matchingItem) {
1202
+ result.push({ ...matchingItem, ...itm })
1203
+ } else if (allowConfigOnly) {
1204
+ result.push(itm)
1205
+ }
1206
+ }
1207
+ return result
1208
+ }
1209
+
1210
+ /**
1211
+ * Apply device-type or remote-type templates to an array of devices.
1212
+ * Templates are config entries with applyToAllDevicesOfType=true.
1213
+ *
1214
+ * @param devices - Array of devices to apply templates to
1215
+ * @param configDevices - User config array that may contain template entries
1216
+ * @param typeKey - Property name to match device types ('deviceType' for devices, 'remoteType' for IR devices)
1217
+ * @param debugLog - Optional debug logging function
1218
+ * @returns Array of devices with templates applied
1219
+ */
1220
+ export function applyDeviceTypeTemplates(
1221
+ devices: any[],
1222
+ configDevices: any[],
1223
+ typeKey: string,
1224
+ debugLog?: (message: string) => void,
1225
+ ): any[] {
1226
+ // Build a map of device-type templates from config devices with applyToAllDevicesOfType=true
1227
+ const typeTemplates = new Map<string, any>()
1228
+
1229
+ for (const configDevice of configDevices || []) {
1230
+ if (configDevice.applyToAllDevicesOfType) {
1231
+ // Get the type value from multiple possible sources
1232
+ const deviceType = configDevice[typeKey] || (configDevice as any).configDeviceType || (configDevice as any).configRemoteType
1233
+ if (!deviceType) {
1234
+ continue
699
1235
  }
700
- if (Array.isArray(v)) {
701
- return v.length > 0
1236
+
1237
+ // Store all config properties except deviceId and applyToAllDevicesOfType flag
1238
+ const template: any = { ...configDevice }
1239
+ delete template.deviceId
1240
+ delete template.applyToAllDevicesOfType
1241
+
1242
+ typeTemplates.set(deviceType, template)
1243
+ if (debugLog) {
1244
+ debugLog(`Device type template found for '${deviceType}': ${JSON.stringify(template)}`)
702
1245
  }
703
- if (typeof v === 'object') {
704
- return Object.keys(v).length > 0
1246
+ }
1247
+ }
1248
+
1249
+ // If no templates found, return original array
1250
+ if (typeTemplates.size === 0) {
1251
+ return devices
1252
+ }
1253
+
1254
+ // Apply templates to devices
1255
+ return devices.map((device) => {
1256
+ const deviceType = device[typeKey] || (device as any).configDeviceType || (device as any).configRemoteType
1257
+ const template = typeTemplates.get(deviceType)
1258
+
1259
+ if (template) {
1260
+ if (debugLog) {
1261
+ debugLog(`Applying device type template to ${device.deviceId} (${deviceType})`)
705
1262
  }
706
- return true
707
- })
1263
+ // Template settings go first, then device data (device data takes precedence)
1264
+ return Object.assign({}, template, device)
1265
+ }
708
1266
 
709
- if (hasMeaningful) {
710
- cleaned[deviceName] = cfg
1267
+ return device
1268
+ })
1269
+ }
1270
+
1271
+ /**
1272
+ * Check if an API status code indicates success
1273
+ */
1274
+ export function isSuccessfulStatusCode(statusCode: number): boolean {
1275
+ return statusCode === 200 || statusCode === 100
1276
+ }
1277
+
1278
+ /**
1279
+ * Log status code messages with appropriate log level
1280
+ */
1281
+ export async function logStatusCode(statusCode: number, log: {
1282
+ debugLog: (...args: any[]) => void | Promise<void>
1283
+ errorLog: (...args: any[]) => void | Promise<void>
1284
+ }): Promise<void> {
1285
+ const messages: { [key: number]: string } = {
1286
+ 151: `Command not supported by this device type, statusCode: ${statusCode}, Submit Feature Request Here:
1287
+ https://tinyurl.com/SwitchBotFeatureRequest`,
1288
+ 152: `Device not found, statusCode: ${statusCode}`,
1289
+ 160: `Command is not supported, statusCode: ${statusCode}, Submit Bugs Here: https://tinyurl.com/SwitchBotBug`,
1290
+ 161: `Device is offline, statusCode: ${statusCode}`,
1291
+ 171: `is offline, statusCode: ${statusCode}`,
1292
+ 190: `Requests reached the daily limit, statusCode: ${statusCode}`,
1293
+ 100: `Command successfully sent, statusCode: ${statusCode}`,
1294
+ 200: `Request successful, statusCode: ${statusCode}`,
1295
+ 400: `Bad Request, The client has issued an invalid request. This is commonly used to specify validation errors in a request payload,
1296
+ statusCode: ${statusCode}`,
1297
+ 401: `Unauthorized, Authorization for the API is required, but the request has not been authenticated, statusCode: ${statusCode}`,
1298
+ 403: `Forbidden, The request has been authenticated but does not have appropriate permissions, or a requested resource is not found,
1299
+ statusCode: ${statusCode}`,
1300
+ 404: `Not Found, Specifies the requested path does not exist, statusCode: ${statusCode}`,
1301
+ 406: `Not Acceptable, The client has requested a MIME type via the Accept header for a value not supported by the server,
1302
+ statusCode: ${statusCode}`,
1303
+ 415: `Unsupported Media Type, The client has defined a contentType header that is not supported by the server, statusCode: ${statusCode}`,
1304
+ 422: `Unprocessable Entity, The client has made a valid request, but the server cannot process it. This is often used for APIs for which
1305
+ certain limits have been exceeded, statusCode: ${statusCode}`,
1306
+ 429: `Too Many Requests, The client has exceeded the number of requests allowed for a given time window, statusCode: ${statusCode}`,
1307
+ 500: `Internal Server Error, An unexpected error on the SmartThings servers has occurred. These errors should be rare,
1308
+ statusCode: ${statusCode}`,
1309
+ }
1310
+
1311
+ const message = messages[statusCode] ?? `Unknown statusCode, statusCode: ${statusCode}, Submit Bugs Here: https://tinyurl.com/SwitchBotBug`
1312
+
1313
+ if ([100, 200].includes(statusCode)) {
1314
+ await log.debugLog(message)
1315
+ } else {
1316
+ await log.errorLog(message)
1317
+ }
1318
+ }
1319
+
1320
+ /**
1321
+ * Shared device logging helpers
1322
+ */
1323
+
1324
+ /**
1325
+ * Check if device logging is in debug mode
1326
+ */
1327
+ export function deviceLoggingIsDebug(deviceLogging?: string): boolean {
1328
+ return deviceLogging === 'debugMode' || deviceLogging === 'debug'
1329
+ }
1330
+
1331
+ /**
1332
+ * Check if device logging is enabled
1333
+ */
1334
+ export function deviceLoggingEnabled(deviceLogging?: string, platformLogging?: string): boolean {
1335
+ // If deviceLogging isn't provided, fall back to platform-wide flag
1336
+ if (deviceLogging === undefined || deviceLogging === '') {
1337
+ return platformLogging === 'debugMode' || platformLogging === 'debug' || platformLogging === 'standard'
1338
+ }
1339
+ return deviceLogging === 'debugMode' || deviceLogging === 'debug' || deviceLogging === 'standard'
1340
+ }
1341
+
1342
+ /**
1343
+ * Device status code handler with comprehensive messages
1344
+ */
1345
+ export interface DeviceStatusCodeLogger {
1346
+ debugLog: (...args: any[]) => void | Promise<void>
1347
+ debugErrorLog?: (...args: any[]) => void | Promise<void>
1348
+ errorLog: (...args: any[]) => void | Promise<void>
1349
+ infoLog?: (...args: any[]) => void | Promise<void>
1350
+ }
1351
+
1352
+ export async function logDeviceStatusCode(
1353
+ statusCode: number,
1354
+ log: DeviceStatusCodeLogger,
1355
+ deviceId?: string,
1356
+ hubDeviceId?: string,
1357
+ ): Promise<void> {
1358
+ let adjustedStatusCode = statusCode
1359
+
1360
+ // Handle special case where device is its own hub
1361
+ if (statusCode === 171 && hubDeviceId && deviceId && (hubDeviceId === deviceId || hubDeviceId === '000000000000')) {
1362
+ if (log.debugErrorLog) {
1363
+ log.debugErrorLog(`statusCode 171 changed to 161: hubDeviceId ${hubDeviceId} matches deviceId ${deviceId}, device is its own hub.`)
711
1364
  }
1365
+ adjustedStatusCode = 161
1366
+ }
1367
+
1368
+ const statusMessages: { [key: number]: string } = {
1369
+ 151: 'Command not supported by this device type',
1370
+ 152: 'Device not found',
1371
+ 160: 'Command is not supported',
1372
+ 161: 'Device is offline',
1373
+ 171: hubDeviceId ? `Hub Device is offline. Hub: ${hubDeviceId}` : 'Hub Device is offline',
1374
+ 190: 'Device internal error due to device states not synchronized with server, or command format is invalid',
1375
+ 100: 'Command successfully sent',
1376
+ 200: 'Request successful',
1377
+ 400: 'Bad Request, an invalid payload request',
1378
+ 401: 'Unauthorized, Authorization for the API is required, but the request has not been authenticated',
1379
+ 403: 'Forbidden, The request has been authenticated but does not have appropriate permissions, or a requested resource is not found',
1380
+ 404: 'Not Found, Specifies the requested path does not exist',
1381
+ 406: 'Not Acceptable, a MIME type has been requested via the Accept header for a value not supported by the server',
1382
+ 415: 'Unsupported Media Type, a contentType header has been defined that is not supported by the server',
1383
+ 422: 'Unprocessable Entity: The server cannot process the request, often due to exceeded API limits.',
1384
+ 429: 'Too Many Requests, exceeded the number of requests allowed for a given time window',
1385
+ 500: 'Internal Server Error, An unexpected error occurred. These errors should be rare',
712
1386
  }
713
1387
 
714
- if (Object.keys(cleaned).length > 0) {
715
- return cleaned
1388
+ const logMessage = statusMessages[adjustedStatusCode] || `Unknown statusCode: ${adjustedStatusCode}, Submit Bugs Here: https://tinyurl.com/SwitchBotBug`
1389
+ const fullMessage = `${logMessage}, statusCode: ${adjustedStatusCode}`
1390
+
1391
+ if ([100, 200].includes(adjustedStatusCode)) {
1392
+ await log.debugLog(fullMessage)
1393
+ } else if (statusMessages[adjustedStatusCode]) {
1394
+ await log.errorLog(fullMessage)
1395
+ } else if (log.infoLog) {
1396
+ await log.infoLog(fullMessage)
1397
+ } else {
1398
+ await log.errorLog(fullMessage)
716
1399
  }
717
- return undefined
718
1400
  }