@homebridge-plugins/homebridge-tado 8.6.0-beta.2 → 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,11 +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
- - Refactor configuration: moved tadoApiUrl and skipAuth into individual home configs to support multiple API URLs (#176)
6
- - Persist tado zone states after every zone states update
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
7
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)
8
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.
9
14
 
10
15
  ## v8.5.0 - 2025-10-27
11
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.2",
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,61 +722,49 @@ export default (api, accessories, config, tado, telegram) => {
729
722
  }
730
723
  }
731
724
 
732
- async function persistZoneStates(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
743
  }
745
744
 
746
- async function persistStates() {
747
- try {
748
- const data = {};
749
- data.counterData = await tado.getCounterData();
750
- await writeFile(join(storagePath, "tado-states.json"), JSON.stringify(data, null, 2), "utf-8");
751
- } catch (error) {
752
- Logger.error(`Error while updating the tado states file: ${error.message || error}`);
753
- }
745
+ async function refreshHistoryServices() {
746
+ if (!helpers[config.homeId].refreshHistoryHandlers.length) return;
754
747
  try {
755
748
  //wait for fakegato history services to be loaded
756
749
  await timeout(4000);
757
- for (const refreshHistory of aRefreshHistoryHandlers) {
750
+ for (const refreshHistory of helpers[config.homeId].refreshHistoryHandlers) {
758
751
  refreshHistory();
759
752
  }
760
753
  } catch (error) {
761
- Logger.error(`Error while refreshing history: ${error.message || error}`);
762
- }
763
- }
764
-
765
- async function logCounter() {
766
- try {
767
- const iCounter = (await tado.getCounterData()).counter;
768
- Logger.info(`Tado API counter: ${iCounter.toLocaleString('en-US')}`);
769
- } catch (error) {
770
- Logger.warn(`Failed to get Tado API counter: ${error.message || error}`);
754
+ Logger.error(`Error while refreshing history services: ${error.message || error}`);
771
755
  }
772
756
  }
773
757
 
774
758
  function initTasks() {
775
- if (tasksInitialized) return;
776
- tasksInitialized = true;
759
+ if (helpers[config.homeId].tasksInitialized) return;
760
+ helpers[config.homeId].tasksInitialized = true;
777
761
 
778
762
  void getStates();
779
- setInterval(() => getStates(), statesIntervalTime);
780
-
781
- void logCounter();
782
- setInterval(() => logCounter(), 60 * 60 * 1000);
763
+ setInterval(() => void getStates(), helpers[config.homeId].statesIntervalTime);
783
764
  }
784
765
 
785
766
  async function getStates() {
786
- lastGetStates = Date.now();
767
+ helpers[config.homeId].lastGetStates = Date.now();
787
768
  try {
788
769
  //ME
789
770
  if (!config.homeId) await updateMe();
@@ -811,12 +792,12 @@ export default (api, accessories, config, tado, telegram) => {
811
792
  } catch (err) {
812
793
  errorHandler(err);
813
794
  } finally {
814
- void persistStates();
795
+ void refreshHistoryServices();
815
796
  }
816
797
  }
817
798
 
818
799
  async function updateMe() {
819
- if (settingState) return;
800
+ if (helpers[config.homeId].settingState) return;
820
801
 
821
802
  Logger.debug('Polling User Info...', config.homeName);
822
803
 
@@ -828,7 +809,7 @@ export default (api, accessories, config, tado, telegram) => {
828
809
  }
829
810
 
830
811
  async function updateHome() {
831
- if (settingState) return;
812
+ if (helpers[config.homeId].settingState) return;
832
813
 
833
814
  Logger.debug('Polling Home Info...', config.homeName);
834
815
 
@@ -853,7 +834,32 @@ export default (api, accessories, config, tado, telegram) => {
853
834
  }
854
835
 
855
836
  async function updateZones() {
856
- 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;
857
863
 
858
864
  Logger.debug('Polling Zones...', config.homeName);
859
865
 
@@ -1316,7 +1322,7 @@ export default (api, accessories, config, tado, telegram) => {
1316
1322
  }
1317
1323
 
1318
1324
  async function updateMobileDevices() {
1319
- if (settingState) return;
1325
+ if (helpers[config.homeId].settingState) return;
1320
1326
 
1321
1327
  Logger.debug('Polling MobileDevices...', config.homeName);
1322
1328
 
@@ -1370,7 +1376,7 @@ export default (api, accessories, config, tado, telegram) => {
1370
1376
  }
1371
1377
 
1372
1378
  async function updateWeather() {
1373
- if (settingState) return;
1379
+ if (helpers[config.homeId].settingState) return;
1374
1380
 
1375
1381
  const weatherTemperatureAccessory = accessories.filter(
1376
1382
  (acc) => acc && acc.displayName === acc.context.config.homeName + ' Weather'
@@ -1425,7 +1431,7 @@ export default (api, accessories, config, tado, telegram) => {
1425
1431
  }
1426
1432
 
1427
1433
  async function updatePresence() {
1428
- if (settingState) return;
1434
+ if (helpers[config.homeId].settingState) return;
1429
1435
 
1430
1436
  const presenceLockAccessory = accessories.filter(
1431
1437
  (acc) => acc && acc.displayName === acc.context.config.homeName + ' Presence Lock'
@@ -1470,7 +1476,7 @@ export default (api, accessories, config, tado, telegram) => {
1470
1476
  }
1471
1477
 
1472
1478
  async function updateRunningTime() {
1473
- if (settingState) return;
1479
+ if (helpers[config.homeId].settingState) return;
1474
1480
 
1475
1481
  const centralSwitchAccessory = accessories.filter(
1476
1482
  (acc) => acc && acc.displayName === acc.context.config.homeName + ' Central Switch'
@@ -1515,7 +1521,7 @@ export default (api, accessories, config, tado, telegram) => {
1515
1521
  }
1516
1522
 
1517
1523
  async function updateDevices() {
1518
- if (settingState) return;
1524
+ if (helpers[config.homeId].settingState) return;
1519
1525
 
1520
1526
  Logger.debug('Polling Devices...', config.homeName);
1521
1527
 
@@ -1586,6 +1592,6 @@ export default (api, accessories, config, tado, telegram) => {
1586
1592
  getStates: getStates,
1587
1593
  setStates: setStates,
1588
1594
  changedStates: changedStates,
1589
- refreshHistoryHandlers: aRefreshHistoryHandlers
1595
+ refreshHistoryHandlers: helpers[config.homeId].refreshHistoryHandlers
1590
1596
  };
1591
- };
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,