pyheatlink 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.
- pyheatlink-0.1.0/LICENSE +21 -0
- pyheatlink-0.1.0/PKG-INFO +156 -0
- pyheatlink-0.1.0/README.md +129 -0
- pyheatlink-0.1.0/pyproject.toml +62 -0
- pyheatlink-0.1.0/setup.cfg +4 -0
- pyheatlink-0.1.0/src/pyheatlink/__init__.py +76 -0
- pyheatlink-0.1.0/src/pyheatlink/__main__.py +87 -0
- pyheatlink-0.1.0/src/pyheatlink/client.py +374 -0
- pyheatlink-0.1.0/src/pyheatlink/const.py +86 -0
- pyheatlink-0.1.0/src/pyheatlink/errors.py +48 -0
- pyheatlink-0.1.0/src/pyheatlink/models.py +849 -0
- pyheatlink-0.1.0/src/pyheatlink/py.typed +0 -0
- pyheatlink-0.1.0/src/pyheatlink.egg-info/PKG-INFO +156 -0
- pyheatlink-0.1.0/src/pyheatlink.egg-info/SOURCES.txt +19 -0
- pyheatlink-0.1.0/src/pyheatlink.egg-info/dependency_links.txt +1 -0
- pyheatlink-0.1.0/src/pyheatlink.egg-info/entry_points.txt +2 -0
- pyheatlink-0.1.0/src/pyheatlink.egg-info/requires.txt +8 -0
- pyheatlink-0.1.0/src/pyheatlink.egg-info/top_level.txt +1 -0
- pyheatlink-0.1.0/tests/test_cli.py +46 -0
- pyheatlink-0.1.0/tests/test_client.py +249 -0
- pyheatlink-0.1.0/tests/test_models.py +153 -0
pyheatlink-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sslivins
|
|
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,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyheatlink
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python client for HeatLink, the local controller for Mitsubishi mini-split heads
|
|
5
|
+
Author-email: Stefan Slivinski <sslivins@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/sslivins/pyheatlink
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/sslivins/pyheatlink/issues
|
|
9
|
+
Project-URL: Firmware, https://github.com/sslivins/heatlink
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Home Automation
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
20
|
+
Provides-Extra: tests
|
|
21
|
+
Requires-Dist: pytest>=8.3.4; extra == "tests"
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.25.3; extra == "tests"
|
|
23
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == "tests"
|
|
24
|
+
Requires-Dist: ruff>=0.6.0; extra == "tests"
|
|
25
|
+
Requires-Dist: mypy>=1.10.0; extra == "tests"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# pyheatlink
|
|
29
|
+
|
|
30
|
+
Async Python client for [HeatLink](https://github.com/sslivins/heatlink), the
|
|
31
|
+
local Wi-Fi controller for Mitsubishi mini-split heads (CN105). It talks to each
|
|
32
|
+
head's HTTP API on your LAN, with no cloud involved. The
|
|
33
|
+
[Home Assistant integration](https://github.com/sslivins/hass-heatlink) is built on it.
|
|
34
|
+
|
|
35
|
+
Needs HeatLink firmware 1.5 or newer (the first version with `/api/info`).
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install pyheatlink
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import asyncio
|
|
47
|
+
import aiohttp
|
|
48
|
+
from pyheatlink import HeatLinkClient, Feature
|
|
49
|
+
|
|
50
|
+
async def main() -> None:
|
|
51
|
+
async with aiohttp.ClientSession() as session:
|
|
52
|
+
head = HeatLinkClient("192.168.1.151", session, token="hlt1_...")
|
|
53
|
+
|
|
54
|
+
info = await head.get_info() # public, no token needed
|
|
55
|
+
print(info.name, info.fw, info.group)
|
|
56
|
+
|
|
57
|
+
s = await head.get_settings()
|
|
58
|
+
print(s.power, s.mode, s.temperature, s.room_temperature)
|
|
59
|
+
|
|
60
|
+
await head.set_climate(mode="heat", temperature=21)
|
|
61
|
+
|
|
62
|
+
if info.has(Feature.REMOTE_TEMP):
|
|
63
|
+
await head.set_remote_temp(20.5, ttl_s=600) # resend before it expires
|
|
64
|
+
|
|
65
|
+
asyncio.run(main())
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Tokens
|
|
69
|
+
|
|
70
|
+
Create an integration token in the head's web UI (**Settings → API Access → Integration tokens**)
|
|
71
|
+
with the scope you need:
|
|
72
|
+
|
|
73
|
+
| Scope | Allows |
|
|
74
|
+
|-----------|--------|
|
|
75
|
+
| `read` | every read |
|
|
76
|
+
| `control` | climate changes and the remote temperature feed |
|
|
77
|
+
| `admin` | off-at-setpoint, Tandem, updates, restart, diagnostics |
|
|
78
|
+
|
|
79
|
+
Heads sharing an outdoor unit can mint tokens for each other, so one token is
|
|
80
|
+
enough to set up the whole group:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
group = await head.get_group()
|
|
84
|
+
for peer in group.members:
|
|
85
|
+
grant = await head.create_peer_token(peer.ip, peer.uid)
|
|
86
|
+
print(peer.name, grant.token) # same scope as `head`'s token
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Discovery
|
|
90
|
+
|
|
91
|
+
Heads advertise `_mmhvac._tcp.local.` over mDNS with their `uid` in the TXT
|
|
92
|
+
record (`pyheatlink.const.ZEROCONF_TYPE`).
|
|
93
|
+
|
|
94
|
+
## API
|
|
95
|
+
|
|
96
|
+
| Method | Endpoint | Scope |
|
|
97
|
+
|--------|----------|-------|
|
|
98
|
+
| `get_info()` | `GET /api/info` | none |
|
|
99
|
+
| `get_house_summary()` | `GET /api/house/summary` | none |
|
|
100
|
+
| `get_settings()` | `GET /api/settings` | read |
|
|
101
|
+
| `get_status()` | `GET /api/status` | read |
|
|
102
|
+
| `get_capabilities()` | `GET /api/capabilities` | read |
|
|
103
|
+
| `get_park()` | `GET /api/park` | read |
|
|
104
|
+
| `get_group()` | `GET /api/group` (includes Tandem) | read |
|
|
105
|
+
| `get_sensors()` | `GET /api/sensors` | read |
|
|
106
|
+
| `get_events(since, limit)` | `GET /api/events` | read |
|
|
107
|
+
| `get_update()` / `get_ota_status()` | `GET /api/update`, `/api/ota/status` | read |
|
|
108
|
+
| `set_climate(power, mode, temperature, fan, vane, wide_vane)` | `POST /api/settings` | control |
|
|
109
|
+
| `set_remote_temp(temp, ttl_s)` / `clear_remote_temp()` | `POST /api/remote-temp` | control |
|
|
110
|
+
| `set_park(...)` | `POST /api/park` | admin |
|
|
111
|
+
| `set_tandem(...)` | `POST /api/group/slice` | admin |
|
|
112
|
+
| `install_update()`, `restart()`, `reset_diag(counter)` | | admin |
|
|
113
|
+
| `request_ticket(uid)`, `redeem_ticket(ticket)`, `create_peer_token(host, uid)` | `/api/tokens/*` | token |
|
|
114
|
+
|
|
115
|
+
All models are frozen dataclasses that tolerate missing or unknown fields; the
|
|
116
|
+
full payload is kept in `.raw`.
|
|
117
|
+
|
|
118
|
+
Errors all derive from `HeatLinkError`:
|
|
119
|
+
|
|
120
|
+
| Exception | When |
|
|
121
|
+
|-----------|------|
|
|
122
|
+
| `HeatLinkConnectionError` | unreachable, timed out, or unreadable reply |
|
|
123
|
+
| `HeatLinkAuthError` | 401: missing, wrong or revoked token |
|
|
124
|
+
| `HeatLinkScopeError` | 403: token scope too low |
|
|
125
|
+
| `HeatLinkConflictError` | 409: refused in the current state (e.g. a room sensor owns the temperature) |
|
|
126
|
+
| `HeatLinkRateLimitError` | 429: locked out after failed attempts |
|
|
127
|
+
| `HeatLinkUnsupportedError` | firmware older than 1.5 |
|
|
128
|
+
| `HeatLinkResponseError` | any other HTTP error (`.status`, `.message`) |
|
|
129
|
+
|
|
130
|
+
Calls on one client never overlap, and each has a timeout (default 10 s).
|
|
131
|
+
|
|
132
|
+
## Command line
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pyheatlink 192.168.1.151 info
|
|
136
|
+
pyheatlink 192.168.1.151 --token hlt1_... set --mode heat --temp 21
|
|
137
|
+
pyheatlink 192.168.1.151 remote-temp 20.5 --ttl 600
|
|
138
|
+
pyheatlink 192.168.1.151 remote-temp clear
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`HEATLINK_TOKEN` in the environment works too.
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
pip install -e .[tests]
|
|
147
|
+
ruff check src tests && ruff format --check src tests
|
|
148
|
+
mypy src/pyheatlink
|
|
149
|
+
pytest --cov=src/pyheatlink
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Tests run against a local fake head serving responses captured from real
|
|
153
|
+
firmware (`tests/fixtures`).
|
|
154
|
+
|
|
155
|
+
Releases are automatic: merging to `main` publishes the version in
|
|
156
|
+
`pyproject.toml` to PyPI and bumps the patch version.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# pyheatlink
|
|
2
|
+
|
|
3
|
+
Async Python client for [HeatLink](https://github.com/sslivins/heatlink), the
|
|
4
|
+
local Wi-Fi controller for Mitsubishi mini-split heads (CN105). It talks to each
|
|
5
|
+
head's HTTP API on your LAN, with no cloud involved. The
|
|
6
|
+
[Home Assistant integration](https://github.com/sslivins/hass-heatlink) is built on it.
|
|
7
|
+
|
|
8
|
+
Needs HeatLink firmware 1.5 or newer (the first version with `/api/info`).
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install pyheatlink
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import asyncio
|
|
20
|
+
import aiohttp
|
|
21
|
+
from pyheatlink import HeatLinkClient, Feature
|
|
22
|
+
|
|
23
|
+
async def main() -> None:
|
|
24
|
+
async with aiohttp.ClientSession() as session:
|
|
25
|
+
head = HeatLinkClient("192.168.1.151", session, token="hlt1_...")
|
|
26
|
+
|
|
27
|
+
info = await head.get_info() # public, no token needed
|
|
28
|
+
print(info.name, info.fw, info.group)
|
|
29
|
+
|
|
30
|
+
s = await head.get_settings()
|
|
31
|
+
print(s.power, s.mode, s.temperature, s.room_temperature)
|
|
32
|
+
|
|
33
|
+
await head.set_climate(mode="heat", temperature=21)
|
|
34
|
+
|
|
35
|
+
if info.has(Feature.REMOTE_TEMP):
|
|
36
|
+
await head.set_remote_temp(20.5, ttl_s=600) # resend before it expires
|
|
37
|
+
|
|
38
|
+
asyncio.run(main())
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Tokens
|
|
42
|
+
|
|
43
|
+
Create an integration token in the head's web UI (**Settings → API Access → Integration tokens**)
|
|
44
|
+
with the scope you need:
|
|
45
|
+
|
|
46
|
+
| Scope | Allows |
|
|
47
|
+
|-----------|--------|
|
|
48
|
+
| `read` | every read |
|
|
49
|
+
| `control` | climate changes and the remote temperature feed |
|
|
50
|
+
| `admin` | off-at-setpoint, Tandem, updates, restart, diagnostics |
|
|
51
|
+
|
|
52
|
+
Heads sharing an outdoor unit can mint tokens for each other, so one token is
|
|
53
|
+
enough to set up the whole group:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
group = await head.get_group()
|
|
57
|
+
for peer in group.members:
|
|
58
|
+
grant = await head.create_peer_token(peer.ip, peer.uid)
|
|
59
|
+
print(peer.name, grant.token) # same scope as `head`'s token
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Discovery
|
|
63
|
+
|
|
64
|
+
Heads advertise `_mmhvac._tcp.local.` over mDNS with their `uid` in the TXT
|
|
65
|
+
record (`pyheatlink.const.ZEROCONF_TYPE`).
|
|
66
|
+
|
|
67
|
+
## API
|
|
68
|
+
|
|
69
|
+
| Method | Endpoint | Scope |
|
|
70
|
+
|--------|----------|-------|
|
|
71
|
+
| `get_info()` | `GET /api/info` | none |
|
|
72
|
+
| `get_house_summary()` | `GET /api/house/summary` | none |
|
|
73
|
+
| `get_settings()` | `GET /api/settings` | read |
|
|
74
|
+
| `get_status()` | `GET /api/status` | read |
|
|
75
|
+
| `get_capabilities()` | `GET /api/capabilities` | read |
|
|
76
|
+
| `get_park()` | `GET /api/park` | read |
|
|
77
|
+
| `get_group()` | `GET /api/group` (includes Tandem) | read |
|
|
78
|
+
| `get_sensors()` | `GET /api/sensors` | read |
|
|
79
|
+
| `get_events(since, limit)` | `GET /api/events` | read |
|
|
80
|
+
| `get_update()` / `get_ota_status()` | `GET /api/update`, `/api/ota/status` | read |
|
|
81
|
+
| `set_climate(power, mode, temperature, fan, vane, wide_vane)` | `POST /api/settings` | control |
|
|
82
|
+
| `set_remote_temp(temp, ttl_s)` / `clear_remote_temp()` | `POST /api/remote-temp` | control |
|
|
83
|
+
| `set_park(...)` | `POST /api/park` | admin |
|
|
84
|
+
| `set_tandem(...)` | `POST /api/group/slice` | admin |
|
|
85
|
+
| `install_update()`, `restart()`, `reset_diag(counter)` | | admin |
|
|
86
|
+
| `request_ticket(uid)`, `redeem_ticket(ticket)`, `create_peer_token(host, uid)` | `/api/tokens/*` | token |
|
|
87
|
+
|
|
88
|
+
All models are frozen dataclasses that tolerate missing or unknown fields; the
|
|
89
|
+
full payload is kept in `.raw`.
|
|
90
|
+
|
|
91
|
+
Errors all derive from `HeatLinkError`:
|
|
92
|
+
|
|
93
|
+
| Exception | When |
|
|
94
|
+
|-----------|------|
|
|
95
|
+
| `HeatLinkConnectionError` | unreachable, timed out, or unreadable reply |
|
|
96
|
+
| `HeatLinkAuthError` | 401: missing, wrong or revoked token |
|
|
97
|
+
| `HeatLinkScopeError` | 403: token scope too low |
|
|
98
|
+
| `HeatLinkConflictError` | 409: refused in the current state (e.g. a room sensor owns the temperature) |
|
|
99
|
+
| `HeatLinkRateLimitError` | 429: locked out after failed attempts |
|
|
100
|
+
| `HeatLinkUnsupportedError` | firmware older than 1.5 |
|
|
101
|
+
| `HeatLinkResponseError` | any other HTTP error (`.status`, `.message`) |
|
|
102
|
+
|
|
103
|
+
Calls on one client never overlap, and each has a timeout (default 10 s).
|
|
104
|
+
|
|
105
|
+
## Command line
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pyheatlink 192.168.1.151 info
|
|
109
|
+
pyheatlink 192.168.1.151 --token hlt1_... set --mode heat --temp 21
|
|
110
|
+
pyheatlink 192.168.1.151 remote-temp 20.5 --ttl 600
|
|
111
|
+
pyheatlink 192.168.1.151 remote-temp clear
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`HEATLINK_TOKEN` in the environment works too.
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pip install -e .[tests]
|
|
120
|
+
ruff check src tests && ruff format --check src tests
|
|
121
|
+
mypy src/pyheatlink
|
|
122
|
+
pytest --cov=src/pyheatlink
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Tests run against a local fake head serving responses captured from real
|
|
126
|
+
firmware (`tests/fixtures`).
|
|
127
|
+
|
|
128
|
+
Releases are automatic: merging to `main` publishes the version in
|
|
129
|
+
`pyproject.toml` to PyPI and bumps the patch version.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyheatlink"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async Python client for HeatLink, the local controller for Mitsubishi mini-split heads"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Stefan Slivinski", email = "sslivins@gmail.com" }
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.11"
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Topic :: Home Automation",
|
|
20
|
+
"Framework :: AsyncIO",
|
|
21
|
+
"Typing :: Typed"
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"aiohttp>=3.9.0"
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
tests = [
|
|
29
|
+
"pytest>=8.3.4",
|
|
30
|
+
"pytest-asyncio>=0.25.3",
|
|
31
|
+
"pytest-cov>=5.0.0",
|
|
32
|
+
"ruff>=0.6.0",
|
|
33
|
+
"mypy>=1.10.0"
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
"Homepage" = "https://github.com/sslivins/pyheatlink"
|
|
38
|
+
"Bug Tracker" = "https://github.com/sslivins/pyheatlink/issues"
|
|
39
|
+
"Firmware" = "https://github.com/sslivins/heatlink"
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
pyheatlink = "pyheatlink.__main__:main"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools]
|
|
45
|
+
package-dir = {"" = "src"}
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.packages.find]
|
|
48
|
+
where = ["src"]
|
|
49
|
+
|
|
50
|
+
[tool.setuptools.package-data]
|
|
51
|
+
pyheatlink = ["py.typed"]
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
line-length = 100
|
|
55
|
+
target-version = "py311"
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "ASYNC"]
|
|
59
|
+
|
|
60
|
+
[tool.mypy]
|
|
61
|
+
python_version = "3.11"
|
|
62
|
+
strict = true
|
|
@@ -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
|
+
]
|
|
@@ -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())
|