diff --git a/README.md b/README.md index eb6442b..68e5282 100644 --- a/README.md +++ b/README.md @@ -14,24 +14,27 @@ Saunas can be dangerous if used without care or without the right security measu ## Installation ### PIP + `pip install huum` ### Poetry + `poetry add huum` ## Quick guide + ```python import asyncio from huum.huum import Huum -async def turn_on_sauna(): +async def turn_on_sauna(): huum = Huum(username="foo", password="bar") - + # If you don't have an existing aiohttp session # then run `open_session()` after initilizing await huum.open_session() - + # Turn on the sauna await huum.turn_on(80) @@ -45,16 +48,16 @@ The `huum` package is fully asynchronous. Supported Python versions: | Python | Supported | -|--------|-----------| -| <= 3.8 | ❌ | +| ------ | --------- | +| <= 3.8 | ❌ | | 3.9 | 🤷 | | 3.10 | 🤷 | -| 3.11 | ✅ | -| 3.12 | ✅ | -| 3.13 | ✅ | - +| 3.11 | ✅ | +| 3.12 | ✅ | +| 3.13 | ✅ | ### Authentication + Authentication uses username + password. The same credentials that you use for logging into the Huum application. **Passing credentials to constructor** @@ -64,6 +67,7 @@ huum = Huum(username=, password=) ``` ### Sessions + You can use the library either with an already existing session or create one yourself. This design decision was created mainly to support Home Assistants (HA) existing sessions and complying with their guidelines. In most cases you will want to create your own session if you are using this outside of HA. @@ -88,25 +92,12 @@ huum.close_session() #### Getting sauna status -The Huum API exposes a status endpoint for getting the current status of the sauna. This will -return the basic information about the sauna. It will however not return all of the info that -the sauna _could_ give you _when the sauna is not heating_. You will however get this info -if you try turning off the sauna again after it is already off. For that reason, this library -exposes two methods of getting the status of the sauna, `status()` and `status_from_status_or_stop()`. -The latter will first call the status endpoint, and if the sauna is off, then call the off endpoint -to get the full status response. - -The main difference, and the main reason to use the latter endpoint, is that `status()` will not -give you the previously set temperature of the sauna is off, while `status_from_status_or_stop()` will. +The Huum API exposes a status endpoint for getting the current status of the sauna. ```python huum.status() ``` -```python -huum.status_from_status_or_stop() -``` - #### Turning on and setting temperature The Huum API does not have a specific endpoint for turning on a setting the temperature. @@ -138,6 +129,7 @@ huum.set_temperature(temperature=80, safety_override=True) ``` #### Turning off the sauna + The sauna can be turned off by calling `turn_off()`. ```python @@ -155,7 +147,7 @@ This library implements custom exceptions for most of its calls. You can find th file. But in short these are the exceptions triggered: | HTTP Status | Exception | -|--------------------|--------------------| +| ------------------ | ------------------ | | 400 | `BadRequest` | | 401 | `NotAuthenticated` | | 403 | `Forbidden` | @@ -165,4 +157,3 @@ All of exceptions triggered by the library inherit from `HuumError`. If the door is open and the sauna is turned on, and the client is not told to explicitly bypass security measures (see "Security concerns" above), then `huum.exceptions.SafetyException` will be raised. - diff --git a/huum/huum.py b/huum/huum.py index 99fd050..f933e99 100644 --- a/huum/huum.py +++ b/huum/huum.py @@ -4,7 +4,6 @@ import aiohttp from aiohttp import ClientResponse -from huum.const import SaunaStatus from huum.exceptions import ( BadRequest, Forbidden, @@ -14,7 +13,7 @@ ) from huum.schemas import HuumStatusResponse -API_BASE = "https://api.huum.eu/action/" +API_BASE = "https://sauna.huum.eu/action/" API_HOME_BASE = f"{API_BASE}/home/" @@ -28,7 +27,7 @@ class Huum: huum = Huum(username="foo", password="bar") # If you don't have an existing aiohttp session - # then run `open_session()` after initilizing + # then run `open_session()` after initializing huum.open_session() # Turn on the sauna @@ -133,7 +132,7 @@ async def set_temperature( """ Alias for turn_on as Huum does not expose an explicit "set_temperature" endpoint - Implementation choice: Yes, aliasing can be done by simply asigning + 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. @@ -160,28 +159,19 @@ async def status(self) -> HuumStatusResponse: return HuumStatusResponse.from_dict(json_data) - async def status_from_status_or_stop(self) -> HuumStatusResponse: + async def toggle_light(self) -> HuumStatusResponse: """ - Get status from the status endpoint or from stop event if that is in option - - The Huum API does not return the target temperature if the sauna - is not heating. Turning off the sauna will give the temperature, - however. So if the sauna is not on, we can get the temperature - set on the thermostat by telling it to turn off. If the sauna is on - we get the target temperature from the status endpoint. - - Why this is not done in the status method is because there is an - additional API call in the case that the status endpoint does not - return target temperature. For this reason the status method is kept - as a pure status request. + Toggles the light/fan on a Sauna Returns: A `HuumStatusResponse` from the Huum API """ - status_response = await self.status() - if status_response.status == SaunaStatus.ONLINE_NOT_HEATING: - status_response = await self.turn_off() - return status_response + 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() diff --git a/huum/schemas.py b/huum/schemas.py index 017ddaf..2d51dc1 100644 --- a/huum/schemas.py +++ b/huum/schemas.py @@ -22,12 +22,34 @@ class HuumSteamerError(DataClassDictMixin): text: str +@dataclass +class SaunaConfig(DataClassDictMixin): + child_lock: str + max_heating_time: int + min_heating_time: int + max_temp: int + min_temp: int + max_timer: int + min_timer: int + + class Config(BaseConfig): + aliases = { + "child_lock": "childLock", + "max_heating_time": "maxHeatingTime", + "min_heating_time": "minHeatingTime", + "max_temp": "maxTemp", + "min_temp": "minTemp", + "max_timer": "maxTimer", + "min_timer": "minTimer", + } + + @dataclass class HuumStatusResponse(DataClassDictMixin): status: int door_closed: bool temperature: int - max_heating_time: int + sauna_name: str target_temperature: int | None = None start_date: int | None = None end_date: int | None = None @@ -35,15 +57,27 @@ class HuumStatusResponse(DataClassDictMixin): config: int | None = None steamer_error: int | None = None payment_end_date: str | None = None + is_private: bool | None = None + show_modal: bool | None = None + light: int | None = None + target_humidity: int | None = None + humidity: int | None = None + remote_safety_state: str | None = None + sauna_config: SaunaConfig | None = None class Config(BaseConfig): aliases = { "status": "statusCode", "door_closed": "door", - "max_heating_time": "maxHeatingTime", "target_temperature": "targetTemperature", "start_date": "startDate", "end_date": "endDate", "steamer_error": "steamerError", "payment_end_date": "paymentEndDate", + "is_private": "isPrivate", + "show_modal": "showModal", + "target_humidity": "targetHumidity", + "remote_safety_state": "remoteSafetyState", + "sauna_config": "saunaConfig", + "sauna_name": "saunaName", } diff --git a/tests/test_api.py b/tests/test_api.py index f16adf4..1c17d17 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -19,6 +19,7 @@ async def test_status_idle(mock_request: Any) -> None: "door": True, "paymentEndDate": None, "temperature": "21", + "saunaName": "test", } expected_result = HuumStatusResponse.from_dict(idle_status_response) mock_request.return_value = MockResponse(idle_status_response, 200) @@ -43,6 +44,7 @@ async def test_status_heating(mock_request: Any) -> None: "startDate": 1631623054, "endDate": 1631633854, "duration": 179, + "saunaName": "test", } expected_result = HuumStatusResponse.from_dict(heating_status_response) mock_request.return_value = MockResponse(heating_status_response, 200) @@ -67,6 +69,7 @@ async def test_heating_stop(mock_request: Any) -> None: "startDate": 1631685790, "endDate": 1631685790, "duration": 0, + "saunaName": "test", } expected_result = HuumStatusResponse.from_dict(heating_stop_response) mock_request.return_value = MockResponse(heating_stop_response, 200) @@ -91,6 +94,7 @@ async def test_heating_start(mock_request: Any) -> None: "startDate": 1631685780, "endDate": 1631696580, "duration": 180, + "saunaName": "test", } expected_result = HuumStatusResponse.from_dict(heating_start_response) mock_request.return_value = MockResponse(heating_start_response, 200) @@ -102,6 +106,32 @@ async def test_heating_start(mock_request: Any) -> None: TestCase().assertDictEqual(response.to_dict(), expected_result.to_dict()) +@pytest.mark.asyncio +@patch("aiohttp.ClientSession._request") +async def test_toggle_light(mock_request: Any) -> None: + toggle_light_response = { + "maxHeatingTime": "3", + "statusCode": 231, + "door": True, + "paymentEndDate": None, + "temperature": "22", + "targetTemperature": "75", + "startDate": 1631685780, + "endDate": 1631696580, + "duration": 180, + "saunaName": "test", + "light": 1, + } + expected_result = HuumStatusResponse.from_dict(toggle_light_response) + mock_request.return_value = MockResponse(toggle_light_response, 200) + + huum = Huum("test", "test") + await huum.open_session() + response = await huum.toggle_light() + + TestCase().assertDictEqual(response.to_dict(), expected_result.to_dict()) + + @pytest.mark.parametrize( ( "status_code", diff --git a/tests/test_huum.py b/tests/test_huum.py index 3cbf8bd..4c07ba7 100644 --- a/tests/test_huum.py +++ b/tests/test_huum.py @@ -7,7 +7,6 @@ from huum.const import SaunaStatus from huum.exceptions import SafetyException from huum.huum import Huum -from huum.schemas import HuumStatusResponse from tests.utils import MockResponse @@ -32,34 +31,6 @@ async def test_no_auth() -> None: Huum() # type: ignore -@pytest.mark.asyncio -@patch("huum.huum.Huum.status") -@patch("huum.huum.Huum.turn_off") -async def test_status_from_status_or_stop(mock_huum_turn_off: Any, mock_huum_status: Any) -> None: - mock_huum_status.return_value = HuumStatusResponse.from_dict( - { - "statusCode": SaunaStatus.ONLINE_NOT_HEATING, - "door": True, - "temperature": 80, - "maxHeatingTime": 1337, - } - ) - mock_huum_turn_off.return_value = HuumStatusResponse.from_dict( - { - "statusCode": SaunaStatus.ONLINE_NOT_HEATING, - "door": True, - "temperature": 90, - "maxHeatingTime": 1337, - } - ) - huum = Huum("test", "test") - await huum.open_session() - - status = await huum.status_from_status_or_stop() - - assert status.temperature == 90 - - @pytest.mark.asyncio @patch("aiohttp.ClientSession._request") async def test_door_open_on_check(mock_request: Any) -> None: @@ -68,6 +39,7 @@ async def test_door_open_on_check(mock_request: Any) -> None: "door": False, "temperature": 80, "maxHeatingTime": 180, + "saunaName": "test", } mock_request.return_value = MockResponse(response, 200) @@ -96,6 +68,7 @@ async def test_set_temperature_turn_on(mock_request: Any) -> None: "door": True, "temperature": 80, "maxHeatingTime": 180, + "saunaName": "test", } mock_request.return_value = MockResponse(response, 200)