@mp-consulting/homebridge-daikin-cloud 1.6.0 → 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/homebridge-ui/server.js +530 -492
- package/package.json +2 -1
package/homebridge-ui/server.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
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');
|
|
@@ -504,645 +503,676 @@ const HtmlTemplates = {
|
|
|
504
503
|
// Main Server Class
|
|
505
504
|
// =============================================================================
|
|
506
505
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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();
|
|
510
515
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
516
|
+
this.pendingAuth = null;
|
|
517
|
+
this.authResult = null;
|
|
518
|
+
this.callbackServer = new CallbackServer();
|
|
519
|
+
this.devicesCache = new Map();
|
|
515
520
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
521
|
+
this.applyTransportFromConfig();
|
|
522
|
+
this.registerHandlers();
|
|
523
|
+
this.ready();
|
|
524
|
+
}
|
|
520
525
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
526
|
+
getStoragePath() {
|
|
527
|
+
return this.homebridgeStoragePath || process.env.UIX_STORAGE_PATH || '';
|
|
528
|
+
}
|
|
524
529
|
|
|
525
|
-
|
|
530
|
+
/**
|
|
526
531
|
* Read the DaikinCloud platform block from the Homebridge config.json.
|
|
527
532
|
* Throws if the config file cannot be read or parsed.
|
|
528
533
|
*/
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
+
}
|
|
534
539
|
|
|
535
|
-
|
|
540
|
+
/**
|
|
536
541
|
* Honour the plugin's httpTransport setting in the setup wizard too, so a
|
|
537
542
|
* network that blocks Node's TLS fingerprint (issue #6) can still complete
|
|
538
543
|
* authentication from the UI. Env var DAIKIN_HTTP_TRANSPORT wins.
|
|
539
544
|
*/
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
+
}
|
|
546
552
|
}
|
|
547
|
-
}
|
|
548
553
|
|
|
549
|
-
|
|
554
|
+
/**
|
|
550
555
|
* Resolve the callback server bind address from the request payload, then the
|
|
551
556
|
* saved platform config, falling back to DEFAULT_CALLBACK_BIND_ADDR when the
|
|
552
557
|
* value is missing or not an IPv4/IPv6 literal.
|
|
553
558
|
*/
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
+
}
|
|
561
567
|
}
|
|
562
|
-
}
|
|
563
568
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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;
|
|
567
574
|
}
|
|
568
|
-
return bindAddr;
|
|
569
|
-
}
|
|
570
575
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
576
|
+
getTokenFilePath() {
|
|
577
|
+
return resolve(this.getStoragePath(), TOKEN_FILES.developer_portal);
|
|
578
|
+
}
|
|
574
579
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
580
|
+
getMobileTokenFilePath() {
|
|
581
|
+
return resolve(this.getStoragePath(), TOKEN_FILES.mobile_app);
|
|
582
|
+
}
|
|
578
583
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
584
|
+
getCertDir() {
|
|
585
|
+
return resolve(this.getStoragePath(), 'daikin-cloud-certs');
|
|
586
|
+
}
|
|
582
587
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
588
|
+
getRateLimitFilePath() {
|
|
589
|
+
return resolve(this.getStoragePath(), RATE_LIMIT_STATUS_FILE);
|
|
590
|
+
}
|
|
586
591
|
|
|
587
|
-
|
|
592
|
+
/**
|
|
588
593
|
* Active token set and the auth mode it belongs to (mobile takes precedence).
|
|
589
594
|
*/
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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' };
|
|
594
601
|
}
|
|
595
|
-
return { tokenSet: TokenManager.load(this.getTokenFilePath()), mode: 'developer_portal' };
|
|
596
|
-
}
|
|
597
602
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
603
|
+
getActiveTokenSet() {
|
|
604
|
+
return this.getActiveToken().tokenSet;
|
|
605
|
+
}
|
|
601
606
|
|
|
602
|
-
|
|
607
|
+
/**
|
|
603
608
|
* Token set for the requested mode, falling back to the active token set.
|
|
604
609
|
*/
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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());
|
|
616
|
+
}
|
|
612
617
|
|
|
613
|
-
|
|
614
|
-
|
|
618
|
+
if (tokenSet?.access_token) {
|
|
619
|
+
return { tokenSet, mode };
|
|
620
|
+
}
|
|
621
|
+
return this.getActiveToken();
|
|
615
622
|
}
|
|
616
|
-
return this.getActiveToken();
|
|
617
|
-
}
|
|
618
623
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
624
|
+
invalidateDevicesCache() {
|
|
625
|
+
this.devicesCache.clear();
|
|
626
|
+
}
|
|
622
627
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
628
|
+
isPendingAuthExpired() {
|
|
629
|
+
return !!this.pendingAuth && Date.now() - this.pendingAuth.createdAt > PENDING_AUTH_TTL_MS;
|
|
630
|
+
}
|
|
626
631
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
+
}
|
|
641
646
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
647
|
+
// -------------------------------------------------------------------------
|
|
648
|
+
// Server Info Handler
|
|
649
|
+
// -------------------------------------------------------------------------
|
|
645
650
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
651
|
+
async handleGetServerInfo() {
|
|
652
|
+
const ipAddresses = this.getServerIpAddresses();
|
|
653
|
+
return {
|
|
654
|
+
ipAddresses,
|
|
655
|
+
primaryIp: ipAddresses[0] || null,
|
|
656
|
+
hostname: os.hostname(),
|
|
657
|
+
};
|
|
658
|
+
}
|
|
654
659
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
660
|
+
getServerIpAddresses() {
|
|
661
|
+
const interfaces = os.networkInterfaces();
|
|
662
|
+
const addresses = [];
|
|
658
663
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
continue;
|
|
662
|
-
}
|
|
663
|
-
for (const net of nets) {
|
|
664
|
-
// Skip internal and non-IPv4 addresses
|
|
665
|
-
if (net.internal || net.family !== 'IPv4') {
|
|
664
|
+
for (const nets of Object.values(interfaces)) {
|
|
665
|
+
if (!nets) {
|
|
666
666
|
continue;
|
|
667
667
|
}
|
|
668
|
-
|
|
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
|
+
}
|
|
669
675
|
}
|
|
670
|
-
}
|
|
671
676
|
|
|
672
|
-
|
|
673
|
-
|
|
677
|
+
return addresses;
|
|
678
|
+
}
|
|
674
679
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
680
|
+
// -------------------------------------------------------------------------
|
|
681
|
+
// Auth Status Handler
|
|
682
|
+
// -------------------------------------------------------------------------
|
|
678
683
|
|
|
679
|
-
|
|
680
|
-
|
|
684
|
+
async handleGetAuthStatus() {
|
|
685
|
+
try {
|
|
681
686
|
// Check both token files - mobile takes precedence if exists
|
|
682
|
-
|
|
683
|
-
|
|
687
|
+
const mobileTokenSet = TokenManager.load(this.getMobileTokenFilePath());
|
|
688
|
+
const devPortalTokenSet = TokenManager.load(this.getTokenFilePath());
|
|
684
689
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
+
if (mobileTokenSet?.access_token) {
|
|
691
|
+
const status = TokenManager.getStatus(mobileTokenSet);
|
|
692
|
+
status.authMode = 'mobile_app';
|
|
693
|
+
return status;
|
|
694
|
+
}
|
|
690
695
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
+
if (devPortalTokenSet?.access_token) {
|
|
697
|
+
const status = TokenManager.getStatus(devPortalTokenSet);
|
|
698
|
+
status.authMode = 'developer_portal';
|
|
699
|
+
return status;
|
|
700
|
+
}
|
|
696
701
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
702
|
+
return { authenticated: false, message: 'Not authenticated' };
|
|
703
|
+
} catch (error) {
|
|
704
|
+
return { authenticated: false, error: error.message, message: 'Error reading token status' };
|
|
705
|
+
}
|
|
700
706
|
}
|
|
701
|
-
}
|
|
702
707
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
708
|
+
// -------------------------------------------------------------------------
|
|
709
|
+
// Start Auth Handler
|
|
710
|
+
// -------------------------------------------------------------------------
|
|
706
711
|
|
|
707
|
-
|
|
708
|
-
|
|
712
|
+
async handleStartAuth(payload) {
|
|
713
|
+
const { clientId, clientSecret, callbackServerExternalAddress, callbackServerPort } = payload;
|
|
709
714
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
+
}
|
|
716
721
|
|
|
717
|
-
|
|
718
|
-
|
|
722
|
+
const bindAddr = this.getCallbackBindAddress(payload);
|
|
723
|
+
if (NetUtils.isLoopback(bindAddr) && !NetUtils.isLoopback(callbackServerExternalAddress)) {
|
|
719
724
|
// The browser is redirected to the external address, which can never reach a loopback-only server
|
|
720
|
-
|
|
721
|
-
|
|
725
|
+
throw new Error(
|
|
726
|
+
`The callback server bind address is ${bindAddr} (this machine only), but the callback address is ` +
|
|
722
727
|
`${callbackServerExternalAddress}, so Daikin's redirect could never reach it. Set "Callback Server Bind Address" ` +
|
|
723
728
|
'to 0.0.0.0 (or this host\'s LAN IP) in Settings > Network, then start authentication again.',
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
const port = parseInt(callbackServerPort || String(DEFAULT_CALLBACK_PORT), 10);
|
|
728
|
-
const redirectUri = `https://${callbackServerExternalAddress}:${port}`;
|
|
729
|
-
const state = crypto.randomBytes(32).toString('hex');
|
|
730
|
-
|
|
731
|
-
this.pendingAuth = { state, clientId, clientSecret, redirectUri, port, createdAt: Date.now() };
|
|
732
|
-
this.authResult = null;
|
|
733
|
-
this.invalidateDevicesCache();
|
|
734
|
-
|
|
735
|
-
// Use static method from compiled src/api
|
|
736
|
-
const authUrl = DaikinOAuth.buildAuthUrlStatic(clientId, redirectUri, state);
|
|
737
|
-
console.log('[DaikinCloud] Redirect URI:', redirectUri);
|
|
738
|
-
|
|
739
|
-
// Try to start callback server for automatic code capture
|
|
740
|
-
let callbackServerRunning = false;
|
|
741
|
-
let callbackServerError = null;
|
|
729
|
+
);
|
|
730
|
+
}
|
|
742
731
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
hostname: callbackServerExternalAddress,
|
|
747
|
-
bindAddr,
|
|
748
|
-
certDir: this.getCertDir(),
|
|
749
|
-
requestHandler: this.handleHttpsCallback.bind(this),
|
|
750
|
-
});
|
|
751
|
-
callbackServerRunning = true;
|
|
752
|
-
console.log('[DaikinCloud] Callback server started successfully');
|
|
753
|
-
} catch (error) {
|
|
754
|
-
callbackServerError = error.message;
|
|
755
|
-
console.warn('[DaikinCloud] Failed to start callback server:', error.message);
|
|
756
|
-
}
|
|
732
|
+
const port = parseInt(callbackServerPort || String(DEFAULT_CALLBACK_PORT), 10);
|
|
733
|
+
const redirectUri = `https://${callbackServerExternalAddress}:${port}`;
|
|
734
|
+
const state = crypto.randomBytes(32).toString('hex');
|
|
757
735
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
redirectUri,
|
|
762
|
-
callbackServerRunning,
|
|
763
|
-
callbackServerError,
|
|
764
|
-
message: callbackServerRunning
|
|
765
|
-
? 'Callback server is running. Authentication will complete automatically.'
|
|
766
|
-
: 'Could not start callback server. After authenticating, copy the full callback URL and paste it below.',
|
|
767
|
-
};
|
|
768
|
-
}
|
|
736
|
+
this.pendingAuth = { state, clientId, clientSecret, redirectUri, port, createdAt: Date.now() };
|
|
737
|
+
this.authResult = null;
|
|
738
|
+
this.invalidateDevicesCache();
|
|
769
739
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
740
|
+
// Use static method from compiled src/api
|
|
741
|
+
const authUrl = DaikinOAuth.buildAuthUrlStatic(clientId, redirectUri, state);
|
|
742
|
+
console.log('[DaikinCloud] Redirect URI:', redirectUri);
|
|
773
743
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
url = new URL(req.url, 'https://localhost');
|
|
778
|
-
} catch (e) {
|
|
779
|
-
this.sendCallbackResponse(res, false, 'Invalid request', 400);
|
|
780
|
-
return;
|
|
781
|
-
}
|
|
744
|
+
// Try to start callback server for automatic code capture
|
|
745
|
+
let callbackServerRunning = false;
|
|
746
|
+
let callbackServerError = null;
|
|
782
747
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
+
}
|
|
787
762
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
+
};
|
|
793
773
|
}
|
|
794
774
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
this.sendCallbackResponse(res, false, 'Authorization request expired. Please start again from Homebridge.', 400);
|
|
799
|
-
this.callbackServer.stop().catch(() => {});
|
|
800
|
-
return;
|
|
801
|
-
}
|
|
775
|
+
// -------------------------------------------------------------------------
|
|
776
|
+
// HTTPS Callback Handler
|
|
777
|
+
// -------------------------------------------------------------------------
|
|
802
778
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
+
}
|
|
808
787
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
}
|
|
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');
|
|
814
792
|
|
|
815
|
-
|
|
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
|
+
}
|
|
816
799
|
|
|
817
|
-
|
|
818
|
-
return DaikinOAuth.exchangeCodeStatic(code, clientId, clientSecret, redirectUri)
|
|
819
|
-
.then((tokenSet) => {
|
|
820
|
-
TokenManager.save(this.getTokenFilePath(), tokenSet);
|
|
821
|
-
this.invalidateDevicesCache();
|
|
822
|
-
this.authResult = {
|
|
823
|
-
success: true,
|
|
824
|
-
message: 'Authentication successful!',
|
|
825
|
-
expiresAt: tokenSet.expires_at ? new Date(tokenSet.expires_at * 1000).toISOString() : null,
|
|
826
|
-
};
|
|
800
|
+
if (this.isPendingAuthExpired()) {
|
|
827
801
|
this.pendingAuth = null;
|
|
828
|
-
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);
|
|
829
804
|
this.callbackServer.stop().catch(() => {});
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
this.authResult = { success: false, error: err.message };
|
|
833
|
-
this.sendCallbackResponse(res, false, `Token exchange failed: ${err.message}`);
|
|
834
|
-
});
|
|
835
|
-
}
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
836
807
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
808
|
+
if (error) {
|
|
809
|
+
this.authResult = { success: false, error: errorDescription || error };
|
|
810
|
+
this.sendCallbackResponse(res, false, errorDescription || error);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
841
813
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
814
|
+
if (!code) {
|
|
815
|
+
this.authResult = { success: false, error: 'Missing code parameter' };
|
|
816
|
+
this.sendCallbackResponse(res, false, 'Missing authorization code', 400);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
845
819
|
|
|
846
|
-
|
|
847
|
-
let { code, state, callbackUrl } = payload;
|
|
820
|
+
const { clientId, clientSecret, redirectUri } = this.pendingAuth;
|
|
848
821
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
+
});
|
|
857
840
|
}
|
|
858
841
|
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
if (!this.pendingAuth) {
|
|
863
|
-
throw new Error('No pending authorization. Please start the auth flow again.');
|
|
864
|
-
}
|
|
865
|
-
if (!state || state !== this.pendingAuth.state) {
|
|
866
|
-
throw new Error('Invalid or missing state parameter. Please paste the full callback URL or start again.');
|
|
867
|
-
}
|
|
868
|
-
if (this.isPendingAuthExpired()) {
|
|
869
|
-
this.pendingAuth = null;
|
|
870
|
-
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));
|
|
871
845
|
}
|
|
872
846
|
|
|
873
|
-
|
|
847
|
+
// -------------------------------------------------------------------------
|
|
848
|
+
// Manual Callback Handler
|
|
849
|
+
// -------------------------------------------------------------------------
|
|
874
850
|
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
const tokenSet = await DaikinOAuth.exchangeCodeStatic(code, clientId, clientSecret, redirectUri);
|
|
878
|
-
TokenManager.save(this.getTokenFilePath(), tokenSet);
|
|
879
|
-
this.invalidateDevicesCache();
|
|
880
|
-
this.pendingAuth = null;
|
|
851
|
+
async handleCallback(payload) {
|
|
852
|
+
let { code, state, callbackUrl } = payload;
|
|
881
853
|
|
|
882
|
-
|
|
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
|
+
}
|
|
883
863
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
+
}
|
|
893
877
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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;
|
|
897
886
|
|
|
898
|
-
async handlePollAuthResult() {
|
|
899
|
-
if (this.authResult) {
|
|
900
|
-
const result = { ...this.authResult };
|
|
901
|
-
if (result.success) {
|
|
902
|
-
this.authResult = null;
|
|
903
887
|
await this.callbackServer.stop();
|
|
888
|
+
|
|
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 });
|
|
904
896
|
}
|
|
905
|
-
return result;
|
|
906
897
|
}
|
|
907
898
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
899
|
+
// -------------------------------------------------------------------------
|
|
900
|
+
// Poll Auth Result Handler
|
|
901
|
+
// -------------------------------------------------------------------------
|
|
902
|
+
|
|
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
|
+
}
|
|
912
|
+
|
|
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 };
|
|
915
922
|
}
|
|
916
|
-
return { pending: true };
|
|
917
|
-
}
|
|
918
923
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
924
|
+
// -------------------------------------------------------------------------
|
|
925
|
+
// Stop Server Handler
|
|
926
|
+
// -------------------------------------------------------------------------
|
|
922
927
|
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
928
|
+
async handleStopServer() {
|
|
929
|
+
this.pendingAuth = null;
|
|
930
|
+
this.authResult = null;
|
|
931
|
+
await this.callbackServer.stop();
|
|
932
|
+
return { success: true };
|
|
933
|
+
}
|
|
929
934
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
935
|
+
// -------------------------------------------------------------------------
|
|
936
|
+
// Mobile Auth Test Handler
|
|
937
|
+
// -------------------------------------------------------------------------
|
|
933
938
|
|
|
934
|
-
|
|
935
|
-
|
|
939
|
+
async handleMobileAuthTest(payload) {
|
|
940
|
+
const { email, password } = payload;
|
|
936
941
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
942
|
+
if (!email || !password) {
|
|
943
|
+
return { success: false, message: 'Email and password are required' };
|
|
944
|
+
}
|
|
940
945
|
|
|
941
|
-
|
|
942
|
-
|
|
946
|
+
const tokenFilePath = this.getMobileTokenFilePath();
|
|
947
|
+
this.invalidateDevicesCache();
|
|
943
948
|
|
|
944
|
-
|
|
949
|
+
try {
|
|
945
950
|
// Create a temporary mobile OAuth client
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
+
const mobileOAuth = new DaikinMobileOAuth({
|
|
952
|
+
email,
|
|
953
|
+
password,
|
|
954
|
+
tokenFilePath,
|
|
955
|
+
});
|
|
951
956
|
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
957
|
+
// Perform authentication
|
|
958
|
+
console.log('[DaikinCloud] Testing mobile app authentication...');
|
|
959
|
+
const tokenSet = await mobileOAuth.authenticate();
|
|
960
|
+
console.log('[DaikinCloud] Mobile authentication successful');
|
|
956
961
|
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
962
|
+
// Test API access and get device count
|
|
963
|
+
let deviceCount = 0;
|
|
964
|
+
let rateLimit = null;
|
|
960
965
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
+
}
|
|
968
973
|
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
+
};
|
|
981
|
+
} catch (error) {
|
|
982
|
+
console.error('[DaikinCloud] Mobile auth test failed:', error.message);
|
|
983
|
+
return {
|
|
984
|
+
success: false,
|
|
985
|
+
message: error.message || 'Authentication failed',
|
|
986
|
+
};
|
|
987
|
+
}
|
|
982
988
|
}
|
|
983
|
-
}
|
|
984
989
|
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
990
|
+
// -------------------------------------------------------------------------
|
|
991
|
+
// Revoke Auth Handler
|
|
992
|
+
// -------------------------------------------------------------------------
|
|
988
993
|
|
|
989
|
-
|
|
990
|
-
|
|
994
|
+
async handleRevokeAuth(payload) {
|
|
995
|
+
this.invalidateDevicesCache();
|
|
991
996
|
|
|
992
|
-
|
|
993
|
-
|
|
997
|
+
const devPortalTokenSet = TokenManager.load(this.getTokenFilePath());
|
|
998
|
+
const mobileTokenSet = TokenManager.load(this.getMobileTokenFilePath());
|
|
994
999
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1000
|
+
if (!devPortalTokenSet && !mobileTokenSet) {
|
|
1001
|
+
return { success: true, message: 'No tokens to revoke' };
|
|
1002
|
+
}
|
|
998
1003
|
|
|
999
|
-
|
|
1004
|
+
const { clientId, clientSecret } = payload;
|
|
1000
1005
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1006
|
+
if (devPortalTokenSet?.refresh_token && clientId && clientSecret) {
|
|
1007
|
+
try {
|
|
1003
1008
|
// Use static method from compiled src/api
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1009
|
+
await DaikinOAuth.revokeTokenStatic(devPortalTokenSet.refresh_token, clientId, clientSecret);
|
|
1010
|
+
} catch (error) {
|
|
1011
|
+
console.warn('Failed to revoke token at server:', error.message);
|
|
1012
|
+
}
|
|
1007
1013
|
}
|
|
1008
|
-
}
|
|
1009
1014
|
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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.' };
|
|
1019
|
+
}
|
|
1015
1020
|
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1021
|
+
// -------------------------------------------------------------------------
|
|
1022
|
+
// Test Connection Handler
|
|
1023
|
+
// -------------------------------------------------------------------------
|
|
1019
1024
|
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
+
async handleTestConnection() {
|
|
1026
|
+
const tokenSet = this.getActiveTokenSet();
|
|
1027
|
+
if (!tokenSet?.access_token) {
|
|
1028
|
+
return { success: false, message: 'Not authenticated. Please authenticate first.' };
|
|
1029
|
+
}
|
|
1025
1030
|
|
|
1026
|
-
|
|
1031
|
+
try {
|
|
1027
1032
|
// Use static method from compiled src/api
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
+
}
|
|
1037
1043
|
}
|
|
1038
|
-
}
|
|
1039
1044
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1045
|
+
// -------------------------------------------------------------------------
|
|
1046
|
+
// List Devices Handler
|
|
1047
|
+
// -------------------------------------------------------------------------
|
|
1043
1048
|
|
|
1044
|
-
|
|
1049
|
+
async handleListDevices(payload) {
|
|
1045
1050
|
// Token for the requested mode, or fall back to the active token
|
|
1046
|
-
|
|
1051
|
+
const { tokenSet, mode } = this.resolveToken(payload?.mode);
|
|
1047
1052
|
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1053
|
+
if (!tokenSet?.access_token) {
|
|
1054
|
+
return { success: false, devices: [], message: 'Not authenticated. Please authenticate first.' };
|
|
1055
|
+
}
|
|
1051
1056
|
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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
|
+
}
|
|
1060
1065
|
|
|
1061
|
-
|
|
1066
|
+
try {
|
|
1062
1067
|
// Use static method from compiled src/api
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
+
}
|
|
1075
1081
|
}
|
|
1076
|
-
}
|
|
1077
1082
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1083
|
+
// -------------------------------------------------------------------------
|
|
1084
|
+
// Get Rate Limit Handler
|
|
1085
|
+
// -------------------------------------------------------------------------
|
|
1081
1086
|
|
|
1082
|
-
|
|
1087
|
+
/**
|
|
1083
1088
|
* Report the rate limit status the running plugin records after each API
|
|
1084
1089
|
* response, instead of spending an API call just to read the headers.
|
|
1085
1090
|
*/
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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}` };
|
|
1097
1104
|
}
|
|
1098
|
-
return { success: false, message: `Could not read rate limit status: ${error.message}` };
|
|
1099
|
-
}
|
|
1100
1105
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1106
|
+
if (!status || typeof status !== 'object') {
|
|
1107
|
+
return { success: false, message: 'Could not read rate limit status: invalid file contents' };
|
|
1108
|
+
}
|
|
1104
1109
|
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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);
|
|
1108
1113
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
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
|
+
};
|
|
1121
|
+
}
|
|
1117
1122
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1123
|
+
// -------------------------------------------------------------------------
|
|
1124
|
+
// Validate Config Handler
|
|
1125
|
+
// -------------------------------------------------------------------------
|
|
1121
1126
|
|
|
1122
|
-
|
|
1127
|
+
/**
|
|
1123
1128
|
* Validate the Developer Portal wizard fields with the plugin's own rules
|
|
1124
1129
|
* (ConfigManager), so the UI and the plugin can never disagree.
|
|
1125
1130
|
* Response: { valid, errors, warnings }.
|
|
1126
1131
|
*/
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
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
|
+
};
|
|
1138
1144
|
}
|
|
1139
1145
|
|
|
1140
1146
|
// =============================================================================
|
|
1141
1147
|
// Initialize Server
|
|
1142
1148
|
// =============================================================================
|
|
1143
1149
|
|
|
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
|
+
|
|
1144
1171
|
module.exports = {
|
|
1145
|
-
|
|
1172
|
+
// undefined on runtimes without require(esm); use createDaikinCloudUiServer
|
|
1173
|
+
DaikinCloudUiServer: uiUtils ? createDaikinCloudUiServer(uiUtils.HomebridgePluginUiServer) : undefined,
|
|
1174
|
+
createDaikinCloudUiServer,
|
|
1175
|
+
loadUiUtils,
|
|
1146
1176
|
CallbackServer,
|
|
1147
1177
|
SSLUtils,
|
|
1148
1178
|
NetUtils,
|
|
@@ -1156,5 +1186,13 @@ module.exports = {
|
|
|
1156
1186
|
// Homebridge UI forks this file as a child process; only start the server then,
|
|
1157
1187
|
// so tests can require it without side effects.
|
|
1158
1188
|
if (require.main === module) {
|
|
1159
|
-
(
|
|
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
|
+
});
|
|
1160
1198
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"displayName": "Daikin Cloud",
|
|
3
3
|
"platformname": "daikincloud",
|
|
4
4
|
"name": "@mp-consulting/homebridge-daikin-cloud",
|
|
5
|
-
"version": "1.6.
|
|
5
|
+
"version": "1.6.1",
|
|
6
6
|
"description": "Integrate with the Daikin Cloud to control your Daikin air conditioning via the cloud",
|
|
7
7
|
"license": "(Apache-2.0 AND MIT)",
|
|
8
8
|
"repository": {
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"test:coverage": "vitest run --coverage",
|
|
26
26
|
"lint": "eslint . --max-warnings=0",
|
|
27
27
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
|
|
28
|
+
"smoke:ui": "node scripts/smoke-ui-server.js",
|
|
28
29
|
"schema:check": "tsx scripts/check-schema-drift.ts",
|
|
29
30
|
"watch": "npm run build && npm link && nodemon",
|
|
30
31
|
"copy:ui-kit": "mkdir -p homebridge-ui/public/lib && cp node_modules/@mp-consulting/homebridge-ui-kit/dist/kit.css homebridge-ui/public/lib/ && cp node_modules/@mp-consulting/homebridge-ui-kit/dist/kit.js homebridge-ui/public/lib/",
|