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,264 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { ThinqFilterState } from '../../core/domain/value-objects/ThinqFilterState.js';
4
+ import { asPartial, createMockLogger } from '../../tests/helpers/testUtils.js';
5
+ import type { ThinqApiClient } from './thinqApiClient.js';
6
+ import { ThinqFilterMonitoringService } from './thinqFilterMonitoringService.js';
7
+
8
+ describe('ThinqFilterMonitoringService', () => {
9
+ let mockApiClient: ThinqApiClient;
10
+ let mockLogger: ReturnType<typeof createMockLogger>;
11
+ let service: ThinqFilterMonitoringService;
12
+
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ vi.useFakeTimers();
16
+ mockLogger = createMockLogger();
17
+ mockApiClient = asPartial<ThinqApiClient>({
18
+ getFilterState: vi.fn(),
19
+ });
20
+ service = new ThinqFilterMonitoringService(mockApiClient, mockLogger);
21
+ });
22
+
23
+ afterEach(() => {
24
+ vi.clearAllTimers();
25
+ vi.useRealTimers();
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ describe('startPolling', () => {
30
+ it('should set interval and call onUpdate with filter state', async () => {
31
+ const deviceIds = ['device-1'];
32
+ const onUpdate = vi.fn();
33
+ const filterData = {
34
+ 'airState.filterMngStates.useTime': 11,
35
+ 'airState.filterMngStates.maxTime': 720,
36
+ };
37
+ vi.mocked(mockApiClient.getFilterState).mockResolvedValue(filterData);
38
+
39
+ service.startPolling(1000, deviceIds, onUpdate);
40
+
41
+ // Advance timer to trigger first poll
42
+ await vi.advanceTimersByTimeAsync(1000);
43
+
44
+ expect(onUpdate).toHaveBeenCalledTimes(1);
45
+ const filterState = onUpdate.mock.calls[0][1] as ThinqFilterState | undefined;
46
+ expect(filterState?.remainingPercent).toBe(98);
47
+ });
48
+
49
+ it('should not call onUpdate before interval elapses', async () => {
50
+ const deviceIds = ['device-1'];
51
+ const onUpdate = vi.fn();
52
+ vi.mocked(mockApiClient.getFilterState).mockResolvedValue({
53
+ 'airState.filterMngStates.useTime': 11,
54
+ 'airState.filterMngStates.maxTime': 720,
55
+ });
56
+
57
+ service.startPolling(1000, deviceIds, onUpdate);
58
+
59
+ // Do not advance timers
60
+ expect(onUpdate).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('should call getFilterState for each device in the list', async () => {
64
+ const deviceIds = ['device-1', 'device-2', 'device-3'];
65
+ const onUpdate = vi.fn();
66
+ vi.mocked(mockApiClient.getFilterState).mockResolvedValue({
67
+ 'airState.filterMngStates.useTime': 11,
68
+ 'airState.filterMngStates.maxTime': 720,
69
+ });
70
+
71
+ service.startPolling(1000, deviceIds, onUpdate);
72
+ await vi.advanceTimersByTimeAsync(1000);
73
+
74
+ expect(vi.mocked(mockApiClient.getFilterState)).toHaveBeenCalledTimes(3);
75
+ expect(vi.mocked(mockApiClient.getFilterState)).toHaveBeenCalledWith('device-1');
76
+ expect(vi.mocked(mockApiClient.getFilterState)).toHaveBeenCalledWith('device-2');
77
+ expect(vi.mocked(mockApiClient.getFilterState)).toHaveBeenCalledWith('device-3');
78
+ });
79
+
80
+ it('should handle per-device errors and continue with other devices', async () => {
81
+ const deviceIds = ['device-1', 'device-2', 'device-3'];
82
+ const onUpdate = vi.fn();
83
+ vi.mocked(mockApiClient.getFilterState)
84
+ .mockResolvedValueOnce({ 'airState.filterMngStates.useTime': 11, 'airState.filterMngStates.maxTime': 720 })
85
+ .mockRejectedValueOnce(new Error('Device 2 failed'))
86
+ .mockResolvedValueOnce({ 'airState.filterMngStates.useTime': 5, 'airState.filterMngStates.maxTime': 600 });
87
+
88
+ service.startPolling(1000, deviceIds, onUpdate);
89
+ await vi.advanceTimersByTimeAsync(1000);
90
+
91
+ expect(onUpdate).toHaveBeenCalledTimes(3);
92
+ // device-1: success
93
+ expect(onUpdate.mock.calls[0][0]).toBe('device-1');
94
+ expect(onUpdate.mock.calls[0][1]).toBeDefined();
95
+ // device-2: error
96
+ expect(onUpdate.mock.calls[1][0]).toBe('device-2');
97
+ expect(onUpdate.mock.calls[1][1]).toBeUndefined();
98
+ // device-3: success
99
+ expect(onUpdate.mock.calls[2][0]).toBe('device-3');
100
+ expect(onUpdate.mock.calls[2][1]).toBeDefined();
101
+ });
102
+
103
+ it('should call onUpdate with undefined when device response is empty', async () => {
104
+ const deviceIds = ['device-1'];
105
+ const onUpdate = vi.fn();
106
+ vi.mocked(mockApiClient.getFilterState).mockResolvedValue(undefined);
107
+
108
+ service.startPolling(1000, deviceIds, onUpdate);
109
+ await vi.advanceTimersByTimeAsync(1000);
110
+
111
+ expect(onUpdate).toHaveBeenCalledWith('device-1', undefined);
112
+ });
113
+
114
+ it('should continue polling on interval', async () => {
115
+ const deviceIds = ['device-1'];
116
+ const onUpdate = vi.fn();
117
+ vi.mocked(mockApiClient.getFilterState).mockResolvedValue({
118
+ 'airState.filterMngStates.useTime': 11,
119
+ 'airState.filterMngStates.maxTime': 720,
120
+ });
121
+
122
+ service.startPolling(500, deviceIds, onUpdate);
123
+
124
+ await vi.advanceTimersByTimeAsync(500);
125
+ expect(onUpdate).toHaveBeenCalledTimes(1);
126
+
127
+ await vi.advanceTimersByTimeAsync(500);
128
+ expect(onUpdate).toHaveBeenCalledTimes(2);
129
+
130
+ await vi.advanceTimersByTimeAsync(500);
131
+ expect(onUpdate).toHaveBeenCalledTimes(3);
132
+
133
+ service.stopPolling();
134
+ });
135
+
136
+ it('should clear prior timer when startPolling called twice', async () => {
137
+ const deviceIds = ['device-1'];
138
+ const onUpdate = vi.fn();
139
+ vi.mocked(mockApiClient.getFilterState).mockResolvedValue({
140
+ 'airState.filterMngStates.useTime': 11,
141
+ 'airState.filterMngStates.maxTime': 720,
142
+ });
143
+
144
+ service.startPolling(1000, deviceIds, onUpdate);
145
+ service.startPolling(1000, deviceIds, onUpdate);
146
+
147
+ // Only one timer should be active
148
+ await vi.advanceTimersByTimeAsync(1000);
149
+
150
+ expect(onUpdate).toHaveBeenCalledTimes(1);
151
+ });
152
+
153
+ it('should log debug message when starting polling', async () => {
154
+ const deviceIds = ['device-1', 'device-2'];
155
+ const onUpdate = vi.fn();
156
+ vi.mocked(mockApiClient.getFilterState).mockResolvedValue(undefined);
157
+
158
+ service.startPolling(1000, deviceIds, onUpdate);
159
+
160
+ expect(mockLogger.debug).toHaveBeenCalledWith(
161
+ expect.stringContaining('starting with interval=1000ms, devices=2'),
162
+ );
163
+ });
164
+
165
+ it('should log per-device errors at debug level', async () => {
166
+ const deviceIds = ['device-1'];
167
+ const onUpdate = vi.fn();
168
+ const error = new Error('Network error');
169
+ vi.mocked(mockApiClient.getFilterState).mockRejectedValue(error);
170
+
171
+ service.startPolling(1000, deviceIds, onUpdate);
172
+ await vi.advanceTimersByTimeAsync(1000);
173
+
174
+ expect(mockLogger.debug).toHaveBeenCalledWith(
175
+ expect.stringContaining('ThinQ filter monitoring failed for device-1'),
176
+ );
177
+ expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('Network error'));
178
+ });
179
+ });
180
+
181
+ describe('stopPolling', () => {
182
+ it('should clear timer and stop polling', async () => {
183
+ const deviceIds = ['device-1'];
184
+ const onUpdate = vi.fn();
185
+ vi.mocked(mockApiClient.getFilterState).mockResolvedValue({
186
+ 'airState.filterMngStates.useTime': 11,
187
+ 'airState.filterMngStates.maxTime': 720,
188
+ });
189
+
190
+ service.startPolling(1000, deviceIds, onUpdate);
191
+ await vi.advanceTimersByTimeAsync(1000);
192
+ expect(onUpdate).toHaveBeenCalledTimes(1);
193
+
194
+ service.stopPolling();
195
+ await vi.advanceTimersByTimeAsync(1000);
196
+
197
+ // Should not have been called again after stopPolling
198
+ expect(onUpdate).toHaveBeenCalledTimes(1);
199
+ });
200
+
201
+ it('should not throw when no timer is active', () => {
202
+ expect(() => service.stopPolling()).not.toThrow();
203
+ });
204
+
205
+ it('should log debug message when stopped', async () => {
206
+ const deviceIds = ['device-1'];
207
+ const onUpdate = vi.fn();
208
+ vi.mocked(mockApiClient.getFilterState).mockResolvedValue({
209
+ 'airState.filterMngStates.useTime': 11,
210
+ 'airState.filterMngStates.maxTime': 720,
211
+ });
212
+
213
+ service.startPolling(1000, deviceIds, onUpdate);
214
+ await vi.advanceTimersByTimeAsync(1000);
215
+
216
+ service.stopPolling();
217
+
218
+ expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('stopped after 1 tick(s)'));
219
+ });
220
+
221
+ it('should reset tick count across start/stop cycles', async () => {
222
+ const deviceIds = ['device-1'];
223
+ const onUpdate = vi.fn();
224
+ vi.mocked(mockApiClient.getFilterState).mockResolvedValue({
225
+ 'airState.filterMngStates.useTime': 11,
226
+ 'airState.filterMngStates.maxTime': 720,
227
+ });
228
+
229
+ service.startPolling(500, deviceIds, onUpdate);
230
+ await vi.advanceTimersByTimeAsync(1000); // 2 ticks
231
+
232
+ service.stopPolling();
233
+
234
+ vi.mocked(mockLogger.debug).mockClear();
235
+
236
+ service.startPolling(500, deviceIds, onUpdate);
237
+ await vi.advanceTimersByTimeAsync(500); // 1 tick
238
+
239
+ service.stopPolling();
240
+
241
+ expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('stopped after 1 tick(s)'));
242
+ });
243
+
244
+ it('should log tick number with timestamp', async () => {
245
+ const deviceIds = ['device-1'];
246
+ const onUpdate = vi.fn();
247
+ vi.mocked(mockApiClient.getFilterState).mockResolvedValue({
248
+ 'airState.filterMngStates.useTime': 11,
249
+ 'airState.filterMngStates.maxTime': 720,
250
+ });
251
+
252
+ service.startPolling(100, deviceIds, onUpdate);
253
+
254
+ await vi.advanceTimersByTimeAsync(100);
255
+ await vi.advanceTimersByTimeAsync(100);
256
+
257
+ service.stopPolling();
258
+
259
+ const debugCalls = vi.mocked(mockLogger.debug).mock.calls.map((call) => call[0]);
260
+ expect(debugCalls.some((call) => call.includes('tick #1'))).toBe(true);
261
+ expect(debugCalls.some((call) => call.includes('tick #2'))).toBe(true);
262
+ });
263
+ });
264
+ });
@@ -0,0 +1,53 @@
1
+ import { AnsiLogger } from 'matterbridge/logger';
2
+
3
+ import { ThinqFilterState } from '../../core/domain/value-objects/ThinqFilterState.js';
4
+ import { ThinqApiClient } from './thinqApiClient.js';
5
+
6
+ export type ThinqFilterUpdateListener = (deviceId: string, filterState: ThinqFilterState | undefined) => void;
7
+
8
+ /**
9
+ * Dedicated low-frequency poll job for AC filter-life state. Separate from `ThinqDeviceService`'s
10
+ * snapshot polling because filter state is a different, per-device `Get` RPC on a much slower cadence.
11
+ */
12
+ export class ThinqFilterMonitoringService {
13
+ private pollTimer: NodeJS.Timeout | undefined;
14
+ private pollTickCount = 0;
15
+
16
+ constructor(
17
+ private readonly apiClient: ThinqApiClient,
18
+ private readonly logger: AnsiLogger,
19
+ ) {}
20
+
21
+ public startPolling(intervalMs: number, deviceIds: string[], onUpdate: ThinqFilterUpdateListener): void {
22
+ this.stopPolling();
23
+ this.pollTickCount = 0;
24
+ this.logger.debug(`ThinQ filter monitoring: starting with interval=${intervalMs}ms, devices=${deviceIds.length}`);
25
+ this.pollTimer = setInterval(() => {
26
+ void this.pollOnce(deviceIds, onUpdate);
27
+ }, intervalMs);
28
+ }
29
+
30
+ public stopPolling(): void {
31
+ if (this.pollTimer) {
32
+ clearInterval(this.pollTimer);
33
+ this.pollTimer = undefined;
34
+ this.logger.debug(`ThinQ filter monitoring: stopped after ${this.pollTickCount} tick(s)`);
35
+ }
36
+ }
37
+
38
+ private async pollOnce(deviceIds: string[], onUpdate: ThinqFilterUpdateListener): Promise<void> {
39
+ this.pollTickCount += 1;
40
+ this.logger.debug(`ThinQ filter monitoring: tick #${this.pollTickCount} at ${new Date().toISOString()}`);
41
+ for (const deviceId of deviceIds) {
42
+ try {
43
+ const data = await this.apiClient.getFilterState(deviceId);
44
+ onUpdate(deviceId, ThinqFilterState.fromRaw(data));
45
+ } catch (error) {
46
+ this.logger.debug(
47
+ `ThinQ filter monitoring failed for ${deviceId}: ${error instanceof Error ? error.message : String(error)}`,
48
+ );
49
+ onUpdate(deviceId, undefined);
50
+ }
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,18 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ interface PackageJson {
6
+ engines: Record<string, string>;
7
+ name: string;
8
+ version: string;
9
+ homepage: string;
10
+ }
11
+
12
+ const PACKAGE_JSON = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
13
+ const PACKAGE = JSON.parse(readFileSync(PACKAGE_JSON, 'utf-8')) as PackageJson;
14
+
15
+ export const ENGINES = PACKAGE.engines;
16
+ export const PLUGIN_NAME = PACKAGE.name;
17
+ export const PLUGIN_VERSION = PACKAGE.version;
18
+ export const PLUGIN_URL = PACKAGE.homepage;
@@ -0,0 +1,293 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ let mockApiClient: any;
4
+
5
+ vi.mock('../../../core/domain/entities/ThinqDevice.js', () => ({
6
+ toThinqDevice: vi.fn((raw: any) => ({
7
+ name: raw.alias,
8
+ id: raw.deviceId,
9
+ type: raw.deviceType,
10
+ platformType: raw.platformType,
11
+ modelName: raw.modelName,
12
+ online: raw.online,
13
+ })),
14
+ }));
15
+
16
+ vi.mock('../../../services/thinq/session.js', () => ({
17
+ ThinqSession: vi.fn(function () {
18
+ return {};
19
+ }),
20
+ }));
21
+
22
+ vi.mock('../../../services/thinq/thinqApiClient.js', () => ({
23
+ ThinqApiClient: vi.fn(function () {
24
+ if (!mockApiClient) {
25
+ mockApiClient = {
26
+ getListDevices: vi.fn(),
27
+ setUserNumber: vi.fn(),
28
+ };
29
+ }
30
+ return mockApiClient;
31
+ }),
32
+ }));
33
+
34
+ import { cmdDevices } from '../../../cli/commands/devices.js';
35
+ import type { CliSession } from '../../../cli/types.js';
36
+ import { toThinqDevice } from '../../../core/domain/entities/ThinqDevice.js';
37
+ import { ThinqSession } from '../../../services/thinq/session.js';
38
+ import { ThinqApiClient } from '../../../services/thinq/thinqApiClient.js';
39
+
40
+ describe('cmdDevices', () => {
41
+ let consoleLogSpy: any;
42
+
43
+ beforeEach(() => {
44
+ vi.clearAllMocks();
45
+ mockApiClient = {
46
+ getListDevices: vi.fn(),
47
+ setUserNumber: vi.fn(),
48
+ };
49
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
50
+ });
51
+
52
+ afterEach(() => {
53
+ vi.clearAllMocks();
54
+ consoleLogSpy.mockRestore();
55
+ });
56
+
57
+ it('should call setUserNumber when userData.userNumber is set', async () => {
58
+ // Arrange
59
+ const session: CliSession = {
60
+ loginType: 'account',
61
+ country: 'US',
62
+ language: 'en-US',
63
+ userData: {
64
+ accessToken: 'token',
65
+ refreshToken: 'refresh',
66
+ expiresAtEpochSeconds: 123,
67
+ country: 'US',
68
+ language: 'en-US',
69
+ userNumber: '12345',
70
+ },
71
+ };
72
+ mockApiClient.getListDevices.mockResolvedValue([]);
73
+
74
+ // Act
75
+ await cmdDevices(session, {} as any);
76
+
77
+ // Assert
78
+ expect(mockApiClient.setUserNumber).toHaveBeenCalledWith('12345');
79
+ });
80
+
81
+ it('should not call setUserNumber when userData.userNumber is undefined', async () => {
82
+ // Arrange
83
+ const session: CliSession = {
84
+ loginType: 'account',
85
+ country: 'US',
86
+ language: 'en-US',
87
+ userData: {
88
+ accessToken: 'token',
89
+ refreshToken: 'refresh',
90
+ expiresAtEpochSeconds: 123,
91
+ country: 'US',
92
+ language: 'en-US',
93
+ },
94
+ };
95
+ mockApiClient.getListDevices.mockResolvedValue([]);
96
+
97
+ // Act
98
+ await cmdDevices(session, {} as any);
99
+
100
+ // Assert
101
+ expect(mockApiClient.setUserNumber).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it('should print "No devices found." when getListDevices returns empty array', async () => {
105
+ // Arrange
106
+ const session: CliSession = {
107
+ loginType: 'account',
108
+ country: 'US',
109
+ language: 'en-US',
110
+ userData: {
111
+ accessToken: 'token',
112
+ refreshToken: 'refresh',
113
+ expiresAtEpochSeconds: 123,
114
+ country: 'US',
115
+ language: 'en-US',
116
+ },
117
+ };
118
+ mockApiClient.getListDevices.mockResolvedValue([]);
119
+
120
+ // Act
121
+ await cmdDevices(session, {} as any);
122
+
123
+ // Assert
124
+ expect(consoleLogSpy).toHaveBeenCalledWith('No devices found.');
125
+ });
126
+
127
+ it('should print device count for multiple devices', async () => {
128
+ // Arrange
129
+ const session: CliSession = {
130
+ loginType: 'account',
131
+ country: 'US',
132
+ language: 'en-US',
133
+ userData: {
134
+ accessToken: 'token',
135
+ refreshToken: 'refresh',
136
+ expiresAtEpochSeconds: 123,
137
+ country: 'US',
138
+ language: 'en-US',
139
+ },
140
+ };
141
+ const rawDevices = [
142
+ { deviceId: '123', alias: 'Device1', deviceType: 401, platformType: 'THINQ', modelName: 'Model1', online: true },
143
+ { deviceId: '456', alias: 'Device2', deviceType: 401, modelName: 'Model2', online: false },
144
+ ];
145
+ mockApiClient.getListDevices.mockResolvedValue(rawDevices);
146
+
147
+ // Act
148
+ await cmdDevices(session, {} as any);
149
+
150
+ // Assert
151
+ expect(consoleLogSpy).toHaveBeenCalledWith('Found 2 device(s):\n');
152
+ });
153
+
154
+ it('should print device details with platformType when present', async () => {
155
+ // Arrange
156
+ const session: CliSession = {
157
+ loginType: 'account',
158
+ country: 'US',
159
+ language: 'en-US',
160
+ userData: {
161
+ accessToken: 'token',
162
+ refreshToken: 'refresh',
163
+ expiresAtEpochSeconds: 123,
164
+ country: 'US',
165
+ language: 'en-US',
166
+ },
167
+ };
168
+ const rawDevices = [
169
+ {
170
+ deviceId: '123',
171
+ alias: 'Living Room AC',
172
+ deviceType: 401,
173
+ platformType: 'THINQ',
174
+ modelName: 'Model1',
175
+ online: true,
176
+ },
177
+ ];
178
+ mockApiClient.getListDevices.mockResolvedValue(rawDevices);
179
+
180
+ // Act
181
+ await cmdDevices(session, {} as any);
182
+
183
+ // Assert
184
+ expect(consoleLogSpy).toHaveBeenCalledWith('1. Living Room AC');
185
+ expect(consoleLogSpy).toHaveBeenCalledWith(' Platform Type: THINQ');
186
+ });
187
+
188
+ it('should print "unknown" when platformType is undefined', async () => {
189
+ // Arrange
190
+ const session: CliSession = {
191
+ loginType: 'account',
192
+ country: 'US',
193
+ language: 'en-US',
194
+ userData: {
195
+ accessToken: 'token',
196
+ refreshToken: 'refresh',
197
+ expiresAtEpochSeconds: 123,
198
+ country: 'US',
199
+ language: 'en-US',
200
+ },
201
+ };
202
+ const rawDevices = [{ deviceId: '123', alias: 'Device', deviceType: 401, modelName: 'Model1', online: true }];
203
+ mockApiClient.getListDevices.mockResolvedValue(rawDevices);
204
+
205
+ // Act
206
+ await cmdDevices(session, {} as any);
207
+
208
+ // Assert
209
+ expect(consoleLogSpy).toHaveBeenCalledWith(' Platform Type: unknown');
210
+ });
211
+
212
+ it('should call toThinqDevice for each raw device', async () => {
213
+ // Arrange
214
+ const session: CliSession = {
215
+ loginType: 'account',
216
+ country: 'US',
217
+ language: 'en-US',
218
+ userData: {
219
+ accessToken: 'token',
220
+ refreshToken: 'refresh',
221
+ expiresAtEpochSeconds: 123,
222
+ country: 'US',
223
+ language: 'en-US',
224
+ },
225
+ };
226
+ const rawDevices = [
227
+ { deviceId: '123', alias: 'Device1', deviceType: 401, modelName: 'Model1', online: true },
228
+ { deviceId: '456', alias: 'Device2', deviceType: 402, modelName: 'Model2', online: false },
229
+ ];
230
+ mockApiClient.getListDevices.mockResolvedValue(rawDevices);
231
+
232
+ // Act
233
+ await cmdDevices(session, {} as any);
234
+
235
+ // Assert
236
+ expect(toThinqDevice).toHaveBeenCalledTimes(2);
237
+ });
238
+
239
+ it('should construct ThinqApiClient with correct parameters', async () => {
240
+ // Arrange
241
+ const session: CliSession = {
242
+ loginType: 'account',
243
+ country: 'KR',
244
+ language: 'ko-KR',
245
+ userData: {
246
+ accessToken: 'access_token',
247
+ refreshToken: 'refresh_token',
248
+ expiresAtEpochSeconds: 123456,
249
+ country: 'KR',
250
+ language: 'ko-KR',
251
+ },
252
+ };
253
+ mockApiClient.getListDevices.mockResolvedValue([]);
254
+ const mockLogger = {} as any;
255
+
256
+ // Act
257
+ await cmdDevices(session, mockLogger);
258
+
259
+ // Assert
260
+ expect(ThinqSession).toHaveBeenCalledWith('access_token', 'refresh_token', 123456);
261
+ expect(ThinqApiClient).toHaveBeenCalledWith(expect.any(Object), 'KR', 'ko-KR', mockLogger);
262
+ });
263
+
264
+ it('should print device numbers starting from 1', async () => {
265
+ // Arrange
266
+ const session: CliSession = {
267
+ loginType: 'account',
268
+ country: 'US',
269
+ language: 'en-US',
270
+ userData: {
271
+ accessToken: 'token',
272
+ refreshToken: 'refresh',
273
+ expiresAtEpochSeconds: 123,
274
+ country: 'US',
275
+ language: 'en-US',
276
+ },
277
+ };
278
+ const rawDevices = [
279
+ { deviceId: '1', alias: 'Device1', deviceType: 401, modelName: 'Model1', online: true },
280
+ { deviceId: '2', alias: 'Device2', deviceType: 402, modelName: 'Model2', online: false },
281
+ { deviceId: '3', alias: 'Device3', deviceType: 403, modelName: 'Model3', online: true },
282
+ ];
283
+ mockApiClient.getListDevices.mockResolvedValue(rawDevices);
284
+
285
+ // Act
286
+ await cmdDevices(session, {} as any);
287
+
288
+ // Assert
289
+ expect(consoleLogSpy).toHaveBeenCalledWith('1. Device1');
290
+ expect(consoleLogSpy).toHaveBeenCalledWith('2. Device2');
291
+ expect(consoleLogSpy).toHaveBeenCalledWith('3. Device3');
292
+ });
293
+ });