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,610 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { EventBusService } from '../services/event-bus-service.js';
3
+ import { MatterService } from '../services/matter-service.js';
4
+ import type { EventBusService as EventBusType } from '../services/event-bus-service.js';
5
+ import { EventType } from '../types/event-type.js';
6
+ import { OriginType } from '../types/origin-type.js';
7
+ import { SecurityState } from '../types/security-state-type.js';
8
+ import type { SystemState } from '../interfaces/system-state-interface.js';
9
+ import type { SecuritySystemOptions } from '../interfaces/options-interface.js';
10
+ import type { MatterAccessory } from 'homebridge';
11
+
12
+ // ── Mocks ─────────────────────────────────────────────────────────────────────
13
+
14
+ class InvalidInState extends Error {}
15
+ class ConstraintError extends Error {}
16
+ class Failure extends Error {}
17
+
18
+ function makeMatterMock() {
19
+ return {
20
+ uuid: { generate: vi.fn((seed: string) => `matter-uuid-${seed}`) },
21
+ deviceTypes: {
22
+ OnOffSwitch: { name: 'OnOffSwitch' },
23
+ MotionSensor: { name: 'MotionSensor' },
24
+ },
25
+ clusterNames: { OnOff: 'onOff', OccupancySensing: 'occupancySensing' },
26
+ status: {
27
+ InvalidInState,
28
+ ConstraintError,
29
+ Failure,
30
+ isMatterProtocolError: (error: unknown) =>
31
+ error instanceof InvalidInState || error instanceof ConstraintError || error instanceof Failure,
32
+ },
33
+ registerPlatformAccessories: vi.fn(async (_pluginIdentifier: string, _platformName: string, _accessories: MatterAccessory[]) => undefined),
34
+ unregisterPlatformAccessories: vi.fn(async (_pluginIdentifier: string, _platformName: string, _accessories: MatterAccessory[]) => undefined),
35
+ updateAccessoryState: vi.fn(async (_uuid: string, _cluster: string, _attributes: Record<string, unknown>) => undefined),
36
+ getAccessoryState: vi.fn(async () => undefined),
37
+ };
38
+ }
39
+
40
+ type MatterMock = ReturnType<typeof makeMatterMock>;
41
+
42
+ // ── Helpers ───────────────────────────────────────────────────────────────────
43
+
44
+ function makeLog() {
45
+ return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
46
+ }
47
+
48
+ function makeState(overrides: Partial<SystemState> = {}): SystemState {
49
+ return {
50
+ currentState: SecurityState.OFF,
51
+ targetState: SecurityState.OFF,
52
+ defaultState: SecurityState.OFF,
53
+ availableTargetStates: [SecurityState.HOME, SecurityState.AWAY, SecurityState.NIGHT, SecurityState.OFF],
54
+ isArming: false,
55
+ isTripping: false,
56
+ isKnocked: false,
57
+ serverAuthenticationAttempts: 0,
58
+ pausedCurrentState: null,
59
+ armingLocks: { global: false, home: false, away: false, night: false },
60
+ modeAwayExtended: false,
61
+ ...overrides,
62
+ };
63
+ }
64
+
65
+ function makeOptions(overrides: Partial<SecuritySystemOptions> = {}): SecuritySystemOptions {
66
+ return {
67
+ serialNumber: 'SEC-1',
68
+ tripSwitchName: 'Trip',
69
+ tripHomeSwitchName: 'Trip Home',
70
+ tripAwaySwitchName: 'Trip Away',
71
+ tripNightSwitchName: 'Trip Night',
72
+ tripOverrideSwitchName: 'Trip Override',
73
+ modeHomeSwitchName: 'Mode Home',
74
+ modeAwaySwitchName: 'Mode Away',
75
+ modeNightSwitchName: 'Mode Night',
76
+ modeOffSwitchName: 'Mode Off',
77
+ modeAwayExtendedSwitchName: 'Mode Away Extended',
78
+ modePauseSwitchName: 'Mode Pause',
79
+ modeSwitches: false,
80
+ modeOffSwitch: false,
81
+ modePauseSwitch: false,
82
+ modeAwayExtendedSwitch: false,
83
+ tripSwitch: false,
84
+ tripOverrideSwitch: false,
85
+ tripModeSwitches: false,
86
+ tripHomeSwitches: [],
87
+ tripAwaySwitches: [],
88
+ tripNightSwitches: [],
89
+ armingLockSwitch: false,
90
+ armingLockSwitches: false,
91
+ armingMotionSensor: false,
92
+ trippedMotionSensor: false,
93
+ resetSensor: false,
94
+ ...overrides,
95
+ } as unknown as SecuritySystemOptions;
96
+ }
97
+
98
+ function makeSwitchHandler() {
99
+ return {
100
+ setModeSwitch: vi.fn((_mode: SecurityState, _value: boolean) => ({ success: true })),
101
+ setModeOffSwitch: vi.fn((_value: boolean) => ({ success: true })),
102
+ setModeAwayExtendedSwitch: vi.fn((_value: boolean) => ({ success: true })),
103
+ setModePauseSwitch: vi.fn((_value: boolean) => ({ success: true })),
104
+ updateArmingLock: vi.fn((_mode: string, _value: boolean) => ({ success: true })),
105
+ };
106
+ }
107
+
108
+ function makeSensorHandler() {
109
+ return {
110
+ getMotionState: vi.fn(() => false),
111
+ };
112
+ }
113
+
114
+ function makeTripHandler() {
115
+ return {
116
+ updateTripSwitch: vi.fn((_value: boolean, _origin: OriginType, _stateChanged: boolean) => ({ success: true })),
117
+ triggerIfModeSet: vi.fn((_mode: SecurityState, _value: boolean) => ({ success: true })),
118
+ };
119
+ }
120
+
121
+ function makeService(options: SecuritySystemOptions, state: SystemState, switchHandler = makeSwitchHandler(), tripHandler = makeTripHandler(), sensorHandler = makeSensorHandler()) {
122
+ const log = makeLog();
123
+ const matter = makeMatterMock();
124
+ const api = { matter, isMatterEnabled: () => true } as never;
125
+ const service = new MatterService(log as never, options, api, state, switchHandler as never, tripHandler as never, sensorHandler as never);
126
+ return { log, matter, service, switchHandler, tripHandler, sensorHandler };
127
+ }
128
+
129
+ function registeredAccessories(matter: MatterMock): MatterAccessory[] {
130
+ return matter.registerPlatformAccessories.mock.calls[0][2] as MatterAccessory[];
131
+ }
132
+
133
+ function accessoryByDisplayName(matter: MatterMock, displayName: string): MatterAccessory {
134
+ const accessory = registeredAccessories(matter).find(a => a.displayName === displayName);
135
+ if (!accessory) {
136
+ throw new Error(`No registered accessory with display name ${displayName}`);
137
+ }
138
+ return accessory;
139
+ }
140
+
141
+ /** Handlers are always present on switches the plugin registers. */
142
+ function switchOnHandler(accessory: MatterAccessory): () => void {
143
+ const onOff = accessory.handlers!.onOff!;
144
+ return onOff.on! as () => void;
145
+ }
146
+
147
+ function switchOffHandler(accessory: MatterAccessory): () => void {
148
+ const onOff = accessory.handlers!.onOff!;
149
+ return onOff.off! as () => void;
150
+ }
151
+
152
+ function countSwitchUpdates(matter: MatterMock, uuid: string, value: boolean): number {
153
+ return matter.updateAccessoryState.mock.calls.filter(
154
+ call => call[0] === uuid && (call[2] as { onOff?: boolean }).onOff === value,
155
+ ).length;
156
+ }
157
+
158
+ function makeBus(): EventBusType {
159
+ return new EventBusService();
160
+ }
161
+
162
+ // ── Tests ─────────────────────────────────────────────────────────────────────
163
+
164
+ describe('MatterService', () => {
165
+ let log: ReturnType<typeof makeLog>;
166
+
167
+ beforeEach(() => {
168
+ log = makeLog();
169
+ vi.clearAllMocks();
170
+ });
171
+
172
+ it('does nothing when Matter is not enabled, even with accessories configured', async () => {
173
+ const state = makeState();
174
+ const options = makeOptions({ tripSwitch: true });
175
+ const service = new MatterService(log as never, options,
176
+ { isMatterEnabled: () => false } as never,
177
+ state, makeSwitchHandler() as never, makeTripHandler() as never, makeSensorHandler() as never);
178
+
179
+ await service.registerAccessories(new Map());
180
+
181
+ expect(log.info).toHaveBeenCalledWith('Matter is not enabled on this bridge; skipping Matter accessories.');
182
+ });
183
+
184
+ it('registers when Matter is enabled on the bridge', async () => {
185
+ const state = makeState();
186
+ const options = makeOptions({ tripSwitch: true });
187
+ const matter = makeMatterMock();
188
+ const service = new MatterService(log as never,
189
+ options, { matter, isMatterEnabled: () => true } as never,
190
+ state, makeSwitchHandler() as never, makeTripHandler() as never, makeSensorHandler() as never);
191
+
192
+ await service.registerAccessories(new Map());
193
+
194
+ expect(matter.registerPlatformAccessories).toHaveBeenCalledTimes(1);
195
+ });
196
+
197
+ it('registers all configured switches and sensors', async () => {
198
+ const state = makeState();
199
+ const options = makeOptions({
200
+ modeSwitches: true,
201
+ modeOffSwitch: true,
202
+ modeAwayExtendedSwitch: true,
203
+ modePauseSwitch: true,
204
+ tripSwitch: true,
205
+ tripOverrideSwitch: true,
206
+ tripModeSwitches: true,
207
+ armingLockSwitch: true,
208
+ armingLockSwitches: true,
209
+ armingMotionSensor: true,
210
+ trippedMotionSensor: true,
211
+ resetSensor: true,
212
+ tripHomeSwitches: [{ label: 'Kitchen' }],
213
+ });
214
+ const { log, matter, service } = makeService(options, state);
215
+
216
+ await service.registerAccessories(new Map());
217
+
218
+ expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Matter accessories registered'));
219
+ expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Mode Home'));
220
+ expect(matter.registerPlatformAccessories).toHaveBeenCalledTimes(1);
221
+ const accessories = registeredAccessories(matter);
222
+ const names = accessories.map(a => a.displayName);
223
+ expect(names).toContain('Mode Home');
224
+ expect(names).toContain('Mode Away');
225
+ expect(names).toContain('Mode Night');
226
+ expect(names).toContain('Mode Off');
227
+ expect(names).toContain('Mode Away Extended');
228
+ expect(names).toContain('Mode Pause');
229
+ expect(names).toContain('Trip');
230
+ expect(names).toContain('Trip Override');
231
+ expect(names).toContain('Trip Home');
232
+ expect(names).toContain('Trip Away');
233
+ expect(names).toContain('Trip Night');
234
+ expect(names).toContain('Trip Home Kitchen');
235
+ expect(names).toContain('Arming Lock');
236
+ expect(names).toContain('Arming Lock Home');
237
+ expect(names).toContain('Arming');
238
+ expect(names).toContain('Tripped');
239
+ expect(names).toContain('Triggered Reset');
240
+
241
+ const modeHome = accessoryByDisplayName(matter, 'Mode Home');
242
+ expect(modeHome.deviceType).toBe(matter.deviceTypes.OnOffSwitch);
243
+ expect(modeHome.clusters).toEqual({ onOff: { onOff: false } });
244
+ expect(modeHome.handlers?.onOff?.on).toBeDefined();
245
+
246
+ const armingSensor = accessoryByDisplayName(matter, 'Arming');
247
+ expect(armingSensor.deviceType).toBe(matter.deviceTypes.MotionSensor);
248
+ expect(armingSensor.clusters).toEqual({
249
+ occupancySensing: { occupancy: { occupied: false }, occupancySensorTypeBitmap: { pir: true } },
250
+ });
251
+ expect(armingSensor.UUID).toMatch(/^matter-uuid-motion-sensor-1$/);
252
+ });
253
+
254
+ it('skips accessories disabled in options and disabled modes', async () => {
255
+ const state = makeState({ availableTargetStates: [SecurityState.AWAY, SecurityState.OFF] });
256
+ const options = makeOptions({ tripSwitch: true, tripHomeSwitches: [{ label: 'Kitchen' }] });
257
+ const { matter, service } = makeService(options, state);
258
+
259
+ await service.registerAccessories(new Map());
260
+
261
+ const names = registeredAccessories(matter).map(a => a.displayName);
262
+ expect(names).toEqual(['Trip']);
263
+ });
264
+
265
+ it('does not re-register cached accessories and removes stale ones', async () => {
266
+ const state = makeState();
267
+ const options = makeOptions({ tripSwitch: true });
268
+ const { matter, service } = makeService(options, state);
269
+
270
+ const cached = new Map<string, MatterAccessory>([
271
+ ['matter-uuid-switch-1', { UUID: 'matter-uuid-switch-1' } as unknown as MatterAccessory],
272
+ ['stale-uuid', { UUID: 'stale-uuid' } as unknown as MatterAccessory],
273
+ ]);
274
+
275
+ await service.registerAccessories(cached);
276
+
277
+ expect(matter.registerPlatformAccessories).not.toHaveBeenCalled();
278
+ expect(matter.unregisterPlatformAccessories).toHaveBeenCalledWith(
279
+ 'homebridge-securitysystem',
280
+ 'security-system',
281
+ [cached.get('stale-uuid')],
282
+ );
283
+ });
284
+
285
+ it('uses device-type-indexed identities for the uuid and serial number', async () => {
286
+ const state = makeState();
287
+ const options = makeOptions({ modeSwitches: true, trippedMotionSensor: true });
288
+ const { matter, service } = makeService(options, state);
289
+
290
+ await service.registerAccessories(new Map());
291
+
292
+ const modeHome = accessoryByDisplayName(matter, 'Mode Home');
293
+ expect(modeHome.UUID).toBe('matter-uuid-switch-6');
294
+ expect(modeHome.serialNumber).toBe('switch-6');
295
+ expect(modeHome.context).toEqual({ key: 'mode-home', identity: 'switch-6' });
296
+
297
+ const trippedSensor = accessoryByDisplayName(matter, 'Tripped');
298
+ expect(trippedSensor.UUID).toBe('matter-uuid-motion-sensor-2');
299
+ expect(trippedSensor.serialNumber).toBe('motion-sensor-2');
300
+ });
301
+
302
+ it('routes mode switch commands to SwitchHandler and blocks with InvalidInState', async () => {
303
+ const state = makeState();
304
+ const options = makeOptions({ modeSwitches: true });
305
+ const switchHandler = makeSwitchHandler();
306
+ switchHandler.setModeSwitch = vi.fn((mode: SecurityState, value: boolean) =>
307
+ mode === SecurityState.HOME && value ? { success: true } : { success: false, reason: 'a mode switch can only be turned on' });
308
+
309
+ const { log, matter, service } = makeService(options, state, switchHandler, makeTripHandler());
310
+
311
+ await service.registerAccessories(new Map());
312
+
313
+ const modeHome = accessoryByDisplayName(matter, 'Mode Home');
314
+ const onHandler = switchOnHandler(modeHome);
315
+ const offHandler = switchOffHandler(modeHome);
316
+
317
+ expect(() => onHandler()).not.toThrow();
318
+ expect(switchHandler.setModeSwitch).toHaveBeenCalledWith(SecurityState.HOME, true);
319
+
320
+ expect(() => offHandler()).toThrow();
321
+ expect(switchHandler.setModeSwitch).toHaveBeenCalledWith(SecurityState.HOME, false);
322
+ expect(log.debug).toHaveBeenCalledWith(expect.stringContaining('Matter command failed (mode-home)'));
323
+ });
324
+
325
+ it('routes trip switch commands to TripHandler with the right origin', async () => {
326
+ const state = makeState();
327
+ const options = makeOptions({ tripSwitch: true, tripOverrideSwitch: true, tripModeSwitches: true });
328
+ const tripHandler = makeTripHandler();
329
+ const { matter, service } = makeService(options, state, makeSwitchHandler(), tripHandler);
330
+
331
+ await service.registerAccessories(new Map());
332
+
333
+ const trip = accessoryByDisplayName(matter, 'Trip');
334
+ switchOnHandler(trip)();
335
+ expect(tripHandler.updateTripSwitch).toHaveBeenCalledWith(true, OriginType.REGULAR_SWITCH, false);
336
+
337
+ const tripHome = accessoryByDisplayName(matter, 'Trip Home');
338
+ switchOnHandler(tripHome)();
339
+ expect(tripHandler.triggerIfModeSet).toHaveBeenCalledWith(SecurityState.HOME, true);
340
+
341
+ const tripOverride = accessoryByDisplayName(matter, 'Trip Override');
342
+ switchOnHandler(tripOverride)();
343
+ expect(tripHandler.updateTripSwitch).toHaveBeenCalledWith(true, OriginType.OVERRIDE_SWITCH, false);
344
+ });
345
+
346
+ it('throws InvalidInState when a trip is blocked by a condition', async () => {
347
+ const state = makeState();
348
+ const options = makeOptions({ tripSwitch: true });
349
+ const tripHandler = makeTripHandler();
350
+ tripHandler.updateTripSwitch = vi.fn(() => ({ success: false, reason: 'system is not armed' }));
351
+
352
+ const { matter, service } = makeService(options, state, makeSwitchHandler(), tripHandler);
353
+
354
+ await service.registerAccessories(new Map());
355
+
356
+ const trip = accessoryByDisplayName(matter, 'Trip');
357
+ expect(() => switchOnHandler(trip)()).toThrowError('system is not armed');
358
+ });
359
+
360
+ it('routes arming lock commands to SwitchHandler', async () => {
361
+ const state = makeState();
362
+ const options = makeOptions({ armingLockSwitch: true, armingLockSwitches: true });
363
+ const switchHandler = makeSwitchHandler();
364
+ const { matter, service } = makeService(options, state, switchHandler, makeTripHandler());
365
+
366
+ await service.registerAccessories(new Map());
367
+
368
+ const globalLock = accessoryByDisplayName(matter, 'Arming Lock');
369
+ switchOnHandler(globalLock)();
370
+ expect(switchHandler.updateArmingLock).toHaveBeenCalledWith('global', true);
371
+
372
+ const homeLock = accessoryByDisplayName(matter, 'Arming Lock Home');
373
+ switchOffHandler(homeLock)();
374
+ expect(switchHandler.updateArmingLock).toHaveBeenCalledWith('home', false);
375
+ });
376
+
377
+ it('pushes mode switch state on bus events, only when the value changed', async () => {
378
+ const state = makeState({ targetState: SecurityState.HOME });
379
+ const options = makeOptions({ modeSwitches: true, modeOffSwitch: true });
380
+ const { matter, service } = makeService(options, state);
381
+
382
+ const bus = makeBus();
383
+ service.attachToBus(bus);
384
+ await service.registerAccessories(new Map());
385
+
386
+ // Startup sync: mode-home on, every other mode switch off.
387
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-6', true)).toBe(1);
388
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-6', false)).toBe(0);
389
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-7', false)).toBe(1);
390
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-9', false)).toBe(1);
391
+
392
+ bus.emit(EventType.RESET_MODE_SWITCHES, {});
393
+
394
+ // Only mode-home changed (on -> off); the others stay untouched.
395
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-6', true)).toBe(1);
396
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-6', false)).toBe(1);
397
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-7', false)).toBe(1);
398
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-9', false)).toBe(1);
399
+
400
+ bus.emit(EventType.UPDATE_MODE_SWITCHES, {});
401
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-6', true)).toBe(2);
402
+
403
+ // Redundant re-emission pushes nothing new.
404
+ bus.emit(EventType.UPDATE_MODE_SWITCHES, {});
405
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-6', true)).toBe(2);
406
+ });
407
+
408
+ it('resets trip switches to off at startup and after a controller trip', async () => {
409
+ const state = makeState();
410
+ const options = makeOptions({ tripSwitch: true, tripModeSwitches: true, tripHomeSwitches: [{ label: 'Kitchen' }] });
411
+ const { matter, service } = makeService(options, state);
412
+
413
+ const bus = makeBus();
414
+ service.attachToBus(bus);
415
+ await service.registerAccessories(new Map());
416
+
417
+ // Startup sync pushes every trip switch off (Homebridge would auto-update
418
+ // onOff after a trip, so the reset must be able to push it back).
419
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-1', false)).toBe(1);
420
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-16', false)).toBe(1);
421
+
422
+ // A controller trip turns the switch on (auto-updated by Homebridge and
423
+ // recorded by the service as if it had happened).
424
+ const trip = accessoryByDisplayName(matter, 'Trip');
425
+ switchOnHandler(trip)();
426
+
427
+ // The reset now sees a change and pushes the switch back off.
428
+ bus.emit(EventType.RESET_TRIP_SWITCHES, {});
429
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-1', false)).toBe(2);
430
+
431
+ // An unchanged trip switch is not pushed again.
432
+ bus.emit(EventType.RESET_TRIP_SWITCHES, {});
433
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-1', false)).toBe(2);
434
+ });
435
+
436
+ it('pushes sensor occupancy state on SENSOR_STATE_CHANGED', async () => {
437
+ const state = makeState();
438
+ const options = makeOptions({ trippedMotionSensor: true, resetSensor: true });
439
+ const { matter, service } = makeService(options, state);
440
+
441
+ const bus = makeBus();
442
+ service.attachToBus(bus);
443
+ await service.registerAccessories(new Map());
444
+
445
+ bus.emit(EventType.SENSOR_STATE_CHANGED, { sensor: 'tripped', value: true });
446
+ bus.emit(EventType.SENSOR_STATE_CHANGED, { sensor: 'tripped', value: true });
447
+ bus.emit(EventType.SENSOR_STATE_CHANGED, { sensor: 'reset', value: false });
448
+
449
+ expect(matter.updateAccessoryState).toHaveBeenCalledWith(
450
+ 'matter-uuid-motion-sensor-2',
451
+ 'occupancySensing',
452
+ { occupancy: { occupied: true } },
453
+ );
454
+ expect(matter.updateAccessoryState).toHaveBeenCalledWith(
455
+ 'matter-uuid-motion-sensor-3',
456
+ 'occupancySensing',
457
+ { occupancy: { occupied: false } },
458
+ );
459
+
460
+ // A repeated reading of the same value is not pushed again. The startup sync
461
+ // pushed an occupied:false once, so only the single occupied:true push counts.
462
+ const occupiedPushes = matter.updateAccessoryState.mock.calls.filter(
463
+ call => call[0] === 'matter-uuid-motion-sensor-2'
464
+ && (call[2] as { occupancy?: { occupied?: boolean } }).occupancy?.occupied === true,
465
+ );
466
+ expect(occupiedPushes).toHaveLength(1);
467
+ });
468
+
469
+ it('syncs every registered mode switch to the current target state after registration', async () => {
470
+ const state = makeState({ targetState: SecurityState.AWAY });
471
+ const options = makeOptions({ modeSwitches: true });
472
+ const { matter, service } = makeService(options, state);
473
+
474
+ await service.registerAccessories(new Map());
475
+
476
+ // mode-away (switch-7) on, mode-home (switch-6) off.
477
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-7', true)).toBe(1);
478
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-6', false)).toBe(1);
479
+ });
480
+
481
+ it('translates unexpected handler errors into a Matter Failure', async () => {
482
+ const state = makeState();
483
+ const options = makeOptions({ modeSwitches: true });
484
+ const switchHandler = makeSwitchHandler();
485
+ switchHandler.setModeSwitch = vi.fn(() => {
486
+ throw new Error('device exploded');
487
+ });
488
+
489
+ const { matter, service } = makeService(options, state, switchHandler, makeTripHandler());
490
+
491
+ await service.registerAccessories(new Map());
492
+
493
+ const modeHome = accessoryByDisplayName(matter, 'Mode Home');
494
+ let caught: unknown;
495
+ try {
496
+ switchOnHandler(modeHome)();
497
+ } catch (error) {
498
+ caught = error;
499
+ }
500
+
501
+ expect(caught).toBeInstanceOf(Failure);
502
+ expect((caught as Error).message).toBe('device exploded');
503
+ });
504
+
505
+ it('keeps already-mapped Matter protocol errors intact', async () => {
506
+ const state = makeState();
507
+ const options = makeOptions({ modeSwitches: true });
508
+ const switchHandler = makeSwitchHandler();
509
+ const blocked = new InvalidInState('still arming');
510
+ switchHandler.setModeSwitch = vi.fn(() => {
511
+ throw blocked;
512
+ });
513
+
514
+ const { matter, service } = makeService(options, state, switchHandler, makeTripHandler());
515
+
516
+ await service.registerAccessories(new Map());
517
+
518
+ const modeHome = accessoryByDisplayName(matter, 'Mode Home');
519
+ let caught: unknown;
520
+ try {
521
+ switchOnHandler(modeHome)();
522
+ } catch (error) {
523
+ caught = error;
524
+ }
525
+
526
+ expect(caught).toBe(blocked);
527
+ });
528
+
529
+ it('keeps the controller state honest when a command is refused', async () => {
530
+ const state = makeState();
531
+ const options = makeOptions({ modeSwitches: true });
532
+ const switchHandler = makeSwitchHandler();
533
+ switchHandler.setModeSwitch = vi.fn(() => ({ success: false, reason: 'arming is blocked by an arming lock switch' }));
534
+ const { matter, service } = makeService(options, state, switchHandler, makeTripHandler());
535
+
536
+ await service.registerAccessories(new Map());
537
+
538
+ const modeHome = accessoryByDisplayName(matter, 'Mode Home');
539
+ expect(() => switchOnHandler(modeHome)()).toThrow();
540
+
541
+ // The startup sync pushed the switch off; the refused command must not
542
+ // record it as on, otherwise the switch would appear stuck on.
543
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-6', true)).toBe(0);
544
+ });
545
+
546
+ it('syncs arming-lock switches from state at registration', async () => {
547
+ const state = makeState({ armingLocks: { global: true, home: false, away: false, night: false } });
548
+ const options = makeOptions({ armingLockSwitch: true, armingLockSwitches: true });
549
+ const { matter, service } = makeService(options, state);
550
+
551
+ await service.registerAccessories(new Map());
552
+
553
+ // global lock (switch-12) on, mode locks (switch-13..15) off.
554
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-12', true)).toBe(1);
555
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-12', false)).toBe(0);
556
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-13', false)).toBe(1);
557
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-14', false)).toBe(1);
558
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-15', false)).toBe(1);
559
+ });
560
+
561
+ it('pushes the current sensor reading at registration', async () => {
562
+ const state = makeState();
563
+ const options = makeOptions({ trippedMotionSensor: true, resetSensor: true });
564
+ const sensorHandler = makeSensorHandler();
565
+ // Sync loop only asks for configured sensors: tripped first, then reset.
566
+ sensorHandler.getMotionState.mockReturnValueOnce(true)
567
+ .mockReturnValueOnce(false);
568
+ const { matter, service } = makeService(options, state, makeSwitchHandler(), makeTripHandler(), sensorHandler);
569
+
570
+ await service.registerAccessories(new Map());
571
+
572
+ expect(matter.updateAccessoryState).toHaveBeenCalledWith(
573
+ 'matter-uuid-motion-sensor-2', 'occupancySensing', { occupancy: { occupied: true } },
574
+ );
575
+ expect(matter.updateAccessoryState).toHaveBeenCalledWith(
576
+ 'matter-uuid-motion-sensor-3', 'occupancySensing', { occupancy: { occupied: false } },
577
+ );
578
+ });
579
+
580
+ it('pushes the trip switch on when a trip fires from the server or webhooks', async () => {
581
+ const state = makeState();
582
+ const options = makeOptions({ tripSwitch: true });
583
+ const { matter, service } = makeService(options, state);
584
+
585
+ const bus = makeBus();
586
+ service.attachToBus(bus);
587
+ await service.registerAccessories(new Map());
588
+
589
+ bus.emit(EventType.TRIGGER_FIRED, { origin: OriginType.EXTERNAL });
590
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-1', true)).toBe(1);
591
+
592
+ // Controller-initiated trips are auto-updated by Homebridge; nothing to push.
593
+ bus.emit(EventType.TRIGGER_FIRED, { origin: OriginType.REGULAR_SWITCH });
594
+ expect(countSwitchUpdates(matter, 'matter-uuid-switch-1', true)).toBe(1);
595
+ });
596
+
597
+ it('exposes pause and away-extended switches even when modeSwitches is off', async () => {
598
+ const state = makeState();
599
+ const options = makeOptions({ modeSwitches: false, modePauseSwitch: true, modeAwayExtendedSwitch: true });
600
+ const { matter, service } = makeService(options, state);
601
+
602
+ await service.registerAccessories(new Map());
603
+
604
+ const names = registeredAccessories(matter).map(a => a.displayName);
605
+ expect(names).toContain('Mode Pause');
606
+ expect(names).toContain('Mode Away Extended');
607
+ expect(names).not.toContain('Mode Home');
608
+ expect(names).not.toContain('Mode Off');
609
+ });
610
+ });