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,879 @@
1
+ import { MatterbridgeEndpoint } from 'matterbridge';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import type { ThinqAirConditionerDevice } from '../../../core/domain/entities/ThinqDevice.js';
5
+ import type { AirConditionerCapabilities } from '../../../core/domain/value-objects/AirConditionerCapabilities.js';
6
+ import { DEFAULT_AIR_CONDITIONER_CAPABILITIES } from '../../../core/domain/value-objects/AirConditionerCapabilities.js';
7
+ import { ThinqSnapshot } from '../../../core/domain/value-objects/ThinqSnapshot.js';
8
+ import {
9
+ registerAirConditionerCommandHandlers,
10
+ THINQ_FAN_SPEED_AUTO,
11
+ THINQ_FAN_SPEED_HIGH,
12
+ THINQ_FAN_SPEED_LOW,
13
+ THINQ_FAN_SPEED_MEDIUM,
14
+ } from '../../../platform/thinq/thinqAirConditionerCommandHandlers.js';
15
+ import type { ThinqApiClient } from '../../../services/thinq/thinqApiClient.js';
16
+ import { asPartial, createMockLogger } from '../../helpers/testUtils.js';
17
+
18
+ function createMockThinqAirConditionerDevice(): ThinqAirConditionerDevice {
19
+ return asPartial<ThinqAirConditionerDevice>({
20
+ id: 'device-123',
21
+ name: 'Living Room AC',
22
+ type: 'AC',
23
+ modelName: 'ModelXYZ',
24
+ platformType: 'THINQ',
25
+ online: true,
26
+ snapshot: new ThinqSnapshot({
27
+ 'airState.operation': 1,
28
+ 'airState.tempState.current': 22,
29
+ 'airState.tempState.target': 24,
30
+ 'airState.opMode': 0,
31
+ 'airState.windStrength': 2,
32
+ }),
33
+ });
34
+ }
35
+
36
+ function createMockApiClient(): ThinqApiClient {
37
+ return asPartial<ThinqApiClient>({
38
+ sendCommand: vi.fn().mockResolvedValue(undefined),
39
+ });
40
+ }
41
+
42
+ describe('registerAirConditionerCommandHandlers', () => {
43
+ let mockLogger: ReturnType<typeof createMockLogger>;
44
+ let mockApiClient: ThinqApiClient;
45
+ let mockDevice: ThinqAirConditionerDevice;
46
+ let capabilities: AirConditionerCapabilities;
47
+ let mockEndpoint: any;
48
+
49
+ beforeEach(() => {
50
+ vi.clearAllMocks();
51
+ mockLogger = createMockLogger();
52
+ mockApiClient = createMockApiClient();
53
+ mockDevice = createMockThinqAirConditionerDevice();
54
+ capabilities = DEFAULT_AIR_CONDITIONER_CAPABILITIES;
55
+
56
+ mockEndpoint = {
57
+ log: mockLogger,
58
+ addCommandHandler: vi.fn().mockReturnValue(mockEndpoint),
59
+ subscribeAttribute: vi.fn().mockReturnValue(mockEndpoint),
60
+ updateAttribute: vi.fn().mockResolvedValue(false),
61
+ } as unknown as MatterbridgeEndpoint;
62
+ });
63
+
64
+ afterEach(() => {
65
+ vi.clearAllMocks();
66
+ });
67
+
68
+ describe('command handler registration', () => {
69
+ it('should call addCommandHandler for on command', () => {
70
+ // Act
71
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
72
+
73
+ // Assert
74
+ expect(mockEndpoint.addCommandHandler).toHaveBeenCalledWith('on', expect.any(Function));
75
+ });
76
+
77
+ it('should call addCommandHandler for off command', () => {
78
+ // Act
79
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
80
+
81
+ // Assert
82
+ expect(mockEndpoint.addCommandHandler).toHaveBeenCalledWith('off', expect.any(Function));
83
+ });
84
+
85
+ it('should call subscribeAttribute for occupiedCoolingSetpoint', () => {
86
+ // Act
87
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
88
+
89
+ // Assert
90
+ const calls = vi.mocked(mockEndpoint.subscribeAttribute).mock.calls;
91
+ const hasCoolingSetpoint = calls.some((call: any[]) => call[1] === 'occupiedCoolingSetpoint');
92
+ expect(hasCoolingSetpoint).toBe(true);
93
+ });
94
+
95
+ it('should call subscribeAttribute for occupiedHeatingSetpoint when supportsHeat is true', () => {
96
+ // Arrange
97
+ const heatCapabilities: AirConditionerCapabilities = {
98
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
99
+ supportsHeat: true,
100
+ };
101
+
102
+ // Act
103
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, heatCapabilities);
104
+
105
+ // Assert
106
+ const calls = vi.mocked(mockEndpoint.subscribeAttribute).mock.calls;
107
+ const hasHeatingSetpoint = calls.some((call: any[]) => call[1] === 'occupiedHeatingSetpoint');
108
+ expect(hasHeatingSetpoint).toBe(true);
109
+ });
110
+
111
+ it('should not call subscribeAttribute for occupiedHeatingSetpoint when supportsHeat is false', () => {
112
+ // Arrange
113
+ const noHeatCapabilities: AirConditionerCapabilities = {
114
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
115
+ supportsHeat: false,
116
+ };
117
+
118
+ // Act
119
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, noHeatCapabilities);
120
+
121
+ // Assert
122
+ const calls = vi.mocked(mockEndpoint.subscribeAttribute).mock.calls;
123
+ const hasHeatingSetpoint = calls.some((call: any[]) => call[1] === 'occupiedHeatingSetpoint');
124
+ expect(hasHeatingSetpoint).toBe(false);
125
+ });
126
+
127
+ it('should call subscribeAttribute for percentSetting', () => {
128
+ // Act
129
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
130
+
131
+ // Assert
132
+ const calls = vi.mocked(mockEndpoint.subscribeAttribute).mock.calls;
133
+ const hasPercentSetting = calls.some((call: any[]) => call[1] === 'percentSetting');
134
+ expect(hasPercentSetting).toBe(true);
135
+ });
136
+
137
+ it('should call subscribeAttribute for fanMode', () => {
138
+ // Act
139
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
140
+
141
+ // Assert
142
+ const calls = vi.mocked(mockEndpoint.subscribeAttribute).mock.calls;
143
+ const hasFanMode = calls.some((call: any[]) => call[1] === 'fanMode');
144
+ expect(hasFanMode).toBe(true);
145
+ });
146
+ });
147
+
148
+ describe('on command handler execution', () => {
149
+ it('should send Operation command with value 1', async () => {
150
+ // Arrange
151
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
152
+ const onHandler = vi
153
+ .mocked(mockEndpoint.addCommandHandler)
154
+ .mock.calls.find((call: any[]) => call[0] === 'on')?.[1] as ((...args: any[]) => any) | undefined;
155
+
156
+ // Act
157
+ if (onHandler) {
158
+ await onHandler({} as never, {} as never);
159
+ }
160
+
161
+ // Assert
162
+ expect(vi.mocked(mockApiClient.sendCommand)).toHaveBeenCalledWith('device-123', {
163
+ command: 'Operation',
164
+ dataKey: 'airState.operation',
165
+ dataValue: 1,
166
+ });
167
+ });
168
+
169
+ it('should rethrow error when sendCommand fails', async () => {
170
+ // Arrange
171
+ const testError = new Error('API request failed');
172
+ vi.mocked(mockApiClient.sendCommand).mockRejectedValueOnce(testError);
173
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
174
+ const onHandler = vi
175
+ .mocked(mockEndpoint.addCommandHandler)
176
+ .mock.calls.find((call: any[]) => call[0] === 'on')?.[1] as ((...args: any[]) => any) | undefined;
177
+
178
+ // Act & Assert
179
+ expect(onHandler).toBeDefined();
180
+ const handler = onHandler as (...args: any[]) => Promise<void>;
181
+ await expect(handler({} as never, {} as never)).rejects.toThrow('API request failed');
182
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("'on' failed"));
183
+ });
184
+ });
185
+
186
+ describe('off command handler execution', () => {
187
+ it('should send Operation command with value 0', async () => {
188
+ // Arrange
189
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
190
+ const offHandler = vi
191
+ .mocked(mockEndpoint.addCommandHandler)
192
+ .mock.calls.find((call: any[]) => call[0] === 'off')?.[1] as ((...args: any[]) => any) | undefined;
193
+
194
+ // Act
195
+ if (offHandler) {
196
+ await offHandler({} as never, {} as never);
197
+ }
198
+
199
+ // Assert
200
+ expect(vi.mocked(mockApiClient.sendCommand)).toHaveBeenCalledWith('device-123', {
201
+ command: 'Operation',
202
+ dataKey: 'airState.operation',
203
+ dataValue: 0,
204
+ });
205
+ });
206
+
207
+ it('should rethrow error when sendCommand fails', async () => {
208
+ // Arrange
209
+ const testError = new Error('API request failed');
210
+ vi.mocked(mockApiClient.sendCommand).mockRejectedValueOnce(testError);
211
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
212
+ const offHandler = vi
213
+ .mocked(mockEndpoint.addCommandHandler)
214
+ .mock.calls.find((call: any[]) => call[0] === 'off')?.[1] as ((...args: any[]) => any) | undefined;
215
+
216
+ // Act & Assert
217
+ expect(offHandler).toBeDefined();
218
+ const handler = offHandler as (...args: any[]) => Promise<void>;
219
+ await expect(handler({} as never, {} as never)).rejects.toThrow('API request failed');
220
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("'off' failed"));
221
+ });
222
+ });
223
+
224
+ describe('occupiedCoolingSetpoint subscribeAttribute handler', () => {
225
+ it('should ignore update when context.fabric is undefined', async () => {
226
+ // Arrange
227
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
228
+ const setCoolingHandler = vi
229
+ .mocked(mockEndpoint.subscribeAttribute)
230
+ .mock.calls.find((call: any[]) => call[1] === 'occupiedCoolingSetpoint')?.[2] as
231
+ ((...args: any[]) => any) | undefined;
232
+ const sendCommandSpy = vi.mocked(mockApiClient.sendCommand);
233
+
234
+ // Act
235
+ if (setCoolingHandler) {
236
+ setCoolingHandler(2200, 2100, { fabric: undefined });
237
+ await new Promise((resolve) => setTimeout(resolve, 50));
238
+ }
239
+
240
+ // Assert
241
+ expect(sendCommandSpy).not.toHaveBeenCalled();
242
+ });
243
+
244
+ it('should send temperature command with rounded value', async () => {
245
+ // Arrange
246
+ vi.clearAllMocks();
247
+ mockApiClient = createMockApiClient();
248
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
249
+ const setCoolingHandler = vi
250
+ .mocked(mockEndpoint.subscribeAttribute)
251
+ .mock.calls.find((call: any[]) => call[1] === 'occupiedCoolingSetpoint')?.[2] as
252
+ ((...args: any[]) => any) | undefined;
253
+
254
+ // Act: 2200 centidegrees = 22 degrees (already on 0.5 step)
255
+ if (setCoolingHandler) {
256
+ setCoolingHandler(2200, 2100, { fabric: { index: 1 } });
257
+ await new Promise((resolve) => setTimeout(resolve, 50));
258
+ }
259
+
260
+ // Assert
261
+ expect(vi.mocked(mockApiClient.sendCommand)).toHaveBeenCalledWith('device-123', {
262
+ dataKey: 'airState.tempState.target',
263
+ dataValue: 22,
264
+ });
265
+ });
266
+
267
+ it('should round setpoint to nearest 0.5°C step', async () => {
268
+ // Arrange
269
+ vi.clearAllMocks();
270
+ mockApiClient = createMockApiClient();
271
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
272
+ const setCoolingHandler = vi
273
+ .mocked(mockEndpoint.subscribeAttribute)
274
+ .mock.calls.find((call: any[]) => call[1] === 'occupiedCoolingSetpoint')?.[2] as
275
+ ((...args: any[]) => any) | undefined;
276
+
277
+ // Act: 2253 centidegrees = 22.53 degrees → rounds to 22.5
278
+ if (setCoolingHandler) {
279
+ setCoolingHandler(2253, 2200, { fabric: { index: 1 } });
280
+ await new Promise((resolve) => setTimeout(resolve, 50));
281
+ }
282
+
283
+ // Assert
284
+ expect(vi.mocked(mockApiClient.sendCommand)).toHaveBeenCalledWith('device-123', {
285
+ dataKey: 'airState.tempState.target',
286
+ dataValue: 22.5,
287
+ });
288
+ });
289
+
290
+ it('should revert setpoint on sendCommand failure', async () => {
291
+ // Arrange
292
+ const testError = new Error('API failed');
293
+ mockApiClient = createMockApiClient();
294
+ vi.mocked(mockApiClient.sendCommand).mockRejectedValueOnce(testError);
295
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
296
+ const setCoolingHandler = vi
297
+ .mocked(mockEndpoint.subscribeAttribute)
298
+ .mock.calls.find((call: any[]) => call[1] === 'occupiedCoolingSetpoint')?.[2] as
299
+ ((...args: any[]) => any) | undefined;
300
+ const updateAttributeSpy = vi.mocked(mockEndpoint.updateAttribute);
301
+
302
+ // Act
303
+ if (setCoolingHandler) {
304
+ setCoolingHandler(2200, 2100, { fabric: { index: 1 } });
305
+ await new Promise((resolve) => setTimeout(resolve, 100));
306
+ }
307
+
308
+ // Assert - should attempt to revert to oldValue
309
+ expect(updateAttributeSpy).toHaveBeenCalledWith(
310
+ 0x0201, // Thermostat.id
311
+ 'occupiedCoolingSetpoint',
312
+ 2100,
313
+ expect.anything(),
314
+ );
315
+ });
316
+ });
317
+
318
+ describe('occupiedHeatingSetpoint subscribeAttribute handler', () => {
319
+ it('should send temperature command when capability.supportsHeat is true', async () => {
320
+ // Arrange
321
+ const heatCapabilities: AirConditionerCapabilities = {
322
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
323
+ supportsHeat: true,
324
+ };
325
+ mockApiClient = createMockApiClient();
326
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, heatCapabilities);
327
+ const setHeatingHandler = vi
328
+ .mocked(mockEndpoint.subscribeAttribute)
329
+ .mock.calls.find((call: any[]) => call[1] === 'occupiedHeatingSetpoint')?.[2] as
330
+ ((...args: any[]) => any) | undefined;
331
+
332
+ // Act
333
+ if (setHeatingHandler) {
334
+ setHeatingHandler(2600, 2400, { fabric: { index: 1 } });
335
+ await new Promise((resolve) => setTimeout(resolve, 50));
336
+ }
337
+
338
+ // Assert
339
+ expect(vi.mocked(mockApiClient.sendCommand)).toHaveBeenCalledWith('device-123', {
340
+ dataKey: 'airState.tempState.target',
341
+ dataValue: 26,
342
+ });
343
+ });
344
+
345
+ it('should revert setpoint on sendCommand failure', async () => {
346
+ // Arrange
347
+ const heatCapabilities: AirConditionerCapabilities = {
348
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
349
+ supportsHeat: true,
350
+ };
351
+ const testError = new Error('API failed');
352
+ mockApiClient = createMockApiClient();
353
+ vi.mocked(mockApiClient.sendCommand).mockRejectedValueOnce(testError);
354
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, heatCapabilities);
355
+ const setHeatingHandler = vi
356
+ .mocked(mockEndpoint.subscribeAttribute)
357
+ .mock.calls.find((call: any[]) => call[1] === 'occupiedHeatingSetpoint')?.[2] as
358
+ ((...args: any[]) => any) | undefined;
359
+ const updateAttributeSpy = vi.mocked(mockEndpoint.updateAttribute);
360
+
361
+ // Act
362
+ if (setHeatingHandler) {
363
+ setHeatingHandler(2600, 2400, { fabric: { index: 1 } });
364
+ await new Promise((resolve) => setTimeout(resolve, 100));
365
+ }
366
+
367
+ // Assert
368
+ expect(updateAttributeSpy).toHaveBeenCalledWith(0x0201, 'occupiedHeatingSetpoint', 2400, expect.anything());
369
+ });
370
+ });
371
+
372
+ describe('FanControl percentSetting subscribeAttribute handler', () => {
373
+ it('should ignore update when context.fabric is undefined', () => {
374
+ // Arrange
375
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
376
+ const setPercentHandler = vi
377
+ .mocked(mockEndpoint.subscribeAttribute)
378
+ .mock.calls.find((call: any[]) => call[1] === 'percentSetting')?.[2] as ((...args: any[]) => any) | undefined;
379
+ const sendCommandSpy = vi.mocked(mockApiClient.sendCommand);
380
+
381
+ // Act
382
+ if (setPercentHandler) {
383
+ setPercentHandler(50, 20, { fabric: undefined });
384
+ }
385
+
386
+ // Assert
387
+ expect(sendCommandSpy).not.toHaveBeenCalled();
388
+ });
389
+
390
+ it('should ignore null newValue', () => {
391
+ // Arrange
392
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
393
+ const setPercentHandler = vi
394
+ .mocked(mockEndpoint.subscribeAttribute)
395
+ .mock.calls.find((call: any[]) => call[1] === 'percentSetting')?.[2] as ((...args: any[]) => any) | undefined;
396
+ const sendCommandSpy = vi.mocked(mockApiClient.sendCommand);
397
+
398
+ // Act
399
+ if (setPercentHandler) {
400
+ setPercentHandler(null, 20, { fabric: { index: 1 } });
401
+ }
402
+
403
+ // Assert
404
+ expect(sendCommandSpy).not.toHaveBeenCalled();
405
+ });
406
+
407
+ it('should ignore percentSetting=0', () => {
408
+ // Arrange
409
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
410
+ const setPercentHandler = vi
411
+ .mocked(mockEndpoint.subscribeAttribute)
412
+ .mock.calls.find((call: any[]) => call[1] === 'percentSetting')?.[2] as ((...args: any[]) => any) | undefined;
413
+ const sendCommandSpy = vi.mocked(mockApiClient.sendCommand);
414
+
415
+ // Act
416
+ if (setPercentHandler) {
417
+ setPercentHandler(0, 50, { fabric: { index: 1 } });
418
+ }
419
+
420
+ // Assert
421
+ expect(sendCommandSpy).not.toHaveBeenCalled();
422
+ expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('ignoring percentSetting=0'));
423
+ });
424
+
425
+ it('should map percent boundaries correctly', async () => {
426
+ // Arrange
427
+ const testCases = [
428
+ { percent: 1, expected: THINQ_FAN_SPEED_LOW },
429
+ { percent: 33, expected: THINQ_FAN_SPEED_LOW },
430
+ { percent: 34, expected: THINQ_FAN_SPEED_MEDIUM },
431
+ { percent: 66, expected: THINQ_FAN_SPEED_MEDIUM },
432
+ { percent: 67, expected: THINQ_FAN_SPEED_HIGH },
433
+ { percent: 100, expected: THINQ_FAN_SPEED_HIGH },
434
+ ];
435
+
436
+ for (const testCase of testCases) {
437
+ vi.clearAllMocks();
438
+ mockApiClient = createMockApiClient();
439
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
440
+ const setPercentHandler = vi
441
+ .mocked(mockEndpoint.subscribeAttribute)
442
+ .mock.calls.find((call: any[]) => call[1] === 'percentSetting')?.[2] as ((...args: any[]) => any) | undefined;
443
+
444
+ // Act
445
+ if (setPercentHandler) {
446
+ setPercentHandler(testCase.percent, 50, { fabric: { index: 1 } });
447
+ await new Promise((resolve) => setTimeout(resolve, 50));
448
+ }
449
+
450
+ // Assert
451
+ expect(vi.mocked(mockApiClient.sendCommand)).toHaveBeenCalledWith('device-123', {
452
+ dataKey: 'airState.windStrength',
453
+ dataValue: testCase.expected,
454
+ });
455
+ }
456
+ });
457
+
458
+ it('should ignore out-of-range percentSetting values', () => {
459
+ // Arrange
460
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
461
+ const setPercentHandler = vi
462
+ .mocked(mockEndpoint.subscribeAttribute)
463
+ .mock.calls.find((call: any[]) => call[1] === 'percentSetting')?.[2] as ((...args: any[]) => any) | undefined;
464
+ const sendCommandSpy = vi.mocked(mockApiClient.sendCommand);
465
+
466
+ // Act
467
+ if (setPercentHandler) {
468
+ setPercentHandler(150, 50, { fabric: { index: 1 } });
469
+ }
470
+
471
+ // Assert
472
+ expect(sendCommandSpy).not.toHaveBeenCalled();
473
+ });
474
+
475
+ it('should not send when supportsFanSpeedControl is false', () => {
476
+ // Arrange
477
+ const noFanCapabilities: AirConditionerCapabilities = {
478
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
479
+ supportsFanSpeedControl: false,
480
+ };
481
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, noFanCapabilities);
482
+ const setPercentHandler = vi
483
+ .mocked(mockEndpoint.subscribeAttribute)
484
+ .mock.calls.find((call: any[]) => call[1] === 'percentSetting')?.[2] as ((...args: any[]) => any) | undefined;
485
+ const sendCommandSpy = vi.mocked(mockApiClient.sendCommand);
486
+
487
+ // Act
488
+ if (setPercentHandler) {
489
+ setPercentHandler(50, 20, { fabric: { index: 1 } });
490
+ }
491
+
492
+ // Assert
493
+ expect(sendCommandSpy).not.toHaveBeenCalled();
494
+ });
495
+ });
496
+
497
+ describe('FanControl fanMode subscribeAttribute handler', () => {
498
+ it('should ignore update when context.fabric is undefined', () => {
499
+ // Arrange
500
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
501
+ const setFanModeHandler = vi
502
+ .mocked(mockEndpoint.subscribeAttribute)
503
+ .mock.calls.find((call: any[]) => call[1] === 'fanMode')?.[2] as ((...args: any[]) => any) | undefined;
504
+ const sendCommandSpy = vi.mocked(mockApiClient.sendCommand);
505
+
506
+ // Act
507
+ if (setFanModeHandler) {
508
+ setFanModeHandler(2, 1, { fabric: undefined }); // 2=Medium
509
+ }
510
+
511
+ // Assert
512
+ expect(sendCommandSpy).not.toHaveBeenCalled();
513
+ });
514
+
515
+ it('should map Low mode to windStrength 2', async () => {
516
+ // Arrange
517
+ mockApiClient = createMockApiClient();
518
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
519
+ const setFanModeHandler = vi
520
+ .mocked(mockEndpoint.subscribeAttribute)
521
+ .mock.calls.find((call: any[]) => call[1] === 'fanMode')?.[2] as ((...args: any[]) => any) | undefined;
522
+
523
+ // Act - FanControl.FanMode.Low = 1
524
+ if (setFanModeHandler) {
525
+ setFanModeHandler(1, 0, { fabric: { index: 1 } });
526
+ await new Promise((resolve) => setTimeout(resolve, 50));
527
+ }
528
+
529
+ // Assert
530
+ expect(vi.mocked(mockApiClient.sendCommand)).toHaveBeenCalledWith('device-123', {
531
+ dataKey: 'airState.windStrength',
532
+ dataValue: THINQ_FAN_SPEED_LOW,
533
+ });
534
+ });
535
+
536
+ it('should map Medium mode to windStrength 4', async () => {
537
+ // Arrange
538
+ mockApiClient = createMockApiClient();
539
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
540
+ const setFanModeHandler = vi
541
+ .mocked(mockEndpoint.subscribeAttribute)
542
+ .mock.calls.find((call: any[]) => call[1] === 'fanMode')?.[2] as ((...args: any[]) => any) | undefined;
543
+
544
+ // Act - FanControl.FanMode.Medium = 2
545
+ if (setFanModeHandler) {
546
+ setFanModeHandler(2, 0, { fabric: { index: 1 } });
547
+ await new Promise((resolve) => setTimeout(resolve, 50));
548
+ }
549
+
550
+ // Assert
551
+ expect(vi.mocked(mockApiClient.sendCommand)).toHaveBeenCalledWith('device-123', {
552
+ dataKey: 'airState.windStrength',
553
+ dataValue: THINQ_FAN_SPEED_MEDIUM,
554
+ });
555
+ });
556
+
557
+ it('should map High mode to windStrength 6', async () => {
558
+ // Arrange
559
+ mockApiClient = createMockApiClient();
560
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, capabilities);
561
+ const setFanModeHandler = vi
562
+ .mocked(mockEndpoint.subscribeAttribute)
563
+ .mock.calls.find((call: any[]) => call[1] === 'fanMode')?.[2] as ((...args: any[]) => any) | undefined;
564
+
565
+ // Act - FanControl.FanMode.High = 3
566
+ if (setFanModeHandler) {
567
+ setFanModeHandler(3, 0, { fabric: { index: 1 } });
568
+ await new Promise((resolve) => setTimeout(resolve, 50));
569
+ }
570
+
571
+ // Assert
572
+ expect(vi.mocked(mockApiClient.sendCommand)).toHaveBeenCalledWith('device-123', {
573
+ dataKey: 'airState.windStrength',
574
+ dataValue: THINQ_FAN_SPEED_HIGH,
575
+ });
576
+ });
577
+
578
+ it('should not send when supportsFanSpeedControl is false', () => {
579
+ // Arrange
580
+ const noFanCapabilities: AirConditionerCapabilities = {
581
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
582
+ supportsFanSpeedControl: false,
583
+ };
584
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, noFanCapabilities);
585
+ const setFanModeHandler = vi
586
+ .mocked(mockEndpoint.subscribeAttribute)
587
+ .mock.calls.find((call: any[]) => call[1] === 'fanMode')?.[2] as ((...args: any[]) => any) | undefined;
588
+ const sendCommandSpy = vi.mocked(mockApiClient.sendCommand);
589
+
590
+ // Act
591
+ if (setFanModeHandler) {
592
+ setFanModeHandler(0, 1, { fabric: { index: 1 } });
593
+ }
594
+
595
+ // Assert
596
+ expect(sendCommandSpy).not.toHaveBeenCalled();
597
+ });
598
+ });
599
+
600
+ describe('FanControl rockSetting subscribeAttribute handler (Phase B)', () => {
601
+ it('should register rockSetting subscribeAttribute when supportsSwingMode is true', () => {
602
+ // Arrange
603
+ const swingCapabilities: AirConditionerCapabilities = {
604
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
605
+ supportsSwingMode: true,
606
+ };
607
+
608
+ // Act
609
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, swingCapabilities);
610
+
611
+ // Assert
612
+ const calls = vi.mocked(mockEndpoint.subscribeAttribute).mock.calls;
613
+ const hasRockSetting = calls.some((call: any[]) => call[1] === 'rockSetting');
614
+ expect(hasRockSetting).toBe(true);
615
+ });
616
+
617
+ it('should not register rockSetting subscribeAttribute when supportsSwingMode is false', () => {
618
+ // Arrange
619
+ const noSwingCapabilities: AirConditionerCapabilities = {
620
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
621
+ supportsSwingMode: false,
622
+ };
623
+
624
+ // Act
625
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, noSwingCapabilities);
626
+
627
+ // Assert
628
+ const calls = vi.mocked(mockEndpoint.subscribeAttribute).mock.calls;
629
+ const hasRockSetting = calls.some((call: any[]) => call[1] === 'rockSetting');
630
+ expect(hasRockSetting).toBe(false);
631
+ });
632
+
633
+ it('should ignore update when context.fabric is undefined', () => {
634
+ // Arrange
635
+ const swingCapabilities: AirConditionerCapabilities = {
636
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
637
+ supportsSwingMode: true,
638
+ };
639
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, swingCapabilities);
640
+ const setRockHandler = vi
641
+ .mocked(mockEndpoint.subscribeAttribute)
642
+ .mock.calls.find((call: any[]) => call[1] === 'rockSetting')?.[2] as ((...args: any[]) => any) | undefined;
643
+ const sendCommandSpy = vi.mocked(mockApiClient.sendCommand);
644
+
645
+ // Act
646
+ if (setRockHandler) {
647
+ setRockHandler({ rockUpDown: true }, { rockUpDown: false }, { fabric: undefined });
648
+ }
649
+
650
+ // Assert
651
+ expect(sendCommandSpy).not.toHaveBeenCalled();
652
+ });
653
+
654
+ it('should send vertical swing command when rockUpDown flips true', async () => {
655
+ // Arrange
656
+ const swingCapabilities: AirConditionerCapabilities = {
657
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
658
+ supportsSwingMode: true,
659
+ };
660
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, swingCapabilities);
661
+ const setRockHandler = vi
662
+ .mocked(mockEndpoint.subscribeAttribute)
663
+ .mock.calls.find((call: any[]) => call[1] === 'rockSetting')?.[2] as ((...args: any[]) => any) | undefined;
664
+
665
+ // Act
666
+ if (setRockHandler) {
667
+ setRockHandler(
668
+ { rockUpDown: true, rockLeftRight: false, rockRound: false },
669
+ { rockUpDown: false, rockLeftRight: false, rockRound: false },
670
+ { fabric: { index: 1 } },
671
+ );
672
+ await new Promise((resolve) => setTimeout(resolve, 50));
673
+ }
674
+
675
+ // Assert
676
+ expect(vi.mocked(mockApiClient.sendCommand)).toHaveBeenCalledWith('device-123', {
677
+ dataKey: 'airState.wDir.vStep',
678
+ dataValue: '100',
679
+ });
680
+ });
681
+
682
+ it('should send vertical swing off command when rockUpDown flips false', async () => {
683
+ // Arrange
684
+ const swingCapabilities: AirConditionerCapabilities = {
685
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
686
+ supportsSwingMode: true,
687
+ };
688
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, swingCapabilities);
689
+ const setRockHandler = vi
690
+ .mocked(mockEndpoint.subscribeAttribute)
691
+ .mock.calls.find((call: any[]) => call[1] === 'rockSetting')?.[2] as ((...args: any[]) => any) | undefined;
692
+
693
+ // Act
694
+ if (setRockHandler) {
695
+ setRockHandler(
696
+ { rockUpDown: false, rockLeftRight: false, rockRound: false },
697
+ { rockUpDown: true, rockLeftRight: false, rockRound: false },
698
+ { fabric: { index: 1 } },
699
+ );
700
+ await new Promise((resolve) => setTimeout(resolve, 50));
701
+ }
702
+
703
+ // Assert
704
+ expect(vi.mocked(mockApiClient.sendCommand)).toHaveBeenCalledWith('device-123', {
705
+ dataKey: 'airState.wDir.vStep',
706
+ dataValue: '0',
707
+ });
708
+ });
709
+
710
+ it('should send horizontal swing command when rockLeftRight flips true', async () => {
711
+ // Arrange
712
+ const swingCapabilities: AirConditionerCapabilities = {
713
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
714
+ supportsSwingMode: true,
715
+ };
716
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, swingCapabilities);
717
+ const setRockHandler = vi
718
+ .mocked(mockEndpoint.subscribeAttribute)
719
+ .mock.calls.find((call: any[]) => call[1] === 'rockSetting')?.[2] as ((...args: any[]) => any) | undefined;
720
+
721
+ // Act
722
+ if (setRockHandler) {
723
+ setRockHandler(
724
+ { rockUpDown: false, rockLeftRight: true, rockRound: false },
725
+ { rockUpDown: false, rockLeftRight: false, rockRound: false },
726
+ { fabric: { index: 1 } },
727
+ );
728
+ await new Promise((resolve) => setTimeout(resolve, 50));
729
+ }
730
+
731
+ // Assert
732
+ expect(vi.mocked(mockApiClient.sendCommand)).toHaveBeenCalledWith('device-123', {
733
+ dataKey: 'airState.wDir.hStep',
734
+ dataValue: '100',
735
+ });
736
+ });
737
+
738
+ it('should send horizontal swing off command when rockLeftRight flips false', async () => {
739
+ // Arrange
740
+ const swingCapabilities: AirConditionerCapabilities = {
741
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
742
+ supportsSwingMode: true,
743
+ };
744
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, swingCapabilities);
745
+ const setRockHandler = vi
746
+ .mocked(mockEndpoint.subscribeAttribute)
747
+ .mock.calls.find((call: any[]) => call[1] === 'rockSetting')?.[2] as ((...args: any[]) => any) | undefined;
748
+
749
+ // Act
750
+ if (setRockHandler) {
751
+ setRockHandler(
752
+ { rockUpDown: false, rockLeftRight: false, rockRound: false },
753
+ { rockUpDown: false, rockLeftRight: true, rockRound: false },
754
+ { fabric: { index: 1 } },
755
+ );
756
+ await new Promise((resolve) => setTimeout(resolve, 50));
757
+ }
758
+
759
+ // Assert
760
+ expect(vi.mocked(mockApiClient.sendCommand)).toHaveBeenCalledWith('device-123', {
761
+ dataKey: 'airState.wDir.hStep',
762
+ dataValue: '0',
763
+ });
764
+ });
765
+
766
+ it('should send compound favoriteCtrl command when both axes flip to true simultaneously', async () => {
767
+ // Arrange
768
+ const swingCapabilities: AirConditionerCapabilities = {
769
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
770
+ supportsSwingMode: true,
771
+ };
772
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, swingCapabilities);
773
+ const setRockHandler = vi
774
+ .mocked(mockEndpoint.subscribeAttribute)
775
+ .mock.calls.find((call: any[]) => call[1] === 'rockSetting')?.[2] as ((...args: any[]) => any) | undefined;
776
+
777
+ // Act
778
+ if (setRockHandler) {
779
+ setRockHandler(
780
+ { rockUpDown: true, rockLeftRight: true, rockRound: false },
781
+ { rockUpDown: false, rockLeftRight: false, rockRound: false },
782
+ { fabric: { index: 1 } },
783
+ );
784
+ await new Promise((resolve) => setTimeout(resolve, 50));
785
+ }
786
+
787
+ // Assert
788
+ expect(vi.mocked(mockApiClient.sendCommand)).toHaveBeenCalledWith('device-123', {
789
+ dataKey: null,
790
+ dataValue: null,
791
+ command: 'Set',
792
+ ctrlKey: 'favoriteCtrl',
793
+ dataSetList: {
794
+ 'airState.wDir.vStep': '100',
795
+ 'airState.wDir.hStep': '100',
796
+ },
797
+ });
798
+ });
799
+
800
+ it('should send individual commands when only one axis changes (not compound favoriteCtrl)', async () => {
801
+ // Arrange
802
+ const swingCapabilities: AirConditionerCapabilities = {
803
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
804
+ supportsSwingMode: true,
805
+ };
806
+ mockApiClient = createMockApiClient();
807
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, swingCapabilities);
808
+ const setRockHandler = vi
809
+ .mocked(mockEndpoint.subscribeAttribute)
810
+ .mock.calls.find((call: any[]) => call[1] === 'rockSetting')?.[2] as ((...args: any[]) => any) | undefined;
811
+
812
+ // Act
813
+ if (setRockHandler) {
814
+ setRockHandler(
815
+ { rockUpDown: true, rockLeftRight: false, rockRound: false },
816
+ { rockUpDown: true, rockLeftRight: true, rockRound: false },
817
+ { fabric: { index: 1 } },
818
+ );
819
+ await new Promise((resolve) => setTimeout(resolve, 50));
820
+ }
821
+
822
+ // Assert
823
+ expect(vi.mocked(mockApiClient.sendCommand)).toHaveBeenCalledWith('device-123', {
824
+ dataKey: 'airState.wDir.hStep',
825
+ dataValue: '0',
826
+ });
827
+ // Should NOT use compound favoriteCtrl
828
+ const sendCalls = vi.mocked(mockApiClient.sendCommand).mock.calls;
829
+ const hasFavoriteCtrl = sendCalls.some((call: any[]) => call[1]?.ctrlKey === 'favoriteCtrl');
830
+ expect(hasFavoriteCtrl).toBe(false);
831
+ });
832
+
833
+ it('should ignore rockRound-only changes with debug log', () => {
834
+ // Arrange
835
+ const swingCapabilities: AirConditionerCapabilities = {
836
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
837
+ supportsSwingMode: true,
838
+ };
839
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, swingCapabilities);
840
+ const setRockHandler = vi
841
+ .mocked(mockEndpoint.subscribeAttribute)
842
+ .mock.calls.find((call: any[]) => call[1] === 'rockSetting')?.[2] as ((...args: any[]) => any) | undefined;
843
+ const sendCommandSpy = vi.mocked(mockApiClient.sendCommand);
844
+
845
+ // Act
846
+ if (setRockHandler) {
847
+ setRockHandler(
848
+ { rockUpDown: false, rockLeftRight: false, rockRound: true },
849
+ { rockUpDown: false, rockLeftRight: false, rockRound: false },
850
+ { fabric: { index: 1 } },
851
+ );
852
+ }
853
+
854
+ // Assert
855
+ expect(sendCommandSpy).not.toHaveBeenCalled();
856
+ });
857
+
858
+ it('should ignore update when both old and new values are undefined', () => {
859
+ // Arrange
860
+ const swingCapabilities: AirConditionerCapabilities = {
861
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
862
+ supportsSwingMode: true,
863
+ };
864
+ registerAirConditionerCommandHandlers(mockEndpoint, mockDevice, mockApiClient, mockLogger, swingCapabilities);
865
+ const setRockHandler = vi
866
+ .mocked(mockEndpoint.subscribeAttribute)
867
+ .mock.calls.find((call: any[]) => call[1] === 'rockSetting')?.[2] as ((...args: any[]) => any) | undefined;
868
+ const sendCommandSpy = vi.mocked(mockApiClient.sendCommand);
869
+
870
+ // Act
871
+ if (setRockHandler) {
872
+ setRockHandler(undefined, undefined, { fabric: { index: 1 } });
873
+ }
874
+
875
+ // Assert
876
+ expect(sendCommandSpy).not.toHaveBeenCalled();
877
+ });
878
+ });
879
+ });