@homebridge-plugins/homebridge-tado 8.6.0-beta.1 → 8.6.0-beta.3

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,10 +1,16 @@
1
1
  # Changelog
2
2
 
3
- ## v8.6.0 - 2025-10-30
4
- - BREAKING CHANGE: If you use tadoApiUrl or skipAuth, you must now define them under each corresponding home in your configuration (#176)
5
- - Refactored configuration: moved tadoApiUrl and skipAuth into individual home configs to support multiple API URLs (#176)
3
+ ## v8.6.0 - 2025-10-31
4
+ - BREAKING CHANGE: If you use tadoApiUrl or skipAuth, they must now be defined under each corresponding home in your configuration (#176)
5
+ - Refactor configuration: Moved tadoApiUrl and skipAuth into individual home configs to support multiple API URLs (#176)
6
+ - Persist Tado zone states after zone state updates
6
7
  - Improved zone update logic: when setting a state, all zones are now updated immediately if the next scheduled update is more than 10 seconds away
8
+ - Advanced queue handling for update and persistence tasks
9
+ - Tado API counter now tracks and persists for each authenticated user
10
+ - Fix: Polling and tasks for multiple homes (#178)
7
11
  - Fix: Corrected zone update handling that could previously cause unintended heating changes (#178)
12
+ - Added additional debug log messages for zone updates
13
+ - Note: This update will reset your tado api counter for the current day to zero.
8
14
 
9
15
  ## v8.5.0 - 2025-10-27
10
16
  - Change minimum polling interval to 30s due to improvements made in v8.2.0
@@ -21,7 +21,7 @@ class UiServer extends HomebridgePluginUiServer {
21
21
  username: config.username,
22
22
  tadoApiUrl: config.tadoApiUrl,
23
23
  skipAuth: config.skipAuth
24
- }, this.homebridgeStoragePath);
24
+ }, this.homebridgeStoragePath, false);
25
25
 
26
26
  return;
27
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebridge-plugins/homebridge-tado",
3
- "version": "8.6.0-beta.1",
3
+ "version": "8.6.0-beta.3",
4
4
  "description": "Homebridge plugin for controlling tado° devices.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -1,42 +1,35 @@
1
1
  import Logger from '../helper/logger.js';
2
2
  import moment from 'moment';
3
- import { writeFile, access, readFile } from 'fs/promises';
3
+ import { writeFile } from 'fs/promises';
4
4
  import { join } from "path";
5
5
 
6
- let settingState = false;
7
- let tasksInitialized = false;
8
- let lastGetStates = 0;
9
- const delayTimer = {};
10
-
11
6
  const timeout = (ms) => new Promise((res) => setTimeout(res, ms));
12
- const aRefreshHistoryHandlers = [];
13
-
14
- export async function getPersistedStates(storagePath) {
15
- try {
16
- const sFilePath = join(storagePath, "tado-states.json");
17
- await access(sFilePath);
18
- const sData = (await readFile(sFilePath, "utf-8"));
19
- if (sData) return JSON.parse(sData);
20
- } catch (error) {
21
- //no states data => ignore
22
- Logger.debug(`Failed to read tado states file: ${error.message || error}`);
23
- }
24
- }
7
+ const helpers = {};
25
8
 
26
9
  export default (api, accessories, config, tado, telegram) => {
27
- const statesIntervalTime = Math.max(config.polling, 30) * 1000;
28
- const storagePath = api.user.storagePath();
10
+ //init helper variables for current home scope
11
+ if (!helpers[config.homeId]) {
12
+ helpers[config.homeId] = {
13
+ settingState: false,
14
+ tasksInitialized: false,
15
+ lastGetStates: 0,
16
+ lastPersistZoneStates: 0,
17
+ persistPromise: Promise.resolve(),
18
+ updateZonesRunning: false,
19
+ updateZonesNextQueued: false,
20
+ delayTimer: {},
21
+ refreshHistoryHandlers: [],
22
+ statesIntervalTime: Math.max(config.polling, 30) * 1000,
23
+ storagePath: api.user.storagePath(),
24
+ }
25
+ }
29
26
 
30
27
  async function setStates(accessory, accs, target, value) {
31
28
  let zoneUpdated = false;
32
-
33
29
  accessories = accs.filter((acc) => acc && acc.context.config.homeName === config.homeName);
34
-
35
30
  try {
36
- settingState = true;
37
-
31
+ helpers[config.homeId].settingState = true;
38
32
  value = typeof value === 'number' ? parseFloat(value.toFixed(2)) : value;
39
-
40
33
  Logger.info(target + ': ' + value, accessory.displayName);
41
34
 
42
35
  switch (accessory.context.config.subtype) {
@@ -63,10 +56,10 @@ export default (api, accessories, config, tado, telegram) => {
63
56
  value < 5
64
57
  ) {
65
58
  if (value === 0) {
66
- if (delayTimer[accessory.displayName]) {
59
+ if (helpers[config.homeId].delayTimer[accessory.displayName]) {
67
60
  Logger.info('Resetting delay timer', accessory.displayName);
68
- clearTimeout(delayTimer[accessory.displayName]);
69
- delayTimer[accessory.displayName] = null;
61
+ clearTimeout(helpers[config.homeId].delayTimer[accessory.displayName]);
62
+ helpers[config.homeId].delayTimer[accessory.displayName] = null;
70
63
  }
71
64
 
72
65
  power = 'OFF';
@@ -111,14 +104,14 @@ export default (api, accessories, config, tado, telegram) => {
111
104
  let timer = accessory.context.delayTimer;
112
105
  let tarState = value === 1 ? 'HEAT' : 'AUTO';
113
106
 
114
- if (delayTimer[accessory.displayName]) {
115
- clearTimeout(delayTimer[accessory.displayName]);
116
- delayTimer[accessory.displayName] = null;
107
+ if (helpers[config.homeId].delayTimer[accessory.displayName]) {
108
+ clearTimeout(helpers[config.homeId].delayTimer[accessory.displayName]);
109
+ helpers[config.homeId].delayTimer[accessory.displayName] = null;
117
110
  }
118
111
 
119
112
  Logger.info('Wait ' + timer + ' seconds before switching state', accessory.displayName);
120
113
 
121
- delayTimer[accessory.displayName] = setTimeout(async () => {
114
+ helpers[config.homeId].delayTimer[accessory.displayName] = setTimeout(async () => {
122
115
  Logger.info('Delay timer finished, switching state to ' + tarState, accessory.displayName);
123
116
 
124
117
  //targetState
@@ -172,7 +165,7 @@ export default (api, accessories, config, tado, telegram) => {
172
165
  );
173
166
  }
174
167
 
175
- delayTimer[accessory.displayName] = null;
168
+ helpers[config.homeId].delayTimer[accessory.displayName] = null;
176
169
  }, timer * 1000);
177
170
  }
178
171
  } else {
@@ -567,11 +560,11 @@ export default (api, accessories, config, tado, telegram) => {
567
560
  } catch (err) {
568
561
  errorHandler(err);
569
562
  } finally {
570
- settingState = false;
563
+ helpers[config.homeId].settingState = false;
571
564
  //update zones to ensure correct state in Apple Home
572
- const timeSinceLastGetStates = Date.now() - lastGetStates;
573
- const statesIntervalTimeLeft = statesIntervalTime - timeSinceLastGetStates;
574
- if (zoneUpdated && statesIntervalTimeLeft > 10_000) await updateZones();
565
+ const timeSinceLastGetStates = helpers[config.homeId].lastGetStates === 0 ? 0 : (Date.now() - helpers[config.homeId].lastGetStates);
566
+ const statesIntervalTimeLeft = helpers[config.homeId].statesIntervalTime - timeSinceLastGetStates;
567
+ if (zoneUpdated && statesIntervalTimeLeft > (10 * 1000)) await updateZones();
575
568
  }
576
569
  }
577
570
 
@@ -729,59 +722,49 @@ export default (api, accessories, config, tado, telegram) => {
729
722
  }
730
723
  }
731
724
 
732
- async function persistStates(homeId, zoneStates) {
725
+ function persistZoneStates(homeId, zoneStates) {
726
+ helpers[config.homeId].persistPromise = helpers[config.homeId].persistPromise.then(() => _persistZoneStates(homeId, zoneStates));
727
+ }
728
+
729
+ async function _persistZoneStates(homeId, zoneStates) {
730
+ if ((Date.now() - helpers[config.homeId].lastPersistZoneStates) < (10 * 1000)) return;
733
731
  try {
734
732
  if (zoneStates && Object.keys(zoneStates).length) {
735
733
  const homeData = {};
736
734
  homeData.zoneStates = zoneStates;
737
- await writeFile(join(storagePath, `tado-states-${homeId}.json`), JSON.stringify(homeData, null, 2), "utf-8");
735
+ await writeFile(join(helpers[config.homeId].storagePath, `tado-states-${homeId}.json`), JSON.stringify(homeData, null, 2), "utf-8");
736
+ helpers[config.homeId].lastPersistZoneStates = Date.now();
738
737
  } else {
739
- Logger.info(`Skipping persistence of tado states for home ${homeId}: zone states are empty.`);
738
+ Logger.warn(`Skipping persistence of tado states file for home ${homeId}: zone states are empty.`);
740
739
  }
741
740
  } catch (error) {
742
741
  Logger.error(`Error while updating the tado states file for home ${homeId}: ${error.message || error}`);
743
742
  }
744
- try {
745
- const data = {};
746
- data.counterData = await tado.getCounterData();
747
- await writeFile(join(storagePath, "tado-states.json"), JSON.stringify(data, null, 2), "utf-8");
748
- } catch (error) {
749
- Logger.error(`Error while updating the tado states file: ${error.message || error}`);
750
- }
743
+ }
744
+
745
+ async function refreshHistoryServices() {
746
+ if (!helpers[config.homeId].refreshHistoryHandlers.length) return;
751
747
  try {
752
748
  //wait for fakegato history services to be loaded
753
749
  await timeout(4000);
754
- for (const refreshHistory of aRefreshHistoryHandlers) {
750
+ for (const refreshHistory of helpers[config.homeId].refreshHistoryHandlers) {
755
751
  refreshHistory();
756
752
  }
757
753
  } catch (error) {
758
- Logger.error(`Error while refreshing history: ${error.message || error}`);
759
- }
760
- }
761
-
762
- async function logCounter() {
763
- try {
764
- const iCounter = (await tado.getCounterData()).counter;
765
- Logger.info(`Tado API counter: ${iCounter.toLocaleString('en-US')}`);
766
- } catch (error) {
767
- Logger.warn(`Failed to get Tado API counter: ${error.message || error}`);
754
+ Logger.error(`Error while refreshing history services: ${error.message || error}`);
768
755
  }
769
756
  }
770
757
 
771
758
  function initTasks() {
772
- if (tasksInitialized) return;
773
- tasksInitialized = true;
759
+ if (helpers[config.homeId].tasksInitialized) return;
760
+ helpers[config.homeId].tasksInitialized = true;
774
761
 
775
762
  void getStates();
776
- setInterval(() => getStates(), statesIntervalTime);
777
-
778
- void logCounter();
779
- setInterval(() => logCounter(), 60 * 60 * 1000);
763
+ setInterval(() => void getStates(), helpers[config.homeId].statesIntervalTime);
780
764
  }
781
765
 
782
766
  async function getStates() {
783
- lastGetStates = Date.now();
784
- let zoneStates = {};
767
+ helpers[config.homeId].lastGetStates = Date.now();
785
768
  try {
786
769
  //ME
787
770
  if (!config.homeId) await updateMe();
@@ -790,7 +773,7 @@ export default (api, accessories, config, tado, telegram) => {
790
773
  if (!config.temperatureUnit) await updateHome();
791
774
 
792
775
  //Zones
793
- if (config.zones.length) zoneStates = await updateZones();
776
+ if (config.zones.length) await updateZones();
794
777
 
795
778
  //MobileDevices
796
779
  if (config.presence.length) await updateMobileDevices();
@@ -809,12 +792,12 @@ export default (api, accessories, config, tado, telegram) => {
809
792
  } catch (err) {
810
793
  errorHandler(err);
811
794
  } finally {
812
- void persistStates(config.homeId, zoneStates);
795
+ void refreshHistoryServices();
813
796
  }
814
797
  }
815
798
 
816
799
  async function updateMe() {
817
- if (settingState) return;
800
+ if (helpers[config.homeId].settingState) return;
818
801
 
819
802
  Logger.debug('Polling User Info...', config.homeName);
820
803
 
@@ -826,7 +809,7 @@ export default (api, accessories, config, tado, telegram) => {
826
809
  }
827
810
 
828
811
  async function updateHome() {
829
- if (settingState) return;
812
+ if (helpers[config.homeId].settingState) return;
830
813
 
831
814
  Logger.debug('Polling Home Info...', config.homeName);
832
815
 
@@ -851,7 +834,32 @@ export default (api, accessories, config, tado, telegram) => {
851
834
  }
852
835
 
853
836
  async function updateZones() {
854
- if (settingState) return;
837
+ if (helpers[config.homeId].updateZonesRunning) {
838
+ helpers[config.homeId].updateZonesNextQueued = true;
839
+ return;
840
+ }
841
+ helpers[config.homeId].updateZonesRunning = true;
842
+ try {
843
+ while (true) {
844
+ try {
845
+ await _updateZones();
846
+ } catch (error) {
847
+ Logger.error(`Failed to update zones: ${error.message || error}`);
848
+ }
849
+ if (helpers[config.homeId].updateZonesNextQueued) {
850
+ helpers[config.homeId].updateZonesNextQueued = false;
851
+ //continue with loop
852
+ } else {
853
+ break;
854
+ }
855
+ }
856
+ } finally {
857
+ helpers[config.homeId].updateZonesRunning = false;
858
+ }
859
+ }
860
+
861
+ async function _updateZones() {
862
+ if (helpers[config.homeId].settingState) return;
855
863
 
856
864
  Logger.debug('Polling Zones...', config.homeName);
857
865
 
@@ -903,6 +911,7 @@ export default (api, accessories, config, tado, telegram) => {
903
911
  }
904
912
 
905
913
  const zoneStates = (await tado.getZoneStates(config.homeId))["zoneStates"] ?? {};
914
+ void persistZoneStates(config.homeId, zoneStates);
906
915
 
907
916
  for (const zone of config.zones) {
908
917
  const zoneState = zoneStates[zone.id.toString()];
@@ -1313,7 +1322,7 @@ export default (api, accessories, config, tado, telegram) => {
1313
1322
  }
1314
1323
 
1315
1324
  async function updateMobileDevices() {
1316
- if (settingState) return;
1325
+ if (helpers[config.homeId].settingState) return;
1317
1326
 
1318
1327
  Logger.debug('Polling MobileDevices...', config.homeName);
1319
1328
 
@@ -1367,7 +1376,7 @@ export default (api, accessories, config, tado, telegram) => {
1367
1376
  }
1368
1377
 
1369
1378
  async function updateWeather() {
1370
- if (settingState) return;
1379
+ if (helpers[config.homeId].settingState) return;
1371
1380
 
1372
1381
  const weatherTemperatureAccessory = accessories.filter(
1373
1382
  (acc) => acc && acc.displayName === acc.context.config.homeName + ' Weather'
@@ -1422,7 +1431,7 @@ export default (api, accessories, config, tado, telegram) => {
1422
1431
  }
1423
1432
 
1424
1433
  async function updatePresence() {
1425
- if (settingState) return;
1434
+ if (helpers[config.homeId].settingState) return;
1426
1435
 
1427
1436
  const presenceLockAccessory = accessories.filter(
1428
1437
  (acc) => acc && acc.displayName === acc.context.config.homeName + ' Presence Lock'
@@ -1467,7 +1476,7 @@ export default (api, accessories, config, tado, telegram) => {
1467
1476
  }
1468
1477
 
1469
1478
  async function updateRunningTime() {
1470
- if (settingState) return;
1479
+ if (helpers[config.homeId].settingState) return;
1471
1480
 
1472
1481
  const centralSwitchAccessory = accessories.filter(
1473
1482
  (acc) => acc && acc.displayName === acc.context.config.homeName + ' Central Switch'
@@ -1512,7 +1521,7 @@ export default (api, accessories, config, tado, telegram) => {
1512
1521
  }
1513
1522
 
1514
1523
  async function updateDevices() {
1515
- if (settingState) return;
1524
+ if (helpers[config.homeId].settingState) return;
1516
1525
 
1517
1526
  Logger.debug('Polling Devices...', config.homeName);
1518
1527
 
@@ -1583,6 +1592,6 @@ export default (api, accessories, config, tado, telegram) => {
1583
1592
  getStates: getStates,
1584
1593
  setStates: setStates,
1585
1594
  changedStates: changedStates,
1586
- refreshHistoryHandlers: aRefreshHistoryHandlers
1595
+ refreshHistoryHandlers: helpers[config.homeId].refreshHistoryHandlers
1587
1596
  };
1588
- };
1597
+ };
@@ -1,5 +1,4 @@
1
1
  import Logger from '../helper/logger.js';
2
- import { getPersistedStates } from '../helper/handler.js';
3
2
  import got from 'got';
4
3
  import { join } from 'path';
5
4
  import { access, readFile, writeFile } from 'fs/promises';
@@ -8,8 +7,17 @@ const tado_url = "https://my.tado.com";
8
7
  const tado_auth_url = "https://login.tado.com/oauth2";
9
8
  const tado_client_id = "1bb50063-6b0c-4d11-bd99-387f4a91cc46";
10
9
 
10
+ function _getSimpleHash(str) {
11
+ let hash = 0;
12
+ for (let i = 0; i < str.length; i++) {
13
+ const char = str.charCodeAt(i);
14
+ hash = (hash << 5) - hash + char;
15
+ }
16
+ return (hash >>> 0).toString(36).padStart(7, '0');
17
+ }
18
+
11
19
  export default class Tado {
12
- constructor(name, auth, storagePath) {
20
+ constructor(name, auth, storagePath, counterActivated) {
13
21
  this.tadoApiUrl = auth.tadoApiUrl || tado_url;
14
22
  this.customTadoApiUrlActive = !!auth.tadoApiUrl;
15
23
  this.skipAuth = auth.skipAuth?.toString() === "true";
@@ -17,28 +25,42 @@ export default class Tado {
17
25
  this.name = name;
18
26
  const usesExternalTokenFile = auth.username?.toLowerCase().endsWith(".json");
19
27
  this._tadoExternalTokenFilePath = usesExternalTokenFile ? auth.username : undefined;
20
- const fnSimpleHash = (str) => {
21
- let hash = 0;
22
- for (let i = 0; i < str.length; i++) {
23
- const char = str.charCodeAt(i);
24
- hash = (hash << 5) - hash + char;
25
- }
26
- return (hash >>> 0).toString(36).padStart(7, '0');
27
- };
28
+ this.hashedUsername = _getSimpleHash(auth.username);
28
29
  this.username = usesExternalTokenFile ? undefined : auth.username;
29
- this._tadoInternalTokenFilePath = usesExternalTokenFile ? undefined : join(this.storagePath, `.tado-token-${fnSimpleHash(auth.username)}.json`);
30
+ this._tadoInternalTokenFilePath = usesExternalTokenFile ? undefined : join(this.storagePath, `.tado-token-${this.hashedUsername}.json`);
30
31
  this._tadoApiClientId = tado_client_id;
31
32
  this._tadoTokenPromise = undefined;
32
33
  this._tadoAuthenticationCallback = undefined;
34
+ this._counterActivated = counterActivated?.toString() === "true";
33
35
  this._counterInitPromise = this._initCounter();
34
36
  Logger.debug("API successfull initialized", this.name);
35
37
  }
36
38
 
37
39
  async _initCounter() {
38
- const persistedCounterData = (await getPersistedStates(this.storagePath))?.counterData;
40
+ if (!this._counterActivated) return;
41
+ const persistedCounterData = (await this._getPersistedCounter())?.counterData;
39
42
  this._counter = persistedCounterData?.counter ?? 0;
40
43
  this._counterTimestamp = persistedCounterData?.counterTimestamp ?? new Date().toISOString();
41
44
  this._checkCounterMidnightReset();
45
+ //wait some seconds to catch recent api calls
46
+ setTimeout(() => {
47
+ void this._logCounter();
48
+ setInterval(() => void this._logCounter(), 60 * 60 * 1000);
49
+ void this._persistCounterData();
50
+ setInterval(() => void this._persistCounterData(), 5 * 60 * 1000);
51
+ }, 4 * 1000);
52
+ }
53
+
54
+ async _getPersistedCounter() {
55
+ try {
56
+ const filePath = join(this.storagePath, `tado-api-${this.hashedUsername}.json`);
57
+ await access(filePath);
58
+ const data = (await readFile(filePath, "utf-8"));
59
+ if (data) return JSON.parse(data);
60
+ } catch (error) {
61
+ //no persisted counter data => ignore
62
+ Logger.debug(`Failed to read tado api file for user ${this.hashedUsername}: ${error.message || error}`);
63
+ }
42
64
  }
43
65
 
44
66
  _checkCounterMidnightReset() {
@@ -53,6 +75,7 @@ export default class Tado {
53
75
  }
54
76
 
55
77
  async _increaseCounter() {
78
+ if (!this._counterActivated) return;
56
79
  try {
57
80
  await this._counterInitPromise;
58
81
  this._checkCounterMidnightReset();
@@ -63,7 +86,7 @@ export default class Tado {
63
86
  }
64
87
  }
65
88
 
66
- async getCounterData() {
89
+ async _getCounterData() {
67
90
  await this._counterInitPromise;
68
91
  return {
69
92
  counter: this._counter,
@@ -71,6 +94,25 @@ export default class Tado {
71
94
  };
72
95
  }
73
96
 
97
+ async _persistCounterData() {
98
+ try {
99
+ const data = {};
100
+ data.counterData = await this._getCounterData();
101
+ await writeFile(join(this.storagePath, `tado-api-${this.hashedUsername}.json`), JSON.stringify(data, null, 2), "utf-8");
102
+ } catch (error) {
103
+ Logger.error(`Error while updating the tado api file for user ${this.hashedUsername}: ${error.message || error}`);
104
+ }
105
+ }
106
+
107
+ async _logCounter() {
108
+ try {
109
+ const counter = (await this._getCounterData()).counter;
110
+ Logger.info(`Tado API counter: ${counter.toLocaleString('en-US')}`);
111
+ } catch (error) {
112
+ Logger.warn(`Failed to get Tado API counter: ${error.message || error}`);
113
+ }
114
+ }
115
+
74
116
  async getToken() {
75
117
  Logger.debug('Get access token...', this.name);
76
118
  if (!this._tadoTokenPromise) {
@@ -23,7 +23,7 @@ export default {
23
23
 
24
24
  for (const auth of auths) {
25
25
 
26
- const tado = new TadoApi('Configuration', auth, storagePath);
26
+ const tado = new TadoApi('Configuration', auth, storagePath, false);
27
27
 
28
28
  const me = await tado.getMe();
29
29
 
@@ -160,7 +160,7 @@ export default {
160
160
  username: auth.username,
161
161
  tadoApiUrl: auth.tadoApiUrl,
162
162
  skipAuth: auth.skipAuth
163
- }, storagePath);
163
+ }, storagePath, false);
164
164
 
165
165
  const me = await tado.getMe();
166
166
 
@@ -208,7 +208,7 @@ export default {
208
208
 
209
209
  refresh: async function (currentHome, config, auth, storagePath) {
210
210
 
211
- const tado = new TadoApi('Configuration', auth, storagePath);
211
+ const tado = new TadoApi('Configuration', auth, storagePath, false);
212
212
 
213
213
  //Home Informations
214
214
  let home = config.homes.find((home) => home && home.name === currentHome);
@@ -505,7 +505,7 @@ export default {
505
505
  username: home.username,
506
506
  tadoApiUrl: home.tadoApiUrl,
507
507
  skipAuth: home.skipAuth
508
- }, storagePath);
508
+ }, storagePath, true);
509
509
 
510
510
  const accessoryConfig = {
511
511
  homeId: home.id,