@mp-consulting/homebridge-daikin-cloud 1.5.2 → 1.6.1

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 (211) hide show
  1. package/README.md +40 -27
  2. package/config.schema.json +66 -12
  3. package/dist/src/accessories/air-conditioning-accessory.js +1 -1
  4. package/dist/src/accessories/air-conditioning-accessory.js.map +1 -1
  5. package/dist/src/accessories/altherma-accessory.js +1 -1
  6. package/dist/src/accessories/altherma-accessory.js.map +1 -1
  7. package/dist/src/accessories/base-accessory.d.ts +5 -4
  8. package/dist/src/accessories/base-accessory.d.ts.map +1 -1
  9. package/dist/src/accessories/base-accessory.js +9 -10
  10. package/dist/src/accessories/base-accessory.js.map +1 -1
  11. package/dist/src/api/daikin-api.d.ts +22 -6
  12. package/dist/src/api/daikin-api.d.ts.map +1 -1
  13. package/dist/src/api/daikin-api.js +49 -23
  14. package/dist/src/api/daikin-api.js.map +1 -1
  15. package/dist/src/api/daikin-controller.d.ts +4 -48
  16. package/dist/src/api/daikin-controller.d.ts.map +1 -1
  17. package/dist/src/api/daikin-controller.js +14 -84
  18. package/dist/src/api/daikin-controller.js.map +1 -1
  19. package/dist/src/api/daikin-mobile-oauth.d.ts +11 -40
  20. package/dist/src/api/daikin-mobile-oauth.d.ts.map +1 -1
  21. package/dist/src/api/daikin-mobile-oauth.js +36 -160
  22. package/dist/src/api/daikin-mobile-oauth.js.map +1 -1
  23. package/dist/src/api/daikin-oauth.d.ts +10 -37
  24. package/dist/src/api/daikin-oauth.d.ts.map +1 -1
  25. package/dist/src/api/daikin-oauth.js +36 -166
  26. package/dist/src/api/daikin-oauth.js.map +1 -1
  27. package/dist/src/api/daikin-schemas.d.ts +20 -22
  28. package/dist/src/api/daikin-schemas.d.ts.map +1 -1
  29. package/dist/src/api/daikin-schemas.js +17 -49
  30. package/dist/src/api/daikin-schemas.js.map +1 -1
  31. package/dist/src/api/daikin-types.d.ts +11 -23
  32. package/dist/src/api/daikin-types.d.ts.map +1 -1
  33. package/dist/src/api/daikin-types.js.map +1 -1
  34. package/dist/src/api/daikin-websocket.d.ts +4 -5
  35. package/dist/src/api/daikin-websocket.d.ts.map +1 -1
  36. package/dist/src/api/daikin-websocket.js +62 -34
  37. package/dist/src/api/daikin-websocket.js.map +1 -1
  38. package/dist/src/api/http-defaults.d.ts +3 -2
  39. package/dist/src/api/http-defaults.d.ts.map +1 -1
  40. package/dist/src/api/http-defaults.js.map +1 -1
  41. package/dist/src/api/http-transport.d.ts +14 -2
  42. package/dist/src/api/http-transport.d.ts.map +1 -1
  43. package/dist/src/api/http-transport.js +32 -9
  44. package/dist/src/api/http-transport.js.map +1 -1
  45. package/dist/src/api/token-client.d.ts +82 -0
  46. package/dist/src/api/token-client.d.ts.map +1 -0
  47. package/dist/src/api/token-client.js +188 -0
  48. package/dist/src/api/token-client.js.map +1 -0
  49. package/dist/src/api/token-storage.d.ts +6 -0
  50. package/dist/src/api/token-storage.d.ts.map +1 -1
  51. package/dist/src/api/token-storage.js +27 -1
  52. package/dist/src/api/token-storage.js.map +1 -1
  53. package/dist/src/config/config-manager.d.ts +69 -125
  54. package/dist/src/config/config-manager.d.ts.map +1 -1
  55. package/dist/src/config/config-manager.js +288 -225
  56. package/dist/src/config/config-manager.js.map +1 -1
  57. package/dist/src/constants/api.constants.d.ts +6 -0
  58. package/dist/src/constants/api.constants.d.ts.map +1 -1
  59. package/dist/src/constants/api.constants.js +7 -1
  60. package/dist/src/constants/api.constants.js.map +1 -1
  61. package/dist/src/constants/auth.constants.d.ts +11 -0
  62. package/dist/src/constants/auth.constants.d.ts.map +1 -1
  63. package/dist/src/constants/auth.constants.js +12 -1
  64. package/dist/src/constants/auth.constants.js.map +1 -1
  65. package/dist/src/constants/device.constants.d.ts +1 -7
  66. package/dist/src/constants/device.constants.d.ts.map +1 -1
  67. package/dist/src/constants/device.constants.js +2 -11
  68. package/dist/src/constants/device.constants.js.map +1 -1
  69. package/dist/src/constants/time.constants.d.ts +0 -2
  70. package/dist/src/constants/time.constants.d.ts.map +1 -1
  71. package/dist/src/constants/time.constants.js +1 -3
  72. package/dist/src/constants/time.constants.js.map +1 -1
  73. package/dist/src/device/accessory-factory.d.ts +0 -5
  74. package/dist/src/device/accessory-factory.d.ts.map +1 -1
  75. package/dist/src/device/accessory-factory.js +0 -7
  76. package/dist/src/device/accessory-factory.js.map +1 -1
  77. package/dist/src/device/capability-detector.d.ts +9 -1
  78. package/dist/src/device/capability-detector.d.ts.map +1 -1
  79. package/dist/src/device/capability-detector.js +32 -28
  80. package/dist/src/device/capability-detector.js.map +1 -1
  81. package/dist/src/device/capability-docs.d.ts.map +1 -1
  82. package/dist/src/device/capability-docs.js +3 -0
  83. package/dist/src/device/capability-docs.js.map +1 -1
  84. package/dist/src/device/data-paths.d.ts +18 -0
  85. package/dist/src/device/data-paths.d.ts.map +1 -0
  86. package/dist/src/device/data-paths.js +20 -0
  87. package/dist/src/device/data-paths.js.map +1 -0
  88. package/dist/src/device/profiles/device-profile.d.ts +0 -5
  89. package/dist/src/device/profiles/device-profile.d.ts.map +1 -1
  90. package/dist/src/device/profiles/device-profile.js +0 -21
  91. package/dist/src/device/profiles/device-profile.js.map +1 -1
  92. package/dist/src/features/base-feature.d.ts +53 -9
  93. package/dist/src/features/base-feature.d.ts.map +1 -1
  94. package/dist/src/features/base-feature.js +93 -40
  95. package/dist/src/features/base-feature.js.map +1 -1
  96. package/dist/src/features/feature-manager.d.ts +27 -15
  97. package/dist/src/features/feature-manager.d.ts.map +1 -1
  98. package/dist/src/features/feature-manager.js +27 -24
  99. package/dist/src/features/feature-manager.js.map +1 -1
  100. package/dist/src/features/index.d.ts +1 -0
  101. package/dist/src/features/index.d.ts.map +1 -1
  102. package/dist/src/features/index.js +1 -0
  103. package/dist/src/features/index.js.map +1 -1
  104. package/dist/src/features/modes/auto-fan-mode.feature.d.ts +16 -10
  105. package/dist/src/features/modes/auto-fan-mode.feature.d.ts.map +1 -1
  106. package/dist/src/features/modes/auto-fan-mode.feature.js +16 -40
  107. package/dist/src/features/modes/auto-fan-mode.feature.js.map +1 -1
  108. package/dist/src/features/modes/dry-operation-mode.feature.d.ts +12 -9
  109. package/dist/src/features/modes/dry-operation-mode.feature.d.ts.map +1 -1
  110. package/dist/src/features/modes/dry-operation-mode.feature.js +11 -30
  111. package/dist/src/features/modes/dry-operation-mode.feature.js.map +1 -1
  112. package/dist/src/features/modes/econo-mode.feature.d.ts +12 -9
  113. package/dist/src/features/modes/econo-mode.feature.d.ts.map +1 -1
  114. package/dist/src/features/modes/econo-mode.feature.js +11 -31
  115. package/dist/src/features/modes/econo-mode.feature.js.map +1 -1
  116. package/dist/src/features/modes/fan-only-operation-mode.feature.d.ts +12 -9
  117. package/dist/src/features/modes/fan-only-operation-mode.feature.d.ts.map +1 -1
  118. package/dist/src/features/modes/fan-only-operation-mode.feature.js +11 -30
  119. package/dist/src/features/modes/fan-only-operation-mode.feature.js.map +1 -1
  120. package/dist/src/features/modes/firmware-update.feature.d.ts +7 -6
  121. package/dist/src/features/modes/firmware-update.feature.d.ts.map +1 -1
  122. package/dist/src/features/modes/firmware-update.feature.js +15 -22
  123. package/dist/src/features/modes/firmware-update.feature.js.map +1 -1
  124. package/dist/src/features/modes/holiday-mode.feature.d.ts +2 -1
  125. package/dist/src/features/modes/holiday-mode.feature.d.ts.map +1 -1
  126. package/dist/src/features/modes/holiday-mode.feature.js +5 -15
  127. package/dist/src/features/modes/holiday-mode.feature.js.map +1 -1
  128. package/dist/src/features/modes/indoor-silent-mode.feature.d.ts +15 -12
  129. package/dist/src/features/modes/indoor-silent-mode.feature.d.ts.map +1 -1
  130. package/dist/src/features/modes/indoor-silent-mode.feature.js +15 -39
  131. package/dist/src/features/modes/indoor-silent-mode.feature.js.map +1 -1
  132. package/dist/src/features/modes/oscillation-mode.feature.d.ts +5 -6
  133. package/dist/src/features/modes/oscillation-mode.feature.d.ts.map +1 -1
  134. package/dist/src/features/modes/oscillation-mode.feature.js +14 -36
  135. package/dist/src/features/modes/oscillation-mode.feature.js.map +1 -1
  136. package/dist/src/features/modes/outdoor-silent-mode.feature.d.ts +12 -9
  137. package/dist/src/features/modes/outdoor-silent-mode.feature.d.ts.map +1 -1
  138. package/dist/src/features/modes/outdoor-silent-mode.feature.js +11 -31
  139. package/dist/src/features/modes/outdoor-silent-mode.feature.js.map +1 -1
  140. package/dist/src/features/modes/powerful-mode.feature.d.ts +14 -10
  141. package/dist/src/features/modes/powerful-mode.feature.d.ts.map +1 -1
  142. package/dist/src/features/modes/powerful-mode.feature.js +13 -32
  143. package/dist/src/features/modes/powerful-mode.feature.js.map +1 -1
  144. package/dist/src/features/modes/streamer-mode.feature.d.ts +12 -9
  145. package/dist/src/features/modes/streamer-mode.feature.d.ts.map +1 -1
  146. package/dist/src/features/modes/streamer-mode.feature.js +11 -31
  147. package/dist/src/features/modes/streamer-mode.feature.js.map +1 -1
  148. package/dist/src/features/on-off-data-point-feature.d.ts +43 -0
  149. package/dist/src/features/on-off-data-point-feature.d.ts.map +1 -0
  150. package/dist/src/features/on-off-data-point-feature.js +49 -0
  151. package/dist/src/features/on-off-data-point-feature.js.map +1 -0
  152. package/dist/src/platform.d.ts +56 -9
  153. package/dist/src/platform.d.ts.map +1 -1
  154. package/dist/src/platform.js +248 -128
  155. package/dist/src/platform.js.map +1 -1
  156. package/dist/src/services/climate-control/fan-speed.d.ts +45 -0
  157. package/dist/src/services/climate-control/fan-speed.d.ts.map +1 -0
  158. package/dist/src/services/climate-control/fan-speed.js +104 -0
  159. package/dist/src/services/climate-control/fan-speed.js.map +1 -0
  160. package/dist/src/services/climate-control/separate-fan.service.d.ts +45 -0
  161. package/dist/src/services/climate-control/separate-fan.service.d.ts.map +1 -0
  162. package/dist/src/services/climate-control/separate-fan.service.js +82 -0
  163. package/dist/src/services/climate-control/separate-fan.service.js.map +1 -0
  164. package/dist/src/services/climate-control/setpoint-resolver.d.ts +68 -0
  165. package/dist/src/services/climate-control/setpoint-resolver.d.ts.map +1 -0
  166. package/dist/src/services/climate-control/setpoint-resolver.js +170 -0
  167. package/dist/src/services/climate-control/setpoint-resolver.js.map +1 -0
  168. package/dist/src/services/climate-control/swing-controller.d.ts +28 -0
  169. package/dist/src/services/climate-control/swing-controller.d.ts.map +1 -0
  170. package/dist/src/services/climate-control/swing-controller.js +65 -0
  171. package/dist/src/services/climate-control/swing-controller.js.map +1 -0
  172. package/dist/src/services/climate-control.service.d.ts +39 -48
  173. package/dist/src/services/climate-control.service.d.ts.map +1 -1
  174. package/dist/src/services/climate-control.service.js +223 -554
  175. package/dist/src/services/climate-control.service.js.map +1 -1
  176. package/dist/src/services/hot-water-tank.service.d.ts +17 -8
  177. package/dist/src/services/hot-water-tank.service.d.ts.map +1 -1
  178. package/dist/src/services/hot-water-tank.service.js +102 -157
  179. package/dist/src/services/hot-water-tank.service.js.map +1 -1
  180. package/dist/src/types/daikin-enums.d.ts +0 -5
  181. package/dist/src/types/daikin-enums.d.ts.map +1 -1
  182. package/dist/src/types/daikin-enums.js +1 -7
  183. package/dist/src/types/daikin-enums.js.map +1 -1
  184. package/dist/src/types/device-capabilities.d.ts +1 -0
  185. package/dist/src/types/device-capabilities.d.ts.map +1 -1
  186. package/dist/src/utils/errors.d.ts +9 -0
  187. package/dist/src/utils/errors.d.ts.map +1 -0
  188. package/dist/src/utils/errors.js +25 -0
  189. package/dist/src/utils/errors.js.map +1 -0
  190. package/dist/src/utils/hap-write.d.ts +22 -0
  191. package/dist/src/utils/hap-write.d.ts.map +1 -0
  192. package/dist/src/utils/hap-write.js +33 -0
  193. package/dist/src/utils/hap-write.js.map +1 -0
  194. package/dist/src/utils/sleep.d.ts +5 -0
  195. package/dist/src/utils/sleep.d.ts.map +1 -0
  196. package/dist/src/utils/sleep.js +10 -0
  197. package/dist/src/utils/sleep.js.map +1 -0
  198. package/homebridge-ui/README.md +25 -2
  199. package/homebridge-ui/public/index.html +19 -17
  200. package/homebridge-ui/public/script.js +95 -32
  201. package/homebridge-ui/public/styles.css +109 -0
  202. package/homebridge-ui/server.js +687 -453
  203. package/package.json +3 -3
  204. package/dist/src/utils/log-context.d.ts +0 -105
  205. package/dist/src/utils/log-context.d.ts.map +0 -1
  206. package/dist/src/utils/log-context.js +0 -206
  207. package/dist/src/utils/log-context.js.map +0 -1
  208. package/dist/src/utils/update-mapper.d.ts +0 -59
  209. package/dist/src/utils/update-mapper.d.ts.map +0 -1
  210. package/dist/src/utils/update-mapper.js +0 -192
  211. package/dist/src/utils/update-mapper.js.map +0 -1
@@ -1,10 +1,10 @@
1
- const { HomebridgePluginUiServer } = require('@homebridge/plugin-ui-utils');
2
1
  const { resolve, join } = require('path');
3
2
  const fs = require('fs');
4
3
  const https = require('https');
5
4
  const crypto = require('crypto');
6
5
  const { execSync } = require('child_process');
7
6
  const os = require('os');
7
+ const net = require('net');
8
8
 
9
9
  // Import from compiled src/api modules (single source of truth)
10
10
  // Use path relative to this file, not cwd
@@ -13,6 +13,15 @@ const { DaikinOAuth } = require(join(distPath, 'daikin-oauth'));
13
13
  const { DaikinMobileOAuth } = require(join(distPath, 'daikin-mobile-oauth'));
14
14
  const { DaikinApi } = require(join(distPath, 'daikin-api'));
15
15
  const { configureHttpTransport } = require(join(distPath, 'http-transport'));
16
+ const { loadTokenFromFile, saveTokenToFile, deleteTokenFile } = require(join(distPath, 'token-storage'));
17
+ const {
18
+ TOKEN_FILES,
19
+ DEFAULT_CALLBACK_PORT,
20
+ DEFAULT_CALLBACK_BIND_ADDR,
21
+ PENDING_AUTH_TTL_MS,
22
+ RATE_LIMIT_STATUS_FILE,
23
+ } = require(join(__dirname, '..', 'dist', 'src', 'constants'));
24
+ const { validateConfig } = require(join(__dirname, '..', 'dist', 'src', 'config', 'config-manager'));
16
25
 
17
26
  // =============================================================================
18
27
  // Configuration
@@ -20,6 +29,63 @@ const { configureHttpTransport } = require(join(distPath, 'http-transport'));
20
29
 
21
30
  const CLIMATE_CONTROL_IDS = ['climateControl', 'climateControlMainZone', 'climateControlSecondaryZone'];
22
31
 
32
+ /** How long a /devices/list result is reused before calling the Daikin API again */
33
+ const DEVICES_CACHE_TTL_MS = 5 * 60 * 1000;
34
+
35
+ /** Security headers for the OAuth callback page */
36
+ const CALLBACK_RESPONSE_HEADERS = {
37
+ 'Content-Type': 'text/html; charset=utf-8',
38
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'",
39
+ 'X-Content-Type-Options': 'nosniff',
40
+ };
41
+
42
+ // =============================================================================
43
+ // Network Helpers
44
+ // =============================================================================
45
+
46
+ const NetUtils = {
47
+ /**
48
+ * Return the bind address if it is a valid IPv4/IPv6 literal, otherwise the default.
49
+ */
50
+ resolveBindAddress(bindAddr) {
51
+ const value = typeof bindAddr === 'string' ? bindAddr.trim() : '';
52
+ return value && net.isIP(value) !== 0 ? value : DEFAULT_CALLBACK_BIND_ADDR;
53
+ },
54
+
55
+ /**
56
+ * True for loopback hosts: 127.0.0.0/8, ::1, IPv4-mapped loopback and "localhost".
57
+ */
58
+ isLoopback(host) {
59
+ if (!host || typeof host !== 'string') {
60
+ return false;
61
+ }
62
+ const value = host.trim().toLowerCase().replace(/^\[|\]$/g, '');
63
+ if (value === 'localhost' || value.endsWith('.localhost')) {
64
+ return true;
65
+ }
66
+ if (net.isIPv4(value)) {
67
+ return value.startsWith('127.');
68
+ }
69
+ if (net.isIPv6(value)) {
70
+ return value === '::1' || /^::ffff:127\./.test(value);
71
+ }
72
+ return false;
73
+ },
74
+ };
75
+
76
+ // =============================================================================
77
+ // HTML Helpers
78
+ // =============================================================================
79
+
80
+ function escapeHtml(value) {
81
+ return String(value ?? '')
82
+ .replace(/&/g, '&')
83
+ .replace(/</g, '&lt;')
84
+ .replace(/>/g, '&gt;')
85
+ .replace(/"/g, '&quot;')
86
+ .replace(/'/g, '&#39;');
87
+ }
88
+
23
89
  // =============================================================================
24
90
  // SSL Certificate Utilities
25
91
  // =============================================================================
@@ -95,30 +161,10 @@ const SSLUtils = {
95
161
  // =============================================================================
96
162
 
97
163
  const TokenManager = {
98
- load(filePath) {
99
- try {
100
- if (fs.existsSync(filePath)) {
101
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
102
- }
103
- } catch (error) {
104
- console.error('Error loading token set:', error.message);
105
- }
106
- return null;
107
- },
108
-
109
- save(filePath, tokenSet) {
110
- fs.writeFileSync(filePath, JSON.stringify(tokenSet, null, 2), { encoding: 'utf8', mode: 0o600 });
111
- },
112
-
113
- delete(filePath) {
114
- try {
115
- fs.unlinkSync(filePath);
116
- } catch (error) {
117
- if (error.code !== 'ENOENT') {
118
- throw error;
119
- }
120
- }
121
- },
164
+ // File access is shared with the plugin (Zod-validated reads, 0600 writes)
165
+ load: (filePath) => loadTokenFromFile(filePath),
166
+ save: (filePath, tokenSet) => saveTokenToFile(filePath, tokenSet),
167
+ delete: (filePath) => deleteTokenFile(filePath),
122
168
 
123
169
  getStatus(tokenSet) {
124
170
  if (!tokenSet || !tokenSet.access_token) {
@@ -273,11 +319,24 @@ class CallbackServer {
273
319
  this.server = null;
274
320
  this.port = null;
275
321
  this.connections = new Set();
322
+ this.ttlTimer = null;
276
323
  }
277
324
 
278
- async start(port, hostname, certDir, requestHandler) {
325
+ /**
326
+ * Start the HTTPS callback server.
327
+ * @param {object} options
328
+ * @param {number} options.port - Port to listen on
329
+ * @param {string} options.hostname - External hostname/IP used for the certificate
330
+ * @param {string} [options.bindAddr] - Interface to bind (validated IP literal)
331
+ * @param {string} options.certDir - Directory for the self-signed certificate
332
+ * @param {Function} options.requestHandler - HTTPS request handler
333
+ * @param {number} [options.ttlMs] - Auto-stop delay (defaults to the pending auth lifetime)
334
+ */
335
+ async start({ port, hostname, bindAddr, certDir, requestHandler, ttlMs = PENDING_AUTH_TTL_MS }) {
279
336
  await this.stop();
280
337
 
338
+ const host = NetUtils.resolveBindAddress(bindAddr);
339
+
281
340
  return new Promise((resolve, reject) => {
282
341
  const tryStart = (attempt = 1) => {
283
342
  try {
@@ -296,14 +355,16 @@ class CallbackServer {
296
355
  setTimeout(() => tryStart(attempt + 1), 1000);
297
356
  } else {
298
357
  console.error('Callback server error:', err.message);
358
+ this.server = null;
299
359
  reject(err);
300
360
  }
301
361
  });
302
362
 
303
- this.server.listen(port, '0.0.0.0', () => {
304
- console.log(`HTTPS callback server listening on port ${port}`);
363
+ this.server.listen(port, host, () => {
364
+ console.log(`HTTPS callback server listening on ${host}:${port}`);
305
365
  this.port = port;
306
- resolve({ success: true, port });
366
+ this.scheduleAutoStop(ttlMs);
367
+ resolve({ success: true, port, host });
307
368
  });
308
369
  } catch (error) {
309
370
  reject(error);
@@ -314,7 +375,28 @@ class CallbackServer {
314
375
  });
315
376
  }
316
377
 
378
+ scheduleAutoStop(ttlMs) {
379
+ this.clearAutoStop();
380
+ this.ttlTimer = setTimeout(() => {
381
+ this.ttlTimer = null;
382
+ console.log('[DaikinCloud] Authorization window expired, stopping callback server');
383
+ this.stop().catch(() => {});
384
+ }, ttlMs);
385
+ if (typeof this.ttlTimer.unref === 'function') {
386
+ this.ttlTimer.unref();
387
+ }
388
+ }
389
+
390
+ clearAutoStop() {
391
+ if (this.ttlTimer) {
392
+ clearTimeout(this.ttlTimer);
393
+ this.ttlTimer = null;
394
+ }
395
+ }
396
+
317
397
  async stop() {
398
+ this.clearAutoStop();
399
+
318
400
  return new Promise((resolve) => {
319
401
  if (!this.server) {
320
402
  resolve({ success: true });
@@ -409,7 +491,7 @@ const HtmlTemplates = {
409
491
  <div class="container">
410
492
  <div class="icon">${icon}</div>
411
493
  <h1>${success ? 'Success!' : 'Error'}</h1>
412
- <p class="message">${message}</p>
494
+ <p class="message">${escapeHtml(message)}</p>
413
495
  <p class="hint">You can close this window and return to Homebridge.</p>
414
496
  </div>
415
497
  </body>
@@ -421,544 +503,696 @@ const HtmlTemplates = {
421
503
  // Main Server Class
422
504
  // =============================================================================
423
505
 
424
- class DaikinCloudUiServer extends HomebridgePluginUiServer {
425
- constructor() {
426
- super();
506
+ /**
507
+ * Build the UI server class on top of HomebridgePluginUiServer. The base is
508
+ * passed in because @homebridge/plugin-ui-utils is ESM-only: require() loads it
509
+ * on Node >= 20.19 / 22.12, older runtimes need import() (see loadUiUtils).
510
+ */
511
+ function createDaikinCloudUiServer(HomebridgePluginUiServer) {
512
+ return class DaikinCloudUiServer extends HomebridgePluginUiServer {
513
+ constructor() {
514
+ super();
515
+
516
+ this.pendingAuth = null;
517
+ this.authResult = null;
518
+ this.callbackServer = new CallbackServer();
519
+ this.devicesCache = new Map();
427
520
 
428
- this.pendingAuth = null;
429
- this.authResult = null;
430
- this.callbackServer = new CallbackServer();
521
+ this.applyTransportFromConfig();
522
+ this.registerHandlers();
523
+ this.ready();
524
+ }
431
525
 
432
- this.applyTransportFromConfig();
433
- this.registerHandlers();
434
- this.ready();
435
- }
526
+ getStoragePath() {
527
+ return this.homebridgeStoragePath || process.env.UIX_STORAGE_PATH || '';
528
+ }
436
529
 
437
- /**
530
+ /**
531
+ * Read the DaikinCloud platform block from the Homebridge config.json.
532
+ * Throws if the config file cannot be read or parsed.
533
+ */
534
+ readPlatformConfig() {
535
+ const configPath = this.homebridgeConfigPath || resolve(this.getStoragePath(), 'config.json');
536
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
537
+ return (config.platforms || []).find((p) => p.platform === 'DaikinCloud') || null;
538
+ }
539
+
540
+ /**
438
541
  * Honour the plugin's httpTransport setting in the setup wizard too, so a
439
542
  * network that blocks Node's TLS fingerprint (issue #6) can still complete
440
543
  * authentication from the UI. Env var DAIKIN_HTTP_TRANSPORT wins.
441
544
  */
442
- applyTransportFromConfig() {
443
- try {
444
- const configPath = resolve(this.homebridgeStoragePath || process.env.UIX_STORAGE_PATH || '', 'config.json');
445
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
446
- const platform = (config.platforms || []).find((p) => p.platform === 'DaikinCloud');
447
- configureHttpTransport(platform && platform.httpTransport);
448
- } catch (e) {
449
- console.log('[DaikinCloud] Could not read httpTransport from config, using default:', e.message);
545
+ applyTransportFromConfig() {
546
+ try {
547
+ const platform = this.readPlatformConfig();
548
+ configureHttpTransport(platform && platform.httpTransport);
549
+ } catch (e) {
550
+ console.log('[DaikinCloud] Could not read httpTransport from config, using default:', e.message);
551
+ }
450
552
  }
451
- }
452
553
 
453
- getTokenFilePath() {
454
- return resolve(this.homebridgeStoragePath || process.env.UIX_STORAGE_PATH || '', '.daikin-controller-cloud-tokenset');
455
- }
554
+ /**
555
+ * Resolve the callback server bind address from the request payload, then the
556
+ * saved platform config, falling back to DEFAULT_CALLBACK_BIND_ADDR when the
557
+ * value is missing or not an IPv4/IPv6 literal.
558
+ */
559
+ getCallbackBindAddress(payload) {
560
+ let configured = payload?.oidcCallbackServerBindAddr;
561
+ if (configured === undefined || configured === null || configured === '') {
562
+ try {
563
+ configured = this.readPlatformConfig()?.oidcCallbackServerBindAddr;
564
+ } catch (e) {
565
+ configured = undefined;
566
+ }
567
+ }
456
568
 
457
- getMobileTokenFilePath() {
458
- return resolve(this.homebridgeStoragePath || process.env.UIX_STORAGE_PATH || '', '.daikin-mobile-tokenset');
459
- }
569
+ const bindAddr = NetUtils.resolveBindAddress(configured);
570
+ if (configured && bindAddr !== String(configured).trim()) {
571
+ console.warn(`[DaikinCloud] Invalid callback bind address "${configured}", using ${bindAddr}`);
572
+ }
573
+ return bindAddr;
574
+ }
460
575
 
461
- getCertDir() {
462
- return resolve(this.homebridgeStoragePath || process.env.UIX_STORAGE_PATH || '', 'daikin-cloud-certs');
463
- }
576
+ getTokenFilePath() {
577
+ return resolve(this.getStoragePath(), TOKEN_FILES.developer_portal);
578
+ }
464
579
 
465
- getActiveTokenSet() {
466
- // Check mobile token first (takes precedence)
467
- const mobileTokenSet = TokenManager.load(this.getMobileTokenFilePath());
468
- if (mobileTokenSet?.access_token) {
469
- return mobileTokenSet;
580
+ getMobileTokenFilePath() {
581
+ return resolve(this.getStoragePath(), TOKEN_FILES.mobile_app);
470
582
  }
471
- // Fall back to developer portal token
472
- return TokenManager.load(this.getTokenFilePath());
473
- }
474
583
 
475
- registerHandlers() {
476
- this.onRequest('/auth/status', this.handleGetAuthStatus.bind(this));
477
- this.onRequest('/auth/start', this.handleStartAuth.bind(this));
478
- this.onRequest('/auth/', this.handleCallback.bind(this));
479
- this.onRequest('/auth/revoke', this.handleRevokeAuth.bind(this));
480
- this.onRequest('/auth/test', this.handleTestConnection.bind(this));
481
- this.onRequest('/auth/poll', this.handlePollAuthResult.bind(this));
482
- this.onRequest('/auth/stop-server', this.handleStopServer.bind(this));
483
- this.onRequest('/auth/mobile-test', this.handleMobileAuthTest.bind(this));
484
- this.onRequest('/config/validate', this.handleValidateConfig.bind(this));
485
- this.onRequest('/devices/list', this.handleListDevices.bind(this));
486
- this.onRequest('/api/rate-limit', this.handleGetRateLimit.bind(this));
487
- this.onRequest('/server/info', this.handleGetServerInfo.bind(this));
488
- }
584
+ getCertDir() {
585
+ return resolve(this.getStoragePath(), 'daikin-cloud-certs');
586
+ }
489
587
 
490
- // -------------------------------------------------------------------------
491
- // Server Info Handler
492
- // -------------------------------------------------------------------------
588
+ getRateLimitFilePath() {
589
+ return resolve(this.getStoragePath(), RATE_LIMIT_STATUS_FILE);
590
+ }
493
591
 
494
- async handleGetServerInfo() {
495
- const ipAddresses = this.getServerIpAddresses();
496
- return {
497
- ipAddresses,
498
- primaryIp: ipAddresses[0] || null,
499
- hostname: os.hostname(),
500
- };
501
- }
592
+ /**
593
+ * Active token set and the auth mode it belongs to (mobile takes precedence).
594
+ */
595
+ getActiveToken() {
596
+ const mobileTokenSet = TokenManager.load(this.getMobileTokenFilePath());
597
+ if (mobileTokenSet?.access_token) {
598
+ return { tokenSet: mobileTokenSet, mode: 'mobile_app' };
599
+ }
600
+ return { tokenSet: TokenManager.load(this.getTokenFilePath()), mode: 'developer_portal' };
601
+ }
502
602
 
503
- getServerIpAddresses() {
504
- const interfaces = os.networkInterfaces();
505
- const addresses = [];
603
+ getActiveTokenSet() {
604
+ return this.getActiveToken().tokenSet;
605
+ }
506
606
 
507
- for (const nets of Object.values(interfaces)) {
508
- if (!nets) {
509
- continue;
607
+ /**
608
+ * Token set for the requested mode, falling back to the active token set.
609
+ */
610
+ resolveToken(mode) {
611
+ let tokenSet = null;
612
+ if (mode === 'mobile_app') {
613
+ tokenSet = TokenManager.load(this.getMobileTokenFilePath());
614
+ } else if (mode === 'developer_portal') {
615
+ tokenSet = TokenManager.load(this.getTokenFilePath());
510
616
  }
511
- for (const net of nets) {
512
- // Skip internal and non-IPv4 addresses
513
- if (net.internal || net.family !== 'IPv4') {
514
- continue;
515
- }
516
- addresses.push(net.address);
617
+
618
+ if (tokenSet?.access_token) {
619
+ return { tokenSet, mode };
517
620
  }
621
+ return this.getActiveToken();
518
622
  }
519
623
 
520
- return addresses;
521
- }
624
+ invalidateDevicesCache() {
625
+ this.devicesCache.clear();
626
+ }
522
627
 
523
- // -------------------------------------------------------------------------
524
- // Auth Status Handler
525
- // -------------------------------------------------------------------------
628
+ isPendingAuthExpired() {
629
+ return !!this.pendingAuth && Date.now() - this.pendingAuth.createdAt > PENDING_AUTH_TTL_MS;
630
+ }
526
631
 
527
- async handleGetAuthStatus() {
528
- try {
529
- // Check both token files - mobile takes precedence if exists
530
- const mobileTokenSet = TokenManager.load(this.getMobileTokenFilePath());
531
- const devPortalTokenSet = TokenManager.load(this.getTokenFilePath());
632
+ registerHandlers() {
633
+ this.onRequest('/auth/status', this.handleGetAuthStatus.bind(this));
634
+ this.onRequest('/auth/start', this.handleStartAuth.bind(this));
635
+ this.onRequest('/auth/', this.handleCallback.bind(this));
636
+ this.onRequest('/auth/revoke', this.handleRevokeAuth.bind(this));
637
+ this.onRequest('/auth/test', this.handleTestConnection.bind(this));
638
+ this.onRequest('/auth/poll', this.handlePollAuthResult.bind(this));
639
+ this.onRequest('/auth/stop-server', this.handleStopServer.bind(this));
640
+ this.onRequest('/auth/mobile-test', this.handleMobileAuthTest.bind(this));
641
+ this.onRequest('/config/validate', this.handleValidateConfig.bind(this));
642
+ this.onRequest('/devices/list', this.handleListDevices.bind(this));
643
+ this.onRequest('/api/rate-limit', this.handleGetRateLimit.bind(this));
644
+ this.onRequest('/server/info', this.handleGetServerInfo.bind(this));
645
+ }
532
646
 
533
- if (mobileTokenSet?.access_token) {
534
- const status = TokenManager.getStatus(mobileTokenSet);
535
- status.authMode = 'mobile_app';
536
- return status;
537
- }
647
+ // -------------------------------------------------------------------------
648
+ // Server Info Handler
649
+ // -------------------------------------------------------------------------
650
+
651
+ async handleGetServerInfo() {
652
+ const ipAddresses = this.getServerIpAddresses();
653
+ return {
654
+ ipAddresses,
655
+ primaryIp: ipAddresses[0] || null,
656
+ hostname: os.hostname(),
657
+ };
658
+ }
659
+
660
+ getServerIpAddresses() {
661
+ const interfaces = os.networkInterfaces();
662
+ const addresses = [];
538
663
 
539
- if (devPortalTokenSet?.access_token) {
540
- const status = TokenManager.getStatus(devPortalTokenSet);
541
- status.authMode = 'developer_portal';
542
- return status;
664
+ for (const nets of Object.values(interfaces)) {
665
+ if (!nets) {
666
+ continue;
667
+ }
668
+ for (const net of nets) {
669
+ // Skip internal and non-IPv4 addresses
670
+ if (net.internal || net.family !== 'IPv4') {
671
+ continue;
672
+ }
673
+ addresses.push(net.address);
674
+ }
543
675
  }
544
676
 
545
- return { authenticated: false, message: 'Not authenticated' };
546
- } catch (error) {
547
- return { authenticated: false, error: error.message, message: 'Error reading token status' };
677
+ return addresses;
548
678
  }
549
- }
550
679
 
551
- // -------------------------------------------------------------------------
552
- // Start Auth Handler
553
- // -------------------------------------------------------------------------
680
+ // -------------------------------------------------------------------------
681
+ // Auth Status Handler
682
+ // -------------------------------------------------------------------------
554
683
 
555
- async handleStartAuth(payload) {
556
- const { clientId, clientSecret, callbackServerExternalAddress, callbackServerPort } = payload;
684
+ async handleGetAuthStatus() {
685
+ try {
686
+ // Check both token files - mobile takes precedence if exists
687
+ const mobileTokenSet = TokenManager.load(this.getMobileTokenFilePath());
688
+ const devPortalTokenSet = TokenManager.load(this.getTokenFilePath());
557
689
 
558
- if (!clientId || !clientSecret) {
559
- throw new Error('Client ID and Client Secret are required');
560
- }
561
- if (!callbackServerExternalAddress) {
562
- throw new Error('Callback Server Address is required');
690
+ if (mobileTokenSet?.access_token) {
691
+ const status = TokenManager.getStatus(mobileTokenSet);
692
+ status.authMode = 'mobile_app';
693
+ return status;
694
+ }
695
+
696
+ if (devPortalTokenSet?.access_token) {
697
+ const status = TokenManager.getStatus(devPortalTokenSet);
698
+ status.authMode = 'developer_portal';
699
+ return status;
700
+ }
701
+
702
+ return { authenticated: false, message: 'Not authenticated' };
703
+ } catch (error) {
704
+ return { authenticated: false, error: error.message, message: 'Error reading token status' };
705
+ }
563
706
  }
564
707
 
565
- const port = parseInt(callbackServerPort || '8582', 10);
566
- const redirectUri = `https://${callbackServerExternalAddress}:${port}`;
567
- const state = crypto.randomBytes(32).toString('hex');
708
+ // -------------------------------------------------------------------------
709
+ // Start Auth Handler
710
+ // -------------------------------------------------------------------------
568
711
 
569
- this.pendingAuth = { state, clientId, clientSecret, redirectUri, port, createdAt: Date.now() };
570
- this.authResult = null;
712
+ async handleStartAuth(payload) {
713
+ const { clientId, clientSecret, callbackServerExternalAddress, callbackServerPort } = payload;
571
714
 
572
- // Use static method from compiled src/api
573
- const authUrl = DaikinOAuth.buildAuthUrlStatic(clientId, redirectUri, state);
574
- console.log('[DaikinCloud] Generated auth URL:', authUrl);
575
- console.log('[DaikinCloud] Redirect URI:', redirectUri);
715
+ if (!clientId || !clientSecret) {
716
+ throw new Error('Client ID and Client Secret are required');
717
+ }
718
+ if (!callbackServerExternalAddress) {
719
+ throw new Error('Callback Server Address is required');
720
+ }
576
721
 
577
- // Try to start callback server for automatic code capture
578
- let callbackServerRunning = false;
579
- let callbackServerError = null;
722
+ const bindAddr = this.getCallbackBindAddress(payload);
723
+ if (NetUtils.isLoopback(bindAddr) && !NetUtils.isLoopback(callbackServerExternalAddress)) {
724
+ // The browser is redirected to the external address, which can never reach a loopback-only server
725
+ throw new Error(
726
+ `The callback server bind address is ${bindAddr} (this machine only), but the callback address is ` +
727
+ `${callbackServerExternalAddress}, so Daikin's redirect could never reach it. Set "Callback Server Bind Address" ` +
728
+ 'to 0.0.0.0 (or this host\'s LAN IP) in Settings > Network, then start authentication again.',
729
+ );
730
+ }
580
731
 
581
- try {
582
- await this.callbackServer.start(
583
- port,
584
- callbackServerExternalAddress,
585
- this.getCertDir(),
586
- this.handleHttpsCallback.bind(this),
587
- );
588
- callbackServerRunning = true;
589
- console.log('[DaikinCloud] Callback server started successfully');
590
- } catch (error) {
591
- callbackServerError = error.message;
592
- console.warn('[DaikinCloud] Failed to start callback server:', error.message);
593
- }
732
+ const port = parseInt(callbackServerPort || String(DEFAULT_CALLBACK_PORT), 10);
733
+ const redirectUri = `https://${callbackServerExternalAddress}:${port}`;
734
+ const state = crypto.randomBytes(32).toString('hex');
594
735
 
595
- return {
596
- authUrl,
597
- state,
598
- redirectUri,
599
- callbackServerRunning,
600
- callbackServerError,
601
- message: callbackServerRunning
602
- ? 'Callback server is running. Authentication will complete automatically.'
603
- : 'Could not start callback server. After authenticating, copy the full callback URL and paste it below.',
604
- };
605
- }
736
+ this.pendingAuth = { state, clientId, clientSecret, redirectUri, port, createdAt: Date.now() };
737
+ this.authResult = null;
738
+ this.invalidateDevicesCache();
606
739
 
607
- // -------------------------------------------------------------------------
608
- // HTTPS Callback Handler
609
- // -------------------------------------------------------------------------
740
+ // Use static method from compiled src/api
741
+ const authUrl = DaikinOAuth.buildAuthUrlStatic(clientId, redirectUri, state);
742
+ console.log('[DaikinCloud] Redirect URI:', redirectUri);
610
743
 
611
- handleHttpsCallback(req, res) {
612
- const url = new URL(req.url, `https://${req.headers.host}`);
744
+ // Try to start callback server for automatic code capture
745
+ let callbackServerRunning = false;
746
+ let callbackServerError = null;
613
747
 
614
- const code = url.searchParams.get('code');
615
- const state = url.searchParams.get('state');
616
- const error = url.searchParams.get('error');
617
- const errorDescription = url.searchParams.get('error_description');
748
+ try {
749
+ await this.callbackServer.start({
750
+ port,
751
+ hostname: callbackServerExternalAddress,
752
+ bindAddr,
753
+ certDir: this.getCertDir(),
754
+ requestHandler: this.handleHttpsCallback.bind(this),
755
+ });
756
+ callbackServerRunning = true;
757
+ console.log('[DaikinCloud] Callback server started successfully');
758
+ } catch (error) {
759
+ callbackServerError = error.message;
760
+ console.warn('[DaikinCloud] Failed to start callback server:', error.message);
761
+ }
618
762
 
619
- if (error) {
620
- this.authResult = { success: false, error: errorDescription || error };
621
- this.sendCallbackResponse(res, false, errorDescription || error);
622
- return;
763
+ return {
764
+ authUrl,
765
+ state,
766
+ redirectUri,
767
+ callbackServerRunning,
768
+ callbackServerError,
769
+ message: callbackServerRunning
770
+ ? 'Callback server is running. Authentication will complete automatically.'
771
+ : 'Could not start callback server. After authenticating, copy the full callback URL and paste it below.',
772
+ };
623
773
  }
624
774
 
625
- if (!code || !state) {
626
- this.authResult = { success: false, error: 'Missing code or state parameter' };
627
- this.sendCallbackResponse(res, false, 'Missing authorization code');
628
- return;
629
- }
775
+ // -------------------------------------------------------------------------
776
+ // HTTPS Callback Handler
777
+ // -------------------------------------------------------------------------
630
778
 
631
- if (!this.pendingAuth || state !== this.pendingAuth.state) {
632
- this.authResult = { success: false, error: 'Invalid state parameter' };
633
- this.sendCallbackResponse(res, false, 'Invalid state parameter');
634
- return;
635
- }
779
+ handleHttpsCallback(req, res) {
780
+ let url;
781
+ try {
782
+ url = new URL(req.url, 'https://localhost');
783
+ } catch (e) {
784
+ this.sendCallbackResponse(res, false, 'Invalid request', 400);
785
+ return;
786
+ }
636
787
 
637
- const { clientId, clientSecret, redirectUri } = this.pendingAuth;
788
+ const code = url.searchParams.get('code');
789
+ const state = url.searchParams.get('state');
790
+ const error = url.searchParams.get('error');
791
+ const errorDescription = url.searchParams.get('error_description');
638
792
 
639
- // Use static method from compiled src/api
640
- DaikinOAuth.exchangeCodeStatic(code, clientId, clientSecret, redirectUri)
641
- .then((tokenSet) => {
642
- TokenManager.save(this.getTokenFilePath(), tokenSet);
643
- this.authResult = {
644
- success: true,
645
- message: 'Authentication successful!',
646
- expiresAt: tokenSet.expires_at ? new Date(tokenSet.expires_at * 1000).toISOString() : null,
647
- };
793
+ // Requests that do not carry the pending state are not ours: reject them
794
+ // without touching authResult so they cannot disrupt a running login.
795
+ if (!this.pendingAuth || !state || state !== this.pendingAuth.state) {
796
+ this.sendCallbackResponse(res, false, 'Invalid or missing state parameter', 400);
797
+ return;
798
+ }
799
+
800
+ if (this.isPendingAuthExpired()) {
648
801
  this.pendingAuth = null;
649
- this.sendCallbackResponse(res, true, 'Authentication successful! You can close this window.');
802
+ this.authResult = { success: false, error: 'Authorization request expired. Please try again.' };
803
+ this.sendCallbackResponse(res, false, 'Authorization request expired. Please start again from Homebridge.', 400);
650
804
  this.callbackServer.stop().catch(() => {});
651
- })
652
- .catch((err) => {
653
- this.authResult = { success: false, error: err.message };
654
- this.sendCallbackResponse(res, false, `Token exchange failed: ${err.message}`);
655
- });
656
- }
805
+ return;
806
+ }
657
807
 
658
- sendCallbackResponse(res, success, message) {
659
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
660
- res.end(HtmlTemplates.callbackResponse(success, message));
661
- }
808
+ if (error) {
809
+ this.authResult = { success: false, error: errorDescription || error };
810
+ this.sendCallbackResponse(res, false, errorDescription || error);
811
+ return;
812
+ }
662
813
 
663
- // -------------------------------------------------------------------------
664
- // Manual Callback Handler
665
- // -------------------------------------------------------------------------
814
+ if (!code) {
815
+ this.authResult = { success: false, error: 'Missing code parameter' };
816
+ this.sendCallbackResponse(res, false, 'Missing authorization code', 400);
817
+ return;
818
+ }
666
819
 
667
- async handleCallback(payload) {
668
- let { code, state, callbackUrl } = payload;
820
+ const { clientId, clientSecret, redirectUri } = this.pendingAuth;
669
821
 
670
- if (callbackUrl) {
671
- try {
672
- const url = new URL(callbackUrl);
673
- code = url.searchParams.get('code');
674
- state = url.searchParams.get('state');
675
- } catch (e) {
676
- throw new Error('Invalid callback URL format', { cause: e });
677
- }
822
+ // Use static method from compiled src/api
823
+ return DaikinOAuth.exchangeCodeStatic(code, clientId, clientSecret, redirectUri)
824
+ .then((tokenSet) => {
825
+ TokenManager.save(this.getTokenFilePath(), tokenSet);
826
+ this.invalidateDevicesCache();
827
+ this.authResult = {
828
+ success: true,
829
+ message: 'Authentication successful!',
830
+ expiresAt: tokenSet.expires_at ? new Date(tokenSet.expires_at * 1000).toISOString() : null,
831
+ };
832
+ this.pendingAuth = null;
833
+ this.sendCallbackResponse(res, true, 'Authentication successful! You can close this window.');
834
+ this.callbackServer.stop().catch(() => {});
835
+ })
836
+ .catch((err) => {
837
+ this.authResult = { success: false, error: err.message };
838
+ this.sendCallbackResponse(res, false, `Token exchange failed: ${err.message}`);
839
+ });
678
840
  }
679
841
 
680
- if (!code) {
681
- throw new Error('Authorization code is required');
682
- }
683
- if (!this.pendingAuth) {
684
- throw new Error('No pending authorization. Please start the auth flow again.');
685
- }
686
- if (state && state !== this.pendingAuth.state) {
687
- throw new Error('Invalid state parameter. Please try again.');
688
- }
689
- if (Date.now() - this.pendingAuth.createdAt > 10 * 60 * 1000) {
690
- this.pendingAuth = null;
691
- throw new Error('Authorization request expired. Please try again.');
842
+ sendCallbackResponse(res, success, message, statusCode = 200) {
843
+ res.writeHead(statusCode, CALLBACK_RESPONSE_HEADERS);
844
+ res.end(HtmlTemplates.callbackResponse(success, message));
692
845
  }
693
846
 
694
- const { clientId, clientSecret, redirectUri } = this.pendingAuth;
847
+ // -------------------------------------------------------------------------
848
+ // Manual Callback Handler
849
+ // -------------------------------------------------------------------------
695
850
 
696
- try {
697
- // Use static method from compiled src/api
698
- const tokenSet = await DaikinOAuth.exchangeCodeStatic(code, clientId, clientSecret, redirectUri);
699
- TokenManager.save(this.getTokenFilePath(), tokenSet);
700
- this.pendingAuth = null;
851
+ async handleCallback(payload) {
852
+ let { code, state, callbackUrl } = payload;
701
853
 
702
- await this.callbackServer.stop();
854
+ if (callbackUrl) {
855
+ try {
856
+ const url = new URL(callbackUrl);
857
+ code = url.searchParams.get('code');
858
+ state = url.searchParams.get('state');
859
+ } catch (e) {
860
+ throw new Error('Invalid callback URL format', { cause: e });
861
+ }
862
+ }
703
863
 
704
- return {
705
- success: true,
706
- message: 'Authentication successful! Restart Homebridge to apply.',
707
- expiresAt: tokenSet.expires_at ? new Date(tokenSet.expires_at * 1000).toISOString() : null,
708
- };
709
- } catch (error) {
710
- throw new Error(`Token exchange failed: ${error.message}`, { cause: error });
711
- }
712
- }
864
+ if (!code) {
865
+ throw new Error('Authorization code is required');
866
+ }
867
+ if (!this.pendingAuth) {
868
+ throw new Error('No pending authorization. Please start the auth flow again.');
869
+ }
870
+ if (!state || state !== this.pendingAuth.state) {
871
+ throw new Error('Invalid or missing state parameter. Please paste the full callback URL or start again.');
872
+ }
873
+ if (this.isPendingAuthExpired()) {
874
+ this.pendingAuth = null;
875
+ throw new Error('Authorization request expired. Please try again.');
876
+ }
713
877
 
714
- // -------------------------------------------------------------------------
715
- // Poll Auth Result Handler
716
- // -------------------------------------------------------------------------
878
+ const { clientId, clientSecret, redirectUri } = this.pendingAuth;
879
+
880
+ try {
881
+ // Use static method from compiled src/api
882
+ const tokenSet = await DaikinOAuth.exchangeCodeStatic(code, clientId, clientSecret, redirectUri);
883
+ TokenManager.save(this.getTokenFilePath(), tokenSet);
884
+ this.invalidateDevicesCache();
885
+ this.pendingAuth = null;
717
886
 
718
- async handlePollAuthResult() {
719
- if (this.authResult) {
720
- const result = { ...this.authResult };
721
- if (result.success) {
722
- this.authResult = null;
723
887
  await this.callbackServer.stop();
724
- }
725
- return result;
726
- }
727
888
 
728
- const tokenSet = TokenManager.load(this.getTokenFilePath());
729
- if (tokenSet && tokenSet.access_token) {
730
- return {
731
- success: true,
732
- message: 'Authentication successful!',
733
- expiresAt: tokenSet.expires_at ? new Date(tokenSet.expires_at * 1000).toISOString() : null,
734
- };
889
+ return {
890
+ success: true,
891
+ message: 'Authentication successful! Restart Homebridge to apply.',
892
+ expiresAt: tokenSet.expires_at ? new Date(tokenSet.expires_at * 1000).toISOString() : null,
893
+ };
894
+ } catch (error) {
895
+ throw new Error(`Token exchange failed: ${error.message}`, { cause: error });
896
+ }
735
897
  }
736
- return { pending: true };
737
- }
738
-
739
- // -------------------------------------------------------------------------
740
- // Stop Server Handler
741
- // -------------------------------------------------------------------------
742
-
743
- async handleStopServer() {
744
- this.pendingAuth = null;
745
- this.authResult = null;
746
- await this.callbackServer.stop();
747
- return { success: true };
748
- }
749
898
 
750
- // -------------------------------------------------------------------------
751
- // Mobile Auth Test Handler
752
- // -------------------------------------------------------------------------
899
+ // -------------------------------------------------------------------------
900
+ // Poll Auth Result Handler
901
+ // -------------------------------------------------------------------------
753
902
 
754
- async handleMobileAuthTest(payload) {
755
- const { email, password } = payload;
903
+ async handlePollAuthResult() {
904
+ if (this.authResult) {
905
+ const result = { ...this.authResult };
906
+ if (result.success) {
907
+ this.authResult = null;
908
+ await this.callbackServer.stop();
909
+ }
910
+ return result;
911
+ }
756
912
 
757
- if (!email || !password) {
758
- return { success: false, message: 'Email and password are required' };
913
+ const tokenSet = TokenManager.load(this.getTokenFilePath());
914
+ if (tokenSet && tokenSet.access_token) {
915
+ return {
916
+ success: true,
917
+ message: 'Authentication successful!',
918
+ expiresAt: tokenSet.expires_at ? new Date(tokenSet.expires_at * 1000).toISOString() : null,
919
+ };
920
+ }
921
+ return { pending: true };
759
922
  }
760
923
 
761
- const tokenFilePath = this.getMobileTokenFilePath();
924
+ // -------------------------------------------------------------------------
925
+ // Stop Server Handler
926
+ // -------------------------------------------------------------------------
762
927
 
763
- try {
764
- // Create a temporary mobile OAuth client
765
- const mobileOAuth = new DaikinMobileOAuth({
766
- email,
767
- password,
768
- tokenFilePath,
769
- });
928
+ async handleStopServer() {
929
+ this.pendingAuth = null;
930
+ this.authResult = null;
931
+ await this.callbackServer.stop();
932
+ return { success: true };
933
+ }
770
934
 
771
- // Perform authentication
772
- console.log('[DaikinCloud] Testing mobile app authentication...');
773
- const tokenSet = await mobileOAuth.authenticate();
774
- console.log('[DaikinCloud] Mobile authentication successful');
935
+ // -------------------------------------------------------------------------
936
+ // Mobile Auth Test Handler
937
+ // -------------------------------------------------------------------------
775
938
 
776
- // Test API access and get device count
777
- let deviceCount = 0;
778
- let rateLimit = null;
939
+ async handleMobileAuthTest(payload) {
940
+ const { email, password } = payload;
779
941
 
780
- try {
781
- const result = await DaikinApi.requestStatic('/v1/gateway-devices', tokenSet.access_token);
782
- deviceCount = Array.isArray(result.data) ? result.data.length : 0;
783
- rateLimit = result.rateLimit;
784
- } catch (apiError) {
785
- console.warn('[DaikinCloud] API test failed:', apiError.message);
942
+ if (!email || !password) {
943
+ return { success: false, message: 'Email and password are required' };
786
944
  }
787
945
 
788
- return {
789
- success: true,
790
- message: 'Authentication successful!',
791
- deviceCount,
792
- rateLimit,
793
- expiresAt: tokenSet.expires_at ? new Date(tokenSet.expires_at * 1000).toISOString() : null,
794
- };
795
- } catch (error) {
796
- console.error('[DaikinCloud] Mobile auth test failed:', error.message);
797
- return {
798
- success: false,
799
- message: error.message || 'Authentication failed',
800
- };
801
- }
802
- }
946
+ const tokenFilePath = this.getMobileTokenFilePath();
947
+ this.invalidateDevicesCache();
803
948
 
804
- // -------------------------------------------------------------------------
805
- // Revoke Auth Handler
806
- // -------------------------------------------------------------------------
949
+ try {
950
+ // Create a temporary mobile OAuth client
951
+ const mobileOAuth = new DaikinMobileOAuth({
952
+ email,
953
+ password,
954
+ tokenFilePath,
955
+ });
807
956
 
808
- async handleRevokeAuth(payload) {
809
- const devPortalTokenSet = TokenManager.load(this.getTokenFilePath());
810
- const mobileTokenSet = TokenManager.load(this.getMobileTokenFilePath());
957
+ // Perform authentication
958
+ console.log('[DaikinCloud] Testing mobile app authentication...');
959
+ const tokenSet = await mobileOAuth.authenticate();
960
+ console.log('[DaikinCloud] Mobile authentication successful');
811
961
 
812
- if (!devPortalTokenSet && !mobileTokenSet) {
813
- return { success: true, message: 'No tokens to revoke' };
814
- }
962
+ // Test API access and get device count
963
+ let deviceCount = 0;
964
+ let rateLimit = null;
815
965
 
816
- const { clientId, clientSecret } = payload;
966
+ try {
967
+ const result = await DaikinApi.requestStatic('/v1/gateway-devices', tokenSet.access_token);
968
+ deviceCount = Array.isArray(result.data) ? result.data.length : 0;
969
+ rateLimit = result.rateLimit;
970
+ } catch (apiError) {
971
+ console.warn('[DaikinCloud] API test failed:', apiError.message);
972
+ }
817
973
 
818
- if (devPortalTokenSet?.refresh_token && clientId && clientSecret) {
819
- try {
820
- // Use static method from compiled src/api
821
- await DaikinOAuth.revokeTokenStatic(devPortalTokenSet.refresh_token, clientId, clientSecret);
974
+ return {
975
+ success: true,
976
+ message: 'Authentication successful!',
977
+ deviceCount,
978
+ rateLimit,
979
+ expiresAt: tokenSet.expires_at ? new Date(tokenSet.expires_at * 1000).toISOString() : null,
980
+ };
822
981
  } catch (error) {
823
- console.warn('Failed to revoke token at server:', error.message);
982
+ console.error('[DaikinCloud] Mobile auth test failed:', error.message);
983
+ return {
984
+ success: false,
985
+ message: error.message || 'Authentication failed',
986
+ };
824
987
  }
825
988
  }
826
989
 
827
- // Delete both token files
828
- TokenManager.delete(this.getTokenFilePath());
829
- TokenManager.delete(this.getMobileTokenFilePath());
830
- return { success: true, message: 'Authentication revoked. You will need to re-authenticate.' };
831
- }
990
+ // -------------------------------------------------------------------------
991
+ // Revoke Auth Handler
992
+ // -------------------------------------------------------------------------
832
993
 
833
- // -------------------------------------------------------------------------
834
- // Test Connection Handler
835
- // -------------------------------------------------------------------------
994
+ async handleRevokeAuth(payload) {
995
+ this.invalidateDevicesCache();
836
996
 
837
- async handleTestConnection() {
838
- const tokenSet = this.getActiveTokenSet();
839
- if (!tokenSet?.access_token) {
840
- return { success: false, message: 'Not authenticated. Please authenticate first.' };
841
- }
997
+ const devPortalTokenSet = TokenManager.load(this.getTokenFilePath());
998
+ const mobileTokenSet = TokenManager.load(this.getMobileTokenFilePath());
842
999
 
843
- try {
844
- // Use static method from compiled src/api
845
- const result = await DaikinApi.requestStatic('/v1/gateway-devices', tokenSet.access_token);
846
- const devices = result.data;
847
- return {
848
- success: true,
849
- message: `Connection successful! Found ${Array.isArray(devices) ? devices.length : 0} device(s).`,
850
- deviceCount: Array.isArray(devices) ? devices.length : 0,
851
- };
852
- } catch (error) {
853
- return { success: false, message: `Connection failed: ${error.message}`, error: error.message };
854
- }
855
- }
1000
+ if (!devPortalTokenSet && !mobileTokenSet) {
1001
+ return { success: true, message: 'No tokens to revoke' };
1002
+ }
856
1003
 
857
- // -------------------------------------------------------------------------
858
- // List Devices Handler
859
- // -------------------------------------------------------------------------
1004
+ const { clientId, clientSecret } = payload;
860
1005
 
861
- async handleListDevices(payload) {
862
- const mode = payload?.mode;
1006
+ if (devPortalTokenSet?.refresh_token && clientId && clientSecret) {
1007
+ try {
1008
+ // Use static method from compiled src/api
1009
+ await DaikinOAuth.revokeTokenStatic(devPortalTokenSet.refresh_token, clientId, clientSecret);
1010
+ } catch (error) {
1011
+ console.warn('Failed to revoke token at server:', error.message);
1012
+ }
1013
+ }
863
1014
 
864
- // Get token based on mode parameter, or fall back to active token
865
- let tokenSet;
866
- if (mode === 'mobile_app') {
867
- tokenSet = TokenManager.load(this.getMobileTokenFilePath());
868
- } else if (mode === 'developer_portal') {
869
- tokenSet = TokenManager.load(this.getTokenFilePath());
870
- } else {
871
- tokenSet = this.getActiveTokenSet();
1015
+ // Delete both token files
1016
+ TokenManager.delete(this.getTokenFilePath());
1017
+ TokenManager.delete(this.getMobileTokenFilePath());
1018
+ return { success: true, message: 'Authentication revoked. You will need to re-authenticate.' };
872
1019
  }
873
1020
 
874
- // If the requested mode's token doesn't exist or is invalid, fall back to active token
875
- if (!tokenSet?.access_token) {
876
- tokenSet = this.getActiveTokenSet();
877
- }
1021
+ // -------------------------------------------------------------------------
1022
+ // Test Connection Handler
1023
+ // -------------------------------------------------------------------------
878
1024
 
879
- if (!tokenSet?.access_token) {
880
- return { success: false, devices: [], message: 'Not authenticated. Please authenticate first.' };
881
- }
1025
+ async handleTestConnection() {
1026
+ const tokenSet = this.getActiveTokenSet();
1027
+ if (!tokenSet?.access_token) {
1028
+ return { success: false, message: 'Not authenticated. Please authenticate first.' };
1029
+ }
882
1030
 
883
- try {
1031
+ try {
884
1032
  // Use static method from compiled src/api
885
- const result = await DaikinApi.requestStatic('/v1/gateway-devices', tokenSet.access_token);
886
- const gatewayDevices = result.data;
887
- const devices = Array.isArray(gatewayDevices)
888
- ? gatewayDevices.map(device => DeviceExtractor.extractAll(device))
889
- : [];
890
-
891
- return { success: true, devices, message: `Found ${devices.length} device(s).` };
892
- } catch (error) {
893
- return { success: false, devices: [], message: `Failed to fetch devices: ${error.message}`, error: error.message };
1033
+ const result = await DaikinApi.requestStatic('/v1/gateway-devices', tokenSet.access_token);
1034
+ const devices = result.data;
1035
+ return {
1036
+ success: true,
1037
+ message: `Connection successful! Found ${Array.isArray(devices) ? devices.length : 0} device(s).`,
1038
+ deviceCount: Array.isArray(devices) ? devices.length : 0,
1039
+ };
1040
+ } catch (error) {
1041
+ return { success: false, message: `Connection failed: ${error.message}`, error: error.message };
1042
+ }
894
1043
  }
895
- }
896
1044
 
897
- // -------------------------------------------------------------------------
898
- // Get Rate Limit Handler
899
- // -------------------------------------------------------------------------
1045
+ // -------------------------------------------------------------------------
1046
+ // List Devices Handler
1047
+ // -------------------------------------------------------------------------
900
1048
 
901
- async handleGetRateLimit(payload) {
902
- const mode = payload?.mode;
1049
+ async handleListDevices(payload) {
1050
+ // Token for the requested mode, or fall back to the active token
1051
+ const { tokenSet, mode } = this.resolveToken(payload?.mode);
903
1052
 
904
- // Get token based on mode parameter, or fall back to active token
905
- let tokenSet;
906
- if (mode === 'mobile_app') {
907
- tokenSet = TokenManager.load(this.getMobileTokenFilePath());
908
- } else if (mode === 'developer_portal') {
909
- tokenSet = TokenManager.load(this.getTokenFilePath());
910
- } else {
911
- tokenSet = this.getActiveTokenSet();
912
- }
1053
+ if (!tokenSet?.access_token) {
1054
+ return { success: false, devices: [], message: 'Not authenticated. Please authenticate first.' };
1055
+ }
913
1056
 
914
- if (!tokenSet?.access_token) {
915
- return { success: false, message: 'Not authenticated' };
916
- }
1057
+ // Reuse a recent result to spare the daily API quota (explicit refresh bypasses it)
1058
+ if (payload?.refresh) {
1059
+ this.devicesCache.delete(mode);
1060
+ }
1061
+ const cached = this.devicesCache.get(mode);
1062
+ if (cached && Date.now() - cached.fetchedAt < DEVICES_CACHE_TTL_MS) {
1063
+ return { ...cached.result, cached: true, fetchedAt: new Date(cached.fetchedAt).toISOString() };
1064
+ }
917
1065
 
918
- try {
1066
+ try {
919
1067
  // Use static method from compiled src/api
920
- const result = await DaikinApi.requestStatic('/v1/gateway-devices', tokenSet.access_token);
921
- return { success: true, rateLimit: result.rateLimit };
922
- } catch (error) {
923
- return { success: false, message: error.message };
1068
+ const result = await DaikinApi.requestStatic('/v1/gateway-devices', tokenSet.access_token);
1069
+ const gatewayDevices = result.data;
1070
+ const devices = Array.isArray(gatewayDevices)
1071
+ ? gatewayDevices.map(device => DeviceExtractor.extractAll(device))
1072
+ : [];
1073
+
1074
+ const response = { success: true, devices, message: `Found ${devices.length} device(s).` };
1075
+ const fetchedAt = Date.now();
1076
+ this.devicesCache.set(mode, { result: response, fetchedAt });
1077
+ return { ...response, cached: false, fetchedAt: new Date(fetchedAt).toISOString() };
1078
+ } catch (error) {
1079
+ return { success: false, devices: [], message: `Failed to fetch devices: ${error.message}`, error: error.message };
1080
+ }
924
1081
  }
925
- }
926
1082
 
927
- // -------------------------------------------------------------------------
928
- // Validate Config Handler
929
- // -------------------------------------------------------------------------
1083
+ // -------------------------------------------------------------------------
1084
+ // Get Rate Limit Handler
1085
+ // -------------------------------------------------------------------------
930
1086
 
931
- async handleValidateConfig(payload) {
932
- const errors = [];
933
- const warnings = [];
934
- const { clientId, clientSecret, callbackServerExternalAddress, callbackServerPort } = payload;
1087
+ /**
1088
+ * Report the rate limit status the running plugin records after each API
1089
+ * response, instead of spending an API call just to read the headers.
1090
+ */
1091
+ async handleGetRateLimit() {
1092
+ let status;
1093
+ try {
1094
+ status = JSON.parse(fs.readFileSync(this.getRateLimitFilePath(), 'utf8'));
1095
+ } catch (error) {
1096
+ if (error.code === 'ENOENT') {
1097
+ return {
1098
+ success: false,
1099
+ noData: true,
1100
+ message: 'No data yet — the plugin records this after its next API call.',
1101
+ };
1102
+ }
1103
+ return { success: false, message: `Could not read rate limit status: ${error.message}` };
1104
+ }
935
1105
 
936
- if (!clientId) {
937
- errors.push('Client ID is required. Get it from the Daikin Developer Portal.');
938
- }
939
- if (!clientSecret) {
940
- errors.push('Client Secret is required. Get it from the Daikin Developer Portal.');
941
- }
1106
+ if (!status || typeof status !== 'object') {
1107
+ return { success: false, message: 'Could not read rate limit status: invalid file contents' };
1108
+ }
942
1109
 
943
- if (!callbackServerExternalAddress) {
944
- errors.push('Callback Server External Address is required.');
945
- } else if (callbackServerExternalAddress === 'localhost' || callbackServerExternalAddress === '127.0.0.1') {
946
- errors.push('Callback address cannot be localhost. Use your external IP or domain.');
947
- }
1110
+ const { limitMinute, remainingMinute, limitDay, remainingDay, mode, updatedAt } = status;
1111
+ const updatedAtMs = typeof updatedAt === 'number' ? updatedAt : Date.parse(updatedAt);
1112
+ const hasTime = Number.isFinite(updatedAtMs);
948
1113
 
949
- const port = parseInt(callbackServerPort || '8582', 10);
950
- if (isNaN(port) || port < 1 || port > 65535) {
951
- errors.push('Invalid port number. Must be between 1 and 65535.');
952
- } else if (port < 1024) {
953
- warnings.push('Using a privileged port (< 1024) may require root permissions.');
1114
+ return {
1115
+ success: true,
1116
+ rateLimit: { limitMinute, remainingMinute, limitDay, remainingDay },
1117
+ mode: mode || null,
1118
+ updatedAt: hasTime ? new Date(updatedAtMs).toISOString() : null,
1119
+ ageMs: hasTime ? Math.max(0, Date.now() - updatedAtMs) : null,
1120
+ };
954
1121
  }
955
1122
 
956
- return { valid: errors.length === 0, errors, warnings };
957
- }
1123
+ // -------------------------------------------------------------------------
1124
+ // Validate Config Handler
1125
+ // -------------------------------------------------------------------------
1126
+
1127
+ /**
1128
+ * Validate the Developer Portal wizard fields with the plugin's own rules
1129
+ * (ConfigManager), so the UI and the plugin can never disagree.
1130
+ * Response: { valid, errors, warnings }.
1131
+ */
1132
+ async handleValidateConfig(payload) {
1133
+ const { clientId, clientSecret, callbackServerExternalAddress, callbackServerPort } = payload || {};
1134
+ return validateConfig({
1135
+ platform: 'DaikinCloud',
1136
+ authMode: 'developer_portal',
1137
+ clientId,
1138
+ clientSecret,
1139
+ callbackServerExternalAddress,
1140
+ callbackServerPort,
1141
+ });
1142
+ }
1143
+ };
958
1144
  }
959
1145
 
960
1146
  // =============================================================================
961
1147
  // Initialize Server
962
1148
  // =============================================================================
963
1149
 
964
- (() => new DaikinCloudUiServer())();
1150
+ /**
1151
+ * Load @homebridge/plugin-ui-utils synchronously when the runtime supports
1152
+ * require() of ES modules, otherwise return null so the caller uses import().
1153
+ */
1154
+ function loadUiUtilsSync() {
1155
+ try {
1156
+ return require('@homebridge/plugin-ui-utils');
1157
+ } catch (error) {
1158
+ if (error.code === 'ERR_REQUIRE_ESM') {
1159
+ return null;
1160
+ }
1161
+ throw error;
1162
+ }
1163
+ }
1164
+
1165
+ async function loadUiUtils() {
1166
+ return loadUiUtilsSync() || import('@homebridge/plugin-ui-utils');
1167
+ }
1168
+
1169
+ const uiUtils = loadUiUtilsSync();
1170
+
1171
+ module.exports = {
1172
+ // undefined on runtimes without require(esm); use createDaikinCloudUiServer
1173
+ DaikinCloudUiServer: uiUtils ? createDaikinCloudUiServer(uiUtils.HomebridgePluginUiServer) : undefined,
1174
+ createDaikinCloudUiServer,
1175
+ loadUiUtils,
1176
+ CallbackServer,
1177
+ SSLUtils,
1178
+ NetUtils,
1179
+ TokenManager,
1180
+ DeviceExtractor,
1181
+ HtmlTemplates,
1182
+ escapeHtml,
1183
+ DEVICES_CACHE_TTL_MS,
1184
+ };
1185
+
1186
+ // Homebridge UI forks this file as a child process; only start the server then,
1187
+ // so tests can require it without side effects.
1188
+ if (require.main === module) {
1189
+ loadUiUtils()
1190
+ .then(({ HomebridgePluginUiServer }) => {
1191
+ const DaikinCloudUiServer = createDaikinCloudUiServer(HomebridgePluginUiServer);
1192
+ return new DaikinCloudUiServer();
1193
+ })
1194
+ .catch((error) => {
1195
+ console.error('Failed to start the Daikin Cloud UI server:', error);
1196
+ process.exit(1);
1197
+ });
1198
+ }