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.
- package/.claude/CLAUDE.md +43 -0
- package/.claude/agents/compiler.md +91 -0
- package/.claude/agents/direct-executor.md +98 -0
- package/.claude/agents/documenter.md +78 -0
- package/.claude/agents/finalizer.md +233 -0
- package/.claude/agents/implementer.md +112 -0
- package/.claude/agents/investigator.md +100 -0
- package/.claude/agents/release-manager.md +203 -0
- package/.claude/agents/reviewer.md +131 -0
- package/.claude/agents/technical-architect.md +403 -0
- package/.claude/agents/test-writer.md +159 -0
- package/.claude/agents/wiki-manager.md +147 -0
- package/.claude/instructions/agent-prompts.md +90 -0
- package/.claude/instructions/shared-rules.md +11 -0
- package/.claude/instructions/team-orchestrator-policy.md +165 -0
- package/.claude/memory.md +186 -0
- package/.claude/output-styles/engineer-manager.md +38 -0
- package/.claude/settings.json +40 -0
- package/.claude/settings.local.json +30 -0
- package/.claude/skills/load-policy/SKILL.md +13 -0
- package/.claude/skills/ref-idea/SKILL.md +44 -0
- package/.claude/skills/status-of/SKILL.md +38 -0
- package/.claude/skills/workspace-console/SKILL.md +49 -0
- package/.claude/statusline-command.sh +130 -0
- package/.claude/statusline.md +53 -0
- package/.claude/templates/approval-packet.template.html +171 -0
- package/.claude/templates/reference-workspaces.md +16 -0
- package/.claude/tools/cli-runner-server.cjs +220 -0
- package/.claude/tools/discord-send-server.cjs +188 -0
- package/.claude/tools/github-release-server.cjs +181 -0
- package/.claude/tools/glob-grep-server.cjs +267 -0
- package/.claude/tools/read-log-server.cjs +175 -0
- package/.github/codeql/codeql-config.yml +14 -0
- package/.github/workflows/build.yml +54 -0
- package/.github/workflows/codeql.yml +59 -0
- package/.github/workflows/coverage.yml +70 -0
- package/.github/workflows/publish.yml +103 -0
- package/.mcp.json +34 -0
- package/CHANGELOG.md +195 -0
- package/LICENSE +21 -0
- package/dist/cli/commands/devices.js +52 -0
- package/dist/cli/commands/energy.js +122 -0
- package/dist/cli/commands/login.js +54 -0
- package/dist/cli/help.js +50 -0
- package/dist/cli/main.js +56 -0
- package/dist/cli/session.js +13 -0
- package/dist/cli/types.js +1 -0
- package/dist/cli/utils.js +29 -0
- package/dist/cli.js +2 -0
- package/dist/constants/index.js +1 -0
- package/dist/core/domain/entities/ThinqDevice.js +43 -0
- package/dist/core/domain/value-objects/AirConditionerCapabilities.js +22 -0
- package/dist/core/domain/value-objects/ThinqFilterState.js +35 -0
- package/dist/core/domain/value-objects/ThinqSnapshot.js +125 -0
- package/dist/errors/AuthenticationError.js +26 -0
- package/dist/errors/BaseError.js +23 -0
- package/dist/errors/index.js +2 -0
- package/dist/model/LgThinkqPluginPlatformConfig.js +23 -0
- package/dist/model/LgThinqPluginPlatformConfig.js +23 -0
- package/dist/module.js +212 -0
- package/dist/platform/deviceRegistry.js +26 -0
- package/dist/platform/platformConfigManager.js +89 -0
- package/dist/platform/platformState.js +9 -0
- package/dist/platform/thinq/thinqAirConditionerCommandHandlers.js +190 -0
- package/dist/platform/thinq/thinqAirConditionerEndpointFactory.js +53 -0
- package/dist/platform/thinq/thinqAirConditionerFilterResetCommandHandler.js +54 -0
- package/dist/platform/thinq/thinqAirConditionerFilterStateSync.js +19 -0
- package/dist/platform/thinq/thinqAirConditionerStateSync.js +205 -0
- package/dist/platform/thinq/thinqDeviceConfigReconciler.js +26 -0
- package/dist/platform/thinq/thinqDeviceConfigurator.js +124 -0
- package/dist/platform/thinq/thinqDeviceDiscovery.js +33 -0
- package/dist/platform/thinq/thinqWasherCommandHandlers.js +54 -0
- package/dist/platform/thinq/thinqWasherEndpointFactory.js +6 -0
- package/dist/platform/thinq/thinqWasherStateSync.js +31 -0
- package/dist/platform/thinq/thinqWasherStopCommandResolver.js +38 -0
- package/dist/services/authentication/AccountAuthStrategy.js +135 -0
- package/dist/services/authentication/AuthContext.js +1 -0
- package/dist/services/authentication/AuthenticationCoordinator.js +29 -0
- package/dist/services/authentication/IAuthStrategy.js +1 -0
- package/dist/services/authentication/TokenAuthStrategy.js +27 -0
- package/dist/services/authentication/UserDataRepository.js +32 -0
- package/dist/services/thinq/gateway.js +61 -0
- package/dist/services/thinq/mqtt/mqttCertificate.js +63 -0
- package/dist/services/thinq/mqtt/mqttConnectionEvents.js +37 -0
- package/dist/services/thinq/mqtt/mqttKeyRepository.js +33 -0
- package/dist/services/thinq/mqtt/mqttRetry.js +25 -0
- package/dist/services/thinq/mqtt/thinqMqttListener.js +97 -0
- package/dist/services/thinq/serviceContainer.js +73 -0
- package/dist/services/thinq/session.js +48 -0
- package/dist/services/thinq/thinqApiClient.js +258 -0
- package/dist/services/thinq/thinqDeviceService.js +75 -0
- package/dist/services/thinq/thinqFilterMonitoringService.js +40 -0
- package/dist/settings.js +9 -0
- package/dist/tests/helpers/testUtils.js +87 -0
- package/dist/utils/mask.js +39 -0
- package/docs/README_CLI.md +166 -0
- package/eslint.config.js +194 -0
- package/matterbridge-lg-thinq-plugin.config.json +27 -0
- package/matterbridge-lg-thinq-plugin.schema.json +520 -0
- package/package.json +107 -0
- package/prettier.config.js +49 -0
- package/scripts/parse-junit-failures.mjs +45 -0
- package/scripts/parse-tsc-failures.mjs +48 -0
- package/scripts/run-build-local-summary.mjs +29 -0
- package/scripts/run-format-summary.mjs +36 -0
- package/scripts/run-lint-fix-summary.mjs +38 -0
- package/scripts/run-precommit-summary.mjs +94 -0
- package/src/cli/commands/devices.ts +67 -0
- package/src/cli/commands/energy.ts +160 -0
- package/src/cli/commands/login.ts +74 -0
- package/src/cli/help.ts +53 -0
- package/src/cli/main.ts +66 -0
- package/src/cli/session.ts +15 -0
- package/src/cli/types.ts +10 -0
- package/src/cli/utils.ts +30 -0
- package/src/cli.ts +3 -0
- package/src/constants/index.ts +8 -0
- package/src/core/domain/entities/ThinqDevice.test.ts +224 -0
- package/src/core/domain/entities/ThinqDevice.ts +74 -0
- package/src/core/domain/value-objects/AirConditionerCapabilities.ts +50 -0
- package/src/core/domain/value-objects/ThinqFilterState.ts +50 -0
- package/src/core/domain/value-objects/ThinqSnapshot.ts +162 -0
- package/src/errors/AuthenticationError.ts +37 -0
- package/src/errors/BaseError.ts +38 -0
- package/src/errors/index.ts +24 -0
- package/src/model/LgThinqPluginPlatformConfig.ts +94 -0
- package/src/module.ts +298 -0
- package/src/platform/deviceRegistry.ts +58 -0
- package/src/platform/platformConfigManager.ts +133 -0
- package/src/platform/platformState.ts +16 -0
- package/src/platform/thinq/thinqAirConditionerCommandHandlers.ts +291 -0
- package/src/platform/thinq/thinqAirConditionerEndpointFactory.ts +145 -0
- package/src/platform/thinq/thinqAirConditionerFilterResetCommandHandler.ts +96 -0
- package/src/platform/thinq/thinqAirConditionerFilterStateSync.ts +42 -0
- package/src/platform/thinq/thinqAirConditionerStateSync.ts +343 -0
- package/src/platform/thinq/thinqDeviceConfigReconciler.ts +42 -0
- package/src/platform/thinq/thinqDeviceConfigurator.test.ts +90 -0
- package/src/platform/thinq/thinqDeviceConfigurator.ts +191 -0
- package/src/platform/thinq/thinqDeviceDiscovery.test.ts +173 -0
- package/src/platform/thinq/thinqDeviceDiscovery.ts +51 -0
- package/src/platform/thinq/thinqWasherCommandHandlers.ts +120 -0
- package/src/platform/thinq/thinqWasherEndpointFactory.ts +37 -0
- package/src/platform/thinq/thinqWasherStateSync.ts +60 -0
- package/src/platform/thinq/thinqWasherStopCommandResolver.ts +62 -0
- package/src/services/authentication/AccountAuthStrategy.test.ts +358 -0
- package/src/services/authentication/AccountAuthStrategy.ts +209 -0
- package/src/services/authentication/AuthContext.ts +19 -0
- package/src/services/authentication/AuthenticationCoordinator.test.ts +217 -0
- package/src/services/authentication/AuthenticationCoordinator.ts +59 -0
- package/src/services/authentication/IAuthStrategy.ts +10 -0
- package/src/services/authentication/TokenAuthStrategy.test.ts +167 -0
- package/src/services/authentication/TokenAuthStrategy.ts +40 -0
- package/src/services/authentication/UserDataRepository.test.ts +149 -0
- package/src/services/authentication/UserDataRepository.ts +46 -0
- package/src/services/thinq/gateway.ts +86 -0
- package/src/services/thinq/mqtt/mqttCertificate.test.ts +344 -0
- package/src/services/thinq/mqtt/mqttCertificate.ts +102 -0
- package/src/services/thinq/mqtt/mqttConnectionEvents.test.ts +276 -0
- package/src/services/thinq/mqtt/mqttConnectionEvents.ts +76 -0
- package/src/services/thinq/mqtt/mqttKeyRepository.test.ts +164 -0
- package/src/services/thinq/mqtt/mqttKeyRepository.ts +43 -0
- package/src/services/thinq/mqtt/mqttRetry.test.ts +239 -0
- package/src/services/thinq/mqtt/mqttRetry.ts +43 -0
- package/src/services/thinq/mqtt/thinqMqttListener.test.ts +349 -0
- package/src/services/thinq/mqtt/thinqMqttListener.ts +120 -0
- package/src/services/thinq/serviceContainer.test.ts +203 -0
- package/src/services/thinq/serviceContainer.ts +105 -0
- package/src/services/thinq/session.test.ts +287 -0
- package/src/services/thinq/session.ts +69 -0
- package/src/services/thinq/thinqApiClient.test.ts +702 -0
- package/src/services/thinq/thinqApiClient.ts +418 -0
- package/src/services/thinq/thinqDeviceService.test.ts +415 -0
- package/src/services/thinq/thinqDeviceService.ts +101 -0
- package/src/services/thinq/thinqFilterMonitoringService.test.ts +264 -0
- package/src/services/thinq/thinqFilterMonitoringService.ts +53 -0
- package/src/settings.ts +18 -0
- package/src/tests/cli/commands/devices.test.ts +293 -0
- package/src/tests/cli/commands/login.test.ts +240 -0
- package/src/tests/cli/help.test.ts +46 -0
- package/src/tests/cli/main.test.ts +214 -0
- package/src/tests/cli/session.test.ts +198 -0
- package/src/tests/cli/utils.test.ts +194 -0
- package/src/tests/core/domain/value-objects/AirConditionerCapabilities.test.ts +685 -0
- package/src/tests/core/domain/value-objects/ThinqFilterState.test.ts +210 -0
- package/src/tests/core/domain/value-objects/ThinqSnapshot.test.ts +1035 -0
- package/src/tests/helpers/testUtils.ts +118 -0
- package/src/tests/model/LgThinqPluginPlatformConfig.test.ts +81 -0
- package/src/tests/platform/deviceRegistry.test.ts +177 -0
- package/src/tests/platform/platformConfigManager.test.ts +790 -0
- package/src/tests/platform/platformState.test.ts +41 -0
- package/src/tests/platform/thinq/thinqAirConditionerCommandHandlers.test.ts +879 -0
- package/src/tests/platform/thinq/thinqAirConditionerEndpointFactory.test.ts +993 -0
- package/src/tests/platform/thinq/thinqAirConditionerFilterResetCommandHandler.test.ts +508 -0
- package/src/tests/platform/thinq/thinqAirConditionerFilterStateSync.test.ts +212 -0
- package/src/tests/platform/thinq/thinqAirConditionerStateSync.test.ts +1586 -0
- package/src/tests/platform/thinq/thinqDeviceConfigReconciler.test.ts +269 -0
- package/src/tests/platform/thinq/thinqDeviceConfigurator.test.ts +492 -0
- package/src/tests/platform/thinq/thinqWasherCommandHandlers.test.ts +594 -0
- package/src/tests/platform/thinq/thinqWasherEndpointFactory.test.ts +84 -0
- package/src/tests/platform/thinq/thinqWasherStateSync.test.ts +373 -0
- package/src/tests/platform/thinq/thinqWasherStopCommandResolver.test.ts +392 -0
- package/src/tests/settings.test.ts +33 -0
- package/src/utils/mask.ts +50 -0
- package/tsconfig.base.json +25 -0
- package/tsconfig.json +9 -0
- package/tsconfig.production.json +13 -0
- package/tsconfig.vitest.json +5 -0
- package/vite.config.ts +43 -0
- package/wiki/memory-archive.md +12 -0
- 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
|
+
});
|