wavin-sentio-connect 0.2.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.
- wavin_sentio_connect/__init__.py +73 -0
- wavin_sentio_connect/_model.py +685 -0
- wavin_sentio_connect/_sentio.py +96 -0
- wavin_sentio_connect/py.typed +0 -0
- wavin_sentio_connect-0.2.0.dist-info/METADATA +259 -0
- wavin_sentio_connect-0.2.0.dist-info/RECORD +9 -0
- wavin_sentio_connect-0.2.0.dist-info/WHEEL +5 -0
- wavin_sentio_connect-0.2.0.dist-info/licenses/LICENSE +21 -0
- wavin_sentio_connect-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Wavin Sentio over Modbus TCP, built on modbus_event_connect."""
|
|
2
|
+
from ._model import (
|
|
3
|
+
NOT_SUPPORTED,
|
|
4
|
+
PERIPHERAL,
|
|
5
|
+
ROOM,
|
|
6
|
+
SENTIO,
|
|
7
|
+
UNITS,
|
|
8
|
+
BlockingSource,
|
|
9
|
+
DeviceType,
|
|
10
|
+
HeatingCoolingMode,
|
|
11
|
+
HeatingCoolingModeOverride,
|
|
12
|
+
LocationPointKey,
|
|
13
|
+
ModbusMode,
|
|
14
|
+
PeripheralPointKey,
|
|
15
|
+
PeripheralType,
|
|
16
|
+
PointKey,
|
|
17
|
+
RoomLock,
|
|
18
|
+
RoomMode,
|
|
19
|
+
RoomModeOverride,
|
|
20
|
+
RoomPointKey,
|
|
21
|
+
RoomState,
|
|
22
|
+
RoomType,
|
|
23
|
+
TemperaturePreset,
|
|
24
|
+
UpdateMode,
|
|
25
|
+
peripheral_key,
|
|
26
|
+
room_key,
|
|
27
|
+
)
|
|
28
|
+
from ._sentio import (
|
|
29
|
+
DEFAULT_PORT,
|
|
30
|
+
DEFAULT_UNIT_ID,
|
|
31
|
+
SentioPeripheral,
|
|
32
|
+
SentioRoom,
|
|
33
|
+
create_client,
|
|
34
|
+
create_client_on,
|
|
35
|
+
peripherals,
|
|
36
|
+
rooms,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__version__ = "0.2.0"
|
|
40
|
+
__all__ = [
|
|
41
|
+
"DEFAULT_PORT",
|
|
42
|
+
"DEFAULT_UNIT_ID",
|
|
43
|
+
"NOT_SUPPORTED",
|
|
44
|
+
"PERIPHERAL",
|
|
45
|
+
"ROOM",
|
|
46
|
+
"SENTIO",
|
|
47
|
+
"UNITS",
|
|
48
|
+
"BlockingSource",
|
|
49
|
+
"DeviceType",
|
|
50
|
+
"HeatingCoolingMode",
|
|
51
|
+
"HeatingCoolingModeOverride",
|
|
52
|
+
"LocationPointKey",
|
|
53
|
+
"ModbusMode",
|
|
54
|
+
"PeripheralPointKey",
|
|
55
|
+
"PeripheralType",
|
|
56
|
+
"PointKey",
|
|
57
|
+
"RoomLock",
|
|
58
|
+
"RoomMode",
|
|
59
|
+
"RoomModeOverride",
|
|
60
|
+
"RoomPointKey",
|
|
61
|
+
"RoomState",
|
|
62
|
+
"RoomType",
|
|
63
|
+
"TemperaturePreset",
|
|
64
|
+
"UpdateMode",
|
|
65
|
+
"SentioPeripheral",
|
|
66
|
+
"SentioRoom",
|
|
67
|
+
"create_client",
|
|
68
|
+
"create_client_on",
|
|
69
|
+
"peripheral_key",
|
|
70
|
+
"peripherals",
|
|
71
|
+
"room_key",
|
|
72
|
+
"rooms",
|
|
73
|
+
]
|
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
"""The Wavin Sentio CCU-208 register map, as a modbus_event_connect model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import IntEnum
|
|
8
|
+
from typing import Any
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
|
|
11
|
+
from modbus_event_connect import (
|
|
12
|
+
DataType,
|
|
13
|
+
Key,
|
|
14
|
+
Labels,
|
|
15
|
+
Limits,
|
|
16
|
+
Model,
|
|
17
|
+
Point,
|
|
18
|
+
PollRate,
|
|
19
|
+
Quality,
|
|
20
|
+
Refresh,
|
|
21
|
+
RepeatedSection,
|
|
22
|
+
Scan,
|
|
23
|
+
Section,
|
|
24
|
+
Unit,
|
|
25
|
+
WriteKind,
|
|
26
|
+
)
|
|
27
|
+
from modbus_event_connect.modbus import (
|
|
28
|
+
DiscreteInput,
|
|
29
|
+
HoldingRegister,
|
|
30
|
+
InputRegister,
|
|
31
|
+
ModbusOptions,
|
|
32
|
+
plain,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_LOGGER = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
ADDRESS_SPACE_REQUIRED = (3, 2)
|
|
38
|
+
"""Oldest Sentio address space this register map was written against (FWPKG 12)."""
|
|
39
|
+
|
|
40
|
+
ROOM_COUNT = 16
|
|
41
|
+
"""Rooms the address space defines. Room N's registers start at N * 100."""
|
|
42
|
+
PERIPHERAL_COUNT = 64
|
|
43
|
+
"""Peripheral slots the address space defines. Slot N's registers start at 51100 + N * 100."""
|
|
44
|
+
|
|
45
|
+
MAX_REGISTERS = 32
|
|
46
|
+
"""The manual caps one request at 32 registers - well below the Modbus protocol's 125."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def room_base(room: int) -> int:
|
|
50
|
+
"""Base address of a room. Room 1 -> 100, room 16 -> 1600."""
|
|
51
|
+
return room * 100
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def peripheral_base(slot: int) -> int:
|
|
55
|
+
"""Base address of a peripheral slot. Slot 1 -> 51200, slot 64 -> 57500."""
|
|
56
|
+
return 51100 + slot * 100
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ======================================================================= value vocabularies
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RoomType(IntEnum):
|
|
63
|
+
NORMAL = 0
|
|
64
|
+
DUMMY = 1
|
|
65
|
+
"""No thermostat or sensor installed, so the room reports no measurements."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class RoomState(IntEnum):
|
|
69
|
+
"""Values of the room state registers (general, radiators, underfloor, integration)."""
|
|
70
|
+
NONE = 0
|
|
71
|
+
"""Function not used in this room, or no load detected on any output."""
|
|
72
|
+
IDLE = 1
|
|
73
|
+
HEATING = 2
|
|
74
|
+
COOLING = 3
|
|
75
|
+
BLOCKED_HEATING = 4
|
|
76
|
+
BLOCKED_COOLING = 5
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class BlockingSource(IntEnum):
|
|
80
|
+
"""Values of the blocking-source registers."""
|
|
81
|
+
NONE = 0
|
|
82
|
+
UNKNOWN = 1
|
|
83
|
+
CONTACT = 2
|
|
84
|
+
FLOOR_TEMP = 3
|
|
85
|
+
LOW_ENERGY = 4
|
|
86
|
+
AIR_TEMP = 5
|
|
87
|
+
DEW_POINT = 6
|
|
88
|
+
OUTDOOR_TEMP = 7
|
|
89
|
+
FAULT = 8
|
|
90
|
+
FAULT_HTCO = 9
|
|
91
|
+
PERIODIC_ACTIVATION = 10
|
|
92
|
+
BMS = 11
|
|
93
|
+
DEADBAND = 12
|
|
94
|
+
DRYING = 13
|
|
95
|
+
HEATING_COOLING_MODE = 14
|
|
96
|
+
INSUFFICIENT_DEMAND = 15
|
|
97
|
+
COOLDOWN_PERIOD = 16
|
|
98
|
+
HCW_SOURCE_NOT_RELEASED = 17
|
|
99
|
+
ROOM_MODE = 18
|
|
100
|
+
SYSTEM_IS_INITIALIZING = 19
|
|
101
|
+
SYSTEM_IS_SHUTTING_DOWN = 20
|
|
102
|
+
NO_OUTPUT = 21
|
|
103
|
+
FIRST_OPEN_ACTIVATION = 22
|
|
104
|
+
ROOM_WITH_NO_TEMP_SOURCE = 23
|
|
105
|
+
HCWS_ELEMENTS_BLOCKED = 24
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class RoomMode(IntEnum):
|
|
109
|
+
"""Values of a room's mode register."""
|
|
110
|
+
SCHEDULE = 0
|
|
111
|
+
"""The scheduler's temperature is used, not the room's setpoint."""
|
|
112
|
+
MANUAL = 1
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class RoomModeOverride(IntEnum):
|
|
116
|
+
"""Values of a room's mode override register; above NONE, the room's setpoint is not used."""
|
|
117
|
+
NONE = 0
|
|
118
|
+
TEMPORARY = 1
|
|
119
|
+
VACATION_AWAY = 2
|
|
120
|
+
ADJUST = 3
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TemperaturePreset(IntEnum):
|
|
124
|
+
"""Values of a room's temperature preset register."""
|
|
125
|
+
ECO = 0
|
|
126
|
+
COMFORT = 1
|
|
127
|
+
EXTRA_COMFORT = 2
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class HeatingCoolingMode(IntEnum):
|
|
131
|
+
"""Values of the location's heating/cooling mode register."""
|
|
132
|
+
HEATING = 0
|
|
133
|
+
COOLING = 1
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class HeatingCoolingModeOverride(IntEnum):
|
|
137
|
+
"""Values of the heating/cooling mode BMS override register.
|
|
138
|
+
|
|
139
|
+
Only hardware profiles with a manual change-over offer it; in others it is DISABLED.
|
|
140
|
+
"""
|
|
141
|
+
DISABLED = 0
|
|
142
|
+
HEATING = 1
|
|
143
|
+
COOLING = 2
|
|
144
|
+
EXTERNAL_SWITCH = 3
|
|
145
|
+
"""Set by an external switch, only where the hardware input is available."""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class DeviceType(IntEnum):
|
|
149
|
+
"""Values of the location's device type register."""
|
|
150
|
+
CCU_208 = 1
|
|
151
|
+
DHW_201 = 2
|
|
152
|
+
"""Calefa domestic hot water controller."""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ModbusMode(IntEnum):
|
|
156
|
+
"""Values of the Modbus mode register."""
|
|
157
|
+
DISABLED = 0
|
|
158
|
+
READ_ONLY = 1
|
|
159
|
+
READ_WRITE = 2
|
|
160
|
+
WRITE_WITH_PASSWORD = 3
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class UpdateMode(IntEnum):
|
|
164
|
+
"""Values of the update mode register."""
|
|
165
|
+
DONT_ALLOW_FROM_MOBILE_APP = 0
|
|
166
|
+
ENABLED = 1
|
|
167
|
+
DISABLED_ENTIRELY = 2
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class PeripheralType(IntEnum):
|
|
171
|
+
"""Values of the peripheral type register."""
|
|
172
|
+
DHW_201 = 0x0
|
|
173
|
+
"""Calefa domestic hot water controller."""
|
|
174
|
+
LCD_200 = 0x4
|
|
175
|
+
RT_201 = 0x5
|
|
176
|
+
RT_250 = 0x6
|
|
177
|
+
RS_211 = 0x7
|
|
178
|
+
RS_251 = 0x8
|
|
179
|
+
RT_250IR = 0x9
|
|
180
|
+
EU_208_A = 0xA
|
|
181
|
+
EU_206_VFR = 0xB
|
|
182
|
+
ET_250 = 0xC
|
|
183
|
+
ET_210 = 0xD
|
|
184
|
+
VH_250 = 0xE
|
|
185
|
+
CCU_208 = 0x11E0
|
|
186
|
+
"""The control unit itself. Wider than a byte, hence a 16-bit register."""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ==================================================================================== keys
|
|
190
|
+
#
|
|
191
|
+
# Every point has its key here, with the type of its value. A location point's key is its
|
|
192
|
+
# whole key; a room's or a peripheral's becomes one through `room_key` or `peripheral_key`.
|
|
193
|
+
# The key strings never change.
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class RoomLock(IntEnum):
|
|
197
|
+
"""Values of a room's lock register; no other value can be written."""
|
|
198
|
+
LOCKED = 8
|
|
199
|
+
HOTEL = 16
|
|
200
|
+
UNLOCKED = 32
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(frozen=True)
|
|
204
|
+
class PointKey[T]:
|
|
205
|
+
"""The key of a point every room, or every peripheral slot, has, and the type of its value."""
|
|
206
|
+
name: str
|
|
207
|
+
type: type[T]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _members[K](namespace: type, kind: type[K]) -> tuple[K, ...]:
|
|
211
|
+
return tuple(value for value in vars(namespace).values() if isinstance(value, kind))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class LocationPointKey:
|
|
215
|
+
"""The key of each point the location has."""
|
|
216
|
+
DATAPOINT_MAJOR = Key("datapoint_major", int)
|
|
217
|
+
DATAPOINT_MINOR = Key("datapoint_minor", int)
|
|
218
|
+
DEVICE_TYPE = Key("device_type", DeviceType)
|
|
219
|
+
HARDWARE_MAJOR = Key("hardware_major", int)
|
|
220
|
+
SOFTWARE_MAJOR = Key("software_major", int)
|
|
221
|
+
SOFTWARE_MINOR = Key("software_minor", int)
|
|
222
|
+
SERIAL_NUMBER_PREFIX = Key("serial_number_prefix", int)
|
|
223
|
+
SERIAL_NUMBER = Key("serial_number", int)
|
|
224
|
+
HEATING_COOLING_MODE = Key("heating_cooling_mode", HeatingCoolingMode)
|
|
225
|
+
SETPOINT_MAJOR = Key("setpoint_major", int)
|
|
226
|
+
SETPOINT_MINOR = Key("setpoint_minor", int)
|
|
227
|
+
MODBUS_MODE = Key("modbus_mode", ModbusMode)
|
|
228
|
+
MODBUS_PASSWORD = Key("modbus_password", int)
|
|
229
|
+
LOCATION_NAME = Key("location_name", str)
|
|
230
|
+
STANDBY_ENABLE = Key("standby_enable", bool)
|
|
231
|
+
VACATION_ENABLE = Key("vacation_enable", bool)
|
|
232
|
+
DATETIME_UNIX = Key("datetime_unix", int)
|
|
233
|
+
DAYLIGHT_SAVING_ENABLE = Key("daylight_saving_enable", bool)
|
|
234
|
+
TEMP_OUTDOOR_COOLING_MIN = Key("temp_outdoor_cooling_min", float)
|
|
235
|
+
TEMP_OUTDOOR_HEATING_MAX = Key("temp_outdoor_heating_max", float)
|
|
236
|
+
UPDATE_MODE = Key("update_mode", UpdateMode)
|
|
237
|
+
HEATING_COOLING_MODE_BMS_OVERRIDE = Key("heating_cooling_mode_bms_override", HeatingCoolingModeOverride)
|
|
238
|
+
TIMEZONE = Key("timezone", int)
|
|
239
|
+
SYSTEM_WARNING = Key("system_warning", bool)
|
|
240
|
+
SYSTEM_ERROR = Key("system_error", bool)
|
|
241
|
+
|
|
242
|
+
@classmethod
|
|
243
|
+
def all(cls) -> tuple[Key[Any], ...]:
|
|
244
|
+
"""Every key, in the order declared."""
|
|
245
|
+
kind: type[Key[Any]] = Key
|
|
246
|
+
return _members(cls, kind)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class RoomPointKey:
|
|
250
|
+
"""The key of each point within a room; `room_key` makes it the point's key."""
|
|
251
|
+
TEMP_AIR_TARGET_ACTIVE = PointKey("temp_air_target_active", float)
|
|
252
|
+
STATE = PointKey("state", RoomState)
|
|
253
|
+
BLOCKING_SOURCE = PointKey("blocking_source", BlockingSource)
|
|
254
|
+
TEMP_AIR_CURRENT = PointKey("temp_air_current", float)
|
|
255
|
+
TEMP_FLOOR_CURRENT = PointKey("temp_floor_current", float)
|
|
256
|
+
HUMIDITY_CURRENT = PointKey("humidity_current", float)
|
|
257
|
+
DEW_POINT_CURRENT = PointKey("dew_point_current", float)
|
|
258
|
+
ASSOCIATED_RADIATORS = PointKey("associated_radiators", int)
|
|
259
|
+
ASSOCIATED_UFHC = PointKey("associated_ufhc", int)
|
|
260
|
+
ASSOCIATED_DRYING = PointKey("associated_drying", int)
|
|
261
|
+
ASSOCIATED_THERMAL_INTEGRATION = PointKey("associated_thermal_integration", int)
|
|
262
|
+
ASSOCIATED_VENTILATION = PointKey("associated_ventilation", int)
|
|
263
|
+
RADIATORS_STATE = PointKey("radiators_state", RoomState)
|
|
264
|
+
UFHC_STATE = PointKey("ufhc_state", RoomState)
|
|
265
|
+
DRYING_STATE = PointKey("drying_state", RoomState)
|
|
266
|
+
THERMAL_INTEGRATION_STATE = PointKey("thermal_integration_state", RoomState)
|
|
267
|
+
VENTILATION_STATE = PointKey("ventilation_state", RoomState)
|
|
268
|
+
BLOCKING_SOURCE_RADIATORS = PointKey("blocking_source_radiators", BlockingSource)
|
|
269
|
+
BLOCKING_SOURCE_UFHC = PointKey("blocking_source_ufhc", BlockingSource)
|
|
270
|
+
BLOCKING_SOURCE_DRYING = PointKey("blocking_source_drying", BlockingSource)
|
|
271
|
+
BLOCKING_SOURCE_THERMAL_INTEGRATION = PointKey("blocking_source_thermal_integration", BlockingSource)
|
|
272
|
+
BLOCKING_SOURCE_VENTILATION = PointKey("blocking_source_ventilation", BlockingSource)
|
|
273
|
+
TYPE = PointKey("type", RoomType)
|
|
274
|
+
ASSOCIATED_HEATING_SOURCE = PointKey("associated_heating_source", int)
|
|
275
|
+
NAME = PointKey("name", str)
|
|
276
|
+
MODE = PointKey("mode", RoomMode)
|
|
277
|
+
MODE_OVERRIDE = PointKey("mode_override", RoomModeOverride)
|
|
278
|
+
TEMP_AIR_TARGET = PointKey("temp_air_target", float)
|
|
279
|
+
LOCK = PointKey("lock", RoomLock)
|
|
280
|
+
TEMP_STANDBY = PointKey("temp_standby", float)
|
|
281
|
+
TEMP_VACATION = PointKey("temp_vacation", float)
|
|
282
|
+
EXCLUDE_FROM_VACATION = PointKey("exclude_from_vacation", int)
|
|
283
|
+
ADAPTIVE_ENABLE = PointKey("adaptive_enable", int)
|
|
284
|
+
THERMAL_INTEGRATION_HEATING_OFFSET = PointKey("thermal_integration_heating_offset", float)
|
|
285
|
+
THERMAL_INTEGRATION_HYSTERESIS = PointKey("thermal_integration_hysteresis", float)
|
|
286
|
+
HUMIDITY_THRESHOLD_HEATING = PointKey("humidity_threshold_heating", float)
|
|
287
|
+
HUMIDITY_THRESHOLD_COOLING = PointKey("humidity_threshold_cooling", float)
|
|
288
|
+
HUMIDITY_HYSTERESIS = PointKey("humidity_hysteresis", float)
|
|
289
|
+
DRYING_COOLING_WATER_OFFSET = PointKey("drying_cooling_water_offset", float)
|
|
290
|
+
DRYING_COOLING_WATER_OFFSET_HYSTERESIS = PointKey("drying_cooling_water_offset_hysteresis", float)
|
|
291
|
+
DEW_POINT_COOLING_THRESHOLD = PointKey("dew_point_cooling_threshold", float)
|
|
292
|
+
DEW_POINT_COOLING_THRESHOLD_HYSTERESIS = PointKey("dew_point_cooling_threshold_hysteresis", float)
|
|
293
|
+
HUMIDITY_HIGH_ALARM_LIMIT = PointKey("humidity_high_alarm_limit", float)
|
|
294
|
+
TEMP_PRESET = PointKey("temp_preset", TemperaturePreset)
|
|
295
|
+
WARNING = PointKey("warning", bool)
|
|
296
|
+
ERROR = PointKey("error", bool)
|
|
297
|
+
LOW_BATTERY = PointKey("low_battery", bool)
|
|
298
|
+
PERIPHERAL_LOST = PointKey("peripheral_lost", bool)
|
|
299
|
+
|
|
300
|
+
@classmethod
|
|
301
|
+
def all(cls) -> tuple[PointKey[Any], ...]:
|
|
302
|
+
"""Every key, in the order declared."""
|
|
303
|
+
kind: type[PointKey[Any]] = PointKey
|
|
304
|
+
return _members(cls, kind)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class PeripheralPointKey:
|
|
308
|
+
"""The key of each point within a peripheral slot; `peripheral_key` makes it the point's key."""
|
|
309
|
+
TYPE = PointKey("type", PeripheralType)
|
|
310
|
+
SERIAL_NUMBER = PointKey("serial_number", int)
|
|
311
|
+
OWNER = PointKey("owner", int)
|
|
312
|
+
SIGNAL_STRENGTH = PointKey("signal_strength", int)
|
|
313
|
+
NAME = PointKey("name", str)
|
|
314
|
+
WARNING = PointKey("warning", bool)
|
|
315
|
+
ERROR = PointKey("error", bool)
|
|
316
|
+
LOW_BATTERY = PointKey("low_battery", bool)
|
|
317
|
+
LOST = PointKey("lost", bool)
|
|
318
|
+
|
|
319
|
+
@classmethod
|
|
320
|
+
def all(cls) -> tuple[PointKey[Any], ...]:
|
|
321
|
+
"""Every key, in the order declared."""
|
|
322
|
+
kind: type[PointKey[Any]] = PointKey
|
|
323
|
+
return _members(cls, kind)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
NOT_SUPPORTED: Mapping[PeripheralType, frozenset[PointKey[Any]]] = MappingProxyType({
|
|
327
|
+
PeripheralType.RT_201: frozenset({PeripheralPointKey.SIGNAL_STRENGTH}),
|
|
328
|
+
PeripheralType.RS_211: frozenset({PeripheralPointKey.SIGNAL_STRENGTH}),
|
|
329
|
+
PeripheralType.ET_210: frozenset({PeripheralPointKey.SIGNAL_STRENGTH}),
|
|
330
|
+
PeripheralType.LCD_200: frozenset({PeripheralPointKey.SIGNAL_STRENGTH}),
|
|
331
|
+
PeripheralType.EU_208_A: frozenset({PeripheralPointKey.SIGNAL_STRENGTH}),
|
|
332
|
+
})
|
|
333
|
+
"""The points a peripheral of each known type does not have, which a scan removes; a type not
|
|
334
|
+
listed has every peripheral point.
|
|
335
|
+
|
|
336
|
+
Sources: the manual's component table calls the RT-201, RS-211 and ET-210 wired. The LCD-200 is
|
|
337
|
+
wired and the EU-208-A has no antenna, and a CCU-208 answers "no reading" for the signal strength
|
|
338
|
+
of both.
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
ROOM = "room"
|
|
342
|
+
"""The instance label of a room: its number, 1 to ROOM_COUNT."""
|
|
343
|
+
PERIPHERAL = "peripheral"
|
|
344
|
+
"""The instance label of a peripheral slot: its number, 1 to PERIPHERAL_COUNT."""
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def room_key[T](room: int, point: PointKey[T]) -> Key[T]:
|
|
348
|
+
"""The key of `point` in room `room`: `room_key(4, RoomPointKey.STATE)` is "room_4_state"."""
|
|
349
|
+
return Key(f"room_{room}_{point.name}", point.type)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def peripheral_key[T](slot: int, point: PointKey[T]) -> Key[T]:
|
|
353
|
+
"""The key of `point` in slot `slot`: `peripheral_key(2, PeripheralPointKey.NAME)` is "peripheral_2_name"."""
|
|
354
|
+
return Key(f"peripheral_{slot}_{point.name}", point.type)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
UNITS = frozenset({Unit.CELSIUS, Unit.PERCENT, Unit.SECONDS})
|
|
358
|
+
"""Every unit a Sentio value has."""
|
|
359
|
+
|
|
360
|
+
_ALARM_KIND = "alarm"
|
|
361
|
+
ALARM = Labels(kind=_ALARM_KIND)
|
|
362
|
+
"""Every alarm and warning bit."""
|
|
363
|
+
|
|
364
|
+
SENSOR = "sensor"
|
|
365
|
+
"""The label kind of a room's measurements, which a room without a sensor does not have."""
|
|
366
|
+
|
|
367
|
+
ROOM_FUNCTIONS: Mapping[str, PointKey[int]] = MappingProxyType({
|
|
368
|
+
"radiators": RoomPointKey.ASSOCIATED_RADIATORS,
|
|
369
|
+
"ufhc": RoomPointKey.ASSOCIATED_UFHC,
|
|
370
|
+
"drying": RoomPointKey.ASSOCIATED_DRYING,
|
|
371
|
+
"thermal_integration": RoomPointKey.ASSOCIATED_THERMAL_INTEGRATION,
|
|
372
|
+
"ventilation": RoomPointKey.ASSOCIATED_VENTILATION,
|
|
373
|
+
})
|
|
374
|
+
"""What a room can be associated with, and the point saying whether it is.
|
|
375
|
+
|
|
376
|
+
Each also has a state and a blocking source, labelled `function` with its name.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ======================================================================= what a write changes
|
|
381
|
+
#
|
|
382
|
+
# A value other writes change belongs to a group: a `group` label its point carries. A write
|
|
383
|
+
# names the groups it changes with `on_write=rereads(...)`, so each group is listed once, here.
|
|
384
|
+
|
|
385
|
+
ROOM_TARGET = "room_target"
|
|
386
|
+
"""The target a room is regulating to right now (`room_{n}_temp_air_target_active`).
|
|
387
|
+
|
|
388
|
+
Measured on a CCU-208 (address space 3.7): writing `vacation_enable` changed every room's, and
|
|
389
|
+
a room's `mode` changed that room's; nothing else in the location or the rooms changed while
|
|
390
|
+
watched. Per the manual, standby and a room's setpoint, override, preset and vacation and
|
|
391
|
+
standby temperatures decide it too.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
REREAD_AFTER_WRITE = 0.5
|
|
395
|
+
"""Seconds after a write before the groups it changes are read. Measured with the above."""
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def rereads(group: str, **where: int) -> Refresh:
|
|
399
|
+
"""Read `group` again after a write: all of it, or the part `where` selects, as `room=n`."""
|
|
400
|
+
return Refresh(Labels(group=group, **where), after=REREAD_AFTER_WRITE)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ================================================================================ encodings
|
|
404
|
+
#
|
|
405
|
+
# How the manual's value types map to points. Its data type table gives each type's range and
|
|
406
|
+
# its "invalid value", which is no reading; a register may give fewer values than its type.
|
|
407
|
+
|
|
408
|
+
def _u8[T](key: Key[T], read: InputRegister | HoldingRegister, *, write: HoldingRegister | None = None,
|
|
409
|
+
maximum: int = 254, poll_rate: PollRate = PollRate.SLOW, unit: Unit | None = None,
|
|
410
|
+
on_write: Refresh | None = None, labels: Mapping[str, str] | None = None) -> Point[T]:
|
|
411
|
+
"""val_u1: 0 to `maximum`; anything above - its invalid 0xFF in particular - is no value."""
|
|
412
|
+
return Point(key, read=read, write=write, data_type=DataType.UINT16, valid_raw=range(0, maximum + 1),
|
|
413
|
+
poll_rate=poll_rate,
|
|
414
|
+
unit=unit, limits=Limits(0, maximum, step=1) if write is not None else None, on_write=on_write,
|
|
415
|
+
labels=labels or {})
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _flag(key: Key[bool], register: HoldingRegister, *, on_write: Refresh | None = None) -> Point[bool]:
|
|
419
|
+
"""val_u1 given as "0 OFF, 1 ON"; anything else - its invalid 0xFF in particular - is no value."""
|
|
420
|
+
return Point(key, read=register, write=register, data_type=DataType.BOOL, valid_raw=range(0, 2),
|
|
421
|
+
poll_rate=PollRate.SLOW, on_write=on_write)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _choice[T](key: Key[T], register: HoldingRegister, *, on_write: Refresh | None = None) -> Point[T]:
|
|
425
|
+
"""val_u1 naming a state, written as one of them; its invalid 0xFF is no value."""
|
|
426
|
+
return Point(key, read=register, write=register, data_type=DataType.UINT16, valid_raw=range(0, 0xFF),
|
|
427
|
+
poll_rate=PollRate.SLOW, on_write=on_write)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _u16[T](key: Key[T], read: InputRegister | HoldingRegister, *, write: HoldingRegister | None = None,
|
|
431
|
+
poll_rate: PollRate = PollRate.SLOW, write_kind: WriteKind = WriteKind.STATE) -> Point[T]:
|
|
432
|
+
"""val_u2: 0 to 0xFFFE; its invalid 0xFFFF is no value."""
|
|
433
|
+
return Point(key, read=read, write=write, data_type=DataType.UINT16, poll_rate=poll_rate, write_kind=write_kind,
|
|
434
|
+
valid_raw=range(0, 0xFFFF))
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _u32(key: Key[int], read: InputRegister | HoldingRegister, *, write: HoldingRegister | None = None,
|
|
438
|
+
poll_rate: PollRate = PollRate.SLOW, unit: Unit | None = None) -> Point[int]:
|
|
439
|
+
"""val_u4: 0 to 0xFFFFFFFE; its invalid 0xFFFFFFFF is no value."""
|
|
440
|
+
return Point(key, read=read, write=write, data_type=DataType.UINT32, valid_raw=range(0, 0xFFFFFFFF),
|
|
441
|
+
poll_rate=poll_rate, unit=unit)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _fp100(key: Key[float], read: InputRegister | HoldingRegister, *, write: HoldingRegister | None = None,
|
|
445
|
+
poll_rate: PollRate = PollRate.SLOW, unit: Unit | None = Unit.CELSIUS, on_write: Refresh | None = None,
|
|
446
|
+
labels: Mapping[str, str] | None = None) -> Point[float]:
|
|
447
|
+
"""val_d2_fp100: signed hundredths, -327.68 to 327.66; its invalid 0x7FFF is no reading."""
|
|
448
|
+
return Point(key, read=read, write=write, data_type=DataType.INT16, scale=0.01, valid_raw=range(-0x8000, 0x7FFF),
|
|
449
|
+
poll_rate=poll_rate, unit=unit, on_write=on_write, labels=labels or {})
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _alarm(key: Key[bool], read: DiscreteInput) -> Point[bool]:
|
|
453
|
+
"""An alarm bit: read rarely by itself, and at once whenever the system's summary changes."""
|
|
454
|
+
return Point(key, read=read, data_type=DataType.BOOL, poll_rate=PollRate.RARE, labels={"kind": _ALARM_KIND})
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _summary(key: Key[bool], read: DiscreteInput) -> Point[bool]:
|
|
458
|
+
"""One of the system's two aggregated alarm bits.
|
|
459
|
+
|
|
460
|
+
Polled even when nobody asks for it, since its change is what sets off reading every
|
|
461
|
+
other alarm bit that is subscribed to.
|
|
462
|
+
"""
|
|
463
|
+
return Point(key, read=read, data_type=DataType.BOOL, poll_rate=PollRate.FAST, poll_always=True,
|
|
464
|
+
on_change=Refresh(ALARM))
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _text(key: Key[str], read: HoldingRegister, *, write: HoldingRegister | None = None) -> Point[str]:
|
|
468
|
+
"""val_utf8: 32 bytes over 16 registers, NUL terminated, no length prefix."""
|
|
469
|
+
return Point(key, read=read, write=write, data_type=DataType.string(16), poll_rate=PollRate.STATIC)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ================================================================================== points
|
|
473
|
+
|
|
474
|
+
LOCATION = [
|
|
475
|
+
_u8(LocationPointKey.DATAPOINT_MAJOR, InputRegister(1), poll_rate=PollRate.STATIC),
|
|
476
|
+
_u8(LocationPointKey.DATAPOINT_MINOR, InputRegister(2), poll_rate=PollRate.STATIC),
|
|
477
|
+
_u8(LocationPointKey.DEVICE_TYPE, InputRegister(10), poll_rate=PollRate.STATIC),
|
|
478
|
+
_u8(LocationPointKey.HARDWARE_MAJOR, InputRegister(11), poll_rate=PollRate.STATIC),
|
|
479
|
+
_u8(LocationPointKey.SOFTWARE_MAJOR, InputRegister(12), poll_rate=PollRate.STATIC),
|
|
480
|
+
_u8(LocationPointKey.SOFTWARE_MINOR, InputRegister(13), poll_rate=PollRate.STATIC),
|
|
481
|
+
_u16(LocationPointKey.SERIAL_NUMBER_PREFIX, InputRegister(14), poll_rate=PollRate.STATIC),
|
|
482
|
+
_u32(LocationPointKey.SERIAL_NUMBER, InputRegister(15), poll_rate=PollRate.STATIC),
|
|
483
|
+
_u8(LocationPointKey.HEATING_COOLING_MODE, InputRegister(20)),
|
|
484
|
+
|
|
485
|
+
_u8(LocationPointKey.SETPOINT_MAJOR, HoldingRegister(1), poll_rate=PollRate.STATIC),
|
|
486
|
+
_u8(LocationPointKey.SETPOINT_MINOR, HoldingRegister(2), poll_rate=PollRate.STATIC),
|
|
487
|
+
# Whether the controller will accept writes at all, so it is always kept current.
|
|
488
|
+
Point(LocationPointKey.MODBUS_MODE, read=HoldingRegister(5), data_type=DataType.UINT16, valid_raw=range(0, 0xFF),
|
|
489
|
+
poll_always=True),
|
|
490
|
+
# Write-only per the manual: it can never be read back. The manual: "range for password is 1 - 65534".
|
|
491
|
+
Point(LocationPointKey.MODBUS_PASSWORD, write=HoldingRegister(6), data_type=DataType.UINT16,
|
|
492
|
+
write_kind=WriteKind.COMMAND, valid_raw=range(1, 0xFFFF)),
|
|
493
|
+
_text(LocationPointKey.LOCATION_NAME, HoldingRegister(10), write=HoldingRegister(10)),
|
|
494
|
+
_flag(LocationPointKey.STANDBY_ENABLE, HoldingRegister(26), on_write=rereads(ROOM_TARGET)),
|
|
495
|
+
_flag(LocationPointKey.VACATION_ENABLE, HoldingRegister(27), on_write=rereads(ROOM_TARGET)),
|
|
496
|
+
_u32(LocationPointKey.DATETIME_UNIX, HoldingRegister(28), write=HoldingRegister(28), unit=Unit.SECONDS),
|
|
497
|
+
_flag(LocationPointKey.DAYLIGHT_SAVING_ENABLE, HoldingRegister(30)),
|
|
498
|
+
_fp100(LocationPointKey.TEMP_OUTDOOR_COOLING_MIN, HoldingRegister(31), write=HoldingRegister(31)),
|
|
499
|
+
_fp100(LocationPointKey.TEMP_OUTDOOR_HEATING_MAX, HoldingRegister(32), write=HoldingRegister(32)),
|
|
500
|
+
_choice(LocationPointKey.UPDATE_MODE, HoldingRegister(33)),
|
|
501
|
+
_choice(LocationPointKey.HEATING_COOLING_MODE_BMS_OVERRIDE, HoldingRegister(34)),
|
|
502
|
+
_u16(LocationPointKey.TIMEZONE, HoldingRegister(35), write=HoldingRegister(35)),
|
|
503
|
+
|
|
504
|
+
# The manual: "A problem is pending in whole system" / "A critical problem is pending".
|
|
505
|
+
_summary(LocationPointKey.SYSTEM_WARNING, DiscreteInput(1)),
|
|
506
|
+
_summary(LocationPointKey.SYSTEM_ERROR, DiscreteInput(2)),
|
|
507
|
+
]
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def room(n: int) -> list[Point[Any]]:
|
|
511
|
+
"""One room's points."""
|
|
512
|
+
base = room_base(n)
|
|
513
|
+
|
|
514
|
+
def state[T](point: PointKey[T], offset: int, function: str | None = None) -> Point[T]:
|
|
515
|
+
return _u8(room_key(n, point), InputRegister(base + offset), poll_rate=PollRate.FAST,
|
|
516
|
+
labels={"function": function} if function else None)
|
|
517
|
+
|
|
518
|
+
def sensor(point: PointKey[float], offset: int, unit: Unit = Unit.CELSIUS) -> Point[float]:
|
|
519
|
+
return _fp100(room_key(n, point), InputRegister(base + offset), poll_rate=PollRate.MEDIUM, unit=unit,
|
|
520
|
+
labels={"kind": SENSOR})
|
|
521
|
+
|
|
522
|
+
def associated(point: PointKey[int], offset: int) -> Point[int]:
|
|
523
|
+
return _u8(room_key(n, point), InputRegister(base + offset), poll_rate=PollRate.STATIC)
|
|
524
|
+
|
|
525
|
+
retarget = rereads(ROOM_TARGET, room=n)
|
|
526
|
+
|
|
527
|
+
def setting(point: PointKey[float], offset: int, unit: Unit = Unit.CELSIUS,
|
|
528
|
+
on_write: Refresh | None = None) -> Point[float]:
|
|
529
|
+
return _fp100(room_key(n, point), HoldingRegister(base + offset), write=HoldingRegister(base + offset),
|
|
530
|
+
unit=unit, on_write=on_write)
|
|
531
|
+
|
|
532
|
+
def switch(point: PointKey[int], offset: int, maximum: int, on_write: Refresh | None = None) -> Point[int]:
|
|
533
|
+
return _u8(room_key(n, point), HoldingRegister(base + offset), write=HoldingRegister(base + offset),
|
|
534
|
+
maximum=maximum, on_write=on_write)
|
|
535
|
+
|
|
536
|
+
return [
|
|
537
|
+
# --- input registers: what the room is doing
|
|
538
|
+
# The target the controller is actually regulating to. Differs from `temp_air_target`
|
|
539
|
+
# (the user's setting, below) under standby, vacation or a schedule.
|
|
540
|
+
_fp100(room_key(n, RoomPointKey.TEMP_AIR_TARGET_ACTIVE), InputRegister(base + 1), poll_rate=PollRate.MEDIUM,
|
|
541
|
+
labels={"group": ROOM_TARGET}),
|
|
542
|
+
state(RoomPointKey.STATE, 2),
|
|
543
|
+
state(RoomPointKey.BLOCKING_SOURCE, 3),
|
|
544
|
+
sensor(RoomPointKey.TEMP_AIR_CURRENT, 4),
|
|
545
|
+
sensor(RoomPointKey.TEMP_FLOOR_CURRENT, 5),
|
|
546
|
+
sensor(RoomPointKey.HUMIDITY_CURRENT, 6, Unit.PERCENT),
|
|
547
|
+
sensor(RoomPointKey.DEW_POINT_CURRENT, 7),
|
|
548
|
+
associated(RoomPointKey.ASSOCIATED_RADIATORS, 11),
|
|
549
|
+
associated(RoomPointKey.ASSOCIATED_UFHC, 12),
|
|
550
|
+
associated(RoomPointKey.ASSOCIATED_DRYING, 14),
|
|
551
|
+
associated(RoomPointKey.ASSOCIATED_THERMAL_INTEGRATION, 15),
|
|
552
|
+
associated(RoomPointKey.ASSOCIATED_VENTILATION, 16),
|
|
553
|
+
state(RoomPointKey.RADIATORS_STATE, 17, "radiators"),
|
|
554
|
+
state(RoomPointKey.UFHC_STATE, 18, "ufhc"),
|
|
555
|
+
state(RoomPointKey.DRYING_STATE, 19, "drying"),
|
|
556
|
+
state(RoomPointKey.THERMAL_INTEGRATION_STATE, 20, "thermal_integration"),
|
|
557
|
+
state(RoomPointKey.VENTILATION_STATE, 21, "ventilation"),
|
|
558
|
+
state(RoomPointKey.BLOCKING_SOURCE_RADIATORS, 22, "radiators"),
|
|
559
|
+
state(RoomPointKey.BLOCKING_SOURCE_UFHC, 23, "ufhc"),
|
|
560
|
+
state(RoomPointKey.BLOCKING_SOURCE_DRYING, 24, "drying"),
|
|
561
|
+
state(RoomPointKey.BLOCKING_SOURCE_THERMAL_INTEGRATION, 25, "thermal_integration"),
|
|
562
|
+
state(RoomPointKey.BLOCKING_SOURCE_VENTILATION, 26, "ventilation"),
|
|
563
|
+
_u8(room_key(n, RoomPointKey.TYPE), InputRegister(base + 27), poll_rate=PollRate.STATIC),
|
|
564
|
+
associated(RoomPointKey.ASSOCIATED_HEATING_SOURCE, 28),
|
|
565
|
+
|
|
566
|
+
# --- holding registers: what the user has set
|
|
567
|
+
_text(room_key(n, RoomPointKey.NAME), HoldingRegister(base + 1), write=HoldingRegister(base + 1)),
|
|
568
|
+
_choice(room_key(n, RoomPointKey.MODE), HoldingRegister(base + 17), on_write=retarget),
|
|
569
|
+
_choice(room_key(n, RoomPointKey.MODE_OVERRIDE), HoldingRegister(base + 18), on_write=retarget),
|
|
570
|
+
setting(RoomPointKey.TEMP_AIR_TARGET, 19, on_write=retarget),
|
|
571
|
+
Point(room_key(n, RoomPointKey.LOCK), read=HoldingRegister(base + 20), write=HoldingRegister(base + 20)),
|
|
572
|
+
setting(RoomPointKey.TEMP_STANDBY, 21, on_write=retarget),
|
|
573
|
+
setting(RoomPointKey.TEMP_VACATION, 22, on_write=retarget),
|
|
574
|
+
switch(RoomPointKey.EXCLUDE_FROM_VACATION, 23, 1, on_write=retarget),
|
|
575
|
+
switch(RoomPointKey.ADAPTIVE_ENABLE, 24, 1),
|
|
576
|
+
setting(RoomPointKey.THERMAL_INTEGRATION_HEATING_OFFSET, 25),
|
|
577
|
+
setting(RoomPointKey.THERMAL_INTEGRATION_HYSTERESIS, 26),
|
|
578
|
+
setting(RoomPointKey.HUMIDITY_THRESHOLD_HEATING, 27, Unit.PERCENT),
|
|
579
|
+
setting(RoomPointKey.HUMIDITY_THRESHOLD_COOLING, 28, Unit.PERCENT),
|
|
580
|
+
setting(RoomPointKey.HUMIDITY_HYSTERESIS, 29, Unit.PERCENT),
|
|
581
|
+
setting(RoomPointKey.DRYING_COOLING_WATER_OFFSET, 30),
|
|
582
|
+
setting(RoomPointKey.DRYING_COOLING_WATER_OFFSET_HYSTERESIS, 31),
|
|
583
|
+
setting(RoomPointKey.DEW_POINT_COOLING_THRESHOLD, 32),
|
|
584
|
+
setting(RoomPointKey.DEW_POINT_COOLING_THRESHOLD_HYSTERESIS, 33),
|
|
585
|
+
setting(RoomPointKey.HUMIDITY_HIGH_ALARM_LIMIT, 34, Unit.PERCENT),
|
|
586
|
+
_choice(room_key(n, RoomPointKey.TEMP_PRESET), HoldingRegister(base + 35), on_write=retarget),
|
|
587
|
+
|
|
588
|
+
# --- discrete inputs: its alarms
|
|
589
|
+
_alarm(room_key(n, RoomPointKey.WARNING), DiscreteInput(base + 1)),
|
|
590
|
+
_alarm(room_key(n, RoomPointKey.ERROR), DiscreteInput(base + 2)),
|
|
591
|
+
_alarm(room_key(n, RoomPointKey.LOW_BATTERY), DiscreteInput(base + 3)),
|
|
592
|
+
_alarm(room_key(n, RoomPointKey.PERIPHERAL_LOST), DiscreteInput(base + 4)),
|
|
593
|
+
]
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def peripheral(slot: int) -> list[Point[Any]]:
|
|
597
|
+
"""One peripheral slot's points."""
|
|
598
|
+
base = peripheral_base(slot)
|
|
599
|
+
return [
|
|
600
|
+
_u16(peripheral_key(slot, PeripheralPointKey.TYPE), InputRegister(base + 1), poll_rate=PollRate.STATIC),
|
|
601
|
+
_u32(peripheral_key(slot, PeripheralPointKey.SERIAL_NUMBER), InputRegister(base + 2), poll_rate=PollRate.STATIC),
|
|
602
|
+
_u16(peripheral_key(slot, PeripheralPointKey.OWNER), InputRegister(base + 4), poll_rate=PollRate.STATIC),
|
|
603
|
+
_u8(peripheral_key(slot, PeripheralPointKey.SIGNAL_STRENGTH), InputRegister(base + 5), poll_rate=PollRate.RARE),
|
|
604
|
+
_text(peripheral_key(slot, PeripheralPointKey.NAME), HoldingRegister(base + 1)),
|
|
605
|
+
_alarm(peripheral_key(slot, PeripheralPointKey.WARNING), DiscreteInput(base + 1)),
|
|
606
|
+
_alarm(peripheral_key(slot, PeripheralPointKey.ERROR), DiscreteInput(base + 2)),
|
|
607
|
+
_alarm(peripheral_key(slot, PeripheralPointKey.LOW_BATTERY), DiscreteInput(base + 3)),
|
|
608
|
+
_alarm(peripheral_key(slot, PeripheralPointKey.LOST), DiscreteInput(base + 4)),
|
|
609
|
+
]
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
# ==================================================================================== scan
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
async def check_the_controller(scan: Scan) -> None:
|
|
616
|
+
"""Warn when the controller's address space is older than this map was written against.
|
|
617
|
+
|
|
618
|
+
The software version and serial number are read too, though never polled: new firmware, or
|
|
619
|
+
another controller, then changes what this scan read, and everything is scanned again.
|
|
620
|
+
"""
|
|
621
|
+
found = await scan.read((LocationPointKey.DATAPOINT_MAJOR, LocationPointKey.DATAPOINT_MINOR,
|
|
622
|
+
LocationPointKey.SOFTWARE_MAJOR, LocationPointKey.SOFTWARE_MINOR,
|
|
623
|
+
LocationPointKey.SERIAL_NUMBER_PREFIX, LocationPointKey.SERIAL_NUMBER))
|
|
624
|
+
major, minor = found[LocationPointKey.DATAPOINT_MAJOR].value, found[LocationPointKey.DATAPOINT_MINOR].value
|
|
625
|
+
if isinstance(major, int) and isinstance(minor, int) and (major, minor) < ADDRESS_SPACE_REQUIRED:
|
|
626
|
+
_LOGGER.warning("Sentio address space %d.%d is older than the %d.%d this register map was "
|
|
627
|
+
"written against. Some registers may be unavailable; update the control unit.",
|
|
628
|
+
major, minor, *ADDRESS_SPACE_REQUIRED)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
async def scan_room(scan: Scan, n: int) -> None:
|
|
632
|
+
"""Remove room `n` if it is not configured, and what it states it does not have.
|
|
633
|
+
|
|
634
|
+
The manual: a DUMMY room has "no thermostat or sensor installed", and an association of 0
|
|
635
|
+
is NONE. An unknown or unread value removes nothing.
|
|
636
|
+
"""
|
|
637
|
+
kind = (await scan.read((room_key(n, RoomPointKey.TYPE),)))[room_key(n, RoomPointKey.TYPE)]
|
|
638
|
+
if kind.quality is Quality.MISSING:
|
|
639
|
+
scan.set_available(Labels(room=n), False, reason="room not configured")
|
|
640
|
+
if kind.quality is not Quality.GOOD:
|
|
641
|
+
return
|
|
642
|
+
if kind.value == RoomType.DUMMY:
|
|
643
|
+
scan.set_available(Labels(room=n, kind=SENSOR), False, reason="a dummy room has no sensor")
|
|
644
|
+
found = await scan.read(tuple(room_key(n, point) for point in ROOM_FUNCTIONS.values()))
|
|
645
|
+
for function, point in ROOM_FUNCTIONS.items():
|
|
646
|
+
association = found[room_key(n, point)]
|
|
647
|
+
if association.quality is Quality.GOOD and association.value == 0:
|
|
648
|
+
scan.set_available(Labels(room=n, function=function), False,
|
|
649
|
+
reason=f"the room is not associated with {function}")
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
async def scan_peripheral(scan: Scan, slot: int) -> None:
|
|
653
|
+
"""Remove peripheral slot `slot` if nothing is paired in it, and the points its type does
|
|
654
|
+
not have.
|
|
655
|
+
|
|
656
|
+
The serial number and owner are read too, though never polled: another peripheral in the
|
|
657
|
+
slot, or one moved to another room, then changes what this scan read, and the slot is read
|
|
658
|
+
afresh.
|
|
659
|
+
"""
|
|
660
|
+
kind = peripheral_key(slot, PeripheralPointKey.TYPE)
|
|
661
|
+
found = (await scan.read((kind,)))[kind]
|
|
662
|
+
if found.quality is Quality.MISSING:
|
|
663
|
+
scan.set_available(Labels(peripheral=slot), False, reason="no peripheral in this slot")
|
|
664
|
+
return
|
|
665
|
+
if found.quality is Quality.GOOD and isinstance(found.value, PeripheralType):
|
|
666
|
+
model = found.value.name.replace("_", "-")
|
|
667
|
+
for point in NOT_SUPPORTED.get(found.value, frozenset()):
|
|
668
|
+
scan.set_available((peripheral_key(slot, point),), False,
|
|
669
|
+
reason=f"{model} has no {point.name.replace('_', ' ')}")
|
|
670
|
+
await scan.read((peripheral_key(slot, PeripheralPointKey.SERIAL_NUMBER),
|
|
671
|
+
peripheral_key(slot, PeripheralPointKey.OWNER)))
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
SENTIO = Model(
|
|
675
|
+
name="Sentio",
|
|
676
|
+
manufacturer="Wavin",
|
|
677
|
+
options=ModbusOptions(numbering=plain(first_address=1), max_registers=MAX_REGISTERS), # verified live
|
|
678
|
+
read_back_after=0.5, # measured with testing.measure_read_back on a CCU-208, address space 3.7
|
|
679
|
+
sections=[
|
|
680
|
+
Section(LOCATION),
|
|
681
|
+
RepeatedSection(room, range(1, ROOM_COUNT + 1), label=ROOM, scan=scan_room),
|
|
682
|
+
RepeatedSection(peripheral, range(1, PERIPHERAL_COUNT + 1), label=PERIPHERAL, scan=scan_peripheral),
|
|
683
|
+
],
|
|
684
|
+
scan_steps=[check_the_controller],
|
|
685
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Using a Sentio: creating a client, and a view of its rooms and peripherals for a consumer."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from modbus_event_connect import Client, Clock, Key
|
|
7
|
+
from modbus_event_connect.modbus import ModbusConnection, ModbusDevice
|
|
8
|
+
|
|
9
|
+
from ._model import (
|
|
10
|
+
PERIPHERAL,
|
|
11
|
+
ROOM,
|
|
12
|
+
SENTIO,
|
|
13
|
+
PeripheralPointKey,
|
|
14
|
+
PeripheralType,
|
|
15
|
+
RoomPointKey,
|
|
16
|
+
RoomType,
|
|
17
|
+
peripheral_key,
|
|
18
|
+
room_key,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
DEFAULT_PORT = 502
|
|
22
|
+
"""The Modbus TCP port, per the manual."""
|
|
23
|
+
DEFAULT_UNIT_ID = 1
|
|
24
|
+
"""The manual gives 255 for Modbus TCP "if needed"."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_client(host: str, *, port: int = DEFAULT_PORT, unit_id: int = DEFAULT_UNIT_ID,
|
|
28
|
+
read_only: bool = False, clock: Clock | None = None) -> Client:
|
|
29
|
+
"""A client for the Sentio at `host`, with its own connection; call `connect()` on it.
|
|
30
|
+
|
|
31
|
+
`read_only=True` refuses every write before it reaches the controller.
|
|
32
|
+
"""
|
|
33
|
+
return Client(ModbusDevice.tcp(host, port, unit_id, clock=clock), SENTIO,
|
|
34
|
+
clock=clock, read_only=read_only)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_client_on(connection: ModbusConnection, *, unit_id: int = DEFAULT_UNIT_ID,
|
|
38
|
+
read_only: bool = False, clock: Clock | None = None) -> Client:
|
|
39
|
+
"""A client on a connection the host already owns - a gateway shared with other devices."""
|
|
40
|
+
return Client(ModbusDevice(connection, unit_id, clock=clock), SENTIO,
|
|
41
|
+
clock=clock, read_only=read_only)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ============================================================================ installation
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class SentioRoom:
|
|
49
|
+
"""One room, as a consumer turns it into a device."""
|
|
50
|
+
number: int
|
|
51
|
+
"""1-16. Also the room's object index in the address space."""
|
|
52
|
+
name: str
|
|
53
|
+
"""As configured on the controller, e.g. "Kitchen". Empty if never named."""
|
|
54
|
+
is_dummy: bool
|
|
55
|
+
"""A dummy room has no thermostat or sensor, so it reports no measurements."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class SentioPeripheral:
|
|
60
|
+
"""One paired peripheral - a thermostat, a display, an extension module."""
|
|
61
|
+
slot: int
|
|
62
|
+
"""1-64; not stable across relearns - use `serial_number` to recognise a device."""
|
|
63
|
+
name: str
|
|
64
|
+
type: PeripheralType | None
|
|
65
|
+
"""None if the controller reported no type, or one this map does not name."""
|
|
66
|
+
serial_number: int | None
|
|
67
|
+
owner: int | None
|
|
68
|
+
"""0 = the location itself, 1-16 = that room."""
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def model(self) -> str:
|
|
72
|
+
"""A readable model name, such as "RT-250", or "unknown"."""
|
|
73
|
+
return self.type.name.replace("_", "-") if self.type is not None else "unknown"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def rooms(client: Client) -> list[SentioRoom]:
|
|
77
|
+
"""The rooms this installation has, from values read during connect()."""
|
|
78
|
+
return [SentioRoom(number=n,
|
|
79
|
+
name=_value(client, room_key(n, RoomPointKey.NAME)) or "",
|
|
80
|
+
is_dummy=_value(client, room_key(n, RoomPointKey.TYPE)) is RoomType.DUMMY)
|
|
81
|
+
for n in client.instances(ROOM)]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def peripherals(client: Client) -> list[SentioPeripheral]:
|
|
85
|
+
"""The peripherals paired with this installation, from values read during connect()."""
|
|
86
|
+
return [SentioPeripheral(slot=slot,
|
|
87
|
+
name=_value(client, peripheral_key(slot, PeripheralPointKey.NAME)) or "",
|
|
88
|
+
type=_value(client, peripheral_key(slot, PeripheralPointKey.TYPE)),
|
|
89
|
+
serial_number=_value(client, peripheral_key(slot, PeripheralPointKey.SERIAL_NUMBER)),
|
|
90
|
+
owner=_value(client, peripheral_key(slot, PeripheralPointKey.OWNER)))
|
|
91
|
+
for slot in client.instances(PERIPHERAL)]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _value[T](client: Client, key: Key[T]) -> T | None:
|
|
95
|
+
current = client.value(key)
|
|
96
|
+
return current.value if current is not None else None
|
|
File without changes
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wavin_sentio_connect
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Event-driven, async client for the Wavin Sentio floor heating controller: every room and peripheral, typed, and told when a value changes - over Modbus TCP.
|
|
5
|
+
Home-page: https://github.com/HairingX/wavin_sentio_connect
|
|
6
|
+
Author: HairingX
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Source, https://github.com/HairingX/wavin_sentio_connect
|
|
9
|
+
Project-URL: Issues, https://github.com/HairingX/wavin_sentio_connect/issues
|
|
10
|
+
Project-URL: Releases, https://github.com/HairingX/wavin_sentio_connect/releases
|
|
11
|
+
Keywords: wavin,sentio,floor heating,modbus,modbus-tcp,asyncio
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Classifier: Topic :: Home Automation
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.12
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: modbus_event_connect<0.3,>=0.2.0
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# Wavin Sentio Connect
|
|
32
|
+
|
|
33
|
+
An event-driven Python client for the **Wavin Sentio** floor heating controller over Modbus TCP,
|
|
34
|
+
built on [modbus_event_connect](https://github.com/HairingX/modbus_event_connect).
|
|
35
|
+
|
|
36
|
+
The register map is complete for the location, all 16 rooms and all 64 peripheral slots,
|
|
37
|
+
modelled from the official Sentio Modbus manual. Connecting finds out which rooms and
|
|
38
|
+
peripherals your installation actually has; you subscribe to the values you care about and are
|
|
39
|
+
told when one changes - with its quality, so "offline", "no reading" and a real value never look
|
|
40
|
+
alike.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install wavin-sentio-connect
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Enabling Modbus on the controller
|
|
49
|
+
|
|
50
|
+
Modbus is **disabled by default**. Enable it from a Sentio Display:
|
|
51
|
+
|
|
52
|
+
`System | Installer settings | Modbus configuration | Modbus TCP`
|
|
53
|
+
|
|
54
|
+
The controller restarts afterwards. It uses DHCP; its hostname is
|
|
55
|
+
`Wavin Sentio CCU#[last four digits of the serial number]`.
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import asyncio
|
|
61
|
+
from wavin_sentio_connect import RoomPointKey, create_client, room_key, rooms
|
|
62
|
+
|
|
63
|
+
def on_change(key, old, new):
|
|
64
|
+
print(f"{key}: {new.value} ({new.quality.name})")
|
|
65
|
+
|
|
66
|
+
async def main():
|
|
67
|
+
client = create_client("<device-ip>")
|
|
68
|
+
await client.connect() # finds the installed rooms and peripherals
|
|
69
|
+
|
|
70
|
+
for room in rooms(client):
|
|
71
|
+
print(room.number, room.name, "dummy" if room.is_dummy else "")
|
|
72
|
+
client.subscribe(room_key(room.number, RoomPointKey.TEMP_AIR_CURRENT), on_change)
|
|
73
|
+
|
|
74
|
+
while True: # you own the clock; the library owns the plan
|
|
75
|
+
await client.poll() # reads only what is due - free when nothing is
|
|
76
|
+
await asyncio.sleep(1)
|
|
77
|
+
|
|
78
|
+
asyncio.run(main())
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
When `connect()` returns, `client.points` holds exactly what this installation has - a room that
|
|
82
|
+
was never set up is not there, so nothing is built for it. Nor is what the controller says a
|
|
83
|
+
room lacks: a dummy room ("no thermostat or sensor installed") has no temperature, humidity or
|
|
84
|
+
dew point, and a room not associated with radiators, underfloor heating, drying, thermal
|
|
85
|
+
integration or ventilation has no state or blocking source for it. Nor has a peripheral what
|
|
86
|
+
its type is known to lack, as `NOT_SUPPORTED` lists: the manual's wired RT-201, RS-211 and
|
|
87
|
+
ET-210, the LCD-200 and the EU-208-A have no signal strength.
|
|
88
|
+
`rooms(client)` and `peripherals(client)` describe the installation from values already read,
|
|
89
|
+
with no extra requests.
|
|
90
|
+
|
|
91
|
+
When the installation changes, `poll()` finds it; nobody needs to connect again. What each scan
|
|
92
|
+
read is read again at `PollRate.SCAN`, and where it has changed, only that part is scanned again:
|
|
93
|
+
|
|
94
|
+
| What changed | What is scanned again |
|
|
95
|
+
|---|---|
|
|
96
|
+
| a room's type, or what it is associated with | that room |
|
|
97
|
+
| the peripheral in a slot, its serial number or the room it belongs to | that slot |
|
|
98
|
+
| the address space, the software version or the controller's serial number | everything |
|
|
99
|
+
|
|
100
|
+
`client.subscribe_points(callback)` is told the keys gained and lost - a room set up, a
|
|
101
|
+
peripheral paired or removed.
|
|
102
|
+
|
|
103
|
+
Every value is a `DataValue`: `value`, `quality` and `timestamp` (UTC).
|
|
104
|
+
|
|
105
|
+
| Quality | Meaning |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `GOOD` | the controller answered with a valid value |
|
|
108
|
+
| `NO_DATA` | it answered "no reading" - a missing sensor, an unconfigured limit |
|
|
109
|
+
| `OFFLINE` | the register exists but what is behind it is not answering |
|
|
110
|
+
| `STALE` | the last read failed; the value is the last good one |
|
|
111
|
+
|
|
112
|
+
## How often values are read
|
|
113
|
+
|
|
114
|
+
Every point has a poll rate, and the library reads it when it is due:
|
|
115
|
+
|
|
116
|
+
| Poll rate | Default | Sentio uses it for |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| `FAST` | 10 s | room states and blocking sources |
|
|
119
|
+
| `MEDIUM` | 30 s | temperatures, humidity, dew point |
|
|
120
|
+
| `SLOW` | 60 s | settings |
|
|
121
|
+
| `RARE` | 15 min | peripheral signal strength |
|
|
122
|
+
| `STATIC` | at connect | versions, serial numbers, names, room types |
|
|
123
|
+
| `SCAN` | 15 min | what the scans read, to find changes to the installation |
|
|
124
|
+
|
|
125
|
+
Override any of them, or a single key:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from modbus_event_connect import PollRate
|
|
129
|
+
client.set_poll_interval(PollRate.FAST, 5)
|
|
130
|
+
client.set_poll_interval("room_4_temp_air_current", 2)
|
|
131
|
+
await client.refresh(PollRate.STATIC) # re-read the static values now
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Only what something wants is read: a subscriber, or `client.set_polling(key)`.
|
|
135
|
+
|
|
136
|
+
## Writing
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
await client.write(room_key(1, RoomPointKey.TEMP_AIR_TARGET), 21.5)
|
|
140
|
+
await client.write(room_key(1, RoomPointKey.LOCK), RoomLock.HOTEL) # LOCKED / HOTEL / UNLOCKED
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- A value of the wrong type is a type error before the program runs: the lock takes a
|
|
144
|
+
`RoomLock`, a temperature a `float`.
|
|
145
|
+
- A value is also checked before anything is sent: its range, its states, and the controller's
|
|
146
|
+
"no reading" sentinel, which could never be read back.
|
|
147
|
+
- Writes are sent in order. Tapping + five times sends the first value and the last, not all
|
|
148
|
+
five.
|
|
149
|
+
- The written point is read back, so subscribers see what the controller holds rather than what
|
|
150
|
+
was asked for. A write that changes the rooms' regulated targets - vacation, standby, a room's
|
|
151
|
+
setpoint, mode or preset - reads those targets again too.
|
|
152
|
+
- The controller answers `SERVER_DEVICE_BUSY` (`0x06`) while it stores a change; the manual says
|
|
153
|
+
such a request "shall be repeated again", and the library does, with backoff.
|
|
154
|
+
- `client.status(Status.WRITE_PENDING)` - which `client.subscribe_status` follows - is true from
|
|
155
|
+
the moment a write is asked for until the last one has finished.
|
|
156
|
+
|
|
157
|
+
For monitoring only, create the client read-only; every write is then refused before it reaches
|
|
158
|
+
the controller:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
client = create_client("<device-ip>", read_only=True)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Alarms
|
|
165
|
+
|
|
166
|
+
Every alarm and warning the manual documents for the location, the rooms and the peripherals is
|
|
167
|
+
a key: `system_warning`, `system_error`, and per room and peripheral `..._warning`,
|
|
168
|
+
`..._error`, `..._low_battery` and `room_{n}_peripheral_lost` / `peripheral_{n}_lost`.
|
|
169
|
+
|
|
170
|
+
The manual describes the location's two bits as covering the whole system ("A problem is
|
|
171
|
+
pending in whole system"). Those two are always read at the `FAST` rate; when either changes,
|
|
172
|
+
every alarm someone subscribes to is read at once. Each alarm is also read by itself at the
|
|
173
|
+
`RARE` rate, in case a controller does not reflect it in the system's bits.
|
|
174
|
+
|
|
175
|
+
## Sharing a connection
|
|
176
|
+
|
|
177
|
+
To put the controller on a connection the host already owns - a gateway shared with other
|
|
178
|
+
devices - use `create_client_on`:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
from modbus_event_connect.modbus import ModbusTcpConnection
|
|
182
|
+
from wavin_sentio_connect import create_client_on
|
|
183
|
+
|
|
184
|
+
connection = ModbusTcpConnection("<device-ip>")
|
|
185
|
+
client = create_client_on(connection, unit_id=1)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The client is asynchronous end to end - no threads, nothing that blocks the event loop - and
|
|
189
|
+
owns no timer: the application calls `poll()` from its own loop.
|
|
190
|
+
|
|
191
|
+
## Keys and addressing
|
|
192
|
+
|
|
193
|
+
Every point has its key in `LocationPointKey`, `RoomPointKey` or `PeripheralPointKey`, with the
|
|
194
|
+
type of its value. A location point's is its whole key; a room's or a peripheral's becomes one
|
|
195
|
+
with the instance:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from wavin_sentio_connect import ROOM, LocationPointKey, RoomPointKey, room_key
|
|
199
|
+
|
|
200
|
+
client.value(LocationPointKey.VACATION_ENABLE) # key "vacation_enable"
|
|
201
|
+
for n in client.instances(ROOM): # the rooms this installation has
|
|
202
|
+
client.value(room_key(n, RoomPointKey.TEMP_AIR_CURRENT)) # key "room_4_temp_air_current"
|
|
203
|
+
client.value(room_key(n, RoomPointKey.STATE)) # a RoomState, such as HEATING
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
A room point's key is the same in every room, so code that handles
|
|
207
|
+
`RoomPointKey.TEMP_AIR_CURRENT` once handles it in all of them; `RoomPointKey.all()` lists them.
|
|
208
|
+
`UNITS` is every unit a Sentio point has. Key strings never change; new points only add keys.
|
|
209
|
+
|
|
210
|
+
A state reads as its member of the enum the manual's values give: `RoomState`, `BlockingSource`,
|
|
211
|
+
`RoomType`, `RoomMode`, `RoomModeOverride`, `TemperaturePreset`, `RoomLock`, `HeatingCoolingMode`,
|
|
212
|
+
`HeatingCoolingModeOverride`, `DeviceType`, `ModbusMode`, `UpdateMode` or `PeripheralType`, and is
|
|
213
|
+
written as one. A number the manual does not name reads as `NO_DATA`, and the value's `.raw` holds
|
|
214
|
+
the number the controller sent. Standby, vacation and daylight saving, which the manual gives as
|
|
215
|
+
0 and 1, are `bool`; their "no value", 255, reads as `NO_DATA` too.
|
|
216
|
+
|
|
217
|
+
The model is in [`_model.py`](https://github.com/HairingX/wavin_sentio_connect/blob/main/src/wavin_sentio_connect/_model.py); every key is declared there
|
|
218
|
+
with its address and encoding.
|
|
219
|
+
|
|
220
|
+
The manual's "Modbus Address" column holds the addresses themselves, so its numbers are used unchanged:
|
|
221
|
+
|
|
222
|
+
| Object | Base | Instances |
|
|
223
|
+
|---|---|---|
|
|
224
|
+
| Location | `0` | one |
|
|
225
|
+
| Room *N* | `N * 100` | 1–16 |
|
|
226
|
+
| Peripheral *N* | `51100 + N * 100` | 1–64 |
|
|
227
|
+
|
|
228
|
+
Peripheral slots are **not stable identities** - the controller reorders them when peripherals
|
|
229
|
+
are learned or unlearned. Use `peripheral_{n}_serial_number` to recognise a device, and
|
|
230
|
+
`peripheral_{n}_owner` (`0` = location, `1`–`16` = room) to find the room it belongs to.
|
|
231
|
+
|
|
232
|
+
Two room values are easy to confuse: `room_{n}_temp_air_target` is the user's setting, and
|
|
233
|
+
`room_{n}_temp_air_target_active` is the target the controller is regulating to right now -
|
|
234
|
+
different under standby, vacation or a schedule.
|
|
235
|
+
|
|
236
|
+
## Documentation
|
|
237
|
+
|
|
238
|
+
- [`docs/sentio-modbus-reference.md`](https://github.com/HairingX/wavin_sentio_connect/blob/main/docs/sentio-modbus-reference.md) - the protocol: register
|
|
239
|
+
tables, data types, error handling, enumerations.
|
|
240
|
+
- [`docs/sentio-registers.csv`](https://github.com/HairingX/wavin_sentio_connect/blob/main/docs/sentio-registers.csv) - all 344 documented registers, one
|
|
241
|
+
row each.
|
|
242
|
+
|
|
243
|
+
## Known gaps
|
|
244
|
+
|
|
245
|
+
- Only the location, room and peripheral objects are modelled. Outdoor, DHW, ITC, HCC, buffer
|
|
246
|
+
tank, ventilation and dehumidifier objects are documented in the CSV but not yet wired.
|
|
247
|
+
|
|
248
|
+
## Disclaimer
|
|
249
|
+
|
|
250
|
+
Wavin Sentio Connect is provided "as is", without warranty of any kind. The authors and
|
|
251
|
+
contributors are not responsible for any damage or data loss that may occur from using this
|
|
252
|
+
library. Users are solely responsible for ensuring the proper and safe operation of their
|
|
253
|
+
Modbus devices.
|
|
254
|
+
|
|
255
|
+
This project is not affiliated with or endorsed by Wavin.
|
|
256
|
+
|
|
257
|
+
## License
|
|
258
|
+
|
|
259
|
+
MIT. See [LICENSE](https://github.com/HairingX/wavin_sentio_connect/blob/main/LICENSE).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
wavin_sentio_connect/__init__.py,sha256=18JWk_v-vV_p_E8JpHAyTJAQ7InXY6IY_NK-9RIis8g,1361
|
|
2
|
+
wavin_sentio_connect/_model.py,sha256=qHOrEq4D0MPk_IlyIjAnISYm_kVYsFcY5Fh1pW_j9ng,31544
|
|
3
|
+
wavin_sentio_connect/_sentio.py,sha256=AZw7_NStS14uETyfxRNQzrJcVR7uKTICFGGQl3-cQz4,3811
|
|
4
|
+
wavin_sentio_connect/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
wavin_sentio_connect-0.2.0.dist-info/licenses/LICENSE,sha256=Y6w0bhB6qJ-_Wn9WS2ZZnFl_ebjnIweV2-dHcvwAEDI,1065
|
|
6
|
+
wavin_sentio_connect-0.2.0.dist-info/METADATA,sha256=h9V-VGIW77plCdTCn28Tets9HUpMunFCwZsW3xDCjZg,11683
|
|
7
|
+
wavin_sentio_connect-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
wavin_sentio_connect-0.2.0.dist-info/top_level.txt,sha256=wpWBrcSYuR8Ytwf8ECP0nJLC_Ny6SYfRpZ7VIqULgWE,21
|
|
9
|
+
wavin_sentio_connect-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 HairingX
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wavin_sentio_connect
|