"""An API client for the public Roth TouchlineSL API. API Documentation: https://api-documentation.roth-touchlinesl.com/ """ import json import logging from typing import Any import aiohttp from pytouchlinesl.client.models import GlobalScheduleModel from .base import BaseClient from .models import AccountModuleModel, ModuleModel API_URL = "https://roth-touchlinesl.com/api/v1" logger = logging.getLogger(__name__) class RothAPIError(Exception): """An exception raised when an unsuccessful request is made to the Roth API.""" def __init__(self, status: int, data: Any = None): """Construct an instance of the exception.""" super().__init__() self._status = status self._data = data @property def status(self) -> int: """Return the HTTP status code of the failed request.""" return self._status @property def data(self) -> Any: """Return any data included in the response of the failed request.""" return self._data def __repr__(self) -> str: # noqa: D105 return f"{self.__class__.__name__}({self.__str__()})" def __str__(self) -> str: # noqa: D105 msg = f"{self.status}" if self.data is not None: msg += " " + json.dumps(self.data) return msg class RothAPI(BaseClient): """An implementation of the BaseClient which interacts with Roth's public API.""" def __init__(self, username, password): """Construct an instance of the client. Args: username: Roth TouchlineSL account email. password: Roth TouchlineSL account password. """ self._username = username self._password = password self._user_id = None self._token = None async def _login(self): """Login to the Roth API, and store the received bearer token and user id.""" data = {"username": self._username, "password": self._password} resp = await self._post("/authentication", json=data) self._user_id = resp.get("user_id") self._token = resp.get("token") async def user_id(self) -> int: """Return the unique user id for the authenticated account.""" if self._token is None: await self._login() assert isinstance(self._user_id, int) return self._user_id async def authenticated(self) -> bool: """Report whether or not the client is authenticated with the API.""" if self._token is None: return False resp = await self._authed_get("/authentication") return resp.get("authenticated", False) async def modules(self) -> list[AccountModuleModel]: """Return a list of TouchlineSL modules associated with the authenticated account.""" if self._token is None: await self._login() resp = await self._authed_get(f"/users/{self._user_id}/modules") return [AccountModuleModel(**m) for m in resp] async def module(self, module_id: str) -> ModuleModel: """Return a specific module, by ID, that's associated with the authenticated account.""" if self._token is None: await self._login() resp = await self._authed_get(f"/users/{self._user_id}/modules/{module_id}") return ModuleModel(**resp) async def set_zone_temperature( self, module_id: str, mode_id: int, zone_id: int, temperature: float ): """Set a constant target temperature for a specific zone, in a specific module.""" data = { "mode": { "id": mode_id, "parentId": zone_id, "mode": "constantTemp", "constTempTime": 0, "setTemperature": int(temperature * 10), "scheduleIndex": 0, } } await self._authd_post(url=f"/users/{self._user_id}/modules/{module_id}/zones", data=data) async def set_zone_schedule(self, module_id: str, zone_id: int, schedule_id: int): """Assign a specific zone to a specific global schedule in the module's configuration.""" module: ModuleModel = await self.module(module_id=module_id) schedules = module.zones.global_schedules.elements schedule = next((s for s in schedules if s.id == schedule_id), None) assert isinstance(schedule, GlobalScheduleModel) # Get a list of zones which are already assigned to the schedule. # The Roth API is relatively limited, and adding a zone to a schedule means # completely re-specifying the schedule, including intervals and assigned zones. zones_on_schedule = [ z for z in module.zones.elements if (z.mode.mode == "globalSchedule" and z.mode.schedule_index == schedule.index) or z.zone.id == zone_id ] p0_intervals = [ i.model_dump(by_alias=True) for i in schedule.p0_intervals if i.start != 6100 ] p1_intervals = [ i.model_dump(by_alias=True) for i in schedule.p1_intervals if i.start != 6100 ] # TODO: This be simplified with Pydantic models data = { "scheduleName": schedule.name, "setInZones": [{"zoneId": z.zone.id, "modeId": z.mode.id} for z in zones_on_schedule], "schedule": { "id": schedule.id, "index": schedule.index, "p0Days": schedule.p0_days, "p0SetbackTemp": schedule.p0_setback_temp, # Empty intervals start/end with a value of 6100, which is invalid # if sent in this request. "p0Intervals": p0_intervals, "p1Days": schedule.p1_days, "p1SetbackTemp": schedule.p1_setback_temp, "p1Intervals": p1_intervals, }, } await self._authd_post( url=f"/users/{self._user_id}/modules/{module_id}/zones/{zone_id}/global_schedule", data=data, ) async def _authed_get(self, url: str, headers: dict[str, str] = {}) -> dict: """Perform an authenticated GET request to the Roth TouchlineSL API.""" headers.update({"authorization": f"Bearer {self._token}"}) return await self._get(url, headers) async def _get(self, url: str, headers: dict[str, str] = {}) -> dict: """Perform a GET request to the Roth TouchlineSL API.""" logger.debug("GET %s%s", API_URL, url) async with aiohttp.ClientSession() as session: req = await session.get(f"{API_URL}{url}", headers=headers) resp = await req.json() if not req.ok: raise RothAPIError(status=req.status, data=resp) return resp async def _authd_post(self, url: str, headers: dict[str, str] = {}, data: dict = {}) -> dict: """Perform an authenticated POST request to the Roth TouchlineSL API.""" headers.update({"authorization": f"Bearer {self._token}"}) return await self._post(url, headers, data) async def _post(self, url: str, headers: dict[str, str] = {}, json: dict = {}) -> dict: """Perform a POST request to the Roth TouchlineSL API.""" logger.debug("POST %s%s", API_URL, url) async with aiohttp.ClientSession() as session: req = await session.post(f"{API_URL}{url}", headers=headers, json=json) resp = await req.json() if not req.ok: raise RothAPIError(status=req.status, data=resp) return resp