weider-modbus 0.1.0__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.
- weider_modbus/__init__.py +36 -0
- weider_modbus/components.py +246 -0
- weider_modbus/const.py +22 -0
- weider_modbus/device.py +75 -0
- weider_modbus/helpers.py +60 -0
- weider_modbus/py.typed +0 -0
- weider_modbus-0.1.0.dist-info/METADATA +160 -0
- weider_modbus-0.1.0.dist-info/RECORD +10 -0
- weider_modbus-0.1.0.dist-info/WHEEL +4 -0
- weider_modbus-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Async Python library for the Modbus TCP interface of Weider WT16 heat pumps."""
|
|
2
|
+
|
|
3
|
+
from .components import DigitalInputs, Measurements, Settings
|
|
4
|
+
from .const import (
|
|
5
|
+
HOT_WATER_TARGET_MAX,
|
|
6
|
+
HOT_WATER_TARGET_MIN,
|
|
7
|
+
MANUFACTURER,
|
|
8
|
+
MAX_PLAUSIBLE_TEMPERATURE,
|
|
9
|
+
MIN_PLAUSIBLE_TEMPERATURE,
|
|
10
|
+
MODEL,
|
|
11
|
+
ROOM_TARGET_MAX,
|
|
12
|
+
ROOM_TARGET_MIN,
|
|
13
|
+
TARGET_STEP,
|
|
14
|
+
)
|
|
15
|
+
from .device import WeiderWT16
|
|
16
|
+
from .helpers import SgReadyMode, clean_error_text, plausible_temperature, sg_ready_mode
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"HOT_WATER_TARGET_MAX",
|
|
20
|
+
"HOT_WATER_TARGET_MIN",
|
|
21
|
+
"MANUFACTURER",
|
|
22
|
+
"MAX_PLAUSIBLE_TEMPERATURE",
|
|
23
|
+
"MIN_PLAUSIBLE_TEMPERATURE",
|
|
24
|
+
"MODEL",
|
|
25
|
+
"ROOM_TARGET_MAX",
|
|
26
|
+
"ROOM_TARGET_MIN",
|
|
27
|
+
"TARGET_STEP",
|
|
28
|
+
"DigitalInputs",
|
|
29
|
+
"Measurements",
|
|
30
|
+
"Settings",
|
|
31
|
+
"SgReadyMode",
|
|
32
|
+
"WeiderWT16",
|
|
33
|
+
"clean_error_text",
|
|
34
|
+
"plausible_temperature",
|
|
35
|
+
"sg_ready_mode",
|
|
36
|
+
]
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Typed Modbus components of the Weider WT16."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from typing import ClassVar, Final
|
|
8
|
+
|
|
9
|
+
from modbus_connection.exceptions import IllegalDataAddressError
|
|
10
|
+
from modbus_connection.model import (
|
|
11
|
+
Component,
|
|
12
|
+
NumberField,
|
|
13
|
+
discrete_input,
|
|
14
|
+
gauge,
|
|
15
|
+
integer,
|
|
16
|
+
string,
|
|
17
|
+
uint32,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from .const import (
|
|
21
|
+
HOT_WATER_TARGET_MAX,
|
|
22
|
+
HOT_WATER_TARGET_MIN,
|
|
23
|
+
ROOM_TARGET_MAX,
|
|
24
|
+
ROOM_TARGET_MIN,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_LOGGER = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _temperature(address: int) -> NumberField[float]:
|
|
31
|
+
"""Return the signed, 0.1-scaled WT16 temperature format.
|
|
32
|
+
|
|
33
|
+
Outdoor, source and refrigerant temperatures go below 0 °C, which the
|
|
34
|
+
device sends as two's complement (65516 is -2.0 °C).
|
|
35
|
+
"""
|
|
36
|
+
return gauge(address, 0.1, unit="°C")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _range_validator(minimum: float, maximum: float) -> Callable[[float], float]:
|
|
40
|
+
"""Return a write validator that rejects values outside [minimum, maximum]."""
|
|
41
|
+
|
|
42
|
+
def validate(value: float) -> float:
|
|
43
|
+
if not minimum <= value <= maximum:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"{value} is outside the allowed range {minimum}..{maximum}"
|
|
46
|
+
)
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
return validate
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _target_temperature(
|
|
53
|
+
address: int, minimum: float, maximum: float
|
|
54
|
+
) -> NumberField[float]:
|
|
55
|
+
"""Return a writable, unsigned, 0.1-scaled target temperature.
|
|
56
|
+
|
|
57
|
+
The device only accepts function code 16 for these registers.
|
|
58
|
+
"""
|
|
59
|
+
return gauge(
|
|
60
|
+
address,
|
|
61
|
+
0.1,
|
|
62
|
+
signed=False,
|
|
63
|
+
unit="°C",
|
|
64
|
+
writable=_range_validator(minimum, maximum),
|
|
65
|
+
force_fc16=True,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_READERS: Final = {
|
|
70
|
+
"input": "read_input_registers",
|
|
71
|
+
"holding": "read_holding_registers",
|
|
72
|
+
"discrete": "read_discrete_inputs",
|
|
73
|
+
"coil": "read_coils",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def _rejected_fields(
|
|
78
|
+
component: Component, space: str, names: set[str]
|
|
79
|
+
) -> set[str]:
|
|
80
|
+
"""Read each field on its own and return those the device rejects."""
|
|
81
|
+
read = getattr(component.modbus_unit, _READERS[space])
|
|
82
|
+
rejected = set()
|
|
83
|
+
for name in sorted(names):
|
|
84
|
+
field = component.resolved_fields[name]
|
|
85
|
+
try:
|
|
86
|
+
await read(field.address, field.count)
|
|
87
|
+
except IllegalDataAddressError:
|
|
88
|
+
rejected.add(name)
|
|
89
|
+
return rejected
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _update_skipping_unsupported(
|
|
93
|
+
component: Component,
|
|
94
|
+
update: Callable[..., Awaitable[None]],
|
|
95
|
+
space: str,
|
|
96
|
+
optional: frozenset[str] | None,
|
|
97
|
+
*,
|
|
98
|
+
notify: bool,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Refresh a component, dropping optional fields the device rejects.
|
|
101
|
+
|
|
102
|
+
If the device rejects a block of several fields, each field is read on its
|
|
103
|
+
own to find the unsupported ones. Unsupported fields are skipped only if
|
|
104
|
+
they are all optional (``optional=None`` treats all fields as optional).
|
|
105
|
+
Anything else is a real error and propagates, so it is reported instead of
|
|
106
|
+
hidden.
|
|
107
|
+
"""
|
|
108
|
+
while True:
|
|
109
|
+
try:
|
|
110
|
+
await update(notify=notify)
|
|
111
|
+
return
|
|
112
|
+
except IllegalDataAddressError as err:
|
|
113
|
+
block = err.block
|
|
114
|
+
if block is None or block.space != space:
|
|
115
|
+
raise
|
|
116
|
+
unsupported = {
|
|
117
|
+
name
|
|
118
|
+
for name, field in component.resolved_fields.items()
|
|
119
|
+
if field.address < block.address + block.count
|
|
120
|
+
and block.address < field.address + field.count
|
|
121
|
+
}
|
|
122
|
+
if len(unsupported) > 1:
|
|
123
|
+
unsupported = await _rejected_fields(component, space, unsupported)
|
|
124
|
+
if not unsupported or (
|
|
125
|
+
optional is not None and not unsupported <= optional
|
|
126
|
+
):
|
|
127
|
+
raise
|
|
128
|
+
addresses = sorted(
|
|
129
|
+
component.resolved_fields[name].address for name in unsupported
|
|
130
|
+
)
|
|
131
|
+
component.restrict_fields(set(component.resolved_fields) - unsupported)
|
|
132
|
+
_LOGGER.info(
|
|
133
|
+
"Skipping unsupported %s %s at %s %s",
|
|
134
|
+
"field" if len(unsupported) == 1 else "fields",
|
|
135
|
+
", ".join(sorted(unsupported)),
|
|
136
|
+
"address" if len(addresses) == 1 else "addresses",
|
|
137
|
+
", ".join(map(str, addresses)),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Measurements(Component):
|
|
142
|
+
"""Read-only WT16 input registers (function code 04)."""
|
|
143
|
+
|
|
144
|
+
register_space = "input"
|
|
145
|
+
# max_gap = 1 merges directly adjacent registers only, never across a gap.
|
|
146
|
+
max_gap = 1
|
|
147
|
+
max_span = 50
|
|
148
|
+
|
|
149
|
+
hot_water_temperature = _temperature(13)
|
|
150
|
+
flow_target_temperature = _temperature(14)
|
|
151
|
+
outdoor_temperature = _temperature(15)
|
|
152
|
+
buffer_temperature = _temperature(16)
|
|
153
|
+
mixer_temperature = _temperature(17)
|
|
154
|
+
reserve_temperature_1 = _temperature(18)
|
|
155
|
+
reserve_temperature_2 = _temperature(19)
|
|
156
|
+
reserve_temperature_3 = _temperature(20)
|
|
157
|
+
defrost_temperature = _temperature(21)
|
|
158
|
+
flow_temperature = _temperature(25)
|
|
159
|
+
return_temperature = _temperature(26)
|
|
160
|
+
source_inlet_temperature = _temperature(27)
|
|
161
|
+
source_outlet_temperature = _temperature(28)
|
|
162
|
+
superheat = _temperature(29)
|
|
163
|
+
evaporation_temperature = _temperature(31)
|
|
164
|
+
condensation_temperature = _temperature(33)
|
|
165
|
+
evaporator_temperature = _temperature(35)
|
|
166
|
+
suction_gas_temperature = _temperature(36)
|
|
167
|
+
hot_gas_temperature = _temperature(37)
|
|
168
|
+
volume_flow = gauge(44, 0.001, signed=False, unit="m³/h")
|
|
169
|
+
room_temperature = _temperature(724)
|
|
170
|
+
mixer_position = integer(736, signed=False)
|
|
171
|
+
last_pump_runtime = uint32(60164, unit="min")
|
|
172
|
+
last_hot_water_runtime = uint32(60168, unit="min")
|
|
173
|
+
active_error = string(63000, 16)
|
|
174
|
+
|
|
175
|
+
# Registers that some firmware versions do not provide.
|
|
176
|
+
OPTIONAL_FIELDS: ClassVar[frozenset[str]] = frozenset(
|
|
177
|
+
{
|
|
178
|
+
"room_temperature",
|
|
179
|
+
"mixer_position",
|
|
180
|
+
"last_pump_runtime",
|
|
181
|
+
"last_hot_water_runtime",
|
|
182
|
+
"active_error",
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
async def async_update(self, *, notify: bool = True) -> None:
|
|
187
|
+
"""Refresh measurements, skipping optional registers this device rejects."""
|
|
188
|
+
await _update_skipping_unsupported(
|
|
189
|
+
self, super().async_update, "input", self.OPTIONAL_FIELDS, notify=notify
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class Settings(Component):
|
|
194
|
+
"""Writable WT16 holding registers (function codes 03/16)."""
|
|
195
|
+
|
|
196
|
+
max_gap = 0
|
|
197
|
+
max_span = 50
|
|
198
|
+
|
|
199
|
+
hot_water_target_temperature = _target_temperature(
|
|
200
|
+
1, HOT_WATER_TARGET_MIN, HOT_WATER_TARGET_MAX
|
|
201
|
+
)
|
|
202
|
+
room_target_temperature = _target_temperature(723, ROOM_TARGET_MIN, ROOM_TARGET_MAX)
|
|
203
|
+
|
|
204
|
+
# Installations without a room sensor may not provide the room target.
|
|
205
|
+
OPTIONAL_FIELDS: ClassVar[frozenset[str]] = frozenset({"room_target_temperature"})
|
|
206
|
+
|
|
207
|
+
async def async_update(self, *, notify: bool = True) -> None:
|
|
208
|
+
"""Refresh settings, skipping optional registers this device rejects."""
|
|
209
|
+
await _update_skipping_unsupported(
|
|
210
|
+
self, super().async_update, "holding", self.OPTIONAL_FIELDS, notify=notify
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class DigitalInputs(Component):
|
|
215
|
+
"""WT16 discrete inputs (function code 02)."""
|
|
216
|
+
|
|
217
|
+
# max_gap = 1 merges directly adjacent inputs only, never across a gap.
|
|
218
|
+
max_gap = 1
|
|
219
|
+
max_span = 50
|
|
220
|
+
|
|
221
|
+
flow_switch_1 = discrete_input(45)
|
|
222
|
+
flow_switch_2 = discrete_input(69)
|
|
223
|
+
compressor = discrete_input(679)
|
|
224
|
+
heating_pump = discrete_input(680)
|
|
225
|
+
source_pump = discrete_input(681)
|
|
226
|
+
mixer_pump = discrete_input(682)
|
|
227
|
+
hot_water_pump = discrete_input(685)
|
|
228
|
+
remote_fault = discrete_input(686)
|
|
229
|
+
four_way_valve = discrete_input(690)
|
|
230
|
+
reserve_1 = discrete_input(691)
|
|
231
|
+
reserve_2 = discrete_input(692)
|
|
232
|
+
reserve_3 = discrete_input(693)
|
|
233
|
+
cooling_output = discrete_input(694)
|
|
234
|
+
reserve_5 = discrete_input(695)
|
|
235
|
+
hot_water_block = discrete_input(703)
|
|
236
|
+
heating_block = discrete_input(704)
|
|
237
|
+
utility_block = discrete_input(705)
|
|
238
|
+
sg_ready_1 = discrete_input(706)
|
|
239
|
+
sg_ready_2 = discrete_input(707)
|
|
240
|
+
reserve = discrete_input(714)
|
|
241
|
+
|
|
242
|
+
async def async_update(self, *, notify: bool = True) -> None:
|
|
243
|
+
"""Refresh supported inputs, excluding addresses rejected by this device."""
|
|
244
|
+
await _update_skipping_unsupported(
|
|
245
|
+
self, super().async_update, "discrete", None, notify=notify
|
|
246
|
+
)
|
weider_modbus/const.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Device facts for the Weider WT16."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
MANUFACTURER: Final = "Weider"
|
|
8
|
+
MODEL: Final = "WT16"
|
|
9
|
+
|
|
10
|
+
# Allowed write ranges for the target temperatures, in °C (inclusive).
|
|
11
|
+
HOT_WATER_TARGET_MIN: Final = 15.0
|
|
12
|
+
HOT_WATER_TARGET_MAX: Final = 55.0
|
|
13
|
+
ROOM_TARGET_MIN: Final = 15.0
|
|
14
|
+
ROOM_TARGET_MAX: Final = 35.0
|
|
15
|
+
TARGET_STEP: Final = 0.5
|
|
16
|
+
|
|
17
|
+
# The device reports a placeholder value, such as 3276.7, for a temperature
|
|
18
|
+
# sensor that is not connected. Readings outside this range are treated as
|
|
19
|
+
# unknown. The range covers outdoor temperatures down to -45 °C and the hot
|
|
20
|
+
# gas temperature of the refrigerant circuit.
|
|
21
|
+
MIN_PLAUSIBLE_TEMPERATURE: Final = -45.0
|
|
22
|
+
MAX_PLAUSIBLE_TEMPERATURE: Final = 150.0
|
weider_modbus/device.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The top-level WT16 device object."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import TYPE_CHECKING, Final
|
|
7
|
+
|
|
8
|
+
from modbus_connection.model import Device, Raw
|
|
9
|
+
|
|
10
|
+
from .components import DigitalInputs, Measurements, Settings
|
|
11
|
+
from .const import MANUFACTURER, MODEL
|
|
12
|
+
from .helpers import SgReadyMode, sg_ready_mode
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from modbus_connection import ModbusUnit
|
|
16
|
+
|
|
17
|
+
# Attribute names of the components, in polling order.
|
|
18
|
+
COMPONENTS: Final = ("measurements", "settings", "digital_inputs")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WeiderWT16(Device):
|
|
22
|
+
"""One Weider WT16 heat pump, reached through a ``ModbusUnit``.
|
|
23
|
+
|
|
24
|
+
The consumer owns the connection and passes a unit. This library only
|
|
25
|
+
reads and writes registers, so it works over any modbus-connection backend.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
manufacturer: Final = MANUFACTURER
|
|
29
|
+
model: Final = MODEL
|
|
30
|
+
|
|
31
|
+
def __init__(self, unit: ModbusUnit) -> None:
|
|
32
|
+
"""Create the device and its components. No I/O happens here."""
|
|
33
|
+
super().__init__(unit)
|
|
34
|
+
self.measurements = Measurements(unit)
|
|
35
|
+
self.settings = Settings(unit)
|
|
36
|
+
self.digital_inputs = DigitalInputs(unit)
|
|
37
|
+
|
|
38
|
+
async def async_update(self) -> None:
|
|
39
|
+
"""Refresh every component.
|
|
40
|
+
|
|
41
|
+
Optional registers the device rejects are dropped on the first poll
|
|
42
|
+
that hits them. Any other ``ModbusError`` propagates, so the caller can
|
|
43
|
+
retry or mark the device unavailable. Listeners fire once every read is
|
|
44
|
+
in, so they see a consistent poll.
|
|
45
|
+
"""
|
|
46
|
+
await self.async_ensure_setup()
|
|
47
|
+
for name in COMPONENTS:
|
|
48
|
+
await getattr(self, name).async_update(notify=False)
|
|
49
|
+
for name in COMPONENTS:
|
|
50
|
+
getattr(self, name).notify()
|
|
51
|
+
|
|
52
|
+
async def async_set_hot_water_target(self, temperature: float) -> None:
|
|
53
|
+
"""Write the hot water target temperature in °C.
|
|
54
|
+
|
|
55
|
+
Raises ``ValueError`` if the value is outside the allowed range.
|
|
56
|
+
"""
|
|
57
|
+
await self.settings.write("hot_water_target_temperature", temperature)
|
|
58
|
+
|
|
59
|
+
async def async_set_room_target(self, temperature: float) -> None:
|
|
60
|
+
"""Write the room target temperature in °C.
|
|
61
|
+
|
|
62
|
+
Raises ``ValueError`` if the value is outside the allowed range and
|
|
63
|
+
``AttributeError`` if this device does not provide the register.
|
|
64
|
+
"""
|
|
65
|
+
await self.settings.write("room_target_temperature", temperature)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def sg_ready_mode(self) -> SgReadyMode | None:
|
|
69
|
+
"""The SG Ready operating state, derived from the digital inputs."""
|
|
70
|
+
inputs = self.digital_inputs
|
|
71
|
+
return sg_ready_mode(inputs.utility_block, inputs.sg_ready_1, inputs.sg_ready_2)
|
|
72
|
+
|
|
73
|
+
async def async_read_raw(self, names: Iterable[str] = COMPONENTS) -> Raw:
|
|
74
|
+
"""Return every register this device reads, undecoded, for diagnostics."""
|
|
75
|
+
return await super().async_read_raw(names)
|
weider_modbus/helpers.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Interpretation of WT16 values that the raw registers do not carry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
from .const import MAX_PLAUSIBLE_TEMPERATURE, MIN_PLAUSIBLE_TEMPERATURE
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def plausible_temperature(value: float | None) -> float | None:
|
|
11
|
+
"""Return the temperature, or ``None`` if it is a placeholder value.
|
|
12
|
+
|
|
13
|
+
The device reports a placeholder (for example 3276.7) for a temperature
|
|
14
|
+
sensor that is not connected.
|
|
15
|
+
"""
|
|
16
|
+
if value is None or MIN_PLAUSIBLE_TEMPERATURE <= value <= MAX_PLAUSIBLE_TEMPERATURE:
|
|
17
|
+
return value
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def clean_error_text(value: str | None) -> str | None:
|
|
22
|
+
"""Return the device's error text without padding, or ``None`` if no error.
|
|
23
|
+
|
|
24
|
+
The device pads the 16-register text with null characters or spaces, so
|
|
25
|
+
"no error" arrives as padding only.
|
|
26
|
+
"""
|
|
27
|
+
if value is None:
|
|
28
|
+
return None
|
|
29
|
+
text = " ".join(value.replace("\x00", " ").split())
|
|
30
|
+
return text or None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SgReadyMode(StrEnum):
|
|
34
|
+
"""SG Ready operating states (Betriebszustände)."""
|
|
35
|
+
|
|
36
|
+
UTILITY_BLOCK = "bz1_utility_block"
|
|
37
|
+
NORMAL = "bz2_normal"
|
|
38
|
+
INCREASED = "bz3_increased"
|
|
39
|
+
START = "bz4_start"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sg_ready_mode(
|
|
43
|
+
utility_block: bool | None, sg_ready_1: bool | None, sg_ready_2: bool | None
|
|
44
|
+
) -> SgReadyMode | None:
|
|
45
|
+
"""Return the SG Ready operating state for the given input states.
|
|
46
|
+
|
|
47
|
+
The utility company block always means BZ1. Otherwise SG Ready 1 and 2
|
|
48
|
+
select the state: only SG Ready 2 on is BZ3, only SG Ready 1 on is BZ4, and
|
|
49
|
+
anything else is BZ2. A missing utility block reading counts as no block;
|
|
50
|
+
missing SG Ready readings make the state unknown.
|
|
51
|
+
"""
|
|
52
|
+
if utility_block:
|
|
53
|
+
return SgReadyMode.UTILITY_BLOCK
|
|
54
|
+
if sg_ready_1 is None or sg_ready_2 is None:
|
|
55
|
+
return None
|
|
56
|
+
if sg_ready_2 and not sg_ready_1:
|
|
57
|
+
return SgReadyMode.INCREASED
|
|
58
|
+
if sg_ready_1 and not sg_ready_2:
|
|
59
|
+
return SgReadyMode.START
|
|
60
|
+
return SgReadyMode.NORMAL
|
weider_modbus/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: weider-modbus
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python library for the Modbus TCP interface of Weider heat pumps, built on modbus-connection
|
|
5
|
+
Project-URL: Homepage, https://github.com/schadom/weider-modbus
|
|
6
|
+
Project-URL: Source, https://github.com/schadom/weider-modbus
|
|
7
|
+
Project-URL: Issues, https://github.com/schadom/weider-modbus/issues
|
|
8
|
+
Author: schadom
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: heat pump,home-assistant,modbus,weider,wt16
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Home Automation
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.13
|
|
22
|
+
Requires-Dist: modbus-connection>=4.12.1
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# weider-modbus
|
|
26
|
+
|
|
27
|
+
Async Python library for the Modbus TCP interface of Weider WT16 heat pumps,
|
|
28
|
+
built on [modbus-connection](https://github.com/home-assistant-libs/modbus-connection).
|
|
29
|
+
|
|
30
|
+
> [!IMPORTANT]
|
|
31
|
+
> This library is not affiliated with Weider Wärmepumpen GmbH and is provided
|
|
32
|
+
> as-is and without warranty.
|
|
33
|
+
|
|
34
|
+
The library only reads and writes registers. It takes a `ModbusUnit` and never
|
|
35
|
+
opens a connection itself, so it works over any modbus-connection backend
|
|
36
|
+
(tmodbus, pymodbus, or the in-memory mock). It is used by the
|
|
37
|
+
[Weider WT16 Home Assistant integration](https://github.com/schadom/hacs_weider).
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install weider-modbus
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The library depends only on the backend-neutral `modbus-connection`
|
|
46
|
+
interface. To open a connection yourself, also install a backend:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install "modbus-connection[tmodbus]"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import asyncio
|
|
56
|
+
|
|
57
|
+
from modbus_connection import ModbusTcpParams
|
|
58
|
+
from modbus_connection.tmodbus import ModbusConnection
|
|
59
|
+
|
|
60
|
+
from weider_modbus import WeiderWT16, plausible_temperature
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def main() -> None:
|
|
64
|
+
connection = ModbusConnection(ModbusTcpParams(host="192.168.1.50", port=502))
|
|
65
|
+
try:
|
|
66
|
+
device = WeiderWT16(connection.for_unit(1))
|
|
67
|
+
await device.async_update()
|
|
68
|
+
|
|
69
|
+
print("Hot water:", device.measurements.hot_water_temperature)
|
|
70
|
+
outdoor = plausible_temperature(device.measurements.outdoor_temperature)
|
|
71
|
+
print("Outdoor:", outdoor)
|
|
72
|
+
print("Compressor running:", device.digital_inputs.compressor)
|
|
73
|
+
print("SG Ready mode:", device.sg_ready_mode)
|
|
74
|
+
|
|
75
|
+
await device.async_set_hot_water_target(47.5)
|
|
76
|
+
finally:
|
|
77
|
+
await connection.close()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
asyncio.run(main())
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Components
|
|
84
|
+
|
|
85
|
+
| Attribute | Modbus space | Content |
|
|
86
|
+
| --- | --- | --- |
|
|
87
|
+
| `device.measurements` | Input registers (FC04) | Temperatures, volume flow, runtimes, mixer position, active error text |
|
|
88
|
+
| `device.settings` | Holding registers (FC03/FC16) | Hot water and room target temperatures |
|
|
89
|
+
| `device.digital_inputs` | Discrete inputs (FC02) | Compressor, pumps, valves, blocks, SG Ready inputs |
|
|
90
|
+
|
|
91
|
+
A value reads as `None` until the first successful poll, or if the device
|
|
92
|
+
does not provide the register.
|
|
93
|
+
|
|
94
|
+
### Optional registers
|
|
95
|
+
|
|
96
|
+
Some firmware versions do not provide every register. The room temperature,
|
|
97
|
+
room target, mixer position, runtimes, active error text and any digital input
|
|
98
|
+
are optional. If the device rejects one of them with an illegal data address,
|
|
99
|
+
the library drops that register on the first poll and stops reading it. Any
|
|
100
|
+
other register error still raises, so real problems are not hidden.
|
|
101
|
+
|
|
102
|
+
### Errors
|
|
103
|
+
|
|
104
|
+
`async_update()` raises a `modbus_connection.ModbusError` subclass (or
|
|
105
|
+
`TimeoutError` / `OSError` from the transport) when a poll fails. The shared
|
|
106
|
+
connection reopens on the next request, so the caller can simply retry.
|
|
107
|
+
|
|
108
|
+
The target setters raise `ValueError` for a value outside the allowed range
|
|
109
|
+
(hot water 15–55 °C, room 15–35 °C), before anything is written.
|
|
110
|
+
|
|
111
|
+
### Helpers
|
|
112
|
+
|
|
113
|
+
- `plausible_temperature(value)` returns `None` for the placeholder values the
|
|
114
|
+
device reports for an unconnected sensor (outside −45 to 150 °C).
|
|
115
|
+
- `clean_error_text(value)` strips the padding from the active error text and
|
|
116
|
+
returns `None` when there is no error.
|
|
117
|
+
- `sg_ready_mode(...)` and `device.sg_ready_mode` derive the SG Ready operating
|
|
118
|
+
state (BZ1–BZ4) from the digital inputs.
|
|
119
|
+
|
|
120
|
+
## Hardware verification
|
|
121
|
+
|
|
122
|
+
The register addresses, the signed 0.1 °C temperature format and the
|
|
123
|
+
multi-register word order come from the existing integration. Confirm them
|
|
124
|
+
against your WT16 documentation, especially for negative temperatures. The
|
|
125
|
+
test simulator checks the software against this map, not the map against a
|
|
126
|
+
real heat pump.
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
Requires Python 3.13 or newer and [uv](https://docs.astral.sh/uv/).
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
uv sync
|
|
134
|
+
uv run pytest
|
|
135
|
+
uv run ruff check .
|
|
136
|
+
uv run ruff format --check .
|
|
137
|
+
uv run mypy
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
To run the synthetic WT16 on port 5020 for manual testing:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
uv run python -m tests.simulator
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Releasing
|
|
147
|
+
|
|
148
|
+
Releases are published to PyPI from GitHub Actions with
|
|
149
|
+
[trusted publishing](https://docs.pypi.org/trusted-publishers/).
|
|
150
|
+
|
|
151
|
+
1. Set the new `version` in `pyproject.toml` and merge it to `main`.
|
|
152
|
+
2. Create a GitHub release with a tag equal to that version (for example
|
|
153
|
+
`0.1.0`).
|
|
154
|
+
3. The `Publish` workflow builds the package and uploads it to PyPI.
|
|
155
|
+
|
|
156
|
+
A version number can be published only once.
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
weider_modbus/__init__.py,sha256=h4jxlwvvQYMyHVxX2_LMMUNkqutusDDbmZg7vXZscS8,894
|
|
2
|
+
weider_modbus/components.py,sha256=Md_uedNQval8jB8Tb9fo2VPEaU5WbZy0nq49UPoLnMs,8238
|
|
3
|
+
weider_modbus/const.py,sha256=VOdpk4EZbBSDMgIU3h_yNNftL0dpJzxt6jyPPKGS5iE,741
|
|
4
|
+
weider_modbus/device.py,sha256=Rbijwj8ddEtK4hku99iQyRY7pjB7NgTFCO2iuz8UYJQ,2879
|
|
5
|
+
weider_modbus/helpers.py,sha256=evUQ4dGz7hpKIfSXJTAuOynYQAHtm2F0ik1bF54xdsE,1988
|
|
6
|
+
weider_modbus/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
weider_modbus-0.1.0.dist-info/METADATA,sha256=wA4FrdIivRzn5EGPdPy3umB9chHQ0Z3P03ZjO4Dc9yY,5433
|
|
8
|
+
weider_modbus-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
weider_modbus-0.1.0.dist-info/licenses/LICENSE,sha256=jnP_BOoMzchc9jaCzOEmdce4S2K1mouGtFl5Md0doCc,1064
|
|
10
|
+
weider_modbus-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 schadom
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|