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,264 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { ThinqFilterState } from '../../core/domain/value-objects/ThinqFilterState.js';
|
|
4
|
+
import { asPartial, createMockLogger } from '../../tests/helpers/testUtils.js';
|
|
5
|
+
import type { ThinqApiClient } from './thinqApiClient.js';
|
|
6
|
+
import { ThinqFilterMonitoringService } from './thinqFilterMonitoringService.js';
|
|
7
|
+
|
|
8
|
+
describe('ThinqFilterMonitoringService', () => {
|
|
9
|
+
let mockApiClient: ThinqApiClient;
|
|
10
|
+
let mockLogger: ReturnType<typeof createMockLogger>;
|
|
11
|
+
let service: ThinqFilterMonitoringService;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
vi.useFakeTimers();
|
|
16
|
+
mockLogger = createMockLogger();
|
|
17
|
+
mockApiClient = asPartial<ThinqApiClient>({
|
|
18
|
+
getFilterState: vi.fn(),
|
|
19
|
+
});
|
|
20
|
+
service = new ThinqFilterMonitoringService(mockApiClient, mockLogger);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.clearAllTimers();
|
|
25
|
+
vi.useRealTimers();
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('startPolling', () => {
|
|
30
|
+
it('should set interval and call onUpdate with filter state', async () => {
|
|
31
|
+
const deviceIds = ['device-1'];
|
|
32
|
+
const onUpdate = vi.fn();
|
|
33
|
+
const filterData = {
|
|
34
|
+
'airState.filterMngStates.useTime': 11,
|
|
35
|
+
'airState.filterMngStates.maxTime': 720,
|
|
36
|
+
};
|
|
37
|
+
vi.mocked(mockApiClient.getFilterState).mockResolvedValue(filterData);
|
|
38
|
+
|
|
39
|
+
service.startPolling(1000, deviceIds, onUpdate);
|
|
40
|
+
|
|
41
|
+
// Advance timer to trigger first poll
|
|
42
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
43
|
+
|
|
44
|
+
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
45
|
+
const filterState = onUpdate.mock.calls[0][1] as ThinqFilterState | undefined;
|
|
46
|
+
expect(filterState?.remainingPercent).toBe(98);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should not call onUpdate before interval elapses', async () => {
|
|
50
|
+
const deviceIds = ['device-1'];
|
|
51
|
+
const onUpdate = vi.fn();
|
|
52
|
+
vi.mocked(mockApiClient.getFilterState).mockResolvedValue({
|
|
53
|
+
'airState.filterMngStates.useTime': 11,
|
|
54
|
+
'airState.filterMngStates.maxTime': 720,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
service.startPolling(1000, deviceIds, onUpdate);
|
|
58
|
+
|
|
59
|
+
// Do not advance timers
|
|
60
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should call getFilterState for each device in the list', async () => {
|
|
64
|
+
const deviceIds = ['device-1', 'device-2', 'device-3'];
|
|
65
|
+
const onUpdate = vi.fn();
|
|
66
|
+
vi.mocked(mockApiClient.getFilterState).mockResolvedValue({
|
|
67
|
+
'airState.filterMngStates.useTime': 11,
|
|
68
|
+
'airState.filterMngStates.maxTime': 720,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
service.startPolling(1000, deviceIds, onUpdate);
|
|
72
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
73
|
+
|
|
74
|
+
expect(vi.mocked(mockApiClient.getFilterState)).toHaveBeenCalledTimes(3);
|
|
75
|
+
expect(vi.mocked(mockApiClient.getFilterState)).toHaveBeenCalledWith('device-1');
|
|
76
|
+
expect(vi.mocked(mockApiClient.getFilterState)).toHaveBeenCalledWith('device-2');
|
|
77
|
+
expect(vi.mocked(mockApiClient.getFilterState)).toHaveBeenCalledWith('device-3');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should handle per-device errors and continue with other devices', async () => {
|
|
81
|
+
const deviceIds = ['device-1', 'device-2', 'device-3'];
|
|
82
|
+
const onUpdate = vi.fn();
|
|
83
|
+
vi.mocked(mockApiClient.getFilterState)
|
|
84
|
+
.mockResolvedValueOnce({ 'airState.filterMngStates.useTime': 11, 'airState.filterMngStates.maxTime': 720 })
|
|
85
|
+
.mockRejectedValueOnce(new Error('Device 2 failed'))
|
|
86
|
+
.mockResolvedValueOnce({ 'airState.filterMngStates.useTime': 5, 'airState.filterMngStates.maxTime': 600 });
|
|
87
|
+
|
|
88
|
+
service.startPolling(1000, deviceIds, onUpdate);
|
|
89
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
90
|
+
|
|
91
|
+
expect(onUpdate).toHaveBeenCalledTimes(3);
|
|
92
|
+
// device-1: success
|
|
93
|
+
expect(onUpdate.mock.calls[0][0]).toBe('device-1');
|
|
94
|
+
expect(onUpdate.mock.calls[0][1]).toBeDefined();
|
|
95
|
+
// device-2: error
|
|
96
|
+
expect(onUpdate.mock.calls[1][0]).toBe('device-2');
|
|
97
|
+
expect(onUpdate.mock.calls[1][1]).toBeUndefined();
|
|
98
|
+
// device-3: success
|
|
99
|
+
expect(onUpdate.mock.calls[2][0]).toBe('device-3');
|
|
100
|
+
expect(onUpdate.mock.calls[2][1]).toBeDefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should call onUpdate with undefined when device response is empty', async () => {
|
|
104
|
+
const deviceIds = ['device-1'];
|
|
105
|
+
const onUpdate = vi.fn();
|
|
106
|
+
vi.mocked(mockApiClient.getFilterState).mockResolvedValue(undefined);
|
|
107
|
+
|
|
108
|
+
service.startPolling(1000, deviceIds, onUpdate);
|
|
109
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
110
|
+
|
|
111
|
+
expect(onUpdate).toHaveBeenCalledWith('device-1', undefined);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should continue polling on interval', async () => {
|
|
115
|
+
const deviceIds = ['device-1'];
|
|
116
|
+
const onUpdate = vi.fn();
|
|
117
|
+
vi.mocked(mockApiClient.getFilterState).mockResolvedValue({
|
|
118
|
+
'airState.filterMngStates.useTime': 11,
|
|
119
|
+
'airState.filterMngStates.maxTime': 720,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
service.startPolling(500, deviceIds, onUpdate);
|
|
123
|
+
|
|
124
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
125
|
+
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
126
|
+
|
|
127
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
128
|
+
expect(onUpdate).toHaveBeenCalledTimes(2);
|
|
129
|
+
|
|
130
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
131
|
+
expect(onUpdate).toHaveBeenCalledTimes(3);
|
|
132
|
+
|
|
133
|
+
service.stopPolling();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should clear prior timer when startPolling called twice', async () => {
|
|
137
|
+
const deviceIds = ['device-1'];
|
|
138
|
+
const onUpdate = vi.fn();
|
|
139
|
+
vi.mocked(mockApiClient.getFilterState).mockResolvedValue({
|
|
140
|
+
'airState.filterMngStates.useTime': 11,
|
|
141
|
+
'airState.filterMngStates.maxTime': 720,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
service.startPolling(1000, deviceIds, onUpdate);
|
|
145
|
+
service.startPolling(1000, deviceIds, onUpdate);
|
|
146
|
+
|
|
147
|
+
// Only one timer should be active
|
|
148
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
149
|
+
|
|
150
|
+
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should log debug message when starting polling', async () => {
|
|
154
|
+
const deviceIds = ['device-1', 'device-2'];
|
|
155
|
+
const onUpdate = vi.fn();
|
|
156
|
+
vi.mocked(mockApiClient.getFilterState).mockResolvedValue(undefined);
|
|
157
|
+
|
|
158
|
+
service.startPolling(1000, deviceIds, onUpdate);
|
|
159
|
+
|
|
160
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
161
|
+
expect.stringContaining('starting with interval=1000ms, devices=2'),
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should log per-device errors at debug level', async () => {
|
|
166
|
+
const deviceIds = ['device-1'];
|
|
167
|
+
const onUpdate = vi.fn();
|
|
168
|
+
const error = new Error('Network error');
|
|
169
|
+
vi.mocked(mockApiClient.getFilterState).mockRejectedValue(error);
|
|
170
|
+
|
|
171
|
+
service.startPolling(1000, deviceIds, onUpdate);
|
|
172
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
173
|
+
|
|
174
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
175
|
+
expect.stringContaining('ThinQ filter monitoring failed for device-1'),
|
|
176
|
+
);
|
|
177
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('Network error'));
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('stopPolling', () => {
|
|
182
|
+
it('should clear timer and stop polling', async () => {
|
|
183
|
+
const deviceIds = ['device-1'];
|
|
184
|
+
const onUpdate = vi.fn();
|
|
185
|
+
vi.mocked(mockApiClient.getFilterState).mockResolvedValue({
|
|
186
|
+
'airState.filterMngStates.useTime': 11,
|
|
187
|
+
'airState.filterMngStates.maxTime': 720,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
service.startPolling(1000, deviceIds, onUpdate);
|
|
191
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
192
|
+
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
193
|
+
|
|
194
|
+
service.stopPolling();
|
|
195
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
196
|
+
|
|
197
|
+
// Should not have been called again after stopPolling
|
|
198
|
+
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should not throw when no timer is active', () => {
|
|
202
|
+
expect(() => service.stopPolling()).not.toThrow();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should log debug message when stopped', async () => {
|
|
206
|
+
const deviceIds = ['device-1'];
|
|
207
|
+
const onUpdate = vi.fn();
|
|
208
|
+
vi.mocked(mockApiClient.getFilterState).mockResolvedValue({
|
|
209
|
+
'airState.filterMngStates.useTime': 11,
|
|
210
|
+
'airState.filterMngStates.maxTime': 720,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
service.startPolling(1000, deviceIds, onUpdate);
|
|
214
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
215
|
+
|
|
216
|
+
service.stopPolling();
|
|
217
|
+
|
|
218
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('stopped after 1 tick(s)'));
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should reset tick count across start/stop cycles', async () => {
|
|
222
|
+
const deviceIds = ['device-1'];
|
|
223
|
+
const onUpdate = vi.fn();
|
|
224
|
+
vi.mocked(mockApiClient.getFilterState).mockResolvedValue({
|
|
225
|
+
'airState.filterMngStates.useTime': 11,
|
|
226
|
+
'airState.filterMngStates.maxTime': 720,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
service.startPolling(500, deviceIds, onUpdate);
|
|
230
|
+
await vi.advanceTimersByTimeAsync(1000); // 2 ticks
|
|
231
|
+
|
|
232
|
+
service.stopPolling();
|
|
233
|
+
|
|
234
|
+
vi.mocked(mockLogger.debug).mockClear();
|
|
235
|
+
|
|
236
|
+
service.startPolling(500, deviceIds, onUpdate);
|
|
237
|
+
await vi.advanceTimersByTimeAsync(500); // 1 tick
|
|
238
|
+
|
|
239
|
+
service.stopPolling();
|
|
240
|
+
|
|
241
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('stopped after 1 tick(s)'));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should log tick number with timestamp', async () => {
|
|
245
|
+
const deviceIds = ['device-1'];
|
|
246
|
+
const onUpdate = vi.fn();
|
|
247
|
+
vi.mocked(mockApiClient.getFilterState).mockResolvedValue({
|
|
248
|
+
'airState.filterMngStates.useTime': 11,
|
|
249
|
+
'airState.filterMngStates.maxTime': 720,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
service.startPolling(100, deviceIds, onUpdate);
|
|
253
|
+
|
|
254
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
255
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
256
|
+
|
|
257
|
+
service.stopPolling();
|
|
258
|
+
|
|
259
|
+
const debugCalls = vi.mocked(mockLogger.debug).mock.calls.map((call) => call[0]);
|
|
260
|
+
expect(debugCalls.some((call) => call.includes('tick #1'))).toBe(true);
|
|
261
|
+
expect(debugCalls.some((call) => call.includes('tick #2'))).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { AnsiLogger } from 'matterbridge/logger';
|
|
2
|
+
|
|
3
|
+
import { ThinqFilterState } from '../../core/domain/value-objects/ThinqFilterState.js';
|
|
4
|
+
import { ThinqApiClient } from './thinqApiClient.js';
|
|
5
|
+
|
|
6
|
+
export type ThinqFilterUpdateListener = (deviceId: string, filterState: ThinqFilterState | undefined) => void;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Dedicated low-frequency poll job for AC filter-life state. Separate from `ThinqDeviceService`'s
|
|
10
|
+
* snapshot polling because filter state is a different, per-device `Get` RPC on a much slower cadence.
|
|
11
|
+
*/
|
|
12
|
+
export class ThinqFilterMonitoringService {
|
|
13
|
+
private pollTimer: NodeJS.Timeout | undefined;
|
|
14
|
+
private pollTickCount = 0;
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly apiClient: ThinqApiClient,
|
|
18
|
+
private readonly logger: AnsiLogger,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
public startPolling(intervalMs: number, deviceIds: string[], onUpdate: ThinqFilterUpdateListener): void {
|
|
22
|
+
this.stopPolling();
|
|
23
|
+
this.pollTickCount = 0;
|
|
24
|
+
this.logger.debug(`ThinQ filter monitoring: starting with interval=${intervalMs}ms, devices=${deviceIds.length}`);
|
|
25
|
+
this.pollTimer = setInterval(() => {
|
|
26
|
+
void this.pollOnce(deviceIds, onUpdate);
|
|
27
|
+
}, intervalMs);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public stopPolling(): void {
|
|
31
|
+
if (this.pollTimer) {
|
|
32
|
+
clearInterval(this.pollTimer);
|
|
33
|
+
this.pollTimer = undefined;
|
|
34
|
+
this.logger.debug(`ThinQ filter monitoring: stopped after ${this.pollTickCount} tick(s)`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private async pollOnce(deviceIds: string[], onUpdate: ThinqFilterUpdateListener): Promise<void> {
|
|
39
|
+
this.pollTickCount += 1;
|
|
40
|
+
this.logger.debug(`ThinQ filter monitoring: tick #${this.pollTickCount} at ${new Date().toISOString()}`);
|
|
41
|
+
for (const deviceId of deviceIds) {
|
|
42
|
+
try {
|
|
43
|
+
const data = await this.apiClient.getFilterState(deviceId);
|
|
44
|
+
onUpdate(deviceId, ThinqFilterState.fromRaw(data));
|
|
45
|
+
} catch (error) {
|
|
46
|
+
this.logger.debug(
|
|
47
|
+
`ThinQ filter monitoring failed for ${deviceId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
48
|
+
);
|
|
49
|
+
onUpdate(deviceId, undefined);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
interface PackageJson {
|
|
6
|
+
engines: Record<string, string>;
|
|
7
|
+
name: string;
|
|
8
|
+
version: string;
|
|
9
|
+
homepage: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const PACKAGE_JSON = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
13
|
+
const PACKAGE = JSON.parse(readFileSync(PACKAGE_JSON, 'utf-8')) as PackageJson;
|
|
14
|
+
|
|
15
|
+
export const ENGINES = PACKAGE.engines;
|
|
16
|
+
export const PLUGIN_NAME = PACKAGE.name;
|
|
17
|
+
export const PLUGIN_VERSION = PACKAGE.version;
|
|
18
|
+
export const PLUGIN_URL = PACKAGE.homepage;
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
let mockApiClient: any;
|
|
4
|
+
|
|
5
|
+
vi.mock('../../../core/domain/entities/ThinqDevice.js', () => ({
|
|
6
|
+
toThinqDevice: vi.fn((raw: any) => ({
|
|
7
|
+
name: raw.alias,
|
|
8
|
+
id: raw.deviceId,
|
|
9
|
+
type: raw.deviceType,
|
|
10
|
+
platformType: raw.platformType,
|
|
11
|
+
modelName: raw.modelName,
|
|
12
|
+
online: raw.online,
|
|
13
|
+
})),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('../../../services/thinq/session.js', () => ({
|
|
17
|
+
ThinqSession: vi.fn(function () {
|
|
18
|
+
return {};
|
|
19
|
+
}),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('../../../services/thinq/thinqApiClient.js', () => ({
|
|
23
|
+
ThinqApiClient: vi.fn(function () {
|
|
24
|
+
if (!mockApiClient) {
|
|
25
|
+
mockApiClient = {
|
|
26
|
+
getListDevices: vi.fn(),
|
|
27
|
+
setUserNumber: vi.fn(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return mockApiClient;
|
|
31
|
+
}),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
import { cmdDevices } from '../../../cli/commands/devices.js';
|
|
35
|
+
import type { CliSession } from '../../../cli/types.js';
|
|
36
|
+
import { toThinqDevice } from '../../../core/domain/entities/ThinqDevice.js';
|
|
37
|
+
import { ThinqSession } from '../../../services/thinq/session.js';
|
|
38
|
+
import { ThinqApiClient } from '../../../services/thinq/thinqApiClient.js';
|
|
39
|
+
|
|
40
|
+
describe('cmdDevices', () => {
|
|
41
|
+
let consoleLogSpy: any;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
vi.clearAllMocks();
|
|
45
|
+
mockApiClient = {
|
|
46
|
+
getListDevices: vi.fn(),
|
|
47
|
+
setUserNumber: vi.fn(),
|
|
48
|
+
};
|
|
49
|
+
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.clearAllMocks();
|
|
54
|
+
consoleLogSpy.mockRestore();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should call setUserNumber when userData.userNumber is set', async () => {
|
|
58
|
+
// Arrange
|
|
59
|
+
const session: CliSession = {
|
|
60
|
+
loginType: 'account',
|
|
61
|
+
country: 'US',
|
|
62
|
+
language: 'en-US',
|
|
63
|
+
userData: {
|
|
64
|
+
accessToken: 'token',
|
|
65
|
+
refreshToken: 'refresh',
|
|
66
|
+
expiresAtEpochSeconds: 123,
|
|
67
|
+
country: 'US',
|
|
68
|
+
language: 'en-US',
|
|
69
|
+
userNumber: '12345',
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
mockApiClient.getListDevices.mockResolvedValue([]);
|
|
73
|
+
|
|
74
|
+
// Act
|
|
75
|
+
await cmdDevices(session, {} as any);
|
|
76
|
+
|
|
77
|
+
// Assert
|
|
78
|
+
expect(mockApiClient.setUserNumber).toHaveBeenCalledWith('12345');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should not call setUserNumber when userData.userNumber is undefined', async () => {
|
|
82
|
+
// Arrange
|
|
83
|
+
const session: CliSession = {
|
|
84
|
+
loginType: 'account',
|
|
85
|
+
country: 'US',
|
|
86
|
+
language: 'en-US',
|
|
87
|
+
userData: {
|
|
88
|
+
accessToken: 'token',
|
|
89
|
+
refreshToken: 'refresh',
|
|
90
|
+
expiresAtEpochSeconds: 123,
|
|
91
|
+
country: 'US',
|
|
92
|
+
language: 'en-US',
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
mockApiClient.getListDevices.mockResolvedValue([]);
|
|
96
|
+
|
|
97
|
+
// Act
|
|
98
|
+
await cmdDevices(session, {} as any);
|
|
99
|
+
|
|
100
|
+
// Assert
|
|
101
|
+
expect(mockApiClient.setUserNumber).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should print "No devices found." when getListDevices returns empty array', async () => {
|
|
105
|
+
// Arrange
|
|
106
|
+
const session: CliSession = {
|
|
107
|
+
loginType: 'account',
|
|
108
|
+
country: 'US',
|
|
109
|
+
language: 'en-US',
|
|
110
|
+
userData: {
|
|
111
|
+
accessToken: 'token',
|
|
112
|
+
refreshToken: 'refresh',
|
|
113
|
+
expiresAtEpochSeconds: 123,
|
|
114
|
+
country: 'US',
|
|
115
|
+
language: 'en-US',
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
mockApiClient.getListDevices.mockResolvedValue([]);
|
|
119
|
+
|
|
120
|
+
// Act
|
|
121
|
+
await cmdDevices(session, {} as any);
|
|
122
|
+
|
|
123
|
+
// Assert
|
|
124
|
+
expect(consoleLogSpy).toHaveBeenCalledWith('No devices found.');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should print device count for multiple devices', async () => {
|
|
128
|
+
// Arrange
|
|
129
|
+
const session: CliSession = {
|
|
130
|
+
loginType: 'account',
|
|
131
|
+
country: 'US',
|
|
132
|
+
language: 'en-US',
|
|
133
|
+
userData: {
|
|
134
|
+
accessToken: 'token',
|
|
135
|
+
refreshToken: 'refresh',
|
|
136
|
+
expiresAtEpochSeconds: 123,
|
|
137
|
+
country: 'US',
|
|
138
|
+
language: 'en-US',
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
const rawDevices = [
|
|
142
|
+
{ deviceId: '123', alias: 'Device1', deviceType: 401, platformType: 'THINQ', modelName: 'Model1', online: true },
|
|
143
|
+
{ deviceId: '456', alias: 'Device2', deviceType: 401, modelName: 'Model2', online: false },
|
|
144
|
+
];
|
|
145
|
+
mockApiClient.getListDevices.mockResolvedValue(rawDevices);
|
|
146
|
+
|
|
147
|
+
// Act
|
|
148
|
+
await cmdDevices(session, {} as any);
|
|
149
|
+
|
|
150
|
+
// Assert
|
|
151
|
+
expect(consoleLogSpy).toHaveBeenCalledWith('Found 2 device(s):\n');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should print device details with platformType when present', async () => {
|
|
155
|
+
// Arrange
|
|
156
|
+
const session: CliSession = {
|
|
157
|
+
loginType: 'account',
|
|
158
|
+
country: 'US',
|
|
159
|
+
language: 'en-US',
|
|
160
|
+
userData: {
|
|
161
|
+
accessToken: 'token',
|
|
162
|
+
refreshToken: 'refresh',
|
|
163
|
+
expiresAtEpochSeconds: 123,
|
|
164
|
+
country: 'US',
|
|
165
|
+
language: 'en-US',
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
const rawDevices = [
|
|
169
|
+
{
|
|
170
|
+
deviceId: '123',
|
|
171
|
+
alias: 'Living Room AC',
|
|
172
|
+
deviceType: 401,
|
|
173
|
+
platformType: 'THINQ',
|
|
174
|
+
modelName: 'Model1',
|
|
175
|
+
online: true,
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
mockApiClient.getListDevices.mockResolvedValue(rawDevices);
|
|
179
|
+
|
|
180
|
+
// Act
|
|
181
|
+
await cmdDevices(session, {} as any);
|
|
182
|
+
|
|
183
|
+
// Assert
|
|
184
|
+
expect(consoleLogSpy).toHaveBeenCalledWith('1. Living Room AC');
|
|
185
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(' Platform Type: THINQ');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should print "unknown" when platformType is undefined', async () => {
|
|
189
|
+
// Arrange
|
|
190
|
+
const session: CliSession = {
|
|
191
|
+
loginType: 'account',
|
|
192
|
+
country: 'US',
|
|
193
|
+
language: 'en-US',
|
|
194
|
+
userData: {
|
|
195
|
+
accessToken: 'token',
|
|
196
|
+
refreshToken: 'refresh',
|
|
197
|
+
expiresAtEpochSeconds: 123,
|
|
198
|
+
country: 'US',
|
|
199
|
+
language: 'en-US',
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
const rawDevices = [{ deviceId: '123', alias: 'Device', deviceType: 401, modelName: 'Model1', online: true }];
|
|
203
|
+
mockApiClient.getListDevices.mockResolvedValue(rawDevices);
|
|
204
|
+
|
|
205
|
+
// Act
|
|
206
|
+
await cmdDevices(session, {} as any);
|
|
207
|
+
|
|
208
|
+
// Assert
|
|
209
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(' Platform Type: unknown');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should call toThinqDevice for each raw device', async () => {
|
|
213
|
+
// Arrange
|
|
214
|
+
const session: CliSession = {
|
|
215
|
+
loginType: 'account',
|
|
216
|
+
country: 'US',
|
|
217
|
+
language: 'en-US',
|
|
218
|
+
userData: {
|
|
219
|
+
accessToken: 'token',
|
|
220
|
+
refreshToken: 'refresh',
|
|
221
|
+
expiresAtEpochSeconds: 123,
|
|
222
|
+
country: 'US',
|
|
223
|
+
language: 'en-US',
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
const rawDevices = [
|
|
227
|
+
{ deviceId: '123', alias: 'Device1', deviceType: 401, modelName: 'Model1', online: true },
|
|
228
|
+
{ deviceId: '456', alias: 'Device2', deviceType: 402, modelName: 'Model2', online: false },
|
|
229
|
+
];
|
|
230
|
+
mockApiClient.getListDevices.mockResolvedValue(rawDevices);
|
|
231
|
+
|
|
232
|
+
// Act
|
|
233
|
+
await cmdDevices(session, {} as any);
|
|
234
|
+
|
|
235
|
+
// Assert
|
|
236
|
+
expect(toThinqDevice).toHaveBeenCalledTimes(2);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should construct ThinqApiClient with correct parameters', async () => {
|
|
240
|
+
// Arrange
|
|
241
|
+
const session: CliSession = {
|
|
242
|
+
loginType: 'account',
|
|
243
|
+
country: 'KR',
|
|
244
|
+
language: 'ko-KR',
|
|
245
|
+
userData: {
|
|
246
|
+
accessToken: 'access_token',
|
|
247
|
+
refreshToken: 'refresh_token',
|
|
248
|
+
expiresAtEpochSeconds: 123456,
|
|
249
|
+
country: 'KR',
|
|
250
|
+
language: 'ko-KR',
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
mockApiClient.getListDevices.mockResolvedValue([]);
|
|
254
|
+
const mockLogger = {} as any;
|
|
255
|
+
|
|
256
|
+
// Act
|
|
257
|
+
await cmdDevices(session, mockLogger);
|
|
258
|
+
|
|
259
|
+
// Assert
|
|
260
|
+
expect(ThinqSession).toHaveBeenCalledWith('access_token', 'refresh_token', 123456);
|
|
261
|
+
expect(ThinqApiClient).toHaveBeenCalledWith(expect.any(Object), 'KR', 'ko-KR', mockLogger);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should print device numbers starting from 1', async () => {
|
|
265
|
+
// Arrange
|
|
266
|
+
const session: CliSession = {
|
|
267
|
+
loginType: 'account',
|
|
268
|
+
country: 'US',
|
|
269
|
+
language: 'en-US',
|
|
270
|
+
userData: {
|
|
271
|
+
accessToken: 'token',
|
|
272
|
+
refreshToken: 'refresh',
|
|
273
|
+
expiresAtEpochSeconds: 123,
|
|
274
|
+
country: 'US',
|
|
275
|
+
language: 'en-US',
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
const rawDevices = [
|
|
279
|
+
{ deviceId: '1', alias: 'Device1', deviceType: 401, modelName: 'Model1', online: true },
|
|
280
|
+
{ deviceId: '2', alias: 'Device2', deviceType: 402, modelName: 'Model2', online: false },
|
|
281
|
+
{ deviceId: '3', alias: 'Device3', deviceType: 403, modelName: 'Model3', online: true },
|
|
282
|
+
];
|
|
283
|
+
mockApiClient.getListDevices.mockResolvedValue(rawDevices);
|
|
284
|
+
|
|
285
|
+
// Act
|
|
286
|
+
await cmdDevices(session, {} as any);
|
|
287
|
+
|
|
288
|
+
// Assert
|
|
289
|
+
expect(consoleLogSpy).toHaveBeenCalledWith('1. Device1');
|
|
290
|
+
expect(consoleLogSpy).toHaveBeenCalledWith('2. Device2');
|
|
291
|
+
expect(consoleLogSpy).toHaveBeenCalledWith('3. Device3');
|
|
292
|
+
});
|
|
293
|
+
});
|