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.
Files changed (210) hide show
  1. package/.claude/CLAUDE.md +43 -0
  2. package/.claude/agents/compiler.md +91 -0
  3. package/.claude/agents/direct-executor.md +98 -0
  4. package/.claude/agents/documenter.md +78 -0
  5. package/.claude/agents/finalizer.md +233 -0
  6. package/.claude/agents/implementer.md +112 -0
  7. package/.claude/agents/investigator.md +100 -0
  8. package/.claude/agents/release-manager.md +203 -0
  9. package/.claude/agents/reviewer.md +131 -0
  10. package/.claude/agents/technical-architect.md +403 -0
  11. package/.claude/agents/test-writer.md +159 -0
  12. package/.claude/agents/wiki-manager.md +147 -0
  13. package/.claude/instructions/agent-prompts.md +90 -0
  14. package/.claude/instructions/shared-rules.md +11 -0
  15. package/.claude/instructions/team-orchestrator-policy.md +165 -0
  16. package/.claude/memory.md +186 -0
  17. package/.claude/output-styles/engineer-manager.md +38 -0
  18. package/.claude/settings.json +40 -0
  19. package/.claude/settings.local.json +30 -0
  20. package/.claude/skills/load-policy/SKILL.md +13 -0
  21. package/.claude/skills/ref-idea/SKILL.md +44 -0
  22. package/.claude/skills/status-of/SKILL.md +38 -0
  23. package/.claude/skills/workspace-console/SKILL.md +49 -0
  24. package/.claude/statusline-command.sh +130 -0
  25. package/.claude/statusline.md +53 -0
  26. package/.claude/templates/approval-packet.template.html +171 -0
  27. package/.claude/templates/reference-workspaces.md +16 -0
  28. package/.claude/tools/cli-runner-server.cjs +220 -0
  29. package/.claude/tools/discord-send-server.cjs +188 -0
  30. package/.claude/tools/github-release-server.cjs +181 -0
  31. package/.claude/tools/glob-grep-server.cjs +267 -0
  32. package/.claude/tools/read-log-server.cjs +175 -0
  33. package/.github/codeql/codeql-config.yml +14 -0
  34. package/.github/workflows/build.yml +54 -0
  35. package/.github/workflows/codeql.yml +59 -0
  36. package/.github/workflows/coverage.yml +70 -0
  37. package/.github/workflows/publish.yml +103 -0
  38. package/.mcp.json +34 -0
  39. package/CHANGELOG.md +195 -0
  40. package/LICENSE +21 -0
  41. package/dist/cli/commands/devices.js +52 -0
  42. package/dist/cli/commands/energy.js +122 -0
  43. package/dist/cli/commands/login.js +54 -0
  44. package/dist/cli/help.js +50 -0
  45. package/dist/cli/main.js +56 -0
  46. package/dist/cli/session.js +13 -0
  47. package/dist/cli/types.js +1 -0
  48. package/dist/cli/utils.js +29 -0
  49. package/dist/cli.js +2 -0
  50. package/dist/constants/index.js +1 -0
  51. package/dist/core/domain/entities/ThinqDevice.js +43 -0
  52. package/dist/core/domain/value-objects/AirConditionerCapabilities.js +22 -0
  53. package/dist/core/domain/value-objects/ThinqFilterState.js +35 -0
  54. package/dist/core/domain/value-objects/ThinqSnapshot.js +125 -0
  55. package/dist/errors/AuthenticationError.js +26 -0
  56. package/dist/errors/BaseError.js +23 -0
  57. package/dist/errors/index.js +2 -0
  58. package/dist/model/LgThinkqPluginPlatformConfig.js +23 -0
  59. package/dist/model/LgThinqPluginPlatformConfig.js +23 -0
  60. package/dist/module.js +212 -0
  61. package/dist/platform/deviceRegistry.js +26 -0
  62. package/dist/platform/platformConfigManager.js +89 -0
  63. package/dist/platform/platformState.js +9 -0
  64. package/dist/platform/thinq/thinqAirConditionerCommandHandlers.js +190 -0
  65. package/dist/platform/thinq/thinqAirConditionerEndpointFactory.js +53 -0
  66. package/dist/platform/thinq/thinqAirConditionerFilterResetCommandHandler.js +54 -0
  67. package/dist/platform/thinq/thinqAirConditionerFilterStateSync.js +19 -0
  68. package/dist/platform/thinq/thinqAirConditionerStateSync.js +205 -0
  69. package/dist/platform/thinq/thinqDeviceConfigReconciler.js +26 -0
  70. package/dist/platform/thinq/thinqDeviceConfigurator.js +124 -0
  71. package/dist/platform/thinq/thinqDeviceDiscovery.js +33 -0
  72. package/dist/platform/thinq/thinqWasherCommandHandlers.js +54 -0
  73. package/dist/platform/thinq/thinqWasherEndpointFactory.js +6 -0
  74. package/dist/platform/thinq/thinqWasherStateSync.js +31 -0
  75. package/dist/platform/thinq/thinqWasherStopCommandResolver.js +38 -0
  76. package/dist/services/authentication/AccountAuthStrategy.js +135 -0
  77. package/dist/services/authentication/AuthContext.js +1 -0
  78. package/dist/services/authentication/AuthenticationCoordinator.js +29 -0
  79. package/dist/services/authentication/IAuthStrategy.js +1 -0
  80. package/dist/services/authentication/TokenAuthStrategy.js +27 -0
  81. package/dist/services/authentication/UserDataRepository.js +32 -0
  82. package/dist/services/thinq/gateway.js +61 -0
  83. package/dist/services/thinq/mqtt/mqttCertificate.js +63 -0
  84. package/dist/services/thinq/mqtt/mqttConnectionEvents.js +37 -0
  85. package/dist/services/thinq/mqtt/mqttKeyRepository.js +33 -0
  86. package/dist/services/thinq/mqtt/mqttRetry.js +25 -0
  87. package/dist/services/thinq/mqtt/thinqMqttListener.js +97 -0
  88. package/dist/services/thinq/serviceContainer.js +73 -0
  89. package/dist/services/thinq/session.js +48 -0
  90. package/dist/services/thinq/thinqApiClient.js +258 -0
  91. package/dist/services/thinq/thinqDeviceService.js +75 -0
  92. package/dist/services/thinq/thinqFilterMonitoringService.js +40 -0
  93. package/dist/settings.js +9 -0
  94. package/dist/tests/helpers/testUtils.js +87 -0
  95. package/dist/utils/mask.js +39 -0
  96. package/docs/README_CLI.md +166 -0
  97. package/eslint.config.js +194 -0
  98. package/matterbridge-lg-thinq-plugin.config.json +27 -0
  99. package/matterbridge-lg-thinq-plugin.schema.json +520 -0
  100. package/package.json +107 -0
  101. package/prettier.config.js +49 -0
  102. package/scripts/parse-junit-failures.mjs +45 -0
  103. package/scripts/parse-tsc-failures.mjs +48 -0
  104. package/scripts/run-build-local-summary.mjs +29 -0
  105. package/scripts/run-format-summary.mjs +36 -0
  106. package/scripts/run-lint-fix-summary.mjs +38 -0
  107. package/scripts/run-precommit-summary.mjs +94 -0
  108. package/src/cli/commands/devices.ts +67 -0
  109. package/src/cli/commands/energy.ts +160 -0
  110. package/src/cli/commands/login.ts +74 -0
  111. package/src/cli/help.ts +53 -0
  112. package/src/cli/main.ts +66 -0
  113. package/src/cli/session.ts +15 -0
  114. package/src/cli/types.ts +10 -0
  115. package/src/cli/utils.ts +30 -0
  116. package/src/cli.ts +3 -0
  117. package/src/constants/index.ts +8 -0
  118. package/src/core/domain/entities/ThinqDevice.test.ts +224 -0
  119. package/src/core/domain/entities/ThinqDevice.ts +74 -0
  120. package/src/core/domain/value-objects/AirConditionerCapabilities.ts +50 -0
  121. package/src/core/domain/value-objects/ThinqFilterState.ts +50 -0
  122. package/src/core/domain/value-objects/ThinqSnapshot.ts +162 -0
  123. package/src/errors/AuthenticationError.ts +37 -0
  124. package/src/errors/BaseError.ts +38 -0
  125. package/src/errors/index.ts +24 -0
  126. package/src/model/LgThinqPluginPlatformConfig.ts +94 -0
  127. package/src/module.ts +298 -0
  128. package/src/platform/deviceRegistry.ts +58 -0
  129. package/src/platform/platformConfigManager.ts +133 -0
  130. package/src/platform/platformState.ts +16 -0
  131. package/src/platform/thinq/thinqAirConditionerCommandHandlers.ts +291 -0
  132. package/src/platform/thinq/thinqAirConditionerEndpointFactory.ts +145 -0
  133. package/src/platform/thinq/thinqAirConditionerFilterResetCommandHandler.ts +96 -0
  134. package/src/platform/thinq/thinqAirConditionerFilterStateSync.ts +42 -0
  135. package/src/platform/thinq/thinqAirConditionerStateSync.ts +343 -0
  136. package/src/platform/thinq/thinqDeviceConfigReconciler.ts +42 -0
  137. package/src/platform/thinq/thinqDeviceConfigurator.test.ts +90 -0
  138. package/src/platform/thinq/thinqDeviceConfigurator.ts +191 -0
  139. package/src/platform/thinq/thinqDeviceDiscovery.test.ts +173 -0
  140. package/src/platform/thinq/thinqDeviceDiscovery.ts +51 -0
  141. package/src/platform/thinq/thinqWasherCommandHandlers.ts +120 -0
  142. package/src/platform/thinq/thinqWasherEndpointFactory.ts +37 -0
  143. package/src/platform/thinq/thinqWasherStateSync.ts +60 -0
  144. package/src/platform/thinq/thinqWasherStopCommandResolver.ts +62 -0
  145. package/src/services/authentication/AccountAuthStrategy.test.ts +358 -0
  146. package/src/services/authentication/AccountAuthStrategy.ts +209 -0
  147. package/src/services/authentication/AuthContext.ts +19 -0
  148. package/src/services/authentication/AuthenticationCoordinator.test.ts +217 -0
  149. package/src/services/authentication/AuthenticationCoordinator.ts +59 -0
  150. package/src/services/authentication/IAuthStrategy.ts +10 -0
  151. package/src/services/authentication/TokenAuthStrategy.test.ts +167 -0
  152. package/src/services/authentication/TokenAuthStrategy.ts +40 -0
  153. package/src/services/authentication/UserDataRepository.test.ts +149 -0
  154. package/src/services/authentication/UserDataRepository.ts +46 -0
  155. package/src/services/thinq/gateway.ts +86 -0
  156. package/src/services/thinq/mqtt/mqttCertificate.test.ts +344 -0
  157. package/src/services/thinq/mqtt/mqttCertificate.ts +102 -0
  158. package/src/services/thinq/mqtt/mqttConnectionEvents.test.ts +276 -0
  159. package/src/services/thinq/mqtt/mqttConnectionEvents.ts +76 -0
  160. package/src/services/thinq/mqtt/mqttKeyRepository.test.ts +164 -0
  161. package/src/services/thinq/mqtt/mqttKeyRepository.ts +43 -0
  162. package/src/services/thinq/mqtt/mqttRetry.test.ts +239 -0
  163. package/src/services/thinq/mqtt/mqttRetry.ts +43 -0
  164. package/src/services/thinq/mqtt/thinqMqttListener.test.ts +349 -0
  165. package/src/services/thinq/mqtt/thinqMqttListener.ts +120 -0
  166. package/src/services/thinq/serviceContainer.test.ts +203 -0
  167. package/src/services/thinq/serviceContainer.ts +105 -0
  168. package/src/services/thinq/session.test.ts +287 -0
  169. package/src/services/thinq/session.ts +69 -0
  170. package/src/services/thinq/thinqApiClient.test.ts +702 -0
  171. package/src/services/thinq/thinqApiClient.ts +418 -0
  172. package/src/services/thinq/thinqDeviceService.test.ts +415 -0
  173. package/src/services/thinq/thinqDeviceService.ts +101 -0
  174. package/src/services/thinq/thinqFilterMonitoringService.test.ts +264 -0
  175. package/src/services/thinq/thinqFilterMonitoringService.ts +53 -0
  176. package/src/settings.ts +18 -0
  177. package/src/tests/cli/commands/devices.test.ts +293 -0
  178. package/src/tests/cli/commands/login.test.ts +240 -0
  179. package/src/tests/cli/help.test.ts +46 -0
  180. package/src/tests/cli/main.test.ts +214 -0
  181. package/src/tests/cli/session.test.ts +198 -0
  182. package/src/tests/cli/utils.test.ts +194 -0
  183. package/src/tests/core/domain/value-objects/AirConditionerCapabilities.test.ts +685 -0
  184. package/src/tests/core/domain/value-objects/ThinqFilterState.test.ts +210 -0
  185. package/src/tests/core/domain/value-objects/ThinqSnapshot.test.ts +1035 -0
  186. package/src/tests/helpers/testUtils.ts +118 -0
  187. package/src/tests/model/LgThinqPluginPlatformConfig.test.ts +81 -0
  188. package/src/tests/platform/deviceRegistry.test.ts +177 -0
  189. package/src/tests/platform/platformConfigManager.test.ts +790 -0
  190. package/src/tests/platform/platformState.test.ts +41 -0
  191. package/src/tests/platform/thinq/thinqAirConditionerCommandHandlers.test.ts +879 -0
  192. package/src/tests/platform/thinq/thinqAirConditionerEndpointFactory.test.ts +993 -0
  193. package/src/tests/platform/thinq/thinqAirConditionerFilterResetCommandHandler.test.ts +508 -0
  194. package/src/tests/platform/thinq/thinqAirConditionerFilterStateSync.test.ts +212 -0
  195. package/src/tests/platform/thinq/thinqAirConditionerStateSync.test.ts +1586 -0
  196. package/src/tests/platform/thinq/thinqDeviceConfigReconciler.test.ts +269 -0
  197. package/src/tests/platform/thinq/thinqDeviceConfigurator.test.ts +492 -0
  198. package/src/tests/platform/thinq/thinqWasherCommandHandlers.test.ts +594 -0
  199. package/src/tests/platform/thinq/thinqWasherEndpointFactory.test.ts +84 -0
  200. package/src/tests/platform/thinq/thinqWasherStateSync.test.ts +373 -0
  201. package/src/tests/platform/thinq/thinqWasherStopCommandResolver.test.ts +392 -0
  202. package/src/tests/settings.test.ts +33 -0
  203. package/src/utils/mask.ts +50 -0
  204. package/tsconfig.base.json +25 -0
  205. package/tsconfig.json +9 -0
  206. package/tsconfig.production.json +13 -0
  207. package/tsconfig.vitest.json +5 -0
  208. package/vite.config.ts +43 -0
  209. package/wiki/memory-archive.md +12 -0
  210. package/wiki/reference-workspaces.md +23 -0
@@ -0,0 +1,162 @@
1
+ /**
2
+ * ThinQ `washerDryer.state` values that do NOT count as "actively running" (ported verbatim from
3
+ * `homebridge-lg-thinq/src/devices/WasherDryer.ts:14-15`).
4
+ */
5
+ const WASHER_NOT_RUNNING_STATES = [
6
+ 'COOLDOWN',
7
+ 'POWEROFF',
8
+ 'POWERFAIL',
9
+ 'INITIAL',
10
+ 'PAUSE',
11
+ 'AUDIBLE_DIAGNOSIS',
12
+ 'FIRMWARE',
13
+ 'COURSE_DOWNLOAD',
14
+ 'ERROR',
15
+ 'END',
16
+ ];
17
+
18
+ /**
19
+ * Typed accessor over a ThinQ device's raw flat-key snapshot bag (e.g. `airState.opMode`).
20
+ * Phase 1 only exposes the AirConditioner fields used by `ThinqDeviceConfigurator`.
21
+ */
22
+ export class ThinqSnapshot {
23
+ constructor(private readonly data: Record<string, unknown>) {}
24
+
25
+ public get raw(): Record<string, unknown> {
26
+ return this.data;
27
+ }
28
+
29
+ public has(key: string): boolean {
30
+ const value = this.data[key];
31
+ return value !== undefined && value !== null;
32
+ }
33
+
34
+ public get isPowerOn(): boolean {
35
+ return Number(this.data['airState.operation']) === 1;
36
+ }
37
+
38
+ public get currentTemperatureCelsius(): number | undefined {
39
+ return this.readNumber('airState.tempState.current');
40
+ }
41
+
42
+ public get targetTemperatureCelsius(): number | undefined {
43
+ return this.readNumber('airState.tempState.target');
44
+ }
45
+
46
+ public get operationMode(): number | undefined {
47
+ return this.readNumber('airState.opMode');
48
+ }
49
+
50
+ public get windStrength(): number | undefined {
51
+ return this.readNumber('airState.windStrength');
52
+ }
53
+
54
+ public get online(): boolean | undefined {
55
+ return typeof this.data.online === 'boolean' ? this.data.online : undefined;
56
+ }
57
+
58
+ public get isJetModeOn(): boolean {
59
+ return Number(this.data['airState.wMode.jet']) === 1;
60
+ }
61
+
62
+ public get isQuietModeOn(): boolean {
63
+ return Number(this.data['airState.miscFuncState.silentAWHP']) === 1;
64
+ }
65
+
66
+ public get isEnergySaveModeOn(): boolean {
67
+ return Number(this.data['airState.powerSave.basic']) === 1;
68
+ }
69
+
70
+ public get isAirCleanModeOn(): boolean {
71
+ return Number(this.data['airState.wMode.airClean']) === 1;
72
+ }
73
+
74
+ public get isLedOn(): boolean {
75
+ return Number(this.data['airState.lightingState.displayControl']) === 1;
76
+ }
77
+
78
+ public get isVerticalSwingOn(): boolean {
79
+ return Number(this.data['airState.wDir.vStep']) === 100;
80
+ }
81
+
82
+ public get isHorizontalSwingOn(): boolean {
83
+ return Number(this.data['airState.wDir.hStep']) === 100;
84
+ }
85
+
86
+ public get humidityPercent(): number | undefined {
87
+ const value = this.readNumber('airState.humidity.current');
88
+ if (value === undefined) {
89
+ return undefined;
90
+ }
91
+ return value > 100 ? value / 10 : value;
92
+ }
93
+
94
+ public get airQualityOverall(): number | undefined {
95
+ return this.readNumber('airState.quality.overall');
96
+ }
97
+
98
+ public get pm25(): number | undefined {
99
+ return this.readNumber('airState.quality.PM2');
100
+ }
101
+
102
+ public get pm10(): number | undefined {
103
+ return this.readNumber('airState.quality.PM10');
104
+ }
105
+
106
+ public get powerConsumptionWatts(): number | undefined {
107
+ const value = this.readNumber('airState.energy.onCurrent');
108
+ return value === undefined || Number.isNaN(value) ? undefined : value;
109
+ }
110
+
111
+ private get washerDryer(): Record<string, unknown> | undefined {
112
+ const value = this.data['washerDryer'];
113
+ return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined;
114
+ }
115
+
116
+ public get hasWasherDryer(): boolean {
117
+ return this.washerDryer !== undefined;
118
+ }
119
+
120
+ public get washerRawState(): string | undefined {
121
+ const value = this.washerDryer?.['state'];
122
+ return typeof value === 'string' ? value : undefined;
123
+ }
124
+
125
+ public get isWasherPowerOn(): boolean {
126
+ return this.washerRawState !== undefined && !['POWEROFF', 'POWERFAIL'].includes(this.washerRawState);
127
+ }
128
+
129
+ public get isWasherRunning(): boolean {
130
+ return (
131
+ this.isWasherPowerOn &&
132
+ this.washerRawState !== undefined &&
133
+ !WASHER_NOT_RUNNING_STATES.includes(this.washerRawState)
134
+ );
135
+ }
136
+
137
+ public get isWasherError(): boolean {
138
+ return this.washerRawState === 'ERROR';
139
+ }
140
+
141
+ public get washerRemainingDurationSeconds(): number | undefined {
142
+ const hours = this.readWasherNumber('remainTimeHour');
143
+ const minutes = this.readWasherNumber('remainTimeMinute');
144
+ if (hours === undefined && minutes === undefined) {
145
+ return undefined;
146
+ }
147
+ if (!this.isWasherRunning) {
148
+ return 0;
149
+ }
150
+ return (hours ?? 0) * 3600 + (minutes ?? 0) * 60;
151
+ }
152
+
153
+ private readNumber(key: string): number | undefined {
154
+ const value = this.data[key];
155
+ return typeof value === 'number' ? value : undefined;
156
+ }
157
+
158
+ private readWasherNumber(key: string): number | undefined {
159
+ const value = this.washerDryer?.[key];
160
+ return typeof value === 'number' ? value : undefined;
161
+ }
162
+ }
@@ -0,0 +1,37 @@
1
+ import { BaseError } from './BaseError.js';
2
+
3
+ /** Base error for ThinQ authentication failures. */
4
+ export class AuthenticationError extends BaseError {
5
+ constructor(message: string, metadata?: Record<string, unknown>) {
6
+ super(message, 'THINQ_AUTH_ERROR', 401, metadata);
7
+ }
8
+ }
9
+
10
+ /** Invalid ThinQ username or password. */
11
+ export class InvalidCredentialsError extends AuthenticationError {
12
+ constructor(username?: string) {
13
+ super('Invalid LG ThinQ username or password. Please check your credentials.', {
14
+ reason: 'INVALID_CREDENTIALS',
15
+ username,
16
+ });
17
+ }
18
+ }
19
+
20
+ /** ThinQ access token has expired and could not be refreshed. */
21
+ export class TokenExpiredError extends AuthenticationError {
22
+ constructor() {
23
+ super('LG ThinQ authentication token has expired. Please log in again.', {
24
+ reason: 'TOKEN_EXPIRED',
25
+ });
26
+ }
27
+ }
28
+
29
+ /** LG requires a manual action (e.g. accepting new terms, or completing a third-party SSO login) before continuing. */
30
+ export class ManualProcessNeededError extends BaseError {
31
+ constructor(
32
+ message = 'LG ThinQ requires a manual process (e.g. accepting new terms in the native LG app) before continuing.',
33
+ metadata?: Record<string, unknown>,
34
+ ) {
35
+ super(message, 'MANUAL_PROCESS_NEEDED', 428, metadata);
36
+ }
37
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Base error class for all plugin errors.
3
+ * Provides common error structure with error codes, status codes, and metadata.
4
+ */
5
+ export abstract class BaseError extends Error {
6
+ /**
7
+ * Creates a new BaseError instance.
8
+ * @param message - Human-readable error message
9
+ * @param code - Error code for programmatic handling
10
+ * @param statusCode - HTTP-like status code (optional)
11
+ * @param metadata - Additional context information
12
+ */
13
+ constructor(
14
+ message: string,
15
+ public readonly code: string,
16
+ public readonly statusCode?: number,
17
+ public readonly metadata?: Record<string, unknown>,
18
+ ) {
19
+ super(message);
20
+ this.name = this.constructor.name;
21
+ Error.captureStackTrace(this, this.constructor);
22
+ }
23
+
24
+ /**
25
+ * Serialize error to JSON for logging.
26
+ * @returns Object representation of the error
27
+ */
28
+ toJSON(): Record<string, unknown> {
29
+ return {
30
+ name: this.name,
31
+ message: this.message,
32
+ code: this.code,
33
+ statusCode: this.statusCode,
34
+ metadata: this.metadata,
35
+ stack: this.stack,
36
+ };
37
+ }
38
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Error handling module.
3
+ *
4
+ * Provides a hierarchical error structure for type-safe error handling throughout the plugin.
5
+ * All errors extend from BaseError and include error codes, status codes, and metadata.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { BaseError } from './errors';
10
+ *
11
+ * class MyError extends BaseError {}
12
+ * ```
13
+ */
14
+
15
+ // Base error
16
+ export { BaseError } from './BaseError.js';
17
+
18
+ // Authentication errors
19
+ export {
20
+ AuthenticationError,
21
+ InvalidCredentialsError,
22
+ ManualProcessNeededError,
23
+ TokenExpiredError,
24
+ } from './AuthenticationError.js';
@@ -0,0 +1,94 @@
1
+ import { PlatformConfig } from 'matterbridge';
2
+
3
+ export interface ThinqDeviceCapabilityConfig {
4
+ supportsHeat?: boolean;
5
+ supportsFanSpeedControl?: boolean;
6
+ supportsSwingMode?: boolean;
7
+ supportsHumiditySensor?: boolean;
8
+ supportsAirQualitySensor?: boolean;
9
+ supportsEnergyMonitoring?: boolean;
10
+ supportsFilterMonitoring?: boolean;
11
+ }
12
+
13
+ export interface MatterOverrideSettings {
14
+ matterVendorName: string;
15
+ matterVendorId: number;
16
+ }
17
+
18
+ export interface ThinqWasherControlConfig {
19
+ allowRemoteStop?: boolean;
20
+ }
21
+
22
+ export interface ThinqAcFilterControlConfig {
23
+ allowFilterReset?: boolean;
24
+ }
25
+
26
+ export interface ThinqDeviceConfigEntry {
27
+ deviceId: string;
28
+ deviceType?: 'AC' | 'WASHER';
29
+ capabilities?: ThinqDeviceCapabilityConfig;
30
+ productName?: string;
31
+ productId?: number;
32
+ washerControl?: ThinqWasherControlConfig;
33
+ acFilterControl?: ThinqAcFilterControlConfig;
34
+ }
35
+
36
+ export interface ThinqAuthConfig {
37
+ loginType: 'account' | 'token';
38
+ username?: string;
39
+ password?: string;
40
+ refreshToken?: string;
41
+ country: string;
42
+ language: string;
43
+ refreshIntervalSeconds?: number;
44
+ filterMonitoringIntervalSeconds?: number;
45
+ devices: ThinqDeviceConfigEntry[];
46
+ }
47
+
48
+ export interface WebosPluginConfig {
49
+ devices: unknown[];
50
+ }
51
+
52
+ export interface AdvancedFeatureSetting {
53
+ debug: boolean;
54
+ clearStorageOnStartup: boolean;
55
+ forceAuthentication: boolean;
56
+ unregisterOnShutdown: boolean;
57
+ overrideMatterConfiguration: boolean;
58
+ matterOverrideSettings: MatterOverrideSettings;
59
+ }
60
+
61
+ export interface AdvancedFeatureConfiguration {
62
+ settings: AdvancedFeatureSetting;
63
+ }
64
+
65
+ export interface LgThinqPluginPlatformConfig extends PlatformConfig {
66
+ thinq: ThinqAuthConfig;
67
+ webos: WebosPluginConfig;
68
+ advancedFeature: AdvancedFeatureConfiguration;
69
+ }
70
+
71
+ export function createDefaultAdvancedFeature(): AdvancedFeatureConfiguration {
72
+ return {
73
+ settings: {
74
+ debug: false,
75
+ clearStorageOnStartup: false,
76
+ forceAuthentication: false,
77
+ unregisterOnShutdown: false,
78
+ overrideMatterConfiguration: false,
79
+ matterOverrideSettings: {
80
+ matterVendorName: 'Matterbridge',
81
+ matterVendorId: 0xfff1,
82
+ },
83
+ },
84
+ };
85
+ }
86
+
87
+ export function createDefaultThinqConfig(): ThinqAuthConfig {
88
+ return {
89
+ loginType: 'account',
90
+ country: 'US',
91
+ language: 'en-US',
92
+ devices: [],
93
+ };
94
+ }
package/src/module.ts ADDED
@@ -0,0 +1,298 @@
1
+ import Path from 'node:path';
2
+
3
+ import { MatterbridgeDynamicPlatform, MatterbridgeEndpoint, PlatformConfig, PlatformMatterbridge } from 'matterbridge';
4
+ import { AnsiLogger, LogLevel } from 'matterbridge/logger';
5
+ import NodePersist from 'node-persist';
6
+
7
+ import { UNREGISTER_DEVICES_DELAY_MS } from './constants/index.js';
8
+ import { isAirConditionerDevice, isWasherDevice } from './core/domain/entities/ThinqDevice.js';
9
+ import { ManualProcessNeededError } from './errors/index.js';
10
+ import { LgThinqPluginPlatformConfig } from './model/LgThinqPluginPlatformConfig.js';
11
+ // Platform layer imports
12
+ import { DeviceRegistry } from './platform/deviceRegistry.js';
13
+ import { PlatformConfigManager } from './platform/platformConfigManager.js';
14
+ import { PlatformState } from './platform/platformState.js';
15
+ import { applyThinqFilterStateToAirConditioner } from './platform/thinq/thinqAirConditionerFilterStateSync.js';
16
+ import { applyThinqSnapshotToAirConditioner } from './platform/thinq/thinqAirConditionerStateSync.js';
17
+ import { reconcileThinqDeviceConfigEntries } from './platform/thinq/thinqDeviceConfigReconciler.js';
18
+ import { applyThinqSnapshotToWasher } from './platform/thinq/thinqWasherStateSync.js';
19
+ import { ThinqServiceContainer } from './services/thinq/serviceContainer.js';
20
+ import { ThinqSession } from './services/thinq/session.js';
21
+ import { ThinqDeviceUpdateListener } from './services/thinq/thinqDeviceService.js';
22
+ import type { ThinqFilterUpdateListener } from './services/thinq/thinqFilterMonitoringService.js';
23
+ import { PLUGIN_NAME } from './settings.js';
24
+
25
+ export default function initializePlugin(
26
+ matterbridge: PlatformMatterbridge,
27
+ log: AnsiLogger,
28
+ config: PlatformConfig,
29
+ ): LgThinqMatterbridgePlatform {
30
+ return new LgThinqMatterbridgePlatform(matterbridge, log, config as LgThinqPluginPlatformConfig);
31
+ }
32
+
33
+ /**
34
+ * LG ThinQ platform for Matterbridge (webOS TV lands in a later phase, see below).
35
+ * Empty lifecycle skeleton (Phase 0) — device discovery/configuration lands in Phase 1 (ThinQ) and Phase 3 (webOS).
36
+ */
37
+ export class LgThinqMatterbridgePlatform extends MatterbridgeDynamicPlatform {
38
+ public persist: NodePersist.LocalStorage;
39
+
40
+ // Platform layer
41
+ public readonly registry: DeviceRegistry;
42
+ public readonly configManager: PlatformConfigManager;
43
+ public readonly state: PlatformState;
44
+
45
+ // ThinQ family services (Phase 1: auth + discovery + polling, AirConditioner only)
46
+ public readonly thinqServices: ThinqServiceContainer;
47
+
48
+ private thinqPollingIntervalMs: number | undefined;
49
+ private readonly thinqDeviceKindById = new Map<string, 'AC' | 'WASHER'>();
50
+ private readonly filterMonitoringDeviceIds: string[] = [];
51
+
52
+ constructor(
53
+ matterbridge: PlatformMatterbridge,
54
+ logger: AnsiLogger,
55
+ override config: LgThinqPluginPlatformConfig,
56
+ ) {
57
+ super(matterbridge, logger, config);
58
+ logger.logLevel = this.config.advancedFeature.settings.debug ? LogLevel.DEBUG : LogLevel.INFO;
59
+
60
+ this.log.info('Initializing platform:', this.config.name);
61
+
62
+ // Initialize persistence
63
+ const persistDir = Path.join(this.matterbridge.matterbridgePluginDirectory, PLUGIN_NAME, 'persist');
64
+ this.persist = NodePersist.create({ dir: persistDir });
65
+
66
+ // Initialize platform layer
67
+ this.configManager = PlatformConfigManager.create(config, this.log);
68
+ this.registry = new DeviceRegistry();
69
+ this.state = new PlatformState();
70
+ this.thinqServices = new ThinqServiceContainer(
71
+ this.log,
72
+ this.persist,
73
+ this.configManager,
74
+ Path.join(this.matterbridge.matterbridgePluginDirectory, PLUGIN_NAME, 'mqtt-certs'),
75
+ );
76
+ }
77
+
78
+ // #region Lifecycle
79
+ public override async onStart(reason?: string): Promise<void> {
80
+ this.log.debug(`onStart: entry (reason=${reason ?? 'none'})`);
81
+ this.log.notice('onStart called with reason:', reason ?? 'none');
82
+
83
+ await this.ready;
84
+ await this.clearSelect();
85
+ await this.persist.init();
86
+
87
+ if (this.configManager.isClearStorageOnStartupEnabled) {
88
+ this.log.debug('onStart: exit early — clear-storage-on-startup enabled, skipping device startup');
89
+ return;
90
+ }
91
+
92
+ if (!this.configManager.validateConfig()) {
93
+ this.log.error('Platform configuration is invalid.');
94
+ this.state.setStartupCompleted(false);
95
+ this.log.debug('onStart: exit — invalid platform configuration');
96
+ return;
97
+ }
98
+
99
+ try {
100
+ await this.startThinqDevices();
101
+ } catch (error) {
102
+ this.log.error(`ThinQ startup failed: ${error instanceof Error ? error.message : String(error)}`);
103
+ this.state.setStartupCompleted(false);
104
+ this.log.debug('onStart: exit — ThinQ startup threw');
105
+ return;
106
+ }
107
+
108
+ this.log.notice('onStart finished');
109
+ this.state.setStartupCompleted(true);
110
+ this.log.debug('onStart: exit — startup completed successfully');
111
+ }
112
+
113
+ public override async onConfigure(): Promise<void> {
114
+ await super.onConfigure();
115
+ this.log.debug('onConfigure: entry');
116
+ this.log.notice('onConfigure called');
117
+
118
+ if (this.configManager.isClearStorageOnStartupEnabled) {
119
+ this.log.warn('Clearing persistence storage as per configuration.');
120
+ await this.persist
121
+ .clear()
122
+ .then(() => this.unregisterAllDevices(UNREGISTER_DEVICES_DELAY_MS))
123
+ .then(() => {
124
+ this.log.notice('Please restart the platform now.');
125
+ this.wssSendRestartRequired();
126
+ })
127
+ .catch((error: unknown) => {
128
+ this.log.error(`Error clearing persistence storage: ${error}`);
129
+ });
130
+ this.log.debug('onConfigure: exit early — clear-storage-on-startup enabled');
131
+ return;
132
+ }
133
+
134
+ if (!this.state.isStartupCompleted) {
135
+ this.log.debug('onConfigure: exit early — startup did not complete, skipping polling setup');
136
+ return;
137
+ }
138
+
139
+ const applyDeviceUpdate: ThinqDeviceUpdateListener = (deviceId, snapshot) => {
140
+ const endpoint = this.registry.getDevice(deviceId) as MatterbridgeEndpoint | undefined;
141
+ if (!endpoint) {
142
+ this.log.debug(`ThinQ device update received for unregistered device ${deviceId}, skipping.`);
143
+ return;
144
+ }
145
+
146
+ if (this.thinqDeviceKindById.get(deviceId) === 'WASHER') {
147
+ void applyThinqSnapshotToWasher(endpoint, snapshot, this.log).catch((error: unknown) => {
148
+ this.log.error(
149
+ `Failed to apply ThinQ state update for ${deviceId}: ${error instanceof Error ? error.message : String(error)}`,
150
+ );
151
+ });
152
+ return;
153
+ }
154
+
155
+ void applyThinqSnapshotToAirConditioner(
156
+ endpoint,
157
+ snapshot,
158
+ this.configManager.getDeviceCapabilities(deviceId),
159
+ this.log,
160
+ ).catch((error: unknown) => {
161
+ this.log.error(
162
+ `Failed to apply ThinQ state update for ${deviceId}: ${error instanceof Error ? error.message : String(error)}`,
163
+ );
164
+ });
165
+ };
166
+
167
+ this.thinqPollingIntervalMs = this.configManager.thinqRefreshIntervalSeconds * 1000;
168
+ this.thinqServices.getDeviceService().startPolling(this.thinqPollingIntervalMs, applyDeviceUpdate);
169
+ this.thinqServices.getDeviceService().startKeepAlive();
170
+ void this.thinqServices
171
+ .getMqttListener()
172
+ .start(applyDeviceUpdate)
173
+ .catch((error: unknown) => {
174
+ this.log.error(
175
+ `ThinQ MQTT listener failed to start: ${error instanceof Error ? error.message : String(error)}`,
176
+ );
177
+ });
178
+
179
+ if (this.filterMonitoringDeviceIds.length > 0) {
180
+ const applyFilterUpdate: ThinqFilterUpdateListener = (deviceId, filterState) => {
181
+ const endpoint = this.registry.getDevice(deviceId) as MatterbridgeEndpoint | undefined;
182
+ if (!endpoint) {
183
+ this.log.debug(`ThinQ filter update received for unregistered device ${deviceId}, skipping.`);
184
+ return;
185
+ }
186
+ void applyThinqFilterStateToAirConditioner(endpoint, filterState, this.log).catch((error: unknown) => {
187
+ this.log.error(
188
+ `Failed to apply ThinQ filter state update for ${deviceId}: ${error instanceof Error ? error.message : String(error)}`,
189
+ );
190
+ });
191
+ };
192
+ const filterMonitoringIntervalMs = this.configManager.thinqFilterMonitoringIntervalSeconds * 1000;
193
+ this.thinqServices
194
+ .getFilterMonitoringService()
195
+ .startPolling(filterMonitoringIntervalMs, this.filterMonitoringDeviceIds, applyFilterUpdate);
196
+ }
197
+
198
+ this.log.debug(`onConfigure: exit — polling started at ${this.thinqPollingIntervalMs}ms interval`);
199
+ }
200
+
201
+ public override async onShutdown(reason?: string): Promise<void> {
202
+ await super.onShutdown(reason);
203
+ this.log.debug(`onShutdown: entry (reason=${reason ?? 'none'})`);
204
+ this.log.notice('onShutdown called with reason:', reason ?? 'none');
205
+
206
+ this.thinqServices.getDeviceService().stopPolling();
207
+ this.thinqServices.getDeviceService().stopKeepAlive();
208
+ this.thinqServices.getMqttListener().stop();
209
+ this.thinqServices.getFilterMonitoringService().stopPolling();
210
+
211
+ if (this.configManager.unregisterOnShutdown) {
212
+ await this.unregisterAllDevices(UNREGISTER_DEVICES_DELAY_MS);
213
+ }
214
+
215
+ this.state.setStartupCompleted(false);
216
+ this.log.debug('onShutdown: exit');
217
+ }
218
+
219
+ /**
220
+ * Authenticates against LG ThinQ (account or token strategy), discovers AirConditioner devices,
221
+ * and registers each of them with Matterbridge. Ports the sibling plugin's discovery/configure split.
222
+ */
223
+ private async startThinqDevices(): Promise<void> {
224
+ const userDataRepository = this.thinqServices.getUserDataRepository();
225
+ let userData = this.configManager.isForceAuthenticationEnabled
226
+ ? undefined
227
+ : await userDataRepository.loadUserData();
228
+
229
+ if (!userData) {
230
+ userData = await this.thinqServices
231
+ .getAuthenticationCoordinator()
232
+ .authenticate(this.configManager.thinqLoginType, {
233
+ username: this.configManager.thinqUsername,
234
+ password: this.configManager.thinqPassword,
235
+ refreshToken: this.configManager.thinqRefreshToken,
236
+ country: this.configManager.country,
237
+ language: this.configManager.language,
238
+ });
239
+ }
240
+
241
+ if (!userData) {
242
+ throw new ManualProcessNeededError(
243
+ 'LG ThinQ authentication did not return user data. This account likely uses a third-party SSO login ' +
244
+ '(Google/Apple/Facebook/Amazon), which is not yet supported headlessly — please log in with a native ' +
245
+ 'LG account (email/password) or refresh token instead.',
246
+ { reason: 'THIRD_PARTY_SSO_NOT_SUPPORTED' },
247
+ );
248
+ }
249
+
250
+ this.thinqServices.apiClient.setSession(ThinqSession.fromData(userData));
251
+ if (userData.userNumber) {
252
+ this.thinqServices.apiClient.setUserNumber(userData.userNumber);
253
+ }
254
+ await userDataRepository.saveUserData(userData);
255
+
256
+ const devices = await this.thinqServices.getDeviceDiscovery().discoverDevices();
257
+
258
+ const configChanged = reconcileThinqDeviceConfigEntries(this.config.thinq.devices, devices);
259
+ if (configChanged) {
260
+ try {
261
+ this.saveConfig(this.config);
262
+ } catch (error) {
263
+ this.log.error(
264
+ `Failed to persist auto-populated ThinQ device config: ${error instanceof Error ? error.message : String(error)}`,
265
+ );
266
+ }
267
+ }
268
+
269
+ const configurator = this.thinqServices.getDeviceConfigurator();
270
+
271
+ for (const device of devices) {
272
+ if (isAirConditionerDevice(device)) {
273
+ const airConditioner = await configurator.registerAirConditioner(device);
274
+ await this.registerDevice(airConditioner);
275
+ this.registry.register(device.id, airConditioner);
276
+ this.thinqDeviceKindById.set(device.id, 'AC');
277
+ if (this.configManager.getDeviceCapabilities(device.id).supportsFilterMonitoring) {
278
+ this.filterMonitoringDeviceIds.push(device.id);
279
+ }
280
+ continue;
281
+ }
282
+
283
+ if (isWasherDevice(device)) {
284
+ const washer = await configurator.registerWasher(device);
285
+ await this.registerDevice(washer);
286
+ this.registry.register(device.id, washer);
287
+ this.thinqDeviceKindById.set(device.id, 'WASHER');
288
+ continue;
289
+ }
290
+ }
291
+ }
292
+
293
+ public override async onChangeLoggerLevel(logLevel: LogLevel): Promise<void> {
294
+ this.log.notice(`Change ${PLUGIN_NAME} log level: ${logLevel} (was ${this.log.logLevel})`);
295
+ this.log.logLevel = logLevel;
296
+ }
297
+ // #endregion Lifecycle
298
+ }