steamloop 1.0.0__tar.gz → 1.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.
Files changed (40) hide show
  1. steamloop-1.1.0/PKG-INFO +223 -0
  2. steamloop-1.1.0/README.md +199 -0
  3. {steamloop-1.0.0 → steamloop-1.1.0}/pyproject.toml +85 -26
  4. steamloop-1.1.0/src/steamloop/__init__.py +31 -0
  5. steamloop-1.1.0/src/steamloop/__main__.py +5 -0
  6. steamloop-1.1.0/src/steamloop/certs.py +189 -0
  7. steamloop-1.1.0/src/steamloop/cli.py +311 -0
  8. steamloop-1.1.0/src/steamloop/connection.py +757 -0
  9. steamloop-1.1.0/src/steamloop/const.py +48 -0
  10. steamloop-1.1.0/src/steamloop/exceptions.py +21 -0
  11. steamloop-1.1.0/src/steamloop/models.py +149 -0
  12. steamloop-1.1.0/src/steamloop.egg-info/PKG-INFO +223 -0
  13. {steamloop-1.0.0 → steamloop-1.1.0}/src/steamloop.egg-info/SOURCES.txt +12 -2
  14. steamloop-1.1.0/src/steamloop.egg-info/entry_points.txt +2 -0
  15. steamloop-1.1.0/src/steamloop.egg-info/requires.txt +1 -0
  16. steamloop-1.1.0/tests/test_certs.py +35 -0
  17. steamloop-1.1.0/tests/test_cli.py +596 -0
  18. steamloop-1.1.0/tests/test_connection.py +960 -0
  19. steamloop-1.1.0/tests/test_const.py +48 -0
  20. {steamloop-1.0.0 → steamloop-1.1.0}/tests/test_dunder_main.py +6 -3
  21. steamloop-1.1.0/tests/test_exceptions.py +30 -0
  22. steamloop-1.1.0/tests/test_models.py +47 -0
  23. steamloop-1.1.0/tests/test_pairing.py +55 -0
  24. steamloop-1.1.0/tests/test_protocol.py +132 -0
  25. steamloop-1.0.0/PKG-INFO +0 -96
  26. steamloop-1.0.0/README.md +0 -69
  27. steamloop-1.0.0/src/steamloop/__init__.py +0 -1
  28. steamloop-1.0.0/src/steamloop/__main__.py +0 -5
  29. steamloop-1.0.0/src/steamloop/cli.py +0 -12
  30. steamloop-1.0.0/src/steamloop/main.py +0 -11
  31. steamloop-1.0.0/src/steamloop.egg-info/PKG-INFO +0 -96
  32. steamloop-1.0.0/src/steamloop.egg-info/entry_points.txt +0 -2
  33. steamloop-1.0.0/src/steamloop.egg-info/requires.txt +0 -2
  34. steamloop-1.0.0/tests/test_cli.py +0 -12
  35. steamloop-1.0.0/tests/test_main.py +0 -6
  36. {steamloop-1.0.0 → steamloop-1.1.0}/LICENSE +0 -0
  37. {steamloop-1.0.0 → steamloop-1.1.0}/setup.cfg +0 -0
  38. {steamloop-1.0.0 → steamloop-1.1.0}/src/steamloop/py.typed +0 -0
  39. {steamloop-1.0.0 → steamloop-1.1.0}/src/steamloop.egg-info/dependency_links.txt +0 -0
  40. {steamloop-1.0.0 → steamloop-1.1.0}/src/steamloop.egg-info/top_level.txt +0 -0
@@ -0,0 +1,223 @@
1
+ Metadata-Version: 2.4
2
+ Name: steamloop
3
+ Version: 1.1.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&amp;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 > Remote Access > Pair New Device), 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
+ Interactive commands: `status`, `heat <temp>`, `cool <temp>`, `mode <off|auto|cool|heat>`, `fan <auto|on|circulate>`, `eheat <on|off>`, `help`.
86
+
87
+ ## Library Usage
88
+
89
+ ```python
90
+ import asyncio
91
+ from steamloop import ThermostatConnection, ZoneMode, FanMode
92
+
93
+ async def main():
94
+ conn = ThermostatConnection(
95
+ "192.168.1.100",
96
+ secret_key="your-secret-key-from-pairing",
97
+ )
98
+ async with conn:
99
+ # State is populated automatically from thermostat events
100
+ for zone_id, zone in conn.state.zones.items():
101
+ print(f"{zone.name}: {zone.indoor_temperature}°F")
102
+
103
+ # Send commands (sync — no await needed)
104
+ conn.set_temperature_setpoint("1", heat_setpoint="72")
105
+ conn.set_zone_mode("1", ZoneMode.COOL)
106
+ conn.set_fan_mode(FanMode.AUTO)
107
+
108
+ asyncio.run(main())
109
+ ```
110
+
111
+ ### Pairing Programmatically
112
+
113
+ `pair()` returns the secret key directly — store it however you like:
114
+
115
+ ```python
116
+ from steamloop import ThermostatConnection
117
+
118
+ async def pair(ip: str) -> str:
119
+ conn = ThermostatConnection(ip, secret_key="")
120
+ try:
121
+ await conn.connect()
122
+ ssk = await conn.pair()
123
+ return ssk["secret_key"] # store in a database, config entry, etc.
124
+ finally:
125
+ await conn.disconnect()
126
+ ```
127
+
128
+ Or use the built-in file helpers to save/load pairing data to disk:
129
+
130
+ ```python
131
+ from steamloop import ThermostatConnection, save_pairing, load_pairing
132
+
133
+ # Save after pairing
134
+ await save_pairing(ip, {
135
+ "secret_key": secret_key,
136
+ "device_type": "automation",
137
+ "device_id": "module",
138
+ })
139
+
140
+ # Load later
141
+ pairing = await load_pairing(ip)
142
+ conn = ThermostatConnection(ip, secret_key=pairing["secret_key"])
143
+ ```
144
+
145
+ ### Event Callbacks
146
+
147
+ ```python
148
+ def on_event(msg):
149
+ print("Received:", msg)
150
+
151
+ remove = conn.add_event_callback(on_event)
152
+ # later: remove() to unregister
153
+ ```
154
+
155
+ ## Home Assistant Integration
156
+
157
+ Key design points for using steamloop in a Home Assistant integration:
158
+
159
+ - **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.
160
+ - **State is always fresh** — the `asyncio.Protocol` receives events via `data_received()` and updates `conn.state` automatically. Just read properties directly.
161
+ - **Auto-reconnect** — after calling `start_background_tasks()`, the connection automatically reconnects with exponential backoff (5s, 10s, 20s, ... up to 5 min).
162
+ - **Event callbacks** — use `add_event_callback()` to trigger `async_write_ha_state()` when the thermostat pushes updates.
163
+ - **Multi-zone** — create one `ClimateEntity` per `conn.state.zones` entry. Zones are populated automatically after login.
164
+
165
+ ## API Reference
166
+
167
+ ### `ThermostatConnection(ip, port=7878, *, secret_key, cert_set=None, device_type="automation", device_id="module")`
168
+
169
+ | Method | Async | Description |
170
+ | ------------------------------------------------------------------------------- | ----- | ----------------------------------------------------- |
171
+ | `connect()` | yes | Establish mTLS connection |
172
+ | `login()` | yes | Authenticate with secret key |
173
+ | `pair()` | yes | Pair and receive secret key |
174
+ | `start_background_tasks()` | no | Start heartbeat + auto-reconnect |
175
+ | `disconnect()` | yes | Close connection and stop tasks |
176
+ | `set_temperature_setpoint(zone_id, *, heat_setpoint, cool_setpoint, hold_type)` | no | Set zone temperature |
177
+ | `set_zone_mode(zone_id, mode)` | no | Set zone HVAC mode |
178
+ | `set_fan_mode(mode)` | no | Set fan mode |
179
+ | `set_emergency_heat(enabled)` | no | Toggle emergency heat |
180
+ | `add_event_callback(fn)` | no | Register event listener (returns unregister callable) |
181
+
182
+ Supports `async with` for automatic connect/login/disconnect:
183
+
184
+ ```python
185
+ async with ThermostatConnection(ip, secret_key=key) as conn:
186
+ ... # connected, logged in, background tasks running
187
+ # automatically disconnected
188
+ ```
189
+
190
+ ### Enums
191
+
192
+ - `ZoneMode` — `OFF`, `AUTO`, `COOL`, `HEAT`
193
+ - `FanMode` — `AUTO`, `ALWAYS_ON`, `CIRCULATE`
194
+ - `HoldType` — `UNDEFINED`, `MANUAL`, `SCHEDULE`, `HOLD`
195
+
196
+ ### State
197
+
198
+ - `conn.state.zones` — `dict[str, Zone]` with temperature, setpoints, mode per zone
199
+ - `conn.state.fan_mode` — current `FanMode`
200
+ - `conn.state.supported_modes` — `list[ZoneMode]`
201
+ - `conn.state.emergency_heat` / `relative_humidity` / `cooling_active` / `heating_active`
202
+
203
+ ## Contributors
204
+
205
+ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
206
+
207
+ <!-- prettier-ignore-start -->
208
+ <!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
209
+ <!-- markdownlint-disable -->
210
+ <!-- markdownlint-enable -->
211
+ <!-- ALL-CONTRIBUTORS-LIST:END -->
212
+ <!-- prettier-ignore-end -->
213
+
214
+ This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
215
+
216
+ ## Credits
217
+
218
+ [![Copier](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/copier-org/copier/master/img/badge/badge-grayscale-inverted-border-orange.json)](https://github.com/copier-org/copier)
219
+
220
+ This package was created with
221
+ [Copier](https://copier.readthedocs.io/) and the
222
+ [browniebroke/pypackage-template](https://github.com/browniebroke/pypackage-template)
223
+ project template.
@@ -0,0 +1,199 @@
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&amp;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 > Remote Access > Pair New Device), 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
+ Interactive commands: `status`, `heat <temp>`, `cool <temp>`, `mode <off|auto|cool|heat>`, `fan <auto|on|circulate>`, `eheat <on|off>`, `help`.
62
+
63
+ ## Library Usage
64
+
65
+ ```python
66
+ import asyncio
67
+ from steamloop import ThermostatConnection, ZoneMode, FanMode
68
+
69
+ async def main():
70
+ conn = ThermostatConnection(
71
+ "192.168.1.100",
72
+ secret_key="your-secret-key-from-pairing",
73
+ )
74
+ async with conn:
75
+ # State is populated automatically from thermostat events
76
+ for zone_id, zone in conn.state.zones.items():
77
+ print(f"{zone.name}: {zone.indoor_temperature}°F")
78
+
79
+ # Send commands (sync — no await needed)
80
+ conn.set_temperature_setpoint("1", heat_setpoint="72")
81
+ conn.set_zone_mode("1", ZoneMode.COOL)
82
+ conn.set_fan_mode(FanMode.AUTO)
83
+
84
+ asyncio.run(main())
85
+ ```
86
+
87
+ ### Pairing Programmatically
88
+
89
+ `pair()` returns the secret key directly — store it however you like:
90
+
91
+ ```python
92
+ from steamloop import ThermostatConnection
93
+
94
+ async def pair(ip: str) -> str:
95
+ conn = ThermostatConnection(ip, secret_key="")
96
+ try:
97
+ await conn.connect()
98
+ ssk = await conn.pair()
99
+ return ssk["secret_key"] # store in a database, config entry, etc.
100
+ finally:
101
+ await conn.disconnect()
102
+ ```
103
+
104
+ Or use the built-in file helpers to save/load pairing data to disk:
105
+
106
+ ```python
107
+ from steamloop import ThermostatConnection, save_pairing, load_pairing
108
+
109
+ # Save after pairing
110
+ await save_pairing(ip, {
111
+ "secret_key": secret_key,
112
+ "device_type": "automation",
113
+ "device_id": "module",
114
+ })
115
+
116
+ # Load later
117
+ pairing = await load_pairing(ip)
118
+ conn = ThermostatConnection(ip, secret_key=pairing["secret_key"])
119
+ ```
120
+
121
+ ### Event Callbacks
122
+
123
+ ```python
124
+ def on_event(msg):
125
+ print("Received:", msg)
126
+
127
+ remove = conn.add_event_callback(on_event)
128
+ # later: remove() to unregister
129
+ ```
130
+
131
+ ## Home Assistant Integration
132
+
133
+ Key design points for using steamloop in a Home Assistant integration:
134
+
135
+ - **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.
136
+ - **State is always fresh** — the `asyncio.Protocol` receives events via `data_received()` and updates `conn.state` automatically. Just read properties directly.
137
+ - **Auto-reconnect** — after calling `start_background_tasks()`, the connection automatically reconnects with exponential backoff (5s, 10s, 20s, ... up to 5 min).
138
+ - **Event callbacks** — use `add_event_callback()` to trigger `async_write_ha_state()` when the thermostat pushes updates.
139
+ - **Multi-zone** — create one `ClimateEntity` per `conn.state.zones` entry. Zones are populated automatically after login.
140
+
141
+ ## API Reference
142
+
143
+ ### `ThermostatConnection(ip, port=7878, *, secret_key, cert_set=None, device_type="automation", device_id="module")`
144
+
145
+ | Method | Async | Description |
146
+ | ------------------------------------------------------------------------------- | ----- | ----------------------------------------------------- |
147
+ | `connect()` | yes | Establish mTLS connection |
148
+ | `login()` | yes | Authenticate with secret key |
149
+ | `pair()` | yes | Pair and receive secret key |
150
+ | `start_background_tasks()` | no | Start heartbeat + auto-reconnect |
151
+ | `disconnect()` | yes | Close connection and stop tasks |
152
+ | `set_temperature_setpoint(zone_id, *, heat_setpoint, cool_setpoint, hold_type)` | no | Set zone temperature |
153
+ | `set_zone_mode(zone_id, mode)` | no | Set zone HVAC mode |
154
+ | `set_fan_mode(mode)` | no | Set fan mode |
155
+ | `set_emergency_heat(enabled)` | no | Toggle emergency heat |
156
+ | `add_event_callback(fn)` | no | Register event listener (returns unregister callable) |
157
+
158
+ Supports `async with` for automatic connect/login/disconnect:
159
+
160
+ ```python
161
+ async with ThermostatConnection(ip, secret_key=key) as conn:
162
+ ... # connected, logged in, background tasks running
163
+ # automatically disconnected
164
+ ```
165
+
166
+ ### Enums
167
+
168
+ - `ZoneMode` — `OFF`, `AUTO`, `COOL`, `HEAT`
169
+ - `FanMode` — `AUTO`, `ALWAYS_ON`, `CIRCULATE`
170
+ - `HoldType` — `UNDEFINED`, `MANUAL`, `SCHEDULE`, `HOLD`
171
+
172
+ ### State
173
+
174
+ - `conn.state.zones` — `dict[str, Zone]` with temperature, setpoints, mode per zone
175
+ - `conn.state.fan_mode` — current `FanMode`
176
+ - `conn.state.supported_modes` — `list[ZoneMode]`
177
+ - `conn.state.emergency_heat` / `relative_humidity` / `cooling_active` / `heating_active`
178
+
179
+ ## Contributors
180
+
181
+ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
182
+
183
+ <!-- prettier-ignore-start -->
184
+ <!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
185
+ <!-- markdownlint-disable -->
186
+ <!-- markdownlint-enable -->
187
+ <!-- ALL-CONTRIBUTORS-LIST:END -->
188
+ <!-- prettier-ignore-end -->
189
+
190
+ This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
191
+
192
+ ## Credits
193
+
194
+ [![Copier](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/copier-org/copier/master/img/badge/badge-grayscale-inverted-border-orange.json)](https://github.com/copier-org/copier)
195
+
196
+ This package was created with
197
+ [Copier](https://copier.readthedocs.io/) and the
198
+ [browniebroke/pypackage-template](https://github.com/browniebroke/pypackage-template)
199
+ project template.
@@ -4,21 +4,19 @@ requires = [ "setuptools>=77.0.3" ]
4
4
 
5
5
  [project]
6
6
  name = "steamloop"
7
- version = "1.0.0"
7
+ version = "1.1.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.10"
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
- "rich>=10",
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
- scripts.steamloop = "steamloop.cli:app"
34
+
35
+ [project.scripts]
36
+ steamloop = "steamloop.cli:main"
37
37
 
38
38
  [dependency-groups]
39
39
  dev = [
40
40
  "pytest==9.*",
41
- "sybil[pytest]",
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
- "B", # flake8-bugbear
56
- "D", # flake8-docstrings
57
- "C4", # flake8-comprehensions
58
- "S", # flake8-bandit
59
- "F", # pyflake
60
- "E", # pycodestyle
61
- "W", # pycodestyle
62
- "UP", # pyupgrade
63
- "I", # isort
64
- "RUF", # ruff specific
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", # 1 blank line required before class docstring
68
- "D212", # Multi-line docstring summary should start at the first line
69
- "D100", # Missing docstring in public module
70
- "D104", # Missing docstring in public package
71
- "D107", # Missing docstring in `__init__`
72
- "D401", # First line of docstring should be in imperative mood
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.1.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
+ ]
@@ -0,0 +1,5 @@
1
+ """Allow running as ``python -m steamloop``."""
2
+
3
+ from steamloop.cli import main
4
+
5
+ main()