@mp-consulting/homebridge-daikin-cloud 1.6.2 → 1.7.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.
@@ -114,6 +114,199 @@ const UI = {
114
114
  },
115
115
  };
116
116
 
117
+ // ============================================================================
118
+ // Assistant Module (Homebridge AI Kit)
119
+ // ============================================================================
120
+ // Shown only when the shared HomebridgeAiKit platform is set up and enabled.
121
+ // Nothing sent to it may contain credentials, tokens or IP addresses.
122
+
123
+ const Assistant = {
124
+ enabled: false,
125
+ available: false,
126
+
127
+ // Keys kept out of every request: credentials and network addresses
128
+ PRIVATE_KEYS: ['clientId', 'clientSecret', 'daikinEmail', 'daikinPassword', 'callbackServerExternalAddress', 'oidcCallbackServerBindAddr'],
129
+
130
+ async init() {
131
+ try {
132
+ if (window.MpKit && MpKit.ai) {
133
+ const status = await MpKit.ai.status();
134
+ this.available = true;
135
+ this.enabled = !!(status && status.enabled);
136
+ }
137
+ } catch {
138
+ // Routes missing (AI Kit not installed) or older Homebridge UI: no Assistant
139
+ }
140
+ DOM.toggle($id('assistant-hint'), this.available && !this.enabled);
141
+ if (this.enabled) {
142
+ this.setupConfigCard();
143
+ }
144
+ },
145
+
146
+ // Error text without e-mail addresses or LAN/public IPs (loopback and 0.0.0.0 stay: they explain bind problems)
147
+ scrub(text) {
148
+ return String(text ?? '')
149
+ .replace(/[^\s@]+@[^\s@]+\.[^\s@]+/g, '<email>')
150
+ .replace(/\b(?!127\.)(?!0\.0\.0\.0\b)(?:\d{1,3}\.){3}\d{1,3}\b/g, '<address>');
151
+ },
152
+
153
+ // Device facts the Assistant may see (whitelist)
154
+ device(d) {
155
+ return {
156
+ name: d.name,
157
+ id: d.id,
158
+ model: d.model,
159
+ type: d.type,
160
+ online: !!d.online,
161
+ features: Array.isArray(d.features) ? d.features : [],
162
+ };
163
+ },
164
+
165
+ // Plugin settings the Assistant may see, as one sentence (no credentials or addresses)
166
+ context(extra) {
167
+ const interval = $id('updateIntervalInMinutes')?.value;
168
+ return [
169
+ extra,
170
+ `Authentication method: ${AuthMode.current}.`,
171
+ interval ? `Update interval: ${interval} minutes.` : '',
172
+ AuthMode.current === 'mobile_app' ? `WebSocket: ${$id('enableWebSocket')?.checked ? 'on' : 'off'}.` : '',
173
+ `HTTP transport: ${$id('httpTransport')?.value || 'node'}.`,
174
+ ].filter(Boolean).join(' ');
175
+ },
176
+
177
+ // Streams an explanation of `error` into `answerEl`
178
+ async explain(button, answerEl, { error, context, device, title }) {
179
+ button.disabled = true;
180
+ button.setAttribute('aria-busy', 'true');
181
+ DOM.show(answerEl);
182
+ const answer = MpKit.ai.renderAnswer(answerEl, { title });
183
+ try {
184
+ const res = await MpKit.ai.explain({ error: this.scrub(error), context: this.scrub(context), device }, { onChunk: answer.append });
185
+ answer.done(res);
186
+ } catch (e) {
187
+ answer.error(e);
188
+ } finally {
189
+ button.disabled = false;
190
+ button.removeAttribute('aria-busy');
191
+ }
192
+ },
193
+
194
+ /**
195
+ * Adds an "Explain" button (and its answer panel) to the slot `slotId`, next to an
196
+ * error the page already shows. With `alert: true` the error itself is shown too
197
+ * (for errors that otherwise only appear in the auto-hiding global banner).
198
+ */
199
+ offer(slotId, { message, context, title, alert = false }) {
200
+ const slot = $id(slotId);
201
+ if (!slot || !this.enabled) {
202
+ return;
203
+ }
204
+ slot.innerHTML = `
205
+ <div class="d-flex justify-content-between align-items-start gap-2 ${alert ? 'alert alert-danger mb-0' : ''}">
206
+ <div>${alert ? Utils.escapeHtml(message) : ''}</div>
207
+ ${MpKit.ai.renderButton({ label: 'Explain', size: 'sm', className: 'flex-shrink-0 js-explain', title: 'Explain this problem' })}
208
+ </div>
209
+ <div class="assistant-answer mt-2 d-none"></div>`;
210
+ DOM.show(slot);
211
+ const button = slot.querySelector('.js-explain');
212
+ button.addEventListener('click', () => this.explain(button, slot.querySelector('.assistant-answer'), {
213
+ error: message,
214
+ context: this.context(context),
215
+ title,
216
+ }));
217
+ },
218
+
219
+ clear(slotId) {
220
+ const slot = $id(slotId);
221
+ if (slot) {
222
+ DOM.hide(slot);
223
+ slot.innerHTML = '';
224
+ }
225
+ },
226
+
227
+ // ── Describe Your Setup ────────────────────────────────────────────────────
228
+
229
+ setupConfigCard() {
230
+ DOM.show($id('assistant-config-card'));
231
+ $id('assistant-config-badge').innerHTML = MpKit.ai.renderBadge();
232
+ $id('assistant-config-action').innerHTML = MpKit.ai.renderButton({ label: 'Suggest changes', id: 'btn-assistant-config' });
233
+ $id('btn-assistant-config').addEventListener('click', () => this.suggestConfig());
234
+ },
235
+
236
+ async suggestConfig() {
237
+ const request = $id('assistant-config-request').value.trim();
238
+ if (!request) {
239
+ UI.showError('Describe what you want to change first');
240
+ return;
241
+ }
242
+ const button = $id('btn-assistant-config');
243
+ const result = $id('assistant-config-result');
244
+ button.disabled = true;
245
+ button.setAttribute('aria-busy', 'true');
246
+ result.innerHTML = MpKit.ai.renderThinking('Preparing a suggestion…');
247
+ try {
248
+ const config = (await homebridge.getPluginConfig())[0] || { platform: 'DaikinCloud', name: 'Daikin Cloud' };
249
+ // Credentials and addresses stay in the browser: strip them before and merge them back on apply
250
+ const shareable = { ...config };
251
+ this.PRIVATE_KEYS.forEach(k => delete shareable[k]);
252
+ const schema = await homebridge.getPluginConfigSchema();
253
+ const res = await MpKit.ai.config({ schema, request, current: shareable });
254
+
255
+ // Keep keys the schema does not describe (platform, _bridge, ...) and the current key order
256
+ const known = new Set(Object.keys((schema.schema || schema).properties || {}));
257
+ const proposed = res.config || {};
258
+ const suggested = {};
259
+ Object.keys(shareable).forEach(k => {
260
+ if (!known.has(k)) {
261
+ suggested[k] = shareable[k];
262
+ } else if (k in proposed) {
263
+ suggested[k] = proposed[k];
264
+ }
265
+ });
266
+ Object.keys(proposed).forEach(k => {
267
+ if (known.has(k) && !(k in suggested)) {
268
+ suggested[k] = proposed[k];
269
+ }
270
+ });
271
+ this.PRIVATE_KEYS.forEach(k => delete suggested[k]);
272
+
273
+ result.innerHTML = '<div class="assistant-explanation mb-2"></div><div class="assistant-diff"></div>';
274
+ MpKit.ai.renderAnswer(result.querySelector('.assistant-explanation'), { text: res.explanation, streaming: false, title: 'Suggested change' });
275
+ MpKit.ai.renderDiff(result.querySelector('.assistant-diff'), {
276
+ before: shareable,
277
+ after: suggested,
278
+ applyLabel: 'Apply',
279
+ onApply: () => this.applyConfig(config, suggested),
280
+ });
281
+ } catch (e) {
282
+ result.innerHTML = '';
283
+ MpKit.ai.renderAnswer(result, { streaming: false }).error(e);
284
+ } finally {
285
+ button.disabled = false;
286
+ button.removeAttribute('aria-busy');
287
+ }
288
+ },
289
+
290
+ async applyConfig(config, suggested) {
291
+ const newConfig = { ...suggested, platform: 'DaikinCloud', name: suggested.name || config.name || 'Daikin Cloud' };
292
+ this.PRIVATE_KEYS.forEach(k => {
293
+ if (config[k] !== undefined) {
294
+ newConfig[k] = config[k];
295
+ }
296
+ });
297
+ // The settings tab saves every change straight away; do the same here
298
+ await homebridge.updatePluginConfig([newConfig]);
299
+ await homebridge.savePluginConfig();
300
+
301
+ Settings.populateForm(newConfig);
302
+ Settings.excludedIds = newConfig.excludedDevicesByDeviceId || [];
303
+ Settings.loadDeviceToggles();
304
+ AuthMode.current = AuthMode.previous = newConfig.authMode || 'developer_portal';
305
+ AuthMode.updateUI();
306
+ homebridge.toast.success('Change saved. Restart Homebridge to apply it.');
307
+ },
308
+ };
309
+
117
310
  // ============================================================================
118
311
  // Tab Navigation
119
312
  // ============================================================================
@@ -307,20 +500,33 @@ const Auth = {
307
500
  async testConnection() {
308
501
  UI.setButtonLoading(El.btnTest, true, 'Testing...');
309
502
  DOM.hide(El.testResult);
503
+ Assistant.clear('test-problem');
310
504
 
311
505
  try {
312
506
  const result = await homebridge.request('/auth/test');
313
507
  El.testResult.className = `alert ${result.success ? 'alert-success' : 'alert-danger'}`;
314
508
  El.testResult.textContent = result.message;
315
509
  DOM.show(El.testResult);
510
+ if (!result.success) {
511
+ this.offerExplain(result.message);
512
+ }
316
513
  } catch (error) {
317
514
  El.testResult.className = 'alert alert-danger';
318
515
  El.testResult.textContent = 'Test failed: ' + error.message;
319
516
  DOM.show(El.testResult);
517
+ this.offerExplain(El.testResult.textContent);
320
518
  }
321
519
 
322
520
  UI.setButtonLoading(El.btnTest, false, null, 'Test Connection');
323
521
  },
522
+
523
+ offerExplain(message) {
524
+ Assistant.offer('test-problem', {
525
+ message,
526
+ context: 'Test Connection in the plugin settings (reads the saved token and lists the gateway devices from the Daikin Onecta API) failed.',
527
+ title: 'Why did the connection test fail?',
528
+ });
529
+ },
324
530
  };
325
531
 
326
532
  // ============================================================================
@@ -387,6 +593,7 @@ const Wizard = {
387
593
  }
388
594
 
389
595
  DOM.hide(El.validationErrors);
596
+ Assistant.clear('wizard-start-problem');
390
597
  const authResult = await Auth.startAuth(config);
391
598
 
392
599
  El.authUrlDisplay.textContent = authResult.authUrl;
@@ -403,6 +610,11 @@ const Wizard = {
403
610
  } catch (error) {
404
611
  El.validationErrors.innerHTML = `<div>${Utils.escapeHtml(error.message)}</div>`;
405
612
  DOM.show(El.validationErrors);
613
+ Assistant.offer('wizard-start-problem', {
614
+ message: error.message,
615
+ context: 'Starting the Developer Portal OAuth login (temporary HTTPS callback server) from the plugin settings failed.',
616
+ title: 'Why can the login not start?',
617
+ });
406
618
  }
407
619
 
408
620
  UI.hideLoading();
@@ -428,6 +640,7 @@ const Wizard = {
428
640
  }
429
641
 
430
642
  UI.showLoading();
643
+ Assistant.clear('wizard-auth-problem');
431
644
  try {
432
645
  const result = await homebridge.request('/auth/', { callbackUrl: url });
433
646
  if (result.success) {
@@ -435,14 +648,24 @@ const Wizard = {
435
648
  this.goToStep(3);
436
649
  await Config.save();
437
650
  } else {
438
- UI.showError('Authentication failed: ' + (result.message || 'Unknown error'));
651
+ this.authFailed('Authentication failed: ' + (result.message || 'Unknown error'));
439
652
  }
440
653
  } catch (error) {
441
- UI.showError('Authentication failed: ' + error.message);
654
+ this.authFailed('Authentication failed: ' + error.message);
442
655
  }
443
656
  UI.hideLoading();
444
657
  },
445
658
 
659
+ authFailed(message) {
660
+ UI.showError(message);
661
+ Assistant.offer('wizard-auth-problem', {
662
+ message,
663
+ alert: true,
664
+ context: 'Completing the Developer Portal OAuth login (exchanging the authorization code from the Daikin redirect for tokens) failed.',
665
+ title: 'Why did the login fail?',
666
+ });
667
+ },
668
+
446
669
  finish() {
447
670
  Polling.stop();
448
671
  this.hide();
@@ -493,7 +716,7 @@ const Polling = {
493
716
  await Config.save();
494
717
  Devices.load();
495
718
  } else {
496
- UI.showError('Authentication failed: ' + (result.error || 'Unknown error'));
719
+ Wizard.authFailed('Authentication failed: ' + (result.error || 'Unknown error'));
497
720
  }
498
721
  return;
499
722
  }
@@ -853,11 +1076,15 @@ const Settings = {
853
1076
  // ============================================================================
854
1077
 
855
1078
  const Devices = {
1079
+ items: [],
1080
+
856
1081
  async load({ refresh = false } = {}) {
857
1082
  DOM.show(El.devicesLoading);
858
1083
  El.devicesList.innerHTML = '';
859
1084
  DOM.hide(El.devicesEmpty);
860
1085
  DOM.hide(El.devicesError);
1086
+ Assistant.clear('devices-problem');
1087
+ this.items = [];
861
1088
 
862
1089
  try {
863
1090
  const result = await DeviceList.fetch({ refresh });
@@ -873,12 +1100,45 @@ const Devices = {
873
1100
  return;
874
1101
  }
875
1102
 
876
- El.devicesList.innerHTML = result.devices.map(d => this.render(d)).join('');
1103
+ this.items = result.devices;
1104
+ El.devicesList.innerHTML = result.devices.map((d, i) => this.render(d, i)).join('');
877
1105
  } catch (error) {
878
1106
  DOM.hide(El.devicesLoading);
879
1107
  El.devicesError.textContent = 'Failed to load: ' + error.message;
880
1108
  DOM.show(El.devicesError);
1109
+ this.offerExplain(El.devicesError.textContent);
1110
+ }
1111
+ },
1112
+
1113
+ offerExplain(message) {
1114
+ Assistant.offer('devices-problem', {
1115
+ message,
1116
+ context: 'Loading the device list (GET /v1/gateway-devices on the Daikin Onecta API) in the plugin settings failed.',
1117
+ title: 'Why did loading devices fail?',
1118
+ });
1119
+ },
1120
+
1121
+ // Why a device needs attention, or null when it looks fine
1122
+ problem(device) {
1123
+ if (!device.online) {
1124
+ return 'The Daikin Onecta cloud reports this unit as offline: its Wi-Fi adapter has no cloud connection (isCloudConnectionUp is false), so HomeKit cannot control it.';
1125
+ }
1126
+ return null;
1127
+ },
1128
+
1129
+ onListClick(e) {
1130
+ const button = e.target.closest('.js-explain-device');
1131
+ const row = button && button.closest('[data-device-index]');
1132
+ const device = row && this.items[Number(row.dataset.deviceIndex)];
1133
+ if (!device) {
1134
+ return;
881
1135
  }
1136
+ Assistant.explain(button, row.querySelector('.assistant-answer'), {
1137
+ error: this.problem(device) || 'The unit does not respond as expected.',
1138
+ context: Assistant.context('The user is looking at the device list of the Daikin Cloud plugin settings.'),
1139
+ device: Assistant.device(device),
1140
+ title: `Why does ${device.name || 'this unit'} need attention?`,
1141
+ });
882
1142
  },
883
1143
 
884
1144
  handleError(result) {
@@ -891,10 +1151,11 @@ const Devices = {
891
1151
  } else {
892
1152
  El.devicesError.textContent = result.message;
893
1153
  DOM.show(El.devicesError);
1154
+ this.offerExplain(result.message);
894
1155
  }
895
1156
  },
896
1157
 
897
- render(device) {
1158
+ render(device, index) {
898
1159
  const online = device.online;
899
1160
  const powerOn = device.powerState === 'on';
900
1161
  const mode = device.operationMode ? Utils.capitalize(device.operationMode) : '-';
@@ -909,15 +1170,21 @@ const Devices = {
909
1170
  `<span class="device-meta-item">Model: <span class="text-body-secondary">${Utils.escapeHtml(device.model)}</span></span>`,
910
1171
  ].filter(Boolean).join('<span class="device-meta-sep">·</span>');
911
1172
 
1173
+ const explainButton = Assistant.enabled && this.problem(device)
1174
+ ? MpKit.ai.renderButton({ label: 'Explain', size: 'sm', className: 'js-explain-device', title: 'Explain this device problem' })
1175
+ : '';
1176
+
912
1177
  return `
913
- <div class="list-group-item">
1178
+ <div class="list-group-item" data-device-index="${index}">
914
1179
  <div class="device-header">
915
1180
  <span class="device-power ${powerOn ? 'power-on' : 'power-off'}">${powerOn ? 'ON' : 'OFF'}</span>
916
1181
  <span class="device-name fw-semibold">${Utils.escapeHtml(device.name)}</span>
917
1182
  ${features ? `<div class="device-features">${features}</div>` : ''}
1183
+ ${explainButton}
918
1184
  <span class="device-status ${online ? 'online' : 'offline'}">${online ? 'Online' : 'Offline'}</span>
919
1185
  </div>
920
1186
  <div class="device-meta mt-1">${meta}</div>
1187
+ <div class="assistant-answer mt-2 d-none"></div>
921
1188
  </div>`;
922
1189
  },
923
1190
 
@@ -1023,6 +1290,7 @@ const MobileAuth = {
1023
1290
 
1024
1291
  DOM.hide(errors);
1025
1292
  DOM.hide(success);
1293
+ Assistant.clear('mobile-auth-problem');
1026
1294
 
1027
1295
  if (!email || !password) {
1028
1296
  errors.textContent = 'Please enter email and password';
@@ -1050,15 +1318,25 @@ const MobileAuth = {
1050
1318
  } else {
1051
1319
  errors.textContent = result.message || 'Authentication failed';
1052
1320
  DOM.show(errors);
1321
+ this.offerExplain(errors.textContent);
1053
1322
  }
1054
1323
  } catch (error) {
1055
1324
  errors.textContent = 'Failed: ' + error.message;
1056
1325
  DOM.show(errors);
1326
+ this.offerExplain(errors.textContent);
1057
1327
  }
1058
1328
 
1059
1329
  UI.setButtonLoading(btn, false, null, 'Test & Save Credentials');
1060
1330
  },
1061
1331
 
1332
+ offerExplain(message) {
1333
+ Assistant.offer('mobile-auth-problem', {
1334
+ message,
1335
+ context: 'Mobile App login (Onecta account, Gigya login at id.daikin.eu, then OIDC token exchange) from the plugin settings failed.',
1336
+ title: 'Why did the Mobile App login fail?',
1337
+ });
1338
+ },
1339
+
1062
1340
  async saveCredentials(email, password) {
1063
1341
  const config = await homebridge.getPluginConfig();
1064
1342
  const platformConfig = config[0] || { platform: 'DaikinCloud', name: 'Daikin Cloud' };
@@ -1237,6 +1515,8 @@ document.addEventListener('DOMContentLoaded', async () => {
1237
1515
  // getUserSettings not available in older versions — keep the early-detected theme
1238
1516
  }
1239
1517
  cacheElements();
1518
+ await Assistant.init();
1519
+ El.devicesList.addEventListener('click', (e) => Devices.onListClick(e));
1240
1520
  Auth.loadStatus();
1241
1521
  Config.load();
1242
1522
  await AuthMode.init();
@@ -22,6 +22,7 @@ const {
22
22
  RATE_LIMIT_STATUS_FILE,
23
23
  } = require(join(__dirname, '..', 'dist', 'src', 'constants'));
24
24
  const { validateConfig } = require(join(__dirname, '..', 'dist', 'src', 'config', 'config-manager'));
25
+ const { registerAssistant } = require('./assistant');
25
26
 
26
27
  // =============================================================================
27
28
  // Configuration
@@ -520,7 +521,11 @@ function createDaikinCloudUiServer(HomebridgePluginUiServer) {
520
521
 
521
522
  this.applyTransportFromConfig();
522
523
  this.registerHandlers();
523
- this.ready();
524
+
525
+ // Assistant: /ai/status, /ai/explain, /ai/ask, /ai/config (configured in Homebridge AI Kit).
526
+ // ai-core is ESM and loaded with import(); report ready once its routes are in place
527
+ // (registerAssistant never rejects) so the UI's first /ai/status call finds them.
528
+ this.assistantReady = registerAssistant(this).then(() => this.ready());
524
529
  }
525
530
 
526
531
  getStoragePath() {
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.2",
5
+ "version": "1.7.0",
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": {
@@ -28,7 +28,7 @@
28
28
  "smoke:ui": "node scripts/smoke-ui-server.js",
29
29
  "schema:check": "tsx scripts/check-schema-drift.ts",
30
30
  "watch": "npm run build && npm link && nodemon",
31
- "copy:ui-kit": "mkdir -p homebridge-ui/public/lib/fonts && cp node_modules/@mp-consulting/homebridge-ui-kit/dist/kit.css node_modules/@mp-consulting/homebridge-ui-kit/dist/kit.js homebridge-ui/public/lib/ && cp node_modules/bootstrap-icons/font/bootstrap-icons.min.css homebridge-ui/public/lib/ && cp node_modules/bootstrap-icons/font/fonts/bootstrap-icons.woff node_modules/bootstrap-icons/font/fonts/bootstrap-icons.woff2 homebridge-ui/public/lib/fonts/",
31
+ "copy:ui-kit": "mp-ui-kit-copy --vendor",
32
32
  "build": "npm run copy:ui-kit && rimraf ./dist && tsc",
33
33
  "prepublishOnly": "npm run lint && npm run build",
34
34
  "release": "npm publish",
@@ -39,12 +39,13 @@
39
39
  ],
40
40
  "dependencies": {
41
41
  "@homebridge/plugin-ui-utils": "^2.2.6",
42
+ "@mp-consulting/homebridge-ai-core": "^2.0.0",
42
43
  "ws": "^8.21.3",
43
44
  "zod": "^4.5.4"
44
45
  },
45
46
  "devDependencies": {
46
47
  "@eslint/js": "^10.0.1",
47
- "@mp-consulting/homebridge-ui-kit": "^1.1.0",
48
+ "@mp-consulting/homebridge-ui-kit": "^1.2.0",
48
49
  "@types/node": "^26.5.0",
49
50
  "@types/ws": "^8.18.1",
50
51
  "@vitest/coverage-v8": "^5.0.0",
@@ -59,6 +60,10 @@
59
60
  "typescript-eslint": "^8.70.0",
60
61
  "vitest": "^5.0.0"
61
62
  },
63
+ "overrides": {
64
+ "@nestjs/platform-fastify": "^12.1.2",
65
+ "fastify": "^5.12.5"
66
+ },
62
67
  "funding": {
63
68
  "type": "paypal",
64
69
  "url": "https://paypal.me/mickaelpalma"