diff --git a/docs/source/conf.py b/docs/source/conf.py index 7076fa5..25fca78 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -58,6 +58,7 @@ intersphinx_mapping = { "outcome": ("https://outcome.readthedocs.io/en/stable", None), "python": ("https://docs.python.org/3", None), + "pymodbus": ("https://pymodbus.readthedocs.io/en/stable", None), "PyQt5": ("https://www.riverbankcomputing.com/static/Docs/PyQt5", None), "PySide2": ("https://doc.qt.io/qtforpython", None), "pytest": ("https://docs.pytest.org/en/stable", None), diff --git a/docs/source/exceptions.rst b/docs/source/exceptions.rst index 0d3e479..a10f42f 100644 --- a/docs/source/exceptions.rst +++ b/docs/source/exceptions.rst @@ -2,7 +2,10 @@ Exceptions ========== .. autoclass:: ssst.SsstError +.. autoclass:: ssst.BaseAddressNotFoundError .. autoclass:: ssst.InternalError +.. autoclass:: ssst.InvalidBaseAddressError +.. autoclass:: ssst.ModbusError .. autoclass:: ssst.QtpyError .. autoclass:: ssst.ReuseError .. autoclass:: ssst.UnexpectedEmissionError diff --git a/docs/source/index.rst b/docs/source/index.rst index fe932ac..7e21689 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -4,6 +4,7 @@ :maxdepth: 2 main.rst + sunspec.rst exceptions.rst cli.rst history.rst diff --git a/docs/source/sunspec.rst b/docs/source/sunspec.rst new file mode 100644 index 0000000..5759ea5 --- /dev/null +++ b/docs/source/sunspec.rst @@ -0,0 +1,15 @@ +SunSpec +======= + +Client +------ +.. autofunction:: ssst.sunspec.client.open_client +.. autoclass:: ssst.sunspec.client.Client + + +Server +------ +.. autoclass:: ssst.sunspec.server.Server +.. autoclass:: ssst.sunspec.server.ModelSummary +.. autoclass:: ssst.sunspec.server.SunSpecModbusSlaveContext +.. autoclass:: ssst.sunspec.server.PreparedRequest diff --git a/mypy.ini b/mypy.ini index 8f1fe2e..da347be 100644 --- a/mypy.ini +++ b/mypy.ini @@ -30,8 +30,14 @@ ignore_missing_imports = True [mypy-importlib_metadata.*] ignore_missing_imports = True +[mypy-pymodbus.*] +ignore_missing_imports = True + [mypy-qtpy.*] ignore_missing_imports = True +[mypy-sunspec2.*] +ignore_missing_imports = True + [mypy-trio.*] ignore_missing_imports = True diff --git a/pytest.ini b/pytest.ini index 6497cd6..e886c5a 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,5 @@ [pytest] trio_mode = true trio_run = qtrio +log_cli = True +log_cli_level = debug diff --git a/setup.cfg b/setup.cfg index 0b46e3b..07cdf9e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,6 +17,9 @@ install_requires = async_generator ~=1.10 attrs ~=20.3.0 click ~=7.1 + pymodbus @ https://github.com/altendky/pymodbus/archive/b0e72eb57fd943650ae97f4bb43481dbdb8be0fe.zip + # git+ gets us the models submodule, as opposed to .zip + pysunspec2 @ git+https://github.com/sunspec/pysunspec2@d6023c394fa717913849c1f6ad7cab3ab7456c47 # TODO: Should not need to duplicate the QTrio version info down below. # https://github.com/pypa/pip/issues/9437 # >=0.4.1 for https://github.com/altendky/qtrio/pull/211 diff --git a/src/ssst/__init__.py b/src/ssst/__init__.py index 235c5e4..259044c 100644 --- a/src/ssst/__init__.py +++ b/src/ssst/__init__.py @@ -3,9 +3,12 @@ from ssst._version import __version__ from ssst.exceptions import ( + SsstError, + BaseAddressNotFoundError, InternalError, + InvalidBaseAddressError, + ModbusError, QtpyError, ReuseError, - SsstError, UnexpectedEmissionError, ) diff --git a/src/ssst/_tests/conftest.py b/src/ssst/_tests/conftest.py index 4d6c0cd..e1fd4e4 100644 --- a/src/ssst/_tests/conftest.py +++ b/src/ssst/_tests/conftest.py @@ -1,7 +1,15 @@ +import functools import typing +import attr import click.testing +import pymodbus.client.asynchronous.tcp +import pymodbus.client.asynchronous.schedulers import pytest +import trio + +import ssst.sunspec.client +import ssst.sunspec.server pytest_plugins = "pytester" @@ -15,3 +23,60 @@ def cli_runner_fixture() -> typing.Iterator[click.testing.CliRunner]: cli_runner = click.testing.CliRunner() with cli_runner.isolated_filesystem(): yield cli_runner + + +@attr.s(auto_attribs=True, frozen=True) +class SunSpecServerFixtureResult: + host: str + port: int + server: ssst.sunspec.server.Server + + +@pytest.fixture(name="sunspec_server") +async def sunspec_server_fixture( + nursery: trio.Nursery, +) -> typing.AsyncIterator[SunSpecServerFixtureResult]: + model_summaries = [ + ssst.sunspec.server.ModelSummary(id=1, length=66), + ssst.sunspec.server.ModelSummary(id=17, length=12), + ssst.sunspec.server.ModelSummary(id=103, length=50), + ssst.sunspec.server.ModelSummary(id=126, length=226), + ] + + server = ssst.sunspec.server.Server.build(model_summaries=model_summaries) + + host = "127.0.0.1" + + [listener] = await nursery.start( + functools.partial( + trio.serve_tcp, + server.tcp_server, + host=host, + port=0, + ), + ) + + yield SunSpecServerFixtureResult( + host=host, + port=listener.socket.getsockname()[1], + server=server, + ) + + +@pytest.fixture(name="unscanned_sunspec_client") +async def unscanned_sunspec_client_fixture( + sunspec_server: SunSpecServerFixtureResult, +) -> typing.AsyncIterator[ssst.sunspec.client.Client]: + async with ssst.sunspec.client.open_client( + host=sunspec_server.host, + port=sunspec_server.port, + ) as client: + yield client + + +@pytest.fixture(name="sunspec_client") +async def sunspec_client_fixture( + unscanned_sunspec_client: ssst.sunspec.client.Client, +) -> ssst.sunspec.client.Client: + await unscanned_sunspec_client.scan() + return unscanned_sunspec_client diff --git a/src/ssst/_tests/sunspec/__init__.py b/src/ssst/_tests/sunspec/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ssst/_tests/sunspec/test_client.py b/src/ssst/_tests/sunspec/test_client.py new file mode 100644 index 0000000..f66449a --- /dev/null +++ b/src/ssst/_tests/sunspec/test_client.py @@ -0,0 +1,180 @@ +import re + +import pytest + +import ssst._tests.conftest +import ssst.sunspec.client +import ssst.sunspec.server + + +async def test_scan_adds_models(sunspec_client: ssst.sunspec.client.Client) -> None: + model_ids = [model.model_id for model in sunspec_client.sunspec_device.model_list] + + assert model_ids == [1, 17, 103, 126] + + +async def test_scan_raises_for_missing_sentinel_when_searching( + unscanned_sunspec_client: ssst.sunspec.client.Client, +) -> None: + unscanned_sunspec_client.sunspec_device.base_addr_list[:] = [40_010, 40_020] + + message = "SunSpec sentinel b'SunS' not found while searching: 40010, 40020" + with pytest.raises(ssst.BaseAddressNotFoundError, match=f"^{re.escape(message)}$"): + await unscanned_sunspec_client.scan() + + +async def test_scan_raises_for_missing_sentinel_when_address_specified( + unscanned_sunspec_client: ssst.sunspec.client.Client, +) -> None: + unscanned_sunspec_client.sunspec_device.base_addr = 40_001 + + message = r"SunSpec sentinel b'SunS' not found at 40001: b'nS\x00\x01'" + with pytest.raises(ssst.InvalidBaseAddressError, match=f"^{re.escape(message)}$"): + await unscanned_sunspec_client.scan() + + +async def test_model_addresses(sunspec_client: ssst.sunspec.client.Client) -> None: + model_ids = [model.model_addr for model in sunspec_client.sunspec_device.model_list] + + assert model_ids == [40_002, 40_070, 40_084, 40_136] + + +async def test_point_address( + sunspec_client: ssst.sunspec.client.Client, +) -> None: + point = sunspec_client[17].points["Bits"] + assert point.model.model_addr + point.offset == 40_078 + + +async def test_read_point_by_registers( + sunspec_server: ssst._tests.conftest.SunSpecServerFixtureResult, + sunspec_client: ssst.sunspec.client.Client, +) -> None: + model = sunspec_server.server[1] + point = model.points["DA"] + address = model.model_addr + point.offset + length = 1 + new_id = 43928 + + written_bytes = point.info.to_data(new_id) + point.set_mb(written_bytes) + + read_bytes = await sunspec_client.read_registers(address=address, count=length) + + assert read_bytes == written_bytes + assert int.from_bytes(read_bytes, byteorder="big") == new_id + + +async def test_read_point( + sunspec_server: ssst._tests.conftest.SunSpecServerFixtureResult, + sunspec_client: ssst.sunspec.client.Client, +) -> None: + new_id = 43928 + + server_point = sunspec_server.server[1].points["DA"] + server_point.cvalue = new_id + + client_point = sunspec_client[1].points["DA"] + + await sunspec_client.read_point(point=client_point) + + assert client_point.cvalue == new_id + + +async def test_read_point_with_scale_factor( + sunspec_server: ssst._tests.conftest.SunSpecServerFixtureResult, + sunspec_client: ssst.sunspec.client.Client, +) -> None: + server_point = sunspec_server.server[103].points["W"] + server_scale_factor_point = server_point.model.points[server_point.sf] + + scale_factor = -2 + scaled_watts = 273 + + server_scale_factor_point.cvalue = scale_factor + server_point.cvalue = scaled_watts + + point = sunspec_client[103].points["W"] + scale_factor_point = point.model.points[point.sf] + + read_scale_factor = await sunspec_client.read_point(point=scale_factor_point) + assert read_scale_factor == scale_factor + + read_value = await sunspec_client.read_point(point=point) + assert read_value == scaled_watts + + +async def test_write_point_by_registers( + sunspec_server: ssst._tests.conftest.SunSpecServerFixtureResult, + sunspec_client: ssst.sunspec.client.Client, +) -> None: + new_id = 43928 + + client_point = sunspec_client[1].points["DA"] + client_point.cvalue = new_id + + await sunspec_client.write_registers( + address=sunspec_client.point_address(point=client_point), + values=client_point.get_mb(), + ) + + server_point = sunspec_server.server[1].points["DA"] + assert server_point.cvalue == new_id + + +async def test_write_point( + sunspec_server: ssst._tests.conftest.SunSpecServerFixtureResult, + sunspec_client: ssst.sunspec.client.Client, +) -> None: + new_id = 43928 + + client_point = sunspec_client[1].points["DA"] + client_point.cvalue = new_id + + await sunspec_client.write_point(point=client_point) + + server_point = sunspec_server.server[1].points["DA"] + assert server_point.cvalue == new_id + + +async def test_write_point_with_scale_factor( + sunspec_server: ssst._tests.conftest.SunSpecServerFixtureResult, + sunspec_client: ssst.sunspec.client.Client, +) -> None: + point = sunspec_client[103].points["W"] + scale_factor_point = point.model.points[point.sf] + + scale_factor = -2 + scaled_watts = 273 + + server_point = sunspec_server.server[103].points["W"] + server_scale_factor_point = server_point.model.points[server_point.sf] + + server_scale_factor_point.cvalue = scale_factor + server_point.cvalue = 0 + + scale_factor_point.cvalue = 0 + point.cvalue = scaled_watts + + await sunspec_client.write_point(point=point) + + assert point.cvalue == server_point.cvalue == scaled_watts + assert scale_factor_point.cvalue == server_scale_factor_point.cvalue == scale_factor + + # assert scale_factor_point.cvalue == server_scale_factor_point.cvalue + # assert server_scale_factor_point.cvalue == scale_factor + # assert server_point.cvalue == scaled_watts + + +async def test_read_modbus_exception_raises( + sunspec_client: ssst.sunspec.client.Client, +): + with pytest.raises(ssst.ModbusError): + await sunspec_client.read_registers(address=0, count=1) + + +async def test_write_modbus_exception_raises( + sunspec_client: ssst.sunspec.client.Client, +): + with pytest.raises(ssst.ModbusError): + await sunspec_client.write_registers(address=0, values=b":]") diff --git a/src/ssst/_tests/sunspec/test_server.py b/src/ssst/_tests/sunspec/test_server.py new file mode 100644 index 0000000..93e0a3f --- /dev/null +++ b/src/ssst/_tests/sunspec/test_server.py @@ -0,0 +1,52 @@ +import ssst._tests.conftest +import ssst.sunspec +import ssst.sunspec.client + + +async def test_base_address_marker( + sunspec_client: ssst.sunspec.client.Client, +) -> None: + register_bytes = await sunspec_client.read_registers(address=40_000, count=2) + + assert register_bytes == ssst.sunspec.base_address_sentinel + + +async def test_addresses( + sunspec_server: ssst._tests.conftest.SunSpecServerFixtureResult, +) -> None: + point = sunspec_server.server[17].points["Bits"] + assert point.model.model_addr + point.offset == 40_078 + + +async def test_write_registers( + sunspec_server: ssst._tests.conftest.SunSpecServerFixtureResult, + sunspec_client: ssst.sunspec.client.Client, +) -> None: + model = sunspec_server.server[1] + point = model.points["DA"] + address = model.model_addr + point.offset + new_id = 43928 + + bytes_to_write = point.info.to_data(new_id) + await sunspec_client.write_registers(address=address, values=bytes_to_write) + + assert point.get_mb() == bytes_to_write + + +async def test_read_bus_value_scaled_as_expected( + sunspec_server: ssst._tests.conftest.SunSpecServerFixtureResult, + sunspec_client: ssst.sunspec.client.Client, +) -> None: + server_point = sunspec_server.server[103].points["W"] + server_scale_factor_point = server_point.model.points[server_point.sf] + + scale_factor = -2 + scaled_watts = 47.35 + + server_scale_factor_point.cvalue = scale_factor + server_point.cvalue = scaled_watts + + client_point = sunspec_client[103].points["W"] + + await sunspec_client.read_point(point=client_point) + assert client_point.value == scaled_watts / 10 ** scale_factor diff --git a/src/ssst/exceptions.py b/src/ssst/exceptions.py index ef60ab2..8825c0d 100644 --- a/src/ssst/exceptions.py +++ b/src/ssst/exceptions.py @@ -1,6 +1,7 @@ import typing if typing.TYPE_CHECKING: + import pymodbus.pdu import qtrio @@ -12,6 +13,22 @@ class SsstError(Exception): __module__ = "ssst" +class BaseAddressNotFoundError(SsstError): + """Raised if no address matched the expected SunSpec sentinel value.""" + + def __init__(self, addresses: typing.Sequence[int]) -> None: + import ssst.sunspec + + sentinel = repr(ssst.sunspec.base_address_sentinel) + addresses_string = ", ".join(str(address) for address in addresses) + super().__init__( + f"SunSpec sentinel {sentinel} not found while searching: {addresses_string}" + ) + + # https://github.com/sphinx-doc/sphinx/issues/7493 + __module__ = "ssst" + + class InternalError(Exception): """Raised when things that should not happen do, and they aren't the user's fault.""" @@ -19,6 +36,43 @@ class InternalError(Exception): __module__ = "ssst" +class InvalidBaseAddressError(SsstError): + """Raised if the specified base address does not match the expected SunSpec + sentinel value. + """ + + def __init__(self, address: int, value: bytes) -> None: + import ssst.sunspec + + sentinel = repr(ssst.sunspec.base_address_sentinel) + super().__init__( + f"SunSpec sentinel {sentinel} not found at {address}: {value!r}" + ) + + # https://github.com/sphinx-doc/sphinx/issues/7493 + __module__ = "ssst" + + +class ModbusError(SsstError): + """Raised when a Modbus action results in a Modbus exception.""" + + def __init__(self, exception: "pymodbus.pdu.ExceptionResponse") -> None: + codes = [ + f"{label}: {value} == 0x{value:02x}" + for label, value in [ + ["original", exception.original_code], + ["function", exception.function_code], + ["exception", exception.exception_code], + ] + ] + message = f"Exception response received. {', '.join(codes)}" + + super().__init__(message) + + # https://github.com/sphinx-doc/sphinx/issues/7493 + __module__ = "ssst" + + class QtpyError(SsstError): """To be used for any error related to dealing with QtPy that doesn't get a dedicated exception type. diff --git a/src/ssst/sunspec/__init__.py b/src/ssst/sunspec/__init__.py new file mode 100644 index 0000000..c581527 --- /dev/null +++ b/src/ssst/sunspec/__init__.py @@ -0,0 +1 @@ +base_address_sentinel = b"SunS" diff --git a/src/ssst/sunspec/client.py b/src/ssst/sunspec/client.py new file mode 100644 index 0000000..c6fd9b2 --- /dev/null +++ b/src/ssst/sunspec/client.py @@ -0,0 +1,264 @@ +import typing + +import async_generator +import attr +import pymodbus.client.asynchronous.schedulers +import pymodbus.client.asynchronous.tcp +import pymodbus.client.asynchronous.trio +import pymodbus.client.common +import sunspec2.mb +import sunspec2.modbus.client +import pymodbus.pdu + +import ssst.sunspec + + +@async_generator.asynccontextmanager +async def open_client(host: str, port: int) -> typing.AsyncIterator["Client"]: + """Open a SunSpec Modbus TCP connection to the passed host and port. + + Arguments: + host: The host name or IP address. + port: The port number. + + Yields: + The SunSpec client. + """ + + modbus_client = pymodbus.client.asynchronous.tcp.AsyncModbusTCPClient( + scheduler=pymodbus.client.asynchronous.schedulers.TRIO, + host=host, + port=port, + ) + sunspec_device = sunspec2.modbus.client.SunSpecModbusClientDevice() + + async with modbus_client.manage_connection() as protocol: + yield Client( + modbus_client=modbus_client, + sunspec_device=sunspec_device, + protocol=protocol, + ) + + +@attr.s(auto_attribs=True) +class Client: + """A SunSpec Modbus TCP client using :mod:`trio` support in :mod:`pymodbus` for + communication and `pysunspec2` for loading models and holding the local cache of + the data. The existing communication abilities of the `pysunspec2` objects are + left intact but should not be used. + + .. automethod:: __getitem__ + """ + + modbus_client: pymodbus.client.asynchronous.trio.TrioModbusTcpClient + """The Modbus TCP client used for communication.""" + protocol: pymodbus.client.common.ModbusClientMixin + """The Modbus client protocol.""" + sunspec_device: sunspec2.modbus.client.SunSpecModbusClientDevice + """The SunSpec device object that holds the local data cache and model structures. + """ + + def __getitem__( + self, item: typing.Union[int, str] + ) -> sunspec2.modbus.client.SunSpecModbusClientModel: + """SunSpec models are accessible by indexing the client using either the model + number or model name. + + .. code-block:: python + + model_1 = client[1] + model_common = client["common"] + assert model_1 is model_common + + Returns: + The requested model. + """ + [model] = self.sunspec_device.models[item] + return model + + async def scan(self) -> None: + """Scan the device to identify the base address, if not already set, and + collect the model list. This also populates all the data. + """ + if self.sunspec_device.base_addr is None: + for maybe_base_address in self.sunspec_device.base_addr_list: + read_bytes = await self.read_registers( + address=maybe_base_address, + count=len(ssst.sunspec.base_address_sentinel) // 2, + ) + if read_bytes == ssst.sunspec.base_address_sentinel: + self.sunspec_device.base_addr = maybe_base_address + break + else: + raise ssst.BaseAddressNotFoundError( + addresses=self.sunspec_device.base_addr_list + ) + else: + read_bytes = await self.read_registers( + address=self.sunspec_device.base_addr, + count=len(ssst.sunspec.base_address_sentinel) // 2, + ) + if read_bytes != ssst.sunspec.base_address_sentinel: + raise ssst.InvalidBaseAddressError( + address=self.sunspec_device.base_addr, + value=read_bytes, + ) + + address = ( + self.sunspec_device.base_addr + len(ssst.sunspec.base_address_sentinel) // 2 + ) + model_id_length = 1 + model_length_length = 1 + + while True: + model_address = address + intra_model_address = address + read_bytes = await self.read_registers( + address=address, count=model_id_length + ) + intra_model_address += model_id_length + maybe_model_id = int.from_bytes( + bytes=read_bytes, byteorder="big", signed=False + ) + if maybe_model_id == sunspec2.mb.SUNS_END_MODEL_ID: + break + + model_id = maybe_model_id + + read_bytes = await self.read_registers( + address=intra_model_address, count=model_length_length + ) + intra_model_address += model_length_length + model_length = int.from_bytes( + bytes=read_bytes, byteorder="big", signed=False + ) + + # TODO: oof, awkward way to write this it seems + whole_model_length = (intra_model_address - address) + model_length + model_data = self.read_registers(address=address, count=whole_model_length) + address += whole_model_length + + model = sunspec2.modbus.client.SunSpecModbusClientModel( + model_id=model_id, + model_addr=model_address, + model_len=model_length, + data=model_data, + mb_device=self.sunspec_device, + ) + self.sunspec_device.add_model(model) + + # TODO: should the local data be updated? + async def read_registers(self, address: int, count: int) -> bytes: + """Read from the specified sequential register range in the device. Based on + the 16-bit Modbus register size, the data in the returned bytes is in 2-byte + chunks with each having a big-endian byte order. The local data is not + updated. + + Arguments: + address: The first register to read. + count: The total number of sequential registers to read. + + Returns: + The raw bytes read from the device. + + Raises: + ssst.ModbusError: When a Modbus exception response is received. + """ + + response = await self.protocol.read_holding_registers( + address=address, count=count, unit=0x01 + ) + + if isinstance(response, pymodbus.pdu.ExceptionResponse): + raise ssst.ModbusError(exception=response) + + return bytes(response.registers) + + async def read_point( + self, point: sunspec2.modbus.client.SunSpecModbusClientPoint + ) -> typing.Union[float, int]: + """Read the passed point from the device and update the local data. + + Arguments: + point: The SunSpec point object to read. + + Returns: + The new computed value of the point. + + Raises: + ssst.ModbusError: When a Modbus exception response is received. + """ + if point.sf is not None: + await self.read_point(point=point.model.points[point.sf]) + + read_bytes = await self.read_registers( + address=self.point_address(point=point), + count=point.len, + ) + point.set_mb(data=read_bytes) + + if point.pdef["type"] == "sunssf": + for other_point in point.model.points.values(): + if other_point.sf == point.pdef["name"]: + other_cvalue = other_point.cvalue + other_point.sf_value = point.cvalue + if other_cvalue is not None: + other_point.cvalue = other_cvalue + + return point.cvalue # type: ignore[no-any-return] + + def point_address( + self, point: sunspec2.modbus.client.SunSpecModbusClientPoint + ) -> int: + """Calculate the start address of a given SunSpec point. + + Arguments: + point: The SunSpec point object to read. + + Returns: + The address of the first register of the point. + """ + return point.model.model_addr + point.offset # type: ignore[no-any-return] + + async def write_registers(self, address: int, values: bytes) -> None: + """Write to the specified sequential register range in the device. Based on + the 16-bit Modbus register size, the data in the passed bytes should in 2-byte + chunks with each having a big-endian byte order. The local data is not + updated. + + Arguments: + address: The first register to write. + count: The total number of sequential registers to write. + + Returns: + The raw bytes to be written to the device. + + Raises: + ssst.ModbusError: When a Modbus exception response is received. + """ + response = await self.protocol.write_registers( + address=address, values=values, unit=0x01 + ) + + if isinstance(response, pymodbus.pdu.ExceptionResponse): + raise ssst.ModbusError(exception=response) + + async def write_point( + self, point: sunspec2.modbus.client.SunSpecModbusClientPoint + ) -> None: + """Write the passed point from the local data to the device. + + Arguments: + point: The SunSpec point object to write. + + Raises: + ssst.ModbusError: When a Modbus exception response is received. + """ + if point.sf is not None: + await self.read_point(point=point.model.points[point.sf]) + + bytes_to_write = point.get_mb() + await self.write_registers( + address=self.point_address(point=point), + values=bytes_to_write, + ) diff --git a/src/ssst/sunspec/server.py b/src/ssst/sunspec/server.py new file mode 100644 index 0000000..c419648 --- /dev/null +++ b/src/ssst/sunspec/server.py @@ -0,0 +1,234 @@ +import functools +import typing + +import attr +import pymodbus.datastore +import pymodbus.device +import pymodbus.server.trio +import pymodbus.interfaces +import sunspec2.mb +import sunspec2.modbus.client +import trio + +import ssst.sunspec + + +base_address = 40_000 + + +@attr.s(auto_attribs=True) +class ModelSummary: + """A model can be summarized by its ID and length. While models of fixed length + would not need the length provided, those with repeatable blocks need a length to + indicate the number of repetitions of the repeating block.""" + + id: int + """The integer model ID.""" + length: int + """The model length inclusive of the fixed and repeating blocks and exclusive of + the model's ID and length header.""" + + +@attr.s(auto_attribs=True) +class SunSpecModbusSlaveContext(pymodbus.interfaces.IModbusSlaveContext): + """A :mod:`pymodbus` slave context that is backed by the ``pysunspec2`` device + object.""" + + sunspec_device: sunspec2.modbus.client.SunSpecModbusClientDevice + """The ``pysunspec2`` device object use for local storage of the SunSpec data.""" + + def getValues(self, fx: int, address: int, count: int = 1) -> bytearray: + """See :meth:`pymodbus.interfaces.IModbusSlaveContext.getValues`.""" + request = PreparedRequest.build( + base_address=self.sunspec_device.base_addr, + requested_address=address, + count=count, + all_registers=self.sunspec_device.get_mb(), + ) + return request.data[request.slice] + + def setValues(self, fx: int, address: int, values: bytes) -> None: + """See :meth:`pymodbus.interfaces.IModbusSlaveContext.setValues`.""" + request = PreparedRequest.build( + base_address=self.sunspec_device.base_addr, + requested_address=address, + count=len(values) // 2, + all_registers=self.sunspec_device.get_mb(), + ) + data = bytearray(request.data) + data[request.slice] = values + self.sunspec_device.set_mb(data=data[len(ssst.sunspec.base_address_sentinel) :]) + + def validate(self, fx: int, address: int, count: int = 1) -> bool: + """See :meth:`pymodbus.interfaces.IModbusSlaveContext.validate`.""" + return ( + self.sunspec_device.base_addr <= address + and address + count <= self._end_address() + ) + + def _end_address(self) -> int: + """Calculate the exclusive last address. This is the first address which + cannot be read. + """ + return ( + base_address + + ( + ( + len(ssst.sunspec.base_address_sentinel) + + len(self.sunspec_device.get_mb()) + ) + // 2 + ) + + 2 + ) + + +@attr.s(auto_attribs=True) +class Server: + """A SunSpec Modbus TCP server using :mod:`trio` support in :mod:`pymodbus` for + communication and `pysunspec2` for loading models and holding the local cache of + the data. The actual TCP server can be launched using :meth:`Server.tcp_server`. + + .. code-block:: python + + await nursery.start( + functools.partial( + trio.serve_tcp, + server.tcp_server, + host="127.0.0.1", + port=0, + ), + ) + + .. automethod:: __getitem__ + """ + + slave_context: SunSpecModbusSlaveContext + """The single slave context to be served by this server. This is backed by the + SunSpec device object. + """ + server_context: pymodbus.datastore.ModbusServerContext + """The datastore for this pymodbus server. Presently only a single slave context + is supported.""" + identity: pymodbus.device.ModbusDeviceIdentification + """The identity information for this Modbus server.""" + + @classmethod + def build(cls, model_summaries: typing.Sequence[ModelSummary]) -> "Server": + """Build the server instance based on the passed model summaries. Any + per-point or bulk data update must be done separately. + + Arguments: + model_summaries: The models which you want the server to provide. + + Returns: + The instance of the server datastore pieces. + """ + address = base_address + len(ssst.sunspec.base_address_sentinel) // 2 + sunspec_device = sunspec2.modbus.client.SunSpecModbusClientDevice() + sunspec_device.base_addr = base_address + + for model_summary in model_summaries: + model = sunspec2.modbus.client.SunSpecModbusClientModel( + model_id=model_summary.id, + model_addr=address, + model_len=model_summary.length, + mb_device=sunspec_device, + ) + address += 2 + model_summary.length + sunspec_device.add_model(model) + + slave_context = SunSpecModbusSlaveContext(sunspec_device=sunspec_device) + + return cls( + slave_context=slave_context, + server_context=pymodbus.datastore.ModbusServerContext( + slaves=slave_context, + single=True, + ), + identity=pymodbus.device.ModbusDeviceIdentification(), + ) + + def __getitem__( + self, item: typing.Union[int, str] + ) -> sunspec2.modbus.client.SunSpecModbusClientModel: + """SunSpec models are accessible by indexing the client using either the model + number or model name. + + .. code-block:: python + + model_1 = server[1] + model_common = server["common"] + assert model_1 is model_common + + Arguments: + item: The integer or string identifying the model. + Returns: + The requested model. + """ + [model] = self.slave_context.sunspec_device.models[item] + return model + + async def tcp_server(self, server_stream: trio.SocketStream) -> None: + """Handle serving over a stream. See :class:`Server` for an example. + + Arguments: + server_stream: The stream to communicate over. + """ + + await pymodbus.server.trio.tcp_server( + server_stream=server_stream, + context=self.server_context, + identity=self.identity, + ) + + +@attr.s(auto_attribs=True) +class PreparedRequest: + """Holds some common bits used in serving a request.""" + + data: bytearray + """The entire block of registers. Each register is a 2-byte chunk stored in + big-endian byte order. The first element is the high byte of the server's register + located at the base address. + """ + slice: slice + """The slice covering the bytes of the registers to be operated on.""" + offset_address: int + """The offset in 16-bit/2-byte registers relative to the server's base address.""" + bytes_offset_address: int + """The offset in bytes relative to the server's base address.""" + + @classmethod + def build( + cls, base_address: int, requested_address: int, count: int, all_registers: bytes + ) -> "PreparedRequest": + """Build the instance based on the passed raw request information. + + Arguments: + base_address: The SunSpec base register address. + requested_address: The requested address. + count: The requested register count. + all_registers: The raw register data for all models. + + Returns: + The prepared request information. + """ + # This is super lazy, what with building _all_ data even if you only need a + # register or two. But, optimize when we need to. + data = bytearray(ssst.sunspec.base_address_sentinel) + data.extend(all_registers) + data.extend( + sunspec2.mb.SUNS_END_MODEL_ID.to_bytes( + length=2, byteorder="big", signed=False + ) + ) + + offset_address = requested_address - base_address + + return cls( + data=data, + slice=slice(2 * offset_address, 2 * (offset_address + count)), + offset_address=offset_address, + bytes_offset_address=2 * offset_address, + )