from typing import Any, Optional from urllib.parse import urljoin import aiohttp from aiohttp import ClientResponse from huum.exceptions import ( BadRequest, Forbidden, NotAuthenticated, RequestError, SafetyException, ) from huum.schemas import HuumStatusResponse API_BASE = "https://sauna.huum.eu/action/" API_HOME_BASE = f"{API_BASE}/home/" class Huum: """ Usage: # Usage with env vars huum = Huum() # Setting auth variables explicitly huum = Huum(username="foo", password="bar") # If you don't have an existing aiohttp session # then run `open_session()` after initializing huum.open_session() # Turn on the sauna huum.turn_on(80) """ min_temp = 40 max_temp = 110 session: aiohttp.ClientSession def __init__( self, username: str, password: str, session: Optional[aiohttp.ClientSession] = None, ) -> None: if session: self.session = session self.auth = aiohttp.BasicAuth(username, password) async def _check_door(self) -> None: """ Check if the door is closed, if not, raise an exception """ status = await self.status() if not status.door_closed: raise SafetyException("Can not start sauna when door is open") async def _make_call(self, method: str, url: str, json: Any | None = None) -> ClientResponse: call_args = { "url": url, "auth": self.auth, } if json: call_args["json"] = json call_request = getattr(self.session, method.lower()) response: ClientResponse = await call_request(**call_args) try: response.raise_for_status() except Exception as err: match response.status: case 400: raise BadRequest("Bad request") from err case 401: raise NotAuthenticated("Not authenticated") from err case 403: raise Forbidden("Forbidden") from err raise RequestError() from err return response async def turn_on( self, temperature: int, safety_override: bool = False, humidity: Optional[int] = None ) -> HuumStatusResponse: """ Turns on the sauna at a given temperature and optionally humidity Args: temperature: Target temperature to set the sauna to safety_override: If False, check if door is close before turning on the sauna humidity: Target humidity duty cycle (optional) Returns: A `HuumStatusResponse` from the Huum API """ if temperature not in range(self.min_temp, self.max_temp + 1): raise ValueError( f"Temperature '{temperature}' must be between {self.min_temp}-{self.max_temp}" ) data = {"targetTemperature": temperature} if humidity is not None: if humidity not in range(0, 11): raise ValueError(f"Humidity '{humidity}' must be between 0-10") data["humidity"] = humidity if not safety_override: await self._check_door() url = urljoin(API_HOME_BASE, "start") response = await self._make_call("post", url, json=data) json_data = await response.json() return HuumStatusResponse.from_dict(json_data) async def turn_off(self) -> HuumStatusResponse: """ Turns off the sauna Returns: A `HuumStatusResponse` from the Huum API """ url = urljoin(API_HOME_BASE, "stop") response = await self._make_call("post", url) json_data = await response.json() return HuumStatusResponse.from_dict(json_data) async def set_temperature( self, temperature: int, safety_override: bool = False ) -> HuumStatusResponse: """ Alias for turn_on as Huum does not expose an explicit "set_temperature" endpoint Implementation choice: Yes, aliasing can be done by simply assigning set_temperature = turn_on, however this will not create documentation, makes the code harder to read and is generally seen as non-pythonic. Args: temperature: Target temperature to set the sauna to safety_override: If False, check if door is close before turning on the sauna Returns: A `HuumStatusResponse` from the Huum API """ return await self.turn_on(temperature, safety_override) async def status(self) -> HuumStatusResponse: """ Get the status of the Sauna Returns: A `HuumStatusResponse` from the Huum API """ url = urljoin(API_HOME_BASE, "status") response = await self._make_call("get", url) json_data = await response.json() return HuumStatusResponse.from_dict(json_data) async def toggle_light(self) -> HuumStatusResponse: """ Toggles the light/fan on a Sauna Returns: A `HuumStatusResponse` from the Huum API """ url = urljoin(API_HOME_BASE, "light") response = await self._make_call("get", url) json_data = await response.json() return HuumStatusResponse.from_dict(json_data) async def open_session(self) -> None: self.session = aiohttp.ClientSession() async def close_session(self) -> None: await self.session.close()