@mp-consulting/homebridge-daikin-cloud 1.5.2 → 1.6.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 (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 +321 -125
  203. package/package.json +2 -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
@@ -5,6 +5,7 @@ const https = require('https');
5
5
  const crypto = require('crypto');
6
6
  const { execSync } = require('child_process');
7
7
  const os = require('os');
8
+ const net = require('net');
8
9
 
9
10
  // Import from compiled src/api modules (single source of truth)
10
11
  // Use path relative to this file, not cwd
@@ -13,6 +14,15 @@ const { DaikinOAuth } = require(join(distPath, 'daikin-oauth'));
13
14
  const { DaikinMobileOAuth } = require(join(distPath, 'daikin-mobile-oauth'));
14
15
  const { DaikinApi } = require(join(distPath, 'daikin-api'));
15
16
  const { configureHttpTransport } = require(join(distPath, 'http-transport'));
17
+ const { loadTokenFromFile, saveTokenToFile, deleteTokenFile } = require(join(distPath, 'token-storage'));
18
+ const {
19
+ TOKEN_FILES,
20
+ DEFAULT_CALLBACK_PORT,
21
+ DEFAULT_CALLBACK_BIND_ADDR,
22
+ PENDING_AUTH_TTL_MS,
23
+ RATE_LIMIT_STATUS_FILE,
24
+ } = require(join(__dirname, '..', 'dist', 'src', 'constants'));
25
+ const { validateConfig } = require(join(__dirname, '..', 'dist', 'src', 'config', 'config-manager'));
16
26
 
17
27
  // =============================================================================
18
28
  // Configuration
@@ -20,6 +30,63 @@ const { configureHttpTransport } = require(join(distPath, 'http-transport'));
20
30
 
21
31
  const CLIMATE_CONTROL_IDS = ['climateControl', 'climateControlMainZone', 'climateControlSecondaryZone'];
22
32
 
33
+ /** How long a /devices/list result is reused before calling the Daikin API again */
34
+ const DEVICES_CACHE_TTL_MS = 5 * 60 * 1000;
35
+
36
+ /** Security headers for the OAuth callback page */
37
+ const CALLBACK_RESPONSE_HEADERS = {
38
+ 'Content-Type': 'text/html; charset=utf-8',
39
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'",
40
+ 'X-Content-Type-Options': 'nosniff',
41
+ };
42
+
43
+ // =============================================================================
44
+ // Network Helpers
45
+ // =============================================================================
46
+
47
+ const NetUtils = {
48
+ /**
49
+ * Return the bind address if it is a valid IPv4/IPv6 literal, otherwise the default.
50
+ */
51
+ resolveBindAddress(bindAddr) {
52
+ const value = typeof bindAddr === 'string' ? bindAddr.trim() : '';
53
+ return value && net.isIP(value) !== 0 ? value : DEFAULT_CALLBACK_BIND_ADDR;
54
+ },
55
+
56
+ /**
57
+ * True for loopback hosts: 127.0.0.0/8, ::1, IPv4-mapped loopback and "localhost".
58
+ */
59
+ isLoopback(host) {
60
+ if (!host || typeof host !== 'string') {
61
+ return false;
62
+ }
63
+ const value = host.trim().toLowerCase().replace(/^\[|\]$/g, '');
64
+ if (value === 'localhost' || value.endsWith('.localhost')) {
65
+ return true;
66
+ }
67
+ if (net.isIPv4(value)) {
68
+ return value.startsWith('127.');
69
+ }
70
+ if (net.isIPv6(value)) {
71
+ return value === '::1' || /^::ffff:127\./.test(value);
72
+ }
73
+ return false;
74
+ },
75
+ };
76
+
77
+ // =============================================================================
78
+ // HTML Helpers
79
+ // =============================================================================
80
+
81
+ function escapeHtml(value) {
82
+ return String(value ?? '')
83
+ .replace(/&/g, '&')
84
+ .replace(/</g, '&lt;')
85
+ .replace(/>/g, '&gt;')
86
+ .replace(/"/g, '&quot;')
87
+ .replace(/'/g, '&#39;');
88
+ }
89
+
23
90
  // =============================================================================
24
91
  // SSL Certificate Utilities
25
92
  // =============================================================================
@@ -95,30 +162,10 @@ const SSLUtils = {
95
162
  // =============================================================================
96
163
 
97
164
  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
- },
165
+ // File access is shared with the plugin (Zod-validated reads, 0600 writes)
166
+ load: (filePath) => loadTokenFromFile(filePath),
167
+ save: (filePath, tokenSet) => saveTokenToFile(filePath, tokenSet),
168
+ delete: (filePath) => deleteTokenFile(filePath),
122
169
 
123
170
  getStatus(tokenSet) {
124
171
  if (!tokenSet || !tokenSet.access_token) {
@@ -273,11 +320,24 @@ class CallbackServer {
273
320
  this.server = null;
274
321
  this.port = null;
275
322
  this.connections = new Set();
323
+ this.ttlTimer = null;
276
324
  }
277
325
 
278
- async start(port, hostname, certDir, requestHandler) {
326
+ /**
327
+ * Start the HTTPS callback server.
328
+ * @param {object} options
329
+ * @param {number} options.port - Port to listen on
330
+ * @param {string} options.hostname - External hostname/IP used for the certificate
331
+ * @param {string} [options.bindAddr] - Interface to bind (validated IP literal)
332
+ * @param {string} options.certDir - Directory for the self-signed certificate
333
+ * @param {Function} options.requestHandler - HTTPS request handler
334
+ * @param {number} [options.ttlMs] - Auto-stop delay (defaults to the pending auth lifetime)
335
+ */
336
+ async start({ port, hostname, bindAddr, certDir, requestHandler, ttlMs = PENDING_AUTH_TTL_MS }) {
279
337
  await this.stop();
280
338
 
339
+ const host = NetUtils.resolveBindAddress(bindAddr);
340
+
281
341
  return new Promise((resolve, reject) => {
282
342
  const tryStart = (attempt = 1) => {
283
343
  try {
@@ -296,14 +356,16 @@ class CallbackServer {
296
356
  setTimeout(() => tryStart(attempt + 1), 1000);
297
357
  } else {
298
358
  console.error('Callback server error:', err.message);
359
+ this.server = null;
299
360
  reject(err);
300
361
  }
301
362
  });
302
363
 
303
- this.server.listen(port, '0.0.0.0', () => {
304
- console.log(`HTTPS callback server listening on port ${port}`);
364
+ this.server.listen(port, host, () => {
365
+ console.log(`HTTPS callback server listening on ${host}:${port}`);
305
366
  this.port = port;
306
- resolve({ success: true, port });
367
+ this.scheduleAutoStop(ttlMs);
368
+ resolve({ success: true, port, host });
307
369
  });
308
370
  } catch (error) {
309
371
  reject(error);
@@ -314,7 +376,28 @@ class CallbackServer {
314
376
  });
315
377
  }
316
378
 
379
+ scheduleAutoStop(ttlMs) {
380
+ this.clearAutoStop();
381
+ this.ttlTimer = setTimeout(() => {
382
+ this.ttlTimer = null;
383
+ console.log('[DaikinCloud] Authorization window expired, stopping callback server');
384
+ this.stop().catch(() => {});
385
+ }, ttlMs);
386
+ if (typeof this.ttlTimer.unref === 'function') {
387
+ this.ttlTimer.unref();
388
+ }
389
+ }
390
+
391
+ clearAutoStop() {
392
+ if (this.ttlTimer) {
393
+ clearTimeout(this.ttlTimer);
394
+ this.ttlTimer = null;
395
+ }
396
+ }
397
+
317
398
  async stop() {
399
+ this.clearAutoStop();
400
+
318
401
  return new Promise((resolve) => {
319
402
  if (!this.server) {
320
403
  resolve({ success: true });
@@ -409,7 +492,7 @@ const HtmlTemplates = {
409
492
  <div class="container">
410
493
  <div class="icon">${icon}</div>
411
494
  <h1>${success ? 'Success!' : 'Error'}</h1>
412
- <p class="message">${message}</p>
495
+ <p class="message">${escapeHtml(message)}</p>
413
496
  <p class="hint">You can close this window and return to Homebridge.</p>
414
497
  </div>
415
498
  </body>
@@ -428,12 +511,27 @@ class DaikinCloudUiServer extends HomebridgePluginUiServer {
428
511
  this.pendingAuth = null;
429
512
  this.authResult = null;
430
513
  this.callbackServer = new CallbackServer();
514
+ this.devicesCache = new Map();
431
515
 
432
516
  this.applyTransportFromConfig();
433
517
  this.registerHandlers();
434
518
  this.ready();
435
519
  }
436
520
 
521
+ getStoragePath() {
522
+ return this.homebridgeStoragePath || process.env.UIX_STORAGE_PATH || '';
523
+ }
524
+
525
+ /**
526
+ * Read the DaikinCloud platform block from the Homebridge config.json.
527
+ * Throws if the config file cannot be read or parsed.
528
+ */
529
+ readPlatformConfig() {
530
+ const configPath = this.homebridgeConfigPath || resolve(this.getStoragePath(), 'config.json');
531
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
532
+ return (config.platforms || []).find((p) => p.platform === 'DaikinCloud') || null;
533
+ }
534
+
437
535
  /**
438
536
  * Honour the plugin's httpTransport setting in the setup wizard too, so a
439
537
  * network that blocks Node's TLS fingerprint (issue #6) can still complete
@@ -441,35 +539,89 @@ class DaikinCloudUiServer extends HomebridgePluginUiServer {
441
539
  */
442
540
  applyTransportFromConfig() {
443
541
  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');
542
+ const platform = this.readPlatformConfig();
447
543
  configureHttpTransport(platform && platform.httpTransport);
448
544
  } catch (e) {
449
545
  console.log('[DaikinCloud] Could not read httpTransport from config, using default:', e.message);
450
546
  }
451
547
  }
452
548
 
549
+ /**
550
+ * Resolve the callback server bind address from the request payload, then the
551
+ * saved platform config, falling back to DEFAULT_CALLBACK_BIND_ADDR when the
552
+ * value is missing or not an IPv4/IPv6 literal.
553
+ */
554
+ getCallbackBindAddress(payload) {
555
+ let configured = payload?.oidcCallbackServerBindAddr;
556
+ if (configured === undefined || configured === null || configured === '') {
557
+ try {
558
+ configured = this.readPlatformConfig()?.oidcCallbackServerBindAddr;
559
+ } catch (e) {
560
+ configured = undefined;
561
+ }
562
+ }
563
+
564
+ const bindAddr = NetUtils.resolveBindAddress(configured);
565
+ if (configured && bindAddr !== String(configured).trim()) {
566
+ console.warn(`[DaikinCloud] Invalid callback bind address "${configured}", using ${bindAddr}`);
567
+ }
568
+ return bindAddr;
569
+ }
570
+
453
571
  getTokenFilePath() {
454
- return resolve(this.homebridgeStoragePath || process.env.UIX_STORAGE_PATH || '', '.daikin-controller-cloud-tokenset');
572
+ return resolve(this.getStoragePath(), TOKEN_FILES.developer_portal);
455
573
  }
456
574
 
457
575
  getMobileTokenFilePath() {
458
- return resolve(this.homebridgeStoragePath || process.env.UIX_STORAGE_PATH || '', '.daikin-mobile-tokenset');
576
+ return resolve(this.getStoragePath(), TOKEN_FILES.mobile_app);
459
577
  }
460
578
 
461
579
  getCertDir() {
462
- return resolve(this.homebridgeStoragePath || process.env.UIX_STORAGE_PATH || '', 'daikin-cloud-certs');
580
+ return resolve(this.getStoragePath(), 'daikin-cloud-certs');
463
581
  }
464
582
 
465
- getActiveTokenSet() {
466
- // Check mobile token first (takes precedence)
583
+ getRateLimitFilePath() {
584
+ return resolve(this.getStoragePath(), RATE_LIMIT_STATUS_FILE);
585
+ }
586
+
587
+ /**
588
+ * Active token set and the auth mode it belongs to (mobile takes precedence).
589
+ */
590
+ getActiveToken() {
467
591
  const mobileTokenSet = TokenManager.load(this.getMobileTokenFilePath());
468
592
  if (mobileTokenSet?.access_token) {
469
- return mobileTokenSet;
593
+ return { tokenSet: mobileTokenSet, mode: 'mobile_app' };
470
594
  }
471
- // Fall back to developer portal token
472
- return TokenManager.load(this.getTokenFilePath());
595
+ return { tokenSet: TokenManager.load(this.getTokenFilePath()), mode: 'developer_portal' };
596
+ }
597
+
598
+ getActiveTokenSet() {
599
+ return this.getActiveToken().tokenSet;
600
+ }
601
+
602
+ /**
603
+ * Token set for the requested mode, falling back to the active token set.
604
+ */
605
+ resolveToken(mode) {
606
+ let tokenSet = null;
607
+ if (mode === 'mobile_app') {
608
+ tokenSet = TokenManager.load(this.getMobileTokenFilePath());
609
+ } else if (mode === 'developer_portal') {
610
+ tokenSet = TokenManager.load(this.getTokenFilePath());
611
+ }
612
+
613
+ if (tokenSet?.access_token) {
614
+ return { tokenSet, mode };
615
+ }
616
+ return this.getActiveToken();
617
+ }
618
+
619
+ invalidateDevicesCache() {
620
+ this.devicesCache.clear();
621
+ }
622
+
623
+ isPendingAuthExpired() {
624
+ return !!this.pendingAuth && Date.now() - this.pendingAuth.createdAt > PENDING_AUTH_TTL_MS;
473
625
  }
474
626
 
475
627
  registerHandlers() {
@@ -562,16 +714,26 @@ class DaikinCloudUiServer extends HomebridgePluginUiServer {
562
714
  throw new Error('Callback Server Address is required');
563
715
  }
564
716
 
565
- const port = parseInt(callbackServerPort || '8582', 10);
717
+ const bindAddr = this.getCallbackBindAddress(payload);
718
+ if (NetUtils.isLoopback(bindAddr) && !NetUtils.isLoopback(callbackServerExternalAddress)) {
719
+ // The browser is redirected to the external address, which can never reach a loopback-only server
720
+ throw new Error(
721
+ `The callback server bind address is ${bindAddr} (this machine only), but the callback address is ` +
722
+ `${callbackServerExternalAddress}, so Daikin's redirect could never reach it. Set "Callback Server Bind Address" ` +
723
+ 'to 0.0.0.0 (or this host\'s LAN IP) in Settings > Network, then start authentication again.',
724
+ );
725
+ }
726
+
727
+ const port = parseInt(callbackServerPort || String(DEFAULT_CALLBACK_PORT), 10);
566
728
  const redirectUri = `https://${callbackServerExternalAddress}:${port}`;
567
729
  const state = crypto.randomBytes(32).toString('hex');
568
730
 
569
731
  this.pendingAuth = { state, clientId, clientSecret, redirectUri, port, createdAt: Date.now() };
570
732
  this.authResult = null;
733
+ this.invalidateDevicesCache();
571
734
 
572
735
  // Use static method from compiled src/api
573
736
  const authUrl = DaikinOAuth.buildAuthUrlStatic(clientId, redirectUri, state);
574
- console.log('[DaikinCloud] Generated auth URL:', authUrl);
575
737
  console.log('[DaikinCloud] Redirect URI:', redirectUri);
576
738
 
577
739
  // Try to start callback server for automatic code capture
@@ -579,12 +741,13 @@ class DaikinCloudUiServer extends HomebridgePluginUiServer {
579
741
  let callbackServerError = null;
580
742
 
581
743
  try {
582
- await this.callbackServer.start(
744
+ await this.callbackServer.start({
583
745
  port,
584
- callbackServerExternalAddress,
585
- this.getCertDir(),
586
- this.handleHttpsCallback.bind(this),
587
- );
746
+ hostname: callbackServerExternalAddress,
747
+ bindAddr,
748
+ certDir: this.getCertDir(),
749
+ requestHandler: this.handleHttpsCallback.bind(this),
750
+ });
588
751
  callbackServerRunning = true;
589
752
  console.log('[DaikinCloud] Callback server started successfully');
590
753
  } catch (error) {
@@ -609,37 +772,53 @@ class DaikinCloudUiServer extends HomebridgePluginUiServer {
609
772
  // -------------------------------------------------------------------------
610
773
 
611
774
  handleHttpsCallback(req, res) {
612
- const url = new URL(req.url, `https://${req.headers.host}`);
775
+ let url;
776
+ try {
777
+ url = new URL(req.url, 'https://localhost');
778
+ } catch (e) {
779
+ this.sendCallbackResponse(res, false, 'Invalid request', 400);
780
+ return;
781
+ }
613
782
 
614
783
  const code = url.searchParams.get('code');
615
784
  const state = url.searchParams.get('state');
616
785
  const error = url.searchParams.get('error');
617
786
  const errorDescription = url.searchParams.get('error_description');
618
787
 
619
- if (error) {
620
- this.authResult = { success: false, error: errorDescription || error };
621
- this.sendCallbackResponse(res, false, errorDescription || error);
788
+ // Requests that do not carry the pending state are not ours: reject them
789
+ // without touching authResult so they cannot disrupt a running login.
790
+ if (!this.pendingAuth || !state || state !== this.pendingAuth.state) {
791
+ this.sendCallbackResponse(res, false, 'Invalid or missing state parameter', 400);
792
+ return;
793
+ }
794
+
795
+ if (this.isPendingAuthExpired()) {
796
+ this.pendingAuth = null;
797
+ this.authResult = { success: false, error: 'Authorization request expired. Please try again.' };
798
+ this.sendCallbackResponse(res, false, 'Authorization request expired. Please start again from Homebridge.', 400);
799
+ this.callbackServer.stop().catch(() => {});
622
800
  return;
623
801
  }
624
802
 
625
- if (!code || !state) {
626
- this.authResult = { success: false, error: 'Missing code or state parameter' };
627
- this.sendCallbackResponse(res, false, 'Missing authorization code');
803
+ if (error) {
804
+ this.authResult = { success: false, error: errorDescription || error };
805
+ this.sendCallbackResponse(res, false, errorDescription || error);
628
806
  return;
629
807
  }
630
808
 
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');
809
+ if (!code) {
810
+ this.authResult = { success: false, error: 'Missing code parameter' };
811
+ this.sendCallbackResponse(res, false, 'Missing authorization code', 400);
634
812
  return;
635
813
  }
636
814
 
637
815
  const { clientId, clientSecret, redirectUri } = this.pendingAuth;
638
816
 
639
817
  // Use static method from compiled src/api
640
- DaikinOAuth.exchangeCodeStatic(code, clientId, clientSecret, redirectUri)
818
+ return DaikinOAuth.exchangeCodeStatic(code, clientId, clientSecret, redirectUri)
641
819
  .then((tokenSet) => {
642
820
  TokenManager.save(this.getTokenFilePath(), tokenSet);
821
+ this.invalidateDevicesCache();
643
822
  this.authResult = {
644
823
  success: true,
645
824
  message: 'Authentication successful!',
@@ -655,8 +834,8 @@ class DaikinCloudUiServer extends HomebridgePluginUiServer {
655
834
  });
656
835
  }
657
836
 
658
- sendCallbackResponse(res, success, message) {
659
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
837
+ sendCallbackResponse(res, success, message, statusCode = 200) {
838
+ res.writeHead(statusCode, CALLBACK_RESPONSE_HEADERS);
660
839
  res.end(HtmlTemplates.callbackResponse(success, message));
661
840
  }
662
841
 
@@ -683,10 +862,10 @@ class DaikinCloudUiServer extends HomebridgePluginUiServer {
683
862
  if (!this.pendingAuth) {
684
863
  throw new Error('No pending authorization. Please start the auth flow again.');
685
864
  }
686
- if (state && state !== this.pendingAuth.state) {
687
- throw new Error('Invalid state parameter. Please try again.');
865
+ if (!state || state !== this.pendingAuth.state) {
866
+ throw new Error('Invalid or missing state parameter. Please paste the full callback URL or start again.');
688
867
  }
689
- if (Date.now() - this.pendingAuth.createdAt > 10 * 60 * 1000) {
868
+ if (this.isPendingAuthExpired()) {
690
869
  this.pendingAuth = null;
691
870
  throw new Error('Authorization request expired. Please try again.');
692
871
  }
@@ -697,6 +876,7 @@ class DaikinCloudUiServer extends HomebridgePluginUiServer {
697
876
  // Use static method from compiled src/api
698
877
  const tokenSet = await DaikinOAuth.exchangeCodeStatic(code, clientId, clientSecret, redirectUri);
699
878
  TokenManager.save(this.getTokenFilePath(), tokenSet);
879
+ this.invalidateDevicesCache();
700
880
  this.pendingAuth = null;
701
881
 
702
882
  await this.callbackServer.stop();
@@ -759,6 +939,7 @@ class DaikinCloudUiServer extends HomebridgePluginUiServer {
759
939
  }
760
940
 
761
941
  const tokenFilePath = this.getMobileTokenFilePath();
942
+ this.invalidateDevicesCache();
762
943
 
763
944
  try {
764
945
  // Create a temporary mobile OAuth client
@@ -806,6 +987,8 @@ class DaikinCloudUiServer extends HomebridgePluginUiServer {
806
987
  // -------------------------------------------------------------------------
807
988
 
808
989
  async handleRevokeAuth(payload) {
990
+ this.invalidateDevicesCache();
991
+
809
992
  const devPortalTokenSet = TokenManager.load(this.getTokenFilePath());
810
993
  const mobileTokenSet = TokenManager.load(this.getMobileTokenFilePath());
811
994
 
@@ -859,25 +1042,20 @@ class DaikinCloudUiServer extends HomebridgePluginUiServer {
859
1042
  // -------------------------------------------------------------------------
860
1043
 
861
1044
  async handleListDevices(payload) {
862
- const mode = payload?.mode;
1045
+ // Token for the requested mode, or fall back to the active token
1046
+ const { tokenSet, mode } = this.resolveToken(payload?.mode);
863
1047
 
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();
872
- }
873
-
874
- // If the requested mode's token doesn't exist or is invalid, fall back to active token
875
1048
  if (!tokenSet?.access_token) {
876
- tokenSet = this.getActiveTokenSet();
1049
+ return { success: false, devices: [], message: 'Not authenticated. Please authenticate first.' };
877
1050
  }
878
1051
 
879
- if (!tokenSet?.access_token) {
880
- return { success: false, devices: [], message: 'Not authenticated. Please authenticate first.' };
1052
+ // Reuse a recent result to spare the daily API quota (explicit refresh bypasses it)
1053
+ if (payload?.refresh) {
1054
+ this.devicesCache.delete(mode);
1055
+ }
1056
+ const cached = this.devicesCache.get(mode);
1057
+ if (cached && Date.now() - cached.fetchedAt < DEVICES_CACHE_TTL_MS) {
1058
+ return { ...cached.result, cached: true, fetchedAt: new Date(cached.fetchedAt).toISOString() };
881
1059
  }
882
1060
 
883
1061
  try {
@@ -888,7 +1066,10 @@ class DaikinCloudUiServer extends HomebridgePluginUiServer {
888
1066
  ? gatewayDevices.map(device => DeviceExtractor.extractAll(device))
889
1067
  : [];
890
1068
 
891
- return { success: true, devices, message: `Found ${devices.length} device(s).` };
1069
+ const response = { success: true, devices, message: `Found ${devices.length} device(s).` };
1070
+ const fetchedAt = Date.now();
1071
+ this.devicesCache.set(mode, { result: response, fetchedAt });
1072
+ return { ...response, cached: false, fetchedAt: new Date(fetchedAt).toISOString() };
892
1073
  } catch (error) {
893
1074
  return { success: false, devices: [], message: `Failed to fetch devices: ${error.message}`, error: error.message };
894
1075
  }
@@ -898,62 +1079,61 @@ class DaikinCloudUiServer extends HomebridgePluginUiServer {
898
1079
  // Get Rate Limit Handler
899
1080
  // -------------------------------------------------------------------------
900
1081
 
901
- async handleGetRateLimit(payload) {
902
- const mode = payload?.mode;
903
-
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();
1082
+ /**
1083
+ * Report the rate limit status the running plugin records after each API
1084
+ * response, instead of spending an API call just to read the headers.
1085
+ */
1086
+ async handleGetRateLimit() {
1087
+ let status;
1088
+ try {
1089
+ status = JSON.parse(fs.readFileSync(this.getRateLimitFilePath(), 'utf8'));
1090
+ } catch (error) {
1091
+ if (error.code === 'ENOENT') {
1092
+ return {
1093
+ success: false,
1094
+ noData: true,
1095
+ message: 'No data yet — the plugin records this after its next API call.',
1096
+ };
1097
+ }
1098
+ return { success: false, message: `Could not read rate limit status: ${error.message}` };
912
1099
  }
913
1100
 
914
- if (!tokenSet?.access_token) {
915
- return { success: false, message: 'Not authenticated' };
1101
+ if (!status || typeof status !== 'object') {
1102
+ return { success: false, message: 'Could not read rate limit status: invalid file contents' };
916
1103
  }
917
1104
 
918
- try {
919
- // 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 };
924
- }
1105
+ const { limitMinute, remainingMinute, limitDay, remainingDay, mode, updatedAt } = status;
1106
+ const updatedAtMs = typeof updatedAt === 'number' ? updatedAt : Date.parse(updatedAt);
1107
+ const hasTime = Number.isFinite(updatedAtMs);
1108
+
1109
+ return {
1110
+ success: true,
1111
+ rateLimit: { limitMinute, remainingMinute, limitDay, remainingDay },
1112
+ mode: mode || null,
1113
+ updatedAt: hasTime ? new Date(updatedAtMs).toISOString() : null,
1114
+ ageMs: hasTime ? Math.max(0, Date.now() - updatedAtMs) : null,
1115
+ };
925
1116
  }
926
1117
 
927
1118
  // -------------------------------------------------------------------------
928
1119
  // Validate Config Handler
929
1120
  // -------------------------------------------------------------------------
930
1121
 
1122
+ /**
1123
+ * Validate the Developer Portal wizard fields with the plugin's own rules
1124
+ * (ConfigManager), so the UI and the plugin can never disagree.
1125
+ * Response: { valid, errors, warnings }.
1126
+ */
931
1127
  async handleValidateConfig(payload) {
932
- const errors = [];
933
- const warnings = [];
934
- const { clientId, clientSecret, callbackServerExternalAddress, callbackServerPort } = payload;
935
-
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
- }
942
-
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
- }
948
-
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.');
954
- }
955
-
956
- return { valid: errors.length === 0, errors, warnings };
1128
+ const { clientId, clientSecret, callbackServerExternalAddress, callbackServerPort } = payload || {};
1129
+ return validateConfig({
1130
+ platform: 'DaikinCloud',
1131
+ authMode: 'developer_portal',
1132
+ clientId,
1133
+ clientSecret,
1134
+ callbackServerExternalAddress,
1135
+ callbackServerPort,
1136
+ });
957
1137
  }
958
1138
  }
959
1139
 
@@ -961,4 +1141,20 @@ class DaikinCloudUiServer extends HomebridgePluginUiServer {
961
1141
  // Initialize Server
962
1142
  // =============================================================================
963
1143
 
964
- (() => new DaikinCloudUiServer())();
1144
+ module.exports = {
1145
+ DaikinCloudUiServer,
1146
+ CallbackServer,
1147
+ SSLUtils,
1148
+ NetUtils,
1149
+ TokenManager,
1150
+ DeviceExtractor,
1151
+ HtmlTemplates,
1152
+ escapeHtml,
1153
+ DEVICES_CACHE_TTL_MS,
1154
+ };
1155
+
1156
+ // Homebridge UI forks this file as a child process; only start the server then,
1157
+ // so tests can require it without side effects.
1158
+ if (require.main === module) {
1159
+ (() => new DaikinCloudUiServer())();
1160
+ }