Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit b3fbdf8

Browse files
committedNov 9, 2024··
feat: add preliminary tadoX support
1 parent 560083a commit b3fbdf8

File tree

3 files changed

+236
-15
lines changed

3 files changed

+236
-15
lines changed
 

‎src/index.ts

+86-8
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const EXPIRATION_WINDOW_IN_SECONDS = 300;
6767

6868
const tado_auth_url = "https://auth.tado.com";
6969
const tado_url = "https://my.tado.com";
70+
const tado_x_url = "https://hops.tado.com";
7071
const tado_config = {
7172
client: {
7273
id: "tado-web-app",
@@ -111,11 +112,15 @@ export class Tado {
111112
#accessToken?: AccessToken | undefined;
112113
#username?: string;
113114
#password?: string;
115+
#firstLogin: boolean;
116+
#isX: boolean;
114117

115118
constructor(username?: string, password?: string) {
116119
this.#username = username;
117120
this.#password = password;
118121
this.#httpsAgent = new Agent({ keepAlive: true });
122+
this.#firstLogin = true;
123+
this.#isX = false;
119124
}
120125

121126
async #login(): Promise<void> {
@@ -130,6 +135,20 @@ export class Tado {
130135
};
131136

132137
this.#accessToken = await client.getToken(tokenParams);
138+
139+
if (this.#firstLogin) {
140+
try {
141+
const me = await this.getMe();
142+
if (me.homes.length > 0) {
143+
const home_id = me.homes[0].id;
144+
const home = await this.getHome(home_id);
145+
this.#isX = home.generation == "LINE_X";
146+
}
147+
} catch (err) {
148+
console.error(`Could not determine TadoX status: ${err}`);
149+
}
150+
this.#firstLogin = false;
151+
}
133152
}
134153

135154
/**
@@ -168,6 +187,10 @@ export class Tado {
168187
return this.#accessToken;
169188
}
170189

190+
get isX(): boolean {
191+
return this.#isX;
192+
}
193+
171194
/**
172195
* Authenticates a user using the provided public client credentials, username and password.
173196
* For more information see
@@ -217,6 +240,21 @@ export class Tado {
217240
return response.data as R;
218241
}
219242

243+
/**
244+
* Makes an API call to the provided TadoX URL with the specified method and data.
245+
*
246+
* @typeParam R - The type of the response
247+
* @typeParam T - The type of the request body
248+
* @param url - The endpoint to which the request is sent. If the URL contains "https", it will be used as is.
249+
* @param method - The HTTP method to use for the request (e.g., "get", "post").
250+
* @param data - The payload to send with the request, if applicable.
251+
* @returns A promise that resolves to the response data.
252+
*/
253+
async apiCallX<R, T = unknown>(url: string, method: Method = "get", data?: T): Promise<R> {
254+
const callUrl = tado_x_url + url;
255+
return this.apiCall(callUrl, method, data);
256+
}
257+
220258
/**
221259
* Fetches the current user data.
222260
*
@@ -315,7 +353,11 @@ export class Tado {
315353
* @returns A promise that resolves to an array of Device objects.
316354
*/
317355
getDevices(home_id: number): Promise<Device[]> {
318-
return this.apiCall(`/api/v2/homes/${home_id}/devices`);
356+
if (this.#isX) {
357+
return this.apiCallX(`/homes/${home_id}/roomsAndDevices`);
358+
} else {
359+
return this.apiCall(`/api/v2/homes/${home_id}/devices`);
360+
}
319361
}
320362

321363
/**
@@ -542,7 +584,11 @@ export class Tado {
542584
* @returns A promise that resolves to an array of Zone objects.
543585
*/
544586
getZones(home_id: number): Promise<Zone[]> {
545-
return this.apiCall(`/api/v2/homes/${home_id}/zones`);
587+
if (this.#isX) {
588+
return this.apiCallX(`/homes/${home_id}/rooms`);
589+
} else {
590+
return this.apiCall(`/api/v2/homes/${home_id}/zones`);
591+
}
546592
}
547593

548594
/**
@@ -553,7 +599,11 @@ export class Tado {
553599
* @returns A promise that resolves to the state of the specified zone.
554600
*/
555601
getZoneState(home_id: number, zone_id: number): Promise<ZoneState> {
556-
return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/state`);
602+
if (this.#isX) {
603+
return this.apiCallX(`/homes/${home_id}/rooms/${zone_id}`);
604+
} else {
605+
return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/state`);
606+
}
557607
}
558608

559609
/**
@@ -785,7 +835,11 @@ export class Tado {
785835
* @deprecated Use {@link clearZoneOverlays} instead.
786836
*/
787837
clearZoneOverlay(home_id: number, zone_id: number): Promise<void> {
788-
return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/overlay`, "delete");
838+
if (this.#isX) {
839+
return this.apiCallX(`/homes/${home_id}/rooms/${zone_id}/resumeSchedule`, "post", {});
840+
} else {
841+
return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/overlay`, "delete");
842+
}
789843
}
790844

791845
/**
@@ -903,7 +957,15 @@ export class Tado {
903957
};
904958
}
905959

906-
return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/overlay`, "put", config);
960+
if (this.#isX) {
961+
return this.apiCallX(
962+
`/api/v2/homes/${home_id}/rooms/${zone_id}/manualControl`,
963+
"post",
964+
config,
965+
);
966+
} else {
967+
return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/overlay`, "put", config);
968+
}
907969
}
908970

909971
/**
@@ -914,8 +976,14 @@ export class Tado {
914976
* @returns A promise that resolves when the overlays are cleared.
915977
*/
916978
async clearZoneOverlays(home_id: number, zone_ids: number[]): Promise<void> {
917-
const rooms = zone_ids.join(",");
918-
return this.apiCall(`/api/v2/homes/${home_id}/overlay?rooms=${rooms}`, "delete");
979+
if (this.#isX) {
980+
for (const zone_id of zone_ids) {
981+
return this.apiCallX(`/homes/${home_id}/rooms/${zone_id}/resumeSchedule`, "post", {});
982+
}
983+
} else {
984+
const rooms = zone_ids.join(",");
985+
return this.apiCall(`/api/v2/homes/${home_id}/overlay?rooms=${rooms}`, "delete");
986+
}
919987
}
920988

921989
/**
@@ -1054,7 +1122,17 @@ export class Tado {
10541122
config.push(overlay_config);
10551123
}
10561124

1057-
return this.apiCall(`/api/v2/homes/${home_id}/overlay`, "post", { overlays: config });
1125+
if (this.#isX) {
1126+
for (const c of config) {
1127+
return this.apiCallX(
1128+
`/api/v2/homes/${home_id}/rooms/${c.room}/manualControl`,
1129+
"post",
1130+
c.overlay,
1131+
);
1132+
}
1133+
} else {
1134+
return this.apiCall(`/api/v2/homes/${home_id}/overlay`, "post", { overlays: config });
1135+
}
10581136
}
10591137

10601138
/**

‎test/index.ts

+93-7
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import eneryIQ_savings_response from "./response.eneryIQ.savings.json";
1717
import eneryIQ_tariff_response from "./response.eneryIQ.tariff.json";
1818
import heating_system_response from "./response.heatingSystem.json";
1919
import home_response from "./response.home.json";
20+
import home_response_x from "./response.home.x.json";
2021
import incident_detection_response from "./response.incidentDetection.json";
2122
import installations_response from "./response.installations.json";
2223
import invitations_response from "./response.invitations.json";
@@ -40,13 +41,14 @@ import zones_response from "./response.zones.json";
4041
chai.use(chaiAsPromised);
4142
const expect = chai.expect;
4243

43-
describe("OAuth2 tests", () => {
44-
beforeEach(() => {
44+
describe("OAuth2 tests", async () => {
45+
afterEach(async () => {
4546
nock.cleanAll();
4647
});
4748

4849
it("Should login", async () => {
4950
nock("https://auth.tado.com").post("/oauth/token").reply(200, auth_response);
51+
nock("https://my.tado.com", { allowUnmocked: false });
5052

5153
const tado = new Tado();
5254
await tado.login("username", "password");
@@ -64,14 +66,20 @@ describe("OAuth2 tests", () => {
6466
});
6567
});
6668

67-
describe("Low-level API tests", () => {
68-
beforeEach(() => {
69+
describe("Low-level API tests", async () => {
70+
afterEach(async () => {
6971
nock.cleanAll();
7072
});
7173

7274
it('Login and get "me"', async () => {
7375
nock("https://auth.tado.com").post("/oauth/token").reply(200, auth_response);
74-
nock("https://my.tado.com").get("/api/v2/me").reply(200, me_response);
76+
nock("https://my.tado.com")
77+
.get("/api/v2/me")
78+
.reply(200, me_response)
79+
.get("/api/v2/me")
80+
.reply(200, me_response) // Needed twice otherwise consumed by login
81+
.get("/api/v2/homes/1907")
82+
.reply(200, home_response);
7583

7684
const tado = new Tado();
7785
await tado.login("username", "password");
@@ -98,15 +106,24 @@ describe("Low-level API tests", () => {
98106
});
99107
});
100108

101-
describe("High-level API tests", () => {
109+
describe("High-level API tests (v2)", async () => {
102110
let tado: Tado;
103111

104112
beforeEach(async () => {
105-
nock.cleanAll();
106113
nock("https://auth.tado.com").post("/oauth/token").reply(200, auth_response);
114+
nock("https://my.tado.com")
115+
.get("/api/v2/me")
116+
.reply(200, me_response)
117+
.get("/api/v2/homes/1907")
118+
.reply(200, home_response);
107119

108120
tado = new Tado();
109121
await tado.login("username", "password");
122+
expect(tado.isX).to.equal(false);
123+
});
124+
125+
afterEach(async () => {
126+
nock.cleanAll();
110127
});
111128

112129
it("Should get the current user", async () => {
@@ -818,3 +835,72 @@ describe("High-level API tests", () => {
818835
expect(response.manufacturers[0].name).to.equal("Junkers");
819836
});
820837
});
838+
839+
describe("High-level API tests (TadoX)", async () => {
840+
let tado: Tado;
841+
842+
beforeEach(async () => {
843+
nock("https://auth.tado.com").post("/oauth/token").reply(200, auth_response);
844+
nock("https://my.tado.com")
845+
.get("/api/v2/me")
846+
.reply(200, me_response)
847+
.get("/api/v2/homes/1907")
848+
.reply(200, home_response_x);
849+
850+
tado = new Tado();
851+
await tado.login("username", "password");
852+
expect(tado.isX).to.equal(true);
853+
});
854+
855+
afterEach(async () => {
856+
nock.cleanAll();
857+
});
858+
859+
it("Should get the user's devices", async () => {
860+
nock("https://hops.tado.com").get("/homes/1907/roomsAndDevices").reply(200, me_response);
861+
862+
const response = await tado.getDevices(1907);
863+
864+
expect(typeof response).to.equal("object");
865+
});
866+
867+
it("Should get zones", async () => {
868+
nock("https://hops.tado.com").get("/homes/1907/rooms").reply(200, zones_response);
869+
870+
const response = await tado.getZones(1907);
871+
872+
expect(typeof response).to.equal("object");
873+
});
874+
875+
it("Should get a zone's state", async () => {
876+
nock("https://hops.tado.com").get("/homes/1907/rooms/1").reply(200, zone_state_response);
877+
878+
const response = await tado.getZoneState(1907, 1);
879+
880+
expect(typeof response).to.equal("object");
881+
});
882+
883+
it("Should clear a zone's overlay", async () => {
884+
nock("https://hops.tado.com").post("/homes/1907/rooms/1/resumeSchedule").reply(200, {});
885+
886+
const response = await tado.clearZoneOverlay(1907, 1);
887+
888+
expect(typeof response).to.equal("object");
889+
});
890+
891+
it("Should set a zone's overlay to Off", async () => {
892+
nock("https://my.tado.com")
893+
.get("/api/v2/homes/1907/zones/1/capabilities")
894+
.reply(200, zone_capabilities_response);
895+
896+
nock("https://hops.tado.com")
897+
.post("/api/v2/homes/1907/rooms/1/manualControl")
898+
.reply(200, (_uri, req) => {
899+
return req;
900+
});
901+
902+
const response = await tado.setZoneOverlay(1907, 1, "OFF");
903+
904+
expect(typeof response).to.equal("object");
905+
});
906+
});

‎test/response.home.x.json

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"id": 1907,
3+
"name": "Dummy Home",
4+
"dateTimeZone": "Europe/Amsterdam",
5+
"dateCreated": "2021-11-15T12:27:24.825Z",
6+
"temperatureUnit": "CELSIUS",
7+
"partner": null,
8+
"simpleSmartScheduleEnabled": true,
9+
"awayRadiusInMeters": 500,
10+
"installationCompleted": true,
11+
"incidentDetection": {
12+
"supported": true,
13+
"enabled": true
14+
},
15+
"generation": "LINE_X",
16+
"zonesCount": 8,
17+
"language": "en",
18+
"skills": ["AUTO_ASSIST"],
19+
"christmasModeEnabled": true,
20+
"showAutoAssistReminders": true,
21+
"contactDetails": {
22+
"name": "Tado User",
23+
"email": "tado.user@tado.com",
24+
"phone": "+3112115194351"
25+
},
26+
"address": {
27+
"addressLine1": "Museumplein 6",
28+
"addressLine2": null,
29+
"zipCode": "1071",
30+
"city": "Amsterdam",
31+
"state": null,
32+
"country": "NLD"
33+
},
34+
"geolocation": {
35+
"latitude": 51.2993,
36+
"longitude": 9.491
37+
},
38+
"consentGrantSkippable": true,
39+
"enabledFeatures": [
40+
"DARK_MODE",
41+
"EIQ_SETTINGS_AS_WEBVIEW",
42+
"HIDE_BOILER_REPAIR_SERVICE",
43+
"HOME_DETAILS_AS_WEBVIEW",
44+
"MORE_AS_WEBVIEW",
45+
"OFFLINE_SCHEDULE_ENABLED",
46+
"OWD_SETTINGS_AS_WEBVIEW",
47+
"PEOPLE_AS_WEBVIEW",
48+
"SETTINGS_OVERVIEW_AS_WEBVIEW"
49+
],
50+
"isAirComfortEligible": true,
51+
"isBalanceAcEligible": false,
52+
"isEnergyIqEligible": true,
53+
"isHeatSourceInstalled": false,
54+
"isHeatPumpInstalled": false,
55+
"isBalanceHpEligible": false,
56+
"supportsFlowTemperatureOptimization": false
57+
}

0 commit comments

Comments
 (0)
Please sign in to comment.