@homebridge-plugins/homebridge-tado 8.8.2 → 9.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ # v9.1.0 - 2026-05-06
4
+ - Add support for multiple bridges in Config UI
5
+ - Validate tado account after authentication
6
+ - Improve browser authentication instructions
7
+ - Update got
8
+
9
+ ## v9.0.0 - 2026-05-03
10
+ - Require Node.js 22
11
+ - Update dependencies
12
+ - Update dependencies to fix vulnerabilities
13
+
3
14
  ## v8.8.2 - 2026-03-21
4
15
  - Update dependencies due to vulnerability
5
16
 
@@ -33,6 +33,22 @@
33
33
  <h6 class="m-0 p-0">Custom UI cannot be displayed. The used Config UI X version is not supported!</h6>
34
34
  </div>
35
35
 
36
+ <!-- selectBridge: shown only when this plugin has multiple platform entries
37
+ (e.g. several child bridges). Lets the user pick which one this UI
38
+ session will configure so that newly added homes don't all funnel into
39
+ platforms[0]. -->
40
+ <div id="selectBridge" style="display:none;">
41
+ <img src="images/tado_logo.png" alt="tado meets Homebridge" width="150px" class="center-it mt-2 tadoLogo">
42
+ <h6 class="text-center">You have multiple Tado bridge instances configured.<br>Pick the one you want to configure now.</h6>
43
+ <div class="container mt-4" style="max-width: 360px;">
44
+ <div class="form-group">
45
+ <label for="bridgeSelectChoice">Bridge</label>
46
+ <select class="custom-select" id="bridgeSelectChoice"></select>
47
+ </div>
48
+ <button id="bridgeSelectContinue" type="submit" class="btn btn-primary float-right mt-3 mr-0">Continue</button>
49
+ </div>
50
+ </div>
51
+
36
52
  <!-- isConfigured -->
37
53
  <div id="isConfigured" style="display:none;">
38
54
  <button type="submit" class="btn btn-elegant oldConfig" style="position: absolute;">
@@ -7,8 +7,39 @@ const pageNavigation = {
7
7
 
8
8
  let customSchemaActive = false;
9
9
  let pluginConfig = false;
10
+ let activeIndex = 0;
10
11
  let currentHome = false;
11
12
 
13
+ function bridgeLabel(entry, i) {
14
+ const name = entry && entry.name;
15
+ const childUsername = entry && entry._bridge && entry._bridge.username;
16
+ if (name && childUsername) return `${name} (child bridge ${childUsername})`;
17
+ if (name) return name;
18
+ if (childUsername) return `Child bridge ${childUsername}`;
19
+ return `Bridge ${i + 1}`;
20
+ }
21
+
22
+ async function chooseActiveBridge() {
23
+ if (!pluginConfig || pluginConfig.length <= 1) return;
24
+ const $select = $('#bridgeSelectChoice');
25
+ $select.empty();
26
+ pluginConfig.forEach((entry, i) => {
27
+ const homeCount = (entry && entry.homes && entry.homes.length) || 0;
28
+ const homesLabel = `${homeCount} home${homeCount === 1 ? '' : 's'}`;
29
+ $select.append(`<option value="${i}">${bridgeLabel(entry, i)} — ${homesLabel}</option>`);
30
+ });
31
+ $select.val(String(activeIndex));
32
+ await new Promise(resolve => {
33
+ $('#bridgeSelectContinue').off('click').on('click', () => {
34
+ const parsed = parseInt($select.val(), 10);
35
+ activeIndex = Number.isInteger(parsed) ? parsed : 0;
36
+ $('#selectBridge').hide();
37
+ resolve();
38
+ });
39
+ $('#selectBridge').fadeIn(250);
40
+ });
41
+ }
42
+
12
43
  const TIMEOUT = (ms) => new Promise((res) => setTimeout(res, ms));
13
44
 
14
45
  function toggleContent() {
@@ -130,20 +161,20 @@ async function createCustomSchema(home) {
130
161
  //schema.layout.homes.forEach()
131
162
 
132
163
  customSchemaActive = homebridge.createForm(schema, {
133
- name: pluginConfig[0].name,
134
- debug: pluginConfig[0].debug,
135
- disableHistoryService: pluginConfig[0].disableHistoryService,
136
- preferSiriTemperature: pluginConfig[0].preferSiriTemperature,
164
+ name: pluginConfig[activeIndex].name,
165
+ debug: pluginConfig[activeIndex].debug,
166
+ disableHistoryService: pluginConfig[activeIndex].disableHistoryService,
167
+ preferSiriTemperature: pluginConfig[activeIndex].preferSiriTemperature,
137
168
  homes: home
138
169
  });
139
170
 
140
171
  customSchemaActive.onChange(async config => {
141
172
 
142
- pluginConfig[0].name = config.name;
143
- pluginConfig[0].debug = config.debug;
144
- pluginConfig[0].disableHistoryService = config.disableHistoryService;
145
- pluginConfig[0].preferSiriTemperature = config.preferSiriTemperature;
146
- pluginConfig[0].homes = pluginConfig[0].homes.map(myHome => {
173
+ pluginConfig[activeIndex].name = config.name;
174
+ pluginConfig[activeIndex].debug = config.debug;
175
+ pluginConfig[activeIndex].disableHistoryService = config.disableHistoryService;
176
+ pluginConfig[activeIndex].preferSiriTemperature = config.preferSiriTemperature;
177
+ pluginConfig[activeIndex].homes = pluginConfig[activeIndex].homes.map(myHome => {
147
178
  if (myHome.name === config.homes.name) {
148
179
  myHome = config.homes;
149
180
  }
@@ -228,21 +259,21 @@ async function addNewDeviceToConfig(config, refresh, resync) {
228
259
 
229
260
  try {
230
261
 
231
- for (const i in config[0].homes) {
262
+ for (const i in config[activeIndex].homes) {
232
263
 
233
264
  let found = false;
234
265
 
235
- for (const j in pluginConfig[0].homes)
236
- if (config[0].homes[i].name === pluginConfig[0].homes[j].name)
266
+ for (const j in pluginConfig[activeIndex].homes)
267
+ if (config[activeIndex].homes[i].name === pluginConfig[activeIndex].homes[j].name)
237
268
  found = true;
238
269
 
239
270
  if (!found) {
240
- addDeviceToList(config[0].homes[i]);
241
- homebridge.toast.success(config[0].homes[i].name + ' added to config!', 'Success');
271
+ addDeviceToList(config[activeIndex].homes[i]);
272
+ homebridge.toast.success(config[activeIndex].homes[i].name + ' added to config!', 'Success');
242
273
  } else if (refresh) {
243
- homebridge.toast.success(config[0].homes[i].name + ' refreshed!', 'Success');
274
+ homebridge.toast.success(config[activeIndex].homes[i].name + ' refreshed!', 'Success');
244
275
  } else if (resync) {
245
- homebridge.toast.success(config[0].homes[i].name + ' resynchronized!', 'Success');
276
+ homebridge.toast.success(config[activeIndex].homes[i].name + ' resynchronized!', 'Success');
246
277
  }
247
278
 
248
279
  }
@@ -270,7 +301,7 @@ async function removeDeviceFromConfig(name) {
270
301
  let foundIndex;
271
302
  let pluginConfigBkp = JSON.parse(JSON.stringify(pluginConfig));
272
303
 
273
- pluginConfig[0].homes.forEach((home, index) => {
304
+ pluginConfig[activeIndex].homes.forEach((home, index) => {
274
305
  if (home.name === currentHome) {
275
306
  foundIndex = index;
276
307
  }
@@ -280,13 +311,13 @@ async function removeDeviceFromConfig(name) {
280
311
 
281
312
  try {
282
313
 
283
- pluginConfig[0].homes.splice(foundIndex, 1);
314
+ pluginConfig[activeIndex].homes.splice(foundIndex, 1);
284
315
  removeDeviceFromList(currentHome);
285
316
 
286
- if (!pluginConfig[0].homes.length) {
287
- delete pluginConfig[0].debug;
288
- delete pluginConfig[0].disableHistoryService;
289
- delete pluginConfig[0].preferSiriTemperature;
317
+ if (!pluginConfig[activeIndex].homes.length) {
318
+ delete pluginConfig[activeIndex].debug;
319
+ delete pluginConfig[activeIndex].disableHistoryService;
320
+ delete pluginConfig[activeIndex].preferSiriTemperature;
290
321
  }
291
322
 
292
323
  await homebridge.updatePluginConfig(pluginConfig);
@@ -324,9 +355,13 @@ async function fetchDevices(auth, refresh, resync) {
324
355
  homebridge.request('/authenticate', params);
325
356
  const instructionsURL = await homebridge.request('/exec', { dest: 'fullAuthentication' });
326
357
  if (instructionsURL && instructionsURL !== "") {
327
- $("#fetchDevices #authenticationInstructions").html(`Open the following URL in your browser, click "submit" and log in to your tado° account "${params.username}": <a href="${instructionsURL}" target ="_blank">${instructionsURL}</a>`);
358
+ $("#fetchDevices #authenticationInstructions").html(
359
+ `Sign in as <strong>${params.username}</strong> and click "Submit":` +
360
+ `<br><a href="${instructionsURL}" target="_blank" rel="noopener noreferrer">${instructionsURL}</a>` +
361
+ `<br><span style="font-size:smaller;color:#666;">Tip: if your browser is signed in to tado.com with a different account, open the link in a private/incognito window.</span>`
362
+ );
328
363
  $("#fetchDevices #authenticationInstructions").css("display", "block");
329
- homebridge.toast.info("Please follow the instructions above and confirm your login.");
364
+ homebridge.toast.info("Open the link and confirm your login.");
330
365
  const authenticationSuccessful = await homebridge.request('/exec', { dest: 'waitForAuthentication' });
331
366
  $("#fetchDevices #authenticationInstructions").html("");
332
367
  $("#fetchDevices #authenticationInstructions").css("display", "none");
@@ -348,7 +383,7 @@ async function fetchDevices(auth, refresh, resync) {
348
383
  //refresh selected home
349
384
 
350
385
  //Home Informations
351
- let home = config[0].homes.find(home => home && home.name === currentHome);
386
+ let home = config[activeIndex].homes.find(home => home && home.name === currentHome);
352
387
 
353
388
  if (!home)
354
389
  return homebridge.toast.error('Cannot refresh ' + currentHome + '. Not found in config!', 'Error');
@@ -371,26 +406,26 @@ async function fetchDevices(auth, refresh, resync) {
371
406
 
372
407
  const homeInfo = await homebridge.request('/exec', { dest: 'getHome', data: home.id });
373
408
 
374
- for (let [i, home] of config[0].homes.entries()) {
409
+ for (let [i, home] of config[activeIndex].homes.entries()) {
375
410
 
376
- if (config[0].homes[i].name === homeInfo.name) {
411
+ if (config[activeIndex].homes[i].name === homeInfo.name) {
377
412
 
378
- config[0].homes[i].id = homeInfo.id;
379
- config[0].homes[i].username = auth.username;
380
- config[0].homes[i].tadoApiUrl = auth.tadoApiUrl;
381
- config[0].homes[i].skipAuth = auth.skipAuth;
382
- config[0].homes[i].temperatureUnit = homeInfo.temperatureUnit || 'CELSIUS';
383
- config[0].homes[i].zones = config[0].homes[i].zones || [];
413
+ config[activeIndex].homes[i].id = homeInfo.id;
414
+ config[activeIndex].homes[i].username = auth.username;
415
+ config[activeIndex].homes[i].tadoApiUrl = auth.tadoApiUrl;
416
+ config[activeIndex].homes[i].skipAuth = auth.skipAuth;
417
+ config[activeIndex].homes[i].temperatureUnit = homeInfo.temperatureUnit || 'CELSIUS';
418
+ config[activeIndex].homes[i].zones = config[activeIndex].homes[i].zones || [];
384
419
 
385
420
  if (homeInfo.geolocation)
386
- config[0].homes[i].geolocation = {
421
+ config[activeIndex].homes[i].geolocation = {
387
422
  longitude: homeInfo.geolocation.longitude.toString(),
388
423
  latitude: homeInfo.geolocation.latitude.toString()
389
424
  };
390
425
 
391
426
  //init devices for childLock
392
- config[0].homes[i].extras = config[0].homes[i].extras || {};
393
- config[0].homes[i].extras.childLockSwitches = config[0].homes[i].extras.childLockSwitches || [];
427
+ config[activeIndex].homes[i].extras = config[activeIndex].homes[i].extras || {};
428
+ config[activeIndex].homes[i].extras.childLockSwitches = config[activeIndex].homes[i].extras.childLockSwitches || [];
394
429
 
395
430
  let allFoundDevices = [];
396
431
 
@@ -401,15 +436,15 @@ async function fetchDevices(auth, refresh, resync) {
401
436
  //Mobile Devices Informations
402
437
  const mobileDevices = await homebridge.request('/exec', { dest: 'getMobileDevices', data: home.id });
403
438
 
404
- if (!config[0].homes[i].presence)
405
- config[0].homes[i].presence = {
439
+ if (!config[activeIndex].homes[i].presence)
440
+ config[activeIndex].homes[i].presence = {
406
441
  anyone: false,
407
442
  accTypeAnyone: 'OCCUPANCY',
408
443
  user: []
409
444
  };
410
445
 
411
446
  //Remove not registred devices
412
- config[0].homes[i].presence.user.forEach((user, index) => {
447
+ config[activeIndex].homes[i].presence.user.forEach((user, index) => {
413
448
  let found = false;
414
449
  mobileDevices.forEach(foundUser => {
415
450
  if (foundUser.name === user.name) {
@@ -418,21 +453,21 @@ async function fetchDevices(auth, refresh, resync) {
418
453
  });
419
454
  if (!found) {
420
455
  homebridge.toast.info(user.name + ' removed from config!', auth.username);
421
- config[0].homes[i].presence.user.splice(index, 1);
456
+ config[activeIndex].homes[i].presence.user.splice(index, 1);
422
457
  }
423
458
  });
424
459
 
425
460
  //Check for new registred devices
426
- if (config[0].homes[i].presence.user.length) {
461
+ if (config[activeIndex].homes[i].presence.user.length) {
427
462
  for (const foundUser of mobileDevices) {
428
463
  let userIndex;
429
- config[0].homes[i].presence.user.forEach((user, index) => {
464
+ config[activeIndex].homes[i].presence.user.forEach((user, index) => {
430
465
  if (user.name === foundUser.name) {
431
466
  userIndex = index;
432
467
  }
433
468
  });
434
469
  if (userIndex === undefined) {
435
- config[0].homes[i].presence.user.push({
470
+ config[activeIndex].homes[i].presence.user.push({
436
471
  active: false,
437
472
  name: foundUser.name,
438
473
  accType: 'OCCUPANCY'
@@ -440,7 +475,7 @@ async function fetchDevices(auth, refresh, resync) {
440
475
  }
441
476
  }
442
477
  } else {
443
- config[0].homes[i].presence.user = mobileDevices.map(user => {
478
+ config[activeIndex].homes[i].presence.user = mobileDevices.map(user => {
444
479
  return {
445
480
  active: false,
446
481
  name: user.name,
@@ -457,7 +492,7 @@ async function fetchDevices(auth, refresh, resync) {
457
492
  const zones = await homebridge.request('/exec', { dest: 'getZones', data: home.id });
458
493
 
459
494
  //Remove not available zones
460
- config[0].homes[i].zones.forEach((zone, index) => {
495
+ config[activeIndex].homes[i].zones.forEach((zone, index) => {
461
496
  let found = false;
462
497
  zones.forEach(foundZone => {
463
498
  if (foundZone.name === zone.name) {
@@ -466,12 +501,12 @@ async function fetchDevices(auth, refresh, resync) {
466
501
  });
467
502
  if (!found) {
468
503
  homebridge.toast.info(zone.name + ' removed from config!', auth.username);
469
- config[0].homes[i].zones.splice(index, 1);
504
+ config[activeIndex].homes[i].zones.splice(index, 1);
470
505
  }
471
506
  });
472
507
 
473
508
  //Check for new zones or refresh exist one
474
- if (config[0].homes[i].zones.length) {
509
+ if (config[activeIndex].homes[i].zones.length) {
475
510
  for (const foundZone of zones) {
476
511
 
477
512
  const capabilities = await homebridge.request('/exec', { dest: 'getZoneCapabilities', data: [home.id, foundZone.id] }) || {};
@@ -516,19 +551,19 @@ async function fetchDevices(auth, refresh, resync) {
516
551
  });
517
552
 
518
553
  let zoneIndex;
519
- config[0].homes[i].zones.forEach((zone, index) => {
554
+ config[activeIndex].homes[i].zones.forEach((zone, index) => {
520
555
  if (zone.name === foundZone.name) {
521
556
  zoneIndex = index;
522
557
  }
523
558
  });
524
559
  if (zoneIndex !== undefined) {
525
- config[0].homes[i].zones[zoneIndex].id = foundZone.id;
526
- config[0].homes[i].zones[zoneIndex].type = foundZone.type;
527
- config[0].homes[i].zones[zoneIndex].minValue = minTempValue;
528
- config[0].homes[i].zones[zoneIndex].maxValue = maxTempValue;
529
- config[0].homes[i].zones[zoneIndex].minStep = minTempStep;
560
+ config[activeIndex].homes[i].zones[zoneIndex].id = foundZone.id;
561
+ config[activeIndex].homes[i].zones[zoneIndex].type = foundZone.type;
562
+ config[activeIndex].homes[i].zones[zoneIndex].minValue = minTempValue;
563
+ config[activeIndex].homes[i].zones[zoneIndex].maxValue = maxTempValue;
564
+ config[activeIndex].homes[i].zones[zoneIndex].minStep = minTempStep;
530
565
  } else {
531
- config[0].homes[i].zones.push({
566
+ config[activeIndex].homes[i].zones.push({
532
567
  active: true,
533
568
  id: foundZone.id,
534
569
  name: foundZone.name,
@@ -595,7 +630,7 @@ async function fetchDevices(auth, refresh, resync) {
595
630
  });
596
631
  });
597
632
 
598
- config[0].homes[i].zones.push({
633
+ config[activeIndex].homes[i].zones.push({
599
634
  active: true,
600
635
  id: zone.id,
601
636
  name: zone.name,
@@ -621,7 +656,7 @@ async function fetchDevices(auth, refresh, resync) {
621
656
  }
622
657
 
623
658
  //remove non existing childLockSwitches
624
- config[0].homes[i].extras.childLockSwitches.forEach((childLockSwitch, index) => {
659
+ config[activeIndex].homes[i].extras.childLockSwitches.forEach((childLockSwitch, index) => {
625
660
  let found = false;
626
661
  allFoundDevices.forEach(foundDevice => {
627
662
  if (foundDevice.serialNumber === childLockSwitch.serialNumber) {
@@ -630,21 +665,21 @@ async function fetchDevices(auth, refresh, resync) {
630
665
  });
631
666
  if (!found) {
632
667
  homebridge.toast.info(childLockSwitch.name + ' removed from config!', auth.username);
633
- config[0].homes[i].extras.childLockSwitches.splice(index, 1);
668
+ config[activeIndex].homes[i].extras.childLockSwitches.splice(index, 1);
634
669
  }
635
670
  });
636
671
 
637
672
  //check for new childLockSwitches
638
- if (config[0].homes[i].extras.childLockSwitches.length) {
673
+ if (config[activeIndex].homes[i].extras.childLockSwitches.length) {
639
674
  for (const foundDevice of allFoundDevices) {
640
675
  let found = false;
641
- config[0].homes[i].extras.childLockSwitches.forEach(childLockSwitch => {
676
+ config[activeIndex].homes[i].extras.childLockSwitches.forEach(childLockSwitch => {
642
677
  if (childLockSwitch.serialNumber === foundDevice.serialNumber) {
643
678
  found = true;
644
679
  }
645
680
  });
646
681
  if (!found) {
647
- config[0].homes[i].extras.childLockSwitches.push({
682
+ config[activeIndex].homes[i].extras.childLockSwitches.push({
648
683
  active: false,
649
684
  name: foundDevice.name,
650
685
  serialNumber: foundDevice.serialNumber
@@ -652,7 +687,7 @@ async function fetchDevices(auth, refresh, resync) {
652
687
  }
653
688
  }
654
689
  } else {
655
- config[0].homes[i].extras.childLockSwitches = allFoundDevices.map(device => {
690
+ config[activeIndex].homes[i].extras.childLockSwitches = allFoundDevices.map(device => {
656
691
  return {
657
692
  active: false,
658
693
  name: device.name,
@@ -671,7 +706,7 @@ async function fetchDevices(auth, refresh, resync) {
671
706
 
672
707
  const availableHomesInApis = [];
673
708
 
674
- for (let home of config[0].homes) {
709
+ for (let home of config[activeIndex].homes) {
675
710
 
676
711
  if (home.name && home.username) {
677
712
 
@@ -710,7 +745,7 @@ async function fetchDevices(auth, refresh, resync) {
710
745
  let removedHomes = 0;
711
746
 
712
747
  //remove non exist homes from config that doesnt exist in api
713
- for (let [i, home] of config[0].homes.entries()) {
748
+ for (let [i, home] of config[activeIndex].homes.entries()) {
714
749
 
715
750
  if (home.name && home.username) {
716
751
 
@@ -734,7 +769,7 @@ async function fetchDevices(auth, refresh, resync) {
734
769
  homebridge.toast.info(home.name + ' removed from config!', home.username);
735
770
 
736
771
  await removeDeviceFromConfig(home.name);
737
- config[0].homes.splice(i, 1);
772
+ config[activeIndex].homes.splice(i, 1);
738
773
 
739
774
  removedHomes += 1;
740
775
 
@@ -754,7 +789,7 @@ async function fetchDevices(auth, refresh, resync) {
754
789
  await TIMEOUT(2000);
755
790
 
756
791
  //refresh existing homes
757
- for (let [i, home] of config[0].homes.entries()) {
792
+ for (let [i, home] of config[activeIndex].homes.entries()) {
758
793
 
759
794
  if (home.name && home.username) {
760
795
 
@@ -780,37 +815,37 @@ async function fetchDevices(auth, refresh, resync) {
780
815
 
781
816
  const homeInfo = await homebridge.request('/exec', { dest: 'getHome', data: home.id });
782
817
 
783
- config[0].homes[i].id = homeInfo.id;
784
- config[0].homes[i].username = foundHome.username;
785
- config[0].homes[i].tadoApiUrl = foundHome.tadoApiUrl;
786
- config[0].homes[i].skipAuth = foundHome.skipAuth;
787
- config[0].homes[i].temperatureUnit = homeInfo.temperatureUnit || 'CELSIUS';
788
- config[0].homes[i].zones = config[0].homes[i].zones || [];
818
+ config[activeIndex].homes[i].id = homeInfo.id;
819
+ config[activeIndex].homes[i].username = foundHome.username;
820
+ config[activeIndex].homes[i].tadoApiUrl = foundHome.tadoApiUrl;
821
+ config[activeIndex].homes[i].skipAuth = foundHome.skipAuth;
822
+ config[activeIndex].homes[i].temperatureUnit = homeInfo.temperatureUnit || 'CELSIUS';
823
+ config[activeIndex].homes[i].zones = config[activeIndex].homes[i].zones || [];
789
824
 
790
825
  if (homeInfo.geolocation)
791
- config[0].homes[i].geolocation = {
826
+ config[activeIndex].homes[i].geolocation = {
792
827
  longitude: homeInfo.geolocation.longitude.toString(),
793
828
  latitude: homeInfo.geolocation.latitude.toString()
794
829
  };
795
830
 
796
831
  //init devices for childLock
797
- config[0].homes[i].extras = config[0].homes[i].extras || {};
798
- config[0].homes[i].extras.childLockSwitches = config[0].homes[i].extras.childLockSwitches || [];
832
+ config[activeIndex].homes[i].extras = config[activeIndex].homes[i].extras || {};
833
+ config[activeIndex].homes[i].extras.childLockSwitches = config[activeIndex].homes[i].extras.childLockSwitches || [];
799
834
 
800
835
  let allFoundDevices = [];
801
836
 
802
837
  //Mobile Devices Informations
803
838
  const mobileDevices = await homebridge.request('/exec', { dest: 'getMobileDevices', data: home.id });
804
839
 
805
- if (!config[0].homes[i].presence)
806
- config[0].homes[i].presence = {
840
+ if (!config[activeIndex].homes[i].presence)
841
+ config[activeIndex].homes[i].presence = {
807
842
  anyone: false,
808
843
  accTypeAnyone: 'OCCUPANCY',
809
844
  user: []
810
845
  };
811
846
 
812
847
  //Remove not registred devices
813
- config[0].homes[i].presence.user.forEach((user, index) => {
848
+ config[activeIndex].homes[i].presence.user.forEach((user, index) => {
814
849
  let found = false;
815
850
  mobileDevices.forEach(foundUser => {
816
851
  if (foundUser.name === user.name) {
@@ -819,21 +854,21 @@ async function fetchDevices(auth, refresh, resync) {
819
854
  });
820
855
  if (!found) {
821
856
  homebridge.toast.info(user.name + ' removed from config!', home.username);
822
- config[0].homes[i].presence.user.splice(index, 1);
857
+ config[activeIndex].homes[i].presence.user.splice(index, 1);
823
858
  }
824
859
  });
825
860
 
826
861
  //Check for new registred devices
827
- if (config[0].homes[i].presence.user.length) {
862
+ if (config[activeIndex].homes[i].presence.user.length) {
828
863
  for (const foundUser of mobileDevices) {
829
864
  let userIndex;
830
- config[0].homes[i].presence.user.forEach((user, index) => {
865
+ config[activeIndex].homes[i].presence.user.forEach((user, index) => {
831
866
  if (user.name === foundUser.name) {
832
867
  userIndex = index;
833
868
  }
834
869
  });
835
870
  if (userIndex === undefined) {
836
- config[0].homes[i].presence.user.push({
871
+ config[activeIndex].homes[i].presence.user.push({
837
872
  active: false,
838
873
  name: foundUser.name,
839
874
  accType: 'OCCUPANCY'
@@ -841,7 +876,7 @@ async function fetchDevices(auth, refresh, resync) {
841
876
  }
842
877
  }
843
878
  } else {
844
- config[0].homes[i].presence.user = mobileDevices.map(user => {
879
+ config[activeIndex].homes[i].presence.user = mobileDevices.map(user => {
845
880
  return {
846
881
  active: false,
847
882
  name: user.name,
@@ -854,7 +889,7 @@ async function fetchDevices(auth, refresh, resync) {
854
889
  const zones = await homebridge.request('/exec', { dest: 'getZones', data: home.id });
855
890
 
856
891
  //Remove not available zones
857
- config[0].homes[i].zones.forEach((zone, index) => {
892
+ config[activeIndex].homes[i].zones.forEach((zone, index) => {
858
893
  let found = false;
859
894
  zones.forEach(foundZone => {
860
895
  if (foundZone.name === zone.name) {
@@ -863,12 +898,12 @@ async function fetchDevices(auth, refresh, resync) {
863
898
  });
864
899
  if (!found) {
865
900
  homebridge.toast.info(zone.name + ' removed from config!', home.username);
866
- config[0].homes[i].zones.splice(index, 1);
901
+ config[activeIndex].homes[i].zones.splice(index, 1);
867
902
  }
868
903
  });
869
904
 
870
905
  //Check for new zones or refresh exist one
871
- if (config[0].homes[i].zones.length) {
906
+ if (config[activeIndex].homes[i].zones.length) {
872
907
  for (const foundZone of zones) {
873
908
 
874
909
  const capabilities = await homebridge.request('/exec', { dest: 'getZoneCapabilities', data: [home.id, foundZone.id] }) || {};
@@ -913,19 +948,19 @@ async function fetchDevices(auth, refresh, resync) {
913
948
  });
914
949
 
915
950
  let zoneIndex;
916
- config[0].homes[i].zones.forEach((zone, index) => {
951
+ config[activeIndex].homes[i].zones.forEach((zone, index) => {
917
952
  if (zone.name === foundZone.name) {
918
953
  zoneIndex = index;
919
954
  }
920
955
  });
921
956
  if (zoneIndex !== undefined) {
922
- config[0].homes[i].zones[zoneIndex].id = foundZone.id;
923
- config[0].homes[i].zones[zoneIndex].type = foundZone.type;
924
- config[0].homes[i].zones[zoneIndex].minValue = minTempValue;
925
- config[0].homes[i].zones[zoneIndex].maxValue = maxTempValue;
926
- config[0].homes[i].zones[zoneIndex].minStep = minTempStep;
957
+ config[activeIndex].homes[i].zones[zoneIndex].id = foundZone.id;
958
+ config[activeIndex].homes[i].zones[zoneIndex].type = foundZone.type;
959
+ config[activeIndex].homes[i].zones[zoneIndex].minValue = minTempValue;
960
+ config[activeIndex].homes[i].zones[zoneIndex].maxValue = maxTempValue;
961
+ config[activeIndex].homes[i].zones[zoneIndex].minStep = minTempStep;
927
962
  } else {
928
- config[0].homes[i].zones.push({
963
+ config[activeIndex].homes[i].zones.push({
929
964
  active: true,
930
965
  id: foundZone.id,
931
966
  name: foundZone.name,
@@ -993,7 +1028,7 @@ async function fetchDevices(auth, refresh, resync) {
993
1028
  });
994
1029
  });
995
1030
 
996
- config[0].homes[i].zones.push({
1031
+ config[activeIndex].homes[i].zones.push({
997
1032
  active: true,
998
1033
  id: zone.id,
999
1034
  name: zone.name,
@@ -1019,7 +1054,7 @@ async function fetchDevices(auth, refresh, resync) {
1019
1054
  }
1020
1055
 
1021
1056
  //remove non existing childLockSwitches
1022
- config[0].homes[i].extras.childLockSwitches.forEach((childLockSwitch, index) => {
1057
+ config[activeIndex].homes[i].extras.childLockSwitches.forEach((childLockSwitch, index) => {
1023
1058
  let found = false;
1024
1059
  allFoundDevices.forEach(foundDevice => {
1025
1060
  if (foundDevice.serialNumber === childLockSwitch.serialNumber) {
@@ -1028,21 +1063,21 @@ async function fetchDevices(auth, refresh, resync) {
1028
1063
  });
1029
1064
  if (!found) {
1030
1065
  homebridge.toast.info(childLockSwitch.serialNumber + ' removed from config!', home.username);
1031
- config[0].homes[i].extras.childLockSwitches.splice(index, 1);
1066
+ config[activeIndex].homes[i].extras.childLockSwitches.splice(index, 1);
1032
1067
  }
1033
1068
  });
1034
1069
 
1035
1070
  //check for new childLockSwitches
1036
- if (config[0].homes[i].extras.childLockSwitches.length) {
1071
+ if (config[activeIndex].homes[i].extras.childLockSwitches.length) {
1037
1072
  for (const foundDevice of allFoundDevices) {
1038
1073
  let found = false;
1039
- config[0].homes[i].extras.childLockSwitches.forEach(childLockSwitch => {
1074
+ config[activeIndex].homes[i].extras.childLockSwitches.forEach(childLockSwitch => {
1040
1075
  if (childLockSwitch.serialNumber === foundDevice.serialNumber) {
1041
1076
  found = true;
1042
1077
  }
1043
1078
  });
1044
1079
  if (!found) {
1045
- config[0].homes[i].extras.childLockSwitches.push({
1080
+ config[activeIndex].homes[i].extras.childLockSwitches.push({
1046
1081
  active: false,
1047
1082
  name: foundDevice.name,
1048
1083
  serialNumber: foundDevice.serialNumber
@@ -1050,7 +1085,7 @@ async function fetchDevices(auth, refresh, resync) {
1050
1085
  }
1051
1086
  }
1052
1087
  } else {
1053
- config[0].homes[i].extras.childLockSwitches = allFoundDevices.map(device => {
1088
+ config[activeIndex].homes[i].extras.childLockSwitches = allFoundDevices.map(device => {
1054
1089
  return {
1055
1090
  active: false,
1056
1091
  name: device.name,
@@ -1083,7 +1118,7 @@ async function fetchDevices(auth, refresh, resync) {
1083
1118
 
1084
1119
  let found = false;
1085
1120
 
1086
- config[0].homes.forEach(home => {
1121
+ config[activeIndex].homes.forEach(home => {
1087
1122
  if (home.name === foundHome.name || home.id === foundHome.id)
1088
1123
  found = true;
1089
1124
  });
@@ -1227,7 +1262,7 @@ async function fetchDevices(auth, refresh, resync) {
1227
1262
 
1228
1263
  }
1229
1264
 
1230
- config[0].homes.push(homeConfig);
1265
+ config[activeIndex].homes.push(homeConfig);
1231
1266
 
1232
1267
  await TIMEOUT(2000);
1233
1268
 
@@ -1253,7 +1288,7 @@ async function fetchDevices(auth, refresh, resync) {
1253
1288
  for (const foundHome of me.homes) {
1254
1289
 
1255
1290
  let homeIndex;
1256
- config[0].homes.forEach((home, index) => {
1291
+ config[activeIndex].homes.forEach((home, index) => {
1257
1292
  if (home.name === foundHome.name || home.id === foundHome.id) {
1258
1293
  homeIndex = index;
1259
1294
  }
@@ -1400,7 +1435,7 @@ async function fetchDevices(auth, refresh, resync) {
1400
1435
  }
1401
1436
 
1402
1437
 
1403
- config[0].homes.push(homeConfig);
1438
+ config[activeIndex].homes.push(homeConfig);
1404
1439
 
1405
1440
  }
1406
1441
 
@@ -1459,12 +1494,18 @@ async function fetchDevices(auth, refresh, resync) {
1459
1494
 
1460
1495
  } else {
1461
1496
 
1462
- if (!pluginConfig[0].homes || (pluginConfig[0].homes && !pluginConfig[0].homes.length)) {
1463
- pluginConfig[0].homes = [];
1497
+ // When the user runs this plugin as multiple platform entries (typical
1498
+ // with child bridges), let them pick which one this UI session will
1499
+ // configure. Otherwise every "+" still goes to platforms[0] and the
1500
+ // other entries silently stay empty.
1501
+ await chooseActiveBridge();
1502
+
1503
+ if (!pluginConfig[activeIndex].homes || (pluginConfig[activeIndex].homes && !pluginConfig[activeIndex].homes.length)) {
1504
+ pluginConfig[activeIndex].homes = [];
1464
1505
  return transPage(false, $('#notConfigured'));
1465
1506
  }
1466
1507
 
1467
- pluginConfig[0].homes.forEach(home => {
1508
+ pluginConfig[activeIndex].homes.forEach(home => {
1468
1509
  $('#deviceSelect').append('<option value="' + home.name + '">' + home.name + ' &lt;' + home.username + '&gt;</option>');
1469
1510
  });
1470
1511
 
@@ -1561,7 +1602,7 @@ $('#editDevice').on('click', () => {
1561
1602
  resetUI();
1562
1603
 
1563
1604
  currentHome = $('#deviceSelect option:selected').val();
1564
- let home = pluginConfig[0].homes.find(home => home.name === currentHome);
1605
+ let home = pluginConfig[activeIndex].homes.find(home => home.name === currentHome);
1565
1606
 
1566
1607
  if (!home)
1567
1608
  return homebridge.toast.error('Can not find selected home!', 'Error');
@@ -1578,7 +1619,7 @@ $('#refreshDevice').on('click', async () => {
1578
1619
 
1579
1620
  resetSchema();
1580
1621
 
1581
- let home = pluginConfig[0].homes.find(home => home.name === currentHome);
1622
+ let home = pluginConfig[activeIndex].homes.find(home => home.name === currentHome);
1582
1623
 
1583
1624
  if (!home)
1584
1625
  return homebridge.toast.error('Can not find home in config!', 'Error');
@@ -1613,7 +1654,7 @@ $('#removeDevice').on('click', async () => {
1613
1654
 
1614
1655
  resetUI();
1615
1656
 
1616
- transPage(false, pluginConfig[0].homes.length ? $('#isConfigured') : $('#notConfigured'));
1657
+ transPage(false, pluginConfig[activeIndex].homes.length ? $('#isConfigured') : $('#notConfigured'));
1617
1658
 
1618
1659
  } catch (err) {
1619
1660
 
@@ -10,64 +10,77 @@ class UiServer extends HomebridgePluginUiServer {
10
10
  this.onRequest('/exec', this.exec.bind(this));
11
11
  this.onRequest('/reset', this.reset.bind(this));
12
12
 
13
- this.tado = false;
13
+ // Track instances per username so two concurrent settings panels
14
+ // (or two child-bridge configurations) don't clobber each other's API.
15
+ this.tadoInstances = new Map();
16
+ this.activeUsername = undefined;
14
17
 
15
18
  this.ready();
16
19
  }
17
20
 
18
21
  authenticate(config) {
19
22
 
20
- this.tado = new TadoApi('Config UI X', {
21
- username: config.username,
23
+ const username = config?.username;
24
+ if (!username) throw new RequestError('Username is required for authentication.');
25
+
26
+ const instance = new TadoApi('Config UI X', {
27
+ username: username,
22
28
  tadoApiUrl: config.tadoApiUrl,
23
29
  skipAuth: config.skipAuth
24
30
  }, this.homebridgeStoragePath, false);
25
31
 
32
+ this.tadoInstances.set(username, instance);
33
+ this.activeUsername = username;
34
+
26
35
  return;
27
36
  }
28
37
 
29
- reset() {
38
+ reset(payload) {
30
39
 
31
- this.tado = false;
40
+ const username = payload?.username;
41
+ if (username) {
42
+ this.tadoInstances.delete(username);
43
+ if (this.activeUsername === username) this.activeUsername = undefined;
44
+ } else {
45
+ this.tadoInstances.clear();
46
+ this.activeUsername = undefined;
47
+ }
32
48
 
33
49
  return;
34
50
  }
35
51
 
36
52
  async exec(payload) {
37
53
 
38
- if (this.tado) {
39
-
40
- try {
54
+ const username = payload?.username || this.activeUsername;
55
+ const tado = username ? this.tadoInstances.get(username) : undefined;
41
56
 
42
- console.log('Executing /' + payload.dest);
57
+ if (!tado) throw new RequestError('API not initialized!');
43
58
 
44
- let value1, value2, value3;
59
+ try {
45
60
 
46
- if (payload.data) {
47
- if (typeof payload.data === 'object') {
48
- value1 = payload.data[0];
49
- value2 = payload.data[1];
50
- value3 = payload.data[2];
51
- } else {
52
- value1 = payload.data;
53
- }
54
- }
61
+ console.log('Executing /' + payload.dest + (username ? ` for ${username}` : ''));
55
62
 
56
- const data = await this.tado[payload.dest](value1, value2, value3);
63
+ let value1, value2, value3;
57
64
 
58
- return data;
65
+ if (payload.data) {
66
+ if (typeof payload.data === 'object') {
67
+ value1 = payload.data[0];
68
+ value2 = payload.data[1];
69
+ value3 = payload.data[2];
70
+ } else {
71
+ value1 = payload.data;
72
+ }
73
+ }
59
74
 
60
- } catch (err) {
75
+ const data = await tado[payload.dest](value1, value2, value3);
61
76
 
62
- console.log(err);
77
+ return data;
63
78
 
64
- throw new RequestError(err.message);
79
+ } catch (err) {
65
80
 
66
- }
67
-
68
- } else {
81
+ console.log(err);
69
82
 
70
- throw new RequestError('API not initialized!');
83
+ throw new RequestError(err.message);
71
84
 
72
85
  }
73
86
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebridge-plugins/homebridge-tado",
3
- "version": "8.8.2",
3
+ "version": "9.1.0",
4
4
  "description": "Homebridge plugin for controlling tado° devices.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -29,21 +29,21 @@
29
29
  "heat"
30
30
  ],
31
31
  "engines": {
32
- "node": ">=20",
32
+ "node": ">=22",
33
33
  "homebridge": "^1.6.0||^2.0.0-beta.0"
34
34
  },
35
35
  "dependencies": {
36
- "@homebridge/plugin-ui-utils": "^2.2.1",
36
+ "@homebridge/plugin-ui-utils": "^2.2.3",
37
37
  "fakegato-history": "^0.6.7",
38
38
  "form-data": "^4.0.5",
39
39
  "fs-extra": "^11.3.4",
40
- "got": "^14.6.6",
40
+ "got": "^15.0.5",
41
41
  "moment": "^2.30.1"
42
42
  },
43
43
  "devDependencies": {
44
- "eslint": "^10.1.0",
44
+ "eslint": "^10.3.0",
45
45
  "@eslint/js": "^10.0.1",
46
- "globals": "^17.4.0",
47
- "prettier": "^3.8.1"
46
+ "globals": "^17.6.0",
47
+ "prettier": "^3.8.3"
48
48
  }
49
49
  }
@@ -145,7 +145,6 @@ export default class Tado {
145
145
  await access(this._tadoInternalTokenFilePath);
146
146
  const refresh_token = await this._retrieveRefreshTokenFromInternalFile();
147
147
  return this._refreshToken(refresh_token);
148
-
149
148
  } catch (_err) {
150
149
  return this._authenticateUser();
151
150
  }
@@ -201,7 +200,10 @@ export default class Tado {
201
200
  await this._increaseCounter();
202
201
  const { device_code, verification_uri_complete } = authResponse.body;
203
202
  if (!device_code) throw new Error("Failed to retrieve device code.");
204
- Logger.info(`Open the following URL in your browser, click "submit" and log in to your tado° account "${this.username}": ${verification_uri_complete}`);
203
+ Logger.info(
204
+ `Open the following URL and sign in as "${this.username}" to authorize the plugin (tip: if your browser is signed in to tado.com with a different account, use a private/incognito window). ` +
205
+ `URL: ${verification_uri_complete}`
206
+ );
205
207
  if (this._tadoAuthenticationCallback) this._tadoAuthenticationCallback(verification_uri_complete);
206
208
  const maxRetries = 30;
207
209
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
@@ -223,6 +225,7 @@ export default class Tado {
223
225
  if (tokenResponse?.body) {
224
226
  const { access_token, refresh_token } = tokenResponse.body;
225
227
  if (access_token && refresh_token) {
228
+ await this._verifyAuthenticatedIdentity();
226
229
  await writeFile(this._tadoInternalTokenFilePath, JSON.stringify({ access_token, refresh_token }));
227
230
  this._tadoBearerToken = { access_token, refresh_token, timestamp: Date.now() };
228
231
  Logger.info("Authentication successful!");
@@ -234,6 +237,44 @@ export default class Tado {
234
237
  throw new Error(`Failed to authenticate after ${maxRetries} attempts.`);
235
238
  }
236
239
 
240
+ /*
241
+ * Tado's device-code "Submit" page silently confirms whichever account is
242
+ * already signed in to tado.com, so a user trying to authenticate account B
243
+ * can end up granting tokens for account A without noticing. Verify the
244
+ * identity that actually came back matches the one we asked for, and abort
245
+ * if it doesn't - better a loud failure than a silent account mix-up that
246
+ * poisons a token file. Uses got directly because apiCall -> getToken would
247
+ * re-enter the in-flight token promise and deadlock.
248
+ */
249
+ async _verifyAuthenticatedIdentity() {
250
+ if (!this.username) return;
251
+ const access_token = this._tadoBearerToken?.access_token;
252
+ if (!access_token) throw new Error('No access token available for identity verification.');
253
+ const url = `${this.tadoApiUrl}/api/v2/me`;
254
+ let me;
255
+ try {
256
+ const response = await got(url, {
257
+ method: 'GET',
258
+ responseType: 'json',
259
+ headers: { Authorization: `Bearer ${access_token}` },
260
+ timeout: { request: 15000 }
261
+ });
262
+ await this._increaseCounter();
263
+ me = response.body;
264
+ } catch (error) {
265
+ throw new Error(`Could not verify identity after authentication: ${error.message || JSON.stringify(error)}`);
266
+ }
267
+ const actual = (me?.email || me?.username || '').toLowerCase();
268
+ const expected = this.username.toLowerCase();
269
+ if (actual && actual !== expected) {
270
+ throw new Error(
271
+ `Authenticated identity "${actual}" does not match the configured username "${this.username}". ` +
272
+ `This usually means tado.com was logged in as a different account when you clicked "Submit". ` +
273
+ `Sign out of tado.com (or use a private/incognito window) and try again.`
274
+ );
275
+ }
276
+ }
277
+
237
278
  async _retrieveTokenFromExternalFile() {
238
279
  const maxRetries = 3;
239
280
  for (let attempt = 1; attempt <= maxRetries; attempt++) {