homesmartlink 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- homesmartlink-0.1.0/.gitignore +15 -0
- homesmartlink-0.1.0/LICENSE +21 -0
- homesmartlink-0.1.0/PKG-INFO +177 -0
- homesmartlink-0.1.0/README.md +160 -0
- homesmartlink-0.1.0/homesmartlink/__init__.py +46 -0
- homesmartlink-0.1.0/homesmartlink/client.py +237 -0
- homesmartlink-0.1.0/homesmartlink/const.py +25 -0
- homesmartlink-0.1.0/homesmartlink/exceptions.py +31 -0
- homesmartlink-0.1.0/homesmartlink/frame.py +129 -0
- homesmartlink-0.1.0/homesmartlink/models.py +274 -0
- homesmartlink-0.1.0/homesmartlink/py.typed +0 -0
- homesmartlink-0.1.0/homesmartlink/status.py +44 -0
- homesmartlink-0.1.0/pyproject.toml +70 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michaël Fery
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: homesmartlink
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async client for the Home-SmartLink (Carrera) heating cloud API
|
|
5
|
+
Project-URL: Source, https://github.com/michaelfery/home-smartlink-ha
|
|
6
|
+
Author-email: Michaël Fery <fery.michael@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Topic :: Home Automation
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.13
|
|
15
|
+
Requires-Dist: aiohttp>=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Home-SmartLink for Home Assistant
|
|
19
|
+
|
|
20
|
+
Custom integration for **Carrera Home-SmartLink** pilot-wire thermostats (fil pilote
|
|
21
|
+
adapters for electric heaters), through the Home-SmartLink cloud used by the mobile app.
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
thermostats ──(BLE mesh)── Home-SmartLink box ──(Wi-Fi)── Home-SmartLink cloud ── Home Assistant
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
> [!WARNING]
|
|
28
|
+
> **Unofficial.** Not affiliated with or endorsed by Carrera or Texas de France. The cloud
|
|
29
|
+
> API is undocumented. It was reverse-engineered from the Android app (see
|
|
30
|
+
> [docs/API.md](docs/API.md)) and may change without notice.
|
|
31
|
+
|
|
32
|
+
This repository contains two parts:
|
|
33
|
+
|
|
34
|
+
- [`custom_components/home_smartlink`](custom_components/home_smartlink): the Home Assistant
|
|
35
|
+
integration (installable with HACS);
|
|
36
|
+
- [`homesmartlink`](homesmartlink): an async Python client for the cloud API, with no Home
|
|
37
|
+
Assistant dependency, published on PyPI.
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
For each room with a thermostat:
|
|
42
|
+
|
|
43
|
+
| Entity | Content |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `climate.<room>_thermostat` | Mode, presets, target temperature, measured temperature |
|
|
46
|
+
| `sensor.<room>_thermostat_temperature` | Measured temperature |
|
|
47
|
+
|
|
48
|
+
For the box (diagnostic entities): last connection to the cloud, and time of the last
|
|
49
|
+
thermostat status update.
|
|
50
|
+
|
|
51
|
+
### Modes
|
|
52
|
+
|
|
53
|
+
| Home Assistant | Home-SmartLink app |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `heat` | Manual (**M**) at the target temperature |
|
|
56
|
+
| `auto` | Program (clock), the weekly program set in the app |
|
|
57
|
+
| `off` | Stop (power button) |
|
|
58
|
+
| preset `comfort` | Manual at the room's comfort temperature (sun button) |
|
|
59
|
+
| preset `eco` | Manual at the room's eco temperature (moon button) |
|
|
60
|
+
| preset `away` | Antifreeze (snowflake) |
|
|
61
|
+
|
|
62
|
+
Target temperature: 13.5 to 30 °C, in 0.5 °C steps (the app's range). Setting a temperature
|
|
63
|
+
switches the room to Manual, like the app's dial.
|
|
64
|
+
|
|
65
|
+
## Requirements
|
|
66
|
+
|
|
67
|
+
- Home Assistant **2026.10** or later.
|
|
68
|
+
- A Home-SmartLink box connected to the cloud, and the e-mail and password of the mobile app.
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
### HACS (recommended)
|
|
73
|
+
|
|
74
|
+
1. HACS → ⋮ → **Custom repositories** → add `https://github.com/michaelfery/home-smartlink-ha`,
|
|
75
|
+
category **Integration**.
|
|
76
|
+
2. Install **Home-SmartLink**, then restart Home Assistant.
|
|
77
|
+
|
|
78
|
+
### Manual
|
|
79
|
+
|
|
80
|
+
Copy `custom_components/home_smartlink` into the `custom_components` folder of your Home
|
|
81
|
+
Assistant configuration, then restart Home Assistant.
|
|
82
|
+
|
|
83
|
+
## Configuration
|
|
84
|
+
|
|
85
|
+
**Settings → Devices & services → Add integration → Home-SmartLink**, then sign in with the
|
|
86
|
+
app's e-mail and password. If the account has access to several homes, choose one; add the
|
|
87
|
+
integration again for each other home.
|
|
88
|
+
|
|
89
|
+
Options (**Configure** on the integration):
|
|
90
|
+
|
|
91
|
+
| Option | Default | Range | Effect |
|
|
92
|
+
|---|---|---|---|
|
|
93
|
+
| Polling interval | 60 s | 30–3600 s | How often modes and set points are read from the cloud |
|
|
94
|
+
| Diagnosis interval | 15 min | 0 (off), 5–120 min | How often the box is asked to poll the thermostats, which refreshes measured temperatures |
|
|
95
|
+
|
|
96
|
+
If the password changes, Home Assistant asks you to sign in again (re-authentication).
|
|
97
|
+
|
|
98
|
+
## How data is refreshed
|
|
99
|
+
|
|
100
|
+
- **Modes and set points** are read from the cloud at every poll. Commands are shown
|
|
101
|
+
immediately, then confirmed by a refresh.
|
|
102
|
+
- **Measured temperatures** are only reported by the thermostats during a *diagnosis*.
|
|
103
|
+
The app runs one each time it opens. The integration runs one at start-up and then at the
|
|
104
|
+
diagnosis interval. Results arrive about 1–2 minutes after the request, so a measured
|
|
105
|
+
temperature can be up to the diagnosis interval (+2 min) old. The
|
|
106
|
+
*Thermostat status update* sensor shows when it was collected.
|
|
107
|
+
- When the cloud cannot be reached, polling backs off (doubling, up to 10 minutes) and
|
|
108
|
+
returns to normal at the first success.
|
|
109
|
+
|
|
110
|
+
## Known limitations
|
|
111
|
+
|
|
112
|
+
- **Cloud only.** No local control: the box and thermostats are only reachable through
|
|
113
|
+
the vendor cloud.
|
|
114
|
+
- **No refresh token.** The API has none, so the integration stores your app password in
|
|
115
|
+
the Home Assistant configuration and signs in again when the session expires.
|
|
116
|
+
- **Measured temperatures are not live** (see above).
|
|
117
|
+
- **Whole-document writes.** Each command saves the whole home document (that is what the
|
|
118
|
+
app does). A change made in the app at the same moment can be overwritten.
|
|
119
|
+
- **In `auto`, the target temperature is not shown.** The API does not say which program
|
|
120
|
+
slot (comfort/eco) is active.
|
|
121
|
+
- **Antifreeze temperature is not shown.** The device value is not exposed.
|
|
122
|
+
- **Not supported:** editing the weekly program, eco/comfort temperatures, vacation mode,
|
|
123
|
+
geofencing, ecopilot, open-window detection, consumption counters. Use the app for these.
|
|
124
|
+
- Program mode on a room relies on a frame rule checked on 3 rooms
|
|
125
|
+
([docs/API.md](docs/API.md) §5.1). Rooms with more than 9 program events are refused
|
|
126
|
+
rather than guessed.
|
|
127
|
+
- Error responses of the cloud (wrong password, expired session, box offline) were never
|
|
128
|
+
observed. They are handled generically.
|
|
129
|
+
|
|
130
|
+
## Privacy and security
|
|
131
|
+
|
|
132
|
+
- Credentials are stored in the Home Assistant config entry (like any cloud integration).
|
|
133
|
+
They are only sent to the Home-SmartLink cloud.
|
|
134
|
+
- Neither the password nor the session token is ever logged.
|
|
135
|
+
- Raw traffic captures used to study the API are never committed. Test fixtures are
|
|
136
|
+
anonymized, and a test fails if one contains personal data.
|
|
137
|
+
|
|
138
|
+
## Python library
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
import aiohttp
|
|
142
|
+
from homesmartlink import HeaterMode, HomeSmartLinkClient
|
|
143
|
+
|
|
144
|
+
async with aiohttp.ClientSession() as session:
|
|
145
|
+
client = HomeSmartLinkClient(session, "email", "password")
|
|
146
|
+
home_id = (await client.login())[0].home_id
|
|
147
|
+
home = await client.get_home(home_id)
|
|
148
|
+
status = await client.get_device_status(home_id) # measured temperatures
|
|
149
|
+
await client.set_room_state(home_id, room_number=0, mode=HeaterMode.MANUAL, manual_value=19.5)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Development
|
|
153
|
+
|
|
154
|
+
Requires [uv](https://docs.astral.sh/uv/). Home Assistant 2026.10 needs Python 3.14.
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
uv sync --all-groups
|
|
158
|
+
uv run ruff check . && uv run ruff format --check . && uv run mypy
|
|
159
|
+
uv run pytest # client tests
|
|
160
|
+
uv run pytest -o addopts= tests/components # integration tests (Home Assistant plugin)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Fixtures in `tests/fixtures/` are generated from local captures (kept out of git) with
|
|
164
|
+
`scripts/build_fixtures.py`, which anonymizes them and refuses to write if anything leaks.
|
|
165
|
+
`scripts/har_extract.py` turns captures into readable, anonymized JSON for analysis.
|
|
166
|
+
|
|
167
|
+
### Release
|
|
168
|
+
|
|
169
|
+
1. Bump the version in `pyproject.toml`, `homesmartlink/__init__.py` and
|
|
170
|
+
`custom_components/home_smartlink/manifest.json` (version and requirement).
|
|
171
|
+
2. Tag `vX.Y.Z` and push the tag. The release workflow checks the versions and publishes
|
|
172
|
+
`homesmartlink` to PyPI (Trusted Publishing).
|
|
173
|
+
3. Create a GitHub release for HACS.
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Home-SmartLink for Home Assistant
|
|
2
|
+
|
|
3
|
+
Custom integration for **Carrera Home-SmartLink** pilot-wire thermostats (fil pilote
|
|
4
|
+
adapters for electric heaters), through the Home-SmartLink cloud used by the mobile app.
|
|
5
|
+
|
|
6
|
+
```text
|
|
7
|
+
thermostats ──(BLE mesh)── Home-SmartLink box ──(Wi-Fi)── Home-SmartLink cloud ── Home Assistant
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
> [!WARNING]
|
|
11
|
+
> **Unofficial.** Not affiliated with or endorsed by Carrera or Texas de France. The cloud
|
|
12
|
+
> API is undocumented. It was reverse-engineered from the Android app (see
|
|
13
|
+
> [docs/API.md](docs/API.md)) and may change without notice.
|
|
14
|
+
|
|
15
|
+
This repository contains two parts:
|
|
16
|
+
|
|
17
|
+
- [`custom_components/home_smartlink`](custom_components/home_smartlink): the Home Assistant
|
|
18
|
+
integration (installable with HACS);
|
|
19
|
+
- [`homesmartlink`](homesmartlink): an async Python client for the cloud API, with no Home
|
|
20
|
+
Assistant dependency, published on PyPI.
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
For each room with a thermostat:
|
|
25
|
+
|
|
26
|
+
| Entity | Content |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `climate.<room>_thermostat` | Mode, presets, target temperature, measured temperature |
|
|
29
|
+
| `sensor.<room>_thermostat_temperature` | Measured temperature |
|
|
30
|
+
|
|
31
|
+
For the box (diagnostic entities): last connection to the cloud, and time of the last
|
|
32
|
+
thermostat status update.
|
|
33
|
+
|
|
34
|
+
### Modes
|
|
35
|
+
|
|
36
|
+
| Home Assistant | Home-SmartLink app |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `heat` | Manual (**M**) at the target temperature |
|
|
39
|
+
| `auto` | Program (clock), the weekly program set in the app |
|
|
40
|
+
| `off` | Stop (power button) |
|
|
41
|
+
| preset `comfort` | Manual at the room's comfort temperature (sun button) |
|
|
42
|
+
| preset `eco` | Manual at the room's eco temperature (moon button) |
|
|
43
|
+
| preset `away` | Antifreeze (snowflake) |
|
|
44
|
+
|
|
45
|
+
Target temperature: 13.5 to 30 °C, in 0.5 °C steps (the app's range). Setting a temperature
|
|
46
|
+
switches the room to Manual, like the app's dial.
|
|
47
|
+
|
|
48
|
+
## Requirements
|
|
49
|
+
|
|
50
|
+
- Home Assistant **2026.10** or later.
|
|
51
|
+
- A Home-SmartLink box connected to the cloud, and the e-mail and password of the mobile app.
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
### HACS (recommended)
|
|
56
|
+
|
|
57
|
+
1. HACS → ⋮ → **Custom repositories** → add `https://github.com/michaelfery/home-smartlink-ha`,
|
|
58
|
+
category **Integration**.
|
|
59
|
+
2. Install **Home-SmartLink**, then restart Home Assistant.
|
|
60
|
+
|
|
61
|
+
### Manual
|
|
62
|
+
|
|
63
|
+
Copy `custom_components/home_smartlink` into the `custom_components` folder of your Home
|
|
64
|
+
Assistant configuration, then restart Home Assistant.
|
|
65
|
+
|
|
66
|
+
## Configuration
|
|
67
|
+
|
|
68
|
+
**Settings → Devices & services → Add integration → Home-SmartLink**, then sign in with the
|
|
69
|
+
app's e-mail and password. If the account has access to several homes, choose one; add the
|
|
70
|
+
integration again for each other home.
|
|
71
|
+
|
|
72
|
+
Options (**Configure** on the integration):
|
|
73
|
+
|
|
74
|
+
| Option | Default | Range | Effect |
|
|
75
|
+
|---|---|---|---|
|
|
76
|
+
| Polling interval | 60 s | 30–3600 s | How often modes and set points are read from the cloud |
|
|
77
|
+
| Diagnosis interval | 15 min | 0 (off), 5–120 min | How often the box is asked to poll the thermostats, which refreshes measured temperatures |
|
|
78
|
+
|
|
79
|
+
If the password changes, Home Assistant asks you to sign in again (re-authentication).
|
|
80
|
+
|
|
81
|
+
## How data is refreshed
|
|
82
|
+
|
|
83
|
+
- **Modes and set points** are read from the cloud at every poll. Commands are shown
|
|
84
|
+
immediately, then confirmed by a refresh.
|
|
85
|
+
- **Measured temperatures** are only reported by the thermostats during a *diagnosis*.
|
|
86
|
+
The app runs one each time it opens. The integration runs one at start-up and then at the
|
|
87
|
+
diagnosis interval. Results arrive about 1–2 minutes after the request, so a measured
|
|
88
|
+
temperature can be up to the diagnosis interval (+2 min) old. The
|
|
89
|
+
*Thermostat status update* sensor shows when it was collected.
|
|
90
|
+
- When the cloud cannot be reached, polling backs off (doubling, up to 10 minutes) and
|
|
91
|
+
returns to normal at the first success.
|
|
92
|
+
|
|
93
|
+
## Known limitations
|
|
94
|
+
|
|
95
|
+
- **Cloud only.** No local control: the box and thermostats are only reachable through
|
|
96
|
+
the vendor cloud.
|
|
97
|
+
- **No refresh token.** The API has none, so the integration stores your app password in
|
|
98
|
+
the Home Assistant configuration and signs in again when the session expires.
|
|
99
|
+
- **Measured temperatures are not live** (see above).
|
|
100
|
+
- **Whole-document writes.** Each command saves the whole home document (that is what the
|
|
101
|
+
app does). A change made in the app at the same moment can be overwritten.
|
|
102
|
+
- **In `auto`, the target temperature is not shown.** The API does not say which program
|
|
103
|
+
slot (comfort/eco) is active.
|
|
104
|
+
- **Antifreeze temperature is not shown.** The device value is not exposed.
|
|
105
|
+
- **Not supported:** editing the weekly program, eco/comfort temperatures, vacation mode,
|
|
106
|
+
geofencing, ecopilot, open-window detection, consumption counters. Use the app for these.
|
|
107
|
+
- Program mode on a room relies on a frame rule checked on 3 rooms
|
|
108
|
+
([docs/API.md](docs/API.md) §5.1). Rooms with more than 9 program events are refused
|
|
109
|
+
rather than guessed.
|
|
110
|
+
- Error responses of the cloud (wrong password, expired session, box offline) were never
|
|
111
|
+
observed. They are handled generically.
|
|
112
|
+
|
|
113
|
+
## Privacy and security
|
|
114
|
+
|
|
115
|
+
- Credentials are stored in the Home Assistant config entry (like any cloud integration).
|
|
116
|
+
They are only sent to the Home-SmartLink cloud.
|
|
117
|
+
- Neither the password nor the session token is ever logged.
|
|
118
|
+
- Raw traffic captures used to study the API are never committed. Test fixtures are
|
|
119
|
+
anonymized, and a test fails if one contains personal data.
|
|
120
|
+
|
|
121
|
+
## Python library
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
import aiohttp
|
|
125
|
+
from homesmartlink import HeaterMode, HomeSmartLinkClient
|
|
126
|
+
|
|
127
|
+
async with aiohttp.ClientSession() as session:
|
|
128
|
+
client = HomeSmartLinkClient(session, "email", "password")
|
|
129
|
+
home_id = (await client.login())[0].home_id
|
|
130
|
+
home = await client.get_home(home_id)
|
|
131
|
+
status = await client.get_device_status(home_id) # measured temperatures
|
|
132
|
+
await client.set_room_state(home_id, room_number=0, mode=HeaterMode.MANUAL, manual_value=19.5)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
Requires [uv](https://docs.astral.sh/uv/). Home Assistant 2026.10 needs Python 3.14.
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
uv sync --all-groups
|
|
141
|
+
uv run ruff check . && uv run ruff format --check . && uv run mypy
|
|
142
|
+
uv run pytest # client tests
|
|
143
|
+
uv run pytest -o addopts= tests/components # integration tests (Home Assistant plugin)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Fixtures in `tests/fixtures/` are generated from local captures (kept out of git) with
|
|
147
|
+
`scripts/build_fixtures.py`, which anonymizes them and refuses to write if anything leaks.
|
|
148
|
+
`scripts/har_extract.py` turns captures into readable, anonymized JSON for analysis.
|
|
149
|
+
|
|
150
|
+
### Release
|
|
151
|
+
|
|
152
|
+
1. Bump the version in `pyproject.toml`, `homesmartlink/__init__.py` and
|
|
153
|
+
`custom_components/home_smartlink/manifest.json` (version and requirement).
|
|
154
|
+
2. Tag `vX.Y.Z` and push the tag. The release workflow checks the versions and publishes
|
|
155
|
+
`homesmartlink` to PyPI (Trusted Publishing).
|
|
156
|
+
3. Create a GitHub release for HACS.
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Async client for the Home-SmartLink (Carrera) heating cloud API."""
|
|
2
|
+
|
|
3
|
+
from .client import HomeSmartLinkClient
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
CannotConnectError,
|
|
7
|
+
CommandFailedError,
|
|
8
|
+
HomeSmartLinkError,
|
|
9
|
+
RateLimitError,
|
|
10
|
+
UnsupportedCommandError,
|
|
11
|
+
)
|
|
12
|
+
from .models import (
|
|
13
|
+
Accessory,
|
|
14
|
+
DeviceStatus,
|
|
15
|
+
Diagnosis,
|
|
16
|
+
Gateway,
|
|
17
|
+
HeaterMode,
|
|
18
|
+
HeaterSettings,
|
|
19
|
+
Home,
|
|
20
|
+
Invitation,
|
|
21
|
+
ProgramEvent,
|
|
22
|
+
Room,
|
|
23
|
+
RoomStatus,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Accessory",
|
|
28
|
+
"AuthenticationError",
|
|
29
|
+
"CannotConnectError",
|
|
30
|
+
"CommandFailedError",
|
|
31
|
+
"DeviceStatus",
|
|
32
|
+
"Diagnosis",
|
|
33
|
+
"Gateway",
|
|
34
|
+
"HeaterMode",
|
|
35
|
+
"HeaterSettings",
|
|
36
|
+
"Home",
|
|
37
|
+
"HomeSmartLinkClient",
|
|
38
|
+
"HomeSmartLinkError",
|
|
39
|
+
"Invitation",
|
|
40
|
+
"ProgramEvent",
|
|
41
|
+
"RateLimitError",
|
|
42
|
+
"Room",
|
|
43
|
+
"RoomStatus",
|
|
44
|
+
"UnsupportedCommandError",
|
|
45
|
+
]
|
|
46
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Async client for the Home-SmartLink cloud API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
|
|
11
|
+
from .const import APP_AGENT, APP_VERSION, BASE_URL, DEFAULT_TIMEOUT, SESSION_HEADER
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
CannotConnectError,
|
|
15
|
+
CommandFailedError,
|
|
16
|
+
HomeSmartLinkError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
)
|
|
19
|
+
from .frame import build_tests, describe, encode_action_b64, validate_set_point
|
|
20
|
+
from .models import (
|
|
21
|
+
DeviceStatus,
|
|
22
|
+
Diagnosis,
|
|
23
|
+
Gateway,
|
|
24
|
+
HeaterMode,
|
|
25
|
+
HeaterSettings,
|
|
26
|
+
Home,
|
|
27
|
+
Invitation,
|
|
28
|
+
)
|
|
29
|
+
from .status import parse_device_status
|
|
30
|
+
|
|
31
|
+
_LOGGER = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
_AUTH_FAILURE_STATUSES = frozenset({401, 403})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HomeSmartLinkClient:
|
|
37
|
+
"""Client for one Home-SmartLink account.
|
|
38
|
+
|
|
39
|
+
The API has no refresh token: when the session token is rejected, the client
|
|
40
|
+
signs in again with the stored credentials (once per request).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
session: aiohttp.ClientSession,
|
|
46
|
+
email: str,
|
|
47
|
+
password: str,
|
|
48
|
+
*,
|
|
49
|
+
base_url: str = BASE_URL,
|
|
50
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._session = session
|
|
53
|
+
self._email = email
|
|
54
|
+
self._password = password
|
|
55
|
+
self._base_url = base_url
|
|
56
|
+
self._timeout = aiohttp.ClientTimeout(total=timeout)
|
|
57
|
+
self._token: str | None = None
|
|
58
|
+
self._login_lock = asyncio.Lock()
|
|
59
|
+
self._write_lock = asyncio.Lock()
|
|
60
|
+
|
|
61
|
+
def __repr__(self) -> str:
|
|
62
|
+
# Never expose credentials or the session token.
|
|
63
|
+
return f"{type(self).__name__}(authenticated={self._token is not None})"
|
|
64
|
+
|
|
65
|
+
# -- authentication ----------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
async def login(self) -> list[Invitation]:
|
|
68
|
+
"""Sign in and return the homes the account can access."""
|
|
69
|
+
async with self._login_lock:
|
|
70
|
+
return await self._login()
|
|
71
|
+
|
|
72
|
+
async def _login(self) -> list[Invitation]:
|
|
73
|
+
data = await self._request(
|
|
74
|
+
"POST",
|
|
75
|
+
"api/1/signin",
|
|
76
|
+
json_body={
|
|
77
|
+
"email": self._email,
|
|
78
|
+
"password": self._password,
|
|
79
|
+
"agent": APP_AGENT,
|
|
80
|
+
"version": APP_VERSION,
|
|
81
|
+
},
|
|
82
|
+
authenticated=False,
|
|
83
|
+
)
|
|
84
|
+
token = data.get("token") if isinstance(data, dict) else None
|
|
85
|
+
if not isinstance(token, str) or not token:
|
|
86
|
+
raise AuthenticationError("Sign-in response did not contain a session token")
|
|
87
|
+
self._token = token
|
|
88
|
+
_LOGGER.debug("Signed in to Home-SmartLink")
|
|
89
|
+
invitation = data.get("invitation")
|
|
90
|
+
return [Invitation.from_dict(invitation)] if isinstance(invitation, dict) else []
|
|
91
|
+
|
|
92
|
+
async def _renew_session(self, rejected_token: str | None) -> None:
|
|
93
|
+
async with self._login_lock:
|
|
94
|
+
# Another request may already have renewed the session.
|
|
95
|
+
if self._token is None or self._token == rejected_token:
|
|
96
|
+
self._token = None
|
|
97
|
+
await self._login()
|
|
98
|
+
|
|
99
|
+
# -- read --------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
async def get_invitations(self) -> list[Invitation]:
|
|
102
|
+
data = await self._request("GET", "api/1/auth", params={"version": str(APP_VERSION)})
|
|
103
|
+
return [Invitation.from_dict(i) for i in data.get("invitations", [])]
|
|
104
|
+
|
|
105
|
+
async def get_home(self, home_id: str) -> Home:
|
|
106
|
+
return Home.from_dict(await self._request("GET", f"api/1/homes/{home_id}"))
|
|
107
|
+
|
|
108
|
+
async def get_gateway(self, home_id: str) -> Gateway:
|
|
109
|
+
return Gateway.from_dict(await self._request("GET", f"api/1/device/{home_id}"))
|
|
110
|
+
|
|
111
|
+
async def get_device_status(self, home_id: str) -> DeviceStatus:
|
|
112
|
+
"""Thermostat status (incl. ambient temperature) from the last diagnosis."""
|
|
113
|
+
return parse_device_status(await self._request("GET", f"api/1/deviceStatus/{home_id}"))
|
|
114
|
+
|
|
115
|
+
async def request_diagnosis(self, home_id: str) -> str:
|
|
116
|
+
"""Ask the box to poll every thermostat; returns the diagnosis id."""
|
|
117
|
+
data = await self._request("POST", "api/1/diagnosys", json_body={"homeId": home_id})
|
|
118
|
+
_check_ok(data, "diagnosys")
|
|
119
|
+
return str(data["id"])
|
|
120
|
+
|
|
121
|
+
async def get_diagnosis(self, diagnosis_id: str) -> Diagnosis:
|
|
122
|
+
return Diagnosis.from_dict(await self._request("GET", f"api/1/diagnosys/{diagnosis_id}"))
|
|
123
|
+
|
|
124
|
+
# -- write -------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
async def set_room_state(
|
|
127
|
+
self,
|
|
128
|
+
home_id: str,
|
|
129
|
+
room_number: int,
|
|
130
|
+
mode: HeaterMode,
|
|
131
|
+
manual_value: float | None = None,
|
|
132
|
+
) -> Home:
|
|
133
|
+
"""Change the mode (and optionally the manual set point) of one room.
|
|
134
|
+
|
|
135
|
+
Mirrors the app: re-read the home, send the command frame to the box,
|
|
136
|
+
then save the whole home document. Returns the home as written.
|
|
137
|
+
"""
|
|
138
|
+
async with self._write_lock:
|
|
139
|
+
home = await self.get_home(home_id)
|
|
140
|
+
current = home.room(room_number).heater_settings
|
|
141
|
+
new_manual = current.manual_value if manual_value is None else manual_value
|
|
142
|
+
if manual_value is not None:
|
|
143
|
+
validate_set_point(new_manual, current.limitation_set_point)
|
|
144
|
+
settings = HeaterSettings(
|
|
145
|
+
current_mode=mode,
|
|
146
|
+
manual_value=new_manual,
|
|
147
|
+
economy_value=current.economy_value,
|
|
148
|
+
standard_value=current.standard_value,
|
|
149
|
+
limitation_set_point=current.limitation_set_point,
|
|
150
|
+
)
|
|
151
|
+
updated = home.with_room_settings(room_number, settings)
|
|
152
|
+
room = updated.room(room_number)
|
|
153
|
+
|
|
154
|
+
notify = {
|
|
155
|
+
"action": encode_action_b64(room),
|
|
156
|
+
"description": describe(room),
|
|
157
|
+
"homeId": home_id,
|
|
158
|
+
"tests": build_tests(updated.rooms),
|
|
159
|
+
}
|
|
160
|
+
_check_ok(
|
|
161
|
+
await self._request("POST", "api/1/device/notify", json_body=notify), "notify"
|
|
162
|
+
)
|
|
163
|
+
_check_ok(
|
|
164
|
+
await self._request("PUT", "api/1/homes/", json_body=updated.to_update_payload()),
|
|
165
|
+
"home update",
|
|
166
|
+
)
|
|
167
|
+
return updated
|
|
168
|
+
|
|
169
|
+
# -- transport ---------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
async def _request(
|
|
172
|
+
self,
|
|
173
|
+
method: str,
|
|
174
|
+
path: str,
|
|
175
|
+
*,
|
|
176
|
+
json_body: dict[str, Any] | None = None,
|
|
177
|
+
params: dict[str, str] | None = None,
|
|
178
|
+
authenticated: bool = True,
|
|
179
|
+
) -> Any:
|
|
180
|
+
if authenticated and self._token is None:
|
|
181
|
+
await self.login()
|
|
182
|
+
|
|
183
|
+
for attempt in (1, 2):
|
|
184
|
+
token = self._token
|
|
185
|
+
headers = {SESSION_HEADER: token} if authenticated and token else {}
|
|
186
|
+
try:
|
|
187
|
+
async with self._session.request(
|
|
188
|
+
method,
|
|
189
|
+
self._base_url + path,
|
|
190
|
+
json=json_body,
|
|
191
|
+
params=params,
|
|
192
|
+
headers=headers,
|
|
193
|
+
timeout=self._timeout,
|
|
194
|
+
) as resp:
|
|
195
|
+
if resp.status in _AUTH_FAILURE_STATUSES:
|
|
196
|
+
if authenticated and attempt == 1:
|
|
197
|
+
_LOGGER.debug(
|
|
198
|
+
"Session rejected (HTTP %s), signing in again", resp.status
|
|
199
|
+
)
|
|
200
|
+
await self._renew_session(token)
|
|
201
|
+
continue
|
|
202
|
+
raise AuthenticationError(f"{method} {path}: HTTP {resp.status}")
|
|
203
|
+
_raise_for_status(resp, method, path, authenticated)
|
|
204
|
+
return await resp.json(content_type=None)
|
|
205
|
+
except TimeoutError as err:
|
|
206
|
+
raise CannotConnectError(f"{method} {path}: timeout") from err
|
|
207
|
+
except aiohttp.ClientError as err:
|
|
208
|
+
raise CannotConnectError(f"{method} {path}: {type(err).__name__}") from err
|
|
209
|
+
except ValueError as err:
|
|
210
|
+
raise HomeSmartLinkError(f"{method} {path}: invalid JSON") from err
|
|
211
|
+
raise AuthenticationError(f"{method} {path}: session rejected after sign-in")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _raise_for_status(
|
|
215
|
+
resp: aiohttp.ClientResponse, method: str, path: str, authenticated: bool
|
|
216
|
+
) -> None:
|
|
217
|
+
status = resp.status
|
|
218
|
+
if status < 400:
|
|
219
|
+
return
|
|
220
|
+
if status == 429:
|
|
221
|
+
retry_after: float | None
|
|
222
|
+
try:
|
|
223
|
+
retry_after = float(resp.headers.get("Retry-After", ""))
|
|
224
|
+
except ValueError:
|
|
225
|
+
retry_after = None
|
|
226
|
+
raise RateLimitError(f"{method} {path}: rate limited", retry_after)
|
|
227
|
+
if status >= 500:
|
|
228
|
+
raise CannotConnectError(f"{method} {path}: HTTP {status}")
|
|
229
|
+
if not authenticated:
|
|
230
|
+
# Sign-in failures (wrong credentials were never captured: any 4xx).
|
|
231
|
+
raise AuthenticationError(f"{method} {path}: HTTP {status}")
|
|
232
|
+
raise HomeSmartLinkError(f"{method} {path}: HTTP {status}")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _check_ok(data: Any, what: str) -> None:
|
|
236
|
+
if not isinstance(data, dict) or data.get("status") != "OK":
|
|
237
|
+
raise CommandFailedError(f"Unexpected {what} response")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Constants of the Home-SmartLink cloud API (see docs/API.md)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
BASE_URL: Final = "https://jcqzjjt6q5.execute-api.eu-west-3.amazonaws.com/Prod/"
|
|
8
|
+
|
|
9
|
+
# Values the official Android app sends; the cloud may rely on them.
|
|
10
|
+
APP_AGENT: Final = "Droid"
|
|
11
|
+
APP_VERSION: Final = 103
|
|
12
|
+
|
|
13
|
+
SESSION_HEADER: Final = "x-session"
|
|
14
|
+
DEFAULT_TIMEOUT: Final = 20.0
|
|
15
|
+
|
|
16
|
+
# Set point range accepted by the official app dial (capture 19), in °C.
|
|
17
|
+
MIN_SET_POINT: Final = 13.5
|
|
18
|
+
MAX_SET_POINT: Final = 30.0
|
|
19
|
+
SET_POINT_STEP: Final = 0.5
|
|
20
|
+
|
|
21
|
+
# Set point carried by Stop and Antifreeze frames, whatever the room settings.
|
|
22
|
+
FROST_FRAME_SET_POINT: Final = 14.0
|
|
23
|
+
|
|
24
|
+
# Longest program the 64-byte frame was observed with.
|
|
25
|
+
MAX_PROGRAM_EVENTS: Final = 9
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Exceptions raised by the Home-SmartLink client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HomeSmartLinkError(Exception):
|
|
7
|
+
"""Base class for every error raised by this package."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthenticationError(HomeSmartLinkError):
|
|
11
|
+
"""Credentials were rejected, or the session could not be renewed."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CannotConnectError(HomeSmartLinkError):
|
|
15
|
+
"""The cloud could not be reached or answered with a server error."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RateLimitError(HomeSmartLinkError):
|
|
19
|
+
"""The cloud asked us to slow down (HTTP 429)."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, retry_after: float | None = None) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.retry_after = retry_after
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UnsupportedCommandError(HomeSmartLinkError):
|
|
27
|
+
"""The requested state cannot be encoded safely (outside observed behavior)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CommandFailedError(HomeSmartLinkError):
|
|
31
|
+
"""The cloud accepted the request but did not report success."""
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Encoding of `device/notify` commands (docs/API.md §5).
|
|
2
|
+
|
|
3
|
+
Every rule here was derived from captured frames; anything outside what was
|
|
4
|
+
observed raises UnsupportedCommandError instead of guessing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
|
|
12
|
+
from .const import (
|
|
13
|
+
FROST_FRAME_SET_POINT,
|
|
14
|
+
MAX_PROGRAM_EVENTS,
|
|
15
|
+
MAX_SET_POINT,
|
|
16
|
+
MIN_SET_POINT,
|
|
17
|
+
SET_POINT_STEP,
|
|
18
|
+
)
|
|
19
|
+
from .exceptions import UnsupportedCommandError
|
|
20
|
+
from .models import HeaterMode, ProgramEvent, Room
|
|
21
|
+
|
|
22
|
+
FRAME_LENGTH = 64
|
|
23
|
+
|
|
24
|
+
_MODE_NAMES = {
|
|
25
|
+
HeaterMode.STOP: "Stop",
|
|
26
|
+
HeaterMode.ANTIFREEZE: "Antifreeze",
|
|
27
|
+
HeaterMode.MANUAL: "Manual",
|
|
28
|
+
HeaterMode.PROGRAM: "Prog",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_TESTS_SUFFIX = "A[0-9]{1,3}J[0-9]{1,3}W[0-9]{1,3}M[0-9]{1,3}Y[0-9]{1,3}"
|
|
32
|
+
# First alternative of C(..|..) in Manual mode; only ever observed as 26.
|
|
33
|
+
_TESTS_MANUAL_LOW = 26
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_set_point(value: float, limit: float = MAX_SET_POINT) -> float:
|
|
37
|
+
"""Check a set point against the range and step the app allows."""
|
|
38
|
+
upper = min(MAX_SET_POINT, limit)
|
|
39
|
+
if not MIN_SET_POINT <= value <= upper:
|
|
40
|
+
raise UnsupportedCommandError(f"Set point {value} outside {MIN_SET_POINT}-{upper}")
|
|
41
|
+
if (value / SET_POINT_STEP) % 1:
|
|
42
|
+
raise UnsupportedCommandError(f"Set point {value} is not a multiple of {SET_POINT_STEP}")
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _mode(room: Room) -> HeaterMode:
|
|
47
|
+
mode = room.heater_settings.current_mode
|
|
48
|
+
if not isinstance(mode, HeaterMode):
|
|
49
|
+
raise UnsupportedCommandError(f"Unknown heater mode {mode}")
|
|
50
|
+
return mode
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _encode_set_point(value: float) -> bytes:
|
|
54
|
+
return (((round(value * 2)) << 3) | 0b011).to_bytes(2, "little")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _encode_event(event: ProgramEvent) -> bytes:
|
|
58
|
+
fields = (event.event_id, event.recurrence, event.mode)
|
|
59
|
+
if not (0 <= event.event_id < 16 and 0 <= event.recurrence < 128 and 0 <= event.mode < 8):
|
|
60
|
+
raise UnsupportedCommandError(f"Program event fields out of range: {fields}")
|
|
61
|
+
start = event.start_hour * 6 + event.start_minute // 10
|
|
62
|
+
end = event.end_hour * 6 + event.end_minute // 10
|
|
63
|
+
if not (0 <= start <= 144 and 0 <= end <= 144):
|
|
64
|
+
raise UnsupportedCommandError(f"Program event time out of range: {fields}")
|
|
65
|
+
return bytes([0x80 | event.event_id << 3 | event.mode, 0x80 | event.recurrence, start, end])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def encode_action(room: Room) -> bytes:
|
|
69
|
+
"""Build the 64-byte frame that puts `room` in its `heater_settings` state."""
|
|
70
|
+
settings = room.heater_settings
|
|
71
|
+
mode = _mode(room)
|
|
72
|
+
if len(room.events) > MAX_PROGRAM_EVENTS:
|
|
73
|
+
raise UnsupportedCommandError(f"Rooms with {len(room.events)} program events")
|
|
74
|
+
if not 0 <= room.number < 256:
|
|
75
|
+
raise UnsupportedCommandError(f"Room number {room.number}")
|
|
76
|
+
|
|
77
|
+
if mode in (HeaterMode.STOP, HeaterMode.ANTIFREEZE):
|
|
78
|
+
set_point = FROST_FRAME_SET_POINT
|
|
79
|
+
state = 0x10 if mode is HeaterMode.STOP else 0x50
|
|
80
|
+
flags = 0x40
|
|
81
|
+
elif mode is HeaterMode.MANUAL:
|
|
82
|
+
set_point = validate_set_point(settings.manual_value, settings.limitation_set_point)
|
|
83
|
+
state, flags = 0xD0, 0x40
|
|
84
|
+
else: # PROGRAM
|
|
85
|
+
set_point = validate_set_point(settings.standard_value, settings.limitation_set_point)
|
|
86
|
+
state = 0xD0 if room.ecopilot_enabled else 0x10
|
|
87
|
+
flags = 0xE1
|
|
88
|
+
|
|
89
|
+
frame = bytearray([0x01, state, flags])
|
|
90
|
+
frame += _encode_set_point(set_point)
|
|
91
|
+
frame += bytes([room.number, 0, 0])
|
|
92
|
+
for event in room.events:
|
|
93
|
+
frame += _encode_event(event)
|
|
94
|
+
frame.append(room.number)
|
|
95
|
+
return bytes(frame.ljust(FRAME_LENGTH, b"\x00"))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def encode_action_b64(room: Room) -> str:
|
|
99
|
+
return base64.b64encode(encode_action(room)).decode("ascii")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def describe(room: Room) -> str:
|
|
103
|
+
"""Human-readable `description`, formatted like the app's."""
|
|
104
|
+
settings = room.heater_settings
|
|
105
|
+
return (
|
|
106
|
+
f"cockpit room {room.number} => mode {_MODE_NAMES[_mode(room)]}, "
|
|
107
|
+
f"manuelSP:{settings.manual_value:g})"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _room_tests(room: Room) -> str:
|
|
112
|
+
settings = room.heater_settings
|
|
113
|
+
mode = _mode(room)
|
|
114
|
+
if mode is HeaterMode.STOP:
|
|
115
|
+
device_mode, set_points = 0, "([0-9]{2})"
|
|
116
|
+
elif mode is HeaterMode.ANTIFREEZE:
|
|
117
|
+
device_mode, set_points = 1, "([0-9]{2})"
|
|
118
|
+
elif mode is HeaterMode.MANUAL:
|
|
119
|
+
device_mode = 3
|
|
120
|
+
set_points = f"({_TESTS_MANUAL_LOW}|{round(settings.manual_value * 2)})"
|
|
121
|
+
else:
|
|
122
|
+
device_mode = 7 if room.ecopilot_enabled else 4
|
|
123
|
+
set_points = f"({round(settings.economy_value * 2)}|{round(settings.standard_value * 2)})"
|
|
124
|
+
return f"R{room.number}D[0-9]M{device_mode}S[0-9]C{set_points}{_TESTS_SUFFIX}"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_tests(rooms: Iterable[Room]) -> str:
|
|
128
|
+
"""Expected status patterns of every room, as sent by the app with each command."""
|
|
129
|
+
return " ".join(_room_tests(room) for room in rooms)
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Typed views over the Home-SmartLink API payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from enum import IntEnum
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HeaterMode(IntEnum):
|
|
13
|
+
"""`heaterSettings.currentMode` values written by the app."""
|
|
14
|
+
|
|
15
|
+
STOP = 1
|
|
16
|
+
ANTIFREEZE = 2
|
|
17
|
+
MANUAL = 3
|
|
18
|
+
PROGRAM = 4
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_datetime(value: Any) -> datetime | None:
|
|
22
|
+
if not isinstance(value, str) or not value:
|
|
23
|
+
return None
|
|
24
|
+
try:
|
|
25
|
+
return datetime.fromisoformat(value)
|
|
26
|
+
except ValueError:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Invitation:
|
|
32
|
+
"""Access of the account to one home (`signin` / `auth` responses)."""
|
|
33
|
+
|
|
34
|
+
id: str
|
|
35
|
+
home_id: str
|
|
36
|
+
role: int
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_dict(cls, data: dict[str, Any]) -> Invitation:
|
|
40
|
+
return cls(id=str(data["_id"]), home_id=str(data["homeid"]), role=int(data["role"]))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class HeaterSettings:
|
|
45
|
+
"""Heating configuration of one room."""
|
|
46
|
+
|
|
47
|
+
current_mode: HeaterMode | int
|
|
48
|
+
manual_value: float
|
|
49
|
+
economy_value: float
|
|
50
|
+
standard_value: float
|
|
51
|
+
limitation_set_point: float
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_dict(cls, data: dict[str, Any]) -> HeaterSettings:
|
|
55
|
+
raw_mode = int(data["currentMode"])
|
|
56
|
+
try:
|
|
57
|
+
mode: HeaterMode | int = HeaterMode(raw_mode)
|
|
58
|
+
except ValueError:
|
|
59
|
+
mode = raw_mode
|
|
60
|
+
return cls(
|
|
61
|
+
current_mode=mode,
|
|
62
|
+
manual_value=float(data["manualValue"]),
|
|
63
|
+
economy_value=float(data["economyValue"]),
|
|
64
|
+
standard_value=float(data["standardValue"]),
|
|
65
|
+
limitation_set_point=float(data["limitationSetPoint"]),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict[str, Any]:
|
|
69
|
+
return {
|
|
70
|
+
"manualValue": self.manual_value,
|
|
71
|
+
"economyValue": self.economy_value,
|
|
72
|
+
"standardValue": self.standard_value,
|
|
73
|
+
"currentMode": int(self.current_mode),
|
|
74
|
+
"limitationSetPoint": self.limitation_set_point,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class ProgramEvent:
|
|
80
|
+
"""One slot of the weekly program of a room."""
|
|
81
|
+
|
|
82
|
+
event_id: int
|
|
83
|
+
recurrence: int
|
|
84
|
+
mode: int
|
|
85
|
+
start_hour: int
|
|
86
|
+
start_minute: int
|
|
87
|
+
end_hour: int
|
|
88
|
+
end_minute: int
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_dict(cls, data: dict[str, Any]) -> ProgramEvent:
|
|
92
|
+
return cls(
|
|
93
|
+
event_id=int(data["eventID"]),
|
|
94
|
+
recurrence=int(data["reccurency"]),
|
|
95
|
+
mode=int(data["mode"]),
|
|
96
|
+
start_hour=int(data["startHour"]),
|
|
97
|
+
start_minute=int(data["startMinute"]),
|
|
98
|
+
end_hour=int(data["endHour"]),
|
|
99
|
+
end_minute=int(data["endMinute"]),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class Accessory:
|
|
105
|
+
"""A device paired to a room (category 5 = thermostat)."""
|
|
106
|
+
|
|
107
|
+
id: str
|
|
108
|
+
category: int
|
|
109
|
+
software_version: str | None
|
|
110
|
+
created_at: datetime | None
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_dict(cls, data: dict[str, Any]) -> Accessory:
|
|
114
|
+
version = data.get("softwareVersion")
|
|
115
|
+
return cls(
|
|
116
|
+
id=str(data["header"]),
|
|
117
|
+
category=int(data["category"]),
|
|
118
|
+
software_version=None if version is None else str(version),
|
|
119
|
+
created_at=_parse_datetime(data.get("creationDate")),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True, slots=True)
|
|
124
|
+
class Room:
|
|
125
|
+
"""A room and its heating configuration."""
|
|
126
|
+
|
|
127
|
+
number: int
|
|
128
|
+
name: str
|
|
129
|
+
area: int | None
|
|
130
|
+
ecopilot_enabled: bool
|
|
131
|
+
open_window_detection_enabled: bool
|
|
132
|
+
accessories: tuple[Accessory, ...]
|
|
133
|
+
heater_settings: HeaterSettings
|
|
134
|
+
events: tuple[ProgramEvent, ...]
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_dict(cls, data: dict[str, Any]) -> Room:
|
|
138
|
+
area = data.get("area")
|
|
139
|
+
return cls(
|
|
140
|
+
number=int(data["roomNumber"]),
|
|
141
|
+
name=str(data.get("name", "")),
|
|
142
|
+
area=None if area is None else int(area),
|
|
143
|
+
ecopilot_enabled=bool(data.get("ecopilotEnabled", False)),
|
|
144
|
+
open_window_detection_enabled=bool(data.get("openWindowsDetectionEnabled", False)),
|
|
145
|
+
accessories=tuple(Accessory.from_dict(a) for a in data.get("accessories", [])),
|
|
146
|
+
heater_settings=HeaterSettings.from_dict(data["heaterSettings"]),
|
|
147
|
+
events=tuple(ProgramEvent.from_dict(e) for e in data.get("events", [])),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def with_settings(self, settings: HeaterSettings) -> Room:
|
|
151
|
+
return replace(self, heater_settings=settings)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True, slots=True)
|
|
155
|
+
class Home:
|
|
156
|
+
"""A home document (`GET homes/{id}`).
|
|
157
|
+
|
|
158
|
+
The raw document is kept because writes must send the whole document back
|
|
159
|
+
(`PUT homes/`), including fields this package does not model.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
id: str
|
|
163
|
+
has_gateway: bool
|
|
164
|
+
rooms: tuple[Room, ...]
|
|
165
|
+
raw: dict[str, Any]
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def from_dict(cls, data: dict[str, Any]) -> Home:
|
|
169
|
+
return cls(
|
|
170
|
+
id=str(data["_id"]),
|
|
171
|
+
has_gateway=bool(data.get("hasGateway", False)),
|
|
172
|
+
rooms=tuple(Room.from_dict(r) for r in data.get("rooms", [])),
|
|
173
|
+
raw=copy.deepcopy(data),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def room(self, number: int) -> Room:
|
|
177
|
+
for room in self.rooms:
|
|
178
|
+
if room.number == number:
|
|
179
|
+
return room
|
|
180
|
+
raise KeyError(f"No room {number} in home")
|
|
181
|
+
|
|
182
|
+
def with_room_settings(self, number: int, settings: HeaterSettings) -> Home:
|
|
183
|
+
"""Return a copy of the home where one room has new heater settings."""
|
|
184
|
+
raw = copy.deepcopy(self.raw)
|
|
185
|
+
for room in raw.get("rooms", []):
|
|
186
|
+
if int(room["roomNumber"]) == number:
|
|
187
|
+
room["heaterSettings"] = {**room["heaterSettings"], **settings.to_dict()}
|
|
188
|
+
break
|
|
189
|
+
else:
|
|
190
|
+
raise KeyError(f"No room {number} in home")
|
|
191
|
+
return Home.from_dict(raw)
|
|
192
|
+
|
|
193
|
+
def to_update_payload(self) -> dict[str, Any]:
|
|
194
|
+
"""Body of `PUT homes/`: the document without server-managed timestamps."""
|
|
195
|
+
payload = copy.deepcopy(self.raw)
|
|
196
|
+
payload.pop("_created_at", None)
|
|
197
|
+
payload.pop("_updated_at", None)
|
|
198
|
+
return payload
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True, slots=True)
|
|
202
|
+
class Gateway:
|
|
203
|
+
"""The Home-SmartLink box (`GET device/{homeId}`)."""
|
|
204
|
+
|
|
205
|
+
home_id: str
|
|
206
|
+
identifier: str
|
|
207
|
+
version: str | None
|
|
208
|
+
installed_at: datetime | None
|
|
209
|
+
last_connection: datetime | None
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def from_dict(cls, data: dict[str, Any]) -> Gateway:
|
|
213
|
+
version = data.get("version")
|
|
214
|
+
return cls(
|
|
215
|
+
home_id=str(data["homeId"]),
|
|
216
|
+
identifier=str(data["identifier"]),
|
|
217
|
+
version=None if version is None else str(version),
|
|
218
|
+
installed_at=_parse_datetime(data.get("installation")),
|
|
219
|
+
last_connection=_parse_datetime(data.get("lastConnection")),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(frozen=True, slots=True)
|
|
224
|
+
class RoomStatus:
|
|
225
|
+
"""One status string reported by a thermostat during a diagnosis."""
|
|
226
|
+
|
|
227
|
+
room_number: int
|
|
228
|
+
device_mode: int
|
|
229
|
+
set_point: float
|
|
230
|
+
ambient_temperature: float
|
|
231
|
+
raw: str
|
|
232
|
+
unknown_d: int
|
|
233
|
+
unknown_s: int
|
|
234
|
+
counters: tuple[int, int, int, int]
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def heater_mode(self) -> HeaterMode | None:
|
|
238
|
+
"""Map the device mode code (0, 1, 3, 4, 7) to the app mode."""
|
|
239
|
+
return DEVICE_MODE_TO_HEATER_MODE.get(self.device_mode)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# Device mode codes seen in status strings and `tests` (docs/API.md §5.2).
|
|
243
|
+
DEVICE_MODE_TO_HEATER_MODE: dict[int, HeaterMode] = {
|
|
244
|
+
0: HeaterMode.STOP,
|
|
245
|
+
1: HeaterMode.ANTIFREEZE,
|
|
246
|
+
3: HeaterMode.MANUAL,
|
|
247
|
+
4: HeaterMode.PROGRAM,
|
|
248
|
+
7: HeaterMode.PROGRAM,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(frozen=True, slots=True)
|
|
253
|
+
class DeviceStatus:
|
|
254
|
+
"""Result of the last diagnosis (`GET deviceStatus/{homeId}`)."""
|
|
255
|
+
|
|
256
|
+
created_at: datetime | None
|
|
257
|
+
rooms: dict[int, RoomStatus]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@dataclass(frozen=True, slots=True)
|
|
261
|
+
class Diagnosis:
|
|
262
|
+
"""A diagnosis request (`GET diagnosys/{id}`)."""
|
|
263
|
+
|
|
264
|
+
id: str
|
|
265
|
+
acknowledged: bool
|
|
266
|
+
updated_at: datetime | None
|
|
267
|
+
|
|
268
|
+
@classmethod
|
|
269
|
+
def from_dict(cls, data: dict[str, Any]) -> Diagnosis:
|
|
270
|
+
return cls(
|
|
271
|
+
id=str(data["_id"]),
|
|
272
|
+
acknowledged=bool(data.get("acknowledge", False)),
|
|
273
|
+
updated_at=_parse_datetime(data.get("_updated_at")),
|
|
274
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Parsing of thermostat status strings (docs/API.md, deviceStatus)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .models import DeviceStatus, RoomStatus, _parse_datetime
|
|
9
|
+
|
|
10
|
+
# Fields are positional: the letter M appears twice (mode, then a counter).
|
|
11
|
+
_STATUS_RE = re.compile(
|
|
12
|
+
r"^R(?P<room>\d+)D(?P<d>\d+)M(?P<mode>\d+)S(?P<s>\d+)C(?P<c>\d+)A(?P<a>\d+)"
|
|
13
|
+
r"J(?P<j>\d+)W(?P<w>\d+)M(?P<m>\d+)Y(?P<y>\d+)$"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_room_status(value: str) -> RoomStatus | None:
|
|
18
|
+
"""Parse one status string, e.g. ``R0D0M3S0C38A42J0W0M0Y0``.
|
|
19
|
+
|
|
20
|
+
Returns None for strings that do not follow the observed format.
|
|
21
|
+
"""
|
|
22
|
+
match = _STATUS_RE.match(value.strip())
|
|
23
|
+
if match is None:
|
|
24
|
+
return None
|
|
25
|
+
g = {k: int(v) for k, v in match.groupdict().items()}
|
|
26
|
+
return RoomStatus(
|
|
27
|
+
room_number=g["room"],
|
|
28
|
+
device_mode=g["mode"],
|
|
29
|
+
set_point=g["c"] / 2,
|
|
30
|
+
ambient_temperature=g["a"] / 2,
|
|
31
|
+
raw=value,
|
|
32
|
+
unknown_d=g["d"],
|
|
33
|
+
unknown_s=g["s"],
|
|
34
|
+
counters=(g["j"], g["w"], g["m"], g["y"]),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_device_status(data: dict[str, Any]) -> DeviceStatus:
|
|
39
|
+
"""Parse a `GET deviceStatus/{homeId}` response, skipping unknown entries."""
|
|
40
|
+
rooms: dict[int, RoomStatus] = {}
|
|
41
|
+
for value in data.get("status") or []:
|
|
42
|
+
if isinstance(value, str) and (status := parse_room_status(value)) is not None:
|
|
43
|
+
rooms[status.room_number] = status
|
|
44
|
+
return DeviceStatus(created_at=_parse_datetime(data.get("_created_at")), rooms=rooms)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "homesmartlink"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async client for the Home-SmartLink (Carrera) heating cloud API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Michaël Fery", email = "fery.michael@gmail.com" }]
|
|
14
|
+
dependencies = ["aiohttp>=3.10"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Framework :: AsyncIO",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: Home Automation",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Source = "https://github.com/michaelfery/home-smartlink-ha"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"mypy>=1.11",
|
|
29
|
+
"pytest>=8.3",
|
|
30
|
+
"pytest-asyncio>=0.24",
|
|
31
|
+
"ruff>=0.6",
|
|
32
|
+
]
|
|
33
|
+
ha = [
|
|
34
|
+
"pytest-homeassistant-custom-component==0.13.373; python_full_version >= '3.14.2'",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["homesmartlink"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.sdist]
|
|
41
|
+
include = ["homesmartlink", "README.md", "LICENSE"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
asyncio_mode = "auto"
|
|
45
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
46
|
+
# Client tests run without the Home Assistant plugin (it blocks sockets);
|
|
47
|
+
# integration tests: pytest -o addopts= tests/components
|
|
48
|
+
addopts = "-p no:homeassistant"
|
|
49
|
+
testpaths = ["tests/client"]
|
|
50
|
+
|
|
51
|
+
[tool.mypy]
|
|
52
|
+
python_version = "3.13"
|
|
53
|
+
strict = true
|
|
54
|
+
files = ["homesmartlink", "custom_components"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
target-version = "py313"
|
|
58
|
+
line-length = 100
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF", "ASYNC"]
|
|
62
|
+
|
|
63
|
+
[[tool.mypy.overrides]]
|
|
64
|
+
# Home Assistant re-exports its constants implicitly; core integrations rely on it.
|
|
65
|
+
module = "homeassistant.*"
|
|
66
|
+
implicit_reexport = true
|
|
67
|
+
|
|
68
|
+
[tool.ruff.lint.per-file-ignores]
|
|
69
|
+
# Home Assistant entities declare their _attr_* lists at class level.
|
|
70
|
+
"custom_components/**" = ["RUF012"]
|