steamloop 1.0.0__tar.gz → 1.2.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.
- steamloop-1.2.0/PKG-INFO +229 -0
- steamloop-1.2.0/README.md +205 -0
- {steamloop-1.0.0 → steamloop-1.2.0}/pyproject.toml +85 -26
- steamloop-1.2.0/src/steamloop/__init__.py +31 -0
- steamloop-1.2.0/src/steamloop/__main__.py +5 -0
- steamloop-1.2.0/src/steamloop/certs.py +189 -0
- steamloop-1.2.0/src/steamloop/cli.py +322 -0
- steamloop-1.2.0/src/steamloop/connection.py +774 -0
- steamloop-1.2.0/src/steamloop/const.py +49 -0
- steamloop-1.2.0/src/steamloop/exceptions.py +21 -0
- steamloop-1.2.0/src/steamloop/models.py +149 -0
- steamloop-1.2.0/src/steamloop.egg-info/PKG-INFO +229 -0
- {steamloop-1.0.0 → steamloop-1.2.0}/src/steamloop.egg-info/SOURCES.txt +12 -2
- steamloop-1.2.0/src/steamloop.egg-info/entry_points.txt +2 -0
- steamloop-1.2.0/src/steamloop.egg-info/requires.txt +1 -0
- steamloop-1.2.0/tests/test_certs.py +35 -0
- steamloop-1.2.0/tests/test_cli.py +658 -0
- steamloop-1.2.0/tests/test_connection.py +995 -0
- steamloop-1.2.0/tests/test_const.py +48 -0
- {steamloop-1.0.0 → steamloop-1.2.0}/tests/test_dunder_main.py +6 -3
- steamloop-1.2.0/tests/test_exceptions.py +30 -0
- steamloop-1.2.0/tests/test_models.py +47 -0
- steamloop-1.2.0/tests/test_pairing.py +55 -0
- steamloop-1.2.0/tests/test_protocol.py +132 -0
- steamloop-1.0.0/PKG-INFO +0 -96
- steamloop-1.0.0/README.md +0 -69
- steamloop-1.0.0/src/steamloop/__init__.py +0 -1
- steamloop-1.0.0/src/steamloop/__main__.py +0 -5
- steamloop-1.0.0/src/steamloop/cli.py +0 -12
- steamloop-1.0.0/src/steamloop/main.py +0 -11
- steamloop-1.0.0/src/steamloop.egg-info/PKG-INFO +0 -96
- steamloop-1.0.0/src/steamloop.egg-info/entry_points.txt +0 -2
- steamloop-1.0.0/src/steamloop.egg-info/requires.txt +0 -2
- steamloop-1.0.0/tests/test_cli.py +0 -12
- steamloop-1.0.0/tests/test_main.py +0 -6
- {steamloop-1.0.0 → steamloop-1.2.0}/LICENSE +0 -0
- {steamloop-1.0.0 → steamloop-1.2.0}/setup.cfg +0 -0
- {steamloop-1.0.0 → steamloop-1.2.0}/src/steamloop/py.typed +0 -0
- {steamloop-1.0.0 → steamloop-1.2.0}/src/steamloop.egg-info/dependency_links.txt +0 -0
- {steamloop-1.0.0 → steamloop-1.2.0}/src/steamloop.egg-info/top_level.txt +0 -0
steamloop-1.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: steamloop
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Local control for choochoo based thermostats
|
|
5
|
+
Author-email: "J. Nick Koston" <nick@koston.org>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/hvaclibs/steamloop/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/hvaclibs/steamloop/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: documentation, https://steamloop.readthedocs.io
|
|
10
|
+
Project-URL: repository, https://github.com/hvaclibs/steamloop
|
|
11
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Natural Language :: English
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: orjson>=3.10
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# steamloop
|
|
26
|
+
|
|
27
|
+
<p align="center">
|
|
28
|
+
<a href="https://github.com/hvaclibs/steamloop/actions/workflows/ci.yml?query=branch%3Amain">
|
|
29
|
+
<img src="https://img.shields.io/github/actions/workflow/status/hvaclibs/steamloop/ci.yml?branch=main&label=CI&logo=github&style=flat-square" alt="CI Status" >
|
|
30
|
+
</a>
|
|
31
|
+
<a href="https://steamloop.readthedocs.io">
|
|
32
|
+
<img src="https://img.shields.io/readthedocs/steamloop.svg?logo=read-the-docs&logoColor=fff&style=flat-square" alt="Documentation Status">
|
|
33
|
+
</a>
|
|
34
|
+
<a href="https://codecov.io/gh/hvaclibs/steamloop">
|
|
35
|
+
<img src="https://img.shields.io/codecov/c/github/hvaclibs/steamloop.svg?logo=codecov&logoColor=fff&style=flat-square" alt="Test coverage percentage">
|
|
36
|
+
</a>
|
|
37
|
+
</p>
|
|
38
|
+
<p align="center">
|
|
39
|
+
<a href="https://github.com/astral-sh/uv">
|
|
40
|
+
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json" alt="uv">
|
|
41
|
+
</a>
|
|
42
|
+
<a href="https://github.com/astral-sh/ruff">
|
|
43
|
+
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff">
|
|
44
|
+
</a>
|
|
45
|
+
<a href="https://github.com/pre-commit/pre-commit">
|
|
46
|
+
<img src="https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white&style=flat-square" alt="pre-commit">
|
|
47
|
+
</a>
|
|
48
|
+
</p>
|
|
49
|
+
<p align="center">
|
|
50
|
+
<a href="https://pypi.org/project/steamloop/">
|
|
51
|
+
<img src="https://img.shields.io/pypi/v/steamloop.svg?logo=python&logoColor=fff&style=flat-square" alt="PyPI Version">
|
|
52
|
+
</a>
|
|
53
|
+
<img src="https://img.shields.io/pypi/pyversions/steamloop.svg?style=flat-square&logo=python&logoColor=fff" alt="Supported Python versions">
|
|
54
|
+
<img src="https://img.shields.io/pypi/l/steamloop.svg?style=flat-square" alt="License">
|
|
55
|
+
</p>
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
Async Python library for local control of thermostat devices over mTLS (port 7878).
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install steamloop
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## CLI
|
|
68
|
+
|
|
69
|
+
### Pairing
|
|
70
|
+
|
|
71
|
+
Put the thermostat in pairing mode (Menu > Settings > Network > Advanced Setup > Remote Connection > Pair), then:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
steamloop 192.168.1.100 --pair
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
This saves a pairing file in the current directory with the secret key.
|
|
78
|
+
|
|
79
|
+
### Monitoring
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
steamloop 192.168.1.100
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
If already paired, you can pass the secret key directly to skip the pairing file:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
steamloop 192.168.1.100 --key YOUR_SECRET_KEY
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Interactive commands: `status`, `heat <temp>`, `cool <temp>`, `mode <off|auto|cool|heat>`, `fan <auto|on|circulate>`, `eheat <on|off>`, `help`.
|
|
92
|
+
|
|
93
|
+
## Library Usage
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import asyncio
|
|
97
|
+
from steamloop import ThermostatConnection, ZoneMode, FanMode
|
|
98
|
+
|
|
99
|
+
async def main():
|
|
100
|
+
conn = ThermostatConnection(
|
|
101
|
+
"192.168.1.100",
|
|
102
|
+
secret_key="your-secret-key-from-pairing",
|
|
103
|
+
)
|
|
104
|
+
async with conn:
|
|
105
|
+
# State is populated automatically from thermostat events
|
|
106
|
+
for zone_id, zone in conn.state.zones.items():
|
|
107
|
+
print(f"{zone.name}: {zone.indoor_temperature}°F")
|
|
108
|
+
|
|
109
|
+
# Send commands (sync — no await needed)
|
|
110
|
+
conn.set_temperature_setpoint("1", heat_setpoint="72")
|
|
111
|
+
conn.set_zone_mode("1", ZoneMode.COOL)
|
|
112
|
+
conn.set_fan_mode(FanMode.AUTO)
|
|
113
|
+
|
|
114
|
+
asyncio.run(main())
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Pairing Programmatically
|
|
118
|
+
|
|
119
|
+
`pair()` returns the secret key directly — store it however you like:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from steamloop import ThermostatConnection
|
|
123
|
+
|
|
124
|
+
async def pair(ip: str) -> str:
|
|
125
|
+
conn = ThermostatConnection(ip, secret_key="")
|
|
126
|
+
try:
|
|
127
|
+
await conn.connect()
|
|
128
|
+
ssk = await conn.pair()
|
|
129
|
+
return ssk["secret_key"] # store in a database, config entry, etc.
|
|
130
|
+
finally:
|
|
131
|
+
await conn.disconnect()
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Or use the built-in file helpers to save/load pairing data to disk:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from steamloop import ThermostatConnection, save_pairing, load_pairing
|
|
138
|
+
|
|
139
|
+
# Save after pairing
|
|
140
|
+
await save_pairing(ip, {
|
|
141
|
+
"secret_key": secret_key,
|
|
142
|
+
"device_type": "automation",
|
|
143
|
+
"device_id": "module",
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
# Load later
|
|
147
|
+
pairing = await load_pairing(ip)
|
|
148
|
+
conn = ThermostatConnection(ip, secret_key=pairing["secret_key"])
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Event Callbacks
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
def on_event(msg):
|
|
155
|
+
print("Received:", msg)
|
|
156
|
+
|
|
157
|
+
remove = conn.add_event_callback(on_event)
|
|
158
|
+
# later: remove() to unregister
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Home Assistant Integration
|
|
162
|
+
|
|
163
|
+
Key design points for using steamloop in a Home Assistant integration:
|
|
164
|
+
|
|
165
|
+
- **Commands are sync** — `set_zone_mode()`, `set_fan_mode()`, `set_temperature_setpoint()` use `transport.write()` internally, so they won't block the event loop. No `await` needed.
|
|
166
|
+
- **State is always fresh** — the `asyncio.Protocol` receives events via `data_received()` and updates `conn.state` automatically. Just read properties directly.
|
|
167
|
+
- **Auto-reconnect** — after calling `start_background_tasks()`, the connection automatically reconnects with exponential backoff (5s, 10s, 20s, ... up to 5 min).
|
|
168
|
+
- **Event callbacks** — use `add_event_callback()` to trigger `async_write_ha_state()` when the thermostat pushes updates.
|
|
169
|
+
- **Multi-zone** — create one `ClimateEntity` per `conn.state.zones` entry. Zones are populated automatically after login.
|
|
170
|
+
|
|
171
|
+
## API Reference
|
|
172
|
+
|
|
173
|
+
### `ThermostatConnection(ip, port=7878, *, secret_key, cert_set=None, device_type="automation", device_id="module")`
|
|
174
|
+
|
|
175
|
+
| Method | Async | Description |
|
|
176
|
+
| ------------------------------------------------------------------------------- | ----- | ----------------------------------------------------- |
|
|
177
|
+
| `connect()` | yes | Establish mTLS connection |
|
|
178
|
+
| `login()` | yes | Authenticate with secret key |
|
|
179
|
+
| `pair()` | yes | Pair and receive secret key |
|
|
180
|
+
| `start_background_tasks()` | no | Start heartbeat + auto-reconnect |
|
|
181
|
+
| `disconnect()` | yes | Close connection and stop tasks |
|
|
182
|
+
| `set_temperature_setpoint(zone_id, *, heat_setpoint, cool_setpoint, hold_type)` | no | Set zone temperature |
|
|
183
|
+
| `set_zone_mode(zone_id, mode)` | no | Set zone HVAC mode |
|
|
184
|
+
| `set_fan_mode(mode)` | no | Set fan mode |
|
|
185
|
+
| `set_emergency_heat(enabled)` | no | Toggle emergency heat |
|
|
186
|
+
| `add_event_callback(fn)` | no | Register event listener (returns unregister callable) |
|
|
187
|
+
|
|
188
|
+
Supports `async with` for automatic connect/login/disconnect:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
async with ThermostatConnection(ip, secret_key=key) as conn:
|
|
192
|
+
... # connected, logged in, background tasks running
|
|
193
|
+
# automatically disconnected
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Enums
|
|
197
|
+
|
|
198
|
+
- `ZoneMode` — `OFF`, `AUTO`, `COOL`, `HEAT`
|
|
199
|
+
- `FanMode` — `AUTO`, `ALWAYS_ON`, `CIRCULATE`
|
|
200
|
+
- `HoldType` — `UNDEFINED`, `MANUAL`, `SCHEDULE`, `HOLD`
|
|
201
|
+
|
|
202
|
+
### State
|
|
203
|
+
|
|
204
|
+
- `conn.state.zones` — `dict[str, Zone]` with temperature, setpoints, mode per zone
|
|
205
|
+
- `conn.state.fan_mode` — current `FanMode`
|
|
206
|
+
- `conn.state.supported_modes` — `list[ZoneMode]`
|
|
207
|
+
- `conn.state.emergency_heat` / `relative_humidity` / `cooling_active` / `heating_active`
|
|
208
|
+
|
|
209
|
+
## Contributors
|
|
210
|
+
|
|
211
|
+
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
|
212
|
+
|
|
213
|
+
<!-- prettier-ignore-start -->
|
|
214
|
+
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
|
215
|
+
<!-- markdownlint-disable -->
|
|
216
|
+
<!-- markdownlint-enable -->
|
|
217
|
+
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
|
218
|
+
<!-- prettier-ignore-end -->
|
|
219
|
+
|
|
220
|
+
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
|
|
221
|
+
|
|
222
|
+
## Credits
|
|
223
|
+
|
|
224
|
+
[](https://github.com/copier-org/copier)
|
|
225
|
+
|
|
226
|
+
This package was created with
|
|
227
|
+
[Copier](https://copier.readthedocs.io/) and the
|
|
228
|
+
[browniebroke/pypackage-template](https://github.com/browniebroke/pypackage-template)
|
|
229
|
+
project template.
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# steamloop
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://github.com/hvaclibs/steamloop/actions/workflows/ci.yml?query=branch%3Amain">
|
|
5
|
+
<img src="https://img.shields.io/github/actions/workflow/status/hvaclibs/steamloop/ci.yml?branch=main&label=CI&logo=github&style=flat-square" alt="CI Status" >
|
|
6
|
+
</a>
|
|
7
|
+
<a href="https://steamloop.readthedocs.io">
|
|
8
|
+
<img src="https://img.shields.io/readthedocs/steamloop.svg?logo=read-the-docs&logoColor=fff&style=flat-square" alt="Documentation Status">
|
|
9
|
+
</a>
|
|
10
|
+
<a href="https://codecov.io/gh/hvaclibs/steamloop">
|
|
11
|
+
<img src="https://img.shields.io/codecov/c/github/hvaclibs/steamloop.svg?logo=codecov&logoColor=fff&style=flat-square" alt="Test coverage percentage">
|
|
12
|
+
</a>
|
|
13
|
+
</p>
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://github.com/astral-sh/uv">
|
|
16
|
+
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json" alt="uv">
|
|
17
|
+
</a>
|
|
18
|
+
<a href="https://github.com/astral-sh/ruff">
|
|
19
|
+
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff">
|
|
20
|
+
</a>
|
|
21
|
+
<a href="https://github.com/pre-commit/pre-commit">
|
|
22
|
+
<img src="https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white&style=flat-square" alt="pre-commit">
|
|
23
|
+
</a>
|
|
24
|
+
</p>
|
|
25
|
+
<p align="center">
|
|
26
|
+
<a href="https://pypi.org/project/steamloop/">
|
|
27
|
+
<img src="https://img.shields.io/pypi/v/steamloop.svg?logo=python&logoColor=fff&style=flat-square" alt="PyPI Version">
|
|
28
|
+
</a>
|
|
29
|
+
<img src="https://img.shields.io/pypi/pyversions/steamloop.svg?style=flat-square&logo=python&logoColor=fff" alt="Supported Python versions">
|
|
30
|
+
<img src="https://img.shields.io/pypi/l/steamloop.svg?style=flat-square" alt="License">
|
|
31
|
+
</p>
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
Async Python library for local control of thermostat devices over mTLS (port 7878).
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install steamloop
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## CLI
|
|
44
|
+
|
|
45
|
+
### Pairing
|
|
46
|
+
|
|
47
|
+
Put the thermostat in pairing mode (Menu > Settings > Network > Advanced Setup > Remote Connection > Pair), then:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
steamloop 192.168.1.100 --pair
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This saves a pairing file in the current directory with the secret key.
|
|
54
|
+
|
|
55
|
+
### Monitoring
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
steamloop 192.168.1.100
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
If already paired, you can pass the secret key directly to skip the pairing file:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
steamloop 192.168.1.100 --key YOUR_SECRET_KEY
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Interactive commands: `status`, `heat <temp>`, `cool <temp>`, `mode <off|auto|cool|heat>`, `fan <auto|on|circulate>`, `eheat <on|off>`, `help`.
|
|
68
|
+
|
|
69
|
+
## Library Usage
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import asyncio
|
|
73
|
+
from steamloop import ThermostatConnection, ZoneMode, FanMode
|
|
74
|
+
|
|
75
|
+
async def main():
|
|
76
|
+
conn = ThermostatConnection(
|
|
77
|
+
"192.168.1.100",
|
|
78
|
+
secret_key="your-secret-key-from-pairing",
|
|
79
|
+
)
|
|
80
|
+
async with conn:
|
|
81
|
+
# State is populated automatically from thermostat events
|
|
82
|
+
for zone_id, zone in conn.state.zones.items():
|
|
83
|
+
print(f"{zone.name}: {zone.indoor_temperature}°F")
|
|
84
|
+
|
|
85
|
+
# Send commands (sync — no await needed)
|
|
86
|
+
conn.set_temperature_setpoint("1", heat_setpoint="72")
|
|
87
|
+
conn.set_zone_mode("1", ZoneMode.COOL)
|
|
88
|
+
conn.set_fan_mode(FanMode.AUTO)
|
|
89
|
+
|
|
90
|
+
asyncio.run(main())
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Pairing Programmatically
|
|
94
|
+
|
|
95
|
+
`pair()` returns the secret key directly — store it however you like:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from steamloop import ThermostatConnection
|
|
99
|
+
|
|
100
|
+
async def pair(ip: str) -> str:
|
|
101
|
+
conn = ThermostatConnection(ip, secret_key="")
|
|
102
|
+
try:
|
|
103
|
+
await conn.connect()
|
|
104
|
+
ssk = await conn.pair()
|
|
105
|
+
return ssk["secret_key"] # store in a database, config entry, etc.
|
|
106
|
+
finally:
|
|
107
|
+
await conn.disconnect()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Or use the built-in file helpers to save/load pairing data to disk:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from steamloop import ThermostatConnection, save_pairing, load_pairing
|
|
114
|
+
|
|
115
|
+
# Save after pairing
|
|
116
|
+
await save_pairing(ip, {
|
|
117
|
+
"secret_key": secret_key,
|
|
118
|
+
"device_type": "automation",
|
|
119
|
+
"device_id": "module",
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
# Load later
|
|
123
|
+
pairing = await load_pairing(ip)
|
|
124
|
+
conn = ThermostatConnection(ip, secret_key=pairing["secret_key"])
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Event Callbacks
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
def on_event(msg):
|
|
131
|
+
print("Received:", msg)
|
|
132
|
+
|
|
133
|
+
remove = conn.add_event_callback(on_event)
|
|
134
|
+
# later: remove() to unregister
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Home Assistant Integration
|
|
138
|
+
|
|
139
|
+
Key design points for using steamloop in a Home Assistant integration:
|
|
140
|
+
|
|
141
|
+
- **Commands are sync** — `set_zone_mode()`, `set_fan_mode()`, `set_temperature_setpoint()` use `transport.write()` internally, so they won't block the event loop. No `await` needed.
|
|
142
|
+
- **State is always fresh** — the `asyncio.Protocol` receives events via `data_received()` and updates `conn.state` automatically. Just read properties directly.
|
|
143
|
+
- **Auto-reconnect** — after calling `start_background_tasks()`, the connection automatically reconnects with exponential backoff (5s, 10s, 20s, ... up to 5 min).
|
|
144
|
+
- **Event callbacks** — use `add_event_callback()` to trigger `async_write_ha_state()` when the thermostat pushes updates.
|
|
145
|
+
- **Multi-zone** — create one `ClimateEntity` per `conn.state.zones` entry. Zones are populated automatically after login.
|
|
146
|
+
|
|
147
|
+
## API Reference
|
|
148
|
+
|
|
149
|
+
### `ThermostatConnection(ip, port=7878, *, secret_key, cert_set=None, device_type="automation", device_id="module")`
|
|
150
|
+
|
|
151
|
+
| Method | Async | Description |
|
|
152
|
+
| ------------------------------------------------------------------------------- | ----- | ----------------------------------------------------- |
|
|
153
|
+
| `connect()` | yes | Establish mTLS connection |
|
|
154
|
+
| `login()` | yes | Authenticate with secret key |
|
|
155
|
+
| `pair()` | yes | Pair and receive secret key |
|
|
156
|
+
| `start_background_tasks()` | no | Start heartbeat + auto-reconnect |
|
|
157
|
+
| `disconnect()` | yes | Close connection and stop tasks |
|
|
158
|
+
| `set_temperature_setpoint(zone_id, *, heat_setpoint, cool_setpoint, hold_type)` | no | Set zone temperature |
|
|
159
|
+
| `set_zone_mode(zone_id, mode)` | no | Set zone HVAC mode |
|
|
160
|
+
| `set_fan_mode(mode)` | no | Set fan mode |
|
|
161
|
+
| `set_emergency_heat(enabled)` | no | Toggle emergency heat |
|
|
162
|
+
| `add_event_callback(fn)` | no | Register event listener (returns unregister callable) |
|
|
163
|
+
|
|
164
|
+
Supports `async with` for automatic connect/login/disconnect:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
async with ThermostatConnection(ip, secret_key=key) as conn:
|
|
168
|
+
... # connected, logged in, background tasks running
|
|
169
|
+
# automatically disconnected
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Enums
|
|
173
|
+
|
|
174
|
+
- `ZoneMode` — `OFF`, `AUTO`, `COOL`, `HEAT`
|
|
175
|
+
- `FanMode` — `AUTO`, `ALWAYS_ON`, `CIRCULATE`
|
|
176
|
+
- `HoldType` — `UNDEFINED`, `MANUAL`, `SCHEDULE`, `HOLD`
|
|
177
|
+
|
|
178
|
+
### State
|
|
179
|
+
|
|
180
|
+
- `conn.state.zones` — `dict[str, Zone]` with temperature, setpoints, mode per zone
|
|
181
|
+
- `conn.state.fan_mode` — current `FanMode`
|
|
182
|
+
- `conn.state.supported_modes` — `list[ZoneMode]`
|
|
183
|
+
- `conn.state.emergency_heat` / `relative_humidity` / `cooling_active` / `heating_active`
|
|
184
|
+
|
|
185
|
+
## Contributors
|
|
186
|
+
|
|
187
|
+
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
|
188
|
+
|
|
189
|
+
<!-- prettier-ignore-start -->
|
|
190
|
+
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
|
191
|
+
<!-- markdownlint-disable -->
|
|
192
|
+
<!-- markdownlint-enable -->
|
|
193
|
+
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
|
194
|
+
<!-- prettier-ignore-end -->
|
|
195
|
+
|
|
196
|
+
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
|
|
197
|
+
|
|
198
|
+
## Credits
|
|
199
|
+
|
|
200
|
+
[](https://github.com/copier-org/copier)
|
|
201
|
+
|
|
202
|
+
This package was created with
|
|
203
|
+
[Copier](https://copier.readthedocs.io/) and the
|
|
204
|
+
[browniebroke/pypackage-template](https://github.com/browniebroke/pypackage-template)
|
|
205
|
+
project template.
|
|
@@ -4,21 +4,19 @@ requires = [ "setuptools>=77.0.3" ]
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "steamloop"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.2.0"
|
|
8
8
|
description = "Local control for choochoo based thermostats"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
11
11
|
authors = [
|
|
12
12
|
{ name = "J. Nick Koston", email = "nick@koston.org" },
|
|
13
13
|
]
|
|
14
|
-
requires-python = ">=3.
|
|
14
|
+
requires-python = ">=3.12"
|
|
15
15
|
classifiers = [
|
|
16
16
|
"Development Status :: 2 - Pre-Alpha",
|
|
17
17
|
"Intended Audience :: Developers",
|
|
18
18
|
"Natural Language :: English",
|
|
19
19
|
"Operating System :: OS Independent",
|
|
20
|
-
"Programming Language :: Python :: 3.10",
|
|
21
|
-
"Programming Language :: Python :: 3.11",
|
|
22
20
|
"Programming Language :: Python :: 3.12",
|
|
23
21
|
"Programming Language :: Python :: 3.13",
|
|
24
22
|
"Programming Language :: Python :: 3.14",
|
|
@@ -26,19 +24,21 @@ classifiers = [
|
|
|
26
24
|
]
|
|
27
25
|
|
|
28
26
|
dependencies = [
|
|
29
|
-
"
|
|
30
|
-
"typer>=0.15,<1",
|
|
27
|
+
"orjson>=3.10",
|
|
31
28
|
]
|
|
29
|
+
|
|
32
30
|
urls."Bug Tracker" = "https://github.com/hvaclibs/steamloop/issues"
|
|
33
31
|
urls.Changelog = "https://github.com/hvaclibs/steamloop/blob/main/CHANGELOG.md"
|
|
34
32
|
urls.documentation = "https://steamloop.readthedocs.io"
|
|
35
33
|
urls.repository = "https://github.com/hvaclibs/steamloop"
|
|
36
|
-
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
steamloop = "steamloop.cli:main"
|
|
37
37
|
|
|
38
38
|
[dependency-groups]
|
|
39
39
|
dev = [
|
|
40
40
|
"pytest==9.*",
|
|
41
|
-
"
|
|
41
|
+
"pytest-asyncio",
|
|
42
42
|
"coverage",
|
|
43
43
|
"pytest-cov==7.*",
|
|
44
44
|
]
|
|
@@ -50,29 +50,70 @@ docs = [
|
|
|
50
50
|
]
|
|
51
51
|
|
|
52
52
|
[tool.ruff]
|
|
53
|
+
target-version = "py312"
|
|
53
54
|
line-length = 88
|
|
54
55
|
lint.select = [
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
56
|
+
"ASYNC", # async rules
|
|
57
|
+
"B", # flake8-bugbear
|
|
58
|
+
"BLE", # flake8-blind-except
|
|
59
|
+
"C", # complexity + comprehensions
|
|
60
|
+
"COM818", # Trailing comma on bare tuple prohibited
|
|
61
|
+
"D", # flake8-docstrings
|
|
62
|
+
"DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow()
|
|
63
|
+
"DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts)
|
|
64
|
+
"E", # pycodestyle
|
|
65
|
+
"F", # pyflakes
|
|
66
|
+
"FLY", # flynt
|
|
67
|
+
"FURB", # refurb
|
|
68
|
+
"G", # flake8-logging-format
|
|
69
|
+
"I", # isort
|
|
70
|
+
"ICN001", # import conventions; {name} should be imported as {asname}
|
|
71
|
+
"INP", # flake8-no-pep420
|
|
72
|
+
"ISC", # flake8-implicit-str-concat
|
|
73
|
+
"LOG", # flake8-logging
|
|
74
|
+
"N804", # First argument of a class method should be named cls
|
|
75
|
+
"N805", # First argument of a method should be named self
|
|
76
|
+
"N815", # Variable {name} in class scope should not be mixedCase
|
|
77
|
+
"PERF", # Perflint
|
|
78
|
+
"PGH", # pygrep-hooks
|
|
79
|
+
"PIE", # flake8-pie
|
|
80
|
+
"PL", # pylint
|
|
81
|
+
"PT", # flake8-pytest-style
|
|
82
|
+
"PTH", # flake8-pathlib
|
|
83
|
+
"PYI", # flake8-pyi
|
|
84
|
+
"RET", # flake8-return
|
|
85
|
+
"RSE", # flake8-raise
|
|
86
|
+
"RUF", # ruff specific
|
|
87
|
+
"S", # flake8-bandit
|
|
88
|
+
"SIM", # flake8-simplify
|
|
89
|
+
"SLF", # flake8-self
|
|
90
|
+
"SLOT", # flake8-slots
|
|
91
|
+
"T100", # Trace found: {name} used
|
|
92
|
+
"T20", # flake8-print
|
|
93
|
+
"TC", # flake8-type-checking
|
|
94
|
+
"TID", # Tidy imports
|
|
95
|
+
"TRY", # tryceratops
|
|
96
|
+
"UP", # pyupgrade
|
|
97
|
+
"W", # pycodestyle
|
|
65
98
|
]
|
|
66
99
|
lint.ignore = [
|
|
67
|
-
"D203",
|
|
68
|
-
"D212",
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
100
|
+
"D203", # 1 blank line required before class docstring
|
|
101
|
+
"D212", # Multi-line docstring summary should start at the first line
|
|
102
|
+
"D213", # Multi-line docstring summary should start at the second line
|
|
103
|
+
"D100", # Missing docstring in public module
|
|
104
|
+
"D104", # Missing docstring in public package
|
|
105
|
+
"D107", # Missing docstring in `__init__`
|
|
106
|
+
"D401", # First line of docstring should be in imperative mood
|
|
107
|
+
"COM812", # May conflict with the formatter
|
|
108
|
+
"ISC001", # May conflict with the formatter
|
|
109
|
+
"TRY003", # Avoid specifying long messages outside the exception class
|
|
110
|
+
"TRY301", # raise within try — used for exception wrapping
|
|
111
|
+
"PLR0913", # Too many arguments to function call
|
|
112
|
+
"PLR0911", # Too many return statements
|
|
113
|
+
"SIM110", # Reimplementing builtin — can be slower
|
|
73
114
|
]
|
|
74
115
|
lint.per-file-ignores."conftest.py" = [ "D100" ]
|
|
75
|
-
lint.per-file-ignores."docs/conf.py" = [ "D100" ]
|
|
116
|
+
lint.per-file-ignores."docs/conf.py" = [ "D100", "INP001" ]
|
|
76
117
|
lint.per-file-ignores."setup.py" = [ "D100" ]
|
|
77
118
|
lint.per-file-ignores."tests/**/*" = [
|
|
78
119
|
"D100",
|
|
@@ -80,14 +121,31 @@ lint.per-file-ignores."tests/**/*" = [
|
|
|
80
121
|
"D102",
|
|
81
122
|
"D103",
|
|
82
123
|
"D104",
|
|
124
|
+
"INP001",
|
|
125
|
+
"PLC0415",
|
|
126
|
+
"PLR2004",
|
|
127
|
+
"PT011",
|
|
128
|
+
"PT012",
|
|
83
129
|
"S101",
|
|
130
|
+
"S105",
|
|
131
|
+
"S106",
|
|
132
|
+
"SLF001",
|
|
133
|
+
"TC002",
|
|
134
|
+
"TRY003",
|
|
135
|
+
]
|
|
136
|
+
lint.per-file-ignores."src/steamloop/cli.py" = [
|
|
137
|
+
"C901", # _handle_command is inherently a command dispatcher
|
|
138
|
+
"PLR0912", # _handle_command has many branches by design
|
|
139
|
+
"PLR0915", # _handle_command has many statements by design
|
|
140
|
+
"PLR2004", # magic values in CLI arg counts are clear
|
|
141
|
+
"T201", # print() is the CLI output mechanism
|
|
84
142
|
]
|
|
85
143
|
lint.isort.known-first-party = [ "steamloop", "tests" ]
|
|
86
144
|
|
|
87
145
|
[tool.pyproject-fmt]
|
|
88
146
|
max_supported_python = "3.14"
|
|
89
147
|
|
|
90
|
-
[tool.pytest]
|
|
148
|
+
[tool.pytest.ini_options]
|
|
91
149
|
minversion = "9.0"
|
|
92
150
|
addopts = [
|
|
93
151
|
"-v",
|
|
@@ -98,6 +156,7 @@ addopts = [
|
|
|
98
156
|
"--cov-report=xml"
|
|
99
157
|
]
|
|
100
158
|
pythonpath = [ "src" ]
|
|
159
|
+
asyncio_mode = "auto"
|
|
101
160
|
|
|
102
161
|
[tool.coverage]
|
|
103
162
|
run.branch = true
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Local control for thermostat devices over mTLS."""
|
|
2
|
+
|
|
3
|
+
__version__ = "1.2.0"
|
|
4
|
+
|
|
5
|
+
from .connection import ThermostatConnection, load_pairing, save_pairing
|
|
6
|
+
from .const import DEFAULT_PORT, FanMode, HoldType, ZoneMode
|
|
7
|
+
from .exceptions import (
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
CommandError,
|
|
10
|
+
PairingError,
|
|
11
|
+
SteamloopConnectionError,
|
|
12
|
+
SteamloopError,
|
|
13
|
+
)
|
|
14
|
+
from .models import ThermostatState, Zone
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DEFAULT_PORT",
|
|
18
|
+
"AuthenticationError",
|
|
19
|
+
"CommandError",
|
|
20
|
+
"FanMode",
|
|
21
|
+
"HoldType",
|
|
22
|
+
"PairingError",
|
|
23
|
+
"SteamloopConnectionError",
|
|
24
|
+
"SteamloopError",
|
|
25
|
+
"ThermostatConnection",
|
|
26
|
+
"ThermostatState",
|
|
27
|
+
"Zone",
|
|
28
|
+
"ZoneMode",
|
|
29
|
+
"load_pairing",
|
|
30
|
+
"save_pairing",
|
|
31
|
+
]
|