Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 16 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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**
Expand All @@ -64,6 +67,7 @@ huum = Huum(username=<username>, password=<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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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` |
Expand All @@ -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.

32 changes: 11 additions & 21 deletions huum/huum.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import aiohttp
from aiohttp import ClientResponse

from huum.const import SaunaStatus
from huum.exceptions import (
BadRequest,
Forbidden,
Expand All @@ -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/"


Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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()
Expand Down
38 changes: 36 additions & 2 deletions huum/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,28 +22,62 @@ 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
duration: int | None = None
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",
}
30 changes: 30 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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",
Expand Down
31 changes: 2 additions & 29 deletions tests/test_huum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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:
Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand Down