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.
@@ -0,0 +1,15 @@
1
+ # Raw traffic captures and secrets: never commit
2
+ captures/
3
+ *.har
4
+ .mitmproxy/
5
+
6
+ # Python
7
+ .venv/
8
+ __pycache__/
9
+ *.pyc
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ .ruff_cache/
13
+
14
+ # Build output
15
+ dist/
@@ -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"]