pystudernext 0.0.1__tar.gz
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-0.0.1/LICENSE +21 -0
- pystudernext-0.0.1/PKG-INFO +150 -0
- pystudernext-0.0.1/README.md +128 -0
- pystudernext-0.0.1/pyproject.toml +45 -0
- pystudernext-0.0.1/setup.cfg +4 -0
- pystudernext-0.0.1/src/pystudernext/__init__.py +22 -0
- pystudernext-0.0.1/src/pystudernext/api_async.py +297 -0
- pystudernext-0.0.1/src/pystudernext/api_sync.py +300 -0
- pystudernext-0.0.1/src/pystudernext/const.py +23 -0
- pystudernext-0.0.1/src/pystudernext/data.py +81 -0
- pystudernext-0.0.1/src/pystudernext/datapoints.py +345 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_acs.json +371 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_acs_enums.json +448 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_bat.json +217 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_bat_enums.json +302 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_flx.json +339 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_flx_enums.json +609 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_nx1.json +427 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_nx1_enums.json +983 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_nx3.json +540 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_nx3_enums.json +1169 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_nxg.json +342 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_nxg_enums.json +603 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_pwr.json +85 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_pwr_enums.json +63 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_sys.json +543 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_sys_enums.json +359 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_tst.json +43 -0
- pystudernext-0.0.1/src/pystudernext/datapoints_tst_enums.json +19 -0
- pystudernext-0.0.1/src/pystudernext/discover_async.py +247 -0
- pystudernext-0.0.1/src/pystudernext/discover_sync.py +250 -0
- pystudernext-0.0.1/src/pystudernext/families.py +251 -0
- pystudernext-0.0.1/src/pystudernext/shared/helpers.py +106 -0
- pystudernext-0.0.1/src/pystudernext/shared/studer_dataset.py +208 -0
- pystudernext-0.0.1/src/pystudernext/shared/studer_families.py +68 -0
- pystudernext-0.0.1/src/pystudernext/shared/studer_interfaces_async.py +101 -0
- pystudernext-0.0.1/src/pystudernext/shared/studer_interfaces_sync.py +103 -0
- pystudernext-0.0.1/src/pystudernext/shared/studer_messageset.py +59 -0
- pystudernext-0.0.1/src/pystudernext/shared/studer_types.py +123 -0
- pystudernext-0.0.1/src/pystudernext/shared/studer_valueset.py +41 -0
- pystudernext-0.0.1/src/pystudernext/values.py +59 -0
- pystudernext-0.0.1/src/pystudernext.egg-info/PKG-INFO +150 -0
- pystudernext-0.0.1/src/pystudernext.egg-info/SOURCES.txt +51 -0
- pystudernext-0.0.1/src/pystudernext.egg-info/dependency_links.txt +1 -0
- pystudernext-0.0.1/src/pystudernext.egg-info/requires.txt +8 -0
- pystudernext-0.0.1/src/pystudernext.egg-info/top_level.txt +1 -0
- pystudernext-0.0.1/tests/test_api_async.py +246 -0
- pystudernext-0.0.1/tests/test_api_sync.py +247 -0
- pystudernext-0.0.1/tests/test_data.py +31 -0
- pystudernext-0.0.1/tests/test_datapoints.py +173 -0
- pystudernext-0.0.1/tests/test_discover_async.py +227 -0
- pystudernext-0.0.1/tests/test_discover_sync.py +229 -0
- pystudernext-0.0.1/tests/test_families.py +63 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anko Hanse
|
|
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,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pystudernext
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Python library to communicate with Studer-Innotec Next devices on the local network via Modbus protocol
|
|
5
|
+
Author-email: Anko Hanse <anko_hanse@hotmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/ankohanse/pystudernext
|
|
7
|
+
Project-URL: Issues, https://github.com/ankohanse/pystudernext/issues
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: httpx
|
|
15
|
+
Requires-Dist: pymodbus
|
|
16
|
+
Requires-Dist: aiofiles
|
|
17
|
+
Requires-Dist: orjson
|
|
18
|
+
Provides-Extra: tests
|
|
19
|
+
Requires-Dist: pytest; extra == "tests"
|
|
20
|
+
Requires-Dist: pytest-asyncio; extra == "tests"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
[](LICENSE)
|
|
24
|
+
[](https://www.buymeacoffee.com/ankohanse)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# pystudernext
|
|
28
|
+
|
|
29
|
+
Python library for retrieving sensor information from Studer-Innotec devices.
|
|
30
|
+
This component connects directly over the local network using the Studer Next modbus protocol.
|
|
31
|
+
|
|
32
|
+
The custom component is comfirmed to be compatible with:
|
|
33
|
+
- Next3 (three-phase)
|
|
34
|
+
- Next1 (single-phase)
|
|
35
|
+
|
|
36
|
+
Disclaimer: this library is NOT created by Studer-Innotec, but is based on their documentation of the Studer Next modbus protocol.
|
|
37
|
+
That documentation can be found on:
|
|
38
|
+
[Studer-Innotec Download Center](https://www.studer-innotec.com/en/downloads/) *-> Openstuder -> Communication protocol next modbus*
|
|
39
|
+
|
|
40
|
+
# Prerequisites
|
|
41
|
+
|
|
42
|
+
This library depends on the Next3/Next1 configured have modbus TCP enabled:
|
|
43
|
+
- Open the Studer Next Web Config
|
|
44
|
+
- Go to 'Monitoring'
|
|
45
|
+
- To the right of 'Modbus' press the 'Config' button
|
|
46
|
+
- Turn 'Modbus mode' On
|
|
47
|
+
- Use the following properties:
|
|
48
|
+
* Modbus mode: TCP
|
|
49
|
+
* Base address: 0
|
|
50
|
+
- Make a note of the other properties:
|
|
51
|
+
* IP address (address of the Next3 or Next1)
|
|
52
|
+
* Port (default is 502)
|
|
53
|
+
|
|
54
|
+
After a few seconds, the Studer modbus configuration should indicate status: 'Ready and listening'.
|
|
55
|
+
|
|
56
|
+
# Usage
|
|
57
|
+
|
|
58
|
+
The library is available from PyPi using:
|
|
59
|
+
`pip install pystudernext`
|
|
60
|
+
|
|
61
|
+
To read or write to a param:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
import logging
|
|
65
|
+
from pystudernext import NextApi, NextDeviceFamilies, NextDataset
|
|
66
|
+
|
|
67
|
+
logger = logging.getLogger(__name__)
|
|
68
|
+
|
|
69
|
+
# Set these values before running this example
|
|
70
|
+
# Host/ip address and port number of the Next Gateway
|
|
71
|
+
GATEWAY_HOST = "192.168.1.123"
|
|
72
|
+
GATEWAY_PORT = 502
|
|
73
|
+
|
|
74
|
+
dataset = NextDataset.get_instance()
|
|
75
|
+
param_2103 = dataset.get_by_address(2103, NextDeviceFamilies.SYSTEM)
|
|
76
|
+
param_0318 = dataset.get_by_address(318, NextDeviceFamilies.BATTERY)
|
|
77
|
+
param_5100 = dataset.get_by_address(5100, NextDeviceFamilies.NEXT3)
|
|
78
|
+
param_1815 = dataset.get_by_address(1815, NextDeviceFamilies.AC_SOURCE)
|
|
79
|
+
|
|
80
|
+
api = NextApi(GATEWAY_HOST, GATEWAY_PORT)
|
|
81
|
+
try:
|
|
82
|
+
if not api.start():
|
|
83
|
+
logger.info(f"Did not connect to Next Gateway")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
# Retrieve individual params
|
|
87
|
+
value = api.request_value(param_2103, "SYS") # System slave range is 1 to 1, or use "SYS"
|
|
88
|
+
logger.info(f"SYS {param_2103.address}: {value} {param_2103.unit or ''} ({param_2103.name})")
|
|
89
|
+
|
|
90
|
+
value = api.request_value(param_0318, "BAT_1") # Battery slave range is 2 to 6, or use "BAT_1" to "BAT_5"
|
|
91
|
+
logger.info(f"BAT_1 {param_0318.address}: {value} {param_0318.unit or ''} ({param_0318.name})")
|
|
92
|
+
|
|
93
|
+
value = api.request_value(param_5100, "NX3_1") # Next3 slave range is 14 to 28, or use "NX3_1" to "NX3_15"
|
|
94
|
+
logger.info(f"NZ3_1 {param_5100.address}: {param_5100.enum_value(value)} {param_5100.unit or ''} ({param_5100.name})")
|
|
95
|
+
|
|
96
|
+
# Retrieve and Update param 1815 (Grid feedin allowed))
|
|
97
|
+
logger.info(f"")
|
|
98
|
+
logger.info(f"Retrieve and then update a param")
|
|
99
|
+
|
|
100
|
+
value = api.request_value(param_1815, "ACS_1") # AC Source slave range is 7 to 8, or use "ACS_1" to "ACS_2"
|
|
101
|
+
logger.info(f"ACS_1 {param_1815.address}: {value} {param_1815.unit} ({param_1815.name})")
|
|
102
|
+
|
|
103
|
+
value = True
|
|
104
|
+
if api.update_value(param_1815, value, "ACS_1"):
|
|
105
|
+
logger.info(f"ACS_1 {param_1815.address} updated to {value} {param_1815.unit} ({param_1815.name})")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.info(f"Unexpected exception: {str(e)}")
|
|
110
|
+
|
|
111
|
+
finally:
|
|
112
|
+
logger.info(f"")
|
|
113
|
+
api.stop()
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
A complete list of param addresses can be found in the source of this library:
|
|
117
|
+
| filename | (sub-)device |
|
|
118
|
+
| ---------| ------------ |
|
|
119
|
+
| `src/pystudernext/datapoints_sys.json` | System |
|
|
120
|
+
| `src/pystudernext/datapoints_bat.json` | Battery |
|
|
121
|
+
| `src/pystudernext/datapoints_acs.json` | AC Source |
|
|
122
|
+
| `src/pystudernext/datapoints_acf.json` | AC FlexLoads |
|
|
123
|
+
| `src/pystudernext/datapoints_nx3.json` | Next3 |
|
|
124
|
+
| `src/pystudernext/datapoints_nx1.json` | Next1 |
|
|
125
|
+
| `src/pystudernext/datapoints_nxg.json` | Next Gateway |
|
|
126
|
+
|
|
127
|
+
Several other coding examples are provided:
|
|
128
|
+
| Synchronous code | Asynchronous code | Description |
|
|
129
|
+
| ---------------- | ----------------- | ------- |
|
|
130
|
+
| example_api_use.py | example_api_use_async.py | read & write params |
|
|
131
|
+
| example_menu.py | example_menu_async.py | display menu structure |
|
|
132
|
+
| example_discover_devices.py | example_discover_devices_async.py | discover local (sub-)devices |
|
|
133
|
+
| example_discover_gateway.py | example_discover_gateway_async.py | discover url to Studer Next Web-config |
|
|
134
|
+
|
|
135
|
+
# Param writes are to device RAM
|
|
136
|
+
|
|
137
|
+
When the value of a Studer param is changed via this library, these are written to the affected device.
|
|
138
|
+
Changes are stored in the device's on the volatile memory (RAM), not in its persistant/non-volatile memory as you can only write to persistent memory a limited number of times over its lifetime.
|
|
139
|
+
|
|
140
|
+
After a restart/reboot of the Studer installation the properties are reset to the original values contained in persistent memory. So you may want to periodically repeat the write of changed param values via an automation.
|
|
141
|
+
|
|
142
|
+
**IMPORTANT**:
|
|
143
|
+
|
|
144
|
+
Be very carefull in changing params marked as having level Expert or Studer. If you do not know what the effect of a Studer param change is, then do not change it.
|
|
145
|
+
|
|
146
|
+
# Credits
|
|
147
|
+
|
|
148
|
+
Special thanks to the following people for providing the information this library is based on and helping out with testing it:
|
|
149
|
+
- [t-baum](https://github.com/t-baum)
|
|
150
|
+
- [anakinch75](https://github.com/anakinch75)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
[](LICENSE)
|
|
2
|
+
[](https://www.buymeacoffee.com/ankohanse)
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
# pystudernext
|
|
6
|
+
|
|
7
|
+
Python library for retrieving sensor information from Studer-Innotec devices.
|
|
8
|
+
This component connects directly over the local network using the Studer Next modbus protocol.
|
|
9
|
+
|
|
10
|
+
The custom component is comfirmed to be compatible with:
|
|
11
|
+
- Next3 (three-phase)
|
|
12
|
+
- Next1 (single-phase)
|
|
13
|
+
|
|
14
|
+
Disclaimer: this library is NOT created by Studer-Innotec, but is based on their documentation of the Studer Next modbus protocol.
|
|
15
|
+
That documentation can be found on:
|
|
16
|
+
[Studer-Innotec Download Center](https://www.studer-innotec.com/en/downloads/) *-> Openstuder -> Communication protocol next modbus*
|
|
17
|
+
|
|
18
|
+
# Prerequisites
|
|
19
|
+
|
|
20
|
+
This library depends on the Next3/Next1 configured have modbus TCP enabled:
|
|
21
|
+
- Open the Studer Next Web Config
|
|
22
|
+
- Go to 'Monitoring'
|
|
23
|
+
- To the right of 'Modbus' press the 'Config' button
|
|
24
|
+
- Turn 'Modbus mode' On
|
|
25
|
+
- Use the following properties:
|
|
26
|
+
* Modbus mode: TCP
|
|
27
|
+
* Base address: 0
|
|
28
|
+
- Make a note of the other properties:
|
|
29
|
+
* IP address (address of the Next3 or Next1)
|
|
30
|
+
* Port (default is 502)
|
|
31
|
+
|
|
32
|
+
After a few seconds, the Studer modbus configuration should indicate status: 'Ready and listening'.
|
|
33
|
+
|
|
34
|
+
# Usage
|
|
35
|
+
|
|
36
|
+
The library is available from PyPi using:
|
|
37
|
+
`pip install pystudernext`
|
|
38
|
+
|
|
39
|
+
To read or write to a param:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
import logging
|
|
43
|
+
from pystudernext import NextApi, NextDeviceFamilies, NextDataset
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
# Set these values before running this example
|
|
48
|
+
# Host/ip address and port number of the Next Gateway
|
|
49
|
+
GATEWAY_HOST = "192.168.1.123"
|
|
50
|
+
GATEWAY_PORT = 502
|
|
51
|
+
|
|
52
|
+
dataset = NextDataset.get_instance()
|
|
53
|
+
param_2103 = dataset.get_by_address(2103, NextDeviceFamilies.SYSTEM)
|
|
54
|
+
param_0318 = dataset.get_by_address(318, NextDeviceFamilies.BATTERY)
|
|
55
|
+
param_5100 = dataset.get_by_address(5100, NextDeviceFamilies.NEXT3)
|
|
56
|
+
param_1815 = dataset.get_by_address(1815, NextDeviceFamilies.AC_SOURCE)
|
|
57
|
+
|
|
58
|
+
api = NextApi(GATEWAY_HOST, GATEWAY_PORT)
|
|
59
|
+
try:
|
|
60
|
+
if not api.start():
|
|
61
|
+
logger.info(f"Did not connect to Next Gateway")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
# Retrieve individual params
|
|
65
|
+
value = api.request_value(param_2103, "SYS") # System slave range is 1 to 1, or use "SYS"
|
|
66
|
+
logger.info(f"SYS {param_2103.address}: {value} {param_2103.unit or ''} ({param_2103.name})")
|
|
67
|
+
|
|
68
|
+
value = api.request_value(param_0318, "BAT_1") # Battery slave range is 2 to 6, or use "BAT_1" to "BAT_5"
|
|
69
|
+
logger.info(f"BAT_1 {param_0318.address}: {value} {param_0318.unit or ''} ({param_0318.name})")
|
|
70
|
+
|
|
71
|
+
value = api.request_value(param_5100, "NX3_1") # Next3 slave range is 14 to 28, or use "NX3_1" to "NX3_15"
|
|
72
|
+
logger.info(f"NZ3_1 {param_5100.address}: {param_5100.enum_value(value)} {param_5100.unit or ''} ({param_5100.name})")
|
|
73
|
+
|
|
74
|
+
# Retrieve and Update param 1815 (Grid feedin allowed))
|
|
75
|
+
logger.info(f"")
|
|
76
|
+
logger.info(f"Retrieve and then update a param")
|
|
77
|
+
|
|
78
|
+
value = api.request_value(param_1815, "ACS_1") # AC Source slave range is 7 to 8, or use "ACS_1" to "ACS_2"
|
|
79
|
+
logger.info(f"ACS_1 {param_1815.address}: {value} {param_1815.unit} ({param_1815.name})")
|
|
80
|
+
|
|
81
|
+
value = True
|
|
82
|
+
if api.update_value(param_1815, value, "ACS_1"):
|
|
83
|
+
logger.info(f"ACS_1 {param_1815.address} updated to {value} {param_1815.unit} ({param_1815.name})")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.info(f"Unexpected exception: {str(e)}")
|
|
88
|
+
|
|
89
|
+
finally:
|
|
90
|
+
logger.info(f"")
|
|
91
|
+
api.stop()
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
A complete list of param addresses can be found in the source of this library:
|
|
95
|
+
| filename | (sub-)device |
|
|
96
|
+
| ---------| ------------ |
|
|
97
|
+
| `src/pystudernext/datapoints_sys.json` | System |
|
|
98
|
+
| `src/pystudernext/datapoints_bat.json` | Battery |
|
|
99
|
+
| `src/pystudernext/datapoints_acs.json` | AC Source |
|
|
100
|
+
| `src/pystudernext/datapoints_acf.json` | AC FlexLoads |
|
|
101
|
+
| `src/pystudernext/datapoints_nx3.json` | Next3 |
|
|
102
|
+
| `src/pystudernext/datapoints_nx1.json` | Next1 |
|
|
103
|
+
| `src/pystudernext/datapoints_nxg.json` | Next Gateway |
|
|
104
|
+
|
|
105
|
+
Several other coding examples are provided:
|
|
106
|
+
| Synchronous code | Asynchronous code | Description |
|
|
107
|
+
| ---------------- | ----------------- | ------- |
|
|
108
|
+
| example_api_use.py | example_api_use_async.py | read & write params |
|
|
109
|
+
| example_menu.py | example_menu_async.py | display menu structure |
|
|
110
|
+
| example_discover_devices.py | example_discover_devices_async.py | discover local (sub-)devices |
|
|
111
|
+
| example_discover_gateway.py | example_discover_gateway_async.py | discover url to Studer Next Web-config |
|
|
112
|
+
|
|
113
|
+
# Param writes are to device RAM
|
|
114
|
+
|
|
115
|
+
When the value of a Studer param is changed via this library, these are written to the affected device.
|
|
116
|
+
Changes are stored in the device's on the volatile memory (RAM), not in its persistant/non-volatile memory as you can only write to persistent memory a limited number of times over its lifetime.
|
|
117
|
+
|
|
118
|
+
After a restart/reboot of the Studer installation the properties are reset to the original values contained in persistent memory. So you may want to periodically repeat the write of changed param values via an automation.
|
|
119
|
+
|
|
120
|
+
**IMPORTANT**:
|
|
121
|
+
|
|
122
|
+
Be very carefull in changing params marked as having level Expert or Studer. If you do not know what the effect of a Studer param change is, then do not change it.
|
|
123
|
+
|
|
124
|
+
# Credits
|
|
125
|
+
|
|
126
|
+
Special thanks to the following people for providing the information this library is based on and helping out with testing it:
|
|
127
|
+
- [t-baum](https://github.com/t-baum)
|
|
128
|
+
- [anakinch75](https://github.com/anakinch75)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pystudernext"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
authors = [
|
|
5
|
+
{ name="Anko Hanse", email="anko_hanse@hotmail.com" },
|
|
6
|
+
]
|
|
7
|
+
description = "Python library to communicate with Studer-Innotec Next devices on the local network via Modbus protocol"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Programming Language :: Python :: 3",
|
|
11
|
+
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
|
12
|
+
"Operating System :: OS Independent",
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.8"
|
|
15
|
+
dependencies = [
|
|
16
|
+
'httpx',
|
|
17
|
+
'pymodbus',
|
|
18
|
+
'aiofiles',
|
|
19
|
+
'orjson',
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
tests = [
|
|
24
|
+
'pytest',
|
|
25
|
+
'pytest-asyncio',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/ankohanse/pystudernext"
|
|
30
|
+
Issues = "https://github.com/ankohanse/pystudernext/issues"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = [
|
|
34
|
+
"setuptools>=61.0",
|
|
35
|
+
"wheel",
|
|
36
|
+
"cython",
|
|
37
|
+
"unasyncd",
|
|
38
|
+
]
|
|
39
|
+
build-backend = "setuptools.build_meta"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.package-data]
|
|
45
|
+
pystudernext = ["*.json"]
|
|
@@ -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
|
+
|