From bc95c9030cc33ffdce6f5c642bd45451f81c0444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20Wickstro=CC=88m?= Date: Wed, 11 Jun 2025 18:01:32 +0300 Subject: [PATCH 1/5] Update/fix readme structure --- README.md | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index eb6442b..44bb4a0 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. @@ -138,6 +142,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 +160,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 +170,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. - From 6041e63a682bf4e10bf52c00a5c0c366516ed94d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20Wickstro=CC=88m?= Date: Wed, 11 Jun 2025 18:05:42 +0300 Subject: [PATCH 2/5] =?UTF-8?q?Fix=20docstring=20type-o=E2=80=99s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- huum/huum.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/huum/huum.py b/huum/huum.py index 99fd050..65c178c 100644 --- a/huum/huum.py +++ b/huum/huum.py @@ -28,7 +28,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 +133,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. From 89a2393ad466400e8f3b935478d304f5f6b09c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20Wickstro=CC=88m?= Date: Wed, 11 Jun 2025 18:09:20 +0300 Subject: [PATCH 3/5] Use new API endpoint This is the same API that is used by the Android application that is used by Huum. It supports the same authentication method as before as well. Some of the response structure has changed with this update and the schemas are therefor changes. The main breaking change is the removal of `max_heating_time` in the reponse (a value that is now found in the `sauna_config`. Note that this minor breaking change should not impact integrations such as Home Assistant, as that value is not used there. --- huum/huum.py | 2 +- huum/schemas.py | 38 ++++++++++++++++++++++++++++++++++++-- tests/test_api.py | 4 ++++ tests/test_huum.py | 2 ++ 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/huum/huum.py b/huum/huum.py index 65c178c..010a31c 100644 --- a/huum/huum.py +++ b/huum/huum.py @@ -14,7 +14,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/" 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..49e6fc6 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) diff --git a/tests/test_huum.py b/tests/test_huum.py index 3cbf8bd..92082fd 100644 --- a/tests/test_huum.py +++ b/tests/test_huum.py @@ -68,6 +68,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 +97,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) From 0ed21c4fd8b3244e968d6a98e89d4d1e62530b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20Wickstro=CC=88m?= Date: Wed, 11 Jun 2025 18:11:13 +0300 Subject: [PATCH 4/5] Remove `status_from_status_or_stop` This is a major breaking change, and will break the Home Assistant integration if code is not updated there. The function is removed as the hack for getting the target temperature is no longer needed due to changes in the Huum API. The API now always returns the correct target temperature without needing to call the `off` endpoint. --- README.md | 15 +-------------- huum/huum.py | 24 ------------------------ tests/test_huum.py | 29 ----------------------------- 3 files changed, 1 insertion(+), 67 deletions(-) diff --git a/README.md b/README.md index 44bb4a0..68e5282 100644 --- a/README.md +++ b/README.md @@ -92,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. diff --git a/huum/huum.py b/huum/huum.py index 010a31c..ffec019 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, @@ -160,29 +159,6 @@ async def status(self) -> HuumStatusResponse: return HuumStatusResponse.from_dict(json_data) - async def status_from_status_or_stop(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. - - 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 - async def open_session(self) -> None: self.session = aiohttp.ClientSession() diff --git a/tests/test_huum.py b/tests/test_huum.py index 92082fd..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: From f316e38b2043ea73d0dcccfcd3163c18202572b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20Wickstro=CC=88m?= Date: Wed, 11 Jun 2025 18:14:06 +0300 Subject: [PATCH 5/5] Add support for toggling the sauna light Note that this has not been tested by me (Frank) but by Age Manning (AgeManning), so a big thanks to him for this change! --- huum/huum.py | 14 ++++++++++++++ tests/test_api.py | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/huum/huum.py b/huum/huum.py index ffec019..f933e99 100644 --- a/huum/huum.py +++ b/huum/huum.py @@ -159,6 +159,20 @@ async def status(self) -> HuumStatusResponse: 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() diff --git a/tests/test_api.py b/tests/test_api.py index 49e6fc6..1c17d17 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -106,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",