homebridge-securitysystem 0.0.1-matter

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 (257) hide show
  1. package/.gitattributes +1 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.yml +46 -0
  3. package/.github/ISSUE_TEMPLATE/feature-request.yml +17 -0
  4. package/.github/ISSUE_TEMPLATE/questions-answers.yml +40 -0
  5. package/.github/demo.jpg +0 -0
  6. package/.github/dependabot.yml +19 -0
  7. package/.github/release.yml +36 -0
  8. package/.github/workflows/build-package.yml +49 -0
  9. package/.github/workflows/bump-version.yml +59 -0
  10. package/.github/workflows/dependency-review.yml +26 -0
  11. package/.github/workflows/publish.yml +155 -0
  12. package/.github/workflows/stale-activity.yml +27 -0
  13. package/AGENTS.md +247 -0
  14. package/CHANGELOG.md +572 -0
  15. package/CODE_OF_CONDUCT.md +128 -0
  16. package/CONTRIBUTING.md +5 -0
  17. package/LICENSE +21 -0
  18. package/README.md +47 -0
  19. package/SECURITY.md +12 -0
  20. package/config.schema.json +934 -0
  21. package/dist/conditions/already-triggered-condition.d.ts +10 -0
  22. package/dist/conditions/already-triggered-condition.js +22 -0
  23. package/dist/conditions/already-triggered-condition.js.map +1 -0
  24. package/dist/conditions/arming-in-progress-condition.d.ts +10 -0
  25. package/dist/conditions/arming-in-progress-condition.js +21 -0
  26. package/dist/conditions/arming-in-progress-condition.js.map +1 -0
  27. package/dist/conditions/arming-lock-condition.d.ts +10 -0
  28. package/dist/conditions/arming-lock-condition.js +43 -0
  29. package/dist/conditions/arming-lock-condition.js.map +1 -0
  30. package/dist/conditions/condition.d.ts +22 -0
  31. package/dist/conditions/condition.js +19 -0
  32. package/dist/conditions/condition.js.map +1 -0
  33. package/dist/conditions/double-knock-condition.d.ts +24 -0
  34. package/dist/conditions/double-knock-condition.js +73 -0
  35. package/dist/conditions/double-knock-condition.js.map +1 -0
  36. package/dist/conditions/not-armed-condition.d.ts +10 -0
  37. package/dist/conditions/not-armed-condition.js +26 -0
  38. package/dist/conditions/not-armed-condition.js.map +1 -0
  39. package/dist/conditions/trigger-already-running-condition.d.ts +10 -0
  40. package/dist/conditions/trigger-already-running-condition.js +21 -0
  41. package/dist/conditions/trigger-already-running-condition.js.map +1 -0
  42. package/dist/constants/default-constant.d.ts +24 -0
  43. package/dist/constants/default-constant.js +27 -0
  44. package/dist/constants/default-constant.js.map +1 -0
  45. package/dist/constants/homekit-constant.d.ts +2 -0
  46. package/dist/constants/homekit-constant.js +3 -0
  47. package/dist/constants/homekit-constant.js.map +1 -0
  48. package/dist/constants/matter-constant.d.ts +21 -0
  49. package/dist/constants/matter-constant.js +61 -0
  50. package/dist/constants/matter-constant.js.map +1 -0
  51. package/dist/handlers/sensor-handler.d.ts +24 -0
  52. package/dist/handlers/sensor-handler.js +63 -0
  53. package/dist/handlers/sensor-handler.js.map +1 -0
  54. package/dist/handlers/state-handler.d.ts +49 -0
  55. package/dist/handlers/state-handler.js +204 -0
  56. package/dist/handlers/state-handler.js.map +1 -0
  57. package/dist/handlers/switch-handler.d.ts +28 -0
  58. package/dist/handlers/switch-handler.js +95 -0
  59. package/dist/handlers/switch-handler.js.map +1 -0
  60. package/dist/handlers/trip-handler.d.ts +44 -0
  61. package/dist/handlers/trip-handler.js +156 -0
  62. package/dist/handlers/trip-handler.js.map +1 -0
  63. package/dist/homekit/homekit-registrar.d.ts +12 -0
  64. package/dist/homekit/homekit-registrar.js +23 -0
  65. package/dist/homekit/homekit-registrar.js.map +1 -0
  66. package/dist/homekit/service-factory.d.ts +12 -0
  67. package/dist/homekit/service-factory.js +24 -0
  68. package/dist/homekit/service-factory.js.map +1 -0
  69. package/dist/index.d.ts +3 -0
  70. package/dist/index.js +7 -0
  71. package/dist/index.js.map +1 -0
  72. package/dist/interfaces/condition-context-interface.d.ts +14 -0
  73. package/dist/interfaces/condition-context-interface.js +2 -0
  74. package/dist/interfaces/condition-context-interface.js.map +1 -0
  75. package/dist/interfaces/hap-types-interface.d.ts +9 -0
  76. package/dist/interfaces/hap-types-interface.js +2 -0
  77. package/dist/interfaces/hap-types-interface.js.map +1 -0
  78. package/dist/interfaces/mqtt-status-payload-interface.d.ts +6 -0
  79. package/dist/interfaces/mqtt-status-payload-interface.js +2 -0
  80. package/dist/interfaces/mqtt-status-payload-interface.js.map +1 -0
  81. package/dist/interfaces/options-interface.d.ts +87 -0
  82. package/dist/interfaces/options-interface.js +2 -0
  83. package/dist/interfaces/options-interface.js.map +1 -0
  84. package/dist/interfaces/persisted-state-interface.d.ts +6 -0
  85. package/dist/interfaces/persisted-state-interface.js +2 -0
  86. package/dist/interfaces/persisted-state-interface.js.map +1 -0
  87. package/dist/interfaces/service-registry-interface.d.ts +10 -0
  88. package/dist/interfaces/service-registry-interface.js +2 -0
  89. package/dist/interfaces/service-registry-interface.js.map +1 -0
  90. package/dist/interfaces/system-state-interface.d.ts +22 -0
  91. package/dist/interfaces/system-state-interface.js +2 -0
  92. package/dist/interfaces/system-state-interface.js.map +1 -0
  93. package/dist/schemas/arming-lock-schema.d.ts +10 -0
  94. package/dist/schemas/arming-lock-schema.js +12 -0
  95. package/dist/schemas/arming-lock-schema.js.map +1 -0
  96. package/dist/schemas/error-schema.d.ts +4 -0
  97. package/dist/schemas/error-schema.js +7 -0
  98. package/dist/schemas/error-schema.js.map +1 -0
  99. package/dist/schemas/mode-request-schema.d.ts +10 -0
  100. package/dist/schemas/mode-request-schema.js +18 -0
  101. package/dist/schemas/mode-request-schema.js.map +1 -0
  102. package/dist/schemas/status-response-schema.d.ts +7 -0
  103. package/dist/schemas/status-response-schema.js +10 -0
  104. package/dist/schemas/status-response-schema.js.map +1 -0
  105. package/dist/schemas/trip-mode-request-schema.d.ts +9 -0
  106. package/dist/schemas/trip-mode-request-schema.js +24 -0
  107. package/dist/schemas/trip-mode-request-schema.js.map +1 -0
  108. package/dist/security-system-platform.d.ts +19 -0
  109. package/dist/security-system-platform.js +56 -0
  110. package/dist/security-system-platform.js.map +1 -0
  111. package/dist/security-system.d.ts +28 -0
  112. package/dist/security-system.js +169 -0
  113. package/dist/security-system.js.map +1 -0
  114. package/dist/services/command-service.d.ts +16 -0
  115. package/dist/services/command-service.js +65 -0
  116. package/dist/services/command-service.js.map +1 -0
  117. package/dist/services/configuration-service.d.ts +24 -0
  118. package/dist/services/configuration-service.js +189 -0
  119. package/dist/services/configuration-service.js.map +1 -0
  120. package/dist/services/event-bus-service.d.ts +12 -0
  121. package/dist/services/event-bus-service.js +17 -0
  122. package/dist/services/event-bus-service.js.map +1 -0
  123. package/dist/services/matter-service.d.ts +69 -0
  124. package/dist/services/matter-service.js +363 -0
  125. package/dist/services/matter-service.js.map +1 -0
  126. package/dist/services/mqtt-service.d.ts +16 -0
  127. package/dist/services/mqtt-service.js +97 -0
  128. package/dist/services/mqtt-service.js.map +1 -0
  129. package/dist/services/server-service.d.ts +28 -0
  130. package/dist/services/server-service.js +275 -0
  131. package/dist/services/server-service.js.map +1 -0
  132. package/dist/services/storage-service.d.ts +14 -0
  133. package/dist/services/storage-service.js +70 -0
  134. package/dist/services/storage-service.js.map +1 -0
  135. package/dist/services/webhook-service.d.ts +16 -0
  136. package/dist/services/webhook-service.js +68 -0
  137. package/dist/services/webhook-service.js.map +1 -0
  138. package/dist/tests/conditions.test.d.ts +1 -0
  139. package/dist/tests/conditions.test.js +175 -0
  140. package/dist/tests/conditions.test.js.map +1 -0
  141. package/dist/tests/matter-service.test.d.ts +1 -0
  142. package/dist/tests/matter-service.test.js +475 -0
  143. package/dist/tests/matter-service.test.js.map +1 -0
  144. package/dist/tests/mqtt-service.test.d.ts +1 -0
  145. package/dist/tests/mqtt-service.test.js +168 -0
  146. package/dist/tests/mqtt-service.test.js.map +1 -0
  147. package/dist/tests/sensor-handler.test.d.ts +1 -0
  148. package/dist/tests/sensor-handler.test.js +87 -0
  149. package/dist/tests/sensor-handler.test.js.map +1 -0
  150. package/dist/tests/server-service.test.d.ts +1 -0
  151. package/dist/tests/server-service.test.js +188 -0
  152. package/dist/tests/server-service.test.js.map +1 -0
  153. package/dist/tests/state-handler.test.d.ts +1 -0
  154. package/dist/tests/state-handler.test.js +276 -0
  155. package/dist/tests/state-handler.test.js.map +1 -0
  156. package/dist/tests/switch-handler.test.d.ts +1 -0
  157. package/dist/tests/switch-handler.test.js +186 -0
  158. package/dist/tests/switch-handler.test.js.map +1 -0
  159. package/dist/tests/trip-handler.test.d.ts +1 -0
  160. package/dist/tests/trip-handler.test.js +179 -0
  161. package/dist/tests/trip-handler.test.js.map +1 -0
  162. package/dist/timers/timer-manager.d.ts +31 -0
  163. package/dist/timers/timer-manager.js +118 -0
  164. package/dist/timers/timer-manager.js.map +1 -0
  165. package/dist/types/event-payload-map-type.d.ts +15 -0
  166. package/dist/types/event-payload-map-type.js +2 -0
  167. package/dist/types/event-payload-map-type.js.map +1 -0
  168. package/dist/types/event-type.d.ts +44 -0
  169. package/dist/types/event-type.js +20 -0
  170. package/dist/types/event-type.js.map +1 -0
  171. package/dist/types/matter-accessory-key-type.d.ts +7 -0
  172. package/dist/types/matter-accessory-key-type.js +2 -0
  173. package/dist/types/matter-accessory-key-type.js.map +1 -0
  174. package/dist/types/mode-type.d.ts +4 -0
  175. package/dist/types/mode-type.js +2 -0
  176. package/dist/types/mode-type.js.map +1 -0
  177. package/dist/types/origin-type.d.ts +9 -0
  178. package/dist/types/origin-type.js +11 -0
  179. package/dist/types/origin-type.js.map +1 -0
  180. package/dist/types/security-state-type.d.ts +11 -0
  181. package/dist/types/security-state-type.js +13 -0
  182. package/dist/types/security-state-type.js.map +1 -0
  183. package/dist/types/sensor-kind-type.d.ts +2 -0
  184. package/dist/types/sensor-kind-type.js +2 -0
  185. package/dist/types/sensor-kind-type.js.map +1 -0
  186. package/dist/types/service-result-type.d.ts +4 -0
  187. package/dist/types/service-result-type.js +2 -0
  188. package/dist/types/service-result-type.js.map +1 -0
  189. package/dist/utils/arming-util.d.ts +19 -0
  190. package/dist/utils/arming-util.js +24 -0
  191. package/dist/utils/arming-util.js.map +1 -0
  192. package/dist/utils/state-util.d.ts +8 -0
  193. package/dist/utils/state-util.js +29 -0
  194. package/dist/utils/state-util.js.map +1 -0
  195. package/docs/MQTT.md +50 -0
  196. package/package.json +64 -0
  197. package/src/@types/homebridge-lib.d.ts +11 -0
  198. package/src/conditions/already-triggered-condition.ts +24 -0
  199. package/src/conditions/arming-in-progress-condition.ts +23 -0
  200. package/src/conditions/arming-lock-condition.ts +50 -0
  201. package/src/conditions/condition.ts +31 -0
  202. package/src/conditions/double-knock-condition.ts +84 -0
  203. package/src/conditions/not-armed-condition.ts +30 -0
  204. package/src/conditions/trigger-already-running-condition.ts +23 -0
  205. package/src/constants/default-constant.ts +28 -0
  206. package/src/constants/homekit-constant.ts +2 -0
  207. package/src/constants/matter-constant.ts +69 -0
  208. package/src/handlers/sensor-handler.ts +78 -0
  209. package/src/handlers/state-handler.ts +257 -0
  210. package/src/handlers/switch-handler.ts +111 -0
  211. package/src/handlers/trip-handler.ts +193 -0
  212. package/src/homekit/homekit-registrar.ts +28 -0
  213. package/src/homekit/service-factory.ts +35 -0
  214. package/src/index.ts +9 -0
  215. package/src/interfaces/condition-context-interface.ts +15 -0
  216. package/src/interfaces/hap-types-interface.ts +11 -0
  217. package/src/interfaces/mqtt-status-payload-interface.ts +6 -0
  218. package/src/interfaces/options-interface.ts +116 -0
  219. package/src/interfaces/persisted-state-interface.ts +7 -0
  220. package/src/interfaces/service-registry-interface.ts +11 -0
  221. package/src/interfaces/system-state-interface.ts +25 -0
  222. package/src/schemas/arming-lock-schema.ts +12 -0
  223. package/src/schemas/error-schema.ts +7 -0
  224. package/src/schemas/mode-request-schema.ts +18 -0
  225. package/src/schemas/status-response-schema.ts +10 -0
  226. package/src/schemas/trip-mode-request-schema.ts +24 -0
  227. package/src/security-system-platform.ts +68 -0
  228. package/src/security-system.ts +199 -0
  229. package/src/services/command-service.ts +76 -0
  230. package/src/services/configuration-service.ts +224 -0
  231. package/src/services/event-bus-service.ts +21 -0
  232. package/src/services/matter-service.ts +446 -0
  233. package/src/services/mqtt-service.ts +123 -0
  234. package/src/services/server-service.ts +327 -0
  235. package/src/services/storage-service.ts +80 -0
  236. package/src/services/webhook-service.ts +77 -0
  237. package/src/tests/conditions.test.ts +212 -0
  238. package/src/tests/matter-service.test.ts +610 -0
  239. package/src/tests/mqtt-service.test.ts +231 -0
  240. package/src/tests/sensor-handler.test.ts +116 -0
  241. package/src/tests/server-service.test.ts +238 -0
  242. package/src/tests/state-handler.test.ts +371 -0
  243. package/src/tests/switch-handler.test.ts +234 -0
  244. package/src/tests/trip-handler.test.ts +244 -0
  245. package/src/timers/timer-manager.ts +141 -0
  246. package/src/types/event-payload-map-type.ts +25 -0
  247. package/src/types/event-type.ts +61 -0
  248. package/src/types/matter-accessory-key-type.ts +33 -0
  249. package/src/types/mode-type.ts +4 -0
  250. package/src/types/origin-type.ts +9 -0
  251. package/src/types/security-state-type.ts +11 -0
  252. package/src/types/sensor-kind-type.ts +2 -0
  253. package/src/types/service-result-type.ts +4 -0
  254. package/src/utils/arming-util.ts +44 -0
  255. package/src/utils/state-util.ts +32 -0
  256. package/tsconfig.json +26 -0
  257. package/vitest.config.ts +8 -0
@@ -0,0 +1,224 @@
1
+ import type { Logging } from 'homebridge';
2
+ import type { SecuritySystemOptions } from '../interfaces/options-interface.js';
3
+ import { DEFAULTS } from '../constants/default-constant.js';
4
+
5
+ type RawConfig = Record<string, unknown>;
6
+
7
+ /**
8
+ * Centralises all configuration parsing, validation, and normalisation.
9
+ * Construct with the raw Homebridge config object; access the fully-typed
10
+ * result via the `options` property.
11
+ */
12
+ export class ConfigurationService {
13
+ readonly options: SecuritySystemOptions;
14
+
15
+ constructor(private readonly log: Logging, raw: RawConfig) {
16
+ log.info('Config', JSON.stringify(raw));
17
+ this.applyDeprecations(raw);
18
+ this.options = this.parse(raw);
19
+ this.validate(this.options);
20
+ this.normalize(this.options);
21
+ log.info('Options', JSON.stringify(this.options));
22
+ }
23
+
24
+ // ── Private parsing helpers ─────────────────────────────────────────────────
25
+
26
+ private isSet(value: unknown): boolean {
27
+ if (value === undefined || value === null) {
28
+ return false;
29
+ }
30
+ if (typeof value === 'string' && (value.trim() === '' || value === 'null')) {
31
+ return false;
32
+ }
33
+ return true;
34
+ }
35
+
36
+ private str(raw: RawConfig, key: string): string | null {
37
+ return this.isSet(raw[key]) ? String(raw[key]) : null;
38
+ }
39
+
40
+ private num(raw: RawConfig, key: string): number | null {
41
+ const v = raw[key];
42
+ if (!this.isSet(v)) {
43
+ return null;
44
+ }
45
+ const n = Number(v);
46
+ return isNaN(n) ? null : n;
47
+ }
48
+
49
+ private bool(raw: RawConfig, key: string, def: boolean): boolean {
50
+ return this.isSet(raw[key]) ? Boolean(raw[key]) : def;
51
+ }
52
+
53
+ private strArr(raw: RawConfig, key: string): string[] {
54
+ return Array.isArray(raw[key]) ? (raw[key] as unknown[]).map(String) : [];
55
+ }
56
+
57
+ // ── Deprecation migration ───────────────────────────────────────────────────
58
+
59
+ /** Rewrites deprecated config keys to their current equivalents in-place. */
60
+ private applyDeprecations(raw: RawConfig): void {
61
+ const renames: Record<string, string> = {
62
+ siren_switch: 'trip_switch',
63
+ siren_override_switch: 'trip_override_switch',
64
+ siren_mode_switches: 'trip_mode_switches',
65
+ };
66
+
67
+ for (const [oldKey, newKey] of Object.entries(renames)) {
68
+ if (this.isSet(raw[oldKey]) && !this.isSet(raw[newKey])) {
69
+ this.log.warn(`Config: '${oldKey}' is deprecated, use '${newKey}' instead.`);
70
+ raw[newKey] = raw[oldKey];
71
+ }
72
+ }
73
+ }
74
+
75
+ // ── Core parsing ────────────────────────────────────────────────────────────
76
+
77
+ private parse(raw: RawConfig): SecuritySystemOptions {
78
+ return {
79
+ name: this.str(raw, 'name') ?? DEFAULTS.NAME,
80
+ serialNumber: this.str(raw, 'serial_number') ?? DEFAULTS.SERIAL_NUMBER,
81
+ defaultMode: this.str(raw, 'default_mode') ?? DEFAULTS.DEFAULT_MODE,
82
+ armSeconds: this.num(raw, 'arm_seconds') ?? DEFAULTS.ARM_SECONDS,
83
+ triggerSeconds: this.num(raw, 'trigger_seconds') ?? DEFAULTS.TRIGGER_SECONDS,
84
+ resetMinutes: this.num(raw, 'reset_minutes') ?? DEFAULTS.RESET_MINUTES,
85
+ saveState: this.bool(raw, 'save_state', false),
86
+ proxyMode: this.bool(raw, 'proxy_mode', false),
87
+ testMode: this.bool(raw, 'test_mode', false),
88
+
89
+ // Switch display names
90
+ tripSwitchName: this.str(raw, 'trip_switch_name') ?? DEFAULTS.TRIP_SWITCH_NAME,
91
+ tripHomeSwitchName: this.str(raw, 'trip_home_switch_name') ?? DEFAULTS.TRIP_HOME_SWITCH_NAME,
92
+ tripAwaySwitchName: this.str(raw, 'trip_away_switch_name') ?? DEFAULTS.TRIP_AWAY_SWITCH_NAME,
93
+ tripNightSwitchName: this.str(raw, 'trip_night_switch_name') ?? DEFAULTS.TRIP_NIGHT_SWITCH_NAME,
94
+ tripOverrideSwitchName: this.str(raw, 'trip_override_switch_name') ?? DEFAULTS.TRIP_OVERRIDE_SWITCH_NAME,
95
+ modeHomeSwitchName: this.str(raw, 'mode_home_switch_name') ?? DEFAULTS.MODE_HOME_SWITCH_NAME,
96
+ modeAwaySwitchName: this.str(raw, 'mode_away_switch_name') ?? DEFAULTS.MODE_AWAY_SWITCH_NAME,
97
+ modeNightSwitchName: this.str(raw, 'mode_night_switch_name') ?? DEFAULTS.MODE_NIGHT_SWITCH_NAME,
98
+ modeOffSwitchName: this.str(raw, 'mode_off_switch_name') ?? DEFAULTS.MODE_OFF_SWITCH_NAME,
99
+ modeAwayExtendedSwitchName: this.str(raw, 'mode_away_extended_switch_name') ?? DEFAULTS.MODE_AWAY_EXTENDED_SWITCH_NAME,
100
+ modePauseSwitchName: this.str(raw, 'mode_pause_switch_name') ?? DEFAULTS.MODE_PAUSE_SWITCH_NAME,
101
+
102
+ // Behaviour toggles
103
+ overrideOff: this.bool(raw, 'override_off', false),
104
+ resetOffFlow: this.bool(raw, 'reset_off_flow', false),
105
+ disabledModes: this.strArr(raw, 'disabled_modes'),
106
+
107
+ // Per-mode arm delays
108
+ homeArmSeconds: this.num(raw, 'home_arm_seconds'),
109
+ awayArmSeconds: this.num(raw, 'away_arm_seconds'),
110
+ nightArmSeconds: this.num(raw, 'night_arm_seconds'),
111
+
112
+ // Per-mode trigger delays
113
+ homeTriggerSeconds: this.num(raw, 'home_trigger_seconds'),
114
+ awayTriggerSeconds: this.num(raw, 'away_trigger_seconds'),
115
+ nightTriggerSeconds: this.num(raw, 'night_trigger_seconds'),
116
+
117
+ // Trip switches
118
+ tripSwitch: this.bool(raw, 'trip_switch', false),
119
+ tripOverrideSwitch: this.bool(raw, 'trip_override_switch', false),
120
+ tripModeSwitches: this.bool(raw, 'trip_mode_switches', true),
121
+
122
+ // Arming lock
123
+ armingLockSwitch: this.bool(raw, 'arming_lock_switch', false),
124
+ armingLockSwitches: this.bool(raw, 'arming_lock_switches', false),
125
+
126
+ // Motion sensors
127
+ armingMotionSensor: this.bool(raw, 'arming_sensor', false),
128
+ trippedMotionSensor: this.bool(raw, 'tripped_sensor', false),
129
+ trippedMotionSensorSeconds: this.num(raw, 'tripped_sensor_seconds') ?? DEFAULTS.TRIPPED_SENSOR_SECONDS,
130
+ resetSensor: this.bool(raw, 'reset_sensor', false),
131
+
132
+ // Mode switches
133
+ modeSwitches: this.bool(raw, 'mode_switches', false),
134
+ modeOffSwitch: this.bool(raw, 'mode_off_switch', false),
135
+ modePauseSwitch: this.bool(raw, 'mode_pause_switch', false),
136
+ pauseMinutes: this.num(raw, 'pause_minutes') ?? DEFAULTS.PAUSE_MINUTES,
137
+ modeAwayExtendedSwitch: this.bool(raw, 'mode_away_extended_switch', false),
138
+ modeAwayExtendedSwitchTriggerSeconds: this.num(raw, 'mode_away_extended_switch_trigger_seconds'),
139
+
140
+ // Double-knock
141
+ doubleKnock: this.bool(raw, 'double_knock', false),
142
+ doubleKnockSeconds: this.num(raw, 'double_knock_seconds') ?? DEFAULTS.DOUBLE_KNOCK_SECONDS,
143
+ doubleKnockModes: this.strArr(raw, 'double_knock_modes'),
144
+ homeDoubleKnockSeconds: this.num(raw, 'home_double_knock_seconds'),
145
+ awayDoubleKnockSeconds: this.num(raw, 'away_double_knock_seconds'),
146
+ nightDoubleKnockSeconds: this.num(raw, 'night_double_knock_seconds'),
147
+
148
+ // Custom trip mode switches
149
+ tripHomeSwitches: Array.isArray(raw.trip_home_switches)
150
+ ? (raw.trip_home_switches as { label: string }[])
151
+ : [],
152
+ tripAwaySwitches: Array.isArray(raw.trip_away_switches)
153
+ ? (raw.trip_away_switches as { label: string }[])
154
+ : [],
155
+ tripNightSwitches: Array.isArray(raw.trip_night_switches)
156
+ ? (raw.trip_night_switches as { label: string }[])
157
+ : [],
158
+
159
+ // Server
160
+ serverPort: this.num(raw, 'server_port'),
161
+ // TODO: Remove serverCode fallback in future major version
162
+ serverApiKey: this.str(raw, 'server_api_key') ?? (this.num(raw, 'server_code') !== null ? String(this.num(raw, 'server_code')!) : null),
163
+
164
+ // Shell commands
165
+ commandTargetHome: this.str(raw, 'command_target_home'),
166
+ commandTargetAway: this.str(raw, 'command_target_away'),
167
+ commandTargetNight: this.str(raw, 'command_target_night'),
168
+ commandTargetOff: this.str(raw, 'command_target_off'),
169
+ commandCurrentHome: this.str(raw, 'command_current_home'),
170
+ commandCurrentAway: this.str(raw, 'command_current_away'),
171
+ commandCurrentNight: this.str(raw, 'command_current_night'),
172
+ commandCurrentOff: this.str(raw, 'command_current_off'),
173
+ commandCurrentWarning: this.str(raw, 'command_current_warning'),
174
+ commandCurrentTriggered: this.str(raw, 'command_current_triggered'),
175
+
176
+ // Webhooks
177
+ webhookTargetHome: this.str(raw, 'webhook_target_home'),
178
+ webhookTargetAway: this.str(raw, 'webhook_target_away'),
179
+ webhookTargetNight: this.str(raw, 'webhook_target_night'),
180
+ webhookTargetOff: this.str(raw, 'webhook_target_off'),
181
+ webhookCurrentHome: this.str(raw, 'webhook_current_home'),
182
+ webhookCurrentAway: this.str(raw, 'webhook_current_away'),
183
+ webhookCurrentNight: this.str(raw, 'webhook_current_night'),
184
+ webhookCurrentOff: this.str(raw, 'webhook_current_off'),
185
+ webhookCurrentWarning: this.str(raw, 'webhook_current_warning'),
186
+ webhookCurrentTriggered: this.str(raw, 'webhook_current_triggered'),
187
+
188
+ // MQTT
189
+ mqttBroker: this.str(raw, 'mqtt_broker'),
190
+ mqttUsername: this.str(raw, 'mqtt_username'),
191
+ mqttPassword: this.str(raw, 'mqtt_password'),
192
+ mqttTopic: this.str(raw, 'mqtt_topic') ?? DEFAULTS.MQTT_TOPIC,
193
+ mqttClientId: this.str(raw, 'mqtt_client_id'),
194
+ };
195
+ }
196
+
197
+ // ── Post-parse validation ───────────────────────────────────────────────────
198
+
199
+ private validate(opts: SecuritySystemOptions): void {
200
+ if (opts.resetMinutes === 0) {
201
+ this.log.error('\'reset_minutes\' must be at least 1. Defaulting to 1.');
202
+ opts.resetMinutes = 1;
203
+ }
204
+
205
+ if (opts.serverPort !== null && (opts.serverPort < 0 || opts.serverPort > 65535)) {
206
+ this.log.error('\'server_port\' must be between 0 and 65535.');
207
+ }
208
+
209
+ if (opts.mqttBroker !== null && !/^mqtts?:\/\//.test(opts.mqttBroker)) {
210
+ this.log.warn('\'mqtt_broker\' should start with mqtt:// or mqtts://');
211
+ }
212
+ }
213
+
214
+ // ── Post-parse normalisation ────────────────────────────────────────────────
215
+
216
+ private normalize(opts: SecuritySystemOptions): void {
217
+ opts.defaultMode = opts.defaultMode.toLowerCase();
218
+
219
+ if (opts.testMode) {
220
+ opts.webhookCurrentTriggered = null;
221
+ opts.commandCurrentTriggered = null;
222
+ }
223
+ }
224
+ }
@@ -0,0 +1,21 @@
1
+ import { EventEmitter } from 'events';
2
+ import { EventType } from '../types/event-type.js';
3
+ import type { EventPayloadMap } from '../types/event-payload-map-type.js';
4
+
5
+ /**
6
+ * Typed event bus used to decouple side-effect services (commands,
7
+ * webhooks, storage) from the core state-machine handlers.
8
+ */
9
+ export class EventBusService extends EventEmitter {
10
+ emit<K extends EventType>(event: K, payload: EventPayloadMap[K]): boolean {
11
+ return super.emit(event, payload);
12
+ }
13
+
14
+ on<K extends EventType>(event: K, listener: (payload: EventPayloadMap[K]) => void): this {
15
+ return super.on(event, listener);
16
+ }
17
+
18
+ off<K extends EventType>(event: K, listener: (payload: EventPayloadMap[K]) => void): this {
19
+ return super.off(event, listener);
20
+ }
21
+ }
@@ -0,0 +1,446 @@
1
+ import type { API, Logging, MatterAccessory } from 'homebridge';
2
+ import {
3
+ MATTER_ARMING_LOCK_SWITCH_KEYS,
4
+ MATTER_CUSTOM_TRIP_START_INDEX,
5
+ MATTER_MANUFACTURER,
6
+ MATTER_MODEL,
7
+ MATTER_MODE_SWITCH_KEYS,
8
+ MATTER_SENSOR_IDENTITIES,
9
+ MATTER_SWITCH_IDENTITIES,
10
+ MATTER_TRIP_SWITCH_KEYS,
11
+ } from '../constants/matter-constant.js';
12
+ import type { SensorHandler } from '../handlers/sensor-handler.js';
13
+ import type { SwitchHandler } from '../handlers/switch-handler.js';
14
+ import type { TripHandler } from '../handlers/trip-handler.js';
15
+ import type { SecuritySystemOptions } from '../interfaces/options-interface.js';
16
+ import type { SystemState } from '../interfaces/system-state-interface.js';
17
+ import type { ServiceResult } from '../types/service-result-type.js';
18
+ import type { EventBusService } from './event-bus-service.js';
19
+ import { EventType } from '../types/event-type.js';
20
+ import type { FixedMatterSwitchKey, MatterAccessoryKey } from '../types/matter-accessory-key-type.js';
21
+ import { OriginType } from '../types/origin-type.js';
22
+ import { SecurityState } from '../types/security-state-type.js';
23
+ import type { SensorKind } from '../types/sensor-kind-type.js';
24
+ import { capitalise, modeToState } from '../utils/state-util.js';
25
+
26
+ const PLUGIN_NAME = 'homebridge-securitysystem';
27
+ const PLATFORM_NAME = 'security-system';
28
+
29
+ /** Maps each Matter arming-lock switch key to its SystemState lock field. */
30
+ const LOCK_FIELD_BY_SWITCH_KEY: Record<(typeof MATTER_ARMING_LOCK_SWITCH_KEYS)[number], keyof SystemState['armingLocks']> = {
31
+ 'arming-lock': 'global',
32
+ 'arming-lock-home': 'home',
33
+ 'arming-lock-away': 'away',
34
+ 'arming-lock-night': 'night',
35
+ };
36
+
37
+ type MatterApi = NonNullable<API['matter']>;
38
+ type CommandResult = ServiceResult | null;
39
+
40
+ /**
41
+ * Publishes the optional switch and sensor accessories over Matter (HAP keeps
42
+ * the main SecuritySystem service and the motion sensors). Switches are
43
+ * Matter-only. The security system has no Matter device type, so every control
44
+ * is exposed as an OnOffSwitch and every sensor as a MotionSensor.
45
+ *
46
+ * Identities follow the `<device-type>-<index>` convention (`switch-1`,
47
+ * `motion-sensor-2`, …) and are fed through `api.matter.uuid.generate()` to
48
+ * produce stable UUIDs. Commands from the Matter controller call straight into
49
+ * the existing handlers; state changes reach it through bus events and
50
+ * `updateAccessoryState()`, mirroring the HAP characteristic updates.
51
+ */
52
+ export class MatterService {
53
+ private readonly accessoryUuidByKey = new Map<MatterAccessoryKey, string>();
54
+ private readonly sensorUuidByKind = new Map<SensorKind, string>();
55
+
56
+ // Last value pushed per accessory, so redundant updates are skipped
57
+ // (best practice: compare before updating).
58
+ private readonly pushedSwitchStateByKey = new Map<MatterAccessoryKey, boolean>();
59
+ private readonly pushedSensorStateByKind = new Map<SensorKind, boolean>();
60
+
61
+ private matter: MatterApi | undefined;
62
+
63
+ constructor(
64
+ private readonly log: Logging,
65
+ private readonly options: SecuritySystemOptions,
66
+ private readonly api: API,
67
+ private readonly state: SystemState,
68
+ private readonly switchHandler: SwitchHandler,
69
+ private readonly tripHandler: TripHandler,
70
+ private readonly sensorHandler: SensorHandler,
71
+ ) {}
72
+
73
+ attachToBus(bus: EventBusService): void {
74
+ bus.on(EventType.RESET_MODE_SWITCHES, () => this.pushModeSwitchesOff());
75
+ bus.on(EventType.UPDATE_MODE_SWITCHES, () => this.pushModeSwitchForTargetState());
76
+ bus.on(EventType.RESET_TRIP_SWITCHES, () => this.pushTripSwitchesOff());
77
+ bus.on(EventType.SENSOR_STATE_CHANGED, ({ sensor, value }) => this.pushSensorState(sensor, value));
78
+ // Trips started by the server/webhooks used to light the HAP trip switch;
79
+ // mirror that to the Matter trip switch so the cause stays visible.
80
+ bus.on(EventType.TRIGGER_FIRED, ({ origin }) => {
81
+ if (origin === OriginType.INTERNAL || origin === OriginType.EXTERNAL) {
82
+ this.pushSwitch('trip', true);
83
+ }
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Registers every configured Matter accessory and removes cached ones that are
89
+ * no longer configured. Accessories restored from Homebridge's cache are
90
+ * matched by UUID and adopted by the Matter server, so they are not
91
+ * re-registered. No-op when Matter is disabled on the bridge.
92
+ */
93
+ async registerAccessories(cachedAccessories: Map<string, MatterAccessory>): Promise<void> {
94
+ // The documented guard: past this point api.matter is guaranteed to be
95
+ // present (see "Checking Matter is available" in the plugin docs).
96
+ if (!this.api.isMatterEnabled()) {
97
+ this.log.info('Matter is not enabled on this bridge; skipping Matter accessories.');
98
+ return;
99
+ }
100
+ const matter = this.api.matter!;
101
+ this.matter = matter;
102
+
103
+ const desired = new Map<string, MatterAccessory>();
104
+ this.addModeSwitches(desired, matter);
105
+ this.addTripSwitches(desired, matter);
106
+ this.addArmingLockSwitches(desired, matter);
107
+ this.addMotionSensors(desired, matter);
108
+
109
+ const accessoriesToRegister = Array.from(desired.values())
110
+ .filter(accessory => !cachedAccessories.has(accessory.UUID));
111
+ if (accessoriesToRegister.length > 0) {
112
+ await matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessoriesToRegister);
113
+ this.log.info(`Matter accessories registered: ${accessoriesToRegister.map(a => a.displayName).join(', ')}`);
114
+ }
115
+
116
+ const staleAccessories = Array.from(cachedAccessories.values())
117
+ .filter(accessory => !desired.has(accessory.UUID));
118
+ if (staleAccessories.length > 0) {
119
+ await matter.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, staleAccessories);
120
+ this.log.info(`Matter accessories removed: ${staleAccessories.map(a => a.displayName).join(', ')}`);
121
+ }
122
+
123
+ // Declared cluster values are only first-run defaults. Force the controller's
124
+ // view to match the real system state: mode switches follow the target state,
125
+ // trip switches come up off.
126
+ this.syncInitialState();
127
+ }
128
+
129
+ // ── Accessory building ─────────────────────────────────────────────────────
130
+
131
+ private addModeSwitches(desired: Map<string, MatterAccessory>, matter: MatterApi): void {
132
+ const available = this.state.availableTargetStates;
133
+ if (this.options.modeSwitches) {
134
+ if (available.includes(SecurityState.HOME)) {
135
+ this.addOnOffSwitch(desired, matter, 'mode-home', this.options.modeHomeSwitchName,
136
+ value => this.switchHandler.setModeSwitch(SecurityState.HOME, value));
137
+ }
138
+ if (available.includes(SecurityState.AWAY)) {
139
+ this.addOnOffSwitch(desired, matter, 'mode-away', this.options.modeAwaySwitchName,
140
+ value => this.switchHandler.setModeSwitch(SecurityState.AWAY, value));
141
+ }
142
+ if (available.includes(SecurityState.NIGHT)) {
143
+ this.addOnOffSwitch(desired, matter, 'mode-night', this.options.modeNightSwitchName,
144
+ value => this.switchHandler.setModeSwitch(SecurityState.NIGHT, value));
145
+ }
146
+ if (this.options.modeOffSwitch) {
147
+ this.addOnOffSwitch(desired, matter, 'mode-off', this.options.modeOffSwitchName,
148
+ value => this.switchHandler.setModeOffSwitch(value));
149
+ }
150
+ }
151
+ if (this.options.modeAwayExtendedSwitch) {
152
+ this.addOnOffSwitch(desired, matter, 'mode-away-extended', this.options.modeAwayExtendedSwitchName,
153
+ value => this.switchHandler.setModeAwayExtendedSwitch(value));
154
+ }
155
+ if (this.options.modePauseSwitch) {
156
+ this.addOnOffSwitch(desired, matter, 'mode-pause', this.options.modePauseSwitchName,
157
+ value => this.switchHandler.setModePauseSwitch(value));
158
+ }
159
+ }
160
+
161
+ private addTripSwitches(desired: Map<string, MatterAccessory>, matter: MatterApi): void {
162
+ if (this.options.tripSwitch) {
163
+ this.addOnOffSwitch(desired, matter, 'trip', this.options.tripSwitchName,
164
+ value => this.tripHandler.updateTripSwitch(value, OriginType.REGULAR_SWITCH, false));
165
+ }
166
+ if (this.options.tripOverrideSwitch) {
167
+ this.addOnOffSwitch(desired, matter, 'trip-override', this.options.tripOverrideSwitchName,
168
+ value => this.tripHandler.updateTripSwitch(value, OriginType.OVERRIDE_SWITCH, false));
169
+ }
170
+ if (this.options.tripModeSwitches) {
171
+ const modeTrips: Array<[MatterAccessoryKey, SecurityState, string]> = [
172
+ ['trip-home', SecurityState.HOME, this.options.tripHomeSwitchName],
173
+ ['trip-away', SecurityState.AWAY, this.options.tripAwaySwitchName],
174
+ ['trip-night', SecurityState.NIGHT, this.options.tripNightSwitchName],
175
+ ];
176
+ for (const [key, mode, displayName] of modeTrips) {
177
+ if (this.state.availableTargetStates.includes(mode)) {
178
+ this.addOnOffSwitch(desired, matter, key, displayName,
179
+ value => this.tripHandler.triggerIfModeSet(mode, value));
180
+ }
181
+ }
182
+ }
183
+
184
+ const customGroups: Array<['home' | 'away' | 'night', { label: string }[]]> = [
185
+ ['home', this.options.tripHomeSwitches],
186
+ ['away', this.options.tripAwaySwitches],
187
+ ['night', this.options.tripNightSwitches],
188
+ ];
189
+ let customSwitchIndex = MATTER_CUSTOM_TRIP_START_INDEX;
190
+ for (const [modeLabel, tripSwitches] of customGroups) {
191
+ const mode = modeToState(modeLabel);
192
+ if (!this.state.availableTargetStates.includes(mode)) {
193
+ continue;
194
+ }
195
+ tripSwitches.forEach((tripSwitch, index) => {
196
+ const key = `trip-custom-${modeLabel}-${index}` as MatterAccessoryKey;
197
+ this.addOnOffSwitch(desired, matter, key,
198
+ `Trip ${capitalise(modeLabel)} ${tripSwitch.label}`,
199
+ value => this.tripHandler.triggerIfModeSet(mode, value),
200
+ `switch-${customSwitchIndex++}`);
201
+ });
202
+ }
203
+ }
204
+
205
+ private addArmingLockSwitches(desired: Map<string, MatterAccessory>, matter: MatterApi): void {
206
+ if (this.options.armingLockSwitch) {
207
+ this.addOnOffSwitch(desired, matter, 'arming-lock', 'Arming Lock',
208
+ value => this.switchHandler.updateArmingLock('global', value));
209
+ }
210
+ if (this.options.armingLockSwitches) {
211
+ const modeLocks: Array<[MatterAccessoryKey, string, string]> = [
212
+ ['arming-lock-home', 'home', 'Arming Lock Home'],
213
+ ['arming-lock-away', 'away', 'Arming Lock Away'],
214
+ ['arming-lock-night', 'night', 'Arming Lock Night'],
215
+ ];
216
+ for (const [key, mode, displayName] of modeLocks) {
217
+ this.addOnOffSwitch(desired, matter, key, displayName,
218
+ value => this.switchHandler.updateArmingLock(mode, value));
219
+ }
220
+ }
221
+ }
222
+
223
+ private addMotionSensors(desired: Map<string, MatterAccessory>, matter: MatterApi): void {
224
+ if (this.options.armingMotionSensor) {
225
+ this.addMotionSensor(desired, matter, 'arming', 'Arming');
226
+ }
227
+ if (this.options.trippedMotionSensor) {
228
+ this.addMotionSensor(desired, matter, 'tripped', 'Tripped');
229
+ }
230
+ if (this.options.resetSensor) {
231
+ this.addMotionSensor(desired, matter, 'reset', 'Triggered Reset');
232
+ }
233
+ }
234
+
235
+ private addOnOffSwitch(
236
+ desired: Map<string, MatterAccessory>,
237
+ matter: MatterApi,
238
+ key: MatterAccessoryKey,
239
+ displayName: string,
240
+ command: (value: boolean) => CommandResult,
241
+ identity: string = MATTER_SWITCH_IDENTITIES[key as FixedMatterSwitchKey],
242
+ ): void {
243
+ const uuid = this.uuidFor(matter, identity);
244
+ this.accessoryUuidByKey.set(key, uuid);
245
+
246
+ desired.set(uuid, {
247
+ UUID: uuid,
248
+ displayName,
249
+ deviceType: matter.deviceTypes.OnOffSwitch,
250
+ serialNumber: identity,
251
+ manufacturer: MATTER_MANUFACTURER,
252
+ model: MATTER_MODEL,
253
+ context: { key, identity },
254
+ clusters: { onOff: { onOff: false } },
255
+ handlers: {
256
+ onOff: {
257
+ on: () => this.runCommand(matter, key, command, true),
258
+ off: () => this.runCommand(matter, key, command, false),
259
+ },
260
+ },
261
+ });
262
+ }
263
+
264
+ /** Sensors declare no handlers — they only push readings with updateAccessoryState. */
265
+ private addMotionSensor(
266
+ desired: Map<string, MatterAccessory>,
267
+ matter: MatterApi,
268
+ kind: SensorKind,
269
+ displayName: string,
270
+ ): void {
271
+ const identity = MATTER_SENSOR_IDENTITIES[kind];
272
+ const uuid = this.uuidFor(matter, identity);
273
+ this.sensorUuidByKind.set(kind, uuid);
274
+
275
+ desired.set(uuid, {
276
+ UUID: uuid,
277
+ displayName,
278
+ deviceType: matter.deviceTypes.MotionSensor,
279
+ serialNumber: identity,
280
+ manufacturer: MATTER_MANUFACTURER,
281
+ model: MATTER_MODEL,
282
+ context: { key: `sensor-${kind}` as MatterAccessoryKey, identity },
283
+ clusters: {
284
+ occupancySensing: {
285
+ occupancy: { occupied: false },
286
+ occupancySensorTypeBitmap: { pir: true },
287
+ },
288
+ },
289
+ });
290
+ }
291
+
292
+ // ── Command handling ───────────────────────────────────────────────────────
293
+
294
+ /**
295
+ * Runs a controller command, translating failures into Matter status errors:
296
+ * a blocked action throws a specific status class, and any unexpected error
297
+ * from the underlying handler becomes a generic Failure instead of escaping
298
+ * as an untyped error (matter-errors best practice).
299
+ */
300
+ private runCommand(
301
+ matter: MatterApi,
302
+ key: MatterAccessoryKey,
303
+ command: (value: boolean) => CommandResult,
304
+ value: boolean,
305
+ ): void {
306
+ try {
307
+ this.throwIfBlocked(matter, command(value));
308
+ // Homebridge auto-updates the onOff attribute after a successful command.
309
+ // Record that so a later reset event sees the change and pushes it back.
310
+ this.pushedSwitchStateByKey.set(key, value);
311
+ } catch (error) {
312
+ this.log.debug(`Matter command failed (${key}): ${String(error)}`);
313
+ this.rethrowAsMatterError(matter, error);
314
+ }
315
+ }
316
+
317
+ private throwIfBlocked(matter: MatterApi, result: CommandResult): void {
318
+ if (result === null) {
319
+ return;
320
+ }
321
+ if (!result.success) {
322
+ const reason = result.reason ?? 'Action not allowed in the current state';
323
+ if (reason === 'target mode is disabled') {
324
+ throw new matter.status.ConstraintError(reason);
325
+ }
326
+ throw new matter.status.InvalidInState(reason);
327
+ }
328
+ }
329
+
330
+ private rethrowAsMatterError(matter: MatterApi, error: unknown): never {
331
+ if (matter.status.isMatterProtocolError(error)) {
332
+ throw error;
333
+ }
334
+ throw new matter.status.Failure(error instanceof Error ? error.message : String(error));
335
+ }
336
+
337
+ // ── State pushing ──────────────────────────────────────────────────────────
338
+
339
+ private pushModeSwitchesOff(): void {
340
+ for (const key of MATTER_MODE_SWITCH_KEYS) {
341
+ this.pushSwitch(key, false);
342
+ }
343
+ }
344
+
345
+ private pushModeSwitchForTargetState(): void {
346
+ const key = this.modeSwitchKeyForTarget();
347
+ if (key) {
348
+ this.pushSwitch(key, true);
349
+ }
350
+ }
351
+
352
+ private modeSwitchKeyForTarget(): MatterAccessoryKey | null {
353
+ const modeMap: Partial<Record<SecurityState, MatterAccessoryKey>> = {
354
+ [SecurityState.HOME]: 'mode-home',
355
+ [SecurityState.AWAY]: 'mode-away',
356
+ [SecurityState.NIGHT]: 'mode-night',
357
+ [SecurityState.OFF]: 'mode-off',
358
+ };
359
+ return modeMap[this.state.targetState] ?? null;
360
+ }
361
+
362
+ private pushTripSwitchesOff(): void {
363
+ for (const key of MATTER_TRIP_SWITCH_KEYS) {
364
+ this.pushSwitch(key, false);
365
+ }
366
+ for (const key of this.accessoryUuidByKey.keys()) {
367
+ if (key.startsWith('trip-custom-')) {
368
+ this.pushSwitch(key, false);
369
+ }
370
+ }
371
+ }
372
+
373
+ private pushLockSwitches(): void {
374
+ for (const key of MATTER_ARMING_LOCK_SWITCH_KEYS) {
375
+ this.pushSwitch(key, this.state.armingLocks[LOCK_FIELD_BY_SWITCH_KEY[key]]);
376
+ }
377
+ }
378
+
379
+ private pushSwitch(key: MatterAccessoryKey, value: boolean): void {
380
+ const uuid = this.accessoryUuidByKey.get(key);
381
+ if (!uuid || !this.matter) {
382
+ return;
383
+ }
384
+ if (this.pushedSwitchStateByKey.get(key) === value) {
385
+ return;
386
+ }
387
+ this.pushedSwitchStateByKey.set(key, value);
388
+ this.pushAccessoryState(uuid, this.matter.clusterNames.OnOff, { onOff: value });
389
+ }
390
+
391
+ private pushSensorState(sensor: SensorKind, value: boolean): void {
392
+ const uuid = this.sensorUuidByKind.get(sensor);
393
+ if (!uuid || !this.matter) {
394
+ return;
395
+ }
396
+ if (this.pushedSensorStateByKind.get(sensor) === value) {
397
+ return;
398
+ }
399
+ this.pushedSensorStateByKind.set(sensor, value);
400
+ this.pushAccessoryState(uuid, this.matter.clusterNames.OccupancySensing, { occupancy: { occupied: value } });
401
+ }
402
+
403
+ /** Syncs every registered switch/sensor to its real state after registration. */
404
+ private syncInitialState(): void {
405
+ if (!this.matter) {
406
+ return;
407
+ }
408
+ const targetKey = this.modeSwitchKeyForTarget();
409
+ for (const key of MATTER_MODE_SWITCH_KEYS) {
410
+ if (this.accessoryUuidByKey.has(key)) {
411
+ this.pushSwitch(key, key === targetKey);
412
+ }
413
+ }
414
+ for (const key of MATTER_TRIP_SWITCH_KEYS) {
415
+ if (this.accessoryUuidByKey.has(key)) {
416
+ this.pushSwitch(key, false);
417
+ }
418
+ }
419
+ for (const key of this.accessoryUuidByKey.keys()) {
420
+ if (key.startsWith('trip-custom-')) {
421
+ this.pushSwitch(key, false);
422
+ }
423
+ }
424
+ this.pushLockSwitches();
425
+
426
+ // Sensors: push their current reading so a mid-pulse state at startup is
427
+ // not lost (declared cluster values are only first-run defaults).
428
+ for (const kind of ['arming', 'tripped', 'reset'] as const) {
429
+ if (this.sensorUuidByKind.has(kind)) {
430
+ this.pushSensorState(kind, this.sensorHandler.getMotionState(kind));
431
+ }
432
+ }
433
+ }
434
+
435
+ private pushAccessoryState(uuid: string, clusterName: string, attributes: Record<string, unknown>): void {
436
+ this.matter?.updateAccessoryState(uuid, clusterName, attributes).catch((error: unknown) => {
437
+ this.log.debug(`Matter state update failed (${uuid}): ${String(error)}`);
438
+ });
439
+ }
440
+
441
+ // ── Helpers ────────────────────────────────────────────────────────────────
442
+
443
+ private uuidFor(matter: MatterApi, identity: string): string {
444
+ return matter.uuid.generate(identity);
445
+ }
446
+ }