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,415 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { asPartial, buildThinqDeviceData, createMockLogger } from '../../tests/helpers/testUtils.js';
4
+ import type { ThinqApiClient } from './thinqApiClient.js';
5
+ import { ThinqDeviceService } from './thinqDeviceService.js';
6
+
7
+ describe('ThinqDeviceService', () => {
8
+ let mockApiClient: ThinqApiClient;
9
+ let mockLogger: ReturnType<typeof createMockLogger>;
10
+ let service: ThinqDeviceService;
11
+
12
+ beforeEach(() => {
13
+ vi.clearAllMocks();
14
+ vi.useFakeTimers();
15
+ mockLogger = createMockLogger();
16
+ mockApiClient = asPartial<ThinqApiClient>({
17
+ getListDevices: vi.fn(),
18
+ sendKeepAlive: vi.fn(),
19
+ });
20
+ service = new ThinqDeviceService(mockApiClient, mockLogger);
21
+ });
22
+
23
+ afterEach(() => {
24
+ vi.clearAllTimers();
25
+ vi.useRealTimers();
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ describe('discoverDevices', () => {
30
+ it('should return empty array when no devices found', async () => {
31
+ vi.mocked(mockApiClient.getListDevices).mockResolvedValue([]);
32
+
33
+ const result = await service.discoverDevices();
34
+
35
+ expect(result).toEqual([]);
36
+ });
37
+
38
+ it('should filter out devices with invalid IDs', async () => {
39
+ const devices = [
40
+ buildThinqDeviceData({ deviceId: '12345678-1234-1234-1234-123456789012' }),
41
+ buildThinqDeviceData({ deviceId: 'invalid-id' }),
42
+ buildThinqDeviceData({ deviceId: '87654321-4321-4321-4321-210987654321' }),
43
+ ];
44
+ vi.mocked(mockApiClient.getListDevices).mockResolvedValue(devices);
45
+
46
+ const result = await service.discoverDevices();
47
+
48
+ expect(result).toHaveLength(2);
49
+ expect(
50
+ result.every((d) => /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(d.id)),
51
+ ).toBe(true);
52
+ });
53
+
54
+ it('should map raw devices to ThinqDevice entities', async () => {
55
+ const devices = [
56
+ buildThinqDeviceData({ deviceId: '12345678-1234-1234-1234-123456789012', alias: 'Living Room AC' }),
57
+ buildThinqDeviceData({ deviceId: '87654321-4321-4321-4321-210987654321', alias: 'Bedroom AC' }),
58
+ ];
59
+ vi.mocked(mockApiClient.getListDevices).mockResolvedValue(devices);
60
+
61
+ const result = await service.discoverDevices();
62
+
63
+ expect(result).toHaveLength(2);
64
+ expect(result[0].name).toBe('Living Room AC');
65
+ expect(result[1].name).toBe('Bedroom AC');
66
+ });
67
+
68
+ it('should propagate getListDevices errors', async () => {
69
+ const error = new Error('API call failed');
70
+ vi.mocked(mockApiClient.getListDevices).mockRejectedValue(error);
71
+
72
+ await expect(service.discoverDevices()).rejects.toThrow('API call failed');
73
+ });
74
+ });
75
+
76
+ describe('startPolling and stopPolling', () => {
77
+ it('should set interval and call onUpdate with device snapshots', async () => {
78
+ const devices = [buildThinqDeviceData({ deviceId: '12345678-1234-1234-1234-123456789012' })];
79
+ vi.mocked(mockApiClient.getListDevices).mockResolvedValue(devices);
80
+ const onUpdate = vi.fn();
81
+
82
+ service.startPolling(1000, onUpdate);
83
+
84
+ // Advance timer to trigger first poll
85
+ await vi.advanceTimersByTimeAsync(1000);
86
+
87
+ expect(onUpdate).toHaveBeenCalledWith('12345678-1234-1234-1234-123456789012', expect.any(Object));
88
+ });
89
+
90
+ it('should clear prior timer when startPolling called twice', async () => {
91
+ const devices = [buildThinqDeviceData()];
92
+ vi.mocked(mockApiClient.getListDevices).mockResolvedValue(devices);
93
+ const onUpdate = vi.fn();
94
+
95
+ service.startPolling(1000, onUpdate);
96
+ service.startPolling(1000, onUpdate);
97
+
98
+ // Only one timer should be active
99
+ await vi.advanceTimersByTimeAsync(1000);
100
+
101
+ expect(onUpdate).toHaveBeenCalledTimes(1);
102
+ });
103
+
104
+ it('should stop polling and clear timer', async () => {
105
+ const devices = [buildThinqDeviceData()];
106
+ vi.mocked(mockApiClient.getListDevices).mockResolvedValue(devices);
107
+ const onUpdate = vi.fn();
108
+
109
+ service.startPolling(1000, onUpdate);
110
+ await vi.advanceTimersByTimeAsync(1000);
111
+ expect(onUpdate).toHaveBeenCalledTimes(1);
112
+
113
+ service.stopPolling();
114
+ await vi.advanceTimersByTimeAsync(1000);
115
+
116
+ // Should not have been called again after stopPolling
117
+ expect(onUpdate).toHaveBeenCalledTimes(1);
118
+ });
119
+
120
+ it('should handle stopPolling when no timer is active', () => {
121
+ // Should not throw
122
+ expect(() => service.stopPolling()).not.toThrow();
123
+ });
124
+
125
+ it('should call onUpdate for each discovered device', async () => {
126
+ const devices = [
127
+ buildThinqDeviceData({ deviceId: '11111111-1111-1111-1111-111111111111' }),
128
+ buildThinqDeviceData({ deviceId: '22222222-2222-2222-2222-222222222222' }),
129
+ buildThinqDeviceData({ deviceId: '33333333-3333-3333-3333-333333333333' }),
130
+ ];
131
+ vi.mocked(mockApiClient.getListDevices).mockResolvedValue(devices);
132
+ const onUpdate = vi.fn();
133
+
134
+ service.startPolling(1000, onUpdate);
135
+ await vi.advanceTimersByTimeAsync(1000);
136
+
137
+ expect(onUpdate).toHaveBeenCalledTimes(3);
138
+ });
139
+
140
+ it('should continue polling on interval', async () => {
141
+ const devices = [buildThinqDeviceData()];
142
+ vi.mocked(mockApiClient.getListDevices).mockResolvedValue(devices);
143
+ const onUpdate = vi.fn();
144
+
145
+ service.startPolling(500, onUpdate);
146
+
147
+ await vi.advanceTimersByTimeAsync(500);
148
+ expect(onUpdate).toHaveBeenCalledTimes(1);
149
+
150
+ await vi.advanceTimersByTimeAsync(500);
151
+ expect(onUpdate).toHaveBeenCalledTimes(2);
152
+
153
+ await vi.advanceTimersByTimeAsync(500);
154
+ expect(onUpdate).toHaveBeenCalledTimes(3);
155
+
156
+ service.stopPolling();
157
+ });
158
+ });
159
+
160
+ describe('pollOnce', () => {
161
+ it('should swallow and log errors from discoverDevices', async () => {
162
+ const error = new Error('Discovery failed');
163
+ vi.mocked(mockApiClient.getListDevices).mockRejectedValue(error);
164
+ const onUpdate = vi.fn();
165
+
166
+ service.startPolling(1000, onUpdate);
167
+ await vi.advanceTimersByTimeAsync(1000);
168
+
169
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('ThinQ polling tick failed'));
170
+ expect(onUpdate).not.toHaveBeenCalled();
171
+ service.stopPolling();
172
+ });
173
+
174
+ it('should log error message with original error details', async () => {
175
+ const error = new Error('Network timeout');
176
+ vi.mocked(mockApiClient.getListDevices).mockRejectedValue(error);
177
+ const onUpdate = vi.fn();
178
+
179
+ service.startPolling(1000, onUpdate);
180
+ await vi.advanceTimersByTimeAsync(1000);
181
+
182
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('Network timeout'));
183
+ service.stopPolling();
184
+ });
185
+
186
+ it('should handle non-Error exceptions gracefully', async () => {
187
+ vi.mocked(mockApiClient.getListDevices).mockRejectedValue('String error');
188
+ const onUpdate = vi.fn();
189
+
190
+ service.startPolling(1000, onUpdate);
191
+ await vi.advanceTimersByTimeAsync(1000);
192
+
193
+ expect(mockLogger.error).toHaveBeenCalled();
194
+ expect(onUpdate).not.toHaveBeenCalled();
195
+ service.stopPolling();
196
+ });
197
+
198
+ it('should continue polling even after an error', async () => {
199
+ const devices = [buildThinqDeviceData()];
200
+ vi.mocked(mockApiClient.getListDevices)
201
+ .mockRejectedValueOnce(new Error('First poll failed'))
202
+ .mockResolvedValueOnce(devices);
203
+ const onUpdate = vi.fn();
204
+
205
+ service.startPolling(1000, onUpdate);
206
+
207
+ await vi.advanceTimersByTimeAsync(1000);
208
+ expect(onUpdate).not.toHaveBeenCalled();
209
+
210
+ await vi.advanceTimersByTimeAsync(1000);
211
+ expect(onUpdate).toHaveBeenCalledTimes(1);
212
+
213
+ service.stopPolling();
214
+ });
215
+
216
+ it('should track AC device online status for subsequent keep-alive', async () => {
217
+ const acDevice = buildThinqDeviceData({
218
+ deviceId: '12345678-1234-1234-1234-123456789012',
219
+ deviceType: 401,
220
+ online: true,
221
+ });
222
+ const nonAcDevice = buildThinqDeviceData({
223
+ deviceId: '87654321-4321-4321-4321-210987654321',
224
+ deviceType: 402,
225
+ online: true,
226
+ });
227
+ vi.mocked(mockApiClient.getListDevices).mockResolvedValue([acDevice, nonAcDevice]);
228
+ const onUpdate = vi.fn();
229
+
230
+ // First, run a polling tick to populate the device list
231
+ service.startPolling(1000, onUpdate);
232
+ await vi.advanceTimersByTimeAsync(1000);
233
+ service.stopPolling();
234
+
235
+ // Now start keep-alive and verify it only calls sendKeepAlive for the AC device
236
+ service.startKeepAlive(1000);
237
+ await vi.advanceTimersByTimeAsync(1000);
238
+
239
+ expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledTimes(1);
240
+ expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledWith('12345678-1234-1234-1234-123456789012');
241
+ service.stopKeepAlive();
242
+ });
243
+ });
244
+
245
+ describe('startKeepAlive and stopKeepAlive', () => {
246
+ it('should set interval and call sendKeepAlive for tracked online AC devices', async () => {
247
+ const acDevice = buildThinqDeviceData({ deviceId: '12345678-1234-1234-1234-123456789012', online: true });
248
+ vi.mocked(mockApiClient.getListDevices).mockResolvedValue([acDevice]);
249
+ const onUpdate = vi.fn();
250
+
251
+ // Run a polling tick first to populate the AC device map
252
+ service.startPolling(1000, onUpdate);
253
+ await vi.advanceTimersByTimeAsync(1000);
254
+ service.stopPolling();
255
+
256
+ // Now start keep-alive
257
+ service.startKeepAlive(1000);
258
+ await vi.advanceTimersByTimeAsync(1000);
259
+
260
+ expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledWith('12345678-1234-1234-1234-123456789012');
261
+ });
262
+
263
+ it('should clear prior timer when startKeepAlive called twice', async () => {
264
+ const acDevice = buildThinqDeviceData({ online: true });
265
+ vi.mocked(mockApiClient.getListDevices).mockResolvedValue([acDevice]);
266
+ const onUpdate = vi.fn();
267
+
268
+ // Run a polling tick to populate the AC device map
269
+ service.startPolling(1000, onUpdate);
270
+ await vi.advanceTimersByTimeAsync(1000);
271
+ service.stopPolling();
272
+
273
+ // Start keep-alive twice
274
+ service.startKeepAlive(1000);
275
+ service.startKeepAlive(1000);
276
+
277
+ // Only one timer should be active
278
+ await vi.advanceTimersByTimeAsync(1000);
279
+
280
+ expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledTimes(1);
281
+ });
282
+
283
+ it('should stop keep-alive and clear timer', async () => {
284
+ const acDevice = buildThinqDeviceData({ online: true });
285
+ vi.mocked(mockApiClient.getListDevices).mockResolvedValue([acDevice]);
286
+ const onUpdate = vi.fn();
287
+
288
+ // Run a polling tick to populate the AC device map
289
+ service.startPolling(1000, onUpdate);
290
+ await vi.advanceTimersByTimeAsync(1000);
291
+ service.stopPolling();
292
+
293
+ // Start keep-alive, advance timer, then stop
294
+ service.startKeepAlive(1000);
295
+ await vi.advanceTimersByTimeAsync(1000);
296
+ expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledTimes(1);
297
+
298
+ service.stopKeepAlive();
299
+ await vi.advanceTimersByTimeAsync(1000);
300
+
301
+ // Should not have been called again after stopKeepAlive
302
+ expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledTimes(1);
303
+ });
304
+
305
+ it('should handle stopKeepAlive when no timer is active', () => {
306
+ // Should not throw
307
+ expect(() => service.stopKeepAlive()).not.toThrow();
308
+ });
309
+
310
+ it('should use default THINQ_KEEP_ALIVE_INTERVAL_MS when no intervalMs provided', async () => {
311
+ const acDevice = buildThinqDeviceData({ online: true });
312
+ vi.mocked(mockApiClient.getListDevices).mockResolvedValue([acDevice]);
313
+ const onUpdate = vi.fn();
314
+
315
+ // Run a polling tick to populate the AC device map
316
+ service.startPolling(1000, onUpdate);
317
+ await vi.advanceTimersByTimeAsync(1000);
318
+ service.stopPolling();
319
+
320
+ vi.clearAllMocks();
321
+
322
+ // Start keep-alive without intervalMs (should use default 60000ms)
323
+ service.startKeepAlive();
324
+
325
+ // Advance by 59999ms - should NOT trigger
326
+ await vi.advanceTimersByTimeAsync(59999);
327
+ expect(vi.mocked(mockApiClient.sendKeepAlive)).not.toHaveBeenCalled();
328
+
329
+ // Advance by 1ms more to reach 60000ms - should trigger
330
+ await vi.advanceTimersByTimeAsync(1);
331
+ expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledTimes(1);
332
+ });
333
+ });
334
+
335
+ describe('keepAliveOnce', () => {
336
+ it('should skip devices where online is false', async () => {
337
+ const onlineAc = buildThinqDeviceData({ deviceId: '11111111-1111-1111-1111-111111111111', online: true });
338
+ const offlineAc = buildThinqDeviceData({ deviceId: '22222222-2222-2222-2222-222222222222', online: false });
339
+ vi.mocked(mockApiClient.getListDevices).mockResolvedValue([onlineAc, offlineAc]);
340
+ const onUpdate = vi.fn();
341
+
342
+ // Run a polling tick to populate the AC device map
343
+ service.startPolling(1000, onUpdate);
344
+ await vi.advanceTimersByTimeAsync(1000);
345
+ service.stopPolling();
346
+
347
+ // Run keep-alive tick
348
+ service.startKeepAlive(1000);
349
+ await vi.advanceTimersByTimeAsync(1000);
350
+
351
+ // Should only call sendKeepAlive for the online device
352
+ expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledTimes(1);
353
+ expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledWith('11111111-1111-1111-1111-111111111111');
354
+ });
355
+
356
+ it('should skip non-AC devices', async () => {
357
+ const acDevice = buildThinqDeviceData({ deviceId: '11111111-1111-1111-1111-111111111111', deviceType: 401 });
358
+ const purifierDevice = buildThinqDeviceData({
359
+ deviceId: '22222222-2222-2222-2222-222222222222',
360
+ deviceType: 402,
361
+ });
362
+ vi.mocked(mockApiClient.getListDevices).mockResolvedValue([acDevice, purifierDevice]);
363
+ const onUpdate = vi.fn();
364
+
365
+ // Run a polling tick to populate the device map
366
+ service.startPolling(1000, onUpdate);
367
+ await vi.advanceTimersByTimeAsync(1000);
368
+ service.stopPolling();
369
+
370
+ // Run keep-alive tick
371
+ service.startKeepAlive(1000);
372
+ await vi.advanceTimersByTimeAsync(1000);
373
+
374
+ // Should only call sendKeepAlive for the AC device
375
+ expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledTimes(1);
376
+ expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledWith('11111111-1111-1111-1111-111111111111');
377
+ });
378
+
379
+ it('should continue on per-device sendKeepAlive failure and log debug message', async () => {
380
+ const device1 = buildThinqDeviceData({ deviceId: '11111111-1111-1111-1111-111111111111', online: true });
381
+ const device2 = buildThinqDeviceData({ deviceId: '22222222-2222-2222-2222-222222222222', online: true });
382
+ vi.mocked(mockApiClient.getListDevices).mockResolvedValue([device1, device2]);
383
+ vi.mocked(mockApiClient.sendKeepAlive)
384
+ .mockRejectedValueOnce(new Error('Network error'))
385
+ .mockResolvedValueOnce(undefined);
386
+ const onUpdate = vi.fn();
387
+
388
+ // Run a polling tick to populate the AC device map
389
+ service.startPolling(1000, onUpdate);
390
+ await vi.advanceTimersByTimeAsync(1000);
391
+ service.stopPolling();
392
+
393
+ vi.clearAllMocks();
394
+
395
+ // Run keep-alive tick
396
+ service.startKeepAlive(1000);
397
+ await vi.advanceTimersByTimeAsync(1000);
398
+
399
+ // Both devices should have been attempted
400
+ expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledTimes(2);
401
+ // Debug log should have been called for the failure
402
+ expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('ThinQ keep-alive failed'));
403
+ });
404
+
405
+ it('should handle empty tracked device map gracefully', async () => {
406
+ // Start keep-alive without running a polling tick first (empty device map)
407
+ service.startKeepAlive(1000);
408
+ await vi.advanceTimersByTimeAsync(1000);
409
+
410
+ // Should not throw and should not call sendKeepAlive
411
+ expect(vi.mocked(mockApiClient.sendKeepAlive)).not.toHaveBeenCalled();
412
+ service.stopKeepAlive();
413
+ });
414
+ });
415
+ });
@@ -0,0 +1,101 @@
1
+ import { AnsiLogger } from 'matterbridge/logger';
2
+
3
+ import {
4
+ isAirConditionerDevice,
5
+ isValidThinqDeviceId,
6
+ ThinqDevice,
7
+ toThinqDevice,
8
+ } from '../../core/domain/entities/ThinqDevice.js';
9
+ import type { ThinqSnapshot } from '../../core/domain/value-objects/ThinqSnapshot.js';
10
+ import { ThinqApiClient } from './thinqApiClient.js';
11
+
12
+ export type ThinqDeviceUpdateListener = (deviceId: string, snapshot: ThinqSnapshot) => void;
13
+
14
+ export const THINQ_KEEP_ALIVE_INTERVAL_MS = 60000;
15
+
16
+ /**
17
+ * Discovers ThinQ devices and polls their state. Polling runs alongside a separate `ThinqMqttListener`
18
+ * (Phase 4) that pushes the same update shape over MQTT — ports `pollThinQ2Devices()`'s
19
+ * full-refetch-per-tick approach (`platformMonitor.ts:104-123`).
20
+ */
21
+ export class ThinqDeviceService {
22
+ private pollTimer: NodeJS.Timeout | undefined;
23
+ private pollTickCount = 0;
24
+ private keepAliveTimer: NodeJS.Timeout | undefined;
25
+ private airConditionerOnlineStatus = new Map<string, boolean>();
26
+
27
+ constructor(
28
+ private readonly apiClient: ThinqApiClient,
29
+ private readonly logger: AnsiLogger,
30
+ ) {}
31
+
32
+ /** Fetches every device on the account and maps it to the generic `ThinqDevice` domain entity. */
33
+ public async discoverDevices(): Promise<ThinqDevice[]> {
34
+ const rawDevices = await this.apiClient.getListDevices();
35
+
36
+ return rawDevices.filter((device) => isValidThinqDeviceId(device.deviceId)).map((device) => toThinqDevice(device));
37
+ }
38
+
39
+ public startPolling(intervalMs: number, onUpdate: ThinqDeviceUpdateListener): void {
40
+ this.stopPolling();
41
+ this.pollTickCount = 0;
42
+ this.logger.debug(`ThinQ polling: starting with interval=${intervalMs}ms`);
43
+ this.pollTimer = setInterval(() => {
44
+ void this.pollOnce(onUpdate);
45
+ }, intervalMs);
46
+ }
47
+
48
+ public stopPolling(): void {
49
+ if (this.pollTimer) {
50
+ clearInterval(this.pollTimer);
51
+ this.pollTimer = undefined;
52
+ this.logger.debug(`ThinQ polling: stopped after ${this.pollTickCount} tick(s)`);
53
+ }
54
+ }
55
+
56
+ public startKeepAlive(intervalMs: number = THINQ_KEEP_ALIVE_INTERVAL_MS): void {
57
+ this.stopKeepAlive();
58
+ this.logger.debug(`ThinQ keep-alive: starting with interval=${intervalMs}ms`);
59
+ this.keepAliveTimer = setInterval(() => {
60
+ void this.keepAliveOnce();
61
+ }, intervalMs);
62
+ }
63
+
64
+ public stopKeepAlive(): void {
65
+ if (this.keepAliveTimer) {
66
+ clearInterval(this.keepAliveTimer);
67
+ this.keepAliveTimer = undefined;
68
+ this.logger.debug(`ThinQ keep-alive: stopped`);
69
+ }
70
+ }
71
+
72
+ private async keepAliveOnce(): Promise<void> {
73
+ this.logger.debug(`ThinQ keep-alive: tick at ${new Date().toISOString()}`);
74
+ for (const [deviceId, online] of this.airConditionerOnlineStatus.entries()) {
75
+ if (!online) {
76
+ continue;
77
+ }
78
+ try {
79
+ await this.apiClient.sendKeepAlive(deviceId);
80
+ } catch (error) {
81
+ this.logger.debug(
82
+ `ThinQ keep-alive failed for ${deviceId}: ${error instanceof Error ? error.message : String(error)}`,
83
+ );
84
+ }
85
+ }
86
+ }
87
+
88
+ private async pollOnce(onUpdate: ThinqDeviceUpdateListener): Promise<void> {
89
+ this.pollTickCount += 1;
90
+ this.logger.debug(`ThinQ polling: tick #${this.pollTickCount} at ${new Date().toISOString()}`);
91
+ try {
92
+ const devices = await this.discoverDevices();
93
+ this.airConditionerOnlineStatus = new Map(devices.filter(isAirConditionerDevice).map((d) => [d.id, d.online]));
94
+ for (const device of devices) {
95
+ onUpdate(device.id, device.snapshot);
96
+ }
97
+ } catch (error) {
98
+ this.logger.error(`ThinQ polling tick failed: ${error instanceof Error ? error.message : String(error)}`);
99
+ }
100
+ }
101
+ }