pytriplesolar 0.1.0a1__py3-none-any.whl
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.
- pytriplesolar/__init__.py +97 -0
- pytriplesolar/alerts.py +83 -0
- pytriplesolar/components.py +334 -0
- pytriplesolar/const.py +31 -0
- pytriplesolar/credentials.py +145 -0
- pytriplesolar/device.py +128 -0
- pytriplesolar/enums.py +101 -0
- pytriplesolar/exceptions.py +19 -0
- pytriplesolar/pairing.py +197 -0
- pytriplesolar/py.typed +0 -0
- pytriplesolar-0.1.0a1.dist-info/METADATA +150 -0
- pytriplesolar-0.1.0a1.dist-info/RECORD +14 -0
- pytriplesolar-0.1.0a1.dist-info/WHEEL +4 -0
- pytriplesolar-0.1.0a1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""pytriplesolar: talk to a Triple Solar PVT heat pump over Modbus/TLS.
|
|
2
|
+
|
|
3
|
+
Built on ``modbus-connection``: the library takes a ``ModbusUnit`` and never
|
|
4
|
+
opens a connection itself.
|
|
5
|
+
|
|
6
|
+
1. Pair once (pairing mode enabled in the My Triple Solar app) with
|
|
7
|
+
:func:`async_pair` to obtain :class:`Credentials`.
|
|
8
|
+
2. Write them to disk with :meth:`Credentials.write_files` and build the
|
|
9
|
+
connection from :meth:`CredentialFiles.tls_params`.
|
|
10
|
+
3. Wrap the unit in :class:`TripleSolarHeatPump` and poll it::
|
|
11
|
+
|
|
12
|
+
heat_pump = TripleSolarHeatPump(unit)
|
|
13
|
+
await heat_pump.async_update()
|
|
14
|
+
heat_pump.general.state
|
|
15
|
+
heat_pump.dhw.tank_temperature
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .alerts import Alert
|
|
19
|
+
from .components import (
|
|
20
|
+
DHW_PROFILE_INDEX,
|
|
21
|
+
Alerts,
|
|
22
|
+
Control,
|
|
23
|
+
DhwProfileSettings,
|
|
24
|
+
DomesticHotWater,
|
|
25
|
+
General,
|
|
26
|
+
HeatPumpModule,
|
|
27
|
+
Settings,
|
|
28
|
+
SpaceConditioningInput,
|
|
29
|
+
SpaceCooling,
|
|
30
|
+
SpaceHeating,
|
|
31
|
+
TripleSolarComponent,
|
|
32
|
+
)
|
|
33
|
+
from .const import (
|
|
34
|
+
CONTROL_KEEPALIVE_SECONDS,
|
|
35
|
+
DEFAULT_UNIT_ID,
|
|
36
|
+
MODBUS_TLS_PORT,
|
|
37
|
+
MODBUS_TLS_SERVICE_TYPE,
|
|
38
|
+
PAIRING_SERVICE_TYPE,
|
|
39
|
+
SIGNING_SERVICE_PORT,
|
|
40
|
+
)
|
|
41
|
+
from .credentials import CredentialFiles, Credentials, normalize_hostname
|
|
42
|
+
from .device import SUBSYSTEMS, TripleSolarHeatPump
|
|
43
|
+
from .enums import (
|
|
44
|
+
AlertType,
|
|
45
|
+
DhwState,
|
|
46
|
+
HeatPumpModuleState,
|
|
47
|
+
HeatPumpState,
|
|
48
|
+
Profile,
|
|
49
|
+
SpaceConditioningMode,
|
|
50
|
+
SpaceConditioningState,
|
|
51
|
+
)
|
|
52
|
+
from .exceptions import (
|
|
53
|
+
InvalidCredentialsError,
|
|
54
|
+
PairingError,
|
|
55
|
+
TripleSolarError,
|
|
56
|
+
TripleSolarValueError,
|
|
57
|
+
)
|
|
58
|
+
from .pairing import async_pair
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"CONTROL_KEEPALIVE_SECONDS",
|
|
62
|
+
"DEFAULT_UNIT_ID",
|
|
63
|
+
"DHW_PROFILE_INDEX",
|
|
64
|
+
"MODBUS_TLS_PORT",
|
|
65
|
+
"MODBUS_TLS_SERVICE_TYPE",
|
|
66
|
+
"PAIRING_SERVICE_TYPE",
|
|
67
|
+
"SIGNING_SERVICE_PORT",
|
|
68
|
+
"SUBSYSTEMS",
|
|
69
|
+
"Alert",
|
|
70
|
+
"AlertType",
|
|
71
|
+
"Alerts",
|
|
72
|
+
"Control",
|
|
73
|
+
"CredentialFiles",
|
|
74
|
+
"Credentials",
|
|
75
|
+
"DhwProfileSettings",
|
|
76
|
+
"DhwState",
|
|
77
|
+
"DomesticHotWater",
|
|
78
|
+
"General",
|
|
79
|
+
"HeatPumpModule",
|
|
80
|
+
"HeatPumpModuleState",
|
|
81
|
+
"HeatPumpState",
|
|
82
|
+
"InvalidCredentialsError",
|
|
83
|
+
"PairingError",
|
|
84
|
+
"Profile",
|
|
85
|
+
"Settings",
|
|
86
|
+
"SpaceConditioningInput",
|
|
87
|
+
"SpaceConditioningMode",
|
|
88
|
+
"SpaceConditioningState",
|
|
89
|
+
"SpaceCooling",
|
|
90
|
+
"SpaceHeating",
|
|
91
|
+
"TripleSolarComponent",
|
|
92
|
+
"TripleSolarError",
|
|
93
|
+
"TripleSolarHeatPump",
|
|
94
|
+
"TripleSolarValueError",
|
|
95
|
+
"async_pair",
|
|
96
|
+
"normalize_hostname",
|
|
97
|
+
]
|
pytriplesolar/alerts.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Decode the alert codes the heat pump reports.
|
|
2
|
+
|
|
3
|
+
An alert is a 16-bit value packing, from least to most significant bit:
|
|
4
|
+
|
|
5
|
+
* 5 bits: fault number within the component
|
|
6
|
+
* 5 bits: component within the module
|
|
7
|
+
* 4 bits: module
|
|
8
|
+
* 2 bits: type (none / warning / fault / info)
|
|
9
|
+
|
|
10
|
+
This mirrors the firmware's ``hw`` bit layout and ``faults.Fault.Code()``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from .enums import AlertType
|
|
18
|
+
|
|
19
|
+
_BITS_VALUE = 5
|
|
20
|
+
_BITS_COMPONENT = 5
|
|
21
|
+
_BITS_MODULE = 4
|
|
22
|
+
_BITS_TYPE = 2
|
|
23
|
+
|
|
24
|
+
_SHIFT_COMPONENT = _BITS_VALUE
|
|
25
|
+
_SHIFT_MODULE = _SHIFT_COMPONENT + _BITS_COMPONENT
|
|
26
|
+
_SHIFT_TYPE = _SHIFT_MODULE + _BITS_MODULE
|
|
27
|
+
|
|
28
|
+
_MASK_VALUE = (1 << _BITS_VALUE) - 1
|
|
29
|
+
_MASK_COMPONENT = (1 << _BITS_COMPONENT) - 1
|
|
30
|
+
_MASK_MODULE = (1 << _BITS_MODULE) - 1
|
|
31
|
+
_MASK_TYPE = (1 << _BITS_TYPE) - 1
|
|
32
|
+
|
|
33
|
+
_PREFIX = {
|
|
34
|
+
AlertType.NONE: "O",
|
|
35
|
+
AlertType.WARNING: "W",
|
|
36
|
+
AlertType.FAULT: "F",
|
|
37
|
+
AlertType.INFO: "I",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class Alert:
|
|
43
|
+
"""One active alert (notification, warning or fault)."""
|
|
44
|
+
|
|
45
|
+
raw: int
|
|
46
|
+
"""The 16-bit value as reported."""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def type(self) -> AlertType:
|
|
50
|
+
"""Severity of the alert."""
|
|
51
|
+
return AlertType((self.raw >> _SHIFT_TYPE) & _MASK_TYPE)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def module(self) -> int:
|
|
55
|
+
"""Module number the alert belongs to."""
|
|
56
|
+
return (self.raw >> _SHIFT_MODULE) & _MASK_MODULE
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def component(self) -> int:
|
|
60
|
+
"""Component number within the module."""
|
|
61
|
+
return (self.raw >> _SHIFT_COMPONENT) & _MASK_COMPONENT
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def number(self) -> int:
|
|
65
|
+
"""Fault number within the component."""
|
|
66
|
+
return self.raw & _MASK_VALUE
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def code(self) -> str:
|
|
70
|
+
"""Service code as shown by Triple Solar, e.g. ``F10203``."""
|
|
71
|
+
return (
|
|
72
|
+
f"{_PREFIX[self.type]}{self.module:01d}"
|
|
73
|
+
f"{self.component:02d}{self.number:02d}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def is_fault(self) -> bool:
|
|
78
|
+
"""Whether the alert is a fault (rather than a warning or notice)."""
|
|
79
|
+
return self.type is AlertType.FAULT
|
|
80
|
+
|
|
81
|
+
def __str__(self) -> str:
|
|
82
|
+
"""Return the service code."""
|
|
83
|
+
return self.code
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Typed register map of the Triple Solar PVT Heat Pump 5.0.
|
|
2
|
+
|
|
3
|
+
Addresses, types and scaling mirror the controller firmware's Modbus server
|
|
4
|
+
(status registers as input registers, settings and control as holding
|
|
5
|
+
registers). Every temperature, percentage and frequency is a signed 16-bit
|
|
6
|
+
value in tenths; powers are signed 16-bit watts.
|
|
7
|
+
|
|
8
|
+
The firmware fails a whole block read with *server device failure* as soon as
|
|
9
|
+
one value in it is unavailable (a sensor that is not fitted, a status value not
|
|
10
|
+
yet published). :class:`TripleSolarComponent` falls back to reading such a
|
|
11
|
+
component register by register, so the values that do exist still update and
|
|
12
|
+
the missing ones read as ``None``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from enum import IntEnum
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from modbus_connection import ServerDeviceFailureError
|
|
23
|
+
from modbus_connection.model import (
|
|
24
|
+
Component,
|
|
25
|
+
RegisterSpace,
|
|
26
|
+
boolean,
|
|
27
|
+
enum,
|
|
28
|
+
gauge,
|
|
29
|
+
integer,
|
|
30
|
+
raw_register,
|
|
31
|
+
repeating_group,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from .alerts import Alert
|
|
35
|
+
from .const import ALERT_SLOTS, CONTROL_KEEPALIVE_SECONDS
|
|
36
|
+
from .enums import (
|
|
37
|
+
DhwState,
|
|
38
|
+
HeatPumpModuleState,
|
|
39
|
+
HeatPumpState,
|
|
40
|
+
Profile,
|
|
41
|
+
SpaceConditioningMode,
|
|
42
|
+
SpaceConditioningState,
|
|
43
|
+
)
|
|
44
|
+
from .exceptions import TripleSolarValueError
|
|
45
|
+
|
|
46
|
+
_LOGGER = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
# -- write validators ----------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _bool(value: Any) -> bool:
|
|
52
|
+
"""Accept only real booleans (the firmware rejects anything but 0/1)."""
|
|
53
|
+
if not isinstance(value, bool):
|
|
54
|
+
raise TripleSolarValueError(f"expected a bool, got {value!r}")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _between(low: float, high: float) -> Callable[[Any], float]:
|
|
59
|
+
"""Build a validator accepting ``low <= value <= high``."""
|
|
60
|
+
|
|
61
|
+
def validate(value: Any) -> float:
|
|
62
|
+
number = float(value)
|
|
63
|
+
if not low <= number <= high:
|
|
64
|
+
raise TripleSolarValueError(f"{number} is outside {low}..{high}")
|
|
65
|
+
return number
|
|
66
|
+
|
|
67
|
+
return validate
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _member[E: IntEnum](enum_type: type[E]) -> Callable[[Any], E]:
|
|
71
|
+
"""Build a validator accepting only members of ``enum_type``."""
|
|
72
|
+
|
|
73
|
+
def validate(value: Any) -> E:
|
|
74
|
+
try:
|
|
75
|
+
return enum_type(value)
|
|
76
|
+
except ValueError:
|
|
77
|
+
raise TripleSolarValueError(
|
|
78
|
+
f"{value!r} is not a valid {enum_type.__name__}"
|
|
79
|
+
) from None
|
|
80
|
+
|
|
81
|
+
return validate
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# -- field helpers -------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _tenths(
|
|
88
|
+
address: int,
|
|
89
|
+
unit: str,
|
|
90
|
+
*,
|
|
91
|
+
stride: int = 0,
|
|
92
|
+
writable: bool | Callable[[Any], Any] = False,
|
|
93
|
+
) -> Any:
|
|
94
|
+
"""A signed 16-bit value with one decimal (``value * 10`` on the wire)."""
|
|
95
|
+
return gauge(address, 0.1, signed=True, unit=unit, stride=stride, writable=writable)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _watts(address: int, *, stride: int = 0) -> Any:
|
|
99
|
+
"""A signed 16-bit power in watts."""
|
|
100
|
+
return integer(address, signed=True, unit="W", stride=stride)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# -- base component ------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TripleSolarComponent(Component):
|
|
107
|
+
"""A heat pump sub-system that tolerates individually missing values."""
|
|
108
|
+
|
|
109
|
+
register_space: RegisterSpace = "input"
|
|
110
|
+
|
|
111
|
+
async def async_update(self, *, notify: bool = True) -> None:
|
|
112
|
+
"""Read this component, register by register if a block is refused.
|
|
113
|
+
|
|
114
|
+
Raises ``ServerDeviceFailureError`` only if not a single value could be
|
|
115
|
+
read. Any other ``ModbusError`` propagates unchanged.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
await super().async_update(notify=notify)
|
|
119
|
+
except ServerDeviceFailureError as err:
|
|
120
|
+
_LOGGER.debug(
|
|
121
|
+
"%s: block read refused (%s), reading per register",
|
|
122
|
+
type(self).__name__,
|
|
123
|
+
err,
|
|
124
|
+
)
|
|
125
|
+
if not await self._async_update_per_register():
|
|
126
|
+
raise
|
|
127
|
+
if notify:
|
|
128
|
+
self.notify()
|
|
129
|
+
|
|
130
|
+
async def _async_update_per_register(self) -> bool:
|
|
131
|
+
"""Read each field on its own; return whether any value was available."""
|
|
132
|
+
unit = self.modbus_unit
|
|
133
|
+
read = (
|
|
134
|
+
unit.read_input_registers
|
|
135
|
+
if self.register_space == "input"
|
|
136
|
+
else unit.read_holding_registers
|
|
137
|
+
)
|
|
138
|
+
values: dict[str, Any] = {}
|
|
139
|
+
any_read = False
|
|
140
|
+
for name, resolved in self.resolved_fields.items():
|
|
141
|
+
try:
|
|
142
|
+
words = await read(resolved.address, resolved.count)
|
|
143
|
+
except ServerDeviceFailureError:
|
|
144
|
+
values[name] = None
|
|
145
|
+
continue
|
|
146
|
+
values[name] = resolved.field.decode(words)
|
|
147
|
+
any_read = True
|
|
148
|
+
if any_read:
|
|
149
|
+
# Same store the block read decodes into (see ReadPlan.execute).
|
|
150
|
+
self._values.update(values)
|
|
151
|
+
return any_read
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# -- status (input registers) --------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class General(TripleSolarComponent):
|
|
158
|
+
"""Unit-wide status."""
|
|
159
|
+
|
|
160
|
+
state = enum(0x0000, HeatPumpState, signed=True)
|
|
161
|
+
"""Overall heat pump state."""
|
|
162
|
+
|
|
163
|
+
rated_power = _watts(0x0016)
|
|
164
|
+
"""Total rated power of this unit's own heat sources."""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _AlertSlot(TripleSolarComponent):
|
|
168
|
+
"""One of the alert slots; 0 means the slot is empty."""
|
|
169
|
+
|
|
170
|
+
code = raw_register(0x0001)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Alerts(Component):
|
|
174
|
+
"""Active alerts (notifications, warnings and faults)."""
|
|
175
|
+
|
|
176
|
+
register_space = "input"
|
|
177
|
+
|
|
178
|
+
slots = repeating_group(ALERT_SLOTS, _AlertSlot, stride=1)
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def active(self) -> list[Alert]:
|
|
182
|
+
"""The alerts currently raised, in reported order."""
|
|
183
|
+
return [Alert(slot.code) for slot in self.slots if slot.code]
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def has_fault(self) -> bool:
|
|
187
|
+
"""Whether any active alert is a fault."""
|
|
188
|
+
return any(alert.is_fault for alert in self.active)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class DomesticHotWater(TripleSolarComponent):
|
|
192
|
+
"""Domestic hot water (DHW) controller status."""
|
|
193
|
+
|
|
194
|
+
state = enum(0x0020, DhwState, signed=True)
|
|
195
|
+
has_demand = boolean(0x0021)
|
|
196
|
+
heat_pump_modulation = _tenths(0x0022, "%")
|
|
197
|
+
"""Share of the available heat pump power used for DHW."""
|
|
198
|
+
tank_temperature = _tenths(0x0025, "°C")
|
|
199
|
+
tank_temperature_setpoint = _tenths(0x0026, "°C")
|
|
200
|
+
valve_enabled = boolean(0x0027)
|
|
201
|
+
"""Whether the three-way valve is switched to the DHW tank."""
|
|
202
|
+
active_profile = enum(0x0028, Profile, signed=True)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class SpaceHeating(TripleSolarComponent):
|
|
206
|
+
"""Space heating controller status."""
|
|
207
|
+
|
|
208
|
+
state = enum(0x0040, SpaceConditioningState, signed=True)
|
|
209
|
+
has_demand = boolean(0x0041)
|
|
210
|
+
heat_pump_modulation = _tenths(0x0042, "%")
|
|
211
|
+
sink_supply_temperature = _tenths(0x0045, "°C")
|
|
212
|
+
sink_supply_temperature_setpoint = _tenths(0x0046, "°C")
|
|
213
|
+
active_profile = enum(0x004A, Profile, signed=True)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class SpaceCooling(TripleSolarComponent):
|
|
217
|
+
"""Space cooling controller status."""
|
|
218
|
+
|
|
219
|
+
state = enum(0x0060, SpaceConditioningState, signed=True)
|
|
220
|
+
has_demand = boolean(0x0061)
|
|
221
|
+
heat_pump_modulation = _tenths(0x0062, "%")
|
|
222
|
+
sink_supply_temperature = _tenths(0x0065, "°C")
|
|
223
|
+
sink_supply_temperature_setpoint = _tenths(0x0066, "°C")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class SpaceConditioningInput(TripleSolarComponent):
|
|
227
|
+
"""Inputs the space conditioning controllers act on (thermostat, outdoor)."""
|
|
228
|
+
|
|
229
|
+
room_temperature = _tenths(0x0120, "°C")
|
|
230
|
+
ambient_temperature = _tenths(0x0121, "°C")
|
|
231
|
+
relative_humidity = _tenths(0x0122, "%")
|
|
232
|
+
room_temperature_setpoint = _tenths(0x0123, "°C")
|
|
233
|
+
modulation_level = _tenths(0x0124, "%")
|
|
234
|
+
"""Modulation level requested by the thermostat."""
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
_HPM_STRIDE = 0x0400 # module 2 sits one module slot above module 1
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class HeatPumpModule(TripleSolarComponent):
|
|
241
|
+
"""One heat pump (compressor) module; ``index`` is 1 or 2."""
|
|
242
|
+
|
|
243
|
+
state = enum(0x0400, HeatPumpModuleState, signed=True, stride=_HPM_STRIDE)
|
|
244
|
+
compressor_frequency = _tenths(0x0422, "Hz", stride=_HPM_STRIDE)
|
|
245
|
+
"""Compressor frequency as reported by the inverter."""
|
|
246
|
+
source_pump_modulation = _tenths(0x0441, "%", stride=_HPM_STRIDE)
|
|
247
|
+
sink_pump_modulation = _tenths(0x0461, "%", stride=_HPM_STRIDE)
|
|
248
|
+
cooling_valve_enabled = boolean(0x04A1, stride=_HPM_STRIDE)
|
|
249
|
+
source_return_temperature = _tenths(0x04C0, "°C", stride=_HPM_STRIDE)
|
|
250
|
+
"""Temperature of the flow from the source (PVT panels) into the module."""
|
|
251
|
+
source_supply_temperature = _tenths(0x04C1, "°C", stride=_HPM_STRIDE)
|
|
252
|
+
"""Temperature of the flow from the module back to the source."""
|
|
253
|
+
sink_return_temperature = _tenths(0x0500, "°C", stride=_HPM_STRIDE)
|
|
254
|
+
"""Temperature of the flow from the sink (house) into the module."""
|
|
255
|
+
sink_supply_temperature = _tenths(0x0501, "°C", stride=_HPM_STRIDE)
|
|
256
|
+
"""Temperature of the flow from the module into the sink."""
|
|
257
|
+
electric_power = _watts(0x0680, stride=_HPM_STRIDE)
|
|
258
|
+
thermal_power = _watts(0x0681, stride=_HPM_STRIDE)
|
|
259
|
+
"""Thermal power delivered to the sink."""
|
|
260
|
+
compressor_starts = integer(0x0683, signed=False, stride=_HPM_STRIDE)
|
|
261
|
+
"""Compressor start count (16-bit, wraps at 65536)."""
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# -- settings and control (holding registers) ----------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class Settings(TripleSolarComponent):
|
|
268
|
+
"""Persistent operating settings (stored by the heat pump)."""
|
|
269
|
+
|
|
270
|
+
register_space = "holding"
|
|
271
|
+
|
|
272
|
+
space_heating_enabled = boolean(0x1101, writable=_bool)
|
|
273
|
+
space_heating_profile = enum(
|
|
274
|
+
0x1108, Profile, signed=True, writable=_member(Profile)
|
|
275
|
+
)
|
|
276
|
+
space_cooling_enabled = boolean(0x2101, writable=_bool)
|
|
277
|
+
space_cooling_profile = enum(
|
|
278
|
+
0x2107, Profile, signed=True, writable=_member(Profile)
|
|
279
|
+
)
|
|
280
|
+
dhw_enabled = boolean(0x3101, writable=_bool)
|
|
281
|
+
dhw_profile = enum(0x310A, Profile, signed=True, writable=_member(Profile))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
_DHW_PROFILE_STRIDE = 0x0100
|
|
285
|
+
|
|
286
|
+
#: Index of each DHW profile's settings block, for :class:`DhwProfileSettings`.
|
|
287
|
+
DHW_PROFILE_INDEX: dict[Profile, int] = {
|
|
288
|
+
Profile.ECO: 1,
|
|
289
|
+
Profile.NORMAL: 2,
|
|
290
|
+
Profile.COMFORT: 3,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class DhwProfileSettings(TripleSolarComponent):
|
|
295
|
+
"""Tank settings of one DHW profile (see :data:`DHW_PROFILE_INDEX`)."""
|
|
296
|
+
|
|
297
|
+
register_space = "holding"
|
|
298
|
+
|
|
299
|
+
tank_temperature_setpoint = _tenths(
|
|
300
|
+
0x3601, "°C", stride=_DHW_PROFILE_STRIDE, writable=_between(10, 70)
|
|
301
|
+
)
|
|
302
|
+
tank_temperature_hysteresis_low = _tenths(
|
|
303
|
+
0x3602, "°C", stride=_DHW_PROFILE_STRIDE, writable=_between(0, 50)
|
|
304
|
+
)
|
|
305
|
+
"""How far the tank may cool below the setpoint before reheating."""
|
|
306
|
+
modulation = _tenths(
|
|
307
|
+
0x3603, "%", stride=_DHW_PROFILE_STRIDE, writable=_between(20, 100)
|
|
308
|
+
)
|
|
309
|
+
"""Heat pump modulation used to charge the tank."""
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class Control(TripleSolarComponent):
|
|
313
|
+
"""External space conditioning control.
|
|
314
|
+
|
|
315
|
+
The heat pump keeps these values in memory only, and acts on them only
|
|
316
|
+
while a Modbus client has been active within the last
|
|
317
|
+
:data:`~pytriplesolar.const.CONTROL_KEEPALIVE_SECONDS` seconds (any
|
|
318
|
+
request counts). Poll faster than that to stay in control.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
keepalive_seconds = CONTROL_KEEPALIVE_SECONDS
|
|
322
|
+
|
|
323
|
+
register_space = "holding"
|
|
324
|
+
|
|
325
|
+
mode = enum(
|
|
326
|
+
0xB101,
|
|
327
|
+
SpaceConditioningMode,
|
|
328
|
+
writable=_member(SpaceConditioningMode),
|
|
329
|
+
)
|
|
330
|
+
modulation_level = gauge(
|
|
331
|
+
0xB102, 0.1, signed=False, unit="%", writable=_between(0, 100)
|
|
332
|
+
)
|
|
333
|
+
power_budget = integer(0xB103, signed=False, unit="W", writable=_between(0, 0xFFFF))
|
|
334
|
+
"""Electric power the heat pump may use."""
|
pytriplesolar/const.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Constants shared across pytriplesolar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
#: Modbus/TLS port (IANA ``mbap-s``).
|
|
8
|
+
MODBUS_TLS_PORT: Final = 802
|
|
9
|
+
|
|
10
|
+
#: mDNS-SD service type of the secure Modbus server (Modbus/TLS).
|
|
11
|
+
MODBUS_TLS_SERVICE_TYPE: Final = "_mbap-s._tcp.local."
|
|
12
|
+
|
|
13
|
+
#: mDNS-SD service type of the single-use CSR signing service that the heat
|
|
14
|
+
#: pump starts while pairing mode is enabled.
|
|
15
|
+
PAIRING_SERVICE_TYPE: Final = "_ts-csr._tcp.local."
|
|
16
|
+
|
|
17
|
+
#: Port of that signing service (firmware ``signingserver.DefaultPort``).
|
|
18
|
+
SIGNING_SERVICE_PORT: Final = 6509
|
|
19
|
+
|
|
20
|
+
#: The heat pump answers any unit id on Modbus/TCP; 1 is the conventional one.
|
|
21
|
+
DEFAULT_UNIT_ID: Final = 1
|
|
22
|
+
|
|
23
|
+
#: The heat pump only trusts the control registers while a client has talked
|
|
24
|
+
#: to it within this many seconds. Poll at least this often to keep control.
|
|
25
|
+
CONTROL_KEEPALIVE_SECONDS: Final = 30
|
|
26
|
+
|
|
27
|
+
#: Number of alert slots in the input register map.
|
|
28
|
+
ALERT_SLOTS: Final = 20
|
|
29
|
+
|
|
30
|
+
#: Number of heat pump (compressor) modules a unit can hold.
|
|
31
|
+
MAX_HEAT_PUMP_MODULES: Final = 2
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Client credentials for Modbus/TLS and how to hand them to a connection.
|
|
2
|
+
|
|
3
|
+
Modbus/TLS on the heat pump uses mutual TLS: the heat pump only accepts
|
|
4
|
+
clients holding a certificate signed by its own root CA, which a client
|
|
5
|
+
obtains once by pairing (see :mod:`pytriplesolar.pairing`). The root CA is
|
|
6
|
+
unique to the heat pump, so verifying the server against it pins the
|
|
7
|
+
connection to that one device.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Final, Self
|
|
16
|
+
|
|
17
|
+
from cryptography import x509
|
|
18
|
+
from cryptography.exceptions import InvalidSignature
|
|
19
|
+
from cryptography.hazmat.primitives import serialization
|
|
20
|
+
from modbus_connection import ModbusTlsParams
|
|
21
|
+
|
|
22
|
+
from .const import MODBUS_TLS_PORT
|
|
23
|
+
from .exceptions import InvalidCredentialsError
|
|
24
|
+
|
|
25
|
+
ROOT_CA_FILENAME: Final = "root_ca.pem"
|
|
26
|
+
CLIENT_CERT_FILENAME: Final = "client_cert.pem"
|
|
27
|
+
CLIENT_KEY_FILENAME: Final = "client_key.pem"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize_hostname(hostname: str) -> str:
|
|
31
|
+
"""Normalize an mDNS host name for comparison (lower case, no root dot)."""
|
|
32
|
+
return hostname.rstrip(".").lower()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class Credentials:
|
|
37
|
+
"""Everything needed to talk Modbus/TLS to one paired heat pump."""
|
|
38
|
+
|
|
39
|
+
root_ca_pem: bytes
|
|
40
|
+
"""The heat pump's root CA certificate (PEM)."""
|
|
41
|
+
client_cert_pem: bytes
|
|
42
|
+
"""Client certificate signed by the root CA (PEM)."""
|
|
43
|
+
client_key_pem: bytes
|
|
44
|
+
"""Private key of the client certificate (unencrypted PKCS#8 PEM)."""
|
|
45
|
+
device_hostname: str
|
|
46
|
+
"""mDNS host name the heat pump was paired under, e.g. ``hpcb-a1b2c3.local``.
|
|
47
|
+
|
|
48
|
+
Stable per heat pump; use it to recognise the same unit in later mDNS
|
|
49
|
+
announcements.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def validate(self) -> None:
|
|
53
|
+
"""Check the parts parse and belong together.
|
|
54
|
+
|
|
55
|
+
Raises ``InvalidCredentialsError`` otherwise.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
root = x509.load_pem_x509_certificate(self.root_ca_pem)
|
|
59
|
+
cert = x509.load_pem_x509_certificate(self.client_cert_pem)
|
|
60
|
+
key = serialization.load_pem_private_key(self.client_key_pem, None)
|
|
61
|
+
except (ValueError, TypeError) as err:
|
|
62
|
+
raise InvalidCredentialsError(f"Malformed credentials: {err}") from err
|
|
63
|
+
if cert.public_key() != key.public_key():
|
|
64
|
+
raise InvalidCredentialsError("Client key does not match certificate")
|
|
65
|
+
try:
|
|
66
|
+
cert.verify_directly_issued_by(root)
|
|
67
|
+
except (ValueError, TypeError, InvalidSignature) as err:
|
|
68
|
+
raise InvalidCredentialsError(
|
|
69
|
+
"Client certificate is not signed by the root CA"
|
|
70
|
+
) from err
|
|
71
|
+
if not self.device_hostname:
|
|
72
|
+
raise InvalidCredentialsError("Missing device hostname")
|
|
73
|
+
|
|
74
|
+
def as_dict(self) -> dict[str, str]:
|
|
75
|
+
"""JSON-serialisable form, for storing the credentials."""
|
|
76
|
+
return {
|
|
77
|
+
"root_ca": self.root_ca_pem.decode(),
|
|
78
|
+
"client_cert": self.client_cert_pem.decode(),
|
|
79
|
+
"client_key": self.client_key_pem.decode(),
|
|
80
|
+
"device_hostname": self.device_hostname,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_dict(cls, data: dict[str, Any]) -> Self:
|
|
85
|
+
"""Rebuild credentials stored with :meth:`as_dict`.
|
|
86
|
+
|
|
87
|
+
Raises ``InvalidCredentialsError`` if a part is missing or malformed.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
credentials = cls(
|
|
91
|
+
root_ca_pem=data["root_ca"].encode(),
|
|
92
|
+
client_cert_pem=data["client_cert"].encode(),
|
|
93
|
+
client_key_pem=data["client_key"].encode(),
|
|
94
|
+
device_hostname=data["device_hostname"],
|
|
95
|
+
)
|
|
96
|
+
except (KeyError, AttributeError) as err:
|
|
97
|
+
raise InvalidCredentialsError(f"Incomplete credentials: {err}") from err
|
|
98
|
+
credentials.validate()
|
|
99
|
+
return credentials
|
|
100
|
+
|
|
101
|
+
def write_files(self, directory: Path) -> CredentialFiles:
|
|
102
|
+
"""Write the PEM files TLS needs into ``directory`` (blocking I/O).
|
|
103
|
+
|
|
104
|
+
Python's ``ssl`` only loads a client certificate from files, so the
|
|
105
|
+
connection needs them on disk. The private key is written owner-only.
|
|
106
|
+
"""
|
|
107
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
files = CredentialFiles(
|
|
109
|
+
root_ca=directory / ROOT_CA_FILENAME,
|
|
110
|
+
client_cert=directory / CLIENT_CERT_FILENAME,
|
|
111
|
+
client_key=directory / CLIENT_KEY_FILENAME,
|
|
112
|
+
)
|
|
113
|
+
files.root_ca.write_bytes(self.root_ca_pem)
|
|
114
|
+
files.client_cert.write_bytes(self.client_cert_pem)
|
|
115
|
+
fd = os.open(files.client_key, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
116
|
+
with os.fdopen(fd, "wb") as handle:
|
|
117
|
+
handle.write(self.client_key_pem)
|
|
118
|
+
os.chmod(files.client_key, 0o600) # also tighten a pre-existing file
|
|
119
|
+
return files
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True, slots=True)
|
|
123
|
+
class CredentialFiles:
|
|
124
|
+
"""Paths of credentials written by :meth:`Credentials.write_files`."""
|
|
125
|
+
|
|
126
|
+
root_ca: Path
|
|
127
|
+
client_cert: Path
|
|
128
|
+
client_key: Path
|
|
129
|
+
|
|
130
|
+
def tls_params(self, host: str, port: int = MODBUS_TLS_PORT) -> ModbusTlsParams:
|
|
131
|
+
"""Connection parameters for the heat pump at ``host``.
|
|
132
|
+
|
|
133
|
+
The server certificate is verified against the heat pump's own root
|
|
134
|
+
CA. Its host name is not checked: the heat pump is usually reached by
|
|
135
|
+
IP address, and the per-device root CA already pins the connection to
|
|
136
|
+
the paired unit.
|
|
137
|
+
"""
|
|
138
|
+
return ModbusTlsParams(
|
|
139
|
+
host=host,
|
|
140
|
+
port=port,
|
|
141
|
+
verify=str(self.root_ca),
|
|
142
|
+
check_hostname=False,
|
|
143
|
+
client_cert=str(self.client_cert),
|
|
144
|
+
client_key=str(self.client_key),
|
|
145
|
+
)
|