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,594 @@
1
+ import { LaundryWasherMode, OnOff, OperationalState } from 'matterbridge/matter/clusters';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import type { ThinqWasherDevice } from '../../../core/domain/entities/ThinqDevice.js';
5
+ import { ThinqSnapshot } from '../../../core/domain/value-objects/ThinqSnapshot.js';
6
+ import type { ThinqWasherControlConfig } from '../../../model/LgThinqPluginPlatformConfig.js';
7
+ import { registerWasherCommandHandlers } from '../../../platform/thinq/thinqWasherCommandHandlers.js';
8
+ import type { WasherStopCommandPayload } from '../../../platform/thinq/thinqWasherStopCommandResolver.js';
9
+ import type { ThinqApiClient } from '../../../services/thinq/thinqApiClient.js';
10
+ import { asPartial, createMockLogger } from '../../helpers/testUtils.js';
11
+
12
+ function createMockWasherDevice(): ThinqWasherDevice {
13
+ return asPartial<ThinqWasherDevice>({
14
+ id: 'washer-123',
15
+ name: 'Living Room Washer',
16
+ type: 'WASHER',
17
+ modelName: 'VCDWL_QEUK',
18
+ platformType: 'THINQ',
19
+ online: true,
20
+ snapshot: new ThinqSnapshot({
21
+ 'washerDryer.state': 'RUNNING',
22
+ }),
23
+ });
24
+ }
25
+
26
+ function createMockApiClient(): ThinqApiClient {
27
+ return asPartial<ThinqApiClient>({
28
+ sendCommand: vi.fn().mockResolvedValue(undefined),
29
+ });
30
+ }
31
+
32
+ function createMockEndpoint(): any {
33
+ const endpoint = asPartial<any>({
34
+ log: createMockLogger(),
35
+ addCommandHandler: vi.fn().mockReturnValue(undefined),
36
+ getAttribute: vi.fn().mockImplementation((clusterId: unknown, attrName: unknown) => {
37
+ if (clusterId === OnOff.id && attrName === 'onOff') {
38
+ return true;
39
+ }
40
+ if (clusterId === OperationalState.id && attrName === 'operationalState') {
41
+ return OperationalState.OperationalStateEnum.Running;
42
+ }
43
+ if (clusterId === LaundryWasherMode.id && attrName === 'currentMode') {
44
+ return 0;
45
+ }
46
+ return undefined;
47
+ }),
48
+ updateAttribute: vi.fn().mockResolvedValue(false),
49
+ });
50
+ return endpoint;
51
+ }
52
+
53
+ describe('registerWasherCommandHandlers', () => {
54
+ let mockLogger: ReturnType<typeof createMockLogger>;
55
+ let mockApiClient: ThinqApiClient;
56
+ let mockDevice: ThinqWasherDevice;
57
+ let mockEndpoint: any;
58
+
59
+ beforeEach(() => {
60
+ vi.clearAllMocks();
61
+ mockLogger = createMockLogger();
62
+ mockApiClient = createMockApiClient();
63
+ mockDevice = createMockWasherDevice();
64
+ mockEndpoint = createMockEndpoint();
65
+ });
66
+
67
+ afterEach(() => {
68
+ vi.clearAllMocks();
69
+ });
70
+
71
+ describe('v1 read-only commands rejection', () => {
72
+ it('should register exactly 6 read-only command handlers (v1)', () => {
73
+ // Arrange
74
+ const washerControl: ThinqWasherControlConfig = {};
75
+ const stopCommandPayload: WasherStopCommandPayload | undefined = undefined;
76
+
77
+ // Act
78
+ registerWasherCommandHandlers(
79
+ mockEndpoint,
80
+ mockDevice,
81
+ mockApiClient,
82
+ mockLogger,
83
+ washerControl,
84
+ stopCommandPayload,
85
+ );
86
+
87
+ // Assert
88
+ const calls = vi.mocked(mockEndpoint.addCommandHandler).mock.calls;
89
+ const readOnlyCommandCalls = calls.filter((call: any[]) =>
90
+ ['on', 'off', 'pause', 'start', 'resume', 'changeToMode'].includes(call[0] as string),
91
+ );
92
+ expect(readOnlyCommandCalls).toHaveLength(6);
93
+ });
94
+
95
+ it('should register on command handler', () => {
96
+ // Arrange
97
+ const washerControl: ThinqWasherControlConfig = {};
98
+ const stopCommandPayload: WasherStopCommandPayload | undefined = undefined;
99
+
100
+ // Act
101
+ registerWasherCommandHandlers(
102
+ mockEndpoint,
103
+ mockDevice,
104
+ mockApiClient,
105
+ mockLogger,
106
+ washerControl,
107
+ stopCommandPayload,
108
+ );
109
+
110
+ // Assert
111
+ expect(mockEndpoint.addCommandHandler).toHaveBeenCalledWith('on', expect.any(Function));
112
+ });
113
+
114
+ it('should register off command handler', () => {
115
+ // Arrange
116
+ const washerControl: ThinqWasherControlConfig = {};
117
+ const stopCommandPayload: WasherStopCommandPayload | undefined = undefined;
118
+
119
+ // Act
120
+ registerWasherCommandHandlers(
121
+ mockEndpoint,
122
+ mockDevice,
123
+ mockApiClient,
124
+ mockLogger,
125
+ washerControl,
126
+ stopCommandPayload,
127
+ );
128
+
129
+ // Assert
130
+ expect(mockEndpoint.addCommandHandler).toHaveBeenCalledWith('off', expect.any(Function));
131
+ });
132
+
133
+ it('should register pause command handler', () => {
134
+ // Arrange
135
+ const washerControl: ThinqWasherControlConfig = {};
136
+ const stopCommandPayload: WasherStopCommandPayload | undefined = undefined;
137
+
138
+ // Act
139
+ registerWasherCommandHandlers(
140
+ mockEndpoint,
141
+ mockDevice,
142
+ mockApiClient,
143
+ mockLogger,
144
+ washerControl,
145
+ stopCommandPayload,
146
+ );
147
+
148
+ // Assert
149
+ expect(mockEndpoint.addCommandHandler).toHaveBeenCalledWith('pause', expect.any(Function));
150
+ });
151
+
152
+ it('should register start command handler', () => {
153
+ // Arrange
154
+ const washerControl: ThinqWasherControlConfig = {};
155
+ const stopCommandPayload: WasherStopCommandPayload | undefined = undefined;
156
+
157
+ // Act
158
+ registerWasherCommandHandlers(
159
+ mockEndpoint,
160
+ mockDevice,
161
+ mockApiClient,
162
+ mockLogger,
163
+ washerControl,
164
+ stopCommandPayload,
165
+ );
166
+
167
+ // Assert
168
+ expect(mockEndpoint.addCommandHandler).toHaveBeenCalledWith('start', expect.any(Function));
169
+ });
170
+
171
+ it('should register resume command handler', () => {
172
+ // Arrange
173
+ const washerControl: ThinqWasherControlConfig = {};
174
+ const stopCommandPayload: WasherStopCommandPayload | undefined = undefined;
175
+
176
+ // Act
177
+ registerWasherCommandHandlers(
178
+ mockEndpoint,
179
+ mockDevice,
180
+ mockApiClient,
181
+ mockLogger,
182
+ washerControl,
183
+ stopCommandPayload,
184
+ );
185
+
186
+ // Assert
187
+ expect(mockEndpoint.addCommandHandler).toHaveBeenCalledWith('resume', expect.any(Function));
188
+ });
189
+
190
+ it('should register changeToMode command handler', () => {
191
+ // Arrange
192
+ const washerControl: ThinqWasherControlConfig = {};
193
+ const stopCommandPayload: WasherStopCommandPayload | undefined = undefined;
194
+
195
+ // Act
196
+ registerWasherCommandHandlers(
197
+ mockEndpoint,
198
+ mockDevice,
199
+ mockApiClient,
200
+ mockLogger,
201
+ washerControl,
202
+ stopCommandPayload,
203
+ );
204
+
205
+ // Assert
206
+ expect(mockEndpoint.addCommandHandler).toHaveBeenCalledWith('changeToMode', expect.any(Function));
207
+ });
208
+
209
+ it('on command should throw with unsupported error', async () => {
210
+ // Arrange
211
+ const washerControl: ThinqWasherControlConfig = {};
212
+ const stopCommandPayload: WasherStopCommandPayload | undefined = undefined;
213
+ registerWasherCommandHandlers(
214
+ mockEndpoint,
215
+ mockDevice,
216
+ mockApiClient,
217
+ mockLogger,
218
+ washerControl,
219
+ stopCommandPayload,
220
+ );
221
+ const onCall = vi.mocked(mockEndpoint.addCommandHandler).mock.calls.find((call: any[]) => call[0] === 'on');
222
+ const onHandler = onCall?.[1] as () => Promise<void>;
223
+
224
+ // Act & Assert
225
+ await expect(onHandler()).rejects.toThrow('not supported yet');
226
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("'on') is not supported yet"));
227
+ });
228
+
229
+ it('off command should throw with unsupported error', async () => {
230
+ // Arrange
231
+ const washerControl: ThinqWasherControlConfig = {};
232
+ const stopCommandPayload: WasherStopCommandPayload | undefined = undefined;
233
+ registerWasherCommandHandlers(
234
+ mockEndpoint,
235
+ mockDevice,
236
+ mockApiClient,
237
+ mockLogger,
238
+ washerControl,
239
+ stopCommandPayload,
240
+ );
241
+ const offCall = vi.mocked(mockEndpoint.addCommandHandler).mock.calls.find((call: any[]) => call[0] === 'off');
242
+ const offHandler = offCall?.[1] as () => Promise<void>;
243
+
244
+ // Act & Assert
245
+ await expect(offHandler()).rejects.toThrow('not supported yet');
246
+ });
247
+
248
+ it('pause command should throw with unsupported error', async () => {
249
+ // Arrange
250
+ const washerControl: ThinqWasherControlConfig = {};
251
+ const stopCommandPayload: WasherStopCommandPayload | undefined = undefined;
252
+ registerWasherCommandHandlers(
253
+ mockEndpoint,
254
+ mockDevice,
255
+ mockApiClient,
256
+ mockLogger,
257
+ washerControl,
258
+ stopCommandPayload,
259
+ );
260
+ const pauseCall = vi.mocked(mockEndpoint.addCommandHandler).mock.calls.find((call: any[]) => call[0] === 'pause');
261
+ const pauseHandler = pauseCall?.[1] as () => Promise<void>;
262
+
263
+ // Act & Assert
264
+ await expect(pauseHandler()).rejects.toThrow('not supported yet');
265
+ });
266
+
267
+ it('on/off commands should revert OnOff.onOff to prior value on failure', async () => {
268
+ // Arrange
269
+ const washerControl: ThinqWasherControlConfig = {};
270
+ const stopCommandPayload: WasherStopCommandPayload | undefined = undefined;
271
+ registerWasherCommandHandlers(
272
+ mockEndpoint,
273
+ mockDevice,
274
+ mockApiClient,
275
+ mockLogger,
276
+ washerControl,
277
+ stopCommandPayload,
278
+ );
279
+ const offCall = vi.mocked(mockEndpoint.addCommandHandler).mock.calls.find((call: any[]) => call[0] === 'off');
280
+ const offHandler = offCall?.[1] as () => Promise<void>;
281
+
282
+ // Act
283
+ await expect(offHandler()).rejects.toThrow();
284
+
285
+ // Assert
286
+ expect(mockEndpoint.updateAttribute).toHaveBeenCalledWith(
287
+ OnOff.id,
288
+ 'onOff',
289
+ true, // Should revert to prior value (true)
290
+ mockEndpoint.log,
291
+ );
292
+ });
293
+
294
+ it('non-on/off commands should revert OperationalState.operationalState on failure', async () => {
295
+ // Arrange
296
+ const washerControl: ThinqWasherControlConfig = {};
297
+ const stopCommandPayload: WasherStopCommandPayload | undefined = undefined;
298
+ registerWasherCommandHandlers(
299
+ mockEndpoint,
300
+ mockDevice,
301
+ mockApiClient,
302
+ mockLogger,
303
+ washerControl,
304
+ stopCommandPayload,
305
+ );
306
+ const pauseCall = vi.mocked(mockEndpoint.addCommandHandler).mock.calls.find((call: any[]) => call[0] === 'pause');
307
+ const pauseHandler = pauseCall?.[1] as () => Promise<void>;
308
+
309
+ // Act
310
+ await expect(pauseHandler()).rejects.toThrow();
311
+
312
+ // Assert
313
+ expect(mockEndpoint.updateAttribute).toHaveBeenCalledWith(
314
+ OperationalState.id,
315
+ 'operationalState',
316
+ OperationalState.OperationalStateEnum.Running, // Should revert to prior value
317
+ mockEndpoint.log,
318
+ );
319
+ });
320
+
321
+ it('changeToMode should revert both operationalState and currentMode on failure', async () => {
322
+ // Arrange
323
+ const washerControl: ThinqWasherControlConfig = {};
324
+ const stopCommandPayload: WasherStopCommandPayload | undefined = undefined;
325
+ registerWasherCommandHandlers(
326
+ mockEndpoint,
327
+ mockDevice,
328
+ mockApiClient,
329
+ mockLogger,
330
+ washerControl,
331
+ stopCommandPayload,
332
+ );
333
+ const changeModeCall = vi
334
+ .mocked(mockEndpoint.addCommandHandler)
335
+ .mock.calls.find((call: any[]) => call[0] === 'changeToMode');
336
+ const changeModeHandler = changeModeCall?.[1] as () => Promise<void>;
337
+
338
+ // Act
339
+ await expect(changeModeHandler()).rejects.toThrow();
340
+
341
+ // Assert
342
+ expect(mockEndpoint.updateAttribute).toHaveBeenCalledWith(
343
+ OperationalState.id,
344
+ 'operationalState',
345
+ expect.any(Number),
346
+ mockEndpoint.log,
347
+ );
348
+ expect(mockEndpoint.updateAttribute).toHaveBeenCalledWith(
349
+ LaundryWasherMode.id,
350
+ 'currentMode',
351
+ 0, // Should revert to prior value
352
+ mockEndpoint.log,
353
+ );
354
+ });
355
+ });
356
+
357
+ describe('v2 real stop command', () => {
358
+ it('should register stop command handler', () => {
359
+ // Arrange
360
+ const washerControl: ThinqWasherControlConfig = { allowRemoteStop: true };
361
+ const stopCommandPayload: WasherStopCommandPayload = {
362
+ dataKey: 'washerDryer.state',
363
+ dataValue: 'STOP',
364
+ };
365
+
366
+ // Act
367
+ registerWasherCommandHandlers(
368
+ mockEndpoint,
369
+ mockDevice,
370
+ mockApiClient,
371
+ mockLogger,
372
+ washerControl,
373
+ stopCommandPayload,
374
+ );
375
+
376
+ // Assert
377
+ expect(mockEndpoint.addCommandHandler).toHaveBeenCalledWith('stop', expect.any(Function));
378
+ });
379
+
380
+ it('stop command should throw when allowRemoteStop is false', async () => {
381
+ // Arrange
382
+ const washerControl: ThinqWasherControlConfig = { allowRemoteStop: false };
383
+ const stopCommandPayload: WasherStopCommandPayload = {
384
+ dataKey: 'washerDryer.state',
385
+ dataValue: 'STOP',
386
+ };
387
+ registerWasherCommandHandlers(
388
+ mockEndpoint,
389
+ mockDevice,
390
+ mockApiClient,
391
+ mockLogger,
392
+ washerControl,
393
+ stopCommandPayload,
394
+ );
395
+ const stopCall1 = vi.mocked(mockEndpoint.addCommandHandler).mock.calls.find((call: any[]) => call[0] === 'stop');
396
+ const stopHandler = stopCall1?.[1] as () => Promise<void>;
397
+
398
+ // Act & Assert
399
+ await expect(stopHandler()).rejects.toThrow('disabled by configuration');
400
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("'stop') is disabled by configuration"));
401
+ expect(mockApiClient.sendCommand).not.toHaveBeenCalled();
402
+ });
403
+
404
+ it('stop command should throw when allowRemoteStop is undefined (falsy)', async () => {
405
+ // Arrange
406
+ const washerControl: ThinqWasherControlConfig = { allowRemoteStop: undefined };
407
+ const stopCommandPayload: WasherStopCommandPayload = {
408
+ dataKey: 'washerDryer.state',
409
+ dataValue: 'STOP',
410
+ };
411
+ registerWasherCommandHandlers(
412
+ mockEndpoint,
413
+ mockDevice,
414
+ mockApiClient,
415
+ mockLogger,
416
+ washerControl,
417
+ stopCommandPayload,
418
+ );
419
+ const stopCall3 = vi.mocked(mockEndpoint.addCommandHandler).mock.calls.find((call: any[]) => call[0] === 'stop');
420
+ const stopHandler = stopCall3?.[1] as () => Promise<void>;
421
+
422
+ // Act & Assert
423
+ await expect(stopHandler()).rejects.toThrow('disabled by configuration');
424
+ expect(mockApiClient.sendCommand).not.toHaveBeenCalled();
425
+ });
426
+
427
+ it('stop command should throw when stopCommandPayload is undefined', async () => {
428
+ // Arrange
429
+ const washerControl: ThinqWasherControlConfig = { allowRemoteStop: true };
430
+ const stopCommandPayload: WasherStopCommandPayload | undefined = undefined;
431
+ registerWasherCommandHandlers(
432
+ mockEndpoint,
433
+ mockDevice,
434
+ mockApiClient,
435
+ mockLogger,
436
+ washerControl,
437
+ stopCommandPayload,
438
+ );
439
+ const stopCall4 = vi.mocked(mockEndpoint.addCommandHandler).mock.calls.find((call: any[]) => call[0] === 'stop');
440
+ const stopHandler = stopCall4?.[1] as () => Promise<void>;
441
+
442
+ // Act & Assert
443
+ if (!stopHandler) throw new Error('stop handler not found');
444
+ await expect(stopHandler()).rejects.toThrow('not available for this device model yet');
445
+ expect(mockApiClient.sendCommand).not.toHaveBeenCalled();
446
+ });
447
+
448
+ it('stop command should send command when both gates are open and payload resolves', async () => {
449
+ // Arrange
450
+ const washerControl: ThinqWasherControlConfig = { allowRemoteStop: true };
451
+ const stopCommandPayload: WasherStopCommandPayload = {
452
+ dataKey: 'washerDryer.state',
453
+ dataValue: 'STOP',
454
+ };
455
+ registerWasherCommandHandlers(
456
+ mockEndpoint,
457
+ mockDevice,
458
+ mockApiClient,
459
+ mockLogger,
460
+ washerControl,
461
+ stopCommandPayload,
462
+ );
463
+ const stopCall5 = vi.mocked(mockEndpoint.addCommandHandler).mock.calls.find((call: any[]) => call[0] === 'stop');
464
+ const stopHandler = stopCall5?.[1] as () => Promise<void>;
465
+
466
+ // Act
467
+ await stopHandler();
468
+
469
+ // Assert
470
+ expect(mockApiClient.sendCommand).toHaveBeenCalledWith('washer-123', {
471
+ ctrlKey: 'WMControl',
472
+ dataKey: 'washerDryer.state',
473
+ dataValue: 'STOP',
474
+ });
475
+ });
476
+
477
+ it('stop command should include full payload in sendCommand call', async () => {
478
+ // Arrange
479
+ const washerControl: ThinqWasherControlConfig = { allowRemoteStop: true };
480
+ const stopCommandPayload: WasherStopCommandPayload = {
481
+ command: 'Set',
482
+ dataKey: 'washerDryer.state',
483
+ dataValue: 'STOP',
484
+ };
485
+ registerWasherCommandHandlers(
486
+ mockEndpoint,
487
+ mockDevice,
488
+ mockApiClient,
489
+ mockLogger,
490
+ washerControl,
491
+ stopCommandPayload,
492
+ );
493
+ const stopCall6 = vi.mocked(mockEndpoint.addCommandHandler).mock.calls.find((call: any[]) => call[0] === 'stop');
494
+ const stopHandler = stopCall6?.[1] as () => Promise<void>;
495
+
496
+ // Act
497
+ if (!stopHandler) throw new Error('stop handler not found');
498
+ await stopHandler();
499
+
500
+ // Assert
501
+ expect(mockApiClient.sendCommand).toHaveBeenCalledWith('washer-123', {
502
+ ctrlKey: 'WMControl',
503
+ command: 'Set',
504
+ dataKey: 'washerDryer.state',
505
+ dataValue: 'STOP',
506
+ });
507
+ });
508
+
509
+ it('stop command should revert operationalState if sendCommand fails', async () => {
510
+ // Arrange
511
+ const washerControl: ThinqWasherControlConfig = { allowRemoteStop: true };
512
+ const stopCommandPayload: WasherStopCommandPayload = {
513
+ dataKey: 'washerDryer.state',
514
+ dataValue: 'STOP',
515
+ };
516
+ mockApiClient.sendCommand = vi.fn().mockRejectedValueOnce(new Error('Network error'));
517
+ registerWasherCommandHandlers(
518
+ mockEndpoint,
519
+ mockDevice,
520
+ mockApiClient,
521
+ mockLogger,
522
+ washerControl,
523
+ stopCommandPayload,
524
+ );
525
+ const stopCall7 = vi.mocked(mockEndpoint.addCommandHandler).mock.calls.find((call: any[]) => call[0] === 'stop');
526
+ const stopHandler = stopCall7?.[1] as () => Promise<void>;
527
+
528
+ // Act
529
+ if (!stopHandler) throw new Error('stop handler not found');
530
+ await expect(stopHandler()).rejects.toThrow('Network error');
531
+
532
+ // Assert
533
+ expect(mockEndpoint.updateAttribute).toHaveBeenCalledWith(
534
+ OperationalState.id,
535
+ 'operationalState',
536
+ OperationalState.OperationalStateEnum.Running, // Should revert to prior value
537
+ mockEndpoint.log,
538
+ );
539
+ });
540
+
541
+ it('stop command should log error when sendCommand fails', async () => {
542
+ // Arrange
543
+ const washerControl: ThinqWasherControlConfig = { allowRemoteStop: true };
544
+ const stopCommandPayload: WasherStopCommandPayload = {
545
+ dataKey: 'washerDryer.state',
546
+ dataValue: 'STOP',
547
+ };
548
+ mockApiClient.sendCommand = vi.fn().mockRejectedValueOnce(new Error('Network error'));
549
+ registerWasherCommandHandlers(
550
+ mockEndpoint,
551
+ mockDevice,
552
+ mockApiClient,
553
+ mockLogger,
554
+ washerControl,
555
+ stopCommandPayload,
556
+ );
557
+ const stopCall8 = vi.mocked(mockEndpoint.addCommandHandler).mock.calls.find((call: any[]) => call[0] === 'stop');
558
+ const stopHandler = stopCall8?.[1] as () => Promise<void>;
559
+
560
+ // Act
561
+ if (!stopHandler) throw new Error('stop handler not found');
562
+ await expect(stopHandler()).rejects.toThrow();
563
+
564
+ // Assert
565
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("ThinQ Washer command 'stop' failed"));
566
+ });
567
+
568
+ it('stop command should not crash if updateAttribute revert fails', async () => {
569
+ // Arrange
570
+ const washerControl: ThinqWasherControlConfig = { allowRemoteStop: true };
571
+ const stopCommandPayload: WasherStopCommandPayload = {
572
+ dataKey: 'washerDryer.state',
573
+ dataValue: 'STOP',
574
+ };
575
+ mockApiClient.sendCommand = vi.fn().mockRejectedValueOnce(new Error('Network error'));
576
+ mockEndpoint.updateAttribute.mockRejectedValueOnce(new Error('Revert failed'));
577
+ registerWasherCommandHandlers(
578
+ mockEndpoint,
579
+ mockDevice,
580
+ mockApiClient,
581
+ mockLogger,
582
+ washerControl,
583
+ stopCommandPayload,
584
+ );
585
+ const stopCall9 = vi.mocked(mockEndpoint.addCommandHandler).mock.calls.find((call: any[]) => call[0] === 'stop');
586
+ const stopHandler = stopCall9?.[1] as () => Promise<void>;
587
+
588
+ // Act & Assert
589
+ if (!stopHandler) throw new Error('stop handler not found');
590
+ await expect(stopHandler()).rejects.toThrow('Network error');
591
+ // Should still throw the original error, not the revert error
592
+ });
593
+ });
594
+ });
@@ -0,0 +1,84 @@
1
+ import { LaundryWasher } from 'matterbridge/devices';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import type { ThinqWasherDevice } from '../../../core/domain/entities/ThinqDevice.js';
5
+ import { ThinqSnapshot } from '../../../core/domain/value-objects/ThinqSnapshot.js';
6
+ import { buildWasherEndpoint } from '../../../platform/thinq/thinqWasherEndpointFactory.js';
7
+ import { asPartial } from '../../helpers/testUtils.js';
8
+
9
+ vi.mock('matterbridge/devices', () => ({
10
+ LaundryWasher: vi.fn(function (name: string, id: string) {
11
+ return {
12
+ name,
13
+ id,
14
+ createDefaultBasicInformationClusterServer: vi.fn().mockReturnThis(),
15
+ };
16
+ }),
17
+ }));
18
+
19
+ function createMockWasherDevice(): ThinqWasherDevice {
20
+ return asPartial<ThinqWasherDevice>({
21
+ id: 'washer-123',
22
+ name: 'Living Room Washer',
23
+ type: 'WASHER',
24
+ modelName: 'VCDWL_QEUK',
25
+ platformType: 'THINQ',
26
+ online: true,
27
+ snapshot: new ThinqSnapshot({
28
+ 'washerDryer.state': 'RUNNING',
29
+ }),
30
+ });
31
+ }
32
+
33
+ describe('thinqWasherEndpointFactory', () => {
34
+ beforeEach(() => {
35
+ vi.clearAllMocks();
36
+ });
37
+
38
+ describe('buildWasherEndpoint', () => {
39
+ it('should create a LaundryWasher endpoint with device name and id', () => {
40
+ // Arrange
41
+ const device = createMockWasherDevice();
42
+ const laundryWasherSpy = vi.mocked(LaundryWasher);
43
+
44
+ // Act
45
+ buildWasherEndpoint(device);
46
+
47
+ // Assert
48
+ expect(laundryWasherSpy).toHaveBeenCalledWith('Living Room Washer', 'washer-123');
49
+ });
50
+
51
+ it('should pass exact device name and id to LaundryWasher constructor', () => {
52
+ // Arrange
53
+ const device = asPartial<ThinqWasherDevice>({
54
+ id: 'custom-id-xyz',
55
+ name: 'Custom Washer Name',
56
+ type: 'WASHER',
57
+ modelName: 'ModelXYZ',
58
+ platformType: 'THINQ',
59
+ online: true,
60
+ snapshot: new ThinqSnapshot({}),
61
+ });
62
+ const laundryWasherSpy = vi.mocked(LaundryWasher);
63
+
64
+ // Act
65
+ buildWasherEndpoint(device);
66
+
67
+ // Assert
68
+ expect(laundryWasherSpy).toHaveBeenCalledWith('Custom Washer Name', 'custom-id-xyz');
69
+ });
70
+
71
+ it('should return the LaundryWasher instance', () => {
72
+ // Arrange
73
+ const device = createMockWasherDevice();
74
+
75
+ // Act
76
+ const endpoint = buildWasherEndpoint(device);
77
+
78
+ // Assert
79
+ expect(endpoint).toBeDefined();
80
+ expect(endpoint).toHaveProperty('name', 'Living Room Washer');
81
+ expect(endpoint).toHaveProperty('id', 'washer-123');
82
+ });
83
+ });
84
+ });