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,62 @@
1
+ export interface WasherStopCommandPayload {
2
+ readonly command?: string;
3
+ readonly dataKey?: string;
4
+ readonly dataValue?: unknown;
5
+ readonly dataSetList?: Record<string, unknown>;
6
+ }
7
+
8
+ function isRecord(value: unknown): value is Record<string, unknown> {
9
+ return typeof value === 'object' && value !== null;
10
+ }
11
+
12
+ function toWasherStopCommandPayload(entry: Record<string, unknown>): WasherStopCommandPayload {
13
+ const payload: {
14
+ command?: string;
15
+ dataKey?: string;
16
+ dataValue?: unknown;
17
+ dataSetList?: Record<string, unknown>;
18
+ } = {};
19
+
20
+ if (typeof entry.command === 'string') {
21
+ payload.command = entry.command;
22
+ }
23
+ if (typeof entry.dataKey === 'string') {
24
+ payload.dataKey = entry.dataKey;
25
+ }
26
+ if ('dataValue' in entry) {
27
+ payload.dataValue = entry.dataValue;
28
+ }
29
+ if (isRecord(entry.dataSetList)) {
30
+ payload.dataSetList = entry.dataSetList;
31
+ } else if (isRecord(entry.data)) {
32
+ payload.dataSetList = entry.data;
33
+ }
34
+
35
+ return payload;
36
+ }
37
+
38
+ /**
39
+ * Extracts a real `WMStop`/`WMOff` command payload from a device's own downloaded model JSON
40
+ * (`ControlWifi` slice). Pure, no I/O — returns `undefined` on any missing/malformed shape instead of
41
+ * throwing, so callers can treat "no usable command" as a normal (fail-closed) outcome.
42
+ */
43
+ export function extractWasherStopCommand(deviceModel: Record<string, unknown>): WasherStopCommandPayload | undefined {
44
+ if (!isRecord(deviceModel)) {
45
+ return undefined;
46
+ }
47
+
48
+ const controlWifi = deviceModel.ControlWifi;
49
+ if (!isRecord(controlWifi)) {
50
+ return undefined;
51
+ }
52
+
53
+ const stopEntry = isRecord(controlWifi.WMStop) ? controlWifi.WMStop : undefined;
54
+ const offEntry = isRecord(controlWifi.WMOff) ? controlWifi.WMOff : undefined;
55
+ const entry = stopEntry ?? offEntry;
56
+
57
+ if (!entry) {
58
+ return undefined;
59
+ }
60
+
61
+ return toWasherStopCommandPayload(entry);
62
+ }
@@ -0,0 +1,358 @@
1
+ import axios from 'axios';
2
+ import MockAdapter from 'axios-mock-adapter';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { AuthenticationError, InvalidCredentialsError } from '../../errors/index.js';
6
+ import { asPartial, buildGatewayResponse, createMockLogger } from '../../tests/helpers/testUtils.js';
7
+ import type { ThinqApiClient } from '../thinq/thinqApiClient.js';
8
+ import { AccountAuthStrategy } from './AccountAuthStrategy.js';
9
+ import type { ThinqAuthContext } from './AuthContext.js';
10
+
11
+ describe('AccountAuthStrategy', () => {
12
+ let mockAxios: MockAdapter;
13
+ let mockApiClient: ThinqApiClient;
14
+ let mockLogger: ReturnType<typeof createMockLogger>;
15
+ let strategy: AccountAuthStrategy;
16
+
17
+ const testContext: ThinqAuthContext = {
18
+ username: 'test@example.com',
19
+ password: 'testpassword123',
20
+ country: 'US',
21
+ language: 'en',
22
+ };
23
+
24
+ beforeEach(() => {
25
+ vi.clearAllMocks();
26
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
+ mockAxios = new MockAdapter(axios as any);
28
+ mockLogger = createMockLogger();
29
+ mockApiClient = asPartial<ThinqApiClient>({
30
+ getGateway: vi.fn(),
31
+ });
32
+ strategy = new AccountAuthStrategy(mockApiClient, mockLogger);
33
+ });
34
+
35
+ afterEach(() => {
36
+ mockAxios.reset();
37
+ vi.clearAllMocks();
38
+ });
39
+
40
+ describe('authenticate', () => {
41
+ it('should throw InvalidCredentialsError when username is missing', async () => {
42
+ const contextWithoutUsername = asPartial<ThinqAuthContext>({
43
+ password: 'password',
44
+ country: 'US',
45
+ language: 'en',
46
+ });
47
+
48
+ await expect(strategy.authenticate(contextWithoutUsername)).rejects.toThrow(InvalidCredentialsError);
49
+ });
50
+
51
+ it('should throw InvalidCredentialsError when password is missing', async () => {
52
+ const contextWithoutPassword = asPartial<ThinqAuthContext>({
53
+ username: 'test@example.com',
54
+ country: 'US',
55
+ language: 'en',
56
+ });
57
+
58
+ await expect(strategy.authenticate(contextWithoutPassword)).rejects.toThrow(InvalidCredentialsError);
59
+ });
60
+
61
+ it('should throw InvalidCredentialsError when both username and password are missing', async () => {
62
+ const emptyContext = asPartial<ThinqAuthContext>({
63
+ country: 'US',
64
+ language: 'en',
65
+ });
66
+
67
+ await expect(strategy.authenticate(emptyContext)).rejects.toThrow(InvalidCredentialsError);
68
+ });
69
+
70
+ it('should throw InvalidCredentialsError on preLogin 4xx response', async () => {
71
+ const gatewayData = buildGatewayResponse();
72
+ vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
73
+
74
+ mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(401);
75
+
76
+ await expect(strategy.authenticate(testContext)).rejects.toThrow(InvalidCredentialsError);
77
+ });
78
+
79
+ it('should throw AuthenticationError on preLogin non-4xx failure', async () => {
80
+ const gatewayData = buildGatewayResponse();
81
+ vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
82
+
83
+ mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(500);
84
+
85
+ await expect(strategy.authenticate(testContext)).rejects.toThrow(AuthenticationError);
86
+ });
87
+
88
+ it('should throw InvalidCredentialsError on account/session 4xx response', async () => {
89
+ const gatewayData = buildGatewayResponse();
90
+ vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
91
+
92
+ const preLoginResponse = {
93
+ signature: 'test-signature',
94
+ tStamp: '1234567890',
95
+ encrypted_pw: 'encrypted-password',
96
+ };
97
+
98
+ mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(200, preLoginResponse);
99
+ mockAxios
100
+ .onPost(
101
+ new RegExp(`${gatewayData.empTermsUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/emp/v2.0/account/session/.*`),
102
+ )
103
+ .reply(403);
104
+
105
+ await expect(strategy.authenticate(testContext)).rejects.toThrow(InvalidCredentialsError);
106
+ });
107
+
108
+ it('should throw AuthenticationError on account/session non-4xx failure', async () => {
109
+ const gatewayData = buildGatewayResponse();
110
+ vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
111
+
112
+ const preLoginResponse = {
113
+ signature: 'test-signature',
114
+ tStamp: '1234567890',
115
+ encrypted_pw: 'encrypted-password',
116
+ };
117
+
118
+ mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(200, preLoginResponse);
119
+ mockAxios
120
+ .onPost(
121
+ new RegExp(`${gatewayData.empTermsUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/emp/v2.0/account/session/.*`),
122
+ )
123
+ .reply(500);
124
+
125
+ await expect(strategy.authenticate(testContext)).rejects.toThrow(AuthenticationError);
126
+ });
127
+
128
+ it('should throw AuthenticationError on OAuth key lookup failure', async () => {
129
+ const gatewayData = buildGatewayResponse();
130
+ vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
131
+
132
+ const preLoginResponse = {
133
+ signature: 'test-signature',
134
+ tStamp: '1234567890',
135
+ encrypted_pw: 'encrypted-password',
136
+ };
137
+
138
+ const accountResponse = {
139
+ account: {
140
+ userIDType: 'LGE',
141
+ country: 'US',
142
+ userID: 'test@example.com',
143
+ loginSessionID: 'session-id',
144
+ },
145
+ };
146
+
147
+ mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(200, preLoginResponse);
148
+ mockAxios
149
+ .onPost(
150
+ new RegExp(`${gatewayData.empTermsUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/emp/v2.0/account/session/.*`),
151
+ )
152
+ .reply(200, accountResponse);
153
+ mockAxios.onGet(`${gatewayData.empSpxUri}/searchKey?key_name=OAUTH_SECRETKEY&sever_type=OP`).reply(500);
154
+
155
+ await expect(strategy.authenticate(testContext)).rejects.toThrow(AuthenticationError);
156
+ });
157
+
158
+ it('should throw AuthenticationError on EMP authorization failure when status is not 1', async () => {
159
+ const gatewayData = buildGatewayResponse();
160
+ vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
161
+
162
+ const preLoginResponse = {
163
+ signature: 'test-signature',
164
+ tStamp: '1234567890',
165
+ encrypted_pw: 'encrypted-password',
166
+ };
167
+
168
+ const accountResponse = {
169
+ account: {
170
+ userIDType: 'LGE',
171
+ country: 'US',
172
+ userID: 'test@example.com',
173
+ loginSessionID: 'session-id',
174
+ },
175
+ };
176
+
177
+ const secretKeyResponse = {
178
+ returnData: 'secret-key-data',
179
+ };
180
+
181
+ mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(200, preLoginResponse);
182
+ mockAxios
183
+ .onPost(
184
+ new RegExp(`${gatewayData.empTermsUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/emp/v2.0/account/session/.*`),
185
+ )
186
+ .reply(200, accountResponse);
187
+ mockAxios
188
+ .onGet(`${gatewayData.empSpxUri}/searchKey?key_name=OAUTH_SECRETKEY&sever_type=OP`)
189
+ .reply(200, secretKeyResponse);
190
+ mockAxios
191
+ .onGet(new RegExp('https://emp-oauth.lgecloud.com/emp/oauth2/authorize/empsession.*'))
192
+ .reply(200, { status: 0, message: 'Auth failed', redirect_uri: '' });
193
+
194
+ await expect(strategy.authenticate(testContext)).rejects.toThrow(AuthenticationError);
195
+ });
196
+
197
+ it('should throw AuthenticationError on EMP authorization network failure', async () => {
198
+ const gatewayData = buildGatewayResponse();
199
+ vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
200
+
201
+ const preLoginResponse = {
202
+ signature: 'test-signature',
203
+ tStamp: '1234567890',
204
+ encrypted_pw: 'encrypted-password',
205
+ };
206
+
207
+ const accountResponse = {
208
+ account: {
209
+ userIDType: 'LGE',
210
+ country: 'US',
211
+ userID: 'test@example.com',
212
+ loginSessionID: 'session-id',
213
+ },
214
+ };
215
+
216
+ const secretKeyResponse = {
217
+ returnData: 'secret-key-data',
218
+ };
219
+
220
+ mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(200, preLoginResponse);
221
+ mockAxios
222
+ .onPost(
223
+ new RegExp(`${gatewayData.empTermsUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/emp/v2.0/account/session/.*`),
224
+ )
225
+ .reply(200, accountResponse);
226
+ mockAxios
227
+ .onGet(`${gatewayData.empSpxUri}/searchKey?key_name=OAUTH_SECRETKEY&sever_type=OP`)
228
+ .reply(200, secretKeyResponse);
229
+ mockAxios.onGet(new RegExp('https://emp-oauth.lgecloud.com/emp/oauth2/authorize/empsession.*')).reply(500);
230
+
231
+ await expect(strategy.authenticate(testContext)).rejects.toThrow(AuthenticationError);
232
+ });
233
+
234
+ it('should throw AuthenticationError on token exchange failure', async () => {
235
+ const gatewayData = buildGatewayResponse();
236
+ vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
237
+
238
+ const preLoginResponse = {
239
+ signature: 'test-signature',
240
+ tStamp: '1234567890',
241
+ encrypted_pw: 'encrypted-password',
242
+ };
243
+
244
+ const accountResponse = {
245
+ account: {
246
+ userIDType: 'LGE',
247
+ country: 'US',
248
+ userID: 'test@example.com',
249
+ loginSessionID: 'session-id',
250
+ },
251
+ };
252
+
253
+ const secretKeyResponse = {
254
+ returnData: 'secret-key-data',
255
+ };
256
+
257
+ const empResponse = {
258
+ status: 1,
259
+ redirect_uri: 'lgaccount.lgsmartthinq:/?code=test-code&oauth2_backend_url=https://test.com/',
260
+ };
261
+
262
+ mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(200, preLoginResponse);
263
+ mockAxios
264
+ .onPost(
265
+ new RegExp(`${gatewayData.empTermsUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/emp/v2.0/account/session/.*`),
266
+ )
267
+ .reply(200, accountResponse);
268
+ mockAxios
269
+ .onGet(`${gatewayData.empSpxUri}/searchKey?key_name=OAUTH_SECRETKEY&sever_type=OP`)
270
+ .reply(200, secretKeyResponse);
271
+ mockAxios
272
+ .onGet(new RegExp('https://emp-oauth.lgecloud.com/emp/oauth2/authorize/empsession.*'))
273
+ .reply(200, empResponse);
274
+ mockAxios.onPost(new RegExp('https://test.com/oauth/1.0/oauth2/token')).reply(500);
275
+
276
+ await expect(strategy.authenticate(testContext)).rejects.toThrow(AuthenticationError);
277
+ });
278
+
279
+ it('should return ThinqUserData on successful authentication', async () => {
280
+ const gatewayData = buildGatewayResponse();
281
+ vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
282
+
283
+ const preLoginResponse = {
284
+ signature: 'test-signature',
285
+ tStamp: '1234567890',
286
+ encrypted_pw: 'encrypted-password',
287
+ };
288
+
289
+ const accountResponse = {
290
+ account: {
291
+ userIDType: 'LGE',
292
+ country: 'US',
293
+ userID: 'test@example.com',
294
+ loginSessionID: 'session-id',
295
+ },
296
+ };
297
+
298
+ const secretKeyResponse = {
299
+ returnData: 'secret-key-data',
300
+ };
301
+
302
+ const empResponse = {
303
+ status: 1,
304
+ redirect_uri: 'lgaccount.lgsmartthinq:/?code=test-code&oauth2_backend_url=https://test.com/',
305
+ };
306
+
307
+ const tokenResponse = {
308
+ access_token: 'access-token-123',
309
+ refresh_token: 'refresh-token-123',
310
+ expires_in: '3600',
311
+ };
312
+
313
+ mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(200, preLoginResponse);
314
+ mockAxios
315
+ .onPost(
316
+ new RegExp(`${gatewayData.empTermsUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/emp/v2.0/account/session/.*`),
317
+ )
318
+ .reply(200, accountResponse);
319
+ mockAxios
320
+ .onGet(`${gatewayData.empSpxUri}/searchKey?key_name=OAUTH_SECRETKEY&sever_type=OP`)
321
+ .reply(200, secretKeyResponse);
322
+ mockAxios
323
+ .onGet(new RegExp('https://emp-oauth.lgecloud.com/emp/oauth2/authorize/empsession.*'))
324
+ .reply(200, empResponse);
325
+ mockAxios.onPost(new RegExp('https://test.com/oauth/1.0/oauth2/token')).reply(200, tokenResponse);
326
+
327
+ const result = await strategy.authenticate(testContext);
328
+
329
+ expect(result).toBeDefined();
330
+ expect(result?.accessToken).toBe('access-token-123');
331
+ expect(result?.refreshToken).toBe('refresh-token-123');
332
+ expect(result?.country).toBe('US');
333
+ expect(result?.language).toBe('en');
334
+ expect(result?.expiresAtEpochSeconds).toBeGreaterThan(0);
335
+ });
336
+
337
+ it('should log authentication start message', async () => {
338
+ const gatewayData = buildGatewayResponse();
339
+ vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
340
+
341
+ const preLoginResponse = {
342
+ signature: 'test-signature',
343
+ tStamp: '1234567890',
344
+ encrypted_pw: 'encrypted-password',
345
+ };
346
+
347
+ mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(200, preLoginResponse);
348
+ mockAxios
349
+ .onPost(
350
+ new RegExp(`${gatewayData.empTermsUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/emp/v2.0/account/session/.*`),
351
+ )
352
+ .reply(401);
353
+
354
+ await expect(strategy.authenticate(testContext)).rejects.toThrow();
355
+ expect(mockLogger.notice).toHaveBeenCalledWith('Authenticating with LG ThinQ account credentials...');
356
+ });
357
+ });
358
+ });
@@ -0,0 +1,209 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ import axios from 'axios';
4
+ import { AnsiLogger } from 'matterbridge/logger';
5
+
6
+ import { AuthenticationError, InvalidCredentialsError } from '../../errors/index.js';
7
+ import {
8
+ buildEmpDefaultHeaders,
9
+ formatRfc2822Utc,
10
+ Gateway,
11
+ signThinqMessage,
12
+ THINQ_CLIENT_ID,
13
+ THINQ_OAUTH_CLIENT_KEY,
14
+ THINQ_OAUTH_SECRET_KEY,
15
+ } from '../thinq/gateway.js';
16
+ import { ThinqSession } from '../thinq/session.js';
17
+ import { ThinqApiClient } from '../thinq/thinqApiClient.js';
18
+ import type { ThinqAuthContext, ThinqUserData } from './AuthContext.js';
19
+ import type { IAuthStrategy } from './IAuthStrategy.js';
20
+
21
+ interface PreLoginResponse {
22
+ signature: string;
23
+ tStamp: string;
24
+ encrypted_pw: string;
25
+ }
26
+
27
+ interface AccountSessionResponse {
28
+ userIDType: string;
29
+ country: string;
30
+ userID: string;
31
+ loginSessionID: string;
32
+ }
33
+
34
+ interface EmpAuthorizeResponse {
35
+ status: number;
36
+ message?: string;
37
+ redirect_uri: string;
38
+ }
39
+
40
+ interface TokenResponse {
41
+ access_token: string;
42
+ refresh_token: string;
43
+ expires_in: string;
44
+ }
45
+
46
+ const EMP_REDIRECT_URI = 'lgaccount.lgsmartthinq:/';
47
+
48
+ /** True when `err` is an Axios error with a 4xx HTTP status (i.e. a real bad-credentials/rejected-request response). */
49
+ function isClientErrorResponse(err: unknown): boolean {
50
+ if (!axios.isAxiosError(err)) {
51
+ return false;
52
+ }
53
+
54
+ const status = err.response?.status;
55
+
56
+ return status !== undefined && status >= 400 && status < 500;
57
+ }
58
+
59
+ /**
60
+ * Account (username/password) authentication strategy. Ports the 5-call EMP login chain
61
+ * (`Auth.login`→`loginStep2`, `homebridge-lg-thinq/src/lib/Auth.ts:99-229`): SHA-512 password hash,
62
+ * preLogin, EMP session, OAuth secret-key lookup, HMAC-SHA1-signed empsession authorize, then oauth2 token exchange.
63
+ */
64
+ export class AccountAuthStrategy implements IAuthStrategy {
65
+ constructor(
66
+ private readonly apiClient: ThinqApiClient,
67
+ private readonly logger: AnsiLogger,
68
+ ) {}
69
+
70
+ public async authenticate(context: ThinqAuthContext): Promise<ThinqUserData | undefined> {
71
+ if (!context.username || !context.password) {
72
+ throw new InvalidCredentialsError(context.username);
73
+ }
74
+
75
+ this.logger.notice('Authenticating with LG ThinQ account credentials...');
76
+
77
+ const gatewayData = await this.apiClient.getGateway();
78
+ const gateway = new Gateway(gatewayData);
79
+ const hashedPassword = crypto.createHash('sha512').update(context.password).digest('hex');
80
+
81
+ return this.loginStep2(gateway, context.username, hashedPassword, context.country, context.language);
82
+ }
83
+
84
+ private async loginStep2(
85
+ gateway: Gateway,
86
+ username: string,
87
+ encryptedPassword: string,
88
+ country: string,
89
+ language: string,
90
+ ): Promise<ThinqUserData> {
91
+ const headers: Record<string, string> = { ...buildEmpDefaultHeaders(gateway) };
92
+
93
+ const preLoginData = new URLSearchParams({
94
+ user_auth2: encryptedPassword,
95
+ log_param: `login request / user_id : ${username} / third_party : null / svc_list : SVC202,SVC710 / 3rd_service : `,
96
+ });
97
+
98
+ const preLogin = await axios
99
+ .post<PreLoginResponse>(`${gateway.loginBaseUrl}preLogin`, preLoginData.toString(), { headers })
100
+ .then((res) => res.data)
101
+ .catch((err: unknown) => {
102
+ if (isClientErrorResponse(err)) {
103
+ throw new InvalidCredentialsError(username);
104
+ }
105
+
106
+ throw new AuthenticationError('LG pre-login failed.', { cause: err });
107
+ });
108
+
109
+ headers['X-Signature'] = preLogin.signature;
110
+ headers['X-Timestamp'] = preLogin.tStamp;
111
+
112
+ const sessionData = new URLSearchParams({
113
+ user_auth2: preLogin.encrypted_pw,
114
+ password_hash_prameter_flag: 'Y',
115
+ svc_list: 'SVC202,SVC710',
116
+ });
117
+
118
+ const loginUrl = `${gateway.empBaseUrl}emp/v2.0/account/session/${encodeURIComponent(username)}`;
119
+ const account = await axios
120
+ .post<{ account: AccountSessionResponse }>(loginUrl, sessionData.toString(), { headers })
121
+ .then((res) => res.data.account)
122
+ .catch((err: unknown) => {
123
+ if (isClientErrorResponse(err)) {
124
+ throw new InvalidCredentialsError(username);
125
+ }
126
+
127
+ throw new AuthenticationError('LG account login failed.', { cause: err });
128
+ });
129
+
130
+ const secretKeyUrl = `${gateway.loginBaseUrl}searchKey?key_name=OAUTH_SECRETKEY&sever_type=OP`;
131
+ const secretKey = await axios
132
+ .get<{ returnData: string }>(secretKeyUrl)
133
+ .then((res) => res.data.returnData)
134
+ .catch((err: unknown) => {
135
+ throw new AuthenticationError('LG OAuth key lookup failed.', { cause: err });
136
+ });
137
+
138
+ const timestamp = formatRfc2822Utc(new Date());
139
+ const empParams = new URLSearchParams({
140
+ account_type: account.userIDType,
141
+ client_id: THINQ_CLIENT_ID,
142
+ country_code: account.country,
143
+ redirect_uri: EMP_REDIRECT_URI,
144
+ response_type: 'code',
145
+ state: '12345',
146
+ username: account.userID,
147
+ });
148
+ const empUrl = new URL(`https://emp-oauth.lgecloud.com/emp/oauth2/authorize/empsession?${empParams.toString()}`);
149
+ const empSignature = signThinqMessage(`${empUrl.pathname}${empUrl.search}\n${timestamp}`, secretKey);
150
+
151
+ const authorize = await axios
152
+ .get<EmpAuthorizeResponse>(empUrl.href, {
153
+ headers: {
154
+ 'lgemp-x-app-key': THINQ_OAUTH_CLIENT_KEY,
155
+ 'lgemp-x-date': timestamp,
156
+ 'lgemp-x-session-key': account.loginSessionID,
157
+ 'lgemp-x-signature': empSignature,
158
+ Accept: 'application/json',
159
+ 'X-Device-Type': 'M01',
160
+ 'X-Device-Platform': 'ADR',
161
+ 'Content-Type': 'application/x-www-form-urlencoded',
162
+ },
163
+ })
164
+ .then((res) => res.data)
165
+ .catch((err: unknown) => {
166
+ throw new AuthenticationError('LG OAuth authorization failed.', { cause: err });
167
+ });
168
+
169
+ if (authorize.status !== 1) {
170
+ throw new AuthenticationError(authorize.message ?? 'LG OAuth authorization was rejected.');
171
+ }
172
+
173
+ const redirectUri = new URL(authorize.redirect_uri);
174
+ const code = redirectUri.searchParams.get('code') ?? '';
175
+ const oauth2BackendUrl = redirectUri.searchParams.get('oauth2_backend_url') ?? gateway.loginBaseUrl;
176
+
177
+ const tokenData = new URLSearchParams({
178
+ code,
179
+ grant_type: 'authorization_code',
180
+ redirect_uri: EMP_REDIRECT_URI,
181
+ });
182
+ const requestPath = `/oauth/1.0/oauth2/token?${tokenData.toString()}`;
183
+ const tokenTimestamp = formatRfc2822Utc(new Date());
184
+
185
+ const token = await axios
186
+ .post<TokenResponse>(`${oauth2BackendUrl}oauth/1.0/oauth2/token`, tokenData.toString(), {
187
+ headers: {
188
+ 'x-lge-app-os': 'ADR',
189
+ 'x-lge-appkey': THINQ_CLIENT_ID,
190
+ 'x-lge-oauth-signature': signThinqMessage(`${requestPath}\n${tokenTimestamp}`, THINQ_OAUTH_SECRET_KEY),
191
+ 'x-lge-oauth-date': tokenTimestamp,
192
+ Accept: 'application/json',
193
+ 'Content-Type': 'application/x-www-form-urlencoded',
194
+ },
195
+ })
196
+ .then((res) => res.data)
197
+ .catch((err: unknown) => {
198
+ throw new AuthenticationError('LG token exchange failed.', { cause: err });
199
+ });
200
+
201
+ return {
202
+ accessToken: token.access_token,
203
+ refreshToken: token.refresh_token,
204
+ expiresAtEpochSeconds: ThinqSession.expiryFromExpiresIn(Number.parseInt(token.expires_in, 10)),
205
+ country,
206
+ language,
207
+ };
208
+ }
209
+ }
@@ -0,0 +1,19 @@
1
+ /** Context object passed to ThinQ authentication strategies. */
2
+ export interface ThinqAuthContext {
3
+ readonly username?: string;
4
+ readonly password?: string;
5
+ readonly refreshToken?: string;
6
+ readonly country: string;
7
+ readonly language: string;
8
+ }
9
+
10
+ /** Result of a successful ThinQ authentication, persisted across restarts. */
11
+ export interface ThinqUserData {
12
+ accessToken: string;
13
+ refreshToken: string;
14
+ expiresAtEpochSeconds: number;
15
+ country: string;
16
+ language: string;
17
+ /** LG's numeric account profile ID (`userNo`), fetched post-login and required for `x-user-no`/`x-client-id`. */
18
+ userNumber?: string;
19
+ }