pyheatlink 0.1.0__py3-none-any.whl

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.
pyheatlink/__init__.py ADDED
@@ -0,0 +1,76 @@
1
+ """Async client for HeatLink, the local controller for Mitsubishi mini-split heads."""
2
+
3
+ from .client import HeatLinkClient
4
+ from .const import Feature, Mode, Power, Scope
5
+ from .errors import (
6
+ HeatLinkAuthError,
7
+ HeatLinkConflictError,
8
+ HeatLinkConnectionError,
9
+ HeatLinkError,
10
+ HeatLinkNotFoundError,
11
+ HeatLinkRateLimitError,
12
+ HeatLinkResponseError,
13
+ HeatLinkScopeError,
14
+ HeatLinkUnsupportedError,
15
+ )
16
+ from .models import (
17
+ Capabilities,
18
+ Event,
19
+ EventLog,
20
+ Group,
21
+ GroupMember,
22
+ HouseSummary,
23
+ Info,
24
+ OtaStatus,
25
+ ParkConfig,
26
+ ParkStatus,
27
+ RemoteTemp,
28
+ RemoteTempLease,
29
+ RoomSensor,
30
+ Sensors,
31
+ Settings,
32
+ Status,
33
+ Tandem,
34
+ TandemMember,
35
+ Ticket,
36
+ TokenGrant,
37
+ UpdateInfo,
38
+ )
39
+
40
+ __all__ = [
41
+ "Capabilities",
42
+ "Event",
43
+ "EventLog",
44
+ "Feature",
45
+ "Group",
46
+ "GroupMember",
47
+ "HeatLinkAuthError",
48
+ "HeatLinkClient",
49
+ "HeatLinkConflictError",
50
+ "HeatLinkConnectionError",
51
+ "HeatLinkError",
52
+ "HeatLinkNotFoundError",
53
+ "HeatLinkRateLimitError",
54
+ "HeatLinkResponseError",
55
+ "HeatLinkScopeError",
56
+ "HeatLinkUnsupportedError",
57
+ "HouseSummary",
58
+ "Info",
59
+ "Mode",
60
+ "OtaStatus",
61
+ "ParkConfig",
62
+ "ParkStatus",
63
+ "Power",
64
+ "RemoteTemp",
65
+ "RemoteTempLease",
66
+ "RoomSensor",
67
+ "Scope",
68
+ "Sensors",
69
+ "Settings",
70
+ "Status",
71
+ "Tandem",
72
+ "TandemMember",
73
+ "Ticket",
74
+ "TokenGrant",
75
+ "UpdateInfo",
76
+ ]
pyheatlink/__main__.py ADDED
@@ -0,0 +1,87 @@
1
+ """Small command-line tool: ``pyheatlink HOST [--token T] COMMAND``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import dataclasses
8
+ import json
9
+ import os
10
+ import sys
11
+ from typing import Any
12
+
13
+ from .client import HeatLinkClient
14
+ from .errors import HeatLinkError
15
+
16
+ _READS = {
17
+ "info": "get_info",
18
+ "summary": "get_house_summary",
19
+ "settings": "get_settings",
20
+ "status": "get_status",
21
+ "capabilities": "get_capabilities",
22
+ "park": "get_park",
23
+ "group": "get_group",
24
+ "sensors": "get_sensors",
25
+ "events": "get_events",
26
+ "update": "get_update",
27
+ "ota": "get_ota_status",
28
+ }
29
+
30
+
31
+ def _dump(obj: Any) -> str:
32
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
33
+ raw = getattr(obj, "raw", None)
34
+ obj = raw if raw else dataclasses.asdict(obj)
35
+ return json.dumps(obj, indent=2, default=list, ensure_ascii=False)
36
+
37
+
38
+ async def _run(args: argparse.Namespace) -> int:
39
+ async with HeatLinkClient(args.host, token=args.token, timeout=args.timeout) as client:
40
+ if args.command in _READS:
41
+ result = await getattr(client, _READS[args.command])()
42
+ elif args.command == "set":
43
+ await client.set_climate(
44
+ power=None if args.power is None else args.power == "on",
45
+ mode=args.mode,
46
+ temperature=args.temp,
47
+ fan=args.fan,
48
+ )
49
+ result = await client.get_settings()
50
+ elif args.command == "remote-temp":
51
+ result = (
52
+ await client.clear_remote_temp()
53
+ if args.value == "clear"
54
+ else await client.set_remote_temp(float(args.value), args.ttl)
55
+ )
56
+ else: # pragma: no cover - argparse rejects anything else
57
+ return 2
58
+ print(_dump(result))
59
+ return 0
60
+
61
+
62
+ def main(argv: list[str] | None = None) -> int:
63
+ p = argparse.ArgumentParser(prog="pyheatlink", description=__doc__)
64
+ p.add_argument("host")
65
+ p.add_argument("--token", default=os.environ.get("HEATLINK_TOKEN"))
66
+ p.add_argument("--timeout", type=float, default=10.0)
67
+ sub = p.add_subparsers(dest="command", required=True)
68
+ for name in _READS:
69
+ sub.add_parser(name)
70
+ s = sub.add_parser("set")
71
+ s.add_argument("--power", choices=["on", "off"])
72
+ s.add_argument("--mode")
73
+ s.add_argument("--temp", type=float)
74
+ s.add_argument("--fan")
75
+ r = sub.add_parser("remote-temp")
76
+ r.add_argument("value", help="degrees C, or 'clear'")
77
+ r.add_argument("--ttl", type=int, default=600)
78
+ args = p.parse_args(argv)
79
+ try:
80
+ return asyncio.run(_run(args))
81
+ except (HeatLinkError, ValueError) as err:
82
+ print(f"error: {err}", file=sys.stderr)
83
+ return 1
84
+
85
+
86
+ if __name__ == "__main__":
87
+ sys.exit(main())
pyheatlink/client.py ADDED
@@ -0,0 +1,374 @@
1
+ """Async HTTP client for one HeatLink head."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from collections.abc import Mapping
8
+ from types import TracebackType
9
+ from typing import Any, Self
10
+
11
+ import aiohttp
12
+
13
+ from .const import (
14
+ DEFAULT_PORT,
15
+ DEFAULT_TIMEOUT,
16
+ DIAG_COUNTERS,
17
+ REMOTE_TEMP_TTL_DEFAULT,
18
+ REMOTE_TEMP_TTL_MAX,
19
+ REMOTE_TEMP_TTL_MIN,
20
+ )
21
+ from .errors import (
22
+ HeatLinkAuthError,
23
+ HeatLinkConflictError,
24
+ HeatLinkConnectionError,
25
+ HeatLinkNotFoundError,
26
+ HeatLinkRateLimitError,
27
+ HeatLinkResponseError,
28
+ HeatLinkScopeError,
29
+ HeatLinkUnsupportedError,
30
+ )
31
+ from .models import (
32
+ Capabilities,
33
+ EventLog,
34
+ Group,
35
+ HouseSummary,
36
+ Info,
37
+ OtaStatus,
38
+ ParkConfig,
39
+ RemoteTempLease,
40
+ Sensors,
41
+ Settings,
42
+ Status,
43
+ Tandem,
44
+ Ticket,
45
+ TokenGrant,
46
+ UpdateInfo,
47
+ )
48
+
49
+ _STATUS_ERRORS: dict[int, type[HeatLinkResponseError]] = {
50
+ 401: HeatLinkAuthError,
51
+ 403: HeatLinkScopeError,
52
+ 404: HeatLinkNotFoundError,
53
+ 409: HeatLinkConflictError,
54
+ 429: HeatLinkRateLimitError,
55
+ }
56
+
57
+
58
+ def _base_url(host: str, port: int) -> str:
59
+ host = host.strip().rstrip("/")
60
+ if "://" in host:
61
+ return host
62
+ if ":" in host and not host.startswith("["):
63
+ if host.count(":") > 1:
64
+ host = f"[{host}]"
65
+ else:
66
+ return f"http://{host}"
67
+ return f"http://{host}" if port == DEFAULT_PORT else f"http://{host}:{port}"
68
+
69
+
70
+ def _drop_none(**kwargs: Any) -> dict[str, Any]:
71
+ return {k: v for k, v in kwargs.items() if v is not None}
72
+
73
+
74
+ class HeatLinkClient:
75
+ """Talks to one head over its local HTTP API.
76
+
77
+ Calls on one client never overlap (the firmware's HTTP server is small),
78
+ and each has a hard timeout. Pass a shared ``aiohttp.ClientSession``
79
+ (Home Assistant's, for instance); otherwise the client makes its own and
80
+ closes it in :meth:`close`.
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ host: str,
86
+ session: aiohttp.ClientSession | None = None,
87
+ *,
88
+ token: str | None = None,
89
+ port: int = DEFAULT_PORT,
90
+ timeout: float = DEFAULT_TIMEOUT,
91
+ ) -> None:
92
+ self.host = host
93
+ self.token = token
94
+ self._base = _base_url(host, port)
95
+ self._session = session
96
+ self._own_session = session is None
97
+ self._timeout = aiohttp.ClientTimeout(total=timeout)
98
+ self._lock = asyncio.Lock()
99
+
100
+ async def __aenter__(self) -> Self:
101
+ return self
102
+
103
+ async def __aexit__(
104
+ self,
105
+ exc_type: type[BaseException] | None,
106
+ exc: BaseException | None,
107
+ tb: TracebackType | None,
108
+ ) -> None:
109
+ await self.close()
110
+
111
+ async def close(self) -> None:
112
+ if self._own_session and self._session is not None:
113
+ await self._session.close()
114
+ self._session = None
115
+
116
+ @property
117
+ def base_url(self) -> str:
118
+ return self._base
119
+
120
+ # ── transport ──────────────────────────────────────────────────────
121
+
122
+ async def request(
123
+ self,
124
+ method: str,
125
+ path: str,
126
+ *,
127
+ body: Mapping[str, Any] | None = None,
128
+ params: Mapping[str, Any] | None = None,
129
+ auth: bool = True,
130
+ ) -> Any:
131
+ """Send one request and return the decoded JSON body (or ``None``)."""
132
+ if self._session is None:
133
+ self._session = aiohttp.ClientSession()
134
+ self._own_session = True
135
+ headers = {"Accept": "application/json"}
136
+ if auth and self.token:
137
+ headers["Authorization"] = f"Bearer {self.token}"
138
+ url = f"{self._base}{path}"
139
+ async with self._lock:
140
+ try:
141
+ async with self._session.request(
142
+ method,
143
+ url,
144
+ json=dict(body) if body is not None else None,
145
+ params=dict(params) if params else None,
146
+ headers=headers,
147
+ timeout=self._timeout,
148
+ ) as resp:
149
+ text = await resp.text()
150
+ status = resp.status
151
+ except TimeoutError as err:
152
+ raise HeatLinkConnectionError(f"Timed out talking to {self.host}") from err
153
+ except aiohttp.ClientError as err:
154
+ raise HeatLinkConnectionError(f"Can't reach {self.host}: {err}") from err
155
+
156
+ if status >= 400:
157
+ raise _STATUS_ERRORS.get(status, HeatLinkResponseError)(status, _error_message(text))
158
+ if not text.strip():
159
+ return None
160
+ try:
161
+ return json.loads(text)
162
+ except ValueError as err:
163
+ raise HeatLinkConnectionError(f"Unreadable reply from {self.host}") from err
164
+
165
+ async def _get(self, path: str, **kw: Any) -> dict[str, Any]:
166
+ return _obj(await self.request("GET", path, **kw))
167
+
168
+ async def _post(self, path: str, body: Mapping[str, Any] | None = None, **kw: Any) -> Any:
169
+ return await self.request("POST", path, body=body if body is not None else {}, **kw)
170
+
171
+ # ── identity (no auth) ─────────────────────────────────────────────
172
+
173
+ async def get_info(self) -> Info:
174
+ """Identity, auth state and feature flags. Needs no token.
175
+
176
+ Raises :class:`HeatLinkUnsupportedError` for firmware older than 1.5,
177
+ which has no ``/api/info``.
178
+ """
179
+ try:
180
+ return Info.from_dict(await self._get("/api/info", auth=False))
181
+ except HeatLinkNotFoundError as err:
182
+ raise HeatLinkUnsupportedError(
183
+ f"{self.host} runs firmware without /api/info (needs 1.5 or newer)"
184
+ ) from err
185
+
186
+ async def get_house_summary(self) -> HouseSummary:
187
+ return HouseSummary.from_dict(await self._get("/api/house/summary", auth=False))
188
+
189
+ # ── reads ──────────────────────────────────────────────────────────
190
+
191
+ async def get_settings(self) -> Settings:
192
+ return Settings.from_dict(await self._get("/api/settings"))
193
+
194
+ async def get_status(self) -> Status:
195
+ return Status.from_dict(await self._get("/api/status"))
196
+
197
+ async def get_capabilities(self) -> Capabilities:
198
+ return Capabilities.from_dict(await self._get("/api/capabilities"))
199
+
200
+ async def get_park(self) -> ParkConfig:
201
+ return ParkConfig.from_dict(await self._get("/api/park"))
202
+
203
+ async def get_group(self) -> Group:
204
+ return Group.from_dict(await self._get("/api/group"))
205
+
206
+ async def get_sensors(self) -> Sensors:
207
+ return Sensors.from_dict(await self._get("/api/sensors"))
208
+
209
+ async def get_events(self, since: int = 0, limit: int = 100) -> EventLog:
210
+ return EventLog.from_dict(
211
+ await self._get("/api/events", params={"since": since, "limit": limit})
212
+ )
213
+
214
+ async def get_update(self) -> UpdateInfo:
215
+ return UpdateInfo.from_dict(await self._get("/api/update"))
216
+
217
+ async def get_ota_status(self) -> OtaStatus:
218
+ return OtaStatus.from_dict(await self._get("/api/ota/status"))
219
+
220
+ # ── control (control scope) ────────────────────────────────────────
221
+
222
+ async def set_climate(
223
+ self,
224
+ *,
225
+ power: bool | None = None,
226
+ mode: str | None = None,
227
+ temperature: float | None = None,
228
+ fan: str | None = None,
229
+ vane: str | None = None,
230
+ wide_vane: str | None = None,
231
+ ) -> None:
232
+ """Change any mix of climate settings in one call.
233
+
234
+ Setting ``mode`` also turns the head on unless ``power=False`` is
235
+ passed with it, in which case the mode is stored and the head stays
236
+ off.
237
+ """
238
+ body = _drop_none(
239
+ mode=mode.upper() if mode is not None else None,
240
+ power=None if power is None else ("ON" if power else "OFF"),
241
+ temperature=temperature,
242
+ fan=fan.upper() if fan is not None else None,
243
+ vane=vane.upper() if vane is not None else None,
244
+ wideVane=wide_vane,
245
+ )
246
+ if not body:
247
+ raise ValueError("set_climate needs at least one setting")
248
+ await self._post("/api/settings", body)
249
+
250
+ async def turn_on(self) -> None:
251
+ await self.set_climate(power=True)
252
+
253
+ async def turn_off(self) -> None:
254
+ await self.set_climate(power=False)
255
+
256
+ async def set_remote_temp(
257
+ self, temp: float, ttl_s: int = REMOTE_TEMP_TTL_DEFAULT
258
+ ) -> RemoteTempLease:
259
+ """Feed the unit a room temperature (°C) from an outside sensor.
260
+
261
+ The head uses it until ``ttl_s`` runs out, so send it again before
262
+ then to keep the feed going. Raises :class:`HeatLinkConflictError`
263
+ while a paired room sensor is feeding the head.
264
+ """
265
+ if not REMOTE_TEMP_TTL_MIN <= ttl_s <= REMOTE_TEMP_TTL_MAX:
266
+ raise ValueError(f"ttl_s must be {REMOTE_TEMP_TTL_MIN}-{REMOTE_TEMP_TTL_MAX}")
267
+ return RemoteTempLease.from_dict(
268
+ _obj(await self._post("/api/remote-temp", {"temp": temp, "ttl_s": ttl_s}))
269
+ )
270
+
271
+ async def clear_remote_temp(self) -> RemoteTempLease:
272
+ """End the remote temperature feed now."""
273
+ return RemoteTempLease.from_dict(_obj(await self._post("/api/remote-temp", {"temp": None})))
274
+
275
+ # ── admin ──────────────────────────────────────────────────────────
276
+
277
+ async def set_park(
278
+ self,
279
+ *,
280
+ enabled: bool | None = None,
281
+ delta_c: float | None = None,
282
+ min_off_s: int | None = None,
283
+ probe_enabled: bool | None = None,
284
+ probe_interval_s: int | None = None,
285
+ probe_duration_s: int | None = None,
286
+ ) -> ParkConfig:
287
+ """Change off-at-setpoint settings. Returns the applied config."""
288
+ body = _drop_none(
289
+ enabled=enabled,
290
+ delta_c=delta_c,
291
+ min_off_s=min_off_s,
292
+ probe_enabled=probe_enabled,
293
+ probe_interval_s=probe_interval_s,
294
+ probe_duration_s=probe_duration_s,
295
+ )
296
+ if not body:
297
+ raise ValueError("set_park needs at least one setting")
298
+ return ParkConfig.from_dict(_obj(await self._post("/api/park", body)))
299
+
300
+ async def set_tandem(
301
+ self,
302
+ *,
303
+ enabled: bool | None = None,
304
+ min_slice_s: int | None = None,
305
+ max_slice_s: int | None = None,
306
+ settle_s: int | None = None,
307
+ ) -> Tandem:
308
+ """Change Tandem (heat/cool time-slicing) for the whole outdoor unit."""
309
+ body = _drop_none(
310
+ enabled=enabled, min_slice_s=min_slice_s, max_slice_s=max_slice_s, settle_s=settle_s
311
+ )
312
+ if not body:
313
+ raise ValueError("set_tandem needs at least one setting")
314
+ return Tandem.from_dict(_obj(await self._post("/api/group/slice", body)))
315
+
316
+ async def install_update(self) -> None:
317
+ """Start installing the latest firmware. Watch :meth:`get_ota_status`."""
318
+ await self._post("/api/update/install")
319
+
320
+ async def restart(self) -> None:
321
+ await self._post("/api/system/restart")
322
+
323
+ async def reset_diag(self, counter: str) -> None:
324
+ """Reset a diagnostic counter: ``brownout``, ``wifi-drops`` or ``vin-min``."""
325
+ if counter not in DIAG_COUNTERS:
326
+ raise ValueError(f"counter must be one of {', '.join(DIAG_COUNTERS)}")
327
+ await self._post(f"/api/diag/reset-{counter}")
328
+
329
+ # ── token tickets ──────────────────────────────────────────────────
330
+
331
+ async def request_ticket(self, uid: str) -> Ticket:
332
+ """Ask this head for a ticket that mints a token on group peer ``uid``.
333
+
334
+ Needs an integration token; the new token gets the same scope.
335
+ """
336
+ return Ticket.from_dict(_obj(await self._post("/api/tokens/ticket", {"uid": uid})))
337
+
338
+ async def redeem_ticket(self, ticket: Ticket) -> TokenGrant:
339
+ """Turn a ticket from a peer into a token on this head. Needs no auth."""
340
+ return TokenGrant.from_dict(
341
+ _obj(
342
+ await self._post(
343
+ "/api/tokens/redeem",
344
+ {"ticket": ticket.ticket, "label": ticket.label},
345
+ auth=False,
346
+ )
347
+ )
348
+ )
349
+
350
+ async def create_peer_token(self, peer_host: str, uid: str) -> TokenGrant:
351
+ """Mint a token on group peer ``uid`` at ``peer_host`` using this head's token."""
352
+ ticket = await self.request_ticket(uid)
353
+ peer = HeatLinkClient(
354
+ peer_host,
355
+ self._session,
356
+ timeout=self._timeout.total or DEFAULT_TIMEOUT,
357
+ )
358
+ return await peer.redeem_ticket(ticket)
359
+
360
+
361
+ def _obj(data: Any) -> dict[str, Any]:
362
+ if isinstance(data, dict):
363
+ return data
364
+ raise HeatLinkConnectionError("Expected a JSON object in the reply")
365
+
366
+
367
+ def _error_message(text: str) -> str:
368
+ try:
369
+ data = json.loads(text)
370
+ except ValueError:
371
+ return text.strip()[:200]
372
+ if isinstance(data, dict) and isinstance(data.get("error"), str):
373
+ return str(data["error"])
374
+ return text.strip()[:200]
pyheatlink/const.py ADDED
@@ -0,0 +1,86 @@
1
+ """Constants shared by the HeatLink HTTP API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import IntEnum, StrEnum
6
+ from typing import Final
7
+
8
+ DEFAULT_PORT: Final = 80
9
+ DEFAULT_TIMEOUT: Final = 10.0
10
+
11
+ ZEROCONF_TYPE: Final = "_mmhvac._tcp.local."
12
+
13
+ #: Highest /api/info ``api_version`` this library was written against.
14
+ SUPPORTED_API_VERSION: Final = 1
15
+
16
+ REMOTE_TEMP_TTL_MIN: Final = 60
17
+ REMOTE_TEMP_TTL_MAX: Final = 3600
18
+ REMOTE_TEMP_TTL_DEFAULT: Final = 600
19
+
20
+
21
+ class Scope(StrEnum):
22
+ """Integration token scopes, weakest first."""
23
+
24
+ READ = "read"
25
+ CONTROL = "control"
26
+ ADMIN = "admin"
27
+
28
+
29
+ class Feature(StrEnum):
30
+ """Feature flags reported in /api/info ``features``."""
31
+
32
+ TOKENS = "tokens"
33
+ TOKEN_TICKETS = "token_tickets"
34
+ EVENTS = "events"
35
+ HISTORY = "history"
36
+ PARK = "park"
37
+ ROOM_SENSORS = "room_sensors"
38
+ GROUP = "group"
39
+ TANDEM = "tandem"
40
+ REMOTE_TEMP = "remote_temp"
41
+ FAULT = "fault"
42
+ SHARED_FIELDS = "shared_fields"
43
+ MQTT_DISCOVERY_SWITCH = "mqtt_discovery_switch"
44
+
45
+
46
+ class Power(StrEnum):
47
+ ON = "ON"
48
+ OFF = "OFF"
49
+
50
+
51
+ class Mode(StrEnum):
52
+ HEAT = "HEAT"
53
+ DRY = "DRY"
54
+ COOL = "COOL"
55
+ FAN = "FAN"
56
+ AUTO = "AUTO"
57
+
58
+
59
+ FAN_SPEEDS: Final = ("AUTO", "QUIET", "1", "2", "3", "4")
60
+ VANE_POSITIONS: Final = ("AUTO", "1", "2", "3", "4", "5", "SWING")
61
+ WIDE_VANE_POSITIONS: Final = ("<<", "<", "|", ">", ">>", "<>", "SWING")
62
+
63
+
64
+ class EventCategory(IntEnum):
65
+ MODE = 0
66
+ POWER = 1
67
+ SETPOINT = 2
68
+ FAN = 3
69
+ GROUP = 4
70
+ MEMBERSHIP = 5
71
+ OTA = 6
72
+ NET = 7
73
+ UNIT = 8
74
+ FAULT = 9
75
+ SYSTEM = 10
76
+
77
+
78
+ class EventActor(IntEnum):
79
+ SYSTEM = 0
80
+ WEB_UI = 1
81
+ HOME_ASSISTANT = 2
82
+ GROUP = 3
83
+ LOCAL = 4
84
+
85
+
86
+ DIAG_COUNTERS: Final = ("brownout", "wifi-drops", "vin-min")
pyheatlink/errors.py ADDED
@@ -0,0 +1,48 @@
1
+ """Exceptions raised by pyheatlink."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class HeatLinkError(Exception):
7
+ """Base class for every pyheatlink error."""
8
+
9
+
10
+ class HeatLinkConnectionError(HeatLinkError):
11
+ """The head could not be reached, timed out, or sent an unreadable reply."""
12
+
13
+
14
+ class HeatLinkResponseError(HeatLinkError):
15
+ """The head answered with an HTTP error status."""
16
+
17
+ def __init__(self, status: int, message: str = "") -> None:
18
+ self.status = status
19
+ self.message = message
20
+ super().__init__(f"HTTP {status}: {message}" if message else f"HTTP {status}")
21
+
22
+
23
+ class HeatLinkAuthError(HeatLinkResponseError):
24
+ """401: the token is missing, wrong or revoked (or a ticket is invalid)."""
25
+
26
+
27
+ class HeatLinkScopeError(HeatLinkResponseError):
28
+ """403: the token is valid but its scope doesn't allow this call."""
29
+
30
+
31
+ class HeatLinkNotFoundError(HeatLinkResponseError):
32
+ """404: unknown endpoint (older firmware) or unknown target."""
33
+
34
+
35
+ class HeatLinkConflictError(HeatLinkResponseError):
36
+ """409: the head refused because of its current state.
37
+
38
+ For example: a paired room sensor owns the room temperature, the head is
39
+ not in a group, or an update is already running.
40
+ """
41
+
42
+
43
+ class HeatLinkRateLimitError(HeatLinkResponseError):
44
+ """429: locked out after too many failed attempts."""
45
+
46
+
47
+ class HeatLinkUnsupportedError(HeatLinkError):
48
+ """The head's firmware doesn't advertise the feature this call needs."""