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,415 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { asPartial, buildThinqDeviceData, createMockLogger } from '../../tests/helpers/testUtils.js';
|
|
4
|
+
import type { ThinqApiClient } from './thinqApiClient.js';
|
|
5
|
+
import { ThinqDeviceService } from './thinqDeviceService.js';
|
|
6
|
+
|
|
7
|
+
describe('ThinqDeviceService', () => {
|
|
8
|
+
let mockApiClient: ThinqApiClient;
|
|
9
|
+
let mockLogger: ReturnType<typeof createMockLogger>;
|
|
10
|
+
let service: ThinqDeviceService;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.clearAllMocks();
|
|
14
|
+
vi.useFakeTimers();
|
|
15
|
+
mockLogger = createMockLogger();
|
|
16
|
+
mockApiClient = asPartial<ThinqApiClient>({
|
|
17
|
+
getListDevices: vi.fn(),
|
|
18
|
+
sendKeepAlive: vi.fn(),
|
|
19
|
+
});
|
|
20
|
+
service = new ThinqDeviceService(mockApiClient, mockLogger);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.clearAllTimers();
|
|
25
|
+
vi.useRealTimers();
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('discoverDevices', () => {
|
|
30
|
+
it('should return empty array when no devices found', async () => {
|
|
31
|
+
vi.mocked(mockApiClient.getListDevices).mockResolvedValue([]);
|
|
32
|
+
|
|
33
|
+
const result = await service.discoverDevices();
|
|
34
|
+
|
|
35
|
+
expect(result).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should filter out devices with invalid IDs', async () => {
|
|
39
|
+
const devices = [
|
|
40
|
+
buildThinqDeviceData({ deviceId: '12345678-1234-1234-1234-123456789012' }),
|
|
41
|
+
buildThinqDeviceData({ deviceId: 'invalid-id' }),
|
|
42
|
+
buildThinqDeviceData({ deviceId: '87654321-4321-4321-4321-210987654321' }),
|
|
43
|
+
];
|
|
44
|
+
vi.mocked(mockApiClient.getListDevices).mockResolvedValue(devices);
|
|
45
|
+
|
|
46
|
+
const result = await service.discoverDevices();
|
|
47
|
+
|
|
48
|
+
expect(result).toHaveLength(2);
|
|
49
|
+
expect(
|
|
50
|
+
result.every((d) => /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(d.id)),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should map raw devices to ThinqDevice entities', async () => {
|
|
55
|
+
const devices = [
|
|
56
|
+
buildThinqDeviceData({ deviceId: '12345678-1234-1234-1234-123456789012', alias: 'Living Room AC' }),
|
|
57
|
+
buildThinqDeviceData({ deviceId: '87654321-4321-4321-4321-210987654321', alias: 'Bedroom AC' }),
|
|
58
|
+
];
|
|
59
|
+
vi.mocked(mockApiClient.getListDevices).mockResolvedValue(devices);
|
|
60
|
+
|
|
61
|
+
const result = await service.discoverDevices();
|
|
62
|
+
|
|
63
|
+
expect(result).toHaveLength(2);
|
|
64
|
+
expect(result[0].name).toBe('Living Room AC');
|
|
65
|
+
expect(result[1].name).toBe('Bedroom AC');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should propagate getListDevices errors', async () => {
|
|
69
|
+
const error = new Error('API call failed');
|
|
70
|
+
vi.mocked(mockApiClient.getListDevices).mockRejectedValue(error);
|
|
71
|
+
|
|
72
|
+
await expect(service.discoverDevices()).rejects.toThrow('API call failed');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('startPolling and stopPolling', () => {
|
|
77
|
+
it('should set interval and call onUpdate with device snapshots', async () => {
|
|
78
|
+
const devices = [buildThinqDeviceData({ deviceId: '12345678-1234-1234-1234-123456789012' })];
|
|
79
|
+
vi.mocked(mockApiClient.getListDevices).mockResolvedValue(devices);
|
|
80
|
+
const onUpdate = vi.fn();
|
|
81
|
+
|
|
82
|
+
service.startPolling(1000, onUpdate);
|
|
83
|
+
|
|
84
|
+
// Advance timer to trigger first poll
|
|
85
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
86
|
+
|
|
87
|
+
expect(onUpdate).toHaveBeenCalledWith('12345678-1234-1234-1234-123456789012', expect.any(Object));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should clear prior timer when startPolling called twice', async () => {
|
|
91
|
+
const devices = [buildThinqDeviceData()];
|
|
92
|
+
vi.mocked(mockApiClient.getListDevices).mockResolvedValue(devices);
|
|
93
|
+
const onUpdate = vi.fn();
|
|
94
|
+
|
|
95
|
+
service.startPolling(1000, onUpdate);
|
|
96
|
+
service.startPolling(1000, onUpdate);
|
|
97
|
+
|
|
98
|
+
// Only one timer should be active
|
|
99
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
100
|
+
|
|
101
|
+
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should stop polling and clear timer', async () => {
|
|
105
|
+
const devices = [buildThinqDeviceData()];
|
|
106
|
+
vi.mocked(mockApiClient.getListDevices).mockResolvedValue(devices);
|
|
107
|
+
const onUpdate = vi.fn();
|
|
108
|
+
|
|
109
|
+
service.startPolling(1000, onUpdate);
|
|
110
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
111
|
+
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
112
|
+
|
|
113
|
+
service.stopPolling();
|
|
114
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
115
|
+
|
|
116
|
+
// Should not have been called again after stopPolling
|
|
117
|
+
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should handle stopPolling when no timer is active', () => {
|
|
121
|
+
// Should not throw
|
|
122
|
+
expect(() => service.stopPolling()).not.toThrow();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should call onUpdate for each discovered device', async () => {
|
|
126
|
+
const devices = [
|
|
127
|
+
buildThinqDeviceData({ deviceId: '11111111-1111-1111-1111-111111111111' }),
|
|
128
|
+
buildThinqDeviceData({ deviceId: '22222222-2222-2222-2222-222222222222' }),
|
|
129
|
+
buildThinqDeviceData({ deviceId: '33333333-3333-3333-3333-333333333333' }),
|
|
130
|
+
];
|
|
131
|
+
vi.mocked(mockApiClient.getListDevices).mockResolvedValue(devices);
|
|
132
|
+
const onUpdate = vi.fn();
|
|
133
|
+
|
|
134
|
+
service.startPolling(1000, onUpdate);
|
|
135
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
136
|
+
|
|
137
|
+
expect(onUpdate).toHaveBeenCalledTimes(3);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should continue polling on interval', async () => {
|
|
141
|
+
const devices = [buildThinqDeviceData()];
|
|
142
|
+
vi.mocked(mockApiClient.getListDevices).mockResolvedValue(devices);
|
|
143
|
+
const onUpdate = vi.fn();
|
|
144
|
+
|
|
145
|
+
service.startPolling(500, onUpdate);
|
|
146
|
+
|
|
147
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
148
|
+
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
149
|
+
|
|
150
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
151
|
+
expect(onUpdate).toHaveBeenCalledTimes(2);
|
|
152
|
+
|
|
153
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
154
|
+
expect(onUpdate).toHaveBeenCalledTimes(3);
|
|
155
|
+
|
|
156
|
+
service.stopPolling();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('pollOnce', () => {
|
|
161
|
+
it('should swallow and log errors from discoverDevices', async () => {
|
|
162
|
+
const error = new Error('Discovery failed');
|
|
163
|
+
vi.mocked(mockApiClient.getListDevices).mockRejectedValue(error);
|
|
164
|
+
const onUpdate = vi.fn();
|
|
165
|
+
|
|
166
|
+
service.startPolling(1000, onUpdate);
|
|
167
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
168
|
+
|
|
169
|
+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('ThinQ polling tick failed'));
|
|
170
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
171
|
+
service.stopPolling();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should log error message with original error details', async () => {
|
|
175
|
+
const error = new Error('Network timeout');
|
|
176
|
+
vi.mocked(mockApiClient.getListDevices).mockRejectedValue(error);
|
|
177
|
+
const onUpdate = vi.fn();
|
|
178
|
+
|
|
179
|
+
service.startPolling(1000, onUpdate);
|
|
180
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
181
|
+
|
|
182
|
+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('Network timeout'));
|
|
183
|
+
service.stopPolling();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should handle non-Error exceptions gracefully', async () => {
|
|
187
|
+
vi.mocked(mockApiClient.getListDevices).mockRejectedValue('String error');
|
|
188
|
+
const onUpdate = vi.fn();
|
|
189
|
+
|
|
190
|
+
service.startPolling(1000, onUpdate);
|
|
191
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
192
|
+
|
|
193
|
+
expect(mockLogger.error).toHaveBeenCalled();
|
|
194
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
195
|
+
service.stopPolling();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should continue polling even after an error', async () => {
|
|
199
|
+
const devices = [buildThinqDeviceData()];
|
|
200
|
+
vi.mocked(mockApiClient.getListDevices)
|
|
201
|
+
.mockRejectedValueOnce(new Error('First poll failed'))
|
|
202
|
+
.mockResolvedValueOnce(devices);
|
|
203
|
+
const onUpdate = vi.fn();
|
|
204
|
+
|
|
205
|
+
service.startPolling(1000, onUpdate);
|
|
206
|
+
|
|
207
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
208
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
209
|
+
|
|
210
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
211
|
+
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
212
|
+
|
|
213
|
+
service.stopPolling();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should track AC device online status for subsequent keep-alive', async () => {
|
|
217
|
+
const acDevice = buildThinqDeviceData({
|
|
218
|
+
deviceId: '12345678-1234-1234-1234-123456789012',
|
|
219
|
+
deviceType: 401,
|
|
220
|
+
online: true,
|
|
221
|
+
});
|
|
222
|
+
const nonAcDevice = buildThinqDeviceData({
|
|
223
|
+
deviceId: '87654321-4321-4321-4321-210987654321',
|
|
224
|
+
deviceType: 402,
|
|
225
|
+
online: true,
|
|
226
|
+
});
|
|
227
|
+
vi.mocked(mockApiClient.getListDevices).mockResolvedValue([acDevice, nonAcDevice]);
|
|
228
|
+
const onUpdate = vi.fn();
|
|
229
|
+
|
|
230
|
+
// First, run a polling tick to populate the device list
|
|
231
|
+
service.startPolling(1000, onUpdate);
|
|
232
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
233
|
+
service.stopPolling();
|
|
234
|
+
|
|
235
|
+
// Now start keep-alive and verify it only calls sendKeepAlive for the AC device
|
|
236
|
+
service.startKeepAlive(1000);
|
|
237
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
238
|
+
|
|
239
|
+
expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledTimes(1);
|
|
240
|
+
expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledWith('12345678-1234-1234-1234-123456789012');
|
|
241
|
+
service.stopKeepAlive();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('startKeepAlive and stopKeepAlive', () => {
|
|
246
|
+
it('should set interval and call sendKeepAlive for tracked online AC devices', async () => {
|
|
247
|
+
const acDevice = buildThinqDeviceData({ deviceId: '12345678-1234-1234-1234-123456789012', online: true });
|
|
248
|
+
vi.mocked(mockApiClient.getListDevices).mockResolvedValue([acDevice]);
|
|
249
|
+
const onUpdate = vi.fn();
|
|
250
|
+
|
|
251
|
+
// Run a polling tick first to populate the AC device map
|
|
252
|
+
service.startPolling(1000, onUpdate);
|
|
253
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
254
|
+
service.stopPolling();
|
|
255
|
+
|
|
256
|
+
// Now start keep-alive
|
|
257
|
+
service.startKeepAlive(1000);
|
|
258
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
259
|
+
|
|
260
|
+
expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledWith('12345678-1234-1234-1234-123456789012');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should clear prior timer when startKeepAlive called twice', async () => {
|
|
264
|
+
const acDevice = buildThinqDeviceData({ online: true });
|
|
265
|
+
vi.mocked(mockApiClient.getListDevices).mockResolvedValue([acDevice]);
|
|
266
|
+
const onUpdate = vi.fn();
|
|
267
|
+
|
|
268
|
+
// Run a polling tick to populate the AC device map
|
|
269
|
+
service.startPolling(1000, onUpdate);
|
|
270
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
271
|
+
service.stopPolling();
|
|
272
|
+
|
|
273
|
+
// Start keep-alive twice
|
|
274
|
+
service.startKeepAlive(1000);
|
|
275
|
+
service.startKeepAlive(1000);
|
|
276
|
+
|
|
277
|
+
// Only one timer should be active
|
|
278
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
279
|
+
|
|
280
|
+
expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledTimes(1);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('should stop keep-alive and clear timer', async () => {
|
|
284
|
+
const acDevice = buildThinqDeviceData({ online: true });
|
|
285
|
+
vi.mocked(mockApiClient.getListDevices).mockResolvedValue([acDevice]);
|
|
286
|
+
const onUpdate = vi.fn();
|
|
287
|
+
|
|
288
|
+
// Run a polling tick to populate the AC device map
|
|
289
|
+
service.startPolling(1000, onUpdate);
|
|
290
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
291
|
+
service.stopPolling();
|
|
292
|
+
|
|
293
|
+
// Start keep-alive, advance timer, then stop
|
|
294
|
+
service.startKeepAlive(1000);
|
|
295
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
296
|
+
expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledTimes(1);
|
|
297
|
+
|
|
298
|
+
service.stopKeepAlive();
|
|
299
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
300
|
+
|
|
301
|
+
// Should not have been called again after stopKeepAlive
|
|
302
|
+
expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledTimes(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should handle stopKeepAlive when no timer is active', () => {
|
|
306
|
+
// Should not throw
|
|
307
|
+
expect(() => service.stopKeepAlive()).not.toThrow();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('should use default THINQ_KEEP_ALIVE_INTERVAL_MS when no intervalMs provided', async () => {
|
|
311
|
+
const acDevice = buildThinqDeviceData({ online: true });
|
|
312
|
+
vi.mocked(mockApiClient.getListDevices).mockResolvedValue([acDevice]);
|
|
313
|
+
const onUpdate = vi.fn();
|
|
314
|
+
|
|
315
|
+
// Run a polling tick to populate the AC device map
|
|
316
|
+
service.startPolling(1000, onUpdate);
|
|
317
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
318
|
+
service.stopPolling();
|
|
319
|
+
|
|
320
|
+
vi.clearAllMocks();
|
|
321
|
+
|
|
322
|
+
// Start keep-alive without intervalMs (should use default 60000ms)
|
|
323
|
+
service.startKeepAlive();
|
|
324
|
+
|
|
325
|
+
// Advance by 59999ms - should NOT trigger
|
|
326
|
+
await vi.advanceTimersByTimeAsync(59999);
|
|
327
|
+
expect(vi.mocked(mockApiClient.sendKeepAlive)).not.toHaveBeenCalled();
|
|
328
|
+
|
|
329
|
+
// Advance by 1ms more to reach 60000ms - should trigger
|
|
330
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
331
|
+
expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledTimes(1);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe('keepAliveOnce', () => {
|
|
336
|
+
it('should skip devices where online is false', async () => {
|
|
337
|
+
const onlineAc = buildThinqDeviceData({ deviceId: '11111111-1111-1111-1111-111111111111', online: true });
|
|
338
|
+
const offlineAc = buildThinqDeviceData({ deviceId: '22222222-2222-2222-2222-222222222222', online: false });
|
|
339
|
+
vi.mocked(mockApiClient.getListDevices).mockResolvedValue([onlineAc, offlineAc]);
|
|
340
|
+
const onUpdate = vi.fn();
|
|
341
|
+
|
|
342
|
+
// Run a polling tick to populate the AC device map
|
|
343
|
+
service.startPolling(1000, onUpdate);
|
|
344
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
345
|
+
service.stopPolling();
|
|
346
|
+
|
|
347
|
+
// Run keep-alive tick
|
|
348
|
+
service.startKeepAlive(1000);
|
|
349
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
350
|
+
|
|
351
|
+
// Should only call sendKeepAlive for the online device
|
|
352
|
+
expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledTimes(1);
|
|
353
|
+
expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledWith('11111111-1111-1111-1111-111111111111');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should skip non-AC devices', async () => {
|
|
357
|
+
const acDevice = buildThinqDeviceData({ deviceId: '11111111-1111-1111-1111-111111111111', deviceType: 401 });
|
|
358
|
+
const purifierDevice = buildThinqDeviceData({
|
|
359
|
+
deviceId: '22222222-2222-2222-2222-222222222222',
|
|
360
|
+
deviceType: 402,
|
|
361
|
+
});
|
|
362
|
+
vi.mocked(mockApiClient.getListDevices).mockResolvedValue([acDevice, purifierDevice]);
|
|
363
|
+
const onUpdate = vi.fn();
|
|
364
|
+
|
|
365
|
+
// Run a polling tick to populate the device map
|
|
366
|
+
service.startPolling(1000, onUpdate);
|
|
367
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
368
|
+
service.stopPolling();
|
|
369
|
+
|
|
370
|
+
// Run keep-alive tick
|
|
371
|
+
service.startKeepAlive(1000);
|
|
372
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
373
|
+
|
|
374
|
+
// Should only call sendKeepAlive for the AC device
|
|
375
|
+
expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledTimes(1);
|
|
376
|
+
expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledWith('11111111-1111-1111-1111-111111111111');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('should continue on per-device sendKeepAlive failure and log debug message', async () => {
|
|
380
|
+
const device1 = buildThinqDeviceData({ deviceId: '11111111-1111-1111-1111-111111111111', online: true });
|
|
381
|
+
const device2 = buildThinqDeviceData({ deviceId: '22222222-2222-2222-2222-222222222222', online: true });
|
|
382
|
+
vi.mocked(mockApiClient.getListDevices).mockResolvedValue([device1, device2]);
|
|
383
|
+
vi.mocked(mockApiClient.sendKeepAlive)
|
|
384
|
+
.mockRejectedValueOnce(new Error('Network error'))
|
|
385
|
+
.mockResolvedValueOnce(undefined);
|
|
386
|
+
const onUpdate = vi.fn();
|
|
387
|
+
|
|
388
|
+
// Run a polling tick to populate the AC device map
|
|
389
|
+
service.startPolling(1000, onUpdate);
|
|
390
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
391
|
+
service.stopPolling();
|
|
392
|
+
|
|
393
|
+
vi.clearAllMocks();
|
|
394
|
+
|
|
395
|
+
// Run keep-alive tick
|
|
396
|
+
service.startKeepAlive(1000);
|
|
397
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
398
|
+
|
|
399
|
+
// Both devices should have been attempted
|
|
400
|
+
expect(vi.mocked(mockApiClient.sendKeepAlive)).toHaveBeenCalledTimes(2);
|
|
401
|
+
// Debug log should have been called for the failure
|
|
402
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('ThinQ keep-alive failed'));
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('should handle empty tracked device map gracefully', async () => {
|
|
406
|
+
// Start keep-alive without running a polling tick first (empty device map)
|
|
407
|
+
service.startKeepAlive(1000);
|
|
408
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
409
|
+
|
|
410
|
+
// Should not throw and should not call sendKeepAlive
|
|
411
|
+
expect(vi.mocked(mockApiClient.sendKeepAlive)).not.toHaveBeenCalled();
|
|
412
|
+
service.stopKeepAlive();
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { AnsiLogger } from 'matterbridge/logger';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isAirConditionerDevice,
|
|
5
|
+
isValidThinqDeviceId,
|
|
6
|
+
ThinqDevice,
|
|
7
|
+
toThinqDevice,
|
|
8
|
+
} from '../../core/domain/entities/ThinqDevice.js';
|
|
9
|
+
import type { ThinqSnapshot } from '../../core/domain/value-objects/ThinqSnapshot.js';
|
|
10
|
+
import { ThinqApiClient } from './thinqApiClient.js';
|
|
11
|
+
|
|
12
|
+
export type ThinqDeviceUpdateListener = (deviceId: string, snapshot: ThinqSnapshot) => void;
|
|
13
|
+
|
|
14
|
+
export const THINQ_KEEP_ALIVE_INTERVAL_MS = 60000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Discovers ThinQ devices and polls their state. Polling runs alongside a separate `ThinqMqttListener`
|
|
18
|
+
* (Phase 4) that pushes the same update shape over MQTT — ports `pollThinQ2Devices()`'s
|
|
19
|
+
* full-refetch-per-tick approach (`platformMonitor.ts:104-123`).
|
|
20
|
+
*/
|
|
21
|
+
export class ThinqDeviceService {
|
|
22
|
+
private pollTimer: NodeJS.Timeout | undefined;
|
|
23
|
+
private pollTickCount = 0;
|
|
24
|
+
private keepAliveTimer: NodeJS.Timeout | undefined;
|
|
25
|
+
private airConditionerOnlineStatus = new Map<string, boolean>();
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly apiClient: ThinqApiClient,
|
|
29
|
+
private readonly logger: AnsiLogger,
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
/** Fetches every device on the account and maps it to the generic `ThinqDevice` domain entity. */
|
|
33
|
+
public async discoverDevices(): Promise<ThinqDevice[]> {
|
|
34
|
+
const rawDevices = await this.apiClient.getListDevices();
|
|
35
|
+
|
|
36
|
+
return rawDevices.filter((device) => isValidThinqDeviceId(device.deviceId)).map((device) => toThinqDevice(device));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public startPolling(intervalMs: number, onUpdate: ThinqDeviceUpdateListener): void {
|
|
40
|
+
this.stopPolling();
|
|
41
|
+
this.pollTickCount = 0;
|
|
42
|
+
this.logger.debug(`ThinQ polling: starting with interval=${intervalMs}ms`);
|
|
43
|
+
this.pollTimer = setInterval(() => {
|
|
44
|
+
void this.pollOnce(onUpdate);
|
|
45
|
+
}, intervalMs);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public stopPolling(): void {
|
|
49
|
+
if (this.pollTimer) {
|
|
50
|
+
clearInterval(this.pollTimer);
|
|
51
|
+
this.pollTimer = undefined;
|
|
52
|
+
this.logger.debug(`ThinQ polling: stopped after ${this.pollTickCount} tick(s)`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public startKeepAlive(intervalMs: number = THINQ_KEEP_ALIVE_INTERVAL_MS): void {
|
|
57
|
+
this.stopKeepAlive();
|
|
58
|
+
this.logger.debug(`ThinQ keep-alive: starting with interval=${intervalMs}ms`);
|
|
59
|
+
this.keepAliveTimer = setInterval(() => {
|
|
60
|
+
void this.keepAliveOnce();
|
|
61
|
+
}, intervalMs);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public stopKeepAlive(): void {
|
|
65
|
+
if (this.keepAliveTimer) {
|
|
66
|
+
clearInterval(this.keepAliveTimer);
|
|
67
|
+
this.keepAliveTimer = undefined;
|
|
68
|
+
this.logger.debug(`ThinQ keep-alive: stopped`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async keepAliveOnce(): Promise<void> {
|
|
73
|
+
this.logger.debug(`ThinQ keep-alive: tick at ${new Date().toISOString()}`);
|
|
74
|
+
for (const [deviceId, online] of this.airConditionerOnlineStatus.entries()) {
|
|
75
|
+
if (!online) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
await this.apiClient.sendKeepAlive(deviceId);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
this.logger.debug(
|
|
82
|
+
`ThinQ keep-alive failed for ${deviceId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async pollOnce(onUpdate: ThinqDeviceUpdateListener): Promise<void> {
|
|
89
|
+
this.pollTickCount += 1;
|
|
90
|
+
this.logger.debug(`ThinQ polling: tick #${this.pollTickCount} at ${new Date().toISOString()}`);
|
|
91
|
+
try {
|
|
92
|
+
const devices = await this.discoverDevices();
|
|
93
|
+
this.airConditionerOnlineStatus = new Map(devices.filter(isAirConditionerDevice).map((d) => [d.id, d.online]));
|
|
94
|
+
for (const device of devices) {
|
|
95
|
+
onUpdate(device.id, device.snapshot);
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
this.logger.error(`ThinQ polling tick failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|