@mp-consulting/homebridge-daikin-cloud 1.0.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 (202) hide show
  1. package/.claude/settings.json +3 -0
  2. package/.claude/settings.local.json +8 -0
  3. package/CLAUDE.md +34 -0
  4. package/LICENSE +176 -0
  5. package/README.md +180 -0
  6. package/changelog.md +217 -0
  7. package/config.md +2 -0
  8. package/config.schema.json +146 -0
  9. package/dist/src/accessories/air-conditioning-accessory.d.ts +9 -0
  10. package/dist/src/accessories/air-conditioning-accessory.d.ts.map +1 -0
  11. package/dist/src/accessories/air-conditioning-accessory.js +19 -0
  12. package/dist/src/accessories/air-conditioning-accessory.js.map +1 -0
  13. package/dist/src/accessories/altherma-accessory.d.ts +11 -0
  14. package/dist/src/accessories/altherma-accessory.d.ts.map +1 -0
  15. package/dist/src/accessories/altherma-accessory.js +31 -0
  16. package/dist/src/accessories/altherma-accessory.js.map +1 -0
  17. package/dist/src/accessories/base-accessory.d.ts +16 -0
  18. package/dist/src/accessories/base-accessory.d.ts.map +1 -0
  19. package/dist/src/accessories/base-accessory.js +56 -0
  20. package/dist/src/accessories/base-accessory.js.map +1 -0
  21. package/dist/src/accessories/index.d.ts +4 -0
  22. package/dist/src/accessories/index.d.ts.map +1 -0
  23. package/dist/src/accessories/index.js +20 -0
  24. package/dist/src/accessories/index.js.map +1 -0
  25. package/dist/src/api/daikin-cloud.repository.d.ts +4 -0
  26. package/dist/src/api/daikin-cloud.repository.d.ts.map +1 -0
  27. package/dist/src/api/daikin-cloud.repository.js +31 -0
  28. package/dist/src/api/daikin-cloud.repository.js.map +1 -0
  29. package/dist/src/api/index.d.ts +2 -0
  30. package/dist/src/api/index.d.ts.map +1 -0
  31. package/dist/src/api/index.js +18 -0
  32. package/dist/src/api/index.js.map +1 -0
  33. package/dist/src/device/accessory-factory.d.ts +36 -0
  34. package/dist/src/device/accessory-factory.d.ts.map +1 -0
  35. package/dist/src/device/accessory-factory.js +61 -0
  36. package/dist/src/device/accessory-factory.js.map +1 -0
  37. package/dist/src/device/capability-detector.d.ts +36 -0
  38. package/dist/src/device/capability-detector.d.ts.map +1 -0
  39. package/dist/src/device/capability-detector.js +130 -0
  40. package/dist/src/device/capability-detector.js.map +1 -0
  41. package/dist/src/device/capability-docs.d.ts +20 -0
  42. package/dist/src/device/capability-docs.d.ts.map +1 -0
  43. package/dist/src/device/capability-docs.js +98 -0
  44. package/dist/src/device/capability-docs.js.map +1 -0
  45. package/dist/src/device/index.d.ts +5 -0
  46. package/dist/src/device/index.d.ts.map +1 -0
  47. package/dist/src/device/index.js +21 -0
  48. package/dist/src/device/index.js.map +1 -0
  49. package/dist/src/device/profiles/device-profile.d.ts +42 -0
  50. package/dist/src/device/profiles/device-profile.d.ts.map +1 -0
  51. package/dist/src/device/profiles/device-profile.js +103 -0
  52. package/dist/src/device/profiles/device-profile.js.map +1 -0
  53. package/dist/src/device/profiles/index.d.ts +2 -0
  54. package/dist/src/device/profiles/index.d.ts.map +1 -0
  55. package/dist/src/device/profiles/index.js +18 -0
  56. package/dist/src/device/profiles/index.js.map +1 -0
  57. package/dist/src/features/base-feature.d.ts +65 -0
  58. package/dist/src/features/base-feature.d.ts.map +1 -0
  59. package/dist/src/features/base-feature.js +99 -0
  60. package/dist/src/features/base-feature.js.map +1 -0
  61. package/dist/src/features/feature-manager.d.ts +37 -0
  62. package/dist/src/features/feature-manager.d.ts.map +1 -0
  63. package/dist/src/features/feature-manager.js +68 -0
  64. package/dist/src/features/feature-manager.js.map +1 -0
  65. package/dist/src/features/index.d.ts +4 -0
  66. package/dist/src/features/index.d.ts.map +1 -0
  67. package/dist/src/features/index.js +20 -0
  68. package/dist/src/features/index.js.map +1 -0
  69. package/dist/src/features/modes/dry-operation-mode.feature.d.ts +16 -0
  70. package/dist/src/features/modes/dry-operation-mode.feature.d.ts.map +1 -0
  71. package/dist/src/features/modes/dry-operation-mode.feature.js +40 -0
  72. package/dist/src/features/modes/dry-operation-mode.feature.js.map +1 -0
  73. package/dist/src/features/modes/econo-mode.feature.d.ts +15 -0
  74. package/dist/src/features/modes/econo-mode.feature.d.ts.map +1 -0
  75. package/dist/src/features/modes/econo-mode.feature.js +37 -0
  76. package/dist/src/features/modes/econo-mode.feature.js.map +1 -0
  77. package/dist/src/features/modes/fan-only-operation-mode.feature.d.ts +16 -0
  78. package/dist/src/features/modes/fan-only-operation-mode.feature.d.ts.map +1 -0
  79. package/dist/src/features/modes/fan-only-operation-mode.feature.js +40 -0
  80. package/dist/src/features/modes/fan-only-operation-mode.feature.js.map +1 -0
  81. package/dist/src/features/modes/index.d.ts +8 -0
  82. package/dist/src/features/modes/index.d.ts.map +1 -0
  83. package/dist/src/features/modes/index.js +24 -0
  84. package/dist/src/features/modes/index.js.map +1 -0
  85. package/dist/src/features/modes/indoor-silent-mode.feature.d.ts +17 -0
  86. package/dist/src/features/modes/indoor-silent-mode.feature.d.ts.map +1 -0
  87. package/dist/src/features/modes/indoor-silent-mode.feature.js +47 -0
  88. package/dist/src/features/modes/indoor-silent-mode.feature.js.map +1 -0
  89. package/dist/src/features/modes/outdoor-silent-mode.feature.d.ts +15 -0
  90. package/dist/src/features/modes/outdoor-silent-mode.feature.d.ts.map +1 -0
  91. package/dist/src/features/modes/outdoor-silent-mode.feature.js +37 -0
  92. package/dist/src/features/modes/outdoor-silent-mode.feature.js.map +1 -0
  93. package/dist/src/features/modes/powerful-mode.feature.d.ts +15 -0
  94. package/dist/src/features/modes/powerful-mode.feature.d.ts.map +1 -0
  95. package/dist/src/features/modes/powerful-mode.feature.js +37 -0
  96. package/dist/src/features/modes/powerful-mode.feature.js.map +1 -0
  97. package/dist/src/features/modes/streamer-mode.feature.d.ts +15 -0
  98. package/dist/src/features/modes/streamer-mode.feature.d.ts.map +1 -0
  99. package/dist/src/features/modes/streamer-mode.feature.js +37 -0
  100. package/dist/src/features/modes/streamer-mode.feature.js.map +1 -0
  101. package/dist/src/index.d.ts +7 -0
  102. package/dist/src/index.d.ts.map +1 -0
  103. package/dist/src/index.js +7 -0
  104. package/dist/src/index.js.map +1 -0
  105. package/dist/src/platform.d.ts +33 -0
  106. package/dist/src/platform.d.ts.map +1 -0
  107. package/dist/src/platform.js +209 -0
  108. package/dist/src/platform.js.map +1 -0
  109. package/dist/src/services/climate-control.service.d.ts +43 -0
  110. package/dist/src/services/climate-control.service.d.ts.map +1 -0
  111. package/dist/src/services/climate-control.service.js +366 -0
  112. package/dist/src/services/climate-control.service.js.map +1 -0
  113. package/dist/src/services/hot-water-tank.service.d.ts +23 -0
  114. package/dist/src/services/hot-water-tank.service.d.ts.map +1 -0
  115. package/dist/src/services/hot-water-tank.service.js +214 -0
  116. package/dist/src/services/hot-water-tank.service.js.map +1 -0
  117. package/dist/src/services/index.d.ts +3 -0
  118. package/dist/src/services/index.d.ts.map +1 -0
  119. package/dist/src/services/index.js +19 -0
  120. package/dist/src/services/index.js.map +1 -0
  121. package/dist/src/settings.d.ts +9 -0
  122. package/dist/src/settings.d.ts.map +1 -0
  123. package/dist/src/settings.js +12 -0
  124. package/dist/src/settings.js.map +1 -0
  125. package/dist/src/types/daikin-enums.d.ts +61 -0
  126. package/dist/src/types/daikin-enums.d.ts.map +1 -0
  127. package/dist/src/types/daikin-enums.js +76 -0
  128. package/dist/src/types/daikin-enums.js.map +1 -0
  129. package/dist/src/types/device-capabilities.d.ts +47 -0
  130. package/dist/src/types/device-capabilities.d.ts.map +1 -0
  131. package/dist/src/types/device-capabilities.js +7 -0
  132. package/dist/src/types/device-capabilities.js.map +1 -0
  133. package/dist/src/types/index.d.ts +3 -0
  134. package/dist/src/types/index.d.ts.map +1 -0
  135. package/dist/src/types/index.js +19 -0
  136. package/dist/src/types/index.js.map +1 -0
  137. package/dist/src/utils/strings.d.ts +5 -0
  138. package/dist/src/utils/strings.d.ts.map +1 -0
  139. package/dist/src/utils/strings.js +22 -0
  140. package/dist/src/utils/strings.js.map +1 -0
  141. package/docs/Screenshot 2024-07-04 at 18.41.28.png +0 -0
  142. package/docs/api-response-for-BRP069A8x.json +520 -0
  143. package/docs/api-response-for-BRP069C4x-2.json +881 -0
  144. package/docs/api-response-for-BRP069C4x.json +916 -0
  145. package/docs/api-response-for-altherma.json +759 -0
  146. package/docs/api-response-for-altherma2.json +2735 -0
  147. package/docs/api-response-with-multiple-devices-incl-heatpump.json +2544 -0
  148. package/docs/cr-insance-altherma-id-0.json +834 -0
  149. package/docs/mock-air-to-air-dx23.json +759 -0
  150. package/docs/mock-air-to-air-dx4.json +1134 -0
  151. package/docs/mock-airpurifier-with-humidifier.json +732 -0
  152. package/docs/mock-airpurifier.json +450 -0
  153. package/docs/mock-altherma-air-to-water-lan.json +845 -0
  154. package/docs/mock-altherma-air-to-water-wlan.json +845 -0
  155. package/docs/mock-d2cnd-gas-boiler.json +649 -0
  156. package/docs/setpointmode-vs-controlmode-vs-setpoints-vs-sensorydata.txt +6 -0
  157. package/homebridge-ui/README.md +35 -0
  158. package/homebridge-ui/public/index.html +222 -0
  159. package/homebridge-ui/public/script.js +796 -0
  160. package/homebridge-ui/public/styles.css +456 -0
  161. package/homebridge-ui/server.js +909 -0
  162. package/jest.config.ts +13 -0
  163. package/package.json +63 -0
  164. package/test/fixtures/altherma-crSense-2.ts +834 -0
  165. package/test/fixtures/altherma-fraction.ts +719 -0
  166. package/test/fixtures/altherma-heat-pump-2.ts +479 -0
  167. package/test/fixtures/altherma-heat-pump.ts +758 -0
  168. package/test/fixtures/altherma-miladcerkic-off.ts +524 -0
  169. package/test/fixtures/altherma-miladcerkic.ts +524 -0
  170. package/test/fixtures/altherma-v1ckoeln.ts +645 -0
  171. package/test/fixtures/altherma-with-embedded-id-zero.ts +834 -0
  172. package/test/fixtures/dx23-airco-2.ts +343 -0
  173. package/test/fixtures/dx23-airco.ts +519 -0
  174. package/test/fixtures/dx4-airco.ts +915 -0
  175. package/test/fixtures/unknown-jan.ts +489 -0
  176. package/test/fixtures/unknown-kitchen-guests.ts +489 -0
  177. package/test/hbConfig/.daikin-controller-cloud-tokenset +8 -0
  178. package/test/hbConfig/.uix-dashboard.json +1 -0
  179. package/test/hbConfig/.uix-secrets +1 -0
  180. package/test/hbConfig/accessories/.cachedAccessories.bak +1 -0
  181. package/test/hbConfig/accessories/cachedAccessories +1 -0
  182. package/test/hbConfig/accessories/uiAccessoriesLayout.json +1 -0
  183. package/test/hbConfig/auth.json +10 -0
  184. package/test/hbConfig/backups/config-backups/config.json.1767953686461 +25 -0
  185. package/test/hbConfig/backups/config-backups/config.json.1767953695236 +29 -0
  186. package/test/hbConfig/backups/config-backups/config.json.1767953814763 +29 -0
  187. package/test/hbConfig/backups/config-backups/config.json.1767953823101 +29 -0
  188. package/test/hbConfig/backups/config-backups/config.json.1767954822835 +29 -0
  189. package/test/hbConfig/backups/config-backups/config.json.1767954859218 +29 -0
  190. package/test/hbConfig/config.json +33 -0
  191. package/test/hbConfig/daikin-cloud-certs/server.crt +22 -0
  192. package/test/hbConfig/daikin-cloud-certs/server.key +28 -0
  193. package/test/hbConfig/persist/AccessoryInfo.1E4A432551BA.json +1 -0
  194. package/test/hbConfig/persist/IdentifierCache.1E4A432551BA.json +1 -0
  195. package/test/integration/air-conditioning.test.ts +396 -0
  196. package/test/integration/altherma.test.ts +288 -0
  197. package/test/integration/platform.test.ts +101 -0
  198. package/test/mocks/index.ts +27 -0
  199. package/test/unit/api/__snapshots__/daikinCloud.test.ts.snap +1323 -0
  200. package/test/unit/api/daikinCloud.test.ts +12 -0
  201. package/test/unit/device/daikin-device.test.ts +29 -0
  202. package/test/unit/services/hot-water-tank.service.test.ts +107 -0
@@ -0,0 +1,909 @@
1
+ const { HomebridgePluginUiServer } = require('@homebridge/plugin-ui-utils');
2
+ const { resolve } = require('path');
3
+ const fs = require('fs');
4
+ const https = require('https');
5
+ const crypto = require('crypto');
6
+ const { execSync } = require('child_process');
7
+
8
+ // =============================================================================
9
+ // Configuration
10
+ // =============================================================================
11
+
12
+ const OIDC_CONFIG = {
13
+ authorizationEndpoint: 'https://idp.onecta.daikineurope.com/v1/oidc/authorize',
14
+ tokenEndpoint: 'https://idp.onecta.daikineurope.com/v1/oidc/token',
15
+ revokeEndpoint: 'https://idp.onecta.daikineurope.com/v1/oidc/revoke',
16
+ apiEndpoint: 'https://api.onecta.daikineurope.com',
17
+ scope: 'openid onecta:basic.integration',
18
+ };
19
+
20
+ const CLIMATE_CONTROL_IDS = ['climateControl', 'climateControlMainZone', 'climateControlSecondaryZone'];
21
+
22
+ // =============================================================================
23
+ // SSL Certificate Utilities
24
+ // =============================================================================
25
+
26
+ const SSLUtils = {
27
+ /**
28
+ * Check if a string is a valid IP address
29
+ */
30
+ isIPAddress(str) {
31
+ const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
32
+ const ipv6Pattern = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
33
+ return ipv4Pattern.test(str) || ipv6Pattern.test(str);
34
+ },
35
+
36
+ /**
37
+ * Generate self-signed certificate for HTTPS callback server
38
+ */
39
+ generateCert(hostname, certDir) {
40
+ const keyPath = resolve(certDir, 'server.key');
41
+ const certPath = resolve(certDir, 'server.crt');
42
+
43
+ if (!fs.existsSync(certDir)) {
44
+ fs.mkdirSync(certDir, { recursive: true });
45
+ }
46
+
47
+ // Return existing certs if valid
48
+ if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
49
+ try {
50
+ return {
51
+ key: fs.readFileSync(keyPath, 'utf8'),
52
+ cert: fs.readFileSync(certPath, 'utf8'),
53
+ };
54
+ } catch (e) {
55
+ // Regenerate if can't read
56
+ }
57
+ }
58
+
59
+ // Generate new certificate
60
+ try {
61
+ execSync(`openssl genrsa -out "${keyPath}" 2048`, { stdio: 'pipe' });
62
+
63
+ const isIP = this.isIPAddress(hostname);
64
+ const sanValue = isIP ? `IP:${hostname}` : `DNS:${hostname}`;
65
+ const subj = `/CN=${hostname}/O=Homebridge Daikin Cloud/C=US`;
66
+
67
+ execSync(
68
+ `openssl req -new -x509 -key "${keyPath}" -out "${certPath}" -days 365 -subj "${subj}" -addext "subjectAltName=${sanValue}"`,
69
+ { stdio: 'pipe' }
70
+ );
71
+
72
+ return {
73
+ key: fs.readFileSync(keyPath, 'utf8'),
74
+ cert: fs.readFileSync(certPath, 'utf8'),
75
+ };
76
+ } catch (error) {
77
+ throw new Error(`Failed to generate SSL certificate. Make sure openssl is installed. Error: ${error.message}`);
78
+ }
79
+ },
80
+ };
81
+
82
+ // =============================================================================
83
+ // Token Management
84
+ // =============================================================================
85
+
86
+ const TokenManager = {
87
+ /**
88
+ * Load token set from file
89
+ */
90
+ load(filePath) {
91
+ try {
92
+ if (fs.existsSync(filePath)) {
93
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
94
+ }
95
+ } catch (error) {
96
+ console.error('Error loading token set:', error.message);
97
+ }
98
+ return null;
99
+ },
100
+
101
+ /**
102
+ * Save token set to file
103
+ */
104
+ save(filePath, tokenSet) {
105
+ fs.writeFileSync(filePath, JSON.stringify(tokenSet, null, 2), 'utf8');
106
+ },
107
+
108
+ /**
109
+ * Delete token file
110
+ */
111
+ delete(filePath) {
112
+ try {
113
+ fs.unlinkSync(filePath);
114
+ } catch (error) {
115
+ if (error.code !== 'ENOENT') throw error;
116
+ }
117
+ },
118
+
119
+ /**
120
+ * Check token status
121
+ */
122
+ getStatus(tokenSet) {
123
+ if (!tokenSet || !tokenSet.access_token) {
124
+ return { authenticated: false, message: 'Not authenticated' };
125
+ }
126
+
127
+ const expiresAt = tokenSet.expires_at ? new Date(tokenSet.expires_at * 1000) : null;
128
+ const isExpired = expiresAt ? expiresAt < new Date() : false;
129
+ const hasRefreshToken = !!tokenSet.refresh_token;
130
+
131
+ return {
132
+ authenticated: true,
133
+ isExpired,
134
+ canRefresh: hasRefreshToken,
135
+ expiresAt: expiresAt ? expiresAt.toISOString() : null,
136
+ message: isExpired
137
+ ? (hasRefreshToken ? 'Token expired, will refresh automatically' : 'Token expired, re-authentication required')
138
+ : 'Authenticated',
139
+ };
140
+ },
141
+ };
142
+
143
+ // =============================================================================
144
+ // HTTP Utilities
145
+ // =============================================================================
146
+
147
+ const HttpUtils = {
148
+ /**
149
+ * Make HTTPS POST request with form data
150
+ */
151
+ async post(url, formData, headers = {}) {
152
+ return new Promise((resolve, reject) => {
153
+ const postData = new URLSearchParams(formData).toString();
154
+ const urlObj = new URL(url);
155
+
156
+ const options = {
157
+ hostname: urlObj.hostname,
158
+ port: 443,
159
+ path: urlObj.pathname,
160
+ method: 'POST',
161
+ headers: {
162
+ 'Content-Type': 'application/x-www-form-urlencoded',
163
+ 'Content-Length': Buffer.byteLength(postData),
164
+ ...headers,
165
+ },
166
+ };
167
+
168
+ const req = https.request(options, (res) => {
169
+ let data = '';
170
+ res.on('data', (chunk) => data += chunk);
171
+ res.on('end', () => resolve({ statusCode: res.statusCode, data, headers: res.headers }));
172
+ });
173
+
174
+ req.on('error', reject);
175
+ req.write(postData);
176
+ req.end();
177
+ });
178
+ },
179
+
180
+ /**
181
+ * Make HTTPS GET request
182
+ */
183
+ async get(url, accessToken) {
184
+ return new Promise((resolve, reject) => {
185
+ const urlObj = new URL(url);
186
+
187
+ const options = {
188
+ hostname: urlObj.hostname,
189
+ port: 443,
190
+ path: urlObj.pathname + urlObj.search,
191
+ method: 'GET',
192
+ headers: {
193
+ 'Authorization': `Bearer ${accessToken}`,
194
+ 'Accept': 'application/json',
195
+ },
196
+ };
197
+
198
+ const req = https.request(options, (res) => {
199
+ let data = '';
200
+ res.on('data', (chunk) => data += chunk);
201
+ res.on('end', () => resolve({ statusCode: res.statusCode, data, headers: res.headers }));
202
+ });
203
+
204
+ req.on('error', reject);
205
+ req.end();
206
+ });
207
+ },
208
+ };
209
+
210
+ // =============================================================================
211
+ // Device Data Extraction
212
+ // =============================================================================
213
+
214
+ const DeviceExtractor = {
215
+ /**
216
+ * Get management point by ID
217
+ */
218
+ getManagementPoint(device, embeddedId) {
219
+ return device.managementPoints?.find(mp => mp.embeddedId === embeddedId) || null;
220
+ },
221
+
222
+ /**
223
+ * Get climate control management point
224
+ */
225
+ getClimateControlPoint(device) {
226
+ for (const id of CLIMATE_CONTROL_IDS) {
227
+ const mp = this.getManagementPoint(device, id);
228
+ if (mp) return mp;
229
+ }
230
+ return null;
231
+ },
232
+
233
+ /**
234
+ * Extract device name
235
+ */
236
+ extractName(device) {
237
+ const climateControl = this.getClimateControlPoint(device);
238
+ return climateControl?.name?.value || device.id || 'Unknown Device';
239
+ },
240
+
241
+ /**
242
+ * Extract model info
243
+ */
244
+ extractModel(device) {
245
+ const gateway = this.getManagementPoint(device, 'gateway');
246
+ return gateway?.modelInfo?.value || device.deviceModel || 'Unknown Model';
247
+ },
248
+
249
+ /**
250
+ * Extract device type
251
+ */
252
+ extractType(device) {
253
+ if (!device.managementPoints) return device.type || 'Unknown Type';
254
+
255
+ for (const mp of device.managementPoints) {
256
+ if (mp.embeddedId === 'climateControl') return 'Climate Control';
257
+ if (mp.embeddedId === 'domesticHotWaterTank') return 'Hot Water Tank';
258
+ }
259
+ return device.type || 'Unknown Type';
260
+ },
261
+
262
+ /**
263
+ * Check if device is online
264
+ */
265
+ isOnline(device) {
266
+ return device.isCloudConnectionUp?.value ?? false;
267
+ },
268
+
269
+ /**
270
+ * Extract room temperature
271
+ */
272
+ extractRoomTemp(device) {
273
+ const climateControl = this.getClimateControlPoint(device);
274
+ const roomTemp = climateControl?.sensoryData?.value?.roomTemperature;
275
+ return roomTemp?.value !== undefined ? `${roomTemp.value}${roomTemp.unit || '°C'}` : null;
276
+ },
277
+
278
+ /**
279
+ * Extract outdoor temperature
280
+ */
281
+ extractOutdoorTemp(device) {
282
+ const climateControl = this.getClimateControlPoint(device);
283
+ const outdoorTemp = climateControl?.sensoryData?.value?.outdoorTemperature;
284
+ return outdoorTemp?.value !== undefined ? `${outdoorTemp.value}${outdoorTemp.unit || '°C'}` : null;
285
+ },
286
+
287
+ /**
288
+ * Extract operation mode
289
+ */
290
+ extractOperationMode(device) {
291
+ const climateControl = this.getClimateControlPoint(device);
292
+ return climateControl?.operationMode?.value || null;
293
+ },
294
+
295
+ /**
296
+ * Extract power state
297
+ */
298
+ extractPowerState(device) {
299
+ const climateControl = this.getClimateControlPoint(device);
300
+ return climateControl?.onOffMode?.value || null;
301
+ },
302
+
303
+ /**
304
+ * Extract device features
305
+ */
306
+ extractFeatures(device) {
307
+ const features = [];
308
+ if (!device.managementPoints) return features;
309
+
310
+ for (const mp of device.managementPoints) {
311
+ if (CLIMATE_CONTROL_IDS.includes(mp.embeddedId)) {
312
+ if (mp.onOffMode) features.push('Power');
313
+ if (mp.temperatureControl) features.push('Temperature');
314
+ if (mp.operationMode) features.push('Mode');
315
+ if (mp.fanControl) features.push('Fan');
316
+ if (mp.sensoryData) features.push('Sensors');
317
+ }
318
+ if (mp.embeddedId === 'domesticHotWaterTank') {
319
+ if (mp.onOffMode) features.push('Hot Water');
320
+ if (mp.temperatureControl) features.push('Water Temp');
321
+ }
322
+ }
323
+ return features;
324
+ },
325
+
326
+ /**
327
+ * Extract all device info
328
+ */
329
+ extractAll(device) {
330
+ return {
331
+ id: device.id,
332
+ name: this.extractName(device),
333
+ model: this.extractModel(device),
334
+ type: this.extractType(device),
335
+ online: this.isOnline(device),
336
+ features: this.extractFeatures(device),
337
+ roomTemp: this.extractRoomTemp(device),
338
+ outdoorTemp: this.extractOutdoorTemp(device),
339
+ operationMode: this.extractOperationMode(device),
340
+ powerState: this.extractPowerState(device),
341
+ };
342
+ },
343
+ };
344
+
345
+ // =============================================================================
346
+ // OAuth Service
347
+ // =============================================================================
348
+
349
+ class OAuthService {
350
+ /**
351
+ * Exchange authorization code for tokens
352
+ */
353
+ static async exchangeCode(code, clientId, clientSecret, redirectUri) {
354
+ const response = await HttpUtils.post(OIDC_CONFIG.tokenEndpoint, {
355
+ grant_type: 'authorization_code',
356
+ code,
357
+ redirect_uri: redirectUri,
358
+ client_id: clientId,
359
+ client_secret: clientSecret,
360
+ });
361
+
362
+ const tokenSet = JSON.parse(response.data);
363
+ if (tokenSet.error) {
364
+ throw new Error(tokenSet.error_description || tokenSet.error);
365
+ }
366
+
367
+ // Calculate expires_at from expires_in
368
+ if (tokenSet.expires_in && !tokenSet.expires_at) {
369
+ tokenSet.expires_at = Math.floor(Date.now() / 1000) + tokenSet.expires_in;
370
+ }
371
+
372
+ return tokenSet;
373
+ }
374
+
375
+ /**
376
+ * Revoke token at server
377
+ */
378
+ static async revokeToken(token, clientId, clientSecret) {
379
+ await HttpUtils.post(OIDC_CONFIG.revokeEndpoint, {
380
+ token,
381
+ token_type_hint: 'refresh_token',
382
+ client_id: clientId,
383
+ client_secret: clientSecret,
384
+ });
385
+ }
386
+
387
+ /**
388
+ * Build authorization URL
389
+ */
390
+ static buildAuthUrl(clientId, redirectUri, state) {
391
+ const url = new URL(OIDC_CONFIG.authorizationEndpoint);
392
+ url.searchParams.set('response_type', 'code');
393
+ url.searchParams.set('client_id', clientId);
394
+ url.searchParams.set('redirect_uri', redirectUri);
395
+ url.searchParams.set('scope', OIDC_CONFIG.scope);
396
+ url.searchParams.set('state', state);
397
+ return url.toString();
398
+ }
399
+ }
400
+
401
+ // =============================================================================
402
+ // API Service
403
+ // =============================================================================
404
+
405
+ class ApiService {
406
+ /**
407
+ * Make authenticated API request
408
+ */
409
+ static async request(path, accessToken) {
410
+ const response = await HttpUtils.get(OIDC_CONFIG.apiEndpoint + path, accessToken);
411
+
412
+ if (response.statusCode === 401) throw new Error('Token expired or invalid');
413
+ if (response.statusCode === 429) throw new Error('Rate limit exceeded');
414
+ if (response.statusCode >= 400) throw new Error(`API error: ${response.statusCode}`);
415
+
416
+ try {
417
+ return JSON.parse(response.data);
418
+ } catch (e) {
419
+ throw new Error('Invalid API response');
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Make request and return with headers
425
+ */
426
+ static async requestWithHeaders(path, accessToken) {
427
+ const response = await HttpUtils.get(OIDC_CONFIG.apiEndpoint + path, accessToken);
428
+
429
+ if (response.statusCode >= 400) throw new Error(`API error: ${response.statusCode}`);
430
+
431
+ const rateLimit = {
432
+ limit: response.headers['x-ratelimit-limit'] || response.headers['ratelimit-limit'],
433
+ remaining: response.headers['x-ratelimit-remaining'] || response.headers['ratelimit-remaining'],
434
+ reset: response.headers['x-ratelimit-reset'] || response.headers['ratelimit-reset'],
435
+ };
436
+
437
+ return {
438
+ data: JSON.parse(response.data),
439
+ headers: response.headers,
440
+ rateLimit,
441
+ };
442
+ }
443
+ }
444
+
445
+ // =============================================================================
446
+ // Callback Server
447
+ // =============================================================================
448
+
449
+ class CallbackServer {
450
+ constructor() {
451
+ this.server = null;
452
+ this.port = null;
453
+ this.connections = new Set();
454
+ }
455
+
456
+ /**
457
+ * Start HTTPS server
458
+ */
459
+ async start(port, hostname, certDir, requestHandler) {
460
+ await this.stop();
461
+
462
+ return new Promise((resolve, reject) => {
463
+ const tryStart = (attempt = 1) => {
464
+ try {
465
+ const { key, cert } = SSLUtils.generateCert(hostname, certDir);
466
+
467
+ this.server = https.createServer({ key, cert }, requestHandler);
468
+
469
+ this.server.on('connection', (conn) => {
470
+ this.connections.add(conn);
471
+ conn.on('close', () => this.connections.delete(conn));
472
+ });
473
+
474
+ this.server.on('error', (err) => {
475
+ if (err.code === 'EADDRINUSE' && attempt < 3) {
476
+ console.warn(`Port ${port} in use, retrying in 1 second (attempt ${attempt}/3)...`);
477
+ setTimeout(() => tryStart(attempt + 1), 1000);
478
+ } else {
479
+ console.error('Callback server error:', err.message);
480
+ reject(err);
481
+ }
482
+ });
483
+
484
+ this.server.listen(port, '0.0.0.0', () => {
485
+ console.log(`HTTPS callback server listening on port ${port}`);
486
+ this.port = port;
487
+ resolve({ success: true, port });
488
+ });
489
+ } catch (error) {
490
+ reject(error);
491
+ }
492
+ };
493
+
494
+ tryStart();
495
+ });
496
+ }
497
+
498
+ /**
499
+ * Stop server
500
+ */
501
+ async stop() {
502
+ return new Promise((resolve) => {
503
+ if (!this.server) {
504
+ resolve({ success: true });
505
+ return;
506
+ }
507
+
508
+ // Force close connections
509
+ for (const conn of this.connections) {
510
+ conn.destroy();
511
+ }
512
+ this.connections.clear();
513
+
514
+ const timeout = setTimeout(() => {
515
+ console.warn('HTTPS callback server close timed out, forcing cleanup');
516
+ this.server = null;
517
+ this.port = null;
518
+ resolve({ success: true });
519
+ }, 2000);
520
+
521
+ this.server.close(() => {
522
+ clearTimeout(timeout);
523
+ console.log('HTTPS callback server stopped');
524
+ this.server = null;
525
+ this.port = null;
526
+ resolve({ success: true });
527
+ });
528
+ });
529
+ }
530
+
531
+ get isRunning() {
532
+ return this.server !== null;
533
+ }
534
+ }
535
+
536
+ // =============================================================================
537
+ // HTML Response Templates
538
+ // =============================================================================
539
+
540
+ const HtmlTemplates = {
541
+ callbackResponse(success, message) {
542
+ return `
543
+ <!DOCTYPE html>
544
+ <html>
545
+ <head>
546
+ <title>Daikin Authentication</title>
547
+ <style>
548
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
549
+ .container { text-align: center; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); max-width: 400px; }
550
+ .icon { font-size: 4rem; margin-bottom: 1rem; }
551
+ .success { color: #4caf50; }
552
+ .error { color: #f44336; }
553
+ h1 { margin: 0 0 1rem; font-size: 1.5rem; }
554
+ p { color: #666; margin: 0; }
555
+ </style>
556
+ </head>
557
+ <body>
558
+ <div class="container">
559
+ <div class="icon ${success ? 'success' : 'error'}">${success ? '✓' : '✗'}</div>
560
+ <h1>${success ? 'Success!' : 'Error'}</h1>
561
+ <p>${message}</p>
562
+ <p style="margin-top: 1rem; font-size: 0.9rem;">You can close this window and return to Homebridge.</p>
563
+ </div>
564
+ </body>
565
+ </html>`;
566
+ },
567
+ };
568
+
569
+ // =============================================================================
570
+ // Main Server Class
571
+ // =============================================================================
572
+
573
+ class DaikinCloudUiServer extends HomebridgePluginUiServer {
574
+ constructor() {
575
+ super();
576
+
577
+ this.pendingAuth = null;
578
+ this.authResult = null;
579
+ this.authStartInProgress = false;
580
+ this.callbackServer = new CallbackServer();
581
+
582
+ this.registerHandlers();
583
+ this.ready();
584
+ }
585
+
586
+ // -------------------------------------------------------------------------
587
+ // Path Helpers
588
+ // -------------------------------------------------------------------------
589
+
590
+ getTokenFilePath() {
591
+ return resolve(this.homebridgeStoragePath || process.env.UIX_STORAGE_PATH || '', '.daikin-controller-cloud-tokenset');
592
+ }
593
+
594
+ getCertDir() {
595
+ return resolve(this.homebridgeStoragePath || process.env.UIX_STORAGE_PATH || '', 'daikin-cloud-certs');
596
+ }
597
+
598
+ // -------------------------------------------------------------------------
599
+ // Request Handler Registration
600
+ // -------------------------------------------------------------------------
601
+
602
+ registerHandlers() {
603
+ this.onRequest('/auth/status', this.handleGetAuthStatus.bind(this));
604
+ this.onRequest('/auth/start', this.handleStartAuth.bind(this));
605
+ this.onRequest('/auth/', this.handleCallback.bind(this));
606
+ this.onRequest('/auth/revoke', this.handleRevokeAuth.bind(this));
607
+ this.onRequest('/auth/test', this.handleTestConnection.bind(this));
608
+ this.onRequest('/auth/poll', this.handlePollAuthResult.bind(this));
609
+ this.onRequest('/auth/stop-server', this.handleStopServer.bind(this));
610
+ this.onRequest('/config/validate', this.handleValidateConfig.bind(this));
611
+ this.onRequest('/devices/list', this.handleListDevices.bind(this));
612
+ this.onRequest('/api/rate-limit', this.handleGetRateLimit.bind(this));
613
+ }
614
+
615
+ // -------------------------------------------------------------------------
616
+ // Auth Status Handler
617
+ // -------------------------------------------------------------------------
618
+
619
+ async handleGetAuthStatus() {
620
+ try {
621
+ const tokenSet = TokenManager.load(this.getTokenFilePath());
622
+ return TokenManager.getStatus(tokenSet);
623
+ } catch (error) {
624
+ return { authenticated: false, error: error.message, message: 'Error reading token status' };
625
+ }
626
+ }
627
+
628
+ // -------------------------------------------------------------------------
629
+ // Start Auth Handler
630
+ // -------------------------------------------------------------------------
631
+
632
+ async handleStartAuth(payload) {
633
+ if (this.authStartInProgress) {
634
+ if (this.pendingAuth && this.callbackServer.isRunning) {
635
+ return {
636
+ authUrl: OAuthService.buildAuthUrl(this.pendingAuth.clientId, this.pendingAuth.redirectUri, this.pendingAuth.state),
637
+ state: this.pendingAuth.state,
638
+ redirectUri: this.pendingAuth.redirectUri,
639
+ callbackServerRunning: true,
640
+ message: 'Authorization already in progress.',
641
+ };
642
+ }
643
+ throw new Error('Authorization already in progress. Please wait.');
644
+ }
645
+
646
+ this.authStartInProgress = true;
647
+
648
+ try {
649
+ const { clientId, clientSecret, callbackServerExternalAddress, callbackServerPort } = payload;
650
+
651
+ if (!clientId || !clientSecret) throw new Error('Client ID and Client Secret are required');
652
+ if (!callbackServerExternalAddress) throw new Error('Callback Server Address is required');
653
+
654
+ const port = parseInt(callbackServerPort || '8582', 10);
655
+ const redirectUri = `https://${callbackServerExternalAddress}:${port}/callback`;
656
+ const state = crypto.randomBytes(32).toString('hex');
657
+
658
+ this.authResult = null;
659
+ this.pendingAuth = { state, clientId, clientSecret, redirectUri, port, createdAt: Date.now() };
660
+
661
+ await this.callbackServer.start(port, callbackServerExternalAddress, this.getCertDir(), (req, res) => {
662
+ this.handleHttpsCallback(req, res);
663
+ });
664
+
665
+ return {
666
+ authUrl: OAuthService.buildAuthUrl(clientId, redirectUri, state),
667
+ state,
668
+ redirectUri,
669
+ callbackServerRunning: true,
670
+ message: 'Authorization URL generated. Callback server is running.',
671
+ };
672
+ } catch (error) {
673
+ this.pendingAuth = null;
674
+ this.authStartInProgress = false;
675
+ throw error;
676
+ }
677
+ }
678
+
679
+ // -------------------------------------------------------------------------
680
+ // HTTPS Callback Handler
681
+ // -------------------------------------------------------------------------
682
+
683
+ handleHttpsCallback(req, res) {
684
+ const url = new URL(req.url, `https://${req.headers.host}`);
685
+
686
+ if (url.pathname !== '/callback') {
687
+ res.writeHead(404);
688
+ res.end('Not found');
689
+ return;
690
+ }
691
+
692
+ const code = url.searchParams.get('code');
693
+ const state = url.searchParams.get('state');
694
+ const error = url.searchParams.get('error');
695
+ const errorDescription = url.searchParams.get('error_description');
696
+
697
+ if (error) {
698
+ this.authResult = { success: false, error: errorDescription || error };
699
+ this.sendCallbackResponse(res, false, errorDescription || error);
700
+ return;
701
+ }
702
+
703
+ if (!code || !state) {
704
+ this.authResult = { success: false, error: 'Missing code or state parameter' };
705
+ this.sendCallbackResponse(res, false, 'Missing authorization code');
706
+ return;
707
+ }
708
+
709
+ if (!this.pendingAuth || state !== this.pendingAuth.state) {
710
+ this.authResult = { success: false, error: 'Invalid state parameter' };
711
+ this.sendCallbackResponse(res, false, 'Invalid state parameter');
712
+ return;
713
+ }
714
+
715
+ const { clientId, clientSecret, redirectUri } = this.pendingAuth;
716
+
717
+ OAuthService.exchangeCode(code, clientId, clientSecret, redirectUri)
718
+ .then((tokenSet) => {
719
+ TokenManager.save(this.getTokenFilePath(), tokenSet);
720
+ this.authResult = {
721
+ success: true,
722
+ message: 'Authentication successful!',
723
+ expiresAt: tokenSet.expires_at ? new Date(tokenSet.expires_at * 1000).toISOString() : null,
724
+ };
725
+ this.pendingAuth = null;
726
+ this.sendCallbackResponse(res, true, 'Authentication successful! You can close this window.');
727
+ })
728
+ .catch((err) => {
729
+ this.authResult = { success: false, error: err.message };
730
+ this.sendCallbackResponse(res, false, `Token exchange failed: ${err.message}`);
731
+ });
732
+ }
733
+
734
+ sendCallbackResponse(res, success, message) {
735
+ res.writeHead(200, { 'Content-Type': 'text/html' });
736
+ res.end(HtmlTemplates.callbackResponse(success, message));
737
+ }
738
+
739
+ // -------------------------------------------------------------------------
740
+ // Manual Callback Handler
741
+ // -------------------------------------------------------------------------
742
+
743
+ async handleCallback(payload) {
744
+ const { code, state } = payload;
745
+
746
+ if (!this.pendingAuth) throw new Error('No pending authorization. Please start the auth flow again.');
747
+ if (state !== this.pendingAuth.state) throw new Error('Invalid state parameter. Possible CSRF attack.');
748
+ if (Date.now() - this.pendingAuth.createdAt > 5 * 60 * 1000) {
749
+ this.pendingAuth = null;
750
+ throw new Error('Authorization request expired. Please try again.');
751
+ }
752
+
753
+ const { clientId, clientSecret, redirectUri } = this.pendingAuth;
754
+
755
+ try {
756
+ const tokenSet = await OAuthService.exchangeCode(code, clientId, clientSecret, redirectUri);
757
+ TokenManager.save(this.getTokenFilePath(), tokenSet);
758
+ this.pendingAuth = null;
759
+
760
+ return {
761
+ success: true,
762
+ message: 'Authentication successful! You can now close this window and restart Homebridge.',
763
+ expiresAt: tokenSet.expires_at ? new Date(tokenSet.expires_at * 1000).toISOString() : null,
764
+ };
765
+ } catch (error) {
766
+ this.pendingAuth = null;
767
+ throw new Error(`Token exchange failed: ${error.message}`);
768
+ }
769
+ }
770
+
771
+ // -------------------------------------------------------------------------
772
+ // Poll Auth Result Handler
773
+ // -------------------------------------------------------------------------
774
+
775
+ async handlePollAuthResult() {
776
+ if (this.authResult) {
777
+ const result = this.authResult;
778
+ this.authResult = null;
779
+ await this.handleStopServer();
780
+ return result;
781
+ }
782
+ return { pending: true };
783
+ }
784
+
785
+ // -------------------------------------------------------------------------
786
+ // Stop Server Handler
787
+ // -------------------------------------------------------------------------
788
+
789
+ async handleStopServer() {
790
+ this.authStartInProgress = false;
791
+ return await this.callbackServer.stop();
792
+ }
793
+
794
+ // -------------------------------------------------------------------------
795
+ // Revoke Auth Handler
796
+ // -------------------------------------------------------------------------
797
+
798
+ async handleRevokeAuth(payload) {
799
+ const tokenSet = TokenManager.load(this.getTokenFilePath());
800
+ if (!tokenSet) return { success: true, message: 'No tokens to revoke' };
801
+
802
+ const { clientId, clientSecret } = payload;
803
+
804
+ if (tokenSet.refresh_token && clientId && clientSecret) {
805
+ try {
806
+ await OAuthService.revokeToken(tokenSet.refresh_token, clientId, clientSecret);
807
+ } catch (error) {
808
+ console.warn('Failed to revoke token at server:', error.message);
809
+ }
810
+ }
811
+
812
+ TokenManager.delete(this.getTokenFilePath());
813
+ return { success: true, message: 'Authentication revoked. You will need to re-authenticate.' };
814
+ }
815
+
816
+ // -------------------------------------------------------------------------
817
+ // Test Connection Handler
818
+ // -------------------------------------------------------------------------
819
+
820
+ async handleTestConnection() {
821
+ const tokenSet = TokenManager.load(this.getTokenFilePath());
822
+ if (!tokenSet?.access_token) {
823
+ return { success: false, message: 'Not authenticated. Please authenticate first.' };
824
+ }
825
+
826
+ try {
827
+ const result = await ApiService.request('/v1/gateway-devices', tokenSet.access_token);
828
+ return {
829
+ success: true,
830
+ message: `Connection successful! Found ${result.length || 0} device(s).`,
831
+ deviceCount: result.length || 0,
832
+ };
833
+ } catch (error) {
834
+ return { success: false, message: `Connection failed: ${error.message}`, error: error.message };
835
+ }
836
+ }
837
+
838
+ // -------------------------------------------------------------------------
839
+ // List Devices Handler
840
+ // -------------------------------------------------------------------------
841
+
842
+ async handleListDevices() {
843
+ const tokenSet = TokenManager.load(this.getTokenFilePath());
844
+ if (!tokenSet?.access_token) {
845
+ return { success: false, devices: [], message: 'Not authenticated. Please authenticate first.' };
846
+ }
847
+
848
+ try {
849
+ const gatewayDevices = await ApiService.request('/v1/gateway-devices', tokenSet.access_token);
850
+ const devices = gatewayDevices.map(device => DeviceExtractor.extractAll(device));
851
+
852
+ return { success: true, devices, message: `Found ${devices.length} device(s).` };
853
+ } catch (error) {
854
+ return { success: false, devices: [], message: `Failed to fetch devices: ${error.message}`, error: error.message };
855
+ }
856
+ }
857
+
858
+ // -------------------------------------------------------------------------
859
+ // Get Rate Limit Handler
860
+ // -------------------------------------------------------------------------
861
+
862
+ async handleGetRateLimit() {
863
+ const tokenSet = TokenManager.load(this.getTokenFilePath());
864
+ if (!tokenSet?.access_token) {
865
+ return { success: false, message: 'Not authenticated' };
866
+ }
867
+
868
+ try {
869
+ const result = await ApiService.requestWithHeaders('/v1/gateway-devices', tokenSet.access_token);
870
+ return { success: true, headers: result.headers, rateLimit: result.rateLimit };
871
+ } catch (error) {
872
+ return { success: false, message: error.message };
873
+ }
874
+ }
875
+
876
+ // -------------------------------------------------------------------------
877
+ // Validate Config Handler
878
+ // -------------------------------------------------------------------------
879
+
880
+ async handleValidateConfig(payload) {
881
+ const errors = [];
882
+ const warnings = [];
883
+ const { clientId, clientSecret, callbackServerExternalAddress, callbackServerPort } = payload;
884
+
885
+ if (!clientId) errors.push('Client ID is required. Get it from the Daikin Developer Portal.');
886
+ if (!clientSecret) errors.push('Client Secret is required. Get it from the Daikin Developer Portal.');
887
+
888
+ if (!callbackServerExternalAddress) {
889
+ errors.push('Callback Server External Address is required.');
890
+ } else if (callbackServerExternalAddress === 'localhost' || callbackServerExternalAddress === '127.0.0.1') {
891
+ errors.push('Callback address cannot be localhost. Use your external IP or domain.');
892
+ }
893
+
894
+ const port = parseInt(callbackServerPort || '8582', 10);
895
+ if (isNaN(port) || port < 1 || port > 65535) {
896
+ errors.push('Invalid port number. Must be between 1 and 65535.');
897
+ } else if (port < 1024) {
898
+ warnings.push('Using a privileged port (< 1024) may require root permissions.');
899
+ }
900
+
901
+ return { valid: errors.length === 0, errors, warnings };
902
+ }
903
+ }
904
+
905
+ // =============================================================================
906
+ // Initialize Server
907
+ // =============================================================================
908
+
909
+ (() => new DaikinCloudUiServer())();