From ed4d97eb49d4b5a997e4f427dd05db6cf01d96f0 Mon Sep 17 00:00:00 2001 From: Hawk Newton Date: Tue, 14 Apr 2026 07:28:00 -0400 Subject: [PATCH 1/4] Add wg5 support --- README.md | 66 +++- ojmicroline_thermostat/__init__.py | 2 + ojmicroline_thermostat/const.py | 9 + ojmicroline_thermostat/models/thermostat.py | 85 +++++- ojmicroline_thermostat/ojmicroline.py | 316 +++++++++++--------- ojmicroline_thermostat/wd5.py | 4 +- ojmicroline_thermostat/wg4.py | 4 +- ojmicroline_thermostat/wg5.py | 293 ++++++++++++++++++ poetry.lock | 202 +++++++------ pyproject.toml | 2 +- tests/fixtures/wg5_building_tree.json | 22 ++ tests/fixtures/wg5_buildings.json | 20 ++ tests/fixtures/wg5_energy.json | 36 +++ tests/fixtures/wg5_schedule.json | 61 ++++ tests/fixtures/wg5_thermostat_control.json | 40 +++ tests/fixtures/wg5_thermostat_detail.json | 110 +++++++ tests/fixtures/wg5_token.json | 7 + tests/ruff.toml | 1 + tests/test_model_thermostat.py | 128 ++++++++ tests/test_ojmicroline.py | 1 - tests/test_wd5.py | 169 +++++------ tests/test_wg4.py | 118 +++----- tests/test_wg5.py | 214 +++++++++++++ tests/test_wg5_update.py | 218 ++++++++++++++ 24 files changed, 1711 insertions(+), 417 deletions(-) create mode 100644 ojmicroline_thermostat/wg5.py create mode 100644 tests/fixtures/wg5_building_tree.json create mode 100644 tests/fixtures/wg5_buildings.json create mode 100644 tests/fixtures/wg5_energy.json create mode 100644 tests/fixtures/wg5_schedule.json create mode 100644 tests/fixtures/wg5_thermostat_control.json create mode 100644 tests/fixtures/wg5_thermostat_detail.json create mode 100644 tests/fixtures/wg5_token.json create mode 100644 tests/test_wg5.py create mode 100644 tests/test_wg5_update.py diff --git a/README.md b/README.md index 6e917e42..43f34940 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Asynchronous Python client for communicating with a OJ Microline Thermostat. ## About -A Python package to control OJ Microline thermostats. It currently supports the WD5 series (OWD5, MWD5) and WG4 series (UWG4, AWG4). +A Python package to control OJ Microline thermostats. It currently supports the WD5 series (OWD5, MWD5), WG4 series (UWG4, AWG4), and WG5 series. ## Installation @@ -63,24 +63,34 @@ These fields are only available on WD5-series thermostats; for others, they may | :------- | :--- | :---------- | | `thermostat_id` | int | The unique identifier for this thermostat. | | `adaptive_mode` | boolean | If on then then the thermostat automatically changes heating start times to ensure that the required temperature has been reached at the beginning of any specific event. | -| `open_window_detection` | boolean | If on then the thermostat shuts off the heating for 30 minutes if an open window is detected. | +| `open_window_detection` | boolean | If on then the thermostat shuts off the heating for 30 minutes if an open window is detected. Also available on WG5-series thermostats. | | `daylight_saving_active` | boolean | If on, the "Daylight Saving Time" function of the thermostat will automatically adjust the clock to the daylight saving time for the "Region" chosen. | -| `sensor_mode` | integer | The currently set sensor mode of the thermostat, see below. | -| `temperature_floor` | integer | The temperature measured by the floor sensor. | -| `temperature_room` | integer | The temperature measured by the room sensor. | +| `sensor_mode` | integer | The currently set sensor mode of the thermostat, see below. Also available on WG5-series thermostats. | +| `temperature_floor` | integer | The temperature measured by the floor sensor. Also available on WG5-series thermostats. | +| `temperature_room` | integer | The temperature measured by the room sensor. Also available on WG5-series thermostats. | | `boost_temperature` | integer | If the regulation mode is set to boost mode, the thermostat will target this temperature. | | `boost_end_time` | datetime | If the regulation mode is set to boost mode, it will end at this time. | -| `frost_protection_temperature` | integer | If the regulation mode is set to frost protection mode, the thermostat will target this temperature. | -| `schedule` | Schedule | The schedule the thermostat currently uses. (This *could* be supported by WG4-series thermostats, it simply isn't implemented.) | +| `frost_protection_temperature` | integer | If the regulation mode is set to frost protection mode, the thermostat will target this temperature. Also available on WG5-series thermostats. | +| `schedule` | Schedule/dict | The schedule the thermostat currently uses. On WD5 this is a `Schedule` object; on WG5 it is a raw dict from the API. | | `energy` | list | The energy usage in kWh for the current day and the six previous days. (Note that the integrated tariff calculation needs to be disabled.) | -These fields are only available on WG4-series thermostats; for others, they may be `None`: +These fields are available on WG4 and WG5-series thermostats; for others, they may be `None`: | Variable | Type | Description | | :------- | :--- | :---------- | | `temperature` | integer | The current temperature; the thermostat uses the room sensor or floor sensor based on its configuration. Avoid using this directly; instead, call the `get_current_temperature()` method which also works for WD5-series thermostats. | | `set_point_temperature` | integer | The temperature the thermostat is targeting. Avoid using this directly; instead, call the `get_target_temperature()` method which also works for WD5-series thermostats. | +These fields are only available on WG5-series thermostats; for others, they may be `None`: + +| Variable | Type | Description | +| :------- | :--- | :---------- | +| `building_id` | string | The building UUID this thermostat belongs to. | +| `zone_uuid` | string | The zone UUID this thermostat belongs to (WG5 uses UUIDs rather than integer zone IDs). | +| `is_in_standby` | boolean | Whether the thermostat is in standby (frost protection) mode. | +| `schedule_id` | string | The UUID of the schedule assigned to this thermostat. | +| `schedule_name` | string | The name of the schedule assigned to this thermostat. | + #### Regulation modes | Integer | Constant | Description | @@ -115,6 +125,8 @@ Keep in mind that certain thermostats only support a subset of these modes; be s ## Usage +### WD5 Example + ```python import asyncio from time import sleep @@ -173,6 +185,44 @@ async def main(): await client.set_regulation_mode(resource, REGULATION_SCHEDULE) +if __name__ == "__main__": + asyncio.run(main()) +``` + +### WG5 Example + +```python +import asyncio + +from ojmicroline_thermostat import OJMicroline, Thermostat +from ojmicroline_thermostat.wg5 import WG5API +from ojmicroline_thermostat.const import ( + REGULATION_MANUAL, + REGULATION_SCHEDULE, +) + + +async def main(): + """Show example on using the OJMicroline client with a WG5 thermostat.""" + async with OJMicroline( + api=WG5API( + username="", + password="", + ), + ) as client: + thermostats: list[Thermostat] = await client.get_thermostats() + + for resource in thermostats: + print(f"{resource.name}: {resource.get_current_temperature() / 100}°C") + print(f" Target: {resource.get_target_temperature() / 100}°C") + + # Set to manual mode at 25°C + await client.set_regulation_mode(resource, REGULATION_MANUAL, 2500) + + # Set back to schedule + await client.set_regulation_mode(resource, REGULATION_SCHEDULE) + + if __name__ == "__main__": asyncio.run(main()) ``` diff --git a/ojmicroline_thermostat/__init__.py b/ojmicroline_thermostat/__init__.py index b0d23cb1..63145921 100644 --- a/ojmicroline_thermostat/__init__.py +++ b/ojmicroline_thermostat/__init__.py @@ -11,10 +11,12 @@ from .ojmicroline import OJMicroline from .wd5 import WD5API from .wg4 import WG4API +from .wg5 import WG5API __all__ = [ "WD5API", "WG4API", + "WG5API", "OJMicroline", "OJMicrolineAuthError", "OJMicrolineConnectionError", diff --git a/ojmicroline_thermostat/const.py b/ojmicroline_thermostat/const.py index 865c4016..d11ed046 100644 --- a/ojmicroline_thermostat/const.py +++ b/ojmicroline_thermostat/const.py @@ -15,3 +15,12 @@ WD5_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S" WG4_DATETIME_FORMAT = "%d/%m/%Y %H:%M:%S %z" COMFORT_DURATION = 240 + +WG5_SENSOR_MAP: dict[str, int] = { + "Floor": SENSOR_FLOOR, + "Room": SENSOR_ROOM, + "RoomFloor": SENSOR_ROOM_FLOOR, +} + +WG5_MODE_SCHEDULE = 1 +WG5_MODE_HOLD = 2 diff --git a/ojmicroline_thermostat/models/thermostat.py b/ojmicroline_thermostat/models/thermostat.py index 608e5437..3bcf0b6b 100644 --- a/ojmicroline_thermostat/models/thermostat.py +++ b/ojmicroline_thermostat/models/thermostat.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from math import ceil from time import gmtime, strftime from typing import Any @@ -22,6 +22,7 @@ SENSOR_ROOM, SENSOR_ROOM_FLOOR, WG4_DATETIME_FORMAT, + WG5_SENSOR_MAP, ) from .schedule import Schedule @@ -66,10 +67,17 @@ class Thermostat: boost_temperature: int | None = None energy: list[float] | None = None - # WG4-only fields: + # WG4/WG5 fields: temperature: int | None = None set_point_temperature: int | None = None + # WG5-only fields: + building_id: str | None = None + zone_uuid: str | None = None + is_in_standby: bool | None = None + schedule_id: str | None = None + schedule_name: str | None = None + @classmethod def from_wd5_json(cls, data: dict[str, Any]) -> Thermostat: """Return a new Thermostat instance based on JSON from the WD5-series API. @@ -178,6 +186,79 @@ def from_wg4_json(cls, data: dict[str, Any]) -> Thermostat: vacation_temperature=data["VacationTemperature"], ) + @classmethod + def from_wg5_json( + cls, + data: dict[str, Any], + building_id: str, + zone_name: str, + *, + away_active: bool = False, + ) -> Thermostat: + """Return a new Thermostat instance based on JSON from the WG5-series API. + + Args: + ---- + data: The thermostat control JSON data from the API. + building_id: The building UUID this thermostat belongs to. + zone_name: The zone name this thermostat belongs to. + away_active: Whether away mode is active for the building. + + Returns: + ------- + A Thermostat Object. + + """ + mode = data["mode"] + setpoint_centideg = round(data["setpoint"] * 100) + + if away_active or mode.get("isAwayActive"): + regulation_mode = REGULATION_VACATION + elif mode["isInStandby"]: + regulation_mode = REGULATION_FROST_PROTECTION + elif mode.get("fallbackMode") == "Auto": + regulation_mode = REGULATION_SCHEDULE + else: + regulation_mode = REGULATION_MANUAL + + return cls( + model="WG5", + serial_number=data["id"], + software_version="", + zone_name=zone_name, + zone_id=0, + zone_uuid=data["zoneId"], + building_id=building_id, + name=data["name"], + online=data["isOnline"], + heating=data["isHeatRelayActive"], + regulation_mode=regulation_mode, + supported_regulation_modes=[ + REGULATION_SCHEDULE, + REGULATION_MANUAL, + REGULATION_COMFORT, + REGULATION_VACATION, + REGULATION_FROST_PROTECTION, + ], + min_temperature=round(data["minimumPossibleSetPoint"] * 100), + max_temperature=round(data["maximumPossibleSetPoint"] * 100), + temperature=round(data["currentTemperature"] * 100), + set_point_temperature=setpoint_centideg, + manual_temperature=setpoint_centideg, + comfort_temperature=setpoint_centideg, + comfort_end_time=datetime.min.replace(tzinfo=UTC), + last_primary_mode_is_auto=mode.get("fallbackMode") == "Auto", + frost_protection_temperature=round( + data["frostProtectionTemperature"] * 100 + ), + sensor_mode=WG5_SENSOR_MAP.get(data.get("sensorApplication", "")), + open_window_detection=data.get("isOpenWindowDetected", False), + vacation_mode=away_active or mode.get("isAwayActive", False), + is_in_standby=mode["isInStandby"], + schedule_id=data.get("scheduleData", {}).get("scheduleId"), + schedule_name=data.get("scheduleData", {}).get("scheduleName"), + ) + def get_target_temperature(self) -> int: """Return the target temperature for the thermostat. diff --git a/ojmicroline_thermostat/ojmicroline.py b/ojmicroline_thermostat/ojmicroline.py index 39513297..a2058a25 100644 --- a/ojmicroline_thermostat/ojmicroline.py +++ b/ojmicroline_thermostat/ojmicroline.py @@ -5,6 +5,7 @@ import json import socket +from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, Protocol, Self @@ -24,6 +25,8 @@ if TYPE_CHECKING: from .models import Thermostat +RequestFunc = Callable[..., Awaitable[Any]] + class OJMicrolineAPI(Protocol): """Implements support for a specific OJ Microline API. @@ -37,52 +40,84 @@ class OJMicrolineAPI(Protocol): host: str """The host to use for API requests, without the protocol prefix.""" - login_path: str - """HTTP path used to log in and fetch a session id.""" - - def login_body(self) -> dict[str, Any]: - """Compute HTTP body parameters used when posting to the login path.""" + request: RequestFunc + """Callable for making HTTP requests, set by OJMicroline.""" - get_thermostats_path: str - """HTTP path used to fetch a list of thermostats.""" + async def login(self) -> None: + """Perform authentication against the API.""" - def get_thermostats_params(self) -> dict[str, Any]: - """Compute additional query string params for fetching thermostats.""" + async def get_thermostats(self) -> list[Thermostat]: + """Fetch all thermostats.""" - def parse_thermostats_response(self, data: Any) -> list[Thermostat]: - """Parse an HTTP response containing thermostat data. + async def get_energy_usage(self, resource: Thermostat) -> list[float]: + """Fetch energy usage for a thermostat. Args: ---- - data: The JSON data contained in the response. + resource: The Thermostat model. """ - get_energy_usage_path: str - """HTTP path used to fetch energy usage data.""" - - def parse_energy_usage_response(self, data: Any) -> list[float]: - """Parse an HTTP response containing energy usage data. + async def set_regulation_mode( + self, + resource: Thermostat, + regulation_mode: int, + temperature: int | None, + duration: int, + ) -> bool: + """Set the regulation mode on a thermostat. Args: ---- - data: The JSON data contained in the response. - - Returns: A list with the energy usage values. + resource: The Thermostat model. + regulation_mode: The mode to set the thermostat to. + temperature: The temperature to set or None. + duration: The duration in minutes (comfort mode only). """ + +class SessionOJMicrolineAPI: + """Base class for session-based OJ Microline APIs (WD5, WG4). + + Provides login/get_thermostats/get_energy_usage/set_regulation_mode + implementations that use a session ID obtained via a login endpoint. + Subclasses provide the low-level details (paths, bodies, parsers). + """ + + host: str + request: RequestFunc + + # Subclass attributes: + login_path: str + get_thermostats_path: str + get_energy_usage_path: str update_regulation_mode_path: str - """HTTP path used to update a thermostat's mode""" - def update_regulation_mode_params(self, thermostat: Thermostat) -> dict[str, Any]: - """Compute additional query string params for posting to the update path. + # Session state: + _session_id: str | None = None + _session_calls_left: int = 0 + _session_calls: int = 300 - Args: - ---- - thermostat: The Thermostat model. + def login_body(self) -> dict[str, Any]: + """Compute HTTP body parameters used when posting to the login path.""" + raise NotImplementedError - """ + def get_thermostats_params(self) -> dict[str, Any]: + """Compute additional query string params for fetching thermostats.""" + raise NotImplementedError + + def parse_thermostats_response(self, data: Any) -> list[Thermostat]: + """Parse an HTTP response containing thermostat data.""" + raise NotImplementedError + + def parse_energy_usage_response(self, data: Any) -> list[float]: + """Parse an HTTP response containing energy usage data.""" + raise NotImplementedError + + def update_regulation_mode_params(self, thermostat: Thermostat) -> dict[str, Any]: + """Compute additional query string params for posting to the update path.""" + raise NotImplementedError def update_regulation_mode_body( self, @@ -91,30 +126,111 @@ def update_regulation_mode_body( temperature: int | None, duration: int, ) -> dict[str, Any]: - """Compute HTTP body parameters used when posting to the update path. + """Compute HTTP body parameters used when posting to the update path.""" + raise NotImplementedError - Args: - ---- - thermostat: The Thermostat model. - regulation_mode: The mode to set the thermostat to. - temperature: The temperature to set or None. - duration: The duration in minutes to set the temperature - for (comfort mode only). + def parse_update_regulation_mode_response(self, data: Any) -> bool: + """Parse the HTTP response received after updating the regulation mode.""" + raise NotImplementedError - Returns: A dict with values to be used in an HTTP POST body. + async def login(self) -> None: + """Get a valid session to do requests with the OJ Microline API. + + Raises + ------ + OJMicrolineAuthError: An error occurred while authenticating. """ + self._session_calls_left -= 1 + if self._session_calls_left < 0 or self._session_id is None: + data = await self.request( + self.login_path, + method=hdrs.METH_POST, + body=self.login_body(), + ) - def parse_update_regulation_mode_response(self, data: Any) -> bool: - """Parse the HTTP response received after updating the regulation mode. + if data["ErrorCode"] == 1: + msg = "Unable to create session, wrong username, password, API key or customer ID provided." # noqa: E501 + raise OJMicrolineAuthError(msg) - Args: - ---- - data: The JSON data contained in the response. + self._session_calls_left = self._session_calls + self._session_id = data["SessionId"] - Returns: True if the update succeeded. + async def get_thermostats(self) -> list[Thermostat]: + """Get all the thermostats. + + Returns + ------- + A list of Thermostats objects. """ + data = await self.request( + self.get_thermostats_path, + method=hdrs.METH_GET, + params={ + "sessionid": self._session_id, + **self.get_thermostats_params(), + }, + ) + thermostats = self.parse_thermostats_response(data) + + for thermostat in thermostats: + thermostat.energy = await self.get_energy_usage(thermostat) + + return thermostats + + async def get_energy_usage(self, resource: Thermostat) -> list[float]: + """Get the energy usage for the provided thermostat.""" + date_tomorrow = (datetime.now(tz=UTC) + timedelta(days=1)).strftime("%Y-%m-%d") + + if self.get_energy_usage_path == "": + return [] + + data = await self.request( + self.get_energy_usage_path, + method=hdrs.METH_POST, + params={ + "sessionid": self._session_id, + }, + body={ + **self.get_thermostats_params(), + "ThermostatID": resource.serial_number, + "ViewType": 2, + "DateTime": date_tomorrow, + "History": 0, + }, + ) + + return self.parse_energy_usage_response(data) + + async def set_regulation_mode( + self, + resource: Thermostat, + regulation_mode: int, + temperature: int | None, + duration: int, + ) -> bool: + """Set the regulation mode.""" + data = await self.request( + self.update_regulation_mode_path, + method=hdrs.METH_POST, + params={ + "sessionid": self._session_id, + **self.update_regulation_mode_params(resource), + }, + body=self.update_regulation_mode_body( + resource, + regulation_mode, + temperature, + duration, + ), + ) + + if not self.parse_update_regulation_mode_response(data): + msg = "Unable to set preset mode." + raise OJMicrolineError(msg) + + return True @dataclass @@ -127,15 +243,6 @@ class OJMicroline: __http_session: ClientSession | None = None __close_http_session: bool = False - # The session ID to perform calls with. - __session_id: str | None = None - - # Reset the login session when this hits zero. - __session_calls_left: int = 0 - - # Maximum number of requests within a single session. - __session_calls: int = 300 - def __init__( self, api: OJMicrolineAPI, session: ClientSession | None = None ) -> None: @@ -148,15 +255,17 @@ def __init__( """ self.__api = api + self.__api.request = self._request self.__http_session = session - async def _request( + async def _request( # pylint: disable=too-many-arguments self, uri: str, *, method: str = hdrs.METH_GET, params: dict[str, Any] | None = None, body: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, ) -> Any: """Handle a request to the OJ Microline API. @@ -166,6 +275,7 @@ async def _request( method: HTTP method to use, for example, 'GET' params: Extra options to improve or limit the response. body: Data can be used in a POST and PATCH request. + headers: Additional HTTP headers to include. Returns: ------- @@ -184,21 +294,23 @@ async def _request( self.__http_session = ClientSession() self.__close_http_session = True - # Reduce the number of calls left by 1. - self.__session_calls_left -= 1 url = URL.build(scheme="https", host=self.__api.host, path="/").join( URL(uri) ) + request_headers = { + "Content-Type": "application/json; charset=utf-8", + "Accept": "application/json", + } + if headers: + request_headers.update(headers) + async with async_timeout.timeout(self.__request_timeout): response = await self.__http_session.request( method, url, params=params, - headers={ - "Content-Type": "application/json; charset=utf-8", - "Accept": "application/json", - }, + headers=request_headers, json=body, ssl=True, ) @@ -221,28 +333,14 @@ async def _request( return json.loads(await response.text()) async def login(self) -> None: - """Get a valid session to do requests with the OJ Microline API. + """Log in to the OJ Microline API. Raises ------ OJMicrolineAuthError: An error occurred while authenticating. """ - if self.__session_calls_left == 0 or self.__session_id is None: - # Get a new session. - data = await self._request( - self.__api.login_path, - method=hdrs.METH_POST, - body=self.__api.login_body(), - ) - - if data["ErrorCode"] == 1: - msg = "Unable to create session, wrong username, password, API key or customer ID provided." # noqa: E501 - raise OJMicrolineAuthError(msg) - - # Reset the number of session calls. - self.__session_calls_left = self.__session_calls - self.__session_id = data["SessionId"] + await self.__api.login() async def get_thermostats(self) -> list[Thermostat]: """Get all the thermostats. @@ -253,22 +351,7 @@ async def get_thermostats(self) -> list[Thermostat]: """ await self.login() - - data = await self._request( - self.__api.get_thermostats_path, - method=hdrs.METH_GET, - params={ - "sessionid": self.__session_id, - **self.__api.get_thermostats_params(), - }, - ) - thermostats = self.__api.parse_thermostats_response(data) - - # add energy data to each thermostat - for thermostat in thermostats: - thermostat.energy = await self.get_energy_usage(thermostat) - - return thermostats + return await self.__api.get_thermostats() async def get_energy_usage(self, resource: Thermostat) -> list[float]: """Get the energy usage. @@ -288,31 +371,8 @@ async def get_energy_usage(self, resource: Thermostat) -> list[float]: OJMicrolineError: An error occurred while fetching the energy usage. """ - if self.__session_id is None: - await self.login() - - date_tomorrow = (datetime.now(tz=UTC) + timedelta(days=1)).strftime("%Y-%m-%d") - - # If no path is defined, return an empty list. - if self.__api.get_energy_usage_path == "": - return [] - - data = await self._request( - self.__api.get_energy_usage_path, - method=hdrs.METH_POST, - params={ - "sessionid": self.__session_id, - }, - body={ - **self.__api.get_thermostats_params(), - "ThermostatID": resource.serial_number, - "ViewType": 2, - "DateTime": date_tomorrow, - "History": 0, - }, - ) - - return self.__api.parse_energy_usage_response(data) + await self.login() + return await self.__api.get_energy_usage(resource) async def set_regulation_mode( self, @@ -344,35 +404,15 @@ async def set_regulation_mode( OJMicrolineError: An error occurred while setting the regulation mode. """ - if self.__session_id is None: - await self.login() - - data = await self._request( - self.__api.update_regulation_mode_path, - method=hdrs.METH_POST, - params={ - "sessionid": self.__session_id, - **self.__api.update_regulation_mode_params(resource), - }, - body=self.__api.update_regulation_mode_body( - resource, - regulation_mode, - temperature, - duration, - ), + await self.login() + return await self.__api.set_regulation_mode( + resource, regulation_mode, temperature, duration ) - if not self.__api.parse_update_regulation_mode_response(data): - msg = "Unable to set preset mode." - raise OJMicrolineError(msg) - - return True - async def close(self) -> None: """Close open client session.""" if self.__http_session and self.__close_http_session: self.__close_http_session = False - self.__session_id = None await self.__http_session.close() async def __aenter__(self) -> Self: diff --git a/ojmicroline_thermostat/wd5.py b/ojmicroline_thermostat/wd5.py index 8f5b3e51..df75239d 100644 --- a/ojmicroline_thermostat/wd5.py +++ b/ojmicroline_thermostat/wd5.py @@ -17,11 +17,11 @@ ) from .exceptions import OJMicrolineResultsError from .models import Thermostat -from .ojmicroline import OJMicrolineAPI +from .ojmicroline import SessionOJMicrolineAPI @dataclass -class WD5API(OJMicrolineAPI): +class WD5API(SessionOJMicrolineAPI): """Controls OJ Microline WD5-series thermostats (OWD5, MWD5, etc.).""" def __init__( diff --git a/ojmicroline_thermostat/wg4.py b/ojmicroline_thermostat/wg4.py index 38092b1e..8f378326 100644 --- a/ojmicroline_thermostat/wg4.py +++ b/ojmicroline_thermostat/wg4.py @@ -9,11 +9,11 @@ from .const import REGULATION_COMFORT, REGULATION_MANUAL from .models import Thermostat -from .ojmicroline import OJMicrolineAPI +from .ojmicroline import SessionOJMicrolineAPI @dataclass -class WG4API(OJMicrolineAPI): +class WG4API(SessionOJMicrolineAPI): """Controls OJ Microline WG4-series thermostats (UWG4, AWG4, etc.).""" def __init__( diff --git a/ojmicroline_thermostat/wg5.py b/ojmicroline_thermostat/wg5.py new file mode 100644 index 00000000..c0b2c4d4 --- /dev/null +++ b/ojmicroline_thermostat/wg5.py @@ -0,0 +1,293 @@ +"""Implementation of OJMicrolineAPI for WG5-series thermostats.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, Any + +import async_timeout +from aiohttp import ClientSession +from yarl import URL + +from .const import ( + REGULATION_COMFORT, + REGULATION_FROST_PROTECTION, + REGULATION_MANUAL, + REGULATION_SCHEDULE, + REGULATION_VACATION, + WG5_MODE_HOLD, + WG5_MODE_SCHEDULE, +) +from .exceptions import OJMicrolineAuthError, OJMicrolineError +from .models import Thermostat + +if TYPE_CHECKING: + from .ojmicroline import RequestFunc + +WG5_SCOPES = ( + "ugw.zones ugw.users ugw.profile ugw.buildings ugw.schedules " + "ugw.thermostats ugw.firmware ugw.linking openid role offline_access" +) + + +@dataclass +class WG5API: + """Controls OJ Microline WG5-series thermostats.""" + + request: RequestFunc + client_id: str = "mobile_app_client" + + def __init__( + self, + username: str, + password: str, + host: str = "user-api.ojmicroline.com", + identity_host: str = "identity.ojmicroline.com", + ) -> None: + """Create a new instance of the API object. + + Args: + ---- + username: The username to log in with. + password: The password for the username. + host: The host name used for API requests. + identity_host: The host name used for OAuth2 authentication. + + """ + self.username = username + self.password = password + self.host = host + self.identity_host = identity_host + self._access_token: str | None = None + self._refresh_token: str | None = None + self._token_expiry: datetime | None = None + + async def login(self) -> None: + """Authenticate via OAuth2 Resource Owner Password Credentials grant. + + Raises + ------ + OJMicrolineAuthError: Authentication failed. + + """ + if ( + self._access_token + and self._token_expiry + and self._token_expiry > datetime.now(tz=UTC) + ): + return + + async with ClientSession() as session: + url = URL.build( + scheme="https", host=self.identity_host, path="/connect/token" + ) + async with async_timeout.timeout(30): + response = await session.post( + url, + data={ + "grant_type": "password", + "username": self.username, + "password": self.password, + "client_id": self.client_id, + "scope": WG5_SCOPES, + }, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + ssl=True, + ) + + if response.status != 200: + msg = "Unable to authenticate, wrong username or password." + raise OJMicrolineAuthError(msg) + + data = await response.json() + + self._access_token = data["access_token"] + self._refresh_token = data.get("refresh_token") + self._token_expiry = datetime.now(tz=UTC) + timedelta( + seconds=data["expires_in"] + ) + + async def _auth_request( + self, + uri: str, + *, + method: str = "GET", + body: dict[str, Any] | None = None, + ) -> Any: + """Make an authenticated request with Bearer token.""" + return await self.request( + uri, + method=method, + body=body, + headers={"Authorization": f"Bearer {self._access_token}"}, + ) + + async def _is_away_active(self, building_id: str) -> bool: + """Check if away mode is active for a building.""" + data = await self._auth_request(f"awaymode/{building_id}") + return "data" in data + + async def _fetch_thermostat( + self, + thermostat_id: str, + building_id: str, + zone_name: str, + *, + away_active: bool, + ) -> Thermostat: + """Fetch and enrich a single thermostat.""" + control_data = await self._auth_request( + f"thermostats/{thermostat_id}/control", + ) + detail_data = await self._auth_request( + f"thermostats/{thermostat_id}", + ) + schedule_id = control_data["data"].get("scheduleData", {}).get("scheduleId") + schedule_data = None + if schedule_id: + resp = await self._auth_request(f"schedules/{schedule_id}") + schedule_data = resp.get("data") + thermostat = Thermostat.from_wg5_json( + control_data["data"], + building_id=building_id, + zone_name=zone_name, + away_active=away_active, + ) + readouts = detail_data.get("data", {}).get("thermostatReadouts", {}) + thermostat.software_version = readouts.get("softwareVersionNumber", "") + floor = readouts.get("floorTemperature") + if floor is not None: + thermostat.temperature_floor = round(float(floor) * 100) + room = readouts.get("roomTemperature") + if room is not None: + thermostat.temperature_room = round(float(room) * 100) + thermostat.schedule = schedule_data + return thermostat + + async def get_thermostats(self) -> list[Thermostat]: + """Fetch all thermostats across all buildings.""" + buildings_data = await self._auth_request("buildings") + thermostats: list[Thermostat] = [] + + for building in buildings_data["data"]: + building_id = building["id"] + away_active = await self._is_away_active(building_id) + tree_data = await self._auth_request(f"buildings/{building_id}/tree") + for zone in tree_data["data"]["zones"]: + zone_name = zone["name"] + for thermostat_stub in zone["thermostats"]: + thermostat = await self._fetch_thermostat( + thermostat_stub["id"], + building_id, + zone_name, + away_active=away_active, + ) + thermostats.append(thermostat) + + return thermostats + + async def get_energy_usage(self, resource: Thermostat) -> list[float]: + """Fetch energy usage for a thermostat.""" + if not resource.building_id: + return [] + + now = datetime.now(tz=UTC) + start = now.replace(hour=0, minute=0, second=0, microsecond=0) + + data = await self._auth_request( + f"energy/usage/building/{resource.building_id}", + method="POST", + body={ + "ThermostatIds": [resource.serial_number], + "Start": start.strftime("%Y-%m-%dT%H:%M:%SZ"), + "End": now.strftime("%Y-%m-%dT%H:%M:%SZ"), + "Size": 0, + }, + ) + + return [ + float(bucket.get("consumedWattHours", 0)) + for bucket in data["data"]["histogram"] + ] + + async def set_regulation_mode( + self, + resource: Thermostat, + regulation_mode: int, + temperature: int | None, + duration: int, + ) -> bool: + """Set the regulation mode on a thermostat.""" + thermostat_id = resource.serial_number + + if regulation_mode == REGULATION_VACATION: + await self._auth_request( + "awaymode", + method="POST", + body={ + "BuildingId": resource.building_id, + "TemperatureLimitMax": 5.0, + "StartTime": datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + "EndTime": (datetime.now(tz=UTC) + timedelta(days=365)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ), + "IsPlanned": False, + "ChangeTimestamp": "0001-01-01T00:00:00", + }, + ) + return True + + if regulation_mode == REGULATION_FROST_PROTECTION: + await self._auth_request( + f"thermostats/{thermostat_id}/standby/True", + method="PUT", + ) + return True + + # Disable away mode if currently active. + if resource.vacation_mode: + await self._auth_request( + f"awaymode/{resource.building_id}", + method="DELETE", + ) + + # Take out of standby if needed. + if resource.is_in_standby: + await self._auth_request( + f"thermostats/{thermostat_id}/standby/False", + method="PUT", + ) + + if regulation_mode == REGULATION_SCHEDULE: + body: dict[str, Any] = { + "ModeAction": WG5_MODE_SCHEDULE, + "ChangeTimestamp": "0001-01-01T00:00:00", + } + elif regulation_mode in (REGULATION_MANUAL, REGULATION_COMFORT): + setpoint = (temperature or resource.set_point_temperature or 0) / 100 + body = { + "ModeAction": WG5_MODE_HOLD, + "Setpoint": setpoint, + "ChangeTimestamp": "0001-01-01T00:00:00", + } + if regulation_mode == REGULATION_COMFORT: + hold_end = datetime.now(tz=UTC) + timedelta(minutes=duration) + body["HoldUntilEndTime"] = hold_end.strftime("%Y-%m-%dT%H:%M:%SZ") + body["HoldIsPermanent"] = False + else: + body["HoldUntilEndTime"] = None + body["HoldIsPermanent"] = True + else: + msg = f"Unsupported regulation mode for WG5: {regulation_mode}" + raise OJMicrolineError(msg) + + await self._auth_request( + f"thermostats/{thermostat_id}/mode", + method="PUT", + body=body, + ) + return True diff --git a/poetry.lock b/poetry.lock index 61e27656..6fd56204 100644 --- a/poetry.lock +++ b/poetry.lock @@ -273,103 +273,117 @@ coverage = ">=6.0.2" [[package]] name = "coverage" -version = "7.13.3" +version = "7.13.4" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" files = [ - {file = "coverage-7.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b4f345f7265cdbdb5ec2521ffff15fa49de6d6c39abf89fc7ad68aa9e3a55f0"}, - {file = "coverage-7.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:96c3be8bae9d0333e403cc1a8eb078a7f928b5650bae94a18fb4820cc993fb9b"}, - {file = "coverage-7.13.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d6f4a21328ea49d38565b55599e1c02834e76583a6953e5586d65cb1efebd8f8"}, - {file = "coverage-7.13.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc970575799a9d17d5c3fafd83a0f6ccf5d5117cdc9ad6fbd791e9ead82418b0"}, - {file = "coverage-7.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ff33b652b3556b05e204ae20793d1f872161b0fa5ec8a9ac76f8430e152ed6"}, - {file = "coverage-7.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7df8759ee57b9f3f7b66799b7660c282f4375bef620ade1686d6a7b03699e75f"}, - {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f45c9bcb16bee25a798ccba8a2f6a1251b19de6a0d617bb365d7d2f386c4e20e"}, - {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:318b2e4753cbf611061e01b6cc81477e1cdfeb69c36c4a14e6595e674caadb56"}, - {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:24db3959de8ee394eeeca89ccb8ba25305c2da9a668dd44173394cbd5aa0777f"}, - {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:be14d0622125edef21b3a4d8cd2d138c4872bf6e38adc90fd92385e3312f406a"}, - {file = "coverage-7.13.3-cp310-cp310-win32.whl", hash = "sha256:53be4aab8ddef18beb6188f3a3fdbf4d1af2277d098d4e618be3a8e6c88e74be"}, - {file = "coverage-7.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:bfeee64ad8b4aae3233abb77eb6b52b51b05fa89da9645518671b9939a78732b"}, - {file = "coverage-7.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5907605ee20e126eeee2abe14aae137043c2c8af2fa9b38d2ab3b7a6b8137f73"}, - {file = "coverage-7.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a88705500988c8acad8b8fd86c2a933d3aa96bec1ddc4bc5cb256360db7bbd00"}, - {file = "coverage-7.13.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bbb5aa9016c4c29e3432e087aa29ebee3f8fda089cfbfb4e6d64bd292dcd1c2"}, - {file = "coverage-7.13.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c2be202a83dde768937a61cdc5d06bf9fb204048ca199d93479488e6247656c"}, - {file = "coverage-7.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f45e32ef383ce56e0ca099b2e02fcdf7950be4b1b56afaab27b4ad790befe5b"}, - {file = "coverage-7.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ed2e787249b922a93cd95c671cc9f4c9797a106e81b455c83a9ddb9d34590c0"}, - {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:05dd25b21afffe545e808265897c35f32d3e4437663923e0d256d9ab5031fb14"}, - {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46d29926349b5c4f1ea4fca95e8c892835515f3600995a383fa9a923b5739ea4"}, - {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fae6a21537519c2af00245e834e5bf2884699cc7c1055738fd0f9dc37a3644ad"}, - {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c672d4e2f0575a4ca2bf2aa0c5ced5188220ab806c1bb6d7179f70a11a017222"}, - {file = "coverage-7.13.3-cp311-cp311-win32.whl", hash = "sha256:fcda51c918c7a13ad93b5f89a58d56e3a072c9e0ba5c231b0ed81404bf2648fb"}, - {file = "coverage-7.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:d1a049b5c51b3b679928dd35e47c4a2235e0b6128b479a7596d0ef5b42fa6301"}, - {file = "coverage-7.13.3-cp311-cp311-win_arm64.whl", hash = "sha256:79f2670c7e772f4917895c3d89aad59e01f3dbe68a4ed2d0373b431fad1dcfba"}, - {file = "coverage-7.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ed48b4170caa2c4420e0cd27dc977caaffc7eecc317355751df8373dddcef595"}, - {file = "coverage-7.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8f2adf4bcffbbec41f366f2e6dffb9d24e8172d16e91da5799c9b7ed6b5716e6"}, - {file = "coverage-7.13.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01119735c690786b6966a1e9f098da4cd7ca9174c4cfe076d04e653105488395"}, - {file = "coverage-7.13.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8bb09e83c603f152d855f666d70a71765ca8e67332e5829e62cb9466c176af23"}, - {file = "coverage-7.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b607a40cba795cfac6d130220d25962931ce101f2f478a29822b19755377fb34"}, - {file = "coverage-7.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f14a62f5da2e9aedf9080e01d2cda61df39197d48e323538ec037336d68da8"}, - {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:debf29e0b157769843dff0981cc76f79e0ed04e36bb773c6cac5f6029054bd8a"}, - {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:824bb95cd71604031ae9a48edb91fd6effde669522f960375668ed21b36e3ec4"}, - {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8f1010029a5b52dc427c8e2a8dbddb2303ddd180b806687d1acd1bb1d06649e7"}, - {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cd5dee4fd7659d8306ffa79eeaaafd91fa30a302dac3af723b9b469e549247e0"}, - {file = "coverage-7.13.3-cp312-cp312-win32.whl", hash = "sha256:f7f153d0184d45f3873b3ad3ad22694fd73aadcb8cdbc4337ab4b41ea6b4dff1"}, - {file = "coverage-7.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:03a6e5e1e50819d6d7436f5bc40c92ded7e484e400716886ac921e35c133149d"}, - {file = "coverage-7.13.3-cp312-cp312-win_arm64.whl", hash = "sha256:51c4c42c0e7d09a822b08b6cf79b3c4db8333fffde7450da946719ba0d45730f"}, - {file = "coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25"}, - {file = "coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a"}, - {file = "coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627"}, - {file = "coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8"}, - {file = "coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1"}, - {file = "coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b"}, - {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc"}, - {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea"}, - {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67"}, - {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86"}, - {file = "coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43"}, - {file = "coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587"}, - {file = "coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051"}, - {file = "coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9"}, - {file = "coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e"}, - {file = "coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107"}, - {file = "coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43"}, - {file = "coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3"}, - {file = "coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a"}, - {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e"}, - {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155"}, - {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e"}, - {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96"}, - {file = "coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f"}, - {file = "coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c"}, - {file = "coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9"}, - {file = "coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b"}, - {file = "coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10"}, - {file = "coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39"}, - {file = "coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f"}, - {file = "coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4"}, - {file = "coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef"}, - {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75"}, - {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895"}, - {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c"}, - {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a"}, - {file = "coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4"}, - {file = "coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0"}, - {file = "coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3"}, - {file = "coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8"}, - {file = "coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca"}, - {file = "coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba"}, - {file = "coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f"}, - {file = "coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508"}, - {file = "coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba"}, - {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd"}, - {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab"}, - {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e"}, - {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024"}, - {file = "coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3"}, - {file = "coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8"}, - {file = "coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3"}, - {file = "coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910"}, - {file = "coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac"}, + {file = "coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415"}, + {file = "coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b"}, + {file = "coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a"}, + {file = "coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f"}, + {file = "coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012"}, + {file = "coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def"}, + {file = "coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256"}, + {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda"}, + {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92"}, + {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c"}, + {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58"}, + {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9"}, + {file = "coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf"}, + {file = "coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95"}, + {file = "coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053"}, + {file = "coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11"}, + {file = "coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa"}, + {file = "coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7"}, + {file = "coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00"}, + {file = "coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef"}, + {file = "coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903"}, + {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f"}, + {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299"}, + {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505"}, + {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6"}, + {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9"}, + {file = "coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9"}, + {file = "coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f"}, + {file = "coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f"}, + {file = "coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459"}, + {file = "coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3"}, + {file = "coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634"}, + {file = "coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3"}, + {file = "coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa"}, + {file = "coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3"}, + {file = "coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a"}, + {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7"}, + {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc"}, + {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47"}, + {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985"}, + {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0"}, + {file = "coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246"}, + {file = "coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126"}, + {file = "coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d"}, + {file = "coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9"}, + {file = "coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac"}, + {file = "coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea"}, + {file = "coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b"}, + {file = "coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525"}, + {file = "coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242"}, + {file = "coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148"}, + {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a"}, + {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23"}, + {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80"}, + {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea"}, + {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a"}, + {file = "coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d"}, + {file = "coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd"}, + {file = "coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af"}, + {file = "coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d"}, + {file = "coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12"}, + {file = "coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b"}, + {file = "coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9"}, + {file = "coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092"}, + {file = "coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9"}, + {file = "coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26"}, + {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2"}, + {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940"}, + {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c"}, + {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0"}, + {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b"}, + {file = "coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9"}, + {file = "coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd"}, + {file = "coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997"}, + {file = "coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601"}, + {file = "coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0"}, + {file = "coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb"}, + {file = "coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505"}, + {file = "coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2"}, + {file = "coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056"}, + {file = "coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0"}, + {file = "coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea"}, + {file = "coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932"}, + {file = "coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b"}, + {file = "coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0"}, + {file = "coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91"}, ] [package.extras] @@ -1654,4 +1668,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "42ed94048371b5dfbaf0f434f72b98ac84d18a9841c59e1c6f37c08c33e618e4" +content-hash = "82cab848323ebe04b0a3a2e7db7240ee9876f641081e3bc5fc21f3517c88747d" diff --git a/pyproject.toml b/pyproject.toml index 0bd67a41..e58d597e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ Changelog = "https://github.com/robbinjanssen/python-ojmicroline-thermostat/rele aresponses = "3.0.0" codespell = "2.4.1" covdefaults = "2.3.0" -coverage = {version = "7.13.3", extras = ["toml"]} +coverage = {version = "7.13.4", extras = ["toml"]} mypy = "1.19.1" pre-commit = "4.5.1" pre-commit-hooks = "6.0.0" diff --git a/tests/fixtures/wg5_building_tree.json b/tests/fixtures/wg5_building_tree.json new file mode 100644 index 00000000..9dc0be83 --- /dev/null +++ b/tests/fixtures/wg5_building_tree.json @@ -0,0 +1,22 @@ +{ + "data": { + "zones": [ + { + "thermostats": [ + { + "id": "2cb3e6c5-8cf8-4e7e-943a-42618c34a506", + "name": "Bathroom" + } + ], + "id": "ae85d8fc-1b4a-445a-bdf1-05f970fc3da4", + "name": "Default" + } + ], + "id": "57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a", + "name": "My Home" + }, + "status": { + "code": "OK", + "traceId": "6835247adfb2e0d1ef8679cce39f1606" + } +} diff --git a/tests/fixtures/wg5_buildings.json b/tests/fixtures/wg5_buildings.json new file mode 100644 index 00000000..200b3501 --- /dev/null +++ b/tests/fixtures/wg5_buildings.json @@ -0,0 +1,20 @@ +{ + "page": { + "total": 1, + "totalPages": 1, + "size": 50, + "number": 0 + }, + "data": [ + { + "isDefault": true, + "userId": "87c522ce-f45f-4993-bf2a-74677d7114f9", + "id": "57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a", + "name": "My Home" + } + ], + "status": { + "code": "OK", + "traceId": "bb18845ebf596416875793381d5bb28c" + } +} diff --git a/tests/fixtures/wg5_energy.json b/tests/fixtures/wg5_energy.json new file mode 100644 index 00000000..d27832ca --- /dev/null +++ b/tests/fixtures/wg5_energy.json @@ -0,0 +1,36 @@ +{ + "data": { + "histogram": [ + { + "consumedWattHours": 150, + "cost": 0.015, + "bucketStart": "2026-04-13T00:00:00Z" + }, + { + "consumedWattHours": 200, + "cost": 0.020, + "bucketStart": "2026-04-13T01:00:00Z" + }, + { + "consumedWattHours": 0, + "cost": 0.0, + "bucketStart": "2026-04-13T02:00:00Z" + } + ], + "priceSettings": { + "isDefault": true, + "price": 0.1, + "currency": "USD" + }, + "floorLoadSettings": [ + { + "thermostatId": "2cb3e6c5-8cf8-4e7e-943a-42618c34a506", + "floorLoad": 100 + } + ] + }, + "status": { + "code": "OK", + "traceId": "abc123" + } +} diff --git a/tests/fixtures/wg5_schedule.json b/tests/fixtures/wg5_schedule.json new file mode 100644 index 00000000..38929fc1 --- /dev/null +++ b/tests/fixtures/wg5_schedule.json @@ -0,0 +1,61 @@ +{ + "data": { + "baseTemperature": 23, + "schedules": [ + { + "days": [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday" + ], + "schedules": [ + { + "start": "06:00:00", + "end": "09:00:00", + "temperature": 28 + }, + { + "start": "17:00:00", + "end": "23:00:00", + "temperature": 28 + } + ] + }, + { + "days": [ + "Saturday" + ], + "schedules": [ + { + "start": "08:00:00", + "end": "23:00:00", + "temperature": 28 + } + ] + }, + { + "days": [ + "Sunday" + ], + "schedules": [ + { + "start": "08:00:00", + "end": "23:00:00", + "temperature": 28 + } + ] + } + ], + "scheduleType": "WorkingDaysAndHolidaysSeparately", + "id": "f9aac20b-4e45-415b-9f56-e53014ee8639", + "name": "Default Schedule", + "creationDate": "2026-04-05T11:26:36Z", + "defaultScheduleType": "FloorSensor" + }, + "status": { + "code": "OK", + "traceId": "a5d720e9638ffae0e4fe322a7242fb1c" + } +} diff --git a/tests/fixtures/wg5_thermostat_control.json b/tests/fixtures/wg5_thermostat_control.json new file mode 100644 index 00000000..4a75b725 --- /dev/null +++ b/tests/fixtures/wg5_thermostat_control.json @@ -0,0 +1,40 @@ +{ + "data": { + "id": "2cb3e6c5-8cf8-4e7e-943a-42618c34a506", + "zoneId": "ae85d8fc-1b4a-445a-bdf1-05f970fc3da4", + "name": "Bathroom", + "createdDate": "2026-04-05T11:29:17Z", + "currentTemperature": 19, + "currentTemperatureFahrenheit": 66, + "minimumPossibleSetPoint": 5, + "maximumPossibleSetPoint": 40, + "isOnline": true, + "isAdaptiveHeatingActive": false, + "errorFlags": "NoErrors", + "hasError": false, + "isOpenWindowDetected": false, + "isHeatRelayActive": true, + "isFrostProtectionEnabled": true, + "frostProtectionTemperature": 5, + "isFollowingZone": false, + "mode": { + "isInStandby": false, + "isAwayActive": false, + "fallbackMode": "Manual", + "manualSetpoint": 27.78, + "changeTimestamp": "2026-04-13T23:40:43Z" + }, + "scheduleData": { + "scheduleId": "f9aac20b-4e45-415b-9f56-e53014ee8639", + "scheduleName": "Default Schedule", + "currentSetPoint": 23 + }, + "sensorApplication": "Floor", + "setpoint": 27.78, + "changeTimestamp": "2026-04-13T23:40:43Z" + }, + "status": { + "code": "OK", + "traceId": "3bbdb398bee4254f0801119ffe38a07c" + } +} diff --git a/tests/fixtures/wg5_thermostat_detail.json b/tests/fixtures/wg5_thermostat_detail.json new file mode 100644 index 00000000..14fda869 --- /dev/null +++ b/tests/fixtures/wg5_thermostat_detail.json @@ -0,0 +1,110 @@ +{ + "data": { + "id": "2cb3e6c5-8cf8-4e7e-943a-42618c34a506", + "name": "Bathroom", + "userId": "87c522ce-f45f-4993-bf2a-74677d7114f9", + "zoneId": "ae85d8fc-1b4a-445a-bdf1-05f970fc3da4", + "zoneName": "Default", + "buildingId": "57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a", + "buildingName": "My Home", + "isOnline": true, + "hasError": false, + "hasUpdates": true, + "errors": "NoErrors", + "isFloorSensorAvailable": true, + "isAwayActive": false, + "serialNumber": "300030132", + "isAdaptiveHeatingActive": false, + "isOpenWindowDetected": false, + "isHeatRelayActive": false, + "distributorId": 10031, + "plConfigFile": "1047AA103", + "thermostatDisplaySettings": { + "temperatureUnit": "Fahrenheit", + "screensaver": "ActualTemperature", + "screenLockEnabled": false, + "displayOn": true, + "brightnessNormal": 6, + "brightnessScreensaver": 3, + "changeTimestamp": "2025-08-05T14:13:36Z" + }, + "thermostatHeatingSettings": { + "floorLoad": 100, + "floorSensorType": "Type10KOhm", + "floorSensorOffset": 0, + "roomSensorOffset": 0, + "isAdaptiveHeatingEnabled": false, + "isOpenWindowDetectionEnabled": true, + "changeTimestamp": "2026-04-14T01:17:18Z" + }, + "thermostatFloorSensorSettings": { + "value15deg": 4714, + "value20deg": 3749, + "value25deg": 3000, + "value30deg": 2417, + "changeTimestamp": "2025-08-05T14:13:36Z" + }, + "thermostatRegulationSettings": { + "sensorApplication": "Floor", + "temperatureLimitMax": 40, + "temperatureLimitMin": 5, + "floorType": "Tile", + "floorProtectionMax": 40, + "floorProtectionMin": 0, + "frostProtectionTemperature": 5, + "frostProtectionEnabled": true, + "changeTimestamp": "2026-04-05T04:20:33Z" + }, + "thermostatDateTimeSettings": { + "dst": "NorthAmerica", + "timeZoneMinutes": -300, + "changeTimestamp": "2026-04-05T11:43:35Z" + }, + "thermostatWifiSettings": { + "ssid": "TestWifi", + "authentication": "Wpa2", + "defaultGateway": "192.168.4.1", + "netmask": "255.255.252.0", + "ipv4": "192.168.4.24", + "ipv6": "fe80::223:38ff:fe62:d449", + "dhcp": false, + "macWifi": "00:23:38:62:D4:49", + "macBle": "00:23:38:62:D4:4A", + "preferedWifiChannel": 0, + "numberOfTimesWifiApConnectWasCalled": 7, + "bleState": 1, + "changeTimestamp": "2026-04-13T01:38:30Z" + }, + "thermostatReadouts": { + "softwareVersionNumber": "1047A108", + "productType": "UWG5", + "floorTemperature": 19, + "floorTemperatureFahrenheit": 67, + "roomTemperature": 21, + "roomTemperatureFahrenheit": 70, + "currentTime": "2026-04-14T01:31:11Z", + "distributorId": "10031", + "wifiSoftwareVersion": "6.02.00-004" + }, + "mode": { + "isInStandby": true, + "isAwayActive": false, + "fallbackMode": "Auto", + "manualSetpoint": 26, + "changeTimestamp": "0001-01-01T00:00:00" + }, + "scheduleData": { + "scheduleId": "f9aac20b-4e45-415b-9f56-e53014ee8639", + "scheduleName": "Default Schedule", + "nextEventStartTime": "23:00:00", + "nextEventDayOfWeek": "Monday", + "nextEventSetPoint": 23, + "currentSetPoint": 28 + }, + "setpoint": 28 + }, + "status": { + "code": "OK", + "traceId": "d078903141fa63e29fff471c0e3c514f" + } +} diff --git a/tests/fixtures/wg5_token.json b/tests/fixtures/wg5_token.json new file mode 100644 index 00000000..2a2a4202 --- /dev/null +++ b/tests/fixtures/wg5_token.json @@ -0,0 +1,7 @@ +{ + "access_token": "eyJ0ZXN0IjoiZmFrZS1hY2Nlc3MtdG9rZW4ifQ", + "expires_in": 3600, + "token_type": "Bearer", + "refresh_token": "fake-refresh-token-for-testing", + "scope": "offline_access openid role ugw.buildings ugw.firmware ugw.linking ugw.profile ugw.schedules ugw.thermostats ugw.users ugw.zones" +} diff --git a/tests/ruff.toml b/tests/ruff.toml index 60b7bf55..08727e59 100644 --- a/tests/ruff.toml +++ b/tests/ruff.toml @@ -8,6 +8,7 @@ lint.extend-select = [ lint.extend-ignore = [ "S101", # Use of assert detected. As these are tests "SLF001", # Tests will access private/protected members + "S105", # Ignore possible hardcoded passwords "S106", # Ignore possible passwords "ANN001", # Ignore missing annotations ] diff --git a/tests/test_model_thermostat.py b/tests/test_model_thermostat.py index db8e3dbd..1d2a7fe6 100644 --- a/tests/test_model_thermostat.py +++ b/tests/test_model_thermostat.py @@ -207,6 +207,109 @@ async def test_thermostat_from_json_wd5_timezone_negative() -> None: assert thermostat.comfort_end_time.strftime("%z") == "-0200" +@pytest.mark.asyncio +async def test_thermostat_from_json_wg5() -> None: + """Make sure the data is accepted by the from_wg5_json method.""" + data = json.loads(load_fixtures("wg5_thermostat_control.json")) + thermostat = Thermostat.from_wg5_json( + data["data"], + building_id="57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a", + zone_name="Default", + ) + + for field in WG5_ONLY_FIELDS: + assert getattr(thermostat, field) is not None, ( + f"Expected {field} to be non-null" + ) + for field in WD5_EXCLUSIVE_FIELDS: + assert getattr(thermostat, field) is None, f"Expected {field} to be null" + + assert thermostat.model == "WG5" + assert thermostat.serial_number == "2cb3e6c5-8cf8-4e7e-943a-42618c34a506" + assert thermostat.software_version == "" + assert thermostat.zone_name == "Default" + assert thermostat.zone_id == 0 + assert thermostat.zone_uuid == "ae85d8fc-1b4a-445a-bdf1-05f970fc3da4" + assert thermostat.building_id == "57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a" + assert thermostat.name == "Bathroom" + assert thermostat.online is True + assert thermostat.heating is True + assert thermostat.regulation_mode == REGULATION_MANUAL + assert thermostat.supported_regulation_modes == [ + REGULATION_SCHEDULE, + REGULATION_MANUAL, + REGULATION_COMFORT, + REGULATION_VACATION, + REGULATION_FROST_PROTECTION, + ] + assert thermostat.min_temperature == 500 + assert thermostat.max_temperature == 4000 + assert thermostat.temperature == 1900 + assert thermostat.set_point_temperature == 2778 + assert thermostat.manual_temperature == 2778 + assert thermostat.comfort_temperature == 2778 + assert thermostat.frost_protection_temperature == 500 + assert thermostat.sensor_mode == SENSOR_FLOOR + assert thermostat.vacation_mode is False + assert thermostat.is_in_standby is False + assert thermostat.open_window_detection is False + assert thermostat.schedule_id == "f9aac20b-4e45-415b-9f56-e53014ee8639" + assert thermostat.schedule_name == "Default Schedule" + + # Test the getter methods: + assert thermostat.get_current_temperature() == 1900 + assert thermostat.get_target_temperature() == 2778 + + +@pytest.mark.asyncio +async def test_thermostat_from_json_wg5_standby() -> None: + """Make sure standby mode maps to REGULATION_FROST_PROTECTION.""" + data = json.loads(load_fixtures("wg5_thermostat_control.json")) + data["data"]["mode"]["isInStandby"] = True + + thermostat = Thermostat.from_wg5_json( + data["data"], + building_id="57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a", + zone_name="Default", + ) + + assert thermostat.regulation_mode == REGULATION_FROST_PROTECTION + assert thermostat.is_in_standby is True + + +@pytest.mark.asyncio +async def test_thermostat_from_json_wg5_away() -> None: + """Make sure away_active maps to REGULATION_VACATION.""" + data = json.loads(load_fixtures("wg5_thermostat_control.json")) + + thermostat = Thermostat.from_wg5_json( + data["data"], + building_id="57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a", + zone_name="Default", + away_active=True, + ) + + assert thermostat.regulation_mode == REGULATION_VACATION + assert thermostat.vacation_mode is True + + +@pytest.mark.asyncio +async def test_thermostat_from_json_wg5_schedule() -> None: + """Make sure fallbackMode Auto maps to REGULATION_SCHEDULE.""" + data = json.loads(load_fixtures("wg5_thermostat_control.json")) + data["data"]["mode"]["fallbackMode"] = "Auto" + + thermostat = Thermostat.from_wg5_json( + data["data"], + building_id="57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a", + zone_name="Default", + ) + + assert thermostat.regulation_mode == REGULATION_SCHEDULE + assert thermostat.last_primary_mode_is_auto is True + assert thermostat.is_in_standby is False + + REQUIRED_FIELDS = [ "model", "serial_number", @@ -248,3 +351,28 @@ async def test_thermostat_from_json_wd5_timezone_negative() -> None: "frost_protection_temperature", "boost_temperature", ] + +WD5_EXCLUSIVE_FIELDS = [ + "thermostat_id", + "schedule", + "adaptive_mode", + "daylight_saving_active", + "temperature_floor", + "temperature_room", + "boost_end_time", + "boost_temperature", +] + +WG5_ONLY_FIELDS = [ + "building_id", + "zone_uuid", + "is_in_standby", + "schedule_id", + "schedule_name", + "temperature", + "set_point_temperature", + "sensor_mode", + "open_window_detection", + "frost_protection_temperature", + "vacation_mode", +] diff --git a/tests/test_ojmicroline.py b/tests/test_ojmicroline.py index 5766cf46..e69052ce 100644 --- a/tests/test_ojmicroline.py +++ b/tests/test_ojmicroline.py @@ -46,7 +46,6 @@ async def test_json_request(aresponses: ResponsesMockServer) -> None: assert response is not None await client.close() assert client._OJMicroline__close_http_session is False - assert client._OJMicroline__session_id is None @pytest.mark.asyncio diff --git a/tests/test_wd5.py b/tests/test_wd5.py index bd780410..6232a46e 100644 --- a/tests/test_wd5.py +++ b/tests/test_wd5.py @@ -35,23 +35,18 @@ async def test_login(aresponses: ResponsesMockServer) -> None: ), ) async with aiohttp.ClientSession() as session: - client = OJMicroline( - api=WD5API( - host="ojmicroline.test.host", - api_key="ap1-k3y-v3ry-s3cret", - customer_id=1337, - username="py", - password="test", - ), - session=session, + api = WD5API( + host="ojmicroline.test.host", + api_key="ap1-k3y-v3ry-s3cret", + customer_id=1337, + username="py", + password="test", ) + client = OJMicroline(api=api, session=session) await client.login() - assert ( - client._OJMicroline__session_calls_left - == client._OJMicroline__session_calls - ) - assert client._OJMicroline__session_id == "f00br4" + assert api._session_calls_left == api._session_calls + assert api._session_id == "f00br4" @pytest.mark.asyncio @@ -68,26 +63,24 @@ async def test_login_failed(aresponses: ResponsesMockServer) -> None: ), ) async with aiohttp.ClientSession() as session: - client = OJMicroline( - api=WD5API( - host="ojmicroline.test.host", - api_key="ap1-k3y-v3ry-s3cret", - customer_id=1337, - username="py", - password="test", - ), - session=session, + api = WD5API( + host="ojmicroline.test.host", + api_key="ap1-k3y-v3ry-s3cret", + customer_id=1337, + username="py", + password="test", ) + client = OJMicroline(api=api, session=session) with pytest.raises(OJMicrolineAuthError): await client.login() - assert client._OJMicroline__session_calls_left == -1 - assert client._OJMicroline__session_id is None + assert api._session_calls_left == -1 + assert api._session_id is None @pytest.mark.asyncio -async def test_get_thermostats(monkeypatch, aresponses: ResponsesMockServer) -> None: +async def test_get_thermostats(aresponses: ResponsesMockServer) -> None: """Test get thermostats function and make sure fields are set.""" aresponses.add( "ojmicroline.test.host", @@ -121,19 +114,17 @@ async def test_get_thermostats(monkeypatch, aresponses: ResponsesMockServer) -> ) async with aiohttp.ClientSession() as session: - client = OJMicroline( - api=WD5API( - host="ojmicroline.test.host", - api_key="ap1-k3y-v3ry-s3cret", - customer_id=1337, - username="py", - password="test", - ), - session=session, + api = WD5API( + host="ojmicroline.test.host", + api_key="ap1-k3y-v3ry-s3cret", + customer_id=1337, + username="py", + password="test", ) + api._session_calls_left = 300 + api._session_id = "f00b4r" + client = OJMicroline(api=api, session=session) - monkeypatch.setattr(client, "_OJMicroline__session_calls_left", 300) - monkeypatch.setattr(client, "_OJMicroline__session_id", "f00b4r") thermostats: list[Thermostat] = await client.get_thermostats() assert thermostats is not None @@ -142,9 +133,7 @@ async def test_get_thermostats(monkeypatch, aresponses: ResponsesMockServer) -> @pytest.mark.asyncio -async def test_get_thermostats_failed( - monkeypatch, aresponses: ResponsesMockServer -) -> None: +async def test_get_thermostats_failed(aresponses: ResponsesMockServer) -> None: """Test get thermostats function to handle an error.""" aresponses.add( "ojmicroline.test.host", @@ -157,30 +146,23 @@ async def test_get_thermostats_failed( ), ) async with aiohttp.ClientSession() as session: - client = OJMicroline( - api=WD5API( - host="ojmicroline.test.host", - api_key="ap1-k3y-v3ry-s3cret", - customer_id=1337, - username="py", - password="test", - ), - session=session, + api = WD5API( + host="ojmicroline.test.host", + api_key="ap1-k3y-v3ry-s3cret", + customer_id=1337, + username="py", + password="test", ) - - monkeypatch.setattr(client, "_OJMicroline__session_calls_left", 300) - monkeypatch.setattr(client, "_OJMicroline__session_id", "f00b4r") + api._session_calls_left = 300 + api._session_id = "f00b4r" + client = OJMicroline(api=api, session=session) with pytest.raises(OJMicrolineResultsError): await client.get_thermostats() - assert client._OJMicroline__session_calls_left == 299 - @pytest.mark.asyncio -async def test_set_regulation_mode( - monkeypatch, aresponses: ResponsesMockServer -) -> None: +async def test_set_regulation_mode(aresponses: ResponsesMockServer) -> None: """Test updating the regulation mode.""" aresponses.add( "ojmicroline.test.host", @@ -196,19 +178,17 @@ async def test_set_regulation_mode( data = load_fixtures("wd5_thermostat.json") thermostat = Thermostat.from_wd5_json(json.loads(data)) - client = OJMicroline( - api=WD5API( - host="ojmicroline.test.host", - api_key="ap1-k3y-v3ry-s3cret", - customer_id=1337, - username="py", - password="test", - ), - session=session, + api = WD5API( + host="ojmicroline.test.host", + api_key="ap1-k3y-v3ry-s3cret", + customer_id=1337, + username="py", + password="test", ) + api._session_calls_left = 300 + api._session_id = "f00b4r" + client = OJMicroline(api=api, session=session) - monkeypatch.setattr(client, "_OJMicroline__session_calls_left", 300) - monkeypatch.setattr(client, "_OJMicroline__session_id", "f00b4r") result = await client.set_regulation_mode(thermostat, REGULATION_MANUAL, 2500) assert result is True @@ -216,7 +196,7 @@ async def test_set_regulation_mode( @pytest.mark.asyncio async def test_set_regulation_mode_expect_login( - monkeypatch, aresponses: ResponsesMockServer + aresponses: ResponsesMockServer, ) -> None: """Test update the regulation mode with login method fired.""" aresponses.add( @@ -230,35 +210,31 @@ async def test_set_regulation_mode_expect_login( ), ) async with aiohttp.ClientSession() as session: - - def set_session_id() -> None: - monkeypatch.setattr(client, "_OJMicroline__session_id", "f00b4r") + api = WD5API( + host="ojmicroline.test.host", + api_key="ap1-k3y-v3ry-s3cret", + customer_id=1337, + username="py", + password="test", + ) data = load_fixtures("wd5_thermostat.json") thermostat = Thermostat.from_wd5_json(json.loads(data)) + def set_session_id() -> None: + api._session_id = "f00b4r" + with patch.object( OJMicroline, "login", side_effect=set_session_id ) as mock_login: - client = OJMicroline( - api=WD5API( - host="ojmicroline.test.host", - api_key="ap1-k3y-v3ry-s3cret", - customer_id=1337, - username="py", - password="test", - ), - session=session, - ) + client = OJMicroline(api=api, session=session) await client.set_regulation_mode(thermostat, REGULATION_MANUAL, None) mock_login.assert_called_once() @pytest.mark.asyncio -async def test_set_regulation_mode_failed( - monkeypatch, aresponses: ResponsesMockServer -) -> None: +async def test_set_regulation_mode_failed(aresponses: ResponsesMockServer) -> None: """Test update the regulation mode when an error occurs.""" aresponses.add( "ojmicroline.test.host", @@ -274,21 +250,16 @@ async def test_set_regulation_mode_failed( data = load_fixtures("wd5_thermostat.json") thermostat = Thermostat.from_wd5_json(json.loads(data)) - client = OJMicroline( - api=WD5API( - host="ojmicroline.test.host", - api_key="ap1-k3y-v3ry-s3cret", - customer_id=1337, - username="py", - password="test", - ), - session=session, + api = WD5API( + host="ojmicroline.test.host", + api_key="ap1-k3y-v3ry-s3cret", + customer_id=1337, + username="py", + password="test", ) - - monkeypatch.setattr(client, "_OJMicroline__session_calls_left", 300) - monkeypatch.setattr(client, "_OJMicroline__session_id", "f00b4r") + api._session_calls_left = 300 + api._session_id = "f00b4r" + client = OJMicroline(api=api, session=session) with pytest.raises(OJMicrolineError): await client.set_regulation_mode(thermostat, REGULATION_COMFORT, 2500, 360) - - assert client._OJMicroline__session_calls_left == 299 diff --git a/tests/test_wg4.py b/tests/test_wg4.py index 41b2a450..60a8b0df 100644 --- a/tests/test_wg4.py +++ b/tests/test_wg4.py @@ -38,21 +38,16 @@ async def test_login(aresponses: ResponsesMockServer) -> None: ), ) async with aiohttp.ClientSession() as session: - client = OJMicroline( - api=WG4API( - host="ojmicroline.test.host", - username="py", - password="test", - ), - session=session, + api = WG4API( + host="ojmicroline.test.host", + username="py", + password="test", ) + client = OJMicroline(api=api, session=session) await client.login() - assert ( - client._OJMicroline__session_calls_left - == client._OJMicroline__session_calls - ) - assert client._OJMicroline__session_id == "f00br4" + assert api._session_calls_left == api._session_calls + assert api._session_id == "f00br4" @pytest.mark.asyncio @@ -69,24 +64,22 @@ async def test_login_failed(aresponses: ResponsesMockServer) -> None: ), ) async with aiohttp.ClientSession() as session: - client = OJMicroline( - api=WG4API( - host="ojmicroline.test.host", - username="py", - password="test", - ), - session=session, + api = WG4API( + host="ojmicroline.test.host", + username="py", + password="test", ) + client = OJMicroline(api=api, session=session) with pytest.raises(OJMicrolineAuthError): await client.login() - assert client._OJMicroline__session_calls_left == -1 - assert client._OJMicroline__session_id is None + assert api._session_calls_left == -1 + assert api._session_id is None @pytest.mark.asyncio -async def test_get_thermostats(monkeypatch, aresponses: ResponsesMockServer) -> None: +async def test_get_thermostats(aresponses: ResponsesMockServer) -> None: """Test get thermostats function and make sure fields are set.""" aresponses.add( "ojmicroline.test.host", @@ -99,17 +92,15 @@ async def test_get_thermostats(monkeypatch, aresponses: ResponsesMockServer) -> ), ) async with aiohttp.ClientSession() as session: - client = OJMicroline( - api=WG4API( - host="ojmicroline.test.host", - username="py", - password="test", - ), - session=session, + api = WG4API( + host="ojmicroline.test.host", + username="py", + password="test", ) + api._session_calls_left = 300 + api._session_id = "f00b4r" + client = OJMicroline(api=api, session=session) - monkeypatch.setattr(client, "_OJMicroline__session_calls_left", 300) - monkeypatch.setattr(client, "_OJMicroline__session_id", "f00b4r") thermostats: list[Thermostat] = await client.get_thermostats() assert thermostats is not None @@ -119,9 +110,7 @@ async def test_get_thermostats(monkeypatch, aresponses: ResponsesMockServer) -> @pytest.mark.asyncio -async def test_set_regulation_mode( - monkeypatch, aresponses: ResponsesMockServer -) -> None: +async def test_set_regulation_mode(aresponses: ResponsesMockServer) -> None: """Test updating the regulation mode.""" aresponses.add( "ojmicroline.test.host", @@ -137,17 +126,15 @@ async def test_set_regulation_mode( data = load_fixtures("wg4_thermostat.json") thermostat = Thermostat.from_wg4_json(json.loads(data)) - client = OJMicroline( - api=WG4API( - host="ojmicroline.test.host", - username="py", - password="test", - ), - session=session, + api = WG4API( + host="ojmicroline.test.host", + username="py", + password="test", ) + api._session_calls_left = 300 + api._session_id = "f00b4r" + client = OJMicroline(api=api, session=session) - monkeypatch.setattr(client, "_OJMicroline__session_calls_left", 300) - monkeypatch.setattr(client, "_OJMicroline__session_id", "f00b4r") result = await client.set_regulation_mode(thermostat, REGULATION_MANUAL, 2500) assert result is True @@ -155,7 +142,7 @@ async def test_set_regulation_mode( @pytest.mark.asyncio async def test_set_regulation_mode_expect_login( - monkeypatch, aresponses: ResponsesMockServer + aresponses: ResponsesMockServer, ) -> None: """Test update the regulation mode with login method fired.""" aresponses.add( @@ -169,33 +156,29 @@ async def test_set_regulation_mode_expect_login( ), ) async with aiohttp.ClientSession() as session: - - def set_session_id() -> None: - monkeypatch.setattr(client, "_OJMicroline__session_id", "f00b4r") + api = WG4API( + host="ojmicroline.test.host", + username="py", + password="test", + ) data = load_fixtures("wg4_thermostat.json") thermostat = Thermostat.from_wg4_json(json.loads(data)) + def set_session_id() -> None: + api._session_id = "f00b4r" + with patch.object( OJMicroline, "login", side_effect=set_session_id ) as mock_login: - client = OJMicroline( - api=WG4API( - host="ojmicroline.test.host", - username="py", - password="test", - ), - session=session, - ) + client = OJMicroline(api=api, session=session) await client.set_regulation_mode(thermostat, REGULATION_SCHEDULE) mock_login.assert_called_once() @pytest.mark.asyncio -async def test_set_regulation_mode_failed( - monkeypatch, aresponses: ResponsesMockServer -) -> None: +async def test_set_regulation_mode_failed(aresponses: ResponsesMockServer) -> None: """Test update the regulation mode when an error occurs.""" aresponses.add( "ojmicroline.test.host", @@ -211,19 +194,14 @@ async def test_set_regulation_mode_failed( data = load_fixtures("wg4_thermostat.json") thermostat = Thermostat.from_wg4_json(json.loads(data)) - client = OJMicroline( - api=WG4API( - host="ojmicroline.test.host", - username="py", - password="test", - ), - session=session, + api = WG4API( + host="ojmicroline.test.host", + username="py", + password="test", ) - - monkeypatch.setattr(client, "_OJMicroline__session_calls_left", 300) - monkeypatch.setattr(client, "_OJMicroline__session_id", "f00b4r") + api._session_calls_left = 300 + api._session_id = "f00b4r" + client = OJMicroline(api=api, session=session) with pytest.raises(OJMicrolineError): await client.set_regulation_mode(thermostat, REGULATION_COMFORT, 2500, 360) - - assert client._OJMicroline__session_calls_left == 299 diff --git a/tests/test_wg5.py b/tests/test_wg5.py new file mode 100644 index 00000000..2daa9ec8 --- /dev/null +++ b/tests/test_wg5.py @@ -0,0 +1,214 @@ +# pylint: disable=protected-access +# mypy: disable-error-code="attr-defined" +"""Integration test for the WG5API class.""" + +import json + +import aiohttp +import pytest +from aresponses import Response, ResponsesMockServer # type: ignore[import] +from ojmicroline_thermostat import ( + OJMicroline, + OJMicrolineAuthError, + Thermostat, +) +from ojmicroline_thermostat.const import REGULATION_MANUAL +from ojmicroline_thermostat.wg5 import WG5API + +from . import load_fixtures + + +@pytest.mark.asyncio +async def test_login(aresponses: ResponsesMockServer) -> None: + """Test the OAuth2 login method.""" + aresponses.add( + "identity.test.host", + "/connect/token", + "POST", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("wg5_token.json"), + ), + ) + async with aiohttp.ClientSession() as session: + api = WG5API( + username="py", + password="test", + host="ojmicroline.test.host", + identity_host="identity.test.host", + ) + client = OJMicroline(api=api, session=session) + + await client.login() + assert api._access_token == "eyJ0ZXN0IjoiZmFrZS1hY2Nlc3MtdG9rZW4ifQ" + assert api._refresh_token == "fake-refresh-token-for-testing" + + +@pytest.mark.asyncio +async def test_login_failed(aresponses: ResponsesMockServer) -> None: + """Test the OAuth2 login method when it fails.""" + aresponses.add( + "identity.test.host", + "/connect/token", + "POST", + Response( + status=400, + headers={"Content-Type": "application/json"}, + text=json.dumps({"error": "invalid_grant"}), + ), + ) + async with aiohttp.ClientSession() as session: + api = WG5API( + username="py", + password="test", + host="ojmicroline.test.host", + identity_host="identity.test.host", + ) + client = OJMicroline(api=api, session=session) + + with pytest.raises(OJMicrolineAuthError): + await client.login() + + +def _add_login_response(aresponses: ResponsesMockServer) -> None: + """Add a successful login response to the mock server.""" + aresponses.add( + "identity.test.host", + "/connect/token", + "POST", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("wg5_token.json"), + ), + ) + + +def _make_api() -> WG5API: + """Create a WG5API for tests.""" + return WG5API( + username="py", + password="test", + host="ojmicroline.test.host", + identity_host="identity.test.host", + ) + + +@pytest.mark.asyncio +async def test_get_thermostats(aresponses: ResponsesMockServer) -> None: + """Test fetching thermostats through the buildings->tree->control flow.""" + _add_login_response(aresponses) + aresponses.add( + "ojmicroline.test.host", + "/buildings", + "GET", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("wg5_buildings.json"), + ), + ) + aresponses.add( + "ojmicroline.test.host", + "/awaymode/57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a", + "GET", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=json.dumps({"status": {"code": "OK"}}), + ), + ) + aresponses.add( + "ojmicroline.test.host", + "/buildings/57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a/tree", + "GET", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("wg5_building_tree.json"), + ), + ) + aresponses.add( + "ojmicroline.test.host", + "/thermostats/2cb3e6c5-8cf8-4e7e-943a-42618c34a506/control", + "GET", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("wg5_thermostat_control.json"), + ), + ) + aresponses.add( + "ojmicroline.test.host", + "/thermostats/2cb3e6c5-8cf8-4e7e-943a-42618c34a506", + "GET", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("wg5_thermostat_detail.json"), + ), + ) + aresponses.add( + "ojmicroline.test.host", + "/schedules/f9aac20b-4e45-415b-9f56-e53014ee8639", + "GET", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("wg5_schedule.json"), + ), + ) + async with aiohttp.ClientSession() as session: + api = _make_api() + client = OJMicroline(api=api, session=session) + + thermostats: list[Thermostat] = await client.get_thermostats() + + assert len(thermostats) == 1 + thermostat = thermostats[0] + assert thermostat.name == "Bathroom" + assert thermostat.model == "WG5" + assert thermostat.serial_number == "2cb3e6c5-8cf8-4e7e-943a-42618c34a506" + assert thermostat.online is True + assert thermostat.heating is True + assert thermostat.regulation_mode == REGULATION_MANUAL + assert thermostat.temperature == 1900 + assert thermostat.set_point_temperature == 2778 + assert thermostat.building_id == "57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a" + assert thermostat.zone_name == "Default" + assert thermostat.software_version == "1047A108" + assert thermostat.temperature_floor == 1900 + assert thermostat.temperature_room == 2100 + assert thermostat.schedule is not None + assert thermostat.schedule["baseTemperature"] == 23 + assert len(thermostat.schedule["schedules"]) == 3 + + +@pytest.mark.asyncio +async def test_get_energy_usage(aresponses: ResponsesMockServer) -> None: + """Test fetching energy usage for a WG5 thermostat.""" + _add_login_response(aresponses) + aresponses.add( + "ojmicroline.test.host", + "/energy/usage/building/57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a", + "POST", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("wg5_energy.json"), + ), + ) + async with aiohttp.ClientSession() as session: + api = _make_api() + client = OJMicroline(api=api, session=session) + + data = json.loads(load_fixtures("wg5_thermostat_control.json")) + thermostat = Thermostat.from_wg5_json( + data["data"], + building_id="57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a", + zone_name="Default", + ) + + energy = await client.get_energy_usage(thermostat) + assert energy == [150.0, 200.0, 0.0] diff --git a/tests/test_wg5_update.py b/tests/test_wg5_update.py new file mode 100644 index 00000000..8892529a --- /dev/null +++ b/tests/test_wg5_update.py @@ -0,0 +1,218 @@ +# pylint: disable=protected-access +# mypy: disable-error-code="attr-defined" +"""Test the WG5API update regulation mode methods.""" + +import json + +import aiohttp +import pytest +from aresponses import Response, ResponsesMockServer # type: ignore[import] +from freezegun import freeze_time +from ojmicroline_thermostat import ( + OJMicroline, + Thermostat, +) +from ojmicroline_thermostat.const import ( + REGULATION_COMFORT, + REGULATION_FROST_PROTECTION, + REGULATION_MANUAL, + REGULATION_SCHEDULE, + REGULATION_VACATION, +) +from ojmicroline_thermostat.wg5 import WG5API + +from . import load_fixtures + + +def _make_api() -> WG5API: + """Create a WG5API for tests.""" + return WG5API( + username="py", + password="test", + host="ojmicroline.test.host", + identity_host="identity.test.host", + ) + + +def _add_login_response(aresponses: ResponsesMockServer) -> None: + """Add a successful login response to the mock server.""" + aresponses.add( + "identity.test.host", + "/connect/token", + "POST", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("wg5_token.json"), + ), + ) + + +def _make_thermostat() -> Thermostat: + """Create a WG5 thermostat for tests.""" + data = json.loads(load_fixtures("wg5_thermostat_control.json")) + return Thermostat.from_wg5_json( + data["data"], + building_id="57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a", + zone_name="Default", + ) + + +@pytest.mark.asyncio +async def test_set_regulation_mode_manual(aresponses: ResponsesMockServer) -> None: + """Test setting manual mode with a temperature.""" + _add_login_response(aresponses) + aresponses.add( + "ojmicroline.test.host", + "/thermostats/2cb3e6c5-8cf8-4e7e-943a-42618c34a506/mode", + "PUT", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=json.dumps({"data": {"changeTimestamp": "2026-04-13T23:40:43Z"}}), + ), + ) + async with aiohttp.ClientSession() as session: + api = _make_api() + client = OJMicroline(api=api, session=session) + thermostat = _make_thermostat() + + result = await client.set_regulation_mode(thermostat, REGULATION_MANUAL, 2600) + assert result is True + + +@pytest.mark.asyncio +@freeze_time("2026-04-13 23:40:00") +async def test_set_regulation_mode_comfort(aresponses: ResponsesMockServer) -> None: + """Test setting comfort mode with duration.""" + _add_login_response(aresponses) + aresponses.add( + "ojmicroline.test.host", + "/thermostats/2cb3e6c5-8cf8-4e7e-943a-42618c34a506/mode", + "PUT", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=json.dumps({"data": {"changeTimestamp": "2026-04-13T23:40:43Z"}}), + ), + ) + async with aiohttp.ClientSession() as session: + api = _make_api() + client = OJMicroline(api=api, session=session) + thermostat = _make_thermostat() + + result = await client.set_regulation_mode( + thermostat, REGULATION_COMFORT, 2200, 120 + ) + assert result is True + + +@pytest.mark.asyncio +async def test_set_regulation_mode_schedule(aresponses: ResponsesMockServer) -> None: + """Test setting schedule mode.""" + _add_login_response(aresponses) + aresponses.add( + "ojmicroline.test.host", + "/thermostats/2cb3e6c5-8cf8-4e7e-943a-42618c34a506/mode", + "PUT", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=json.dumps({"data": {"changeTimestamp": "2026-04-13T23:40:43Z"}}), + ), + ) + async with aiohttp.ClientSession() as session: + api = _make_api() + client = OJMicroline(api=api, session=session) + thermostat = _make_thermostat() + + result = await client.set_regulation_mode(thermostat, REGULATION_SCHEDULE) + assert result is True + + +@pytest.mark.asyncio +async def test_set_regulation_mode_frost_protection( + aresponses: ResponsesMockServer, +) -> None: + """Test setting frost protection (standby) mode.""" + _add_login_response(aresponses) + aresponses.add( + "ojmicroline.test.host", + "/thermostats/2cb3e6c5-8cf8-4e7e-943a-42618c34a506/standby/True", + "PUT", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=json.dumps({"data": {"changeTimestamp": "2026-04-13T23:41:50Z"}}), + ), + ) + async with aiohttp.ClientSession() as session: + api = _make_api() + client = OJMicroline(api=api, session=session) + thermostat = _make_thermostat() + + result = await client.set_regulation_mode( + thermostat, REGULATION_FROST_PROTECTION + ) + assert result is True + + +@pytest.mark.asyncio +async def test_set_regulation_mode_vacation( + aresponses: ResponsesMockServer, +) -> None: + """Test enabling away (vacation) mode.""" + _add_login_response(aresponses) + aresponses.add( + "ojmicroline.test.host", + "/awaymode", + "POST", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=json.dumps({"status": {"code": "OK"}}), + ), + ) + async with aiohttp.ClientSession() as session: + api = _make_api() + client = OJMicroline(api=api, session=session) + thermostat = _make_thermostat() + + result = await client.set_regulation_mode(thermostat, REGULATION_VACATION) + assert result is True + + +@pytest.mark.asyncio +async def test_set_regulation_mode_disable_vacation( + aresponses: ResponsesMockServer, +) -> None: + """Test switching from vacation to schedule disables away mode.""" + _add_login_response(aresponses) + aresponses.add( + "ojmicroline.test.host", + "/awaymode/57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a", + "DELETE", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=json.dumps({"status": {"code": "OK"}}), + ), + ) + aresponses.add( + "ojmicroline.test.host", + "/thermostats/2cb3e6c5-8cf8-4e7e-943a-42618c34a506/mode", + "PUT", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=json.dumps({"data": {"changeTimestamp": "2026-04-14T01:00:00Z"}}), + ), + ) + async with aiohttp.ClientSession() as session: + api = _make_api() + client = OJMicroline(api=api, session=session) + thermostat = _make_thermostat() + thermostat.vacation_mode = True + + result = await client.set_regulation_mode(thermostat, REGULATION_SCHEDULE) + assert result is True From 85f26d288eed291b4db1f80183cbd91e422a5b5d Mon Sep 17 00:00:00 2001 From: Hawk Newton Date: Tue, 14 Apr 2026 09:36:09 -0400 Subject: [PATCH 2/4] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e58d597e..8e0aed29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ojmicroline-thermostat" -version = "3.3.0" +version = "4.0.0" description = "Asynchronous Python client controlling an OJ Microline Thermostat." authors = ["Robbin Janssen "] maintainers = ["Robbin Janssen "] From 50813eb074b978713ad5c9f7643ff01e9b7af5b8 Mon Sep 17 00:00:00 2001 From: Hawk Newton Date: Mon, 20 Apr 2026 07:18:10 -0400 Subject: [PATCH 3/4] Get to 100% coverage --- tests/test_model_thermostat.py | 13 ++++ tests/test_ojmicroline.py | 7 +- tests/test_wd5.py | 32 ++++++++ tests/test_wg4.py | 11 +++ tests/test_wg5.py | 133 +++++++++++++++++++++++++++++++++ tests/test_wg5_update.py | 56 ++++++++++++++ 6 files changed, 246 insertions(+), 6 deletions(-) diff --git a/tests/test_model_thermostat.py b/tests/test_model_thermostat.py index 1d2a7fe6..da2a68d5 100644 --- a/tests/test_model_thermostat.py +++ b/tests/test_model_thermostat.py @@ -310,6 +310,19 @@ async def test_thermostat_from_json_wg5_schedule() -> None: assert thermostat.is_in_standby is False +@pytest.mark.asyncio +async def test_thermostat_get_current_energy_with_data() -> None: + """Test get_current_energy returns the first element when energy is set.""" + data = json.loads(load_fixtures("wd5_thermostat.json")) + thermostat = Thermostat.from_wd5_json(data) + thermostat.energy = [3.5, 2.1, 1.0] + + assert thermostat.get_current_energy() == 3.5 + + thermostat.energy = None + assert thermostat.get_current_energy() == 0.0 + + REQUIRED_FIELDS = [ "model", "serial_number", diff --git a/tests/test_ojmicroline.py b/tests/test_ojmicroline.py index e69052ce..21b0f1ff 100644 --- a/tests/test_ojmicroline.py +++ b/tests/test_ojmicroline.py @@ -52,14 +52,9 @@ async def test_json_request(aresponses: ResponsesMockServer) -> None: async def test_timeout(monkeypatch, aresponses: ResponsesMockServer) -> None: """Test request timeout.""" - async def response_handler(_: aiohttp.ClientResponse) -> Response: + async def response_handler(_: aiohttp.ClientResponse) -> None: # Faking a timeout by sleeping await asyncio.sleep(0.2) - return Response( - status=200, - headers={"Content-Type": "application/json"}, - text=json.dumps({"ErrorCode": 0}), - ) aresponses.add("ojmicroline.test.host", "/test", "GET", response_handler) diff --git a/tests/test_wd5.py b/tests/test_wd5.py index 6232a46e..1fe39586 100644 --- a/tests/test_wd5.py +++ b/tests/test_wd5.py @@ -233,6 +233,38 @@ def set_session_id() -> None: mock_login.assert_called_once() +@pytest.mark.asyncio +async def test_get_energy_usage_failed(aresponses: ResponsesMockServer) -> None: + """Test energy usage when the API returns an error.""" + aresponses.add( + "ojmicroline.test.host", + "/api/EnergyUsage/GetEnergyUsage", + "POST", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=json.dumps({"ErrorCode": 1}), + ), + ) + async with aiohttp.ClientSession() as session: + data = load_fixtures("wd5_thermostat.json") + thermostat = Thermostat.from_wd5_json(json.loads(data)) + + api = WD5API( + host="ojmicroline.test.host", + api_key="ap1-k3y-v3ry-s3cret", + customer_id=1337, + username="py", + password="test", + ) + api._session_calls_left = 300 + api._session_id = "f00b4r" + client = OJMicroline(api=api, session=session) + + with pytest.raises(OJMicrolineResultsError): + await client.get_energy_usage(thermostat) + + @pytest.mark.asyncio async def test_set_regulation_mode_failed(aresponses: ResponsesMockServer) -> None: """Test update the regulation mode when an error occurs.""" diff --git a/tests/test_wg4.py b/tests/test_wg4.py index 60a8b0df..254ec746 100644 --- a/tests/test_wg4.py +++ b/tests/test_wg4.py @@ -205,3 +205,14 @@ async def test_set_regulation_mode_failed(aresponses: ResponsesMockServer) -> No with pytest.raises(OJMicrolineError): await client.set_regulation_mode(thermostat, REGULATION_COMFORT, 2500, 360) + + +@pytest.mark.asyncio +async def test_parse_energy_usage_response() -> None: + """Test that WG4 energy usage response parser returns an empty list.""" + api = WG4API( + host="ojmicroline.test.host", + username="py", + password="test", + ) + assert api.parse_energy_usage_response({}) == [] diff --git a/tests/test_wg5.py b/tests/test_wg5.py index 2daa9ec8..91258a67 100644 --- a/tests/test_wg5.py +++ b/tests/test_wg5.py @@ -3,6 +3,7 @@ """Integration test for the WG5API class.""" import json +from datetime import UTC, datetime, timedelta import aiohttp import pytest @@ -95,6 +96,41 @@ def _make_api() -> WG5API: ) +@pytest.mark.asyncio +async def test_login_skips_when_token_valid(aresponses: ResponsesMockServer) -> None: + """Test that login is skipped when a valid token exists.""" + _add_login_response(aresponses) + # Only one token response is mocked; a second login call would fail. + aresponses.add( + "ojmicroline.test.host", + "/buildings", + "GET", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=json.dumps({"data": []}), + ), + ) + aresponses.add( + "ojmicroline.test.host", + "/buildings", + "GET", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=json.dumps({"data": []}), + ), + ) + async with aiohttp.ClientSession() as session: + api = _make_api() + client = OJMicroline(api=api, session=session) + + # First call triggers login + await client.get_thermostats() + # Second call reuses the cached token (line 79) + await client.get_thermostats() + + @pytest.mark.asyncio async def test_get_thermostats(aresponses: ResponsesMockServer) -> None: """Test fetching thermostats through the buildings->tree->control flow.""" @@ -185,6 +221,103 @@ async def test_get_thermostats(aresponses: ResponsesMockServer) -> None: assert len(thermostat.schedule["schedules"]) == 3 +@pytest.mark.asyncio +async def test_get_thermostats_no_schedule_no_readouts( + aresponses: ResponsesMockServer, +) -> None: + """Test fetching thermostats when schedule and readouts are missing.""" + _add_login_response(aresponses) + aresponses.add( + "ojmicroline.test.host", + "/buildings", + "GET", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("wg5_buildings.json"), + ), + ) + aresponses.add( + "ojmicroline.test.host", + "/awaymode/57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a", + "GET", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=json.dumps({"status": {"code": "OK"}}), + ), + ) + aresponses.add( + "ojmicroline.test.host", + "/buildings/57dc7778-4ed3-4382-9e89-5cf2e0bc0f8a/tree", + "GET", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("wg5_building_tree.json"), + ), + ) + # Control data with no scheduleId + control_data = json.loads(load_fixtures("wg5_thermostat_control.json")) + control_data["data"]["scheduleData"] = {} + aresponses.add( + "ojmicroline.test.host", + "/thermostats/2cb3e6c5-8cf8-4e7e-943a-42618c34a506/control", + "GET", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=json.dumps(control_data), + ), + ) + # Detail data with no readout temperatures + detail_data = json.loads(load_fixtures("wg5_thermostat_detail.json")) + del detail_data["data"]["thermostatReadouts"]["floorTemperature"] + del detail_data["data"]["thermostatReadouts"]["roomTemperature"] + aresponses.add( + "ojmicroline.test.host", + "/thermostats/2cb3e6c5-8cf8-4e7e-943a-42618c34a506", + "GET", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=json.dumps(detail_data), + ), + ) + async with aiohttp.ClientSession() as session: + api = _make_api() + client = OJMicroline(api=api, session=session) + + thermostats: list[Thermostat] = await client.get_thermostats() + + assert len(thermostats) == 1 + thermostat = thermostats[0] + assert thermostat.schedule is None + assert thermostat.temperature_floor is None + assert thermostat.temperature_room is None + + +@pytest.mark.asyncio +async def test_get_energy_usage_no_building_id() -> None: + """Test energy usage returns empty list when building_id is missing.""" + data = json.loads(load_fixtures("wg5_thermostat_control.json")) + thermostat = Thermostat.from_wg5_json( + data["data"], + building_id="", + zone_name="Default", + ) + + async with aiohttp.ClientSession() as session: + api = _make_api() + client = OJMicroline(api=api, session=session) + # No login mock needed; should return before making any request + api._access_token = "fake" + api._token_expiry = datetime.now(tz=UTC) + timedelta(hours=1) + + energy = await client.get_energy_usage(thermostat) + assert energy == [] + + @pytest.mark.asyncio async def test_get_energy_usage(aresponses: ResponsesMockServer) -> None: """Test fetching energy usage for a WG5 thermostat.""" diff --git a/tests/test_wg5_update.py b/tests/test_wg5_update.py index 8892529a..c3ed0ab2 100644 --- a/tests/test_wg5_update.py +++ b/tests/test_wg5_update.py @@ -10,9 +10,11 @@ from freezegun import freeze_time from ojmicroline_thermostat import ( OJMicroline, + OJMicrolineError, Thermostat, ) from ojmicroline_thermostat.const import ( + REGULATION_BOOST, REGULATION_COMFORT, REGULATION_FROST_PROTECTION, REGULATION_MANUAL, @@ -130,6 +132,60 @@ async def test_set_regulation_mode_schedule(aresponses: ResponsesMockServer) -> assert result is True +@pytest.mark.asyncio +async def test_set_regulation_mode_exits_standby( + aresponses: ResponsesMockServer, +) -> None: + """Test that setting manual mode exits standby first.""" + _add_login_response(aresponses) + aresponses.add( + "ojmicroline.test.host", + "/thermostats/2cb3e6c5-8cf8-4e7e-943a-42618c34a506/standby/False", + "PUT", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=json.dumps({"data": {"changeTimestamp": "2026-04-14T01:00:00Z"}}), + ), + ) + aresponses.add( + "ojmicroline.test.host", + "/thermostats/2cb3e6c5-8cf8-4e7e-943a-42618c34a506/mode", + "PUT", + Response( + status=200, + headers={"Content-Type": "application/json"}, + text=json.dumps({"data": {"changeTimestamp": "2026-04-14T01:00:00Z"}}), + ), + ) + async with aiohttp.ClientSession() as session: + api = _make_api() + client = OJMicroline(api=api, session=session) + thermostat = _make_thermostat() + thermostat.is_in_standby = True + + result = await client.set_regulation_mode(thermostat, REGULATION_MANUAL, 2600) + assert result is True + + +@pytest.mark.asyncio +async def test_set_regulation_mode_unsupported() -> None: + """Test that an unsupported regulation mode raises OJMicrolineError.""" + async with aiohttp.ClientSession() as session: + api = _make_api() + client = OJMicroline(api=api, session=session) + thermostat = _make_thermostat() + + # Set token to avoid login attempt + from datetime import UTC, datetime, timedelta + + api._access_token = "fake" + api._token_expiry = datetime.now(tz=UTC) + timedelta(hours=1) + + with pytest.raises(OJMicrolineError, match="Unsupported regulation mode"): + await client.set_regulation_mode(thermostat, REGULATION_BOOST) + + @pytest.mark.asyncio async def test_set_regulation_mode_frost_protection( aresponses: ResponsesMockServer, From 5d7980d38048613e708842b5a26654f34d21f8ce Mon Sep 17 00:00:00 2001 From: Hawk Newton Date: Mon, 20 Apr 2026 07:31:04 -0400 Subject: [PATCH 4/4] Fix linting and type checking issues --- ojmicroline_thermostat/wg5.py | 4 +--- tests/test_wg4.py | 2 +- tests/test_wg5_update.py | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/ojmicroline_thermostat/wg5.py b/ojmicroline_thermostat/wg5.py index c0b2c4d4..a83f56d1 100644 --- a/ojmicroline_thermostat/wg5.py +++ b/ojmicroline_thermostat/wg5.py @@ -2,7 +2,6 @@ from __future__ import annotations -from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any @@ -31,12 +30,10 @@ ) -@dataclass class WG5API: """Controls OJ Microline WG5-series thermostats.""" request: RequestFunc - client_id: str = "mobile_app_client" def __init__( self, @@ -59,6 +56,7 @@ def __init__( self.password = password self.host = host self.identity_host = identity_host + self.client_id = "mobile_app_client" self._access_token: str | None = None self._refresh_token: str | None = None self._token_expiry: datetime | None = None diff --git a/tests/test_wg4.py b/tests/test_wg4.py index 254ec746..2c582abe 100644 --- a/tests/test_wg4.py +++ b/tests/test_wg4.py @@ -215,4 +215,4 @@ async def test_parse_energy_usage_response() -> None: username="py", password="test", ) - assert api.parse_energy_usage_response({}) == [] + assert not api.parse_energy_usage_response({}) diff --git a/tests/test_wg5_update.py b/tests/test_wg5_update.py index c3ed0ab2..3094c5e9 100644 --- a/tests/test_wg5_update.py +++ b/tests/test_wg5_update.py @@ -3,6 +3,7 @@ """Test the WG5API update regulation mode methods.""" import json +from datetime import UTC, datetime, timedelta import aiohttp import pytest @@ -177,8 +178,6 @@ async def test_set_regulation_mode_unsupported() -> None: thermostat = _make_thermostat() # Set token to avoid login attempt - from datetime import UTC, datetime, timedelta - api._access_token = "fake" api._token_expiry = datetime.now(tz=UTC) + timedelta(hours=1)