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,33 @@
|
|
|
1
|
+
export class ThinqDeviceDiscovery {
|
|
2
|
+
deviceService;
|
|
3
|
+
logger;
|
|
4
|
+
constructor(deviceService, logger) {
|
|
5
|
+
this.deviceService = deviceService;
|
|
6
|
+
this.logger = logger;
|
|
7
|
+
}
|
|
8
|
+
async discoverDevices() {
|
|
9
|
+
const devices = await this.deviceService.discoverDevices();
|
|
10
|
+
this.logger.debug(`ThinQ discoverDevices: ${devices.length} raw device(s) returned before filtering`);
|
|
11
|
+
const supported = [];
|
|
12
|
+
for (const device of devices) {
|
|
13
|
+
const resolved = this.dispatch(device);
|
|
14
|
+
if (resolved) {
|
|
15
|
+
supported.push(resolved);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
this.logger.info(`ThinQ device type not supported, skipping: ${device.type} (${device.id})`);
|
|
19
|
+
}
|
|
20
|
+
this.logger.debug(`ThinQ discoverDevices: ${supported.length} device(s) supported after filtering`);
|
|
21
|
+
return supported;
|
|
22
|
+
}
|
|
23
|
+
dispatch(device) {
|
|
24
|
+
switch (device.type) {
|
|
25
|
+
case 'AC':
|
|
26
|
+
return { ...device, type: 'AC' };
|
|
27
|
+
case 'WASHER':
|
|
28
|
+
return { ...device, type: 'WASHER' };
|
|
29
|
+
default:
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { LaundryWasherMode, OnOff, OperationalState } from 'matterbridge/matter/clusters';
|
|
2
|
+
const READ_ONLY_WASHER_COMMANDS = ['on', 'off', 'pause', 'start', 'resume', 'changeToMode'];
|
|
3
|
+
const ON_OFF_COMMANDS = new Set(['on', 'off']);
|
|
4
|
+
function revertOnOffOnFailure(washer, oldValue) {
|
|
5
|
+
washer.updateAttribute(OnOff.id, 'onOff', oldValue, washer.log).catch(() => {
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
function revertOperationalStateOnFailure(washer, oldOperationalState, command, oldCurrentMode) {
|
|
9
|
+
washer.updateAttribute(OperationalState.id, 'operationalState', oldOperationalState, washer.log).catch(() => {
|
|
10
|
+
});
|
|
11
|
+
if (command === 'changeToMode') {
|
|
12
|
+
washer.updateAttribute(LaundryWasherMode.id, 'currentMode', oldCurrentMode, washer.log).catch(() => {
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function registerWasherCommandHandlers(washer, device, apiClient, logger, washerControl, stopCommandPayload) {
|
|
17
|
+
for (const command of READ_ONLY_WASHER_COMMANDS) {
|
|
18
|
+
washer.addCommandHandler(command, async () => {
|
|
19
|
+
logger.warn(`ThinQ Washer remote control ('${command}') is not supported yet (deviceId=${device.id})`);
|
|
20
|
+
if (ON_OFF_COMMANDS.has(command)) {
|
|
21
|
+
const previousOnOff = washer.getAttribute(OnOff.id, 'onOff');
|
|
22
|
+
revertOnOffOnFailure(washer, previousOnOff ?? false);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
const previousOperationalState = washer.getAttribute(OperationalState.id, 'operationalState');
|
|
26
|
+
const previousCurrentMode = washer.getAttribute(LaundryWasherMode.id, 'currentMode');
|
|
27
|
+
revertOperationalStateOnFailure(washer, previousOperationalState ?? OperationalState.OperationalStateEnum.Stopped, command, previousCurrentMode ?? 0);
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`ThinQ Washer remote control ('${command}') is not supported yet`);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
washer.addCommandHandler('stop', async () => {
|
|
33
|
+
if (!washerControl.allowRemoteStop) {
|
|
34
|
+
logger.warn(`ThinQ Washer remote control ('stop') is disabled by configuration (deviceId=${device.id})`);
|
|
35
|
+
throw new Error(`ThinQ Washer remote control ('stop') is disabled by configuration.`);
|
|
36
|
+
}
|
|
37
|
+
if (!stopCommandPayload) {
|
|
38
|
+
logger.warn(`ThinQ Washer remote control ('stop') is not available for this device model yet (deviceId=${device.id})`);
|
|
39
|
+
throw new Error(`ThinQ Washer remote control ('stop') is not available for this device model yet.`);
|
|
40
|
+
}
|
|
41
|
+
const previousState = washer.getAttribute(OperationalState.id, 'operationalState');
|
|
42
|
+
try {
|
|
43
|
+
await apiClient.sendCommand(device.id, { ctrlKey: 'WMControl', ...stopCommandPayload });
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
logger.error(`ThinQ Washer command 'stop' failed: ${error instanceof Error ? error.message : String(error)} (deviceId=${device.id})`);
|
|
47
|
+
washer
|
|
48
|
+
.updateAttribute(OperationalState.id, 'operationalState', previousState ?? OperationalState.OperationalStateEnum.Stopped, washer.log)
|
|
49
|
+
.catch(() => {
|
|
50
|
+
});
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { LaundryWasher } from 'matterbridge/devices';
|
|
2
|
+
export function buildWasherEndpoint(device, options) {
|
|
3
|
+
const washer = new LaundryWasher(device.name, device.id);
|
|
4
|
+
washer.createDefaultBasicInformationClusterServer(device.name, device.id, options?.vendorId ?? 0xfff1, options?.vendorName ?? 'Matterbridge', options?.productId ?? 0x8000, options?.productName ?? 'Matterbridge Laundry Washer');
|
|
5
|
+
return washer;
|
|
6
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { OnOff, OperationalState } from 'matterbridge/matter/clusters';
|
|
2
|
+
export function mapWasherStateToOperationalState(snapshot) {
|
|
3
|
+
if (snapshot.isWasherError) {
|
|
4
|
+
return OperationalState.OperationalStateEnum.Error;
|
|
5
|
+
}
|
|
6
|
+
if (snapshot.isWasherRunning) {
|
|
7
|
+
return OperationalState.OperationalStateEnum.Running;
|
|
8
|
+
}
|
|
9
|
+
if (snapshot.washerRawState === 'PAUSE') {
|
|
10
|
+
return OperationalState.OperationalStateEnum.Paused;
|
|
11
|
+
}
|
|
12
|
+
return OperationalState.OperationalStateEnum.Stopped;
|
|
13
|
+
}
|
|
14
|
+
export async function applyThinqSnapshotToWasher(washer, snapshot, logger) {
|
|
15
|
+
const deviceId = washer.serialNumber ?? washer.uniqueId ?? 'unknown';
|
|
16
|
+
if (snapshot.washerRawState === undefined) {
|
|
17
|
+
logger.debug(`applyThinqSnapshotToWasher: skipped (source key absent): washerDryer.state, deviceId=${deviceId}`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
logger.debug(`applyThinqSnapshotToWasher: entry for deviceId=${deviceId}`);
|
|
21
|
+
await washer.updateAttribute(OnOff.id, 'onOff', snapshot.isWasherPowerOn, logger);
|
|
22
|
+
await washer.updateAttribute(OperationalState.id, 'operationalState', mapWasherStateToOperationalState(snapshot), logger);
|
|
23
|
+
await washer.updateAttribute(OperationalState.id, 'operationalError', {
|
|
24
|
+
errorStateId: snapshot.isWasherError
|
|
25
|
+
? OperationalState.ErrorState.UnableToCompleteOperation
|
|
26
|
+
: OperationalState.ErrorState.NoError,
|
|
27
|
+
}, logger);
|
|
28
|
+
if (snapshot.washerRemainingDurationSeconds !== undefined) {
|
|
29
|
+
await washer.updateAttribute(OperationalState.id, 'countdownTime', snapshot.washerRemainingDurationSeconds, logger);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
function isRecord(value) {
|
|
2
|
+
return typeof value === 'object' && value !== null;
|
|
3
|
+
}
|
|
4
|
+
function toWasherStopCommandPayload(entry) {
|
|
5
|
+
const payload = {};
|
|
6
|
+
if (typeof entry.command === 'string') {
|
|
7
|
+
payload.command = entry.command;
|
|
8
|
+
}
|
|
9
|
+
if (typeof entry.dataKey === 'string') {
|
|
10
|
+
payload.dataKey = entry.dataKey;
|
|
11
|
+
}
|
|
12
|
+
if ('dataValue' in entry) {
|
|
13
|
+
payload.dataValue = entry.dataValue;
|
|
14
|
+
}
|
|
15
|
+
if (isRecord(entry.dataSetList)) {
|
|
16
|
+
payload.dataSetList = entry.dataSetList;
|
|
17
|
+
}
|
|
18
|
+
else if (isRecord(entry.data)) {
|
|
19
|
+
payload.dataSetList = entry.data;
|
|
20
|
+
}
|
|
21
|
+
return payload;
|
|
22
|
+
}
|
|
23
|
+
export function extractWasherStopCommand(deviceModel) {
|
|
24
|
+
if (!isRecord(deviceModel)) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const controlWifi = deviceModel.ControlWifi;
|
|
28
|
+
if (!isRecord(controlWifi)) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const stopEntry = isRecord(controlWifi.WMStop) ? controlWifi.WMStop : undefined;
|
|
32
|
+
const offEntry = isRecord(controlWifi.WMOff) ? controlWifi.WMOff : undefined;
|
|
33
|
+
const entry = stopEntry ?? offEntry;
|
|
34
|
+
if (!entry) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
return toWasherStopCommandPayload(entry);
|
|
38
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { AuthenticationError, InvalidCredentialsError } from '../../errors/index.js';
|
|
4
|
+
import { buildEmpDefaultHeaders, formatRfc2822Utc, Gateway, signThinqMessage, THINQ_CLIENT_ID, THINQ_OAUTH_CLIENT_KEY, THINQ_OAUTH_SECRET_KEY, } from '../thinq/gateway.js';
|
|
5
|
+
import { ThinqSession } from '../thinq/session.js';
|
|
6
|
+
const EMP_REDIRECT_URI = 'lgaccount.lgsmartthinq:/';
|
|
7
|
+
function isClientErrorResponse(err) {
|
|
8
|
+
if (!axios.isAxiosError(err)) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
const status = err.response?.status;
|
|
12
|
+
return status !== undefined && status >= 400 && status < 500;
|
|
13
|
+
}
|
|
14
|
+
export class AccountAuthStrategy {
|
|
15
|
+
apiClient;
|
|
16
|
+
logger;
|
|
17
|
+
constructor(apiClient, logger) {
|
|
18
|
+
this.apiClient = apiClient;
|
|
19
|
+
this.logger = logger;
|
|
20
|
+
}
|
|
21
|
+
async authenticate(context) {
|
|
22
|
+
if (!context.username || !context.password) {
|
|
23
|
+
throw new InvalidCredentialsError(context.username);
|
|
24
|
+
}
|
|
25
|
+
this.logger.notice('Authenticating with LG ThinQ account credentials...');
|
|
26
|
+
const gatewayData = await this.apiClient.getGateway();
|
|
27
|
+
const gateway = new Gateway(gatewayData);
|
|
28
|
+
const hashedPassword = crypto.createHash('sha512').update(context.password).digest('hex');
|
|
29
|
+
return this.loginStep2(gateway, context.username, hashedPassword, context.country, context.language);
|
|
30
|
+
}
|
|
31
|
+
async loginStep2(gateway, username, encryptedPassword, country, language) {
|
|
32
|
+
const headers = { ...buildEmpDefaultHeaders(gateway) };
|
|
33
|
+
const preLoginData = new URLSearchParams({
|
|
34
|
+
user_auth2: encryptedPassword,
|
|
35
|
+
log_param: `login request / user_id : ${username} / third_party : null / svc_list : SVC202,SVC710 / 3rd_service : `,
|
|
36
|
+
});
|
|
37
|
+
const preLogin = await axios
|
|
38
|
+
.post(`${gateway.loginBaseUrl}preLogin`, preLoginData.toString(), { headers })
|
|
39
|
+
.then((res) => res.data)
|
|
40
|
+
.catch((err) => {
|
|
41
|
+
if (isClientErrorResponse(err)) {
|
|
42
|
+
throw new InvalidCredentialsError(username);
|
|
43
|
+
}
|
|
44
|
+
throw new AuthenticationError('LG pre-login failed.', { cause: err });
|
|
45
|
+
});
|
|
46
|
+
headers['X-Signature'] = preLogin.signature;
|
|
47
|
+
headers['X-Timestamp'] = preLogin.tStamp;
|
|
48
|
+
const sessionData = new URLSearchParams({
|
|
49
|
+
user_auth2: preLogin.encrypted_pw,
|
|
50
|
+
password_hash_prameter_flag: 'Y',
|
|
51
|
+
svc_list: 'SVC202,SVC710',
|
|
52
|
+
});
|
|
53
|
+
const loginUrl = `${gateway.empBaseUrl}emp/v2.0/account/session/${encodeURIComponent(username)}`;
|
|
54
|
+
const account = await axios
|
|
55
|
+
.post(loginUrl, sessionData.toString(), { headers })
|
|
56
|
+
.then((res) => res.data.account)
|
|
57
|
+
.catch((err) => {
|
|
58
|
+
if (isClientErrorResponse(err)) {
|
|
59
|
+
throw new InvalidCredentialsError(username);
|
|
60
|
+
}
|
|
61
|
+
throw new AuthenticationError('LG account login failed.', { cause: err });
|
|
62
|
+
});
|
|
63
|
+
const secretKeyUrl = `${gateway.loginBaseUrl}searchKey?key_name=OAUTH_SECRETKEY&sever_type=OP`;
|
|
64
|
+
const secretKey = await axios
|
|
65
|
+
.get(secretKeyUrl)
|
|
66
|
+
.then((res) => res.data.returnData)
|
|
67
|
+
.catch((err) => {
|
|
68
|
+
throw new AuthenticationError('LG OAuth key lookup failed.', { cause: err });
|
|
69
|
+
});
|
|
70
|
+
const timestamp = formatRfc2822Utc(new Date());
|
|
71
|
+
const empParams = new URLSearchParams({
|
|
72
|
+
account_type: account.userIDType,
|
|
73
|
+
client_id: THINQ_CLIENT_ID,
|
|
74
|
+
country_code: account.country,
|
|
75
|
+
redirect_uri: EMP_REDIRECT_URI,
|
|
76
|
+
response_type: 'code',
|
|
77
|
+
state: '12345',
|
|
78
|
+
username: account.userID,
|
|
79
|
+
});
|
|
80
|
+
const empUrl = new URL(`https://emp-oauth.lgecloud.com/emp/oauth2/authorize/empsession?${empParams.toString()}`);
|
|
81
|
+
const empSignature = signThinqMessage(`${empUrl.pathname}${empUrl.search}\n${timestamp}`, secretKey);
|
|
82
|
+
const authorize = await axios
|
|
83
|
+
.get(empUrl.href, {
|
|
84
|
+
headers: {
|
|
85
|
+
'lgemp-x-app-key': THINQ_OAUTH_CLIENT_KEY,
|
|
86
|
+
'lgemp-x-date': timestamp,
|
|
87
|
+
'lgemp-x-session-key': account.loginSessionID,
|
|
88
|
+
'lgemp-x-signature': empSignature,
|
|
89
|
+
Accept: 'application/json',
|
|
90
|
+
'X-Device-Type': 'M01',
|
|
91
|
+
'X-Device-Platform': 'ADR',
|
|
92
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
.then((res) => res.data)
|
|
96
|
+
.catch((err) => {
|
|
97
|
+
throw new AuthenticationError('LG OAuth authorization failed.', { cause: err });
|
|
98
|
+
});
|
|
99
|
+
if (authorize.status !== 1) {
|
|
100
|
+
throw new AuthenticationError(authorize.message ?? 'LG OAuth authorization was rejected.');
|
|
101
|
+
}
|
|
102
|
+
const redirectUri = new URL(authorize.redirect_uri);
|
|
103
|
+
const code = redirectUri.searchParams.get('code') ?? '';
|
|
104
|
+
const oauth2BackendUrl = redirectUri.searchParams.get('oauth2_backend_url') ?? gateway.loginBaseUrl;
|
|
105
|
+
const tokenData = new URLSearchParams({
|
|
106
|
+
code,
|
|
107
|
+
grant_type: 'authorization_code',
|
|
108
|
+
redirect_uri: EMP_REDIRECT_URI,
|
|
109
|
+
});
|
|
110
|
+
const requestPath = `/oauth/1.0/oauth2/token?${tokenData.toString()}`;
|
|
111
|
+
const tokenTimestamp = formatRfc2822Utc(new Date());
|
|
112
|
+
const token = await axios
|
|
113
|
+
.post(`${oauth2BackendUrl}oauth/1.0/oauth2/token`, tokenData.toString(), {
|
|
114
|
+
headers: {
|
|
115
|
+
'x-lge-app-os': 'ADR',
|
|
116
|
+
'x-lge-appkey': THINQ_CLIENT_ID,
|
|
117
|
+
'x-lge-oauth-signature': signThinqMessage(`${requestPath}\n${tokenTimestamp}`, THINQ_OAUTH_SECRET_KEY),
|
|
118
|
+
'x-lge-oauth-date': tokenTimestamp,
|
|
119
|
+
Accept: 'application/json',
|
|
120
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
.then((res) => res.data)
|
|
124
|
+
.catch((err) => {
|
|
125
|
+
throw new AuthenticationError('LG token exchange failed.', { cause: err });
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
accessToken: token.access_token,
|
|
129
|
+
refreshToken: token.refresh_token,
|
|
130
|
+
expiresAtEpochSeconds: ThinqSession.expiryFromExpiresIn(Number.parseInt(token.expires_in, 10)),
|
|
131
|
+
country,
|
|
132
|
+
language,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export class AuthenticationCoordinator {
|
|
2
|
+
apiClient;
|
|
3
|
+
logger;
|
|
4
|
+
strategies;
|
|
5
|
+
constructor(accountStrategy, tokenStrategy, apiClient, logger) {
|
|
6
|
+
this.apiClient = apiClient;
|
|
7
|
+
this.logger = logger;
|
|
8
|
+
this.strategies = new Map([
|
|
9
|
+
['account', accountStrategy],
|
|
10
|
+
['token', tokenStrategy],
|
|
11
|
+
]);
|
|
12
|
+
}
|
|
13
|
+
async authenticate(method, context) {
|
|
14
|
+
const strategy = this.strategies.get(method);
|
|
15
|
+
if (!strategy) {
|
|
16
|
+
const availableMethods = Array.from(this.strategies.keys()).join(', ');
|
|
17
|
+
this.logger.error(`Unknown ThinQ authentication method: ${method}. Available methods: ${availableMethods}`);
|
|
18
|
+
throw new Error(`Unknown ThinQ authentication method: ${method}`);
|
|
19
|
+
}
|
|
20
|
+
const userData = await strategy.authenticate(context);
|
|
21
|
+
if (!userData) {
|
|
22
|
+
return userData;
|
|
23
|
+
}
|
|
24
|
+
this.logger.debug('AuthenticationCoordinator: resolving ThinQ user number post-login');
|
|
25
|
+
const userNumber = await this.apiClient.getUserNumber(userData.accessToken);
|
|
26
|
+
this.apiClient.setUserNumber(userNumber);
|
|
27
|
+
return { ...userData, userNumber };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { AuthenticationError } from '../../errors/index.js';
|
|
2
|
+
import { ThinqSession } from '../thinq/session.js';
|
|
3
|
+
export class TokenAuthStrategy {
|
|
4
|
+
apiClient;
|
|
5
|
+
logger;
|
|
6
|
+
constructor(apiClient, logger) {
|
|
7
|
+
this.apiClient = apiClient;
|
|
8
|
+
this.logger = logger;
|
|
9
|
+
}
|
|
10
|
+
async authenticate(context) {
|
|
11
|
+
if (!context.refreshToken) {
|
|
12
|
+
throw new AuthenticationError('A ThinQ refresh token is required for token-based authentication.');
|
|
13
|
+
}
|
|
14
|
+
this.logger.notice('Authenticating with LG ThinQ refresh token...');
|
|
15
|
+
this.logger.debug('TokenAuthStrategy: resolving gateway before refresh-token exchange');
|
|
16
|
+
await this.apiClient.getGateway();
|
|
17
|
+
const session = new ThinqSession('', context.refreshToken, 0);
|
|
18
|
+
const refreshed = await this.apiClient.refreshToken(session);
|
|
19
|
+
return {
|
|
20
|
+
accessToken: refreshed.accessToken,
|
|
21
|
+
refreshToken: refreshed.refreshToken,
|
|
22
|
+
expiresAtEpochSeconds: refreshed.expiresAtEpochSeconds,
|
|
23
|
+
country: context.country,
|
|
24
|
+
language: context.language,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const STORAGE_KEY = 'thinq:userData';
|
|
2
|
+
export class UserDataRepository {
|
|
3
|
+
persist;
|
|
4
|
+
configManager;
|
|
5
|
+
logger;
|
|
6
|
+
constructor(persist, configManager, logger) {
|
|
7
|
+
this.persist = persist;
|
|
8
|
+
this.configManager = configManager;
|
|
9
|
+
this.logger = logger;
|
|
10
|
+
}
|
|
11
|
+
async loadUserData() {
|
|
12
|
+
const savedUserData = (await this.persist.getItem(STORAGE_KEY));
|
|
13
|
+
if (!savedUserData) {
|
|
14
|
+
this.logger.debug('No saved ThinQ userData found');
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
if (savedUserData.country !== this.configManager.country) {
|
|
18
|
+
this.logger.debug('Saved ThinQ userData country does not match current config, ignoring saved data');
|
|
19
|
+
await this.clearUserData();
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
this.logger.debug('Loading saved ThinQ userData');
|
|
23
|
+
return savedUserData;
|
|
24
|
+
}
|
|
25
|
+
async saveUserData(userData) {
|
|
26
|
+
await this.persist.setItem(STORAGE_KEY, userData);
|
|
27
|
+
this.logger.debug('ThinQ user data saved successfully');
|
|
28
|
+
}
|
|
29
|
+
async clearUserData() {
|
|
30
|
+
await this.persist.removeItem(STORAGE_KEY);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
export const THINQ_CLIENT_ID = 'LGAO221A02';
|
|
3
|
+
export const THINQ_OAUTH_SECRET_KEY = 'c053c2a6ddeb7ad97cb0eed0dcb31cf8';
|
|
4
|
+
export const THINQ_OAUTH_CLIENT_KEY = 'LGAO722A02';
|
|
5
|
+
export const THINQ_APPLICATION_KEY = '6V1V8H2BN5P9ZQGOI5DAQ92YZBDO3EK9';
|
|
6
|
+
export class Gateway {
|
|
7
|
+
data;
|
|
8
|
+
constructor(data) {
|
|
9
|
+
this.data = data;
|
|
10
|
+
}
|
|
11
|
+
get empBaseUrl() {
|
|
12
|
+
return `${this.data.empTermsUri}/`;
|
|
13
|
+
}
|
|
14
|
+
get loginBaseUrl() {
|
|
15
|
+
return `${this.data.empSpxUri}/`;
|
|
16
|
+
}
|
|
17
|
+
get thinq2Url() {
|
|
18
|
+
return `${this.data.thinq2Uri}/`;
|
|
19
|
+
}
|
|
20
|
+
get thinq1Url() {
|
|
21
|
+
return `${this.data.thinq1Uri}/`;
|
|
22
|
+
}
|
|
23
|
+
get countryCode() {
|
|
24
|
+
return this.data.countryCode;
|
|
25
|
+
}
|
|
26
|
+
get languageCode() {
|
|
27
|
+
return this.data.languageCode;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function signThinqMessage(message, secret) {
|
|
31
|
+
return crypto.createHmac('sha1', Buffer.from(secret)).update(message).digest('base64');
|
|
32
|
+
}
|
|
33
|
+
const RFC2822_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
34
|
+
const RFC2822_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
35
|
+
export function formatRfc2822Utc(date) {
|
|
36
|
+
const day = RFC2822_DAYS[date.getUTCDay()];
|
|
37
|
+
const dd = String(date.getUTCDate()).padStart(2, '0');
|
|
38
|
+
const month = RFC2822_MONTHS[date.getUTCMonth()];
|
|
39
|
+
const hh = String(date.getUTCHours()).padStart(2, '0');
|
|
40
|
+
const mi = String(date.getUTCMinutes()).padStart(2, '0');
|
|
41
|
+
const ss = String(date.getUTCSeconds()).padStart(2, '0');
|
|
42
|
+
return `${day}, ${dd} ${month} ${date.getUTCFullYear()} ${hh}:${mi}:${ss} +0000`;
|
|
43
|
+
}
|
|
44
|
+
export function buildEmpDefaultHeaders(gateway) {
|
|
45
|
+
return {
|
|
46
|
+
Accept: 'application/json',
|
|
47
|
+
'X-Application-Key': THINQ_APPLICATION_KEY,
|
|
48
|
+
'X-Client-App-Key': THINQ_CLIENT_ID,
|
|
49
|
+
'X-Lge-Svccode': 'SVC709',
|
|
50
|
+
'X-Device-Type': 'M01',
|
|
51
|
+
'X-Device-Platform': 'ADR',
|
|
52
|
+
'X-Device-Language-Type': 'IETF',
|
|
53
|
+
'X-Device-Publish-Flag': 'Y',
|
|
54
|
+
'X-Device-Country': gateway.countryCode,
|
|
55
|
+
'X-Device-Language': gateway.languageCode,
|
|
56
|
+
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
57
|
+
'Access-Control-Allow-Origin': '*',
|
|
58
|
+
'Accept-Encoding': 'gzip, deflate, br',
|
|
59
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import FS from 'node:fs';
|
|
2
|
+
import Path from 'node:path';
|
|
3
|
+
import axios from 'axios';
|
|
4
|
+
import forge from 'node-forge';
|
|
5
|
+
export const ATS_ROOT_CA_URL = 'https://www.amazontrust.com/repository/AmazonRootCA1.pem';
|
|
6
|
+
export const LG_ROOT_CA_URL = 'https://support.sectigo.com/sfc/servlet.shepherd/version/download/0683l00000G9fLm';
|
|
7
|
+
export const LEGACY_ROOT_CA_URL = 'https://www.websecurity.digicert.com/content/dam/websitesecurity/digitalassets/desktop/pdfs/roots/' +
|
|
8
|
+
'VeriSign-Class%203-Public-Primary-Certification-Authority-G5.pem';
|
|
9
|
+
export function generateMqttKeyPair() {
|
|
10
|
+
const keys = forge.pki.rsa.generateKeyPair(2048);
|
|
11
|
+
return {
|
|
12
|
+
privateKey: forge.pki.privateKeyToPem(keys.privateKey),
|
|
13
|
+
publicKey: forge.pki.publicKeyToPem(keys.publicKey),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function createMqttCsr(keys) {
|
|
17
|
+
const csr = forge.pki.createCertificationRequest();
|
|
18
|
+
csr.publicKey = forge.pki.publicKeyFromPem(keys.publicKey);
|
|
19
|
+
csr.setSubject([
|
|
20
|
+
{ shortName: 'CN', value: 'AWS IoT Certificate' },
|
|
21
|
+
{ shortName: 'O', value: 'Amazon' },
|
|
22
|
+
]);
|
|
23
|
+
csr.sign(forge.pki.privateKeyFromPem(keys.privateKey), forge.md.sha256.create());
|
|
24
|
+
return forge.pki.certificationRequestToPem(csr);
|
|
25
|
+
}
|
|
26
|
+
export function certificateRequestBody(csr) {
|
|
27
|
+
return csr.replace(/-----(BEGIN|END) CERTIFICATE REQUEST-----/g, '').replace(/(\r\n|\r|\n)/g, '');
|
|
28
|
+
}
|
|
29
|
+
export function rootCaUrlForMqttHost(hostname) {
|
|
30
|
+
if (/^([^.]+)-ats\.iot\.([^.]+)\.amazonaws\.com$/g.exec(hostname)) {
|
|
31
|
+
return ATS_ROOT_CA_URL;
|
|
32
|
+
}
|
|
33
|
+
if (/^([^.]+)\.iot\.ruic\.lgthinq\.com$/g.exec(hostname)) {
|
|
34
|
+
return LG_ROOT_CA_URL;
|
|
35
|
+
}
|
|
36
|
+
return LEGACY_ROOT_CA_URL;
|
|
37
|
+
}
|
|
38
|
+
export async function downloadRootCa(hostname) {
|
|
39
|
+
const response = await axios.get(rootCaUrlForMqttHost(hostname), { responseType: 'text' });
|
|
40
|
+
return response.data;
|
|
41
|
+
}
|
|
42
|
+
export function mqttCertificatePaths(mqttDir) {
|
|
43
|
+
return {
|
|
44
|
+
caPath: Path.join(mqttDir, 'ca.pem'),
|
|
45
|
+
keyPath: Path.join(mqttDir, 'key.pem'),
|
|
46
|
+
certPath: Path.join(mqttDir, 'cert.pem'),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export async function writeIfChanged(path, content) {
|
|
50
|
+
const existing = await FS.promises.readFile(path, 'utf8').catch(() => null);
|
|
51
|
+
if (existing !== content) {
|
|
52
|
+
await FS.promises.writeFile(path, content, 'utf8');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export async function writeMqttCertificateFiles(options) {
|
|
56
|
+
const { mqttDir, rootCA, privateKey, certificatePem } = options;
|
|
57
|
+
await FS.promises.mkdir(mqttDir, { recursive: true });
|
|
58
|
+
const paths = mqttCertificatePaths(mqttDir);
|
|
59
|
+
await writeIfChanged(paths.caPath, rootCA);
|
|
60
|
+
await writeIfChanged(paths.keyPath, privateKey);
|
|
61
|
+
await writeIfChanged(paths.certPath, certificatePem);
|
|
62
|
+
return paths;
|
|
63
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const MQTT_OFFLINE_RECONNECT_DELAY_MS = 60000;
|
|
2
|
+
export function wireMqttDeviceEvents(options) {
|
|
3
|
+
const { device, logger, mqttServer, subscriptions, onMessage, reconnect, scheduleReconnect = (handler, delayMs) => setTimeout(handler, delayMs), } = options;
|
|
4
|
+
device.on('error', (err) => {
|
|
5
|
+
logger.error('mqtt err:', err);
|
|
6
|
+
});
|
|
7
|
+
device.on('connect', () => {
|
|
8
|
+
logger.info('Successfully connected to the MQTT server.');
|
|
9
|
+
logger.debug('mqtt connected:', mqttServer);
|
|
10
|
+
for (const subscription of subscriptions) {
|
|
11
|
+
device.subscribe(subscription);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
device.on('message', (_topic, payload) => {
|
|
15
|
+
const payloadText = payload.toString();
|
|
16
|
+
let parsedPayload;
|
|
17
|
+
try {
|
|
18
|
+
parsedPayload = JSON.parse(payloadText);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
logger.error('mqtt message parse error:', err);
|
|
22
|
+
logger.debug('mqtt invalid message received:', payloadText);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
onMessage(parsedPayload);
|
|
26
|
+
logger.debug('mqtt message received:', payloadText);
|
|
27
|
+
});
|
|
28
|
+
device.on('offline', () => {
|
|
29
|
+
device.end();
|
|
30
|
+
logger.info('MQTT disconnected, retrying in 60 seconds!');
|
|
31
|
+
scheduleReconnect(() => {
|
|
32
|
+
reconnect().catch((err) => {
|
|
33
|
+
logger.error('mqtt reconnect failed:', err);
|
|
34
|
+
});
|
|
35
|
+
}, MQTT_OFFLINE_RECONNECT_DELAY_MS);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createMqttCsr, generateMqttKeyPair } from './mqttCertificate.js';
|
|
2
|
+
const KEY_PAIR_STORAGE_KEY = 'thinq:mqtt:keyPair';
|
|
3
|
+
const CSR_STORAGE_KEY = 'thinq:mqtt:csr';
|
|
4
|
+
export class MqttKeyRepository {
|
|
5
|
+
persist;
|
|
6
|
+
logger;
|
|
7
|
+
constructor(persist, logger) {
|
|
8
|
+
this.persist = persist;
|
|
9
|
+
this.logger = logger;
|
|
10
|
+
}
|
|
11
|
+
async getOrCreateKeyPair() {
|
|
12
|
+
const cached = (await this.persist.getItem(KEY_PAIR_STORAGE_KEY));
|
|
13
|
+
if (cached) {
|
|
14
|
+
this.logger.debug('Loaded cached MQTT key pair');
|
|
15
|
+
return cached;
|
|
16
|
+
}
|
|
17
|
+
this.logger.debug('Generating 2048-bit MQTT key pair...');
|
|
18
|
+
const keys = generateMqttKeyPair();
|
|
19
|
+
await this.persist.setItem(KEY_PAIR_STORAGE_KEY, keys);
|
|
20
|
+
return keys;
|
|
21
|
+
}
|
|
22
|
+
async getOrCreateCsr(keys) {
|
|
23
|
+
const cached = (await this.persist.getItem(CSR_STORAGE_KEY));
|
|
24
|
+
if (cached) {
|
|
25
|
+
this.logger.debug('Loaded cached MQTT CSR');
|
|
26
|
+
return cached;
|
|
27
|
+
}
|
|
28
|
+
this.logger.debug('Creating MQTT certification request (CSR)...');
|
|
29
|
+
const csr = createMqttCsr(keys);
|
|
30
|
+
await this.persist.setItem(CSR_STORAGE_KEY, csr);
|
|
31
|
+
return csr;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const MQTT_RETRY_ATTEMPTS = 5;
|
|
2
|
+
export const MQTT_RETRY_DELAY_MS = 5000;
|
|
3
|
+
export function delayMs(ms) {
|
|
4
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
5
|
+
}
|
|
6
|
+
export async function retryMqttRegistration(options) {
|
|
7
|
+
const { register, logger, delay = delayMs, attempts = MQTT_RETRY_ATTEMPTS, retryDelayMs = MQTT_RETRY_DELAY_MS, } = options;
|
|
8
|
+
let tried = attempts;
|
|
9
|
+
while (tried > 0) {
|
|
10
|
+
try {
|
|
11
|
+
await register();
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
tried--;
|
|
16
|
+
logger.debug(`mqtt err: ${err instanceof Error ? err.message : String(err)}`);
|
|
17
|
+
if (tried > 0) {
|
|
18
|
+
logger.debug('Cannot start MQTT, retrying in 5s.');
|
|
19
|
+
await delay(retryDelayMs);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
logger.error('Cannot start MQTT!');
|
|
24
|
+
return false;
|
|
25
|
+
}
|