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.
- pystudernext/__init__.py +22 -0
- pystudernext/api_async.py +297 -0
- pystudernext/api_sync.py +300 -0
- pystudernext/const.py +23 -0
- pystudernext/data.py +81 -0
- pystudernext/datapoints.py +345 -0
- pystudernext/datapoints_acs.json +371 -0
- pystudernext/datapoints_acs_enums.json +448 -0
- pystudernext/datapoints_bat.json +217 -0
- pystudernext/datapoints_bat_enums.json +302 -0
- pystudernext/datapoints_flx.json +339 -0
- pystudernext/datapoints_flx_enums.json +609 -0
- pystudernext/datapoints_nx1.json +427 -0
- pystudernext/datapoints_nx1_enums.json +983 -0
- pystudernext/datapoints_nx3.json +540 -0
- pystudernext/datapoints_nx3_enums.json +1169 -0
- pystudernext/datapoints_nxg.json +342 -0
- pystudernext/datapoints_nxg_enums.json +603 -0
- pystudernext/datapoints_pwr.json +85 -0
- pystudernext/datapoints_pwr_enums.json +63 -0
- pystudernext/datapoints_sys.json +543 -0
- pystudernext/datapoints_sys_enums.json +359 -0
- pystudernext/datapoints_tst.json +43 -0
- pystudernext/datapoints_tst_enums.json +19 -0
- pystudernext/discover_async.py +247 -0
- pystudernext/discover_sync.py +250 -0
- pystudernext/families.py +251 -0
- pystudernext/shared/helpers.py +106 -0
- pystudernext/shared/studer_dataset.py +208 -0
- pystudernext/shared/studer_families.py +68 -0
- pystudernext/shared/studer_interfaces_async.py +101 -0
- pystudernext/shared/studer_interfaces_sync.py +103 -0
- pystudernext/shared/studer_messageset.py +59 -0
- pystudernext/shared/studer_types.py +123 -0
- pystudernext/shared/studer_valueset.py +41 -0
- pystudernext/values.py +59 -0
- pystudernext-0.0.1.dist-info/METADATA +150 -0
- pystudernext-0.0.1.dist-info/RECORD +41 -0
- pystudernext-0.0.1.dist-info/WHEEL +5 -0
- pystudernext-0.0.1.dist-info/licenses/LICENSE +21 -0
- pystudernext-0.0.1.dist-info/top_level.txt +1 -0
pystudernext/__init__.py
ADDED
|
@@ -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
|
+
|
pystudernext/api_sync.py
ADDED
|
@@ -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)
|