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.
Files changed (53) hide show
  1. pystudernext-0.0.1/LICENSE +21 -0
  2. pystudernext-0.0.1/PKG-INFO +150 -0
  3. pystudernext-0.0.1/README.md +128 -0
  4. pystudernext-0.0.1/pyproject.toml +45 -0
  5. pystudernext-0.0.1/setup.cfg +4 -0
  6. pystudernext-0.0.1/src/pystudernext/__init__.py +22 -0
  7. pystudernext-0.0.1/src/pystudernext/api_async.py +297 -0
  8. pystudernext-0.0.1/src/pystudernext/api_sync.py +300 -0
  9. pystudernext-0.0.1/src/pystudernext/const.py +23 -0
  10. pystudernext-0.0.1/src/pystudernext/data.py +81 -0
  11. pystudernext-0.0.1/src/pystudernext/datapoints.py +345 -0
  12. pystudernext-0.0.1/src/pystudernext/datapoints_acs.json +371 -0
  13. pystudernext-0.0.1/src/pystudernext/datapoints_acs_enums.json +448 -0
  14. pystudernext-0.0.1/src/pystudernext/datapoints_bat.json +217 -0
  15. pystudernext-0.0.1/src/pystudernext/datapoints_bat_enums.json +302 -0
  16. pystudernext-0.0.1/src/pystudernext/datapoints_flx.json +339 -0
  17. pystudernext-0.0.1/src/pystudernext/datapoints_flx_enums.json +609 -0
  18. pystudernext-0.0.1/src/pystudernext/datapoints_nx1.json +427 -0
  19. pystudernext-0.0.1/src/pystudernext/datapoints_nx1_enums.json +983 -0
  20. pystudernext-0.0.1/src/pystudernext/datapoints_nx3.json +540 -0
  21. pystudernext-0.0.1/src/pystudernext/datapoints_nx3_enums.json +1169 -0
  22. pystudernext-0.0.1/src/pystudernext/datapoints_nxg.json +342 -0
  23. pystudernext-0.0.1/src/pystudernext/datapoints_nxg_enums.json +603 -0
  24. pystudernext-0.0.1/src/pystudernext/datapoints_pwr.json +85 -0
  25. pystudernext-0.0.1/src/pystudernext/datapoints_pwr_enums.json +63 -0
  26. pystudernext-0.0.1/src/pystudernext/datapoints_sys.json +543 -0
  27. pystudernext-0.0.1/src/pystudernext/datapoints_sys_enums.json +359 -0
  28. pystudernext-0.0.1/src/pystudernext/datapoints_tst.json +43 -0
  29. pystudernext-0.0.1/src/pystudernext/datapoints_tst_enums.json +19 -0
  30. pystudernext-0.0.1/src/pystudernext/discover_async.py +247 -0
  31. pystudernext-0.0.1/src/pystudernext/discover_sync.py +250 -0
  32. pystudernext-0.0.1/src/pystudernext/families.py +251 -0
  33. pystudernext-0.0.1/src/pystudernext/shared/helpers.py +106 -0
  34. pystudernext-0.0.1/src/pystudernext/shared/studer_dataset.py +208 -0
  35. pystudernext-0.0.1/src/pystudernext/shared/studer_families.py +68 -0
  36. pystudernext-0.0.1/src/pystudernext/shared/studer_interfaces_async.py +101 -0
  37. pystudernext-0.0.1/src/pystudernext/shared/studer_interfaces_sync.py +103 -0
  38. pystudernext-0.0.1/src/pystudernext/shared/studer_messageset.py +59 -0
  39. pystudernext-0.0.1/src/pystudernext/shared/studer_types.py +123 -0
  40. pystudernext-0.0.1/src/pystudernext/shared/studer_valueset.py +41 -0
  41. pystudernext-0.0.1/src/pystudernext/values.py +59 -0
  42. pystudernext-0.0.1/src/pystudernext.egg-info/PKG-INFO +150 -0
  43. pystudernext-0.0.1/src/pystudernext.egg-info/SOURCES.txt +51 -0
  44. pystudernext-0.0.1/src/pystudernext.egg-info/dependency_links.txt +1 -0
  45. pystudernext-0.0.1/src/pystudernext.egg-info/requires.txt +8 -0
  46. pystudernext-0.0.1/src/pystudernext.egg-info/top_level.txt +1 -0
  47. pystudernext-0.0.1/tests/test_api_async.py +246 -0
  48. pystudernext-0.0.1/tests/test_api_sync.py +247 -0
  49. pystudernext-0.0.1/tests/test_data.py +31 -0
  50. pystudernext-0.0.1/tests/test_datapoints.py +173 -0
  51. pystudernext-0.0.1/tests/test_discover_async.py +227 -0
  52. pystudernext-0.0.1/tests/test_discover_sync.py +229 -0
  53. 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](https://img.shields.io/github/license/ankohanse/pystudernext?style=for-the-badge)](LICENSE)
24
+ [![buy_me_a_coffee](https://img.shields.io/badge/If%20you%20like%20it-Buy%20me%20a%20coffee-yellow.svg?style=for-the-badge)](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](https://img.shields.io/github/license/ankohanse/pystudernext?style=for-the-badge)](LICENSE)
2
+ [![buy_me_a_coffee](https://img.shields.io/badge/If%20you%20like%20it-Buy%20me%20a%20coffee-yellow.svg?style=for-the-badge)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+