From 220cac9e65c87f2d9dfca09c1e7e0603ab00c006 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 12 Jan 2021 09:45:11 -0500 Subject: [PATCH 01/13] add preliminary sunspec server and SunS test --- setup.cfg | 3 + src/ssst/_tests/sunspec/__init__.py | 0 src/ssst/_tests/sunspec/test_server.py | 59 ++++++++++++++ src/ssst/sunspec/__init__.py | 0 src/ssst/sunspec/server.py | 108 +++++++++++++++++++++++++ 5 files changed, 170 insertions(+) create mode 100644 src/ssst/_tests/sunspec/__init__.py create mode 100644 src/ssst/_tests/sunspec/test_server.py create mode 100644 src/ssst/sunspec/__init__.py create mode 100644 src/ssst/sunspec/server.py diff --git a/setup.cfg b/setup.cfg index 0b46e3b..8d05b63 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/4123eb48a4e01bbdc19259bb51b32500adb6c0c0.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/_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_server.py b/src/ssst/_tests/sunspec/test_server.py new file mode 100644 index 0000000..d84b7bc --- /dev/null +++ b/src/ssst/_tests/sunspec/test_server.py @@ -0,0 +1,59 @@ +import functools + +import attr +import pymodbus.client.asynchronous.tcp +import pymodbus.client.asynchronous.schedulers +import pytest +import trio + +import ssst.sunspec.server + + +@attr.s(auto_attribs=True, frozen=True) +class SunSpecServerFixtureResult: + host: str + port: int + + +@pytest.fixture(name="sunspec_server") +async def sunspec_server_fixture(nursery): + model_summaries = [ + ssst.sunspec.server.ModelSummary(id=1, length=66), + ] + + server_callable = ssst.sunspec.server.create_server_callable( + model_summaries=model_summaries + ) + + result = SunSpecServerFixtureResult(host="127.0.0.1", port=5020) + + await nursery.start( + functools.partial( + trio.serve_tcp, + server_callable, + port=result.port, + host=result.host, + ), + ) + + yield result + + +@pytest.fixture(name="sunspec_client") +async def sunspec_client_fixture(sunspec_server): + client = pymodbus.client.asynchronous.tcp.AsyncModbusTCPClient( + scheduler=pymodbus.client.asynchronous.schedulers.TRIO, + host=sunspec_server.host, + port=sunspec_server.port, + ) + + async with client.manage_connection() as protocol: + yield protocol + + +async def test_server_SunS(sunspec_client): + response = await sunspec_client.read_holding_registers( + address=40_000, count=2, unit=0x01 + ) + + assert bytes(response.registers) == b"SunS" diff --git a/src/ssst/sunspec/__init__.py b/src/ssst/sunspec/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ssst/sunspec/server.py b/src/ssst/sunspec/server.py new file mode 100644 index 0000000..3da5270 --- /dev/null +++ b/src/ssst/sunspec/server.py @@ -0,0 +1,108 @@ +import functools + +import attr +import pymodbus.datastore +import pymodbus.device +import pymodbus.server.trio +import pymodbus.interfaces +import sunspec2.modbus.client + + +suns_marker = b"SunS" +base_address = 40_000 + + +@attr.s(auto_attribs=True) +class ModelSummary: + id: int + length: int + + +def create_server_callable(model_summaries): + address = len(suns_marker) + 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) + server_context = pymodbus.datastore.ModbusServerContext( + slaves=slave_context, single=True + ) + identity = pymodbus.device.ModbusDeviceIdentification() + + return functools.partial( + pymodbus.server.trio.tcp_server, + context=server_context, + identity=identity, + ) + + +@attr.s(auto_attribs=True) +class PreparedRequest: + data: bytearray + slice: slice + offset_address: int + bytes_offset_address: int + + @classmethod + def build( + cls, base_address: int, requested_address: int, count: int, all_registers: bytes + ) -> "PreparedRequest": # TODO: should this be a TypeVar? + # 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(suns_marker) + data.extend(all_registers) + + 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, + ) + + +@attr.s(auto_attribs=True) +class SunSpecModbusSlaveContext(pymodbus.interfaces.IModbusSlaveContext): + sunspec_device: sunspec2.modbus.client.SunSpecModbusClientDevice + """The valid range is exclusive of this address.""" + single: bool = attr.ib(default=True, init=False) + + def getValues(self, fx, address, count=1): + 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, address, values): + request = PreparedRequest.build( + base_address=self.sunspec_device.base_addr, + requested_address=address, + count=len(values), + all_registers=self.sunspec_device.get_mb(), + ) + data = bytearray(request.data) + data[request.slice] = values + self.sunspec_device.set_mb(data=data[len(suns_marker) :]) + + def validate(self, fx, address, count=1): + base_address = self.sunspec_device.base_addr + end_address = base_address + ( + (len(suns_marker) + len(self.sunspec_device.get_mb())) / 2 + ) + return ( + self.sunspec_device.base_addr <= address and address + count <= end_address + ) From 07afd1cd57ff87de982957501cd622b45155b3a0 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 12 Jan 2021 10:25:44 -0500 Subject: [PATCH 02/13] add sunspec server write id test --- src/ssst/_tests/sunspec/test_server.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/ssst/_tests/sunspec/test_server.py b/src/ssst/_tests/sunspec/test_server.py index d84b7bc..801f94b 100644 --- a/src/ssst/_tests/sunspec/test_server.py +++ b/src/ssst/_tests/sunspec/test_server.py @@ -57,3 +57,20 @@ async def test_server_SunS(sunspec_client): ) assert bytes(response.registers) == b"SunS" + + +async def test_server_set_device_address(sunspec_client): + register = 40_068 + length = 1 + new_id = 43928 + b = new_id.to_bytes(length=2 * length, byteorder="big") + + await sunspec_client.write_registers( + address=register, values=b, unit=0x01 + ) + + response = await sunspec_client.read_holding_registers( + address=register, count=length, unit=0x01 + ) + + assert int.from_bytes(bytes(response.registers), byteorder="big") == new_id From 9d9e9a7d424b74cb4645ee11c47904ad947947ce Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 12 Jan 2021 21:05:18 -0500 Subject: [PATCH 03/13] add sunspec client and more --- docs/source/exceptions.rst | 2 + src/ssst/__init__.py | 2 + src/ssst/_tests/conftest.py | 61 ++++++++++ src/ssst/_tests/sunspec/test_client.py | 68 +++++++++++ src/ssst/_tests/sunspec/test_server.py | 88 ++++---------- src/ssst/exceptions.py | 33 ++++++ src/ssst/sunspec/__init__.py | 1 + src/ssst/sunspec/client.py | 132 +++++++++++++++++++++ src/ssst/sunspec/server.py | 155 +++++++++++++++---------- 9 files changed, 416 insertions(+), 126 deletions(-) create mode 100644 src/ssst/_tests/sunspec/test_client.py create mode 100644 src/ssst/sunspec/client.py diff --git a/docs/source/exceptions.rst b/docs/source/exceptions.rst index 0d3e479..a49945f 100644 --- a/docs/source/exceptions.rst +++ b/docs/source/exceptions.rst @@ -2,7 +2,9 @@ Exceptions ========== .. autoclass:: ssst.SsstError +.. autoclass:: ssst.BaseAddressNotFoundError .. autoclass:: ssst.InternalError +.. autoclass:: ssst.InvalidBaseAddressError .. autoclass:: ssst.QtpyError .. autoclass:: ssst.ReuseError .. autoclass:: ssst.UnexpectedEmissionError diff --git a/src/ssst/__init__.py b/src/ssst/__init__.py index 235c5e4..db3ae74 100644 --- a/src/ssst/__init__.py +++ b/src/ssst/__init__.py @@ -3,7 +3,9 @@ from ssst._version import __version__ from ssst.exceptions import ( + BaseAddressNotFoundError, InternalError, + InvalidBaseAddressError, QtpyError, ReuseError, SsstError, diff --git a/src/ssst/_tests/conftest.py b/src/ssst/_tests/conftest.py index 4d6c0cd..9723c43 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,56 @@ 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): + 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): + client = ssst.sunspec.client.Client.build( + host=sunspec_server.host, + port=sunspec_server.port, + ) + + async with client.manage_connection(): + yield client + + +@pytest.fixture(name="sunspec_client") +async def sunspec_client_fixture(unscanned_sunspec_client): + await unscanned_sunspec_client.scan() + return unscanned_sunspec_client diff --git a/src/ssst/_tests/sunspec/test_client.py b/src/ssst/_tests/sunspec/test_client.py new file mode 100644 index 0000000..ed313cb --- /dev/null +++ b/src/ssst/_tests/sunspec/test_client.py @@ -0,0 +1,68 @@ +import ssst._tests.conftest +import ssst.sunspec.client +import ssst.sunspec.server + + +async def test_scan_adds_models(sunspec_client: ssst.sunspec.client.Client): + model_ids = [model.model_id for model in sunspec_client.sunspec_device.model_list] + + assert model_ids == [1, 17, 103, 126] + + +async def test_model_addresses(sunspec_client: ssst.sunspec.client.Client): + 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, +): + 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, +): + 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_with_scale_factor( + sunspec_server: ssst._tests.conftest.SunSpecServerFixtureResult, + sunspec_client: ssst.sunspec.client.Client, +): + server_point = sunspec_server.server[103].points["W"] + server_scale_factor_point = server_point.model.points[server_point.sf] + + scale_factor = 2 + scaled_watts = 473 + + server_scale_factor_point.set_mb( + data=server_scale_factor_point.info.to_data(scale_factor), + ) + server_point.set_mb( + data=server_point.info.to_data(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 diff --git a/src/ssst/_tests/sunspec/test_server.py b/src/ssst/_tests/sunspec/test_server.py index 801f94b..f06f72e 100644 --- a/src/ssst/_tests/sunspec/test_server.py +++ b/src/ssst/_tests/sunspec/test_server.py @@ -1,76 +1,32 @@ -import functools +import ssst._tests.conftest +import ssst.sunspec -import attr -import pymodbus.client.asynchronous.tcp -import pymodbus.client.asynchronous.schedulers -import pytest -import trio -import ssst.sunspec.server +async def test_base_address_marker( + sunspec_client: ssst.sunspec.client.Client, +): + register_bytes = await sunspec_client.read_registers(address=40_000, count=2) + assert register_bytes == ssst.sunspec.base_address_sentinel -@attr.s(auto_attribs=True, frozen=True) -class SunSpecServerFixtureResult: - host: str - port: int +async def test_addresses( + sunspec_server: ssst._tests.conftest.SunSpecServerFixtureResult, +): + point = sunspec_server.server[17].points["Bits"] + assert point.model.model_addr + point.offset == 40_078 -@pytest.fixture(name="sunspec_server") -async def sunspec_server_fixture(nursery): - model_summaries = [ - ssst.sunspec.server.ModelSummary(id=1, length=66), - ] - server_callable = ssst.sunspec.server.create_server_callable( - model_summaries=model_summaries - ) - - result = SunSpecServerFixtureResult(host="127.0.0.1", port=5020) - - await nursery.start( - functools.partial( - trio.serve_tcp, - server_callable, - port=result.port, - host=result.host, - ), - ) - - yield result - - -@pytest.fixture(name="sunspec_client") -async def sunspec_client_fixture(sunspec_server): - client = pymodbus.client.asynchronous.tcp.AsyncModbusTCPClient( - scheduler=pymodbus.client.asynchronous.schedulers.TRIO, - host=sunspec_server.host, - port=sunspec_server.port, - ) - - async with client.manage_connection() as protocol: - yield protocol - - -async def test_server_SunS(sunspec_client): - response = await sunspec_client.read_holding_registers( - address=40_000, count=2, unit=0x01 - ) - - assert bytes(response.registers) == b"SunS" - - -async def test_server_set_device_address(sunspec_client): - register = 40_068 - length = 1 +async def test_write_registers( + sunspec_server: ssst._tests.conftest.SunSpecServerFixtureResult, + sunspec_client: ssst.sunspec.client.Client, +): + model = sunspec_server.server[1] + point = model.points["DA"] + address = model.model_addr + point.offset new_id = 43928 - b = new_id.to_bytes(length=2 * length, byteorder="big") - - await sunspec_client.write_registers( - address=register, values=b, unit=0x01 - ) - response = await sunspec_client.read_holding_registers( - address=register, count=length, unit=0x01 - ) + bytes_to_write = point.info.to_data(new_id) + await sunspec_client.write_registers(address=address, values=bytes_to_write) - assert int.from_bytes(bytes(response.registers), byteorder="big") == new_id + assert point.get_mb() == bytes_to_write diff --git a/src/ssst/exceptions.py b/src/ssst/exceptions.py index ef60ab2..c20c3ce 100644 --- a/src/ssst/exceptions.py +++ b/src/ssst/exceptions.py @@ -12,6 +12,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 = ", ".join(str(address) for address in addresses) + super().__init__( + f"SunSpec sentinel {sentinel} not found while searching: {addresses}" + ) + + # 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 +35,23 @@ 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}: {bytes!r}" + ) + + # 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 index e69de29..c581527 100644 --- a/src/ssst/sunspec/__init__.py +++ 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..671f330 --- /dev/null +++ b/src/ssst/sunspec/client.py @@ -0,0 +1,132 @@ +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 ssst.sunspec + + +@attr.s(auto_attribs=True) +class Client: + modbus_client: pymodbus.client.asynchronous.trio.TrioModbusTcpClient + sunspec_device: sunspec2.modbus.client.SunSpecModbusClientDevice + protocol: typing.Optional[pymodbus.client.common.ModbusClientMixin] = None + + @classmethod + def build(cls, host, port): + modbus_client = pymodbus.client.asynchronous.tcp.AsyncModbusTCPClient( + scheduler=pymodbus.client.asynchronous.schedulers.TRIO, + host=host, + port=port, + ) + sunspec_device = sunspec2.modbus.client.SunSpecModbusClientDevice() + + return cls(modbus_client=modbus_client, sunspec_device=sunspec_device) + + @async_generator.asynccontextmanager + async def manage_connection(self): + try: + async with self.modbus_client.manage_connection() as self.protocol: + yield self + finally: + self.protocol = None + + def __getitem__(self, item): + [model] = self.sunspec_device.models[item] + return model + + async def scan(self): + 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() + 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) + + async def read_registers(self, address, count): + response = await self.protocol.read_holding_registers( + address=address, count=count, unit=0x01 + ) + + return bytes(response.registers) + + async def read_point(self, point: sunspec2.modbus.client.SunSpecModbusClientPoint): + 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) + return point.value + + def point_address(self, point: sunspec2.modbus.client.SunSpecModbusClientPoint): + return point.model.model_addr + point.offset + + async def write_registers(self, address, values): + await self.protocol.write_registers(address=address, values=values, unit=0x01) diff --git a/src/ssst/sunspec/server.py b/src/ssst/sunspec/server.py index 3da5270..861dd3f 100644 --- a/src/ssst/sunspec/server.py +++ b/src/ssst/sunspec/server.py @@ -1,14 +1,17 @@ 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 ssst.sunspec + -suns_marker = b"SunS" base_address = 40_000 @@ -18,60 +21,6 @@ class ModelSummary: length: int -def create_server_callable(model_summaries): - address = len(suns_marker) - 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) - server_context = pymodbus.datastore.ModbusServerContext( - slaves=slave_context, single=True - ) - identity = pymodbus.device.ModbusDeviceIdentification() - - return functools.partial( - pymodbus.server.trio.tcp_server, - context=server_context, - identity=identity, - ) - - -@attr.s(auto_attribs=True) -class PreparedRequest: - data: bytearray - slice: slice - offset_address: int - bytes_offset_address: int - - @classmethod - def build( - cls, base_address: int, requested_address: int, count: int, all_registers: bytes - ) -> "PreparedRequest": # TODO: should this be a TypeVar? - # 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(suns_marker) - data.extend(all_registers) - - 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, - ) - - @attr.s(auto_attribs=True) class SunSpecModbusSlaveContext(pymodbus.interfaces.IModbusSlaveContext): sunspec_device: sunspec2.modbus.client.SunSpecModbusClientDevice @@ -96,13 +45,99 @@ def setValues(self, fx, address, values): ) data = bytearray(request.data) data[request.slice] = values - self.sunspec_device.set_mb(data=data[len(suns_marker) :]) + self.sunspec_device.set_mb(data=data[len(ssst.sunspec.base_address_sentinel) :]) def validate(self, fx, address, count=1): - base_address = self.sunspec_device.base_addr - end_address = base_address + ( - (len(suns_marker) + len(self.sunspec_device.get_mb())) / 2 + return ( + self.sunspec_device.base_addr <= address + and address + count <= self.end_address() ) + + def end_address(self): return ( - self.sunspec_device.base_addr <= address and address + count <= end_address + base_address + + ( + ( + len(ssst.sunspec.base_address_sentinel) + + len(self.sunspec_device.get_mb()) + ) + / 2 + ) + + 2 + ) + + +@attr.s(auto_attribs=True) +class Server: + slave_context: SunSpecModbusSlaveContext + server_context: pymodbus.datastore.ModbusServerContext + identity: pymodbus.device.ModbusDeviceIdentification + + @classmethod + def build(cls, model_summaries: typing.Sequence[ModelSummary]): + 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): + [model] = self.slave_context.sunspec_device.models[item] + return model + + async def tcp_server(self, server_stream): + return await pymodbus.server.trio.tcp_server( + server_stream=server_stream, + context=self.server_context, + identity=self.identity, + ) + + +@attr.s(auto_attribs=True) +class PreparedRequest: + data: bytearray + slice: slice + offset_address: int + bytes_offset_address: int + + @classmethod + def build( + cls, base_address: int, requested_address: int, count: int, all_registers: bytes + ) -> "PreparedRequest": # TODO: should this be a TypeVar? + # 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, ) From cd75a930f15ddcaf24df61712051e187ef740d3c Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 12 Jan 2021 21:19:50 -0500 Subject: [PATCH 04/13] update pymodbus --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 8d05b63..07cdf9e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,7 +17,7 @@ install_requires = async_generator ~=1.10 attrs ~=20.3.0 click ~=7.1 - pymodbus @ https://github.com/altendky/pymodbus/archive/4123eb48a4e01bbdc19259bb51b32500adb6c0c0.zip + 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. From e223e9bec716b8355eb15c4926fb8890bb66095c Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 12 Jan 2021 21:41:44 -0500 Subject: [PATCH 05/13] black --- src/ssst/_tests/sunspec/test_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ssst/_tests/sunspec/test_client.py b/src/ssst/_tests/sunspec/test_client.py index ed313cb..afa2ad1 100644 --- a/src/ssst/_tests/sunspec/test_client.py +++ b/src/ssst/_tests/sunspec/test_client.py @@ -16,7 +16,7 @@ async def test_model_addresses(sunspec_client: ssst.sunspec.client.Client): async def test_point_address( - sunspec_client: ssst.sunspec.client.Client, + sunspec_client: ssst.sunspec.client.Client, ): point = sunspec_client[17].points["Bits"] assert point.model.model_addr + point.offset == 40_078 From e3f33dc3b143cc08a4cf569d4a2e947fc2a87cd6 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 12 Jan 2021 22:03:52 -0500 Subject: [PATCH 06/13] coverage --- src/ssst/_tests/sunspec/test_client.py | 24 ++++++++++++++++++++++++ src/ssst/exceptions.py | 2 +- src/ssst/sunspec/client.py | 4 +++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/ssst/_tests/sunspec/test_client.py b/src/ssst/_tests/sunspec/test_client.py index afa2ad1..60ee7c3 100644 --- a/src/ssst/_tests/sunspec/test_client.py +++ b/src/ssst/_tests/sunspec/test_client.py @@ -1,3 +1,7 @@ +import re + +import pytest + import ssst._tests.conftest import ssst.sunspec.client import ssst.sunspec.server @@ -9,6 +13,26 @@ async def test_scan_adds_models(sunspec_client: ssst.sunspec.client.Client): assert model_ids == [1, 17, 103, 126] +async def test_scan_raises_for_missing_sentinel_when_searching( + unscanned_sunspec_client: ssst.sunspec.client.Client, +): + 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, +): + 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): model_ids = [model.model_addr for model in sunspec_client.sunspec_device.model_list] diff --git a/src/ssst/exceptions.py b/src/ssst/exceptions.py index c20c3ce..25e0d67 100644 --- a/src/ssst/exceptions.py +++ b/src/ssst/exceptions.py @@ -45,7 +45,7 @@ def __init__(self, address: int, value: bytes) -> None: sentinel = repr(ssst.sunspec.base_address_sentinel) super().__init__( - f"SunSpec sentinel {sentinel} not found at {address}: {bytes!r}" + f"SunSpec sentinel {sentinel} not found at {address}: {value!r}" ) # https://github.com/sphinx-doc/sphinx/issues/7493 diff --git a/src/ssst/sunspec/client.py b/src/ssst/sunspec/client.py index 671f330..d0e0db0 100644 --- a/src/ssst/sunspec/client.py +++ b/src/ssst/sunspec/client.py @@ -52,7 +52,9 @@ async def scan(self): self.sunspec_device.base_addr = maybe_base_address break else: - raise ssst.BaseAddressNotFoundError() + raise ssst.BaseAddressNotFoundError( + addresses=self.sunspec_device.base_addr_list + ) else: read_bytes = await self.read_registers( address=self.sunspec_device.base_addr, From fecfd8694be20f40e7858fd81b42b259b8285273 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 13 Jan 2021 15:33:56 -0500 Subject: [PATCH 07/13] add write_point, and lot's more --- docs/source/exceptions.rst | 2 + mypy.ini | 6 +++ pytest.ini | 2 + src/ssst/__init__.py | 4 +- src/ssst/_tests/conftest.py | 12 +++-- src/ssst/_tests/sunspec/test_client.py | 75 ++++++++++++++++++++------ src/ssst/_tests/sunspec/test_server.py | 26 +++++++-- src/ssst/exceptions.py | 32 ++++++++++- src/ssst/sunspec/client.py | 54 +++++++++++++++---- src/ssst/sunspec/server.py | 21 ++++---- 10 files changed, 190 insertions(+), 44 deletions(-) diff --git a/docs/source/exceptions.rst b/docs/source/exceptions.rst index a49945f..e0e179e 100644 --- a/docs/source/exceptions.rst +++ b/docs/source/exceptions.rst @@ -5,6 +5,8 @@ Exceptions .. autoclass:: ssst.BaseAddressNotFoundError .. autoclass:: ssst.InternalError .. autoclass:: ssst.InvalidBaseAddressError +.. autoclass:: ssst.InvalidActionError +.. autoclass:: ssst.ModbusError .. autoclass:: ssst.QtpyError .. autoclass:: ssst.ReuseError .. autoclass:: ssst.UnexpectedEmissionError 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/src/ssst/__init__.py b/src/ssst/__init__.py index db3ae74..9d3dff7 100644 --- a/src/ssst/__init__.py +++ b/src/ssst/__init__.py @@ -3,11 +3,13 @@ from ssst._version import __version__ from ssst.exceptions import ( + SsstError, BaseAddressNotFoundError, InternalError, InvalidBaseAddressError, + InvalidActionError, + ModbusError, QtpyError, ReuseError, - SsstError, UnexpectedEmissionError, ) diff --git a/src/ssst/_tests/conftest.py b/src/ssst/_tests/conftest.py index 9723c43..3d9c55f 100644 --- a/src/ssst/_tests/conftest.py +++ b/src/ssst/_tests/conftest.py @@ -33,7 +33,9 @@ class SunSpecServerFixtureResult: @pytest.fixture(name="sunspec_server") -async def sunspec_server_fixture(nursery): +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), @@ -62,7 +64,9 @@ async def sunspec_server_fixture(nursery): @pytest.fixture(name="unscanned_sunspec_client") -async def unscanned_sunspec_client_fixture(sunspec_server): +async def unscanned_sunspec_client_fixture( + sunspec_server: ssst._tests.conftest.SunSpecServerFixtureResult, +) -> typing.AsyncIterator[ssst.sunspec.client.Client]: client = ssst.sunspec.client.Client.build( host=sunspec_server.host, port=sunspec_server.port, @@ -73,6 +77,8 @@ async def unscanned_sunspec_client_fixture(sunspec_server): @pytest.fixture(name="sunspec_client") -async def sunspec_client_fixture(unscanned_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/test_client.py b/src/ssst/_tests/sunspec/test_client.py index 60ee7c3..4a569bd 100644 --- a/src/ssst/_tests/sunspec/test_client.py +++ b/src/ssst/_tests/sunspec/test_client.py @@ -7,7 +7,7 @@ import ssst.sunspec.server -async def test_scan_adds_models(sunspec_client: ssst.sunspec.client.Client): +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] @@ -15,7 +15,7 @@ async def test_scan_adds_models(sunspec_client: ssst.sunspec.client.Client): 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" @@ -25,7 +25,7 @@ async def test_scan_raises_for_missing_sentinel_when_searching( 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'" @@ -33,7 +33,7 @@ async def test_scan_raises_for_missing_sentinel_when_address_specified( await unscanned_sunspec_client.scan() -async def test_model_addresses(sunspec_client: ssst.sunspec.client.Client): +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] @@ -41,7 +41,7 @@ async def test_model_addresses(sunspec_client: ssst.sunspec.client.Client): 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 @@ -49,7 +49,7 @@ async def test_point_address( 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 @@ -65,22 +65,34 @@ async def test_read_point_by_registers( 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 = 473 + scale_factor = -2 + scaled_watts = 273 - server_scale_factor_point.set_mb( - data=server_scale_factor_point.info.to_data(scale_factor), - ) - server_point.set_mb( - data=server_point.info.to_data(scaled_watts), - ) + 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] @@ -90,3 +102,36 @@ async def test_read_point_with_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 diff --git a/src/ssst/_tests/sunspec/test_server.py b/src/ssst/_tests/sunspec/test_server.py index f06f72e..93e0a3f 100644 --- a/src/ssst/_tests/sunspec/test_server.py +++ b/src/ssst/_tests/sunspec/test_server.py @@ -1,10 +1,11 @@ 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 @@ -12,7 +13,7 @@ async def test_base_address_marker( 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 @@ -20,7 +21,7 @@ async def test_addresses( 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 @@ -30,3 +31,22 @@ async def test_write_registers( 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 25e0d67..863dee3 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 @@ -19,9 +20,9 @@ def __init__(self, addresses: typing.Sequence[int]) -> None: import ssst.sunspec sentinel = repr(ssst.sunspec.base_address_sentinel) - addresses = ", ".join(str(address) for address in addresses) + addresses_string = ", ".join(str(address) for address in addresses) super().__init__( - f"SunSpec sentinel {sentinel} not found while searching: {addresses}" + f"SunSpec sentinel {sentinel} not found while searching: {addresses_string}" ) # https://github.com/sphinx-doc/sphinx/issues/7493 @@ -52,6 +53,33 @@ def __init__(self, address: int, value: bytes) -> None: __module__ = "ssst" +class InvalidActionError(Exception): + """Raised when an object is in a state where the requested action is invalid.""" + + # 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/client.py b/src/ssst/sunspec/client.py index d0e0db0..a59a95a 100644 --- a/src/ssst/sunspec/client.py +++ b/src/ssst/sunspec/client.py @@ -8,6 +8,7 @@ import pymodbus.client.common import sunspec2.mb import sunspec2.modbus.client +import pymodbus.pdu import ssst.sunspec @@ -19,7 +20,7 @@ class Client: protocol: typing.Optional[pymodbus.client.common.ModbusClientMixin] = None @classmethod - def build(cls, host, port): + def build(cls, host: str, port: int) -> "Client": modbus_client = pymodbus.client.asynchronous.tcp.AsyncModbusTCPClient( scheduler=pymodbus.client.asynchronous.schedulers.TRIO, host=host, @@ -30,18 +31,20 @@ def build(cls, host, port): return cls(modbus_client=modbus_client, sunspec_device=sunspec_device) @async_generator.asynccontextmanager - async def manage_connection(self): + async def manage_connection(self) -> typing.AsyncIterator["Client"]: try: async with self.modbus_client.manage_connection() as self.protocol: yield self finally: self.protocol = None - def __getitem__(self, item): + def __getitem__( + self, item: typing.Union[int, str] + ) -> sunspec2.modbus.client.SunSpecModbusClientModel: [model] = self.sunspec_device.models[item] return model - async def scan(self): + async def scan(self) -> None: 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( @@ -109,14 +112,22 @@ async def scan(self): ) self.sunspec_device.add_model(model) - async def read_registers(self, address, count): + async def read_registers(self, address: int, count: int) -> bytes: + if self.protocol is None: + raise ssst.InvalidActionError("Cannot read without a managed connection.") + 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): + async def read_point( + self, point: sunspec2.modbus.client.SunSpecModbusClientPoint + ) -> typing.Union[float, int]: if point.sf is not None: await self.read_point(point=point.model.points[point.sf]) @@ -125,10 +136,31 @@ async def read_point(self, point: sunspec2.modbus.client.SunSpecModbusClientPoin count=point.len, ) point.set_mb(data=read_bytes) - return point.value + return point.cvalue # type: ignore[no-any-return] + + def point_address( + self, point: sunspec2.modbus.client.SunSpecModbusClientPoint + ) -> int: + return point.model.model_addr + point.offset # type: ignore[no-any-return] + + async def write_registers(self, address: int, values: bytes) -> None: + if self.protocol is None: + raise ssst.InvalidActionError("Cannot write without a managed connection.") + + response = await self.protocol.write_registers( + address=address, values=values, unit=0x01 + ) - def point_address(self, point: sunspec2.modbus.client.SunSpecModbusClientPoint): - return point.model.model_addr + point.offset + if isinstance(response, pymodbus.pdu.ExceptionResponse): + raise ssst.ModbusError(exception=response) - async def write_registers(self, address, values): - await self.protocol.write_registers(address=address, values=values, unit=0x01) + async def write_point( + self, point: sunspec2.modbus.client.SunSpecModbusClientPoint + ) -> None: + 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 index 861dd3f..b08862c 100644 --- a/src/ssst/sunspec/server.py +++ b/src/ssst/sunspec/server.py @@ -8,6 +8,7 @@ import pymodbus.interfaces import sunspec2.mb import sunspec2.modbus.client +import trio import ssst.sunspec @@ -27,7 +28,7 @@ class SunSpecModbusSlaveContext(pymodbus.interfaces.IModbusSlaveContext): """The valid range is exclusive of this address.""" single: bool = attr.ib(default=True, init=False) - def getValues(self, fx, address, count=1): + def getValues(self, fx: int, address: int, count: int = 1) -> bytearray: request = PreparedRequest.build( base_address=self.sunspec_device.base_addr, requested_address=address, @@ -36,7 +37,7 @@ def getValues(self, fx, address, count=1): ) return request.data[request.slice] - def setValues(self, fx, address, values): + def setValues(self, fx: int, address: int, values: bytes) -> None: request = PreparedRequest.build( base_address=self.sunspec_device.base_addr, requested_address=address, @@ -47,13 +48,13 @@ def setValues(self, fx, address, values): data[request.slice] = values self.sunspec_device.set_mb(data=data[len(ssst.sunspec.base_address_sentinel) :]) - def validate(self, fx, address, count=1): + def validate(self, fx: int, address: int, count: int = 1) -> bool: return ( self.sunspec_device.base_addr <= address and address + count <= self.end_address() ) - def end_address(self): + def end_address(self) -> int: return ( base_address + ( @@ -61,7 +62,7 @@ def end_address(self): len(ssst.sunspec.base_address_sentinel) + len(self.sunspec_device.get_mb()) ) - / 2 + // 2 ) + 2 ) @@ -74,7 +75,7 @@ class Server: identity: pymodbus.device.ModbusDeviceIdentification @classmethod - def build(cls, model_summaries: typing.Sequence[ModelSummary]): + def build(cls, model_summaries: typing.Sequence[ModelSummary]) -> "Server": address = base_address + len(ssst.sunspec.base_address_sentinel) // 2 sunspec_device = sunspec2.modbus.client.SunSpecModbusClientDevice() sunspec_device.base_addr = base_address @@ -100,12 +101,14 @@ def build(cls, model_summaries: typing.Sequence[ModelSummary]): identity=pymodbus.device.ModbusDeviceIdentification(), ) - def __getitem__(self, item): + def __getitem__( + self, item: typing.Union[int, str] + ) -> sunspec2.modbus.client.SunSpecModbusClientModel: [model] = self.slave_context.sunspec_device.models[item] return model - async def tcp_server(self, server_stream): - return await pymodbus.server.trio.tcp_server( + async def tcp_server(self, server_stream: trio.SocketStream) -> None: + await pymodbus.server.trio.tcp_server( server_stream=server_stream, context=self.server_context, identity=self.identity, From 0590e370c083ce436f6f74a0d03d6b16ea166324 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 13 Jan 2021 15:40:00 -0500 Subject: [PATCH 08/13] oops --- src/ssst/_tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ssst/_tests/conftest.py b/src/ssst/_tests/conftest.py index 3d9c55f..1f36ad7 100644 --- a/src/ssst/_tests/conftest.py +++ b/src/ssst/_tests/conftest.py @@ -65,7 +65,7 @@ async def sunspec_server_fixture( @pytest.fixture(name="unscanned_sunspec_client") async def unscanned_sunspec_client_fixture( - sunspec_server: ssst._tests.conftest.SunSpecServerFixtureResult, + sunspec_server: SunSpecServerFixtureResult, ) -> typing.AsyncIterator[ssst.sunspec.client.Client]: client = ssst.sunspec.client.Client.build( host=sunspec_server.host, From 4e3642a1d460cc9f1986909842d8ea10571a4b2c Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 13 Jan 2021 15:59:21 -0500 Subject: [PATCH 09/13] simplify client creation, protocol is non-optional --- src/ssst/__init__.py | 1 - src/ssst/_tests/conftest.py | 6 ++---- src/ssst/exceptions.py | 12 +++++------ src/ssst/sunspec/client.py | 40 +++++++++++++------------------------ 4 files changed, 21 insertions(+), 38 deletions(-) diff --git a/src/ssst/__init__.py b/src/ssst/__init__.py index 9d3dff7..259044c 100644 --- a/src/ssst/__init__.py +++ b/src/ssst/__init__.py @@ -7,7 +7,6 @@ BaseAddressNotFoundError, InternalError, InvalidBaseAddressError, - InvalidActionError, ModbusError, QtpyError, ReuseError, diff --git a/src/ssst/_tests/conftest.py b/src/ssst/_tests/conftest.py index 1f36ad7..e1fd4e4 100644 --- a/src/ssst/_tests/conftest.py +++ b/src/ssst/_tests/conftest.py @@ -67,12 +67,10 @@ async def sunspec_server_fixture( async def unscanned_sunspec_client_fixture( sunspec_server: SunSpecServerFixtureResult, ) -> typing.AsyncIterator[ssst.sunspec.client.Client]: - client = ssst.sunspec.client.Client.build( + async with ssst.sunspec.client.open_client( host=sunspec_server.host, port=sunspec_server.port, - ) - - async with client.manage_connection(): + ) as client: yield client diff --git a/src/ssst/exceptions.py b/src/ssst/exceptions.py index 863dee3..b2f03ad 100644 --- a/src/ssst/exceptions.py +++ b/src/ssst/exceptions.py @@ -51,13 +51,11 @@ def __init__(self, address: int, value: bytes) -> None: # https://github.com/sphinx-doc/sphinx/issues/7493 __module__ = "ssst" - - -class InvalidActionError(Exception): - """Raised when an object is in a state where the requested action is invalid.""" - - # https://github.com/sphinx-doc/sphinx/issues/7493 - __module__ = "ssst" +# class InvalidActionError(Exception): +# """Raised when an object is in a state where the requested action is invalid.""" +# +# # https://github.com/sphinx-doc/sphinx/issues/7493 +# __module__ = "ssst" class ModbusError(SsstError): diff --git a/src/ssst/sunspec/client.py b/src/ssst/sunspec/client.py index a59a95a..aff477a 100644 --- a/src/ssst/sunspec/client.py +++ b/src/ssst/sunspec/client.py @@ -13,30 +13,24 @@ import ssst.sunspec +@async_generator.asynccontextmanager +async def open_client(host: str, port: int) -> typing.AsyncIterator["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: modbus_client: pymodbus.client.asynchronous.trio.TrioModbusTcpClient sunspec_device: sunspec2.modbus.client.SunSpecModbusClientDevice - protocol: typing.Optional[pymodbus.client.common.ModbusClientMixin] = None - - @classmethod - def build(cls, host: str, port: int) -> "Client": - modbus_client = pymodbus.client.asynchronous.tcp.AsyncModbusTCPClient( - scheduler=pymodbus.client.asynchronous.schedulers.TRIO, - host=host, - port=port, - ) - sunspec_device = sunspec2.modbus.client.SunSpecModbusClientDevice() - - return cls(modbus_client=modbus_client, sunspec_device=sunspec_device) - - @async_generator.asynccontextmanager - async def manage_connection(self) -> typing.AsyncIterator["Client"]: - try: - async with self.modbus_client.manage_connection() as self.protocol: - yield self - finally: - self.protocol = None + protocol: pymodbus.client.common.ModbusClientMixin def __getitem__( self, item: typing.Union[int, str] @@ -113,9 +107,6 @@ async def scan(self) -> None: self.sunspec_device.add_model(model) async def read_registers(self, address: int, count: int) -> bytes: - if self.protocol is None: - raise ssst.InvalidActionError("Cannot read without a managed connection.") - response = await self.protocol.read_holding_registers( address=address, count=count, unit=0x01 ) @@ -144,9 +135,6 @@ def point_address( return point.model.model_addr + point.offset # type: ignore[no-any-return] async def write_registers(self, address: int, values: bytes) -> None: - if self.protocol is None: - raise ssst.InvalidActionError("Cannot write without a managed connection.") - response = await self.protocol.write_registers( address=address, values=values, unit=0x01 ) From 57a4cba113c244eb77206c371ec139ee75618100 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 13 Jan 2021 17:04:43 -0500 Subject: [PATCH 10/13] still needs some more documentation --- docs/source/conf.py | 1 + docs/source/exceptions.rst | 1 - docs/source/index.rst | 1 + docs/source/sunspec.rst | 15 ++++++ src/ssst/exceptions.py | 5 -- src/ssst/sunspec/client.py | 104 ++++++++++++++++++++++++++++++++++++- src/ssst/sunspec/server.py | 11 ++-- 7 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 docs/source/sunspec.rst 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 e0e179e..a10f42f 100644 --- a/docs/source/exceptions.rst +++ b/docs/source/exceptions.rst @@ -5,7 +5,6 @@ Exceptions .. autoclass:: ssst.BaseAddressNotFoundError .. autoclass:: ssst.InternalError .. autoclass:: ssst.InvalidBaseAddressError -.. autoclass:: ssst.InvalidActionError .. autoclass:: ssst.ModbusError .. autoclass:: ssst.QtpyError .. autoclass:: ssst.ReuseError 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..8577180 --- /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/src/ssst/exceptions.py b/src/ssst/exceptions.py index b2f03ad..8825c0d 100644 --- a/src/ssst/exceptions.py +++ b/src/ssst/exceptions.py @@ -51,11 +51,6 @@ def __init__(self, address: int, value: bytes) -> None: # https://github.com/sphinx-doc/sphinx/issues/7493 __module__ = "ssst" -# class InvalidActionError(Exception): -# """Raised when an object is in a state where the requested action is invalid.""" -# -# # https://github.com/sphinx-doc/sphinx/issues/7493 -# __module__ = "ssst" class ModbusError(SsstError): diff --git a/src/ssst/sunspec/client.py b/src/ssst/sunspec/client.py index aff477a..2115925 100644 --- a/src/ssst/sunspec/client.py +++ b/src/ssst/sunspec/client.py @@ -15,6 +15,16 @@ @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, @@ -23,22 +33,53 @@ async def open_client(host: str, port: int) -> typing.AsyncIterator["Client"]: 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) + 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 - sunspec_device: sunspec2.modbus.client.SunSpecModbusClientDevice + """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( @@ -106,7 +147,24 @@ async def scan(self) -> None: ) 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 ) @@ -119,6 +177,17 @@ async def read_registers(self, address: int, count: int) -> bytes: 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]) @@ -132,9 +201,32 @@ async def read_point( 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 ) @@ -145,6 +237,14 @@ async def write_registers(self, address: int, values: bytes) -> None: 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]) diff --git a/src/ssst/sunspec/server.py b/src/ssst/sunspec/server.py index b08862c..b59ac27 100644 --- a/src/ssst/sunspec/server.py +++ b/src/ssst/sunspec/server.py @@ -26,9 +26,9 @@ class ModelSummary: class SunSpecModbusSlaveContext(pymodbus.interfaces.IModbusSlaveContext): sunspec_device: sunspec2.modbus.client.SunSpecModbusClientDevice """The valid range is exclusive of this address.""" - single: bool = attr.ib(default=True, init=False) def getValues(self, fx: int, address: int, count: int = 1) -> bytearray: + """See :meth:`pymodbus.interface.IModbusSlaveContext.getValues`.""" request = PreparedRequest.build( base_address=self.sunspec_device.base_addr, requested_address=address, @@ -38,6 +38,7 @@ def getValues(self, fx: int, address: int, count: int = 1) -> bytearray: return request.data[request.slice] def setValues(self, fx: int, address: int, values: bytes) -> None: + """See :meth:`pymodbus.interface.IModbusSlaveContext.setValues`.""" request = PreparedRequest.build( base_address=self.sunspec_device.base_addr, requested_address=address, @@ -49,12 +50,16 @@ def setValues(self, fx: int, address: int, values: bytes) -> None: 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.interface.IModbusSlaveContext.validate`.""" return ( self.sunspec_device.base_addr <= address - and address + count <= self.end_address() + and address + count <= self._end_address() ) - def end_address(self) -> int: + def _end_address(self) -> int: + """Calculate the exclusive last address. This is the first address which + cannot be read. + """ return ( base_address + ( From cd409d06e2c6886279dadf4a2cfa911877efb443 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 13 Jan 2021 19:11:15 -0500 Subject: [PATCH 11/13] more docs --- docs/source/sunspec.rst | 6 +-- src/ssst/sunspec/server.py | 91 +++++++++++++++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/docs/source/sunspec.rst b/docs/source/sunspec.rst index 8577180..5759ea5 100644 --- a/docs/source/sunspec.rst +++ b/docs/source/sunspec.rst @@ -10,6 +10,6 @@ Client Server ------ .. autoclass:: ssst.sunspec.server.Server -.. autoclass:: ssst..sunspec.server.ModelSummary -.. autoclass:: ssst..sunspec.server.SunSpecModbusSlaveContext -.. autoclass:: ssst..sunspec.server.PreparedRequest +.. autoclass:: ssst.sunspec.server.ModelSummary +.. autoclass:: ssst.sunspec.server.SunSpecModbusSlaveContext +.. autoclass:: ssst.sunspec.server.PreparedRequest diff --git a/src/ssst/sunspec/server.py b/src/ssst/sunspec/server.py index b59ac27..dfb2aeb 100644 --- a/src/ssst/sunspec/server.py +++ b/src/ssst/sunspec/server.py @@ -18,17 +18,25 @@ @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 valid range is exclusive of this address.""" + """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.interface.IModbusSlaveContext.getValues`.""" + """See :meth:`pymodbus.interfaces.IModbusSlaveContext.getValues`.""" request = PreparedRequest.build( base_address=self.sunspec_device.base_addr, requested_address=address, @@ -38,7 +46,7 @@ def getValues(self, fx: int, address: int, count: int = 1) -> bytearray: return request.data[request.slice] def setValues(self, fx: int, address: int, values: bytes) -> None: - """See :meth:`pymodbus.interface.IModbusSlaveContext.setValues`.""" + """See :meth:`pymodbus.interfaces.IModbusSlaveContext.setValues`.""" request = PreparedRequest.build( base_address=self.sunspec_device.base_addr, requested_address=address, @@ -50,7 +58,7 @@ def setValues(self, fx: int, address: int, values: bytes) -> None: 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.interface.IModbusSlaveContext.validate`.""" + """See :meth:`pymodbus.interfaces.IModbusSlaveContext.validate`.""" return ( self.sunspec_device.base_addr <= address and address + count <= self._end_address() @@ -75,12 +83,45 @@ def _end_address(self) -> int: @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 @@ -109,10 +150,30 @@ def build(cls, model_summaries: typing.Sequence[ModelSummary]) -> "Server": 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, @@ -122,15 +183,35 @@ async def tcp_server(self, server_stream: trio.SocketStream) -> None: @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": # TODO: should this be a TypeVar? + ) -> "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) From 51859aebeb5c60bc5ee54835118cafb65fb209d2 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 13 Jan 2021 20:11:11 -0500 Subject: [PATCH 12/13] black --- src/ssst/sunspec/server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ssst/sunspec/server.py b/src/ssst/sunspec/server.py index dfb2aeb..535c6d7 100644 --- a/src/ssst/sunspec/server.py +++ b/src/ssst/sunspec/server.py @@ -21,6 +21,7 @@ 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 @@ -32,6 +33,7 @@ class ModelSummary: 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.""" From 5c24bcfde9cd01fcdeb29e5b03e24f9f403d4e86 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 13 Jan 2021 22:33:11 -0500 Subject: [PATCH 13/13] coverage and bug fixes --- src/ssst/_tests/sunspec/test_client.py | 43 ++++++++++++++++++++++++++ src/ssst/sunspec/client.py | 12 ++++++- src/ssst/sunspec/server.py | 2 +- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/ssst/_tests/sunspec/test_client.py b/src/ssst/_tests/sunspec/test_client.py index 4a569bd..f66449a 100644 --- a/src/ssst/_tests/sunspec/test_client.py +++ b/src/ssst/_tests/sunspec/test_client.py @@ -135,3 +135,46 @@ async def test_write_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/sunspec/client.py b/src/ssst/sunspec/client.py index 2115925..c6fd9b2 100644 --- a/src/ssst/sunspec/client.py +++ b/src/ssst/sunspec/client.py @@ -196,6 +196,15 @@ async def read_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( @@ -250,5 +259,6 @@ async def write_point( bytes_to_write = point.get_mb() await self.write_registers( - address=self.point_address(point=point), values=bytes_to_write + address=self.point_address(point=point), + values=bytes_to_write, ) diff --git a/src/ssst/sunspec/server.py b/src/ssst/sunspec/server.py index 535c6d7..c419648 100644 --- a/src/ssst/sunspec/server.py +++ b/src/ssst/sunspec/server.py @@ -52,7 +52,7 @@ def setValues(self, fx: int, address: int, values: bytes) -> None: request = PreparedRequest.build( base_address=self.sunspec_device.base_addr, requested_address=address, - count=len(values), + count=len(values) // 2, all_registers=self.sunspec_device.get_mb(), ) data = bytearray(request.data)