From c5edae835446a78f68fc7f1a99d8ebb4830f561f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:21:08 +0000 Subject: [PATCH 1/5] Initial plan From e8502928879261929596073ce6ec4c599f613a60 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:31:40 +0000 Subject: [PATCH 2/5] Upgrade PyModbus to 3.16.1 with API migration - Updated Python requirement from >=3.8.2 to >=3.10 - Upgraded PyModbus from 3.6.9 to 3.16.1 - Created compat module for BinaryPayloadBuilder (removed in PyModbus) - Migrated imports: Endian, framer classes to new locations - Updated datastore initialization to use ModbusDeviceContext - Changed framer usage from classes to FramerType enums - Fixed TLS server to use SSL context instead of certfile/keyfile - Updated test client calls to use device_id instead of slave parameter - Fixed data initialization bug (bit pattern mismatch) - Updated tox.ini to test Python 3.10-3.14 only Tests: 15/16 passing. Only TLS test timing out - needs investigation Co-authored-by: gavinying <10823762+gavinying@users.noreply.github.com> --- modsim/compat.py | 131 ++++++++++++++++++++++++++++++ modsim/main.py | 37 +++++---- modsim/utils.py | 21 ++--- poetry.lock | 184 +++++++++++++++++-------------------------- pyproject.toml | 4 +- requirements.txt | 2 +- tests/test_modsim.py | 42 +++++----- tox.ini | 6 +- 8 files changed, 262 insertions(+), 165 deletions(-) create mode 100644 modsim/compat.py diff --git a/modsim/compat.py b/modsim/compat.py new file mode 100644 index 0000000..3be64ca --- /dev/null +++ b/modsim/compat.py @@ -0,0 +1,131 @@ +"""Compatibility layer for PyModbus API changes. + +This module provides backward-compatible wrappers for APIs that changed +between PyModbus 3.6.9 and 3.16.1. +""" + +import struct +from typing import List, Union + + +class Endian: + """Compatibility class for endianness constants removed in PyModbus 3.16.1.""" + + BIG = "big" + LITTLE = "little" + + +class BinaryPayloadBuilder: + """Compatibility wrapper for PyModbus BinaryPayloadBuilder. + + This class was removed in PyModbus 3.9.0+ but is needed for backward + compatibility with existing datastore initialization code. + """ + + def __init__(self, byteorder: str = "big", wordorder: str = "big") -> None: + """Initialize the builder. + + Args: + byteorder: Either "big" or "little" for byte order + wordorder: Either "big" or "little" for word order (register order) + """ + self.byteorder = byteorder + self.wordorder = wordorder + self._bits: List[bool] = [] + self._registers: List[int] = [] + + def add_bits(self, bits: List[bool]) -> "BinaryPayloadBuilder": + """Add a list of bits to the payload. + + Args: + bits: List of boolean values + + Returns: + Self for chaining + """ + self._bits.extend(bits) + return self + + def add_16bit_uint(self, value: int) -> "BinaryPayloadBuilder": + """Add a 16-bit unsigned integer.""" + self._registers.append(value & 0xFFFF) + return self + + def add_16bit_int(self, value: int) -> "BinaryPayloadBuilder": + """Add a 16-bit signed integer.""" + # Convert negative to unsigned representation (two's complement) + if value < 0: + value = (1 << 16) + value + self._registers.append(value & 0xFFFF) + return self + + def add_32bit_uint(self, value: int) -> "BinaryPayloadBuilder": + """Add a 32-bit unsigned integer (uses 2 registers).""" + if self.wordorder == "big": + self._registers.append((value >> 16) & 0xFFFF) + self._registers.append(value & 0xFFFF) + else: + self._registers.append(value & 0xFFFF) + self._registers.append((value >> 16) & 0xFFFF) + return self + + def add_32bit_int(self, value: int) -> "BinaryPayloadBuilder": + """Add a 32-bit signed integer (uses 2 registers).""" + # Convert to unsigned if negative + if value < 0: + value = (1 << 32) + value + return self.add_32bit_uint(value) + + def add_32bit_float(self, value: float) -> "BinaryPayloadBuilder": + """Add a 32-bit float (uses 2 registers).""" + fmt = f">{chr(102)}" # ">f" for big-endian float + packed = struct.pack(fmt, value) + value_int = struct.unpack(f">I", packed)[0] + return self.add_32bit_uint(value_int) + + def add_64bit_uint(self, value: int) -> "BinaryPayloadBuilder": + """Add a 64-bit unsigned integer (uses 4 registers).""" + if self.wordorder == "big": + self._registers.append((value >> 48) & 0xFFFF) + self._registers.append((value >> 32) & 0xFFFF) + self._registers.append((value >> 16) & 0xFFFF) + self._registers.append(value & 0xFFFF) + else: + self._registers.append(value & 0xFFFF) + self._registers.append((value >> 16) & 0xFFFF) + self._registers.append((value >> 32) & 0xFFFF) + self._registers.append((value >> 48) & 0xFFFF) + return self + + def add_64bit_int(self, value: int) -> "BinaryPayloadBuilder": + """Add a 64-bit signed integer (uses 4 registers).""" + # Convert to unsigned if negative + if value < 0: + value = (1 << 64) + value + return self.add_64bit_uint(value) + + def add_64bit_float(self, value: float) -> "BinaryPayloadBuilder": + """Add a 64-bit float (uses 4 registers).""" + packed = struct.pack(">d", value) + value_int = struct.unpack(">Q", packed)[0] + return self.add_64bit_uint(value_int) + + def add_string(self, value: str) -> "BinaryPayloadBuilder": + """Add a string (encoded as UTF-8).""" + encoded = value.encode("utf-8") + # Pad to even number of bytes + if len(encoded) % 2: + encoded += b"\x00" + # Convert bytes to registers (2 bytes per register) + for i in range(0, len(encoded), 2): + reg_value = (encoded[i] << 8) | encoded[i + 1] + self._registers.append(reg_value) + return self + + def to_coils(self) -> List[bool]: + """Return the bits as a list of booleans.""" + return self._bits + + def to_registers(self) -> List[int]: + """Return the registers as a list of integers.""" + return self._registers diff --git a/modsim/main.py b/modsim/main.py index 4be7859..aba1f9a 100644 --- a/modsim/main.py +++ b/modsim/main.py @@ -2,20 +2,19 @@ """Modbus simulator server and process lifecycle.""" import os +import ssl import asyncio import signal import logging from contextlib import suppress from typing import Any -from pymodbus.constants import Endian from pymodbus.datastore import ( + ModbusDeviceContext, ModbusSequentialDataBlock, ModbusServerContext, - ModbusSlaveContext, ) -from pymodbus.device import ModbusDeviceIdentification -from pymodbus.payload import BinaryPayloadBuilder +from pymodbus.pdu.device import ModbusDeviceIdentification from pymodbus.server import ( StartAsyncSerialServer, ModbusTcpServer, @@ -25,6 +24,7 @@ ) from . import __version__ +from .compat import BinaryPayloadBuilder, Endian from .utils import get_commandline, get_utc_time, get_logger _logger = logging.getLogger(__name__) @@ -44,7 +44,7 @@ def setup_server(args: Any) -> Any: for _ in range(3): # The bits within each byte are big-endian # The coils status (from the first position) = [1,0,1,0,0,0,0,1] - co_builder.add_bits([True, False, False, False, False, True, False, True]) + co_builder.add_bits([True, False, True, False, False, False, False, True]) hr_builder = BinaryPayloadBuilder(byteorder=Endian.BIG, wordorder=Endian.BIG) # address=0, bytes=8 @@ -79,14 +79,16 @@ def setup_server(args: Any) -> Any: # use that payload in the data store co_values = co_builder.to_coils() hr_values = hr_builder.to_registers() - store = ModbusSlaveContext( - co=ModbusSequentialDataBlock(0, co_values), - di=ModbusSequentialDataBlock(10000, co_values), - ir=ModbusSequentialDataBlock(30000, hr_values), - hr=ModbusSequentialDataBlock(40000, hr_values), - zero_mode=True, + # Note: In PyModbus 3.16.1+, ModbusSequentialDataBlock uses address - 1 internally + # to convert to SimData. To maintain address ranges, we use address + 1 here. + # This maps Modbus addresses the same way as zero_mode=True did in older versions. + device_context = ModbusDeviceContext( + co=ModbusSequentialDataBlock(1, co_values), + di=ModbusSequentialDataBlock(10001, co_values), + ir=ModbusSequentialDataBlock(30001, hr_values), + hr=ModbusSequentialDataBlock(40001, hr_values), ) - args.context = ModbusServerContext(slaves=store, single=True) + args.context = ModbusServerContext(devices=device_context, single=True) # initialize the server information # If you don't set this or any fields, they are defaulted to empty strings. @@ -155,13 +157,20 @@ async def run_async_server(args: Any) -> Any: path = "../examples" else: path = "examples" + + # Create SSL context from cert and key files + sslctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + sslctx.load_cert_chain( + certfile=f"{path}/certificates/pymodbus.crt", + keyfile=f"{path}/certificates/pymodbus.key", + ) + server = ModbusTlsServer( context=args.context, identity=args.identity, address=address, framer=args.framer, - certfile=f"{path}/certificates/pymodbus.crt", - keyfile=f"{path}/certificates/pymodbus.key", + sslctx=sslctx, ) try: if not await server.listen(): diff --git a/modsim/utils.py b/modsim/utils.py index a298877..ad8ed1f 100644 --- a/modsim/utils.py +++ b/modsim/utils.py @@ -4,12 +4,8 @@ import logging.config from datetime import datetime, timezone from typing import Any, Dict -from pymodbus.transaction import ( - ModbusAsciiFramer, - ModbusBinaryFramer, - ModbusRtuFramer, - ModbusSocketFramer, - ModbusTlsFramer, +from pymodbus.framer import ( + FramerType, ) from . import __version__ @@ -33,7 +29,7 @@ def get_parser() -> argparse.ArgumentParser: ) parser.add_argument( "--framer", - choices=["ascii", "binary", "rtu", "socket", "tls"], + choices=["ascii", "rtu", "socket", "tls"], help="set framer, default depends on --comm", type=str, ) @@ -86,7 +82,7 @@ def get_commandline() -> argparse.Namespace: allowed_framers = { "tcp": {"socket"}, "udp": {"socket"}, - "serial": {"rtu", "ascii", "binary"}, + "serial": {"rtu", "ascii"}, "tls": {"tls"}, } if args.framer and args.framer not in allowed_framers[args.comm]: @@ -100,11 +96,10 @@ def get_commandline() -> argparse.Namespace: "tls": ["tls", 5020], } framers: Dict[str, Any] = { - "ascii": ModbusAsciiFramer, - "binary": ModbusBinaryFramer, - "rtu": ModbusRtuFramer, - "socket": ModbusSocketFramer, - "tls": ModbusTlsFramer, + "ascii": FramerType.ASCII, + "rtu": FramerType.RTU, + "socket": FramerType.SOCKET, + "tls": FramerType.TLS, } args.framer = framers[args.framer or comm_defaults[args.comm][0]] args.port = args.port or comm_defaults[args.comm][1] diff --git a/poetry.lock b/poetry.lock index bda6738..206baf1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,19 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. - -[[package]] -name = "astunparse" -version = "1.6.3" -description = "An AST unparser for Python" -optional = false -python-versions = "*" -files = [ - {file = "astunparse-1.6.3-py2.py3-none-any.whl", hash = "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8"}, - {file = "astunparse-1.6.3.tar.gz", hash = "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872"}, -] - -[package.dependencies] -six = ">=1.6.1,<2.0" -wheel = ">=0.23.0,<1.0" +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "babel" @@ -21,14 +6,12 @@ version = "2.16.0" description = "Internationalization utilities" optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, ] -[package.dependencies] -pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} - [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] @@ -38,6 +21,7 @@ version = "5.5.0" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, @@ -49,6 +33,7 @@ version = "2024.8.30" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["docs"] files = [ {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, @@ -60,6 +45,7 @@ version = "3.4.0" description = "Validate configuration and produce human readable error messages." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, @@ -71,6 +57,7 @@ version = "5.2.0" description = "Universal encoding detector for Python 3" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, @@ -82,6 +69,7 @@ version = "3.4.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" +groups = ["docs"] files = [ {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, @@ -196,6 +184,7 @@ version = "8.1.7" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["dev", "docs"] files = [ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, @@ -210,6 +199,7 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev", "docs"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -221,6 +211,7 @@ version = "0.16.2" description = "A command line utility to check for unused, missing and transitive dependencies in a Python project." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "deptry-0.16.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:24bfbae07bd6533c852c795e8d88d05a8ad0801bec0d3662e1a37db763c52540"}, {file = "deptry-0.16.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:fc881688a2eaeafe51c0617d32a6535057bccdb74559cc667109f48f81cd976e"}, @@ -246,6 +237,7 @@ version = "0.3.9" description = "Distribution utilities" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, @@ -257,6 +249,8 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -271,6 +265,7 @@ version = "3.16.1" description = "A platform independent file lock." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, @@ -279,7 +274,7 @@ files = [ [package.extras] docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] -typing = ["typing-extensions (>=4.12.2)"] +typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] [[package]] name = "ghp-import" @@ -287,6 +282,7 @@ version = "2.1.0" description = "Copy your docs directly to the gh-pages branch." optional = false python-versions = "*" +groups = ["docs"] files = [ {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, @@ -304,13 +300,13 @@ version = "1.3.2" description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "griffe-1.3.2-py3-none-any.whl", hash = "sha256:2e34b5e46507d615915c8e6288bb1a2234bd35dee44d01e40a2bc2f25bd4d10c"}, {file = "griffe-1.3.2.tar.gz", hash = "sha256:1ec50335aa507ed2445f2dd45a15c9fa3a45f52c9527e880571dfc61912fd60c"}, ] [package.dependencies] -astunparse = {version = ">=1.6", markers = "python_version < \"3.9\""} colorama = ">=0.4" [[package]] @@ -319,6 +315,7 @@ version = "2.6.1" description = "File identification library for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0"}, {file = "identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98"}, @@ -333,6 +330,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" +groups = ["docs"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -341,35 +339,13 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] -[[package]] -name = "importlib-metadata" -version = "8.5.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, - {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] - [[package]] name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -381,6 +357,7 @@ version = "3.1.4" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, @@ -398,14 +375,12 @@ version = "3.7" description = "Python implementation of John Gruber's Markdown." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"}, {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, ] -[package.dependencies] -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} - [package.extras] docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] testing = ["coverage", "pyyaml"] @@ -416,6 +391,7 @@ version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, @@ -485,6 +461,7 @@ version = "1.3.4" description = "A deep merge function for 🐍." optional = false python-versions = ">=3.6" +groups = ["docs"] files = [ {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, @@ -496,6 +473,7 @@ version = "1.6.1" description = "Project documentation with Markdown." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, @@ -505,7 +483,6 @@ files = [ click = ">=7.0" colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} ghp-import = ">=1.0" -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} jinja2 = ">=2.11.1" markdown = ">=3.3.6" markupsafe = ">=2.0.1" @@ -519,7 +496,7 @@ watchdog = ">=2.0" [package.extras] i18n = ["babel (>=2.9.0)"] -min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.4)", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4) ; platform_system == \"Windows\"", "ghp-import (==1.0)", "importlib-metadata (==4.4) ; python_version < \"3.10\"", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] [[package]] name = "mkdocs-autorefs" @@ -527,6 +504,7 @@ version = "1.2.0" description = "Automatically link across pages in MkDocs." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "mkdocs_autorefs-1.2.0-py3-none-any.whl", hash = "sha256:d588754ae89bd0ced0c70c06f58566a4ee43471eeeee5202427da7de9ef85a2f"}, {file = "mkdocs_autorefs-1.2.0.tar.gz", hash = "sha256:a86b93abff653521bda71cf3fc5596342b7a23982093915cb74273f67522190f"}, @@ -543,13 +521,13 @@ version = "0.2.0" description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, ] [package.dependencies] -importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} mergedeep = ">=1.3.4" platformdirs = ">=2.2.0" pyyaml = ">=5.1" @@ -560,6 +538,7 @@ version = "9.5.39" description = "Documentation that simply works" optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "mkdocs_material-9.5.39-py3-none-any.whl", hash = "sha256:0f2f68c8db89523cb4a59705cd01b4acd62b2f71218ccb67e1e004e560410d2b"}, {file = "mkdocs_material-9.5.39.tar.gz", hash = "sha256:25faa06142afa38549d2b781d475a86fb61de93189f532b88e69bf11e5e5c3be"}, @@ -589,6 +568,7 @@ version = "1.3.1" description = "Extension pack for Python Markdown and MkDocs Material." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, @@ -600,6 +580,7 @@ version = "0.25.2" description = "Automatic documentation from sources, for MkDocs." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "mkdocstrings-0.25.2-py3-none-any.whl", hash = "sha256:9e2cda5e2e12db8bb98d21e3410f3f27f8faab685a24b03b06ba7daa5b92abfc"}, {file = "mkdocstrings-0.25.2.tar.gz", hash = "sha256:5cf57ad7f61e8be3111a2458b4e49c2029c9cb35525393b179f9c916ca8042dc"}, @@ -607,7 +588,6 @@ files = [ [package.dependencies] click = ">=7.0" -importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} Jinja2 = ">=2.11.1" Markdown = ">=3.3" MarkupSafe = ">=1.1" @@ -616,7 +596,6 @@ mkdocs-autorefs = ">=0.3.1" mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""} platformdirs = ">=2.2.0" pymdown-extensions = ">=6.3" -typing-extensions = {version = ">=4.1", markers = "python_version < \"3.10\""} [package.extras] crystal = ["mkdocstrings-crystal (>=0.3.4)"] @@ -629,6 +608,7 @@ version = "1.10.9" description = "A Python handler for mkdocstrings." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "mkdocstrings_python-1.10.9-py3-none-any.whl", hash = "sha256:cbe98710a6757dfd4dff79bf36cb9731908fb4c69dd2736b15270ae7a488243d"}, {file = "mkdocstrings_python-1.10.9.tar.gz", hash = "sha256:f344aaa47e727d8a2dc911e063025e58e2b7fb31a41110ccc3902aa6be7ca196"}, @@ -645,6 +625,7 @@ version = "1.11.2" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, @@ -692,6 +673,7 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -703,6 +685,7 @@ version = "1.9.1" description = "Node.js virtual environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] files = [ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, @@ -714,6 +697,7 @@ version = "24.1" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev", "docs"] files = [ {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, @@ -725,6 +709,7 @@ version = "0.5.7" description = "Divides large result sets into pages for easier browsing" optional = false python-versions = "*" +groups = ["docs"] files = [ {file = "paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591"}, {file = "paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945"}, @@ -740,6 +725,7 @@ version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -751,6 +737,7 @@ version = "4.3.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" +groups = ["dev", "docs"] files = [ {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, @@ -767,6 +754,7 @@ version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -782,6 +770,7 @@ version = "3.5.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"}, {file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"}, @@ -800,6 +789,7 @@ version = "2.18.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, @@ -814,6 +804,7 @@ version = "10.11.2" description = "Extension pack for Python Markdown." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "pymdown_extensions-10.11.2-py3-none-any.whl", hash = "sha256:41cdde0a77290e480cf53892f5c5e50921a7ee3e5cd60ba91bf19837b33badcf"}, {file = "pymdown_extensions-10.11.2.tar.gz", hash = "sha256:bc8847ecc9e784a098efd35e20cba772bc5a1b529dfcef9dc1972db9021a1049"}, @@ -828,22 +819,22 @@ extra = ["pygments (>=2.12)"] [[package]] name = "pymodbus" -version = "3.6.9" +version = "3.16.1" description = "A fully featured modbus protocol stack in python" optional = false -python-versions = ">=3.8.0" +python-versions = ">=3.10.0" +groups = ["main"] files = [ - {file = "pymodbus-3.6.9-py3-none-any.whl", hash = "sha256:91dea79f157ca2d26c137190de76dce5a6c99c0ea514d2d1bf3980acbbdaf29c"}, - {file = "pymodbus-3.6.9.tar.gz", hash = "sha256:f4223b72c20cd00a2cf25f6ddb31ecb2c0587309111a55283b2f4a1ac115e4cc"}, + {file = "pymodbus-3.16.1-py3-none-any.whl", hash = "sha256:bb433f7417280bdea1190d95495aa6fb8e1fa8e0d720c436922b270bc378024c"}, + {file = "pymodbus-3.16.1.tar.gz", hash = "sha256:19e571826dff37860afc31c845c1fd2fb72acffca02a233506b32d94fa022c33"}, ] [package.extras] -all = ["pymodbus[development,documentation,repl,serial,simulator]"] -development = ["build (>=1.1.1)", "codespell (>=2.2.6)", "coverage (>=7.4.3)", "mypy (>=1.9.0)", "pylint (>=3.1.0)", "pytest (>=8.1.0)", "pytest-asyncio (>=0.23.5.post1)", "pytest-cov (>=4.1.0)", "pytest-profiling (>=1.7.0)", "pytest-timeout (>=2.3.1)", "pytest-xdist (>=3.5.0)", "ruff (>=0.3.3)", "twine (>=5.0.0)", "types-Pygments", "types-pyserial"] -documentation = ["Sphinx (>=5.3.0)", "recommonmark (>=0.7.1)", "sphinx-rtd-theme (>=1.1.1)"] -repl = ["pymodbus-repl (>=2.0.3)"] +all = ["pymodbus[development,documentation,serial,simulator]"] +development = ["build (>=1.5.0)", "codespell (>=2.4.2)", "coverage (>=7.14.3)", "pylint (>=4.0.6)", "pyserial (>=3.5)", "pytest (>=9.1.1)", "pytest-aiohttp (>=1.1.1)", "pytest-asyncio (>=1.4.0)", "pytest-cov (>=7.1.0)", "pytest-profiling (>=1.7.0) ; python_version < \"3.13\"", "pytest-timeout (>=2.3.1)", "pytest-xdist (>=3.6.1)", "ruff (>=0.15.20)", "twine (>=6.2.0)", "types-Pygments", "types-pyserial", "zuban (>=0.9.0)"] +documentation = ["Sphinx (>=7.3.7) ; python_version < \"3.12\"", "Sphinx (>=9.1.0) ; python_version >= \"3.12\"", "recommonmark (>=0.7.1)", "sphinx-rtd-theme (>=3.1.0)"] serial = ["pyserial (>=3.5)"] -simulator = ["aiohttp (>=3.8.6)", "aiohttp (>=3.9.0b0)"] +simulator = ["aiohttp (>=3.14.1) ; python_version >= \"3.12\"", "aiohttp (>=3.8.6) ; python_version < \"3.12\""] [[package]] name = "pyproject-api" @@ -851,6 +842,7 @@ version = "1.8.0" description = "API to interact with the python pyproject.toml based projects" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pyproject_api-1.8.0-py3-none-any.whl", hash = "sha256:3d7d347a047afe796fd5d1885b1e391ba29be7169bd2f102fcd378f04273d228"}, {file = "pyproject_api-1.8.0.tar.gz", hash = "sha256:77b8049f2feb5d33eefcc21b57f1e279636277a8ac8ad6b5871037b243778496"}, @@ -870,6 +862,7 @@ version = "8.3.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, @@ -892,6 +885,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["docs"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -900,23 +894,13 @@ files = [ [package.dependencies] six = ">=1.5" -[[package]] -name = "pytz" -version = "2024.2" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -files = [ - {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, - {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, -] - [[package]] name = "pyyaml" version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" +groups = ["dev", "docs"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -979,6 +963,7 @@ version = "0.1" description = "A custom YAML tag for referencing environment variables in YAML files. " optional = false python-versions = ">=3.6" +groups = ["docs"] files = [ {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, @@ -993,6 +978,7 @@ version = "2024.9.11" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "regex-2024.9.11-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1494fa8725c285a81d01dc8c06b55287a1ee5e0e382d8413adc0a9197aac6408"}, {file = "regex-2024.9.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0e12c481ad92d129c78f13a2a3662317e46ee7ef96c94fd332e1c29131875b7d"}, @@ -1096,6 +1082,7 @@ version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, @@ -1117,6 +1104,7 @@ version = "1.16.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["docs"] files = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, @@ -1128,6 +1116,8 @@ version = "2.0.2" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" files = [ {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, @@ -1139,6 +1129,7 @@ version = "4.21.2" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "tox-4.21.2-py3-none-any.whl", hash = "sha256:13d996adcd792e7c82994b0e116d85efd84f0c6d185254d83d156f73f86b2038"}, {file = "tox-4.21.2.tar.gz", hash = "sha256:49381ff102296753e378fa5ff30e42a35e695f149b4dbf8a2c49d15fdb5797b2"}, @@ -1159,7 +1150,7 @@ virtualenv = ">=20.26.6" [package.extras] docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-argparse-cli (>=1.18.2)", "sphinx-autodoc-typehints (>=2.4.4)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=24.8)"] -testing = ["build[virtualenv] (>=1.2.2)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1.0.2)", "diff-cover (>=9.2)", "distlib (>=0.3.8)", "flaky (>=3.8.1)", "hatch-vcs (>=0.4)", "hatchling (>=1.25)", "psutil (>=6)", "pytest (>=8.3.3)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-xdist (>=3.6.1)", "re-assert (>=1.1)", "setuptools (>=75.1)", "time-machine (>=2.15)", "wheel (>=0.44)"] +testing = ["build[virtualenv] (>=1.2.2)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1.0.2)", "diff-cover (>=9.2)", "distlib (>=0.3.8)", "flaky (>=3.8.1)", "hatch-vcs (>=0.4)", "hatchling (>=1.25)", "psutil (>=6)", "pytest (>=8.3.3)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-xdist (>=3.6.1)", "re-assert (>=1.1)", "setuptools (>=75.1)", "time-machine (>=2.15) ; implementation_name != \"pypy\"", "wheel (>=0.44)"] [[package]] name = "typing-extensions" @@ -1167,6 +1158,7 @@ version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, @@ -1178,13 +1170,14 @@ version = "2.2.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -1195,6 +1188,7 @@ version = "20.26.6" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, @@ -1207,7 +1201,7 @@ platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] [[package]] name = "watchdog" @@ -1215,6 +1209,7 @@ version = "4.0.2" description = "Filesystem events monitoring" optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "watchdog-4.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ede7f010f2239b97cc79e6cb3c249e72962404ae3865860855d5cbe708b0fd22"}, {file = "watchdog-4.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2cffa171445b0efa0726c561eca9a27d00a1f2b83846dbd5a4f639c4f8ca8e1"}, @@ -1256,40 +1251,7 @@ files = [ [package.extras] watchmedo = ["PyYAML (>=3.10)"] -[[package]] -name = "wheel" -version = "0.44.0" -description = "A built-package format for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "wheel-0.44.0-py3-none-any.whl", hash = "sha256:2376a90c98cc337d18623527a97c31797bd02bad0033d41547043a1cbfbe448f"}, - {file = "wheel-0.44.0.tar.gz", hash = "sha256:a29c3f2817e95ab89aa4660681ad547c0e9547f20e75b0562fe7723c9a2a9d49"}, -] - -[package.extras] -test = ["pytest (>=6.0.0)", "setuptools (>=65)"] - -[[package]] -name = "zipp" -version = "3.20.2" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.8" -files = [ - {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, - {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - [metadata] -lock-version = "2.0" -python-versions = ">=3.8.2,<4.0" -content-hash = "b09e1008c0ea11d9fe59bb8b1ceb550664da0181170690bf11f4fcd5d2c7f56d" +lock-version = "2.1" +python-versions = ">=3.10,<4.0" +content-hash = "0d39d6a2dd5c3989e497d8254dbfc7011d218ca42fd93454a75009b983bce403" diff --git a/pyproject.toml b/pyproject.toml index 5fabe92..edcbb31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,8 @@ readme = "README.md" packages = [{ include = "modsim" }] [tool.poetry.dependencies] -python = ">=3.8.2,<4.0" -pymodbus = "~3.6.9" +python = ">=3.10,<4.0" +pymodbus = "~3.16.1" [tool.poetry.group.dev.dependencies] pre-commit = "^3.5.0" diff --git a/requirements.txt b/requirements.txt index 424b27c..fa1fae8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -pymodbus==3.6.9 ; python_full_version >= "3.8.2" and python_version < "4.0" +pymodbus==3.16.1 ; python_full_version >= "3.10" and python_version < "4.0" diff --git a/tests/test_modsim.py b/tests/test_modsim.py index ababed2..f6c20b9 100644 --- a/tests/test_modsim.py +++ b/tests/test_modsim.py @@ -56,13 +56,13 @@ def test_module_name(): def test_local_tcp_server_reads_and_writes_default_holding_registers(local_tcp_client): client = local_tcp_client - result = client.read_holding_registers(40000, count=4, slave=1) + result = client.read_holding_registers(40000, count=4, device_id=1) assert not result.isError() assert result.registers == [1, 2, 3, 4] - result = client.write_registers(40000, [12, 13, 14, 15], slave=1) + result = client.write_registers(40000, [12, 13, 14, 15], device_id=1) assert not result.isError() - result = client.read_holding_registers(40000, count=4, slave=1) + result = client.read_holding_registers(40000, count=4, device_id=1) assert not result.isError() assert result.registers == [12, 13, 14, 15] @@ -71,17 +71,17 @@ def test_local_tcp_server_reads_and_writes_default_holding_registers(local_tcp_c def test_local_tcp_server_exercises_all_tables_and_boundaries(local_tcp_client): client = local_tcp_client - coils = client.read_coils(0, count=8, slave=1) + coils = client.read_coils(0, count=8, device_id=1) assert not coils.isError() assert coils.bits[:8] == [True, False, True, False, False, False, False, True] - coil_write = client.write_coil(1, True, slave=1) + coil_write = client.write_coil(1, True, device_id=1) assert not coil_write.isError() - coil_readback = client.read_coils(0, count=2, slave=1) + coil_readback = client.read_coils(0, count=2, device_id=1) assert not coil_readback.isError() assert coil_readback.bits[:2] == [True, True] - discrete = client.read_discrete_inputs(10000, count=8, slave=1) + discrete = client.read_discrete_inputs(10000, count=8, device_id=1) assert not discrete.isError() assert discrete.bits[:8] == [ True, @@ -94,37 +94,37 @@ def test_local_tcp_server_exercises_all_tables_and_boundaries(local_tcp_client): True, ] - readonly_coil_write = client.write_coil(10000, True, slave=1) + readonly_coil_write = client.write_coil(10000, True, device_id=1) assert readonly_coil_write.isError() - assert client.read_discrete_inputs(10000, count=1, slave=1).bits[0] is True + assert client.read_discrete_inputs(10000, count=1, device_id=1).bits[0] is True - last_coil_before = client.read_coils(31, count=1, slave=1) + last_coil_before = client.read_coils(31, count=1, device_id=1) assert not last_coil_before.isError() - assert client.write_coils(31, [True, True], slave=1).isError() - last_coil_after = client.read_coils(31, count=1, slave=1) + assert client.write_coils(31, [True, True], device_id=1).isError() + last_coil_after = client.read_coils(31, count=1, device_id=1) assert not last_coil_after.isError() assert last_coil_after.bits[0] == last_coil_before.bits[0] - input_registers = client.read_input_registers(30000, count=4, slave=1) + input_registers = client.read_input_registers(30000, count=4, device_id=1) assert not input_registers.isError() assert input_registers.registers == [1, 2, 3, 4] - readonly_write = client.write_register(30000, 99, slave=1) + readonly_write = client.write_register(30000, 99, device_id=1) assert readonly_write.isError() - unchanged = client.read_input_registers(30000, count=1, slave=1) + unchanged = client.read_input_registers(30000, count=1, device_id=1) assert not unchanged.isError() assert unchanged.registers == [1] - out_of_range = client.read_holding_registers(40043, count=2, slave=1) + out_of_range = client.read_holding_registers(40043, count=2, device_id=1) assert out_of_range.isError() - last_before = client.read_holding_registers(40043, count=1, slave=1) + last_before = client.read_holding_registers(40043, count=1, device_id=1) assert not last_before.isError() - out_of_range_write = client.write_registers(40043, [11, 12], slave=1) + out_of_range_write = client.write_registers(40043, [11, 12], device_id=1) assert out_of_range_write.isError() - last_after = client.read_holding_registers(40043, count=1, slave=1) + last_after = client.read_holding_registers(40043, count=1, device_id=1) assert not last_after.isError() assert last_after.registers == last_before.registers - shared_unit = client.read_holding_registers(40000, count=1, slave=42) + shared_unit = client.read_holding_registers(40000, count=1, device_id=42) assert not shared_unit.isError() assert shared_unit.registers == [1] @@ -255,7 +255,7 @@ def test_local_udp_server_and_shutdown(): if process.poll() is not None: pytest.fail("UDP server exited during startup") try: - result = client.read_holding_registers(40000, count=2, slave=1) + result = client.read_holding_registers(40000, count=2, device_id=1) if result and not result.isError(): break except Exception: diff --git a/tox.ini b/tox.ini index fa85b0b..e429dca 100644 --- a/tox.ini +++ b/tox.ini @@ -1,14 +1,14 @@ [tox] skipsdist = true -envlist = py38, py39, py310, py311, py312 +envlist = py310, py311, py312, py313, py314 [gh-actions] python = - 3.8: py38 - 3.9: py39 3.10: py310 3.11: py311 3.12: py312 + 3.13: py313 + 3.14: py314 [testenv] passenv = PYTHON_VERSION From 76142f8abf0f17c0f14c13a3e3e88a917289322a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:37:35 +0000 Subject: [PATCH 3/5] Fix TLS server test by adding MBAP header to message format Co-authored-by: gavinying <10823762+gavinying@users.noreply.github.com> --- tests/test_modsim.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_modsim.py b/tests/test_modsim.py index f6c20b9..0b8160c 100644 --- a/tests/test_modsim.py +++ b/tests/test_modsim.py @@ -373,13 +373,19 @@ def test_local_tls_server_with_ephemeral_certificate(tmp_path): time.sleep(0.05) with context.wrap_socket(raw, server_hostname="localhost") as client: client.settimeout(2) - client.sendall(b"\x03\x9c\x40\x00\x02") + # Send Modbus TCP message with MBAP header + # MBAP: transaction_id (2) + protocol_id (2) + length (2) + unit_id (1) + # PDU: function_code (1) + starting_address (2) + quantity (2) + message = b"\x00\x01\x00\x00\x00\x06\x01\x03\x9c\x40\x00\x02" + client.sendall(message) response = b"" - while len(response) < 6: - part = client.recv(6 - len(response)) + # Expect MBAP header (6 bytes) + PDU response (at least 1 byte) + while len(response) < 13: + part = client.recv(13 - len(response)) assert part response += part - assert response == b"\x03\x04\x00\x01\x00\x02" + # Verify MBAP header and response + assert response == b"\x00\x01\x00\x00\x00\x07\x01\x03\x04\x00\x01\x00\x02" conflict = subprocess.run( [ sys.executable, From ababcfc54556ded54ce77b0d6449bd6fac28902a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:40:24 +0000 Subject: [PATCH 4/5] Fix type annotations and code quality checks Co-authored-by: gavinying <10823762+gavinying@users.noreply.github.com> --- modsim/main.py | 20 ++++++++++++-------- requirements.txt | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/modsim/main.py b/modsim/main.py index aba1f9a..a686afa 100644 --- a/modsim/main.py +++ b/modsim/main.py @@ -7,7 +7,7 @@ import signal import logging from contextlib import suppress -from typing import Any +from typing import Any, Union from pymodbus.datastore import ( ModbusDeviceContext, @@ -110,7 +110,7 @@ async def run_async_server(args: Any) -> Any: _logger.info(txt) server: Any if args.comm == "tcp": - address = ("", args.port) if args.port else None + address: tuple[str, int] = ("", args.port) server = ModbusTcpServer( context=args.context, identity=args.identity, @@ -125,8 +125,9 @@ async def run_async_server(args: Any) -> Any: await server.serving finally: await server.shutdown() + return server elif args.comm == "udp": - address = ("127.0.0.1", args.port) if args.port else None + address = ("127.0.0.1", args.port) server = ModbusUdpServer( context=args.context, identity=args.identity, @@ -141,15 +142,17 @@ async def run_async_server(args: Any) -> Any: await server.serving finally: await server.shutdown() + return server elif args.comm == "serial": - server = await StartAsyncSerialServer( + await StartAsyncSerialServer( context=args.context, identity=args.identity, port=args.port, framer=args.framer, ) + return None elif args.comm == "tls": - address = ("", args.port) if args.port else None + address = ("", args.port) cwd = os.getcwd().split("/")[-1] if cwd == "examples": path = "." @@ -157,14 +160,14 @@ async def run_async_server(args: Any) -> Any: path = "../examples" else: path = "examples" - + # Create SSL context from cert and key files sslctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) sslctx.load_cert_chain( certfile=f"{path}/certificates/pymodbus.crt", keyfile=f"{path}/certificates/pymodbus.key", ) - + server = ModbusTlsServer( context=args.context, identity=args.identity, @@ -180,7 +183,8 @@ async def run_async_server(args: Any) -> Any: await server.serving finally: await server.shutdown() - return server + return server + raise ValueError(f"Unknown communication type: {args.comm}") def app() -> None: diff --git a/requirements.txt b/requirements.txt index fa1fae8..2323983 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -pymodbus==3.16.1 ; python_full_version >= "3.10" and python_version < "4.0" +pymodbus==3.16.1 ; python_version >= "3.10" and python_version < "4.0" From 91c0b2f35148aa903eb4e178f15fb14639d73baa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:01:57 +0000 Subject: [PATCH 5/5] Fix all review comments: CI matrix, unused imports, format strings, docs, test comment Co-authored-by: gavinying <10823762+gavinying@users.noreply.github.com> --- .github/workflows/main.yml | 2 +- README.md | 2 +- docs/index.md | 2 +- modsim/compat.py | 6 +++--- modsim/main.py | 2 +- tests/test_modsim.py | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 897acb7..cd0dc43 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] fail-fast: false steps: - name: Check out diff --git a/README.md b/README.md index 455f5bb..8d03aa6 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Run the simulator without Docker with `python -m modsim --comm tcp --port 5020`. The default TCP simulator uses one sequential datastore shared by all Unit IDs. The CLI currently supports only `--store sequential`; `--slaves` and `--context` are not implemented and are rejected. TCP and UDP use the socket framer, TLS uses -the TLS framer, and serial accepts RTU, ASCII, or binary framing. Serial requires a suitable local device and is not covered by the local protocol regression suite. TLS uses `examples/certificates/pymodbus.crt` and `pymodbus.key` relative to the working directory. A local test covers a self-signed server certificate and a verified client connection; the server does not authenticate client certificates. +the TLS framer, and serial accepts RTU or ASCII framing. Serial requires a suitable local device and is not covered by the local protocol regression suite. TLS uses `examples/certificates/pymodbus.crt` and `pymodbus.key` relative to the working directory. A local test covers a self-signed server certificate and a verified client connection; the server does not authenticate client certificates. UDP mode (`--comm udp`) listens on the loopback address and has local read and shutdown coverage. TCP and UDP report a startup error if the requested port is diff --git a/docs/index.md b/docs/index.md index 9e548cd..8728081 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,7 +28,7 @@ addresses return a Modbus exception. TCP and UDP use socket framing; UDP listens on loopback. TLS uses TLS framing and expects `examples/certificates/pymodbus.crt` and `pymodbus.key` relative to the working directory. The TLS server does not authenticate clients. Serial -supports RTU, ASCII and binary framing; physical serial devices have not been +supports RTU and ASCII framing; physical serial devices have not been validated in the local regression suite. Only the sequential datastore is implemented. The CLI rejects non-default diff --git a/modsim/compat.py b/modsim/compat.py index 3be64ca..907c501 100644 --- a/modsim/compat.py +++ b/modsim/compat.py @@ -5,7 +5,7 @@ """ import struct -from typing import List, Union +from typing import List class Endian: @@ -78,9 +78,9 @@ def add_32bit_int(self, value: int) -> "BinaryPayloadBuilder": def add_32bit_float(self, value: float) -> "BinaryPayloadBuilder": """Add a 32-bit float (uses 2 registers).""" - fmt = f">{chr(102)}" # ">f" for big-endian float + fmt = ">f" # big-endian float packed = struct.pack(fmt, value) - value_int = struct.unpack(f">I", packed)[0] + value_int = struct.unpack(">I", packed)[0] return self.add_32bit_uint(value_int) def add_64bit_uint(self, value: int) -> "BinaryPayloadBuilder": diff --git a/modsim/main.py b/modsim/main.py index a686afa..17a3553 100644 --- a/modsim/main.py +++ b/modsim/main.py @@ -7,7 +7,7 @@ import signal import logging from contextlib import suppress -from typing import Any, Union +from typing import Any from pymodbus.datastore import ( ModbusDeviceContext, diff --git a/tests/test_modsim.py b/tests/test_modsim.py index 0b8160c..9d3a528 100644 --- a/tests/test_modsim.py +++ b/tests/test_modsim.py @@ -379,7 +379,7 @@ def test_local_tls_server_with_ephemeral_certificate(tmp_path): message = b"\x00\x01\x00\x00\x00\x06\x01\x03\x9c\x40\x00\x02" client.sendall(message) response = b"" - # Expect MBAP header (6 bytes) + PDU response (at least 1 byte) + # Expect MBAP header (7 bytes) + PDU response (at least 1 byte) while len(response) < 13: part = client.recv(13 - len(response)) assert part