pystudernext 0.0.1__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.
Files changed (41) hide show
  1. pystudernext/__init__.py +22 -0
  2. pystudernext/api_async.py +297 -0
  3. pystudernext/api_sync.py +300 -0
  4. pystudernext/const.py +23 -0
  5. pystudernext/data.py +81 -0
  6. pystudernext/datapoints.py +345 -0
  7. pystudernext/datapoints_acs.json +371 -0
  8. pystudernext/datapoints_acs_enums.json +448 -0
  9. pystudernext/datapoints_bat.json +217 -0
  10. pystudernext/datapoints_bat_enums.json +302 -0
  11. pystudernext/datapoints_flx.json +339 -0
  12. pystudernext/datapoints_flx_enums.json +609 -0
  13. pystudernext/datapoints_nx1.json +427 -0
  14. pystudernext/datapoints_nx1_enums.json +983 -0
  15. pystudernext/datapoints_nx3.json +540 -0
  16. pystudernext/datapoints_nx3_enums.json +1169 -0
  17. pystudernext/datapoints_nxg.json +342 -0
  18. pystudernext/datapoints_nxg_enums.json +603 -0
  19. pystudernext/datapoints_pwr.json +85 -0
  20. pystudernext/datapoints_pwr_enums.json +63 -0
  21. pystudernext/datapoints_sys.json +543 -0
  22. pystudernext/datapoints_sys_enums.json +359 -0
  23. pystudernext/datapoints_tst.json +43 -0
  24. pystudernext/datapoints_tst_enums.json +19 -0
  25. pystudernext/discover_async.py +247 -0
  26. pystudernext/discover_sync.py +250 -0
  27. pystudernext/families.py +251 -0
  28. pystudernext/shared/helpers.py +106 -0
  29. pystudernext/shared/studer_dataset.py +208 -0
  30. pystudernext/shared/studer_families.py +68 -0
  31. pystudernext/shared/studer_interfaces_async.py +101 -0
  32. pystudernext/shared/studer_interfaces_sync.py +103 -0
  33. pystudernext/shared/studer_messageset.py +59 -0
  34. pystudernext/shared/studer_types.py +123 -0
  35. pystudernext/shared/studer_valueset.py +41 -0
  36. pystudernext/values.py +59 -0
  37. pystudernext-0.0.1.dist-info/METADATA +150 -0
  38. pystudernext-0.0.1.dist-info/RECORD +41 -0
  39. pystudernext-0.0.1.dist-info/WHEEL +5 -0
  40. pystudernext-0.0.1.dist-info/licenses/LICENSE +21 -0
  41. pystudernext-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,22 @@
1
+ from .shared.studer_types import StuderUserLevel, StuderAccess, StuderTarget, StuderDataType
2
+ from .shared.studer_types import StuderDiscoveredGateway, StuderDiscoveredDevice, StuderDiscoverNotConnected
3
+ from .shared.studer_types import StuderParamException
4
+ from .shared.studer_dataset import StuderDataset, StuderDatapoint, StuderDatapointUnknownException, StuderDatapointSyntaxException, StuderDatapointEnumNotFoundException
5
+ from .shared.studer_families import StuderDeviceFamily, StuderDeviceFamilies, StuderDeviceFamilyUnknownException, StuderDeviceCodeUnknownException, StuderDeviceAddressUnknownException, StuderDeviceSlaveUnknownException
6
+ from .shared.studer_interfaces_async import AsyncStuderApi, AsyncStuderDiscover, StuderDiscoverFlags
7
+ from .shared.studer_interfaces_sync import StuderApi, StuderDiscover
8
+
9
+ from .api_async import AsyncNextApi
10
+ from .api_sync import NextApi
11
+ from .discover_async import AsyncNextDiscover
12
+ from .discover_sync import NextDiscover
13
+
14
+ from .const import DEFAULT_HOST, DEFAULT_PORT
15
+ from .data import NextDataType, NextUserLevel
16
+ from .data import NextApiConnectException, NextApiTimeoutException, NextPackException, NextUnpackException
17
+ from .datapoints import NextDataset, NextDatapoint, NextDatasetFlag
18
+ from .families import NextDeviceFamily, NextDeviceFamilies, NextDeviceFamiliesFlag
19
+ from .values import NextValueItem, NextValueSet
20
+
21
+ # For unit testing
22
+ # - none
@@ -0,0 +1,297 @@
1
+ """
2
+ api.py: communication api to Studer Next via Modbus over TCP.
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+
8
+ from datetime import datetime, timedelta
9
+ from pymodbus.client import AsyncModbusTcpClient, ModbusTcpClient
10
+ from typing import Any
11
+
12
+ from .shared.studer_dataset import StuderDatapoint
13
+ from .shared.studer_interfaces_async import AsyncStuderApi
14
+ from .shared.studer_interfaces_sync import StuderApi
15
+ from .shared.studer_types import StuderAccess, StuderDataType, StuderDiscoveredDevice, StuderParamException
16
+ from .const import DEFAULT_HOST, DEFAULT_PORT, REQ_BURST_PERIOD
17
+ from .data import NextDataType, NextApiConnectException, NextApiReadException, NextApiUpdateException, NextPackException, NextUnpackException
18
+ from .datapoints import NextDatapoint
19
+ from .families import NextDeviceFamilies
20
+ from .values import NextValueItem, NextValueSet
21
+
22
+
23
+ _LOGGER = logging.getLogger(__name__)
24
+ logging.getLogger("pymodbus").setLevel(logging.WARNING)
25
+
26
+
27
+ class AsyncNextApi(AsyncStuderApi):
28
+ """
29
+ The actual Api for requesting and updating parameters via an async modbus tcp client.
30
+ """
31
+
32
+ def __init__(self, host:str=DEFAULT_HOST, port:int=DEFAULT_PORT):
33
+ """
34
+ We connect to the MX Gateway.
35
+ Once it is connected we can send Modbus requests.
36
+ """
37
+ self._host = host
38
+ self._port = port
39
+
40
+ self._client: AsyncModbusTcpClient = None
41
+ self._families = NextDeviceFamilies.get_instance()
42
+
43
+ # Diagnostics gathering
44
+ self._diag_retries = {}
45
+ self._diag_durations = {}
46
+
47
+
48
+ async def start(self) -> bool:
49
+ """
50
+ Connect to the remote gateway
51
+ """
52
+ try:
53
+ await self._get_connected_client()
54
+ return True
55
+
56
+ except Exception as err:
57
+ return False
58
+
59
+
60
+ async def stop(self):
61
+ """
62
+ Close the client
63
+ """
64
+ try:
65
+ if self._client:
66
+ await self._client.close()
67
+
68
+ except Exception:
69
+ pass
70
+
71
+ finally:
72
+ self._client = None
73
+
74
+
75
+ @property
76
+ def connected(self) -> bool:
77
+ """Returns True if the Next client is connected, otherwise False"""
78
+ return self._client is not None and self._client.connected
79
+
80
+
81
+ @property
82
+ def remote_host(self) -> str|None:
83
+ """Returns the Host or IP address of the Next Gateway we connect to, otherwise None"""
84
+ return self._host
85
+
86
+ @property
87
+ def remote_port(self) -> str|None:
88
+ """Returns the port of the Next Gateway we connect to, otherwise None"""
89
+ return self._port
90
+
91
+
92
+ async def request_value(self, parameter: StuderDatapoint, device: StuderDiscoveredDevice|int|str=None, retries = None, timeout = None, verbose=False) -> Any:
93
+ """
94
+ Request a parameter.
95
+ One of device, slave or code needs to be passed.
96
+ Returns None if not connected, otherwise returns the requested value
97
+
98
+ Throws
99
+ StuderParamException
100
+ NextApiConnectException
101
+ NextApiTimeoutException
102
+ NextUnpackException
103
+ """
104
+
105
+ # Sanity check
106
+ if parameter is None:
107
+ return None
108
+
109
+ if parameter.access not in [StuderAccess.READ, StuderAccess.READ_WRITE]:
110
+ raise StuderParamException(f"Datapoint {parameter.family_id}:{parameter.address} is not readable")
111
+
112
+ if isinstance(device, StuderDiscoveredDevice):
113
+ slave = device.slave
114
+ elif isinstance(device, int):
115
+ slave = device
116
+ elif isinstance(device, str):
117
+ slave = self._families.get_slave_by_code(code=device)
118
+ else:
119
+ raise StuderParamException(f"Parameter 'device' must be a NextDiscoverdDevice, a slave number or a device code in call to request_value")
120
+
121
+ # Send the request
122
+ try:
123
+ if verbose:
124
+ _LOGGER.debug(f"Modbus read registers for '{parameter.name}' ({parameter.address} via {slave})")
125
+
126
+ client = await self._get_connected_client()
127
+ result = await client.read_holding_registers(address=parameter.address, count=parameter.size, device_id=slave)
128
+
129
+ except Exception as err:
130
+ raise NextApiReadException(f"Modbus exception while requesting value for slave {slave}, address {parameter.address}, count {parameter.size}, error: {err}")
131
+
132
+ if result.isError():
133
+ raise NextApiReadException(f"Modbus error while requesting value for slave {slave}, address {parameter.address}, count {parameter.size}, error: {result.exception_code}")
134
+
135
+ # Unpack the response value
136
+ try:
137
+ value = AsyncModbusTcpClient.convert_from_registers(result.registers, data_type=NextDataType.to_datatype(parameter.data_type))
138
+
139
+ match parameter.data_type:
140
+ case StuderDataType.ENUM32: return parameter.enum_value(value)
141
+ case StuderDataType.BITFIELD: return parameter.bitfield_value(value)
142
+ case _: return value
143
+
144
+ except Exception as e:
145
+ raise NextPackException(f"Failed to unpack response value for slave {slave}, address {parameter.address}: registers={result.registers}, format={parameter.data_type}, size={parameter.size}") from None
146
+
147
+
148
+ async def request_values(self, request_data: NextValueSet, retries = None, timeout = None, verbose=False) -> NextValueSet:
149
+ """
150
+ Request multiple parameters in one call.
151
+ Can only retrieve actual device values, NOT the average or sum over multiple devices.
152
+
153
+ Returns None if not connected, otherwise returns the list of requested values
154
+ Throws
155
+ StuderParamException
156
+ NextApiConnectException
157
+ NextApiTimeoutException
158
+ NextUnpackException
159
+ """
160
+
161
+ # Unlike the Studer Xcom protocol, the Studer Next protocol does not have a function to request multiple
162
+ # items in one call.
163
+ # As a result we just resolve all requested values sequentially
164
+ result_items: list[NextValueItem] = []
165
+ burst_start = datetime.now()
166
+
167
+ for req_single in request_data.items:
168
+ try:
169
+ error = None
170
+ value = await self.request_value(req_single.datapoint, req_single.address, retries=retries, timeout=timeout, verbose=verbose)
171
+
172
+ except Exception as ex:
173
+ value = None
174
+ error = str(ex)
175
+
176
+ if error is not None:
177
+ _LOGGER.debug(f"Failed to retrieve info or param {req_single.datapoint.nr}:{req_single.address}; {error}")
178
+
179
+ # Add to results
180
+ rsp_single = NextValueItem(
181
+ datapoint = req_single.datapoint,
182
+ device = req_single.code,
183
+ value = value,
184
+ error = error,
185
+ )
186
+ result_items.append(rsp_single)
187
+
188
+ # Periodically wait for a second.
189
+ # This will make sure we do not block the Next Gateway with too many requests at once
190
+ if (datetime.now() - burst_start).total_seconds() > REQ_BURST_PERIOD:
191
+ await asyncio.sleep(1)
192
+ burst_start = datetime.now()
193
+
194
+ # Return all reponse items as one XcomValueSet object
195
+ return NextValueSet(result_items)
196
+
197
+
198
+ async def update_value(self, parameter: NextDatapoint, value: Any, device: StuderDiscoveredDevice|int|str=None, retries = None, timeout = None, verbose=False):
199
+ """
200
+ Update a parameter
201
+ Returns None if not connected, otherwise returns True on success
202
+
203
+ Throws
204
+ StuderParamException
205
+ NextApiConnectException
206
+ NextApiTimeoutException
207
+ NextPackException
208
+ """
209
+ # Sanity check
210
+ if parameter is None or value is None:
211
+ return None
212
+
213
+ if parameter.access not in [StuderAccess.WRITE, StuderAccess.READ_WRITE]:
214
+ raise StuderParamException(f"Device parameter {parameter.family_id}:{parameter.address} is not writable")
215
+
216
+ if isinstance(device, StuderDiscoveredDevice):
217
+ slave = device.slave
218
+ elif isinstance(device, int):
219
+ slave = device
220
+ elif isinstance(device, str):
221
+ slave = self._families.get_slave_by_code(code=device)
222
+ else:
223
+ raise StuderParamException(f"Device parameter must be a NextDiscoverdDevice, a slave number or a device code in call to update_value")
224
+
225
+ _LOGGER.debug(f"Update '{parameter.name}' ({parameter.address} via {slave}) to {value}")
226
+
227
+ # Pack the data
228
+ try:
229
+ client = await self._get_connected_client()
230
+ regs = AsyncModbusTcpClient.convert_to_registers(value, data_type=NextDataType.to_datatype(parameter.data_type))
231
+
232
+ except Exception as e:
233
+ raise NextPackException(f"Failed to pack value for slave {slave}, address {parameter.address}: value={value}, format={parameter.data_type}, size={parameter.size}") from None
234
+
235
+ # Send the request
236
+ try:
237
+ if verbose:
238
+ _LOGGER.debug(f"Modbus update registers for '{parameter.name}' ({parameter.address} via {slave})")
239
+
240
+ result = await client.write_registers(address=parameter.address, values=regs, device_id=slave)
241
+
242
+ except Exception as err:
243
+ raise NextApiUpdateException(f"Modbus exception while updating value for slave {slave}, address {parameter.address}, error: {err}")
244
+
245
+ if result.isError():
246
+ raise NextApiReadException(f"Modbus error while updating value for slave {slave}, address {parameter.address}, count {parameter.size}, error: {result.exception_code}")
247
+
248
+ return None
249
+
250
+
251
+ async def _get_connected_client(self) -> AsyncModbusTcpClient:
252
+ """
253
+ Return a connected client, reconnecting if needed.
254
+ """
255
+ if not self.connected:
256
+ client = self._create_client()
257
+
258
+ if await client.connect():
259
+ self._client = client
260
+ else:
261
+ self._client = None
262
+ raise NextApiConnectException(f"Cannot connect to Studer Gateway at {self._host}:{self._port}")
263
+
264
+ return self._client
265
+
266
+
267
+ def _create_client(self):
268
+ """
269
+ Helper to create the Modbus Client.
270
+ In a separate function to make it easier to replace the client with a stub for unit-tests.
271
+ """
272
+ return AsyncModbusTcpClient(host=self._host, port=self._port)
273
+
274
+
275
+ async def _add_diagnostics(self, retries: int = None, duration: timedelta = None):
276
+ if retries is not None:
277
+ if retries not in self._diag_retries:
278
+ self._diag_retries[retries] = 1
279
+ else:
280
+ self._diag_retries[retries] += 1
281
+
282
+ if duration is not None:
283
+ duration = round(duration.total_seconds(), 1)
284
+ if duration not in self._diag_durations:
285
+ self._diag_durations[duration] = 1
286
+ else:
287
+ self._diag_durations[duration] += 1
288
+
289
+
290
+ async def get_diagnostics(self):
291
+ return {
292
+ "statistics": {
293
+ "retries": dict(sorted(self._diag_retries.items())),
294
+ "durations": dict(sorted(self._diag_durations.items())),
295
+ }
296
+ }
297
+
@@ -0,0 +1,300 @@
1
+ # Do not edit this file directly. It has been autogenerated from
2
+ # src\pystudernext\api_async.py
3
+ """
4
+ api.py: communication api to Studer Next via Modbus over TCP.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+
10
+ from datetime import datetime, timedelta
11
+ from pymodbus.client import AsyncModbusTcpClient, ModbusTcpClient
12
+ from typing import Any
13
+
14
+ from .shared.studer_dataset import StuderDatapoint
15
+ from .shared.studer_interfaces_async import AsyncStuderApi
16
+ from .shared.studer_interfaces_sync import StuderApi
17
+ from .shared.studer_types import StuderAccess, StuderDataType, StuderDiscoveredDevice, StuderParamException
18
+ from .const import DEFAULT_HOST, DEFAULT_PORT, REQ_BURST_PERIOD
19
+ from .data import NextDataType, NextApiConnectException, NextApiReadException, NextApiUpdateException, NextPackException, NextUnpackException
20
+ from .datapoints import NextDatapoint
21
+ from .families import NextDeviceFamilies
22
+ from .values import NextValueItem, NextValueSet
23
+ import time
24
+
25
+
26
+ _LOGGER = logging.getLogger(__name__)
27
+ logging.getLogger("pymodbus").setLevel(logging.WARNING)
28
+
29
+
30
+ class NextApi(StuderApi):
31
+ """
32
+ The actual Api for requesting and updating parameters via an async modbus tcp client.
33
+ """
34
+
35
+ def __init__(self, host:str=DEFAULT_HOST, port:int=DEFAULT_PORT):
36
+ """
37
+ We connect to the MX Gateway.
38
+ Once it is connected we can send Modbus requests.
39
+ """
40
+ self._host = host
41
+ self._port = port
42
+
43
+ self._client: ModbusTcpClient = None
44
+ self._families = NextDeviceFamilies.get_instance()
45
+
46
+ # Diagnostics gathering
47
+ self._diag_retries = {}
48
+ self._diag_durations = {}
49
+
50
+
51
+ def start(self) -> bool:
52
+ """
53
+ Connect to the remote gateway
54
+ """
55
+ try:
56
+ self._get_connected_client()
57
+ return True
58
+
59
+ except Exception as err:
60
+ return False
61
+
62
+
63
+ def stop(self):
64
+ """
65
+ Close the client
66
+ """
67
+ try:
68
+ if self._client:
69
+ self._client.close()
70
+
71
+ except Exception:
72
+ pass
73
+
74
+ finally:
75
+ self._client = None
76
+
77
+
78
+ @property
79
+ def connected(self) -> bool:
80
+ """Returns True if the Next client is connected, otherwise False"""
81
+ return self._client is not None and self._client.connected
82
+
83
+
84
+ @property
85
+ def remote_host(self) -> str|None:
86
+ """Returns the Host or IP address of the Next Gateway we connect to, otherwise None"""
87
+ return self._host
88
+
89
+ @property
90
+ def remote_port(self) -> str|None:
91
+ """Returns the port of the Next Gateway we connect to, otherwise None"""
92
+ return self._port
93
+
94
+
95
+ def request_value(self, parameter: StuderDatapoint, device: StuderDiscoveredDevice|int|str=None, retries = None, timeout = None, verbose=False) -> Any:
96
+ """
97
+ Request a parameter.
98
+ One of device, slave or code needs to be passed.
99
+ Returns None if not connected, otherwise returns the requested value
100
+
101
+ Throws
102
+ StuderParamException
103
+ NextApiConnectException
104
+ NextApiTimeoutException
105
+ NextUnpackException
106
+ """
107
+
108
+ # Sanity check
109
+ if parameter is None:
110
+ return None
111
+
112
+ if parameter.access not in [StuderAccess.READ, StuderAccess.READ_WRITE]:
113
+ raise StuderParamException(f"Datapoint {parameter.family_id}:{parameter.address} is not readable")
114
+
115
+ if isinstance(device, StuderDiscoveredDevice):
116
+ slave = device.slave
117
+ elif isinstance(device, int):
118
+ slave = device
119
+ elif isinstance(device, str):
120
+ slave = self._families.get_slave_by_code(code=device)
121
+ else:
122
+ raise StuderParamException(f"Parameter 'device' must be a NextDiscoverdDevice, a slave number or a device code in call to request_value")
123
+
124
+ # Send the request
125
+ try:
126
+ if verbose:
127
+ _LOGGER.debug(f"Modbus read registers for '{parameter.name}' ({parameter.address} via {slave})")
128
+
129
+ client = self._get_connected_client()
130
+ result = client.read_holding_registers(address=parameter.address, count=parameter.size, device_id=slave)
131
+
132
+ except Exception as err:
133
+ raise NextApiReadException(f"Modbus exception while requesting value for slave {slave}, address {parameter.address}, count {parameter.size}, error: {err}")
134
+
135
+ if result.isError():
136
+ raise NextApiReadException(f"Modbus error while requesting value for slave {slave}, address {parameter.address}, count {parameter.size}, error: {result.exception_code}")
137
+
138
+ # Unpack the response value
139
+ try:
140
+ value = ModbusTcpClient.convert_from_registers(result.registers, data_type=NextDataType.to_datatype(parameter.data_type))
141
+
142
+ match parameter.data_type:
143
+ case StuderDataType.ENUM32: return parameter.enum_value(value)
144
+ case StuderDataType.BITFIELD: return parameter.bitfield_value(value)
145
+ case _: return value
146
+
147
+ except Exception as e:
148
+ raise NextPackException(f"Failed to unpack response value for slave {slave}, address {parameter.address}: registers={result.registers}, format={parameter.data_type}, size={parameter.size}") from None
149
+
150
+
151
+ def request_values(self, request_data: NextValueSet, retries = None, timeout = None, verbose=False) -> NextValueSet:
152
+ """
153
+ Request multiple parameters in one call.
154
+ Can only retrieve actual device values, NOT the average or sum over multiple devices.
155
+
156
+ Returns None if not connected, otherwise returns the list of requested values
157
+ Throws
158
+ StuderParamException
159
+ NextApiConnectException
160
+ NextApiTimeoutException
161
+ NextUnpackException
162
+ """
163
+
164
+ # Unlike the Studer Xcom protocol, the Studer Next protocol does not have a function to request multiple
165
+ # items in one call.
166
+ # As a result we just resolve all requested values sequentially
167
+ result_items: list[NextValueItem] = []
168
+ burst_start = datetime.now()
169
+
170
+ for req_single in request_data.items:
171
+ try:
172
+ error = None
173
+ value = self.request_value(req_single.datapoint, req_single.address, retries=retries, timeout=timeout, verbose=verbose)
174
+
175
+ except Exception as ex:
176
+ value = None
177
+ error = str(ex)
178
+
179
+ if error is not None:
180
+ _LOGGER.debug(f"Failed to retrieve info or param {req_single.datapoint.nr}:{req_single.address}; {error}")
181
+
182
+ # Add to results
183
+ rsp_single = NextValueItem(
184
+ datapoint = req_single.datapoint,
185
+ device = req_single.code,
186
+ value = value,
187
+ error = error,
188
+ )
189
+ result_items.append(rsp_single)
190
+
191
+ # Periodically wait for a second.
192
+ # This will make sure we do not block the Next Gateway with too many requests at once
193
+ if (datetime.now() - burst_start).total_seconds() > REQ_BURST_PERIOD:
194
+ time.sleep(1)
195
+ burst_start = datetime.now()
196
+
197
+ # Return all reponse items as one XcomValueSet object
198
+ return NextValueSet(result_items)
199
+
200
+
201
+ def update_value(self, parameter: NextDatapoint, value: Any, device: StuderDiscoveredDevice|int|str=None, retries = None, timeout = None, verbose=False):
202
+ """
203
+ Update a parameter
204
+ Returns None if not connected, otherwise returns True on success
205
+
206
+ Throws
207
+ StuderParamException
208
+ NextApiConnectException
209
+ NextApiTimeoutException
210
+ NextPackException
211
+ """
212
+ # Sanity check
213
+ if parameter is None or value is None:
214
+ return None
215
+
216
+ if parameter.access not in [StuderAccess.WRITE, StuderAccess.READ_WRITE]:
217
+ raise StuderParamException(f"Device parameter {parameter.family_id}:{parameter.address} is not writable")
218
+
219
+ if isinstance(device, StuderDiscoveredDevice):
220
+ slave = device.slave
221
+ elif isinstance(device, int):
222
+ slave = device
223
+ elif isinstance(device, str):
224
+ slave = self._families.get_slave_by_code(code=device)
225
+ else:
226
+ raise StuderParamException(f"Device parameter must be a NextDiscoverdDevice, a slave number or a device code in call to update_value")
227
+
228
+ _LOGGER.debug(f"Update '{parameter.name}' ({parameter.address} via {slave}) to {value}")
229
+
230
+ # Pack the data
231
+ try:
232
+ client = self._get_connected_client()
233
+ regs = ModbusTcpClient.convert_to_registers(value, data_type=NextDataType.to_datatype(parameter.data_type))
234
+
235
+ except Exception as e:
236
+ raise NextPackException(f"Failed to pack value for slave {slave}, address {parameter.address}: value={value}, format={parameter.data_type}, size={parameter.size}") from None
237
+
238
+ # Send the request
239
+ try:
240
+ if verbose:
241
+ _LOGGER.debug(f"Modbus update registers for '{parameter.name}' ({parameter.address} via {slave})")
242
+
243
+ result = client.write_registers(address=parameter.address, values=regs, device_id=slave)
244
+
245
+ except Exception as err:
246
+ raise NextApiUpdateException(f"Modbus exception while updating value for slave {slave}, address {parameter.address}, error: {err}")
247
+
248
+ if result.isError():
249
+ raise NextApiReadException(f"Modbus error while updating value for slave {slave}, address {parameter.address}, count {parameter.size}, error: {result.exception_code}")
250
+
251
+ return None
252
+
253
+
254
+ def _get_connected_client(self) -> ModbusTcpClient:
255
+ """
256
+ Return a connected client, reconnecting if needed.
257
+ """
258
+ if not self.connected:
259
+ client = self._create_client()
260
+
261
+ if client.connect():
262
+ self._client = client
263
+ else:
264
+ self._client = None
265
+ raise NextApiConnectException(f"Cannot connect to Studer Gateway at {self._host}:{self._port}")
266
+
267
+ return self._client
268
+
269
+
270
+ def _create_client(self):
271
+ """
272
+ Helper to create the Modbus Client.
273
+ In a separate function to make it easier to replace the client with a stub for unit-tests.
274
+ """
275
+ return ModbusTcpClient(host=self._host, port=self._port)
276
+
277
+
278
+ def _add_diagnostics(self, retries: int = None, duration: timedelta = None):
279
+ if retries is not None:
280
+ if retries not in self._diag_retries:
281
+ self._diag_retries[retries] = 1
282
+ else:
283
+ self._diag_retries[retries] += 1
284
+
285
+ if duration is not None:
286
+ duration = round(duration.total_seconds(), 1)
287
+ if duration not in self._diag_durations:
288
+ self._diag_durations[duration] = 1
289
+ else:
290
+ self._diag_durations[duration] += 1
291
+
292
+
293
+ def get_diagnostics(self):
294
+ return {
295
+ "statistics": {
296
+ "retries": dict(sorted(self._diag_retries.items())),
297
+ "durations": dict(sorted(self._diag_durations.items())),
298
+ }
299
+ }
300
+
pystudernext/const.py ADDED
@@ -0,0 +1,23 @@
1
+ #! /usr/bin/env python3
2
+
3
+ ##
4
+ # Definition of all parameters / constants used in the Next protocol
5
+ ##
6
+
7
+ from dataclasses import dataclass
8
+ from enum import IntEnum, StrEnum
9
+ from pymodbus.client import AsyncModbusTcpClient
10
+ from typing import Iterable
11
+
12
+
13
+ DEFAULT_HOST = ""
14
+ DEFAULT_PORT = 502
15
+
16
+ REQ_BURST_PERIOD = 5 # do burst of requests for 5 seconds, then wait a second, then the next burst
17
+
18
+
19
+ def safe_len(lst: Iterable):
20
+ try:
21
+ return len(lst)
22
+ except:
23
+ return sum(1 for i in lst)