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,239 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createMockLogger } from '../../../tests/helpers/testUtils.js';
|
|
4
|
+
import { delayMs, MQTT_RETRY_ATTEMPTS, MQTT_RETRY_DELAY_MS, retryMqttRegistration } from './mqttRetry.js';
|
|
5
|
+
|
|
6
|
+
describe('mqttRetry', () => {
|
|
7
|
+
let mockLogger: ReturnType<typeof createMockLogger>;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.clearAllMocks();
|
|
11
|
+
mockLogger = createMockLogger();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('delayMs', () => {
|
|
19
|
+
it('should return a promise that resolves after the specified time', async () => {
|
|
20
|
+
vi.useFakeTimers();
|
|
21
|
+
const promise = delayMs(1000);
|
|
22
|
+
const start = Date.now();
|
|
23
|
+
|
|
24
|
+
vi.advanceTimersByTime(1000);
|
|
25
|
+
await promise;
|
|
26
|
+
|
|
27
|
+
const elapsed = Date.now() - start;
|
|
28
|
+
expect(elapsed).toBeGreaterThanOrEqual(1000);
|
|
29
|
+
|
|
30
|
+
vi.useRealTimers();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should resolve immediately for zero delay', async () => {
|
|
34
|
+
vi.useFakeTimers();
|
|
35
|
+
const promise = delayMs(0);
|
|
36
|
+
|
|
37
|
+
vi.advanceTimersByTime(0);
|
|
38
|
+
await promise;
|
|
39
|
+
|
|
40
|
+
vi.useRealTimers();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('retryMqttRegistration', () => {
|
|
45
|
+
it('should return true when register succeeds on first call', async () => {
|
|
46
|
+
const register = vi.fn().mockResolvedValue(undefined);
|
|
47
|
+
const delay = vi.fn().mockResolvedValue(undefined);
|
|
48
|
+
|
|
49
|
+
const result = await retryMqttRegistration({
|
|
50
|
+
register,
|
|
51
|
+
logger: mockLogger,
|
|
52
|
+
delay,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(result).toBe(true);
|
|
56
|
+
expect(register).toHaveBeenCalledTimes(1);
|
|
57
|
+
expect(delay).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should return false after default 5 attempts when register always fails', async () => {
|
|
61
|
+
const register = vi.fn().mockRejectedValue(new Error('Connection failed'));
|
|
62
|
+
const delay = vi.fn().mockResolvedValue(undefined);
|
|
63
|
+
|
|
64
|
+
const result = await retryMqttRegistration({
|
|
65
|
+
register,
|
|
66
|
+
logger: mockLogger,
|
|
67
|
+
delay,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(result).toBe(false);
|
|
71
|
+
expect(register).toHaveBeenCalledTimes(MQTT_RETRY_ATTEMPTS);
|
|
72
|
+
expect(delay).toHaveBeenCalledTimes(MQTT_RETRY_ATTEMPTS - 1);
|
|
73
|
+
expect(mockLogger.error).toHaveBeenCalledWith('Cannot start MQTT!');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should return true when register succeeds on the 3rd attempt', async () => {
|
|
77
|
+
const register = vi
|
|
78
|
+
.fn()
|
|
79
|
+
.mockRejectedValueOnce(new Error('Attempt 1 failed'))
|
|
80
|
+
.mockRejectedValueOnce(new Error('Attempt 2 failed'))
|
|
81
|
+
.mockResolvedValueOnce(undefined);
|
|
82
|
+
const delay = vi.fn().mockResolvedValue(undefined);
|
|
83
|
+
|
|
84
|
+
const result = await retryMqttRegistration({
|
|
85
|
+
register,
|
|
86
|
+
logger: mockLogger,
|
|
87
|
+
delay,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(result).toBe(true);
|
|
91
|
+
expect(register).toHaveBeenCalledTimes(3);
|
|
92
|
+
expect(delay).toHaveBeenCalledTimes(2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should respect custom attempts parameter', async () => {
|
|
96
|
+
const register = vi.fn().mockRejectedValue(new Error('Connection failed'));
|
|
97
|
+
const delay = vi.fn().mockResolvedValue(undefined);
|
|
98
|
+
|
|
99
|
+
const result = await retryMqttRegistration({
|
|
100
|
+
register,
|
|
101
|
+
logger: mockLogger,
|
|
102
|
+
delay,
|
|
103
|
+
attempts: 3,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(result).toBe(false);
|
|
107
|
+
expect(register).toHaveBeenCalledTimes(3);
|
|
108
|
+
expect(delay).toHaveBeenCalledTimes(2);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should respect custom retryDelayMs parameter', async () => {
|
|
112
|
+
const register = vi.fn().mockRejectedValue(new Error('Connection failed'));
|
|
113
|
+
const delay = vi.fn().mockResolvedValue(undefined);
|
|
114
|
+
const customDelayMs = 1000;
|
|
115
|
+
|
|
116
|
+
await retryMqttRegistration({
|
|
117
|
+
register,
|
|
118
|
+
logger: mockLogger,
|
|
119
|
+
delay,
|
|
120
|
+
attempts: 2,
|
|
121
|
+
retryDelayMs: customDelayMs,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(delay).toHaveBeenCalledWith(customDelayMs);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should use provided delay function', async () => {
|
|
128
|
+
const register = vi.fn().mockRejectedValueOnce(new Error('Attempt 1 failed')).mockResolvedValueOnce(undefined);
|
|
129
|
+
const delay = vi.fn().mockResolvedValue(undefined);
|
|
130
|
+
|
|
131
|
+
const result = await retryMqttRegistration({
|
|
132
|
+
register,
|
|
133
|
+
logger: mockLogger,
|
|
134
|
+
delay,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(result).toBe(true);
|
|
138
|
+
expect(delay).toHaveBeenCalledWith(MQTT_RETRY_DELAY_MS);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should use default delayMs when delay function not provided', async () => {
|
|
142
|
+
const register = vi.fn().mockRejectedValueOnce(new Error('Attempt 1 failed')).mockResolvedValueOnce(undefined);
|
|
143
|
+
|
|
144
|
+
vi.useFakeTimers();
|
|
145
|
+
|
|
146
|
+
const resultPromise = retryMqttRegistration({
|
|
147
|
+
register,
|
|
148
|
+
logger: mockLogger,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Advance timer to allow delay to complete
|
|
152
|
+
await vi.advanceTimersByTimeAsync(MQTT_RETRY_DELAY_MS);
|
|
153
|
+
const result = await resultPromise;
|
|
154
|
+
|
|
155
|
+
expect(result).toBe(true);
|
|
156
|
+
expect(register).toHaveBeenCalledTimes(2);
|
|
157
|
+
|
|
158
|
+
vi.useRealTimers();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should log debug messages on each retry', async () => {
|
|
162
|
+
const register = vi
|
|
163
|
+
.fn()
|
|
164
|
+
.mockRejectedValueOnce(new Error('Attempt 1 failed'))
|
|
165
|
+
.mockRejectedValueOnce(new Error('Attempt 2 failed'))
|
|
166
|
+
.mockResolvedValueOnce(undefined);
|
|
167
|
+
const delay = vi.fn().mockResolvedValue(undefined);
|
|
168
|
+
|
|
169
|
+
await retryMqttRegistration({
|
|
170
|
+
register,
|
|
171
|
+
logger: mockLogger,
|
|
172
|
+
delay,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Should have debug logs: error message + retry message for each failed attempt before success
|
|
176
|
+
expect(mockLogger.debug).toHaveBeenCalledWith('Cannot start MQTT, retrying in 5s.');
|
|
177
|
+
// Two attempts failed, so 2 error messages + 2 retry messages = 4 total debug calls
|
|
178
|
+
expect(
|
|
179
|
+
vi.mocked(mockLogger.debug).mock.calls.filter((call) => call[0]?.includes('Cannot start MQTT')),
|
|
180
|
+
).toHaveLength(2);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should not call delay after the last failed attempt', async () => {
|
|
184
|
+
const register = vi.fn().mockRejectedValue(new Error('Connection failed'));
|
|
185
|
+
const delay = vi.fn().mockResolvedValue(undefined);
|
|
186
|
+
|
|
187
|
+
await retryMqttRegistration({
|
|
188
|
+
register,
|
|
189
|
+
logger: mockLogger,
|
|
190
|
+
delay,
|
|
191
|
+
attempts: 3,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// delay should be called 2 times (after attempt 1 and 2, but not after attempt 3)
|
|
195
|
+
expect(delay).toHaveBeenCalledTimes(2);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should handle error objects in debug logging', async () => {
|
|
199
|
+
const error = new Error('Custom error message');
|
|
200
|
+
const register = vi.fn().mockRejectedValue(error);
|
|
201
|
+
const delay = vi.fn().mockResolvedValue(undefined);
|
|
202
|
+
|
|
203
|
+
await retryMqttRegistration({
|
|
204
|
+
register,
|
|
205
|
+
logger: mockLogger,
|
|
206
|
+
delay,
|
|
207
|
+
attempts: 2,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(mockLogger.debug).toHaveBeenCalledWith('mqtt err: Custom error message');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should handle non-Error objects in debug logging', async () => {
|
|
214
|
+
const register = vi.fn().mockRejectedValue('string error');
|
|
215
|
+
const delay = vi.fn().mockResolvedValue(undefined);
|
|
216
|
+
|
|
217
|
+
await retryMqttRegistration({
|
|
218
|
+
register,
|
|
219
|
+
logger: mockLogger,
|
|
220
|
+
delay,
|
|
221
|
+
attempts: 2,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(mockLogger.debug).toHaveBeenCalledWith('mqtt err: string error');
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('MQTT_RETRY_ATTEMPTS constant', () => {
|
|
229
|
+
it('should be 5', () => {
|
|
230
|
+
expect(MQTT_RETRY_ATTEMPTS).toBe(5);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('MQTT_RETRY_DELAY_MS constant', () => {
|
|
235
|
+
it('should be 5000', () => {
|
|
236
|
+
expect(MQTT_RETRY_DELAY_MS).toBe(5000);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { AnsiLogger } from 'matterbridge/logger';
|
|
2
|
+
|
|
3
|
+
export const MQTT_RETRY_ATTEMPTS = 5;
|
|
4
|
+
export const MQTT_RETRY_DELAY_MS = 5000;
|
|
5
|
+
|
|
6
|
+
export function delayMs(ms: number): Promise<void> {
|
|
7
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Bounded-attempt retry with delay for MQTT registration; ports `retryMqttRegistration` from `mqttCertificate.ts`. */
|
|
11
|
+
export async function retryMqttRegistration(options: {
|
|
12
|
+
register: () => Promise<void>;
|
|
13
|
+
logger: Pick<AnsiLogger, 'debug' | 'error'>;
|
|
14
|
+
delay?: (ms: number) => Promise<void>;
|
|
15
|
+
attempts?: number;
|
|
16
|
+
retryDelayMs?: number;
|
|
17
|
+
}): Promise<boolean> {
|
|
18
|
+
const {
|
|
19
|
+
register,
|
|
20
|
+
logger,
|
|
21
|
+
delay = delayMs,
|
|
22
|
+
attempts = MQTT_RETRY_ATTEMPTS,
|
|
23
|
+
retryDelayMs = MQTT_RETRY_DELAY_MS,
|
|
24
|
+
} = options;
|
|
25
|
+
let tried = attempts;
|
|
26
|
+
|
|
27
|
+
while (tried > 0) {
|
|
28
|
+
try {
|
|
29
|
+
await register();
|
|
30
|
+
return true;
|
|
31
|
+
} catch (err) {
|
|
32
|
+
tried--;
|
|
33
|
+
logger.debug(`mqtt err: ${err instanceof Error ? err.message : String(err)}`);
|
|
34
|
+
if (tried > 0) {
|
|
35
|
+
logger.debug('Cannot start MQTT, retrying in 5s.');
|
|
36
|
+
await delay(retryDelayMs);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
logger.error('Cannot start MQTT!');
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { ThinqSnapshot } from '../../../core/domain/value-objects/ThinqSnapshot.js';
|
|
4
|
+
import { asPartial, createMockLogger } from '../../../tests/helpers/testUtils.js';
|
|
5
|
+
import type { ThinqApiClient } from '../thinqApiClient.js';
|
|
6
|
+
import type { MqttKeyRepository } from './mqttKeyRepository.js';
|
|
7
|
+
import { ThinqMqttListener } from './thinqMqttListener.js';
|
|
8
|
+
|
|
9
|
+
// Mock aws-iot-device-sdk
|
|
10
|
+
const mockMqttDevice = {
|
|
11
|
+
on: vi.fn((event: string, handler: unknown) => {
|
|
12
|
+
// Handler registration (no-op)
|
|
13
|
+
return mockMqttDevice;
|
|
14
|
+
}),
|
|
15
|
+
subscribe: vi.fn(),
|
|
16
|
+
end: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
vi.mock('aws-iot-device-sdk', () => {
|
|
20
|
+
const DeviceConstructor = function () {
|
|
21
|
+
return mockMqttDevice;
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
device: DeviceConstructor,
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Mock file system operations
|
|
29
|
+
vi.mock('node:fs', () => ({
|
|
30
|
+
promises: {
|
|
31
|
+
readFile: vi.fn().mockResolvedValue(null),
|
|
32
|
+
writeFile: vi.fn().mockResolvedValue(undefined),
|
|
33
|
+
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
// Mock mqttCertificate module
|
|
38
|
+
vi.mock('./mqttCertificate.js', () => ({
|
|
39
|
+
downloadRootCa: vi.fn().mockResolvedValue('-----BEGIN CERTIFICATE-----\nFAKE-PEM\n-----END CERTIFICATE-----'),
|
|
40
|
+
writeMqttCertificateFiles: vi.fn().mockResolvedValue({
|
|
41
|
+
ca: '/tmp/ca.pem',
|
|
42
|
+
cert: '/tmp/cert.pem',
|
|
43
|
+
key: '/tmp/key.pem',
|
|
44
|
+
}),
|
|
45
|
+
certificateRequestBody: vi.fn().mockReturnValue('fake-csr-body'),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
describe('ThinqMqttListener', () => {
|
|
49
|
+
let mockApiClient: ThinqApiClient;
|
|
50
|
+
let mockKeyRepository: MqttKeyRepository;
|
|
51
|
+
let mockLogger: ReturnType<typeof createMockLogger>;
|
|
52
|
+
let listener: ThinqMqttListener;
|
|
53
|
+
const mqttDir = '/tmp/mqtt';
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.clearAllMocks();
|
|
57
|
+
|
|
58
|
+
mockLogger = createMockLogger();
|
|
59
|
+
|
|
60
|
+
mockApiClient = asPartial<ThinqApiClient>({
|
|
61
|
+
getMqttRouteInfo: vi.fn(),
|
|
62
|
+
registerMqttClient: vi.fn(),
|
|
63
|
+
requestMqttCertificate: vi.fn(),
|
|
64
|
+
getClientId: vi.fn().mockReturnValue('test-client-id'),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
mockKeyRepository = asPartial<MqttKeyRepository>({
|
|
68
|
+
getOrCreateKeyPair: vi.fn(),
|
|
69
|
+
getOrCreateCsr: vi.fn(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
listener = new ThinqMqttListener(mockApiClient, mockKeyRepository, mqttDir, mockLogger);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
vi.clearAllMocks();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('stop', () => {
|
|
80
|
+
it('should set stopped flag', () => {
|
|
81
|
+
listener.stop();
|
|
82
|
+
|
|
83
|
+
expect(listener['stopped']).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should clear reconnect timer on stop', () => {
|
|
87
|
+
listener['reconnectTimer'] = setTimeout(() => {
|
|
88
|
+
// This should not be called
|
|
89
|
+
}, 5000);
|
|
90
|
+
|
|
91
|
+
listener.stop();
|
|
92
|
+
|
|
93
|
+
expect(listener['reconnectTimer']).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should be idempotent when called multiple times', () => {
|
|
97
|
+
listener.stop();
|
|
98
|
+
listener.stop();
|
|
99
|
+
|
|
100
|
+
expect(listener['stopped']).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should call device end if device exists', () => {
|
|
104
|
+
const mockEnd = vi.fn();
|
|
105
|
+
listener['device'] = asPartial({
|
|
106
|
+
on: vi.fn(),
|
|
107
|
+
subscribe: vi.fn(),
|
|
108
|
+
end: mockEnd,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
listener.stop();
|
|
112
|
+
|
|
113
|
+
expect(mockEnd).toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('handleMessage', () => {
|
|
118
|
+
const onUpdate = vi.fn();
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
vi.clearAllMocks();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should call onUpdate with valid MQTT message', () => {
|
|
125
|
+
listener['handleMessage'](
|
|
126
|
+
{
|
|
127
|
+
deviceId: 'device-123',
|
|
128
|
+
data: { state: { reported: { 'airState.operation': 1 } } },
|
|
129
|
+
},
|
|
130
|
+
onUpdate,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(onUpdate).toHaveBeenCalledWith('device-123', expect.any(ThinqSnapshot));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should not call onUpdate for message missing deviceId', () => {
|
|
137
|
+
listener['handleMessage'](
|
|
138
|
+
{
|
|
139
|
+
data: { state: { reported: { 'airState.operation': 1 } } },
|
|
140
|
+
},
|
|
141
|
+
onUpdate,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
145
|
+
expect(mockLogger.debug).toHaveBeenCalledWith('ThinQ MQTT: received malformed message, skipping.');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should not call onUpdate for message missing data', () => {
|
|
149
|
+
listener['handleMessage'](
|
|
150
|
+
{
|
|
151
|
+
deviceId: 'device-123',
|
|
152
|
+
},
|
|
153
|
+
onUpdate,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should not call onUpdate for message missing reported state', () => {
|
|
160
|
+
listener['handleMessage'](
|
|
161
|
+
{
|
|
162
|
+
deviceId: 'device-123',
|
|
163
|
+
data: { state: {} },
|
|
164
|
+
},
|
|
165
|
+
onUpdate,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should not call onUpdate for non-object payload', () => {
|
|
172
|
+
listener['handleMessage']('not an object', onUpdate);
|
|
173
|
+
|
|
174
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should not call onUpdate for null payload', () => {
|
|
178
|
+
listener['handleMessage'](null, onUpdate);
|
|
179
|
+
|
|
180
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should construct ThinqSnapshot with reported data', () => {
|
|
184
|
+
const reportedData = { 'airState.operation': 1, 'airState.tempState.current': 24 };
|
|
185
|
+
|
|
186
|
+
listener['handleMessage'](
|
|
187
|
+
{
|
|
188
|
+
deviceId: 'device-123',
|
|
189
|
+
data: { state: { reported: reportedData } },
|
|
190
|
+
},
|
|
191
|
+
onUpdate,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const call = vi.mocked(onUpdate).mock.calls[0];
|
|
195
|
+
const snapshot = call[1];
|
|
196
|
+
expect(snapshot).toBeInstanceOf(ThinqSnapshot);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should not call onUpdate for non-string deviceId', () => {
|
|
200
|
+
listener['handleMessage'](
|
|
201
|
+
{
|
|
202
|
+
deviceId: 123,
|
|
203
|
+
data: { state: { reported: { 'airState.operation': 1 } } },
|
|
204
|
+
},
|
|
205
|
+
onUpdate,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should not call onUpdate for non-object reported state', () => {
|
|
212
|
+
listener['handleMessage'](
|
|
213
|
+
{
|
|
214
|
+
deviceId: 'device-123',
|
|
215
|
+
data: { state: { reported: 'not an object' } },
|
|
216
|
+
},
|
|
217
|
+
onUpdate,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('start integration', () => {
|
|
225
|
+
it('should not reject when start fails', async () => {
|
|
226
|
+
vi.mocked(mockApiClient.getMqttRouteInfo).mockRejectedValue(new Error('Network error'));
|
|
227
|
+
|
|
228
|
+
// Use fake timers to speed up retries
|
|
229
|
+
vi.useFakeTimers();
|
|
230
|
+
const startPromise = listener.start(vi.fn());
|
|
231
|
+
|
|
232
|
+
// Run all pending timers
|
|
233
|
+
await vi.runAllTimersAsync();
|
|
234
|
+
|
|
235
|
+
vi.useRealTimers();
|
|
236
|
+
|
|
237
|
+
await startPromise;
|
|
238
|
+
|
|
239
|
+
// Should log error about unable to start
|
|
240
|
+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('unable to start after retries'));
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('connect happy path', () => {
|
|
245
|
+
beforeEach(() => {
|
|
246
|
+
vi.clearAllMocks();
|
|
247
|
+
|
|
248
|
+
// Setup successful API responses
|
|
249
|
+
vi.mocked(mockApiClient.getMqttRouteInfo).mockResolvedValue({
|
|
250
|
+
mqttServer: 'mqtts://mqtt.example.com:8883',
|
|
251
|
+
});
|
|
252
|
+
vi.mocked(mockApiClient.registerMqttClient).mockResolvedValue(undefined);
|
|
253
|
+
vi.mocked(mockApiClient.requestMqttCertificate).mockResolvedValue({
|
|
254
|
+
certificatePem: 'FAKE-CERT-PEM',
|
|
255
|
+
subscriptions: ['topic/1', 'topic/2'],
|
|
256
|
+
});
|
|
257
|
+
vi.mocked(mockKeyRepository.getOrCreateKeyPair).mockResolvedValue({
|
|
258
|
+
privateKey: 'FAKE-PRIVATE-KEY',
|
|
259
|
+
publicKey: 'FAKE-PUBLIC-KEY',
|
|
260
|
+
});
|
|
261
|
+
vi.mocked(mockKeyRepository.getOrCreateCsr).mockResolvedValue('FAKE-CSR');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should construct awsIotDevice with correct clientId and hostname', async () => {
|
|
265
|
+
const onUpdate = vi.fn();
|
|
266
|
+
vi.mocked(mockApiClient.getClientId).mockReturnValue('test-client-id');
|
|
267
|
+
|
|
268
|
+
await listener['connect'](onUpdate);
|
|
269
|
+
|
|
270
|
+
// Verify device was created by checking it was assigned
|
|
271
|
+
expect(listener['device']).toBe(mockMqttDevice);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should call wireMqttDeviceEvents after device construction', async () => {
|
|
275
|
+
const onUpdate = vi.fn();
|
|
276
|
+
vi.mocked(mockMqttDevice.on).mockClear();
|
|
277
|
+
|
|
278
|
+
await listener['connect'](onUpdate);
|
|
279
|
+
|
|
280
|
+
// wireMqttDeviceEvents calls device.on to set up event handlers
|
|
281
|
+
expect(vi.mocked(mockMqttDevice.on)).toHaveBeenCalled();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should store device instance after successful connection', async () => {
|
|
285
|
+
const onUpdate = vi.fn();
|
|
286
|
+
|
|
287
|
+
await listener['connect'](onUpdate);
|
|
288
|
+
|
|
289
|
+
expect(listener['device']).toBeDefined();
|
|
290
|
+
expect(listener['device']).toBe(mockMqttDevice);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should call getMqttRouteInfo to get server details', async () => {
|
|
294
|
+
const onUpdate = vi.fn();
|
|
295
|
+
vi.mocked(mockApiClient.getMqttRouteInfo).mockClear();
|
|
296
|
+
|
|
297
|
+
await listener['connect'](onUpdate);
|
|
298
|
+
|
|
299
|
+
expect(vi.mocked(mockApiClient.getMqttRouteInfo)).toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should register MQTT client before requesting certificate', async () => {
|
|
303
|
+
const onUpdate = vi.fn();
|
|
304
|
+
const callOrder: string[] = [];
|
|
305
|
+
|
|
306
|
+
vi.mocked(mockApiClient.registerMqttClient).mockImplementation(async () => {
|
|
307
|
+
callOrder.push('registerMqttClient');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
vi.mocked(mockApiClient.requestMqttCertificate).mockImplementation(async () => {
|
|
311
|
+
callOrder.push('requestMqttCertificate');
|
|
312
|
+
return { certificatePem: 'FAKE', subscriptions: [] };
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
await listener['connect'](onUpdate);
|
|
316
|
+
|
|
317
|
+
expect(callOrder[0]).toBe('registerMqttClient');
|
|
318
|
+
expect(callOrder[1]).toBe('requestMqttCertificate');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should get key pair before requesting certificate', async () => {
|
|
322
|
+
const onUpdate = vi.fn();
|
|
323
|
+
vi.mocked(mockKeyRepository.getOrCreateKeyPair).mockClear();
|
|
324
|
+
vi.mocked(mockApiClient.registerMqttClient).mockClear();
|
|
325
|
+
|
|
326
|
+
await listener['connect'](onUpdate);
|
|
327
|
+
|
|
328
|
+
expect(vi.mocked(mockKeyRepository.getOrCreateKeyPair)).toHaveBeenCalled();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should get CSR before registering client', async () => {
|
|
332
|
+
const onUpdate = vi.fn();
|
|
333
|
+
const callOrder: string[] = [];
|
|
334
|
+
|
|
335
|
+
vi.mocked(mockKeyRepository.getOrCreateCsr).mockImplementation(async () => {
|
|
336
|
+
callOrder.push('getOrCreateCsr');
|
|
337
|
+
return 'FAKE-CSR';
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
vi.mocked(mockApiClient.registerMqttClient).mockImplementation(async () => {
|
|
341
|
+
callOrder.push('registerMqttClient');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
await listener['connect'](onUpdate);
|
|
345
|
+
|
|
346
|
+
expect(callOrder.indexOf('getOrCreateCsr')).toBeLessThan(callOrder.indexOf('registerMqttClient'));
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
});
|