Metadata-Version: 2.5
Name: pytriplesolar
Version: 0.1.0a2
Summary: Async library for Triple Solar PVT heat pumps over Modbus/TLS.
Project-URL: Homepage, https://github.com/florianschut/pytriplesolar
Project-URL: Source, https://github.com/florianschut/pytriplesolar
Project-URL: Issues, https://github.com/florianschut/pytriplesolar/issues
Project-URL: Changelog, https://github.com/florianschut/pytriplesolar/releases
Author: Florian Schut
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: heat-pump,modbus,modbus-tls,pvt,triple-solar
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Home Automation
Classifier: Typing :: Typed
Requires-Python: >=3.13
Requires-Dist: aiohttp>=3.11
Requires-Dist: cryptography>=44
Requires-Dist: modbus-connection<5,>=4.12.7
Provides-Extra: test
Requires-Dist: modbus-connection[tmodbus]<5,>=4.12.7; extra == 'test'
Requires-Dist: pytest-asyncio>=0.25; extra == 'test'
Requires-Dist: pytest>=8; extra == 'test'
Description-Content-Type: text/markdown

# pytriplesolar

Asynchronous Python library for the **Triple Solar PVT Heat Pump 5.0**, over
Modbus/TLS.

It is built on [`modbus-connection`](https://pypi.org/project/modbus-connection/):
the library describes the heat pump's registers as typed components and takes a
`ModbusUnit`. It never opens a connection itself, so the application decides
how and when to connect, and can share one connection between users.

## Requirements

- Python 3.13+
- A Triple Solar heat pump with Modbus TCP set to *secure* (the default), on
  the same network. Discovery uses mDNS, which does not cross routers or VLANs.
- Heat pump firmware 2026.09.30.0 or later.

## Pairing

Modbus/TLS uses mutual certificate authentication. A client pairs once to get
a certificate signed by the heat pump's own root CA:

1. Enable pairing mode in the My Triple Solar app. The heat pump then runs a
   single-use signing service for one minute, announced over mDNS-SD as
   `_ts-csr._tcp`.
2. Browse for that service with your zeroconf library of choice. Then pass its
   address, port and host name to `async_pair`:

```python
import aiohttp
from pytriplesolar import async_pair

async with aiohttp.ClientSession() as session:
    credentials = await async_pair(
        session, "192.0.2.10", 6509, device_hostname="hpcb-a1b2c3.local"
    )
```

`Credentials` holds the root CA, the client certificate and key (PEM), plus the
host name the heat pump was paired under. Store it with `as_dict()` and restore
it with `Credentials.from_dict()`. It contains a private key, so keep it
private.

## Reading and writing

Find the paired heat pump's `_mbap-s._tcp` service (port 802), then connect
with the credentials:

```python
from pathlib import Path

from modbus_connection.tmodbus import connect_tls
from pytriplesolar import Profile, SpaceConditioningMode, TripleSolarHeatPump

files = credentials.write_files(Path("certs"))  # blocking: ssl needs files
params = files.tls_params("192.0.2.10")

connection = await connect_tls(
    params.host,
    port=params.port,
    verify=params.verify,
    check_hostname=params.check_hostname,
    client_cert=params.client_cert,
    client_key=params.client_key,
)
heat_pump = TripleSolarHeatPump(connection.for_unit(1))

report = await heat_pump.async_update()
print(heat_pump.general.state, [str(a) for a in heat_pump.alerts.active])
print(heat_pump.dhw.tank_temperature, heat_pump.heat_pump_module_1.electric_power)

await heat_pump.settings.write("dhw_profile", Profile.COMFORT)
await heat_pump.dhw_comfort.write("tank_temperature_setpoint", 55.0)
await heat_pump.control.write("mode", SpaceConditioningMode.HEATING)
```

The server certificate is verified against the heat pump's root CA. That CA is
unique to the unit, so the connection is pinned to the paired heat pump. The
host name is not checked, because the heat pump is normally reached by IP.

## Sub-systems

| Attribute | Registers | Contents |
| --- | --- | --- |
| `general` | input | overall state, total rated power |
| `alerts` | input | up to 20 active alerts, decoded to service codes (`F10203`) |
| `dhw` | input | DHW state, demand, modulation, tank temperature and setpoint, valve, active profile |
| `space_heating` / `space_cooling` | input | state, demand, modulation, sink supply temperature and setpoint |
| `space_conditioning_input` | input | room/ambient temperature, humidity, room setpoint, thermostat modulation |
| `heat_pump_module_1` / `_2` | input | module state, compressor frequency, pumps, cooling valve, source/sink temperatures, electric/thermal power, compressor starts |
| `settings` | holding (R/W) | space heating / cooling / DHW enabled and profile |
| `dhw_eco` / `dhw_normal` / `dhw_comfort` | holding (R/W) | tank setpoint (10–70 °C), lower hysteresis (0–50 °C), modulation (20–100 %) per DHW profile |
| `control` | holding (R/W) | external control: mode, modulation level (0–100 %), power budget (W) |

`heat_pump_module_2` is `None` on a unit with a single module (detected on the
first update).

Each sub-system is read in as few requests as possible: a read may span
addresses the heat pump does not serve (they read as `0xFFFF`), up to 125
registers per request.

Values the heat pump does not currently have (for example, no room thermostat
connected, or the cooling temperatures while cooling is disabled) read as
`None`. The heat pump refuses a whole block of registers as soon as one value
in it is missing, so the library then re-reads that sub-system one register at
a time.

### Failures and stale values

A sub-system whose update fails (`report.failed` after `async_update()`, or an
exception from the component's own `async_update()`) drops its values: they
read as `None`, `component.available` is `False`, and its update listeners are
notified. It is `True` again after the next successful update. Map
`available` to entity availability; a single value that is missing from an
otherwise working sub-system is normal and does not make it unavailable.
`alerts.active` is empty while `alerts.available` is `False`.

### Writing

`await component.write(field, value)` validates the value, writes it, and then
reads the component back, so the new value shows up straight away and
listeners fire. The read-back is best effort: once the write was accepted, a
failing read-back does not raise (the component turns unavailable until the
next successful update).

### Rejected credentials

A heat pump that no longer accepts the client certificate (re-paired, or its
root CA replaced), or whose certificate does not match the stored root CA,
raises `CredentialsRejectedError` (a `ModbusConnectionError` subclass) from
`async_update()`, `async_poll()` and `write()` when the TLS error is in the
exception chain, as it is when connecting. `is_credentials_error(err)` applies
the same test to any exception, for example one from `connect_tls`.

Under TLS 1.3 the heat pump rejects an unknown client certificate only after
the handshake, and `modbus-connection` then reports a bare lost connection
without the TLS alert. After such a `ModbusConnectionError`, ask the heat pump:

```python
from pytriplesolar import CredentialsRejectedError, async_check_credentials

try:
    await async_check_credentials(params)  # the same ModbusTlsParams
except CredentialsRejectedError:
    ...  # pair again
except ModbusConnectionError:
    ...  # unreachable: retry later
```

It makes a short connection of its own and does not touch the one you hold.
If the heat pump just closes the connection without saying why, that is
reported as a plain `ModbusConnectionError`.

### External control

The `control` registers live in memory only. The heat pump acts on them only
while a Modbus client has been active within the last 30 seconds
(`CONTROL_KEEPALIVE_SECONDS`). Poll faster than that while controlling it.

## Development

```sh
python -m venv .venv
.venv/bin/pip install -e ".[test]"
.venv/bin/pytest
```

Tests run against `modbus-connection`'s in-memory mock unit via its pytest
plugin (`mock_modbus_unit` fixture), so no hardware is needed.

## License

Apache-2.0
