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