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,96 @@
1
+ import { MatterbridgeEndpoint } from 'matterbridge';
2
+ import { AnsiLogger } from 'matterbridge/logger';
3
+
4
+ import type { ThinqAirConditionerDevice } from '../../core/domain/entities/ThinqDevice.js';
5
+ import type { AirConditionerCapabilities } from '../../core/domain/value-objects/AirConditionerCapabilities.js';
6
+ import { ThinqFilterState } from '../../core/domain/value-objects/ThinqFilterState.js';
7
+ import type { ThinqAcFilterControlConfig } from '../../model/LgThinqPluginPlatformConfig.js';
8
+ import type { ThinqApiClient } from '../../services/thinq/thinqApiClient.js';
9
+
10
+ const KEY_USE_TIME = 'airState.filterMngState.useTime';
11
+ const KEY_REMAIN_TIME = 'airState.filterMngState.remainTime';
12
+ const KEY_MAX_TIME = 'airState.filterMngState.maxTime';
13
+ const KEY_CHANGE_DATE = 'airState.filterMngState.changeDate';
14
+
15
+ /**
16
+ * Builds the `filterMngStateCtrl` `Set` payload for a filter-counter reset, scoped strictly to the
17
+ * field names already proven present in this device's own live `Get` response — echoes `maxTime`
18
+ * back unchanged (never mutates the device's rated filter life) and resets `useTime`/`remainTime`/
19
+ * `changeDate` to their reset-state values.
20
+ */
21
+ export function resolveFilterResetPayload(
22
+ filterState: ThinqFilterState,
23
+ now: Date = new Date(),
24
+ ): Record<string, unknown> {
25
+ return {
26
+ [KEY_USE_TIME]: 0,
27
+ [KEY_REMAIN_TIME]: filterState.ratedMaxTimeHours,
28
+ [KEY_MAX_TIME]: filterState.ratedMaxTimeHours,
29
+ [KEY_CHANGE_DATE]: formatChangeDate(now),
30
+ };
31
+ }
32
+
33
+ function formatChangeDate(date: Date): number {
34
+ const year = date.getUTCFullYear();
35
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
36
+ const day = String(date.getUTCDate()).padStart(2, '0');
37
+ return Number(`${year}${month}${day}`);
38
+ }
39
+
40
+ /**
41
+ * Registers the Matter `HepaFilterMonitoring.resetCondition()` command handler. No-ops entirely when
42
+ * `supportsFilterMonitoring` is false (the cluster is never created on the endpoint in that case).
43
+ * Never writes `condition`/`changeIndication`/`lastChangedTime` itself — matterbridge's own
44
+ * `MatterbridgeHepaFilterMonitoringServer.resetCondition()` does that automatically, but only after
45
+ * this handler resolves without throwing.
46
+ */
47
+ export function registerFilterResetCommandHandler(
48
+ airConditioner: MatterbridgeEndpoint,
49
+ device: ThinqAirConditionerDevice,
50
+ apiClient: ThinqApiClient,
51
+ logger: AnsiLogger,
52
+ capabilities: AirConditionerCapabilities,
53
+ filterResetControl: ThinqAcFilterControlConfig,
54
+ ): void {
55
+ if (!capabilities.supportsFilterMonitoring) {
56
+ return;
57
+ }
58
+
59
+ airConditioner.addCommandHandler('resetCondition', async () => {
60
+ if (!filterResetControl.allowFilterReset) {
61
+ logger.warn(`ThinQ AirConditioner filter reset is disabled by configuration (deviceId=${device.id})`);
62
+ throw new Error('ThinQ AirConditioner filter reset is disabled by configuration.');
63
+ }
64
+
65
+ let filterState: ThinqFilterState | undefined;
66
+ try {
67
+ filterState = ThinqFilterState.fromRaw(await apiClient.getFilterState(device.id));
68
+ } catch (error) {
69
+ logger.error(
70
+ `ThinQ AirConditioner filter reset failed to read current filter state: ${error instanceof Error ? error.message : String(error)} (deviceId=${device.id})`,
71
+ );
72
+ throw error;
73
+ }
74
+
75
+ if (!filterState) {
76
+ logger.warn(
77
+ `ThinQ AirConditioner filter reset: no current filter data available, refusing to reset (deviceId=${device.id})`,
78
+ );
79
+ throw new Error('ThinQ AirConditioner filter reset: no current filter data available for this device.');
80
+ }
81
+
82
+ try {
83
+ await apiClient.sendCommand(device.id, {
84
+ ctrlKey: 'filterMngStateCtrl',
85
+ command: 'Set',
86
+ dataSetList: resolveFilterResetPayload(filterState),
87
+ });
88
+ logger.info(`ThinQ AirConditioner filter reset succeeded (deviceId=${device.id})`);
89
+ } catch (error) {
90
+ logger.error(
91
+ `ThinQ AirConditioner filter reset command failed: ${error instanceof Error ? error.message : String(error)} (deviceId=${device.id})`,
92
+ );
93
+ throw error;
94
+ }
95
+ });
96
+ }
@@ -0,0 +1,42 @@
1
+ import { MatterbridgeEndpoint } from 'matterbridge';
2
+ import { AnsiLogger } from 'matterbridge/logger';
3
+ import { HepaFilterMonitoring, ResourceMonitoring } from 'matterbridge/matter/clusters';
4
+
5
+ import type { ThinqFilterState } from '../../core/domain/value-objects/ThinqFilterState.js';
6
+
7
+ const FILTER_WARNING_THRESHOLD_PERCENT = 10;
8
+
9
+ /** 3-way threshold mapping from remaining filter life percent to a Matter `ChangeIndication`. */
10
+ export function resolveFilterChangeIndication(remainingPercent: number): ResourceMonitoring.ChangeIndication {
11
+ if (remainingPercent <= 0) {
12
+ return ResourceMonitoring.ChangeIndication.Critical;
13
+ }
14
+ if (remainingPercent <= FILTER_WARNING_THRESHOLD_PERCENT) {
15
+ return ResourceMonitoring.ChangeIndication.Warning;
16
+ }
17
+ return ResourceMonitoring.ChangeIndication.Ok;
18
+ }
19
+
20
+ /**
21
+ * Pushes filter-life state to the AC endpoint's `HepaFilterMonitoring` cluster. When `filterState` is
22
+ * `undefined` (e.g. the device has no filter sensor), writes nothing and leaves the cluster's
23
+ * construction-time defaults (`condition=100, changeIndication=Ok`) in place.
24
+ */
25
+ export async function applyThinqFilterStateToAirConditioner(
26
+ airConditioner: MatterbridgeEndpoint,
27
+ filterState: ThinqFilterState | undefined,
28
+ logger: AnsiLogger,
29
+ ): Promise<void> {
30
+ if (!filterState) {
31
+ logger.debug('ThinQ filter state: no data, skipping HepaFilterMonitoring update.');
32
+ return;
33
+ }
34
+
35
+ await airConditioner.updateAttribute(HepaFilterMonitoring.id, 'condition', filterState.remainingPercent, logger);
36
+ await airConditioner.updateAttribute(
37
+ HepaFilterMonitoring.id,
38
+ 'changeIndication',
39
+ resolveFilterChangeIndication(filterState.remainingPercent),
40
+ logger,
41
+ );
42
+ }
@@ -0,0 +1,343 @@
1
+ import { MatterbridgeEndpoint } from 'matterbridge';
2
+ import { AnsiLogger } from 'matterbridge/logger';
3
+ import {
4
+ AirQuality,
5
+ ElectricalPowerMeasurement,
6
+ FanControl,
7
+ OnOff,
8
+ Pm10ConcentrationMeasurement,
9
+ Pm25ConcentrationMeasurement,
10
+ RelativeHumidityMeasurement,
11
+ TemperatureMeasurement,
12
+ Thermostat,
13
+ } from 'matterbridge/matter/clusters';
14
+
15
+ import type { AirConditionerCapabilities } from '../../core/domain/value-objects/AirConditionerCapabilities.js';
16
+ import type { ThinqSnapshot } from '../../core/domain/value-objects/ThinqSnapshot.js';
17
+ import {
18
+ THINQ_FAN_SPEED_AUTO,
19
+ THINQ_FAN_SPEED_LOW,
20
+ THINQ_FAN_SPEED_MEDIUM,
21
+ } from './thinqAirConditionerCommandHandlers.js';
22
+ import { mapWindStrengthToFanMode, mapWindStrengthToFixedFanMode } from './thinqDeviceConfigurator.js';
23
+
24
+ /** LG `airState.opMode` values (`homebridge-lg-thinq/src/devices/AirConditioner.ts`). */
25
+ const THINQ_OP_MODE_COOL = 0;
26
+ const THINQ_OP_MODE_DRY = 1;
27
+ const THINQ_OP_MODE_FAN = 2;
28
+ const THINQ_OP_MODE_HEAT = 4;
29
+ const THINQ_OP_MODE_AIR_CLEAN = 5;
30
+ const THINQ_OP_MODE_AUTO = 6;
31
+
32
+ const WIND_STRENGTH_LOW_PERCENT = 20;
33
+ const WIND_STRENGTH_MEDIUM_PERCENT = 50;
34
+ const WIND_STRENGTH_HIGH_PERCENT = 90;
35
+
36
+ const KEY_OPERATION = 'airState.operation';
37
+ const KEY_OP_MODE = 'airState.opMode';
38
+ const KEY_TEMP_CURRENT = 'airState.tempState.current';
39
+ const KEY_TEMP_TARGET = 'airState.tempState.target';
40
+ const KEY_WIND_STRENGTH = 'airState.windStrength';
41
+ const KEY_SWING_VERTICAL = 'airState.wDir.vStep';
42
+ const KEY_SWING_HORIZONTAL = 'airState.wDir.hStep';
43
+
44
+ /** Maps a ThinQ `airState.windStrength` value to a Matter `FanControl.percentCurrent` (Auto has no percent equivalent). */
45
+ export function mapWindStrengthToPercent(windStrength: number | undefined): number | undefined {
46
+ if (windStrength === undefined || windStrength === THINQ_FAN_SPEED_AUTO) {
47
+ return undefined;
48
+ }
49
+ if (windStrength <= THINQ_FAN_SPEED_LOW) {
50
+ return WIND_STRENGTH_LOW_PERCENT;
51
+ }
52
+ if (windStrength <= THINQ_FAN_SPEED_MEDIUM) {
53
+ return WIND_STRENGTH_MEDIUM_PERCENT;
54
+ }
55
+ return WIND_STRENGTH_HIGH_PERCENT;
56
+ }
57
+
58
+ /**
59
+ * Maps ThinQ power-state + `airState.opMode` to a Matter `Thermostat.SystemMode`.
60
+ * Apple Home's Thermostat UI only exposes Off/Heat/Cool/Auto, so Dry and Fan-only opModes
61
+ * (which have no equivalent Apple-visible slot) collapse into Cool.
62
+ */
63
+ export function mapOperationModeToSystemMode(
64
+ operationMode: number | undefined,
65
+ isPowerOn: boolean,
66
+ capabilities: AirConditionerCapabilities,
67
+ ): Thermostat.SystemMode {
68
+ if (!isPowerOn) {
69
+ return Thermostat.SystemMode.Off;
70
+ }
71
+
72
+ switch (operationMode) {
73
+ case THINQ_OP_MODE_AUTO:
74
+ return capabilities.supportsHeat ? Thermostat.SystemMode.Auto : Thermostat.SystemMode.Cool;
75
+ case THINQ_OP_MODE_COOL:
76
+ return Thermostat.SystemMode.Cool;
77
+ case THINQ_OP_MODE_HEAT:
78
+ return capabilities.supportsHeat ? Thermostat.SystemMode.Heat : Thermostat.SystemMode.Cool;
79
+ case THINQ_OP_MODE_FAN:
80
+ return Thermostat.SystemMode.Cool;
81
+ case THINQ_OP_MODE_DRY:
82
+ return Thermostat.SystemMode.Cool;
83
+ case THINQ_OP_MODE_AIR_CLEAN:
84
+ default:
85
+ return capabilities.supportsHeat ? Thermostat.SystemMode.Auto : Thermostat.SystemMode.Cool;
86
+ }
87
+ }
88
+
89
+ /** Resolves systemMode update from a snapshot, returning undefined if the write should be skipped. */
90
+ function resolveSystemModeUpdate(
91
+ airConditioner: MatterbridgeEndpoint,
92
+ snapshot: ThinqSnapshot,
93
+ capabilities: AirConditionerCapabilities,
94
+ ): Thermostat.SystemMode | undefined {
95
+ if (!snapshot.has(KEY_OPERATION)) {
96
+ if (!snapshot.has(KEY_OP_MODE)) {
97
+ return undefined;
98
+ }
99
+ // operation absent, opMode present: derive power from current OnOff attribute
100
+ const currentPowerOn = (airConditioner.getAttribute(OnOff.id, 'onOff') as boolean | undefined) === true;
101
+ if (!currentPowerOn) {
102
+ return undefined;
103
+ }
104
+ return mapOperationModeToSystemMode(snapshot.operationMode, true, capabilities);
105
+ }
106
+
107
+ // operation present
108
+ const isPowerOn = snapshot.isPowerOn;
109
+ if (isPowerOn && !snapshot.has(KEY_OP_MODE)) {
110
+ // operation present and 1, opMode absent: skip systemMode
111
+ return undefined;
112
+ }
113
+
114
+ return mapOperationModeToSystemMode(snapshot.operationMode, isPowerOn, capabilities);
115
+ }
116
+
117
+ /**
118
+ * True when the AC is known to be off: the snapshot's `airState.operation` if present, otherwise the endpoint's
119
+ * current OnOff attribute (unknown/undefined is treated as NOT off).
120
+ */
121
+ function isKnownOff(airConditioner: MatterbridgeEndpoint, snapshot: ThinqSnapshot): boolean {
122
+ if (snapshot.has(KEY_OPERATION)) {
123
+ return !snapshot.isPowerOn;
124
+ }
125
+ return (airConditioner.getAttribute(OnOff.id, 'onOff') as boolean | undefined) === false;
126
+ }
127
+
128
+ /** Resolves rock setting from a snapshot, returning undefined if neither swing axis key is present. */
129
+ function resolveRockSetting(
130
+ airConditioner: MatterbridgeEndpoint,
131
+ snapshot: ThinqSnapshot,
132
+ ): { rockLeftRight: boolean; rockUpDown: boolean; rockRound: boolean } | undefined {
133
+ const hasVertical = snapshot.has(KEY_SWING_VERTICAL);
134
+ const hasHorizontal = snapshot.has(KEY_SWING_HORIZONTAL);
135
+
136
+ if (!hasVertical && !hasHorizontal) {
137
+ return undefined;
138
+ }
139
+
140
+ let verticalOn = snapshot.isVerticalSwingOn;
141
+ let horizontalOn = snapshot.isHorizontalSwingOn;
142
+
143
+ // Fallback to current attribute if only one axis is present
144
+ if (!hasVertical) {
145
+ const currentRockSetting = airConditioner.getAttribute(FanControl.id, 'rockSetting') as
146
+ { rockUpDown?: boolean } | undefined;
147
+ verticalOn = currentRockSetting?.rockUpDown ?? false;
148
+ }
149
+
150
+ if (!hasHorizontal) {
151
+ const currentRockSetting = airConditioner.getAttribute(FanControl.id, 'rockSetting') as
152
+ { rockLeftRight?: boolean } | undefined;
153
+ horizontalOn = currentRockSetting?.rockLeftRight ?? false;
154
+ }
155
+
156
+ return {
157
+ rockLeftRight: horizontalOn,
158
+ rockUpDown: verticalOn,
159
+ rockRound: verticalOn && horizontalOn,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Pushes a freshly polled ThinQ snapshot onto the Matter `AirConditioner` endpoint's attributes
165
+ * (device → Apple Home). Uses `updateAttribute` (idempotent) to avoid redundant attribute-report churn.
166
+ */
167
+ export async function applyThinqSnapshotToAirConditioner(
168
+ airConditioner: MatterbridgeEndpoint,
169
+ snapshot: ThinqSnapshot,
170
+ capabilities: AirConditionerCapabilities,
171
+ logger: AnsiLogger,
172
+ ): Promise<void> {
173
+ const deviceId = airConditioner.serialNumber ?? airConditioner.uniqueId ?? 'unknown';
174
+ const hasPower = snapshot.has(KEY_OPERATION);
175
+ const systemModeUpdate = resolveSystemModeUpdate(airConditioner, snapshot, capabilities);
176
+ const rockSetting =
177
+ capabilities.supportsFanSpeedControl && capabilities.supportsSwingMode
178
+ ? resolveRockSetting(airConditioner, snapshot)
179
+ : undefined;
180
+
181
+ const attributesToPush: string[] = [];
182
+ const attributesToSkip: string[] = [];
183
+
184
+ if (hasPower) {
185
+ attributesToPush.push('power');
186
+ } else {
187
+ attributesToSkip.push('power');
188
+ }
189
+
190
+ if (systemModeUpdate !== undefined) {
191
+ attributesToPush.push('systemMode');
192
+ } else {
193
+ attributesToSkip.push('systemMode');
194
+ }
195
+
196
+ if (snapshot.currentTemperatureCelsius !== undefined) {
197
+ attributesToPush.push('currentTemp');
198
+ } else if (snapshot.has(KEY_TEMP_CURRENT)) {
199
+ attributesToSkip.push('currentTemp');
200
+ }
201
+
202
+ if (snapshot.targetTemperatureCelsius !== undefined) {
203
+ attributesToPush.push('targetTemp');
204
+ } else if (snapshot.has(KEY_TEMP_TARGET)) {
205
+ attributesToSkip.push('targetTemp');
206
+ }
207
+
208
+ if (snapshot.has(KEY_WIND_STRENGTH)) {
209
+ attributesToPush.push('fanSpeed');
210
+ } else if (capabilities.supportsFanSpeedControl) {
211
+ attributesToSkip.push('fanSpeed');
212
+ }
213
+
214
+ logger.debug(
215
+ `applyThinqSnapshotToAirConditioner: entry for deviceId=${deviceId}, pushing ${attributesToPush.length} attributes: ${attributesToPush.join(', ')}`,
216
+ );
217
+
218
+ if (attributesToSkip.length > 0) {
219
+ logger.debug(`applyThinqSnapshotToAirConditioner: skipped (source key absent): ${attributesToSkip.join(', ')}`);
220
+ }
221
+
222
+ if (hasPower) {
223
+ await airConditioner.updateAttribute(OnOff.id, 'onOff', snapshot.isPowerOn, logger);
224
+ }
225
+
226
+ const currentTemperatureCelsius = snapshot.currentTemperatureCelsius;
227
+ if (currentTemperatureCelsius !== undefined) {
228
+ await airConditioner.updateAttribute(
229
+ TemperatureMeasurement.id,
230
+ 'measuredValue',
231
+ currentTemperatureCelsius * 100,
232
+ logger,
233
+ );
234
+ await airConditioner.updateAttribute(Thermostat.id, 'localTemperature', currentTemperatureCelsius * 100, logger);
235
+ }
236
+
237
+ const targetTemperatureCelsius = snapshot.targetTemperatureCelsius;
238
+ if (targetTemperatureCelsius !== undefined) {
239
+ await airConditioner.updateAttribute(
240
+ Thermostat.id,
241
+ 'occupiedCoolingSetpoint',
242
+ targetTemperatureCelsius * 100,
243
+ logger,
244
+ );
245
+ if (capabilities.supportsHeat) {
246
+ await airConditioner.updateAttribute(
247
+ Thermostat.id,
248
+ 'occupiedHeatingSetpoint',
249
+ targetTemperatureCelsius * 100,
250
+ logger,
251
+ );
252
+ }
253
+ }
254
+
255
+ if (systemModeUpdate !== undefined) {
256
+ await airConditioner.updateAttribute(Thermostat.id, 'systemMode', systemModeUpdate, logger);
257
+ }
258
+
259
+ if (snapshot.has(KEY_WIND_STRENGTH)) {
260
+ if (capabilities.supportsFanSpeedControl) {
261
+ await airConditioner.updateAttribute(
262
+ FanControl.id,
263
+ 'fanMode',
264
+ mapWindStrengthToFanMode(snapshot.windStrength),
265
+ logger,
266
+ );
267
+
268
+ const percentCurrent = mapWindStrengthToPercent(snapshot.windStrength);
269
+ if (percentCurrent !== undefined) {
270
+ await airConditioner.updateAttribute(FanControl.id, 'percentCurrent', percentCurrent, logger);
271
+ }
272
+ } else {
273
+ await airConditioner.updateAttribute(
274
+ FanControl.id,
275
+ 'fanMode',
276
+ mapWindStrengthToFixedFanMode(snapshot.windStrength),
277
+ logger,
278
+ );
279
+ }
280
+ }
281
+
282
+ if (capabilities.supportsFanSpeedControl && capabilities.supportsSwingMode && rockSetting !== undefined) {
283
+ await airConditioner.updateAttribute(FanControl.id, 'rockSetting', rockSetting, logger);
284
+ }
285
+
286
+ if (capabilities.supportsHumiditySensor) {
287
+ const humidityPercent = snapshot.humidityPercent;
288
+ if (humidityPercent !== undefined) {
289
+ const humiditySensorChild = airConditioner.getChildEndpointById('HumiditySensor');
290
+ if (humiditySensorChild) {
291
+ await humiditySensorChild.updateAttribute(
292
+ RelativeHumidityMeasurement.id,
293
+ 'measuredValue',
294
+ humidityPercent * 100,
295
+ logger,
296
+ );
297
+ }
298
+ }
299
+ }
300
+
301
+ if (capabilities.supportsAirQualitySensor) {
302
+ const airQualitySensorChild = airConditioner.getChildEndpointById('AirQualitySensor');
303
+ if (airQualitySensorChild) {
304
+ const airQualityOverall = snapshot.airQualityOverall;
305
+ if (airQualityOverall !== undefined) {
306
+ await airQualitySensorChild.updateAttribute(AirQuality.id, 'airQuality', airQualityOverall, logger);
307
+ }
308
+
309
+ const pm25Value = snapshot.pm25;
310
+ if (pm25Value !== undefined) {
311
+ await airQualitySensorChild.updateAttribute(
312
+ Pm25ConcentrationMeasurement.id,
313
+ 'measuredValue',
314
+ pm25Value,
315
+ logger,
316
+ );
317
+ }
318
+
319
+ const pm10Value = snapshot.pm10;
320
+ if (pm10Value !== undefined) {
321
+ await airQualitySensorChild.updateAttribute(
322
+ Pm10ConcentrationMeasurement.id,
323
+ 'measuredValue',
324
+ pm10Value,
325
+ logger,
326
+ );
327
+ }
328
+ }
329
+ }
330
+
331
+ if (capabilities.supportsEnergyMonitoring) {
332
+ // LG reports a floor raw value (~50 W) while the AC is off; never show that as consumption.
333
+ const watts = isKnownOff(airConditioner, snapshot) ? 0 : snapshot.powerConsumptionWatts;
334
+ if (watts !== undefined) {
335
+ await airConditioner.updateAttribute(
336
+ ElectricalPowerMeasurement.id,
337
+ 'activePower',
338
+ Math.round(watts * 1000),
339
+ logger,
340
+ );
341
+ }
342
+ }
343
+ }
@@ -0,0 +1,42 @@
1
+ import { isAirConditionerDevice, isWasherDevice, type ThinqDevice } from '../../core/domain/entities/ThinqDevice.js';
2
+ import type { ThinqDeviceConfigEntry } from '../../model/LgThinqPluginPlatformConfig.js';
3
+
4
+ /**
5
+ * Reconciles the config device entries with discovered devices by auto-populating/backfilling
6
+ * the `deviceType` field on all entries (required for schema visibility gating) and creating
7
+ * minimal entries for newly discovered devices.
8
+ *
9
+ * Mutates `configDevices` in place. Returns `true` iff at least one push/backfill happened.
10
+ * Never throws — defensively skips any device/entry that cannot be classified.
11
+ */
12
+ export function reconcileThinqDeviceConfigEntries(
13
+ configDevices: ThinqDeviceConfigEntry[],
14
+ discoveredDevices: ThinqDevice[],
15
+ ): boolean {
16
+ let changed = false;
17
+
18
+ for (const discovered of discoveredDevices) {
19
+ // Defensively determine the device type; skip if not recognizable.
20
+ let deviceType: 'AC' | 'WASHER' | undefined;
21
+ if (isAirConditionerDevice(discovered)) {
22
+ deviceType = 'AC';
23
+ } else if (isWasherDevice(discovered)) {
24
+ deviceType = 'WASHER';
25
+ } else {
26
+ continue; // Skip devices that match neither guard.
27
+ }
28
+
29
+ const existingEntry = configDevices.find((d) => d.deviceId === discovered.id);
30
+ if (!existingEntry) {
31
+ // New device: create a minimal entry with just deviceId and deviceType.
32
+ configDevices.push({ deviceId: discovered.id, deviceType });
33
+ changed = true;
34
+ } else if (existingEntry.deviceType !== deviceType) {
35
+ // Existing entry with mismatched or missing deviceType: backfill/correct it.
36
+ existingEntry.deviceType = deviceType;
37
+ changed = true;
38
+ }
39
+ }
40
+
41
+ return changed;
42
+ }
@@ -0,0 +1,90 @@
1
+ import { FanControl } from 'matterbridge/matter/clusters';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { mapWindStrengthToFanMode, mapWindStrengthToFixedFanMode } from './thinqDeviceConfigurator.js';
5
+
6
+ describe('thinqDeviceConfigurator', () => {
7
+ beforeEach(() => {
8
+ vi.clearAllMocks();
9
+ });
10
+
11
+ afterEach(() => {
12
+ vi.clearAllMocks();
13
+ });
14
+
15
+ describe('mapWindStrengthToFanMode', () => {
16
+ it('should map undefined to Auto mode', () => {
17
+ const mode = mapWindStrengthToFanMode(undefined);
18
+ expect(mode).toBe(FanControl.FanMode.Auto);
19
+ });
20
+
21
+ it('should map windStrength 8 (AUTO) to Auto mode', () => {
22
+ const mode = mapWindStrengthToFanMode(8);
23
+ expect(mode).toBe(FanControl.FanMode.Auto);
24
+ });
25
+
26
+ it('should map windStrength <= 2 to Low mode', () => {
27
+ expect(mapWindStrengthToFanMode(0)).toBe(FanControl.FanMode.Low);
28
+ expect(mapWindStrengthToFanMode(1)).toBe(FanControl.FanMode.Low);
29
+ expect(mapWindStrengthToFanMode(2)).toBe(FanControl.FanMode.Low);
30
+ });
31
+
32
+ it('should map windStrength > 2 and <= 4 to Medium mode', () => {
33
+ expect(mapWindStrengthToFanMode(3)).toBe(FanControl.FanMode.Medium);
34
+ expect(mapWindStrengthToFanMode(4)).toBe(FanControl.FanMode.Medium);
35
+ });
36
+
37
+ it('should map windStrength > 4 to High mode', () => {
38
+ expect(mapWindStrengthToFanMode(5)).toBe(FanControl.FanMode.High);
39
+ expect(mapWindStrengthToFanMode(6)).toBe(FanControl.FanMode.High);
40
+ expect(mapWindStrengthToFanMode(7)).toBe(FanControl.FanMode.High);
41
+ expect(mapWindStrengthToFanMode(9)).toBe(FanControl.FanMode.High);
42
+ });
43
+
44
+ it('should handle edge case windStrength = 2.5', () => {
45
+ const mode = mapWindStrengthToFanMode(2.5);
46
+ expect(mode).toBe(FanControl.FanMode.Medium);
47
+ });
48
+
49
+ it('should handle edge case windStrength = 4.1', () => {
50
+ const mode = mapWindStrengthToFanMode(4.1);
51
+ expect(mode).toBe(FanControl.FanMode.High);
52
+ });
53
+
54
+ it('should handle very high windStrength values', () => {
55
+ const mode = mapWindStrengthToFanMode(100);
56
+ expect(mode).toBe(FanControl.FanMode.High);
57
+ });
58
+
59
+ it('should handle negative windStrength', () => {
60
+ const mode = mapWindStrengthToFanMode(-1);
61
+ expect(mode).toBe(FanControl.FanMode.Low);
62
+ });
63
+ });
64
+
65
+ describe('mapWindStrengthToFixedFanMode', () => {
66
+ it('should map undefined to Off mode', () => {
67
+ const mode = mapWindStrengthToFixedFanMode(undefined);
68
+ expect(mode).toBe(FanControl.FanMode.Off);
69
+ });
70
+
71
+ it('should map any defined windStrength to High mode', () => {
72
+ expect(mapWindStrengthToFixedFanMode(0)).toBe(FanControl.FanMode.High);
73
+ expect(mapWindStrengthToFixedFanMode(1)).toBe(FanControl.FanMode.High);
74
+ expect(mapWindStrengthToFixedFanMode(2)).toBe(FanControl.FanMode.High);
75
+ expect(mapWindStrengthToFixedFanMode(5)).toBe(FanControl.FanMode.High);
76
+ expect(mapWindStrengthToFixedFanMode(8)).toBe(FanControl.FanMode.High);
77
+ expect(mapWindStrengthToFixedFanMode(100)).toBe(FanControl.FanMode.High);
78
+ });
79
+
80
+ it('should map negative windStrength to High mode', () => {
81
+ const mode = mapWindStrengthToFixedFanMode(-1);
82
+ expect(mode).toBe(FanControl.FanMode.High);
83
+ });
84
+
85
+ it('should map zero windStrength to High mode', () => {
86
+ const mode = mapWindStrengthToFixedFanMode(0);
87
+ expect(mode).toBe(FanControl.FanMode.High);
88
+ });
89
+ });
90
+ });