Skip to content

Commit f8baaa7

Browse files
Merge pull request #701 from robbinjanssen/perf/wg5-zone-control
Fetch WG5 thermostat control data per zone
2 parents cacddce + 4777707 commit f8baaa7

3 files changed

Lines changed: 236 additions & 26 deletions

File tree

‎ojmicroline_thermostat/wg5.py‎

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import logging
6+
from copy import deepcopy
67
from datetime import UTC, datetime, timedelta
78
from typing import TYPE_CHECKING, Any
89

@@ -28,6 +29,9 @@
2829
# Renew the access token this long before it expires.
2930
TOKEN_REFRESH_MARGIN = timedelta(seconds=30)
3031

32+
# Number of thermostats requested per page of a zone.
33+
ZONE_PAGE_SIZE = 100
34+
3135
WG5_SCOPES = (
3236
"ugw.zones ugw.users ugw.profile ugw.buildings ugw.schedules "
3337
"ugw.thermostats ugw.firmware ugw.linking openid role offline_access"
@@ -152,12 +156,14 @@ async def _auth_request(
152156
uri: str,
153157
*,
154158
method: str = "GET",
159+
params: dict[str, Any] | None = None,
155160
body: dict[str, Any] | None = None,
156161
) -> Any:
157162
"""Make an authenticated request with Bearer token."""
158163
return await self.request(
159164
uri,
160165
method=method,
166+
params=params,
161167
body=body,
162168
headers={"Authorization": f"Bearer {self._access_token}"},
163169
)
@@ -167,28 +173,51 @@ async def _is_away_active(self, building_id: str) -> bool:
167173
data = await self._auth_request(f"awaymode/{building_id}")
168174
return "data" in data
169175

176+
async def _get_zone_control(self, zone_id: str) -> list[dict[str, Any]]:
177+
"""Fetch the control data of all thermostats in a zone.
178+
179+
This returns the same data as `thermostats/{id}/control` for every
180+
thermostat in the zone, so a single request covers the whole zone.
181+
"""
182+
thermostats: list[dict[str, Any]] = []
183+
page_number = 0
184+
while True:
185+
data = await self._auth_request(
186+
f"zones/{zone_id}/control/thermostats",
187+
params={"pageNumber": page_number, "pageSize": ZONE_PAGE_SIZE},
188+
)
189+
thermostats.extend(data["data"])
190+
page_number += 1
191+
if page_number >= data.get("page", {}).get("totalPages", 1):
192+
return thermostats
193+
194+
async def _get_schedule(
195+
self,
196+
schedule_id: str | None,
197+
schedules: dict[str, dict[str, Any] | None],
198+
) -> dict[str, Any] | None:
199+
"""Fetch a schedule, once per update for thermostats sharing it."""
200+
if not schedule_id:
201+
return None
202+
if schedule_id not in schedules:
203+
resp = await self._auth_request(f"schedules/{schedule_id}")
204+
schedules[schedule_id] = resp.get("data")
205+
# Every thermostat gets its own copy of a shared schedule.
206+
return deepcopy(schedules[schedule_id])
207+
170208
async def _fetch_thermostat(
171209
self,
172-
thermostat_id: str,
210+
control_data: dict[str, Any],
173211
building_id: str,
174212
zone_name: str,
213+
schedules: dict[str, dict[str, Any] | None],
175214
*,
176215
away_active: bool,
177216
) -> Thermostat:
178-
"""Fetch and enrich a single thermostat."""
179-
control_data = await self._auth_request(
180-
f"thermostats/{thermostat_id}/control",
181-
)
182-
detail_data = await self._auth_request(
183-
f"thermostats/{thermostat_id}",
184-
)
185-
schedule_id = control_data["data"].get("scheduleData", {}).get("scheduleId")
186-
schedule_data = None
187-
if schedule_id:
188-
resp = await self._auth_request(f"schedules/{schedule_id}")
189-
schedule_data = resp.get("data")
217+
"""Enrich the control data of a thermostat with its details."""
218+
detail_data = await self._auth_request(f"thermostats/{control_data['id']}")
190219
thermostat = Thermostat.from_wg5_json(
191-
control_data["data"],
220+
control_data,
192221
building_id=building_id,
193222
zone_name=zone_name,
194223
away_active=away_active,
@@ -201,25 +230,30 @@ async def _fetch_thermostat(
201230
room = readouts.get("roomTemperature")
202231
if room is not None:
203232
thermostat.temperature_room = round(float(room) * 100)
204-
thermostat.schedule = schedule_data
233+
thermostat.schedule = await self._get_schedule(
234+
control_data.get("scheduleData", {}).get("scheduleId"), schedules
235+
)
205236
return thermostat
206237

207238
async def get_thermostats(self) -> list[Thermostat]:
208239
"""Fetch all thermostats across all buildings."""
209240
buildings_data = await self._auth_request("buildings")
210241
thermostats: list[Thermostat] = []
242+
schedules: dict[str, dict[str, Any] | None] = {}
211243

212244
for building in buildings_data["data"]:
213245
building_id = building["id"]
214246
away_active = await self._is_away_active(building_id)
215247
tree_data = await self._auth_request(f"buildings/{building_id}/tree")
216248
for zone in tree_data["data"]["zones"]:
217-
zone_name = zone["name"]
218-
for thermostat_stub in zone["thermostats"]:
249+
if not zone["thermostats"]:
250+
continue
251+
for control_data in await self._get_zone_control(zone["id"]):
219252
thermostat = await self._fetch_thermostat(
220-
thermostat_stub["id"],
253+
control_data,
221254
building_id,
222-
zone_name,
255+
zone["name"],
256+
schedules,
223257
away_active=away_active,
224258
)
225259
thermostats.append(thermostat)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"page": {
3+
"total": 1,
4+
"totalPages": 1,
5+
"size": 100,
6+
"number": 0
7+
},
8+
"data": [
9+
{
10+
"id": "2cb3e6c5-8cf8-4e7e-943a-42618c34a506",
11+
"zoneId": "ae85d8fc-1b4a-445a-bdf1-05f970fc3da4",
12+
"name": "Bathroom",
13+
"createdDate": "2026-04-05T11:29:17Z",
14+
"currentTemperature": 19,
15+
"currentTemperatureFahrenheit": 66,
16+
"minimumPossibleSetPoint": 5,
17+
"maximumPossibleSetPoint": 40,
18+
"isOnline": true,
19+
"isAdaptiveHeatingActive": false,
20+
"errorFlags": "NoErrors",
21+
"hasError": false,
22+
"isOpenWindowDetected": false,
23+
"isHeatRelayActive": true,
24+
"isFrostProtectionEnabled": true,
25+
"frostProtectionTemperature": 5,
26+
"isFollowingZone": false,
27+
"mode": {
28+
"isInStandby": false,
29+
"isAwayActive": false,
30+
"fallbackMode": "Manual",
31+
"manualSetpoint": 27.78,
32+
"changeTimestamp": "2026-04-13T23:40:43Z"
33+
},
34+
"scheduleData": {
35+
"scheduleId": "f9aac20b-4e45-415b-9f56-e53014ee8639",
36+
"scheduleName": "Default Schedule",
37+
"currentSetPoint": 23
38+
},
39+
"sensorApplication": "Floor",
40+
"setpoint": 27.78,
41+
"changeTimestamp": "2026-04-13T23:40:43Z"
42+
}
43+
],
44+
"status": {
45+
"code": "OK",
46+
"traceId": "3bbdb398bee4254f0801119ffe38a07c"
47+
}
48+
}

‎tests/test_wg5.py‎

Lines changed: 135 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ async def test_login_skips_when_token_valid(aresponses: ResponsesMockServer) ->
138138

139139
@pytest.mark.asyncio
140140
async def test_get_thermostats(aresponses: ResponsesMockServer) -> None:
141-
"""Test fetching thermostats through the buildings->tree->control flow."""
141+
"""Test fetching thermostats through the buildings->tree->zone flow."""
142142
_add_login_response(aresponses)
143143
aresponses.add(
144144
"ojmicroline.test.host",
@@ -172,12 +172,12 @@ async def test_get_thermostats(aresponses: ResponsesMockServer) -> None:
172172
)
173173
aresponses.add(
174174
"ojmicroline.test.host",
175-
"/thermostats/2cb3e6c5-8cf8-4e7e-943a-42618c34a506/control",
175+
"/zones/ae85d8fc-1b4a-445a-bdf1-05f970fc3da4/control/thermostats",
176176
"GET",
177177
Response(
178178
status=200,
179179
headers={"Content-Type": "application/json"},
180-
text=load_fixtures("wg5_thermostat_control.json"),
180+
text=load_fixtures("wg5_zone_thermostats.json"),
181181
),
182182
)
183183
aresponses.add(
@@ -263,16 +263,16 @@ async def test_get_thermostats_no_schedule_no_readouts(
263263
),
264264
)
265265
# Control data with no scheduleId
266-
control_data = json.loads(load_fixtures("wg5_thermostat_control.json"))
267-
control_data["data"]["scheduleData"] = {}
266+
zone_data = json.loads(load_fixtures("wg5_zone_thermostats.json"))
267+
zone_data["data"][0]["scheduleData"] = {}
268268
aresponses.add(
269269
"ojmicroline.test.host",
270-
"/thermostats/2cb3e6c5-8cf8-4e7e-943a-42618c34a506/control",
270+
"/zones/ae85d8fc-1b4a-445a-bdf1-05f970fc3da4/control/thermostats",
271271
"GET",
272272
Response(
273273
status=200,
274274
headers={"Content-Type": "application/json"},
275-
text=json.dumps(control_data),
275+
text=json.dumps(zone_data),
276276
),
277277
)
278278
# Detail data with no readout temperatures
@@ -302,6 +302,134 @@ async def test_get_thermostats_no_schedule_no_readouts(
302302
assert thermostat.temperature_room is None
303303

304304

305+
ZONE_ID = "ae85d8fc-1b4a-445a-bdf1-05f970fc3da4"
306+
BUILDING_ID = "57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a"
307+
SCHEDULE_ID = "f9aac20b-4e45-415b-9f56-e53014ee8639"
308+
309+
310+
def _json_response(data: object) -> Response:
311+
"""Return a JSON response for aresponses."""
312+
return Response(
313+
status=200,
314+
headers={"Content-Type": "application/json"},
315+
text=json.dumps(data),
316+
)
317+
318+
319+
def _add_building_responses(
320+
aresponses: ResponsesMockServer, tree: dict[str, object]
321+
) -> None:
322+
"""Add the login, buildings, away mode and tree responses."""
323+
_add_login_response(aresponses)
324+
aresponses.add(
325+
"ojmicroline.test.host",
326+
"/buildings",
327+
"GET",
328+
_json_response(json.loads(load_fixtures("wg5_buildings.json"))),
329+
)
330+
aresponses.add(
331+
"ojmicroline.test.host",
332+
f"/awaymode/{BUILDING_ID}",
333+
"GET",
334+
_json_response({"status": {"code": "OK"}}),
335+
)
336+
aresponses.add(
337+
"ojmicroline.test.host",
338+
f"/buildings/{BUILDING_ID}/tree",
339+
"GET",
340+
_json_response(tree),
341+
)
342+
343+
344+
def _zone_thermostat(thermostat_id: str) -> dict[str, object]:
345+
"""Return the zone control data of a thermostat with the given ID."""
346+
data: dict[str, object] = json.loads(load_fixtures("wg5_zone_thermostats.json"))[
347+
"data"
348+
][0]
349+
data["id"] = thermostat_id
350+
return data
351+
352+
353+
@pytest.mark.asyncio
354+
async def test_get_thermostats_zone_pages_and_shared_schedule(
355+
aresponses: ResponsesMockServer,
356+
) -> None:
357+
"""Test fetching a zone over multiple pages, with a shared schedule."""
358+
tree = json.loads(load_fixtures("wg5_building_tree.json"))
359+
_add_building_responses(aresponses, tree)
360+
361+
page_numbers: list[str] = []
362+
pages = [["thermostat-1", "thermostat-2"], ["thermostat-3"]]
363+
364+
async def zone_handler(request: web.BaseRequest) -> Response:
365+
assert request.query["pageSize"] == "100"
366+
page_number = int(request.query["pageNumber"])
367+
page_numbers.append(request.query["pageNumber"])
368+
return _json_response(
369+
{
370+
"page": {"total": 3, "totalPages": 2, "size": 100},
371+
"data": [_zone_thermostat(tid) for tid in pages[page_number]],
372+
"status": {"code": "OK"},
373+
}
374+
)
375+
376+
for _ in pages:
377+
aresponses.add(
378+
"ojmicroline.test.host",
379+
f"/zones/{ZONE_ID}/control/thermostats",
380+
"GET",
381+
zone_handler,
382+
)
383+
detail = json.loads(load_fixtures("wg5_thermostat_detail.json"))
384+
for thermostat_id in ("thermostat-1", "thermostat-2", "thermostat-3"):
385+
aresponses.add(
386+
"ojmicroline.test.host",
387+
f"/thermostats/{thermostat_id}",
388+
"GET",
389+
_json_response(detail),
390+
)
391+
# The thermostats share one schedule, which is only fetched once.
392+
aresponses.add(
393+
"ojmicroline.test.host",
394+
f"/schedules/{SCHEDULE_ID}",
395+
"GET",
396+
_json_response(json.loads(load_fixtures("wg5_schedule.json"))),
397+
)
398+
399+
async with aiohttp.ClientSession() as session:
400+
client = OJMicroline(api=_make_api(), session=session)
401+
thermostats = await client.get_thermostats()
402+
403+
# Every route was used exactly once and no other requests were made.
404+
aresponses.assert_no_unused_routes()
405+
aresponses.assert_all_requests_matched()
406+
assert page_numbers == ["0", "1"]
407+
assert [t.serial_number for t in thermostats] == [
408+
"thermostat-1",
409+
"thermostat-2",
410+
"thermostat-3",
411+
]
412+
assert all(t.schedule is not None for t in thermostats)
413+
# Every thermostat has its own copy of the shared schedule.
414+
assert thermostats[0].schedule is not thermostats[1].schedule
415+
416+
417+
@pytest.mark.asyncio
418+
async def test_get_thermostats_skips_empty_zone(
419+
aresponses: ResponsesMockServer,
420+
) -> None:
421+
"""Test that zones without thermostats are not requested."""
422+
tree = json.loads(load_fixtures("wg5_building_tree.json"))
423+
tree["data"]["zones"][0]["thermostats"] = []
424+
_add_building_responses(aresponses, tree)
425+
426+
async with aiohttp.ClientSession() as session:
427+
client = OJMicroline(api=_make_api(), session=session)
428+
assert await client.get_thermostats() == []
429+
430+
aresponses.assert_plan_strictly_followed()
431+
432+
305433
@pytest.mark.asyncio
306434
async def test_get_energy_usage_no_building_id() -> None:
307435
"""Test energy usage returns empty list when building_id is missing."""

0 commit comments

Comments
 (0)