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,1586 @@
1
+ import {
2
+ AirQuality,
3
+ ElectricalPowerMeasurement,
4
+ FanControl,
5
+ OnOff,
6
+ Pm10ConcentrationMeasurement,
7
+ Pm25ConcentrationMeasurement,
8
+ RelativeHumidityMeasurement,
9
+ TemperatureMeasurement,
10
+ Thermostat,
11
+ } from 'matterbridge/matter/clusters';
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13
+
14
+ import type { AirConditionerCapabilities } from '../../../core/domain/value-objects/AirConditionerCapabilities.js';
15
+ import { DEFAULT_AIR_CONDITIONER_CAPABILITIES } from '../../../core/domain/value-objects/AirConditionerCapabilities.js';
16
+ import { ThinqSnapshot } from '../../../core/domain/value-objects/ThinqSnapshot.js';
17
+ import {
18
+ applyThinqSnapshotToAirConditioner,
19
+ mapOperationModeToSystemMode,
20
+ mapWindStrengthToPercent,
21
+ } from '../../../platform/thinq/thinqAirConditionerStateSync.js';
22
+ import { createMockLogger } from '../../helpers/testUtils.js';
23
+
24
+ describe('applyThinqSnapshotToAirConditioner', () => {
25
+ let mockLogger: ReturnType<typeof createMockLogger>;
26
+ let airConditioner: any;
27
+ let capabilities: AirConditionerCapabilities;
28
+
29
+ beforeEach(async () => {
30
+ vi.clearAllMocks();
31
+ mockLogger = createMockLogger();
32
+ capabilities = DEFAULT_AIR_CONDITIONER_CAPABILITIES;
33
+
34
+ airConditioner = {
35
+ id: 0x01,
36
+ name: 'Living Room AC',
37
+ log: mockLogger,
38
+ updateAttribute: vi.fn().mockResolvedValue(false),
39
+ };
40
+ });
41
+
42
+ afterEach(() => {
43
+ vi.clearAllMocks();
44
+ });
45
+
46
+ it('should update all attributes with full snapshot when powered on with all values defined', async () => {
47
+ // Arrange
48
+ const snapshot = new ThinqSnapshot({
49
+ 'airState.operation': 1,
50
+ 'airState.tempState.current': 22,
51
+ 'airState.tempState.target': 24,
52
+ 'airState.opMode': 0, // COOL
53
+ 'airState.windStrength': 2, // LOW
54
+ });
55
+
56
+ const updateAttributeSpy = vi.spyOn(airConditioner, 'updateAttribute').mockResolvedValue(false);
57
+
58
+ // Act
59
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
60
+
61
+ // Assert
62
+ expect(updateAttributeSpy).toHaveBeenCalledWith(OnOff.id, 'onOff', true, mockLogger);
63
+ expect(updateAttributeSpy).toHaveBeenCalledWith(TemperatureMeasurement.id, 'measuredValue', 2200, mockLogger);
64
+ expect(updateAttributeSpy).toHaveBeenCalledWith(Thermostat.id, 'localTemperature', 2200, mockLogger);
65
+ expect(updateAttributeSpy).toHaveBeenCalledWith(Thermostat.id, 'occupiedCoolingSetpoint', 2400, mockLogger);
66
+ expect(updateAttributeSpy).toHaveBeenCalledWith(Thermostat.id, 'occupiedHeatingSetpoint', 2400, mockLogger);
67
+ expect(updateAttributeSpy).toHaveBeenCalledWith(
68
+ Thermostat.id,
69
+ 'systemMode',
70
+ Thermostat.SystemMode.Cool,
71
+ mockLogger,
72
+ );
73
+ expect(updateAttributeSpy).toHaveBeenCalledWith(FanControl.id, 'fanMode', FanControl.FanMode.Low, mockLogger);
74
+ expect(updateAttributeSpy).toHaveBeenCalledWith(FanControl.id, 'percentCurrent', 20, mockLogger);
75
+ });
76
+
77
+ it('should set systemMode to Off when power is off', async () => {
78
+ // Arrange
79
+ const snapshot = new ThinqSnapshot({
80
+ 'airState.operation': 0,
81
+ 'airState.tempState.current': 22,
82
+ 'airState.tempState.target': 24,
83
+ 'airState.opMode': 0, // COOL - ignored when powered off
84
+ 'airState.windStrength': 2,
85
+ });
86
+
87
+ const updateAttributeSpy = vi.spyOn(airConditioner, 'updateAttribute').mockResolvedValue(false);
88
+
89
+ // Act
90
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
91
+
92
+ // Assert
93
+ expect(updateAttributeSpy).toHaveBeenCalledWith(Thermostat.id, 'systemMode', Thermostat.SystemMode.Off, mockLogger);
94
+ });
95
+
96
+ it('should skip measuredValue and localTemperature updates when currentTemperatureCelsius is undefined', async () => {
97
+ // Arrange
98
+ const snapshot = new ThinqSnapshot({
99
+ 'airState.operation': 1,
100
+ 'airState.tempState.target': 24,
101
+ 'airState.opMode': 0,
102
+ 'airState.windStrength': 2,
103
+ });
104
+
105
+ const updateAttributeSpy = vi.spyOn(airConditioner, 'updateAttribute').mockResolvedValue(false);
106
+
107
+ // Act
108
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
109
+
110
+ // Assert
111
+ const calls = updateAttributeSpy.mock.calls;
112
+ const hasTemperatureMeasurement = calls.some(
113
+ (call) => call[0] === TemperatureMeasurement.id && call[1] === 'measuredValue',
114
+ );
115
+ const hasLocalTemp = calls.some((call) => call[0] === Thermostat.id && call[1] === 'localTemperature');
116
+
117
+ expect(hasTemperatureMeasurement).toBe(false);
118
+ expect(hasLocalTemp).toBe(false);
119
+ });
120
+
121
+ it('should skip setpoint updates when targetTemperatureCelsius is undefined', async () => {
122
+ // Arrange
123
+ const snapshot = new ThinqSnapshot({
124
+ 'airState.operation': 1,
125
+ 'airState.tempState.current': 22,
126
+ 'airState.opMode': 0,
127
+ 'airState.windStrength': 2,
128
+ });
129
+
130
+ const updateAttributeSpy = vi.spyOn(airConditioner, 'updateAttribute').mockResolvedValue(false);
131
+
132
+ // Act
133
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
134
+
135
+ // Assert
136
+ const calls = updateAttributeSpy.mock.calls;
137
+ const hasCoolingSetpoint = calls.some((call) => call[0] === Thermostat.id && call[1] === 'occupiedCoolingSetpoint');
138
+ const hasHeatingSetpoint = calls.some((call) => call[0] === Thermostat.id && call[1] === 'occupiedHeatingSetpoint');
139
+
140
+ expect(hasCoolingSetpoint).toBe(false);
141
+ expect(hasHeatingSetpoint).toBe(false);
142
+ });
143
+
144
+ it('should not update occupiedHeatingSetpoint when capabilities.supportsHeat is false', async () => {
145
+ // Arrange
146
+ const noHeatCapabilities: AirConditionerCapabilities = {
147
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
148
+ supportsHeat: false,
149
+ };
150
+
151
+ const snapshot = new ThinqSnapshot({
152
+ 'airState.operation': 1,
153
+ 'airState.tempState.current': 22,
154
+ 'airState.tempState.target': 24,
155
+ 'airState.opMode': 0,
156
+ 'airState.windStrength': 2,
157
+ });
158
+
159
+ const updateAttributeSpy = vi.spyOn(airConditioner, 'updateAttribute').mockResolvedValue(false);
160
+
161
+ // Act
162
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, noHeatCapabilities, mockLogger);
163
+
164
+ // Assert
165
+ const calls = updateAttributeSpy.mock.calls;
166
+ const hasHeatingSetpoint = calls.some((call) => call[0] === Thermostat.id && call[1] === 'occupiedHeatingSetpoint');
167
+
168
+ expect(hasHeatingSetpoint).toBe(false);
169
+ });
170
+
171
+ it('should not update fan attributes when supportsFanSpeedControl is false', async () => {
172
+ // Arrange
173
+ const noFanCapabilities: AirConditionerCapabilities = {
174
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
175
+ supportsFanSpeedControl: false,
176
+ };
177
+
178
+ const snapshot = new ThinqSnapshot({
179
+ 'airState.operation': 1,
180
+ 'airState.tempState.current': 22,
181
+ 'airState.tempState.target': 24,
182
+ 'airState.opMode': 0,
183
+ 'airState.windStrength': 2,
184
+ });
185
+
186
+ const updateAttributeSpy = vi.spyOn(airConditioner, 'updateAttribute').mockResolvedValue(false);
187
+
188
+ // Act
189
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, noFanCapabilities, mockLogger);
190
+
191
+ // Assert
192
+ const calls = updateAttributeSpy.mock.calls;
193
+ const hasPercentCurrent = calls.some((call: any[]) => call[0] === FanControl.id && call[1] === 'percentCurrent');
194
+
195
+ // Should have fanMode but mapped to fixed mode
196
+ expect(calls.some((call: any[]) => call[0] === FanControl.id && call[1] === 'fanMode')).toBe(true);
197
+ // Should not have percentCurrent
198
+ expect(hasPercentCurrent).toBe(false);
199
+ });
200
+ });
201
+
202
+ describe('mapWindStrengthToPercent', () => {
203
+ it('should map LOW windStrength to 20 percent', () => {
204
+ // Act & Assert
205
+ expect(mapWindStrengthToPercent(2)).toBe(20);
206
+ });
207
+
208
+ it('should map MEDIUM windStrength to 50 percent', () => {
209
+ // Act & Assert
210
+ expect(mapWindStrengthToPercent(4)).toBe(50);
211
+ });
212
+
213
+ it('should map HIGH windStrength to 90 percent', () => {
214
+ // Act & Assert
215
+ expect(mapWindStrengthToPercent(6)).toBe(90);
216
+ });
217
+
218
+ it('should map AUTO windStrength to undefined', () => {
219
+ // Act & Assert
220
+ expect(mapWindStrengthToPercent(8)).toBeUndefined();
221
+ });
222
+
223
+ it('should return undefined when windStrength is undefined', () => {
224
+ // Act & Assert
225
+ expect(mapWindStrengthToPercent(undefined)).toBeUndefined();
226
+ });
227
+ });
228
+
229
+ describe('mapOperationModeToSystemMode', () => {
230
+ let capabilities: AirConditionerCapabilities;
231
+
232
+ beforeEach(() => {
233
+ capabilities = DEFAULT_AIR_CONDITIONER_CAPABILITIES;
234
+ });
235
+
236
+ it('should return SystemMode.Off when powered off regardless of operation mode', () => {
237
+ // Act & Assert
238
+ expect(mapOperationModeToSystemMode(0, false, capabilities)).toBe(Thermostat.SystemMode.Off);
239
+ expect(mapOperationModeToSystemMode(4, false, capabilities)).toBe(Thermostat.SystemMode.Off);
240
+ expect(mapOperationModeToSystemMode(undefined, false, capabilities)).toBe(Thermostat.SystemMode.Off);
241
+ });
242
+
243
+ it('should map opMode 0 (COOL) to SystemMode.Cool', () => {
244
+ // Act & Assert
245
+ expect(mapOperationModeToSystemMode(0, true, capabilities)).toBe(Thermostat.SystemMode.Cool);
246
+ });
247
+
248
+ it('should map opMode 1 (DRY) to SystemMode.Cool (Apple Home has no Dry slot)', () => {
249
+ // Act & Assert
250
+ // Note: supportsDry is irrelevant; Dry always collapses to Cool for Apple Home compatibility
251
+ expect(mapOperationModeToSystemMode(1, true, capabilities)).toBe(Thermostat.SystemMode.Cool);
252
+ });
253
+
254
+ it('should map opMode 2 (FAN) to SystemMode.Cool (Apple Home has no FanOnly slot)', () => {
255
+ // Act & Assert
256
+ expect(mapOperationModeToSystemMode(2, true, capabilities)).toBe(Thermostat.SystemMode.Cool);
257
+ });
258
+
259
+ it('should map opMode 4 (HEAT) to SystemMode.Heat when supported', () => {
260
+ // Act & Assert
261
+ const heatCapabilities: AirConditionerCapabilities = {
262
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
263
+ supportsHeat: true,
264
+ };
265
+ expect(mapOperationModeToSystemMode(4, true, heatCapabilities)).toBe(Thermostat.SystemMode.Heat);
266
+ });
267
+
268
+ it('should map opMode 4 (HEAT) to SystemMode.Cool when not supported', () => {
269
+ // Act & Assert
270
+ const noHeatCapabilities: AirConditionerCapabilities = {
271
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
272
+ supportsHeat: false,
273
+ };
274
+ expect(mapOperationModeToSystemMode(4, true, noHeatCapabilities)).toBe(Thermostat.SystemMode.Cool);
275
+ });
276
+
277
+ it('should map opMode 5 (AIR_CLEAN) to SystemMode.Auto when heat supported', () => {
278
+ // Act & Assert
279
+ const heatCapabilities: AirConditionerCapabilities = {
280
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
281
+ supportsHeat: true,
282
+ };
283
+ expect(mapOperationModeToSystemMode(5, true, heatCapabilities)).toBe(Thermostat.SystemMode.Auto);
284
+ });
285
+
286
+ it('should map opMode 5 (AIR_CLEAN) to SystemMode.Cool when heat not supported', () => {
287
+ // Act & Assert
288
+ const noHeatCapabilities: AirConditionerCapabilities = {
289
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
290
+ supportsHeat: false,
291
+ };
292
+ expect(mapOperationModeToSystemMode(5, true, noHeatCapabilities)).toBe(Thermostat.SystemMode.Cool);
293
+ });
294
+
295
+ it('should map opMode 6 (AUTO) to SystemMode.Auto when heat supported', () => {
296
+ // Act & Assert
297
+ const heatCapabilities: AirConditionerCapabilities = {
298
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
299
+ supportsHeat: true,
300
+ };
301
+ expect(mapOperationModeToSystemMode(6, true, heatCapabilities)).toBe(Thermostat.SystemMode.Auto);
302
+ });
303
+
304
+ it('should map opMode 6 (AUTO) to SystemMode.Cool when heat not supported', () => {
305
+ // Act & Assert
306
+ const noHeatCapabilities: AirConditionerCapabilities = {
307
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
308
+ supportsHeat: false,
309
+ };
310
+ expect(mapOperationModeToSystemMode(6, true, noHeatCapabilities)).toBe(Thermostat.SystemMode.Cool);
311
+ });
312
+
313
+ it('should map unknown opMode to SystemMode.Auto when heat supported', () => {
314
+ // Act & Assert
315
+ const heatCapabilities: AirConditionerCapabilities = {
316
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
317
+ supportsHeat: true,
318
+ };
319
+ expect(mapOperationModeToSystemMode(999, true, heatCapabilities)).toBe(Thermostat.SystemMode.Auto);
320
+ });
321
+
322
+ it('should map unknown opMode to SystemMode.Cool when heat not supported', () => {
323
+ // Act & Assert
324
+ const noHeatCapabilities: AirConditionerCapabilities = {
325
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
326
+ supportsHeat: false,
327
+ };
328
+ expect(mapOperationModeToSystemMode(999, true, noHeatCapabilities)).toBe(Thermostat.SystemMode.Cool);
329
+ });
330
+
331
+ it('should map undefined opMode to SystemMode.Auto when heat supported', () => {
332
+ // Act & Assert
333
+ const heatCapabilities: AirConditionerCapabilities = {
334
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
335
+ supportsHeat: true,
336
+ };
337
+ expect(mapOperationModeToSystemMode(undefined, true, heatCapabilities)).toBe(Thermostat.SystemMode.Auto);
338
+ });
339
+
340
+ it('should map undefined opMode to SystemMode.Cool when heat not supported', () => {
341
+ // Act & Assert
342
+ const noHeatCapabilities: AirConditionerCapabilities = {
343
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
344
+ supportsHeat: false,
345
+ };
346
+ expect(mapOperationModeToSystemMode(undefined, true, noHeatCapabilities)).toBe(Thermostat.SystemMode.Cool);
347
+ });
348
+ });
349
+
350
+ describe('applyThinqSnapshotToAirConditioner with auxiliary toggles (Phase A)', () => {
351
+ let mockLogger: ReturnType<typeof createMockLogger>;
352
+ let airConditioner: any;
353
+ let capabilities: AirConditionerCapabilities;
354
+
355
+ beforeEach(() => {
356
+ vi.clearAllMocks();
357
+ mockLogger = createMockLogger();
358
+
359
+ airConditioner = {
360
+ id: 0x01,
361
+ name: 'Living Room AC',
362
+ log: mockLogger,
363
+ updateAttribute: vi.fn().mockResolvedValue(false),
364
+ getChildEndpointById: vi.fn(),
365
+ };
366
+ });
367
+
368
+ afterEach(() => {
369
+ vi.clearAllMocks();
370
+ });
371
+
372
+ describe('applyThinqSnapshotToAirConditioner with rockSetting (Phase B)', () => {
373
+ let mockLogger: ReturnType<typeof createMockLogger>;
374
+ let airConditioner: any;
375
+ let capabilities: AirConditionerCapabilities;
376
+
377
+ beforeEach(() => {
378
+ vi.clearAllMocks();
379
+ mockLogger = createMockLogger();
380
+
381
+ airConditioner = {
382
+ id: 0x01,
383
+ name: 'Living Room AC',
384
+ log: mockLogger,
385
+ updateAttribute: vi.fn().mockResolvedValue(false),
386
+ };
387
+ });
388
+
389
+ afterEach(() => {
390
+ vi.clearAllMocks();
391
+ });
392
+
393
+ it('should update rockSetting when supportsSwingMode is true with both axes on', async () => {
394
+ // Arrange
395
+ capabilities = {
396
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
397
+ supportsSwingMode: true,
398
+ };
399
+
400
+ const snapshot = new ThinqSnapshot({
401
+ 'airState.operation': 1,
402
+ 'airState.tempState.current': 22,
403
+ 'airState.tempState.target': 24,
404
+ 'airState.opMode': 0,
405
+ 'airState.windStrength': 2,
406
+ 'airState.wDir.vStep': 100,
407
+ 'airState.wDir.hStep': 100,
408
+ });
409
+
410
+ const updateAttributeSpy = vi.spyOn(airConditioner, 'updateAttribute').mockResolvedValue(false);
411
+
412
+ // Act
413
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
414
+
415
+ // Assert
416
+ expect(updateAttributeSpy).toHaveBeenCalledWith(
417
+ FanControl.id,
418
+ 'rockSetting',
419
+ {
420
+ rockLeftRight: true,
421
+ rockUpDown: true,
422
+ rockRound: true,
423
+ },
424
+ mockLogger,
425
+ );
426
+ });
427
+
428
+ it('should update rockSetting with vertical swing only', async () => {
429
+ // Arrange
430
+ capabilities = {
431
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
432
+ supportsSwingMode: true,
433
+ };
434
+
435
+ const snapshot = new ThinqSnapshot({
436
+ 'airState.operation': 1,
437
+ 'airState.tempState.current': 22,
438
+ 'airState.tempState.target': 24,
439
+ 'airState.opMode': 0,
440
+ 'airState.windStrength': 2,
441
+ 'airState.wDir.vStep': 100,
442
+ 'airState.wDir.hStep': 0,
443
+ });
444
+
445
+ const updateAttributeSpy = vi.spyOn(airConditioner, 'updateAttribute').mockResolvedValue(false);
446
+
447
+ // Act
448
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
449
+
450
+ // Assert
451
+ expect(updateAttributeSpy).toHaveBeenCalledWith(
452
+ FanControl.id,
453
+ 'rockSetting',
454
+ {
455
+ rockLeftRight: false,
456
+ rockUpDown: true,
457
+ rockRound: false,
458
+ },
459
+ mockLogger,
460
+ );
461
+ });
462
+
463
+ it('should update rockSetting with horizontal swing only', async () => {
464
+ // Arrange
465
+ capabilities = {
466
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
467
+ supportsSwingMode: true,
468
+ };
469
+
470
+ const snapshot = new ThinqSnapshot({
471
+ 'airState.operation': 1,
472
+ 'airState.tempState.current': 22,
473
+ 'airState.tempState.target': 24,
474
+ 'airState.opMode': 0,
475
+ 'airState.windStrength': 2,
476
+ 'airState.wDir.vStep': 0,
477
+ 'airState.wDir.hStep': 100,
478
+ });
479
+
480
+ const updateAttributeSpy = vi.spyOn(airConditioner, 'updateAttribute').mockResolvedValue(false);
481
+
482
+ // Act
483
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
484
+
485
+ // Assert
486
+ expect(updateAttributeSpy).toHaveBeenCalledWith(
487
+ FanControl.id,
488
+ 'rockSetting',
489
+ {
490
+ rockLeftRight: true,
491
+ rockUpDown: false,
492
+ rockRound: false,
493
+ },
494
+ mockLogger,
495
+ );
496
+ });
497
+
498
+ it('should update rockSetting with both axes off', async () => {
499
+ // Arrange
500
+ capabilities = {
501
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
502
+ supportsSwingMode: true,
503
+ };
504
+
505
+ const snapshot = new ThinqSnapshot({
506
+ 'airState.operation': 1,
507
+ 'airState.tempState.current': 22,
508
+ 'airState.tempState.target': 24,
509
+ 'airState.opMode': 0,
510
+ 'airState.windStrength': 2,
511
+ 'airState.wDir.vStep': 0,
512
+ 'airState.wDir.hStep': 0,
513
+ });
514
+
515
+ const updateAttributeSpy = vi.spyOn(airConditioner, 'updateAttribute').mockResolvedValue(false);
516
+
517
+ // Act
518
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
519
+
520
+ // Assert
521
+ expect(updateAttributeSpy).toHaveBeenCalledWith(
522
+ FanControl.id,
523
+ 'rockSetting',
524
+ {
525
+ rockLeftRight: false,
526
+ rockUpDown: false,
527
+ rockRound: false,
528
+ },
529
+ mockLogger,
530
+ );
531
+ });
532
+
533
+ it('should not update rockSetting when supportsSwingMode is false', async () => {
534
+ // Arrange
535
+ capabilities = {
536
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
537
+ supportsSwingMode: false,
538
+ };
539
+
540
+ const snapshot = new ThinqSnapshot({
541
+ 'airState.operation': 1,
542
+ 'airState.tempState.current': 22,
543
+ 'airState.tempState.target': 24,
544
+ 'airState.opMode': 0,
545
+ 'airState.windStrength': 2,
546
+ 'airState.wDir.vStep': 100,
547
+ 'airState.wDir.hStep': 100,
548
+ });
549
+
550
+ const updateAttributeSpy = vi.spyOn(airConditioner, 'updateAttribute').mockResolvedValue(false);
551
+
552
+ // Act
553
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
554
+
555
+ // Assert
556
+ const rockSettingCalls = updateAttributeSpy.mock.calls.filter(
557
+ (call: any[]) => call[0] === FanControl.id && call[1] === 'rockSetting',
558
+ );
559
+ expect(rockSettingCalls).toHaveLength(0);
560
+ });
561
+
562
+ it('should handle string values for swing steps (100 as string)', async () => {
563
+ // Arrange
564
+ capabilities = {
565
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
566
+ supportsSwingMode: true,
567
+ };
568
+
569
+ const snapshot = new ThinqSnapshot({
570
+ 'airState.operation': 1,
571
+ 'airState.tempState.current': 22,
572
+ 'airState.tempState.target': 24,
573
+ 'airState.opMode': 0,
574
+ 'airState.windStrength': 2,
575
+ 'airState.wDir.vStep': '100',
576
+ 'airState.wDir.hStep': '100',
577
+ });
578
+
579
+ const updateAttributeSpy = vi.spyOn(airConditioner, 'updateAttribute').mockResolvedValue(false);
580
+
581
+ // Act
582
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
583
+
584
+ // Assert
585
+ expect(updateAttributeSpy).toHaveBeenCalledWith(
586
+ FanControl.id,
587
+ 'rockSetting',
588
+ {
589
+ rockLeftRight: true,
590
+ rockUpDown: true,
591
+ rockRound: true,
592
+ },
593
+ mockLogger,
594
+ );
595
+ });
596
+
597
+ it('should maintain parity with existing attributes when supportsSwingMode is false', async () => {
598
+ // Arrange - regression test: all capabilities at default (false for new features)
599
+ capabilities = DEFAULT_AIR_CONDITIONER_CAPABILITIES;
600
+
601
+ const snapshot = new ThinqSnapshot({
602
+ 'airState.operation': 1,
603
+ 'airState.tempState.current': 22,
604
+ 'airState.tempState.target': 24,
605
+ 'airState.opMode': 0,
606
+ 'airState.windStrength': 2,
607
+ });
608
+
609
+ const updateAttributeSpy = vi.spyOn(airConditioner, 'updateAttribute').mockResolvedValue(false);
610
+
611
+ // Act
612
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
613
+
614
+ // Assert - existing attributes unchanged
615
+ expect(updateAttributeSpy).toHaveBeenCalledWith(OnOff.id, 'onOff', true, mockLogger);
616
+ expect(updateAttributeSpy).toHaveBeenCalledWith(TemperatureMeasurement.id, 'measuredValue', 2200, mockLogger);
617
+ expect(updateAttributeSpy).toHaveBeenCalledWith(Thermostat.id, 'localTemperature', 2200, mockLogger);
618
+ expect(updateAttributeSpy).toHaveBeenCalledWith(Thermostat.id, 'occupiedCoolingSetpoint', 2400, mockLogger);
619
+ expect(updateAttributeSpy).toHaveBeenCalledWith(Thermostat.id, 'occupiedHeatingSetpoint', 2400, mockLogger);
620
+ expect(updateAttributeSpy).toHaveBeenCalledWith(
621
+ Thermostat.id,
622
+ 'systemMode',
623
+ Thermostat.SystemMode.Cool,
624
+ mockLogger,
625
+ );
626
+ expect(updateAttributeSpy).toHaveBeenCalledWith(FanControl.id, 'fanMode', FanControl.FanMode.Low, mockLogger);
627
+ expect(updateAttributeSpy).toHaveBeenCalledWith(FanControl.id, 'percentCurrent', 20, mockLogger);
628
+ // rockSetting should NOT be called
629
+ const rockSettingCalls = updateAttributeSpy.mock.calls.filter(
630
+ (call: any[]) => call[0] === FanControl.id && call[1] === 'rockSetting',
631
+ );
632
+ expect(rockSettingCalls).toHaveLength(0);
633
+ });
634
+
635
+ describe('applyThinqSnapshotToAirConditioner with humidity and air quality sensors (Phase C)', () => {
636
+ let mockLogger: ReturnType<typeof createMockLogger>;
637
+ let airConditioner: any;
638
+ let capabilities: AirConditionerCapabilities;
639
+
640
+ beforeEach(() => {
641
+ vi.clearAllMocks();
642
+ mockLogger = createMockLogger();
643
+
644
+ airConditioner = {
645
+ id: 0x01,
646
+ name: 'Living Room AC',
647
+ log: mockLogger,
648
+ updateAttribute: vi.fn().mockResolvedValue(false),
649
+ getChildEndpointById: vi.fn(),
650
+ };
651
+ });
652
+
653
+ afterEach(() => {
654
+ vi.clearAllMocks();
655
+ });
656
+
657
+ it('should update humidity sensor when supportsHumiditySensor is enabled and humidityPercent is defined', async () => {
658
+ // Arrange
659
+ capabilities = {
660
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
661
+ supportsHumiditySensor: true,
662
+ };
663
+
664
+ const mockChild = {
665
+ updateAttribute: vi.fn().mockResolvedValue(false),
666
+ };
667
+ airConditioner.getChildEndpointById.mockReturnValue(mockChild);
668
+
669
+ const snapshot = new ThinqSnapshot({
670
+ 'airState.operation': 1,
671
+ 'airState.tempState.current': 22,
672
+ 'airState.tempState.target': 24,
673
+ 'airState.opMode': 0,
674
+ 'airState.windStrength': 2,
675
+ 'airState.humidity.current': 65,
676
+ });
677
+
678
+ // Act
679
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
680
+
681
+ // Assert
682
+ expect(airConditioner.getChildEndpointById).toHaveBeenCalledWith('HumiditySensor');
683
+ expect(mockChild.updateAttribute).toHaveBeenCalledWith(expect.any(Number), 'measuredValue', 6500, mockLogger);
684
+ });
685
+
686
+ it('should apply humidity heuristic when value > 100', async () => {
687
+ // Arrange
688
+ capabilities = {
689
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
690
+ supportsHumiditySensor: true,
691
+ };
692
+
693
+ const mockChild = {
694
+ updateAttribute: vi.fn().mockResolvedValue(false),
695
+ };
696
+ airConditioner.getChildEndpointById.mockReturnValue(mockChild);
697
+
698
+ const snapshot = new ThinqSnapshot({
699
+ 'airState.operation': 1,
700
+ 'airState.tempState.current': 22,
701
+ 'airState.tempState.target': 24,
702
+ 'airState.opMode': 0,
703
+ 'airState.windStrength': 2,
704
+ 'airState.humidity.current': 650,
705
+ });
706
+
707
+ // Act
708
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
709
+
710
+ // Assert
711
+ expect(mockChild.updateAttribute).toHaveBeenCalledWith(
712
+ expect.any(Number),
713
+ 'measuredValue',
714
+ 6500, // 65 * 100
715
+ mockLogger,
716
+ );
717
+ });
718
+
719
+ it('should not push humidity sensor when humidityPercent is undefined even if capability is enabled', async () => {
720
+ // Arrange
721
+ capabilities = {
722
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
723
+ supportsHumiditySensor: true,
724
+ };
725
+
726
+ const mockChild = {
727
+ updateAttribute: vi.fn().mockResolvedValue(false),
728
+ };
729
+ airConditioner.getChildEndpointById.mockReturnValue(mockChild);
730
+
731
+ const snapshot = new ThinqSnapshot({
732
+ 'airState.operation': 1,
733
+ 'airState.tempState.current': 22,
734
+ 'airState.tempState.target': 24,
735
+ 'airState.opMode': 0,
736
+ 'airState.windStrength': 2,
737
+ // humidity key absent
738
+ });
739
+
740
+ // Act
741
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
742
+
743
+ // Assert
744
+ // Child should not be retrieved since humidity is undefined (defensive check prevents child lookup)
745
+ expect(airConditioner.getChildEndpointById).not.toHaveBeenCalledWith('HumiditySensor');
746
+ expect(mockChild.updateAttribute).not.toHaveBeenCalled();
747
+ });
748
+
749
+ it('should not attempt humidity push when supportsHumiditySensor is disabled', async () => {
750
+ // Arrange
751
+ capabilities = {
752
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
753
+ supportsHumiditySensor: false,
754
+ };
755
+
756
+ const snapshot = new ThinqSnapshot({
757
+ 'airState.operation': 1,
758
+ 'airState.tempState.current': 22,
759
+ 'airState.tempState.target': 24,
760
+ 'airState.opMode': 0,
761
+ 'airState.windStrength': 2,
762
+ 'airState.humidity.current': 65,
763
+ });
764
+
765
+ // Act
766
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
767
+
768
+ // Assert
769
+ expect(airConditioner.getChildEndpointById).not.toHaveBeenCalledWith('HumiditySensor');
770
+ });
771
+
772
+ it('should handle missing HumiditySensor child endpoint gracefully', async () => {
773
+ // Arrange
774
+ capabilities = {
775
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
776
+ supportsHumiditySensor: true,
777
+ };
778
+
779
+ airConditioner.getChildEndpointById.mockReturnValue(undefined); // child doesn't exist
780
+
781
+ const snapshot = new ThinqSnapshot({
782
+ 'airState.operation': 1,
783
+ 'airState.tempState.current': 22,
784
+ 'airState.tempState.target': 24,
785
+ 'airState.opMode': 0,
786
+ 'airState.windStrength': 2,
787
+ 'airState.humidity.current': 65,
788
+ });
789
+
790
+ // Act & Assert - should not throw
791
+ await expect(
792
+ applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger),
793
+ ).resolves.not.toThrow();
794
+ expect(airConditioner.getChildEndpointById).toHaveBeenCalledWith('HumiditySensor');
795
+ });
796
+
797
+ it('should update air quality sensor when supportsAirQualitySensor is enabled and all values are defined', async () => {
798
+ // Arrange
799
+ capabilities = {
800
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
801
+ supportsAirQualitySensor: true,
802
+ };
803
+
804
+ const mockChild = {
805
+ updateAttribute: vi.fn().mockResolvedValue(false),
806
+ };
807
+ airConditioner.getChildEndpointById.mockReturnValue(mockChild);
808
+
809
+ const snapshot = new ThinqSnapshot({
810
+ 'airState.operation': 1,
811
+ 'airState.tempState.current': 22,
812
+ 'airState.tempState.target': 24,
813
+ 'airState.opMode': 0,
814
+ 'airState.windStrength': 2,
815
+ 'airState.quality.overall': 3,
816
+ 'airState.quality.PM2': 25,
817
+ 'airState.quality.PM10': 50,
818
+ });
819
+
820
+ // Act
821
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
822
+
823
+ // Assert
824
+ expect(airConditioner.getChildEndpointById).toHaveBeenCalledWith('AirQualitySensor');
825
+ expect(mockChild.updateAttribute).toHaveBeenCalledWith(expect.any(Number), 'airQuality', 3, mockLogger);
826
+ expect(mockChild.updateAttribute).toHaveBeenCalledWith(expect.any(Number), 'measuredValue', 25, mockLogger);
827
+ expect(mockChild.updateAttribute).toHaveBeenCalledWith(expect.any(Number), 'measuredValue', 50, mockLogger);
828
+ });
829
+
830
+ it('should only push air quality overall when PM values are undefined', async () => {
831
+ // Arrange
832
+ capabilities = {
833
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
834
+ supportsAirQualitySensor: true,
835
+ };
836
+
837
+ const mockChild = {
838
+ updateAttribute: vi.fn().mockResolvedValue(false),
839
+ };
840
+ airConditioner.getChildEndpointById.mockReturnValue(mockChild);
841
+
842
+ const snapshot = new ThinqSnapshot({
843
+ 'airState.operation': 1,
844
+ 'airState.tempState.current': 22,
845
+ 'airState.tempState.target': 24,
846
+ 'airState.opMode': 0,
847
+ 'airState.windStrength': 2,
848
+ 'airState.quality.overall': 2,
849
+ // PM2 and PM10 absent
850
+ });
851
+
852
+ // Act
853
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
854
+
855
+ // Assert
856
+ expect(mockChild.updateAttribute).toHaveBeenCalledWith(expect.any(Number), 'airQuality', 2, mockLogger);
857
+ // PM updates should not happen
858
+ const pmCalls = mockChild.updateAttribute.mock.calls.filter((call: any[]) => call[1] === 'measuredValue');
859
+ expect(pmCalls).toHaveLength(0);
860
+ });
861
+
862
+ it('should not push air quality when all values are undefined even if capability is enabled', async () => {
863
+ // Arrange
864
+ capabilities = {
865
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
866
+ supportsAirQualitySensor: true,
867
+ };
868
+
869
+ const mockChild = {
870
+ updateAttribute: vi.fn().mockResolvedValue(false),
871
+ };
872
+ airConditioner.getChildEndpointById.mockReturnValue(mockChild);
873
+
874
+ const snapshot = new ThinqSnapshot({
875
+ 'airState.operation': 1,
876
+ 'airState.tempState.current': 22,
877
+ 'airState.tempState.target': 24,
878
+ 'airState.opMode': 0,
879
+ 'airState.windStrength': 2,
880
+ // all quality values absent
881
+ });
882
+
883
+ // Act
884
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
885
+
886
+ // Assert
887
+ expect(airConditioner.getChildEndpointById).toHaveBeenCalledWith('AirQualitySensor');
888
+ expect(mockChild.updateAttribute).not.toHaveBeenCalled();
889
+ });
890
+
891
+ it('should not attempt air quality push when supportsAirQualitySensor is disabled', async () => {
892
+ // Arrange
893
+ capabilities = {
894
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
895
+ supportsAirQualitySensor: false,
896
+ };
897
+
898
+ const snapshot = new ThinqSnapshot({
899
+ 'airState.operation': 1,
900
+ 'airState.tempState.current': 22,
901
+ 'airState.tempState.target': 24,
902
+ 'airState.opMode': 0,
903
+ 'airState.windStrength': 2,
904
+ 'airState.quality.overall': 3,
905
+ 'airState.quality.PM2': 25,
906
+ 'airState.quality.PM10': 50,
907
+ });
908
+
909
+ // Act
910
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
911
+
912
+ // Assert
913
+ expect(airConditioner.getChildEndpointById).not.toHaveBeenCalledWith('AirQualitySensor');
914
+ });
915
+
916
+ it('should handle missing AirQualitySensor child endpoint gracefully', async () => {
917
+ // Arrange
918
+ capabilities = {
919
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
920
+ supportsAirQualitySensor: true,
921
+ };
922
+
923
+ airConditioner.getChildEndpointById.mockReturnValue(undefined); // child doesn't exist
924
+
925
+ const snapshot = new ThinqSnapshot({
926
+ 'airState.operation': 1,
927
+ 'airState.tempState.current': 22,
928
+ 'airState.tempState.target': 24,
929
+ 'airState.opMode': 0,
930
+ 'airState.windStrength': 2,
931
+ 'airState.quality.overall': 3,
932
+ 'airState.quality.PM2': 25,
933
+ 'airState.quality.PM10': 50,
934
+ });
935
+
936
+ // Act & Assert - should not throw
937
+ await expect(
938
+ applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger),
939
+ ).resolves.not.toThrow();
940
+ expect(airConditioner.getChildEndpointById).toHaveBeenCalledWith('AirQualitySensor');
941
+ });
942
+
943
+ it('should push both humidity and air quality sensors when both capabilities are enabled', async () => {
944
+ // Arrange
945
+ capabilities = {
946
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
947
+ supportsHumiditySensor: true,
948
+ supportsAirQualitySensor: true,
949
+ };
950
+
951
+ const mockHumidityChild = {
952
+ updateAttribute: vi.fn().mockResolvedValue(false),
953
+ };
954
+ const mockAirQualityChild = {
955
+ updateAttribute: vi.fn().mockResolvedValue(false),
956
+ };
957
+
958
+ airConditioner.getChildEndpointById.mockImplementation((id: string) => {
959
+ if (id === 'HumiditySensor') return mockHumidityChild;
960
+ if (id === 'AirQualitySensor') return mockAirQualityChild;
961
+ return undefined;
962
+ });
963
+
964
+ const snapshot = new ThinqSnapshot({
965
+ 'airState.operation': 1,
966
+ 'airState.tempState.current': 22,
967
+ 'airState.tempState.target': 24,
968
+ 'airState.opMode': 0,
969
+ 'airState.windStrength': 2,
970
+ 'airState.humidity.current': 65,
971
+ 'airState.quality.overall': 3,
972
+ 'airState.quality.PM2': 25,
973
+ 'airState.quality.PM10': 50,
974
+ });
975
+
976
+ // Act
977
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
978
+
979
+ // Assert
980
+ expect(airConditioner.getChildEndpointById).toHaveBeenCalledWith('HumiditySensor');
981
+ expect(airConditioner.getChildEndpointById).toHaveBeenCalledWith('AirQualitySensor');
982
+ expect(mockHumidityChild.updateAttribute).toHaveBeenCalled();
983
+ expect(mockAirQualityChild.updateAttribute).toHaveBeenCalled();
984
+ });
985
+
986
+ it('should maintain full regression parity with default capabilities (all sensors disabled)', async () => {
987
+ // Arrange
988
+ capabilities = DEFAULT_AIR_CONDITIONER_CAPABILITIES; // all new flags = false
989
+
990
+ const snapshot = new ThinqSnapshot({
991
+ 'airState.operation': 1,
992
+ 'airState.tempState.current': 22,
993
+ 'airState.tempState.target': 24,
994
+ 'airState.opMode': 0, // COOL
995
+ 'airState.windStrength': 2, // LOW
996
+ 'airState.humidity.current': 65,
997
+ 'airState.quality.overall': 3,
998
+ 'airState.quality.PM2': 25,
999
+ 'airState.quality.PM10': 50,
1000
+ });
1001
+
1002
+ const updateAttributeSpy = vi.spyOn(airConditioner, 'updateAttribute').mockResolvedValue(false);
1003
+
1004
+ // Act
1005
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1006
+
1007
+ // Assert - only the existing attributes should be updated
1008
+ expect(updateAttributeSpy).toHaveBeenCalledWith(OnOff.id, 'onOff', true, mockLogger);
1009
+ expect(updateAttributeSpy).toHaveBeenCalledWith(TemperatureMeasurement.id, 'measuredValue', 2200, mockLogger);
1010
+ expect(updateAttributeSpy).toHaveBeenCalledWith(Thermostat.id, 'localTemperature', 2200, mockLogger);
1011
+ expect(updateAttributeSpy).toHaveBeenCalledWith(Thermostat.id, 'occupiedCoolingSetpoint', 2400, mockLogger);
1012
+ expect(updateAttributeSpy).toHaveBeenCalledWith(Thermostat.id, 'occupiedHeatingSetpoint', 2400, mockLogger);
1013
+ expect(updateAttributeSpy).toHaveBeenCalledWith(
1014
+ Thermostat.id,
1015
+ 'systemMode',
1016
+ Thermostat.SystemMode.Cool,
1017
+ mockLogger,
1018
+ );
1019
+ expect(updateAttributeSpy).toHaveBeenCalledWith(FanControl.id, 'fanMode', FanControl.FanMode.Low, mockLogger);
1020
+ expect(updateAttributeSpy).toHaveBeenCalledWith(FanControl.id, 'percentCurrent', 20, mockLogger);
1021
+ // No sensor child endpoint calls should happen
1022
+ expect(airConditioner.getChildEndpointById).not.toHaveBeenCalled();
1023
+ });
1024
+
1025
+ describe('energy monitoring on AC endpoint (Phase D)', () => {
1026
+ let mockLogger: ReturnType<typeof createMockLogger>;
1027
+ let airConditioner: any;
1028
+ let capabilities: AirConditionerCapabilities;
1029
+
1030
+ beforeEach(() => {
1031
+ vi.clearAllMocks();
1032
+ mockLogger = createMockLogger();
1033
+
1034
+ airConditioner = {
1035
+ id: 0x01,
1036
+ name: 'Living Room AC',
1037
+ log: mockLogger,
1038
+ updateAttribute: vi.fn().mockResolvedValue(false),
1039
+ getChildEndpointById: vi.fn(),
1040
+ };
1041
+ });
1042
+
1043
+ afterEach(() => {
1044
+ vi.clearAllMocks();
1045
+ });
1046
+
1047
+ it('should push power consumption to AC endpoint when supportsEnergyMonitoring is true and value is defined', async () => {
1048
+ // Arrange
1049
+ capabilities = {
1050
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
1051
+ supportsEnergyMonitoring: true,
1052
+ };
1053
+
1054
+ const snapshot = new ThinqSnapshot({
1055
+ 'airState.operation': 1,
1056
+ 'airState.tempState.current': 22,
1057
+ 'airState.tempState.target': 24,
1058
+ 'airState.opMode': 0,
1059
+ 'airState.windStrength': 2,
1060
+ 'airState.energy.onCurrent': 500,
1061
+ });
1062
+
1063
+ // Act
1064
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1065
+
1066
+ // Assert
1067
+ expect(airConditioner.updateAttribute).toHaveBeenCalledWith(
1068
+ ElectricalPowerMeasurement.id,
1069
+ 'activePower',
1070
+ 500000,
1071
+ mockLogger,
1072
+ );
1073
+ });
1074
+
1075
+ it('should not push power consumption when supportsEnergyMonitoring is true but value is undefined', async () => {
1076
+ // Arrange
1077
+ capabilities = {
1078
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
1079
+ supportsEnergyMonitoring: true,
1080
+ };
1081
+
1082
+ const snapshot = new ThinqSnapshot({
1083
+ 'airState.operation': 1,
1084
+ 'airState.tempState.current': 22,
1085
+ 'airState.tempState.target': 24,
1086
+ 'airState.opMode': 0,
1087
+ 'airState.windStrength': 2,
1088
+ // airState.energy.onCurrent deliberately omitted
1089
+ });
1090
+
1091
+ // Act
1092
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1093
+
1094
+ // Assert
1095
+ const powerUpdateCalls = (airConditioner.updateAttribute as any).mock.calls.filter(
1096
+ (call: any[]) => call[1] === 'activePower',
1097
+ );
1098
+ expect(powerUpdateCalls).toHaveLength(0);
1099
+ });
1100
+
1101
+ it('should skip energy monitoring when capability is false (default)', async () => {
1102
+ // Arrange
1103
+ capabilities = {
1104
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
1105
+ supportsEnergyMonitoring: false,
1106
+ };
1107
+
1108
+ const snapshot = new ThinqSnapshot({
1109
+ 'airState.operation': 1,
1110
+ 'airState.tempState.current': 22,
1111
+ 'airState.tempState.target': 24,
1112
+ 'airState.opMode': 0,
1113
+ 'airState.windStrength': 2,
1114
+ 'airState.energy.onCurrent': 500,
1115
+ });
1116
+
1117
+ // Act & Assert - should not throw
1118
+ await expect(
1119
+ applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger),
1120
+ ).resolves.not.toThrow();
1121
+ const powerUpdateCalls = (airConditioner.updateAttribute as any).mock.calls.filter(
1122
+ (call: any[]) => call[1] === 'activePower',
1123
+ );
1124
+ expect(powerUpdateCalls).toHaveLength(0);
1125
+ });
1126
+
1127
+ it('should write 0 watts correctly when onCurrent is 0', async () => {
1128
+ // Arrange
1129
+ capabilities = {
1130
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
1131
+ supportsEnergyMonitoring: true,
1132
+ };
1133
+
1134
+ const snapshot = new ThinqSnapshot({
1135
+ 'airState.operation': 1,
1136
+ 'airState.tempState.current': 22,
1137
+ 'airState.tempState.target': 24,
1138
+ 'airState.opMode': 0,
1139
+ 'airState.windStrength': 2,
1140
+ 'airState.energy.onCurrent': 0,
1141
+ });
1142
+
1143
+ // Act
1144
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1145
+
1146
+ // Assert
1147
+ expect(airConditioner.updateAttribute).toHaveBeenCalledWith(
1148
+ ElectricalPowerMeasurement.id,
1149
+ 'activePower',
1150
+ 0,
1151
+ mockLogger,
1152
+ );
1153
+ });
1154
+
1155
+ it('should push power value 999 W correctly', async () => {
1156
+ // Arrange
1157
+ capabilities = {
1158
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
1159
+ supportsEnergyMonitoring: true,
1160
+ };
1161
+
1162
+ const snapshot = new ThinqSnapshot({
1163
+ 'airState.operation': 1,
1164
+ 'airState.tempState.current': 22,
1165
+ 'airState.tempState.target': 24,
1166
+ 'airState.opMode': 0,
1167
+ 'airState.windStrength': 2,
1168
+ 'airState.energy.onCurrent': 999,
1169
+ });
1170
+
1171
+ // Act
1172
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1173
+
1174
+ // Assert
1175
+ expect(airConditioner.updateAttribute).toHaveBeenCalledWith(
1176
+ ElectricalPowerMeasurement.id,
1177
+ 'activePower',
1178
+ 999000,
1179
+ mockLogger,
1180
+ );
1181
+ });
1182
+
1183
+ it('should maintain full regression parity with default capabilities (energy monitoring disabled)', async () => {
1184
+ // Arrange
1185
+ capabilities = { ...DEFAULT_AIR_CONDITIONER_CAPABILITIES };
1186
+
1187
+ const snapshot = new ThinqSnapshot({
1188
+ 'airState.operation': 1,
1189
+ 'airState.tempState.current': 22,
1190
+ 'airState.tempState.target': 24,
1191
+ 'airState.opMode': 0,
1192
+ 'airState.windStrength': 2,
1193
+ 'airState.energy.onCurrent': 500,
1194
+ });
1195
+
1196
+ const updateAttributeSpy = vi.spyOn(airConditioner, 'updateAttribute').mockResolvedValue(false);
1197
+
1198
+ // Act
1199
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1200
+
1201
+ // Assert - only the existing attributes should be updated
1202
+ expect(updateAttributeSpy).toHaveBeenCalledWith(OnOff.id, 'onOff', true, mockLogger);
1203
+ expect(updateAttributeSpy).toHaveBeenCalledWith(TemperatureMeasurement.id, 'measuredValue', 2200, mockLogger);
1204
+ expect(updateAttributeSpy).toHaveBeenCalledWith(Thermostat.id, 'localTemperature', 2200, mockLogger);
1205
+ expect(updateAttributeSpy).toHaveBeenCalledWith(Thermostat.id, 'occupiedCoolingSetpoint', 2400, mockLogger);
1206
+ expect(updateAttributeSpy).toHaveBeenCalledWith(Thermostat.id, 'occupiedHeatingSetpoint', 2400, mockLogger);
1207
+ expect(updateAttributeSpy).toHaveBeenCalledWith(
1208
+ Thermostat.id,
1209
+ 'systemMode',
1210
+ Thermostat.SystemMode.Cool,
1211
+ mockLogger,
1212
+ );
1213
+ expect(updateAttributeSpy).toHaveBeenCalledWith(FanControl.id, 'fanMode', FanControl.FanMode.Low, mockLogger);
1214
+ expect(updateAttributeSpy).toHaveBeenCalledWith(FanControl.id, 'percentCurrent', 20, mockLogger);
1215
+ // No energy monitor child endpoint calls should happen
1216
+ expect(airConditioner.getChildEndpointById).not.toHaveBeenCalledWith('EnergyMonitor');
1217
+ });
1218
+ });
1219
+ });
1220
+ });
1221
+
1222
+ describe('applyThinqSnapshotToAirConditioner (partial pushes)', () => {
1223
+ let mockLogger: ReturnType<typeof createMockLogger>;
1224
+ let airConditioner: any;
1225
+ let capabilities: AirConditionerCapabilities;
1226
+
1227
+ beforeEach(async () => {
1228
+ vi.clearAllMocks();
1229
+ mockLogger = createMockLogger();
1230
+ capabilities = DEFAULT_AIR_CONDITIONER_CAPABILITIES;
1231
+
1232
+ airConditioner = {
1233
+ id: 0x01,
1234
+ name: 'Living Room AC',
1235
+ log: mockLogger,
1236
+ updateAttribute: vi.fn().mockResolvedValue(false),
1237
+ getAttribute: vi.fn(),
1238
+ getChildEndpointById: vi.fn(),
1239
+ };
1240
+ });
1241
+
1242
+ afterEach(() => {
1243
+ vi.clearAllMocks();
1244
+ });
1245
+
1246
+ it('should write only temperature attributes for temp-only push', async () => {
1247
+ // Arrange
1248
+ const snapshot = new ThinqSnapshot({ 'airState.tempState.current': 27.5 });
1249
+
1250
+ // Act
1251
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1252
+
1253
+ // Assert
1254
+ const calls = (airConditioner.updateAttribute as any).mock.calls;
1255
+ const hasOnOff = calls.some((call: any[]) => call[0] === OnOff.id && call[1] === 'onOff');
1256
+ const hasSystemMode = calls.some((call: any[]) => call[0] === Thermostat.id && call[1] === 'systemMode');
1257
+ const hasFanMode = calls.some((call: any[]) => call[0] === FanControl.id && call[1] === 'fanMode');
1258
+ const hasMeasuredValue = calls.some(
1259
+ (call: any[]) => call[0] === TemperatureMeasurement.id && call[1] === 'measuredValue',
1260
+ );
1261
+
1262
+ expect(hasOnOff).toBe(false);
1263
+ expect(hasSystemMode).toBe(false);
1264
+ expect(hasFanMode).toBe(false);
1265
+ expect(hasMeasuredValue).toBe(true);
1266
+ });
1267
+
1268
+ it('should write energy to AC endpoint for energy-only push when OnOff is true', async () => {
1269
+ // Arrange
1270
+ capabilities = {
1271
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
1272
+ supportsEnergyMonitoring: true,
1273
+ };
1274
+
1275
+ airConditioner.getAttribute.mockImplementation((clusterId: number, attr: string) => {
1276
+ if (clusterId === OnOff.id && attr === 'onOff') {
1277
+ return true; // Explicitly true
1278
+ }
1279
+ return undefined;
1280
+ });
1281
+
1282
+ const snapshot = new ThinqSnapshot({ 'airState.energy.onCurrent': 996 });
1283
+
1284
+ // Act
1285
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1286
+
1287
+ // Assert
1288
+ const acCalls = (airConditioner.updateAttribute as any).mock.calls;
1289
+ const hasOnOff = acCalls.some((call: any[]) => call[0] === OnOff.id);
1290
+ const hasSystemMode = acCalls.some((call: any[]) => call[0] === Thermostat.id);
1291
+
1292
+ expect(hasOnOff).toBe(false);
1293
+ expect(hasSystemMode).toBe(false);
1294
+ expect(airConditioner.updateAttribute).toHaveBeenCalledWith(
1295
+ ElectricalPowerMeasurement.id,
1296
+ 'activePower',
1297
+ 996000,
1298
+ mockLogger,
1299
+ );
1300
+ });
1301
+
1302
+ it('should not write activePower for energy-only push when operation is absent (no bogus 0 W)', async () => {
1303
+ // Arrange
1304
+ capabilities = {
1305
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
1306
+ supportsEnergyMonitoring: true,
1307
+ };
1308
+
1309
+ const snapshot = new ThinqSnapshot({ 'airState.tempState.current': 27.5 });
1310
+
1311
+ // Act
1312
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1313
+
1314
+ // Assert
1315
+ const calls = (airConditioner.updateAttribute as any).mock.calls;
1316
+ const hasActivePower = calls.some((call: any[]) => call[1] === 'activePower');
1317
+
1318
+ expect(hasActivePower).toBe(false);
1319
+ });
1320
+
1321
+ it('should write OnOff false and systemMode Off for operation=0 only push', async () => {
1322
+ // Arrange
1323
+ const snapshot = new ThinqSnapshot({ 'airState.operation': 0 });
1324
+
1325
+ // Act
1326
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1327
+
1328
+ // Assert
1329
+ expect(airConditioner.updateAttribute).toHaveBeenCalledWith(OnOff.id, 'onOff', false, mockLogger);
1330
+ expect(airConditioner.updateAttribute).toHaveBeenCalledWith(
1331
+ Thermostat.id,
1332
+ 'systemMode',
1333
+ Thermostat.SystemMode.Off,
1334
+ mockLogger,
1335
+ );
1336
+ });
1337
+
1338
+ it('should write OnOff true but not systemMode for operation=1 only push', async () => {
1339
+ // Arrange
1340
+ const snapshot = new ThinqSnapshot({ 'airState.operation': 1 });
1341
+
1342
+ // Act
1343
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1344
+
1345
+ // Assert
1346
+ expect(airConditioner.updateAttribute).toHaveBeenCalledWith(OnOff.id, 'onOff', true, mockLogger);
1347
+
1348
+ const calls = (airConditioner.updateAttribute as any).mock.calls;
1349
+ const hasSystemMode = calls.some((call: any[]) => call[0] === Thermostat.id && call[1] === 'systemMode');
1350
+ expect(hasSystemMode).toBe(false);
1351
+ });
1352
+
1353
+ it('should write systemMode derived from opMode when OnOff is currently true', async () => {
1354
+ // Arrange
1355
+ airConditioner.getAttribute.mockImplementation((clusterId: number, attr: string) => {
1356
+ if (clusterId === OnOff.id && attr === 'onOff') {
1357
+ return true;
1358
+ }
1359
+ return undefined;
1360
+ });
1361
+
1362
+ const snapshot = new ThinqSnapshot({ 'airState.opMode': 0 }); // COOL
1363
+
1364
+ // Act
1365
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1366
+
1367
+ // Assert
1368
+ expect(airConditioner.updateAttribute).toHaveBeenCalledWith(
1369
+ Thermostat.id,
1370
+ 'systemMode',
1371
+ Thermostat.SystemMode.Cool,
1372
+ mockLogger,
1373
+ );
1374
+ });
1375
+
1376
+ it('should not write systemMode for opMode-only push when OnOff is currently false', async () => {
1377
+ // Arrange
1378
+ airConditioner.getAttribute.mockImplementation((clusterId: number, attr: string) => {
1379
+ if (clusterId === OnOff.id && attr === 'onOff') {
1380
+ return false;
1381
+ }
1382
+ return undefined;
1383
+ });
1384
+
1385
+ const snapshot = new ThinqSnapshot({ 'airState.opMode': 0 });
1386
+
1387
+ // Act
1388
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1389
+
1390
+ // Assert
1391
+ const calls = (airConditioner.updateAttribute as any).mock.calls;
1392
+ const hasSystemMode = calls.some((call: any[]) => call[0] === Thermostat.id && call[1] === 'systemMode');
1393
+ expect(hasSystemMode).toBe(false);
1394
+ });
1395
+
1396
+ it('should not write systemMode for opMode-only push when OnOff is undefined', async () => {
1397
+ // Arrange
1398
+ airConditioner.getAttribute.mockReturnValue(undefined);
1399
+
1400
+ const snapshot = new ThinqSnapshot({ 'airState.opMode': 0 });
1401
+
1402
+ // Act
1403
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1404
+
1405
+ // Assert
1406
+ const calls = (airConditioner.updateAttribute as any).mock.calls;
1407
+ const hasSystemMode = calls.some((call: any[]) => call[0] === Thermostat.id && call[1] === 'systemMode');
1408
+ expect(hasSystemMode).toBe(false);
1409
+ });
1410
+
1411
+ it('should map unsupported opMode (Dry when disabled) to Cool when OnOff is true', async () => {
1412
+ // Arrange
1413
+ // Note: supportsDry is irrelevant; Dry always collapses to Cool for Apple Home compatibility
1414
+ capabilities = DEFAULT_AIR_CONDITIONER_CAPABILITIES;
1415
+
1416
+ airConditioner.getAttribute.mockImplementation((clusterId: number, attr: string) => {
1417
+ if (clusterId === OnOff.id && attr === 'onOff') {
1418
+ return true;
1419
+ }
1420
+ return undefined;
1421
+ });
1422
+
1423
+ const snapshot = new ThinqSnapshot({ 'airState.opMode': 1 }); // DRY (unsupported)
1424
+
1425
+ // Act
1426
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1427
+
1428
+ // Assert
1429
+ expect(airConditioner.updateAttribute).toHaveBeenCalledWith(
1430
+ Thermostat.id,
1431
+ 'systemMode',
1432
+ Thermostat.SystemMode.Cool,
1433
+ mockLogger,
1434
+ );
1435
+ });
1436
+
1437
+ it('should write fan mode and percent for windStrength-only push', async () => {
1438
+ // Arrange
1439
+ const snapshot = new ThinqSnapshot({ 'airState.windStrength': 2 }); // LOW
1440
+
1441
+ // Act
1442
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1443
+
1444
+ // Assert
1445
+ expect(airConditioner.updateAttribute).toHaveBeenCalledWith(
1446
+ FanControl.id,
1447
+ 'fanMode',
1448
+ FanControl.FanMode.Low,
1449
+ mockLogger,
1450
+ );
1451
+ expect(airConditioner.updateAttribute).toHaveBeenCalledWith(FanControl.id, 'percentCurrent', 20, mockLogger);
1452
+ });
1453
+
1454
+ it('should write rockSetting when vertical swing axis only is present', async () => {
1455
+ // Arrange
1456
+ capabilities = {
1457
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
1458
+ supportsFanSpeedControl: true,
1459
+ supportsSwingMode: true,
1460
+ };
1461
+
1462
+ airConditioner.getAttribute.mockImplementation((clusterId: number, attr: string) => {
1463
+ if (clusterId === FanControl.id && attr === 'rockSetting') {
1464
+ return { rockLeftRight: true, rockUpDown: false, rockRound: false };
1465
+ }
1466
+ return undefined;
1467
+ });
1468
+
1469
+ const snapshot = new ThinqSnapshot({ 'airState.wDir.vStep': 100 });
1470
+
1471
+ // Act
1472
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1473
+
1474
+ // Assert
1475
+ expect(airConditioner.updateAttribute).toHaveBeenCalledWith(
1476
+ FanControl.id,
1477
+ 'rockSetting',
1478
+ {
1479
+ rockLeftRight: true,
1480
+ rockUpDown: true,
1481
+ rockRound: true,
1482
+ },
1483
+ mockLogger,
1484
+ );
1485
+ });
1486
+
1487
+ it('should not write rockSetting when neither swing axis is present', async () => {
1488
+ // Arrange
1489
+ capabilities = {
1490
+ ...DEFAULT_AIR_CONDITIONER_CAPABILITIES,
1491
+ supportsFanSpeedControl: true,
1492
+ supportsSwingMode: true,
1493
+ };
1494
+
1495
+ const snapshot = new ThinqSnapshot({ 'airState.tempState.current': 27.5 });
1496
+
1497
+ // Act
1498
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1499
+
1500
+ // Assert
1501
+ const calls = (airConditioner.updateAttribute as any).mock.calls;
1502
+ const hasRockSetting = calls.some((call: any[]) => call[0] === FanControl.id && call[1] === 'rockSetting');
1503
+ expect(hasRockSetting).toBe(false);
1504
+ });
1505
+
1506
+ it('should not write OnOff for push with online extra', async () => {
1507
+ // Arrange
1508
+ const snapshot = new ThinqSnapshot({ online: true, 'airState.tempState.current': 27.5 });
1509
+
1510
+ // Act
1511
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1512
+
1513
+ // Assert
1514
+ const calls = (airConditioner.updateAttribute as any).mock.calls;
1515
+ const hasOnOff = calls.some((call: any[]) => call[0] === OnOff.id && call[1] === 'onOff');
1516
+ expect(hasOnOff).toBe(false);
1517
+ });
1518
+
1519
+ it('should log skipped attributes for partial push', async () => {
1520
+ // Arrange
1521
+ const snapshot = new ThinqSnapshot({ 'airState.tempState.current': 27.5 });
1522
+
1523
+ // Act
1524
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1525
+
1526
+ // Assert
1527
+ expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('skipped (source key absent)'));
1528
+ });
1529
+
1530
+ it('should maintain full parity with full snapshot', async () => {
1531
+ // Arrange
1532
+ const snapshot = new ThinqSnapshot({
1533
+ 'airState.operation': 1,
1534
+ 'airState.tempState.current': 22,
1535
+ 'airState.tempState.target': 24,
1536
+ 'airState.opMode': 0, // COOL
1537
+ 'airState.windStrength': 2, // LOW
1538
+ });
1539
+
1540
+ const updateAttributeSpy = vi.spyOn(airConditioner, 'updateAttribute').mockResolvedValue(false);
1541
+
1542
+ // Act
1543
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1544
+
1545
+ // Assert
1546
+ expect(updateAttributeSpy).toHaveBeenCalledWith(OnOff.id, 'onOff', true, mockLogger);
1547
+ expect(updateAttributeSpy).toHaveBeenCalledWith(TemperatureMeasurement.id, 'measuredValue', 2200, mockLogger);
1548
+ expect(updateAttributeSpy).toHaveBeenCalledWith(Thermostat.id, 'localTemperature', 2200, mockLogger);
1549
+ expect(updateAttributeSpy).toHaveBeenCalledWith(Thermostat.id, 'occupiedCoolingSetpoint', 2400, mockLogger);
1550
+ expect(updateAttributeSpy).toHaveBeenCalledWith(Thermostat.id, 'occupiedHeatingSetpoint', 2400, mockLogger);
1551
+ expect(updateAttributeSpy).toHaveBeenCalledWith(
1552
+ Thermostat.id,
1553
+ 'systemMode',
1554
+ Thermostat.SystemMode.Cool,
1555
+ mockLogger,
1556
+ );
1557
+ expect(updateAttributeSpy).toHaveBeenCalledWith(FanControl.id, 'fanMode', FanControl.FanMode.Low, mockLogger);
1558
+ expect(updateAttributeSpy).toHaveBeenCalledWith(FanControl.id, 'percentCurrent', 20, mockLogger);
1559
+ });
1560
+
1561
+ it('should write OnOff false and systemMode Off for full snapshot with operation 0', async () => {
1562
+ // Arrange
1563
+ const snapshot = new ThinqSnapshot({
1564
+ 'airState.operation': 0,
1565
+ 'airState.tempState.current': 22,
1566
+ 'airState.tempState.target': 24,
1567
+ 'airState.opMode': 0,
1568
+ 'airState.windStrength': 2,
1569
+ });
1570
+
1571
+ const updateAttributeSpy = vi.spyOn(airConditioner, 'updateAttribute').mockResolvedValue(false);
1572
+
1573
+ // Act
1574
+ await applyThinqSnapshotToAirConditioner(airConditioner, snapshot, capabilities, mockLogger);
1575
+
1576
+ // Assert
1577
+ expect(updateAttributeSpy).toHaveBeenCalledWith(OnOff.id, 'onOff', false, mockLogger);
1578
+ expect(updateAttributeSpy).toHaveBeenCalledWith(
1579
+ Thermostat.id,
1580
+ 'systemMode',
1581
+ Thermostat.SystemMode.Off,
1582
+ mockLogger,
1583
+ );
1584
+ });
1585
+ });
1586
+ });