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,191 @@
|
|
|
1
|
+
import { MatterbridgeEndpoint } from 'matterbridge';
|
|
2
|
+
import { AnsiLogger } from 'matterbridge/logger';
|
|
3
|
+
import { FanControl } from 'matterbridge/matter/clusters';
|
|
4
|
+
|
|
5
|
+
import type { ThinqAirConditionerDevice, ThinqWasherDevice } from '../../core/domain/entities/ThinqDevice.js';
|
|
6
|
+
import type { ThinqApiClient } from '../../services/thinq/thinqApiClient.js';
|
|
7
|
+
import { PlatformConfigManager } from '../platformConfigManager.js';
|
|
8
|
+
import {
|
|
9
|
+
registerAirConditionerCommandHandlers,
|
|
10
|
+
THINQ_FAN_SPEED_AUTO,
|
|
11
|
+
THINQ_FAN_SPEED_LOW,
|
|
12
|
+
THINQ_FAN_SPEED_MEDIUM,
|
|
13
|
+
} from './thinqAirConditionerCommandHandlers.js';
|
|
14
|
+
import { buildAirConditionerEndpoint } from './thinqAirConditionerEndpointFactory.js';
|
|
15
|
+
import { registerFilterResetCommandHandler } from './thinqAirConditionerFilterResetCommandHandler.js';
|
|
16
|
+
import { registerWasherCommandHandlers } from './thinqWasherCommandHandlers.js';
|
|
17
|
+
import { buildWasherEndpoint } from './thinqWasherEndpointFactory.js';
|
|
18
|
+
import { extractWasherStopCommand, type WasherStopCommandPayload } from './thinqWasherStopCommandResolver.js';
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TEMPERATURE_CELSIUS = 20;
|
|
21
|
+
const MAX_HEAT_SETPOINT_LIMIT_CELSIUS = 30;
|
|
22
|
+
const MIN_COOL_SETPOINT_LIMIT_CELSIUS = 18;
|
|
23
|
+
|
|
24
|
+
const DEFAULT_PRODUCT_IDENTITY: Record<'AC' | 'WASHER', { productId: number; productName: string }> = {
|
|
25
|
+
AC: { productId: 0x8000, productName: 'LG Air Conditioner' },
|
|
26
|
+
WASHER: { productId: 0x8001, productName: 'LG Washer' },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function mapWindStrengthToFanMode(windStrength: number | undefined): FanControl.FanMode {
|
|
30
|
+
if (windStrength === undefined || windStrength === THINQ_FAN_SPEED_AUTO) {
|
|
31
|
+
return FanControl.FanMode.Auto;
|
|
32
|
+
}
|
|
33
|
+
if (windStrength <= THINQ_FAN_SPEED_LOW) {
|
|
34
|
+
return FanControl.FanMode.Low;
|
|
35
|
+
}
|
|
36
|
+
if (windStrength <= THINQ_FAN_SPEED_MEDIUM) {
|
|
37
|
+
return FanControl.FanMode.Medium;
|
|
38
|
+
}
|
|
39
|
+
return FanControl.FanMode.High;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function mapWindStrengthToFixedFanMode(windStrength: number | undefined): FanControl.FanMode {
|
|
43
|
+
return windStrength === undefined ? FanControl.FanMode.Off : FanControl.FanMode.High;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Builds Matterbridge `AirConditioner` device endpoints from a ThinQ AirConditioner snapshot
|
|
48
|
+
* (mirrors `matterbridge-example-dynamic-platform/src/module.ts:2726-2734`).
|
|
49
|
+
*/
|
|
50
|
+
export class ThinqDeviceConfigurator {
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly logger: AnsiLogger,
|
|
53
|
+
private readonly apiClient: ThinqApiClient,
|
|
54
|
+
private readonly configManager: PlatformConfigManager,
|
|
55
|
+
) {}
|
|
56
|
+
|
|
57
|
+
private resolveProductIdentity(device: ThinqAirConditionerDevice | ThinqWasherDevice): {
|
|
58
|
+
productId: number;
|
|
59
|
+
productName: string;
|
|
60
|
+
} {
|
|
61
|
+
const productIdOverride = this.configManager.getProductIdForDevice(device.id);
|
|
62
|
+
const productNameOverride = this.configManager.getProductNameForDevice(device.id);
|
|
63
|
+
const defaultIdentity = DEFAULT_PRODUCT_IDENTITY[device.type];
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
productId: productIdOverride ?? defaultIdentity.productId,
|
|
67
|
+
productName: productNameOverride ?? defaultIdentity.productName,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public async registerAirConditioner(device: ThinqAirConditionerDevice): Promise<MatterbridgeEndpoint> {
|
|
72
|
+
const snapshot = device.snapshot;
|
|
73
|
+
const currentTemperature = snapshot.currentTemperatureCelsius ?? DEFAULT_TEMPERATURE_CELSIUS;
|
|
74
|
+
const targetTemperature = snapshot.targetTemperatureCelsius ?? DEFAULT_TEMPERATURE_CELSIUS;
|
|
75
|
+
const capabilities = this.configManager.getDeviceCapabilities(device.id);
|
|
76
|
+
|
|
77
|
+
this.logger.debug(`registerAirConditioner: entry for deviceId=${device.id}`);
|
|
78
|
+
this.logger.info(`Registering ThinQ AirConditioner: ${device.name} (${device.id})`);
|
|
79
|
+
|
|
80
|
+
const initialFanMode = capabilities.supportsFanSpeedControl
|
|
81
|
+
? mapWindStrengthToFanMode(snapshot.windStrength)
|
|
82
|
+
: mapWindStrengthToFixedFanMode(snapshot.windStrength);
|
|
83
|
+
|
|
84
|
+
const matterOverride = this.configManager.overrideMatterConfiguration
|
|
85
|
+
? this.configManager.matterOverrideSettings
|
|
86
|
+
: undefined;
|
|
87
|
+
const productIdentity = this.resolveProductIdentity(device);
|
|
88
|
+
|
|
89
|
+
const airConditioner = buildAirConditionerEndpoint(
|
|
90
|
+
device,
|
|
91
|
+
capabilities,
|
|
92
|
+
{
|
|
93
|
+
currentTemperature,
|
|
94
|
+
targetTemperature,
|
|
95
|
+
minHeatSetpointLimitCelsius: 0,
|
|
96
|
+
maxHeatSetpointLimitCelsius: MAX_HEAT_SETPOINT_LIMIT_CELSIUS,
|
|
97
|
+
minCoolSetpointLimitCelsius: MIN_COOL_SETPOINT_LIMIT_CELSIUS,
|
|
98
|
+
maxCoolSetpointLimitCelsius: 50,
|
|
99
|
+
},
|
|
100
|
+
initialFanMode,
|
|
101
|
+
{
|
|
102
|
+
vendorId: matterOverride?.matterVendorId,
|
|
103
|
+
vendorName: matterOverride?.matterVendorName,
|
|
104
|
+
productId: productIdentity.productId,
|
|
105
|
+
productName: productIdentity.productName,
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
.createDefaultTemperatureMeasurementClusterServer(currentTemperature * 100)
|
|
109
|
+
.addRequiredClusterServers();
|
|
110
|
+
|
|
111
|
+
// Hardcoded: the AC is always exposed as its own standalone Matter node (server mode) so it can get its own
|
|
112
|
+
// power tile in Apple Home. Not user-configurable.
|
|
113
|
+
airConditioner.mode = 'server';
|
|
114
|
+
|
|
115
|
+
registerAirConditionerCommandHandlers(airConditioner, device, this.apiClient, this.logger, capabilities);
|
|
116
|
+
|
|
117
|
+
const filterResetControl = this.configManager.getAcFilterControlConfig(device.id);
|
|
118
|
+
registerFilterResetCommandHandler(
|
|
119
|
+
airConditioner,
|
|
120
|
+
device,
|
|
121
|
+
this.apiClient,
|
|
122
|
+
this.logger,
|
|
123
|
+
capabilities,
|
|
124
|
+
filterResetControl,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
this.logger.debug(`registerAirConditioner: completed for deviceId=${device.id}`);
|
|
128
|
+
return Promise.resolve(airConditioner);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
public async registerWasher(device: ThinqWasherDevice): Promise<MatterbridgeEndpoint> {
|
|
132
|
+
this.logger.debug(`registerWasher: entry for deviceId=${device.id}`);
|
|
133
|
+
this.logger.info(`Registering ThinQ Washer: ${device.name} (${device.id})`);
|
|
134
|
+
|
|
135
|
+
const matterOverride = this.configManager.overrideMatterConfiguration
|
|
136
|
+
? this.configManager.matterOverrideSettings
|
|
137
|
+
: undefined;
|
|
138
|
+
const productIdentity = this.resolveProductIdentity(device);
|
|
139
|
+
|
|
140
|
+
const washer = buildWasherEndpoint(device, {
|
|
141
|
+
vendorId: matterOverride?.matterVendorId,
|
|
142
|
+
vendorName: matterOverride?.matterVendorName,
|
|
143
|
+
productId: productIdentity.productId,
|
|
144
|
+
productName: productIdentity.productName,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Hardcoded: the washer is always exposed as its own standalone Matter node (server mode), matching the AC.
|
|
148
|
+
washer.mode = 'server';
|
|
149
|
+
|
|
150
|
+
const washerControl = this.configManager.getWasherControlConfig(device.id);
|
|
151
|
+
const stopCommandPayload = await this.resolveWasherStopCommandPayload(device);
|
|
152
|
+
registerWasherCommandHandlers(washer, device, this.apiClient, this.logger, washerControl, stopCommandPayload);
|
|
153
|
+
|
|
154
|
+
this.logger.debug(`registerWasher: completed for deviceId=${device.id}`);
|
|
155
|
+
return washer;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolves a real `WMStop`/`WMOff` command payload from the device's own downloaded model JSON.
|
|
160
|
+
* Runs unconditionally at registration (read-only GET, no washer state mutated) regardless of
|
|
161
|
+
* whether `washerControl.allowRemoteStop` is set, so the resolved payload is visible in the logs
|
|
162
|
+
* for manual sanity-checking before the opt-in flag is ever turned on. Never throws — any
|
|
163
|
+
* fetch/parse failure resolves to `undefined`, so a broken model-JSON URL can never block
|
|
164
|
+
* washer registration.
|
|
165
|
+
*/
|
|
166
|
+
private async resolveWasherStopCommandPayload(
|
|
167
|
+
device: ThinqWasherDevice,
|
|
168
|
+
): Promise<WasherStopCommandPayload | undefined> {
|
|
169
|
+
if (!device.modelJsonUri) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const model = await this.apiClient.getDeviceModel(device.modelJsonUri);
|
|
175
|
+
const payload = extractWasherStopCommand(model);
|
|
176
|
+
if (payload) {
|
|
177
|
+
this.logger.info(
|
|
178
|
+
`ThinQ Washer ${device.id}: resolved stop-command payload from device model: ${JSON.stringify(payload)}`,
|
|
179
|
+
);
|
|
180
|
+
} else {
|
|
181
|
+
this.logger.info(`ThinQ Washer ${device.id}: device model has no WMStop/WMOff entry.`);
|
|
182
|
+
}
|
|
183
|
+
return payload;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
this.logger.debug(
|
|
186
|
+
`ThinQ Washer ${device.id}: failed to resolve stop-command payload from device model: ${error instanceof Error ? error.message : String(error)}`,
|
|
187
|
+
);
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import type { ThinqDevice } from '../../core/domain/entities/ThinqDevice.js';
|
|
4
|
+
import type { ThinqDeviceService } from '../../services/thinq/thinqDeviceService.js';
|
|
5
|
+
import { asPartial, createMockLogger } from '../../tests/helpers/testUtils.js';
|
|
6
|
+
import { ThinqDeviceDiscovery } from './thinqDeviceDiscovery.js';
|
|
7
|
+
|
|
8
|
+
describe('ThinqDeviceDiscovery', () => {
|
|
9
|
+
let mockDeviceService: ThinqDeviceService;
|
|
10
|
+
let mockLogger: ReturnType<typeof createMockLogger>;
|
|
11
|
+
let discovery: ThinqDeviceDiscovery;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
mockLogger = createMockLogger();
|
|
16
|
+
mockDeviceService = asPartial<ThinqDeviceService>({
|
|
17
|
+
discoverDevices: vi.fn(),
|
|
18
|
+
});
|
|
19
|
+
discovery = new ThinqDeviceDiscovery(mockDeviceService, mockLogger);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.clearAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('discoverDevices', () => {
|
|
27
|
+
it('should return empty array when no devices discovered', async () => {
|
|
28
|
+
vi.mocked(mockDeviceService.discoverDevices).mockResolvedValue([]);
|
|
29
|
+
|
|
30
|
+
const result = await discovery.discoverDevices();
|
|
31
|
+
|
|
32
|
+
expect(result).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should include AC device type', async () => {
|
|
36
|
+
const acDevice = asPartial<ThinqDevice>({
|
|
37
|
+
type: 'AC',
|
|
38
|
+
id: 'device-ac-1',
|
|
39
|
+
name: 'Air Conditioner',
|
|
40
|
+
modelName: 'ModelXYZ',
|
|
41
|
+
online: true,
|
|
42
|
+
});
|
|
43
|
+
vi.mocked(mockDeviceService.discoverDevices).mockResolvedValue([acDevice]);
|
|
44
|
+
|
|
45
|
+
const result = await discovery.discoverDevices();
|
|
46
|
+
|
|
47
|
+
expect(result).toHaveLength(1);
|
|
48
|
+
expect(result[0].type).toBe('AC');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should include WASHER device type (v1 support)', async () => {
|
|
52
|
+
const washerDevice = asPartial<ThinqDevice>({
|
|
53
|
+
type: 'WASHER',
|
|
54
|
+
id: 'device-washer-1',
|
|
55
|
+
name: 'Washer',
|
|
56
|
+
modelName: 'VCDWL_QEUK',
|
|
57
|
+
online: true,
|
|
58
|
+
});
|
|
59
|
+
vi.mocked(mockDeviceService.discoverDevices).mockResolvedValue([washerDevice]);
|
|
60
|
+
|
|
61
|
+
const result = await discovery.discoverDevices();
|
|
62
|
+
|
|
63
|
+
expect(result).toHaveLength(1);
|
|
64
|
+
expect(result[0].type).toBe('WASHER');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should skip unsupported washer variants (WASHER_NEW)', async () => {
|
|
68
|
+
const washerNewDevice = asPartial<ThinqDevice>({
|
|
69
|
+
type: 'WASHER_NEW',
|
|
70
|
+
id: 'device-washer-new-1',
|
|
71
|
+
name: 'Washer New',
|
|
72
|
+
});
|
|
73
|
+
vi.mocked(mockDeviceService.discoverDevices).mockResolvedValue([washerNewDevice]);
|
|
74
|
+
|
|
75
|
+
await discovery.discoverDevices();
|
|
76
|
+
|
|
77
|
+
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
78
|
+
expect.stringContaining('ThinQ device type not supported, skipping: WASHER_NEW'),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should log device ID in skip message', async () => {
|
|
83
|
+
const device = asPartial<ThinqDevice>({
|
|
84
|
+
type: 'REFRIGERATOR',
|
|
85
|
+
id: 'test-device-id-123',
|
|
86
|
+
name: 'Fridge',
|
|
87
|
+
});
|
|
88
|
+
vi.mocked(mockDeviceService.discoverDevices).mockResolvedValue([device]);
|
|
89
|
+
|
|
90
|
+
await discovery.discoverDevices();
|
|
91
|
+
|
|
92
|
+
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('test-device-id-123'));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should filter supported and unsupported devices correctly', async () => {
|
|
96
|
+
const acDevice = asPartial<ThinqDevice>({
|
|
97
|
+
type: 'AC',
|
|
98
|
+
id: 'device-ac-1',
|
|
99
|
+
name: 'Air Conditioner',
|
|
100
|
+
});
|
|
101
|
+
const washerDevice = asPartial<ThinqDevice>({
|
|
102
|
+
type: 'WASHER',
|
|
103
|
+
id: 'device-washer-1',
|
|
104
|
+
name: 'Washer',
|
|
105
|
+
});
|
|
106
|
+
const anotherAcDevice = asPartial<ThinqDevice>({
|
|
107
|
+
type: 'AC',
|
|
108
|
+
id: 'device-ac-2',
|
|
109
|
+
name: 'Bedroom AC',
|
|
110
|
+
});
|
|
111
|
+
const dishwasherDevice = asPartial<ThinqDevice>({
|
|
112
|
+
type: 'DISHWASHER',
|
|
113
|
+
id: 'device-dw-1',
|
|
114
|
+
name: 'Dishwasher',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
vi.mocked(mockDeviceService.discoverDevices).mockResolvedValue([
|
|
118
|
+
acDevice,
|
|
119
|
+
washerDevice,
|
|
120
|
+
anotherAcDevice,
|
|
121
|
+
dishwasherDevice,
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
const result = await discovery.discoverDevices();
|
|
125
|
+
|
|
126
|
+
expect(result).toHaveLength(3);
|
|
127
|
+
expect(result[0].id).toBe('device-ac-1');
|
|
128
|
+
expect(result[1].id).toBe('device-washer-1');
|
|
129
|
+
expect(result[2].id).toBe('device-ac-2');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should properly narrow AC device type', async () => {
|
|
133
|
+
const acDevice = asPartial<ThinqDevice>({
|
|
134
|
+
type: 'AC',
|
|
135
|
+
id: 'device-ac-1',
|
|
136
|
+
name: 'Air Conditioner',
|
|
137
|
+
modelName: 'Model123',
|
|
138
|
+
online: true,
|
|
139
|
+
});
|
|
140
|
+
vi.mocked(mockDeviceService.discoverDevices).mockResolvedValue([acDevice]);
|
|
141
|
+
|
|
142
|
+
const result = await discovery.discoverDevices();
|
|
143
|
+
|
|
144
|
+
expect(result).toHaveLength(1);
|
|
145
|
+
const discoveredDevice = result[0];
|
|
146
|
+
// The dispatch method should return the device with AC type narrowed
|
|
147
|
+
expect(discoveredDevice.type).toBe('AC');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should propagate errors from device service', async () => {
|
|
151
|
+
const error = new Error('Device discovery failed');
|
|
152
|
+
vi.mocked(mockDeviceService.discoverDevices).mockRejectedValue(error);
|
|
153
|
+
|
|
154
|
+
await expect(discovery.discoverDevices()).rejects.toThrow('Device discovery failed');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should skip devices with other types like DRYER, REFRIGERATOR, OVEN', async () => {
|
|
158
|
+
const devices: ThinqDevice[] = [
|
|
159
|
+
asPartial<ThinqDevice>({ type: 'DRYER', id: 'device-1' }),
|
|
160
|
+
asPartial<ThinqDevice>({ type: 'REFRIGERATOR', id: 'device-2' }),
|
|
161
|
+
asPartial<ThinqDevice>({ type: 'OVEN', id: 'device-3' }),
|
|
162
|
+
asPartial<ThinqDevice>({ type: 'MICROWAVE', id: 'device-4' }),
|
|
163
|
+
asPartial<ThinqDevice>({ type: 'AC', id: 'device-5' }),
|
|
164
|
+
];
|
|
165
|
+
vi.mocked(mockDeviceService.discoverDevices).mockResolvedValue(devices);
|
|
166
|
+
|
|
167
|
+
const result = await discovery.discoverDevices();
|
|
168
|
+
|
|
169
|
+
expect(result).toHaveLength(1);
|
|
170
|
+
expect(result[0].id).toBe('device-5');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { AnsiLogger } from 'matterbridge/logger';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ThinqAirConditionerDevice,
|
|
5
|
+
ThinqDevice,
|
|
6
|
+
ThinqWasherDevice,
|
|
7
|
+
} from '../../core/domain/entities/ThinqDevice.js';
|
|
8
|
+
import { ThinqDeviceService } from '../../services/thinq/thinqDeviceService.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Dispatches discovered ThinQ devices to their typed device entity, keyed by `deviceType` string
|
|
12
|
+
* (ports `Helper.make`, `homebridge-lg-thinq/src/helper.ts:32-49`). Supports AirConditioner ('AC') and
|
|
13
|
+
* Washer ('WASHER') — every other type is unregistered (returns `undefined`), matching the source's
|
|
14
|
+
* `unregisterUnsupportedDevice` fail-closed behavior.
|
|
15
|
+
*/
|
|
16
|
+
export class ThinqDeviceDiscovery {
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly deviceService: ThinqDeviceService,
|
|
19
|
+
private readonly logger: AnsiLogger,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
public async discoverDevices(): Promise<ThinqDevice[]> {
|
|
23
|
+
const devices = await this.deviceService.discoverDevices();
|
|
24
|
+
this.logger.debug(`ThinQ discoverDevices: ${devices.length} raw device(s) returned before filtering`);
|
|
25
|
+
const supported: ThinqDevice[] = [];
|
|
26
|
+
|
|
27
|
+
for (const device of devices) {
|
|
28
|
+
const resolved = this.dispatch(device);
|
|
29
|
+
if (resolved) {
|
|
30
|
+
supported.push(resolved);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.logger.info(`ThinQ device type not supported, skipping: ${device.type} (${device.id})`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.logger.debug(`ThinQ discoverDevices: ${supported.length} device(s) supported after filtering`);
|
|
38
|
+
return supported;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private dispatch(device: ThinqDevice): ThinqAirConditionerDevice | ThinqWasherDevice | undefined {
|
|
42
|
+
switch (device.type) {
|
|
43
|
+
case 'AC':
|
|
44
|
+
return { ...device, type: 'AC' };
|
|
45
|
+
case 'WASHER':
|
|
46
|
+
return { ...device, type: 'WASHER' };
|
|
47
|
+
default:
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { MatterbridgeEndpoint } from 'matterbridge';
|
|
2
|
+
import { AnsiLogger } from 'matterbridge/logger';
|
|
3
|
+
import { LaundryWasherMode, OnOff, OperationalState } from 'matterbridge/matter/clusters';
|
|
4
|
+
|
|
5
|
+
import type { ThinqWasherDevice } from '../../core/domain/entities/ThinqDevice.js';
|
|
6
|
+
import type { ThinqWasherControlConfig } from '../../model/LgThinqPluginPlatformConfig.js';
|
|
7
|
+
import type { ThinqApiClient } from '../../services/thinq/thinqApiClient.js';
|
|
8
|
+
import type { WasherStopCommandPayload } from './thinqWasherStopCommandResolver.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* v1 washer support is read-only telemetry only (mirrors `homebridge-lg-thinq`'s own washer
|
|
12
|
+
* implementation), except for `stop` (v2, real command — see `registerWasherCommandHandlers`).
|
|
13
|
+
* Every command in this list throws before any real ThinQ call happens. Because the currently
|
|
14
|
+
* installed matterbridge dispatches commands without awaiting the handler (see revert helpers
|
|
15
|
+
* below), each throw is paired with a best-effort revert so Apple Home self-corrects immediately
|
|
16
|
+
* instead of showing a value ThinQ never accepted.
|
|
17
|
+
*/
|
|
18
|
+
const READ_ONLY_WASHER_COMMANDS = ['on', 'off', 'pause', 'start', 'resume', 'changeToMode'] as const;
|
|
19
|
+
|
|
20
|
+
const ON_OFF_COMMANDS = new Set<(typeof READ_ONLY_WASHER_COMMANDS)[number]>(['on', 'off']);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Reverts `OnOff.onOff` back to `oldValue` after a rejected command, so Apple Home self-corrects
|
|
24
|
+
* immediately instead of showing a value ThinQ never accepted. Best-effort: `updateAttribute`
|
|
25
|
+
* failures are swallowed since the rejection is already logged by the caller.
|
|
26
|
+
*/
|
|
27
|
+
function revertOnOffOnFailure(washer: MatterbridgeEndpoint, oldValue: boolean): void {
|
|
28
|
+
washer.updateAttribute(OnOff.id, 'onOff', oldValue, washer.log).catch(() => {
|
|
29
|
+
// Best-effort revert; rejection already logged by the caller.
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reverts `OperationalState.operationalState` (and, for `changeToMode`, `LaundryWasherMode.currentMode`)
|
|
35
|
+
* back to their last-known-correct values after a rejected command. Best-effort: `updateAttribute`
|
|
36
|
+
* failures are swallowed since the rejection is already logged by the caller.
|
|
37
|
+
*/
|
|
38
|
+
function revertOperationalStateOnFailure(
|
|
39
|
+
washer: MatterbridgeEndpoint,
|
|
40
|
+
oldOperationalState: number,
|
|
41
|
+
command: (typeof READ_ONLY_WASHER_COMMANDS)[number],
|
|
42
|
+
oldCurrentMode: number,
|
|
43
|
+
): void {
|
|
44
|
+
washer.updateAttribute(OperationalState.id, 'operationalState', oldOperationalState, washer.log).catch(() => {
|
|
45
|
+
// Best-effort revert; rejection already logged by the caller.
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (command === 'changeToMode') {
|
|
49
|
+
washer.updateAttribute(LaundryWasherMode.id, 'currentMode', oldCurrentMode, washer.log).catch(() => {
|
|
50
|
+
// Best-effort revert; rejection already logged by the caller.
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function registerWasherCommandHandlers(
|
|
56
|
+
washer: MatterbridgeEndpoint,
|
|
57
|
+
device: ThinqWasherDevice,
|
|
58
|
+
apiClient: ThinqApiClient,
|
|
59
|
+
logger: AnsiLogger,
|
|
60
|
+
washerControl: ThinqWasherControlConfig,
|
|
61
|
+
stopCommandPayload: WasherStopCommandPayload | undefined,
|
|
62
|
+
): void {
|
|
63
|
+
for (const command of READ_ONLY_WASHER_COMMANDS) {
|
|
64
|
+
washer.addCommandHandler(command, async () => {
|
|
65
|
+
logger.warn(`ThinQ Washer remote control ('${command}') is not supported yet (deviceId=${device.id})`);
|
|
66
|
+
|
|
67
|
+
if (ON_OFF_COMMANDS.has(command)) {
|
|
68
|
+
const previousOnOff = washer.getAttribute(OnOff.id, 'onOff') as boolean | undefined;
|
|
69
|
+
revertOnOffOnFailure(washer, previousOnOff ?? false);
|
|
70
|
+
} else {
|
|
71
|
+
const previousOperationalState = washer.getAttribute(OperationalState.id, 'operationalState') as
|
|
72
|
+
number | undefined;
|
|
73
|
+
const previousCurrentMode = washer.getAttribute(LaundryWasherMode.id, 'currentMode') as number | undefined;
|
|
74
|
+
revertOperationalStateOnFailure(
|
|
75
|
+
washer,
|
|
76
|
+
previousOperationalState ?? OperationalState.OperationalStateEnum.Stopped,
|
|
77
|
+
command,
|
|
78
|
+
previousCurrentMode ?? 0,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
throw new Error(`ThinQ Washer remote control ('${command}') is not supported yet`);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
washer.addCommandHandler('stop', async () => {
|
|
87
|
+
if (!washerControl.allowRemoteStop) {
|
|
88
|
+
logger.warn(`ThinQ Washer remote control ('stop') is disabled by configuration (deviceId=${device.id})`);
|
|
89
|
+
throw new Error(`ThinQ Washer remote control ('stop') is disabled by configuration.`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!stopCommandPayload) {
|
|
93
|
+
logger.warn(
|
|
94
|
+
`ThinQ Washer remote control ('stop') is not available for this device model yet (deviceId=${device.id})`,
|
|
95
|
+
);
|
|
96
|
+
throw new Error(`ThinQ Washer remote control ('stop') is not available for this device model yet.`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const previousState = washer.getAttribute(OperationalState.id, 'operationalState') as number | undefined;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
await apiClient.sendCommand(device.id, { ctrlKey: 'WMControl', ...stopCommandPayload });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
logger.error(
|
|
105
|
+
`ThinQ Washer command 'stop' failed: ${error instanceof Error ? error.message : String(error)} (deviceId=${device.id})`,
|
|
106
|
+
);
|
|
107
|
+
washer
|
|
108
|
+
.updateAttribute(
|
|
109
|
+
OperationalState.id,
|
|
110
|
+
'operationalState',
|
|
111
|
+
previousState ?? OperationalState.OperationalStateEnum.Stopped,
|
|
112
|
+
washer.log,
|
|
113
|
+
)
|
|
114
|
+
.catch(() => {
|
|
115
|
+
// Best-effort revert; original failure already logged above.
|
|
116
|
+
});
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { MatterbridgeEndpoint } from 'matterbridge';
|
|
2
|
+
import { LaundryWasher } from 'matterbridge/devices';
|
|
3
|
+
|
|
4
|
+
import type { ThinqWasherDevice } from '../../core/domain/entities/ThinqDevice.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Options for building a washer endpoint with custom vendor/product identity.
|
|
8
|
+
* Mirrors `BuildAirConditionerEndpointOptions` field-for-field.
|
|
9
|
+
*/
|
|
10
|
+
export interface BuildWasherEndpointOptions {
|
|
11
|
+
vendorId?: number;
|
|
12
|
+
vendorName?: string;
|
|
13
|
+
productId?: number;
|
|
14
|
+
productName?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Builds a Matterbridge `LaundryWasher`-shaped endpoint for a ThinQ Washer device.
|
|
19
|
+
* `LaundryWasher`'s constructor already self-composes every mandatory cluster (OnOff DeadFront,
|
|
20
|
+
* LaundryWasherMode, LaundryWasherControls, TemperatureControl, OperationalState) — no capability
|
|
21
|
+
* gating or extra cluster-server calls are needed, unlike the AirConditioner's hand-composed pattern.
|
|
22
|
+
*/
|
|
23
|
+
export function buildWasherEndpoint(
|
|
24
|
+
device: ThinqWasherDevice,
|
|
25
|
+
options?: BuildWasherEndpointOptions,
|
|
26
|
+
): MatterbridgeEndpoint {
|
|
27
|
+
const washer = new LaundryWasher(device.name, device.id);
|
|
28
|
+
washer.createDefaultBasicInformationClusterServer(
|
|
29
|
+
device.name,
|
|
30
|
+
device.id,
|
|
31
|
+
options?.vendorId ?? 0xfff1,
|
|
32
|
+
options?.vendorName ?? 'Matterbridge',
|
|
33
|
+
options?.productId ?? 0x8000,
|
|
34
|
+
options?.productName ?? 'Matterbridge Laundry Washer',
|
|
35
|
+
);
|
|
36
|
+
return washer;
|
|
37
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { MatterbridgeEndpoint } from 'matterbridge';
|
|
2
|
+
import { AnsiLogger } from 'matterbridge/logger';
|
|
3
|
+
import { OnOff, OperationalState } from 'matterbridge/matter/clusters';
|
|
4
|
+
|
|
5
|
+
import type { ThinqSnapshot } from '../../core/domain/value-objects/ThinqSnapshot.js';
|
|
6
|
+
|
|
7
|
+
/** Maps a ThinQ washer snapshot to a Matter `OperationalState.OperationalStateEnum`. */
|
|
8
|
+
export function mapWasherStateToOperationalState(snapshot: ThinqSnapshot): OperationalState.OperationalStateEnum {
|
|
9
|
+
if (snapshot.isWasherError) {
|
|
10
|
+
return OperationalState.OperationalStateEnum.Error;
|
|
11
|
+
}
|
|
12
|
+
if (snapshot.isWasherRunning) {
|
|
13
|
+
return OperationalState.OperationalStateEnum.Running;
|
|
14
|
+
}
|
|
15
|
+
if (snapshot.washerRawState === 'PAUSE') {
|
|
16
|
+
return OperationalState.OperationalStateEnum.Paused;
|
|
17
|
+
}
|
|
18
|
+
return OperationalState.OperationalStateEnum.Stopped;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pushes a freshly polled ThinQ Washer snapshot onto the Matter `LaundryWasher` endpoint's attributes
|
|
23
|
+
* (device → Apple Home). Read-only v1: no washer-specific commands are ever sent back.
|
|
24
|
+
*/
|
|
25
|
+
export async function applyThinqSnapshotToWasher(
|
|
26
|
+
washer: MatterbridgeEndpoint,
|
|
27
|
+
snapshot: ThinqSnapshot,
|
|
28
|
+
logger: AnsiLogger,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
const deviceId = washer.serialNumber ?? washer.uniqueId ?? 'unknown';
|
|
31
|
+
|
|
32
|
+
if (snapshot.washerRawState === undefined) {
|
|
33
|
+
logger.debug(`applyThinqSnapshotToWasher: skipped (source key absent): washerDryer.state, deviceId=${deviceId}`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
logger.debug(`applyThinqSnapshotToWasher: entry for deviceId=${deviceId}`);
|
|
38
|
+
|
|
39
|
+
await washer.updateAttribute(OnOff.id, 'onOff', snapshot.isWasherPowerOn, logger);
|
|
40
|
+
await washer.updateAttribute(
|
|
41
|
+
OperationalState.id,
|
|
42
|
+
'operationalState',
|
|
43
|
+
mapWasherStateToOperationalState(snapshot),
|
|
44
|
+
logger,
|
|
45
|
+
);
|
|
46
|
+
await washer.updateAttribute(
|
|
47
|
+
OperationalState.id,
|
|
48
|
+
'operationalError',
|
|
49
|
+
{
|
|
50
|
+
errorStateId: snapshot.isWasherError
|
|
51
|
+
? OperationalState.ErrorState.UnableToCompleteOperation
|
|
52
|
+
: OperationalState.ErrorState.NoError,
|
|
53
|
+
},
|
|
54
|
+
logger,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (snapshot.washerRemainingDurationSeconds !== undefined) {
|
|
58
|
+
await washer.updateAttribute(OperationalState.id, 'countdownTime', snapshot.washerRemainingDurationSeconds, logger);
|
|
59
|
+
}
|
|
60
|
+
}
|