@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.
- package/README.md +40 -27
- package/config.schema.json +66 -12
- package/dist/src/accessories/air-conditioning-accessory.js +1 -1
- package/dist/src/accessories/air-conditioning-accessory.js.map +1 -1
- package/dist/src/accessories/altherma-accessory.js +1 -1
- package/dist/src/accessories/altherma-accessory.js.map +1 -1
- package/dist/src/accessories/base-accessory.d.ts +5 -4
- package/dist/src/accessories/base-accessory.d.ts.map +1 -1
- package/dist/src/accessories/base-accessory.js +9 -10
- package/dist/src/accessories/base-accessory.js.map +1 -1
- package/dist/src/api/daikin-api.d.ts +22 -6
- package/dist/src/api/daikin-api.d.ts.map +1 -1
- package/dist/src/api/daikin-api.js +49 -23
- package/dist/src/api/daikin-api.js.map +1 -1
- package/dist/src/api/daikin-controller.d.ts +4 -48
- package/dist/src/api/daikin-controller.d.ts.map +1 -1
- package/dist/src/api/daikin-controller.js +14 -84
- package/dist/src/api/daikin-controller.js.map +1 -1
- package/dist/src/api/daikin-mobile-oauth.d.ts +11 -40
- package/dist/src/api/daikin-mobile-oauth.d.ts.map +1 -1
- package/dist/src/api/daikin-mobile-oauth.js +36 -160
- package/dist/src/api/daikin-mobile-oauth.js.map +1 -1
- package/dist/src/api/daikin-oauth.d.ts +10 -37
- package/dist/src/api/daikin-oauth.d.ts.map +1 -1
- package/dist/src/api/daikin-oauth.js +36 -166
- package/dist/src/api/daikin-oauth.js.map +1 -1
- package/dist/src/api/daikin-schemas.d.ts +20 -22
- package/dist/src/api/daikin-schemas.d.ts.map +1 -1
- package/dist/src/api/daikin-schemas.js +17 -49
- package/dist/src/api/daikin-schemas.js.map +1 -1
- package/dist/src/api/daikin-types.d.ts +11 -23
- package/dist/src/api/daikin-types.d.ts.map +1 -1
- package/dist/src/api/daikin-types.js.map +1 -1
- package/dist/src/api/daikin-websocket.d.ts +4 -5
- package/dist/src/api/daikin-websocket.d.ts.map +1 -1
- package/dist/src/api/daikin-websocket.js +62 -34
- package/dist/src/api/daikin-websocket.js.map +1 -1
- package/dist/src/api/http-defaults.d.ts +3 -2
- package/dist/src/api/http-defaults.d.ts.map +1 -1
- package/dist/src/api/http-defaults.js.map +1 -1
- package/dist/src/api/http-transport.d.ts +14 -2
- package/dist/src/api/http-transport.d.ts.map +1 -1
- package/dist/src/api/http-transport.js +32 -9
- package/dist/src/api/http-transport.js.map +1 -1
- package/dist/src/api/token-client.d.ts +82 -0
- package/dist/src/api/token-client.d.ts.map +1 -0
- package/dist/src/api/token-client.js +188 -0
- package/dist/src/api/token-client.js.map +1 -0
- package/dist/src/api/token-storage.d.ts +6 -0
- package/dist/src/api/token-storage.d.ts.map +1 -1
- package/dist/src/api/token-storage.js +27 -1
- package/dist/src/api/token-storage.js.map +1 -1
- package/dist/src/config/config-manager.d.ts +69 -125
- package/dist/src/config/config-manager.d.ts.map +1 -1
- package/dist/src/config/config-manager.js +288 -225
- package/dist/src/config/config-manager.js.map +1 -1
- package/dist/src/constants/api.constants.d.ts +6 -0
- package/dist/src/constants/api.constants.d.ts.map +1 -1
- package/dist/src/constants/api.constants.js +7 -1
- package/dist/src/constants/api.constants.js.map +1 -1
- package/dist/src/constants/auth.constants.d.ts +11 -0
- package/dist/src/constants/auth.constants.d.ts.map +1 -1
- package/dist/src/constants/auth.constants.js +12 -1
- package/dist/src/constants/auth.constants.js.map +1 -1
- package/dist/src/constants/device.constants.d.ts +1 -7
- package/dist/src/constants/device.constants.d.ts.map +1 -1
- package/dist/src/constants/device.constants.js +2 -11
- package/dist/src/constants/device.constants.js.map +1 -1
- package/dist/src/constants/time.constants.d.ts +0 -2
- package/dist/src/constants/time.constants.d.ts.map +1 -1
- package/dist/src/constants/time.constants.js +1 -3
- package/dist/src/constants/time.constants.js.map +1 -1
- package/dist/src/device/accessory-factory.d.ts +0 -5
- package/dist/src/device/accessory-factory.d.ts.map +1 -1
- package/dist/src/device/accessory-factory.js +0 -7
- package/dist/src/device/accessory-factory.js.map +1 -1
- package/dist/src/device/capability-detector.d.ts +9 -1
- package/dist/src/device/capability-detector.d.ts.map +1 -1
- package/dist/src/device/capability-detector.js +32 -28
- package/dist/src/device/capability-detector.js.map +1 -1
- package/dist/src/device/capability-docs.d.ts.map +1 -1
- package/dist/src/device/capability-docs.js +3 -0
- package/dist/src/device/capability-docs.js.map +1 -1
- package/dist/src/device/data-paths.d.ts +18 -0
- package/dist/src/device/data-paths.d.ts.map +1 -0
- package/dist/src/device/data-paths.js +20 -0
- package/dist/src/device/data-paths.js.map +1 -0
- package/dist/src/device/profiles/device-profile.d.ts +0 -5
- package/dist/src/device/profiles/device-profile.d.ts.map +1 -1
- package/dist/src/device/profiles/device-profile.js +0 -21
- package/dist/src/device/profiles/device-profile.js.map +1 -1
- package/dist/src/features/base-feature.d.ts +53 -9
- package/dist/src/features/base-feature.d.ts.map +1 -1
- package/dist/src/features/base-feature.js +93 -40
- package/dist/src/features/base-feature.js.map +1 -1
- package/dist/src/features/feature-manager.d.ts +27 -15
- package/dist/src/features/feature-manager.d.ts.map +1 -1
- package/dist/src/features/feature-manager.js +27 -24
- package/dist/src/features/feature-manager.js.map +1 -1
- package/dist/src/features/index.d.ts +1 -0
- package/dist/src/features/index.d.ts.map +1 -1
- package/dist/src/features/index.js +1 -0
- package/dist/src/features/index.js.map +1 -1
- package/dist/src/features/modes/auto-fan-mode.feature.d.ts +16 -10
- package/dist/src/features/modes/auto-fan-mode.feature.d.ts.map +1 -1
- package/dist/src/features/modes/auto-fan-mode.feature.js +16 -40
- package/dist/src/features/modes/auto-fan-mode.feature.js.map +1 -1
- package/dist/src/features/modes/dry-operation-mode.feature.d.ts +12 -9
- package/dist/src/features/modes/dry-operation-mode.feature.d.ts.map +1 -1
- package/dist/src/features/modes/dry-operation-mode.feature.js +11 -30
- package/dist/src/features/modes/dry-operation-mode.feature.js.map +1 -1
- package/dist/src/features/modes/econo-mode.feature.d.ts +12 -9
- package/dist/src/features/modes/econo-mode.feature.d.ts.map +1 -1
- package/dist/src/features/modes/econo-mode.feature.js +11 -31
- package/dist/src/features/modes/econo-mode.feature.js.map +1 -1
- package/dist/src/features/modes/fan-only-operation-mode.feature.d.ts +12 -9
- package/dist/src/features/modes/fan-only-operation-mode.feature.d.ts.map +1 -1
- package/dist/src/features/modes/fan-only-operation-mode.feature.js +11 -30
- package/dist/src/features/modes/fan-only-operation-mode.feature.js.map +1 -1
- package/dist/src/features/modes/firmware-update.feature.d.ts +7 -6
- package/dist/src/features/modes/firmware-update.feature.d.ts.map +1 -1
- package/dist/src/features/modes/firmware-update.feature.js +15 -22
- package/dist/src/features/modes/firmware-update.feature.js.map +1 -1
- package/dist/src/features/modes/holiday-mode.feature.d.ts +2 -1
- package/dist/src/features/modes/holiday-mode.feature.d.ts.map +1 -1
- package/dist/src/features/modes/holiday-mode.feature.js +5 -15
- package/dist/src/features/modes/holiday-mode.feature.js.map +1 -1
- package/dist/src/features/modes/indoor-silent-mode.feature.d.ts +15 -12
- package/dist/src/features/modes/indoor-silent-mode.feature.d.ts.map +1 -1
- package/dist/src/features/modes/indoor-silent-mode.feature.js +15 -39
- package/dist/src/features/modes/indoor-silent-mode.feature.js.map +1 -1
- package/dist/src/features/modes/oscillation-mode.feature.d.ts +5 -6
- package/dist/src/features/modes/oscillation-mode.feature.d.ts.map +1 -1
- package/dist/src/features/modes/oscillation-mode.feature.js +14 -36
- package/dist/src/features/modes/oscillation-mode.feature.js.map +1 -1
- package/dist/src/features/modes/outdoor-silent-mode.feature.d.ts +12 -9
- package/dist/src/features/modes/outdoor-silent-mode.feature.d.ts.map +1 -1
- package/dist/src/features/modes/outdoor-silent-mode.feature.js +11 -31
- package/dist/src/features/modes/outdoor-silent-mode.feature.js.map +1 -1
- package/dist/src/features/modes/powerful-mode.feature.d.ts +14 -10
- package/dist/src/features/modes/powerful-mode.feature.d.ts.map +1 -1
- package/dist/src/features/modes/powerful-mode.feature.js +13 -32
- package/dist/src/features/modes/powerful-mode.feature.js.map +1 -1
- package/dist/src/features/modes/streamer-mode.feature.d.ts +12 -9
- package/dist/src/features/modes/streamer-mode.feature.d.ts.map +1 -1
- package/dist/src/features/modes/streamer-mode.feature.js +11 -31
- package/dist/src/features/modes/streamer-mode.feature.js.map +1 -1
- package/dist/src/features/on-off-data-point-feature.d.ts +43 -0
- package/dist/src/features/on-off-data-point-feature.d.ts.map +1 -0
- package/dist/src/features/on-off-data-point-feature.js +49 -0
- package/dist/src/features/on-off-data-point-feature.js.map +1 -0
- package/dist/src/platform.d.ts +56 -9
- package/dist/src/platform.d.ts.map +1 -1
- package/dist/src/platform.js +248 -128
- package/dist/src/platform.js.map +1 -1
- package/dist/src/services/climate-control/fan-speed.d.ts +45 -0
- package/dist/src/services/climate-control/fan-speed.d.ts.map +1 -0
- package/dist/src/services/climate-control/fan-speed.js +104 -0
- package/dist/src/services/climate-control/fan-speed.js.map +1 -0
- package/dist/src/services/climate-control/separate-fan.service.d.ts +45 -0
- package/dist/src/services/climate-control/separate-fan.service.d.ts.map +1 -0
- package/dist/src/services/climate-control/separate-fan.service.js +82 -0
- package/dist/src/services/climate-control/separate-fan.service.js.map +1 -0
- package/dist/src/services/climate-control/setpoint-resolver.d.ts +68 -0
- package/dist/src/services/climate-control/setpoint-resolver.d.ts.map +1 -0
- package/dist/src/services/climate-control/setpoint-resolver.js +170 -0
- package/dist/src/services/climate-control/setpoint-resolver.js.map +1 -0
- package/dist/src/services/climate-control/swing-controller.d.ts +28 -0
- package/dist/src/services/climate-control/swing-controller.d.ts.map +1 -0
- package/dist/src/services/climate-control/swing-controller.js +65 -0
- package/dist/src/services/climate-control/swing-controller.js.map +1 -0
- package/dist/src/services/climate-control.service.d.ts +39 -48
- package/dist/src/services/climate-control.service.d.ts.map +1 -1
- package/dist/src/services/climate-control.service.js +223 -554
- package/dist/src/services/climate-control.service.js.map +1 -1
- package/dist/src/services/hot-water-tank.service.d.ts +17 -8
- package/dist/src/services/hot-water-tank.service.d.ts.map +1 -1
- package/dist/src/services/hot-water-tank.service.js +102 -157
- package/dist/src/services/hot-water-tank.service.js.map +1 -1
- package/dist/src/types/daikin-enums.d.ts +0 -5
- package/dist/src/types/daikin-enums.d.ts.map +1 -1
- package/dist/src/types/daikin-enums.js +1 -7
- package/dist/src/types/daikin-enums.js.map +1 -1
- package/dist/src/types/device-capabilities.d.ts +1 -0
- package/dist/src/types/device-capabilities.d.ts.map +1 -1
- package/dist/src/utils/errors.d.ts +9 -0
- package/dist/src/utils/errors.d.ts.map +1 -0
- package/dist/src/utils/errors.js +25 -0
- package/dist/src/utils/errors.js.map +1 -0
- package/dist/src/utils/hap-write.d.ts +22 -0
- package/dist/src/utils/hap-write.d.ts.map +1 -0
- package/dist/src/utils/hap-write.js +33 -0
- package/dist/src/utils/hap-write.js.map +1 -0
- package/dist/src/utils/sleep.d.ts +5 -0
- package/dist/src/utils/sleep.d.ts.map +1 -0
- package/dist/src/utils/sleep.js +10 -0
- package/dist/src/utils/sleep.js.map +1 -0
- package/homebridge-ui/README.md +25 -2
- package/homebridge-ui/public/index.html +19 -17
- package/homebridge-ui/public/script.js +95 -32
- package/homebridge-ui/public/styles.css +109 -0
- package/homebridge-ui/server.js +687 -453
- package/package.json +3 -3
- package/dist/src/utils/log-context.d.ts +0 -105
- package/dist/src/utils/log-context.d.ts.map +0 -1
- package/dist/src/utils/log-context.js +0 -206
- package/dist/src/utils/log-context.js.map +0 -1
- package/dist/src/utils/update-mapper.d.ts +0 -59
- package/dist/src/utils/update-mapper.d.ts.map +0 -1
- package/dist/src/utils/update-mapper.js +0 -192
- package/dist/src/utils/update-mapper.js.map +0 -1
package/homebridge-ui/server.js
CHANGED
|
@@ -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, '<')
|
|
84
|
+
.replace(/>/g, '>')
|
|
85
|
+
.replace(/"/g, '"')
|
|
86
|
+
.replace(/'/g, ''');
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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,
|
|
304
|
-
console.log(`HTTPS callback server listening on
|
|
363
|
+
this.server.listen(port, host, () => {
|
|
364
|
+
console.log(`HTTPS callback server listening on ${host}:${port}`);
|
|
305
365
|
this.port = port;
|
|
306
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
521
|
+
this.applyTransportFromConfig();
|
|
522
|
+
this.registerHandlers();
|
|
523
|
+
this.ready();
|
|
524
|
+
}
|
|
431
525
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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
|
-
|
|
458
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
576
|
+
getTokenFilePath() {
|
|
577
|
+
return resolve(this.getStoragePath(), TOKEN_FILES.developer_portal);
|
|
578
|
+
}
|
|
464
579
|
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
492
|
-
|
|
588
|
+
getRateLimitFilePath() {
|
|
589
|
+
return resolve(this.getStoragePath(), RATE_LIMIT_STATUS_FILE);
|
|
590
|
+
}
|
|
493
591
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
603
|
+
getActiveTokenSet() {
|
|
604
|
+
return this.getActiveToken().tokenSet;
|
|
605
|
+
}
|
|
506
606
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
521
|
-
|
|
624
|
+
invalidateDevicesCache() {
|
|
625
|
+
this.devicesCache.clear();
|
|
626
|
+
}
|
|
522
627
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
628
|
+
isPendingAuthExpired() {
|
|
629
|
+
return !!this.pendingAuth && Date.now() - this.pendingAuth.createdAt > PENDING_AUTH_TTL_MS;
|
|
630
|
+
}
|
|
526
631
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
|
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
|
-
|
|
553
|
-
|
|
680
|
+
// -------------------------------------------------------------------------
|
|
681
|
+
// Auth Status Handler
|
|
682
|
+
// -------------------------------------------------------------------------
|
|
554
683
|
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
708
|
+
// -------------------------------------------------------------------------
|
|
709
|
+
// Start Auth Handler
|
|
710
|
+
// -------------------------------------------------------------------------
|
|
568
711
|
|
|
569
|
-
|
|
570
|
-
|
|
712
|
+
async handleStartAuth(payload) {
|
|
713
|
+
const { clientId, clientSecret, callbackServerExternalAddress, callbackServerPort } = payload;
|
|
571
714
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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
|
-
|
|
612
|
-
|
|
744
|
+
// Try to start callback server for automatic code capture
|
|
745
|
+
let callbackServerRunning = false;
|
|
746
|
+
let callbackServerError = null;
|
|
613
747
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
775
|
+
// -------------------------------------------------------------------------
|
|
776
|
+
// HTTPS Callback Handler
|
|
777
|
+
// -------------------------------------------------------------------------
|
|
630
778
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
.
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
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
|
-
|
|
668
|
-
let { code, state, callbackUrl } = payload;
|
|
820
|
+
const { clientId, clientSecret, redirectUri } = this.pendingAuth;
|
|
669
821
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
681
|
-
|
|
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
|
-
|
|
847
|
+
// -------------------------------------------------------------------------
|
|
848
|
+
// Manual Callback Handler
|
|
849
|
+
// -------------------------------------------------------------------------
|
|
695
850
|
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
752
|
-
|
|
899
|
+
// -------------------------------------------------------------------------
|
|
900
|
+
// Poll Auth Result Handler
|
|
901
|
+
// -------------------------------------------------------------------------
|
|
753
902
|
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
758
|
-
|
|
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
|
-
|
|
924
|
+
// -------------------------------------------------------------------------
|
|
925
|
+
// Stop Server Handler
|
|
926
|
+
// -------------------------------------------------------------------------
|
|
762
927
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
console.log('[DaikinCloud] Mobile authentication successful');
|
|
935
|
+
// -------------------------------------------------------------------------
|
|
936
|
+
// Mobile Auth Test Handler
|
|
937
|
+
// -------------------------------------------------------------------------
|
|
775
938
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
let rateLimit = null;
|
|
939
|
+
async handleMobileAuthTest(payload) {
|
|
940
|
+
const { email, password } = payload;
|
|
779
941
|
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
789
|
-
|
|
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
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
962
|
+
// Test API access and get device count
|
|
963
|
+
let deviceCount = 0;
|
|
964
|
+
let rateLimit = null;
|
|
815
965
|
|
|
816
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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.
|
|
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
|
-
//
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
return { success: true, message: 'Authentication revoked. You will need to re-authenticate.' };
|
|
831
|
-
}
|
|
990
|
+
// -------------------------------------------------------------------------
|
|
991
|
+
// Revoke Auth Handler
|
|
992
|
+
// -------------------------------------------------------------------------
|
|
832
993
|
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
// -------------------------------------------------------------------------
|
|
994
|
+
async handleRevokeAuth(payload) {
|
|
995
|
+
this.invalidateDevicesCache();
|
|
836
996
|
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
862
|
-
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
//
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
}
|
|
1021
|
+
// -------------------------------------------------------------------------
|
|
1022
|
+
// Test Connection Handler
|
|
1023
|
+
// -------------------------------------------------------------------------
|
|
878
1024
|
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
1031
|
+
try {
|
|
884
1032
|
// Use static method from compiled src/api
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
-
|
|
899
|
-
|
|
1045
|
+
// -------------------------------------------------------------------------
|
|
1046
|
+
// List Devices Handler
|
|
1047
|
+
// -------------------------------------------------------------------------
|
|
900
1048
|
|
|
901
|
-
|
|
902
|
-
|
|
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
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
|
|
915
|
-
|
|
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
|
-
|
|
1066
|
+
try {
|
|
919
1067
|
// Use static method from compiled src/api
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
|
|
929
|
-
|
|
1083
|
+
// -------------------------------------------------------------------------
|
|
1084
|
+
// Get Rate Limit Handler
|
|
1085
|
+
// -------------------------------------------------------------------------
|
|
930
1086
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
-
|
|
937
|
-
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|