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
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { AnsiLogger, LogLevel } from 'matterbridge/logger';
|
|
2
|
+
|
|
3
|
+
import { cmdDevices } from './commands/devices.js';
|
|
4
|
+
import { cmdEnergy, parseEnergyOptions } from './commands/energy.js';
|
|
5
|
+
import { cmdLogin } from './commands/login.js';
|
|
6
|
+
import { HELP_TEXT } from './help.js';
|
|
7
|
+
import { loadSession } from './session.js';
|
|
8
|
+
import { parseArgs } from './utils.js';
|
|
9
|
+
|
|
10
|
+
export async function main(): Promise<void> {
|
|
11
|
+
const args = parseArgs(process.argv.slice(2));
|
|
12
|
+
const command = args['command'];
|
|
13
|
+
|
|
14
|
+
const help = args['help'] === 'true';
|
|
15
|
+
if (!command || command === 'help' || help) {
|
|
16
|
+
console.log(HELP_TEXT);
|
|
17
|
+
process.exit(command || help ? 0 : 1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const debug = args['debug'] === 'true';
|
|
21
|
+
const logger = AnsiLogger.create({ logName: 'CLI', logLevel: debug ? LogLevel.DEBUG : LogLevel.WARN });
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
if (command === 'login') {
|
|
25
|
+
const type = (args['type'] || 'account') as 'account' | 'token';
|
|
26
|
+
const country = args['country'] || 'US';
|
|
27
|
+
const language = args['language'] || 'en-US';
|
|
28
|
+
|
|
29
|
+
await cmdLogin(type, country, language, logger);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (command === 'devices') {
|
|
34
|
+
const session = loadSession();
|
|
35
|
+
if (!session) {
|
|
36
|
+
console.error('No session found. Run `--command login` first.');
|
|
37
|
+
process.exitCode = 1;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const dumpSnapshotDeviceId = args['dump-snapshot'];
|
|
42
|
+
const probeFilterDeviceId = args['probe-filter'];
|
|
43
|
+
await cmdDevices(session, logger, dumpSnapshotDeviceId, probeFilterDeviceId);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (command === 'energy') {
|
|
48
|
+
const options = parseEnergyOptions(args);
|
|
49
|
+
const session = loadSession();
|
|
50
|
+
if (!session) {
|
|
51
|
+
console.error('No session found. Run `--command login` first.');
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await cmdEnergy(session, options, logger);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.error(`Unknown command: ${command}\n\n${HELP_TEXT}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error('Error:', err instanceof Error ? err.message : String(err));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { CliSession, SESSION_FILE } from './types.js';
|
|
4
|
+
|
|
5
|
+
export function saveSession(session: CliSession): void {
|
|
6
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function loadSession(): CliSession | null {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(fs.readFileSync(SESSION_FILE, 'utf-8')) as CliSession;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/cli/types.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ThinqUserData } from '../services/authentication/AuthContext.js';
|
|
2
|
+
|
|
3
|
+
export interface CliSession {
|
|
4
|
+
readonly loginType: 'account' | 'token';
|
|
5
|
+
readonly country: string;
|
|
6
|
+
readonly language: string;
|
|
7
|
+
readonly userData: ThinqUserData;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const SESSION_FILE = '.cli-session.json';
|
package/src/cli/utils.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
|
|
3
|
+
export { maskSecret } from '../utils/mask.js';
|
|
4
|
+
|
|
5
|
+
export function parseArgs(args: string[]): Record<string, string> {
|
|
6
|
+
const result: Record<string, string> = {};
|
|
7
|
+
for (let i = 0; i < args.length; i++) {
|
|
8
|
+
const key = args[i];
|
|
9
|
+
if (!key.startsWith('--')) continue;
|
|
10
|
+
const name = key.slice(2);
|
|
11
|
+
const next = args[i + 1];
|
|
12
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
13
|
+
result[name] = next;
|
|
14
|
+
i++;
|
|
15
|
+
} else {
|
|
16
|
+
result[name] = 'true';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function prompt(question: string): Promise<string> {
|
|
23
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
rl.question(question, (answer) => {
|
|
26
|
+
rl.close();
|
|
27
|
+
resolve(answer.trim());
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { asPartial, buildThinqDeviceData } from '../../../tests/helpers/testUtils.js';
|
|
4
|
+
import type { ThinqDevice } from './ThinqDevice.js';
|
|
5
|
+
import { isAirConditionerDevice, isValidThinqDeviceId, isWasherDevice, toThinqDevice } from './ThinqDevice.js';
|
|
6
|
+
|
|
7
|
+
describe('ThinqDevice', () => {
|
|
8
|
+
describe('isValidThinqDeviceId', () => {
|
|
9
|
+
it('should return true for a valid UUID v4 format', () => {
|
|
10
|
+
const validId = '12345678-1234-1234-1234-123456789012';
|
|
11
|
+
expect(isValidThinqDeviceId(validId)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should return true for a valid uppercase UUID v4 format', () => {
|
|
15
|
+
const validId = '12345678-ABCD-ABCD-ABCD-123456789ABC';
|
|
16
|
+
expect(isValidThinqDeviceId(validId)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should return false for a malformed UUID', () => {
|
|
20
|
+
expect(isValidThinqDeviceId('12345678-1234-1234-1234')).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should return false for a non-UUID string', () => {
|
|
24
|
+
expect(isValidThinqDeviceId('not-a-uuid')).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should return false for non-string values', () => {
|
|
28
|
+
expect(isValidThinqDeviceId(null)).toBe(false);
|
|
29
|
+
expect(isValidThinqDeviceId(undefined)).toBe(false);
|
|
30
|
+
expect(isValidThinqDeviceId(12345)).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should return false for an empty string', () => {
|
|
34
|
+
expect(isValidThinqDeviceId('')).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('isAirConditionerDevice', () => {
|
|
39
|
+
it('should return true for a device with type AC', () => {
|
|
40
|
+
const device = asPartial<ThinqDevice>({ type: 'AC' });
|
|
41
|
+
expect(isAirConditionerDevice(device)).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should return false for a non-AC device type', () => {
|
|
45
|
+
const device = asPartial<ThinqDevice>({ type: 'WASHER' });
|
|
46
|
+
expect(isAirConditionerDevice(device)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should return false for any other type string', () => {
|
|
50
|
+
const device = asPartial<ThinqDevice>({ type: 'REFRIGERATOR' });
|
|
51
|
+
expect(isAirConditionerDevice(device)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('isWasherDevice', () => {
|
|
56
|
+
it('should return true for a device with type WASHER', () => {
|
|
57
|
+
const device = asPartial<ThinqDevice>({ type: 'WASHER' });
|
|
58
|
+
expect(isWasherDevice(device)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should return false for AC device type', () => {
|
|
62
|
+
const device = asPartial<ThinqDevice>({ type: 'AC' });
|
|
63
|
+
expect(isWasherDevice(device)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should return false for WASHER_NEW type (not supported in v1)', () => {
|
|
67
|
+
const device = asPartial<ThinqDevice>({ type: 'WASHER_NEW' });
|
|
68
|
+
expect(isWasherDevice(device)).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should return false for WASH_TOWER type (not supported in v1)', () => {
|
|
72
|
+
const device = asPartial<ThinqDevice>({ type: 'WASH_TOWER' });
|
|
73
|
+
expect(isWasherDevice(device)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should return false for WASH_TOWER_2 type (not supported in v1)', () => {
|
|
77
|
+
const device = asPartial<ThinqDevice>({ type: 'WASH_TOWER_2' });
|
|
78
|
+
expect(isWasherDevice(device)).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should return false for any other type string', () => {
|
|
82
|
+
const device = asPartial<ThinqDevice>({ type: 'REFRIGERATOR' });
|
|
83
|
+
expect(isWasherDevice(device)).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('toThinqDevice', () => {
|
|
88
|
+
it('should map raw ThinqDeviceData to ThinqDevice with all fields', () => {
|
|
89
|
+
const data = buildThinqDeviceData({
|
|
90
|
+
deviceId: 'test-id',
|
|
91
|
+
alias: 'Test Device',
|
|
92
|
+
deviceType: 401,
|
|
93
|
+
modelName: 'ModelXYZ',
|
|
94
|
+
online: true,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const device = toThinqDevice(data);
|
|
98
|
+
|
|
99
|
+
expect(device.id).toBe('test-id');
|
|
100
|
+
expect(device.name).toBe('Test Device');
|
|
101
|
+
expect(device.type).toBe('AC');
|
|
102
|
+
expect(device.modelName).toBe('ModelXYZ');
|
|
103
|
+
expect(device.online).toBe(true);
|
|
104
|
+
expect(device.platformType).toBe('THINQ');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should use modemInfo.modelName as fallback when modelName is absent', () => {
|
|
108
|
+
const data = buildThinqDeviceData({
|
|
109
|
+
modelName: undefined,
|
|
110
|
+
modemInfo: { modelName: 'ModemModel', appVersion: '1.0' },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const device = toThinqDevice(data);
|
|
114
|
+
|
|
115
|
+
expect(device.modelName).toBe('ModemModel');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should use manufacture.manufactureModel as fallback when both modelName and modemInfo.modelName are absent', () => {
|
|
119
|
+
const data = buildThinqDeviceData({
|
|
120
|
+
modelName: undefined,
|
|
121
|
+
modemInfo: undefined,
|
|
122
|
+
manufacture: { manufactureModel: 'ManuModel' },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const device = toThinqDevice(data);
|
|
126
|
+
|
|
127
|
+
expect(device.modelName).toBe('ManuModel');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should use empty string as final fallback for modelName', () => {
|
|
131
|
+
const data = buildThinqDeviceData({
|
|
132
|
+
modelName: undefined,
|
|
133
|
+
modemInfo: undefined,
|
|
134
|
+
manufacture: undefined,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const device = toThinqDevice(data);
|
|
138
|
+
|
|
139
|
+
expect(device.modelName).toBe('');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should resolve deviceType to correct type string', () => {
|
|
143
|
+
const testCases = [
|
|
144
|
+
{ deviceType: 401, expectedType: 'AC' },
|
|
145
|
+
{ deviceType: 201, expectedType: 'WASHER' },
|
|
146
|
+
{ deviceType: 202, expectedType: 'DRYER' },
|
|
147
|
+
{ deviceType: 204, expectedType: 'DISHWASHER' },
|
|
148
|
+
{ deviceType: 301, expectedType: 'OVEN' },
|
|
149
|
+
{ deviceType: 302, expectedType: 'MICROWAVE' },
|
|
150
|
+
{ deviceType: 101, expectedType: 'REFRIGERATOR' },
|
|
151
|
+
{ deviceType: 403, expectedType: 'DEHUMIDIFIER' },
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
for (const { deviceType, expectedType } of testCases) {
|
|
155
|
+
const data = buildThinqDeviceData({ deviceType });
|
|
156
|
+
const device = toThinqDevice(data);
|
|
157
|
+
expect(device.type).toBe(expectedType);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should fallback to string representation of unknown deviceType', () => {
|
|
162
|
+
const data = buildThinqDeviceData({ deviceType: 9999 });
|
|
163
|
+
|
|
164
|
+
const device = toThinqDevice(data);
|
|
165
|
+
|
|
166
|
+
expect(device.type).toBe('9999');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should use snapshot.online as fallback when data.online is absent', () => {
|
|
170
|
+
const data = buildThinqDeviceData({
|
|
171
|
+
online: undefined,
|
|
172
|
+
snapshot: { online: true },
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const device = toThinqDevice(data);
|
|
176
|
+
|
|
177
|
+
expect(device.online).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should use false as final fallback for online when both are absent', () => {
|
|
181
|
+
const data = buildThinqDeviceData({
|
|
182
|
+
online: undefined,
|
|
183
|
+
snapshot: { online: undefined },
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const device = toThinqDevice(data);
|
|
187
|
+
|
|
188
|
+
expect(device.online).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should create a ThinqSnapshot from the snapshot data', () => {
|
|
192
|
+
const data = buildThinqDeviceData({
|
|
193
|
+
snapshot: { online: true, 'airState.operation': 1, 'airState.windStrength': 2 },
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const device = toThinqDevice(data);
|
|
197
|
+
|
|
198
|
+
expect(device.snapshot).toBeDefined();
|
|
199
|
+
expect(device.snapshot.isPowerOn).toBe(true);
|
|
200
|
+
expect(device.snapshot.windStrength).toBe(2);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should carry modelJsonUri when present', () => {
|
|
204
|
+
const modelUri = 'https://example.com/model.json';
|
|
205
|
+
const data = buildThinqDeviceData({
|
|
206
|
+
modelJsonUri: modelUri,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const device = toThinqDevice(data);
|
|
210
|
+
|
|
211
|
+
expect(device.modelJsonUri).toBe(modelUri);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should have undefined modelJsonUri when absent', () => {
|
|
215
|
+
const data = buildThinqDeviceData({
|
|
216
|
+
modelJsonUri: undefined,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const device = toThinqDevice(data);
|
|
220
|
+
|
|
221
|
+
expect(device.modelJsonUri).toBeUndefined();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ThinqDeviceData } from '../../../services/thinq/thinqApiClient.js';
|
|
2
|
+
import { ThinqSnapshot } from '../value-objects/ThinqSnapshot.js';
|
|
3
|
+
|
|
4
|
+
const DEVICE_ID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
5
|
+
|
|
6
|
+
/** Numeric `deviceType` → ThinQ type-name mapping (`homebridge-lg-thinq/src/lib/constants.ts`). Phase 1 subset. */
|
|
7
|
+
const DEVICE_TYPE_NAMES: Record<number, string> = {
|
|
8
|
+
101: 'REFRIGERATOR',
|
|
9
|
+
102: 'KIMCHI_REFRIGERATOR',
|
|
10
|
+
103: 'WATER_PURIFIER',
|
|
11
|
+
201: 'WASHER',
|
|
12
|
+
221: 'WASHER_NEW',
|
|
13
|
+
222: 'WASH_TOWER',
|
|
14
|
+
223: 'WASH_TOWER_2',
|
|
15
|
+
202: 'DRYER',
|
|
16
|
+
203: 'STYLER',
|
|
17
|
+
204: 'DISHWASHER',
|
|
18
|
+
301: 'OVEN',
|
|
19
|
+
302: 'MICROWAVE',
|
|
20
|
+
303: 'COOKTOP',
|
|
21
|
+
304: 'HOOD',
|
|
22
|
+
401: 'AC',
|
|
23
|
+
402: 'AIR_PURIFIER',
|
|
24
|
+
403: 'DEHUMIDIFIER',
|
|
25
|
+
410: 'AERO_TOWER',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Generic ThinQ device entity, common to every device type/generation. */
|
|
29
|
+
export interface ThinqDevice {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly type: string;
|
|
33
|
+
readonly modelName: string;
|
|
34
|
+
readonly platformType: string | undefined;
|
|
35
|
+
readonly online: boolean;
|
|
36
|
+
readonly snapshot: ThinqSnapshot;
|
|
37
|
+
readonly modelJsonUri?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** ThinQ device narrowed to the AirConditioner type (`deviceType === 401`, `type === 'AC'`). */
|
|
41
|
+
export interface ThinqAirConditionerDevice extends ThinqDevice {
|
|
42
|
+
readonly type: 'AC';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** ThinQ device narrowed to the Washer type (`deviceType === 201`, `type === 'WASHER'`). */
|
|
46
|
+
export interface ThinqWasherDevice extends ThinqDevice {
|
|
47
|
+
readonly type: 'WASHER';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isValidThinqDeviceId(id: unknown): id is string {
|
|
51
|
+
return typeof id === 'string' && DEVICE_ID_PATTERN.test(id);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isAirConditionerDevice(device: ThinqDevice): device is ThinqAirConditionerDevice {
|
|
55
|
+
return device.type === 'AC';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isWasherDevice(device: ThinqDevice): device is ThinqWasherDevice {
|
|
59
|
+
return device.type === 'WASHER';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Maps a raw `ThinqDeviceData` REST response into the generic `ThinqDevice` domain entity. */
|
|
63
|
+
export function toThinqDevice(data: ThinqDeviceData): ThinqDevice {
|
|
64
|
+
return {
|
|
65
|
+
id: data.deviceId,
|
|
66
|
+
name: data.alias,
|
|
67
|
+
type: DEVICE_TYPE_NAMES[data.deviceType] ?? String(data.deviceType),
|
|
68
|
+
modelName: data.modelName ?? data.modemInfo?.modelName ?? data.manufacture?.manufactureModel ?? '',
|
|
69
|
+
platformType: data.platformType,
|
|
70
|
+
online: data.online ?? data.snapshot.online ?? false,
|
|
71
|
+
snapshot: new ThinqSnapshot(data.snapshot),
|
|
72
|
+
modelJsonUri: data.modelJsonUri,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ThinqDeviceConfigEntry } from '../../../model/LgThinqPluginPlatformConfig.js';
|
|
2
|
+
|
|
3
|
+
/** Resolved per-device Matter capability set for a ThinQ AirConditioner endpoint. */
|
|
4
|
+
export interface AirConditionerCapabilities {
|
|
5
|
+
readonly supportsHeat: boolean;
|
|
6
|
+
readonly supportsFanSpeedControl: boolean;
|
|
7
|
+
readonly supportsSwingMode: boolean;
|
|
8
|
+
readonly supportsHumiditySensor: boolean;
|
|
9
|
+
readonly supportsAirQualitySensor: boolean;
|
|
10
|
+
readonly supportsEnergyMonitoring: boolean;
|
|
11
|
+
readonly supportsFilterMonitoring: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_AIR_CONDITIONER_CAPABILITIES: AirConditionerCapabilities = {
|
|
15
|
+
supportsHeat: true,
|
|
16
|
+
supportsFanSpeedControl: true,
|
|
17
|
+
supportsSwingMode: false,
|
|
18
|
+
supportsHumiditySensor: false,
|
|
19
|
+
supportsAirQualitySensor: false,
|
|
20
|
+
supportsEnergyMonitoring: false,
|
|
21
|
+
supportsFilterMonitoring: false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Merges configured per-device capability flags over the full-support default.
|
|
26
|
+
* Existing flags (supportsHeat/supportsFanSpeedControl) default to `true`.
|
|
27
|
+
* New flags (all others) default to `false` (opt-in).
|
|
28
|
+
*/
|
|
29
|
+
export function resolveAirConditionerCapabilities(
|
|
30
|
+
devices: ThinqDeviceConfigEntry[] | undefined,
|
|
31
|
+
deviceId: string,
|
|
32
|
+
): AirConditionerCapabilities {
|
|
33
|
+
const entry = devices?.find((device) => device.deviceId === deviceId);
|
|
34
|
+
const capabilities = entry?.capabilities;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
supportsHeat: capabilities?.supportsHeat ?? DEFAULT_AIR_CONDITIONER_CAPABILITIES.supportsHeat,
|
|
38
|
+
supportsFanSpeedControl:
|
|
39
|
+
capabilities?.supportsFanSpeedControl ?? DEFAULT_AIR_CONDITIONER_CAPABILITIES.supportsFanSpeedControl,
|
|
40
|
+
supportsSwingMode: capabilities?.supportsSwingMode ?? DEFAULT_AIR_CONDITIONER_CAPABILITIES.supportsSwingMode,
|
|
41
|
+
supportsHumiditySensor:
|
|
42
|
+
capabilities?.supportsHumiditySensor ?? DEFAULT_AIR_CONDITIONER_CAPABILITIES.supportsHumiditySensor,
|
|
43
|
+
supportsAirQualitySensor:
|
|
44
|
+
capabilities?.supportsAirQualitySensor ?? DEFAULT_AIR_CONDITIONER_CAPABILITIES.supportsAirQualitySensor,
|
|
45
|
+
supportsEnergyMonitoring:
|
|
46
|
+
capabilities?.supportsEnergyMonitoring ?? DEFAULT_AIR_CONDITIONER_CAPABILITIES.supportsEnergyMonitoring,
|
|
47
|
+
supportsFilterMonitoring:
|
|
48
|
+
capabilities?.supportsFilterMonitoring ?? DEFAULT_AIR_CONDITIONER_CAPABILITIES.supportsFilterMonitoring,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const KEY_USE_TIME = 'airState.filterMngStates.useTime';
|
|
2
|
+
const KEY_MAX_TIME = 'airState.filterMngStates.maxTime';
|
|
3
|
+
const KEY_CHANGE_DATE = 'airState.filterMngStates.changeDate';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parses the `filterMngStateCtrl` `Get` response, which comes back keyed with PLURAL
|
|
7
|
+
* `airState.filterMngStates.*` fields (not the singular `airState.filterMngState.*` request keys — a
|
|
8
|
+
* confirmed LG request/response key-casing mismatch, see `.claude/memory.md`).
|
|
9
|
+
*/
|
|
10
|
+
export class ThinqFilterState {
|
|
11
|
+
private constructor(
|
|
12
|
+
private readonly useTimeHours: number,
|
|
13
|
+
private readonly maxTimeHours: number,
|
|
14
|
+
private readonly changeDateRaw: number | undefined,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Returns `undefined` (not a zeroed object) when `useTime`/`maxTime` are missing or `maxTime<=0` —
|
|
19
|
+
* this is the mechanism that keeps AC units with no filter sensor from ever showing fake data.
|
|
20
|
+
*/
|
|
21
|
+
public static fromRaw(data: Record<string, unknown> | undefined): ThinqFilterState | undefined {
|
|
22
|
+
const useTimeHours = readNumber(data, KEY_USE_TIME);
|
|
23
|
+
const maxTimeHours = readNumber(data, KEY_MAX_TIME);
|
|
24
|
+
|
|
25
|
+
if (useTimeHours === undefined || maxTimeHours === undefined || maxTimeHours <= 0) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return new ThinqFilterState(useTimeHours, maxTimeHours, readNumber(data, KEY_CHANGE_DATE));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public get remainingPercent(): number {
|
|
33
|
+
const percent = ((this.maxTimeHours - this.useTimeHours) / this.maxTimeHours) * 100;
|
|
34
|
+
return Math.max(0, Math.min(100, Math.round(percent)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public get changeDate(): number | undefined {
|
|
38
|
+
return this.changeDateRaw;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The device's rated total filter life in hours — echoed back unchanged in a reset `Set` payload. */
|
|
42
|
+
public get ratedMaxTimeHours(): number {
|
|
43
|
+
return this.maxTimeHours;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readNumber(data: Record<string, unknown> | undefined, key: string): number | undefined {
|
|
48
|
+
const value = data?.[key];
|
|
49
|
+
return typeof value === 'number' ? value : undefined;
|
|
50
|
+
}
|