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