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.
@@ -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
+ ]
@@ -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
+ )