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,418 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ import axios from 'axios';
4
+ import { AnsiLogger } from 'matterbridge/logger';
5
+
6
+ import { AuthenticationError, TokenExpiredError } from '../../errors/index.js';
7
+ import { maskSensitiveFields } from '../../utils/mask.js';
8
+ import {
9
+ formatRfc2822Utc,
10
+ Gateway,
11
+ GatewayData,
12
+ signThinqMessage,
13
+ THINQ_APPLICATION_KEY,
14
+ THINQ_CLIENT_ID,
15
+ THINQ_OAUTH_SECRET_KEY,
16
+ } from './gateway.js';
17
+ import { ThinqSession } from './session.js';
18
+
19
+ const GATEWAY_URL = 'https://route.lgthinq.com:46030/v1/service/application/gateway-uri';
20
+ export const MQTT_ROUTE_URL = 'https://common.lgthinq.com/route';
21
+ const API_KEY = 'VGhpblEyLjAgU0VSVklDRQ==';
22
+ const API_CLIENT_ID = 'c713ea8e50f657534ff8b9d373dfebfc2ed70b88285c26b8ade49868c0b164d9';
23
+ /**
24
+ * LG's own signal that the ThinQ access token has expired, returned as HTTP 400 with this `resultCode`
25
+ * on some endpoints (e.g. `control-sync`) instead of a true 401. Mirrors `homebridge-lg-thinq`'s
26
+ * `TokenExpiredErrorCode` (`errors/TokenExpiredError.ts:1`).
27
+ */
28
+ const THINQ_TOKEN_EXPIRED_RESULT_CODE = '0102';
29
+
30
+ export interface ThinqHome {
31
+ homeId: string;
32
+ [key: string]: unknown;
33
+ }
34
+
35
+ export interface ThinqDeviceData {
36
+ deviceId: string;
37
+ alias: string;
38
+ modelJsonUri: string;
39
+ deviceType: number;
40
+ modelName?: string;
41
+ manufacture?: {
42
+ macAddress?: string;
43
+ salesModel?: string;
44
+ serialNo?: string;
45
+ manufactureModel?: string;
46
+ };
47
+ modemInfo?: {
48
+ appVersion?: string;
49
+ modelName?: string;
50
+ };
51
+ snapshot: { online?: boolean } & Record<string, unknown>;
52
+ platformType?: string;
53
+ online?: boolean;
54
+ }
55
+
56
+ export interface ThinqCommandPayload {
57
+ dataKey?: string | null;
58
+ dataValue?: unknown;
59
+ dataSetList?: Record<string, unknown>;
60
+ /** Field list for `Get` commands, e.g. `filterMngStateCtrl`'s `dataGetList`. */
61
+ dataGetList?: string[];
62
+ /** Overrides `sendCommand`'s default `'Set'`, e.g. `'Operation'` for AC power (homebridge-lg-thinq parity). */
63
+ command?: string;
64
+ /** Overrides `sendCommand`'s default `'basicCtrl'`, e.g. `'favoriteCtrl'` for swing mode compound writes (homebridge-lg-thinq parity). */
65
+ ctrlKey?: string;
66
+ }
67
+
68
+ function randomMessageId(length = 22): string {
69
+ const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
70
+ let result = '';
71
+ for (let i = 0; i < length; i++) {
72
+ result += characters.charAt(Math.floor(Math.random() * characters.length));
73
+ }
74
+ return result;
75
+ }
76
+
77
+ /**
78
+ * REST client for the LG ThinQ2 cloud API. Ports `API.ts` (`homebridge-lg-thinq`) — gateway discovery,
79
+ * home/device listing, device control, and reactive token refresh on 401.
80
+ */
81
+ export class ThinqApiClient {
82
+ private gateway: Gateway | undefined;
83
+ private homesCache: ThinqHome[] | undefined;
84
+ private userNumber: string | undefined;
85
+ private clientId: string | undefined;
86
+
87
+ constructor(
88
+ private session: ThinqSession,
89
+ private readonly country: string,
90
+ private readonly language: string,
91
+ private readonly logger: AnsiLogger,
92
+ ) {}
93
+
94
+ /** Replaces the active session, e.g. once authentication completes. */
95
+ public setSession(session: ThinqSession): void {
96
+ this.session = session;
97
+ }
98
+
99
+ /**
100
+ * Records the user's numeric profile ID (`userNo`, from `getUserNumber`) for this client instance and
101
+ * computes a fresh per-session `x-client-id` from it (`sha256(userNumber + Date.now())`), mirroring
102
+ * `API.ready()` (`API.ts:392-399`). Must be called once after every successful authentication before any
103
+ * ThinQ2 listing call (`service/homes`, etc.), otherwise those calls 400.
104
+ */
105
+ public setUserNumber(userNumber: string): void {
106
+ this.userNumber = userNumber;
107
+ this.clientId = crypto.createHash('sha256').update(`${userNumber}${Date.now()}`).digest('hex');
108
+ }
109
+
110
+ public async getGateway(): Promise<GatewayData> {
111
+ if (!this.gateway) {
112
+ const headers = this.defaultHeaders;
113
+ this.logger.debug(
114
+ `ThinQ getGateway request -> GET ${GATEWAY_URL} headers=${JSON.stringify(maskSensitiveFields(headers))}`,
115
+ );
116
+
117
+ const response = await axios.get<{ result: GatewayData }>(GATEWAY_URL, { headers });
118
+ this.gateway = new Gateway(response.data.result);
119
+ }
120
+
121
+ return this.gateway.data;
122
+ }
123
+
124
+ /**
125
+ * Fetches a device's model JSON from its own CDN `modelJsonUri` (an absolute URL, not gateway-relative),
126
+ * so this bypasses the private `request()` helper entirely — direct unauthenticated `axios.get`, mirroring
127
+ * how both reference implementations fetch model JSON.
128
+ */
129
+ public async getDeviceModel(modelJsonUri: string): Promise<Record<string, unknown>> {
130
+ this.logger.debug(`ThinQ getDeviceModel request -> GET ${modelJsonUri}`);
131
+ const response = await axios.get<Record<string, unknown>>(modelJsonUri);
132
+ return response.data;
133
+ }
134
+
135
+ public async getListHomes(): Promise<ThinqHome[]> {
136
+ if (!this.homesCache) {
137
+ const data = await this.request<{ result: { item: ThinqHome[] } }>('get', 'service/homes');
138
+ this.homesCache = data.result?.item ?? [];
139
+ }
140
+
141
+ return this.homesCache;
142
+ }
143
+
144
+ public async getListDevices(): Promise<ThinqDeviceData[]> {
145
+ const homes = await this.getListHomes();
146
+ const devices: ThinqDeviceData[] = [];
147
+
148
+ for (const home of homes) {
149
+ const data = await this.request<{ result: { devices: ThinqDeviceData[] } }>(
150
+ 'get',
151
+ `service/homes/${home.homeId}`,
152
+ );
153
+ devices.push(...(data.result?.devices ?? []));
154
+ }
155
+
156
+ return devices;
157
+ }
158
+
159
+ public async sendCommand(deviceId: string, payload: ThinqCommandPayload): Promise<void> {
160
+ if (!deviceId.trim()) {
161
+ throw new Error('Invalid deviceId: must be a non-empty string.');
162
+ }
163
+
164
+ await this.request('post', `service/devices/${deviceId}/control-sync`, {
165
+ ctrlKey: 'basicCtrl',
166
+ command: 'Set',
167
+ ...payload,
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Same wire shape as `sendCommand`, but returns the raw response body instead of discarding it. Used both
173
+ * for diagnostic probes (e.g. `--probe-filter`) and real production polling (e.g. `getFilterState`) that
174
+ * need to inspect what the cloud actually returns for a given `Get` command, rather than fire-and-forget
175
+ * `Set` commands.
176
+ */
177
+ public async sendCommandAndGetResponse<T = unknown>(deviceId: string, payload: ThinqCommandPayload): Promise<T> {
178
+ if (!deviceId.trim()) {
179
+ throw new Error('Invalid deviceId: must be a non-empty string.');
180
+ }
181
+
182
+ return this.request<T>('post', `service/devices/${deviceId}/control-sync`, {
183
+ ctrlKey: 'basicCtrl',
184
+ command: 'Set',
185
+ ...payload,
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Fetches the filter-life state for an AC device (`filterMngStateCtrl` `Get`). Returns
191
+ * `undefined` if the response contains no data (e.g. devices with no filter sensor).
192
+ */
193
+ public async getFilterState(deviceId: string): Promise<Record<string, unknown> | undefined> {
194
+ if (!deviceId.trim()) {
195
+ throw new Error('Invalid deviceId: must be a non-empty string.');
196
+ }
197
+
198
+ const response = await this.sendCommandAndGetResponse<{ result?: { data?: Record<string, unknown> } }>(deviceId, {
199
+ ctrlKey: 'filterMngStateCtrl',
200
+ command: 'Get',
201
+ dataGetList: [
202
+ 'airState.filterMngState.useTime',
203
+ 'airState.filterMngState.remainTime',
204
+ 'airState.filterMngState.maxTime',
205
+ 'airState.filterMngState.changeDate',
206
+ 'airState.filterMngState.type',
207
+ ],
208
+ });
209
+
210
+ return response.result?.data;
211
+ }
212
+
213
+ /**
214
+ * Sends a keep-alive command to an AC device to maintain MQTT push updates from LG's cloud.
215
+ * POSTs to `service/devices/{id}/control` (not `control-sync`) with `ctrlKey: 'allEventEnable'`
216
+ * to refresh the 70-second MQTT event window.
217
+ */
218
+ public async sendKeepAlive(deviceId: string): Promise<void> {
219
+ if (!deviceId.trim()) {
220
+ throw new Error('Invalid deviceId: must be a non-empty string.');
221
+ }
222
+
223
+ await this.request('post', `service/devices/${deviceId}/control`, {
224
+ ctrlKey: 'allEventEnable',
225
+ command: 'Set',
226
+ dataKey: 'airState.mon.timeout',
227
+ dataValue: '70',
228
+ });
229
+ }
230
+
231
+ /** Exchanges a refresh token for a new access token (`grant_type=refresh_token`), mutating and returning `session`. */
232
+ public async refreshToken(session: ThinqSession): Promise<ThinqSession> {
233
+ const tokenUrl = `https://${this.country.toLowerCase()}.lgeapi.com/oauth/1.0/oauth2/token`;
234
+ const data = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: session.refreshToken });
235
+ const timestamp = formatRfc2822Utc(new Date());
236
+ const requestUrl = `/oauth/1.0/oauth2/token?${data.toString()}`;
237
+ const signature = signThinqMessage(`${requestUrl}\n${timestamp}`, THINQ_OAUTH_SECRET_KEY);
238
+ const headers = {
239
+ 'x-lge-app-os': 'ADR',
240
+ 'x-lge-appkey': THINQ_CLIENT_ID,
241
+ 'x-lge-oauth-signature': signature,
242
+ 'x-lge-oauth-date': timestamp,
243
+ Accept: 'application/json',
244
+ 'Content-Type': 'application/x-www-form-urlencoded',
245
+ };
246
+
247
+ this.logger.debug(
248
+ `ThinQ refreshToken request -> POST ${tokenUrl} headers=${JSON.stringify(maskSensitiveFields(headers))} body=${JSON.stringify(maskSensitiveFields(Object.fromEntries(data.entries())))}`,
249
+ );
250
+
251
+ try {
252
+ const response = await axios.post<{ access_token: string; expires_in: string }>(tokenUrl, data.toString(), {
253
+ headers,
254
+ });
255
+
256
+ session.updateAccessToken(
257
+ response.data.access_token,
258
+ ThinqSession.expiryFromExpiresIn(Number.parseInt(response.data.expires_in, 10)),
259
+ );
260
+ this.session = session;
261
+ return session;
262
+ } catch (error) {
263
+ this.logger.error(`ThinQ token refresh failed: ${error instanceof Error ? error.message : String(error)}`);
264
+ throw new TokenExpiredError();
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Fetches the user's numeric profile ID (`userNo`) required for `x-user-no`/`x-client-id`. Port of
270
+ * `Auth.ts:399-422` (`getUserNumber`) — `GET {lgeapi_url}users/profile` with `Authorization: Bearer`
271
+ * plus the same HMAC signing primitive used by `refreshToken()`. Does not mutate client state; call
272
+ * `setUserNumber()` with the result to activate it on subsequent requests.
273
+ */
274
+ public async getUserNumber(accessToken: string): Promise<string> {
275
+ const profileUrl = `https://${this.country.toLowerCase()}.lgeapi.com/users/profile`;
276
+ const timestamp = formatRfc2822Utc(new Date());
277
+ const signature = signThinqMessage(`/users/profile\n${timestamp}`, THINQ_OAUTH_SECRET_KEY);
278
+ const headers = {
279
+ Accept: 'application/json',
280
+ Authorization: `Bearer ${accessToken}`,
281
+ 'X-Lge-Svccode': 'SVC202',
282
+ 'X-Application-Key': THINQ_APPLICATION_KEY,
283
+ 'lgemp-x-app-key': THINQ_CLIENT_ID,
284
+ 'X-Device-Type': 'M01',
285
+ 'X-Device-Platform': 'ADR',
286
+ 'x-lge-oauth-date': timestamp,
287
+ 'x-lge-oauth-signature': signature,
288
+ };
289
+
290
+ this.logger.debug(
291
+ `ThinQ getUserNumber request -> GET ${profileUrl} headers=${JSON.stringify(maskSensitiveFields(headers))}`,
292
+ );
293
+
294
+ try {
295
+ const response = await axios.get<{ status?: number; message?: string; account: { userNo: string } }>(profileUrl, {
296
+ headers,
297
+ });
298
+
299
+ if (response.data.status === 2) {
300
+ throw new AuthenticationError(response.data.message ?? 'LG user profile lookup failed.');
301
+ }
302
+
303
+ return response.data.account.userNo;
304
+ } catch (error) {
305
+ this.logger.error(`ThinQ getUserNumber failed: ${error instanceof Error ? error.message : String(error)}`);
306
+ throw error;
307
+ }
308
+ }
309
+
310
+ /** Fetches the MQTT broker route (`GET https://common.lgthinq.com/route`), first step of MQTT cert setup. */
311
+ public async getMqttRouteInfo(): Promise<{ mqttServer: string }> {
312
+ const data = await this.request<{ result: { mqttServer: string } }>('get', MQTT_ROUTE_URL);
313
+ return data.result;
314
+ }
315
+
316
+ /** Registers this account as an MQTT client (`POST service/users/client`), required before requesting a certificate. */
317
+ public async registerMqttClient(): Promise<void> {
318
+ await this.request('post', 'service/users/client', {});
319
+ }
320
+
321
+ /** Submits the CSR body and returns the signed client certificate + broker subscription topics. */
322
+ public async requestMqttCertificate(csrBody: string): Promise<{ certificatePem: string; subscriptions: string[] }> {
323
+ const data = await this.request<{ result: { certificatePem: string; subscriptions: string[] } }>(
324
+ 'post',
325
+ 'service/users/client/certificate',
326
+ { csr: csrBody },
327
+ );
328
+ return data.result;
329
+ }
330
+
331
+ /** Returns the per-session client id (`x-client-id`), falling back to the static default like `defaultHeaders`. */
332
+ public getClientId(): string {
333
+ return this.clientId ?? API_CLIENT_ID;
334
+ }
335
+
336
+ private get defaultHeaders(): Record<string, string> {
337
+ const authHeaders: Record<string, string> = {};
338
+ if (this.session.accessToken) {
339
+ authHeaders['x-emp-token'] = this.session.accessToken;
340
+ }
341
+ if (this.userNumber) {
342
+ authHeaders['x-user-no'] = this.userNumber;
343
+ }
344
+ authHeaders['x-client-id'] = this.clientId ?? API_CLIENT_ID;
345
+
346
+ return {
347
+ 'x-api-key': API_KEY,
348
+ 'x-thinq-app-ver': '3.6.1200',
349
+ 'x-thinq-app-type': 'NUTS',
350
+ 'x-thinq-app-level': 'PRD',
351
+ 'x-thinq-app-os': 'ANDROID',
352
+ 'x-thinq-app-logintype': 'LGE',
353
+ 'x-service-code': 'SVC202',
354
+ 'x-country-code': this.country,
355
+ 'x-language-code': this.language,
356
+ 'x-service-phase': 'OP',
357
+ 'x-origin': 'app-native',
358
+ 'x-model-name': 'samsung/SM-G930L',
359
+ 'x-os-version': 'AOS/7.1.2',
360
+ 'x-app-version': 'LG ThinQ/3.6.12110',
361
+ 'x-message-id': randomMessageId(),
362
+ 'user-agent': 'okhttp/3.14.9',
363
+ ...authHeaders,
364
+ };
365
+ }
366
+
367
+ /**
368
+ * True when `error` signals an expired ThinQ access token — either a real HTTP 401, or LG's own
369
+ * `resultCode: '0102'` expiry signal returned as HTTP 400 by some endpoints (e.g. `control-sync`).
370
+ * Mirrors homebridge-lg-thinq's dual check (`request.ts:80-97`).
371
+ */
372
+ private isTokenExpiredError(error: unknown): boolean {
373
+ if (!axios.isAxiosError(error)) {
374
+ return false;
375
+ }
376
+ if (error.response?.status === 401) {
377
+ return true;
378
+ }
379
+ const data = error.response?.data as Record<string, unknown> | undefined;
380
+ return typeof data?.resultCode === 'string' && data.resultCode === THINQ_TOKEN_EXPIRED_RESULT_CODE;
381
+ }
382
+
383
+ private async request<T>(method: 'get' | 'post', uri: string, data?: unknown, retry = false): Promise<T> {
384
+ await this.getGateway();
385
+ const gateway = this.gateway;
386
+ if (!gateway) {
387
+ throw new Error('ThinQ gateway is unavailable.');
388
+ }
389
+
390
+ const url = new URL(uri, gateway.thinq2Url).href;
391
+ const headers = this.defaultHeaders;
392
+ this.logger.debug(
393
+ `ThinQ request -> ${method.toUpperCase()} ${url} headers=${JSON.stringify(maskSensitiveFields(headers))} data=${JSON.stringify(maskSensitiveFields(data as Record<string, unknown> | undefined))}`,
394
+ );
395
+
396
+ try {
397
+ const response = await axios.request<T>({ method, url, data, headers });
398
+ return response.data;
399
+ } catch (error) {
400
+ if (this.isTokenExpiredError(error)) {
401
+ if (retry) {
402
+ throw new TokenExpiredError();
403
+ }
404
+
405
+ await this.refreshToken(this.session);
406
+ return this.request<T>(method, uri, data, true);
407
+ }
408
+
409
+ if (axios.isAxiosError(error)) {
410
+ this.logger.debug(
411
+ `ThinQ request failed <- ${method.toUpperCase()} ${url} status=${error.response?.status} data=${JSON.stringify(error.response?.data)}`,
412
+ );
413
+ }
414
+
415
+ throw error;
416
+ }
417
+ }
418
+ }