matterbridge-lg-thinq-plugin 0.1.0

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 (210) hide show
  1. package/.claude/CLAUDE.md +43 -0
  2. package/.claude/agents/compiler.md +91 -0
  3. package/.claude/agents/direct-executor.md +98 -0
  4. package/.claude/agents/documenter.md +78 -0
  5. package/.claude/agents/finalizer.md +233 -0
  6. package/.claude/agents/implementer.md +112 -0
  7. package/.claude/agents/investigator.md +100 -0
  8. package/.claude/agents/release-manager.md +203 -0
  9. package/.claude/agents/reviewer.md +131 -0
  10. package/.claude/agents/technical-architect.md +403 -0
  11. package/.claude/agents/test-writer.md +159 -0
  12. package/.claude/agents/wiki-manager.md +147 -0
  13. package/.claude/instructions/agent-prompts.md +90 -0
  14. package/.claude/instructions/shared-rules.md +11 -0
  15. package/.claude/instructions/team-orchestrator-policy.md +165 -0
  16. package/.claude/memory.md +186 -0
  17. package/.claude/output-styles/engineer-manager.md +38 -0
  18. package/.claude/settings.json +40 -0
  19. package/.claude/settings.local.json +30 -0
  20. package/.claude/skills/load-policy/SKILL.md +13 -0
  21. package/.claude/skills/ref-idea/SKILL.md +44 -0
  22. package/.claude/skills/status-of/SKILL.md +38 -0
  23. package/.claude/skills/workspace-console/SKILL.md +49 -0
  24. package/.claude/statusline-command.sh +130 -0
  25. package/.claude/statusline.md +53 -0
  26. package/.claude/templates/approval-packet.template.html +171 -0
  27. package/.claude/templates/reference-workspaces.md +16 -0
  28. package/.claude/tools/cli-runner-server.cjs +220 -0
  29. package/.claude/tools/discord-send-server.cjs +188 -0
  30. package/.claude/tools/github-release-server.cjs +181 -0
  31. package/.claude/tools/glob-grep-server.cjs +267 -0
  32. package/.claude/tools/read-log-server.cjs +175 -0
  33. package/.github/codeql/codeql-config.yml +14 -0
  34. package/.github/workflows/build.yml +54 -0
  35. package/.github/workflows/codeql.yml +59 -0
  36. package/.github/workflows/coverage.yml +70 -0
  37. package/.github/workflows/publish.yml +103 -0
  38. package/.mcp.json +34 -0
  39. package/CHANGELOG.md +195 -0
  40. package/LICENSE +21 -0
  41. package/dist/cli/commands/devices.js +52 -0
  42. package/dist/cli/commands/energy.js +122 -0
  43. package/dist/cli/commands/login.js +54 -0
  44. package/dist/cli/help.js +50 -0
  45. package/dist/cli/main.js +56 -0
  46. package/dist/cli/session.js +13 -0
  47. package/dist/cli/types.js +1 -0
  48. package/dist/cli/utils.js +29 -0
  49. package/dist/cli.js +2 -0
  50. package/dist/constants/index.js +1 -0
  51. package/dist/core/domain/entities/ThinqDevice.js +43 -0
  52. package/dist/core/domain/value-objects/AirConditionerCapabilities.js +22 -0
  53. package/dist/core/domain/value-objects/ThinqFilterState.js +35 -0
  54. package/dist/core/domain/value-objects/ThinqSnapshot.js +125 -0
  55. package/dist/errors/AuthenticationError.js +26 -0
  56. package/dist/errors/BaseError.js +23 -0
  57. package/dist/errors/index.js +2 -0
  58. package/dist/model/LgThinkqPluginPlatformConfig.js +23 -0
  59. package/dist/model/LgThinqPluginPlatformConfig.js +23 -0
  60. package/dist/module.js +212 -0
  61. package/dist/platform/deviceRegistry.js +26 -0
  62. package/dist/platform/platformConfigManager.js +89 -0
  63. package/dist/platform/platformState.js +9 -0
  64. package/dist/platform/thinq/thinqAirConditionerCommandHandlers.js +190 -0
  65. package/dist/platform/thinq/thinqAirConditionerEndpointFactory.js +53 -0
  66. package/dist/platform/thinq/thinqAirConditionerFilterResetCommandHandler.js +54 -0
  67. package/dist/platform/thinq/thinqAirConditionerFilterStateSync.js +19 -0
  68. package/dist/platform/thinq/thinqAirConditionerStateSync.js +205 -0
  69. package/dist/platform/thinq/thinqDeviceConfigReconciler.js +26 -0
  70. package/dist/platform/thinq/thinqDeviceConfigurator.js +124 -0
  71. package/dist/platform/thinq/thinqDeviceDiscovery.js +33 -0
  72. package/dist/platform/thinq/thinqWasherCommandHandlers.js +54 -0
  73. package/dist/platform/thinq/thinqWasherEndpointFactory.js +6 -0
  74. package/dist/platform/thinq/thinqWasherStateSync.js +31 -0
  75. package/dist/platform/thinq/thinqWasherStopCommandResolver.js +38 -0
  76. package/dist/services/authentication/AccountAuthStrategy.js +135 -0
  77. package/dist/services/authentication/AuthContext.js +1 -0
  78. package/dist/services/authentication/AuthenticationCoordinator.js +29 -0
  79. package/dist/services/authentication/IAuthStrategy.js +1 -0
  80. package/dist/services/authentication/TokenAuthStrategy.js +27 -0
  81. package/dist/services/authentication/UserDataRepository.js +32 -0
  82. package/dist/services/thinq/gateway.js +61 -0
  83. package/dist/services/thinq/mqtt/mqttCertificate.js +63 -0
  84. package/dist/services/thinq/mqtt/mqttConnectionEvents.js +37 -0
  85. package/dist/services/thinq/mqtt/mqttKeyRepository.js +33 -0
  86. package/dist/services/thinq/mqtt/mqttRetry.js +25 -0
  87. package/dist/services/thinq/mqtt/thinqMqttListener.js +97 -0
  88. package/dist/services/thinq/serviceContainer.js +73 -0
  89. package/dist/services/thinq/session.js +48 -0
  90. package/dist/services/thinq/thinqApiClient.js +258 -0
  91. package/dist/services/thinq/thinqDeviceService.js +75 -0
  92. package/dist/services/thinq/thinqFilterMonitoringService.js +40 -0
  93. package/dist/settings.js +9 -0
  94. package/dist/tests/helpers/testUtils.js +87 -0
  95. package/dist/utils/mask.js +39 -0
  96. package/docs/README_CLI.md +166 -0
  97. package/eslint.config.js +194 -0
  98. package/matterbridge-lg-thinq-plugin.config.json +27 -0
  99. package/matterbridge-lg-thinq-plugin.schema.json +520 -0
  100. package/package.json +107 -0
  101. package/prettier.config.js +49 -0
  102. package/scripts/parse-junit-failures.mjs +45 -0
  103. package/scripts/parse-tsc-failures.mjs +48 -0
  104. package/scripts/run-build-local-summary.mjs +29 -0
  105. package/scripts/run-format-summary.mjs +36 -0
  106. package/scripts/run-lint-fix-summary.mjs +38 -0
  107. package/scripts/run-precommit-summary.mjs +94 -0
  108. package/src/cli/commands/devices.ts +67 -0
  109. package/src/cli/commands/energy.ts +160 -0
  110. package/src/cli/commands/login.ts +74 -0
  111. package/src/cli/help.ts +53 -0
  112. package/src/cli/main.ts +66 -0
  113. package/src/cli/session.ts +15 -0
  114. package/src/cli/types.ts +10 -0
  115. package/src/cli/utils.ts +30 -0
  116. package/src/cli.ts +3 -0
  117. package/src/constants/index.ts +8 -0
  118. package/src/core/domain/entities/ThinqDevice.test.ts +224 -0
  119. package/src/core/domain/entities/ThinqDevice.ts +74 -0
  120. package/src/core/domain/value-objects/AirConditionerCapabilities.ts +50 -0
  121. package/src/core/domain/value-objects/ThinqFilterState.ts +50 -0
  122. package/src/core/domain/value-objects/ThinqSnapshot.ts +162 -0
  123. package/src/errors/AuthenticationError.ts +37 -0
  124. package/src/errors/BaseError.ts +38 -0
  125. package/src/errors/index.ts +24 -0
  126. package/src/model/LgThinqPluginPlatformConfig.ts +94 -0
  127. package/src/module.ts +298 -0
  128. package/src/platform/deviceRegistry.ts +58 -0
  129. package/src/platform/platformConfigManager.ts +133 -0
  130. package/src/platform/platformState.ts +16 -0
  131. package/src/platform/thinq/thinqAirConditionerCommandHandlers.ts +291 -0
  132. package/src/platform/thinq/thinqAirConditionerEndpointFactory.ts +145 -0
  133. package/src/platform/thinq/thinqAirConditionerFilterResetCommandHandler.ts +96 -0
  134. package/src/platform/thinq/thinqAirConditionerFilterStateSync.ts +42 -0
  135. package/src/platform/thinq/thinqAirConditionerStateSync.ts +343 -0
  136. package/src/platform/thinq/thinqDeviceConfigReconciler.ts +42 -0
  137. package/src/platform/thinq/thinqDeviceConfigurator.test.ts +90 -0
  138. package/src/platform/thinq/thinqDeviceConfigurator.ts +191 -0
  139. package/src/platform/thinq/thinqDeviceDiscovery.test.ts +173 -0
  140. package/src/platform/thinq/thinqDeviceDiscovery.ts +51 -0
  141. package/src/platform/thinq/thinqWasherCommandHandlers.ts +120 -0
  142. package/src/platform/thinq/thinqWasherEndpointFactory.ts +37 -0
  143. package/src/platform/thinq/thinqWasherStateSync.ts +60 -0
  144. package/src/platform/thinq/thinqWasherStopCommandResolver.ts +62 -0
  145. package/src/services/authentication/AccountAuthStrategy.test.ts +358 -0
  146. package/src/services/authentication/AccountAuthStrategy.ts +209 -0
  147. package/src/services/authentication/AuthContext.ts +19 -0
  148. package/src/services/authentication/AuthenticationCoordinator.test.ts +217 -0
  149. package/src/services/authentication/AuthenticationCoordinator.ts +59 -0
  150. package/src/services/authentication/IAuthStrategy.ts +10 -0
  151. package/src/services/authentication/TokenAuthStrategy.test.ts +167 -0
  152. package/src/services/authentication/TokenAuthStrategy.ts +40 -0
  153. package/src/services/authentication/UserDataRepository.test.ts +149 -0
  154. package/src/services/authentication/UserDataRepository.ts +46 -0
  155. package/src/services/thinq/gateway.ts +86 -0
  156. package/src/services/thinq/mqtt/mqttCertificate.test.ts +344 -0
  157. package/src/services/thinq/mqtt/mqttCertificate.ts +102 -0
  158. package/src/services/thinq/mqtt/mqttConnectionEvents.test.ts +276 -0
  159. package/src/services/thinq/mqtt/mqttConnectionEvents.ts +76 -0
  160. package/src/services/thinq/mqtt/mqttKeyRepository.test.ts +164 -0
  161. package/src/services/thinq/mqtt/mqttKeyRepository.ts +43 -0
  162. package/src/services/thinq/mqtt/mqttRetry.test.ts +239 -0
  163. package/src/services/thinq/mqtt/mqttRetry.ts +43 -0
  164. package/src/services/thinq/mqtt/thinqMqttListener.test.ts +349 -0
  165. package/src/services/thinq/mqtt/thinqMqttListener.ts +120 -0
  166. package/src/services/thinq/serviceContainer.test.ts +203 -0
  167. package/src/services/thinq/serviceContainer.ts +105 -0
  168. package/src/services/thinq/session.test.ts +287 -0
  169. package/src/services/thinq/session.ts +69 -0
  170. package/src/services/thinq/thinqApiClient.test.ts +702 -0
  171. package/src/services/thinq/thinqApiClient.ts +418 -0
  172. package/src/services/thinq/thinqDeviceService.test.ts +415 -0
  173. package/src/services/thinq/thinqDeviceService.ts +101 -0
  174. package/src/services/thinq/thinqFilterMonitoringService.test.ts +264 -0
  175. package/src/services/thinq/thinqFilterMonitoringService.ts +53 -0
  176. package/src/settings.ts +18 -0
  177. package/src/tests/cli/commands/devices.test.ts +293 -0
  178. package/src/tests/cli/commands/login.test.ts +240 -0
  179. package/src/tests/cli/help.test.ts +46 -0
  180. package/src/tests/cli/main.test.ts +214 -0
  181. package/src/tests/cli/session.test.ts +198 -0
  182. package/src/tests/cli/utils.test.ts +194 -0
  183. package/src/tests/core/domain/value-objects/AirConditionerCapabilities.test.ts +685 -0
  184. package/src/tests/core/domain/value-objects/ThinqFilterState.test.ts +210 -0
  185. package/src/tests/core/domain/value-objects/ThinqSnapshot.test.ts +1035 -0
  186. package/src/tests/helpers/testUtils.ts +118 -0
  187. package/src/tests/model/LgThinqPluginPlatformConfig.test.ts +81 -0
  188. package/src/tests/platform/deviceRegistry.test.ts +177 -0
  189. package/src/tests/platform/platformConfigManager.test.ts +790 -0
  190. package/src/tests/platform/platformState.test.ts +41 -0
  191. package/src/tests/platform/thinq/thinqAirConditionerCommandHandlers.test.ts +879 -0
  192. package/src/tests/platform/thinq/thinqAirConditionerEndpointFactory.test.ts +993 -0
  193. package/src/tests/platform/thinq/thinqAirConditionerFilterResetCommandHandler.test.ts +508 -0
  194. package/src/tests/platform/thinq/thinqAirConditionerFilterStateSync.test.ts +212 -0
  195. package/src/tests/platform/thinq/thinqAirConditionerStateSync.test.ts +1586 -0
  196. package/src/tests/platform/thinq/thinqDeviceConfigReconciler.test.ts +269 -0
  197. package/src/tests/platform/thinq/thinqDeviceConfigurator.test.ts +492 -0
  198. package/src/tests/platform/thinq/thinqWasherCommandHandlers.test.ts +594 -0
  199. package/src/tests/platform/thinq/thinqWasherEndpointFactory.test.ts +84 -0
  200. package/src/tests/platform/thinq/thinqWasherStateSync.test.ts +373 -0
  201. package/src/tests/platform/thinq/thinqWasherStopCommandResolver.test.ts +392 -0
  202. package/src/tests/settings.test.ts +33 -0
  203. package/src/utils/mask.ts +50 -0
  204. package/tsconfig.base.json +25 -0
  205. package/tsconfig.json +9 -0
  206. package/tsconfig.production.json +13 -0
  207. package/tsconfig.vitest.json +5 -0
  208. package/vite.config.ts +43 -0
  209. package/wiki/memory-archive.md +12 -0
  210. package/wiki/reference-workspaces.md +23 -0
@@ -0,0 +1,276 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { asPartial, createMockLogger } from '../../../tests/helpers/testUtils.js';
4
+ import {
5
+ MQTT_OFFLINE_RECONNECT_DELAY_MS,
6
+ MqttRuntimeDevice,
7
+ MqttRuntimeLogger,
8
+ wireMqttDeviceEvents,
9
+ } from './mqttConnectionEvents.js';
10
+
11
+ describe('mqttConnectionEvents', () => {
12
+ let mockDevice: MqttRuntimeDevice;
13
+ let mockLogger: MqttRuntimeLogger;
14
+ let deviceHandlers: Map<string, unknown[]>;
15
+
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ vi.useFakeTimers();
19
+ deviceHandlers = new Map();
20
+ mockLogger = createMockLogger();
21
+ mockDevice = asPartial<MqttRuntimeDevice>({
22
+ on: vi.fn((event: string, handler: unknown) => {
23
+ if (!deviceHandlers.has(event)) {
24
+ deviceHandlers.set(event, []);
25
+ }
26
+ deviceHandlers.get(event)?.push(handler);
27
+ }),
28
+ subscribe: vi.fn(),
29
+ end: vi.fn(),
30
+ });
31
+ });
32
+
33
+ afterEach(() => {
34
+ vi.clearAllTimers();
35
+ vi.useRealTimers();
36
+ vi.clearAllMocks();
37
+ });
38
+
39
+ describe('wireMqttDeviceEvents', () => {
40
+ it('should subscribe to all topics on connect event', () => {
41
+ const subscriptions = [
42
+ '$aws/things/device1/shadow/update/accepted',
43
+ '$aws/things/device1/shadow/update/rejected',
44
+ ];
45
+ const onMessage = vi.fn();
46
+ const reconnect = vi.fn();
47
+
48
+ wireMqttDeviceEvents({
49
+ device: mockDevice,
50
+ logger: mockLogger,
51
+ mqttServer: 'mqtt.example.com',
52
+ subscriptions,
53
+ onMessage,
54
+ reconnect,
55
+ });
56
+
57
+ const connectHandlers = deviceHandlers.get('connect') as (() => void)[];
58
+ expect(connectHandlers).toBeDefined();
59
+ connectHandlers[0]();
60
+
61
+ expect(mockDevice.subscribe).toHaveBeenCalledWith(subscriptions[0]);
62
+ expect(mockDevice.subscribe).toHaveBeenCalledWith(subscriptions[1]);
63
+ expect(mockDevice.subscribe).toHaveBeenCalledTimes(2);
64
+ });
65
+
66
+ it('should log info and debug on connect', () => {
67
+ const onMessage = vi.fn();
68
+ const reconnect = vi.fn();
69
+
70
+ wireMqttDeviceEvents({
71
+ device: mockDevice,
72
+ logger: mockLogger,
73
+ mqttServer: 'mqtt.example.com',
74
+ subscriptions: [],
75
+ onMessage,
76
+ reconnect,
77
+ });
78
+
79
+ const connectHandlers = deviceHandlers.get('connect') as (() => void)[];
80
+ connectHandlers[0]();
81
+
82
+ expect(mockLogger.info).toHaveBeenCalledWith('Successfully connected to the MQTT server.');
83
+ expect(mockLogger.debug).toHaveBeenCalledWith('mqtt connected:', 'mqtt.example.com');
84
+ });
85
+
86
+ it('should log error on error event', () => {
87
+ const error = new Error('Connection failed');
88
+ const onMessage = vi.fn();
89
+ const reconnect = vi.fn();
90
+
91
+ wireMqttDeviceEvents({
92
+ device: mockDevice,
93
+ logger: mockLogger,
94
+ mqttServer: 'mqtt.example.com',
95
+ subscriptions: [],
96
+ onMessage,
97
+ reconnect,
98
+ });
99
+
100
+ const errorHandlers = deviceHandlers.get('error') as ((err: unknown) => void)[];
101
+ errorHandlers[0](error);
102
+
103
+ expect(mockLogger.error).toHaveBeenCalledWith('mqtt err:', error);
104
+ });
105
+
106
+ it('should parse valid JSON payload and call onMessage', () => {
107
+ const onMessage = vi.fn();
108
+ const reconnect = vi.fn();
109
+ const payload = Buffer.from(JSON.stringify({ deviceId: 'device-1', data: { state: { reported: {} } } }));
110
+
111
+ wireMqttDeviceEvents({
112
+ device: mockDevice,
113
+ logger: mockLogger,
114
+ mqttServer: 'mqtt.example.com',
115
+ subscriptions: [],
116
+ onMessage,
117
+ reconnect,
118
+ });
119
+
120
+ const messageHandlers = deviceHandlers.get('message') as ((topic: string, payload: Buffer) => void)[];
121
+ messageHandlers[0]('topic/test', payload);
122
+
123
+ expect(onMessage).toHaveBeenCalledWith({ deviceId: 'device-1', data: { state: { reported: {} } } });
124
+ expect(mockLogger.debug).toHaveBeenCalledWith('mqtt message received:', payload.toString());
125
+ });
126
+
127
+ it('should log error and debug when JSON parse fails', () => {
128
+ const onMessage = vi.fn();
129
+ const reconnect = vi.fn();
130
+ const invalidJson = Buffer.from('not valid json');
131
+
132
+ wireMqttDeviceEvents({
133
+ device: mockDevice,
134
+ logger: mockLogger,
135
+ mqttServer: 'mqtt.example.com',
136
+ subscriptions: [],
137
+ onMessage,
138
+ reconnect,
139
+ });
140
+
141
+ const messageHandlers = deviceHandlers.get('message') as ((topic: string, payload: Buffer) => void)[];
142
+ messageHandlers[0]('topic/test', invalidJson);
143
+
144
+ expect(mockLogger.error).toHaveBeenCalledWith('mqtt message parse error:', expect.any(Error));
145
+ expect(mockLogger.debug).toHaveBeenCalledWith('mqtt invalid message received:', 'not valid json');
146
+ expect(onMessage).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it('should not call onMessage when JSON payload is invalid', () => {
150
+ const onMessage = vi.fn();
151
+ const reconnect = vi.fn();
152
+
153
+ wireMqttDeviceEvents({
154
+ device: mockDevice,
155
+ logger: mockLogger,
156
+ mqttServer: 'mqtt.example.com',
157
+ subscriptions: [],
158
+ onMessage,
159
+ reconnect,
160
+ });
161
+
162
+ const messageHandlers = deviceHandlers.get('message') as ((topic: string, payload: Buffer) => void)[];
163
+ messageHandlers[0]('topic/test', Buffer.from('{invalid json}'));
164
+
165
+ expect(onMessage).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it('should end device connection, log, and schedule reconnect on offline', async () => {
169
+ const onMessage = vi.fn();
170
+ const reconnect = vi.fn().mockResolvedValue(undefined);
171
+
172
+ wireMqttDeviceEvents({
173
+ device: mockDevice,
174
+ logger: mockLogger,
175
+ mqttServer: 'mqtt.example.com',
176
+ subscriptions: [],
177
+ onMessage,
178
+ reconnect,
179
+ });
180
+
181
+ const offlineHandlers = deviceHandlers.get('offline') as (() => void)[];
182
+ offlineHandlers[0]();
183
+
184
+ expect(mockDevice.end).toHaveBeenCalled();
185
+ expect(mockLogger.info).toHaveBeenCalledWith('MQTT disconnected, retrying in 60 seconds!');
186
+
187
+ // Advance timer to trigger reconnect
188
+ await vi.advanceTimersByTimeAsync(MQTT_OFFLINE_RECONNECT_DELAY_MS);
189
+
190
+ expect(reconnect).toHaveBeenCalled();
191
+ });
192
+
193
+ it('should log error when reconnect fails', async () => {
194
+ const onMessage = vi.fn();
195
+ const reconnectError = new Error('Reconnect failed');
196
+ const reconnect = vi.fn().mockRejectedValue(reconnectError);
197
+
198
+ wireMqttDeviceEvents({
199
+ device: mockDevice,
200
+ logger: mockLogger,
201
+ mqttServer: 'mqtt.example.com',
202
+ subscriptions: [],
203
+ onMessage,
204
+ reconnect,
205
+ });
206
+
207
+ const offlineHandlers = deviceHandlers.get('offline') as (() => void)[];
208
+ offlineHandlers[0]();
209
+
210
+ await vi.advanceTimersByTimeAsync(MQTT_OFFLINE_RECONNECT_DELAY_MS);
211
+
212
+ expect(mockLogger.error).toHaveBeenCalledWith('mqtt reconnect failed:', reconnectError);
213
+ });
214
+
215
+ it('should use custom scheduleReconnect when provided', async () => {
216
+ const onMessage = vi.fn();
217
+ const reconnect = vi.fn().mockResolvedValue(undefined);
218
+ const customScheduleReconnect = vi.fn();
219
+
220
+ wireMqttDeviceEvents({
221
+ device: mockDevice,
222
+ logger: mockLogger,
223
+ mqttServer: 'mqtt.example.com',
224
+ subscriptions: [],
225
+ onMessage,
226
+ reconnect,
227
+ scheduleReconnect: customScheduleReconnect,
228
+ });
229
+
230
+ const offlineHandlers = deviceHandlers.get('offline') as (() => void)[];
231
+ offlineHandlers[0]();
232
+
233
+ expect(customScheduleReconnect).toHaveBeenCalledWith(expect.any(Function), MQTT_OFFLINE_RECONNECT_DELAY_MS);
234
+ });
235
+
236
+ it('should call custom scheduleReconnect with the correct handler and delay', async () => {
237
+ const onMessage = vi.fn();
238
+ const reconnect = vi.fn().mockResolvedValue(undefined);
239
+ let capturedHandler: (() => Promise<void> | void) | undefined;
240
+ const customScheduleReconnect = vi.fn((handler) => {
241
+ capturedHandler = handler;
242
+ });
243
+
244
+ wireMqttDeviceEvents({
245
+ device: mockDevice,
246
+ logger: mockLogger,
247
+ mqttServer: 'mqtt.example.com',
248
+ subscriptions: [],
249
+ onMessage,
250
+ reconnect,
251
+ scheduleReconnect: customScheduleReconnect,
252
+ });
253
+
254
+ const offlineHandlers = deviceHandlers.get('offline') as (() => void)[];
255
+ offlineHandlers[0]();
256
+
257
+ expect(customScheduleReconnect).toHaveBeenCalledWith(expect.any(Function), MQTT_OFFLINE_RECONNECT_DELAY_MS);
258
+
259
+ // Manually call the captured handler
260
+ if (capturedHandler) {
261
+ const result = capturedHandler();
262
+ if (result instanceof Promise) {
263
+ await result;
264
+ }
265
+ }
266
+
267
+ expect(reconnect).toHaveBeenCalled();
268
+ });
269
+ });
270
+
271
+ describe('MQTT_OFFLINE_RECONNECT_DELAY_MS', () => {
272
+ it('should be 60000 milliseconds', () => {
273
+ expect(MQTT_OFFLINE_RECONNECT_DELAY_MS).toBe(60000);
274
+ });
275
+ });
276
+ });
@@ -0,0 +1,76 @@
1
+ export const MQTT_OFFLINE_RECONNECT_DELAY_MS = 60000;
2
+
3
+ export interface MqttRuntimeDevice {
4
+ on(event: 'error', handler: (err: unknown) => void): unknown;
5
+ on(event: 'connect' | 'offline', handler: () => void): unknown;
6
+ on(event: 'message', handler: (topic: string, payload: Buffer) => void): unknown;
7
+ subscribe(subscription: string): unknown;
8
+ end(): unknown;
9
+ }
10
+
11
+ export interface MqttRuntimeLogger {
12
+ debug(...args: unknown[]): void;
13
+ error(...args: unknown[]): void;
14
+ info(...args: unknown[]): void;
15
+ }
16
+
17
+ export type MqttReconnectScheduler = (handler: () => void | Promise<void>, delayMs: number) => unknown;
18
+
19
+ /** Wires connect/message/error/offline handlers onto an MQTT device, ports `mqttConnection.ts` verbatim. */
20
+ export function wireMqttDeviceEvents(options: {
21
+ device: MqttRuntimeDevice;
22
+ logger: MqttRuntimeLogger;
23
+ mqttServer: string;
24
+ subscriptions: string[];
25
+ onMessage: (data: unknown) => void;
26
+ reconnect: () => Promise<void>;
27
+ scheduleReconnect?: MqttReconnectScheduler;
28
+ }): void {
29
+ const {
30
+ device,
31
+ logger,
32
+ mqttServer,
33
+ subscriptions,
34
+ onMessage,
35
+ reconnect,
36
+ scheduleReconnect = (handler, delayMs) => setTimeout(handler, delayMs),
37
+ } = options;
38
+
39
+ device.on('error', (err) => {
40
+ logger.error('mqtt err:', err);
41
+ });
42
+
43
+ device.on('connect', () => {
44
+ logger.info('Successfully connected to the MQTT server.');
45
+ logger.debug('mqtt connected:', mqttServer);
46
+ for (const subscription of subscriptions) {
47
+ device.subscribe(subscription);
48
+ }
49
+ });
50
+
51
+ device.on('message', (_topic, payload) => {
52
+ const payloadText = payload.toString();
53
+ let parsedPayload: unknown;
54
+ try {
55
+ parsedPayload = JSON.parse(payloadText);
56
+ } catch (err) {
57
+ logger.error('mqtt message parse error:', err);
58
+ logger.debug('mqtt invalid message received:', payloadText);
59
+ return;
60
+ }
61
+
62
+ onMessage(parsedPayload);
63
+ logger.debug('mqtt message received:', payloadText);
64
+ });
65
+
66
+ device.on('offline', () => {
67
+ device.end();
68
+
69
+ logger.info('MQTT disconnected, retrying in 60 seconds!');
70
+ scheduleReconnect(() => {
71
+ reconnect().catch((err: unknown) => {
72
+ logger.error('mqtt reconnect failed:', err);
73
+ });
74
+ }, MQTT_OFFLINE_RECONNECT_DELAY_MS);
75
+ });
76
+ }
@@ -0,0 +1,164 @@
1
+ import type NodePersist from 'node-persist';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { asPartial, createMockLogger } from '../../../tests/helpers/testUtils.js';
5
+ import { MqttKeyRepository } from './mqttKeyRepository.js';
6
+
7
+ describe('MqttKeyRepository', () => {
8
+ let mockPersist: NodePersist.LocalStorage;
9
+ let mockLogger: ReturnType<typeof createMockLogger>;
10
+ let repository: MqttKeyRepository;
11
+
12
+ beforeEach(() => {
13
+ vi.clearAllMocks();
14
+ mockPersist = asPartial<NodePersist.LocalStorage>({
15
+ getItem: vi.fn(),
16
+ setItem: vi.fn(),
17
+ });
18
+ mockLogger = createMockLogger();
19
+ repository = new MqttKeyRepository(mockPersist, mockLogger);
20
+ });
21
+
22
+ afterEach(() => {
23
+ vi.clearAllMocks();
24
+ });
25
+
26
+ describe('getOrCreateKeyPair', () => {
27
+ it('should return cached key pair when it exists', async () => {
28
+ const cachedKeyPair = {
29
+ privateKey: 'cached-private-key',
30
+ publicKey: 'cached-public-key',
31
+ };
32
+
33
+ vi.mocked(mockPersist.getItem).mockResolvedValue(cachedKeyPair);
34
+
35
+ const result = await repository.getOrCreateKeyPair();
36
+
37
+ expect(result).toEqual(cachedKeyPair);
38
+ expect(mockPersist.getItem).toHaveBeenCalledWith('thinq:mqtt:keyPair');
39
+ expect(mockPersist.setItem).not.toHaveBeenCalled();
40
+ expect(mockLogger.debug).toHaveBeenCalledWith('Loaded cached MQTT key pair');
41
+ });
42
+
43
+ it('should generate and cache new key pair when cache is empty', async () => {
44
+ vi.mocked(mockPersist.getItem).mockResolvedValue(undefined);
45
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
+ (vi.mocked(mockPersist.setItem) as any).mockImplementation(() => Promise.resolve());
47
+
48
+ const result = await repository.getOrCreateKeyPair();
49
+
50
+ expect(result).toBeDefined();
51
+ expect(result.privateKey).toBeDefined();
52
+ expect(result.publicKey).toBeDefined();
53
+ expect(mockPersist.getItem).toHaveBeenCalledWith('thinq:mqtt:keyPair');
54
+ expect(mockPersist.setItem).toHaveBeenCalledWith('thinq:mqtt:keyPair', result);
55
+ expect(mockLogger.debug).toHaveBeenCalledWith('Generating 2048-bit MQTT key pair...');
56
+ });
57
+
58
+ it('should persist the generated key pair', async () => {
59
+ vi.mocked(mockPersist.getItem).mockResolvedValue(undefined);
60
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
+ (vi.mocked(mockPersist.setItem) as any).mockImplementation(() => Promise.resolve());
62
+
63
+ await repository.getOrCreateKeyPair();
64
+
65
+ expect(mockPersist.setItem).toHaveBeenCalledWith(
66
+ 'thinq:mqtt:keyPair',
67
+ expect.objectContaining({
68
+ privateKey: expect.stringContaining('PRIVATE KEY'),
69
+ publicKey: expect.stringContaining('PUBLIC KEY'),
70
+ }),
71
+ );
72
+ });
73
+
74
+ it('should return the same instance on repeated calls', async () => {
75
+ const cachedKeyPair = {
76
+ privateKey: 'cached-private-key',
77
+ publicKey: 'cached-public-key',
78
+ };
79
+
80
+ vi.mocked(mockPersist.getItem).mockResolvedValue(cachedKeyPair);
81
+
82
+ const result1 = await repository.getOrCreateKeyPair();
83
+ const result2 = await repository.getOrCreateKeyPair();
84
+
85
+ expect(result1).toBe(result2);
86
+ // getItem should be called once per call
87
+ expect(mockPersist.getItem).toHaveBeenCalledTimes(2);
88
+ });
89
+
90
+ it('should generate keys with RSA format', async () => {
91
+ vi.mocked(mockPersist.getItem).mockResolvedValue(undefined);
92
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
93
+ (vi.mocked(mockPersist.setItem) as any).mockImplementation(() => Promise.resolve());
94
+
95
+ const result = await repository.getOrCreateKeyPair();
96
+
97
+ expect(result.privateKey).toContain('BEGIN RSA PRIVATE KEY');
98
+ expect(result.publicKey).toContain('BEGIN PUBLIC KEY');
99
+ });
100
+ });
101
+
102
+ describe('getOrCreateCsr', () => {
103
+ it('should return cached CSR when it exists', async () => {
104
+ const cachedCsr = 'cached-csr-data';
105
+ const keyPair = { privateKey: 'key', publicKey: 'pubkey' };
106
+
107
+ vi.mocked(mockPersist.getItem).mockResolvedValue(cachedCsr);
108
+
109
+ const result = await repository.getOrCreateCsr(keyPair);
110
+
111
+ expect(result).toBe(cachedCsr);
112
+ expect(mockPersist.getItem).toHaveBeenCalledWith('thinq:mqtt:csr');
113
+ expect(mockPersist.setItem).not.toHaveBeenCalled();
114
+ expect(mockLogger.debug).toHaveBeenCalledWith('Loaded cached MQTT CSR');
115
+ });
116
+
117
+ it('should generate and cache new CSR when cache is empty', async () => {
118
+ vi.mocked(mockPersist.getItem).mockResolvedValue(undefined);
119
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
120
+ (vi.mocked(mockPersist.setItem) as any).mockImplementation(() => Promise.resolve());
121
+
122
+ // Use the actual generateMqttKeyPair to create valid keys
123
+ const { generateMqttKeyPair } = await import('./mqttCertificate.js');
124
+ const keyPair = generateMqttKeyPair();
125
+
126
+ const result = await repository.getOrCreateCsr(keyPair);
127
+
128
+ expect(result).toBeDefined();
129
+ expect(result).toContain('BEGIN CERTIFICATE REQUEST');
130
+ expect(mockPersist.getItem).toHaveBeenCalledWith('thinq:mqtt:csr');
131
+ expect(mockPersist.setItem).toHaveBeenCalledWith('thinq:mqtt:csr', result);
132
+ expect(mockLogger.debug).toHaveBeenCalledWith('Creating MQTT certification request (CSR)...');
133
+ });
134
+
135
+ it('should return the same CSR on repeated calls with cache', async () => {
136
+ const cachedCsr = 'cached-csr';
137
+ const keyPair = { privateKey: 'key', publicKey: 'pubkey' };
138
+
139
+ vi.mocked(mockPersist.getItem).mockResolvedValue(cachedCsr);
140
+
141
+ const result1 = await repository.getOrCreateCsr(keyPair);
142
+ const result2 = await repository.getOrCreateCsr(keyPair);
143
+
144
+ expect(result1).toBe(result2);
145
+ expect(result1).toBe(cachedCsr);
146
+ });
147
+
148
+ it('should store CSR under correct storage key', async () => {
149
+ vi.mocked(mockPersist.getItem).mockResolvedValue(undefined);
150
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
151
+ (vi.mocked(mockPersist.setItem) as any).mockImplementation(() => Promise.resolve());
152
+
153
+ const { generateMqttKeyPair } = await import('./mqttCertificate.js');
154
+ const keyPair = generateMqttKeyPair();
155
+
156
+ await repository.getOrCreateCsr(keyPair);
157
+
158
+ expect(mockPersist.setItem).toHaveBeenCalledWith(
159
+ 'thinq:mqtt:csr',
160
+ expect.stringContaining('BEGIN CERTIFICATE REQUEST'),
161
+ );
162
+ });
163
+ });
164
+ });
@@ -0,0 +1,43 @@
1
+ import { AnsiLogger } from 'matterbridge/logger';
2
+ import type NodePersist from 'node-persist';
3
+
4
+ import { createMqttCsr, generateMqttKeyPair, MqttKeyPair } from './mqttCertificate.js';
5
+
6
+ const KEY_PAIR_STORAGE_KEY = 'thinq:mqtt:keyPair';
7
+ const CSR_STORAGE_KEY = 'thinq:mqtt:csr';
8
+
9
+ /** Persisted RSA keypair/CSR repository for MQTT client certificate enrollment, mirrors `UserDataRepository`. */
10
+ export class MqttKeyRepository {
11
+ constructor(
12
+ private readonly persist: NodePersist.LocalStorage,
13
+ private readonly logger: AnsiLogger,
14
+ ) {}
15
+
16
+ /** Loads the cached MQTT key pair, generating and caching one on first call. */
17
+ public async getOrCreateKeyPair(): Promise<MqttKeyPair> {
18
+ const cached = (await this.persist.getItem(KEY_PAIR_STORAGE_KEY)) as MqttKeyPair | undefined;
19
+ if (cached) {
20
+ this.logger.debug('Loaded cached MQTT key pair');
21
+ return cached;
22
+ }
23
+
24
+ this.logger.debug('Generating 2048-bit MQTT key pair...');
25
+ const keys = generateMqttKeyPair();
26
+ await this.persist.setItem(KEY_PAIR_STORAGE_KEY, keys);
27
+ return keys;
28
+ }
29
+
30
+ /** Loads the cached MQTT CSR, generating and caching one on first call. */
31
+ public async getOrCreateCsr(keys: MqttKeyPair): Promise<string> {
32
+ const cached = (await this.persist.getItem(CSR_STORAGE_KEY)) as string | undefined;
33
+ if (cached) {
34
+ this.logger.debug('Loaded cached MQTT CSR');
35
+ return cached;
36
+ }
37
+
38
+ this.logger.debug('Creating MQTT certification request (CSR)...');
39
+ const csr = createMqttCsr(keys);
40
+ await this.persist.setItem(CSR_STORAGE_KEY, csr);
41
+ return csr;
42
+ }
43
+ }