spacepak-modbus 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.
- spacepak_modbus-0.1.0/.gitattributes +1 -0
- spacepak_modbus-0.1.0/.github/renovate.json +47 -0
- spacepak_modbus-0.1.0/.github/workflows/ci.yml +36 -0
- spacepak_modbus-0.1.0/.github/workflows/publish.yml +52 -0
- spacepak_modbus-0.1.0/.gitignore +10 -0
- spacepak_modbus-0.1.0/LICENSE +24 -0
- spacepak_modbus-0.1.0/PKG-INFO +177 -0
- spacepak_modbus-0.1.0/README.md +161 -0
- spacepak_modbus-0.1.0/RELEASING.md +26 -0
- spacepak_modbus-0.1.0/pyproject.toml +61 -0
- spacepak_modbus-0.1.0/script/query.py +92 -0
- spacepak_modbus-0.1.0/src/spacepak_modbus/__init__.py +26 -0
- spacepak_modbus-0.1.0/src/spacepak_modbus/controls.py +68 -0
- spacepak_modbus-0.1.0/src/spacepak_modbus/device.py +58 -0
- spacepak_modbus-0.1.0/src/spacepak_modbus/enums.py +95 -0
- spacepak_modbus-0.1.0/src/spacepak_modbus/faults.py +245 -0
- spacepak_modbus-0.1.0/src/spacepak_modbus/measurements.py +57 -0
- spacepak_modbus-0.1.0/src/spacepak_modbus/py.typed +0 -0
- spacepak_modbus-0.1.0/src/spacepak_modbus/status.py +65 -0
- spacepak_modbus-0.1.0/tests/__init__.py +0 -0
- spacepak_modbus-0.1.0/tests/conftest.py +59 -0
- spacepak_modbus-0.1.0/tests/test_device.py +224 -0
- spacepak_modbus-0.1.0/tests/test_faults.py +56 -0
- spacepak_modbus-0.1.0/uv.lock +456 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* text=auto eol=lf
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
3
|
+
"description": "Renovate configuration for spacepak-modbus",
|
|
4
|
+
"dependencyDashboard": false,
|
|
5
|
+
"extends": [
|
|
6
|
+
"config:recommended",
|
|
7
|
+
":separateMajorReleases",
|
|
8
|
+
":combinePatchMinorReleases",
|
|
9
|
+
"helpers:pinGitHubActionDigests"
|
|
10
|
+
],
|
|
11
|
+
"rebaseWhen": "conflicted",
|
|
12
|
+
"ignorePaths": [
|
|
13
|
+
"**/node_modules/**"
|
|
14
|
+
],
|
|
15
|
+
"github-actions": {
|
|
16
|
+
"enabled": true
|
|
17
|
+
},
|
|
18
|
+
"lockFileMaintenance": {
|
|
19
|
+
"enabled": true,
|
|
20
|
+
"schedule": [
|
|
21
|
+
"before 5am on monday"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"packageRules": [
|
|
25
|
+
{
|
|
26
|
+
"matchManagers": [
|
|
27
|
+
"github-actions"
|
|
28
|
+
],
|
|
29
|
+
"groupName": "GitHub Actions",
|
|
30
|
+
"minimumReleaseAge": "3 days"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"matchDepTypes": [
|
|
34
|
+
"dev",
|
|
35
|
+
"test"
|
|
36
|
+
],
|
|
37
|
+
"groupName": "Dev & Test Dependencies",
|
|
38
|
+
"minimumReleaseAge": "3 days"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"matchPackageNames": [
|
|
42
|
+
"modbus-connection"
|
|
43
|
+
],
|
|
44
|
+
"groupName": "Core Protocol Dependencies"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Written by Claude, guided by Chris.
|
|
2
|
+
name: CI
|
|
3
|
+
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
pull_request:
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
lint:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
18
|
+
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
|
19
|
+
- name: Install dependencies
|
|
20
|
+
run: uv sync
|
|
21
|
+
- name: Ruff format
|
|
22
|
+
run: uv run ruff format --check .
|
|
23
|
+
- name: Ruff lint
|
|
24
|
+
run: uv run ruff check .
|
|
25
|
+
- name: Mypy
|
|
26
|
+
run: uv run mypy
|
|
27
|
+
|
|
28
|
+
test:
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
steps:
|
|
31
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
32
|
+
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
|
33
|
+
- name: Install dependencies
|
|
34
|
+
run: uv sync
|
|
35
|
+
- name: Run tests
|
|
36
|
+
run: uv run pytest
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Written by Claude, guided by Chris.
|
|
2
|
+
name: Publish to PyPI
|
|
3
|
+
|
|
4
|
+
on:
|
|
5
|
+
release:
|
|
6
|
+
types: [published]
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
inputs:
|
|
9
|
+
tag:
|
|
10
|
+
description: Existing release tag to build and publish (e.g. 4.0.1)
|
|
11
|
+
required: true
|
|
12
|
+
type: string
|
|
13
|
+
|
|
14
|
+
env:
|
|
15
|
+
RELEASE_TAG: ${{ inputs.tag || github.event.release.tag_name }}
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
build:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
permissions:
|
|
21
|
+
contents: read
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
24
|
+
with:
|
|
25
|
+
ref: ${{ env.RELEASE_TAG }}
|
|
26
|
+
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
|
27
|
+
- name: Set package version from release tag
|
|
28
|
+
run: |
|
|
29
|
+
VERSION="${RELEASE_TAG#v}"
|
|
30
|
+
sed -i "s/^version = \"0\\.0\\.0\"$/version = \"${VERSION}\"/" pyproject.toml
|
|
31
|
+
- name: Verify the version was substituted
|
|
32
|
+
run: |
|
|
33
|
+
VERSION="${RELEASE_TAG#v}"
|
|
34
|
+
grep -qx "version = \"${VERSION}\"" pyproject.toml
|
|
35
|
+
- run: uv build
|
|
36
|
+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
37
|
+
with:
|
|
38
|
+
name: dist
|
|
39
|
+
path: dist/
|
|
40
|
+
|
|
41
|
+
publish:
|
|
42
|
+
needs: build
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
environment: pypi
|
|
45
|
+
permissions:
|
|
46
|
+
id-token: write
|
|
47
|
+
steps:
|
|
48
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
|
49
|
+
with:
|
|
50
|
+
name: dist
|
|
51
|
+
path: dist/
|
|
52
|
+
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
This is free and unencumbered software released into the public domain.
|
|
2
|
+
|
|
3
|
+
Anyone is free to copy, modify, publish, use, compile, sell, or
|
|
4
|
+
distribute this software, either in source code form or as a compiled
|
|
5
|
+
binary, for any purpose, commercial or non-commercial, and by any
|
|
6
|
+
means.
|
|
7
|
+
|
|
8
|
+
In jurisdictions that recognize copyright laws, the author or authors
|
|
9
|
+
of this software dedicate any and all copyright interest in the
|
|
10
|
+
software to the public domain. We make this dedication for the benefit
|
|
11
|
+
of the public at large and to the detriment of our heirs and
|
|
12
|
+
successors. We intend this dedication to be an overt act of
|
|
13
|
+
relinquishment in perpetuity of all present and future rights to this
|
|
14
|
+
software under copyright law.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
19
|
+
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
20
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
21
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
22
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
|
23
|
+
|
|
24
|
+
For more information, please refer to <https://unlicense.org>
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: spacepak-modbus
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read and control SpacePak Solstice Inverter Extreme (ILAHP) heat pumps over Modbus.
|
|
5
|
+
Project-URL: Homepage, https://github.com/cwm12345/spacepak-modbus
|
|
6
|
+
Author: spacepak-modbus contributors
|
|
7
|
+
License: Unlicense
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: air-to-water,heat-pump,hvac,modbus,spacepak
|
|
10
|
+
Classifier: License :: OSI Approved :: The Unlicense (Unlicense)
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Home Automation
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Requires-Dist: modbus-connection[tmodbus]>=4.12.1
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
<!-- Written by Claude, guided by Chris. -->
|
|
18
|
+
|
|
19
|
+
# spacepak-modbus
|
|
20
|
+
|
|
21
|
+
Read and control SpacePak Solstice Inverter Extreme (ILAHP) air-to-water heat
|
|
22
|
+
pumps over Modbus, as typed Python objects rather than register numbers.
|
|
23
|
+
|
|
24
|
+
The library maps the ILAHP register set onto
|
|
25
|
+
[modbus-connection](https://github.com/home-assistant-libs/modbus-connection)'s
|
|
26
|
+
device model: you hand it a `ModbusUnit`, call `async_update()`, and read
|
|
27
|
+
sub-systems as attributes. It owns no connection and no I/O policy. The caller
|
|
28
|
+
does.
|
|
29
|
+
|
|
30
|
+
## Status
|
|
31
|
+
|
|
32
|
+
This is a working reference pulled from one real installation, shared in case
|
|
33
|
+
it is useful. It is **not an actively maintained package**: there is no
|
|
34
|
+
commitment to triage issues or pull requests, or to track future
|
|
35
|
+
modbus-connection changes. Forks are welcome, and so is anyone who wants to take
|
|
36
|
+
it over.
|
|
37
|
+
|
|
38
|
+
## Supported devices
|
|
39
|
+
|
|
40
|
+
The SpacePak Solstice Inverter Extreme (ILAHP) range, per the register map in
|
|
41
|
+
its installation manual (ILHP2-0423). It has been exercised against two units
|
|
42
|
+
behind a Modbus TCP gateway. Other SpacePak models are untested.
|
|
43
|
+
|
|
44
|
+
The unit speaks Modbus RTU at 9600 baud, 8N1, on its RS-485 port. Every
|
|
45
|
+
register this library reads is a holding register.
|
|
46
|
+
|
|
47
|
+
## What it covers
|
|
48
|
+
|
|
49
|
+
| Component | Registers | What |
|
|
50
|
+
| :--- | :--- | :--- |
|
|
51
|
+
| `controls` | 1011-1012, 1028, 1158-1165 | Power, operating mode, whether the hot water function is enabled, heating and cooling targets, the unit's own setpoint limits |
|
|
52
|
+
| `status` | 2011-2032 | Running, current mode (defrost included), load outputs, compressor hours |
|
|
53
|
+
| `measurements` | 2042-2072 | Water, air, coil and refrigerant temperatures, currents, voltages, compressor frequency |
|
|
54
|
+
| `faults` | 2081-2090 | The nine failure registers, decoded into named faults |
|
|
55
|
+
|
|
56
|
+
The manual lists room temperature (T09, 2058) and water flow (T39, 2077) as not
|
|
57
|
+
used on this unit, so they are left out. The hot water tank temperature (T08)
|
|
58
|
+
only has a sensor behind it when the unit's hot water function is enabled
|
|
59
|
+
(`controls.hot_water_enabled`).
|
|
60
|
+
|
|
61
|
+
The installer parameters (registers 1013-1270: compressor frequency curves, EEV
|
|
62
|
+
steps, defrost timing, weather compensation, timers) are deliberately left out.
|
|
63
|
+
Writing those by mistake would misconfigure the heat pump, not just misreport
|
|
64
|
+
it.
|
|
65
|
+
|
|
66
|
+
Every temperature is stored in tenths of a degree Celsius, whatever the unit's
|
|
67
|
+
display is set to show, so every temperature here is in °C.
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import asyncio
|
|
73
|
+
|
|
74
|
+
from modbus_connection import ModbusTcpParams
|
|
75
|
+
from modbus_connection.tmodbus import ModbusConnection
|
|
76
|
+
from spacepak_modbus import IlahpHeatPump
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def main() -> None:
|
|
80
|
+
connection = ModbusConnection(ModbusTcpParams(host="192.168.1.50", port=502))
|
|
81
|
+
try:
|
|
82
|
+
heat_pump = IlahpHeatPump(connection.for_unit(1))
|
|
83
|
+
await heat_pump.async_update()
|
|
84
|
+
|
|
85
|
+
print("Running:", heat_pump.status.running, heat_pump.status.unit_mode)
|
|
86
|
+
print("Outlet:", heat_pump.measurements.outlet_temperature, "°C")
|
|
87
|
+
print("Outdoor:", heat_pump.measurements.ambient_temperature, "°C")
|
|
88
|
+
print("Target:", heat_pump.controls.heating_target_temperature, "°C")
|
|
89
|
+
print("Faults:", [fault.key for fault in heat_pump.faults.active_faults])
|
|
90
|
+
finally:
|
|
91
|
+
await connection.close()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
asyncio.run(main())
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Readings and settings refresh separately
|
|
98
|
+
|
|
99
|
+
- `async_update_readings()` reads `status`, `measurements` and `faults`: what the
|
|
100
|
+
unit measures and reports.
|
|
101
|
+
- `async_update_settings()` reads `controls`: registers that change when
|
|
102
|
+
something writes them. Run one after a write to read back what took effect.
|
|
103
|
+
- `async_update()` does both, in one report.
|
|
104
|
+
|
|
105
|
+
Each returns an `UpdateReport`. A component whose block the unit refuses keeps
|
|
106
|
+
its previous values and is listed in `report.failed`; the rest still refresh. A
|
|
107
|
+
dead link (`ModbusConnectionError`) raises, and so does a timeout before
|
|
108
|
+
anything has answered.
|
|
109
|
+
|
|
110
|
+
### Writing
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
await heat_pump.controls.write("power_on", True)
|
|
114
|
+
await heat_pump.controls.write("heating_target_temperature", 45.0)
|
|
115
|
+
await heat_pump.async_update_settings()
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Setpoint writes are checked against the widest range each register accepts.
|
|
119
|
+
The unit applies its own tighter limits too, which it reports as
|
|
120
|
+
`min_heating_setpoint`/`max_heating_setpoint` and
|
|
121
|
+
`min_cooling_setpoint`/`max_cooling_setpoint`. Clamp to those before writing.
|
|
122
|
+
|
|
123
|
+
The operating mode is read-only here. The mode register's values depend on
|
|
124
|
+
whether the installer enabled cooling (parameter H05), and a wrong mode can
|
|
125
|
+
leave a hydronic system without heat.
|
|
126
|
+
|
|
127
|
+
### Faults
|
|
128
|
+
|
|
129
|
+
The unit reports faults as nine bitmask registers. `faults.active_faults`
|
|
130
|
+
decodes them into `Fault` entries, each with its failure register, bit, a
|
|
131
|
+
stable snake_case `key`, and a description. `FAULTS` is the full table of the
|
|
132
|
+
bits the manual names. A set bit the manual does not name (failure register 3
|
|
133
|
+
has no published bit table) is still reported, as `failure_<n>_bit_<b>`, so
|
|
134
|
+
nothing is hidden. `faults.any_fault` is true when any register has a bit set.
|
|
135
|
+
|
|
136
|
+
## Checking a real heat pump
|
|
137
|
+
|
|
138
|
+
`script/query.py` reads one unit once and prints every value, which is the
|
|
139
|
+
quickest way to see whether a unit is reachable and addressed correctly:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
uv run script/query.py 192.168.1.50 --port 502 --unit 1
|
|
143
|
+
uv run script/query.py socket://192.168.1.50:8899 --transport serial --unit 1
|
|
144
|
+
uv run script/query.py /dev/ttyUSB0 --transport serial --baudrate 9600 --unit 1 --raw
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`--raw` adds every register it read, undecoded, which is what an issue about a
|
|
148
|
+
wrong value should quote. A raw dump loads straight into modbus-connection's
|
|
149
|
+
mock with `load_raw()`, so it can become a test.
|
|
150
|
+
|
|
151
|
+
## Development
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
uv sync
|
|
155
|
+
uv run pytest
|
|
156
|
+
uv run ruff check . && uv run ruff format --check . && uv run mypy
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The tests run against modbus-connection's in-memory mock backend. No hardware
|
|
160
|
+
is needed.
|
|
161
|
+
|
|
162
|
+
## Source
|
|
163
|
+
|
|
164
|
+
The register map comes from the SpacePak ILAHP Installation, Operation &
|
|
165
|
+
Maintenance manual (ILHP2-0423), Modbus section. Addresses, scales and the
|
|
166
|
+
enum and bit tables follow it. On live units:
|
|
167
|
+
|
|
168
|
+
- 1011, 1158 and 1159 have been read and written.
|
|
169
|
+
- Every register this library reads has been read from two units and checked
|
|
170
|
+
against the values an existing integration reported for the same units.
|
|
171
|
+
- The operating mode (1012) and the setpoint limits (1162-1165) read as
|
|
172
|
+
expected. The limits are whatever the installer configured, so they differ
|
|
173
|
+
from the manual's defaults.
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
[Unlicense](LICENSE): public domain.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
<!-- Written by Claude, guided by Chris. -->
|
|
2
|
+
|
|
3
|
+
# spacepak-modbus
|
|
4
|
+
|
|
5
|
+
Read and control SpacePak Solstice Inverter Extreme (ILAHP) air-to-water heat
|
|
6
|
+
pumps over Modbus, as typed Python objects rather than register numbers.
|
|
7
|
+
|
|
8
|
+
The library maps the ILAHP register set onto
|
|
9
|
+
[modbus-connection](https://github.com/home-assistant-libs/modbus-connection)'s
|
|
10
|
+
device model: you hand it a `ModbusUnit`, call `async_update()`, and read
|
|
11
|
+
sub-systems as attributes. It owns no connection and no I/O policy. The caller
|
|
12
|
+
does.
|
|
13
|
+
|
|
14
|
+
## Status
|
|
15
|
+
|
|
16
|
+
This is a working reference pulled from one real installation, shared in case
|
|
17
|
+
it is useful. It is **not an actively maintained package**: there is no
|
|
18
|
+
commitment to triage issues or pull requests, or to track future
|
|
19
|
+
modbus-connection changes. Forks are welcome, and so is anyone who wants to take
|
|
20
|
+
it over.
|
|
21
|
+
|
|
22
|
+
## Supported devices
|
|
23
|
+
|
|
24
|
+
The SpacePak Solstice Inverter Extreme (ILAHP) range, per the register map in
|
|
25
|
+
its installation manual (ILHP2-0423). It has been exercised against two units
|
|
26
|
+
behind a Modbus TCP gateway. Other SpacePak models are untested.
|
|
27
|
+
|
|
28
|
+
The unit speaks Modbus RTU at 9600 baud, 8N1, on its RS-485 port. Every
|
|
29
|
+
register this library reads is a holding register.
|
|
30
|
+
|
|
31
|
+
## What it covers
|
|
32
|
+
|
|
33
|
+
| Component | Registers | What |
|
|
34
|
+
| :--- | :--- | :--- |
|
|
35
|
+
| `controls` | 1011-1012, 1028, 1158-1165 | Power, operating mode, whether the hot water function is enabled, heating and cooling targets, the unit's own setpoint limits |
|
|
36
|
+
| `status` | 2011-2032 | Running, current mode (defrost included), load outputs, compressor hours |
|
|
37
|
+
| `measurements` | 2042-2072 | Water, air, coil and refrigerant temperatures, currents, voltages, compressor frequency |
|
|
38
|
+
| `faults` | 2081-2090 | The nine failure registers, decoded into named faults |
|
|
39
|
+
|
|
40
|
+
The manual lists room temperature (T09, 2058) and water flow (T39, 2077) as not
|
|
41
|
+
used on this unit, so they are left out. The hot water tank temperature (T08)
|
|
42
|
+
only has a sensor behind it when the unit's hot water function is enabled
|
|
43
|
+
(`controls.hot_water_enabled`).
|
|
44
|
+
|
|
45
|
+
The installer parameters (registers 1013-1270: compressor frequency curves, EEV
|
|
46
|
+
steps, defrost timing, weather compensation, timers) are deliberately left out.
|
|
47
|
+
Writing those by mistake would misconfigure the heat pump, not just misreport
|
|
48
|
+
it.
|
|
49
|
+
|
|
50
|
+
Every temperature is stored in tenths of a degree Celsius, whatever the unit's
|
|
51
|
+
display is set to show, so every temperature here is in °C.
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import asyncio
|
|
57
|
+
|
|
58
|
+
from modbus_connection import ModbusTcpParams
|
|
59
|
+
from modbus_connection.tmodbus import ModbusConnection
|
|
60
|
+
from spacepak_modbus import IlahpHeatPump
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def main() -> None:
|
|
64
|
+
connection = ModbusConnection(ModbusTcpParams(host="192.168.1.50", port=502))
|
|
65
|
+
try:
|
|
66
|
+
heat_pump = IlahpHeatPump(connection.for_unit(1))
|
|
67
|
+
await heat_pump.async_update()
|
|
68
|
+
|
|
69
|
+
print("Running:", heat_pump.status.running, heat_pump.status.unit_mode)
|
|
70
|
+
print("Outlet:", heat_pump.measurements.outlet_temperature, "°C")
|
|
71
|
+
print("Outdoor:", heat_pump.measurements.ambient_temperature, "°C")
|
|
72
|
+
print("Target:", heat_pump.controls.heating_target_temperature, "°C")
|
|
73
|
+
print("Faults:", [fault.key for fault in heat_pump.faults.active_faults])
|
|
74
|
+
finally:
|
|
75
|
+
await connection.close()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
asyncio.run(main())
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Readings and settings refresh separately
|
|
82
|
+
|
|
83
|
+
- `async_update_readings()` reads `status`, `measurements` and `faults`: what the
|
|
84
|
+
unit measures and reports.
|
|
85
|
+
- `async_update_settings()` reads `controls`: registers that change when
|
|
86
|
+
something writes them. Run one after a write to read back what took effect.
|
|
87
|
+
- `async_update()` does both, in one report.
|
|
88
|
+
|
|
89
|
+
Each returns an `UpdateReport`. A component whose block the unit refuses keeps
|
|
90
|
+
its previous values and is listed in `report.failed`; the rest still refresh. A
|
|
91
|
+
dead link (`ModbusConnectionError`) raises, and so does a timeout before
|
|
92
|
+
anything has answered.
|
|
93
|
+
|
|
94
|
+
### Writing
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
await heat_pump.controls.write("power_on", True)
|
|
98
|
+
await heat_pump.controls.write("heating_target_temperature", 45.0)
|
|
99
|
+
await heat_pump.async_update_settings()
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Setpoint writes are checked against the widest range each register accepts.
|
|
103
|
+
The unit applies its own tighter limits too, which it reports as
|
|
104
|
+
`min_heating_setpoint`/`max_heating_setpoint` and
|
|
105
|
+
`min_cooling_setpoint`/`max_cooling_setpoint`. Clamp to those before writing.
|
|
106
|
+
|
|
107
|
+
The operating mode is read-only here. The mode register's values depend on
|
|
108
|
+
whether the installer enabled cooling (parameter H05), and a wrong mode can
|
|
109
|
+
leave a hydronic system without heat.
|
|
110
|
+
|
|
111
|
+
### Faults
|
|
112
|
+
|
|
113
|
+
The unit reports faults as nine bitmask registers. `faults.active_faults`
|
|
114
|
+
decodes them into `Fault` entries, each with its failure register, bit, a
|
|
115
|
+
stable snake_case `key`, and a description. `FAULTS` is the full table of the
|
|
116
|
+
bits the manual names. A set bit the manual does not name (failure register 3
|
|
117
|
+
has no published bit table) is still reported, as `failure_<n>_bit_<b>`, so
|
|
118
|
+
nothing is hidden. `faults.any_fault` is true when any register has a bit set.
|
|
119
|
+
|
|
120
|
+
## Checking a real heat pump
|
|
121
|
+
|
|
122
|
+
`script/query.py` reads one unit once and prints every value, which is the
|
|
123
|
+
quickest way to see whether a unit is reachable and addressed correctly:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
uv run script/query.py 192.168.1.50 --port 502 --unit 1
|
|
127
|
+
uv run script/query.py socket://192.168.1.50:8899 --transport serial --unit 1
|
|
128
|
+
uv run script/query.py /dev/ttyUSB0 --transport serial --baudrate 9600 --unit 1 --raw
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`--raw` adds every register it read, undecoded, which is what an issue about a
|
|
132
|
+
wrong value should quote. A raw dump loads straight into modbus-connection's
|
|
133
|
+
mock with `load_raw()`, so it can become a test.
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
uv sync
|
|
139
|
+
uv run pytest
|
|
140
|
+
uv run ruff check . && uv run ruff format --check . && uv run mypy
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The tests run against modbus-connection's in-memory mock backend. No hardware
|
|
144
|
+
is needed.
|
|
145
|
+
|
|
146
|
+
## Source
|
|
147
|
+
|
|
148
|
+
The register map comes from the SpacePak ILAHP Installation, Operation &
|
|
149
|
+
Maintenance manual (ILHP2-0423), Modbus section. Addresses, scales and the
|
|
150
|
+
enum and bit tables follow it. On live units:
|
|
151
|
+
|
|
152
|
+
- 1011, 1158 and 1159 have been read and written.
|
|
153
|
+
- Every register this library reads has been read from two units and checked
|
|
154
|
+
against the values an existing integration reported for the same units.
|
|
155
|
+
- The operating mode (1012) and the setpoint limits (1162-1165) read as
|
|
156
|
+
expected. The limits are whatever the installer configured, so they differ
|
|
157
|
+
from the manual's defaults.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
[Unlicense](LICENSE): public domain.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<!-- Written by Claude, guided by Chris. -->
|
|
2
|
+
|
|
3
|
+
# Releasing `spacepak-modbus`
|
|
4
|
+
|
|
5
|
+
This document describes how to release a new version of `spacepak-modbus` to PyPI.
|
|
6
|
+
|
|
7
|
+
## Tagging Convention
|
|
8
|
+
|
|
9
|
+
> [!IMPORTANT]
|
|
10
|
+
> **Release tags must be bare semantic versions without a leading `v`** (e.g. `0.1.5`, `1.0.0`).
|
|
11
|
+
> Do not use `v`-prefixed tags like `v0.1.5`.
|
|
12
|
+
|
|
13
|
+
Release tags are directly substituted into `pyproject.toml` during the release workflow to set the package version according to PEP 440.
|
|
14
|
+
|
|
15
|
+
## Release Process
|
|
16
|
+
|
|
17
|
+
1. **Verify CI**: Ensure that the `CI` workflow is green on the `main` branch.
|
|
18
|
+
2. **Draft a GitHub Release**:
|
|
19
|
+
- Go to GitHub Releases: **Draft a new release**.
|
|
20
|
+
- **Choose a tag**: Enter the new version number as a bare tag (e.g. `0.1.5`).
|
|
21
|
+
- **Target**: `main`.
|
|
22
|
+
- **Title**: Enter the version number (e.g. `0.1.5`).
|
|
23
|
+
- **Description**: Provide release notes / changelog.
|
|
24
|
+
3. **Publish the Release**:
|
|
25
|
+
- Click **Publish release**.
|
|
26
|
+
- The `.github/workflows/publish.yml` workflow will automatically trigger, build the wheel and source distribution, and publish the package to PyPI via Trusted Publishing.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Written by Claude, guided by Chris.
|
|
2
|
+
[build-system]
|
|
3
|
+
requires = ["hatchling"]
|
|
4
|
+
build-backend = "hatchling.build"
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "spacepak-modbus"
|
|
8
|
+
version = "0.1.0"
|
|
9
|
+
description = "Read and control SpacePak Solstice Inverter Extreme (ILAHP) heat pumps over Modbus."
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
license = { text = "Unlicense" }
|
|
13
|
+
authors = [{ name = "spacepak-modbus contributors" }]
|
|
14
|
+
keywords = ["modbus", "spacepak", "heat-pump", "air-to-water", "hvac"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"License :: OSI Approved :: The Unlicense (Unlicense)",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Home Automation",
|
|
19
|
+
]
|
|
20
|
+
# The caller injects a ModbusUnit; tmodbus is the backend this is exercised against.
|
|
21
|
+
dependencies = ["modbus-connection[tmodbus]>=4.12.1"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/cwm12345/spacepak-modbus"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"mypy>=1.11",
|
|
29
|
+
"pytest>=8",
|
|
30
|
+
"pytest-asyncio>=0.24",
|
|
31
|
+
"ruff>=0.6",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/spacepak_modbus"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
asyncio_mode = "auto"
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
target-version = "py312"
|
|
43
|
+
src = ["src"]
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
select = ["E", "F", "W", "I", "UP", "B", "D"]
|
|
47
|
+
# D401 wants imperative docstrings; this codebase names things, matching
|
|
48
|
+
# modbus-connection's own style. D107 is covered by the class docstring.
|
|
49
|
+
ignore = ["D107", "D401"]
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint.pydocstyle]
|
|
52
|
+
convention = "pep257"
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint.per-file-ignores]
|
|
55
|
+
# A test's name is its documentation; docstrings are for the ones that need a why.
|
|
56
|
+
"tests/*" = ["D100", "D103", "D104"]
|
|
57
|
+
|
|
58
|
+
[tool.mypy]
|
|
59
|
+
python_version = "3.12"
|
|
60
|
+
files = ["src", "tests", "script"]
|
|
61
|
+
strict = true
|