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,993 @@
1
+ import { FanControl, PowerTopology } from 'matterbridge/matter/clusters';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import type { ThinqAirConditionerDevice } from '../../../core/domain/entities/ThinqDevice.js';
5
+ import type { AirConditionerCapabilities } from '../../../core/domain/value-objects/AirConditionerCapabilities.js';
6
+ import { DEFAULT_AIR_CONDITIONER_CAPABILITIES } from '../../../core/domain/value-objects/AirConditionerCapabilities.js';
7
+ import { buildAirConditionerEndpoint } from '../../../platform/thinq/thinqAirConditionerEndpointFactory.js';
8
+ import { asPartial } from '../../helpers/testUtils.js';
9
+
10
+ // Use hoisted to set up mocks before vi.mock is processed
11
+ const { MatterbridgeEndpointMockFn } = vi.hoisted(() => {
12
+ let mockEndpointInstance: any;
13
+
14
+ const MatterbridgeEndpointMockFn = vi.fn(function () {
15
+ return mockEndpointInstance;
16
+ });
17
+
18
+ // Store the setter on the mock function itself
19
+ (MatterbridgeEndpointMockFn as any).setEndpoint = (endpoint: any) => {
20
+ mockEndpointInstance = endpoint;
21
+ };
22
+
23
+ return { MatterbridgeEndpointMockFn };
24
+ });
25
+
26
+ vi.mock('matterbridge', () => ({
27
+ MatterbridgeEndpoint: MatterbridgeEndpointMockFn,
28
+ roomAirConditioner: { id: 'roomAirConditioner' },
29
+ powerSource: { id: 'powerSource' },
30
+ humiditySensor: { id: 'humiditySensor' },
31
+ airQualitySensor: { id: 'airQualitySensor' },
32
+ electricalSensor: { id: 'electricalSensor' },
33
+ genericSwitch: { id: 'genericSwitch' },
34
+ }));
35
+
36
+ vi.mock('../../../platform/thinq/thinqAirConditionerAuxiliaryToggles.js', () => ({
37
+ addAuxiliaryToggleEndpoints: vi.fn(),
38
+ }));
39
+
40
+ function createChainableMock() {
41
+ return {
42
+ createDefaultIdentifyClusterServer: vi.fn().mockReturnThis(),
43
+ createDefaultBasicInformationClusterServer: vi.fn().mockReturnThis(),
44
+ createDefaultPowerSourceWiredClusterServer: vi.fn().mockReturnThis(),
45
+ createDeadFrontOnOffClusterServer: vi.fn().mockReturnThis(),
46
+ createDefaultThermostatClusterServer: vi.fn().mockReturnThis(),
47
+ createDefaultCoolingThermostatClusterServer: vi.fn().mockReturnThis(),
48
+ createDefaultThermostatUserInterfaceConfigurationClusterServer: vi.fn().mockReturnThis(),
49
+ createDefaultFanControlClusterServer: vi.fn().mockReturnThis(),
50
+ createCompleteFanControlClusterServer: vi.fn().mockReturnThis(),
51
+ createOnOffFanControlClusterServer: vi.fn().mockReturnThis(),
52
+ addChildDeviceType: vi.fn().mockReturnThis(),
53
+ createDefaultRelativeHumidityMeasurementClusterServer: vi.fn().mockReturnThis(),
54
+ createDefaultAirQualityClusterServer: vi.fn().mockReturnThis(),
55
+ createDefaultPm25ConcentrationMeasurementClusterServer: vi.fn().mockReturnThis(),
56
+ createDefaultPm10ConcentrationMeasurementClusterServer: vi.fn().mockReturnThis(),
57
+ createDefaultPowerTopologyClusterServer: vi.fn().mockReturnThis(),
58
+ createDefaultElectricalPowerMeasurementClusterServer: vi.fn().mockReturnThis(),
59
+ createDefaultHepaFilterMonitoringClusterServer: vi.fn().mockReturnThis(),
60
+ createDefaultMomentarySwitchClusterServer: vi.fn().mockReturnThis(),
61
+ };
62
+ }
63
+
64
+ describe('buildAirConditionerEndpoint', () => {
65
+ let mockDevice: ThinqAirConditionerDevice;
66
+ let setpoints: {
67
+ currentTemperature: number;
68
+ targetTemperature: number;
69
+ minHeatSetpointLimitCelsius: number;
70
+ maxHeatSetpointLimitCelsius: number;
71
+ minCoolSetpointLimitCelsius: number;
72
+ maxCoolSetpointLimitCelsius: number;
73
+ };
74
+ let mockEndpoint: any;
75
+
76
+ beforeEach(() => {
77
+ vi.clearAllMocks();
78
+
79
+ // Create a fresh chainable mock for each test
80
+ mockEndpoint = createChainableMock();
81
+
82
+ // Set the endpoint for the mock constructor
83
+ (MatterbridgeEndpointMockFn as any).setEndpoint(mockEndpoint);
84
+
85
+ mockDevice = asPartial<ThinqAirConditionerDevice>({
86
+ name: 'Living Room AC',
87
+ id: '12345678-1234-1234-1234-123456789012',
88
+ type: 'AC',
89
+ });
90
+
91
+ setpoints = {
92
+ currentTemperature: 24,
93
+ targetTemperature: 22,
94
+ minHeatSetpointLimitCelsius: 16,
95
+ maxHeatSetpointLimitCelsius: 30,
96
+ minCoolSetpointLimitCelsius: 16,
97
+ maxCoolSetpointLimitCelsius: 30,
98
+ };
99
+ });
100
+
101
+ describe('endpoint construction', () => {
102
+ it('should construct MatterbridgeEndpoint with roomAirConditioner and powerSource devices', () => {
103
+ const capabilities = asPartial<AirConditionerCapabilities>({
104
+ supportsHeat: true,
105
+ supportsFanSpeedControl: true,
106
+ });
107
+
108
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
109
+
110
+ expect(MatterbridgeEndpointMockFn).toHaveBeenCalledWith(
111
+ expect.any(Array),
112
+ expect.objectContaining({
113
+ id: expect.any(String),
114
+ }),
115
+ );
116
+ });
117
+
118
+ it('should remove spaces from device name and id in endpoint id', () => {
119
+ mockDevice = asPartial<ThinqAirConditionerDevice>({
120
+ name: 'Living Room AC With Spaces',
121
+ id: 'device id with spaces',
122
+ type: 'AC',
123
+ });
124
+
125
+ const capabilities = asPartial<AirConditionerCapabilities>({
126
+ supportsHeat: true,
127
+ supportsFanSpeedControl: true,
128
+ });
129
+
130
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
131
+
132
+ expect(MatterbridgeEndpointMockFn).toHaveBeenCalledWith(
133
+ expect.any(Array),
134
+ expect.objectContaining({
135
+ id: 'LivingRoomACWithSpaces-deviceidwithspaces',
136
+ }),
137
+ );
138
+ });
139
+
140
+ it('should call createDefaultIdentifyClusterServer', () => {
141
+ const capabilities = asPartial<AirConditionerCapabilities>({
142
+ supportsHeat: true,
143
+ supportsFanSpeedControl: true,
144
+ });
145
+
146
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
147
+
148
+ expect(mockEndpoint.createDefaultIdentifyClusterServer).toHaveBeenCalledTimes(1);
149
+ });
150
+
151
+ it('should call createDefaultBasicInformationClusterServer with correct args', () => {
152
+ const capabilities = asPartial<AirConditionerCapabilities>({
153
+ supportsHeat: true,
154
+ supportsFanSpeedControl: true,
155
+ });
156
+
157
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
158
+
159
+ expect(mockEndpoint.createDefaultBasicInformationClusterServer).toHaveBeenCalledWith(
160
+ mockDevice.name,
161
+ mockDevice.id,
162
+ 0xfff1,
163
+ 'Matterbridge',
164
+ 0x8000,
165
+ 'Matterbridge Air Conditioner',
166
+ );
167
+ });
168
+
169
+ it('should call createDefaultPowerSourceWiredClusterServer', () => {
170
+ const capabilities = asPartial<AirConditionerCapabilities>({
171
+ supportsHeat: true,
172
+ supportsFanSpeedControl: true,
173
+ });
174
+
175
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
176
+
177
+ expect(mockEndpoint.createDefaultPowerSourceWiredClusterServer).toHaveBeenCalledTimes(1);
178
+ });
179
+
180
+ it('should call createDeadFrontOnOffClusterServer with true', () => {
181
+ const capabilities = asPartial<AirConditionerCapabilities>({
182
+ supportsHeat: true,
183
+ supportsFanSpeedControl: true,
184
+ });
185
+
186
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
187
+
188
+ expect(mockEndpoint.createDeadFrontOnOffClusterServer).toHaveBeenCalledWith(true);
189
+ });
190
+
191
+ it('should call createDefaultThermostatUserInterfaceConfigurationClusterServer', () => {
192
+ const capabilities = asPartial<AirConditionerCapabilities>({
193
+ supportsHeat: true,
194
+ supportsFanSpeedControl: true,
195
+ });
196
+
197
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
198
+
199
+ expect(mockEndpoint.createDefaultThermostatUserInterfaceConfigurationClusterServer).toHaveBeenCalledTimes(1);
200
+ });
201
+ });
202
+
203
+ describe('thermostat cluster selection when supportsHeat=true', () => {
204
+ it('should call createDefaultThermostatClusterServer with correct args', () => {
205
+ const capabilities = asPartial<AirConditionerCapabilities>({
206
+ supportsHeat: true,
207
+ supportsFanSpeedControl: true,
208
+ });
209
+
210
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
211
+
212
+ expect(mockEndpoint.createDefaultThermostatClusterServer).toHaveBeenCalledWith(
213
+ setpoints.currentTemperature,
214
+ setpoints.targetTemperature,
215
+ setpoints.targetTemperature,
216
+ 1,
217
+ setpoints.minHeatSetpointLimitCelsius,
218
+ setpoints.maxHeatSetpointLimitCelsius,
219
+ setpoints.minCoolSetpointLimitCelsius,
220
+ setpoints.maxCoolSetpointLimitCelsius,
221
+ );
222
+ });
223
+
224
+ it('should not call createDefaultCoolingThermostatClusterServer', () => {
225
+ const capabilities = asPartial<AirConditionerCapabilities>({
226
+ supportsHeat: true,
227
+ supportsFanSpeedControl: true,
228
+ });
229
+
230
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
231
+
232
+ expect(mockEndpoint.createDefaultCoolingThermostatClusterServer).not.toHaveBeenCalled();
233
+ });
234
+ });
235
+
236
+ describe('thermostat cluster selection when supportsHeat=false', () => {
237
+ it('should call createDefaultCoolingThermostatClusterServer with correct args', () => {
238
+ const capabilities = asPartial<AirConditionerCapabilities>({
239
+ supportsHeat: false,
240
+ supportsFanSpeedControl: true,
241
+ });
242
+
243
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
244
+
245
+ expect(mockEndpoint.createDefaultCoolingThermostatClusterServer).toHaveBeenCalledWith(
246
+ setpoints.currentTemperature,
247
+ setpoints.targetTemperature,
248
+ setpoints.minCoolSetpointLimitCelsius,
249
+ setpoints.maxCoolSetpointLimitCelsius,
250
+ );
251
+ });
252
+
253
+ it('should not call createDefaultThermostatClusterServer', () => {
254
+ const capabilities = asPartial<AirConditionerCapabilities>({
255
+ supportsHeat: false,
256
+ supportsFanSpeedControl: true,
257
+ });
258
+
259
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
260
+
261
+ expect(mockEndpoint.createDefaultThermostatClusterServer).not.toHaveBeenCalled();
262
+ });
263
+ });
264
+
265
+ describe('fan control cluster selection when supportsFanSpeedControl=true', () => {
266
+ it('should call createDefaultFanControlClusterServer with correct args', () => {
267
+ const capabilities = asPartial<AirConditionerCapabilities>({
268
+ supportsHeat: true,
269
+ supportsFanSpeedControl: true,
270
+ });
271
+
272
+ const initialFanMode = FanControl.FanMode.Low;
273
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, initialFanMode);
274
+
275
+ expect(mockEndpoint.createDefaultFanControlClusterServer).toHaveBeenCalledWith(
276
+ initialFanMode,
277
+ FanControl.FanModeSequence.OffLowMedHighAuto,
278
+ 0,
279
+ 0,
280
+ );
281
+ });
282
+
283
+ it('should not call createOnOffFanControlClusterServer', () => {
284
+ const capabilities = asPartial<AirConditionerCapabilities>({
285
+ supportsHeat: true,
286
+ supportsFanSpeedControl: true,
287
+ });
288
+
289
+ const initialFanMode = FanControl.FanMode.Low;
290
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, initialFanMode);
291
+
292
+ expect(mockEndpoint.createOnOffFanControlClusterServer).not.toHaveBeenCalled();
293
+ });
294
+ });
295
+
296
+ describe('fan control cluster selection when supportsFanSpeedControl=false', () => {
297
+ it('should call createOnOffFanControlClusterServer with correct args', () => {
298
+ const capabilities = asPartial<AirConditionerCapabilities>({
299
+ supportsHeat: true,
300
+ supportsFanSpeedControl: false,
301
+ });
302
+
303
+ const initialFanMode = FanControl.FanMode.Low;
304
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, initialFanMode);
305
+
306
+ expect(mockEndpoint.createOnOffFanControlClusterServer).toHaveBeenCalledWith(initialFanMode);
307
+ });
308
+
309
+ it('should not call createDefaultFanControlClusterServer', () => {
310
+ const capabilities = asPartial<AirConditionerCapabilities>({
311
+ supportsHeat: true,
312
+ supportsFanSpeedControl: false,
313
+ });
314
+
315
+ const initialFanMode = FanControl.FanMode.Low;
316
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, initialFanMode);
317
+
318
+ expect(mockEndpoint.createDefaultFanControlClusterServer).not.toHaveBeenCalled();
319
+ });
320
+ });
321
+
322
+ describe('return value', () => {
323
+ it('should return the chainable endpoint', () => {
324
+ const capabilities = asPartial<AirConditionerCapabilities>({
325
+ supportsHeat: true,
326
+ supportsFanSpeedControl: true,
327
+ });
328
+
329
+ const result = buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
330
+
331
+ expect(result).toBe(mockEndpoint);
332
+ });
333
+ });
334
+
335
+ describe('swing mode (Phase B)', () => {
336
+ it('should call createCompleteFanControlClusterServer when supportsFanSpeedControl and supportsSwingMode are both true', () => {
337
+ // Arrange
338
+ const capabilities = asPartial<AirConditionerCapabilities>({
339
+ supportsHeat: true,
340
+ supportsFanSpeedControl: true,
341
+ supportsSwingMode: true,
342
+ });
343
+
344
+ const initialFanMode = FanControl.FanMode.Low;
345
+
346
+ // Act
347
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, initialFanMode);
348
+
349
+ // Assert
350
+ expect(mockEndpoint.createCompleteFanControlClusterServer).toHaveBeenCalledWith(
351
+ initialFanMode,
352
+ FanControl.FanModeSequence.OffLowMedHighAuto,
353
+ 0,
354
+ 0,
355
+ undefined,
356
+ undefined,
357
+ undefined,
358
+ { rockLeftRight: true, rockUpDown: true, rockRound: true },
359
+ { rockLeftRight: false, rockUpDown: false, rockRound: false },
360
+ );
361
+ });
362
+
363
+ it('should not call createDefaultFanControlClusterServer when supportsSwingMode is true', () => {
364
+ // Arrange
365
+ const capabilities = asPartial<AirConditionerCapabilities>({
366
+ supportsHeat: true,
367
+ supportsFanSpeedControl: true,
368
+ supportsSwingMode: true,
369
+ });
370
+
371
+ // Act
372
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
373
+
374
+ // Assert
375
+ expect(mockEndpoint.createDefaultFanControlClusterServer).not.toHaveBeenCalled();
376
+ });
377
+
378
+ it('should call createDefaultFanControlClusterServer when supportsFanSpeedControl is true but supportsSwingMode is false', () => {
379
+ // Arrange
380
+ const capabilities = asPartial<AirConditionerCapabilities>({
381
+ supportsHeat: true,
382
+ supportsFanSpeedControl: true,
383
+ supportsSwingMode: false,
384
+ });
385
+
386
+ const initialFanMode = FanControl.FanMode.Low;
387
+
388
+ // Act
389
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, initialFanMode);
390
+
391
+ // Assert
392
+ expect(mockEndpoint.createDefaultFanControlClusterServer).toHaveBeenCalledWith(
393
+ initialFanMode,
394
+ FanControl.FanModeSequence.OffLowMedHighAuto,
395
+ 0,
396
+ 0,
397
+ );
398
+ expect(mockEndpoint.createCompleteFanControlClusterServer).not.toHaveBeenCalled();
399
+ });
400
+
401
+ it('should call createOnOffFanControlClusterServer when supportsFanSpeedControl is false, even if supportsSwingMode is true', () => {
402
+ // Arrange
403
+ const capabilities = asPartial<AirConditionerCapabilities>({
404
+ supportsHeat: true,
405
+ supportsFanSpeedControl: false,
406
+ supportsSwingMode: true,
407
+ });
408
+
409
+ const initialFanMode = FanControl.FanMode.Low;
410
+
411
+ // Act
412
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, initialFanMode);
413
+
414
+ // Assert
415
+ expect(mockEndpoint.createOnOffFanControlClusterServer).toHaveBeenCalledWith(initialFanMode);
416
+ expect(mockEndpoint.createCompleteFanControlClusterServer).not.toHaveBeenCalled();
417
+ expect(mockEndpoint.createDefaultFanControlClusterServer).not.toHaveBeenCalled();
418
+ });
419
+ });
420
+
421
+ describe('humidity sensor child endpoint (Phase C)', () => {
422
+ it('should create HumiditySensor child endpoint when supportsHumiditySensor is true', () => {
423
+ // Arrange
424
+ const capabilities = asPartial<AirConditionerCapabilities>({
425
+ supportsHeat: true,
426
+ supportsFanSpeedControl: true,
427
+ supportsHumiditySensor: true,
428
+ });
429
+
430
+ // Act
431
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
432
+
433
+ // Assert
434
+ expect(mockEndpoint.addChildDeviceType).toHaveBeenCalledWith('HumiditySensor', expect.any(Array));
435
+ });
436
+
437
+ it('should create RelativeHumidityMeasurementClusterServer on HumiditySensor child', () => {
438
+ // Arrange
439
+ const capabilities = asPartial<AirConditionerCapabilities>({
440
+ supportsHeat: true,
441
+ supportsFanSpeedControl: true,
442
+ supportsHumiditySensor: true,
443
+ });
444
+
445
+ // Act
446
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
447
+
448
+ // Assert
449
+ expect(mockEndpoint.createDefaultRelativeHumidityMeasurementClusterServer).toHaveBeenCalledWith(0);
450
+ });
451
+
452
+ it('should not create HumiditySensor child endpoint when supportsHumiditySensor is false', () => {
453
+ // Arrange
454
+ const capabilities = asPartial<AirConditionerCapabilities>({
455
+ supportsHeat: true,
456
+ supportsFanSpeedControl: true,
457
+ supportsHumiditySensor: false,
458
+ });
459
+
460
+ // Act
461
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
462
+
463
+ // Assert
464
+ const calls = (mockEndpoint.addChildDeviceType as any).mock.calls;
465
+ const humidityCallExists = calls.some((call: any[]) => call[0] === 'HumiditySensor');
466
+ expect(humidityCallExists).toBe(false);
467
+ });
468
+
469
+ it('should not call createDefaultRelativeHumidityMeasurementClusterServer when supportsHumiditySensor is false', () => {
470
+ // Arrange
471
+ const capabilities = asPartial<AirConditionerCapabilities>({
472
+ supportsHeat: true,
473
+ supportsFanSpeedControl: true,
474
+ supportsHumiditySensor: false,
475
+ });
476
+
477
+ // Act
478
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
479
+
480
+ // Assert
481
+ expect(mockEndpoint.createDefaultRelativeHumidityMeasurementClusterServer).not.toHaveBeenCalled();
482
+ });
483
+ });
484
+
485
+ describe('air quality sensor child endpoint (Phase C)', () => {
486
+ it('should create AirQualitySensor child endpoint when supportsAirQualitySensor is true', () => {
487
+ // Arrange
488
+ const capabilities = asPartial<AirConditionerCapabilities>({
489
+ supportsHeat: true,
490
+ supportsFanSpeedControl: true,
491
+ supportsAirQualitySensor: true,
492
+ });
493
+
494
+ // Act
495
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
496
+
497
+ // Assert
498
+ expect(mockEndpoint.addChildDeviceType).toHaveBeenCalledWith('AirQualitySensor', expect.any(Array));
499
+ });
500
+
501
+ it('should create AirQualityClusterServer on AirQualitySensor child', () => {
502
+ // Arrange
503
+ const capabilities = asPartial<AirConditionerCapabilities>({
504
+ supportsHeat: true,
505
+ supportsFanSpeedControl: true,
506
+ supportsAirQualitySensor: true,
507
+ });
508
+
509
+ // Act
510
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
511
+
512
+ // Assert
513
+ expect(mockEndpoint.createDefaultAirQualityClusterServer).toHaveBeenCalled();
514
+ });
515
+
516
+ it('should create PM2.5 ConcentrationMeasurementClusterServer on AirQualitySensor child', () => {
517
+ // Arrange
518
+ const capabilities = asPartial<AirConditionerCapabilities>({
519
+ supportsHeat: true,
520
+ supportsFanSpeedControl: true,
521
+ supportsAirQualitySensor: true,
522
+ });
523
+
524
+ // Act
525
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
526
+
527
+ // Assert
528
+ expect(mockEndpoint.createDefaultPm25ConcentrationMeasurementClusterServer).toHaveBeenCalled();
529
+ });
530
+
531
+ it('should create PM10 ConcentrationMeasurementClusterServer on AirQualitySensor child', () => {
532
+ // Arrange
533
+ const capabilities = asPartial<AirConditionerCapabilities>({
534
+ supportsHeat: true,
535
+ supportsFanSpeedControl: true,
536
+ supportsAirQualitySensor: true,
537
+ });
538
+
539
+ // Act
540
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
541
+
542
+ // Assert
543
+ expect(mockEndpoint.createDefaultPm10ConcentrationMeasurementClusterServer).toHaveBeenCalled();
544
+ });
545
+
546
+ it('should not create AirQualitySensor child endpoint when supportsAirQualitySensor is false', () => {
547
+ // Arrange
548
+ const capabilities = asPartial<AirConditionerCapabilities>({
549
+ supportsHeat: true,
550
+ supportsFanSpeedControl: true,
551
+ supportsAirQualitySensor: false,
552
+ });
553
+
554
+ // Act
555
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
556
+
557
+ // Assert
558
+ const calls = (mockEndpoint.addChildDeviceType as any).mock.calls;
559
+ const airQualityCallExists = calls.some((call: any[]) => call[0] === 'AirQualitySensor');
560
+ expect(airQualityCallExists).toBe(false);
561
+ });
562
+
563
+ it('should not call air quality cluster servers when supportsAirQualitySensor is false', () => {
564
+ // Arrange
565
+ const capabilities = asPartial<AirConditionerCapabilities>({
566
+ supportsHeat: true,
567
+ supportsFanSpeedControl: true,
568
+ supportsAirQualitySensor: false,
569
+ });
570
+
571
+ // Act
572
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
573
+
574
+ // Assert
575
+ expect(mockEndpoint.createDefaultAirQualityClusterServer).not.toHaveBeenCalled();
576
+ expect(mockEndpoint.createDefaultPm25ConcentrationMeasurementClusterServer).not.toHaveBeenCalled();
577
+ expect(mockEndpoint.createDefaultPm10ConcentrationMeasurementClusterServer).not.toHaveBeenCalled();
578
+ });
579
+
580
+ it('should create both humidity and air quality sensors when both capabilities are enabled', () => {
581
+ // Arrange
582
+ const capabilities = asPartial<AirConditionerCapabilities>({
583
+ supportsHeat: true,
584
+ supportsFanSpeedControl: true,
585
+ supportsHumiditySensor: true,
586
+ supportsAirQualitySensor: true,
587
+ });
588
+
589
+ // Act
590
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
591
+
592
+ // Assert
593
+ const calls = (mockEndpoint.addChildDeviceType as any).mock.calls;
594
+ expect(calls.some((call: any[]) => call[0] === 'HumiditySensor')).toBe(true);
595
+ expect(calls.some((call: any[]) => call[0] === 'AirQualitySensor')).toBe(true);
596
+ });
597
+
598
+ it('should maintain regression parity when both sensor capabilities are false (default)', () => {
599
+ // Arrange
600
+ const capabilities = { ...DEFAULT_AIR_CONDITIONER_CAPABILITIES };
601
+
602
+ // Act
603
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
604
+
605
+ // Assert
606
+ const calls = (mockEndpoint.addChildDeviceType as any).mock.calls;
607
+ const hasHumiditySensor = calls.some((call: any[]) => call[0] === 'HumiditySensor');
608
+ const hasAirQualitySensor = calls.some((call: any[]) => call[0] === 'AirQualitySensor');
609
+ expect(hasHumiditySensor).toBe(false);
610
+ expect(hasAirQualitySensor).toBe(false);
611
+ });
612
+ });
613
+
614
+ describe('energy monitoring on AC endpoint (Phase D)', () => {
615
+ it('should add electricalSensor device type when supportsEnergyMonitoring is true', () => {
616
+ // Arrange
617
+ const capabilities = asPartial<AirConditionerCapabilities>({
618
+ supportsHeat: true,
619
+ supportsFanSpeedControl: true,
620
+ supportsEnergyMonitoring: true,
621
+ });
622
+
623
+ // Act
624
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
625
+
626
+ // Assert
627
+ const firstArg = (MatterbridgeEndpointMockFn as any).mock.calls[0][0];
628
+ expect(firstArg).toContainEqual({ id: 'roomAirConditioner' });
629
+ expect(firstArg).toContainEqual({ id: 'powerSource' });
630
+ expect(firstArg).toContainEqual({ id: 'electricalSensor' });
631
+ });
632
+
633
+ it('should have roomAirConditioner as first device type', () => {
634
+ // Arrange
635
+ const capabilities = asPartial<AirConditionerCapabilities>({
636
+ supportsHeat: true,
637
+ supportsFanSpeedControl: true,
638
+ supportsEnergyMonitoring: true,
639
+ });
640
+
641
+ // Act
642
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
643
+
644
+ // Assert
645
+ const firstArg = (MatterbridgeEndpointMockFn as any).mock.calls[0][0];
646
+ expect(firstArg[0]).toEqual({ id: 'roomAirConditioner' });
647
+ });
648
+
649
+ it('should create PowerTopology and ElectricalPowerMeasurement on AC endpoint when supportsEnergyMonitoring is true', () => {
650
+ // Arrange
651
+ const capabilities = asPartial<AirConditionerCapabilities>({
652
+ supportsHeat: true,
653
+ supportsFanSpeedControl: true,
654
+ supportsEnergyMonitoring: true,
655
+ });
656
+
657
+ // Act
658
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
659
+
660
+ // Assert
661
+ expect(mockEndpoint.createDefaultPowerTopologyClusterServer).toHaveBeenCalledWith(
662
+ PowerTopology.Feature.NodeTopology,
663
+ );
664
+ expect(mockEndpoint.createDefaultElectricalPowerMeasurementClusterServer).toHaveBeenCalledWith(
665
+ null,
666
+ null,
667
+ 0,
668
+ null,
669
+ );
670
+ });
671
+
672
+ it('should not create EnergyMonitor child endpoint', () => {
673
+ // Arrange
674
+ const capabilities = asPartial<AirConditionerCapabilities>({
675
+ supportsHeat: true,
676
+ supportsFanSpeedControl: true,
677
+ supportsEnergyMonitoring: true,
678
+ });
679
+
680
+ // Act
681
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
682
+
683
+ // Assert
684
+ const calls = (mockEndpoint.addChildDeviceType as any).mock.calls;
685
+ const hasEnergyMonitor = calls.some((call: any[]) => call[0] === 'EnergyMonitor');
686
+ expect(hasEnergyMonitor).toBe(false);
687
+ });
688
+
689
+ it('should not add electricalSensor or create energy clusters when supportsEnergyMonitoring is false', () => {
690
+ // Arrange
691
+ const capabilities = asPartial<AirConditionerCapabilities>({
692
+ supportsHeat: true,
693
+ supportsFanSpeedControl: true,
694
+ supportsEnergyMonitoring: false,
695
+ });
696
+
697
+ // Act
698
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
699
+
700
+ // Assert
701
+ const firstArg = (MatterbridgeEndpointMockFn as any).mock.calls[0][0];
702
+ const hasSensor = firstArg.some((dt: any) => dt.id === 'electricalSensor');
703
+ expect(hasSensor).toBe(false);
704
+ expect(mockEndpoint.createDefaultPowerTopologyClusterServer).not.toHaveBeenCalled();
705
+ expect(mockEndpoint.createDefaultElectricalPowerMeasurementClusterServer).not.toHaveBeenCalled();
706
+ });
707
+
708
+ it('should maintain regression parity when energy monitoring capability is false (default)', () => {
709
+ // Arrange
710
+ const capabilities = { ...DEFAULT_AIR_CONDITIONER_CAPABILITIES };
711
+
712
+ // Act
713
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
714
+
715
+ // Assert
716
+ const firstArg = (MatterbridgeEndpointMockFn as any).mock.calls[0][0];
717
+ const hasSensor = firstArg.some((dt: any) => dt.id === 'electricalSensor');
718
+ expect(hasSensor).toBe(false);
719
+ });
720
+
721
+ it('should not add electricalSensor when supportsEnergyMonitoring is false', () => {
722
+ // Arrange
723
+ const capabilities = asPartial<AirConditionerCapabilities>({
724
+ supportsHeat: true,
725
+ supportsFanSpeedControl: true,
726
+ supportsEnergyMonitoring: false,
727
+ });
728
+
729
+ // Act
730
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
731
+
732
+ // Assert
733
+ const firstArg = (MatterbridgeEndpointMockFn as any).mock.calls[0][0];
734
+ expect(firstArg).toContainEqual({ id: 'roomAirConditioner' });
735
+ expect(firstArg).toContainEqual({ id: 'powerSource' });
736
+ expect(firstArg).not.toContainEqual({ id: 'electricalSensor' });
737
+ });
738
+
739
+ it('should coexist with humidity sensor and air quality sensor when energy is on endpoint', () => {
740
+ // Arrange
741
+ const childHumidityMock = createChainableMock();
742
+ const childAirQualityMock = createChainableMock();
743
+ const capabilities = asPartial<AirConditionerCapabilities>({
744
+ supportsHeat: true,
745
+ supportsFanSpeedControl: true,
746
+ supportsEnergyMonitoring: true,
747
+ supportsHumiditySensor: true,
748
+ supportsAirQualitySensor: true,
749
+ });
750
+
751
+ let callCount = 0;
752
+ mockEndpoint.addChildDeviceType = vi.fn((name: string) => {
753
+ callCount++;
754
+ if (name === 'HumiditySensor') return childHumidityMock;
755
+ if (name === 'AirQualitySensor') return childAirQualityMock;
756
+ return createChainableMock();
757
+ });
758
+
759
+ // Act
760
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
761
+
762
+ // Assert
763
+ const calls = (mockEndpoint.addChildDeviceType as any).mock.calls;
764
+ const childNames = calls.map((call: any[]) => call[0]);
765
+ expect(childNames).toContain('HumiditySensor');
766
+ expect(childNames).toContain('AirQualitySensor');
767
+ expect(childNames).not.toContain('EnergyMonitor');
768
+ });
769
+
770
+ it('should not include onOffPlugInUnit in device types for endpoint mode', () => {
771
+ // Arrange
772
+ const capabilities = asPartial<AirConditionerCapabilities>({
773
+ supportsHeat: true,
774
+ supportsFanSpeedControl: true,
775
+ supportsEnergyMonitoring: true,
776
+ });
777
+
778
+ // Act
779
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
780
+
781
+ // Assert
782
+ const firstArg = (MatterbridgeEndpointMockFn as any).mock.calls[0][0];
783
+ const deviceTypeIds = firstArg.map((dt: any) => dt.id);
784
+ expect(deviceTypeIds).not.toContain('onOffPlugInUnit');
785
+ });
786
+ });
787
+
788
+ describe('filter monitoring cluster on AC endpoint', () => {
789
+ it('should call createDefaultHepaFilterMonitoringClusterServer when supportsFilterMonitoring is true', () => {
790
+ // Arrange
791
+ const capabilities = asPartial<AirConditionerCapabilities>({
792
+ supportsHeat: true,
793
+ supportsFanSpeedControl: true,
794
+ supportsFilterMonitoring: true,
795
+ });
796
+
797
+ // Act
798
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
799
+
800
+ // Assert
801
+ expect(mockEndpoint.createDefaultHepaFilterMonitoringClusterServer).toHaveBeenCalledTimes(1);
802
+ });
803
+
804
+ it('should not call createDefaultHepaFilterMonitoringClusterServer when supportsFilterMonitoring is false', () => {
805
+ // Arrange
806
+ const capabilities = asPartial<AirConditionerCapabilities>({
807
+ supportsHeat: true,
808
+ supportsFanSpeedControl: true,
809
+ supportsFilterMonitoring: false,
810
+ });
811
+
812
+ // Act
813
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
814
+
815
+ // Assert
816
+ expect(mockEndpoint.createDefaultHepaFilterMonitoringClusterServer).not.toHaveBeenCalled();
817
+ });
818
+
819
+ it('should maintain regression parity when filter monitoring capability is false (default)', () => {
820
+ // Arrange
821
+ const capabilities = { ...DEFAULT_AIR_CONDITIONER_CAPABILITIES };
822
+
823
+ // Act
824
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
825
+
826
+ // Assert
827
+ expect(mockEndpoint.createDefaultHepaFilterMonitoringClusterServer).not.toHaveBeenCalled();
828
+ });
829
+
830
+ it('should coexist with energy monitoring when both capabilities are enabled', () => {
831
+ // Arrange
832
+ const capabilities = asPartial<AirConditionerCapabilities>({
833
+ supportsHeat: true,
834
+ supportsFanSpeedControl: true,
835
+ supportsEnergyMonitoring: true,
836
+ supportsFilterMonitoring: true,
837
+ });
838
+
839
+ // Act
840
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
841
+
842
+ // Assert
843
+ expect(mockEndpoint.createDefaultPowerTopologyClusterServer).toHaveBeenCalled();
844
+ expect(mockEndpoint.createDefaultElectricalPowerMeasurementClusterServer).toHaveBeenCalled();
845
+ expect(mockEndpoint.createDefaultHepaFilterMonitoringClusterServer).toHaveBeenCalled();
846
+ });
847
+
848
+ it('should coexist with humidity and air quality sensors when all capabilities are enabled', () => {
849
+ // Arrange
850
+ const capabilities = asPartial<AirConditionerCapabilities>({
851
+ supportsHeat: true,
852
+ supportsFanSpeedControl: true,
853
+ supportsHumiditySensor: true,
854
+ supportsAirQualitySensor: true,
855
+ supportsFilterMonitoring: true,
856
+ });
857
+
858
+ // Act
859
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
860
+
861
+ // Assert
862
+ const calls = (mockEndpoint.addChildDeviceType as any).mock.calls;
863
+ const childNames = calls.map((call: any[]) => call[0]);
864
+ expect(childNames).toContain('HumiditySensor');
865
+ expect(childNames).toContain('AirQualitySensor');
866
+ // Filter monitoring is on the AC endpoint itself, not a child
867
+ expect(childNames).not.toContain('FilterMonitor');
868
+ expect(mockEndpoint.createDefaultHepaFilterMonitoringClusterServer).toHaveBeenCalled();
869
+ });
870
+ });
871
+
872
+ describe('Matter override configuration (Phase G)', () => {
873
+ it('should use default values when no options passed', () => {
874
+ // Arrange
875
+ const capabilities = asPartial<AirConditionerCapabilities>({
876
+ supportsHeat: true,
877
+ supportsFanSpeedControl: true,
878
+ });
879
+
880
+ // Act
881
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low);
882
+
883
+ // Assert
884
+ expect(mockEndpoint.createDefaultBasicInformationClusterServer).toHaveBeenCalledWith(
885
+ mockDevice.name,
886
+ mockDevice.id,
887
+ 0xfff1, // Default vendor ID
888
+ 'Matterbridge', // Default vendor name
889
+ 0x8000, // Default product ID
890
+ 'Matterbridge Air Conditioner', // Default product name
891
+ );
892
+ });
893
+
894
+ it('should use override values when options with all 4 fields are passed', () => {
895
+ // Arrange
896
+ const capabilities = asPartial<AirConditionerCapabilities>({
897
+ supportsHeat: true,
898
+ supportsFanSpeedControl: true,
899
+ });
900
+
901
+ // Act
902
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low, {
903
+ vendorId: 4996,
904
+ vendorName: 'LG',
905
+ productId: 1,
906
+ productName: 'My AC',
907
+ });
908
+
909
+ // Assert
910
+ expect(mockEndpoint.createDefaultBasicInformationClusterServer).toHaveBeenCalledWith(
911
+ mockDevice.name,
912
+ mockDevice.id,
913
+ 4996, // Custom vendor ID
914
+ 'LG', // Custom vendor name
915
+ 1, // Custom product ID
916
+ 'My AC', // Custom product name
917
+ );
918
+ });
919
+
920
+ it('should fall back to defaults when only some override fields are provided', () => {
921
+ // Arrange
922
+ const capabilities = asPartial<AirConditionerCapabilities>({
923
+ supportsHeat: true,
924
+ supportsFanSpeedControl: true,
925
+ });
926
+
927
+ // Act
928
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low, {
929
+ productName: 'Premium AC',
930
+ });
931
+
932
+ // Assert
933
+ expect(mockEndpoint.createDefaultBasicInformationClusterServer).toHaveBeenCalledWith(
934
+ mockDevice.name,
935
+ mockDevice.id,
936
+ 0xfff1, // Falls back to default
937
+ 'Matterbridge', // Falls back to default
938
+ 0x8000, // Falls back to default
939
+ 'Premium AC', // Custom value
940
+ );
941
+ });
942
+
943
+ it('should use undefined values when options are explicitly passed as undefined', () => {
944
+ // Arrange
945
+ const capabilities = asPartial<AirConditionerCapabilities>({
946
+ supportsHeat: true,
947
+ supportsFanSpeedControl: true,
948
+ });
949
+
950
+ // Act
951
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low, {
952
+ vendorId: undefined,
953
+ vendorName: undefined,
954
+ productId: undefined,
955
+ productName: undefined,
956
+ });
957
+
958
+ // Assert
959
+ expect(mockEndpoint.createDefaultBasicInformationClusterServer).toHaveBeenCalledWith(
960
+ mockDevice.name,
961
+ mockDevice.id,
962
+ 0xfff1, // Falls back to default when undefined
963
+ 'Matterbridge', // Falls back to default when undefined
964
+ 0x8000, // Falls back to default when undefined
965
+ 'Matterbridge Air Conditioner', // Falls back to default when undefined
966
+ );
967
+ });
968
+
969
+ it('should support partial override with multiple fields', () => {
970
+ // Arrange
971
+ const capabilities = asPartial<AirConditionerCapabilities>({
972
+ supportsHeat: true,
973
+ supportsFanSpeedControl: true,
974
+ });
975
+
976
+ // Act
977
+ buildAirConditionerEndpoint(mockDevice, capabilities, setpoints, FanControl.FanMode.Low, {
978
+ vendorId: 0xabcd,
979
+ productName: 'Custom Premium AC',
980
+ });
981
+
982
+ // Assert
983
+ expect(mockEndpoint.createDefaultBasicInformationClusterServer).toHaveBeenCalledWith(
984
+ mockDevice.name,
985
+ mockDevice.id,
986
+ 0xabcd, // Custom vendor ID
987
+ 'Matterbridge', // Falls back to default
988
+ 0x8000, // Falls back to default
989
+ 'Custom Premium AC', // Custom product name
990
+ );
991
+ });
992
+ });
993
+ });