Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add preliminary sunspec server and SunS test #19

Merged
merged 13 commits into from
Jan 14, 2021
1 change: 1 addition & 0 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
3 changes: 3 additions & 0 deletions docs/source/exceptions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
:maxdepth: 2

main.rst
sunspec.rst
exceptions.rst
cli.rst
history.rst
Expand Down
15 changes: 15 additions & 0 deletions docs/source/sunspec.rst
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
[pytest]
trio_mode = true
trio_run = qtrio
log_cli = True
log_cli_level = debug
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/ssst/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
from ssst._version import __version__

from ssst.exceptions import (
SsstError,
BaseAddressNotFoundError,
InternalError,
InvalidBaseAddressError,
ModbusError,
QtpyError,
ReuseError,
SsstError,
UnexpectedEmissionError,
)
65 changes: 65 additions & 0 deletions src/ssst/_tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Empty file.
180 changes: 180 additions & 0 deletions src/ssst/_tests/sunspec/test_client.py
Original file line number Diff line number Diff line change
@@ -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":]")
52 changes: 52 additions & 0 deletions src/ssst/_tests/sunspec/test_server.py
Original file line number Diff line number Diff line change
@@ -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
Loading